// The key of words of spec §38.1 as a credential of the opening: a capsule // whose INNER_ACCESS_AGE holds, in place of a dummy, a stanza for the key of // some words, as its creator could write it, opens with the identity that // wordKey derives from those words, the chain, the round and the capsule_id // that the inspection gives; with other words it does not, with the text of // the stanza rules of agewrap. @TestOn('vm') library; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:datekeys/datekeys.dart'; import 'package:datekeys/src/age.dart'; import 'package:datekeys/src/base64.dart' show goBase64Encode; import 'package:datekeys/src/curve25519.dart' show x25519PublicKey; import 'package:datekeys/src/sha256.dart' show sha256; import 'package:datekeys/src/tlock.dart'; import 'package:test/test.dart'; import 'age_support.dart' show x25519Stanza; import 'large_capsule.dart'; typedef Json = Map; Json readJson(String path) => jsonDecode(File(path).readAsStringSync()) as Json; Uint8List fixture(String file) => File('testdata/fixtures/$file').readAsBytesSync(); void main() { final r = readJson('testdata/fixtures/format3_time_and_key_portable.json'); final rel = r['release']! as Json; final release = Release( rel['round']! as int, fromHex(rel['signature']! as String), ); final dkc = fixture('format3_time_and_key_portable.dkc'); final dkk = fixture('format3_time_and_key_portable.dkk'); final words = normalizeWords('Casa perro LUNA verde árbol cielo'); // The capsule with a stanza for the key of the words in place of a dummy: // INNER_ACCESS_AGE gets a new header, whose MAC FK_ACCESS gives, and // OUTER_TIME_AGE is sealed again over it with FK_TIME and its nonce. The // stanza has the size of the one it replaces, so no length changes. final inspection = inspectCapsule(dkc); final h = inspection.header!; final p = inspection.profile!; final key = wordKey(words, p.chainHash, h.dateKey.round, h.capsuleId); final s = splitCapsule(dkc); final sealed = s.sealedControl!; final tlock = parseAgeHeader(sealed).stanzas.single; final fkTime = unwrapTlockStanza( p, h.dateKey.round, release, tlock.args, tlock.body, ); final inner = openStream(sealed, fkTime); final innerHeader = parseAgeHeader(inner); final access = decodeAccessKey(dkk); final fkAccess = X25519Identity(access.material) .unwrap([innerHeader.stanzas[r['access_key_stanza']! as int]]); final dummy = ((r['access_key_stanza']! as int) + 1) % 16; final stanzas = [...innerHeader.stanzas]; stanzas[dummy] = x25519Stanza( fkAccess, x25519PublicKey(key), Uint8List.fromList(List.generate(32, (i) => i + 1)), ); final newInner = concatBytes([ marshalAgeHeaderWithoutMac(stanzas), ' '.codeUnits, goBase64Encode(ageHeaderMac(fkAccess, stanzas), padded: false).codeUnits, '\n'.codeUnits, Uint8List.sublistView(inner, innerHeader.length), ]); if (newInner.length != inner.length) { throw StateError('the new INNER_ACCESS_AGE changes its length'); } final withWords = concatBytes([ s.preludeBytes, s.publicHeader, resealStream(sealed, fkTime, newInner), s.payload, ]); OpenOptions options(List identities, FileSink sink) => OpenOptions( source: suppliedRelease(release), now: () => parseRfc3339(r['unlock_at']! as String), identities: identities, sink: sink, ); test('opens with the key of the words, as one more credential', () async { final sink = MemoryFileSink(); final o = await openCapsule(withWords, options([key], sink)); expect(o.ok, isTrue, reason: o.error?.message); expect( o.checks.firstWhere((c) => c.step == 9).detail, '1 identities to try', ); final files = (r['files']! as List).cast(); expect( [for (final f in o.head!.files) f.path], [for (final f in files) f['path']], ); expect( [for (final f in sink.files!) toHex(sha256(f))], [for (final f in files) f['sha256']], ); // With the .dkk too, two credentials, each with its stanza: its // capsule_digest is that of the fixture, so it goes without it. final noDigest = AccessKey( credentialId: access.credentialId, capsuleId: access.capsuleId, type: access.type, material: access.material, ); final both = await openCapsule( withWords, OpenOptions( source: suppliedRelease(release), now: () => parseRfc3339(r['unlock_at']! as String), identities: [key], accessKey: noDigest, sink: MemoryFileSink(), ), ); expect( [both.ok, both.checks.firstWhere((c) => c.step == 9).detail], [true, '2 identities to try'], ); // With its capsule_digest, the .dkk is not of this capsule. final digest = await openCapsule( withWords, OpenOptions( source: suppliedRelease(release), now: () => parseRfc3339(r['unlock_at']! as String), identities: [key], accessKey: access, sink: MemoryFileSink(), ), ); expect( [digest.error?.message, digest.checks.last.step], [ 'capsule: the .dkk capsule_digest does not match this .dkc: ' 'ERR_ACCESS_INVALID', 9, ], ); }); test('does not open with other words', () async { final other = wordKey( normalizeWords('casa perro luna verde arbol mar'), p.chainHash, h.dateKey.round, h.capsuleId, ); final o = await openCapsule(withWords, options([other], MemoryFileSink())); expect( [o.error?.message, o.checks.last.step], [ 'capsule: age: agewrap: no supplied identity is a recipient of ' 'INNER_ACCESS_AGE: ERR_ACCESS_INVALID', 13, ], ); // The fixture itself has no stanza for the words. final f = await openCapsule(dkc, options([key], MemoryFileSink())); expect(f.error?.code, ErrorCode.accessInvalid); }); }