wordlist.ts does what wordkey.Generate, CheckList and Bits of Go do at 27a75ee, with their texts: generateWords draws different words, 7 by default, with crypto.getRandomValues; wordBits is their strength; checkWordList refuses a list of fewer than 2048 words, with two words that are one once normalized or with a character outside the alphabet of its language, which the code gives and not the list. readWordList takes a list only with the SHA-256 pinned for its language, in UTF-8 and accepted by checkWordList: no list is trusted, not even those of DateKeys. wordkey.ts gains wordRules, which loads the Unicode tables once for a caller that reads many words; normalizeWords and checkWords use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
a077e85e47
commit
704838016b
@ -0,0 +1,153 @@
|
||||
// Tests of wordlist.ts: the cases of generate_test.go of Go wordkey, with its
|
||||
// texts; the list of datekeys-go, read with the SHA-256 that its README
|
||||
// records; and draws that are uniform and never repeat a word.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toHex, utf8Bytes } from './bytes.ts';
|
||||
import type { RandomWords } from './random.ts';
|
||||
import { checkWords, normalizeWords } from './wordkey.ts';
|
||||
import { checkWordList, DEFAULT_WORD_COUNT, generateWords, MIN_LIST_SIZE, readWordList, WORD_LIST_SHA256, wordBits } from './wordlist.ts';
|
||||
|
||||
const listFile = (name: string): Uint8Array => new Uint8Array(readFileSync(fileURLToPath(new URL(`../../../wordlists/${name}`, import.meta.url))));
|
||||
const cp = (r: number): string => String.fromCodePoint(r);
|
||||
const hash = async (b: Uint8Array): Promise<string> => toHex(new Uint8Array(await crypto.subtle.digest('SHA-256', b as Uint8Array<ArrayBuffer>)));
|
||||
|
||||
// pal followed by three letters: palaaa, palaab, …, as in Go.
|
||||
const base = Array.from({ length: MIN_LIST_SIZE }, (_, i) => `pal${String.fromCharCode(97 + Math.floor(i / 676), 97 + (Math.floor(i / 26) % 26), 97 + (i % 26))}`);
|
||||
const withWord = (i: number, w: string): string[] => base.map((x, j) => (j === i ? w : x));
|
||||
const fileOf = (words: readonly string[]): Uint8Array => utf8Bytes(`${words.join('\n')}\n`);
|
||||
|
||||
// A source of 32-bit words that repeats `seed`.
|
||||
function repeating(seed: readonly number[]): RandomWords {
|
||||
let i = 0;
|
||||
return () => seed[i++ % seed.length]!;
|
||||
}
|
||||
|
||||
describe('readWordList', () => {
|
||||
it('reads the list es of datekeys-go, with the SHA-256 that its README records', async () => {
|
||||
const readme = new TextDecoder().decode(listFile('README.md'));
|
||||
expect(readme).toContain(`| \`es.txt\` | 7776 | \`${WORD_LIST_SHA256['es']}\` |`);
|
||||
const words = await readWordList('es', listFile('es.txt'));
|
||||
expect(words).toHaveLength(7776);
|
||||
expect([words[0], words.at(-1)]).toEqual(['abad', 'útil']);
|
||||
});
|
||||
|
||||
it('refuses a list of another SHA-256 and a language without a list, with the texts of Go', async () => {
|
||||
const changed = listFile('es.txt');
|
||||
changed[0]! ^= 1;
|
||||
const refused = `wordkey: the list "es" has the SHA-256 ${await hash(changed)}, not ${WORD_LIST_SHA256['es']}`;
|
||||
await expect(readWordList('es', changed)).rejects.toThrow(refused);
|
||||
await expect(readWordList('xx', listFile('es.txt'))).rejects.toThrow(/^wordkey: no word list for "xx"; the lists are es$/);
|
||||
await expect(readWordList('es', fileOf(base), { fr: '00', de: '11' })).rejects.toThrow(/^wordkey: no word list for "es"; the lists are de, fr$/);
|
||||
});
|
||||
|
||||
it('refuses a list with its pin that is not UTF-8, or that checkWordList refuses, as Go wordkey.List', async () => {
|
||||
const pin = async (lang: string, b: Uint8Array): Promise<Record<string, string>> => ({ [lang]: await hash(b) });
|
||||
const ok = fileOf(base);
|
||||
await expect(readWordList('es', ok, await pin('es', ok))).resolves.toEqual(base);
|
||||
// Without the end of its last line, too.
|
||||
const unended = ok.slice(0, -1);
|
||||
await expect(readWordList('es', unended, await pin('es', unended))).resolves.toEqual(base);
|
||||
const cyrillic = fileOf(withWord(5, `${cp(0x441)}asa`));
|
||||
const err = (await readWordList('es', cyrillic, await pin('es', cyrillic)).catch((e: unknown) => e)) as Error;
|
||||
expect(err.message).toBe(`wordkey: the list "es": line 6, "${cp(0x441)}asa", holds U+0441, which is not in the alphabet of "es"`);
|
||||
expect((err.cause as Error).message).toBe(`line 6, "${cp(0x441)}asa", holds U+0441, which is not in the alphabet of "es"`);
|
||||
// A BOM stays: it is an invisible character of the first word.
|
||||
const bom = utf8Bytes(`${cp(0xfeff)}${base.join('\n')}\n`);
|
||||
await expect(readWordList('es', bom, await pin('es', bom))).rejects.toThrow(/^wordkey: the list "es": line 1: wordkey: the words hold the invisible character U\+FEFF$/);
|
||||
const latin1 = fileOf(base);
|
||||
latin1[3] = 0xe1;
|
||||
await expect(readWordList('es', latin1, await pin('es', latin1))).rejects.toThrow(/^wordkey: the list "es" is not UTF-8$/);
|
||||
await expect(readWordList('xx', ok, await pin('xx', ok))).rejects.toThrow(/^wordkey: the list "xx": no alphabet for the language "xx"$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkWordList', () => {
|
||||
it('accepts a list of 2048 different words of the alphabet, and refuses the cases of Go wordkey.TestCheckList', async () => {
|
||||
await expect(checkWordList('es', base)).resolves.toBeUndefined();
|
||||
await expect(checkWordList('xx', base)).rejects.toThrow(/^no alphabet for the language "xx"$/);
|
||||
const cases: [readonly string[], string][] = [
|
||||
[base.slice(0, MIN_LIST_SIZE - 1), '2047 words, fewer than 2048'],
|
||||
[withWord(5, 'dos palabras'), 'line 6, "dos palabras", is not one word'],
|
||||
[withWord(5, ' '), 'is not one word'],
|
||||
[withWord(5, 'mi'), '"mi", is not one word of 3 or more letters'],
|
||||
[withWord(5, `casa${cp(0x200b)}`), 'invisible character U+200B'],
|
||||
// Only the letters of the alphabet of the language, as the list writes
|
||||
// them: no capitals, no Cyrillic U+0441 that looks like a Latin c, no
|
||||
// digits, no carriage return of a file with CRLF lines.
|
||||
[withWord(5, 'Palaaf'), 'line 6, "Palaaf", holds U+0050, which is not in the alphabet of "es"'],
|
||||
[withWord(5, `${cp(0x441)}asa`), 'holds U+0441, which is not in the alphabet'],
|
||||
[withWord(5, 'pal1'), 'holds U+0031'],
|
||||
[withWord(5, `palaaf${cp(0x0d)}`), `line 6, "palaaf${cp(0x5c)}r", holds U+000D`],
|
||||
[withWord(5, base[4]!), 'line 6, "palaae", is the same word as "palaae"'],
|
||||
[withWord(5, 'palaáe'), 'line 6, "palaáe", is the same word as "palaae"'],
|
||||
[[...withWord(0, 'papá'), 'papa'], '"papa", is the same word as "papá"'],
|
||||
];
|
||||
for (const [list, want] of cases) await expect(checkWordList('es', list)).rejects.toThrow(want);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateWords', () => {
|
||||
it('draws different words of the list that make a key, by default 7 with crypto.getRandomValues', async () => {
|
||||
const list = await readWordList('es', listFile('es.txt'));
|
||||
const words = generateWords(list);
|
||||
expect(words).toHaveLength(DEFAULT_WORD_COUNT);
|
||||
expect(new Set(words).size).toBe(DEFAULT_WORD_COUNT);
|
||||
for (const w of words) expect(list).toContain(w);
|
||||
await expect(checkWords(await normalizeWords(words.join(' ')))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('reads nothing but its random words, and draws a word only once', () => {
|
||||
const seed = [7, 1, 200, 33, 0x9e3779b9, 12345];
|
||||
expect(generateWords(base, 6, repeating(seed))).toEqual(generateWords(base, 6, repeating(seed)));
|
||||
// Index 0 comes twice: the second is drawn again.
|
||||
expect(generateWords(base, 6, repeating([0, 0, 1, 2, 3, 4, 5]))).toEqual(base.slice(0, 6));
|
||||
expect(() =>
|
||||
generateWords(base, 6, () => {
|
||||
throw new Error('no more randomness');
|
||||
}),
|
||||
).toThrow('no more randomness');
|
||||
});
|
||||
|
||||
it('refuses fewer than 6 words and more than half the list, with the texts of Go', () => {
|
||||
expect(() => generateWords(base, 5)).toThrow(/^wordkey: a key of words needs at least 6 words, not 5$/);
|
||||
expect(() => generateWords(base, 6.5)).toThrow(/^wordkey: a key of words needs at least 6 words, not 6\.5$/);
|
||||
expect(() => generateWords(base, 1025)).toThrow(/^wordkey: 1025 words of a list of 2048$/);
|
||||
expect(generateWords(base, 1024)).toHaveLength(1024);
|
||||
});
|
||||
|
||||
// As TestGenerateUniform of Go: over 7776·40 draws of one word, each index
|
||||
// falls in its bucket of 64 between 0.8 and 1.2 times the mean.
|
||||
it('draws every word about as often', async () => {
|
||||
const list = await readWordList('es', listFile('es.txt'));
|
||||
const index = new Map(list.map((w, i) => [w, i]));
|
||||
const buckets = 64;
|
||||
const count = new Array<number>(buckets).fill(0);
|
||||
let draws = 0;
|
||||
while (draws < list.length * 40) {
|
||||
for (const w of generateWords(list, 6)) {
|
||||
count[Math.floor((index.get(w)! * buckets) / list.length)]!++;
|
||||
draws++;
|
||||
}
|
||||
}
|
||||
const mean = draws / buckets;
|
||||
for (const c of count) {
|
||||
expect(c).toBeGreaterThan(0.8 * mean);
|
||||
expect(c).toBeLessThan(1.2 * mean);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('wordBits', () => {
|
||||
it('is log2 of the draws in order, as Go wordkey.Bits', () => {
|
||||
expect(wordBits(7776, 1)).toBe(Math.log2(7776));
|
||||
expect(wordBits(7776, 0)).toBe(0);
|
||||
// 7 words of 7776 are a little under 90.5 bits, and 6 of 2048, the
|
||||
// fewest, a little under 66.
|
||||
expect(wordBits(7776, 7)).toBeCloseTo(90.4698, 4);
|
||||
expect(wordBits(7776, 8)).toBeCloseTo(103.3933, 4);
|
||||
expect(wordBits(2048, 6)).toBeCloseTo(65.9894, 4);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue