A port of List, CheckList, Generate and Bits of package wordkey of datekeys-go at 27a75ee (generate.go, spec 38.1), with the same checks in the same order and the same texts, each error a WordKeyException: - readWordList takes the file of a list that the app downloads or bundles only with the SHA-256 that wordListSha256 pins, "wordkey: the list "es" has the SHA-256 ..., not ...", and then reads it as List reads its text; - checkWordList refuses fewer than 2048 words, a word that is not one word of 3 letters or more once normalized, a rune that checkWords refuses, a character outside the alphabet of the language, which only the code gives (es: a to z, the five vowels with an acute accent, u with diaeresis and n with tilde, lower case, NFC), and two words that are one once normalized; - generateWords draws each index with randomIndex, as crypto/rand.Int, and draws again an index already drawn, so that the same bytes draw the same words as Go; what the RandomSource throws goes through; - wordBits sums Go's math.Log2, ported with its Frexp and its Log, so that the double is Go's, on the VM and on the web. wordkey.dart shares its check of each rune, checkWordRune, with the same behaviour. lib/datekeys.dart does not export the new file yet. test/vectors/wordlist_vectors.json, from tool/wordlist_go_vectors.go in an export of datekeys-go at 27a75ee, holds what Go gives: List on 19 texts, CheckList on 83 lists and on every code point of planes 0, 1 and 14, Generate in 51 cases, on the Spanish list and on lists of 12 to 2^20 + 1 words, from seeded, counter and finite streams, and Bits bit for bit. wordlist_test.dart runs them, also compiled to JavaScript, with the cases of TestCheckList, TestGenerate and TestBits of Go; wordlist_vm_test.dart checks the Dart copy of the vectors and every code point. The seed of TestGenerate of Go draws two indices only, 1793 and 2081, so that Generate runs out of bytes and the test compares two errors; the vectors record it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.15
parent
faa2c4c891
commit
a770d4d8b0
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,341 @@
|
||||
// The word lists of the key of words (spec §38.1) against
|
||||
// test/vectors/wordlist_vectors.json, whose expected values the Go reference
|
||||
// computed (tool/wordlist_go_vectors.go): the constants and the pinned
|
||||
// lists; the text of a list read as wordkey.List reads it; wordkey.CheckList
|
||||
// with its texts, also on each code point up to U+017F; the words that
|
||||
// wordkey.Generate draws from the same bytes; and wordkey.Bits, bit for bit.
|
||||
// And the cases of TestCheckList, TestGenerate and TestBits of Go, as Go
|
||||
// writes them. The vectors come from a Dart constant, so that these tests
|
||||
// also run compiled to JavaScript; the Spanish list, the CSPRNG of the
|
||||
// platform and the alphabet of every code point are in wordlist_vm_test.dart.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:datekeys/src/bytes.dart';
|
||||
import 'package:datekeys/src/pathrule.dart' show codePointName;
|
||||
import 'package:datekeys/src/random.dart';
|
||||
import 'package:datekeys/src/sha256.dart';
|
||||
import 'package:datekeys/src/wordkey.dart';
|
||||
import 'package:datekeys/src/wordlist.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'random_support.dart' show seeded;
|
||||
import 'vectors/wordlist_vectors.g.dart';
|
||||
import 'wordkey_support.dart' show fromWtf8;
|
||||
import 'wordlist_support.dart';
|
||||
|
||||
final Json vectors = jsonDecode(wordlistVectorsJson) as Json;
|
||||
|
||||
List<Json> cases(String name) => (vectors[name]! as List).cast<Json>();
|
||||
|
||||
void main() {
|
||||
test('the constants and the pinned lists are those of Go', () {
|
||||
expect(vectors['default_count'], defaultWordCount);
|
||||
expect(vectors['min_list_size'], minListSize);
|
||||
expect(vectors['min_words'], minWords);
|
||||
expect(vectors['min_letters'], minLetters);
|
||||
// Languages of Go, and the SHA-256 of the file of each of its lists.
|
||||
final lists = cases('lists');
|
||||
expect(wordListLanguages(), [for (final l in lists) l['lang']]);
|
||||
expect(wordListSha256, {for (final l in lists) l['lang']: l['sha256']});
|
||||
});
|
||||
|
||||
group('checkWordList', () {
|
||||
test('the cases of TestCheckList of Go', () {
|
||||
// pal followed by three letters: palaaa, palaab, …
|
||||
final base = [for (var i = 0; i < minListSize; i++) baseWord(i)];
|
||||
checkWordList('es', base);
|
||||
expect(
|
||||
outcome(() => checkWordList('xx', base)),
|
||||
'no alphabet for the language "xx"',
|
||||
);
|
||||
List<String> withWord(int i, String w) => [...base]..[i] = w;
|
||||
for (final (list, want) in [
|
||||
(base.sublist(0, minListSize - 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\u200b'), '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, '\u0441asa'),
|
||||
'holds U+0441, which is not in the alphabet',
|
||||
),
|
||||
(withWord(5, 'pal1'), 'holds U+0031'),
|
||||
(withWord(5, 'palaaf\r'), r'line 6, "palaaf\r", holds U+000D'),
|
||||
(
|
||||
withWord(5, base[4]),
|
||||
'line 6, "palaae", is the same word as "palaae"',
|
||||
),
|
||||
(
|
||||
withWord(5, 'pala\u00e1e'),
|
||||
'line 6, "pala\u00e1e", is the same word as "palaae"',
|
||||
),
|
||||
(
|
||||
[...withWord(0, 'pap\u00e1'), 'papa'],
|
||||
'"papa", is the same word as "pap\u00e1"',
|
||||
),
|
||||
]) {
|
||||
expect(outcome(() => checkWordList('es', list)), contains(want));
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses lists as wordkey.CheckList, with its texts', () {
|
||||
final all = cases('check_list');
|
||||
var refused = 0;
|
||||
var strings = 0;
|
||||
for (final c in all) {
|
||||
final name = c['name']! as String;
|
||||
final lang = c['lang']! as String;
|
||||
final want = c['result']! as String;
|
||||
final words = caseWords(c);
|
||||
expect(
|
||||
toHex(sha256(joinBytes(words, const [0x0a]))),
|
||||
c['sha256'],
|
||||
reason: name,
|
||||
);
|
||||
expect(
|
||||
outcome(() => checkWordListUtf8(lang, words)),
|
||||
want,
|
||||
reason: name,
|
||||
);
|
||||
if (want != 'ok') refused++;
|
||||
// The same words as Strings, where they have one: the bytes of a
|
||||
// surrogate are a lone surrogate, as utf8Bytes writes it.
|
||||
final asStrings = [for (final w in words) fromWtf8(w)];
|
||||
if (asStrings.every((s) => s != null)) {
|
||||
strings++;
|
||||
expect(
|
||||
outcome(() => checkWordList(lang, [for (final s in asStrings) s!])),
|
||||
want,
|
||||
reason: name,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(all, hasLength(greaterThan(80)));
|
||||
expect(refused, greaterThan(70));
|
||||
// All but three, whose bytes no String has.
|
||||
expect(strings, all.length - 3);
|
||||
});
|
||||
|
||||
test('the alphabet of each code point up to U+017F', () {
|
||||
final results = cases('alphabet_results');
|
||||
expect(
|
||||
[for (final c in results) c['rune']],
|
||||
[for (var r = 0; r < 0x180; r++) r],
|
||||
);
|
||||
// The first word is pala, the code point and zz.
|
||||
final words = caseWords(const {'size': minListSize});
|
||||
final accepted = <int>[];
|
||||
for (final c in results) {
|
||||
final r = c['rune']! as int;
|
||||
words[0] = utf8Bytes('pala${String.fromCharCode(r)}zz');
|
||||
final got = outcome(() => checkWordListUtf8('es', words));
|
||||
expect(got, c['result'], reason: codePointName(r));
|
||||
if (got == 'ok') accepted.add(r);
|
||||
}
|
||||
// a to z, á, é, í, ñ, ó, ú and ü: all that Go accepts in planes 0, 1
|
||||
// and 14.
|
||||
expect(
|
||||
String.fromCharCodes(accepted),
|
||||
'abcdefghijklmnopqrstuvwxyz'
|
||||
'\u00e1\u00e9\u00ed\u00f1\u00f3\u00fa\u00fc',
|
||||
);
|
||||
final ok = ((vectors['alphabet']! as Json)['ok']! as List).cast<String>();
|
||||
expect([for (final s in ok) s.runes.single], accepted);
|
||||
});
|
||||
});
|
||||
|
||||
test('parseWordList reads the text of a list as wordkey.List', () {
|
||||
for (final c in cases('parse')) {
|
||||
final name = c['name']! as String;
|
||||
final lang = c['lang']! as String;
|
||||
final lines = caseWords(c);
|
||||
final text = concatBytes([
|
||||
fromHex(c['prefix']! as String),
|
||||
joinBytes(lines, fromHex(c['sep']! as String)),
|
||||
fromHex(c['suffix']! as String),
|
||||
]);
|
||||
expect(toHex(sha256(text)), c['sha256'], reason: name);
|
||||
final want = c['result']! as String;
|
||||
expect(outcome(() => parseWordList(lang, text)), want, reason: name);
|
||||
if (want == 'ok') {
|
||||
expect(lines, hasLength(c['words']), reason: name);
|
||||
expect(parseWordList(lang, text), [
|
||||
for (final l in lines) decodeUtf8(l),
|
||||
], reason: name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('readWordList refuses a language without a list and other bytes', () {
|
||||
expect(
|
||||
outcome(() => readWordList('xx', const [])),
|
||||
'wordkey: no word list for "xx"; the lists are es',
|
||||
);
|
||||
final text = utf8Bytes('palaaa\n');
|
||||
expect(
|
||||
outcome(() => readWordList('es', text)),
|
||||
'wordkey: the list "es" has the SHA-256 ${toHex(sha256(text))}, not '
|
||||
'${wordListSha256['es']}',
|
||||
);
|
||||
// The language first, as Go's List looks for its list first.
|
||||
expect(
|
||||
outcome(() => readWordList('ES', text)),
|
||||
'wordkey: no word list for "ES"; the lists are es',
|
||||
);
|
||||
});
|
||||
|
||||
group('generateWords', () {
|
||||
test('draws the words of wordkey.Generate from the same bytes', () {
|
||||
final all = cases('generate');
|
||||
var drawn = 0;
|
||||
for (final c in all) {
|
||||
final name = c['name']! as String;
|
||||
final size = c['size']! as int;
|
||||
final n = c['n']! as int;
|
||||
final read = c['read']! as int;
|
||||
final error = c['error'] as String?;
|
||||
final source = CountingSource(streamOf(c['stream']! as Json));
|
||||
final list = IndexWords(size);
|
||||
if (error == null) {
|
||||
final words = generateWords(list, n, source);
|
||||
expect(
|
||||
[for (final w in words) int.parse(w)],
|
||||
c['indices'],
|
||||
reason: name,
|
||||
);
|
||||
expect(source.read, read, reason: name);
|
||||
final next = c['next'] as String?;
|
||||
if (next != null) {
|
||||
expect(toHex(randomBytes(source, 4)), next, reason: name);
|
||||
}
|
||||
drawn++;
|
||||
} else if (error.endsWith('EOF')) {
|
||||
// The bytes end: the source throws, and generateWords lets it
|
||||
// through, after the draws that Go made in full.
|
||||
expect(
|
||||
() => generateWords(list, n, source),
|
||||
throwsStateError,
|
||||
reason: name,
|
||||
);
|
||||
expect(source.read, read - read % drawSize(size), reason: name);
|
||||
} else {
|
||||
// The arguments, refused before anything is drawn.
|
||||
expect(
|
||||
outcome(() => generateWords(list, n, source)),
|
||||
error,
|
||||
reason: name,
|
||||
);
|
||||
expect(source.read, 0, reason: name);
|
||||
}
|
||||
}
|
||||
expect(all, hasLength(greaterThan(40)));
|
||||
expect(drawn, greaterThan(30));
|
||||
});
|
||||
|
||||
test('the cases of TestGenerate of Go', () {
|
||||
final list = IndexWords(7776);
|
||||
// The same random bytes draw the same words: generateWords reads
|
||||
// nothing else.
|
||||
final a = generateWords(list, 6, seeded('TestGenerate'));
|
||||
expect(generateWords(list, 6, seeded('TestGenerate')), a);
|
||||
expect(a.toSet(), hasLength(6));
|
||||
// The seed of TestGenerate of Go draws two indices only, 1793 and
|
||||
// 2081, again and again until it runs out: Go's Generate then fails
|
||||
// with EOF, and its two results compare equal (see the vectors).
|
||||
expect(
|
||||
() => generateWords(
|
||||
list,
|
||||
6,
|
||||
ReplaySource(repeatBytes(const [7, 1, 200, 33], 64)),
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
for (final (n, want) in [
|
||||
(5, 'at least 6 words, not 5'),
|
||||
(list.length, '7776 words of a list of 7776'),
|
||||
]) {
|
||||
expect(
|
||||
outcome(() => generateWords(list, n, ReplaySource(const []))),
|
||||
contains(want),
|
||||
);
|
||||
}
|
||||
// Without random bytes: the source throws, where Go's Generate fails
|
||||
// with EOF.
|
||||
expect(
|
||||
() => generateWords(list, 6, ReplaySource(const [])),
|
||||
throwsStateError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('wordBits', () {
|
||||
double goBits(int size, int count) => hexDouble(
|
||||
cases(
|
||||
'bits',
|
||||
).singleWhere((c) => c['size'] == size && c['count'] == count)['hex']!
|
||||
as String,
|
||||
);
|
||||
|
||||
test('the cases of TestBits of Go', () {
|
||||
// Go: Bits(7776, 1) is math.Log2(7776).
|
||||
expect(wordBits(7776, 1), goBits(7776, 1));
|
||||
expect(wordBits(7776, 1), closeTo(math.log(7776) / math.ln2, 1e-12));
|
||||
// 7 words of 7776 are a little under 90.5 bits, and 6 of 2048, the
|
||||
// fewest that generateWords draws, a little under 66.
|
||||
for (final (size, count, low, high) in [
|
||||
(7776, 0, 0.0, 0.0),
|
||||
(7776, 7, 90.469, 90.470),
|
||||
(7776, 8, 103.393, 103.394),
|
||||
(2048, 6, 65.989, 65.990),
|
||||
]) {
|
||||
expect(
|
||||
wordBits(size, count),
|
||||
inInclusiveRange(low, high),
|
||||
reason: '$size, $count',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('is Bits of Go, bit for bit', () {
|
||||
for (final c in cases('bits')) {
|
||||
final size = c['size']! as int;
|
||||
final count = c['count']! as int;
|
||||
final want = c['hex']! as String;
|
||||
final got = wordBits(size, count);
|
||||
if (hexDouble(want).isNaN) {
|
||||
// The NaN of Go, 7ff8000000000001, is not that of Dart.
|
||||
expect(got.isNaN, isTrue, reason: '$size, $count');
|
||||
} else {
|
||||
expect(doubleHex(got), want, reason: '$size, $count: ${c['bits']}');
|
||||
}
|
||||
}
|
||||
for (final d in cases('bits_digests')) {
|
||||
final sizes = (d['sizes']! as List).cast<int>();
|
||||
final counts = (d['counts']! as List).cast<int>();
|
||||
final digest = Sha256();
|
||||
final b = ByteData(8);
|
||||
for (var size = sizes[0]; size <= sizes[1]; size++) {
|
||||
for (var count = counts[0]; count <= counts[1]; count++) {
|
||||
b.setFloat64(0, wordBits(size, count));
|
||||
digest.add(Uint8List.sublistView(b));
|
||||
}
|
||||
}
|
||||
expect(toHex(digest.finish()), d['sha256'], reason: '${d['name']}');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
// The word lists against files, and at length: the copy of
|
||||
// test/vectors/wordlist_vectors.json that wordlist_test.dart reads, a Dart
|
||||
// constant for the tests compiled to JavaScript, is the JSON file byte for
|
||||
// byte; and checkWordList gives the result of Go's CheckList for every code
|
||||
// point of planes 0, 1 and 14. On the VM only: they read a file or take a
|
||||
// few seconds.
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:datekeys/src/bytes.dart';
|
||||
import 'package:datekeys/src/pathrule.dart' show codePointName;
|
||||
import 'package:datekeys/src/sha256.dart';
|
||||
import 'package:datekeys/src/wordlist.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'vectors/wordlist_vectors.g.dart';
|
||||
import 'wordlist_support.dart';
|
||||
|
||||
void main() {
|
||||
final vectors = jsonDecode(wordlistVectorsJson) as Json;
|
||||
|
||||
test('wordlist_vectors.g.dart holds wordlist_vectors.json', () {
|
||||
final file = File('test/vectors/wordlist_vectors.json').readAsStringSync();
|
||||
expect(wordlistVectorsJson, file);
|
||||
});
|
||||
|
||||
test('the alphabet of every code point of planes 0, 1 and 14', () {
|
||||
// The first word of the base list is pala, the code point and zz: the
|
||||
// lines of every result, but for the surrogates, have Go's SHA-256.
|
||||
final a = vectors['alphabet']! as Json;
|
||||
final words = caseWords(const {'size': minListSize});
|
||||
final digest = Sha256();
|
||||
final accepted = <String>[];
|
||||
var count = 0;
|
||||
for (final p in (a['planes']! as List).cast<int>()) {
|
||||
for (var r = p << 16; r <= (p << 16 | 0xffff); r++) {
|
||||
if (r >= 0xd800 && r <= 0xdfff) continue;
|
||||
final letter = String.fromCharCode(r);
|
||||
words[0] = utf8Bytes('pala${letter}zz');
|
||||
final result = outcome(() => checkWordListUtf8('es', words));
|
||||
digest.add(utf8Bytes('${codePointName(r)} $result\n'));
|
||||
count++;
|
||||
if (result == 'ok') accepted.add(letter);
|
||||
}
|
||||
}
|
||||
expect(count, a['count']);
|
||||
expect(toHex(digest.finish()), a['sha256']);
|
||||
expect(accepted, a['ok']);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue