Stage 6b: the vectors of Go for the writer of capsules

tool/capsule_writer_go_vectors_test.go runs capsule.EncryptFiles of Go on
87 recipes while crypto/rand reads the keystream of SeededRandomSource, as
a test in an export of datekeys-go so that the CMS signatures and tokens of
its hooks come out the same on every run. It records the size of each
draw, what each hook was given and returned, the capsule, the .dkk and the
openings of Go with each credential, and the text, the code and the bytes
written of each error.

The tests write each recipe again: the same draws, the same requests to
the hooks, and the same bytes or the same error, in 21 capsules and 66
errors; and this library opens each capsule as Go did. The cases marked
node also run compiled to JavaScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.11
dev 1 day ago
parent 4c9bc723a4
commit 07a525f9c8

@ -0,0 +1,71 @@
// The cases of test/vectors/capsule_writer.json as tests: each recipe is
// written again with the seeded source of its seed and the hooks that give
// what Go's gave, and must draw what Go drew, ask the hooks what Go's were
// asked, and write the bytes that capsule.EncryptFiles wrote, or fail with
// its text and code after writing as many bytes. The capsule is then opened
// by this library with each credential, as Go opened it, with the same
// files, verdicts and area. capsule_writer_vm_test.dart runs them all from
// the file, and capsule_writer_test.dart those marked node from the Dart
// constant, also compiled to JavaScript.
import 'package:datekeys/datekeys.dart';
import 'package:test/test.dart';
import 'capsule_writer_support.dart';
/// The tests of the cases of [doc]; with [allOpens] false, only the first
/// opening of each capsule, which costs a verification of the round.
void writerCases(Json doc, {bool allOpens = true}) {
final releases = releasesOf(doc);
for (final c in listOf(doc['cases'])) {
final r = c['recipe']! as Json;
test(r['name'], () async {
final w = await writeRecipe(r, c['hooks'] as Json? ?? const {});
expect(w.draws, c['draws'], reason: 'the draws');
expect(w.asked, c['hooks'] ?? const {}, reason: 'the hooks');
final e = c['error'] as Json?;
if (e != null) {
final err = w.error;
expect(err, isNotNull, reason: 'Go fails with ${e['text']}');
expect(errorText(err!), e['text']);
expect(writerCode(err), e['code']);
expect(w.dkc.length, e['written'], reason: 'the bytes written');
expect(w.sink.aborted, same(err));
expect(w.sink.closed, isFalse);
return;
}
expect(w.error, isNull);
final g = c['written']! as Json;
final res = w.result!;
final header = hexOf(g['header']);
expect(toHex(w.dkc.sublist(16, 16 + header.length)), g['header']);
expect(w.dkc.length, g['length_dkc']);
expect(toHex(sha256Of(w.dkc)), g['sha256']);
if (g['dkc'] != null) expect(toHex(w.dkc), g['dkc']);
expect(w.sink.closed, isTrue);
expect(w.sink.aborted, isNull);
expect(res.dateKey.round, g['round']);
expect(res.unlockAt.toString(), g['unlock_at']);
expect(toHex(res.capsuleId), g['capsule_id']);
expect(res.length, g['length']);
expect(res.padding.code, g['padding']);
expect(res.paddedLength, g['padded_length']);
expect(toHex(encodeHead(res.head)), g['head']);
final k = res.portableKey;
expect(k == null ? null : toHex(encodeAccessKey(k)), g['dkk']);
expect(g['reencoded'], isTrue);
final want = listOf(c['opens']);
final got = await opensOf(
r,
w.dkc,
releases,
res.dateKey.round,
res.capsuleId,
k == null ? null : encodeAccessKey(k),
limit: allOpens ? null : 1,
);
expect(got, want.sublist(0, got.length));
});
}
}

@ -0,0 +1,479 @@
// 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;

@ -0,0 +1,15 @@
// The writer of capsules against Go, the cases marked node of
// test/vectors/capsule_writer.json, from a Dart constant, so that they also
// run compiled to JavaScript (see capsule_writer_cases.dart). Each capsule is
// opened only with its first credential there: every opening verifies the
// release of the round.
import 'dart:convert';
import 'capsule_writer_cases.dart';
import 'capsule_writer_support.dart';
import 'vectors/capsule_writer.g.dart';
void main() {
writerCases(jsonDecode(capsuleWriterJson) as Json, allOpens: !isWeb);
}

@ -0,0 +1,29 @@
// The writer of capsules against Go: every case of
// test/vectors/capsule_writer.json (see capsule_writer_cases.dart).
@TestOn('vm')
library;
import 'dart:convert';
import 'dart:io';
import 'package:test/test.dart';
import 'capsule_writer_cases.dart';
import 'capsule_writer_support.dart';
import 'vectors/capsule_writer.g.dart';
void main() {
final text = File('test/vectors/capsule_writer.json').readAsStringSync();
final doc = jsonDecode(text) as Json;
test('the Dart constant holds the cases marked node', () {
final node = jsonDecode(capsuleWriterJson) as Json;
expect(node['releases'], doc['releases']);
expect(node['cases'], [
for (final c in listOf(doc['cases']))
if ((c['recipe']! as Json)['node'] == true) c,
]);
});
writerCases(doc);
}

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,974 @@
// Writes test/vectors/capsule_writer.json, the vectors of the writer of
// capsules of format 3 of datekeys-dart, stage 6b of docs/PLAN_dart.md:
// what capsule.EncryptFiles of datekeys-go writes for each recipe of this
// file, and the text of each error it gives.
//
// EncryptFiles draws every random value from crypto/rand: the portable
// identity, the salt of the head, capsule_id, I_PAYLOAD, the dummies of
// INNER_ACCESS_AGE and their permutation, what age draws for the measured
// seal, the real seal and PAYLOAD_AGE, and the credential_id of the .dkk.
// Here crypto/rand.Reader reads the keystream of SeededRandomSource of
// lib/src/random.dart (ChaCha20 under SHA-256(seed), zero nonce), as in
// tool/age_writer_go_vectors.go: with the same seed the writer of
// datekeys-dart draws the same values, in the same order, and must write the
// same bytes. Each case records the size of each draw.
//
// A recipe that signs or seals runs the hooks of the tests of package
// capsule (signed_test.go): an Ed25519 author key from a seed (alg 1), the
// CMS signatures and RFC 3161 tokens of internal/cms/cmstest (alg 2 and
// seal_type 2). ECDSA and RSA draw from Go's internal generator, which only
// testing/cryptotest.SetGlobalRandom fixes, in a test binary: this file runs
// as a test. What each hook was given and returned is recorded, so that the
// tests of datekeys-dart give the writer the same signatures and tokens and
// check that it asks for them over the same messages.
//
// For each capsule written it records its length and SHA-256, the capsule
// itself when it is small, the .dkk, the Result, its PUBLIC_HEADER, its
// CONTROL_CBOR and its head; it opens the capsule with capsule.Open, with
// each credential alone, all together and none, and records the files, the
// verdicts and the area; and it encodes PUBLIC_HEADER, CONTROL_CBOR and the
// .dkk again and compares them with what was written. For each error it
// records the text, the code, and the number of bytes written before it.
//
// It imports internal packages, so it runs as a test in an export of
// datekeys-go made with git archive, which it does not change, never in the
// repository itself. From the root of datekeys-dart:
//
// commit=$(git -C ../datekeys-go rev-parse v0.12)
// out=$PWD/test/vectors
// tmp=$(mktemp -d)
// git -C ../datekeys-go archive "$commit" | tar -x -C "$tmp"
// mkdir "$tmp/capsulewriter"
// cp tool/capsule_writer_go_vectors_test.go "$tmp/capsulewriter/"
// (cd "$tmp/capsulewriter" && go test -run TestWriteVectors -count=1 \
// -args -source "$commit" -out "$out")
// rm -rf "$tmp"
//
// It also writes test/vectors/capsule_writer.g.dart, the cases marked node,
// for the tests that also run compiled to JavaScript. Every run writes the
// same bytes with Go 1.26.8.
package capsulewriter
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"testing/cryptotest"
"time"
"filippo.io/age"
"golang.org/x/crypto/chacha20"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/authorkey"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/codec/bech32"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/cms/cmstest"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
"g.activething.com/go/DateKeys/wordkey"
)
var (
sourceFlag = flag.String("source", "", "the commit of datekeys-go that this tree exports")
outFlag = flag.String("out", "", "the directory test/vectors of datekeys-dart")
)
type obj = map[string]any
func h(b []byte) string { return hex.EncodeToString(b) }
func sum(b []byte) string {
s := sha256.Sum256(b)
return h(s[:])
}
func must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
func unhex(s string) []byte { return must(hex.DecodeString(s)) }
// ---------------------------------------------------------------------------
// The recipes
type extIn struct {
ID string `json:"id"`
Version uint64 `json:"version"`
Data *string `json:"data,omitempty"` // hex; absent for none
}
// fileIn is a file of a recipe: its content is the text, or n bytes of the
// pattern (31·i + 7) mod 256, and its declared size the length of the
// content unless size is given.
type fileIn struct {
Path string `json:"path"`
Text *string `json:"text,omitempty"`
Pattern int `json:"pattern,omitempty"`
Size *int64 `json:"size,omitempty"`
MTime []int64 `json:"mtime,omitempty"` // seconds and nanoseconds since 1970
// Fault is what goes wrong with its source:
// - open1, open2: the first or the second Open fails with "the file is
// gone";
// - short1, short2: that reading gives one byte less;
// - long1, long2: that reading gives one byte more, 0x78;
// - change2: the second reading gives the content with its first byte
// XOR 1;
// - read2: the second reading gives the first half of the content and
// then fails with "the disk failed".
Fault string `json:"fault,omitempty"`
}
type authorIn struct {
Seed string `json:"seed"` // hex, 32 bytes
Bad string `json:"bad,omitempty"` // zero_signature, short_key
}
type cmsIn struct {
Signers []string `json:"signers"` // names of the certificates that sign
Extra string `json:"extra,omitempty"` // a required signer that does not sign
Unsealed bool `json:"unsealed,omitempty"` // no seal in the signatures
Junk int `json:"junk,omitempty"` // bytes of an unsigned attribute
Error string `json:"error,omitempty"` // Sign fails with this text
Garbage bool `json:"garbage,omitempty"` // Sign returns bytes that are no signature
}
type sealerIn struct {
Error string `json:"error,omitempty"` // Seal fails with this text
}
type recipe struct {
Name string `json:"name"`
Seed string `json:"seed"`
Round uint64 `json:"round"`
// UnlockNs is added to the time of the round: the requested instant.
UnlockNs int64 `json:"unlock_ns,omitempty"`
// Now is the clock, in RFC 3339; the genesis of Quicknet by default.
Now string `json:"now,omitempty"`
Policy string `json:"policy,omitempty"` // time_and_key; time_only by default
// Identities are labels: the raw secret of each is
// SHA-256("identity " + label), and its recipient is given.
Identities []string `json:"identities,omitempty"`
// RawRecipients are given after them, as raw public keys.
RawRecipients []string `json:"raw_recipients,omitempty"`
Portable bool `json:"portable,omitempty"`
Words []string `json:"words,omitempty"`
Padding int `json:"padding,omitempty"`
Files []fileIn `json:"files,omitempty"`
// ManyFiles adds that many empty files, f00000 and on.
ManyFiles int `json:"many_files,omitempty"`
Comment string `json:"comment,omitempty"`
Author string `json:"author,omitempty"`
Note string `json:"note,omitempty"`
Critical []extIn `json:"critical,omitempty"`
Noncritical []extIn `json:"noncritical,omitempty"`
ControlCritical []extIn `json:"control_critical,omitempty"`
ControlNoncritical []extIn `json:"control_noncritical,omitempty"`
HeadCritical []extIn `json:"head_critical,omitempty"`
HeadNoncritical []extIn `json:"head_noncritical,omitempty"`
AuthorKey *authorIn `json:"author_key,omitempty"`
CMS *cmsIn `json:"cms,omitempty"`
Sealer *sealerIn `json:"sealer,omitempty"`
LargeArea bool `json:"large_area,omitempty"`
TestVectors bool `json:"test_vectors,omitempty"`
TestAreaLen uint32 `json:"test_area_len,omitempty"`
// Profile is "" for Quicknet, or genesis_plus_one, Quicknet with a
// genesis time one second later, which its chain hash does not match.
Profile string `json:"profile,omitempty"`
// Node puts the case in the .g.dart, for the tests compiled to
// JavaScript.
Node bool `json:"node,omitempty"`
}
func ptr[T any](v T) *T { return &v }
func ext(id string, version uint64, data string) extIn {
d := hex.EncodeToString([]byte(data))
return extIn{ID: id, Version: version, Data: &d}
}
func text(path, s string) fileIn { return fileIn{Path: path, Text: ptr(s)} }
var words = []string{"faro", "nube", "trigo", "menta", "barco", "lince"}
func ids(n int) []string {
var out []string
for i := range n {
out = append(out, fmt.Sprintf("holder %d", i))
}
return out
}
const authorSeed = "a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0"
func recipes() []recipe {
small := func(name string) recipe {
return recipe{Name: name, Round: 1000, TestVectors: true, TestAreaLen: 512}
}
var out []recipe
add := func(r recipe) { out = append(out, r) }
// Capsules.
r := small("time_only, one file, area of 512")
r.Files, r.Node = []fileIn{text("nota.txt", "Hola.\n")}, true
add(r)
r = small("time_only, a comment alone, bloque256")
r.Comment, r.Author, r.Padding, r.Node = "Una línea.\r\nOtra.\rFin", "Ana López", 1, true
add(r)
r = small("time_and_key, a portable key")
r.Policy, r.Portable, r.Node = "time_and_key", true, true
r.Files = []fileIn{text("a.txt", "a"), text("b/c.txt", "")}
add(r)
r = small("time_and_key, three recipients and a portable key, a note and extensions")
r.Policy, r.Portable, r.Identities = "time_and_key", true, ids(3)
r.Note = "Para Ana, en su cumpleaños"
r.Files = []fileIn{text("carta.txt", "Querida Ana:\n")}
r.Critical = []extIn{ext("org.example.crit", 1, "c")}
r.Noncritical = []extIn{ext("org.example.b", 2, "nb"), {ID: "org.example.a", Version: 1}}
r.ControlCritical = []extIn{ext("org.example.ctrl", 1, "k")}
r.ControlNoncritical = []extIn{ext("org.example.ctrl2", 3, "kk")}
r.HeadCritical = []extIn{ext("org.example.head", 1, "h")}
r.HeadNoncritical = []extIn{ext("org.example.head2", 1, "hh")}
add(r)
add(recipe{Name: "time_only, the common area, files in byte order with mtimes", Round: 1000,
Comment: "Fotos del verano", Author: "Luis",
Files: []fileIn{
{Path: "vacío.txt", Text: ptr("")},
{Path: "nota.txt", Text: ptr("Hola.\n"), MTime: []int64{1727712000, 0}},
{Path: "fotos/playa.jpg", Pattern: 200000, MTime: []int64{1727712000, 999999999}},
{Path: "\U0001F600.txt", Text: ptr("emoji")},
{Path: "~.txt", Text: ptr("tilde")},
{Path: "fotos/a.txt", Text: ptr("a"), MTime: []int64{0, 0}},
}})
add(recipe{Name: "time_only, mtimes out of range and at its ends", Round: 1000, Padding: 1,
Files: []fileIn{
{Path: "a", Text: ptr("1"), MTime: []int64{-1, 999999999}},
{Path: "b", Text: ptr("2"), MTime: []int64{253402300799, 999999999}},
{Path: "c", Text: ptr("3"), MTime: []int64{253402300800, 0}},
{Path: "d", Text: ptr("4"), MTime: []int64{-62135596800, 0}},
{Path: "e", Text: ptr("5"), MTime: []int64{-62135596800, 1}},
}})
add(recipe{Name: "time_and_key, fifteen recipients and a portable key", Round: 1000, Policy: "time_and_key",
Identities: ids(15), Portable: true, Files: []fileIn{{Path: "x.bin", Pattern: 65536}}})
add(recipe{Name: "time_and_key, sixteen recipients", Round: 1001, Policy: "time_and_key",
Identities: ids(16), Files: []fileIn{{Path: "x.bin", Pattern: 65537}}})
add(recipe{Name: "time_and_key, a key of words and a recipient", Round: 1000, Policy: "time_and_key",
Identities: ids(1), Words: words, Files: []fileIn{text("secreto.txt", "42")}})
add(recipe{Name: "a requested instant one nanosecond after round 1000", Round: 1000, UnlockNs: 1,
Files: []fileIn{text("a", "a")}})
add(recipe{Name: "a requested instant one nanosecond before round 1000", Round: 1000, UnlockNs: -1,
Now: "2023-08-23T15:09:30.5Z", Files: []fileIn{text("a", "a")}})
r = small("signed with alg 1, area of 512")
r.AuthorKey, r.Files, r.Node = &authorIn{Seed: authorSeed}, []fileIn{text("nota.txt", "Hola.\n")}, true
add(r)
add(recipe{Name: "signed with alg 1 and sealed", Round: 1000, AuthorKey: &authorIn{Seed: authorSeed},
Sealer: &sealerIn{}, Policy: "time_and_key", Portable: true, Files: []fileIn{text("nota.txt", "Hola.\n")}})
add(recipe{Name: "sealed, without a signature", Round: 1000, Sealer: &sealerIn{},
Files: []fileIn{text("nota.txt", "Hola.\n")}})
add(recipe{Name: "signed with alg 2 by two signers, each sealed", Round: 1000,
CMS: &cmsIn{Signers: []string{"Ana López", "Luis Gómez"}}, Files: []fileIn{text("nota.txt", "Hola.\n")}})
add(recipe{Name: "a signature of 40 KiB with LargeArea: the area of 64 KiB", Round: 1000, LargeArea: true,
CMS: &cmsIn{Signers: []string{"Ana López"}, Junk: 40 << 10}, Files: []fileIn{text("nota.txt", "Hola.\n")}})
add(recipe{Name: "LargeArea without a signature keeps the common area", Round: 1000, LargeArea: true,
Files: []fileIn{text("nota.txt", "x")}})
add(recipe{Name: "content of whole chunks", Round: 1000, Padding: 1,
Files: []fileIn{{Path: "a", Pattern: 65536}, {Path: "b", Pattern: 131072 - 12 - 32768 - 200}}})
add(recipe{Name: "unknown extensions of the application in every array", Round: 1000, Policy: "time_and_key",
Words: words, Portable: true,
Critical: []extIn{{ID: "z", Version: 4294967295}}, HeadNoncritical: []extIn{ext("org.example.n", 1, "x")},
Files: []fileIn{text("a", "a")}})
// Errors of the options.
e := func(name string, f func(r *recipe)) {
r := recipe{Name: name, Round: 1000, Files: []fileIn{text("a.txt", "a")}, Node: true}
f(&r)
add(r)
}
e("TestAreaLen without TestVectors", func(r *recipe) { r.TestAreaLen = 512 })
e("TestAreaLen not a multiple of 512", func(r *recipe) { r.TestVectors, r.TestAreaLen = true, 1000 })
e("TestAreaLen above the largest", func(r *recipe) { r.TestVectors, r.TestAreaLen = true, 66048 })
e("TestAreaLen with LargeArea", func(r *recipe) { r.TestVectors, r.TestAreaLen, r.LargeArea = true, 512, true })
e("AuthorKey and CMSSigner", func(r *recipe) {
r.AuthorKey, r.CMS = &authorIn{Seed: authorSeed}, &cmsIn{Signers: []string{"Ana López"}}
})
e("CMSSigner and Sealer", func(r *recipe) { r.CMS, r.Sealer = &cmsIn{Signers: []string{"Ana López"}}, &sealerIn{} })
e("a public note with a line feed", func(r *recipe) { r.Note = "dos\nlíneas" })
e("a public note of 1025 bytes", func(r *recipe) { r.Note = strings.Repeat("a", 1025) })
e("datekeys.note in CONTROL_CBOR", func(r *recipe) {
r.ControlNoncritical = []extIn{ext("datekeys.note", 1, "hola")}
})
e("datekeys.capsule in PUBLIC_HEADER", func(r *recipe) {
r.Noncritical = []extIn{ext("datekeys.capsule", 1, "x")}
})
e("datekeys.note with invalid data", func(r *recipe) {
r.Noncritical = []extIn{ext("datekeys.note", 1, "a\tb")}
})
e("datekeys.note without data", func(r *recipe) { r.Noncritical = []extIn{{ID: "datekeys.note", Version: 1}} })
e("datekeys.note in the critical array of the head", func(r *recipe) {
r.HeadCritical = []extIn{ext("datekeys.note", 1, "hola")}
})
e("an extension in both arrays", func(r *recipe) {
r.Critical = []extIn{ext("org.example.x", 1, "a")}
r.Noncritical = []extIn{ext("org.example.x", 2, "b")}
})
e("a profile whose chain hash does not match", func(r *recipe) { r.Profile = "genesis_plus_one" })
e("an instant that is now", func(r *recipe) { r.Now = "2023-08-23T15:59:24Z" })
e("an instant after now by one nanosecond", func(r *recipe) { r.Now = "2023-08-23T15:59:23.999999999Z"; r.Node = false })
e("an instant before the genesis", func(r *recipe) { r.Round, r.UnlockNs, r.Now = 1, -5e9, "2023-08-23T14:00:00Z" })
e("time_only with a recipient", func(r *recipe) { r.Identities = ids(1) })
e("time_only with a portable key", func(r *recipe) { r.Portable = true })
e("time_only with words", func(r *recipe) { r.Words = words })
e("time_and_key without credentials", func(r *recipe) { r.Policy = "time_and_key" })
e("seventeen credentials", func(r *recipe) { r.Policy, r.Identities, r.Portable = "time_and_key", ids(16), true })
e("seventeen credentials with words", func(r *recipe) { r.Policy, r.Identities, r.Words = "time_and_key", ids(16), words })
e("a recipient with bit 255 set", func(r *recipe) {
r.Policy, r.Identities = "time_and_key", ids(1)
raw := must(agewrap.RawX25519Recipient(identity("holder 0").Recipient()))
raw[31] |= 0x80
r.RawRecipients = []string{h(raw)}
})
e("a recipient of u = p", func(r *recipe) {
r.Policy = "time_and_key"
r.RawRecipients = []string{"edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f"}
})
e("a recipient of low order", func(r *recipe) {
r.Policy = "time_and_key"
r.RawRecipients = []string{h(make([]byte, 32))}
})
e("a recipient listed twice", func(r *recipe) {
r.Policy, r.Identities = "time_and_key", ids(2)
r.RawRecipients = []string{h(must(agewrap.RawX25519Recipient(identity("holder 1").Recipient())))}
})
e("five words", func(r *recipe) { r.Policy, r.Words = "time_and_key", words[:5] })
e("words with a control character", func(r *recipe) {
r.Policy, r.Words = "time_and_key", append(append([]string(nil), words...), "a\u0085b")
})
e("words with an invisible character", func(r *recipe) {
r.Policy, r.Words = "time_and_key", append(append([]string(nil), words...), "a​b")
})
e("no file and no comment", func(r *recipe) { r.Files = nil })
e("a comment that is a lone CR", func(r *recipe) { r.Files, r.Comment = nil, "\r" })
e("a comment with a control character", func(r *recipe) { r.Comment = "hola\x01" })
e("a comment of 16385 bytes", func(r *recipe) { r.Comment = strings.Repeat("ñ", 8192) + "a" })
e("a declared author with a line feed", func(r *recipe) { r.Author = "Ana\nLópez" })
e("a declared author of 257 bytes", func(r *recipe) { r.Author = strings.Repeat("a", 257) })
e("a path given twice", func(r *recipe) { r.Files = []fileIn{text("b", "1"), text("a", "2"), text("b", "3")} })
e("an empty path", func(r *recipe) { r.Files = []fileIn{text("", "1")} })
e("a path of 1025 bytes", func(r *recipe) { r.Files = []fileIn{text(strings.Repeat("a", 1025), "1")} })
e("an absolute path", func(r *recipe) { r.Files = []fileIn{text("/etc/passwd", "1")} })
e("a path with ..", func(r *recipe) { r.Files = []fileIn{text("a/../b", "1")} })
e("a path with a reserved name", func(r *recipe) { r.Files = []fileIn{text("docs/CON.txt", "1")} })
e("a file that is a folder too", func(r *recipe) {
r.Files = []fileIn{text("a/b", "1"), text("a", "2"), text("c", "3")}
})
e("two paths that fold to the same key", func(r *recipe) {
r.Files = []fileIn{text("x/Año.txt", "1"), text("x/AÑO.txt", "2")}
})
e("a negative size", func(r *recipe) { r.Files = []fileIn{{Path: "a", Text: ptr(""), Size: ptr(int64(-1))}} })
e("files beyond L_MAX", func(r *recipe) {
r.Files = []fileIn{text("a", "a"), {Path: "b", Text: ptr(""), Size: ptr(int64(capsule.MaxPayloadLength))}}
})
e("L beyond L_MAX", func(r *recipe) {
r.Files = []fileIn{{Path: "b", Text: ptr(""), Size: ptr(int64(capsule.MaxPayloadLength - 100))}}
})
e("65536 files", func(r *recipe) { r.Files, r.ManyFiles, r.Node = nil, 65536, false })
for _, f := range []string{"open1", "short1", "long1", "open2", "short2", "long2", "change2", "read2"} {
e("a source that fails: "+f, func(r *recipe) {
r.Files = []fileIn{text("a.txt", "a"), {Path: "b.bin", Pattern: 100000, Fault: f}}
r.Node = false
})
}
e("a source that changes in a small file", func(r *recipe) { r.Files = []fileIn{{Path: "a", Text: ptr("abc"), Fault: "change2"}} })
// Errors of the hooks.
e("an author key whose signature does not verify", func(r *recipe) {
r.AuthorKey = &authorIn{Seed: authorSeed, Bad: "zero_signature"}
})
e("an author key of 31 bytes", func(r *recipe) { r.AuthorKey = &authorIn{Seed: authorSeed, Bad: "short_key"} })
e("a CMS signature that lacks a required signer", func(r *recipe) {
r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Extra: "Falta"}, false
})
e("a CMS signature without seals", func(r *recipe) {
r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Unsealed: true}, false
})
e("a CMS signature of 40 KiB without LargeArea", func(r *recipe) {
r.CMS, r.Node = &cmsIn{Signers: []string{"Ana López"}, Junk: 40 << 10}, false
})
e("a CMS signature larger than a test area", func(r *recipe) {
r.CMS, r.TestVectors, r.TestAreaLen, r.Node = &cmsIn{Signers: []string{"Ana López"}}, true, 512, false
})
e("a CMS signer that fails", func(r *recipe) {
r.CMS = &cmsIn{Signers: []string{"Ana López"}, Error: "the person cancelled"}
})
e("a CMS signer that returns garbage", func(r *recipe) {
r.CMS = &cmsIn{Signers: []string{"Ana López"}, Garbage: true}
})
e("a CMS signer without signers", func(r *recipe) { r.CMS = &cmsIn{} })
e("a sealer that fails", func(r *recipe) { r.Sealer = &sealerIn{Error: "the authority is down"} })
for i := range out {
if out[i].Seed == "" {
out[i].Seed = "capsule writer " + out[i].Name
}
}
return out
}
// ---------------------------------------------------------------------------
// crypto/rand from a seed
// seeded is the crypto/rand.Reader of a case: the ChaCha20 keystream under
// SHA-256(seed) and a zero nonce, the stream of SeededRandomSource. It
// records the size of every read.
type seeded struct {
c *chacha20.Cipher
draws []int
}
func newSeeded(seed string) *seeded {
key := sha256.Sum256([]byte(seed))
return &seeded{c: must(chacha20.NewUnauthenticatedCipher(key[:], make([]byte, chacha20.NonceSize)))}
}
func (s *seeded) Read(p []byte) (int, error) {
clear(p)
s.c.XORKeyStream(p, p)
s.draws = append(s.draws, len(p))
return len(p), nil
}
// ---------------------------------------------------------------------------
// The keys and the hooks
// identity is the X25519 identity of a label: its raw secret is
// SHA-256("identity " + label).
func identity(label string) *age.X25519Identity {
s := sha256.Sum256([]byte("identity " + label))
return must(agewrap.X25519IdentityFromRaw(s[:]))
}
// recipientOfRaw is the age recipient of 32 raw bytes, whatever they are:
// age.ParseX25519Recipient accepts any point.
func recipientOfRaw(raw []byte) *age.X25519Recipient {
return must(age.ParseX25519Recipient(must(bech32.Encode("age", raw))))
}
// The certificates of the CMS hooks, made in this order under the fixed
// randomness of the test, and the authority.
type certs struct {
byName map[string]cmstest.Signer
tsa cmstest.Signer
}
var (
certFrom, certTo = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2040, 1, 1, 0, 0, 0, 0, time.UTC)
)
func newCerts() *certs {
c := &certs{byName: map[string]cmstest.Signer{}}
c.byName["Ana López"] = cmstest.NewECDSA("Ana López", elliptic.P256(), certFrom, certTo)
c.byName["Luis Gómez"] = cmstest.NewRSA("Luis Gómez", 2048, certFrom, certTo)
c.byName["Falta"] = cmstest.NewECDSA("Falta", elliptic.P256(), certFrom, certTo)
c.tsa = cmstest.NewECDSA("TSA de prueba", elliptic.P256(), certFrom, certTo)
return c
}
// hooks records what each hook was given and what it returned.
type hooks struct {
rec obj
}
func (k *hooks) set(name string, v any) { k.rec[name] = v }
// authorHook is the author key of a recipe.
type authorHook struct {
key *authorkey.Key
bad string
k *hooks
}
func (a *authorHook) Public() []byte {
pub := a.key.Public()
if a.bad == "short_key" {
pub = pub[:31]
}
a.k.set("author_public", h(pub))
return pub
}
func (a *authorHook) Sign(msg []byte) []byte {
sig := a.key.Sign(msg)
if a.bad == "zero_signature" {
sig = make([]byte, ed25519.SignatureSize)
}
a.k.set("author_message", h(msg))
a.k.set("author_signature", h(sig))
return sig
}
// cmsHook signs as a signing application, as cmsSigner of signed_test.go.
type cmsHook struct {
in cmsIn
c *certs
when time.Time
k *hooks
}
func (s *cmsHook) signers() []cmstest.Signer {
var out []cmstest.Signer
for _, n := range s.in.Signers {
out = append(out, s.c.byName[n])
}
return out
}
func (s *cmsHook) Signers() [][32]byte {
var out [][32]byte
for _, x := range s.signers() {
out = append(out, sha256.Sum256(x.Cert.Raw))
}
if s.in.Extra != "" {
out = append(out, sha256.Sum256(s.c.byName[s.in.Extra].Cert.Raw))
}
list := []string{}
for _, x := range out {
list = append(list, h(x[:]))
}
s.k.set("cms_signers", list)
return out
}
func (s *cmsHook) Sign(msg []byte) ([]byte, error) {
s.k.set("cms_message", h(msg))
if s.in.Error != "" {
return nil, errors.New(s.in.Error)
}
var der []byte
if s.in.Garbage {
der = []byte("not a signature")
} else {
o := cmstest.Options{Junk: s.in.Junk}
if !s.in.Unsealed {
o.Token = func(sig []byte) []byte {
return cmstest.Token(sig, s.when, cmstest.TokenOptions{Accuracy: time.Second}, s.c.tsa)
}
}
der = cmstest.Signature(msg, o, s.signers()...)
}
s.k.set("cms_der", h(der))
return der, nil
}
// sealHook asks the authority, as sealer of signed_test.go.
type sealHook struct {
in sealerIn
c *certs
when time.Time
k *hooks
}
func (s *sealHook) Seal(subject [32]byte) ([]byte, error) {
s.k.set("seal_subject", h(subject[:]))
if s.in.Error != "" {
return nil, errors.New(s.in.Error)
}
token := cmstest.Token(subject[:], s.when, cmstest.TokenOptions{}, s.c.tsa)
s.k.set("seal_token", h(token))
return token, nil
}
// ---------------------------------------------------------------------------
// Sources
func pattern(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = byte(31*i + 7)
}
return b
}
func contentOf(f fileIn) []byte {
if f.Text != nil {
return []byte(*f.Text)
}
return pattern(f.Pattern)
}
type errReader struct{ err error }
func (r errReader) Read([]byte) (int, error) { return 0, r.err }
func sourceOf(f fileIn) capsule.Source {
content := contentOf(f)
size := int64(len(content))
if f.Size != nil {
size = *f.Size
}
calls := 0
var mtime time.Time
if f.MTime != nil {
mtime = time.Unix(f.MTime[0], f.MTime[1])
}
return capsule.Source{Path: f.Path, Size: size, ModTime: mtime, Open: func() (io.ReadCloser, error) {
if f.Size != nil && *f.Size != int64(len(content)) {
panic("a source opened whose size is not its content's")
}
calls++
c := content
switch fmt.Sprintf("%s/%d", f.Fault, calls) {
case "open1/1", "open2/2":
return nil, errors.New("the file is gone")
case "short1/1", "short2/2":
c = c[:len(c)-1]
case "long1/1", "long2/2":
c = append(bytes.Clone(c), 'x')
case "change2/2":
c = bytes.Clone(c)
c[0] ^= 1
case "read2/2":
return io.NopCloser(io.MultiReader(bytes.NewReader(c[:len(c)/2]), errReader{errors.New("the disk failed")})), nil
}
return io.NopCloser(bytes.NewReader(c)), nil
}}
}
// ---------------------------------------------------------------------------
// Running a recipe
func exts(list []extIn) []extension.Extension {
var out []extension.Extension
for _, e := range list {
x := extension.Extension{ID: e.ID, Version: e.Version}
if e.Data != nil {
x.Data = unhex(*e.Data)
if x.Data == nil {
x.Data = []byte{}
}
}
out = append(out, x)
}
return out
}
func profileOf(name string) *profile.Profile {
p := profile.Quicknet()
switch name {
case "":
case "genesis_plus_one":
p.GenesisTime++
default:
panic("profile " + name)
}
return p
}
func nowOf(r recipe, p *profile.Profile) time.Time {
if r.Now == "" {
return time.Unix(p.GenesisTime, 0).UTC()
}
return must(time.Parse(time.RFC3339Nano, r.Now))
}
// run writes the capsule of r with EncryptFiles while crypto/rand reads the
// keystream of its seed.
func run(r recipe, c *certs) (*capsule.Result, []byte, *seeded, obj, error) {
p := profileOf(r.Profile)
q := profile.Quicknet()
unlock := must(datekey.RoundTime(q, r.Round)).Add(time.Duration(r.UnlockNs))
now := nowOf(r, q)
opts := capsule.EncryptOptions{
Profile: p, UnlockAt: unlock, Now: func() time.Time { return now },
NewPortableKey: r.Portable, Words: r.Words, Padding: capsule.Padding(r.Padding),
Critical: exts(r.Critical), Noncritical: exts(r.Noncritical),
ControlCritical: exts(r.ControlCritical), ControlNoncritical: exts(r.ControlNoncritical),
HeadCritical: exts(r.HeadCritical), HeadNoncritical: exts(r.HeadNoncritical),
Comment: r.Comment, Author: r.Author, PublicNote: r.Note,
LargeArea: r.LargeArea, TestVectors: r.TestVectors, TestAreaLen: r.TestAreaLen,
}
if r.Policy == "time_and_key" {
opts.Policy = capsule.TimeAndKey
}
for _, l := range r.Identities {
opts.Recipients = append(opts.Recipients, identity(l).Recipient())
}
for _, raw := range r.RawRecipients {
opts.Recipients = append(opts.Recipients, recipientOfRaw(unhex(raw)))
}
k := &hooks{rec: obj{}}
if r.AuthorKey != nil {
opts.AuthorKey = &authorHook{key: must(authorkey.NewFromSeed(unhex(r.AuthorKey.Seed))), bad: r.AuthorKey.Bad, k: k}
}
if r.CMS != nil {
opts.CMSSigner = &cmsHook{in: *r.CMS, c: c, when: now, k: k}
}
if r.Sealer != nil {
opts.Sealer = &sealHook{in: *r.Sealer, c: c, when: now, k: k}
}
var sources []capsule.Source
for _, f := range r.Files {
sources = append(sources, sourceOf(f))
}
for i := range r.ManyFiles {
sources = append(sources, sourceOf(text(fmt.Sprintf("f%05d", i), "")))
}
s := newSeeded(r.Seed)
old := rand.Reader
rand.Reader = s
var dst bytes.Buffer
res, err := capsule.EncryptFiles(&dst, sources, opts)
rand.Reader = old
return res, dst.Bytes(), s, k.rec, err
}
// ---------------------------------------------------------------------------
// Opening
func releases() provider.ReleaseSource {
return testkit.NewSource(testkit.Release(1000), testkit.Release(1001))
}
func extSet(r recipe) extension.Set {
s := extension.Set{}
for _, l := range [][]extIn{r.Critical, r.Noncritical, r.ControlCritical, r.ControlNoncritical, r.HeadCritical, r.HeadNoncritical} {
for _, e := range l {
s[e.ID] = append(s[e.ID], e.Version)
}
}
return s
}
func opened(r recipe, dkc []byte, ids []age.Identity, dkk []byte) obj {
files := &testkit.MemorySink{}
o := capsule.OpenOptions{
Registry: testkit.Registry(), Extensions: extSet(r), Source: releases(), Identities: ids, Sink: files,
Now: func() time.Time { return time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC) },
}
if dkk != nil {
o.AccessKeyFile = bytes.NewReader(dkk)
}
res, err := capsule.Open(context.Background(), nil, bytes.NewReader(dkc), o)
v := obj{}
if err != nil {
v["result"] = datekeys.Code(err)
if v["result"] == "" {
v["result"] = "error: " + err.Error()
}
if res != nil && res.Inspection != nil && len(res.Inspection.Checks) > 0 {
v["step"] = res.Inspection.Checks[len(res.Inspection.Checks)-1].Step
}
return v
}
v["result"] = "ok"
var fs []obj
for i, f := range res.Head.Files {
fj := obj{"path": f.Path, "size": f.Size, "sha256": sum(files.Files[i])}
if f.HasMTime {
fj["mtime"] = f.MTime
}
fs = append(fs, fj)
}
if fs == nil {
fs = []obj{}
}
v["files"] = fs
v["head"] = h(must(capsule.EncodeHead(res.Head)))
v["verdicts"] = []string{string(res.Verdicts.Signature), string(res.Verdicts.Seal)}
v["area_len"] = res.AreaLen
v["length"] = res.PayloadLength
v["padded_length"] = res.PaddedLength
return v
}
func opens(r recipe, dkc []byte, res *capsule.Result, dkk []byte) []obj {
if r.Policy != "time_and_key" {
v := opened(r, dkc, nil, nil)
v["with"] = "time"
return []obj{v}
}
var out []obj
var all []age.Identity
for _, l := range r.Identities {
id := identity(l)
v := opened(r, dkc, []age.Identity{id}, nil)
v["with"] = "identity " + l
out = append(out, v)
all = append(all, id)
}
if len(r.Words) != 0 {
id := must(wordkey.Identity(r.Words, profile.Quicknet().ChainHash[:], res.DateKey.Round, res.CapsuleID[:]))
v := opened(r, dkc, []age.Identity{id}, nil)
v["with"] = "words"
out = append(out, v)
all = append(all, id)
}
if dkk != nil {
v := opened(r, dkc, nil, dkk)
v["with"] = "portable"
out = append(out, v)
}
v := opened(r, dkc, all, dkk)
v["with"] = "all"
out = append(out, v)
v = opened(r, dkc, nil, nil)
v["with"] = "none"
out = append(out, v)
return out
}
// layers opens SEALED_CONTROL with the release and, in time_and_key, with
// the first credential, and returns PUBLIC_HEADER, CONTROL_CBOR and whether
// they and the .dkk encode again to the bytes written.
func layers(r recipe, dkc []byte, res *capsule.Result, dkk []byte) (header, control []byte, same bool) {
pre := must(capsule.ParsePrelude(dkc))
header = dkc[capsule.PreludeSize : capsule.PreludeSize+int(pre.PublicHeaderLen)]
sealed := dkc[capsule.PreludeSize+int(pre.PublicHeaderLen) : capsule.PreludeSize+int(pre.PublicHeaderLen)+int(pre.SealedControlLen)]
hd := must(capsule.DecodeHeader(header))
same = bytes.Equal(must(capsule.EncodeHeader(hd)), header)
tid := must(agewrap.NewTimeIdentity(profile.Quicknet(), res.DateKey.Round, testkit.Release(res.DateKey.Round)))
control = must(io.ReadAll(must(age.Decrypt(bytes.NewReader(sealed), tid))))
if r.Policy == "time_and_key" {
var id age.Identity
switch {
case len(r.Identities) > 0:
id = identity(r.Identities[0])
case dkk != nil:
k := must(accesskey.Decode(bytes.NewReader(dkk)))
id = must(agewrap.X25519IdentityFromRaw(k.Material))
default:
id = must(wordkey.Identity(r.Words, profile.Quicknet().ChainHash[:], res.DateKey.Round, res.CapsuleID[:]))
}
aid := must(agewrap.NewAccessIdentity(agewrap.AccessSlots, id))
control = must(io.ReadAll(must(age.Decrypt(bytes.NewReader(control), aid))))
}
c := must(capsule.DecodeControl(control, pre.Format))
same = same && bytes.Equal(must(capsule.EncodeControl(c, pre.Format)), control)
if dkk != nil {
k := must(accesskey.Decode(bytes.NewReader(dkk)))
var b bytes.Buffer
must(0, accesskey.Encode(&b, k))
same = same && bytes.Equal(b.Bytes(), dkk)
}
return header, control, same
}
// ---------------------------------------------------------------------------
// The vectors
func caseOf(r recipe, c *certs) obj {
res, dkc, s, rec, err := run(r, c)
draws := s.draws
if draws == nil {
draws = []int{}
}
out := obj{"recipe": r, "draws": draws}
if len(rec) > 0 {
out["hooks"] = rec
}
if err != nil {
e := obj{"text": err.Error(), "written": len(dkc)}
if code := datekeys.Code(err); code != "" {
e["code"] = code
}
out["error"] = e
return out
}
var dkk []byte
if res.PortableKey != nil {
var b bytes.Buffer
must(0, accesskey.Encode(&b, res.PortableKey))
dkk = b.Bytes()
}
header, control, same := layers(r, dkc, res, dkk)
w := obj{
"length_dkc": len(dkc), "sha256": sum(dkc),
"round": res.DateKey.Round, "unlock_at": res.UnlockAt.UTC().Format(time.RFC3339Nano),
"capsule_id": h(res.CapsuleID[:]), "length": res.Length, "padding": int(res.Padding),
"padded_length": res.PaddedLength, "head": h(must(capsule.EncodeHead(res.Head))),
"header": h(header), "control": h(control), "reencoded": same,
}
if len(dkc) <= 4096 {
w["dkc"] = h(dkc)
}
if dkk != nil {
w["dkk"] = h(dkk)
}
out["written"] = w
out["opens"] = opens(r, dkc, res, dkk)
return out
}
func TestWriteVectors(t *testing.T) {
if *sourceFlag == "" || *outFlag == "" {
t.Skip("run with -args -source COMMIT -out DIR")
}
cryptotest.SetGlobalRandom(t, 20261006)
c := newCerts()
var cases, node []obj
for _, r := range recipes() {
v := caseOf(r, c)
cases = append(cases, v)
if r.Node {
node = append(node, v)
}
if e, ok := v["error"]; ok {
t.Logf("%s: %s", r.Name, e.(obj)["text"])
} else {
t.Logf("%s: %d bytes", r.Name, v["written"].(obj)["length_dkc"])
}
}
rel := obj{}
for _, round := range []uint64{1000, 1001} {
rel[fmt.Sprint(round)] = h(testkit.Release(round).Signature)
}
doc := func(cases []obj) []byte {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
must(0, enc.Encode(obj{
"description": "What capsule.EncryptFiles of datekeys-go writes for each recipe while crypto/rand reads the keystream of SeededRandomSource (ChaCha20 under SHA-256(seed), zero nonce), with the size of each draw and what each hook was given and returned; and the text, the code and the bytes written of each error (tool/capsule_writer_go_vectors_test.go). A capsule written is opened with capsule.Open with each credential, all together and none. A file of n bytes of pattern has (31·i + 7) mod 256 as byte i; the raw secret of the identity of a label is SHA-256(\"identity \" + label).",
"source": *sourceFlag,
"go": runtime.Version(),
"releases": rel,
"cases": cases,
}))
if bytes.Contains(buf.Bytes(), []byte("'''")) {
t.Fatal("the JSON holds three quotes")
}
return buf.Bytes()
}
full := doc(cases)
path := filepath.Join(*outFlag, "capsule_writer.json")
must(0, os.WriteFile(path, full, 0o644))
t.Logf("wrote %s, %d bytes", path, len(full))
dart := "// Generated by tool/capsule_writer_go_vectors_test.go: the cases of\n" +
"// test/vectors/capsule_writer.json marked node, for the tests that also run\n" +
"// compiled to JavaScript, where no file can be read. Do not edit.\n\n" +
"/// Part of test/vectors/capsule_writer.json.\n" +
"const capsuleWriterJson = r'''\n" + string(doc(node)) + "''';\n"
dpath := filepath.Join(*outFlag, "capsule_writer.g.dart")
must(0, os.WriteFile(dpath, []byte(dart), 0o644))
t.Logf("wrote %s", dpath)
}
Loading…
Cancel
Save

Powered by TurnKey Linux.