You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
480 lines
14 KiB
480 lines
14 KiB
// Helpers of the tests of the writer of capsules: the recipes of
|
|
// test/vectors/capsule_writer.json made into options and sources of
|
|
// encryptFiles, as tool/capsule_writer_go_vectors_test.go makes them for Go,
|
|
// the hooks that give the writer what the hooks of Go gave, the sink that
|
|
// receives a capsule, and the opening of a capsule with each credential, in
|
|
// the form of the vectors. They read no file, so that the tests that run on
|
|
// Node.js can use them.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:datekeys/datekeys.dart';
|
|
import 'package:datekeys/src/age.dart' show X25519Identity;
|
|
import 'package:datekeys/src/bytes.dart' show utf8Bytes;
|
|
import 'package:datekeys/src/encrypt3.dart';
|
|
import 'package:datekeys/src/random.dart';
|
|
import 'package:datekeys/src/recipient.dart';
|
|
import 'package:datekeys/src/sha256.dart';
|
|
|
|
import 'age_support.dart' show pattern;
|
|
import 'age_writer_support.dart' show labelIdentity;
|
|
import 'random_support.dart';
|
|
|
|
export 'age_support.dart' show pattern;
|
|
export 'age_writer_support.dart' show labelIdentity;
|
|
export 'random_support.dart';
|
|
|
|
Uint8List hexOf(Object? v) => fromHex(v! as String);
|
|
|
|
/// The published signatures of the rounds of the vectors.
|
|
Map<int, Uint8List> releasesOf(Json doc) => {
|
|
for (final e in (doc['releases']! as Json).entries)
|
|
int.parse(e.key): fromHex(e.value! as String),
|
|
};
|
|
|
|
/// A source of the releases of [releases], which counts its requests.
|
|
final class KnownReleases implements ReleaseSource {
|
|
KnownReleases(this.releases);
|
|
|
|
final Map<int, Uint8List> releases;
|
|
|
|
@override
|
|
Future<Release> fetch(PinnedProfile p, int round) async {
|
|
final s = releases[round];
|
|
if (s == null) {
|
|
throw DateKeysException(
|
|
ErrorCode.releaseUnavailable,
|
|
'test: no release of round $round',
|
|
);
|
|
}
|
|
return Release(round, s);
|
|
}
|
|
}
|
|
|
|
/// The genesis of Quicknet.
|
|
final Instant genesis = Instant(quicknetGenesisTime);
|
|
|
|
/// [i] plus [ns] nanoseconds.
|
|
Instant addNanos(Instant i, int ns) {
|
|
final total = i.nanos + ns;
|
|
final s = total >= 0
|
|
? total ~/ 1000000000
|
|
: -((-total + 999999999) ~/ 1000000000);
|
|
return Instant(i.seconds + s, total - s * 1000000000);
|
|
}
|
|
|
|
List<Extension> extsOf(Object? v) => [
|
|
for (final e in (v as List? ?? const []).cast<Json>())
|
|
Extension(
|
|
e['id']! as String,
|
|
e['version']! as int,
|
|
e['data'] == null ? null : hexOf(e['data']),
|
|
),
|
|
];
|
|
|
|
/// The content of a file of a recipe: its text, or n bytes of the pattern.
|
|
Uint8List contentOf(Json f) {
|
|
final t = f['text'] as String?;
|
|
return t != null ? utf8Bytes(t) : pattern(f['pattern'] as int? ?? 0);
|
|
}
|
|
|
|
/// An error whose text is its message, as Go's errors.New.
|
|
final class TextError implements Exception {
|
|
const TextError(this.message);
|
|
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
/// The source of a file of a recipe, with its fault, as sourceOf of the
|
|
/// generator: each Open counts, and the fault of the reading it names
|
|
/// happens there.
|
|
FileSource sourceOf(Json f) {
|
|
final content = contentOf(f);
|
|
final size = f['size'] as int? ?? content.length;
|
|
final mtime = f['mtime'] as List?;
|
|
final fault = f['fault'] as String? ?? '';
|
|
var calls = 0;
|
|
return FileSource(
|
|
path: f['path']! as String,
|
|
size: size,
|
|
modTime: mtime == null ? null : Instant(mtime[0] as int, mtime[1] as int),
|
|
open: () {
|
|
if (size != content.length) {
|
|
throw StateError('a source opened whose size is not its content\'s');
|
|
}
|
|
calls++;
|
|
var c = content;
|
|
switch ('$fault/$calls') {
|
|
case 'open1/1' || 'open2/2':
|
|
throw const TextError('the file is gone');
|
|
case 'short1/1' || 'short2/2':
|
|
c = Uint8List.sublistView(c, 0, c.length - 1);
|
|
case 'long1/1' || 'long2/2':
|
|
c = Uint8List.fromList([...c, 0x78]);
|
|
case 'change2/2':
|
|
c = Uint8List.fromList(c)..[0] ^= 1;
|
|
case 'read2/2':
|
|
return _failing(Uint8List.sublistView(c, 0, c.length ~/ 2));
|
|
}
|
|
return _chunks(c);
|
|
},
|
|
);
|
|
}
|
|
|
|
// The bytes of c in pieces of 32 KiB, as Go's readSource reads them.
|
|
Stream<List<int>> _chunks(Uint8List c) async* {
|
|
for (var i = 0; i < c.length; i += 32 << 10) {
|
|
yield Uint8List.sublistView(
|
|
c,
|
|
i,
|
|
i + (32 << 10) < c.length ? i + (32 << 10) : c.length,
|
|
);
|
|
}
|
|
}
|
|
|
|
Stream<List<int>> _failing(Uint8List first) async* {
|
|
yield* _chunks(first);
|
|
throw const TextError('the disk failed');
|
|
}
|
|
|
|
/// The sources of a recipe, with its many empty files.
|
|
List<FileSource> sourcesOf(Json r) => [
|
|
for (final f in listOf(r['files'] ?? const [])) sourceOf(f),
|
|
for (var i = 0; i < (r['many_files'] as int? ?? 0); i++)
|
|
FileSource.bytes('f${'$i'.padLeft(5, '0')}', const []),
|
|
];
|
|
|
|
/// The profile of a recipe: Quicknet, or Quicknet with a genesis one second
|
|
/// later.
|
|
Profile profileOf(Json r) {
|
|
final p = quicknet();
|
|
return switch (r['profile'] as String? ?? '') {
|
|
'' => p,
|
|
'genesis_plus_one' => p.copyWith(genesisTime: p.genesisTime + 1),
|
|
final s => throw ArgumentError('profile $s'),
|
|
};
|
|
}
|
|
|
|
/// The hooks of a recipe, which give what the hooks of Go gave and record
|
|
/// what the writer asks of them.
|
|
final class Hooks {
|
|
Hooks(this.recipe, this.recorded);
|
|
|
|
final Json recipe;
|
|
final Json recorded;
|
|
final Json asked = {};
|
|
|
|
AuthorKey? get authorKey =>
|
|
recipe['author_key'] == null ? null : _ReplayAuthor(this);
|
|
|
|
CmsSigner? get cmsSigner => recipe['cms'] == null ? null : _ReplayCms(this);
|
|
|
|
Sealer? get sealer => recipe['sealer'] == null ? null : _ReplaySealer(this);
|
|
}
|
|
|
|
final class _ReplayAuthor implements AuthorKey {
|
|
_ReplayAuthor(this.h);
|
|
|
|
final Hooks h;
|
|
|
|
@override
|
|
Uint8List get publicKey {
|
|
final pub = hexOf(h.recorded['author_public']);
|
|
h.asked['author_public'] = toHex(pub);
|
|
return pub;
|
|
}
|
|
|
|
@override
|
|
Uint8List sign(Uint8List message) {
|
|
h.asked['author_message'] = toHex(message);
|
|
final sig = hexOf(h.recorded['author_signature']);
|
|
h.asked['author_signature'] = toHex(sig);
|
|
return sig;
|
|
}
|
|
}
|
|
|
|
final class _ReplayCms implements CmsSigner {
|
|
_ReplayCms(this.h);
|
|
|
|
final Hooks h;
|
|
|
|
@override
|
|
List<Uint8List> get signers {
|
|
final list = (h.recorded['cms_signers'] as List? ?? const [])
|
|
.cast<String>();
|
|
h.asked['cms_signers'] = list;
|
|
return [for (final s in list) fromHex(s)];
|
|
}
|
|
|
|
@override
|
|
Future<Uint8List> sign(Uint8List message) async {
|
|
h.asked['cms_message'] = toHex(message);
|
|
final e = (h.recipe['cms']! as Json)['error'] as String?;
|
|
if (e != null) throw TextError(e);
|
|
final der = hexOf(h.recorded['cms_der']);
|
|
h.asked['cms_der'] = toHex(der);
|
|
return der;
|
|
}
|
|
}
|
|
|
|
final class _ReplaySealer implements Sealer {
|
|
_ReplaySealer(this.h);
|
|
|
|
final Hooks h;
|
|
|
|
@override
|
|
Future<Uint8List> seal(Uint8List subject) async {
|
|
h.asked['seal_subject'] = toHex(subject);
|
|
final e = (h.recipe['sealer']! as Json)['error'] as String?;
|
|
if (e != null) throw TextError(e);
|
|
final token = hexOf(h.recorded['seal_token']);
|
|
h.asked['seal_token'] = toHex(token);
|
|
return token;
|
|
}
|
|
}
|
|
|
|
/// The options of a recipe, with the source [random] and the [hooks].
|
|
EncryptOptions optionsOf(Json r, RandomSource random, Hooks hooks) {
|
|
final q = quicknet();
|
|
final unlock = addNanos(
|
|
roundTime(q, r['round']! as int),
|
|
r['unlock_ns'] as int? ?? 0,
|
|
);
|
|
final nowText = r['now'] as String?;
|
|
final now = nowText == null ? genesis : parseRfc3339(nowText);
|
|
final padding = r['padding'] as int? ?? 0;
|
|
return EncryptOptions(
|
|
profile: profileOf(r),
|
|
unlockAt: unlock,
|
|
now: () => now,
|
|
policy: r['policy'] == 'time_and_key'
|
|
? AccessPolicy.timeAndKey
|
|
: AccessPolicy.timeOnly,
|
|
recipients: [
|
|
for (final l in (r['identities'] as List? ?? const []).cast<String>())
|
|
X25519Recipient.of(labelIdentity(l)),
|
|
for (final raw
|
|
in (r['raw_recipients'] as List? ?? const []).cast<String>())
|
|
X25519Recipient(fromHex(raw)),
|
|
],
|
|
newPortableKey: r['portable'] as bool? ?? false,
|
|
words: (r['words'] as List? ?? const []).cast<String>(),
|
|
padding: padding == 0 ? null : PaddingRule.fromCode(padding),
|
|
critical: extsOf(r['critical']),
|
|
noncritical: extsOf(r['noncritical']),
|
|
controlCritical: extsOf(r['control_critical']),
|
|
controlNoncritical: extsOf(r['control_noncritical']),
|
|
headCritical: extsOf(r['head_critical']),
|
|
headNoncritical: extsOf(r['head_noncritical']),
|
|
comment: r['comment'] as String? ?? '',
|
|
author: r['author'] as String? ?? '',
|
|
publicNote: r['note'] as String? ?? '',
|
|
authorKey: hooks.authorKey,
|
|
cmsSigner: hooks.cmsSigner,
|
|
sealer: hooks.sealer,
|
|
largeArea: r['large_area'] as bool? ?? false,
|
|
testVectors: r['test_vectors'] as bool? ?? false,
|
|
testAreaLen: r['test_area_len'] as int? ?? 0,
|
|
random: random,
|
|
);
|
|
}
|
|
|
|
/// A sink that keeps what it receives, and whether it was closed or
|
|
/// aborted.
|
|
final class CapsuleSink implements ByteSink {
|
|
final BytesBuilder _received = BytesBuilder();
|
|
bool closed = false;
|
|
Object? aborted;
|
|
int adds = 0;
|
|
|
|
int get length => _received.length;
|
|
|
|
Uint8List get bytes => _received.toBytes();
|
|
|
|
@override
|
|
void add(Uint8List bytes) {
|
|
if (closed || aborted != null) throw StateError('add after the end');
|
|
adds++;
|
|
_received.add(bytes);
|
|
}
|
|
|
|
@override
|
|
void close() {
|
|
if (aborted != null) throw StateError('close after abort');
|
|
closed = true;
|
|
}
|
|
|
|
@override
|
|
void abort(Object reason) => aborted = reason;
|
|
}
|
|
|
|
/// What a recipe gave: the capsule and the result, or the error, with the
|
|
/// sizes of the draws and the hooks as asked.
|
|
typedef Written = ({
|
|
Uint8List dkc,
|
|
EncryptResult? result,
|
|
Object? error,
|
|
List<int> draws,
|
|
Json asked,
|
|
CapsuleSink sink,
|
|
});
|
|
|
|
/// Writes the capsule of the recipe [r] with the seeded source of its seed,
|
|
/// and the hooks that give what [recorded] says Go's gave.
|
|
Future<Written> writeRecipe(Json r, Json recorded) async {
|
|
final random = RecordingSource(seeded(r['seed']! as String));
|
|
final hooks = Hooks(r, recorded);
|
|
final sink = CapsuleSink();
|
|
EncryptResult? res;
|
|
Object? error;
|
|
try {
|
|
res = await encryptFiles(sink, sourcesOf(r), optionsOf(r, random, hooks));
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
return (
|
|
dkc: sink.bytes,
|
|
result: res,
|
|
error: error,
|
|
draws: [for (final d in random.draws) d.length],
|
|
asked: hooks.asked,
|
|
sink: sink,
|
|
);
|
|
}
|
|
|
|
/// The text of an error of the writer, as Go's err.Error().
|
|
String errorText(Object e) => switch (e) {
|
|
DateKeysException(:final message) => message,
|
|
CapsuleWriteException(:final message) => message,
|
|
ArgumentError(:final message) => '$message',
|
|
_ => 'unexpected ${e.runtimeType}: $e',
|
|
};
|
|
|
|
/// The code of an error of the writer, or null.
|
|
String? writerCode(Object e) => e is DateKeysException ? e.code.code : null;
|
|
|
|
/// The extensions of a recipe, known to the opening, as extSet of the
|
|
/// generator.
|
|
ExtensionRegistry extSetOf(Json r) {
|
|
final known = <String, List<int>>{};
|
|
for (final k in const [
|
|
'critical',
|
|
'noncritical',
|
|
'control_critical',
|
|
'control_noncritical',
|
|
'head_critical',
|
|
'head_noncritical',
|
|
]) {
|
|
for (final e in extsOf(r[k])) {
|
|
(known[e.id] ??= []).add(e.version);
|
|
}
|
|
}
|
|
return ExtensionSet(known);
|
|
}
|
|
|
|
/// The raw identity of the key of words of [r] for the capsule [capsuleId]
|
|
/// of [round].
|
|
Uint8List wordIdentityOf(Json r, int round, List<int> capsuleId) => wordKey(
|
|
(r['words']! as List).cast<String>(),
|
|
quicknet().chainHash,
|
|
round,
|
|
capsuleId,
|
|
);
|
|
|
|
/// Opens [dkc] as the generator does: with the identities, the .dkk, or
|
|
/// both, and returns the verdict in the form of the vectors.
|
|
Future<Json> openedJson(
|
|
Json r,
|
|
Uint8List dkc,
|
|
Map<int, Uint8List> releases, {
|
|
List<Uint8List> identities = const [],
|
|
Uint8List? dkk,
|
|
}) async {
|
|
final files = MemoryFileSink();
|
|
final o = await openCapsule(
|
|
dkc,
|
|
OpenOptions(
|
|
source: KnownReleases(releases),
|
|
now: () => Instant(1791244800),
|
|
extensions: extSetOf(r),
|
|
identities: identities,
|
|
accessKeyFile: dkk,
|
|
sink: files,
|
|
),
|
|
);
|
|
final err = o.error;
|
|
if (err != null) {
|
|
return {'result': err.code.code, 'step': o.inspection.checks.last.step};
|
|
}
|
|
final head = o.head!;
|
|
return {
|
|
'result': 'ok',
|
|
'files': [
|
|
for (var i = 0; i < head.files.length; i++)
|
|
{
|
|
'path': head.files[i].path,
|
|
'size': head.files[i].size,
|
|
'sha256': toHex(sha256(files.files![i])),
|
|
if (head.files[i].mtime != null) 'mtime': head.files[i].mtime,
|
|
},
|
|
],
|
|
'head': toHex(encodeHead(head)),
|
|
'verdicts': [o.verdicts!.signature!.code, o.verdicts!.seal!.code],
|
|
'area_len': o.areaLen,
|
|
'length': o.payloadLength,
|
|
'padded_length': o.paddedLength,
|
|
};
|
|
}
|
|
|
|
/// The openings of a capsule with each credential of its recipe, all
|
|
/// together and none, as opens of the generator: the first [limit] of them,
|
|
/// all without one.
|
|
Future<List<Json>> opensOf(
|
|
Json r,
|
|
Uint8List dkc,
|
|
Map<int, Uint8List> releases,
|
|
int round,
|
|
Uint8List capsuleId,
|
|
Uint8List? dkk, {
|
|
int? limit,
|
|
}) async {
|
|
final todo = <(String, List<Uint8List>, Uint8List?)>[];
|
|
if (r['policy'] != 'time_and_key') {
|
|
todo.add(('time', const [], null));
|
|
} else {
|
|
final all = <Uint8List>[];
|
|
for (final l in (r['identities'] as List? ?? const []).cast<String>()) {
|
|
final id = labelSecret(l);
|
|
todo.add(('identity $l', [id], null));
|
|
all.add(id);
|
|
}
|
|
if ((r['words'] as List? ?? const []).isNotEmpty) {
|
|
final id = wordIdentityOf(r, round, capsuleId);
|
|
todo.add(('words', [id], null));
|
|
all.add(id);
|
|
}
|
|
if (dkk != null) todo.add(('portable', const [], dkk));
|
|
todo.add(('all', all, dkk));
|
|
todo.add(('none', const [], null));
|
|
}
|
|
return [
|
|
for (final (name, ids, k) in todo.take(limit ?? todo.length))
|
|
{
|
|
...await openedJson(r, dkc, releases, identities: ids, dkk: k),
|
|
'with': name,
|
|
},
|
|
];
|
|
}
|
|
|
|
/// SHA-256 of [b].
|
|
Uint8List sha256Of(List<int> b) => sha256(b);
|
|
|
|
/// The identity of a label, raw.
|
|
Uint8List labelSecret(String label) =>
|
|
X25519Identity(sha256(utf8Bytes('identity $label'))).secretKey;
|