Skip to content

Commit 4e2334d

Browse files
committed
feat: add universal RNG engine
1 parent 3f211f2 commit 4e2334d

9 files changed

Lines changed: 1506 additions & 58 deletions

File tree

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@
3737
"2fa"
3838
],
3939
"devDependencies": {
40+
"@types/node": "^24.10.1",
41+
"@vitest/coverage-v8": "^4.0.14",
42+
"ts-node": "^10.9.2",
4043
"tsup": "^8.0.0",
4144
"typescript": "^5.6.0",
42-
"vitest": "^4.0.14",
43-
"@vitest/coverage-v8": "^4.0.14",
44-
"ts-node": "^10.9.2"
45+
"vitest": "^4.0.14"
4546
}
4647
}

src/index.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,48 @@ import {
33
CodeLength,
44
Strategy,
55
CustomStrategyContext,
6-
CodeAnalysis,
6+
HumanProfile,
7+
SecurityMode,
8+
RngProfile,
9+
type CodeAnalysis,
10+
type CodePrediction,
711
} from './types'
812
import { getStrategyImpl } from './strategy'
913
import { isCodeAllowed } from './validator'
1014
import {
1115
getAllowedDigits,
12-
getRng,
1316
fromDigits,
1417
isAllSame,
1518
isTooSimpleSequence,
1619
pickDigit,
1720
pickTwoDifferentDigits,
1821
pickThreeDifferentDigits,
22+
getRng,
1923
} from './utils'
24+
import { registerRng } from './rng'
25+
import { analyze } from './analyze'
26+
import { predictCodeQuality } from './predictor'
2027

21-
export { analyze } from './analyze'
22-
23-
export {
28+
export type {
2429
GenerateOptions,
2530
CodeLength,
26-
Strategy,
2731
CustomStrategyContext,
32+
HumanProfile,
33+
SecurityMode,
34+
RngProfile,
2835
CodeAnalysis,
36+
CodePrediction,
2937
}
38+
export { Strategy, analyze, predictCodeQuality, registerRng }
3039

3140
export function generateCode(options: GenerateOptions = {}): string {
3241
const length: CodeLength = options.length === 4 ? 4 : 6
3342
const strategy: Strategy = options.strategy ?? Strategy.Mixed
3443

44+
if (options.mode === 'banking') {
45+
return generateBankingCode({ ...options, length })
46+
}
47+
3548
const impl = getStrategyImpl(length, strategy, options)
3649

3750
const maxAttempts = 100
@@ -50,6 +63,32 @@ export function generateCode(options: GenerateOptions = {}): string {
5063
return code.slice(0, length)
5164
}
5265

66+
function generateBankingCode(options: GenerateOptions = {}): string {
67+
const length: CodeLength = options.length === 4 ? 4 : 6
68+
const digits = getAllowedDigits(options)
69+
const rng = getRng({ ...options, mode: 'banking' })
70+
71+
const maxAttempts = 100
72+
for (let i = 0; i < maxAttempts; i++) {
73+
const result: string[] = []
74+
while (result.length < length) {
75+
const d = pickDigit(digits, rng)
76+
if (!result.includes(d)) {
77+
result.push(d)
78+
}
79+
}
80+
const code = fromDigits(result)
81+
if (isCodeAllowed(code, { ...options, mode: 'banking' })) {
82+
return code
83+
}
84+
}
85+
86+
const fallback = fromDigits(
87+
Array.from({ length }, () => pickDigit(digits, rng))
88+
)
89+
return fallback.slice(0, length)
90+
}
91+
5392
export function isHumanFriendly(
5493
code: string,
5594
options?: GenerateOptions

src/predictor.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { CodePrediction, GenerateOptions } from './types'
2+
import { analyze } from './analyze'
3+
4+
function hammingSimilarity(a: string, b: string): number {
5+
if (!a || !b || a.length !== b.length) return 0
6+
let same = 0
7+
for (let i = 0; i < a.length; i++) {
8+
if (a[i] === b[i]) same++
9+
}
10+
return same / a.length
11+
}
12+
13+
export function predictCodeQuality(
14+
code: string,
15+
options: GenerateOptions = {}
16+
): CodePrediction {
17+
const a = analyze(code)
18+
19+
let baseMem = 0.5
20+
if (a.memorability === 'low') baseMem = 0.25
21+
if (a.memorability === 'medium') baseMem = 0.55
22+
if (a.memorability === 'high') baseMem = 0.85
23+
24+
switch (options.profile) {
25+
case 'children':
26+
baseMem += 0.1
27+
break
28+
case 'elderly':
29+
if (a.entropy > 10) baseMem -= 0.1
30+
break
31+
case 'asia':
32+
if (code.includes('4')) baseMem -= 0.15
33+
break
34+
case 'latam':
35+
if (a.memorability === 'high') baseMem += 0.05
36+
break
37+
default:
38+
break
39+
}
40+
41+
let confusion = 0
42+
if (options.previousCode && options.previousCode.length === code.length) {
43+
confusion = hammingSimilarity(code, options.previousCode)
44+
}
45+
46+
const clamp = (v: number) => Math.max(0, Math.min(1, v))
47+
48+
return {
49+
memorabilityScore: clamp(baseMem),
50+
confusionScore: clamp(confusion),
51+
}
52+
}

src/profile.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { GenerateOptions, HumanProfile, SecurityMode } from './types'
2+
import { analyze } from './analyze'
3+
import { isAllSame, isTooSimpleSequence } from './utils'
4+
import { predictCodeQuality } from './predictor'
5+
6+
function hasRepeats(code: string): boolean {
7+
return new Set(code).size < code.length
8+
}
9+
10+
export function isProfileCompatible(
11+
code: string,
12+
options: GenerateOptions
13+
): boolean {
14+
const profile: HumanProfile = options.profile ?? 'default'
15+
const analysis = analyze(code)
16+
const prediction = predictCodeQuality(code, options)
17+
18+
switch (profile) {
19+
case 'children': {
20+
if (analysis.memorability === 'low') return false
21+
if (prediction.memorabilityScore < 0.6) return false
22+
if (analysis.isSequentialAsc || analysis.isSequentialDesc)
23+
return false
24+
return true
25+
}
26+
case 'elderly': {
27+
if (analysis.memorability === 'low') return false
28+
if (analysis.entropy > 11 && analysis.length === 6) return false
29+
return true
30+
}
31+
case 'asia': {
32+
if (code.includes('4')) return false
33+
return true
34+
}
35+
case 'latam': {
36+
if (code[0] === '0' || code[0] === '1') return false
37+
return true
38+
}
39+
case 'default':
40+
default:
41+
return true
42+
}
43+
}
44+
45+
export function isModeCompatible(
46+
code: string,
47+
options: GenerateOptions
48+
): boolean {
49+
const mode: SecurityMode = options.mode ?? 'normal'
50+
if (mode === 'normal') return true
51+
52+
const a = analyze(code)
53+
54+
if (isAllSame(code)) return false
55+
if (isTooSimpleSequence(code)) return false
56+
if (hasRepeats(code)) return false
57+
if (a.isSequentialAsc || a.isSequentialDesc) return false
58+
59+
if (a.length === 4 && a.entropy < 6.5) return false
60+
if (a.length === 6 && a.entropy < 10) return false
61+
62+
return true
63+
}
64+
65+
export function isPredictionCompatible(
66+
code: string,
67+
options: GenerateOptions
68+
): boolean {
69+
if (!options.avoidSimilarPrevious || !options.previousCode) return true
70+
if (options.previousCode.length !== code.length) return true
71+
72+
const prediction = predictCodeQuality(code, options)
73+
74+
if (prediction.confusionScore > 0.5) return false
75+
return true
76+
}

src/rng.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { GenerateOptions, RngProfile } from './types'
2+
3+
export type RngFunction = () => number
4+
5+
const rngRegistry = new Map<RngProfile, RngFunction>()
6+
7+
function defaultInsecureRng(): number {
8+
return Math.random()
9+
}
10+
11+
function defaultSecureRng(): number {
12+
if (typeof globalThis !== 'undefined') {
13+
const g: any = globalThis as any
14+
if (g.crypto && typeof g.crypto.getRandomValues === 'function') {
15+
const arr = new Uint32Array(1)
16+
g.crypto.getRandomValues(arr)
17+
return arr[0]! / 0xffffffff
18+
}
19+
}
20+
21+
try {
22+
const nodeCrypto = require('crypto')
23+
const web = nodeCrypto.webcrypto
24+
25+
if (web && typeof web.getRandomValues === 'function') {
26+
const arr = new Uint32Array(1)
27+
web.getRandomValues(arr)
28+
return arr[0]! / 0xffffffff
29+
}
30+
} catch {}
31+
32+
return Math.random()
33+
}
34+
35+
function defaultHybridRng(): number {
36+
const a = defaultInsecureRng()
37+
const b = defaultSecureRng()
38+
return (a + b) % 1
39+
}
40+
41+
rngRegistry.set('insecure', defaultInsecureRng)
42+
rngRegistry.set('secure', defaultSecureRng)
43+
rngRegistry.set('hybrid', defaultHybridRng)
44+
45+
export function registerRng(name: RngProfile, rng: RngFunction): void {
46+
if (typeof rng !== 'function') {
47+
throw new Error('RNG must be a function that returns number in [0,1)')
48+
}
49+
rngRegistry.set(name, rng)
50+
}
51+
52+
export function resolveRng(options?: GenerateOptions): RngFunction {
53+
if (options?.rng) return options.rng
54+
55+
if (options?.rngProfile && rngRegistry.has(options.rngProfile)) {
56+
return rngRegistry.get(options.rngProfile)!
57+
}
58+
59+
if (options?.mode === 'banking') {
60+
return rngRegistry.get('secure') ?? defaultSecureRng
61+
}
62+
63+
return rngRegistry.get('insecure') ?? defaultInsecureRng
64+
}

src/types.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export enum Strategy {
1111
Custom = 'custom',
1212
}
1313

14+
export type HumanProfile = 'default' | 'children' | 'elderly' | 'asia' | 'latam'
15+
export type SecurityMode = 'normal' | 'banking'
16+
export type RngProfile = 'insecure' | 'secure' | 'hybrid' | (string & {})
17+
1418
export interface CustomStrategyContext {
1519
length: CodeLength
1620
pickDigit(): string
@@ -21,6 +25,24 @@ export interface CustomStrategyContext {
2125
isAllSame(code: string): boolean
2226
}
2327

28+
export interface CodeAnalysis {
29+
length: CodeLength
30+
isAllSame: boolean
31+
isTooSimple: boolean
32+
isSequentialAsc: boolean
33+
isSequentialDesc: boolean
34+
repeatedGroups: number
35+
36+
entropy: number
37+
collisionRisk: 'low' | 'medium' | 'high'
38+
memorability: 'low' | 'medium' | 'high'
39+
}
40+
41+
export interface CodePrediction {
42+
memorabilityScore: number
43+
confusionScore: number
44+
}
45+
2446
export interface GenerateOptions {
2547
length?: CodeLength
2648
strategy?: Strategy
@@ -29,19 +51,14 @@ export interface GenerateOptions {
2951
blackList?: string[]
3052
previousCode?: string
3153
allowedDigits?: string[]
54+
3255
rng?: () => number
33-
customStrategy?: (ctx: CustomStrategyContext) => string
34-
}
56+
rngProfile?: RngProfile
3557

36-
export interface CodeAnalysis {
37-
length: CodeLength
38-
isAllSame: boolean
39-
isTooSimple: boolean
40-
isSequentialAsc: boolean
41-
isSequentialDesc: boolean
42-
repeatedGroups: number
43-
pattern?: string
44-
entropy: number
45-
memorability: 'low' | 'medium' | 'high'
46-
collisionRisk: 'low' | 'medium' | 'high'
58+
profile?: HumanProfile
59+
mode?: SecurityMode
60+
61+
avoidSimilarPrevious?: boolean
62+
63+
customStrategy?: (ctx: CustomStrategyContext) => string
4764
}

0 commit comments

Comments
 (0)