@cloudyventures/baseh 1.1.0
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/dist/basen.d.ts +5 -0
- package/dist/basen.js +34 -0
- package/dist/blocklist.d.ts +15 -0
- package/dist/blocklist.js +29 -0
- package/dist/checksum.d.ts +8 -0
- package/dist/checksum.js +30 -0
- package/dist/codec.d.ts +38 -0
- package/dist/codec.js +185 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +11 -0
- package/dist/feistel.d.ts +10 -0
- package/dist/feistel.js +108 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +9 -0
- package/dist/profile.d.ts +40 -0
- package/dist/profile.js +167 -0
- package/dist/profiles.d.ts +22 -0
- package/dist/profiles.js +117 -0
- package/dist/zero.d.ts +4 -0
- package/dist/zero.js +39 -0
- package/package.json +33 -0
package/dist/basen.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Spec 5.1. Fixed-length base-N encode, most significant digit first. */
|
|
2
|
+
export declare function encodeBaseN(value: bigint, alphabet: string, length: number): string;
|
|
3
|
+
/** Spec 5.2. */
|
|
4
|
+
export declare function decodeBaseN(text: string, alphabet: string, index: Map<string, bigint>): bigint;
|
|
5
|
+
export declare function alphabetIndex(alphabet: string): Map<string, bigint>;
|
package/dist/basen.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { BasehError } from "./errors.js";
|
|
2
|
+
/** Spec 5.1. Fixed-length base-N encode, most significant digit first. */
|
|
3
|
+
export function encodeBaseN(value, alphabet, length) {
|
|
4
|
+
const base = BigInt(alphabet.length);
|
|
5
|
+
const out = new Array(length);
|
|
6
|
+
let v = value;
|
|
7
|
+
for (let pos = length - 1; pos >= 0; pos -= 1) {
|
|
8
|
+
const digit = Number(v % base);
|
|
9
|
+
const ch = alphabet[digit];
|
|
10
|
+
if (ch === undefined)
|
|
11
|
+
throw new BasehError("OUT_OF_RANGE", "digit outside alphabet");
|
|
12
|
+
out[pos] = ch;
|
|
13
|
+
v = v / base;
|
|
14
|
+
}
|
|
15
|
+
return out.join("");
|
|
16
|
+
}
|
|
17
|
+
/** Spec 5.2. */
|
|
18
|
+
export function decodeBaseN(text, alphabet, index) {
|
|
19
|
+
const base = BigInt(alphabet.length);
|
|
20
|
+
let value = 0n;
|
|
21
|
+
for (const ch of text) {
|
|
22
|
+
const digit = index.get(ch);
|
|
23
|
+
if (digit === undefined) {
|
|
24
|
+
throw new BasehError("INVALID_CHARACTER", `Symbol ${JSON.stringify(ch)} is not in the alphabet`);
|
|
25
|
+
}
|
|
26
|
+
value = value * base + digit;
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
export function alphabetIndex(alphabet) {
|
|
31
|
+
const m = new Map();
|
|
32
|
+
[...alphabet].forEach((ch, i) => m.set(ch, BigInt(i)));
|
|
33
|
+
return m;
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type BasehProfanityMode = "none" | "no-vowels" | "blocklist";
|
|
2
|
+
/** Spec 18. Optional profanity safety configuration. */
|
|
3
|
+
export interface BasehProfanity {
|
|
4
|
+
mode: BasehProfanityMode;
|
|
5
|
+
/** Replaces the default list when present (mode "blocklist" only). */
|
|
6
|
+
words?: string[];
|
|
7
|
+
/** Appended to the effective list (mode "blocklist" only). */
|
|
8
|
+
extraWords?: string[];
|
|
9
|
+
}
|
|
10
|
+
/** Spec 18.2 default list. Deliberately small; applications extend it. */
|
|
11
|
+
export declare const DEFAULT_BLOCKLIST: readonly string[];
|
|
12
|
+
/** Spec 18.2: replacement semantics, then augmentation, uppercased and deduplicated. */
|
|
13
|
+
export declare function effectiveBlocklist(profanity: BasehProfanity): string[];
|
|
14
|
+
/** Spec 18.1: vowels removed for no-vowels mode, applied after case normalization. */
|
|
15
|
+
export declare function stripVowels(alphabetNorm: string): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { BasehError } from "./errors.js";
|
|
2
|
+
/** Spec 18.2 default list. Deliberately small; applications extend it. */
|
|
3
|
+
export const DEFAULT_BLOCKLIST = [
|
|
4
|
+
"CRAP", "TWAT", "SHAG", "DAMN", "FCK", "FUC",
|
|
5
|
+
"SHT", "CNT", "TWT", "DCK", "AZZ", "BCH"
|
|
6
|
+
];
|
|
7
|
+
const WORD = /^[A-Za-z]{2,32}$/;
|
|
8
|
+
function fail(reason) {
|
|
9
|
+
throw new BasehError("INVALID_PROFILE", `Invalid baseH profile: ${reason}`, false);
|
|
10
|
+
}
|
|
11
|
+
/** Spec 18.2: replacement semantics, then augmentation, uppercased and deduplicated. */
|
|
12
|
+
export function effectiveBlocklist(profanity) {
|
|
13
|
+
const base = profanity.words ? [...profanity.words] : [...DEFAULT_BLOCKLIST];
|
|
14
|
+
const list = [...base, ...(profanity.extraWords ?? [])];
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const word of list) {
|
|
17
|
+
if (typeof word !== "string" || !WORD.test(word)) {
|
|
18
|
+
fail("blocklist entries must be 2 through 32 ASCII letters");
|
|
19
|
+
}
|
|
20
|
+
const upper = word.toUpperCase();
|
|
21
|
+
if (!out.includes(upper))
|
|
22
|
+
out.push(upper);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
/** Spec 18.1: vowels removed for no-vowels mode, applied after case normalization. */
|
|
27
|
+
export function stripVowels(alphabetNorm) {
|
|
28
|
+
return [...alphabetNorm].filter((c) => !"AEIOU".includes(c)).join("");
|
|
29
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PreparedProfile } from "./profile.js";
|
|
2
|
+
/**
|
|
3
|
+
* Spec 6.2. Rolling polynomial checksum over symbol values.
|
|
4
|
+
* Returns the checksum value in [0, modulus).
|
|
5
|
+
*/
|
|
6
|
+
export declare function checksumValue(profile: PreparedProfile, body: string, bodyIndex: Map<string, bigint>): bigint;
|
|
7
|
+
/** Compute the expected checksum string for a normalized body. */
|
|
8
|
+
export declare function calculateChecksum(profile: PreparedProfile, body: string): string;
|
package/dist/checksum.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { BasehError } from "./errors.js";
|
|
2
|
+
import { alphabetIndex, encodeBaseN } from "./basen.js";
|
|
3
|
+
/**
|
|
4
|
+
* Spec 6.2. Rolling polynomial checksum over symbol values.
|
|
5
|
+
* Returns the checksum value in [0, modulus).
|
|
6
|
+
*/
|
|
7
|
+
export function checksumValue(profile, body, bodyIndex) {
|
|
8
|
+
const modulus = profile.checksumModulus;
|
|
9
|
+
let state = 17n;
|
|
10
|
+
for (let i = 0; i < profile.profileId.length; i += 1) {
|
|
11
|
+
state = (state * 37n + BigInt(profile.profileId.charCodeAt(i)) + 1n) % modulus;
|
|
12
|
+
}
|
|
13
|
+
state = (state * 37n) % modulus;
|
|
14
|
+
for (let pos = 0; pos < body.length; pos += 1) {
|
|
15
|
+
const symValue = bodyIndex.get(body[pos]);
|
|
16
|
+
if (symValue === undefined) {
|
|
17
|
+
throw new BasehError("INVALID_CHARACTER", `Body symbol ${JSON.stringify(body[pos])} is not in the body alphabet`);
|
|
18
|
+
}
|
|
19
|
+
state = (state * 37n + symValue + BigInt(pos + 1)) % modulus;
|
|
20
|
+
}
|
|
21
|
+
return state;
|
|
22
|
+
}
|
|
23
|
+
/** Compute the expected checksum string for a normalized body. */
|
|
24
|
+
export function calculateChecksum(profile, body) {
|
|
25
|
+
if (profile.checksumLength === 0)
|
|
26
|
+
return "";
|
|
27
|
+
const index = alphabetIndex(profile.bodyAlphabetNorm);
|
|
28
|
+
const value = checksumValue(profile, body, index);
|
|
29
|
+
return encodeBaseN(value, profile.checksumAlphabetNorm, profile.checksumLength);
|
|
30
|
+
}
|
package/dist/codec.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type BasehErrorCode } from "./errors.js";
|
|
2
|
+
import { type BasehProfile, type PreparedProfile } from "./profile.js";
|
|
3
|
+
export type ConfusionProfileName = "none" | "light" | "medium" | "heavy";
|
|
4
|
+
/** Built-in spoken-confusion candidate maps. Spec 3.3; pairs apply to body symbols only. */
|
|
5
|
+
export declare const CONFUSION_MAPS: Record<Exclude<ConfusionProfileName, "none">, Record<string, string[]>>;
|
|
6
|
+
export interface DecodeOptions {
|
|
7
|
+
acceptSpaces?: boolean;
|
|
8
|
+
tryCorrection?: boolean;
|
|
9
|
+
confusionProfile?: ConfusionProfileName;
|
|
10
|
+
maxCorrections?: 0 | 1;
|
|
11
|
+
}
|
|
12
|
+
export interface DecodeResult {
|
|
13
|
+
id: bigint;
|
|
14
|
+
canonicalCode: string;
|
|
15
|
+
corrected: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ValidateResult {
|
|
18
|
+
valid: boolean;
|
|
19
|
+
canonicalCode?: string;
|
|
20
|
+
reason?: BasehErrorCode;
|
|
21
|
+
}
|
|
22
|
+
/** Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string. */
|
|
23
|
+
export declare function normalize(input: string, profile: PreparedProfile, acceptSpaces?: boolean): string;
|
|
24
|
+
export declare function formatRaw(raw: string, profile: PreparedProfile): string;
|
|
25
|
+
/** Spec 10. Substitution-only candidate generation, capped and deduplicated. */
|
|
26
|
+
export declare function generateCandidates(body: string, confusionMap: Record<string, string[]>, maxEdits?: number): string[];
|
|
27
|
+
export declare class Baseh {
|
|
28
|
+
readonly profile: PreparedProfile;
|
|
29
|
+
private readonly bodyIndex;
|
|
30
|
+
constructor(profile: BasehProfile);
|
|
31
|
+
capacity(): bigint;
|
|
32
|
+
/** Spec 8. */
|
|
33
|
+
encode(id: bigint | number): string;
|
|
34
|
+
/** Spec 9. */
|
|
35
|
+
decode(input: string, options?: DecodeOptions): DecodeResult;
|
|
36
|
+
/** Spec 12.4. Never throws on user input. */
|
|
37
|
+
validate(input: string, options?: DecodeOptions): ValidateResult;
|
|
38
|
+
}
|
package/dist/codec.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { BasehError } from "./errors.js";
|
|
2
|
+
import { decodeBaseN, encodeBaseN, alphabetIndex } from "./basen.js";
|
|
3
|
+
import { calculateChecksum } from "./checksum.js";
|
|
4
|
+
import { inversePermute, permute } from "./feistel.js";
|
|
5
|
+
import { prepareProfile } from "./profile.js";
|
|
6
|
+
/** Built-in spoken-confusion candidate maps. Spec 3.3; pairs apply to body symbols only. */
|
|
7
|
+
export const CONFUSION_MAPS = {
|
|
8
|
+
light: { B: ["D"], D: ["B"], P: ["T"], T: ["P"] },
|
|
9
|
+
medium: { B: ["D"], D: ["B"], P: ["T"], T: ["P"], M: ["N"], N: ["M"], V: ["W"], W: ["V"] },
|
|
10
|
+
heavy: {
|
|
11
|
+
B: ["D"], D: ["B"], P: ["T"], T: ["P"], M: ["N"], N: ["M"],
|
|
12
|
+
V: ["W"], W: ["V"], F: ["S"], S: ["F"], C: ["G"], G: ["C"]
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const ASCII_WS = /^[\t\n\v\f\r ]+|[\t\n\v\f\r ]+$/g;
|
|
16
|
+
const MAX_CANDIDATES = 64;
|
|
17
|
+
/** Spec 3.1 normalization, steps 1-7. Returns the raw unformatted string. */
|
|
18
|
+
export function normalize(input, profile, acceptSpaces = false) {
|
|
19
|
+
let s = input.replace(ASCII_WS, "");
|
|
20
|
+
if (profile.separator.length > 0) {
|
|
21
|
+
s = s.split(profile.separator).join("");
|
|
22
|
+
}
|
|
23
|
+
if (acceptSpaces) {
|
|
24
|
+
s = s.replace(/ /g, "");
|
|
25
|
+
}
|
|
26
|
+
if (!profile.caseSensitive) {
|
|
27
|
+
s = s.toUpperCase();
|
|
28
|
+
}
|
|
29
|
+
s = [...s].map((ch) => profile.aliasesNorm[ch] ?? ch).join("");
|
|
30
|
+
const allowed = new Set([...profile.bodyAlphabetNorm, ...profile.checksumAlphabetNorm]);
|
|
31
|
+
for (const ch of s) {
|
|
32
|
+
if (!allowed.has(ch)) {
|
|
33
|
+
throw new BasehError("INVALID_CHARACTER", `Symbol ${JSON.stringify(ch)} is not accepted`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const expected = profile.bodyLength + profile.checksumLength;
|
|
37
|
+
// Spec 3.4: a code that lost leading zero body symbols is re-padded with
|
|
38
|
+
// the body zero symbol. The checksum symbols always remain, so the split
|
|
39
|
+
// point is unambiguous. A fully stripped no-checksum code would be empty
|
|
40
|
+
// and stays a length error.
|
|
41
|
+
if (s.length < expected && s.length >= Math.max(profile.checksumLength, 1)) {
|
|
42
|
+
const zero = profile.bodyAlphabetNorm[0];
|
|
43
|
+
s = zero.repeat(expected - s.length) + s;
|
|
44
|
+
}
|
|
45
|
+
if (s.length !== expected) {
|
|
46
|
+
throw new BasehError("INVALID_LENGTH", `Expected ${expected} symbols, got ${s.length}`);
|
|
47
|
+
}
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
export function formatRaw(raw, profile) {
|
|
51
|
+
if (profile.separator.length === 0)
|
|
52
|
+
return raw;
|
|
53
|
+
const parts = [];
|
|
54
|
+
let o = 0;
|
|
55
|
+
for (const size of profile.grouping) {
|
|
56
|
+
parts.push(raw.slice(o, o + size));
|
|
57
|
+
o += size;
|
|
58
|
+
}
|
|
59
|
+
return parts.join(profile.separator);
|
|
60
|
+
}
|
|
61
|
+
/** Spec 10. Substitution-only candidate generation, capped and deduplicated. */
|
|
62
|
+
export function generateCandidates(body, confusionMap, maxEdits = 1) {
|
|
63
|
+
if (maxEdits === 0)
|
|
64
|
+
return [];
|
|
65
|
+
const results = new Set();
|
|
66
|
+
const chars = [...body];
|
|
67
|
+
for (let pos = 0; pos < chars.length; pos += 1) {
|
|
68
|
+
const source = chars[pos];
|
|
69
|
+
for (const replacement of confusionMap[source] ?? []) {
|
|
70
|
+
const candidate = [...chars];
|
|
71
|
+
candidate[pos] = replacement;
|
|
72
|
+
results.add(candidate.join(""));
|
|
73
|
+
if (results.size > MAX_CANDIDATES) {
|
|
74
|
+
throw new BasehError("TOO_MANY_CANDIDATES", "Candidate generation exceeded 64 entries", false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return [...results];
|
|
79
|
+
}
|
|
80
|
+
export class Baseh {
|
|
81
|
+
profile;
|
|
82
|
+
bodyIndex;
|
|
83
|
+
constructor(profile) {
|
|
84
|
+
this.profile = prepareProfile(profile);
|
|
85
|
+
this.bodyIndex = alphabetIndex(this.profile.bodyAlphabetNorm);
|
|
86
|
+
}
|
|
87
|
+
capacity() {
|
|
88
|
+
return this.profile.capacity;
|
|
89
|
+
}
|
|
90
|
+
/** Spec 8. */
|
|
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
|
+
}
|
|
96
|
+
const perm = this.profile.permutation;
|
|
97
|
+
if (perm.enabled) {
|
|
98
|
+
value = permute(value, this.profile.capacity, {
|
|
99
|
+
profileId: this.profile.profileId,
|
|
100
|
+
keyBytes: perm.keyBytes,
|
|
101
|
+
rounds: perm.rounds
|
|
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;
|
|
107
|
+
// Spec 18.2: case-insensitive substring scan over the raw code.
|
|
108
|
+
if (this.profile.blocklist.length > 0) {
|
|
109
|
+
const upper = raw.toUpperCase();
|
|
110
|
+
for (const word of this.profile.blocklist) {
|
|
111
|
+
if (upper.includes(word)) {
|
|
112
|
+
throw new BasehError("BLOCKED_CODE", "The generated reference contains a blocked substring", false);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return formatRaw(raw, this.profile);
|
|
117
|
+
}
|
|
118
|
+
/** Spec 9. */
|
|
119
|
+
decode(input, options = {}) {
|
|
120
|
+
const raw = normalize(input, this.profile, options.acceptSpaces === true);
|
|
121
|
+
let body = raw.slice(0, this.profile.bodyLength);
|
|
122
|
+
const suppliedChecksum = raw.slice(this.profile.bodyLength);
|
|
123
|
+
// Spec 3.1 validates union membership before the split. There is no
|
|
124
|
+
// per-region membership check: a checksum-region symbol outside the
|
|
125
|
+
// checksum alphabet simply fails as INVALID_CHECKSUM, and a body symbol
|
|
126
|
+
// outside the body alphabet fails later in decodeBaseN as INVALID_CHARACTER.
|
|
127
|
+
if (calculateChecksum(this.profile, body) !== suppliedChecksum) {
|
|
128
|
+
if (!options.tryCorrection || (options.maxCorrections ?? 1) === 0) {
|
|
129
|
+
throw new BasehError("INVALID_CHECKSUM", "The reference code did not pass validation");
|
|
130
|
+
}
|
|
131
|
+
const mapName = options.confusionProfile ?? "none";
|
|
132
|
+
// Spec 10: replacements that are not body alphabet symbols are
|
|
133
|
+
// dropped before candidate generation. A suggested symbol the alphabet
|
|
134
|
+
// cannot contain (say a spoken drop on a stripped-alphabet profile)
|
|
135
|
+
// could never validate; generating it anyway would throw
|
|
136
|
+
// INVALID_CHARACTER from the checksum step instead of reporting an
|
|
137
|
+
// honest INVALID_CHECKSUM.
|
|
138
|
+
const bodySet = new Set(this.profile.bodyAlphabetNorm);
|
|
139
|
+
const rawMap = mapName === "none" ? {} : CONFUSION_MAPS[mapName];
|
|
140
|
+
const map = {};
|
|
141
|
+
for (const [source, replacements] of Object.entries(rawMap)) {
|
|
142
|
+
const kept = replacements.filter((r) => bodySet.has(r));
|
|
143
|
+
if (kept.length > 0)
|
|
144
|
+
map[source] = kept;
|
|
145
|
+
}
|
|
146
|
+
const valid = new Set();
|
|
147
|
+
for (const candidate of generateCandidates(body, map, options.maxCorrections ?? 1)) {
|
|
148
|
+
if (calculateChecksum(this.profile, candidate) === suppliedChecksum) {
|
|
149
|
+
valid.add(candidate);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (valid.size === 0) {
|
|
153
|
+
throw new BasehError("INVALID_CHECKSUM", "The reference code did not pass validation");
|
|
154
|
+
}
|
|
155
|
+
if (valid.size > 1) {
|
|
156
|
+
throw new BasehError("AMBIGUOUS_INPUT", "The reference code matches more than one record", false);
|
|
157
|
+
}
|
|
158
|
+
body = [...valid][0];
|
|
159
|
+
}
|
|
160
|
+
let value = decodeBaseN(body, this.profile.bodyAlphabetNorm, this.bodyIndex);
|
|
161
|
+
const perm = this.profile.permutation;
|
|
162
|
+
if (perm.enabled) {
|
|
163
|
+
value = inversePermute(value, this.profile.capacity, {
|
|
164
|
+
profileId: this.profile.profileId,
|
|
165
|
+
keyBytes: perm.keyBytes,
|
|
166
|
+
rounds: perm.rounds
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const canonicalCode = this.encode(value);
|
|
170
|
+
const canonicalRaw = canonicalCode.split(this.profile.separator).join("");
|
|
171
|
+
return { id: value, canonicalCode, corrected: raw !== canonicalRaw };
|
|
172
|
+
}
|
|
173
|
+
/** Spec 12.4. Never throws on user input. */
|
|
174
|
+
validate(input, options = {}) {
|
|
175
|
+
try {
|
|
176
|
+
const result = this.decode(input, options);
|
|
177
|
+
return { valid: true, canonicalCode: result.canonicalCode };
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (err instanceof BasehError)
|
|
181
|
+
return { valid: false, reason: err.code };
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export class BasehError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
/** True when the message may be shown to an end user unchanged. */
|
|
4
|
+
safeForCustomer;
|
|
5
|
+
constructor(code, message, safeForCustomer = true) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "BasehError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.safeForCustomer = safeForCustomer;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface FeistelKey {
|
|
2
|
+
profileId: string;
|
|
3
|
+
keyBytes: Uint8Array;
|
|
4
|
+
rounds: number;
|
|
5
|
+
}
|
|
6
|
+
/** Spec 7.3 forward permutation with cycle walking. */
|
|
7
|
+
export declare function permute(value: bigint, capacity: bigint, key: FeistelKey): bigint;
|
|
8
|
+
/** Spec 7.3 inverse permutation with cycle walking. */
|
|
9
|
+
export declare function inversePermute(value: bigint, capacity: bigint, key: FeistelKey): bigint;
|
|
10
|
+
export {};
|
package/dist/feistel.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { hmac } from "@noble/hashes/hmac";
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
3
|
+
import { BasehError } from "./errors.js";
|
|
4
|
+
const TAG = new TextEncoder().encode("BASEH-FEISTEL-V1");
|
|
5
|
+
const MAX_WALKS = 1000;
|
|
6
|
+
function bitLength(capacity) {
|
|
7
|
+
return (capacity - 1n).toString(2).length;
|
|
8
|
+
}
|
|
9
|
+
/** Low n bits of the HMAC-SHA-256 digest, per spec 7.3. */
|
|
10
|
+
function lowBits(digest, n) {
|
|
11
|
+
const byteCount = Math.ceil(n / 8);
|
|
12
|
+
let v = 0n;
|
|
13
|
+
for (let i = 0; i < byteCount; i += 1) {
|
|
14
|
+
v = (v << 8n) | BigInt(digest[i]);
|
|
15
|
+
}
|
|
16
|
+
return v & ((1n << BigInt(n)) - 1n);
|
|
17
|
+
}
|
|
18
|
+
function toBe(value, byteCount) {
|
|
19
|
+
const out = new Uint8Array(byteCount);
|
|
20
|
+
let v = value;
|
|
21
|
+
for (let i = byteCount - 1; i >= 0; i -= 1) {
|
|
22
|
+
out[i] = Number(v & 0xffn);
|
|
23
|
+
v >>= 8n;
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function roundMessage(profileId, round, right, wr) {
|
|
28
|
+
const pidBytes = new TextEncoder().encode(profileId);
|
|
29
|
+
const rightBytes = toBe(right, Math.ceil(wr / 8));
|
|
30
|
+
const msg = new Uint8Array(TAG.length + 1 + pidBytes.length + 1 + 1 + rightBytes.length);
|
|
31
|
+
let o = 0;
|
|
32
|
+
msg.set(TAG, o);
|
|
33
|
+
o += TAG.length;
|
|
34
|
+
msg[o] = 0;
|
|
35
|
+
o += 1;
|
|
36
|
+
msg.set(pidBytes, o);
|
|
37
|
+
o += pidBytes.length;
|
|
38
|
+
msg[o] = 0;
|
|
39
|
+
o += 1;
|
|
40
|
+
msg[o] = round;
|
|
41
|
+
o += 1;
|
|
42
|
+
msg.set(rightBytes, o);
|
|
43
|
+
return msg;
|
|
44
|
+
}
|
|
45
|
+
function runRounds(h, key, w0, w1) {
|
|
46
|
+
let { left, right } = h;
|
|
47
|
+
for (let i = 0; i < key.rounds; i += 1) {
|
|
48
|
+
const even = i % 2 === 0;
|
|
49
|
+
const wr = even ? w1 : w0;
|
|
50
|
+
const wl = even ? w0 : w1;
|
|
51
|
+
const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, right, wr));
|
|
52
|
+
const f = lowBits(digest, wl);
|
|
53
|
+
const newLeft = right;
|
|
54
|
+
const newRight = left ^ f;
|
|
55
|
+
left = newLeft;
|
|
56
|
+
right = newRight;
|
|
57
|
+
}
|
|
58
|
+
return { left, right };
|
|
59
|
+
}
|
|
60
|
+
function runInverse(h, key, w0, w1) {
|
|
61
|
+
let { left, right } = h;
|
|
62
|
+
for (let i = key.rounds - 1; i >= 0; i -= 1) {
|
|
63
|
+
const even = i % 2 === 0;
|
|
64
|
+
const wr = even ? w1 : w0;
|
|
65
|
+
const wl = even ? w0 : w1;
|
|
66
|
+
const digest = hmac(sha256, key.keyBytes, roundMessage(key.profileId, i, left, wr));
|
|
67
|
+
const f = lowBits(digest, wl);
|
|
68
|
+
const prevRight = left;
|
|
69
|
+
const prevLeft = right ^ f;
|
|
70
|
+
left = prevLeft;
|
|
71
|
+
right = prevRight;
|
|
72
|
+
}
|
|
73
|
+
return { left, right };
|
|
74
|
+
}
|
|
75
|
+
function combine(h, w1) {
|
|
76
|
+
return (h.left << BigInt(w1)) | h.right;
|
|
77
|
+
}
|
|
78
|
+
function split(value, w1) {
|
|
79
|
+
return { left: value >> BigInt(w1), right: value & ((1n << BigInt(w1)) - 1n) };
|
|
80
|
+
}
|
|
81
|
+
/** Spec 7.3 forward permutation with cycle walking. */
|
|
82
|
+
export function permute(value, capacity, key) {
|
|
83
|
+
const bits = bitLength(capacity);
|
|
84
|
+
const w1 = Math.floor(bits / 2);
|
|
85
|
+
const w0 = bits - w1;
|
|
86
|
+
let v = value;
|
|
87
|
+
for (let walk = 0; walk < MAX_WALKS; walk += 1) {
|
|
88
|
+
const out = combine(runRounds(split(v, w1), key, w0, w1), w1);
|
|
89
|
+
if (out < capacity)
|
|
90
|
+
return out;
|
|
91
|
+
v = out;
|
|
92
|
+
}
|
|
93
|
+
throw new BasehError("PERMUTATION_FAILURE", "Feistel cycle walking exceeded 1000 iterations", false);
|
|
94
|
+
}
|
|
95
|
+
/** Spec 7.3 inverse permutation with cycle walking. */
|
|
96
|
+
export function inversePermute(value, capacity, key) {
|
|
97
|
+
const bits = bitLength(capacity);
|
|
98
|
+
const w1 = Math.floor(bits / 2);
|
|
99
|
+
const w0 = bits - w1;
|
|
100
|
+
let v = value;
|
|
101
|
+
for (let walk = 0; walk < MAX_WALKS; walk += 1) {
|
|
102
|
+
const out = combine(runInverse(split(v, w1), key, w0, w1), w1);
|
|
103
|
+
if (out < capacity)
|
|
104
|
+
return out;
|
|
105
|
+
v = out;
|
|
106
|
+
}
|
|
107
|
+
throw new BasehError("PERMUTATION_FAILURE", "Feistel cycle walking exceeded 1000 iterations", false);
|
|
108
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
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 } from "./profile.js";
|
|
5
|
+
export { Baseh, normalize, formatRaw, generateCandidates, CONFUSION_MAPS } from "./codec.js";
|
|
6
|
+
export type { DecodeOptions, DecodeResult, ValidateResult, 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, type FrozenKeyOptions } from "./profiles.js";
|
|
11
|
+
export { toCode, fromCode } from "./zero.js";
|
|
12
|
+
export { DEFAULT_BLOCKLIST, effectiveBlocklist, stripVowels } from "./blocklist.js";
|
|
13
|
+
export type { BasehProfanity, BasehProfanityMode } from "./blocklist.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { BasehError } from "./errors.js";
|
|
2
|
+
export { prepareProfile } from "./profile.js";
|
|
3
|
+
export { Baseh, normalize, formatRaw, generateCandidates, CONFUSION_MAPS } from "./codec.js";
|
|
4
|
+
export { encodeBaseN, decodeBaseN, alphabetIndex } from "./basen.js";
|
|
5
|
+
export { calculateChecksum, checksumValue } from "./checksum.js";
|
|
6
|
+
export { permute, inversePermute } from "./feistel.js";
|
|
7
|
+
export { basehMinimumV1, basehLightV1, basehMediumV1, basehHeavyV1, basehMinimumPV1, basehLightPV1, basehMediumPV1, basehHeavyPV1 } from "./profiles.js";
|
|
8
|
+
export { toCode, fromCode } from "./zero.js";
|
|
9
|
+
export { DEFAULT_BLOCKLIST, effectiveBlocklist, stripVowels } from "./blocklist.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type BasehProfanity } from "./blocklist.js";
|
|
2
|
+
export type BasehPermutation = {
|
|
3
|
+
enabled: false;
|
|
4
|
+
} | {
|
|
5
|
+
enabled: true;
|
|
6
|
+
algorithm: "feistel-v1";
|
|
7
|
+
keyId: string;
|
|
8
|
+
keyBytes: Uint8Array;
|
|
9
|
+
rounds: number;
|
|
10
|
+
};
|
|
11
|
+
export interface BasehProfile {
|
|
12
|
+
profileId: string;
|
|
13
|
+
bodyAlphabet: string;
|
|
14
|
+
bodyLength: number;
|
|
15
|
+
checksumAlphabet: string;
|
|
16
|
+
checksumLength: number;
|
|
17
|
+
caseSensitive: boolean;
|
|
18
|
+
separator: string;
|
|
19
|
+
grouping: number[];
|
|
20
|
+
aliases: Record<string, string>;
|
|
21
|
+
permutation: BasehPermutation;
|
|
22
|
+
/** Spec 18. Defaults to mode "none". */
|
|
23
|
+
profanity?: BasehProfanity;
|
|
24
|
+
}
|
|
25
|
+
/** Case-prepared derived data, computed once at construction. */
|
|
26
|
+
export interface PreparedProfile extends BasehProfile {
|
|
27
|
+
readonly bodyAlphabetNorm: string;
|
|
28
|
+
readonly checksumAlphabetNorm: string;
|
|
29
|
+
readonly aliasesNorm: Record<string, string>;
|
|
30
|
+
readonly checksumModulus: bigint;
|
|
31
|
+
readonly capacity: bigint;
|
|
32
|
+
/** Spec 18. Empty unless the profile uses mode "blocklist". */
|
|
33
|
+
readonly blocklist: string[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Validates a profile per spec section 2.2 and returns it with derived,
|
|
37
|
+
* pre-computed values. Throws BasehError INVALID_PROFILE on any violation.
|
|
38
|
+
* Call once at construction, never per encode/decode.
|
|
39
|
+
*/
|
|
40
|
+
export declare function prepareProfile(profile: BasehProfile): PreparedProfile;
|
package/dist/profile.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { BasehError } from "./errors.js";
|
|
2
|
+
import { effectiveBlocklist, stripVowels } from "./blocklist.js";
|
|
3
|
+
const ASCII_ONLY = /^[\x20-\x7e]*$/;
|
|
4
|
+
function fail(reason) {
|
|
5
|
+
throw new BasehError("INVALID_PROFILE", `Invalid baseH profile: ${reason}`, false);
|
|
6
|
+
}
|
|
7
|
+
function isAsciiChar(ch) {
|
|
8
|
+
return ch.length === 1 && ASCII_ONLY.test(ch);
|
|
9
|
+
}
|
|
10
|
+
function norm(profile, ch) {
|
|
11
|
+
return profile.caseSensitive ? ch : ch.toUpperCase();
|
|
12
|
+
}
|
|
13
|
+
function powBigInt(base, exp) {
|
|
14
|
+
let result = 1n;
|
|
15
|
+
for (let i = 0; i < exp; i += 1)
|
|
16
|
+
result *= base;
|
|
17
|
+
return result;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Validates a profile per spec section 2.2 and returns it with derived,
|
|
21
|
+
* pre-computed values. Throws BasehError INVALID_PROFILE on any violation.
|
|
22
|
+
* Call once at construction, never per encode/decode.
|
|
23
|
+
*/
|
|
24
|
+
export function prepareProfile(profile) {
|
|
25
|
+
if (!profile || typeof profile !== "object")
|
|
26
|
+
fail("profile is required");
|
|
27
|
+
if (typeof profile.profileId !== "string" || profile.profileId.length === 0) {
|
|
28
|
+
fail("profileId must be non-empty");
|
|
29
|
+
}
|
|
30
|
+
if (!ASCII_ONLY.test(profile.profileId))
|
|
31
|
+
fail("profileId must be ASCII");
|
|
32
|
+
const caseSensitive = profile.caseSensitive === true;
|
|
33
|
+
const bodyAlphabet = profile.bodyAlphabet;
|
|
34
|
+
if (typeof bodyAlphabet !== "string" || bodyAlphabet.length < 2) {
|
|
35
|
+
fail("bodyAlphabet needs at least two symbols");
|
|
36
|
+
}
|
|
37
|
+
for (const ch of bodyAlphabet) {
|
|
38
|
+
if (!isAsciiChar(ch))
|
|
39
|
+
fail(`body alphabet symbol is not single ASCII: ${JSON.stringify(ch)}`);
|
|
40
|
+
}
|
|
41
|
+
const view = { caseSensitive };
|
|
42
|
+
let bodyNorm = [...bodyAlphabet].map((c) => norm(view, c)).join("");
|
|
43
|
+
if (new Set(bodyNorm).size !== bodyNorm.length) {
|
|
44
|
+
fail("body alphabet symbols must be unique after case normalization");
|
|
45
|
+
}
|
|
46
|
+
if (!Number.isInteger(profile.bodyLength) ||
|
|
47
|
+
profile.bodyLength < 1 ||
|
|
48
|
+
profile.bodyLength > 32) {
|
|
49
|
+
fail("bodyLength must be an integer from 1 through 32");
|
|
50
|
+
}
|
|
51
|
+
if (!Number.isInteger(profile.checksumLength) ||
|
|
52
|
+
profile.checksumLength < 0 ||
|
|
53
|
+
profile.checksumLength > 8) {
|
|
54
|
+
fail("checksumLength must be an integer from 0 through 8");
|
|
55
|
+
}
|
|
56
|
+
const checksumAlphabet = profile.checksumAlphabet ?? "";
|
|
57
|
+
if (profile.checksumLength > 0) {
|
|
58
|
+
if (typeof checksumAlphabet !== "string" || checksumAlphabet.length < 2) {
|
|
59
|
+
fail("checksumAlphabet needs at least two symbols when checksumLength is positive");
|
|
60
|
+
}
|
|
61
|
+
for (const ch of checksumAlphabet) {
|
|
62
|
+
if (!isAsciiChar(ch))
|
|
63
|
+
fail(`checksum alphabet symbol is not single ASCII: ${JSON.stringify(ch)}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
let checksumNorm = [...checksumAlphabet].map((c) => norm(view, c)).join("");
|
|
67
|
+
if (new Set(checksumNorm).size !== checksumNorm.length) {
|
|
68
|
+
fail("checksum alphabet symbols must be unique after case normalization");
|
|
69
|
+
}
|
|
70
|
+
// Spec 18. no-vowels strips vowels before every downstream rule; blocklist
|
|
71
|
+
// only arms the encode-time scan.
|
|
72
|
+
const profanity = profile.profanity ?? { mode: "none" };
|
|
73
|
+
if (!["none", "no-vowels", "blocklist"].includes(profanity.mode)) {
|
|
74
|
+
fail("profanity mode must be none, no-vowels or blocklist");
|
|
75
|
+
}
|
|
76
|
+
if (profanity.mode === "no-vowels") {
|
|
77
|
+
bodyNorm = stripVowels(bodyNorm);
|
|
78
|
+
checksumNorm = stripVowels(checksumNorm);
|
|
79
|
+
if (bodyNorm.length < 2) {
|
|
80
|
+
fail("no-vowels mode leaves the body alphabet with fewer than two symbols");
|
|
81
|
+
}
|
|
82
|
+
if (profile.checksumLength > 0 && checksumNorm.length < 2) {
|
|
83
|
+
fail("no-vowels mode leaves the checksum alphabet with fewer than two symbols");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const blocklist = profanity.mode === "blocklist" ? effectiveBlocklist(profanity) : [];
|
|
87
|
+
const separator = profile.separator ?? "";
|
|
88
|
+
for (const ch of separator) {
|
|
89
|
+
if (bodyNorm.includes(ch) || checksumNorm.includes(ch)) {
|
|
90
|
+
fail("separator must not occur in either alphabet");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const aliases = profile.aliases ?? {};
|
|
94
|
+
const aliasesNorm = {};
|
|
95
|
+
const canonicalSet = new Set([...bodyNorm, ...checksumNorm]);
|
|
96
|
+
for (const [src, tgt] of Object.entries(aliases)) {
|
|
97
|
+
if (!isAsciiChar(src))
|
|
98
|
+
fail(`alias source is not single ASCII: ${JSON.stringify(src)}`);
|
|
99
|
+
if (!isAsciiChar(tgt))
|
|
100
|
+
fail(`alias target is not single ASCII: ${JSON.stringify(tgt)}`);
|
|
101
|
+
const sNorm = norm(view, src);
|
|
102
|
+
const tNorm = norm(view, tgt);
|
|
103
|
+
if (canonicalSet.has(sNorm)) {
|
|
104
|
+
fail(`alias source ${JSON.stringify(src)} is already a canonical symbol`);
|
|
105
|
+
}
|
|
106
|
+
if (!canonicalSet.has(tNorm)) {
|
|
107
|
+
fail(`alias target ${JSON.stringify(tgt)} is not a canonical symbol`);
|
|
108
|
+
}
|
|
109
|
+
if (sNorm in aliasesNorm)
|
|
110
|
+
fail(`duplicate alias source ${JSON.stringify(sNorm)} after case normalization`);
|
|
111
|
+
if (tNorm in aliases || [...Object.keys(aliases)].some((k) => norm(view, k) === tNorm)) {
|
|
112
|
+
fail(`alias chain forbidden: target ${tNorm} is also an alias source`);
|
|
113
|
+
}
|
|
114
|
+
aliasesNorm[sNorm] = tNorm;
|
|
115
|
+
}
|
|
116
|
+
const total = bodySum(profile.grouping);
|
|
117
|
+
if (separator.length === 0) {
|
|
118
|
+
if (profile.grouping.length !== 0)
|
|
119
|
+
fail("grouping must be empty when separator is empty");
|
|
120
|
+
}
|
|
121
|
+
else if (total !== profile.bodyLength + profile.checksumLength) {
|
|
122
|
+
fail("group sizes must sum to bodyLength + checksumLength");
|
|
123
|
+
}
|
|
124
|
+
const permutation = profile.permutation ?? { enabled: false };
|
|
125
|
+
if (permutation.enabled) {
|
|
126
|
+
if (permutation.algorithm !== "feistel-v1")
|
|
127
|
+
fail("unknown permutation algorithm");
|
|
128
|
+
if (typeof permutation.keyId !== "string" || permutation.keyId.length === 0) {
|
|
129
|
+
fail("permutation requires a keyId");
|
|
130
|
+
}
|
|
131
|
+
if (!(permutation.keyBytes instanceof Uint8Array) || permutation.keyBytes.length === 0) {
|
|
132
|
+
fail("permutation requires key material");
|
|
133
|
+
}
|
|
134
|
+
if (!Number.isInteger(permutation.rounds) ||
|
|
135
|
+
permutation.rounds < 4 ||
|
|
136
|
+
permutation.rounds > 16 ||
|
|
137
|
+
permutation.rounds % 2 !== 0) {
|
|
138
|
+
fail("Feistel rounds must be an even integer from 4 through 16");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
...profile,
|
|
143
|
+
caseSensitive,
|
|
144
|
+
checksumAlphabet,
|
|
145
|
+
separator,
|
|
146
|
+
grouping: [...profile.grouping],
|
|
147
|
+
aliases: { ...aliases },
|
|
148
|
+
permutation,
|
|
149
|
+
bodyAlphabetNorm: bodyNorm,
|
|
150
|
+
checksumAlphabetNorm: checksumNorm,
|
|
151
|
+
aliasesNorm,
|
|
152
|
+
checksumModulus: powBigInt(BigInt(checksumNorm.length || 1), profile.checksumLength),
|
|
153
|
+
capacity: powBigInt(BigInt(bodyNorm.length), profile.bodyLength),
|
|
154
|
+
blocklist
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function bodySum(grouping) {
|
|
158
|
+
if (!Array.isArray(grouping))
|
|
159
|
+
return -1;
|
|
160
|
+
let sum = 0;
|
|
161
|
+
for (const g of grouping) {
|
|
162
|
+
if (!Number.isInteger(g) || g < 1)
|
|
163
|
+
return -1;
|
|
164
|
+
sum += g;
|
|
165
|
+
}
|
|
166
|
+
return sum;
|
|
167
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BasehProfile } from "./profile.js";
|
|
2
|
+
export interface FrozenKeyOptions {
|
|
3
|
+
keyBytes: Uint8Array;
|
|
4
|
+
keyId?: string;
|
|
5
|
+
rounds?: number;
|
|
6
|
+
}
|
|
7
|
+
/** Alphanumeric, no safety strips, no checksum, hyphen-delimited XXX-XXX. */
|
|
8
|
+
export declare function basehMinimumV1(): BasehProfile;
|
|
9
|
+
/** baseh-minimum with feistel-v1 permutation. */
|
|
10
|
+
export declare function basehMinimumPV1(options: FrozenKeyOptions): BasehProfile;
|
|
11
|
+
/** Visual light plus spoken light, one checksum symbol. */
|
|
12
|
+
export declare function basehLightV1(): BasehProfile;
|
|
13
|
+
/** baseh-light with feistel-v1 permutation. */
|
|
14
|
+
export declare function basehLightPV1(options: FrozenKeyOptions): BasehProfile;
|
|
15
|
+
/** Visual medium plus spoken medium, one checksum symbol. The default. */
|
|
16
|
+
export declare function basehMediumV1(): BasehProfile;
|
|
17
|
+
/** baseh-medium with feistel-v1 permutation. */
|
|
18
|
+
export declare function basehMediumPV1(options: FrozenKeyOptions): BasehProfile;
|
|
19
|
+
/** Conservative alphabet plus spoken heavy, one checksum symbol. */
|
|
20
|
+
export declare function basehHeavyV1(): BasehProfile;
|
|
21
|
+
/** baseh-heavy with feistel-v1 permutation. */
|
|
22
|
+
export declare function basehHeavyPV1(options: FrozenKeyOptions): BasehProfile;
|
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen tiers. Each is built from the full alphanumeric set with cumulative
|
|
3
|
+
* visual and spoken strips; the spoken strips interact with the visual ones
|
|
4
|
+
* exactly as the web tools derive them, so the tool capacities match.
|
|
5
|
+
*
|
|
6
|
+
* Minimum 36 symbols, no checksum 2,176,782,336 ids
|
|
7
|
+
* Light 31 symbols, 1 checksum 887,503,681 ids
|
|
8
|
+
* Medium 28 symbols, 1 checksum 481,890,304 ids (default)
|
|
9
|
+
* Heavy 26 symbols, 1 checksum 308,915,776 ids
|
|
10
|
+
*
|
|
11
|
+
* All four keep the typed O/I/L aliases where possible and run the default
|
|
12
|
+
* profanity blocklist. Minimum also uses a hyphen delimiter; the rest have
|
|
13
|
+
* none. The -p variants are identical but with feistel-v1 permutation and
|
|
14
|
+
* require caller-supplied key material.
|
|
15
|
+
*/
|
|
16
|
+
const OIL_ALIASES = { O: "0", I: "1", L: "1" };
|
|
17
|
+
const MINIMUM_BODY = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
18
|
+
const LIGHT_BODY = "0123456789ABCEFGHJKMNPQRSUVWXYZ";
|
|
19
|
+
const MEDIUM_BODY = "0123456789ACDEFGHJKMPQRUVXYZ";
|
|
20
|
+
const HEAVY_BODY = "0123456789ABCEFHJKMPQRVXYZ";
|
|
21
|
+
const LIGHT_CHECK = "234679ACEFGHJKMNPQRUVWXY";
|
|
22
|
+
const MEDIUM_CHECK = "234679ACDEFGHJKMPQRUVXY";
|
|
23
|
+
const HEAVY_CHECK = "234679ACEFHJKMPQRUVXY";
|
|
24
|
+
function keyedPermutation(options) {
|
|
25
|
+
return {
|
|
26
|
+
enabled: true,
|
|
27
|
+
algorithm: "feistel-v1",
|
|
28
|
+
keyId: options.keyId ?? "default",
|
|
29
|
+
keyBytes: options.keyBytes,
|
|
30
|
+
rounds: options.rounds ?? 8
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function tier(shape, permutation, pSuffix) {
|
|
34
|
+
return {
|
|
35
|
+
profileId: shape.profileId + (pSuffix ? "-p" : "") + "-v1",
|
|
36
|
+
bodyAlphabet: shape.bodyAlphabet,
|
|
37
|
+
bodyLength: 6,
|
|
38
|
+
checksumAlphabet: shape.checksumAlphabet,
|
|
39
|
+
checksumLength: shape.checksumLength,
|
|
40
|
+
caseSensitive: false,
|
|
41
|
+
separator: shape.separator,
|
|
42
|
+
grouping: shape.grouping,
|
|
43
|
+
aliases: { ...shape.aliases },
|
|
44
|
+
permutation,
|
|
45
|
+
profanity: { mode: "blocklist" }
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const MINIMUM = {
|
|
49
|
+
profileId: "baseh-minimum",
|
|
50
|
+
bodyAlphabet: MINIMUM_BODY,
|
|
51
|
+
checksumAlphabet: "",
|
|
52
|
+
checksumLength: 0,
|
|
53
|
+
separator: "-",
|
|
54
|
+
grouping: [3, 3],
|
|
55
|
+
aliases: {}
|
|
56
|
+
};
|
|
57
|
+
const LIGHT = {
|
|
58
|
+
profileId: "baseh-light",
|
|
59
|
+
bodyAlphabet: LIGHT_BODY,
|
|
60
|
+
checksumAlphabet: LIGHT_CHECK,
|
|
61
|
+
checksumLength: 1,
|
|
62
|
+
separator: "",
|
|
63
|
+
grouping: [],
|
|
64
|
+
aliases: { ...OIL_ALIASES, D: "B", T: "P" }
|
|
65
|
+
};
|
|
66
|
+
const MEDIUM = {
|
|
67
|
+
profileId: "baseh-medium",
|
|
68
|
+
bodyAlphabet: MEDIUM_BODY,
|
|
69
|
+
checksumAlphabet: MEDIUM_CHECK,
|
|
70
|
+
checksumLength: 1,
|
|
71
|
+
separator: "",
|
|
72
|
+
grouping: [],
|
|
73
|
+
// B and S are dropped for looking like 8 and 5; since they can never be
|
|
74
|
+
// issued, a typed B is always an 8 and a typed S always a 5.
|
|
75
|
+
aliases: { ...OIL_ALIASES, B: "8", S: "5", T: "P", N: "M", W: "V" }
|
|
76
|
+
};
|
|
77
|
+
const HEAVY = {
|
|
78
|
+
profileId: "baseh-heavy",
|
|
79
|
+
bodyAlphabet: HEAVY_BODY,
|
|
80
|
+
checksumAlphabet: HEAVY_CHECK,
|
|
81
|
+
checksumLength: 1,
|
|
82
|
+
separator: "",
|
|
83
|
+
grouping: [],
|
|
84
|
+
aliases: { ...OIL_ALIASES, D: "B", T: "P", N: "M", W: "V", S: "F", G: "C" }
|
|
85
|
+
};
|
|
86
|
+
/** Alphanumeric, no safety strips, no checksum, hyphen-delimited XXX-XXX. */
|
|
87
|
+
export function basehMinimumV1() {
|
|
88
|
+
return tier(MINIMUM, { enabled: false }, false);
|
|
89
|
+
}
|
|
90
|
+
/** baseh-minimum with feistel-v1 permutation. */
|
|
91
|
+
export function basehMinimumPV1(options) {
|
|
92
|
+
return tier(MINIMUM, keyedPermutation(options), true);
|
|
93
|
+
}
|
|
94
|
+
/** Visual light plus spoken light, one checksum symbol. */
|
|
95
|
+
export function basehLightV1() {
|
|
96
|
+
return tier(LIGHT, { enabled: false }, false);
|
|
97
|
+
}
|
|
98
|
+
/** baseh-light with feistel-v1 permutation. */
|
|
99
|
+
export function basehLightPV1(options) {
|
|
100
|
+
return tier(LIGHT, keyedPermutation(options), true);
|
|
101
|
+
}
|
|
102
|
+
/** Visual medium plus spoken medium, one checksum symbol. The default. */
|
|
103
|
+
export function basehMediumV1() {
|
|
104
|
+
return tier(MEDIUM, { enabled: false }, false);
|
|
105
|
+
}
|
|
106
|
+
/** baseh-medium with feistel-v1 permutation. */
|
|
107
|
+
export function basehMediumPV1(options) {
|
|
108
|
+
return tier(MEDIUM, keyedPermutation(options), true);
|
|
109
|
+
}
|
|
110
|
+
/** Conservative alphabet plus spoken heavy, one checksum symbol. */
|
|
111
|
+
export function basehHeavyV1() {
|
|
112
|
+
return tier(HEAVY, { enabled: false }, false);
|
|
113
|
+
}
|
|
114
|
+
/** baseh-heavy with feistel-v1 permutation. */
|
|
115
|
+
export function basehHeavyPV1(options) {
|
|
116
|
+
return tier(HEAVY, keyedPermutation(options), true);
|
|
117
|
+
}
|
package/dist/zero.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Encode an identifier with the zero-config Medium profile. */
|
|
2
|
+
export declare function toCode(id: bigint | number | string): string;
|
|
3
|
+
/** Decode a code from the zero-config Medium profile back to its identifier. */
|
|
4
|
+
export declare function fromCode(code: string): bigint;
|
package/dist/zero.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Baseh } from "./codec.js";
|
|
2
|
+
import { basehMediumV1 } from "./profiles.js";
|
|
3
|
+
/**
|
|
4
|
+
* Zero-config pair over the frozen baseh-medium-v1 profile. No profile
|
|
5
|
+
* object, no key: just the two functions an application needs when it
|
|
6
|
+
* does not want to think about configuration.
|
|
7
|
+
*
|
|
8
|
+
* toCode(id) -> "7KM4Q2H"
|
|
9
|
+
* fromCode(code) -> id
|
|
10
|
+
*
|
|
11
|
+
* toCode accepts a bigint, a safe integer number or a decimal string.
|
|
12
|
+
* fromCode strips every whitespace character (edges and internal),
|
|
13
|
+
* accepts lowercase and the typed aliases (O, I, L) and returns the id
|
|
14
|
+
* as a bigint. Any invalid input throws BasehError, including the rare
|
|
15
|
+
* BLOCKED_CODE identifiers that spell a blocklisted word; no correction
|
|
16
|
+
* attempts are ever made.
|
|
17
|
+
*/
|
|
18
|
+
const ZERO = new Baseh(basehMediumV1());
|
|
19
|
+
function toBigInt(id) {
|
|
20
|
+
if (typeof id === "bigint")
|
|
21
|
+
return id;
|
|
22
|
+
if (typeof id === "number") {
|
|
23
|
+
if (!Number.isSafeInteger(id) || id < 0) {
|
|
24
|
+
throw new TypeError("toCode expects a non-negative safe integer, bigint or decimal string");
|
|
25
|
+
}
|
|
26
|
+
return BigInt(id);
|
|
27
|
+
}
|
|
28
|
+
if (typeof id === "string" && /^[0-9]+$/.test(id))
|
|
29
|
+
return BigInt(id);
|
|
30
|
+
throw new TypeError("toCode expects a non-negative safe integer, bigint or decimal string");
|
|
31
|
+
}
|
|
32
|
+
/** Encode an identifier with the zero-config Medium profile. */
|
|
33
|
+
export function toCode(id) {
|
|
34
|
+
return ZERO.encode(toBigInt(id));
|
|
35
|
+
}
|
|
36
|
+
/** Decode a code from the zero-config Medium profile back to its identifier. */
|
|
37
|
+
export function fromCode(code) {
|
|
38
|
+
return ZERO.decode(code.replace(/\s+/g, "")).id;
|
|
39
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cloudyventures/baseh",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "baseH: reversible, checksummed, human-safe short references for integers.",
|
|
5
|
+
"repository": "github:cloudyventures/baseh",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"test": "tsx --test test/*.test.ts",
|
|
21
|
+
"vectors": "tsx scripts/generate-vectors.ts"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@noble/hashes": "^1.8.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^24.0.0",
|
|
28
|
+
"fast-check": "^4.0.0",
|
|
29
|
+
"tsx": "^4.19.0",
|
|
30
|
+
"typescript": "^5.6.0"
|
|
31
|
+
},
|
|
32
|
+
"license": "AGPL-3.0-only"
|
|
33
|
+
}
|