@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,712 @@
1
+ /**
2
+ * The owner link against the programs it exists to refuse: a squatter on the control socket, a
3
+ * runtime nobody certified or another keyring certified, a stolen certificate, a rewritten mode, a
4
+ * replayed or spliced message and tampered frames. Challenges are built here by hand from the
5
+ * protocol description (a positive control shows the builder is right when it holds every secret),
6
+ * so each refusal is the one missing secret. In every case the initiator has sent only its hello,
7
+ * which is checked to be nothing but fresh randomness, and it can never be made to send more.
8
+ */
9
+ import { Buffer } from 'node:buffer';
10
+ import {
11
+ createCipheriv,
12
+ createHash,
13
+ createHmac,
14
+ createPrivateKey,
15
+ createPublicKey,
16
+ randomBytes,
17
+ sign,
18
+ } from 'node:crypto';
19
+ import { readFileSync } from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
23
+
24
+ import {
25
+ createKeyring,
26
+ createLinkResponder,
27
+ openOwnerHandover,
28
+ parseKeyring,
29
+ type ILinkBinding,
30
+ type IOwnerHandover,
31
+ type IRuntimeIdentity,
32
+ type SpawnerKey,
33
+ type TLinkInitiatorAuth,
34
+ type TLinkRefusalReason,
35
+ } from '../index.js';
36
+ import { createLinkInitiatorWith } from '../owner-link.js';
37
+ import { createRuntimeIdentityWith } from '../runtime-identity.js';
38
+ import { spawnerKeyBytes } from '../spawner-exchange.js';
39
+ import {
40
+ FIXTURE_RECOVERY_KEY,
41
+ KAT_BINDING,
42
+ bytesOf,
43
+ codeOf,
44
+ hex,
45
+ lengthPrefixed,
46
+ tamper,
47
+ } from './helpers.js';
48
+ import {
49
+ KAT_OWNER_LINK,
50
+ certifiedRuntime,
51
+ fixedEntropy,
52
+ katOwnerLink,
53
+ refusalOf,
54
+ spawnedRuntime,
55
+ } from './link-helpers.js';
56
+
57
+ const fixture = parseKeyring(
58
+ readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
59
+ );
60
+ let owner: IOwnerHandover;
61
+ let stranger: IOwnerHandover;
62
+
63
+ beforeAll(async () => {
64
+ owner = await openOwnerHandover(fixture, {
65
+ type: 'recoveryKey',
66
+ recoveryKey: FIXTURE_RECOVERY_KEY,
67
+ });
68
+ const other = await createKeyring({ passphrase: 'a keyring another program made' });
69
+ stranger = await openOwnerHandover(other.keyring, {
70
+ type: 'recoveryKey',
71
+ recoveryKey: other.recoveryKey,
72
+ });
73
+ });
74
+
75
+ afterAll(() => {
76
+ owner.dispose();
77
+ stranger.dispose();
78
+ });
79
+
80
+ // ── The protocol, written out here from its description ─────────────────────────────────────────
81
+
82
+ const MODE_BYTE = { spawner: 1, owner: 2 } as const;
83
+ type TMode = keyof typeof MODE_BYTE;
84
+
85
+ function edPrivate(seed: Uint8Array) {
86
+ return createPrivateKey({
87
+ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]),
88
+ format: 'der',
89
+ type: 'pkcs8',
90
+ });
91
+ }
92
+ function rawPublic(seed: Uint8Array, prefix: string): Buffer {
93
+ const key = createPrivateKey({
94
+ key: Buffer.concat([Buffer.from(prefix, 'hex'), seed]),
95
+ format: 'der',
96
+ type: 'pkcs8',
97
+ });
98
+ return createPublicKey(key).export({ format: 'der', type: 'spki' }).subarray(12);
99
+ }
100
+ const edPublic = (seed: Uint8Array): Buffer => rawPublic(seed, '302e020100300506032b657004220420');
101
+ const xPublic = (seed: Uint8Array): Buffer => rawPublic(seed, '302e020100300506032b656e04220420');
102
+
103
+ function transcript(
104
+ mode: TMode,
105
+ hello: Uint8Array,
106
+ responderEphemeral: Uint8Array,
107
+ responderNonce: Uint8Array,
108
+ instance: Uint8Array,
109
+ binding: ILinkBinding,
110
+ certificate: Uint8Array,
111
+ ): Buffer {
112
+ return createHash('sha256')
113
+ .update(
114
+ lengthPrefixed(
115
+ bytesOf('robota/link/v1/handshake'),
116
+ Buffer.from([MODE_BYTE[mode]]),
117
+ hello.subarray(2, 34),
118
+ hello.subarray(34, 66),
119
+ responderEphemeral,
120
+ responderNonce,
121
+ instance,
122
+ bytesOf(binding.runtimeId),
123
+ bytesOf(binding.generation),
124
+ certificate,
125
+ ),
126
+ )
127
+ .digest();
128
+ }
129
+
130
+ interface ISquatter {
131
+ /** The mode the hello claims, which is the one the squatter answers in. */
132
+ readonly mode: TMode;
133
+ /** The instance key it presents. */
134
+ readonly instance: Uint8Array;
135
+ /** The seed it signs with: the real instance's only for a positive control. */
136
+ readonly signingSeed: Uint8Array;
137
+ /** The spawner key it believes in (spawner mode). */
138
+ readonly spawnerKey?: Uint8Array;
139
+ readonly binding?: ILinkBinding;
140
+ readonly certificate?: Uint8Array;
141
+ /** The mode its transcript hash names, when it differs from the one it answers in. */
142
+ readonly transcriptMode?: TMode;
143
+ /** The ephemeral it shows, when it is not a fresh key. */
144
+ readonly responderEphemeral?: Uint8Array;
145
+ }
146
+
147
+ /** A challenge for `hello` built from whatever `squatter` holds. */
148
+ function squatterChallenge(hello: Uint8Array, squatter: ISquatter): Buffer {
149
+ const responderEphemeral = squatter.responderEphemeral ?? xPublic(randomBytes(32));
150
+ const responderNonce = randomBytes(32);
151
+ const binding = squatter.binding ?? KAT_BINDING;
152
+ const certificate = squatter.certificate ?? new Uint8Array(0);
153
+ const th = transcript(
154
+ squatter.transcriptMode ?? squatter.mode,
155
+ hello,
156
+ responderEphemeral,
157
+ responderNonce,
158
+ squatter.instance,
159
+ binding,
160
+ certificate,
161
+ );
162
+ const signature = sign(null, Buffer.concat([bytesOf('r'), th]), edPrivate(squatter.signingSeed));
163
+ const authR =
164
+ squatter.mode === 'spawner'
165
+ ? Buffer.concat([
166
+ createHmac('sha256', squatter.spawnerKey ?? randomBytes(32))
167
+ .update(Buffer.concat([bytesOf('r'), th]))
168
+ .digest(),
169
+ signature,
170
+ ])
171
+ : signature;
172
+ return Buffer.concat([
173
+ responderEphemeral,
174
+ responderNonce,
175
+ squatter.instance,
176
+ lengthPrefixed(bytesOf(binding.runtimeId), bytesOf(binding.generation), certificate),
177
+ authR,
178
+ ]);
179
+ }
180
+
181
+ /** An initiator whose hello is checked to carry only the fresh ephemeral and nonce it drew. */
182
+ function observedInitiator(auth: TLinkInitiatorAuth) {
183
+ const seed = randomBytes(32);
184
+ const nonce = randomBytes(32);
185
+ const initiator = createLinkInitiatorWith(auth, fixedEntropy([seed], [nonce]));
186
+ const hello = initiator.hello();
187
+ expect(hex(hello)).toBe(
188
+ hex(Buffer.concat([Buffer.from([1, MODE_BYTE[auth.mode]]), xPublic(seed), nonce])),
189
+ );
190
+ return { initiator, hello };
191
+ }
192
+
193
+ /** Refused with `reason`, without a proof, and spent: nothing later makes it send one. */
194
+ function expectRefused(
195
+ initiator: ReturnType<typeof observedInitiator>['initiator'],
196
+ challenge: Uint8Array,
197
+ reason: TLinkRefusalReason,
198
+ ): void {
199
+ expect(refusalOf(() => initiator.answer(challenge))).toBe(reason);
200
+ expect(refusalOf(() => initiator.answer(challenge))).toBe('out-of-order');
201
+ }
202
+
203
+ /** A spawned runtime whose instance seed this test knows, for the positive controls. */
204
+ function knownRuntime() {
205
+ const instanceSeed = randomBytes(32);
206
+ const identity = createRuntimeIdentityWith(
207
+ fixedEntropy(
208
+ [instanceSeed, randomBytes(32), randomBytes(32), randomBytes(32)],
209
+ [randomBytes(32), randomBytes(32)],
210
+ ),
211
+ );
212
+ return { instanceSeed, ...spawnedRuntime(identity) };
213
+ }
214
+
215
+ function spawnerAuth(runtime: {
216
+ readonly spawnerKey: SpawnerKey;
217
+ readonly instancePublicKey: Uint8Array;
218
+ }): TLinkInitiatorAuth {
219
+ return {
220
+ mode: 'spawner',
221
+ spawnerKey: runtime.spawnerKey,
222
+ instancePublicKey: runtime.instancePublicKey,
223
+ };
224
+ }
225
+
226
+ const OWNER_AUTH = (): TLinkInitiatorAuth => ({ mode: 'owner', handover: owner });
227
+
228
+ // ── Spawner mode ─────────────────────────────────────────────────────────────────────────────────
229
+
230
+ describe('a spawner-mode squatter', () => {
231
+ it('positive control: a challenge built here with every secret is accepted', () => {
232
+ const runtime = knownRuntime();
233
+ const { initiator, hello } = observedInitiator(spawnerAuth(runtime));
234
+ const challenge = squatterChallenge(hello, {
235
+ mode: 'spawner',
236
+ instance: runtime.instancePublicKey,
237
+ signingSeed: runtime.instanceSeed,
238
+ spawnerKey: spawnerKeyBytes(runtime.spawnerKey),
239
+ });
240
+ expect(initiator.answer(challenge).proof).toHaveLength(32);
241
+ });
242
+
243
+ it('without the spawner key is refused, whatever instance key it shows', () => {
244
+ const runtime = knownRuntime();
245
+ const ownSeed = randomBytes(32);
246
+ const first = observedInitiator(spawnerAuth(runtime));
247
+ expectRefused(
248
+ first.initiator,
249
+ squatterChallenge(first.hello, {
250
+ mode: 'spawner',
251
+ instance: edPublic(ownSeed),
252
+ signingSeed: ownSeed,
253
+ }),
254
+ 'instance-mismatch',
255
+ );
256
+ const second = observedInitiator(spawnerAuth(runtime));
257
+ expectRefused(
258
+ second.initiator,
259
+ squatterChallenge(second.hello, {
260
+ mode: 'spawner',
261
+ instance: runtime.instancePublicKey,
262
+ signingSeed: runtime.instanceSeed,
263
+ }),
264
+ 'bad-runtime-proof',
265
+ );
266
+ });
267
+
268
+ it('with the spawner key but not the instance key is refused', () => {
269
+ const runtime = knownRuntime();
270
+ const { initiator, hello } = observedInitiator(spawnerAuth(runtime));
271
+ expectRefused(
272
+ initiator,
273
+ squatterChallenge(hello, {
274
+ mode: 'spawner',
275
+ instance: runtime.instancePublicKey,
276
+ signingSeed: randomBytes(32),
277
+ spawnerKey: spawnerKeyBytes(runtime.spawnerKey),
278
+ }),
279
+ 'bad-runtime-proof',
280
+ );
281
+ });
282
+
283
+ it('is refused at the responder too: a spawner hello without the spawner key gets no challenge', () => {
284
+ const identity = createRuntimeIdentityWith(fixedEntropy([randomBytes(32)]));
285
+ const runtime = spawnedRuntime();
286
+ const { hello } = observedInitiator(spawnerAuth(runtime));
287
+ expect(refusalOf(() => createLinkResponder(identity, KAT_BINDING).challenge(hello))).toBe(
288
+ 'spawner-unavailable',
289
+ );
290
+ });
291
+ });
292
+
293
+ describe('spawner mode after installTrustRoot', () => {
294
+ it('is refused: the spawner key is gone, even for a handshake already under way', () => {
295
+ const runtime = spawnedRuntime();
296
+ const pending = observedInitiator(spawnerAuth(runtime));
297
+ const pendingResponder = createLinkResponder(runtime.identity, KAT_BINDING);
298
+ const { proof } = pending.initiator.answer(pendingResponder.challenge(pending.hello));
299
+
300
+ runtime.identity.installTrustRoot(
301
+ owner.certify(runtime.identity.instancePublicKey, KAT_BINDING),
302
+ KAT_BINDING,
303
+ );
304
+ expect(refusalOf(() => pendingResponder.accept(proof))).toBe('spawner-unavailable');
305
+ const later = observedInitiator(spawnerAuth(runtime));
306
+ expect(
307
+ refusalOf(() => createLinkResponder(runtime.identity, KAT_BINDING).challenge(later.hello)),
308
+ ).toBe('spawner-unavailable');
309
+ });
310
+ });
311
+
312
+ // ── Owner mode ───────────────────────────────────────────────────────────────────────────────────
313
+
314
+ describe('an owner-mode runtime that is not the owner’s', () => {
315
+ it('positive control: a challenge built here with the instance key and its certificate is accepted', () => {
316
+ const runtime = knownRuntime();
317
+ const { initiator, hello } = observedInitiator(OWNER_AUTH());
318
+ const challenge = squatterChallenge(hello, {
319
+ mode: 'owner',
320
+ instance: runtime.instancePublicKey,
321
+ signingSeed: runtime.instanceSeed,
322
+ certificate: owner.certify(runtime.instancePublicKey, KAT_BINDING).certificate,
323
+ });
324
+ expect(initiator.answer(challenge).proof).toHaveLength(64);
325
+ });
326
+
327
+ it('is refused when uncertified: the runtime gives an owner hello no challenge', () => {
328
+ const runtime = spawnedRuntime();
329
+ const { hello } = observedInitiator(OWNER_AUTH());
330
+ expect(
331
+ refusalOf(() => createLinkResponder(runtime.identity, KAT_BINDING).challenge(hello)),
332
+ ).toBe('not-certified');
333
+ });
334
+
335
+ it('is refused when it shows no certificate the owner made', () => {
336
+ const ownSeed = randomBytes(32);
337
+ const { initiator, hello } = observedInitiator(OWNER_AUTH());
338
+ expectRefused(
339
+ initiator,
340
+ squatterChallenge(hello, {
341
+ mode: 'owner',
342
+ instance: edPublic(ownSeed),
343
+ signingSeed: ownSeed,
344
+ certificate: randomBytes(64),
345
+ }),
346
+ 'certificate-mismatch',
347
+ );
348
+ });
349
+
350
+ it('is refused when another keyring certified it', () => {
351
+ const identity = certifiedRuntime(stranger, KAT_BINDING);
352
+ const { initiator, hello } = observedInitiator(OWNER_AUTH());
353
+ expectRefused(
354
+ initiator,
355
+ createLinkResponder(identity, KAT_BINDING).challenge(hello),
356
+ 'certificate-mismatch',
357
+ );
358
+ });
359
+
360
+ it('is refused when it shows the owner’s certificate of another instance or another start', () => {
361
+ const runtime = knownRuntime();
362
+ const stolen = owner.certify(runtime.instancePublicKey, KAT_BINDING).certificate;
363
+ const ownSeed = randomBytes(32);
364
+ const otherInstance = observedInitiator(OWNER_AUTH());
365
+ expectRefused(
366
+ otherInstance.initiator,
367
+ squatterChallenge(otherInstance.hello, {
368
+ mode: 'owner',
369
+ instance: edPublic(ownSeed),
370
+ signingSeed: ownSeed,
371
+ certificate: stolen,
372
+ }),
373
+ 'certificate-mismatch',
374
+ );
375
+ const otherStart = observedInitiator(OWNER_AUTH());
376
+ expectRefused(
377
+ otherStart.initiator,
378
+ squatterChallenge(otherStart.hello, {
379
+ mode: 'owner',
380
+ instance: runtime.instancePublicKey,
381
+ signingSeed: runtime.instanceSeed,
382
+ certificate: stolen,
383
+ binding: { ...KAT_BINDING, generation: 'a-later-start' },
384
+ }),
385
+ 'certificate-mismatch',
386
+ );
387
+ });
388
+
389
+ it('is refused when it shows a valid certificate without the instance key', () => {
390
+ const runtime = knownRuntime();
391
+ const { initiator, hello } = observedInitiator(OWNER_AUTH());
392
+ expectRefused(
393
+ initiator,
394
+ squatterChallenge(hello, {
395
+ mode: 'owner',
396
+ instance: runtime.instancePublicKey,
397
+ signingSeed: randomBytes(32),
398
+ certificate: owner.certify(runtime.instancePublicKey, KAT_BINDING).certificate,
399
+ }),
400
+ 'bad-runtime-proof',
401
+ );
402
+ });
403
+
404
+ it('is refused by the runtime itself for a binding it was not certified for', () => {
405
+ const identity = certifiedRuntime(owner, KAT_BINDING);
406
+ const { hello } = observedInitiator(OWNER_AUTH());
407
+ expect(
408
+ refusalOf(() =>
409
+ createLinkResponder(identity, { ...KAT_BINDING, generation: 'other' }).challenge(hello),
410
+ ),
411
+ ).toBe('not-certified');
412
+ });
413
+ });
414
+
415
+ describe('a rewritten mode', () => {
416
+ it('owner to spawner: the spawner-shaped answer is refused before any proof', () => {
417
+ const runtime = spawnedRuntime();
418
+ const { initiator, hello } = observedInitiator(OWNER_AUTH());
419
+ const rewritten = Buffer.from(hello);
420
+ rewritten[1] = MODE_BYTE.spawner;
421
+ expectRefused(
422
+ initiator,
423
+ createLinkResponder(runtime.identity, KAT_BINDING).challenge(rewritten),
424
+ 'malformed',
425
+ );
426
+ });
427
+
428
+ it('spawner to owner: the owner-shaped answer is refused before any proof', () => {
429
+ const runtime = spawnedRuntime();
430
+ const certified = certifiedRuntime(owner, KAT_BINDING);
431
+ const { initiator, hello } = observedInitiator(spawnerAuth(runtime));
432
+ const rewritten = Buffer.from(hello);
433
+ rewritten[1] = MODE_BYTE.owner;
434
+ expectRefused(
435
+ initiator,
436
+ createLinkResponder(certified, KAT_BINDING).challenge(rewritten),
437
+ 'malformed',
438
+ );
439
+ });
440
+
441
+ it('is bound into the transcript: a well-shaped answer proving the other mode is refused', () => {
442
+ const runtime = knownRuntime();
443
+ const { initiator, hello } = observedInitiator(OWNER_AUTH());
444
+ expectRefused(
445
+ initiator,
446
+ squatterChallenge(hello, {
447
+ mode: 'owner',
448
+ transcriptMode: 'spawner',
449
+ instance: runtime.instancePublicKey,
450
+ signingSeed: runtime.instanceSeed,
451
+ certificate: owner.certify(runtime.instancePublicKey, KAT_BINDING).certificate,
452
+ }),
453
+ 'bad-runtime-proof',
454
+ );
455
+ });
456
+ });
457
+
458
+ describe('replay and splicing', () => {
459
+ function runtimes(): Array<{ auth: () => TLinkInitiatorAuth; identity: IRuntimeIdentity }> {
460
+ const spawned = spawnedRuntime();
461
+ return [
462
+ { auth: () => spawnerAuth(spawned), identity: spawned.identity },
463
+ { auth: OWNER_AUTH, identity: certifiedRuntime(owner, KAT_BINDING) },
464
+ ];
465
+ }
466
+
467
+ it('refuses a challenge replayed into another link', () => {
468
+ for (const { auth, identity } of runtimes()) {
469
+ const first = observedInitiator(auth());
470
+ const recorded = createLinkResponder(identity, KAT_BINDING).challenge(first.hello);
471
+ first.initiator.answer(recorded);
472
+ const second = observedInitiator(auth());
473
+ expectRefused(second.initiator, recorded, 'bad-runtime-proof');
474
+ }
475
+ });
476
+
477
+ it('refuses a proof replayed into another link, even for the same hello', () => {
478
+ for (const { auth, identity } of runtimes()) {
479
+ const first = observedInitiator(auth());
480
+ const { proof } = first.initiator.answer(
481
+ createLinkResponder(identity, KAT_BINDING).challenge(first.hello),
482
+ );
483
+ const replayed = createLinkResponder(identity, KAT_BINDING);
484
+ replayed.challenge(first.hello);
485
+ expect(refusalOf(() => replayed.accept(proof))).toBe('bad-proof');
486
+ }
487
+ });
488
+
489
+ it('refuses messages spliced between two links under way at once', () => {
490
+ for (const { auth, identity } of runtimes()) {
491
+ const a = observedInitiator(auth());
492
+ const b = observedInitiator(auth());
493
+ const responderA = createLinkResponder(identity, KAT_BINDING);
494
+ const responderB = createLinkResponder(identity, KAT_BINDING);
495
+ const challengeA = responderA.challenge(a.hello);
496
+ const challengeB = responderB.challenge(b.hello);
497
+ expectRefused(a.initiator, challengeB, 'bad-runtime-proof');
498
+ const { proof } = b.initiator.answer(challengeB);
499
+ expect(refusalOf(() => responderA.accept(proof))).toBe('bad-proof');
500
+ expect(challengeA).not.toEqual(challengeB);
501
+ }
502
+ });
503
+ });
504
+
505
+ // ── Malformed and oversized messages ─────────────────────────────────────────────────────────────
506
+
507
+ describe('malformed messages', () => {
508
+ it('are refused as hellos', () => {
509
+ const runtime = spawnedRuntime();
510
+ const { hello } = observedInitiator(spawnerAuth(runtime));
511
+ const withByte = (index: number, value: number): Buffer => {
512
+ const copy = Buffer.from(hello);
513
+ copy[index] = value;
514
+ return copy;
515
+ };
516
+ for (const bad of [
517
+ new Uint8Array(0),
518
+ hello.subarray(0, 65),
519
+ Buffer.concat([hello, Buffer.from([0])]),
520
+ withByte(0, 2),
521
+ withByte(0, 0),
522
+ withByte(1, 0),
523
+ withByte(1, 3),
524
+ Buffer.concat([hello.subarray(0, 2), Buffer.alloc(32), hello.subarray(34)]),
525
+ 'hello' as never,
526
+ ]) {
527
+ expect(
528
+ refusalOf(() => createLinkResponder(runtime.identity, KAT_BINDING).challenge(bad)),
529
+ ).toBe('malformed');
530
+ }
531
+ });
532
+
533
+ it('are refused as challenges, before any proof', () => {
534
+ const runtime = knownRuntime();
535
+ const good = (hello: Uint8Array, patch: Partial<ISquatter> = {}): Buffer =>
536
+ squatterChallenge(hello, {
537
+ mode: 'spawner',
538
+ instance: runtime.instancePublicKey,
539
+ signingSeed: runtime.instanceSeed,
540
+ spawnerKey: spawnerKeyBytes(runtime.spawnerKey),
541
+ ...patch,
542
+ });
543
+ const variants: Array<(hello: Uint8Array) => Uint8Array> = [
544
+ (hello) => good(hello).subarray(0, good(hello).length - 1),
545
+ (hello) => Buffer.concat([good(hello), Buffer.from([0])]),
546
+ (hello) => good(hello, { binding: { runtimeId: 'r'.repeat(129), generation: 'g' } }),
547
+ (hello) => good(hello, { binding: { runtimeId: 'a b', generation: 'g' } }),
548
+ (hello) => good(hello, { binding: { runtimeId: 'ré', generation: 'g' } }),
549
+ (hello) => good(hello, { binding: { runtimeId: 'r', generation: '' } }),
550
+ (hello) => good(hello, { certificate: randomBytes(64) }),
551
+ (hello) => {
552
+ const challenge = good(hello);
553
+ challenge.writeUInt16BE(0xffff, 96);
554
+ return challenge;
555
+ },
556
+ (hello) => good(hello, { responderEphemeral: Buffer.alloc(32) }),
557
+ () => new Uint8Array(0),
558
+ () => 'challenge' as never,
559
+ ];
560
+ for (const variant of variants) {
561
+ const { initiator, hello } = observedInitiator(spawnerAuth(runtime));
562
+ expectRefused(initiator, variant(hello), 'malformed');
563
+ }
564
+ for (const certificate of [new Uint8Array(0), randomBytes(63), randomBytes(65)]) {
565
+ const { initiator, hello } = observedInitiator(OWNER_AUTH());
566
+ const challenge = squatterChallenge(hello, {
567
+ mode: 'owner',
568
+ instance: runtime.instancePublicKey,
569
+ signingSeed: runtime.instanceSeed,
570
+ certificate,
571
+ });
572
+ expectRefused(initiator, challenge, 'malformed');
573
+ }
574
+ });
575
+
576
+ it('are refused as proofs', () => {
577
+ for (const length of [0, 31, 33]) {
578
+ const runtime = spawnedRuntime();
579
+ const { hello } = observedInitiator(spawnerAuth(runtime));
580
+ const responder = createLinkResponder(runtime.identity, KAT_BINDING);
581
+ responder.challenge(hello);
582
+ expect(refusalOf(() => responder.accept(randomBytes(length)))).toBe('malformed');
583
+ }
584
+ for (const length of [32, 63, 65]) {
585
+ const identity = certifiedRuntime(owner, KAT_BINDING);
586
+ const { hello } = observedInitiator(OWNER_AUTH());
587
+ const responder = createLinkResponder(identity, KAT_BINDING);
588
+ responder.challenge(hello);
589
+ expect(refusalOf(() => responder.accept(randomBytes(length)))).toBe('malformed');
590
+ }
591
+ });
592
+ });
593
+
594
+ // ── Frames ───────────────────────────────────────────────────────────────────────────────────────
595
+
596
+ function establishedPair() {
597
+ const identity = certifiedRuntime(owner, KAT_BINDING);
598
+ const { initiator, hello } = observedInitiator(OWNER_AUTH());
599
+ const responder = createLinkResponder(identity, KAT_BINDING);
600
+ const { proof, link } = initiator.answer(responder.challenge(hello));
601
+ return { initiatorLink: link, responderLink: responder.accept(proof).link };
602
+ }
603
+
604
+ describe('frames', () => {
605
+ it('refuse any changed byte, and the link closes', () => {
606
+ const { initiatorLink } = establishedPair();
607
+ const frame = initiatorLink.seal('session', bytesOf('{"type":"submit","text":"hi"}'));
608
+ for (let index = 0; index < frame.length; index += 1) {
609
+ const { initiatorLink: sender, responderLink } = establishedPair();
610
+ const sealed = sender.seal('session', bytesOf('{"type":"submit","text":"hi"}'));
611
+ expect(['decryption-failed', 'link-refused']).toContain(
612
+ codeOf(() => responderLink.open(tamper(sealed, index))),
613
+ );
614
+ expect(responderLink.closed).toBe(true);
615
+ expect(codeOf(() => responderLink.open(sealed))).toBe('link-closed');
616
+ }
617
+ // A changed kind byte to another valid kind is still refused: the kind is in the AAD.
618
+ const { initiatorLink: sender, responderLink } = establishedPair();
619
+ const sealed = sender.seal('session', bytesOf('x'));
620
+ const relabelled = Buffer.from(sealed);
621
+ relabelled[0] = 2;
622
+ expect(codeOf(() => responderLink.open(relabelled))).toBe('decryption-failed');
623
+ });
624
+
625
+ it('refuse a replayed frame', () => {
626
+ const { initiatorLink, responderLink } = establishedPair();
627
+ const frame = initiatorLink.seal('owner', bytesOf('once'));
628
+ responderLink.open(frame);
629
+ expect(codeOf(() => responderLink.open(frame))).toBe('decryption-failed');
630
+ expect(responderLink.closed).toBe(true);
631
+ });
632
+
633
+ it('refuse frames out of order', () => {
634
+ const { initiatorLink, responderLink } = establishedPair();
635
+ initiatorLink.seal('session', bytesOf('first'));
636
+ const second = initiatorLink.seal('session', bytesOf('second'));
637
+ expect(codeOf(() => responderLink.open(second))).toBe('decryption-failed');
638
+ });
639
+
640
+ it('refuse a frame sent back the way it came', () => {
641
+ const { initiatorLink, responderLink } = establishedPair();
642
+ const up = initiatorLink.seal('session', bytesOf('up'));
643
+ expect(codeOf(() => initiatorLink.open(up))).toBe('decryption-failed');
644
+ const down = responderLink.seal('session', bytesOf('down'));
645
+ expect(codeOf(() => responderLink.open(down))).toBe('decryption-failed');
646
+ });
647
+
648
+ it('refuse a frame from another link', () => {
649
+ const first = establishedPair();
650
+ const second = establishedPair();
651
+ expect(
652
+ codeOf(() => second.responderLink.open(first.initiatorLink.seal('session', bytesOf('x')))),
653
+ ).toBe('decryption-failed');
654
+ });
655
+
656
+ it('refuse to seal more than the frame bound, or a kind that is not one', () => {
657
+ const { initiatorLink } = establishedPair();
658
+ expect(codeOf(() => initiatorLink.seal('session', new Uint8Array(16 * 1024 * 1024 + 1)))).toBe(
659
+ 'invalid-argument',
660
+ );
661
+ expect(codeOf(() => initiatorLink.seal('grant' as never, bytesOf('x')))).toBe(
662
+ 'invalid-argument',
663
+ );
664
+ expect(codeOf(() => initiatorLink.seal('session', 'text' as never))).toBe('invalid-argument');
665
+ expect(initiatorLink.closed).toBe(false);
666
+ });
667
+ });
668
+
669
+ describe('a read grant', () => {
670
+ it('opens once: the same grant again is refused and closes the link', () => {
671
+ const { initiatorLink, responderLink } = establishedPair();
672
+ const grant = initiatorLink.sealReadGrant(owner);
673
+ responderLink.openReadGrant(grant).lock();
674
+ expect(codeOf(() => responderLink.openReadGrant(grant))).toBe('decryption-failed');
675
+ expect(responderLink.closed).toBe(true);
676
+ });
677
+
678
+ it('refuses a grant cut short: it does not authenticate', () => {
679
+ const { initiatorLink, responderLink } = establishedPair();
680
+ const grant = initiatorLink.sealReadGrant(owner);
681
+ expect(codeOf(() => responderLink.openReadGrant(grant.subarray(0, grant.length - 1)))).toBe(
682
+ 'decryption-failed',
683
+ );
684
+ });
685
+
686
+ describe('sealed by hand under the key of a known link', () => {
687
+ /** A grant-kind frame as the initiator would seal it (counter 0), but around any content. */
688
+ const sealGrantContent = (content: Uint8Array): Buffer => {
689
+ const key = Buffer.from(KAT_OWNER_LINK.initiatorToResponder, 'hex');
690
+ const cipher = createCipheriv('aes-256-gcm', key, Buffer.alloc(12), { authTagLength: 16 });
691
+ cipher.setAAD(Buffer.from([1, 3]));
692
+ const body = Buffer.concat([cipher.update(content), cipher.final()]);
693
+ return Buffer.concat([Buffer.from([3]), body, cipher.getAuthTag()]);
694
+ };
695
+ const content = (version: number, length: number): Buffer =>
696
+ Buffer.concat([Buffer.from([version]), randomBytes(length - 1)]);
697
+
698
+ it('opens content of exactly the right shape, so the builder is right', () => {
699
+ const { link } = katOwnerLink(owner);
700
+ link.openReadGrant(sealGrantContent(content(1, 65))).lock();
701
+ expect(link.closed).toBe(false);
702
+ });
703
+
704
+ it('refuses authentic content that is not a grant: the wrong length or the wrong version', () => {
705
+ for (const wrong of [content(1, 64), content(1, 66), content(2, 65)]) {
706
+ const { link } = katOwnerLink(owner);
707
+ expect(refusalOf(() => link.openReadGrant(sealGrantContent(wrong)))).toBe('malformed');
708
+ expect(link.closed).toBe(true);
709
+ }
710
+ });
711
+ });
712
+ });