@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
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Baseh = exports.CONFUSION_MAPS = void 0;
|
|
4
|
+
exports.normalize = normalize;
|
|
5
|
+
exports.formatRaw = formatRaw;
|
|
6
|
+
exports.expandableGrouping = expandableGrouping;
|
|
7
|
+
exports.generationBase = generationBase;
|
|
8
|
+
exports.generationCapacity = generationCapacity;
|
|
9
|
+
exports.generationForId = generationForId;
|
|
10
|
+
exports.generateCandidates = generateCandidates;
|
|
11
|
+
const errors_js_1 = require("./errors.js");
|
|
12
|
+
const basen_js_1 = require("./basen.js");
|
|
13
|
+
const checksum_js_1 = require("./checksum.js");
|
|
14
|
+
const feistel_js_1 = require("./feistel.js");
|
|
15
|
+
const profile_js_1 = require("./profile.js");
|
|
16
|
+
/** Built-in spoken-confusion candidate maps. Spec 3.3; pairs apply to body symbols only. */
|
|
17
|
+
exports.CONFUSION_MAPS = {
|
|
18
|
+
light: { B: ["D"], D: ["B"], P: ["T"], T: ["P"] },
|
|
19
|
+
medium: { B: ["D"], D: ["B"], P: ["T"], T: ["P"], M: ["N"], N: ["M"], V: ["W"], W: ["V"] },
|
|
20
|
+
heavy: {
|
|
21
|
+
B: ["D"], D: ["B"], P: ["T"], T: ["P"], M: ["N"], N: ["M"],
|
|
22
|
+
V: ["W"], W: ["V"], F: ["S"], S: ["F"], C: ["G"], G: ["C"]
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const ASCII_WS = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
|
|
26
|
+
const INSPECT_WS = /[\t\n\v\f\r ]/;
|
|
27
|
+
const MAX_CANDIDATES = 64;
|
|
28
|
+
/** Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string. */
|
|
29
|
+
function normalize(input, profile, acceptSpaces = false) {
|
|
30
|
+
let s = input.replace(ASCII_WS, "");
|
|
31
|
+
const hadSeparator = profile.separator.length > 0 && s.includes(profile.separator);
|
|
32
|
+
if (profile.separator.length > 0) {
|
|
33
|
+
s = s.split(profile.separator).join("");
|
|
34
|
+
}
|
|
35
|
+
if (acceptSpaces) {
|
|
36
|
+
s = s.replace(/ /g, "");
|
|
37
|
+
}
|
|
38
|
+
if (!profile.caseSensitive) {
|
|
39
|
+
s = s.toUpperCase();
|
|
40
|
+
}
|
|
41
|
+
const allowed = new Set([...profile.bodyAlphabetNorm, ...profile.checksumAlphabetNorm]);
|
|
42
|
+
// Spec 3.2: an alias never maps two distinct canonical symbols into one
|
|
43
|
+
// value, so a symbol that is already canonical stays as-is and only
|
|
44
|
+
// non-canonical symbols are aliased. (In fixed tiers alias sources are
|
|
45
|
+
// never canonical, so this changes nothing there.)
|
|
46
|
+
s = [...s].map((ch) => (allowed.has(ch) ? ch : (profile.aliasesNorm[ch] ?? ch))).join("");
|
|
47
|
+
for (const ch of s) {
|
|
48
|
+
if (!allowed.has(ch)) {
|
|
49
|
+
throw new errors_js_1.BasehError("INVALID_CHARACTER", `Symbol ${JSON.stringify(ch)} is not accepted`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (profile.mode === "expandable") {
|
|
53
|
+
// Spec 19.2/19.7: no left-padding and no stripped-zero leniency. Input
|
|
54
|
+
// shorter than minLength or longer than 32 fails INVALID_LENGTH, and a
|
|
55
|
+
// separator below separatorMinLength is rejected (spec 19.5: the decoder
|
|
56
|
+
// expects no separators there).
|
|
57
|
+
if (s.length < profile.minLength) {
|
|
58
|
+
throw new errors_js_1.BasehError("INVALID_LENGTH", `Expected at least ${profile.minLength} symbols, got ${s.length}`);
|
|
59
|
+
}
|
|
60
|
+
if (s.length > 32) {
|
|
61
|
+
throw new errors_js_1.BasehError("INVALID_LENGTH", `Expected at most 32 symbols, got ${s.length}`);
|
|
62
|
+
}
|
|
63
|
+
if (hadSeparator && s.length < profile.separatorMinLength) {
|
|
64
|
+
throw new errors_js_1.BasehError("INVALID_CHARACTER", `Separators do not appear below ${profile.separatorMinLength} symbols`);
|
|
65
|
+
}
|
|
66
|
+
return s;
|
|
67
|
+
}
|
|
68
|
+
const expected = profile.bodyLength + profile.checksumLength;
|
|
69
|
+
// Spec 3.4: a code that lost leading zero body symbols is re-padded with
|
|
70
|
+
// the body zero symbol. The checksum symbols always remain, so the split
|
|
71
|
+
// point is unambiguous. A fully stripped no-checksum code would be empty
|
|
72
|
+
// and stays a length error.
|
|
73
|
+
if (s.length < expected && s.length >= Math.max(profile.checksumLength, 1)) {
|
|
74
|
+
const zero = profile.bodyAlphabetNorm[0];
|
|
75
|
+
s = zero.repeat(expected - s.length) + s;
|
|
76
|
+
}
|
|
77
|
+
if (s.length !== expected) {
|
|
78
|
+
throw new errors_js_1.BasehError("INVALID_LENGTH", `Expected ${expected} symbols, got ${s.length}`);
|
|
79
|
+
}
|
|
80
|
+
return s;
|
|
81
|
+
}
|
|
82
|
+
function formatWith(raw, sizes, separator) {
|
|
83
|
+
if (separator.length === 0)
|
|
84
|
+
return raw;
|
|
85
|
+
const parts = [];
|
|
86
|
+
let o = 0;
|
|
87
|
+
for (const size of sizes) {
|
|
88
|
+
parts.push(raw.slice(o, o + size));
|
|
89
|
+
o += size;
|
|
90
|
+
}
|
|
91
|
+
return parts.join(separator);
|
|
92
|
+
}
|
|
93
|
+
function formatRaw(raw, profile) {
|
|
94
|
+
if (profile.mode === "expandable") {
|
|
95
|
+
if (raw.length < profile.separatorMinLength)
|
|
96
|
+
return raw;
|
|
97
|
+
return formatWith(raw, expandableGrouping(raw.length), profile.separator);
|
|
98
|
+
}
|
|
99
|
+
return formatWith(raw, profile.grouping, profile.separator);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Spec 19.5. Balanced grouping: the split is a pure function of the total
|
|
103
|
+
* length — `g = max(2, ceil(L / 5))` groups differing in size by at most
|
|
104
|
+
* one, larger groups to the left. There is no configurable pattern in
|
|
105
|
+
* expandable mode (`grouping` must be empty, section 2.2).
|
|
106
|
+
*/
|
|
107
|
+
function expandableGrouping(length) {
|
|
108
|
+
const g = Math.max(2, Math.ceil(length / 5));
|
|
109
|
+
const base = Math.floor(length / g);
|
|
110
|
+
if (base < 1)
|
|
111
|
+
return [length];
|
|
112
|
+
const rem = length % g;
|
|
113
|
+
return [
|
|
114
|
+
...Array(rem).fill(base + 1),
|
|
115
|
+
...Array(g - rem).fill(base)
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Spec 19.1/22.3. First id of generation L: the sum of each generation's
|
|
120
|
+
* capacity A^(k - effectiveK(k)) for k from minLength through L-1. The
|
|
121
|
+
* effective checksum length is per-generation (spec 22), so the sum is not
|
|
122
|
+
* a single geometric series when the short checksum is on.
|
|
123
|
+
*/
|
|
124
|
+
function generationBase(profile, length) {
|
|
125
|
+
let base = 0n;
|
|
126
|
+
for (let l = profile.minLength; l < length; l += 1) {
|
|
127
|
+
base += generationCapacity(profile, l);
|
|
128
|
+
}
|
|
129
|
+
return base;
|
|
130
|
+
}
|
|
131
|
+
/** Spec 19.1/22.3. Ids held by generation L: A^(L - effectiveK(L)). */
|
|
132
|
+
function generationCapacity(profile, length) {
|
|
133
|
+
return (0, basen_js_1.powBigInt)(BigInt(profile.bodyAlphabetNorm.length), length - (0, profile_js_1.effectiveChecksumLength)(profile, length));
|
|
134
|
+
}
|
|
135
|
+
/** Smallest generation whose range holds id, per spec 19.6. */
|
|
136
|
+
function generationForId(profile, id) {
|
|
137
|
+
let l = profile.minLength;
|
|
138
|
+
let base = 0n;
|
|
139
|
+
let cap = generationCapacity(profile, l);
|
|
140
|
+
while (id >= base + cap) {
|
|
141
|
+
// Codes cap at 32 symbols, so generation 33 is the hard ceiling.
|
|
142
|
+
// Throwing from inside keeps an adversarial huge id from spinning the
|
|
143
|
+
// loop (and its bigint multiplications) without bound.
|
|
144
|
+
if (l >= 32) {
|
|
145
|
+
throw new errors_js_1.BasehError("OUT_OF_RANGE", "ID requires a code longer than 32 symbols");
|
|
146
|
+
}
|
|
147
|
+
base += cap;
|
|
148
|
+
l += 1;
|
|
149
|
+
cap = generationCapacity(profile, l);
|
|
150
|
+
}
|
|
151
|
+
return l;
|
|
152
|
+
}
|
|
153
|
+
/** Spec 10. Substitution-only candidate generation, capped and deduplicated. */
|
|
154
|
+
function generateCandidates(body, confusionMap, maxEdits = 1) {
|
|
155
|
+
if (maxEdits === 0)
|
|
156
|
+
return [];
|
|
157
|
+
const results = new Set();
|
|
158
|
+
const chars = [...body];
|
|
159
|
+
for (let pos = 0; pos < chars.length; pos += 1) {
|
|
160
|
+
const source = chars[pos];
|
|
161
|
+
for (const replacement of confusionMap[source] ?? []) {
|
|
162
|
+
const candidate = [...chars];
|
|
163
|
+
candidate[pos] = replacement;
|
|
164
|
+
results.add(candidate.join(""));
|
|
165
|
+
if (results.size > MAX_CANDIDATES) {
|
|
166
|
+
throw new errors_js_1.BasehError("TOO_MANY_CANDIDATES", "Candidate generation exceeded 64 entries", false);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...results];
|
|
171
|
+
}
|
|
172
|
+
class Baseh {
|
|
173
|
+
profile;
|
|
174
|
+
bodyIndex;
|
|
175
|
+
constructor(profile) {
|
|
176
|
+
this.profile = (0, profile_js_1.prepareProfile)(profile);
|
|
177
|
+
this.bodyIndex = (0, basen_js_1.alphabetIndex)(this.profile.bodyAlphabetNorm);
|
|
178
|
+
}
|
|
179
|
+
capacity() {
|
|
180
|
+
// Spec 12.3: fixed mode only. Expandable profiles have no single
|
|
181
|
+
// capacity; use the per-generation formulas of spec 19.1.
|
|
182
|
+
if (this.profile.mode !== "fixed") {
|
|
183
|
+
throw new errors_js_1.BasehError("INVALID_PROFILE", "capacity() is only defined for fixed-mode profiles", false);
|
|
184
|
+
}
|
|
185
|
+
return this.profile.capacity;
|
|
186
|
+
}
|
|
187
|
+
permKey(length) {
|
|
188
|
+
const perm = this.profile.permutation;
|
|
189
|
+
if (!perm.enabled)
|
|
190
|
+
throw new errors_js_1.BasehError("INVALID_PROFILE", "permutation is disabled", false);
|
|
191
|
+
return { profileId: this.profile.profileId, keyBytes: perm.keyBytes, rounds: perm.rounds, ...(length === undefined ? {} : { length }) };
|
|
192
|
+
}
|
|
193
|
+
checkBlocked(raw) {
|
|
194
|
+
// Spec 18.2: case-insensitive substring scan over the raw code.
|
|
195
|
+
if (this.profile.blocklist.length > 0) {
|
|
196
|
+
const upper = raw.toUpperCase();
|
|
197
|
+
for (const word of this.profile.blocklist) {
|
|
198
|
+
if (upper.includes(word)) {
|
|
199
|
+
throw new errors_js_1.BasehError("BLOCKED_CODE", "The generated reference contains a blocked substring", false);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Spec 21.2: a run of the same symbol at or above maxRepetition blocks
|
|
204
|
+
// the code. Runs are measured on the raw string, so a separator never
|
|
205
|
+
// breaks a run.
|
|
206
|
+
const max = this.profile.maxRepetition;
|
|
207
|
+
if (max > 0 && new RegExp(`(.)\\1{${max - 1},}`).test(raw)) {
|
|
208
|
+
throw new errors_js_1.BasehError("BLOCKED_CODE", "The generated reference repeats a symbol beyond the profile limit", false);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** Spec 8 (fixed mode). */
|
|
212
|
+
encodeFixed(id) {
|
|
213
|
+
let value = id;
|
|
214
|
+
if (value < 0n || value >= this.profile.capacity) {
|
|
215
|
+
throw new errors_js_1.BasehError("OUT_OF_RANGE", `ID ${value} is outside the profile capacity`);
|
|
216
|
+
}
|
|
217
|
+
const perm = this.profile.permutation;
|
|
218
|
+
if (perm.enabled) {
|
|
219
|
+
value = (0, feistel_js_1.permute)(value, this.profile.capacity, this.permKey());
|
|
220
|
+
}
|
|
221
|
+
const body = (0, basen_js_1.encodeBaseN)(value, this.profile.bodyAlphabetNorm, this.profile.bodyLength);
|
|
222
|
+
const raw = body + (0, checksum_js_1.calculateChecksum)(this.profile, body, this.profile.checksumLength, this.bodyIndex);
|
|
223
|
+
this.checkBlocked(raw);
|
|
224
|
+
return formatRaw(raw, this.profile);
|
|
225
|
+
}
|
|
226
|
+
/** Spec 19.6. */
|
|
227
|
+
encodeExpandable(id) {
|
|
228
|
+
if (id < 0n) {
|
|
229
|
+
throw new errors_js_1.BasehError("OUT_OF_RANGE", `ID ${id} is negative`);
|
|
230
|
+
}
|
|
231
|
+
// generationForId throws OUT_OF_RANGE for ids beyond the 32-symbol ceiling.
|
|
232
|
+
const l = generationForId(this.profile, id);
|
|
233
|
+
let value = id - generationBase(this.profile, l);
|
|
234
|
+
const domain = generationCapacity(this.profile, l);
|
|
235
|
+
const perm = this.profile.permutation;
|
|
236
|
+
if (perm.enabled) {
|
|
237
|
+
value = (0, feistel_js_1.permute)(value, domain, this.permKey(l));
|
|
238
|
+
}
|
|
239
|
+
const k = (0, profile_js_1.effectiveChecksumLength)(this.profile, l);
|
|
240
|
+
const body = (0, basen_js_1.encodeBaseN)(value, this.profile.bodyAlphabetNorm, l - k);
|
|
241
|
+
const raw = body + (0, checksum_js_1.calculateChecksum)(this.profile, body, k, this.bodyIndex);
|
|
242
|
+
this.checkBlocked(raw);
|
|
243
|
+
return formatRaw(raw, this.profile);
|
|
244
|
+
}
|
|
245
|
+
/** Spec 8/19.6. */
|
|
246
|
+
encode(id) {
|
|
247
|
+
const value = BigInt(id);
|
|
248
|
+
return this.profile.mode === "expandable" ? this.encodeExpandable(value) : this.encodeFixed(value);
|
|
249
|
+
}
|
|
250
|
+
/** Spec 9/19.7. */
|
|
251
|
+
decode(input, options = {}) {
|
|
252
|
+
// Spec API is maxCorrections?: 0 | 1; anything else is a caller bug and
|
|
253
|
+
// is rejected at the boundary instead of silently coerced.
|
|
254
|
+
if (options.maxCorrections !== undefined && options.maxCorrections !== 0 && options.maxCorrections !== 1) {
|
|
255
|
+
throw new errors_js_1.BasehError("INVALID_PROFILE", "maxCorrections must be 0 or 1", false);
|
|
256
|
+
}
|
|
257
|
+
const raw = normalize(input, this.profile, options.acceptSpaces === true);
|
|
258
|
+
// Spec 22: the generation is selected by the presented total length, so
|
|
259
|
+
// the effective checksum length is a deterministic function of it.
|
|
260
|
+
const effectiveK = this.profile.mode === "expandable"
|
|
261
|
+
? (0, profile_js_1.effectiveChecksumLength)(this.profile, raw.length)
|
|
262
|
+
: this.profile.checksumLength;
|
|
263
|
+
const bodyLength = this.profile.mode === "expandable"
|
|
264
|
+
? raw.length - effectiveK
|
|
265
|
+
: this.profile.bodyLength;
|
|
266
|
+
let body = raw.slice(0, bodyLength);
|
|
267
|
+
const suppliedChecksum = raw.slice(bodyLength);
|
|
268
|
+
// Spec 3.1 validates union membership before the split. There is no
|
|
269
|
+
// per-region membership check: a checksum-region symbol outside the
|
|
270
|
+
// checksum alphabet simply fails as INVALID_CHECKSUM, and a body symbol
|
|
271
|
+
// outside the body alphabet fails later in decodeBaseN as INVALID_CHARACTER.
|
|
272
|
+
if ((0, checksum_js_1.calculateChecksum)(this.profile, body, effectiveK, this.bodyIndex) !== suppliedChecksum) {
|
|
273
|
+
if (!options.tryCorrection || (options.maxCorrections ?? 1) === 0) {
|
|
274
|
+
throw new errors_js_1.BasehError("INVALID_CHECKSUM", "The reference code did not pass validation");
|
|
275
|
+
}
|
|
276
|
+
const mapName = options.confusionProfile ?? "none";
|
|
277
|
+
// Spec 10: replacements that are not body alphabet symbols are
|
|
278
|
+
// dropped before candidate generation. A suggested symbol the alphabet
|
|
279
|
+
// cannot contain (say a spoken drop on a stripped-alphabet profile)
|
|
280
|
+
// could never validate; generating it anyway would throw
|
|
281
|
+
// INVALID_CHARACTER from the checksum step instead of reporting an
|
|
282
|
+
// honest INVALID_CHECKSUM.
|
|
283
|
+
const bodySet = new Set(this.profile.bodyAlphabetNorm);
|
|
284
|
+
const rawMap = mapName === "none" ? {} : exports.CONFUSION_MAPS[mapName];
|
|
285
|
+
const map = {};
|
|
286
|
+
for (const [source, replacements] of Object.entries(rawMap)) {
|
|
287
|
+
const kept = replacements.filter((r) => bodySet.has(r));
|
|
288
|
+
if (kept.length > 0)
|
|
289
|
+
map[source] = kept;
|
|
290
|
+
}
|
|
291
|
+
const valid = new Set();
|
|
292
|
+
for (const candidate of generateCandidates(body, map, options.maxCorrections ?? 1)) {
|
|
293
|
+
if ((0, checksum_js_1.calculateChecksum)(this.profile, candidate, effectiveK, this.bodyIndex) === suppliedChecksum) {
|
|
294
|
+
valid.add(candidate);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (valid.size === 0) {
|
|
298
|
+
throw new errors_js_1.BasehError("INVALID_CHECKSUM", "The reference code did not pass validation");
|
|
299
|
+
}
|
|
300
|
+
if (valid.size > 1) {
|
|
301
|
+
throw new errors_js_1.BasehError("AMBIGUOUS_INPUT", "The reference code matches more than one record", false);
|
|
302
|
+
}
|
|
303
|
+
body = [...valid][0];
|
|
304
|
+
}
|
|
305
|
+
let value = (0, basen_js_1.decodeBaseN)(body, this.profile.bodyAlphabetNorm, this.bodyIndex);
|
|
306
|
+
const perm = this.profile.permutation;
|
|
307
|
+
if (this.profile.mode === "expandable") {
|
|
308
|
+
// Spec 19.7: the offset is de-permuted within the generation's own
|
|
309
|
+
// domain, then the generation base is added back.
|
|
310
|
+
const l = raw.length;
|
|
311
|
+
if (perm.enabled) {
|
|
312
|
+
value = (0, feistel_js_1.inversePermute)(value, generationCapacity(this.profile, l), this.permKey(l));
|
|
313
|
+
}
|
|
314
|
+
value = generationBase(this.profile, l) + value;
|
|
315
|
+
}
|
|
316
|
+
else if (perm.enabled) {
|
|
317
|
+
value = (0, feistel_js_1.inversePermute)(value, this.profile.capacity, this.permKey());
|
|
318
|
+
}
|
|
319
|
+
const canonicalCode = this.encode(value);
|
|
320
|
+
const canonicalRaw = canonicalCode.split(this.profile.separator).join("");
|
|
321
|
+
return { id: value, canonicalCode, corrected: raw !== canonicalRaw };
|
|
322
|
+
}
|
|
323
|
+
/** Spec 12.4. Never throws on user input. */
|
|
324
|
+
validate(input, options = {}) {
|
|
325
|
+
try {
|
|
326
|
+
const result = this.decode(input, options);
|
|
327
|
+
return { valid: true, canonicalCode: result.canonicalCode };
|
|
328
|
+
}
|
|
329
|
+
catch (err) {
|
|
330
|
+
if (err instanceof errors_js_1.BasehError)
|
|
331
|
+
return { valid: false, reason: err.code };
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Spec 12.5. Live as-you-type inspection. Gates on the typed length before
|
|
337
|
+
* validating, so spec 3.4 re-padding can never paint an incomplete fixed-mode
|
|
338
|
+
* code `valid` (or `invalid`): a short fixed input is `typing`, never
|
|
339
|
+
* checked. Never throws on user input.
|
|
340
|
+
*/
|
|
341
|
+
inspect(input) {
|
|
342
|
+
const p = this.profile;
|
|
343
|
+
// Remove every occurrence of the separator string, then drop ASCII
|
|
344
|
+
// whitespace anywhere (a paste can carry either inside the code).
|
|
345
|
+
const noSep = p.separator.length > 0 ? input.split(p.separator).join("") : input;
|
|
346
|
+
const cleaned = [...noSep].filter((ch) => !INSPECT_WS.test(ch));
|
|
347
|
+
const typedCount = cleaned.length;
|
|
348
|
+
if (typedCount === 0)
|
|
349
|
+
return { state: "empty" };
|
|
350
|
+
const fixed = p.mode === "fixed";
|
|
351
|
+
const expected = fixed ? p.bodyLength + p.checksumLength : 32;
|
|
352
|
+
if (typedCount > expected)
|
|
353
|
+
return { state: "too-long" };
|
|
354
|
+
// Spec 3.1 steps 4-6, without the length checks: case normalization,
|
|
355
|
+
// aliases, then union membership. A symbol outside both alphabets is
|
|
356
|
+
// bad-char; a symbol valid only in the other region (say a checksum-only
|
|
357
|
+
// symbol typed into the body) passes here and fails later under validate.
|
|
358
|
+
const allowed = new Set([...p.bodyAlphabetNorm, ...p.checksumAlphabetNorm]);
|
|
359
|
+
let s = cleaned.join("");
|
|
360
|
+
if (!p.caseSensitive)
|
|
361
|
+
s = s.toUpperCase();
|
|
362
|
+
const raw = [...s].map((ch) => (allowed.has(ch) ? ch : (p.aliasesNorm[ch] ?? ch))).join("");
|
|
363
|
+
if ([...raw].some((ch) => !allowed.has(ch)))
|
|
364
|
+
return { state: "bad-char" };
|
|
365
|
+
// Fixed mode: complete means exactly bodyLength + checksumLength symbols.
|
|
366
|
+
// Expandable mode: every length from minLength through 32 is a complete
|
|
367
|
+
// code (the length selects the generation), so typing is only below
|
|
368
|
+
// minLength.
|
|
369
|
+
const complete = fixed ? typedCount === expected : typedCount >= p.minLength;
|
|
370
|
+
if (!complete) {
|
|
371
|
+
return {
|
|
372
|
+
state: "typing",
|
|
373
|
+
typed: formatPartial(raw, p),
|
|
374
|
+
progress: typedCount / (fixed ? expected : p.minLength)
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
const result = this.validate(raw);
|
|
378
|
+
if (!result.valid)
|
|
379
|
+
return { state: "invalid", reason: result.reason ?? "INVALID_CHECKSUM" };
|
|
380
|
+
const decoded = this.decode(raw);
|
|
381
|
+
return { state: "valid", id: decoded.id, canonicalCode: decoded.canonicalCode };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
exports.Baseh = Baseh;
|
|
385
|
+
/**
|
|
386
|
+
* Spec 12.5. Separators inserted into a partially typed code, as far as the
|
|
387
|
+
* groups go. Fixed mode walks the configured grouping; expandable mode uses
|
|
388
|
+
* the balanced grouping rule of spec 19.5 for the typed length, bare below
|
|
389
|
+
* separatorMinLength.
|
|
390
|
+
*/
|
|
391
|
+
function formatPartial(raw, profile) {
|
|
392
|
+
if (profile.separator.length === 0)
|
|
393
|
+
return raw;
|
|
394
|
+
if (profile.mode === "expandable") {
|
|
395
|
+
if (raw.length < profile.separatorMinLength)
|
|
396
|
+
return raw;
|
|
397
|
+
return formatWith(raw, expandableGrouping(raw.length), profile.separator);
|
|
398
|
+
}
|
|
399
|
+
const parts = [];
|
|
400
|
+
let offset = 0;
|
|
401
|
+
for (const size of profile.grouping) {
|
|
402
|
+
if (offset >= raw.length)
|
|
403
|
+
break;
|
|
404
|
+
parts.push(raw.slice(offset, offset + size));
|
|
405
|
+
offset += size;
|
|
406
|
+
}
|
|
407
|
+
return parts.join(profile.separator);
|
|
408
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Error codes defined by the baseH codec specification. */
|
|
2
|
+
export type BasehErrorCode = "INVALID_PROFILE" | "OUT_OF_RANGE" | "PERMUTATION_FAILURE" | "INVALID_LENGTH" | "INVALID_CHARACTER" | "INVALID_CHECKSUM" | "AMBIGUOUS_INPUT" | "TOO_MANY_CANDIDATES" | "BLOCKED_CODE";
|
|
3
|
+
export declare class BasehError extends Error {
|
|
4
|
+
readonly code: BasehErrorCode;
|
|
5
|
+
/** True when the message may be shown to an end user unchanged. */
|
|
6
|
+
readonly safeForCustomer: boolean;
|
|
7
|
+
constructor(code: BasehErrorCode, message: string, safeForCustomer?: boolean);
|
|
8
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BasehError = void 0;
|
|
4
|
+
class BasehError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
/** True when the message may be shown to an end user unchanged. */
|
|
7
|
+
safeForCustomer;
|
|
8
|
+
constructor(code, message, safeForCustomer = true) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "BasehError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.safeForCustomer = safeForCustomer;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
exports.BasehError = BasehError;
|
|
@@ -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;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.encode = encode;
|
|
4
|
+
exports.decode = decode;
|
|
5
|
+
exports.validate = validate;
|
|
6
|
+
exports.inspect = inspect;
|
|
7
|
+
const codec_js_1 = require("./codec.js");
|
|
8
|
+
const profiles_js_1 = require("./profiles.js");
|
|
9
|
+
/**
|
|
10
|
+
* Zero-config facade over the shipped expandable v1 default profile. Most
|
|
11
|
+
* callers never need to touch a profile object: `encode(id)` and
|
|
12
|
+
* `decode(code)` here behave exactly like the same methods on a
|
|
13
|
+
* `new Baseh(basehExpandableV1())` instance, sharing one lazily constructed
|
|
14
|
+
* instance for the process.
|
|
15
|
+
*/
|
|
16
|
+
let shared;
|
|
17
|
+
function sharedInstance() {
|
|
18
|
+
shared ??= new codec_js_1.Baseh((0, profiles_js_1.basehExpandableV1)());
|
|
19
|
+
return shared;
|
|
20
|
+
}
|
|
21
|
+
/** Encode an id with the default expandable v1 profile. */
|
|
22
|
+
function encode(id) {
|
|
23
|
+
return sharedInstance().encode(id);
|
|
24
|
+
}
|
|
25
|
+
/** Decode a code with the default expandable v1 profile. Throws BasehError like the instance API. */
|
|
26
|
+
function decode(input, options = {}) {
|
|
27
|
+
return sharedInstance().decode(input, options);
|
|
28
|
+
}
|
|
29
|
+
/** Validate a code with the default expandable v1 profile. Never throws on user input. */
|
|
30
|
+
function validate(input, options = {}) {
|
|
31
|
+
return sharedInstance().validate(input, options);
|
|
32
|
+
}
|
|
33
|
+
/** Live as-you-type inspection with the default expandable v1 profile. Never throws on user input. */
|
|
34
|
+
function inspect(input) {
|
|
35
|
+
return sharedInstance().inspect(input);
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
interface FeistelKey {
|
|
2
|
+
profileId: string;
|
|
3
|
+
keyBytes: Uint8Array;
|
|
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;
|
|
11
|
+
}
|
|
12
|
+
/** Spec 7.3 forward permutation with cycle walking. */
|
|
13
|
+
export declare function permute(value: bigint, capacity: bigint, key: FeistelKey): bigint;
|
|
14
|
+
/** Spec 7.3 inverse permutation with cycle walking. */
|
|
15
|
+
export declare function inversePermute(value: bigint, capacity: bigint, key: FeistelKey): bigint;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.permute = permute;
|
|
4
|
+
exports.inversePermute = inversePermute;
|
|
5
|
+
const hmac_1 = require("@noble/hashes/hmac");
|
|
6
|
+
const sha256_1 = require("@noble/hashes/sha256");
|
|
7
|
+
const errors_js_1 = require("./errors.js");
|
|
8
|
+
const TAG = new TextEncoder().encode("BASEH-FEISTEL-V1");
|
|
9
|
+
const MAX_WALKS = 1000;
|
|
10
|
+
function bitLength(capacity) {
|
|
11
|
+
return (capacity - 1n).toString(2).length;
|
|
12
|
+
}
|
|
13
|
+
/** Low n bits of the HMAC-SHA-256 digest, per spec 7.3. */
|
|
14
|
+
function lowBits(digest, n) {
|
|
15
|
+
const byteCount = Math.ceil(n / 8);
|
|
16
|
+
let v = 0n;
|
|
17
|
+
for (let i = 0; i < byteCount; i += 1) {
|
|
18
|
+
v = (v << 8n) | BigInt(digest[i]);
|
|
19
|
+
}
|
|
20
|
+
return v & ((1n << BigInt(n)) - 1n);
|
|
21
|
+
}
|
|
22
|
+
function toBe(value, byteCount) {
|
|
23
|
+
const out = new Uint8Array(byteCount);
|
|
24
|
+
let v = value;
|
|
25
|
+
for (let i = byteCount - 1; i >= 0; i -= 1) {
|
|
26
|
+
out[i] = Number(v & 0xffn);
|
|
27
|
+
v >>= 8n;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
function roundMessage(profileId, round, right, wr, length) {
|
|
32
|
+
const pidBytes = new TextEncoder().encode(profileId);
|
|
33
|
+
const lenBytes = length === undefined ? new Uint8Array(0) : new TextEncoder().encode(String(length));
|
|
34
|
+
const rightBytes = toBe(right, Math.ceil(wr / 8));
|
|
35
|
+
const msg = new Uint8Array(TAG.length + 1 + pidBytes.length + 1 + lenBytes.length + (length === undefined ? 0 : 1) + 1 + rightBytes.length);
|
|
36
|
+
let o = 0;
|
|
37
|
+
msg.set(TAG, o);
|
|
38
|
+
o += TAG.length;
|
|
39
|
+
msg[o] = 0;
|
|
40
|
+
o += 1;
|
|
41
|
+
msg.set(pidBytes, o);
|
|
42
|
+
o += pidBytes.length;
|
|
43
|
+
msg[o] = 0;
|
|
44
|
+
o += 1;
|
|
45
|
+
if (length !== undefined) {
|
|
46
|
+
msg.set(lenBytes, o);
|
|
47
|
+
o += lenBytes.length;
|
|
48
|
+
msg[o] = 0;
|
|
49
|
+
o += 1;
|
|
50
|
+
}
|
|
51
|
+
msg[o] = round;
|
|
52
|
+
o += 1;
|
|
53
|
+
msg.set(rightBytes, o);
|
|
54
|
+
return msg;
|
|
55
|
+
}
|
|
56
|
+
function runRounds(h, key, w0, w1) {
|
|
57
|
+
let { left, right } = h;
|
|
58
|
+
for (let i = 0; i < key.rounds; i += 1) {
|
|
59
|
+
const even = i % 2 === 0;
|
|
60
|
+
const wr = even ? w1 : w0;
|
|
61
|
+
const wl = even ? w0 : w1;
|
|
62
|
+
const digest = (0, hmac_1.hmac)(sha256_1.sha256, key.keyBytes, roundMessage(key.profileId, i, right, wr, key.length));
|
|
63
|
+
const f = lowBits(digest, wl);
|
|
64
|
+
const newLeft = right;
|
|
65
|
+
const newRight = left ^ f;
|
|
66
|
+
left = newLeft;
|
|
67
|
+
right = newRight;
|
|
68
|
+
}
|
|
69
|
+
return { left, right };
|
|
70
|
+
}
|
|
71
|
+
function runInverse(h, key, w0, w1) {
|
|
72
|
+
let { left, right } = h;
|
|
73
|
+
for (let i = key.rounds - 1; i >= 0; i -= 1) {
|
|
74
|
+
const even = i % 2 === 0;
|
|
75
|
+
const wr = even ? w1 : w0;
|
|
76
|
+
const wl = even ? w0 : w1;
|
|
77
|
+
const digest = (0, hmac_1.hmac)(sha256_1.sha256, key.keyBytes, roundMessage(key.profileId, i, left, wr, key.length));
|
|
78
|
+
const f = lowBits(digest, wl);
|
|
79
|
+
const prevRight = left;
|
|
80
|
+
const prevLeft = right ^ f;
|
|
81
|
+
left = prevLeft;
|
|
82
|
+
right = prevRight;
|
|
83
|
+
}
|
|
84
|
+
return { left, right };
|
|
85
|
+
}
|
|
86
|
+
function combine(h, w1) {
|
|
87
|
+
return (h.left << BigInt(w1)) | h.right;
|
|
88
|
+
}
|
|
89
|
+
function split(value, w1) {
|
|
90
|
+
return { left: value >> BigInt(w1), right: value & ((1n << BigInt(w1)) - 1n) };
|
|
91
|
+
}
|
|
92
|
+
/** Spec 7.3 forward permutation with cycle walking. */
|
|
93
|
+
function permute(value, capacity, key) {
|
|
94
|
+
const bits = bitLength(capacity);
|
|
95
|
+
const w1 = Math.floor(bits / 2);
|
|
96
|
+
const w0 = bits - w1;
|
|
97
|
+
let v = value;
|
|
98
|
+
for (let walk = 0; walk < MAX_WALKS; walk += 1) {
|
|
99
|
+
const out = combine(runRounds(split(v, w1), key, w0, w1), w1);
|
|
100
|
+
if (out < capacity)
|
|
101
|
+
return out;
|
|
102
|
+
v = out;
|
|
103
|
+
}
|
|
104
|
+
throw new errors_js_1.BasehError("PERMUTATION_FAILURE", "Feistel cycle walking exceeded 1000 iterations", false);
|
|
105
|
+
}
|
|
106
|
+
/** Spec 7.3 inverse permutation with cycle walking. */
|
|
107
|
+
function inversePermute(value, capacity, key) {
|
|
108
|
+
const bits = bitLength(capacity);
|
|
109
|
+
const w1 = Math.floor(bits / 2);
|
|
110
|
+
const w0 = bits - w1;
|
|
111
|
+
let v = value;
|
|
112
|
+
for (let walk = 0; walk < MAX_WALKS; walk += 1) {
|
|
113
|
+
const out = combine(runInverse(split(v, w1), key, w0, w1), w1);
|
|
114
|
+
if (out < capacity)
|
|
115
|
+
return out;
|
|
116
|
+
v = out;
|
|
117
|
+
}
|
|
118
|
+
throw new errors_js_1.BasehError("PERMUTATION_FAILURE", "Feistel cycle walking exceeded 1000 iterations", false);
|
|
119
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { BasehError } from "./errors.js";
|
|
2
|
+
export type { BasehErrorCode } from "./errors.js";
|
|
3
|
+
export type { BasehProfile, BasehPermutation, PreparedProfile } from "./profile.js";
|
|
4
|
+
export { prepareProfile, effectiveChecksumLength } from "./profile.js";
|
|
5
|
+
export { Baseh, normalize, formatRaw, generateCandidates, CONFUSION_MAPS } from "./codec.js";
|
|
6
|
+
export type { DecodeOptions, DecodeResult, ValidateResult, InspectResult, ConfusionProfileName } from "./codec.js";
|
|
7
|
+
export { encodeBaseN, decodeBaseN, alphabetIndex } from "./basen.js";
|
|
8
|
+
export { calculateChecksum, checksumValue } from "./checksum.js";
|
|
9
|
+
export { permute, inversePermute } from "./feistel.js";
|
|
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";
|
|
13
|
+
export { DEFAULT_BLOCKLIST, effectiveBlocklist, stripVowels } from "./blocklist.js";
|
|
14
|
+
export type { BasehProfanity, BasehProfanityMode } from "./blocklist.js";
|