Stage 4a: the key of words of spec §38.1
lib/src/wordkey.dart ports package wordkey of datekeys-go at c531e93:
normalizeWords is wordkey.Normalize (the NFD of pathrule.dart, without
U+0300 to U+036F, the simple lowercase of Unicode 18.0.0, split at the
white space of §38.1), checkWords is wordkey.Check with its texts,
wordKey is wordkey.Key (PBKDF2-HMAC-SHA256 of sha256.dart, 600 000
iterations, with the salt of §38.1) and wordIdentity is wordkey.Identity,
an X25519Identity of age.dart. As in pathrule.dart, the functions whose
name ends in Utf8 take the bytes of a Go string, and a String is taken as
utf8Bytes writes it.
tool/wordkey_go_vectors.go runs in the module context of datekeys-go and
writes test/vectors/wordkey_vectors.json and its Dart copy: 400 texts and
their words, 513 lists of words and the result of Check, and four keys
with their salt, the PBKDF2 of 1000 iterations for Node.js and the
recipient, the vector of §38.1 first. The keys of 600 000 iterations run
on the VM only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 days ago
|
|
|
// The key of words (spec §38.1) against test/vectors/wordkey_vectors.json,
|
|
|
|
|
// whose expected values the Go reference computed
|
|
|
|
|
// (tool/wordkey_go_vectors.go): the words of wordkey.Normalize, the refusals
|
|
|
|
|
// of wordkey.Check with their texts, and the salt and the password of
|
|
|
|
|
// wordkey.Key, with PBKDF2 at 1000 iterations. The vectors come from a Dart
|
|
|
|
|
// constant, so that these tests also run compiled to JavaScript; the keys of
|
|
|
|
|
// 600 000 iterations and their identities are in wordkey_vm_test.dart.
|
|
|
|
|
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
import 'dart:typed_data';
|
|
|
|
|
|
|
|
|
|
import 'package:datekeys/src/bytes.dart';
|
|
|
|
|
import 'package:datekeys/src/sha256.dart';
|
|
|
|
|
import 'package:datekeys/src/wordkey.dart';
|
|
|
|
|
import 'package:test/test.dart';
|
|
|
|
|
|
|
|
|
|
import 'vectors/wordkey_vectors.g.dart';
|
|
|
|
|
import 'wordkey_support.dart';
|
|
|
|
|
|
|
|
|
|
final Map<String, Object?> vectors =
|
|
|
|
|
jsonDecode(wordkeyVectorsJson) as Map<String, Object?>;
|
|
|
|
|
|
|
|
|
|
List<Map<String, Object?>> cases(String name) =>
|
|
|
|
|
(vectors[name]! as List).cast<Map<String, Object?>>();
|
|
|
|
|
|
|
|
|
|
/// "ok", or the message of the [WordKeyException] that [f] throws.
|
|
|
|
|
String outcome(void Function() f) {
|
|
|
|
|
try {
|
|
|
|
|
f();
|
|
|
|
|
return 'ok';
|
|
|
|
|
} on WordKeyException catch (e) {
|
|
|
|
|
return e.message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String label(Map<String, Object?> c) =>
|
|
|
|
|
c['name'] as String? ?? '${c['in'] ?? c['words']}';
|
|
|
|
|
|
|
|
|
|
void main() {
|
|
|
|
|
test('the constants are those of Go', () {
|
|
|
|
|
expect(vectors['rounds'], wordKeyRounds);
|
|
|
|
|
expect(vectors['min_words'], minWords);
|
|
|
|
|
expect(vectors['min_letters'], minLetters);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('normalizeWords reads the words as wordkey.Normalize', () {
|
|
|
|
|
final all = cases('normalize');
|
|
|
|
|
expect(all, hasLength(greaterThan(350)));
|
|
|
|
|
var strings = 0;
|
|
|
|
|
for (final c in all) {
|
|
|
|
|
final input = fromHex(c['in']! as String);
|
|
|
|
|
final want = [
|
|
|
|
|
for (final w in (c['words']! as List).cast<String>())
|
|
|
|
|
decodeUtf8(fromHex(w))!,
|
|
|
|
|
];
|
|
|
|
|
expect(normalizeWordsUtf8(input), want, reason: label(c));
|
|
|
|
|
final s = fromWtf8(input);
|
|
|
|
|
if (s != null) {
|
|
|
|
|
strings++;
|
|
|
|
|
expect(normalizeWords(s), want, reason: label(c));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(strings, greaterThan(300));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('checkWords refuses words as wordkey.Check, with its texts', () {
|
|
|
|
|
final all = cases('check');
|
|
|
|
|
expect(all, hasLength(greaterThan(450)));
|
|
|
|
|
var refused = 0;
|
|
|
|
|
for (final c in all) {
|
|
|
|
|
final words = wordBytes(c);
|
|
|
|
|
final want = c['result']! as String;
|
|
|
|
|
expect(outcome(() => checkWordsUtf8(words)), want, reason: label(c));
|
|
|
|
|
if (want != 'ok') refused++;
|
|
|
|
|
final strings = [for (final w in words) fromWtf8(w)];
|
|
|
|
|
if (strings.every((s) => s != null)) {
|
|
|
|
|
expect(
|
|
|
|
|
outcome(() => checkWords([for (final s in strings) s!])),
|
|
|
|
|
want,
|
|
|
|
|
reason: label(c),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
expect(refused, greaterThan(400));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the salt and the password of wordkey.Key', () {
|
|
|
|
|
for (final c in cases('keys')) {
|
|
|
|
|
final words = [for (final w in wordBytes(c)) fromWtf8(w)!];
|
|
|
|
|
final chain = fromHex(c['chain_hash']! as String);
|
|
|
|
|
final round = c['round']! as int;
|
|
|
|
|
final capsuleId = fromHex(c['capsule_id']! as String);
|
|
|
|
|
final salt = wordKeySalt(chain, round, capsuleId);
|
|
|
|
|
expect(salt, utf8Bytes(c['salt']! as String), reason: label(c));
|
|
|
|
|
// The password is the words joined by one U+0020, in UTF-8: with the
|
Stage 4a: both sides of every limit in the vectors, and the password P
Faults injected one at a time found four that the vectors let through:
R2 at 32 segments, R3 counting code points instead of UTF-16 code units
in an astral NFD, U+036F kept by normalizeWords, and DEL let through by
checkWords. The generators now write both sides of each limit: 32 and 33
segments, 255 and 256 bytes in letters of two and four bytes, 255 and 256
UTF-16 code units of NFD and 252 and 258 from astral decompositions,
bases of 8 and 9 runes, extensions of 3 and 4 also astral, the first and
the last mark of U+0300 to U+036F and their neighbours, and U+001F,
U+007E, U+007F, U+0080 and U+00A0 in a word. All four faults are caught
now.
wordKeyPassword is the password P of spec §38.1, as wordKeySalt is S, and
wordKey uses both: the tests compiled to JavaScript check P against the
one of Go, so that a wrong separator of the words is caught there too,
not only by the keys of 600 000 iterations on the VM.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 days ago
|
|
|
// salt and 1000 iterations, the PBKDF2 of Go. wordKey derives with
|
|
|
|
|
// the same two and 600 000 iterations, on the VM in
|
|
|
|
|
// wordkey_vm_test.dart.
|
|
|
|
|
final password = wordKeyPassword(words);
|
|
|
|
|
expect(toHex(password), c['password'], reason: label(c));
|
Stage 4a: the key of words of spec §38.1
lib/src/wordkey.dart ports package wordkey of datekeys-go at c531e93:
normalizeWords is wordkey.Normalize (the NFD of pathrule.dart, without
U+0300 to U+036F, the simple lowercase of Unicode 18.0.0, split at the
white space of §38.1), checkWords is wordkey.Check with its texts,
wordKey is wordkey.Key (PBKDF2-HMAC-SHA256 of sha256.dart, 600 000
iterations, with the salt of §38.1) and wordIdentity is wordkey.Identity,
an X25519Identity of age.dart. As in pathrule.dart, the functions whose
name ends in Utf8 take the bytes of a Go string, and a String is taken as
utf8Bytes writes it.
tool/wordkey_go_vectors.go runs in the module context of datekeys-go and
writes test/vectors/wordkey_vectors.json and its Dart copy: 400 texts and
their words, 513 lists of words and the result of Check, and four keys
with their salt, the PBKDF2 of 1000 iterations for Node.js and the
recipient, the vector of §38.1 first. The keys of 600 000 iterations run
on the VM only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 days ago
|
|
|
expect(
|
|
|
|
|
toHex(pbkdf2HmacSha256(password, salt, 1000, 32)),
|
|
|
|
|
c['key_1000'],
|
|
|
|
|
reason: label(c),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a round is an int of 0 to 2^53-1', () {
|
|
|
|
|
final chain = Uint8List(32);
|
|
|
|
|
final id = Uint8List(16);
|
|
|
|
|
expect(() => wordKeySalt(chain, -1, id), throwsRangeError);
|
|
|
|
|
expect(
|
|
|
|
|
() => wordKeySalt(chain, 9007199254740991 + 1, id),
|
|
|
|
|
throwsRangeError,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
utf8.decode(wordKeySalt(chain, 9007199254740991, id)),
|
|
|
|
|
contains('|9007199254740991|'),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('the examples of spec §38.1 give the same words', () {
|
|
|
|
|
// The three spellings of «abaco arbol», by the names of their cases, and
|
|
|
|
|
// the punctuation that counts.
|
|
|
|
|
final examples = [
|
|
|
|
|
for (final c in cases('normalize'))
|
|
|
|
|
if ((c['name'] as String? ?? '').contains('38.1: ')) c,
|
|
|
|
|
];
|
|
|
|
|
final spellings = [
|
|
|
|
|
for (final c in examples)
|
|
|
|
|
if ((c['name']! as String).contains('baco')) c['words'],
|
|
|
|
|
];
|
|
|
|
|
expect(spellings, hasLength(3));
|
|
|
|
|
expect(spellings[0], hasLength(2));
|
|
|
|
|
expect(spellings[1], spellings[0]);
|
|
|
|
|
expect(spellings[2], spellings[0]);
|
|
|
|
|
final punctuation = examples.singleWhere(
|
|
|
|
|
(c) => (c['name']! as String).endsWith('punctuation counts'),
|
|
|
|
|
);
|
|
|
|
|
expect(normalizeWordsUtf8(fromHex(punctuation['in']! as String)), [
|
|
|
|
|
'perro,',
|
|
|
|
|
'perro',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
}
|