@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
package/src/keyslot.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { randomBytes } from 'node:crypto';
3
+
4
+ import { gcmDecrypt, gcmEncrypt, GCM_IV_BYTES } from './aead.js';
5
+ import { concatLengthPrefixed, decodeKeyringField, toBase64Url, utf8 } from './bytes.js';
6
+ import { KeyringError } from './errors.js';
7
+ import { hkdf32 } from './kdf.js';
8
+ import { SLOT_ID_BYTES, WRAPPED_KEY_BYTES } from './keyring-document.js';
9
+ import { KEYRING_VERSION, type TKeyslot, type TKeyslotType } from './keyring-types.js';
10
+
11
+ /** `info` of the recovery slot's key-encryption key. Versioned like every other HKDF label. */
12
+ export const RECOVERY_KEK_LABEL = 'robota/keyring/v1/recovery-kek';
13
+
14
+ export function newSlotId(): string {
15
+ return toBase64Url(randomBytes(SLOT_ID_BYTES));
16
+ }
17
+
18
+ /**
19
+ * The key-encryption key of a recovery slot. The recovery key is 160 random bits, so HKDF is all it
20
+ * needs; stretching it with scrypt would add cost and no strength.
21
+ */
22
+ export function deriveRecoveryKek(recoveryKeyBytes: Uint8Array): Buffer {
23
+ return hkdf32(recoveryKeyBytes, RECOVERY_KEK_LABEL);
24
+ }
25
+
26
+ /**
27
+ * What the wrapping authenticates: version, slot id and slot type. A slot that is relabelled,
28
+ * re-identified or moved to a document of another version stops unwrapping. The sealing public key
29
+ * is deliberately NOT bound: the product detects a swapped public key at unlock and repairs it, and
30
+ * a binding would turn that repairable tamper into a lockout.
31
+ */
32
+ export function keyslotAad(slotId: string, slotType: TKeyslotType): Buffer {
33
+ return concatLengthPrefixed([Buffer.from([KEYRING_VERSION]), utf8(slotId), utf8(slotType)]);
34
+ }
35
+
36
+ export interface IWrappedMasterKey {
37
+ readonly iv: string;
38
+ readonly wrappedKey: string;
39
+ }
40
+
41
+ /** AES-256-GCM wrap of the master key under `kek` with a fresh random 12-byte IV. */
42
+ export function wrapMasterKey(
43
+ kek: Uint8Array,
44
+ masterKey: Uint8Array,
45
+ slotId: string,
46
+ slotType: TKeyslotType,
47
+ ): IWrappedMasterKey {
48
+ const iv = randomBytes(GCM_IV_BYTES);
49
+ const wrapped = gcmEncrypt(kek, iv, masterKey, keyslotAad(slotId, slotType));
50
+ return { iv: toBase64Url(iv), wrappedKey: toBase64Url(wrapped) };
51
+ }
52
+
53
+ /**
54
+ * Unwraps the master key of `slot` with `kek`. Any failure to authenticate is `wrong-credential`:
55
+ * a wrong passphrase, a wrong recovery key and a tampered slot cannot be told apart, and the
56
+ * caller must not be able to either. The caller owns (and wipes) `kek` and the returned key.
57
+ */
58
+ export function unwrapMasterKey(kek: Uint8Array, slot: TKeyslot): Buffer {
59
+ const iv = decodeKeyringField(slot.iv, GCM_IV_BYTES);
60
+ const wrapped = decodeKeyringField(slot.wrappedKey, WRAPPED_KEY_BYTES);
61
+ try {
62
+ return gcmDecrypt(kek, iv, wrapped, keyslotAad(slot.id, slot.type));
63
+ } catch {
64
+ throw new KeyringError('wrong-credential', 'The credential did not open the keyring.');
65
+ }
66
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Where the owner link's randomness comes from. Every instance key, ephemeral key and nonce of the
3
+ * spawner exchange and the handshake is drawn here, so the package's known-answer tests can replay a
4
+ * whole exchange or handshake from fixed values. The entry point only ever draws from `randomBytes`;
5
+ * nothing a caller of the package can reach takes other entropy.
6
+ */
7
+ import { randomBytes } from 'node:crypto';
8
+
9
+ import type { Buffer } from 'node:buffer';
10
+
11
+ export const LINK_SEED_BYTES = 32;
12
+ export const LINK_NONCE_BYTES = 32;
13
+
14
+ export interface ILinkEntropy {
15
+ /** A fresh 32-byte private-key seed. The caller wipes it once the key exists. */
16
+ seed(): Buffer;
17
+ /** A fresh 32-byte nonce. */
18
+ nonce(): Buffer;
19
+ }
20
+
21
+ export const SYSTEM_LINK_ENTROPY: ILinkEntropy = {
22
+ seed: () => randomBytes(LINK_SEED_BYTES),
23
+ nonce: () => randomBytes(LINK_NONCE_BYTES),
24
+ };
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Frame protection once a link is established.
3
+ *
4
+ * Every frame after the handshake is AES-256-GCM under the key of its direction, with a 96-bit
5
+ * big-endian counter as the nonce and the direction and frame kind as the AAD. The counter is never
6
+ * sent: each end expects the next one, so a replayed, reordered, dropped or misdirected frame fails
7
+ * authentication like a tampered one, and the first frame that fails closes the link. A relay
8
+ * between the two ends can only carry or drop frames.
9
+ *
10
+ * There are three kinds. `session` and `owner` frames carry bytes the carrier decodes. A `grant`
11
+ * frame carries the owner's read keys and never passes through JSON: it is sealed from and opened
12
+ * into key objects and byte buffers this module zero-fills. A grant travels only on an owner link,
13
+ * from the handover that opened it, because whoever can send one decides which key the runtime
14
+ * seals to.
15
+ */
16
+ import { Buffer } from 'node:buffer';
17
+
18
+ import { gcmDecrypt, gcmEncrypt, GCM_IV_BYTES, GCM_TAG_BYTES } from './aead.js';
19
+ import { isBytes, wipe } from './bytes.js';
20
+ import { KeyringError, LinkRefusedError } from './errors.js';
21
+ import { x25519KeyPairFromSeed } from './key-pairs.js';
22
+ import { KEY_BYTES } from './kdf.js';
23
+ import { ownerHandoverSecrets, type IOwnerHandover } from './owner-handover.js';
24
+ import { createReadKeys, type IReadKeys } from './read-keys.js';
25
+ import type { ILinkBinding } from './runtime-certificate.js';
26
+
27
+ /** How the initiator was admitted, which is the standing its link has. */
28
+ export type TLinkMode = 'spawner' | 'owner';
29
+ export type TLinkFrameKind = 'session' | 'owner' | 'grant';
30
+ /** The kinds a caller seals and opens as bytes; a grant has its own two operations. */
31
+ export type TLinkDataFrameKind = Exclude<TLinkFrameKind, 'grant'>;
32
+
33
+ const KIND_BYTES: Readonly<Record<TLinkFrameKind, number>> = { session: 1, owner: 2, grant: 3 };
34
+ const KINDS_BY_BYTE: ReadonlyMap<number, TLinkFrameKind> = new Map(
35
+ Object.entries(KIND_BYTES).map(([kind, byte]) => [byte, kind as TLinkFrameKind]),
36
+ );
37
+ /** The direction byte of the AAD names the sending end. */
38
+ const DIRECTION_BYTES = { initiator: 1, responder: 2 } as const;
39
+ type TLinkRole = keyof typeof DIRECTION_BYTES;
40
+
41
+ /** The kind byte and the GCM tag around every frame's ciphertext. */
42
+ export const LINK_FRAME_OVERHEAD_BYTES = 1 + GCM_TAG_BYTES;
43
+ /** The most one frame carries; a carrier bounds its frames below this. */
44
+ export const LINK_FRAME_MAX_PLAINTEXT_BYTES = 16 * 1024 * 1024;
45
+ const GRANT_VERSION = 1;
46
+ /** version | sealing key seed | authorship key */
47
+ const GRANT_PLAINTEXT_BYTES = 1 + KEY_BYTES + KEY_BYTES;
48
+ /** A sealed read grant is always exactly this long. */
49
+ export const LINK_GRANT_FRAME_BYTES = GRANT_PLAINTEXT_BYTES + LINK_FRAME_OVERHEAD_BYTES;
50
+
51
+ export interface ILinkFrameKeys {
52
+ readonly initiatorToResponder: Buffer;
53
+ readonly responderToInitiator: Buffer;
54
+ }
55
+
56
+ /** The kind a frame says it is. Unauthenticated until it opens, which checks the kind too. */
57
+ export function linkFrameKind(frame: Uint8Array): TLinkFrameKind {
58
+ const kind =
59
+ isBytes(frame) && frame.length >= LINK_FRAME_OVERHEAD_BYTES
60
+ ? KINDS_BY_BYTE.get(frame[0]!)
61
+ : undefined;
62
+ if (kind === undefined) throw new LinkRefusedError('malformed');
63
+ return kind;
64
+ }
65
+
66
+ function nonceFor(counter: number): Buffer {
67
+ const nonce = Buffer.alloc(GCM_IV_BYTES);
68
+ nonce.writeBigUInt64BE(BigInt(counter), GCM_IV_BYTES - 8);
69
+ return nonce;
70
+ }
71
+
72
+ /** One end of an established link. */
73
+ export interface IEstablishedLink {
74
+ readonly standing: TLinkMode;
75
+ /** The runtime start the link is bound to: authenticated by the handshake on both ends. */
76
+ readonly binding: ILinkBinding;
77
+ /** The runtime's instance key: checked by the initiator, its own at the responder. */
78
+ readonly instancePublicKey: Uint8Array;
79
+ readonly closed: boolean;
80
+ seal(kind: TLinkDataFrameKind, plaintext: Uint8Array): Uint8Array;
81
+ /** The next frame from the other end; throws, and closes the link, unless it is exactly that. */
82
+ open(frame: Uint8Array): { readonly kind: TLinkDataFrameKind; readonly plaintext: Buffer };
83
+ /** Zero-fills both frame keys. Idempotent. */
84
+ close(): void;
85
+ }
86
+
87
+ export interface IInitiatorLink extends IEstablishedLink {
88
+ /**
89
+ * The owner's read keys (the sealing key and the authorship key, never the master key or the
90
+ * attach key) as one grant frame. Only on an owner link, and only from the handover that opened it.
91
+ */
92
+ sealReadGrant(handover: IOwnerHandover): Uint8Array;
93
+ }
94
+
95
+ export interface IResponderLink extends IEstablishedLink {
96
+ /** The read keys a grant frame carries. Only on an owner-standing link; anything else closes it. */
97
+ openReadGrant(frame: Uint8Array): IReadKeys;
98
+ }
99
+
100
+ abstract class LinkEnd implements IEstablishedLink {
101
+ readonly standing: TLinkMode;
102
+ readonly binding: ILinkBinding;
103
+ readonly #role: TLinkRole;
104
+ readonly #instancePublicKey: Uint8Array;
105
+ #sendKey: Buffer | undefined;
106
+ #receiveKey: Buffer | undefined;
107
+ #sent = 0;
108
+ #received = 0;
109
+
110
+ constructor(
111
+ role: TLinkRole,
112
+ standing: TLinkMode,
113
+ binding: ILinkBinding,
114
+ instancePublicKey: Uint8Array,
115
+ keys: ILinkFrameKeys,
116
+ ) {
117
+ this.#role = role;
118
+ this.standing = standing;
119
+ this.binding = Object.freeze({ runtimeId: binding.runtimeId, generation: binding.generation });
120
+ this.#instancePublicKey = Uint8Array.from(instancePublicKey);
121
+ this.#sendKey = role === 'initiator' ? keys.initiatorToResponder : keys.responderToInitiator;
122
+ this.#receiveKey = role === 'initiator' ? keys.responderToInitiator : keys.initiatorToResponder;
123
+ }
124
+
125
+ get instancePublicKey(): Uint8Array {
126
+ return Uint8Array.from(this.#instancePublicKey);
127
+ }
128
+
129
+ get closed(): boolean {
130
+ return this.#sendKey === undefined;
131
+ }
132
+
133
+ seal(kind: TLinkDataFrameKind, plaintext: Uint8Array): Uint8Array {
134
+ if (kind !== 'session' && kind !== 'owner') {
135
+ throw new KeyringError('invalid-argument', 'A frame is a session or an owner frame.');
136
+ }
137
+ return this.sealKind(kind, plaintext);
138
+ }
139
+
140
+ open(frame: Uint8Array): { readonly kind: TLinkDataFrameKind; readonly plaintext: Buffer } {
141
+ const kind = this.kindOf(frame);
142
+ if (kind === 'grant') {
143
+ this.close();
144
+ throw new KeyringError('invalid-argument', 'A read grant opens only as a grant.');
145
+ }
146
+ return { kind, plaintext: this.openKind(frame, kind) };
147
+ }
148
+
149
+ close(): void {
150
+ wipe(this.#sendKey, this.#receiveKey);
151
+ this.#sendKey = undefined;
152
+ this.#receiveKey = undefined;
153
+ }
154
+
155
+ protected sealKind(kind: TLinkFrameKind, plaintext: Uint8Array): Uint8Array {
156
+ if (!isBytes(plaintext) || plaintext.length > LINK_FRAME_MAX_PLAINTEXT_BYTES) {
157
+ throw new KeyringError(
158
+ 'invalid-argument',
159
+ `A frame carries 0-${LINK_FRAME_MAX_PLAINTEXT_BYTES} bytes.`,
160
+ );
161
+ }
162
+ const key = this.#requireOpen(this.#sendKey);
163
+ if (!Number.isSafeInteger(this.#sent + 1)) {
164
+ this.close();
165
+ throw new KeyringError('link-closed', 'The link has sent all the frames it can.');
166
+ }
167
+ const aad = Buffer.from([DIRECTION_BYTES[this.#role], KIND_BYTES[kind]]);
168
+ const sealed = gcmEncrypt(key, nonceFor(this.#sent), plaintext, aad);
169
+ this.#sent += 1;
170
+ return Buffer.concat([Buffer.from([KIND_BYTES[kind]]), sealed]);
171
+ }
172
+
173
+ /** The frame's kind; a frame that is not one closes the link. */
174
+ protected kindOf(frame: Uint8Array): TLinkFrameKind {
175
+ this.#requireOpen(this.#receiveKey);
176
+ try {
177
+ return linkFrameKind(frame);
178
+ } catch (error) {
179
+ this.close();
180
+ throw error;
181
+ }
182
+ }
183
+
184
+ /** The next frame's plaintext, which the caller owns; any failure closes the link. */
185
+ protected openKind(frame: Uint8Array, kind: TLinkFrameKind): Buffer {
186
+ const key = this.#requireOpen(this.#receiveKey);
187
+ const peer: TLinkRole = this.#role === 'initiator' ? 'responder' : 'initiator';
188
+ const aad = Buffer.from([DIRECTION_BYTES[peer], KIND_BYTES[kind]]);
189
+ let plaintext: Buffer;
190
+ try {
191
+ plaintext = gcmDecrypt(key, nonceFor(this.#received), frame.subarray(1), aad);
192
+ } catch (error) {
193
+ this.close();
194
+ throw error;
195
+ }
196
+ this.#received += 1;
197
+ return plaintext;
198
+ }
199
+
200
+ #requireOpen(key: Buffer | undefined): Buffer {
201
+ if (key === undefined) throw new KeyringError('link-closed', 'The link is closed.');
202
+ return key;
203
+ }
204
+ }
205
+
206
+ export class InitiatorLink extends LinkEnd implements IInitiatorLink {
207
+ readonly #handover: IOwnerHandover | undefined;
208
+
209
+ constructor(
210
+ standing: TLinkMode,
211
+ binding: ILinkBinding,
212
+ instancePublicKey: Uint8Array,
213
+ keys: ILinkFrameKeys,
214
+ handover: IOwnerHandover | undefined,
215
+ ) {
216
+ super('initiator', standing, binding, instancePublicKey, keys);
217
+ this.#handover = handover;
218
+ }
219
+
220
+ sealReadGrant(handover: IOwnerHandover): Uint8Array {
221
+ if (this.standing !== 'owner' || handover !== this.#handover) {
222
+ throw new KeyringError(
223
+ 'invalid-argument',
224
+ 'A read grant goes only over an owner link, from the handover that opened it.',
225
+ );
226
+ }
227
+ const { sealingSeed, authorship } = ownerHandoverSecrets(handover);
228
+ const plaintext = Buffer.alloc(GRANT_PLAINTEXT_BYTES);
229
+ try {
230
+ plaintext[0] = GRANT_VERSION;
231
+ sealingSeed.copy(plaintext, 1);
232
+ authorship.copy(plaintext, 1 + KEY_BYTES);
233
+ return this.sealKind('grant', plaintext);
234
+ } finally {
235
+ wipe(plaintext);
236
+ }
237
+ }
238
+ }
239
+
240
+ export class ResponderLink extends LinkEnd implements IResponderLink {
241
+ constructor(
242
+ standing: TLinkMode,
243
+ binding: ILinkBinding,
244
+ instancePublicKey: Uint8Array,
245
+ keys: ILinkFrameKeys,
246
+ ) {
247
+ super('responder', standing, binding, instancePublicKey, keys);
248
+ }
249
+
250
+ openReadGrant(frame: Uint8Array): IReadKeys {
251
+ if (this.standing !== 'owner') {
252
+ this.close();
253
+ throw new KeyringError('invalid-argument', 'A read grant is accepted only on an owner link.');
254
+ }
255
+ if (this.kindOf(frame) !== 'grant') {
256
+ this.close();
257
+ throw new KeyringError('invalid-argument', 'Only a read grant opens as a grant.');
258
+ }
259
+ const plaintext = this.openKind(frame, 'grant');
260
+ try {
261
+ if (plaintext.length !== GRANT_PLAINTEXT_BYTES || plaintext[0] !== GRANT_VERSION) {
262
+ this.close();
263
+ throw new LinkRefusedError('malformed');
264
+ }
265
+ const sealing = x25519KeyPairFromSeed(plaintext.subarray(1, 1 + KEY_BYTES));
266
+ return createReadKeys(sealing, Buffer.from(plaintext.subarray(1 + KEY_BYTES)));
267
+ } finally {
268
+ wipe(plaintext);
269
+ }
270
+ }
271
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The few operations the spawner exchange and the handshake are built from, each in the one form
3
+ * they use it: a key agreement that refuses a point with no usable secret, transcript hashing and
4
+ * MACs over length-prefixed parts, and HKDF to any length. Each wraps a `node:crypto` primitive the
5
+ * keyring already uses, so the link adds nothing a supported runtime lacks.
6
+ */
7
+ import { Buffer } from 'node:buffer';
8
+ import {
9
+ createHash,
10
+ createHmac,
11
+ diffieHellman,
12
+ hkdfSync,
13
+ timingSafeEqual,
14
+ type KeyObject,
15
+ } from 'node:crypto';
16
+
17
+ import { concatLengthPrefixed, isBytes, wipe } from './bytes.js';
18
+ import { KeyringError, LinkRefusedError } from './errors.js';
19
+ import { x25519KeyPairFromSeed, x25519PublicKeyFromRaw, type IKeyPair } from './key-pairs.js';
20
+ import { KEY_BYTES } from './kdf.js';
21
+ import type { ILinkEntropy } from './link-entropy.js';
22
+
23
+ /** A fresh X25519 keypair from the entropy's seed; the seed is wiped once the pair exists. */
24
+ export function ephemeralKeyPair(entropy: ILinkEntropy): IKeyPair {
25
+ const seed = entropy.seed();
26
+ try {
27
+ return x25519KeyPairFromSeed(seed);
28
+ } finally {
29
+ wipe(seed);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * The X25519 shared secret with the peer's raw public key. A value that is not a 32-byte key, or a
35
+ * low-order point with no usable secret, is the peer's malformed message: refused, never retried.
36
+ */
37
+ export function agree(privateKey: KeyObject, peerPublicKey: Uint8Array): Buffer {
38
+ if (!isBytes(peerPublicKey) || peerPublicKey.length !== KEY_BYTES) {
39
+ throw new LinkRefusedError('malformed');
40
+ }
41
+ try {
42
+ return diffieHellman({ privateKey, publicKey: x25519PublicKeyFromRaw(peerPublicKey) });
43
+ } catch {
44
+ throw new LinkRefusedError('malformed');
45
+ }
46
+ }
47
+
48
+ /** SHA-256 over length-prefixed parts, so no two part lists hash alike. */
49
+ export function hashParts(parts: readonly Uint8Array[]): Buffer {
50
+ return createHash('sha256').update(concatLengthPrefixed(parts)).digest();
51
+ }
52
+
53
+ /** HMAC-SHA256 of the concatenation of `parts` under `key`. */
54
+ export function hmac(key: Uint8Array, ...parts: readonly Uint8Array[]): Buffer {
55
+ const mac = createHmac('sha256', key);
56
+ for (const part of parts) mac.update(part);
57
+ return mac.digest();
58
+ }
59
+
60
+ /** Whether two byte strings are equal, in constant time once their (public) lengths match. */
61
+ export function sameBytes(left: Uint8Array, right: Uint8Array): boolean {
62
+ return left.length === right.length && timingSafeEqual(left, right);
63
+ }
64
+
65
+ /** HKDF-SHA256 to `length` bytes; a host that cannot run it fails as `kdf-failed`. */
66
+ export function hkdf(
67
+ inputKeyMaterial: Uint8Array,
68
+ salt: Uint8Array,
69
+ info: string,
70
+ length: number,
71
+ ): Buffer {
72
+ try {
73
+ return Buffer.from(hkdfSync('sha256', inputKeyMaterial, salt, info, length));
74
+ } catch {
75
+ throw new KeyringError('kdf-failed', 'The key derivation could not run on this host.');
76
+ }
77
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The owner, present for a moment.
3
+ *
4
+ * Reading needs only the read keys; proving ownership needs the attach key, and that is the one key
5
+ * a runtime must never hold, since whoever holds it can certify impostors and present as the owner to
6
+ * any runtime. So the attach key has one home: a handover, opened from the keyring with a credential
7
+ * a person just typed, held for the seconds a command needs it, and disposed. A handover hands out
8
+ * operations and public values, never a key. Certifying a runtime instance is its one public
9
+ * operation; signing an owner proof and sealing a read grant happen inside the link initiator,
10
+ * through an accessor this package does not export.
11
+ */
12
+ import type { Buffer } from 'node:buffer';
13
+
14
+ import { deriveAuthorshipKey } from './authorship.js';
15
+ import { wipe } from './bytes.js';
16
+ import { KeyringError } from './errors.js';
17
+ import { fingerprint } from './fingerprint.js';
18
+ import {
19
+ deriveAttachKeyPair,
20
+ deriveSealingSeed,
21
+ x25519KeyPairFromSeed,
22
+ type IKeyPair,
23
+ } from './key-pairs.js';
24
+ import { validateKeyring } from './keyring-document.js';
25
+ import { recoverMasterKey, sealingPublicKeyMatches, type TKeyringCredential } from './keyring.js';
26
+ import type { IKeyring } from './keyring-types.js';
27
+ import {
28
+ runtimeCertificateMessage,
29
+ type ILinkBinding,
30
+ type ITrustRoot,
31
+ } from './runtime-certificate.js';
32
+ import { signEd25519 } from './signatures.js';
33
+
34
+ export interface IOwnerHandover {
35
+ /**
36
+ * Whether the keyring file holds the sealing public key the credential derives. `false` means the
37
+ * file was altered: a runtime given these keys seals to the derived key, and the file is repaired
38
+ * by `secure verify`, not here.
39
+ */
40
+ readonly sealingKeyMatches: boolean;
41
+ /** The derived sealing public key's fingerprint, shown so a replaced keyring file is noticed. */
42
+ readonly fingerprint: string;
43
+ readonly disposed: boolean;
44
+ /**
45
+ * The trust root that tells a runtime its owner: the attach public key and the certificate of
46
+ * `instancePublicKey` for `binding`. Give it only for an instance key and binding learned over an
47
+ * authenticated link, since the certificate vouches for exactly them.
48
+ */
49
+ certify(instancePublicKey: Uint8Array, binding: ILinkBinding): ITrustRoot;
50
+ /** Zero-fills what it can and drops every key; later calls throw `keys-locked`. Idempotent. */
51
+ dispose(): void;
52
+ }
53
+
54
+ /** What only the link initiator reaches: everything an owner proof and a read grant are made of. */
55
+ export interface IOwnerHandoverSecrets {
56
+ readonly attach: IKeyPair;
57
+ /** The 32-byte seed of the sealing keypair: what a read grant carries, since it rebuilds the pair. */
58
+ readonly sealingSeed: Buffer;
59
+ readonly sealingPublicKey: Uint8Array;
60
+ readonly authorship: Buffer;
61
+ }
62
+
63
+ const SECRETS = new WeakMap<IOwnerHandover, () => IOwnerHandoverSecrets>();
64
+
65
+ class OwnerHandover implements IOwnerHandover {
66
+ #secrets: IOwnerHandoverSecrets | undefined;
67
+ readonly #sealingKeyMatches: boolean;
68
+
69
+ constructor(secrets: IOwnerHandoverSecrets, sealingKeyMatches: boolean) {
70
+ this.#secrets = secrets;
71
+ this.#sealingKeyMatches = sealingKeyMatches;
72
+ SECRETS.set(this, () => this.#require());
73
+ }
74
+
75
+ get sealingKeyMatches(): boolean {
76
+ this.#require();
77
+ return this.#sealingKeyMatches;
78
+ }
79
+
80
+ get fingerprint(): string {
81
+ return fingerprint(this.#require().sealingPublicKey);
82
+ }
83
+
84
+ get disposed(): boolean {
85
+ return this.#secrets === undefined;
86
+ }
87
+
88
+ certify(instancePublicKey: Uint8Array, binding: ILinkBinding): ITrustRoot {
89
+ const { attach } = this.#require();
90
+ const message = runtimeCertificateMessage(instancePublicKey, binding);
91
+ return {
92
+ attachPublicKey: Uint8Array.from(attach.publicKey),
93
+ certificate: Uint8Array.from(signEd25519(attach.privateKey, message)),
94
+ };
95
+ }
96
+
97
+ dispose(): void {
98
+ const secrets = this.#secrets;
99
+ if (secrets === undefined) return;
100
+ this.#secrets = undefined;
101
+ wipe(secrets.sealingSeed, secrets.authorship);
102
+ }
103
+
104
+ #require(): IOwnerHandoverSecrets {
105
+ if (this.#secrets === undefined) {
106
+ throw new KeyringError('keys-locked', 'The owner handover was disposed.');
107
+ }
108
+ return this.#secrets;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Package-private: the owner's keys behind a handover, for the link initiator alone. It is not
114
+ * exported from the entry point, and it answers only for a handover this package opened.
115
+ */
116
+ export function ownerHandoverSecrets(handover: IOwnerHandover): IOwnerHandoverSecrets {
117
+ const secrets = SECRETS.get(handover);
118
+ if (secrets === undefined) {
119
+ throw new KeyringError(
120
+ 'invalid-argument',
121
+ 'This is not an owner handover this package opened.',
122
+ );
123
+ }
124
+ return secrets();
125
+ }
126
+
127
+ /**
128
+ * Opens the owner handover with `credential`: one key derivation (one scrypt run per passphrase
129
+ * slot, as an unlock), from whose master key the attach keypair and the read keys are derived; the
130
+ * master key is zero-filled before this returns. A wrong credential and a tampered slot fail alike.
131
+ */
132
+ export async function openOwnerHandover(
133
+ keyring: IKeyring,
134
+ credential: TKeyringCredential,
135
+ ): Promise<IOwnerHandover> {
136
+ const document = validateKeyring(keyring);
137
+ const { masterKey } = await recoverMasterKey(document, credential);
138
+ let sealingSeed: Buffer | undefined;
139
+ let authorship: Buffer | undefined;
140
+ try {
141
+ sealingSeed = deriveSealingSeed(masterKey);
142
+ authorship = deriveAuthorshipKey(masterKey);
143
+ const attach = deriveAttachKeyPair(masterKey);
144
+ const sealingPublicKey = x25519KeyPairFromSeed(sealingSeed).publicKey;
145
+ return new OwnerHandover(
146
+ { attach, sealingSeed, sealingPublicKey, authorship },
147
+ sealingPublicKeyMatches(document, sealingPublicKey),
148
+ );
149
+ } catch (error) {
150
+ wipe(sealingSeed, authorship);
151
+ throw error;
152
+ } finally {
153
+ wipe(masterKey);
154
+ }
155
+ }