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.
dateKeys-dart/test/capsule_writer_support.dart

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;

Powered by TurnKey Linux.