@robota-sdk/agent-keyring 0.0.0-stage → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +661 -0
  3. package/README.md +91 -2
  4. package/dist/node/index.cjs +1 -0
  5. package/dist/node/index.d.cts +470 -0
  6. package/dist/node/index.d.cts.map +1 -0
  7. package/dist/node/index.d.ts +470 -0
  8. package/dist/node/index.d.ts.map +1 -0
  9. package/dist/node/index.js +2 -0
  10. package/dist/node/index.js.map +1 -0
  11. package/package.json +74 -3
  12. package/src/__tests__/authorship-constant-time.test.ts +46 -0
  13. package/src/__tests__/authorship.test.ts +65 -0
  14. package/src/__tests__/crypto-failures.test.ts +89 -0
  15. package/src/__tests__/fixtures/keyring-v1.json +25 -0
  16. package/src/__tests__/fixtures/sealed-v1.json +8 -0
  17. package/src/__tests__/helpers.ts +130 -0
  18. package/src/__tests__/kdf-bounds.test.ts +154 -0
  19. package/src/__tests__/keyring-document.test.ts +251 -0
  20. package/src/__tests__/keyring.test.ts +329 -0
  21. package/src/__tests__/keyslot.test.ts +98 -0
  22. package/src/__tests__/known-answers.test.ts +110 -0
  23. package/src/__tests__/link-helpers.ts +228 -0
  24. package/src/__tests__/owner-handover-derivation.test.ts +50 -0
  25. package/src/__tests__/owner-handover-dispose.test.ts +64 -0
  26. package/src/__tests__/owner-handover.test.ts +145 -0
  27. package/src/__tests__/owner-link-adversarial.test.ts +712 -0
  28. package/src/__tests__/owner-link-grant-wipe.test.ts +75 -0
  29. package/src/__tests__/owner-link-known-answers.test.ts +117 -0
  30. package/src/__tests__/owner-link-secret-wipe.test.ts +116 -0
  31. package/src/__tests__/owner-link.test.ts +229 -0
  32. package/src/__tests__/read-keys.test.ts +67 -0
  33. package/src/__tests__/recovery-key.test.ts +76 -0
  34. package/src/__tests__/runtime-identity.test.ts +171 -0
  35. package/src/__tests__/sealed-format-vectors.test.ts +91 -0
  36. package/src/__tests__/sealing.test.ts +376 -0
  37. package/src/__tests__/spawner-exchange.test.ts +127 -0
  38. package/src/__tests__/transient-key-pair.test.ts +36 -0
  39. package/src/aead.ts +62 -0
  40. package/src/authorship.ts +61 -0
  41. package/src/bytes.ts +67 -0
  42. package/src/errors.ts +106 -0
  43. package/src/fingerprint.ts +32 -0
  44. package/src/index.ts +77 -0
  45. package/src/kdf.ts +111 -0
  46. package/src/key-pairs.ts +148 -0
  47. package/src/keyring-document.ts +168 -0
  48. package/src/keyring-types.ts +51 -0
  49. package/src/keyring.ts +307 -0
  50. package/src/keyslot.ts +66 -0
  51. package/src/link-entropy.ts +24 -0
  52. package/src/link-frames.ts +271 -0
  53. package/src/link-primitives.ts +77 -0
  54. package/src/owner-handover.ts +155 -0
  55. package/src/owner-link.ts +508 -0
  56. package/src/read-keys.ts +78 -0
  57. package/src/recovery-key.ts +79 -0
  58. package/src/runtime-certificate.ts +129 -0
  59. package/src/runtime-identity.ts +166 -0
  60. package/src/sealing.ts +281 -0
  61. package/src/signatures.ts +45 -0
  62. package/src/spawner-exchange.ts +155 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A read grant's secret bytes exist in the clear only for the length of one call on each end: the
3
+ * buffer the initiator seals and the buffer the responder decrypts are zero-filled before the call
4
+ * returns, and the read keys own copies of their own. Kept apart because it wraps the AEAD module
5
+ * for the whole file to see those buffers.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ import { describe, expect, it, vi } from 'vitest';
11
+
12
+ const seen = vi.hoisted(() => ({ sealed: [] as Uint8Array[], opened: [] as Uint8Array[] }));
13
+
14
+ vi.mock('../aead.js', async (importOriginal) => {
15
+ const actual = await importOriginal<typeof import('../aead.js')>();
16
+ return {
17
+ ...actual,
18
+ gcmEncrypt: (...args: Parameters<typeof actual.gcmEncrypt>) => {
19
+ seen.sealed.push(args[2]);
20
+ return actual.gcmEncrypt(...args);
21
+ },
22
+ gcmDecrypt: (...args: Parameters<typeof actual.gcmDecrypt>) => {
23
+ const plaintext = actual.gcmDecrypt(...args);
24
+ seen.opened.push(plaintext);
25
+ return plaintext;
26
+ },
27
+ };
28
+ });
29
+
30
+ const { createLinkInitiator, createLinkResponder, openOwnerHandover, parseKeyring } =
31
+ await import('../index.js');
32
+ const {
33
+ FIXTURE_RECOVERY_KEY,
34
+ KAT_BINDING,
35
+ KNOWN_AUTHORSHIP_MESSAGE,
36
+ KNOWN_AUTHORSHIP_TAG_HEX,
37
+ bytesOf,
38
+ hex,
39
+ } = await import('./helpers.js');
40
+ const { certifiedRuntime } = await import('./link-helpers.js');
41
+
42
+ const fixture = parseKeyring(
43
+ readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
44
+ );
45
+
46
+ describe('the read grant buffers', () => {
47
+ it('are zero-filled on both ends once the grant is sealed and opened', async () => {
48
+ const owner = await openOwnerHandover(fixture, {
49
+ type: 'recoveryKey',
50
+ recoveryKey: FIXTURE_RECOVERY_KEY,
51
+ });
52
+ const identity = certifiedRuntime(owner, KAT_BINDING);
53
+ const initiator = createLinkInitiator({ mode: 'owner', handover: owner });
54
+ const responder = createLinkResponder(identity, KAT_BINDING);
55
+ const { proof, link } = initiator.answer(responder.challenge(initiator.hello()));
56
+ const { link: runtimeSide } = responder.accept(proof);
57
+
58
+ seen.sealed.length = 0;
59
+ seen.opened.length = 0;
60
+ const grant = link.sealReadGrant(owner);
61
+ expect(seen.sealed).toHaveLength(1);
62
+ expect(seen.sealed[0]).toHaveLength(65);
63
+ expect(seen.sealed[0]!.every((byte) => byte === 0)).toBe(true);
64
+
65
+ const keys = runtimeSide.openReadGrant(grant);
66
+ expect(seen.opened).toHaveLength(1);
67
+ expect(seen.opened[0]).toHaveLength(65);
68
+ expect(seen.opened[0]!.every((byte) => byte === 0)).toBe(true);
69
+ expect(hex(keys.tagAuthorship(bytesOf(KNOWN_AUTHORSHIP_MESSAGE)))).toBe(
70
+ KNOWN_AUTHORSHIP_TAG_HEX,
71
+ );
72
+ keys.lock();
73
+ owner.dispose();
74
+ });
75
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * The owner link byte for byte: from fixed keys, ephemerals and nonces, both handshake modes give
3
+ * the pinned hello, challenge, transcript hash, proofs and frame keys, and every frame kind seals to
4
+ * the pinned bytes. The answers were computed outside this package (see `link-helpers.ts`), so a
5
+ * change to any label, field order, length prefix or nonce layout fails here.
6
+ */
7
+ import { Buffer } from 'node:buffer';
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { beforeAll, describe, expect, it } from 'vitest';
12
+
13
+ import { openOwnerHandover, parseKeyring, type IOwnerHandover } from '../index.js';
14
+ import { deriveFrameKeys, transcriptHash } from '../owner-link.js';
15
+ import {
16
+ FIXTURE_RECOVERY_KEY,
17
+ KAT_BINDING,
18
+ KAT_CERTIFICATE_HEX,
19
+ KAT_INSTANCE_PUBLIC_KEY_HEX,
20
+ KNOWN_AUTHORSHIP_MESSAGE,
21
+ KNOWN_AUTHORSHIP_TAG_HEX,
22
+ KNOWN_SEALING_PUBLIC_KEY_HEX,
23
+ bytesOf,
24
+ hex,
25
+ } from './helpers.js';
26
+ import {
27
+ KAT_FRAMES,
28
+ KAT_OWNER_LINK,
29
+ KAT_SPAWNER_LINK,
30
+ katOwnerLink,
31
+ katSpawnerLink,
32
+ } from './link-helpers.js';
33
+
34
+ const fixture = parseKeyring(
35
+ readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
36
+ );
37
+ let owner: IOwnerHandover;
38
+
39
+ beforeAll(async () => {
40
+ owner = await openOwnerHandover(fixture, {
41
+ type: 'recoveryKey',
42
+ recoveryKey: FIXTURE_RECOVERY_KEY,
43
+ });
44
+ });
45
+
46
+ describe('owner link known answers', () => {
47
+ it('runs the spawner-mode handshake to the pinned bytes', () => {
48
+ const { hello, challenge, proof, standing, initiatorLink } = katSpawnerLink();
49
+ expect(hex(hello)).toBe(KAT_SPAWNER_LINK.hello);
50
+ expect(hex(challenge)).toBe(KAT_SPAWNER_LINK.challenge);
51
+ expect(hex(proof)).toBe(KAT_SPAWNER_LINK.proof);
52
+ expect(standing).toBe('spawner');
53
+ expect(initiatorLink.binding).toEqual(KAT_BINDING);
54
+ expect(hex(initiatorLink.instancePublicKey)).toBe(KAT_INSTANCE_PUBLIC_KEY_HEX);
55
+ });
56
+
57
+ it('runs the owner-mode handshake to the pinned bytes, with the pinned certificate', () => {
58
+ const { hello, challenge, proof, standing, initiatorLink } = katOwnerLink(owner);
59
+ expect(hex(hello)).toBe(KAT_OWNER_LINK.hello);
60
+ expect(hex(challenge)).toBe(KAT_OWNER_LINK.challenge);
61
+ expect(hex(challenge)).toContain(KAT_CERTIFICATE_HEX);
62
+ expect(hex(proof)).toBe(KAT_OWNER_LINK.proof);
63
+ expect(standing).toBe('owner');
64
+ expect(initiatorLink.binding).toEqual(KAT_BINDING);
65
+ });
66
+
67
+ it('hashes the transcript and derives the frame keys as pinned', () => {
68
+ for (const [link, mode, certificate] of [
69
+ [KAT_SPAWNER_LINK, 'spawner', ''],
70
+ [KAT_OWNER_LINK, 'owner', KAT_CERTIFICATE_HEX],
71
+ ] as const) {
72
+ const challenge = Buffer.from(link.challenge, 'hex');
73
+ const hello = Buffer.from(link.hello, 'hex');
74
+ const th = transcriptHash({
75
+ mode,
76
+ initiatorEphemeral: hello.subarray(2, 34),
77
+ initiatorNonce: hello.subarray(34, 66),
78
+ responderEphemeral: challenge.subarray(0, 32),
79
+ responderNonce: challenge.subarray(32, 64),
80
+ instancePublicKey: challenge.subarray(64, 96),
81
+ binding: KAT_BINDING,
82
+ certificate: Buffer.from(certificate, 'hex'),
83
+ });
84
+ expect(hex(th)).toBe(link.transcript);
85
+ const keys = deriveFrameKeys(Buffer.from(link.sharedSecret, 'hex'), th);
86
+ expect(hex(keys.initiatorToResponder)).toBe(link.initiatorToResponder);
87
+ expect(hex(keys.responderToInitiator)).toBe(link.responderToInitiator);
88
+ }
89
+ });
90
+
91
+ it('seals one frame of each kind to the pinned bytes, and opens them on the other end', () => {
92
+ const spawner = katSpawnerLink();
93
+ const session = spawner.initiatorLink.seal('session', bytesOf(KAT_FRAMES.session.plaintext));
94
+ const ownerFrame = spawner.initiatorLink.seal('owner', bytesOf(KAT_FRAMES.owner.plaintext));
95
+ const reply = spawner.link.seal('owner', bytesOf(KAT_FRAMES.ownerReply.plaintext));
96
+ expect(hex(session)).toBe(KAT_FRAMES.session.sealed);
97
+ expect(hex(ownerFrame)).toBe(KAT_FRAMES.owner.sealed);
98
+ expect(hex(reply)).toBe(KAT_FRAMES.ownerReply.sealed);
99
+ expect(spawner.link.open(session)).toMatchObject({ kind: 'session' });
100
+ expect(Buffer.from(spawner.link.open(ownerFrame).plaintext).toString()).toBe(
101
+ KAT_FRAMES.owner.plaintext,
102
+ );
103
+ expect(Buffer.from(spawner.initiatorLink.open(reply).plaintext).toString()).toBe(
104
+ KAT_FRAMES.ownerReply.plaintext,
105
+ );
106
+
107
+ const ownerSide = katOwnerLink(owner);
108
+ const grant = ownerSide.initiatorLink.sealReadGrant(owner);
109
+ expect(hex(grant)).toBe(KAT_FRAMES.grant.sealed);
110
+ const keys = ownerSide.link.openReadGrant(grant);
111
+ expect(hex(keys.sealing.publicKey)).toBe(KNOWN_SEALING_PUBLIC_KEY_HEX);
112
+ expect(hex(keys.tagAuthorship(bytesOf(KNOWN_AUTHORSHIP_MESSAGE)))).toBe(
113
+ KNOWN_AUTHORSHIP_TAG_HEX,
114
+ );
115
+ keys.lock();
116
+ });
117
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Secrets that live only for part of a handshake are zero-filled when that part ends: the responder
3
+ * wipes the frame keys it derived and the proof it expected when the proof it gets is refused (and
4
+ * the expected proof once it is accepted), and the spawner wipes the shared secret of the exchange
5
+ * once the spawner key is derived. Kept apart because it wraps the link primitives for the whole file
6
+ * to see the buffers they hand out; the frame keys are copies made inside the derivation, so those
7
+ * are found by their known values among what the derivation allocates.
8
+ */
9
+ import { Buffer } from 'node:buffer';
10
+
11
+ import { describe, expect, it, vi } from 'vitest';
12
+
13
+ type TSecret = { readonly before: Uint8Array; readonly live: Uint8Array };
14
+
15
+ const seen = vi.hoisted(() => ({ agreed: [] as TSecret[], macs: [] as TSecret[] }));
16
+
17
+ vi.mock('../link-primitives.js', async (importOriginal) => {
18
+ const actual = await importOriginal<typeof import('../link-primitives.js')>();
19
+ return {
20
+ ...actual,
21
+ agree: (...args: Parameters<typeof actual.agree>) => {
22
+ const shared = actual.agree(...args);
23
+ seen.agreed.push({ before: Uint8Array.from(shared), live: shared });
24
+ return shared;
25
+ },
26
+ hmac: (...args: Parameters<typeof actual.hmac>) => {
27
+ const mac = actual.hmac(...args);
28
+ seen.macs.push({ before: Uint8Array.from(mac), live: mac });
29
+ return mac;
30
+ },
31
+ };
32
+ });
33
+
34
+ const { createLinkResponder, createRuntimeIdentity, startSpawnerExchange } =
35
+ await import('../index.js');
36
+ const { createLinkInitiatorWith } = await import('../owner-link.js');
37
+ const { KAT_BINDING, hex } = await import('./helpers.js');
38
+ const { KAT_SPAWNER_LINK, fixedEntropy, katRuntime, refusalOf } = await import('./link-helpers.js');
39
+
40
+ const isZero = (bytes: Uint8Array): boolean => bytes.every((byte) => byte === 0);
41
+
42
+ /** Every buffer `Buffer.from` makes while `run` runs. */
43
+ function buffersMadeBy(run: () => void): Buffer[] {
44
+ const original = Buffer.from.bind(Buffer) as (...args: unknown[]) => Buffer;
45
+ const made: Buffer[] = [];
46
+ const spy = vi.spyOn(Buffer, 'from').mockImplementation(((...args: unknown[]) => {
47
+ const buffer = original(...args);
48
+ made.push(buffer);
49
+ return buffer;
50
+ }) as typeof Buffer.from);
51
+ try {
52
+ run();
53
+ } finally {
54
+ spy.mockRestore();
55
+ }
56
+ return made;
57
+ }
58
+
59
+ /** The KAT spawner-mode responder that has answered the KAT hello, and what it is holding for the proof. */
60
+ function answeredResponder() {
61
+ const { identity, spawnerKey, instancePublicKey } = katRuntime(KAT_SPAWNER_LINK);
62
+ const initiator = createLinkInitiatorWith(
63
+ { mode: 'spawner', spawnerKey, instancePublicKey },
64
+ fixedEntropy([KAT_SPAWNER_LINK.initiatorSeed], [KAT_SPAWNER_LINK.initiatorNonce]),
65
+ );
66
+ const responder = createLinkResponder(identity, KAT_BINDING);
67
+ const hello = initiator.hello();
68
+ const macsBefore = seen.macs.length;
69
+ const made = buffersMadeBy(() => responder.challenge(hello));
70
+ const frameKeys = [KAT_SPAWNER_LINK.initiatorToResponder, KAT_SPAWNER_LINK.responderToInitiator].map(
71
+ (known) => made.find((buffer) => hex(buffer) === known),
72
+ );
73
+ const expectedProof = seen.macs
74
+ .slice(macsBefore)
75
+ .find((mac) => hex(mac.before) === KAT_SPAWNER_LINK.proof);
76
+ return { responder, frameKeys, expectedProof: expectedProof?.live };
77
+ }
78
+
79
+ describe('the responder, once the proof it waited for has come', () => {
80
+ it('wipes the frame keys and the expected proof when it refuses the proof', () => {
81
+ const { responder, frameKeys, expectedProof } = answeredResponder();
82
+ const held = [...frameKeys, expectedProof];
83
+ expect(held.every((secret) => secret !== undefined && !isZero(secret))).toBe(true);
84
+
85
+ expect(refusalOf(() => responder.accept(Buffer.alloc(32, 7)))).toBe('bad-proof');
86
+ for (const secret of held) expect(isZero(secret!)).toBe(true);
87
+ });
88
+
89
+ it('wipes the expected proof, and not the frame keys the link now owns, when it accepts it', () => {
90
+ const { responder, frameKeys, expectedProof } = answeredResponder();
91
+ expect(expectedProof).toBeDefined();
92
+ expect(isZero(expectedProof!)).toBe(false);
93
+
94
+ const proof = Buffer.from(KAT_SPAWNER_LINK.proof, 'hex');
95
+ const { link } = responder.accept(proof);
96
+ expect(isZero(expectedProof!)).toBe(true);
97
+ expect(frameKeys.every((key) => key !== undefined && !isZero(key))).toBe(true);
98
+ link.close();
99
+ });
100
+ });
101
+
102
+ describe('the spawner exchange', () => {
103
+ it('wipes the shared secret once the spawner key is derived', () => {
104
+ const identity = createRuntimeIdentity();
105
+ const exchange = startSpawnerExchange();
106
+ const reply = identity.answerSpawnerExchange(exchange.message);
107
+ const agreedBefore = seen.agreed.length;
108
+
109
+ const { spawnerKey } = exchange.complete(reply);
110
+ const secrets = seen.agreed.slice(agreedBefore);
111
+ expect(secrets).toHaveLength(1);
112
+ expect(isZero(secrets[0]!.before)).toBe(false);
113
+ expect(isZero(secrets[0]!.live)).toBe(true);
114
+ spawnerKey.wipe();
115
+ });
116
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The owner link as its two ends use it: a spawner links with its spawner key and learns the
3
+ * binding it then certifies; an owner links with its handover once the runtime is certified; frames
4
+ * flow both ways under the link keys; and the read grant goes only over an owner link, from the
5
+ * handover that opened it, into read keys on the runtime's side.
6
+ */
7
+ import { Buffer } from 'node:buffer';
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
12
+
13
+ import {
14
+ createKeyring,
15
+ createLinkInitiator,
16
+ createLinkResponder,
17
+ linkFrameKind,
18
+ openOwnerHandover,
19
+ parseKeyring,
20
+ type IOwnerHandover,
21
+ type IRuntimeIdentity,
22
+ type ILinkBinding,
23
+ } from '../index.js';
24
+ import {
25
+ FIXTURE_RECOVERY_KEY,
26
+ KAT_BINDING,
27
+ KNOWN_AUTHORSHIP_MESSAGE,
28
+ KNOWN_AUTHORSHIP_TAG_HEX,
29
+ KNOWN_SEALING_PUBLIC_KEY_HEX,
30
+ bytesOf,
31
+ codeOf,
32
+ hex,
33
+ } from './helpers.js';
34
+ import { certifiedRuntime, refusalOf, spawnedRuntime } from './link-helpers.js';
35
+
36
+ const fixture = parseKeyring(
37
+ readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
38
+ );
39
+ let owner: IOwnerHandover;
40
+ let stranger: IOwnerHandover;
41
+
42
+ beforeAll(async () => {
43
+ owner = await openOwnerHandover(fixture, {
44
+ type: 'recoveryKey',
45
+ recoveryKey: FIXTURE_RECOVERY_KEY,
46
+ });
47
+ const other = await createKeyring({ passphrase: 'a keyring that is not the owner one' });
48
+ stranger = await openOwnerHandover(other.keyring, {
49
+ type: 'recoveryKey',
50
+ recoveryKey: other.recoveryKey,
51
+ });
52
+ });
53
+
54
+ afterAll(() => {
55
+ owner.dispose();
56
+ stranger.dispose();
57
+ });
58
+
59
+ function ownerLink(identity: IRuntimeIdentity, binding: ILinkBinding = KAT_BINDING) {
60
+ const initiator = createLinkInitiator({ mode: 'owner', handover: owner });
61
+ const responder = createLinkResponder(identity, binding);
62
+ const { proof, link } = initiator.answer(responder.challenge(initiator.hello()));
63
+ return { initiatorLink: link, ...responder.accept(proof) };
64
+ }
65
+
66
+ describe('a spawner link', () => {
67
+ it('admits the spawner, teaches it the binding, and lets it certify the runtime', () => {
68
+ const { identity, spawnerKey, instancePublicKey } = spawnedRuntime();
69
+ const initiator = createLinkInitiator({ mode: 'spawner', spawnerKey, instancePublicKey });
70
+ const responder = createLinkResponder(identity, KAT_BINDING);
71
+ const { proof, link } = initiator.answer(responder.challenge(initiator.hello()));
72
+ const accepted = responder.accept(proof);
73
+ expect(accepted.standing).toBe('spawner');
74
+ expect(link.standing).toBe('spawner');
75
+ expect(link.binding).toEqual(KAT_BINDING);
76
+ expect(hex(link.instancePublicKey)).toBe(hex(identity.instancePublicKey));
77
+
78
+ // What the spawner learned is authentic, so it is what it certifies.
79
+ identity.installTrustRoot(owner.certify(link.instancePublicKey, link.binding), link.binding);
80
+ spawnerKey.wipe();
81
+ expect(identity.certified).toBe(true);
82
+ expect(ownerLink(identity).standing).toBe('owner');
83
+ });
84
+ });
85
+
86
+ describe('an owner link', () => {
87
+ it('admits the owner of a certified runtime with owner standing', () => {
88
+ const identity = certifiedRuntime(owner, KAT_BINDING);
89
+ const { initiatorLink, link, standing } = ownerLink(identity);
90
+ expect(standing).toBe('owner');
91
+ expect(link.standing).toBe('owner');
92
+ expect(initiatorLink.binding).toEqual(KAT_BINDING);
93
+ expect(hex(initiatorLink.instancePublicKey)).toBe(hex(identity.instancePublicKey));
94
+ });
95
+
96
+ it('carries frames both ways, in order, with their kinds', () => {
97
+ const { initiatorLink, link } = ownerLink(certifiedRuntime(owner, KAT_BINDING));
98
+ for (let index = 0; index < 5; index += 1) {
99
+ const up = initiatorLink.seal(index % 2 === 0 ? 'session' : 'owner', bytesOf(`up ${index}`));
100
+ expect(linkFrameKind(up)).toBe(index % 2 === 0 ? 'session' : 'owner');
101
+ const opened = link.open(up);
102
+ expect(opened.kind).toBe(index % 2 === 0 ? 'session' : 'owner');
103
+ expect(Buffer.from(opened.plaintext).toString()).toBe(`up ${index}`);
104
+ const down = link.seal('session', bytesOf(`down ${index}`));
105
+ expect(Buffer.from(initiatorLink.open(down).plaintext).toString()).toBe(`down ${index}`);
106
+ }
107
+ const empty = initiatorLink.seal('owner', new Uint8Array(0));
108
+ expect(link.open(empty).plaintext).toHaveLength(0);
109
+ });
110
+
111
+ it('is closed by close(), which ends sealing and opening on that end', () => {
112
+ const { initiatorLink, link } = ownerLink(certifiedRuntime(owner, KAT_BINDING));
113
+ const frame = initiatorLink.seal('session', bytesOf('before'));
114
+ initiatorLink.close();
115
+ expect(initiatorLink.closed).toBe(true);
116
+ expect(codeOf(() => initiatorLink.seal('session', bytesOf('after')))).toBe('link-closed');
117
+ expect(codeOf(() => initiatorLink.open(link.seal('session', bytesOf('x'))))).toBe(
118
+ 'link-closed',
119
+ );
120
+ initiatorLink.close();
121
+ expect(Buffer.from(link.open(frame).plaintext).toString()).toBe('before');
122
+ });
123
+ });
124
+
125
+ describe('the read grant', () => {
126
+ it('opens on the runtime into read keys of the owner, never the attach key', () => {
127
+ const { initiatorLink, link } = ownerLink(certifiedRuntime(owner, KAT_BINDING));
128
+ const keys = link.openReadGrant(initiatorLink.sealReadGrant(owner));
129
+ expect(hex(keys.sealing.publicKey)).toBe(KNOWN_SEALING_PUBLIC_KEY_HEX);
130
+ expect(keys.sealing.privateKey.asymmetricKeyType).toBe('x25519');
131
+ expect(hex(keys.tagAuthorship(bytesOf(KNOWN_AUTHORSHIP_MESSAGE)))).toBe(
132
+ KNOWN_AUTHORSHIP_TAG_HEX,
133
+ );
134
+ expect('attach' in keys).toBe(false);
135
+ keys.lock();
136
+ expect(codeOf(() => keys.sealing)).toBe('keys-locked');
137
+ });
138
+
139
+ it('is sealed only over an owner link, and only from the handover that opened it', () => {
140
+ const { identity, spawnerKey, instancePublicKey } = spawnedRuntime();
141
+ const initiator = createLinkInitiator({ mode: 'spawner', spawnerKey, instancePublicKey });
142
+ const responder = createLinkResponder(identity, KAT_BINDING);
143
+ const { link } = initiator.answer(responder.challenge(initiator.hello()));
144
+ expect(codeOf(() => link.sealReadGrant(owner))).toBe('invalid-argument');
145
+
146
+ const { initiatorLink } = ownerLink(certifiedRuntime(owner, KAT_BINDING));
147
+ expect(codeOf(() => initiatorLink.sealReadGrant(stranger))).toBe('invalid-argument');
148
+ });
149
+
150
+ it('is refused by a spawner-standing link, which closes', () => {
151
+ const { identity, spawnerKey, instancePublicKey } = spawnedRuntime();
152
+ const initiator = createLinkInitiator({ mode: 'spawner', spawnerKey, instancePublicKey });
153
+ const responder = createLinkResponder(identity, KAT_BINDING);
154
+ const { proof, link: spawnerSide } = initiator.answer(responder.challenge(initiator.hello()));
155
+ const { link } = responder.accept(proof);
156
+ // A grant-kind frame sealed under the spawner link's own keys still opens nothing.
157
+ const { initiatorLink } = ownerLink(certifiedRuntime(owner, KAT_BINDING));
158
+ const grant = initiatorLink.sealReadGrant(owner);
159
+ expect(codeOf(() => link.openReadGrant(grant))).toBe('invalid-argument');
160
+ expect(link.closed).toBe(true);
161
+ expect(spawnerSide.closed).toBe(false);
162
+ });
163
+
164
+ it('is opened only as a grant: open() refuses it, and openReadGrant refuses other kinds', () => {
165
+ const first = ownerLink(certifiedRuntime(owner, KAT_BINDING));
166
+ const grant = first.initiatorLink.sealReadGrant(owner);
167
+ expect(linkFrameKind(grant)).toBe('grant');
168
+ expect(codeOf(() => first.link.open(grant))).toBe('invalid-argument');
169
+ expect(first.link.closed).toBe(true);
170
+
171
+ const second = ownerLink(certifiedRuntime(owner, KAT_BINDING));
172
+ expect(
173
+ codeOf(() => second.link.openReadGrant(second.initiatorLink.seal('owner', bytesOf('x')))),
174
+ ).toBe('invalid-argument');
175
+ expect(second.link.closed).toBe(true);
176
+ });
177
+ });
178
+
179
+ describe('steps out of turn', () => {
180
+ it('are refused on both ends, and a spent end stays spent', () => {
181
+ const identity = certifiedRuntime(owner, KAT_BINDING);
182
+ const initiator = createLinkInitiator({ mode: 'owner', handover: owner });
183
+ expect(refusalOf(() => initiator.answer(new Uint8Array(10)))).toBe('out-of-order');
184
+ const hello = initiator.hello();
185
+ expect(refusalOf(() => initiator.hello())).toBe('out-of-order');
186
+ const responder = createLinkResponder(identity, KAT_BINDING);
187
+ expect(refusalOf(() => responder.accept(new Uint8Array(64)))).toBe('out-of-order');
188
+ const challenge = responder.challenge(hello);
189
+ expect(refusalOf(() => responder.challenge(hello))).toBe('out-of-order');
190
+ const { proof } = initiator.answer(challenge);
191
+ expect(refusalOf(() => initiator.answer(challenge))).toBe('out-of-order');
192
+ responder.accept(proof);
193
+ expect(refusalOf(() => responder.accept(proof))).toBe('out-of-order');
194
+ });
195
+
196
+ it('refuse a disposed handover or a wiped spawner key before any message', async () => {
197
+ const disposed = await openOwnerHandover(fixture, {
198
+ type: 'recoveryKey',
199
+ recoveryKey: FIXTURE_RECOVERY_KEY,
200
+ });
201
+ disposed.dispose();
202
+ expect(codeOf(() => createLinkInitiator({ mode: 'owner', handover: disposed }))).toBe(
203
+ 'keys-locked',
204
+ );
205
+ const { identity, spawnerKey, instancePublicKey } = spawnedRuntime();
206
+ spawnerKey.wipe();
207
+ expect(
208
+ codeOf(() => createLinkInitiator({ mode: 'spawner', spawnerKey, instancePublicKey })),
209
+ ).toBe('keys-locked');
210
+ expect(identity.spawnerKeyHeld).toBe(true);
211
+ expect(codeOf(() => createLinkInitiator({ mode: 'other' } as never))).toBe('invalid-argument');
212
+ expect(codeOf(() => createLinkResponder(identity, { runtimeId: '', generation: 'g' }))).toBe(
213
+ 'invalid-argument',
214
+ );
215
+ });
216
+ });
217
+
218
+ describe('linkFrameKind', () => {
219
+ it('names the kind byte and refuses anything that is not a frame', () => {
220
+ expect(refusalOf(() => linkFrameKind(new Uint8Array(0)))).toBe('malformed');
221
+ expect(refusalOf(() => linkFrameKind(Buffer.from([4, ...new Uint8Array(16)])))).toBe(
222
+ 'malformed',
223
+ );
224
+ expect(refusalOf(() => linkFrameKind(Buffer.from([1, ...new Uint8Array(15)])))).toBe(
225
+ 'malformed',
226
+ );
227
+ expect(linkFrameKind(Buffer.from([3, ...new Uint8Array(16)]))).toBe('grant');
228
+ });
229
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * What an unlock yields is what reading needs and nothing more: the sealing keypair and the
3
+ * authorship key. The attach keypair, which proves ownership to a verifier, is reachable from none
4
+ * of it, so a process that holds read keys for a while can read and vouch but never present itself
5
+ * as the owner.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { KeyObject } from 'node:crypto';
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import {
14
+ checkSealingPublicKey,
15
+ createKeyring,
16
+ getSealingPublicKey,
17
+ parseKeyring,
18
+ repairSealingPublicKey,
19
+ unlockWithPassphrase,
20
+ unlockWithRecoveryKey,
21
+ type IReadKeys,
22
+ } from '../index.js';
23
+ import {
24
+ FIXTURE_PASSPHRASE_AS_TYPED,
25
+ FIXTURE_RECOVERY_KEY,
26
+ KNOWN_SEALING_PUBLIC_KEY_HEX,
27
+ codeOf,
28
+ hex,
29
+ reachableValues,
30
+ } from './helpers.js';
31
+
32
+ const fixture = parseKeyring(
33
+ readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
34
+ );
35
+
36
+ function keyTypes(keys: IReadKeys): string[] {
37
+ return reachableValues(keys)
38
+ .filter((value): value is KeyObject => value instanceof KeyObject)
39
+ .map((key) => `${key.type}:${key.asymmetricKeyType ?? 'secret'}`)
40
+ .sort();
41
+ }
42
+
43
+ describe('read keys', () => {
44
+ it('are what every unlock yields, with the sealing keypair and no attach keypair', async () => {
45
+ const created = await createKeyring({ passphrase: 'a passphrase for this test' });
46
+ for (const keys of [
47
+ await unlockWithPassphrase(fixture, FIXTURE_PASSPHRASE_AS_TYPED),
48
+ await unlockWithRecoveryKey(fixture, FIXTURE_RECOVERY_KEY),
49
+ created.unlocked,
50
+ ]) {
51
+ expect('attach' in keys).toBe(false);
52
+ expect(keyTypes(keys)).toEqual(['private:x25519']);
53
+ keys.lock();
54
+ }
55
+ });
56
+
57
+ it('carry what the sealing-key check and repair need, and stop doing so once locked', async () => {
58
+ const keys = await unlockWithRecoveryKey(fixture, FIXTURE_RECOVERY_KEY);
59
+ const swapped = { ...fixture, sealingPublicKey: 'A'.repeat(42) + 'E' };
60
+ expect(checkSealingPublicKey(swapped, keys)).toEqual({ matches: false });
61
+ const repaired = repairSealingPublicKey(swapped, keys);
62
+ expect(hex(getSealingPublicKey(repaired))).toBe(KNOWN_SEALING_PUBLIC_KEY_HEX);
63
+ keys.lock();
64
+ expect(codeOf(() => checkSealingPublicKey(fixture, keys))).toBe('keys-locked');
65
+ expect(codeOf(() => repairSealingPublicKey(swapped, keys))).toBe('keys-locked');
66
+ });
67
+ });
@@ -0,0 +1,76 @@
1
+ import { randomBytes } from 'node:crypto';
2
+
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ import { formatRecoveryKey, parseRecoveryKey, RECOVERY_KEY_BYTES } from '../recovery-key.js';
6
+ import {
7
+ bytesOf,
8
+ codeOf,
9
+ FIXTURE_RECOVERY_KEY,
10
+ FIXTURE_RECOVERY_KEY_BYTES,
11
+ hex,
12
+ } from './helpers.js';
13
+
14
+ describe('recovery key format', () => {
15
+ it('formats known bytes as grouped Crockford base32', () => {
16
+ expect(formatRecoveryKey(FIXTURE_RECOVERY_KEY_BYTES)).toBe(FIXTURE_RECOVERY_KEY);
17
+ expect(formatRecoveryKey(new Uint8Array(RECOVERY_KEY_BYTES))).toBe(
18
+ '0000-0000-0000-0000-0000-0000-0000-0000',
19
+ );
20
+ expect(formatRecoveryKey(new Uint8Array(RECOVERY_KEY_BYTES).fill(0xff))).toBe(
21
+ 'ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ',
22
+ );
23
+ });
24
+
25
+ it('round-trips random keys', () => {
26
+ for (let index = 0; index < 200; index += 1) {
27
+ const bytes = randomBytes(RECOVERY_KEY_BYTES);
28
+ const text = formatRecoveryKey(bytes);
29
+ expect(text).toMatch(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){7}$/);
30
+ expect(hex(parseRecoveryKey(text))).toBe(hex(bytes));
31
+ }
32
+ });
33
+
34
+ it('reads the key the way a person types it', () => {
35
+ const expected = hex(FIXTURE_RECOVERY_KEY_BYTES);
36
+ const lower = FIXTURE_RECOVERY_KEY.toLowerCase();
37
+ expect(hex(parseRecoveryKey(lower))).toBe(expected);
38
+ expect(hex(parseRecoveryKey(FIXTURE_RECOVERY_KEY.replaceAll('-', '')))).toBe(expected);
39
+ expect(hex(parseRecoveryKey(FIXTURE_RECOVERY_KEY.replaceAll('-', ' ')))).toBe(expected);
40
+ expect(hex(parseRecoveryKey(` ${FIXTURE_RECOVERY_KEY}\n`))).toBe(expected);
41
+ // Crockford look-alikes: O reads as 0, I and L read as 1.
42
+ expect(
43
+ hex(parseRecoveryKey('0000-0000-0000-0000-0000-0000-0000-0000'.replaceAll('0', 'o'))),
44
+ ).toBe('00'.repeat(RECOVERY_KEY_BYTES));
45
+ expect(
46
+ hex(parseRecoveryKey('1111-1111-1111-1111-1111-1111-1111-1111'.replaceAll('1', 'L'))),
47
+ ).toBe(hex(parseRecoveryKey('1111-1111-1111-1111-1111-1111-1111-1111')));
48
+ expect(
49
+ hex(parseRecoveryKey('1111-1111-1111-1111-1111-1111-1111-1111'.replaceAll('1', 'i'))),
50
+ ).toBe(hex(parseRecoveryKey('1111-1111-1111-1111-1111-1111-1111-1111')));
51
+ });
52
+
53
+ it('rejects text that is not a recovery key with invalid-recovery-key', () => {
54
+ const wrong = [
55
+ '',
56
+ FIXTURE_RECOVERY_KEY.slice(0, -1),
57
+ `${FIXTURE_RECOVERY_KEY}0`,
58
+ FIXTURE_RECOVERY_KEY.replace('M', 'U'), // U is not in the Crockford alphabet
59
+ FIXTURE_RECOVERY_KEY.replace('M', '*'),
60
+ FIXTURE_RECOVERY_KEY.replace('M', 'ß'), // would fold to "SS" under toUpperCase
61
+ FIXTURE_RECOVERY_KEY.replace('M', 'ı'), // would fold to "I"
62
+ 'x'.repeat(10_000),
63
+ ];
64
+ for (const text of wrong)
65
+ expect(codeOf(() => parseRecoveryKey(text))).toBe('invalid-recovery-key');
66
+ expect(codeOf(() => parseRecoveryKey(undefined))).toBe('invalid-recovery-key');
67
+ expect(codeOf(() => parseRecoveryKey(bytesOf(FIXTURE_RECOVERY_KEY)))).toBe(
68
+ 'invalid-recovery-key',
69
+ );
70
+ });
71
+
72
+ it('refuses to format anything but 20 bytes', () => {
73
+ expect(codeOf(() => formatRecoveryKey(new Uint8Array(19)))).toBe('invalid-argument');
74
+ expect(codeOf(() => formatRecoveryKey(new Uint8Array(21)))).toBe('invalid-argument');
75
+ });
76
+ });