@cloudyventures/baseh 1.1.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -0
- package/dist/basen.d.ts +2 -0
- package/dist/basen.js +7 -0
- package/dist/blocklist.js +4 -1
- package/dist/checksum.d.ts +4 -2
- package/dist/checksum.js +14 -8
- package/dist/cjs/basen.d.ts +7 -0
- package/dist/cjs/basen.js +47 -0
- package/dist/cjs/blocklist.d.ts +15 -0
- package/dist/cjs/blocklist.js +37 -0
- package/dist/cjs/checksum.d.ts +10 -0
- package/dist/cjs/checksum.js +40 -0
- package/dist/cjs/codec.d.ts +94 -0
- package/dist/cjs/codec.js +408 -0
- package/dist/cjs/errors.d.ts +8 -0
- package/dist/cjs/errors.js +15 -0
- package/dist/cjs/facade.d.ts +9 -0
- package/dist/cjs/facade.js +36 -0
- package/dist/cjs/feistel.d.ts +16 -0
- package/dist/cjs/feistel.js +119 -0
- package/dist/cjs/index.d.ts +14 -0
- package/dist/cjs/index.js +50 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/profile.d.ts +91 -0
- package/dist/cjs/profile.js +279 -0
- package/dist/cjs/profiles.d.ts +33 -0
- package/dist/cjs/profiles.js +192 -0
- package/dist/codec.d.ts +58 -2
- package/dist/codec.js +246 -34
- package/dist/facade.d.ts +9 -0
- package/dist/facade.js +30 -0
- package/dist/feistel.d.ts +6 -0
- package/dist/feistel.js +11 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/profile.d.ts +52 -1
- package/dist/profile.js +129 -21
- package/dist/profiles.d.ts +18 -7
- package/dist/profiles.js +91 -29
- package/package.json +33 -7
- package/dist/zero.d.ts +0 -4
- package/dist/zero.js +0 -39
package/dist/codec.d.ts
CHANGED
|
@@ -19,9 +19,52 @@ export interface ValidateResult {
|
|
|
19
19
|
canonicalCode?: string;
|
|
20
20
|
reason?: BasehErrorCode;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Spec 12.5. Live as-you-type feedback for a code entry field. `typing`
|
|
24
|
+
* carries the normalized typed symbols with separators inserted as far as
|
|
25
|
+
* the groups go, plus a progress fraction; `invalid` carries the
|
|
26
|
+
* BasehErrorCode from validate; `valid` is only ever reported for a
|
|
27
|
+
* complete code.
|
|
28
|
+
*/
|
|
29
|
+
export type InspectResult = {
|
|
30
|
+
state: "empty";
|
|
31
|
+
} | {
|
|
32
|
+
state: "typing";
|
|
33
|
+
typed: string;
|
|
34
|
+
progress: number;
|
|
35
|
+
} | {
|
|
36
|
+
state: "bad-char";
|
|
37
|
+
} | {
|
|
38
|
+
state: "too-long";
|
|
39
|
+
} | {
|
|
40
|
+
state: "invalid";
|
|
41
|
+
reason: BasehErrorCode;
|
|
42
|
+
} | {
|
|
43
|
+
state: "valid";
|
|
44
|
+
id: bigint;
|
|
45
|
+
canonicalCode: string;
|
|
46
|
+
};
|
|
22
47
|
/** Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string. */
|
|
23
48
|
export declare function normalize(input: string, profile: PreparedProfile, acceptSpaces?: boolean): string;
|
|
24
49
|
export declare function formatRaw(raw: string, profile: PreparedProfile): string;
|
|
50
|
+
/**
|
|
51
|
+
* Spec 19.5. Balanced grouping: the split is a pure function of the total
|
|
52
|
+
* length — `g = max(2, ceil(L / 5))` groups differing in size by at most
|
|
53
|
+
* one, larger groups to the left. There is no configurable pattern in
|
|
54
|
+
* expandable mode (`grouping` must be empty, section 2.2).
|
|
55
|
+
*/
|
|
56
|
+
export declare function expandableGrouping(length: number): number[];
|
|
57
|
+
/**
|
|
58
|
+
* Spec 19.1/22.3. First id of generation L: the sum of each generation's
|
|
59
|
+
* capacity A^(k - effectiveK(k)) for k from minLength through L-1. The
|
|
60
|
+
* effective checksum length is per-generation (spec 22), so the sum is not
|
|
61
|
+
* a single geometric series when the short checksum is on.
|
|
62
|
+
*/
|
|
63
|
+
export declare function generationBase(profile: PreparedProfile, length: number): bigint;
|
|
64
|
+
/** Spec 19.1/22.3. Ids held by generation L: A^(L - effectiveK(L)). */
|
|
65
|
+
export declare function generationCapacity(profile: PreparedProfile, length: number): bigint;
|
|
66
|
+
/** Smallest generation whose range holds id, per spec 19.6. */
|
|
67
|
+
export declare function generationForId(profile: PreparedProfile, id: bigint): number;
|
|
25
68
|
/** Spec 10. Substitution-only candidate generation, capped and deduplicated. */
|
|
26
69
|
export declare function generateCandidates(body: string, confusionMap: Record<string, string[]>, maxEdits?: number): string[];
|
|
27
70
|
export declare class Baseh {
|
|
@@ -29,10 +72,23 @@ export declare class Baseh {
|
|
|
29
72
|
private readonly bodyIndex;
|
|
30
73
|
constructor(profile: BasehProfile);
|
|
31
74
|
capacity(): bigint;
|
|
32
|
-
|
|
75
|
+
private permKey;
|
|
76
|
+
private checkBlocked;
|
|
77
|
+
/** Spec 8 (fixed mode). */
|
|
78
|
+
private encodeFixed;
|
|
79
|
+
/** Spec 19.6. */
|
|
80
|
+
private encodeExpandable;
|
|
81
|
+
/** Spec 8/19.6. */
|
|
33
82
|
encode(id: bigint | number): string;
|
|
34
|
-
/** Spec 9. */
|
|
83
|
+
/** Spec 9/19.7. */
|
|
35
84
|
decode(input: string, options?: DecodeOptions): DecodeResult;
|
|
36
85
|
/** Spec 12.4. Never throws on user input. */
|
|
37
86
|
validate(input: string, options?: DecodeOptions): ValidateResult;
|
|
87
|
+
/**
|
|
88
|
+
* Spec 12.5. Live as-you-type inspection. Gates on the typed length before
|
|
89
|
+
* validating, so spec 3.4 re-padding can never paint an incomplete fixed-mode
|
|
90
|
+
* code `valid` (or `invalid`): a short fixed input is `typing`, never
|
|
91
|
+
* checked. Never throws on user input.
|
|
92
|
+
*/
|
|
93
|
+
inspect(input: string): InspectResult;
|
|
38
94
|
}
|
package/dist/codec.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { BasehError } from "./errors.js";
|
|
2
|
-
import { decodeBaseN, encodeBaseN, alphabetIndex } from "./basen.js";
|
|
2
|
+
import { decodeBaseN, encodeBaseN, alphabetIndex, powBigInt } from "./basen.js";
|
|
3
3
|
import { calculateChecksum } from "./checksum.js";
|
|
4
4
|
import { inversePermute, permute } from "./feistel.js";
|
|
5
|
-
import { prepareProfile } from "./profile.js";
|
|
5
|
+
import { prepareProfile, effectiveChecksumLength } from "./profile.js";
|
|
6
6
|
/** Built-in spoken-confusion candidate maps. Spec 3.3; pairs apply to body symbols only. */
|
|
7
7
|
export const CONFUSION_MAPS = {
|
|
8
8
|
light: { B: ["D"], D: ["B"], P: ["T"], T: ["P"] },
|
|
@@ -13,10 +13,12 @@ export const CONFUSION_MAPS = {
|
|
|
13
13
|
}
|
|
14
14
|
};
|
|
15
15
|
const ASCII_WS = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
|
|
16
|
+
const INSPECT_WS = /[\t\n\v\f\r ]/;
|
|
16
17
|
const MAX_CANDIDATES = 64;
|
|
17
18
|
/** Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string. */
|
|
18
19
|
export function normalize(input, profile, acceptSpaces = false) {
|
|
19
20
|
let s = input.replace(ASCII_WS, "");
|
|
21
|
+
const hadSeparator = profile.separator.length > 0 && s.includes(profile.separator);
|
|
20
22
|
if (profile.separator.length > 0) {
|
|
21
23
|
s = s.split(profile.separator).join("");
|
|
22
24
|
}
|
|
@@ -26,13 +28,33 @@ export function normalize(input, profile, acceptSpaces = false) {
|
|
|
26
28
|
if (!profile.caseSensitive) {
|
|
27
29
|
s = s.toUpperCase();
|
|
28
30
|
}
|
|
29
|
-
s = [...s].map((ch) => profile.aliasesNorm[ch] ?? ch).join("");
|
|
30
31
|
const allowed = new Set([...profile.bodyAlphabetNorm, ...profile.checksumAlphabetNorm]);
|
|
32
|
+
// Spec 3.2: an alias never maps two distinct canonical symbols into one
|
|
33
|
+
// value, so a symbol that is already canonical stays as-is and only
|
|
34
|
+
// non-canonical symbols are aliased. (In fixed tiers alias sources are
|
|
35
|
+
// never canonical, so this changes nothing there.)
|
|
36
|
+
s = [...s].map((ch) => (allowed.has(ch) ? ch : (profile.aliasesNorm[ch] ?? ch))).join("");
|
|
31
37
|
for (const ch of s) {
|
|
32
38
|
if (!allowed.has(ch)) {
|
|
33
39
|
throw new BasehError("INVALID_CHARACTER", `Symbol ${JSON.stringify(ch)} is not accepted`);
|
|
34
40
|
}
|
|
35
41
|
}
|
|
42
|
+
if (profile.mode === "expandable") {
|
|
43
|
+
// Spec 19.2/19.7: no left-padding and no stripped-zero leniency. Input
|
|
44
|
+
// shorter than minLength or longer than 32 fails INVALID_LENGTH, and a
|
|
45
|
+
// separator below separatorMinLength is rejected (spec 19.5: the decoder
|
|
46
|
+
// expects no separators there).
|
|
47
|
+
if (s.length < profile.minLength) {
|
|
48
|
+
throw new BasehError("INVALID_LENGTH", `Expected at least ${profile.minLength} symbols, got ${s.length}`);
|
|
49
|
+
}
|
|
50
|
+
if (s.length > 32) {
|
|
51
|
+
throw new BasehError("INVALID_LENGTH", `Expected at most 32 symbols, got ${s.length}`);
|
|
52
|
+
}
|
|
53
|
+
if (hadSeparator && s.length < profile.separatorMinLength) {
|
|
54
|
+
throw new BasehError("INVALID_CHARACTER", `Separators do not appear below ${profile.separatorMinLength} symbols`);
|
|
55
|
+
}
|
|
56
|
+
return s;
|
|
57
|
+
}
|
|
36
58
|
const expected = profile.bodyLength + profile.checksumLength;
|
|
37
59
|
// Spec 3.4: a code that lost leading zero body symbols is re-padded with
|
|
38
60
|
// the body zero symbol. The checksum symbols always remain, so the split
|
|
@@ -47,16 +69,76 @@ export function normalize(input, profile, acceptSpaces = false) {
|
|
|
47
69
|
}
|
|
48
70
|
return s;
|
|
49
71
|
}
|
|
50
|
-
|
|
51
|
-
if (
|
|
72
|
+
function formatWith(raw, sizes, separator) {
|
|
73
|
+
if (separator.length === 0)
|
|
52
74
|
return raw;
|
|
53
75
|
const parts = [];
|
|
54
76
|
let o = 0;
|
|
55
|
-
for (const size of
|
|
77
|
+
for (const size of sizes) {
|
|
56
78
|
parts.push(raw.slice(o, o + size));
|
|
57
79
|
o += size;
|
|
58
80
|
}
|
|
59
|
-
return parts.join(
|
|
81
|
+
return parts.join(separator);
|
|
82
|
+
}
|
|
83
|
+
export function formatRaw(raw, profile) {
|
|
84
|
+
if (profile.mode === "expandable") {
|
|
85
|
+
if (raw.length < profile.separatorMinLength)
|
|
86
|
+
return raw;
|
|
87
|
+
return formatWith(raw, expandableGrouping(raw.length), profile.separator);
|
|
88
|
+
}
|
|
89
|
+
return formatWith(raw, profile.grouping, profile.separator);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Spec 19.5. Balanced grouping: the split is a pure function of the total
|
|
93
|
+
* length — `g = max(2, ceil(L / 5))` groups differing in size by at most
|
|
94
|
+
* one, larger groups to the left. There is no configurable pattern in
|
|
95
|
+
* expandable mode (`grouping` must be empty, section 2.2).
|
|
96
|
+
*/
|
|
97
|
+
export function expandableGrouping(length) {
|
|
98
|
+
const g = Math.max(2, Math.ceil(length / 5));
|
|
99
|
+
const base = Math.floor(length / g);
|
|
100
|
+
if (base < 1)
|
|
101
|
+
return [length];
|
|
102
|
+
const rem = length % g;
|
|
103
|
+
return [
|
|
104
|
+
...Array(rem).fill(base + 1),
|
|
105
|
+
...Array(g - rem).fill(base)
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Spec 19.1/22.3. First id of generation L: the sum of each generation's
|
|
110
|
+
* capacity A^(k - effectiveK(k)) for k from minLength through L-1. The
|
|
111
|
+
* effective checksum length is per-generation (spec 22), so the sum is not
|
|
112
|
+
* a single geometric series when the short checksum is on.
|
|
113
|
+
*/
|
|
114
|
+
export function generationBase(profile, length) {
|
|
115
|
+
let base = 0n;
|
|
116
|
+
for (let l = profile.minLength; l < length; l += 1) {
|
|
117
|
+
base += generationCapacity(profile, l);
|
|
118
|
+
}
|
|
119
|
+
return base;
|
|
120
|
+
}
|
|
121
|
+
/** Spec 19.1/22.3. Ids held by generation L: A^(L - effectiveK(L)). */
|
|
122
|
+
export function generationCapacity(profile, length) {
|
|
123
|
+
return powBigInt(BigInt(profile.bodyAlphabetNorm.length), length - effectiveChecksumLength(profile, length));
|
|
124
|
+
}
|
|
125
|
+
/** Smallest generation whose range holds id, per spec 19.6. */
|
|
126
|
+
export function generationForId(profile, id) {
|
|
127
|
+
let l = profile.minLength;
|
|
128
|
+
let base = 0n;
|
|
129
|
+
let cap = generationCapacity(profile, l);
|
|
130
|
+
while (id >= base + cap) {
|
|
131
|
+
// Codes cap at 32 symbols, so generation 33 is the hard ceiling.
|
|
132
|
+
// Throwing from inside keeps an adversarial huge id from spinning the
|
|
133
|
+
// loop (and its bigint multiplications) without bound.
|
|
134
|
+
if (l >= 32) {
|
|
135
|
+
throw new BasehError("OUT_OF_RANGE", "ID requires a code longer than 32 symbols");
|
|
136
|
+
}
|
|
137
|
+
base += cap;
|
|
138
|
+
l += 1;
|
|
139
|
+
cap = generationCapacity(profile, l);
|
|
140
|
+
}
|
|
141
|
+
return l;
|
|
60
142
|
}
|
|
61
143
|
/** Spec 10. Substitution-only candidate generation, capped and deduplicated. */
|
|
62
144
|
export function generateCandidates(body, confusionMap, maxEdits = 1) {
|
|
@@ -85,25 +167,20 @@ export class Baseh {
|
|
|
85
167
|
this.bodyIndex = alphabetIndex(this.profile.bodyAlphabetNorm);
|
|
86
168
|
}
|
|
87
169
|
capacity() {
|
|
170
|
+
// Spec 12.3: fixed mode only. Expandable profiles have no single
|
|
171
|
+
// capacity; use the per-generation formulas of spec 19.1.
|
|
172
|
+
if (this.profile.mode !== "fixed") {
|
|
173
|
+
throw new BasehError("INVALID_PROFILE", "capacity() is only defined for fixed-mode profiles", false);
|
|
174
|
+
}
|
|
88
175
|
return this.profile.capacity;
|
|
89
176
|
}
|
|
90
|
-
|
|
91
|
-
encode(id) {
|
|
92
|
-
let value = BigInt(id);
|
|
93
|
-
if (value < 0n || value >= this.profile.capacity) {
|
|
94
|
-
throw new BasehError("OUT_OF_RANGE", `ID ${value} is outside the profile capacity`);
|
|
95
|
-
}
|
|
177
|
+
permKey(length) {
|
|
96
178
|
const perm = this.profile.permutation;
|
|
97
|
-
if (perm.enabled)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
const body = encodeBaseN(value, this.profile.bodyAlphabetNorm, this.profile.bodyLength);
|
|
105
|
-
const checksum = calculateChecksum(this.profile, body);
|
|
106
|
-
const raw = body + checksum;
|
|
179
|
+
if (!perm.enabled)
|
|
180
|
+
throw new BasehError("INVALID_PROFILE", "permutation is disabled", false);
|
|
181
|
+
return { profileId: this.profile.profileId, keyBytes: perm.keyBytes, rounds: perm.rounds, ...(length === undefined ? {} : { length }) };
|
|
182
|
+
}
|
|
183
|
+
checkBlocked(raw) {
|
|
107
184
|
// Spec 18.2: case-insensitive substring scan over the raw code.
|
|
108
185
|
if (this.profile.blocklist.length > 0) {
|
|
109
186
|
const upper = raw.toUpperCase();
|
|
@@ -113,18 +190,76 @@ export class Baseh {
|
|
|
113
190
|
}
|
|
114
191
|
}
|
|
115
192
|
}
|
|
193
|
+
// Spec 21.2: a run of the same symbol at or above maxRepetition blocks
|
|
194
|
+
// the code. Runs are measured on the raw string, so a separator never
|
|
195
|
+
// breaks a run.
|
|
196
|
+
const max = this.profile.maxRepetition;
|
|
197
|
+
if (max > 0 && new RegExp(`(.)\\1{${max - 1},}`).test(raw)) {
|
|
198
|
+
throw new BasehError("BLOCKED_CODE", "The generated reference repeats a symbol beyond the profile limit", false);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Spec 8 (fixed mode). */
|
|
202
|
+
encodeFixed(id) {
|
|
203
|
+
let value = id;
|
|
204
|
+
if (value < 0n || value >= this.profile.capacity) {
|
|
205
|
+
throw new BasehError("OUT_OF_RANGE", `ID ${value} is outside the profile capacity`);
|
|
206
|
+
}
|
|
207
|
+
const perm = this.profile.permutation;
|
|
208
|
+
if (perm.enabled) {
|
|
209
|
+
value = permute(value, this.profile.capacity, this.permKey());
|
|
210
|
+
}
|
|
211
|
+
const body = encodeBaseN(value, this.profile.bodyAlphabetNorm, this.profile.bodyLength);
|
|
212
|
+
const raw = body + calculateChecksum(this.profile, body, this.profile.checksumLength, this.bodyIndex);
|
|
213
|
+
this.checkBlocked(raw);
|
|
116
214
|
return formatRaw(raw, this.profile);
|
|
117
215
|
}
|
|
118
|
-
/** Spec
|
|
216
|
+
/** Spec 19.6. */
|
|
217
|
+
encodeExpandable(id) {
|
|
218
|
+
if (id < 0n) {
|
|
219
|
+
throw new BasehError("OUT_OF_RANGE", `ID ${id} is negative`);
|
|
220
|
+
}
|
|
221
|
+
// generationForId throws OUT_OF_RANGE for ids beyond the 32-symbol ceiling.
|
|
222
|
+
const l = generationForId(this.profile, id);
|
|
223
|
+
let value = id - generationBase(this.profile, l);
|
|
224
|
+
const domain = generationCapacity(this.profile, l);
|
|
225
|
+
const perm = this.profile.permutation;
|
|
226
|
+
if (perm.enabled) {
|
|
227
|
+
value = permute(value, domain, this.permKey(l));
|
|
228
|
+
}
|
|
229
|
+
const k = effectiveChecksumLength(this.profile, l);
|
|
230
|
+
const body = encodeBaseN(value, this.profile.bodyAlphabetNorm, l - k);
|
|
231
|
+
const raw = body + calculateChecksum(this.profile, body, k, this.bodyIndex);
|
|
232
|
+
this.checkBlocked(raw);
|
|
233
|
+
return formatRaw(raw, this.profile);
|
|
234
|
+
}
|
|
235
|
+
/** Spec 8/19.6. */
|
|
236
|
+
encode(id) {
|
|
237
|
+
const value = BigInt(id);
|
|
238
|
+
return this.profile.mode === "expandable" ? this.encodeExpandable(value) : this.encodeFixed(value);
|
|
239
|
+
}
|
|
240
|
+
/** Spec 9/19.7. */
|
|
119
241
|
decode(input, options = {}) {
|
|
242
|
+
// Spec API is maxCorrections?: 0 | 1; anything else is a caller bug and
|
|
243
|
+
// is rejected at the boundary instead of silently coerced.
|
|
244
|
+
if (options.maxCorrections !== undefined && options.maxCorrections !== 0 && options.maxCorrections !== 1) {
|
|
245
|
+
throw new BasehError("INVALID_PROFILE", "maxCorrections must be 0 or 1", false);
|
|
246
|
+
}
|
|
120
247
|
const raw = normalize(input, this.profile, options.acceptSpaces === true);
|
|
121
|
-
|
|
122
|
-
|
|
248
|
+
// Spec 22: the generation is selected by the presented total length, so
|
|
249
|
+
// the effective checksum length is a deterministic function of it.
|
|
250
|
+
const effectiveK = this.profile.mode === "expandable"
|
|
251
|
+
? effectiveChecksumLength(this.profile, raw.length)
|
|
252
|
+
: this.profile.checksumLength;
|
|
253
|
+
const bodyLength = this.profile.mode === "expandable"
|
|
254
|
+
? raw.length - effectiveK
|
|
255
|
+
: this.profile.bodyLength;
|
|
256
|
+
let body = raw.slice(0, bodyLength);
|
|
257
|
+
const suppliedChecksum = raw.slice(bodyLength);
|
|
123
258
|
// Spec 3.1 validates union membership before the split. There is no
|
|
124
259
|
// per-region membership check: a checksum-region symbol outside the
|
|
125
260
|
// checksum alphabet simply fails as INVALID_CHECKSUM, and a body symbol
|
|
126
261
|
// outside the body alphabet fails later in decodeBaseN as INVALID_CHARACTER.
|
|
127
|
-
if (calculateChecksum(this.profile, body) !== suppliedChecksum) {
|
|
262
|
+
if (calculateChecksum(this.profile, body, effectiveK, this.bodyIndex) !== suppliedChecksum) {
|
|
128
263
|
if (!options.tryCorrection || (options.maxCorrections ?? 1) === 0) {
|
|
129
264
|
throw new BasehError("INVALID_CHECKSUM", "The reference code did not pass validation");
|
|
130
265
|
}
|
|
@@ -145,7 +280,7 @@ export class Baseh {
|
|
|
145
280
|
}
|
|
146
281
|
const valid = new Set();
|
|
147
282
|
for (const candidate of generateCandidates(body, map, options.maxCorrections ?? 1)) {
|
|
148
|
-
if (calculateChecksum(this.profile, candidate) === suppliedChecksum) {
|
|
283
|
+
if (calculateChecksum(this.profile, candidate, effectiveK, this.bodyIndex) === suppliedChecksum) {
|
|
149
284
|
valid.add(candidate);
|
|
150
285
|
}
|
|
151
286
|
}
|
|
@@ -159,12 +294,17 @@ export class Baseh {
|
|
|
159
294
|
}
|
|
160
295
|
let value = decodeBaseN(body, this.profile.bodyAlphabetNorm, this.bodyIndex);
|
|
161
296
|
const perm = this.profile.permutation;
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
297
|
+
if (this.profile.mode === "expandable") {
|
|
298
|
+
// Spec 19.7: the offset is de-permuted within the generation's own
|
|
299
|
+
// domain, then the generation base is added back.
|
|
300
|
+
const l = raw.length;
|
|
301
|
+
if (perm.enabled) {
|
|
302
|
+
value = inversePermute(value, generationCapacity(this.profile, l), this.permKey(l));
|
|
303
|
+
}
|
|
304
|
+
value = generationBase(this.profile, l) + value;
|
|
305
|
+
}
|
|
306
|
+
else if (perm.enabled) {
|
|
307
|
+
value = inversePermute(value, this.profile.capacity, this.permKey());
|
|
168
308
|
}
|
|
169
309
|
const canonicalCode = this.encode(value);
|
|
170
310
|
const canonicalRaw = canonicalCode.split(this.profile.separator).join("");
|
|
@@ -182,4 +322,76 @@ export class Baseh {
|
|
|
182
322
|
throw err;
|
|
183
323
|
}
|
|
184
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Spec 12.5. Live as-you-type inspection. Gates on the typed length before
|
|
327
|
+
* validating, so spec 3.4 re-padding can never paint an incomplete fixed-mode
|
|
328
|
+
* code `valid` (or `invalid`): a short fixed input is `typing`, never
|
|
329
|
+
* checked. Never throws on user input.
|
|
330
|
+
*/
|
|
331
|
+
inspect(input) {
|
|
332
|
+
const p = this.profile;
|
|
333
|
+
// Remove every occurrence of the separator string, then drop ASCII
|
|
334
|
+
// whitespace anywhere (a paste can carry either inside the code).
|
|
335
|
+
const noSep = p.separator.length > 0 ? input.split(p.separator).join("") : input;
|
|
336
|
+
const cleaned = [...noSep].filter((ch) => !INSPECT_WS.test(ch));
|
|
337
|
+
const typedCount = cleaned.length;
|
|
338
|
+
if (typedCount === 0)
|
|
339
|
+
return { state: "empty" };
|
|
340
|
+
const fixed = p.mode === "fixed";
|
|
341
|
+
const expected = fixed ? p.bodyLength + p.checksumLength : 32;
|
|
342
|
+
if (typedCount > expected)
|
|
343
|
+
return { state: "too-long" };
|
|
344
|
+
// Spec 3.1 steps 4-6, without the length checks: case normalization,
|
|
345
|
+
// aliases, then union membership. A symbol outside both alphabets is
|
|
346
|
+
// bad-char; a symbol valid only in the other region (say a checksum-only
|
|
347
|
+
// symbol typed into the body) passes here and fails later under validate.
|
|
348
|
+
const allowed = new Set([...p.bodyAlphabetNorm, ...p.checksumAlphabetNorm]);
|
|
349
|
+
let s = cleaned.join("");
|
|
350
|
+
if (!p.caseSensitive)
|
|
351
|
+
s = s.toUpperCase();
|
|
352
|
+
const raw = [...s].map((ch) => (allowed.has(ch) ? ch : (p.aliasesNorm[ch] ?? ch))).join("");
|
|
353
|
+
if ([...raw].some((ch) => !allowed.has(ch)))
|
|
354
|
+
return { state: "bad-char" };
|
|
355
|
+
// Fixed mode: complete means exactly bodyLength + checksumLength symbols.
|
|
356
|
+
// Expandable mode: every length from minLength through 32 is a complete
|
|
357
|
+
// code (the length selects the generation), so typing is only below
|
|
358
|
+
// minLength.
|
|
359
|
+
const complete = fixed ? typedCount === expected : typedCount >= p.minLength;
|
|
360
|
+
if (!complete) {
|
|
361
|
+
return {
|
|
362
|
+
state: "typing",
|
|
363
|
+
typed: formatPartial(raw, p),
|
|
364
|
+
progress: typedCount / (fixed ? expected : p.minLength)
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
const result = this.validate(raw);
|
|
368
|
+
if (!result.valid)
|
|
369
|
+
return { state: "invalid", reason: result.reason ?? "INVALID_CHECKSUM" };
|
|
370
|
+
const decoded = this.decode(raw);
|
|
371
|
+
return { state: "valid", id: decoded.id, canonicalCode: decoded.canonicalCode };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Spec 12.5. Separators inserted into a partially typed code, as far as the
|
|
376
|
+
* groups go. Fixed mode walks the configured grouping; expandable mode uses
|
|
377
|
+
* the balanced grouping rule of spec 19.5 for the typed length, bare below
|
|
378
|
+
* separatorMinLength.
|
|
379
|
+
*/
|
|
380
|
+
function formatPartial(raw, profile) {
|
|
381
|
+
if (profile.separator.length === 0)
|
|
382
|
+
return raw;
|
|
383
|
+
if (profile.mode === "expandable") {
|
|
384
|
+
if (raw.length < profile.separatorMinLength)
|
|
385
|
+
return raw;
|
|
386
|
+
return formatWith(raw, expandableGrouping(raw.length), profile.separator);
|
|
387
|
+
}
|
|
388
|
+
const parts = [];
|
|
389
|
+
let offset = 0;
|
|
390
|
+
for (const size of profile.grouping) {
|
|
391
|
+
if (offset >= raw.length)
|
|
392
|
+
break;
|
|
393
|
+
parts.push(raw.slice(offset, offset + size));
|
|
394
|
+
offset += size;
|
|
395
|
+
}
|
|
396
|
+
return parts.join(profile.separator);
|
|
185
397
|
}
|
package/dist/facade.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type DecodeOptions, type DecodeResult, type InspectResult, type ValidateResult } from "./codec.js";
|
|
2
|
+
/** Encode an id with the default expandable v1 profile. */
|
|
3
|
+
export declare function encode(id: bigint | number): string;
|
|
4
|
+
/** Decode a code with the default expandable v1 profile. Throws BasehError like the instance API. */
|
|
5
|
+
export declare function decode(input: string, options?: DecodeOptions): DecodeResult;
|
|
6
|
+
/** Validate a code with the default expandable v1 profile. Never throws on user input. */
|
|
7
|
+
export declare function validate(input: string, options?: DecodeOptions): ValidateResult;
|
|
8
|
+
/** Live as-you-type inspection with the default expandable v1 profile. Never throws on user input. */
|
|
9
|
+
export declare function inspect(input: string): InspectResult;
|
package/dist/facade.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Baseh } from "./codec.js";
|
|
2
|
+
import { basehExpandableV1 } from "./profiles.js";
|
|
3
|
+
/**
|
|
4
|
+
* Zero-config facade over the shipped expandable v1 default profile. Most
|
|
5
|
+
* callers never need to touch a profile object: `encode(id)` and
|
|
6
|
+
* `decode(code)` here behave exactly like the same methods on a
|
|
7
|
+
* `new Baseh(basehExpandableV1())` instance, sharing one lazily constructed
|
|
8
|
+
* instance for the process.
|
|
9
|
+
*/
|
|
10
|
+
let shared;
|
|
11
|
+
function sharedInstance() {
|
|
12
|
+
shared ??= new Baseh(basehExpandableV1());
|
|
13
|
+
return shared;
|
|
14
|
+
}
|
|
15
|
+
/** Encode an id with the default expandable v1 profile. */
|
|
16
|
+
export function encode(id) {
|
|
17
|
+
return sharedInstance().encode(id);
|
|
18
|
+
}
|
|
19
|
+
/** Decode a code with the default expandable v1 profile. Throws BasehError like the instance API. */
|
|
20
|
+
export function decode(input, options = {}) {
|
|
21
|
+
return sharedInstance().decode(input, options);
|
|
22
|
+
}
|
|
23
|
+
/** Validate a code with the default expandable v1 profile. Never throws on user input. */
|
|
24
|
+
export function validate(input, options = {}) {
|
|
25
|
+
return sharedInstance().validate(input, options);
|
|
26
|
+
}
|
|
27
|
+
/** Live as-you-type inspection with the default expandable v1 profile. Never throws on user input. */
|
|
28
|
+
export function inspect(input) {
|
|
29
|
+
return sharedInstance().inspect(input);
|
|
30
|
+
}
|
package/dist/feistel.d.ts
CHANGED
|
@@ -2,6 +2,12 @@ interface FeistelKey {
|
|
|
2
2
|
profileId: string;
|
|
3
3
|
keyBytes: Uint8Array;
|
|
4
4
|
rounds: number;
|
|
5
|
+
/**
|
|
6
|
+
* Expandable mode only (spec 7.3/19.4): the total code length L of the
|
|
7
|
+
* generation, mixed into the round message. Absent in fixed mode, where
|
|
8
|
+
* the message stays byte-for-byte unchanged.
|
|
9
|
+
*/
|
|
10
|
+
length?: number;
|
|
5
11
|
}
|
|
6
12
|
/** Spec 7.3 forward permutation with cycle walking. */
|
|
7
13
|
export declare function permute(value: bigint, capacity: bigint, key: FeistelKey): bigint;
|
package/dist/feistel.js
CHANGED
|
@@ -24,10 +24,11 @@ function toBe(value, byteCount) {
|
|
|
24
24
|
}
|
|
25
25
|
return out;
|
|
26
26
|
}
|
|
27
|
-
function roundMessage(profileId, round, right, wr) {
|
|
27
|
+
function roundMessage(profileId, round, right, wr, length) {
|
|
28
28
|
const pidBytes = new TextEncoder().encode(profileId);
|
|
29
|
+
const lenBytes = length === undefined ? new Uint8Array(0) : new TextEncoder().encode(String(length));
|
|
29
30
|
const rightBytes = toBe(right, Math.ceil(wr / 8));
|
|
30
|
-
const msg = new Uint8Array(TAG.length + 1 + pidBytes.length + 1 + 1 + rightBytes.length);
|
|
31
|
+
const msg = new Uint8Array(TAG.length + 1 + pidBytes.length + 1 + lenBytes.length + (length === undefined ? 0 : 1) + 1 + rightBytes.length);
|
|
31
32
|
let o = 0;
|
|
32
33
|
msg.set(TAG, o);
|
|
33
34
|
o += TAG.length;
|
|
@@ -37,6 +38,12 @@ function roundMessage(profileId, round, right, wr) {
|
|
|
37
38
|
o += pidBytes.length;
|
|
38
39
|
msg[o] = 0;
|
|
39
40
|
o += 1;
|
|
41
|
+
if (length !== undefined) {
|
|
42
|
+
msg.set(lenBytes, o);
|
|
43
|
+
o += lenBytes.length;
|
|
44
|
+
msg[o] = 0;
|
|
45
|
+
o += 1;
|
|
46
|
+
}
|
|
40
47
|
msg[o] = round;
|
|
41
48
|
o += 1;
|
|
42
49
|
msg.set(rightBytes, o);
|
|
@@ -48,7 +55,7 @@ function runRounds(h, key, w0, w1) {
|
|
|
48
55
|
const even = i % 2 === 0;
|
|
49
56
|
const wr = even ? w1 : w0;
|
|
50
57
|
const wl = even ? w0 : w1;
|
|
51
|
-
const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, right, wr));
|
|
58
|
+
const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, right, wr, key.length));
|
|
52
59
|
const f = lowBits(digest, wl);
|
|
53
60
|
const newLeft = right;
|
|
54
61
|
const newRight = left ^ f;
|
|
@@ -63,7 +70,7 @@ function runInverse(h, key, w0, w1) {
|
|
|
63
70
|
const even = i % 2 === 0;
|
|
64
71
|
const wr = even ? w1 : w0;
|
|
65
72
|
const wl = even ? w0 : w1;
|
|
66
|
-
const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, left, wr));
|
|
73
|
+
const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, left, wr, key.length));
|
|
67
74
|
const f = lowBits(digest, wl);
|
|
68
75
|
const prevRight = left;
|
|
69
76
|
const prevLeft = right ^ f;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
export { BasehError } from "./errors.js";
|
|
2
2
|
export type { BasehErrorCode } from "./errors.js";
|
|
3
3
|
export type { BasehProfile, BasehPermutation, PreparedProfile } from "./profile.js";
|
|
4
|
-
export { prepareProfile } from "./profile.js";
|
|
4
|
+
export { prepareProfile, effectiveChecksumLength } from "./profile.js";
|
|
5
5
|
export { Baseh, normalize, formatRaw, generateCandidates, CONFUSION_MAPS } from "./codec.js";
|
|
6
|
-
export type { DecodeOptions, DecodeResult, ValidateResult, ConfusionProfileName } from "./codec.js";
|
|
6
|
+
export type { DecodeOptions, DecodeResult, ValidateResult, InspectResult, ConfusionProfileName } from "./codec.js";
|
|
7
7
|
export { encodeBaseN, decodeBaseN, alphabetIndex } from "./basen.js";
|
|
8
8
|
export { calculateChecksum, checksumValue } from "./checksum.js";
|
|
9
9
|
export { permute, inversePermute } from "./feistel.js";
|
|
10
|
-
export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1, type FrozenKeyOptions } from "./profiles.js";
|
|
11
|
-
export {
|
|
10
|
+
export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1, basehExpandableV1, basehExpandablePV1, FROZEN_KEY_BYTES, type FrozenKeyOptions } from "./profiles.js";
|
|
11
|
+
export { generationBase, generationCapacity, generationForId, expandableGrouping } from "./codec.js";
|
|
12
|
+
export { encode, decode, validate, inspect } from "./facade.js";
|
|
12
13
|
export { DEFAULT_BLOCKLIST, effectiveBlocklist, stripVowels } from "./blocklist.js";
|
|
13
14
|
export type { BasehProfanity, BasehProfanityMode } from "./blocklist.js";
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export { BasehError } from "./errors.js";
|
|
2
|
-
export { prepareProfile } from "./profile.js";
|
|
2
|
+
export { prepareProfile, effectiveChecksumLength } from "./profile.js";
|
|
3
3
|
export { Baseh, normalize, formatRaw, generateCandidates, CONFUSION_MAPS } from "./codec.js";
|
|
4
4
|
export { encodeBaseN, decodeBaseN, alphabetIndex } from "./basen.js";
|
|
5
5
|
export { calculateChecksum, checksumValue } from "./checksum.js";
|
|
6
6
|
export { permute, inversePermute } from "./feistel.js";
|
|
7
|
-
export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1 } from "./profiles.js";
|
|
8
|
-
export {
|
|
7
|
+
export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1, basehExpandableV1, basehExpandablePV1, FROZEN_KEY_BYTES } from "./profiles.js";
|
|
8
|
+
export { generationBase, generationCapacity, generationForId, expandableGrouping } from "./codec.js";
|
|
9
|
+
export { encode, decode, validate, inspect } from "./facade.js";
|
|
9
10
|
export { DEFAULT_BLOCKLIST, effectiveBlocklist, stripVowels } from "./blocklist.js";
|
package/dist/profile.d.ts
CHANGED
|
@@ -10,28 +10,79 @@ export type BasehPermutation = {
|
|
|
10
10
|
};
|
|
11
11
|
export interface BasehProfile {
|
|
12
12
|
profileId: string;
|
|
13
|
+
/**
|
|
14
|
+
* Spec 2.1/19.9. "fixed" keeps the classic constant-width behaviour;
|
|
15
|
+
* "expandable" gives variable-length codes driven by id magnitude
|
|
16
|
+
* (spec 19). Profiles that predate the mode field are fixed: the shared
|
|
17
|
+
* frozen vectors pin their byte-for-byte behaviour, so a missing mode is
|
|
18
|
+
* prepared as "fixed".
|
|
19
|
+
*/
|
|
20
|
+
mode?: "fixed" | "expandable";
|
|
13
21
|
bodyAlphabet: string;
|
|
14
|
-
|
|
22
|
+
/** Fixed mode only; ignored in expandable mode. */
|
|
23
|
+
bodyLength?: number;
|
|
24
|
+
/** Expandable mode only; default 4. Must exceed checksumLength. */
|
|
25
|
+
minLength?: number;
|
|
15
26
|
checksumAlphabet: string;
|
|
16
27
|
checksumLength: number;
|
|
28
|
+
/**
|
|
29
|
+
* Spec 22. Expandable mode only. The checksum width used by generations at
|
|
30
|
+
* or below `shortChecksumUntil`; may be 0 (a zero-checksum window: those
|
|
31
|
+
* generations carry no checksum symbols and no typo detection). Without a
|
|
32
|
+
* window (`shortChecksumUntil` absent or 0) this must be absent or 0.
|
|
33
|
+
*/
|
|
34
|
+
shortChecksumLength?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Spec 22. The last generation (total length) that uses the short
|
|
37
|
+
* checksum; 0 or absent turns the feature off (the codebase convention,
|
|
38
|
+
* like maxRepetition). When set it must be an integer from `minLength`
|
|
39
|
+
* through 8.
|
|
40
|
+
*/
|
|
41
|
+
shortChecksumUntil?: number;
|
|
17
42
|
caseSensitive: boolean;
|
|
18
43
|
separator: string;
|
|
44
|
+
/** Expandable mode only; default 0 (separator always applies). */
|
|
45
|
+
separatorMinLength?: number;
|
|
19
46
|
grouping: number[];
|
|
20
47
|
aliases: Record<string, string>;
|
|
21
48
|
permutation: BasehPermutation;
|
|
22
49
|
/** Spec 18. Defaults to mode "none". */
|
|
23
50
|
profanity?: BasehProfanity;
|
|
51
|
+
/**
|
|
52
|
+
* Spec 21. Maximum allowed run of the same symbol in a raw code. 0 (the
|
|
53
|
+
* default) disables the filter; otherwise it must be an integer of at
|
|
54
|
+
* least 3. A value above the code length is a legal no-op.
|
|
55
|
+
*/
|
|
56
|
+
maxRepetition?: number;
|
|
24
57
|
}
|
|
25
58
|
/** Case-prepared derived data, computed once at construction. */
|
|
26
59
|
export interface PreparedProfile extends BasehProfile {
|
|
60
|
+
readonly mode: "fixed" | "expandable";
|
|
61
|
+
readonly minLength: number;
|
|
62
|
+
readonly separatorMinLength: number;
|
|
27
63
|
readonly bodyAlphabetNorm: string;
|
|
28
64
|
readonly checksumAlphabetNorm: string;
|
|
29
65
|
readonly aliasesNorm: Record<string, string>;
|
|
30
66
|
readonly checksumModulus: bigint;
|
|
67
|
+
/** Fixed-mode capacity A^bodyLength. Meaningless in expandable mode. */
|
|
31
68
|
readonly capacity: bigint;
|
|
32
69
|
/** Spec 18. Empty unless the profile uses mode "blocklist". */
|
|
33
70
|
readonly blocklist: string[];
|
|
71
|
+
/** Spec 21. 0 disables the repetition filter. */
|
|
72
|
+
readonly maxRepetition: number;
|
|
73
|
+
/** Spec 22. 0 disables the short checksum. */
|
|
74
|
+
readonly shortChecksumLength: number;
|
|
75
|
+
/** Spec 22. Last short-checksum generation; 0 when the feature is off. */
|
|
76
|
+
readonly shortChecksumUntil: number;
|
|
34
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Spec 22. The checksum length that applies to a generation of the given
|
|
80
|
+
* total length: `shortChecksumLength` at or below `shortChecksumUntil`,
|
|
81
|
+
* `checksumLength` above it (and always in fixed mode). The feature is on
|
|
82
|
+
* exactly when `shortChecksumUntil` is non-zero; a `shortChecksumLength` of
|
|
83
|
+
* 0 then means the window's generations carry no checksum symbols at all.
|
|
84
|
+
*/
|
|
85
|
+
export declare function effectiveChecksumLength(profile: PreparedProfile, length: number): number;
|
|
35
86
|
/**
|
|
36
87
|
* Validates a profile per spec section 2.2 and returns it with derived,
|
|
37
88
|
* pre-computed values. Throws BasehError INVALID_PROFILE on any violation.
|