@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,329 @@
1
+ import { Buffer } from 'node:buffer';
2
+
3
+ import { beforeAll, describe, expect, it } from 'vitest';
4
+
5
+ import {
6
+ changePassphrase,
7
+ checkSealingPublicKey,
8
+ createKeyring,
9
+ getSealingPublicKey,
10
+ openBlob,
11
+ parseKeyring,
12
+ repairSealingPublicKey,
13
+ sealBlob,
14
+ serializeKeyring,
15
+ unlockWithPassphrase,
16
+ unlockWithRecoveryKey,
17
+ type ICreatedKeyring,
18
+ type IKeyring,
19
+ type IPassphraseKeyslot,
20
+ type IReadKeys,
21
+ type IRecoveryKeyslot,
22
+ openOwnerHandover,
23
+ } from '../index.js';
24
+ import { toBase64Url } from '../bytes.js';
25
+ import { deriveKeyPairs } from '../key-pairs.js';
26
+ import { bytesOf, codeOf, codeOfAsync, hex } from './helpers.js';
27
+
28
+ const PASSPHRASE = 'correct horse battery staple';
29
+
30
+ // One scrypt-backed creation shared by the tests below; each passphrase unlock costs ~160 ms.
31
+ let created: ICreatedKeyring;
32
+
33
+ beforeAll(async () => {
34
+ created = await createKeyring({ passphrase: PASSPHRASE });
35
+ });
36
+
37
+ function privateKeyHex(keys: IReadKeys): string {
38
+ return hex(keys.sealing.privateKey.export({ format: 'der', type: 'pkcs8' }));
39
+ }
40
+
41
+ function passphraseSlot(keyring: IKeyring): IPassphraseKeyslot {
42
+ return keyring.slots.find((slot) => slot.type === 'passphrase') as IPassphraseKeyslot;
43
+ }
44
+
45
+ function recoverySlot(keyring: IKeyring): IRecoveryKeyslot {
46
+ return keyring.slots.find((slot) => slot.type === 'recovery') as IRecoveryKeyslot;
47
+ }
48
+
49
+ describe('createKeyring', () => {
50
+ it('writes the documented version-1 document', () => {
51
+ const { keyring } = created;
52
+ expect(keyring.version).toBe(1);
53
+ expect(keyring.slots.map((slot) => slot.type)).toEqual(['passphrase', 'recovery']);
54
+ expect(passphraseSlot(keyring).kdf).toMatchObject({
55
+ algorithm: 'scrypt',
56
+ N: 131072,
57
+ r: 8,
58
+ p: 1,
59
+ });
60
+ expect(new Set(keyring.slots.map((slot) => slot.id)).size).toBe(2);
61
+ expect(hex(getSealingPublicKey(keyring))).toBe(hex(created.unlocked.sealing.publicKey));
62
+ });
63
+
64
+ it('shows the recovery key once, in grouped Crockford base32', () => {
65
+ expect(created.recoveryKey).toMatch(/^[0-9A-HJKMNP-TV-Z]{4}(-[0-9A-HJKMNP-TV-Z]{4}){7}$/);
66
+ expect(serializeKeyring(created.keyring)).not.toContain(created.recoveryKey);
67
+ });
68
+
69
+ it('has no place in the file for the attach public key or any secret', async () => {
70
+ const text = serializeKeyring(created.keyring);
71
+ expect(Object.keys(JSON.parse(text))).toEqual(['version', 'sealingPublicKey', 'slots']);
72
+ const handover = await openOwnerHandover(created.keyring, {
73
+ type: 'recoveryKey',
74
+ recoveryKey: created.recoveryKey,
75
+ });
76
+ const attach = Buffer.from(
77
+ handover.certify(Buffer.alloc(32, 1), { runtimeId: 'r', generation: 'g' }).attachPublicKey,
78
+ );
79
+ handover.dispose();
80
+ for (const spelling of [
81
+ attach.toString('hex'),
82
+ attach.toString('base64'),
83
+ attach.toString('base64url'),
84
+ ]) {
85
+ expect(text).not.toContain(spelling);
86
+ }
87
+ expect(text).not.toContain(PASSPHRASE);
88
+ expect(
89
+ codeOf(() => parseKeyring(text.replace('"slots"', '"attachPublicKey": "x", "slots"'))),
90
+ ).toBe('malformed-keyring');
91
+ });
92
+
93
+ it('returns a document that survives a trip through serialization', () => {
94
+ expect(parseKeyring(serializeKeyring(created.keyring))).toEqual(created.keyring);
95
+ });
96
+
97
+ it('refuses an empty passphrase before doing any work, and accepts anything else', async () => {
98
+ expect(await codeOfAsync(createKeyring({ passphrase: '' }))).toBe('empty-passphrase');
99
+ expect(await codeOfAsync(createKeyring({ passphrase: undefined as never }))).toBe(
100
+ 'invalid-argument',
101
+ );
102
+ });
103
+ });
104
+
105
+ describe('unlocking', () => {
106
+ it('opens with the passphrase and yields the keys that were derived at creation', async () => {
107
+ const unlocked = await unlockWithPassphrase(created.keyring, PASSPHRASE);
108
+ expect(unlocked.locked).toBe(false);
109
+ expect(hex(unlocked.sealing.publicKey)).toBe(hex(created.unlocked.sealing.publicKey));
110
+ expect(privateKeyHex(unlocked)).toBe(privateKeyHex(created.unlocked));
111
+ });
112
+
113
+ it('keeps the event loop running while scrypt works', async () => {
114
+ // scryptSync would hold the loop for the whole ~160 ms; the async form lets timers fire.
115
+ let ticks = 0;
116
+ const timer = setInterval(() => {
117
+ ticks += 1;
118
+ }, 5);
119
+ try {
120
+ await unlockWithPassphrase(created.keyring, PASSPHRASE);
121
+ } finally {
122
+ clearInterval(timer);
123
+ }
124
+ expect(ticks).toBeGreaterThan(5);
125
+ });
126
+
127
+ it('opens with the recovery key however it is typed', async () => {
128
+ const { keyring, recoveryKey } = created;
129
+ for (const typed of [
130
+ recoveryKey,
131
+ recoveryKey.toLowerCase(),
132
+ recoveryKey.replaceAll('-', ''),
133
+ ` ${recoveryKey.replaceAll('-', ' ')} `,
134
+ ]) {
135
+ const unlocked = await unlockWithRecoveryKey(keyring, typed);
136
+ expect(privateKeyHex(unlocked)).toBe(privateKeyHex(created.unlocked));
137
+ }
138
+ });
139
+
140
+ it('refuses a wrong passphrase with wrong-credential', async () => {
141
+ expect(await codeOfAsync(unlockWithPassphrase(created.keyring, `${PASSPHRASE}!`))).toBe(
142
+ 'wrong-credential',
143
+ );
144
+ });
145
+
146
+ it('refuses a wrong recovery key with wrong-credential, and a malformed one differently', async () => {
147
+ const wrong = created.recoveryKey.replace(/^./, (first) => (first === '0' ? '1' : '0'));
148
+ expect(await codeOfAsync(unlockWithRecoveryKey(created.keyring, wrong))).toBe(
149
+ 'wrong-credential',
150
+ );
151
+ expect(await codeOfAsync(unlockWithRecoveryKey(created.keyring, 'not a recovery key'))).toBe(
152
+ 'invalid-recovery-key',
153
+ );
154
+ });
155
+
156
+ it('refuses an empty or non-string passphrase before deriving anything', async () => {
157
+ expect(await codeOfAsync(unlockWithPassphrase(created.keyring, ''))).toBe('empty-passphrase');
158
+ expect(await codeOfAsync(unlockWithPassphrase(created.keyring, 42 as never))).toBe(
159
+ 'invalid-argument',
160
+ );
161
+ });
162
+
163
+ it('refuses a tampered wrapped key, IV or slot id on the recovery slot', async () => {
164
+ const slot = recoverySlot(created.keyring);
165
+ const swapped = (patch: Partial<IRecoveryKeyslot>): IKeyring => ({
166
+ ...created.keyring,
167
+ slots: created.keyring.slots.map((existing) =>
168
+ existing.id === slot.id ? { ...slot, ...patch } : existing,
169
+ ),
170
+ });
171
+ const flipFirst = (encoded: string): string => {
172
+ const bytes = Buffer.from(encoded, 'base64url');
173
+ bytes[0] ^= 1;
174
+ return bytes.toString('base64url');
175
+ };
176
+ for (const patch of [
177
+ { wrappedKey: flipFirst(slot.wrappedKey) },
178
+ { iv: flipFirst(slot.iv) },
179
+ { id: toBase64Url(Buffer.alloc(16, 9)) },
180
+ ]) {
181
+ expect(await codeOfAsync(unlockWithRecoveryKey(swapped(patch), created.recoveryKey))).toBe(
182
+ 'wrong-credential',
183
+ );
184
+ }
185
+ });
186
+
187
+ it('validates a hand-built keyring as strictly as a parsed one', async () => {
188
+ const withExtra = { ...created.keyring, attachPublicKey: 'x' } as unknown as IKeyring;
189
+ expect(await codeOfAsync(unlockWithRecoveryKey(withExtra, created.recoveryKey))).toBe(
190
+ 'malformed-keyring',
191
+ );
192
+ expect(
193
+ await codeOfAsync(
194
+ unlockWithRecoveryKey({ ...created.keyring, version: 2 } as never, created.recoveryKey),
195
+ ),
196
+ ).toBe('unsupported-version');
197
+ });
198
+
199
+ it('reports a keyring without the needed slot type', async () => {
200
+ const onlyRecovery: IKeyring = { ...created.keyring, slots: [recoverySlot(created.keyring)] };
201
+ const onlyPassphrase: IKeyring = {
202
+ ...created.keyring,
203
+ slots: [passphraseSlot(created.keyring)],
204
+ };
205
+ expect(await codeOfAsync(unlockWithPassphrase(onlyRecovery, PASSPHRASE))).toBe(
206
+ 'slot-not-found',
207
+ );
208
+ expect(await codeOfAsync(unlockWithRecoveryKey(onlyPassphrase, created.recoveryKey))).toBe(
209
+ 'slot-not-found',
210
+ );
211
+ });
212
+ });
213
+
214
+ describe('a swapped sealing public key', () => {
215
+ const attackerKey = deriveKeyPairs(Buffer.alloc(32, 0xee)).sealing.publicKey;
216
+
217
+ function tampered(): IKeyring {
218
+ return { ...created.keyring, sealingPublicKey: toBase64Url(attackerKey) };
219
+ }
220
+
221
+ it('does not stop the keyring from unlocking, and is reported by the check', async () => {
222
+ const unlocked = await unlockWithRecoveryKey(tampered(), created.recoveryKey);
223
+ expect(privateKeyHex(unlocked)).toBe(privateKeyHex(created.unlocked));
224
+ expect(checkSealingPublicKey(tampered(), unlocked)).toEqual({ matches: false });
225
+ expect(checkSealingPublicKey(created.keyring, unlocked)).toEqual({ matches: true });
226
+ });
227
+
228
+ it('can be repaired from the unlocked keys, after which sealing reaches the owner again', async () => {
229
+ const unlocked = await unlockWithRecoveryKey(tampered(), created.recoveryKey);
230
+ const repaired = repairSealingPublicKey(tampered(), unlocked);
231
+ expect(checkSealingPublicKey(repaired, unlocked)).toEqual({ matches: true });
232
+ expect(repaired.slots).toEqual(created.keyring.slots);
233
+ const blob = sealBlob(getSealingPublicKey(repaired), bytesOf('hello'), bytesOf('a'));
234
+ expect(openBlob(unlocked.sealing.privateKey, blob, bytesOf('a')).toString()).toBe('hello');
235
+ });
236
+ });
237
+
238
+ describe('changePassphrase', () => {
239
+ const NEW_PASSPHRASE = 'a different passphrase entirely';
240
+ let changed: IKeyring;
241
+
242
+ beforeAll(async () => {
243
+ // The recovery key is the credential here, so the only scrypt run is the new slot's.
244
+ changed = await changePassphrase(
245
+ created.keyring,
246
+ { type: 'recoveryKey', recoveryKey: created.recoveryKey },
247
+ NEW_PASSPHRASE,
248
+ );
249
+ });
250
+
251
+ it('re-wraps only the passphrase slot', () => {
252
+ const before = created.keyring;
253
+ expect(changed.sealingPublicKey).toBe(before.sealingPublicKey);
254
+ expect(recoverySlot(changed)).toEqual(recoverySlot(before));
255
+ const oldSlot = passphraseSlot(before);
256
+ const newSlot = passphraseSlot(changed);
257
+ expect(newSlot.id).toBe(oldSlot.id);
258
+ expect(newSlot.kdf.salt).not.toBe(oldSlot.kdf.salt);
259
+ expect(newSlot.iv).not.toBe(oldSlot.iv);
260
+ expect(newSlot.wrappedKey).not.toBe(oldSlot.wrappedKey);
261
+ expect(changed.slots.map((slot) => slot.type)).toEqual(['passphrase', 'recovery']);
262
+ });
263
+
264
+ it('leaves the master key, and so everything sealed under it, unchanged', async () => {
265
+ const blob = sealBlob(getSealingPublicKey(created.keyring), bytesOf('kept'), bytesOf('a'));
266
+ const unlocked = await unlockWithPassphrase(changed, NEW_PASSPHRASE);
267
+ expect(privateKeyHex(unlocked)).toBe(privateKeyHex(created.unlocked));
268
+ expect(openBlob(unlocked.sealing.privateKey, blob, bytesOf('a')).toString()).toBe('kept');
269
+ });
270
+
271
+ it('stops the old passphrase from working on the new keyring, and does not touch the input', async () => {
272
+ expect(await codeOfAsync(unlockWithPassphrase(changed, PASSPHRASE))).toBe('wrong-credential');
273
+ expect(created.keyring.slots.map((slot) => slot.id)).toEqual(
274
+ changed.slots.map((slot) => slot.id),
275
+ );
276
+ expect(passphraseSlot(created.keyring).kdf.salt).not.toBe(passphraseSlot(changed).kdf.salt);
277
+ });
278
+
279
+ it('accepts the current passphrase as the credential', async () => {
280
+ const again = await changePassphrase(
281
+ changed,
282
+ { type: 'passphrase', passphrase: NEW_PASSPHRASE },
283
+ 'third passphrase',
284
+ );
285
+ expect(passphraseSlot(again).id).toBe(passphraseSlot(changed).id);
286
+ expect(recoverySlot(again)).toEqual(recoverySlot(changed));
287
+ });
288
+
289
+ it('refuses without a working credential, and an empty new passphrase before any work', async () => {
290
+ const wrongRecovery = created.recoveryKey.replace(/^./, (first) => (first === '0' ? '1' : '0'));
291
+ expect(
292
+ await codeOfAsync(
293
+ changePassphrase(created.keyring, { type: 'recoveryKey', recoveryKey: wrongRecovery }, 'x'),
294
+ ),
295
+ ).toBe('wrong-credential');
296
+ expect(
297
+ await codeOfAsync(
298
+ changePassphrase(
299
+ created.keyring,
300
+ { type: 'recoveryKey', recoveryKey: created.recoveryKey },
301
+ '',
302
+ ),
303
+ ),
304
+ ).toBe('empty-passphrase');
305
+ expect(
306
+ await codeOfAsync(changePassphrase(created.keyring, { type: 'other' } as never, 'x')),
307
+ ).toBe('invalid-argument');
308
+ });
309
+ });
310
+
311
+ describe('lock', () => {
312
+ it('stops handing out keys', async () => {
313
+ const unlocked = await unlockWithRecoveryKey(created.keyring, created.recoveryKey);
314
+ expect(unlocked.locked).toBe(false);
315
+ unlocked.lock();
316
+ expect(unlocked.locked).toBe(true);
317
+ expect(codeOf(() => unlocked.sealing)).toBe('keys-locked');
318
+ expect(codeOf(() => checkSealingPublicKey(created.keyring, unlocked))).toBe('keys-locked');
319
+ unlocked.lock();
320
+ expect(unlocked.locked).toBe(true);
321
+ });
322
+
323
+ it('does not affect another unlocked set from the same keyring', async () => {
324
+ const first = await unlockWithRecoveryKey(created.keyring, created.recoveryKey);
325
+ const second = await unlockWithRecoveryKey(created.keyring, created.recoveryKey);
326
+ first.lock();
327
+ expect(hex(second.sealing.publicKey)).toBe(hex(created.unlocked.sealing.publicKey));
328
+ });
329
+ });
@@ -0,0 +1,98 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { randomBytes } from 'node:crypto';
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { gcmDecrypt, GCM_IV_BYTES } from '../aead.js';
7
+ import { concatLengthPrefixed, fromBase64Url, toBase64Url, utf8 } from '../bytes.js';
8
+ import type { IPassphraseKeyslot, IRecoveryKeyslot } from '../keyring-types.js';
9
+ import {
10
+ deriveRecoveryKek,
11
+ keyslotAad,
12
+ newSlotId,
13
+ unwrapMasterKey,
14
+ wrapMasterKey,
15
+ } from '../keyslot.js';
16
+ import { codeOf, FIXED_MASTER_KEY, hex, tamper } from './helpers.js';
17
+
18
+ const kek = randomBytes(32);
19
+
20
+ function recoverySlotFor(masterKey: Uint8Array, slotId = newSlotId()): IRecoveryKeyslot {
21
+ return { id: slotId, type: 'recovery', ...wrapMasterKey(kek, masterKey, slotId, 'recovery') };
22
+ }
23
+
24
+ describe('keyslot wrapping', () => {
25
+ it('wraps and unwraps the master key', () => {
26
+ const slot = recoverySlotFor(FIXED_MASTER_KEY);
27
+ expect(fromBase64Url(slot.iv, GCM_IV_BYTES)).toBeDefined();
28
+ expect(fromBase64Url(slot.wrappedKey, 48)).toBeDefined();
29
+ expect(hex(unwrapMasterKey(kek, slot))).toBe(hex(FIXED_MASTER_KEY));
30
+ });
31
+
32
+ it('uses a fresh IV for every wrap, so one master key never wraps to the same bytes', () => {
33
+ const ivs = new Set<string>();
34
+ const wrapped = new Set<string>();
35
+ for (let index = 0; index < 100; index += 1) {
36
+ const slot = recoverySlotFor(FIXED_MASTER_KEY, 'AAAAAAAAAAAAAAAAAAAAAA');
37
+ ivs.add(slot.iv);
38
+ wrapped.add(slot.wrappedKey);
39
+ }
40
+ expect(ivs.size).toBe(100);
41
+ expect(wrapped.size).toBe(100);
42
+ });
43
+
44
+ it('fails under a different key-encryption key', () => {
45
+ const slot = recoverySlotFor(FIXED_MASTER_KEY);
46
+ expect(codeOf(() => unwrapMasterKey(randomBytes(32), slot))).toBe('wrong-credential');
47
+ });
48
+
49
+ it('binds the slot id', () => {
50
+ const slot = recoverySlotFor(FIXED_MASTER_KEY);
51
+ expect(codeOf(() => unwrapMasterKey(kek, { ...slot, id: newSlotId() }))).toBe(
52
+ 'wrong-credential',
53
+ );
54
+ });
55
+
56
+ it('binds the slot type', () => {
57
+ const slot = recoverySlotFor(FIXED_MASTER_KEY);
58
+ const relabelled: IPassphraseKeyslot = {
59
+ ...slot,
60
+ type: 'passphrase',
61
+ kdf: { algorithm: 'scrypt', N: 2 ** 17, r: 8, p: 1, salt: toBase64Url(randomBytes(16)) },
62
+ };
63
+ expect(codeOf(() => unwrapMasterKey(kek, relabelled))).toBe('wrong-credential');
64
+ });
65
+
66
+ it('binds the keyring version, but not the sealing public key', () => {
67
+ const slot = recoverySlotFor(FIXED_MASTER_KEY);
68
+ const iv = fromBase64Url(slot.iv, GCM_IV_BYTES) as Buffer;
69
+ const wrapped = fromBase64Url(slot.wrappedKey, 48) as Buffer;
70
+ const aadFor = (version: number) =>
71
+ concatLengthPrefixed([Buffer.from([version]), utf8(slot.id), utf8(slot.type)]);
72
+ expect(hex(gcmDecrypt(kek, iv, wrapped, aadFor(1)))).toBe(hex(FIXED_MASTER_KEY));
73
+ expect(() => gcmDecrypt(kek, iv, wrapped, aadFor(2))).toThrow();
74
+ // The AAD is exactly version | slot id | slot type, so no public key can be part of it.
75
+ expect(hex(keyslotAad(slot.id, slot.type))).toBe(hex(aadFor(1)));
76
+ });
77
+
78
+ it('fails on a change to any byte of the IV or the wrapped key', () => {
79
+ const slot = recoverySlotFor(FIXED_MASTER_KEY);
80
+ const iv = fromBase64Url(slot.iv, GCM_IV_BYTES) as Buffer;
81
+ const wrapped = fromBase64Url(slot.wrappedKey, 48) as Buffer;
82
+ for (let index = 0; index < iv.length; index += 1) {
83
+ const changed = { ...slot, iv: toBase64Url(tamper(iv, index)) };
84
+ expect(codeOf(() => unwrapMasterKey(kek, changed))).toBe('wrong-credential');
85
+ }
86
+ for (let index = 0; index < wrapped.length; index += 1) {
87
+ const changed = { ...slot, wrappedKey: toBase64Url(tamper(wrapped, index)) };
88
+ expect(codeOf(() => unwrapMasterKey(kek, changed))).toBe('wrong-credential');
89
+ }
90
+ });
91
+
92
+ it('derives the recovery key-encryption key deterministically and independently of the key', () => {
93
+ const first = deriveRecoveryKek(Buffer.alloc(20, 1));
94
+ expect(hex(first)).toBe(hex(deriveRecoveryKek(Buffer.alloc(20, 1))));
95
+ expect(hex(first)).not.toBe(hex(deriveRecoveryKek(Buffer.alloc(20, 2))));
96
+ expect(first).toHaveLength(32);
97
+ });
98
+ });
@@ -0,0 +1,110 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import {
7
+ checkSealingPublicKey,
8
+ fingerprint,
9
+ getSealingPublicKey,
10
+ openOwnerHandover,
11
+ parseKeyring,
12
+ serializeKeyring,
13
+ unlockWithPassphrase,
14
+ unlockWithRecoveryKey,
15
+ } from '../index.js';
16
+ import { AUTHORSHIP_KEY_LABEL, authorshipTag, deriveAuthorshipKey } from '../authorship.js';
17
+ import { ATTACH_KEY_LABEL, deriveKeyPairs, SEALING_KEY_LABEL } from '../key-pairs.js';
18
+ import { ARTIFACT_KEY_WRAP_LABEL } from '../sealing.js';
19
+ import { RECOVERY_KEK_LABEL } from '../keyslot.js';
20
+ import {
21
+ FIXED_MASTER_KEY,
22
+ FIXTURE_PASSPHRASE_AS_TYPED,
23
+ FIXTURE_RECOVERY_KEY,
24
+ bytesOf,
25
+ hex,
26
+ KNOWN_ATTACH_PUBLIC_KEY_HEX,
27
+ KNOWN_AUTHORSHIP_KEY_HEX,
28
+ KNOWN_AUTHORSHIP_MESSAGE,
29
+ KNOWN_AUTHORSHIP_TAG_HEX,
30
+ KNOWN_SEALING_PUBLIC_KEY_HEX,
31
+ } from './helpers.js';
32
+
33
+ const fixtureText = readFileSync(
34
+ fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)),
35
+ 'utf8',
36
+ );
37
+
38
+ describe('known answers', () => {
39
+ it('pins the HKDF labels', () => {
40
+ expect(SEALING_KEY_LABEL).toBe('robota/keyring/v1/sealing-x25519');
41
+ expect(ATTACH_KEY_LABEL).toBe('robota/keyring/v1/attach-ed25519');
42
+ expect(RECOVERY_KEK_LABEL).toBe('robota/keyring/v1/recovery-kek');
43
+ expect(ARTIFACT_KEY_WRAP_LABEL).toBe('robota/keyring/v1/dek-wrap');
44
+ expect(AUTHORSHIP_KEY_LABEL).toBe('robota/keyring/v1/authorship-hmac');
45
+ });
46
+
47
+ it('derives a fixed authorship key, and it tags a fixed message to a fixed HMAC-SHA256', () => {
48
+ const key = deriveAuthorshipKey(FIXED_MASTER_KEY);
49
+ expect(hex(key)).toBe(KNOWN_AUTHORSHIP_KEY_HEX);
50
+ expect(hex(authorshipTag(key, bytesOf(KNOWN_AUTHORSHIP_MESSAGE)))).toBe(
51
+ KNOWN_AUTHORSHIP_TAG_HEX,
52
+ );
53
+ });
54
+
55
+ it('derives fixed public keys from a fixed master key', () => {
56
+ const pairs = deriveKeyPairs(FIXED_MASTER_KEY);
57
+ expect(hex(pairs.sealing.publicKey)).toBe(KNOWN_SEALING_PUBLIC_KEY_HEX);
58
+ expect(hex(pairs.attach.publicKey)).toBe(KNOWN_ATTACH_PUBLIC_KEY_HEX);
59
+ expect(pairs.sealing.privateKey.asymmetricKeyType).toBe('x25519');
60
+ expect(pairs.attach.privateKey.asymmetricKeyType).toBe('ed25519');
61
+ });
62
+
63
+ it('fingerprints a public key as 80 bits in groups of four', () => {
64
+ const pairs = deriveKeyPairs(FIXED_MASTER_KEY);
65
+ expect(fingerprint(pairs.sealing.publicKey)).toBe('3A2E-D129-B98E-02D9-DF09');
66
+ expect(fingerprint(pairs.attach.publicKey)).toBe('1F1F-6AD4-E8A7-0A15-4249');
67
+ });
68
+ });
69
+
70
+ describe('the checked-in version-1 keyring', () => {
71
+ const keyring = parseKeyring(fixtureText);
72
+
73
+ it('still serializes byte for byte as it is stored', () => {
74
+ expect(serializeKeyring(keyring)).toBe(fixtureText.trimEnd());
75
+ });
76
+
77
+ it('stores the sealing public key the master key derives', () => {
78
+ expect(hex(getSealingPublicKey(keyring))).toBe(KNOWN_SEALING_PUBLIC_KEY_HEX);
79
+ });
80
+
81
+ it('still unlocks with the passphrase, as typed in a different Unicode form', async () => {
82
+ const unlocked = await unlockWithPassphrase(keyring, FIXTURE_PASSPHRASE_AS_TYPED);
83
+ expect(hex(unlocked.sealing.publicKey)).toBe(KNOWN_SEALING_PUBLIC_KEY_HEX);
84
+ expect(checkSealingPublicKey(keyring, unlocked)).toEqual({ matches: true });
85
+ });
86
+
87
+ it('still unlocks with the recovery key', async () => {
88
+ const unlocked = await unlockWithRecoveryKey(keyring, FIXTURE_RECOVERY_KEY);
89
+ expect(hex(unlocked.sealing.publicKey)).toBe(KNOWN_SEALING_PUBLIC_KEY_HEX);
90
+ });
91
+
92
+ it('still opens an owner handover holding the pinned attach key, with either credential', async () => {
93
+ for (const credential of [
94
+ { type: 'passphrase', passphrase: FIXTURE_PASSPHRASE_AS_TYPED },
95
+ { type: 'recoveryKey', recoveryKey: FIXTURE_RECOVERY_KEY },
96
+ ] as const) {
97
+ const handover = await openOwnerHandover(keyring, credential);
98
+ const root = handover.certify(new Uint8Array(32), { runtimeId: 'r', generation: 'g' });
99
+ expect(hex(root.attachPublicKey)).toBe(KNOWN_ATTACH_PUBLIC_KEY_HEX);
100
+ handover.dispose();
101
+ }
102
+ });
103
+
104
+ it('yields the authorship key of its master key once unlocked', async () => {
105
+ const unlocked = await unlockWithRecoveryKey(keyring, FIXTURE_RECOVERY_KEY);
106
+ expect(hex(unlocked.tagAuthorship(bytesOf(KNOWN_AUTHORSHIP_MESSAGE)))).toBe(
107
+ KNOWN_AUTHORSHIP_TAG_HEX,
108
+ );
109
+ });
110
+ });