@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.
- package/CHANGELOG.md +33 -0
- package/LICENSE +661 -0
- package/README.md +91 -2
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +470 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +470 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +74 -3
- package/src/__tests__/authorship-constant-time.test.ts +46 -0
- package/src/__tests__/authorship.test.ts +65 -0
- package/src/__tests__/crypto-failures.test.ts +89 -0
- package/src/__tests__/fixtures/keyring-v1.json +25 -0
- package/src/__tests__/fixtures/sealed-v1.json +8 -0
- package/src/__tests__/helpers.ts +130 -0
- package/src/__tests__/kdf-bounds.test.ts +154 -0
- package/src/__tests__/keyring-document.test.ts +251 -0
- package/src/__tests__/keyring.test.ts +329 -0
- package/src/__tests__/keyslot.test.ts +98 -0
- package/src/__tests__/known-answers.test.ts +110 -0
- package/src/__tests__/link-helpers.ts +228 -0
- package/src/__tests__/owner-handover-derivation.test.ts +50 -0
- package/src/__tests__/owner-handover-dispose.test.ts +64 -0
- package/src/__tests__/owner-handover.test.ts +145 -0
- package/src/__tests__/owner-link-adversarial.test.ts +712 -0
- package/src/__tests__/owner-link-grant-wipe.test.ts +75 -0
- package/src/__tests__/owner-link-known-answers.test.ts +117 -0
- package/src/__tests__/owner-link-secret-wipe.test.ts +116 -0
- package/src/__tests__/owner-link.test.ts +229 -0
- package/src/__tests__/read-keys.test.ts +67 -0
- package/src/__tests__/recovery-key.test.ts +76 -0
- package/src/__tests__/runtime-identity.test.ts +171 -0
- package/src/__tests__/sealed-format-vectors.test.ts +91 -0
- package/src/__tests__/sealing.test.ts +376 -0
- package/src/__tests__/spawner-exchange.test.ts +127 -0
- package/src/__tests__/transient-key-pair.test.ts +36 -0
- package/src/aead.ts +62 -0
- package/src/authorship.ts +61 -0
- package/src/bytes.ts +67 -0
- package/src/errors.ts +106 -0
- package/src/fingerprint.ts +32 -0
- package/src/index.ts +77 -0
- package/src/kdf.ts +111 -0
- package/src/key-pairs.ts +148 -0
- package/src/keyring-document.ts +168 -0
- package/src/keyring-types.ts +51 -0
- package/src/keyring.ts +307 -0
- package/src/keyslot.ts +66 -0
- package/src/link-entropy.ts +24 -0
- package/src/link-frames.ts +271 -0
- package/src/link-primitives.ts +77 -0
- package/src/owner-handover.ts +155 -0
- package/src/owner-link.ts +508 -0
- package/src/read-keys.ts +78 -0
- package/src/recovery-key.ts +79 -0
- package/src/runtime-certificate.ts +129 -0
- package/src/runtime-identity.ts +166 -0
- package/src/sealing.ts +281 -0
- package/src/signatures.ts +45 -0
- package/src/spawner-exchange.ts +155 -0
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner link handshake: how a client and a served runtime decide that each is who it must be,
|
|
3
|
+
* before anything that depends on a secret crosses between them.
|
|
4
|
+
*
|
|
5
|
+
* I → R Hello v=1 | mode | e_i | n_i
|
|
6
|
+
* R → I Challenge e_r | n_r | inst | runtime id | generation | cert? | auth_r
|
|
7
|
+
* I → R Proof auth_i
|
|
8
|
+
*
|
|
9
|
+
* th = SHA-256 of the length-prefixed label, mode, e_i, n_i, e_r, n_r, inst, runtime id,
|
|
10
|
+
* generation and certificate (empty in spawner mode)
|
|
11
|
+
* spawner: auth_r = HMAC(K_s, "r" | th) | Sig_inst("r" | th); auth_i = HMAC(K_s, "i" | th)
|
|
12
|
+
* owner: auth_r = Sig_inst("r" | th), with the certificate; auth_i = Sig_attach("i" | th)
|
|
13
|
+
* frame keys = HKDF(X25519(e_i, e_r), salt = th, "robota/link/v1/frames"), one per direction
|
|
14
|
+
*
|
|
15
|
+
* The runtime answers first, and the initiator checks the whole answer before it produces its
|
|
16
|
+
* proof. A spawner checks that the instance key is the one its exchange carried, then both halves of
|
|
17
|
+
* `auth_r`: only the child it started holds the spawner key and that instance key. An owner checks
|
|
18
|
+
* the certificate under the attach key it derived itself, for exactly the instance and binding the
|
|
19
|
+
* answer names, then the instance signature. So a program on a taken-over socket, a runtime nobody
|
|
20
|
+
* certified and a runtime another keyring certified are all refused having received only a hello,
|
|
21
|
+
* which is fresh randomness. The mode, the binding and both ephemerals are in `th`, so no answer can
|
|
22
|
+
* be moved to another mode, another link or another runtime start.
|
|
23
|
+
*
|
|
24
|
+
* Every refusal is a `LinkRefusedError` naming the failed check for the caller's logs; the carrier
|
|
25
|
+
* closes the stream and tells the other end nothing.
|
|
26
|
+
*/
|
|
27
|
+
import { Buffer } from 'node:buffer';
|
|
28
|
+
|
|
29
|
+
import { concatLengthPrefixed, isBytes, utf8, wipe } from './bytes.js';
|
|
30
|
+
import { KeyringError, LinkRefusedError } from './errors.js';
|
|
31
|
+
import type { IKeyPair } from './key-pairs.js';
|
|
32
|
+
import { KEY_BYTES } from './kdf.js';
|
|
33
|
+
import { SYSTEM_LINK_ENTROPY, LINK_NONCE_BYTES, type ILinkEntropy } from './link-entropy.js';
|
|
34
|
+
import {
|
|
35
|
+
InitiatorLink,
|
|
36
|
+
ResponderLink,
|
|
37
|
+
type IInitiatorLink,
|
|
38
|
+
type ILinkFrameKeys,
|
|
39
|
+
type IResponderLink,
|
|
40
|
+
type TLinkMode,
|
|
41
|
+
} from './link-frames.js';
|
|
42
|
+
import { agree, ephemeralKeyPair, hashParts, hkdf, hmac, sameBytes } from './link-primitives.js';
|
|
43
|
+
import { ownerHandoverSecrets, type IOwnerHandover } from './owner-handover.js';
|
|
44
|
+
import {
|
|
45
|
+
certifies,
|
|
46
|
+
checkLinkBinding,
|
|
47
|
+
decodeBindingPart,
|
|
48
|
+
MAX_BINDING_PART_LENGTH,
|
|
49
|
+
requireInstanceKey,
|
|
50
|
+
type ILinkBinding,
|
|
51
|
+
} from './runtime-certificate.js';
|
|
52
|
+
import { runtimeIdentitySecrets, type IRuntimeIdentity } from './runtime-identity.js';
|
|
53
|
+
import { SIGNATURE_BYTES, signEd25519, verifyEd25519 } from './signatures.js';
|
|
54
|
+
import { spawnerKeyBytes, type SpawnerKey } from './spawner-exchange.js';
|
|
55
|
+
|
|
56
|
+
export const LINK_PROTOCOL_VERSION = 1;
|
|
57
|
+
export const LINK_HANDSHAKE_LABEL = 'robota/link/v1/handshake';
|
|
58
|
+
export const LINK_FRAME_KEYS_LABEL = 'robota/link/v1/frames';
|
|
59
|
+
|
|
60
|
+
const MODE_BYTES: Readonly<Record<TLinkMode, number>> = { spawner: 1, owner: 2 };
|
|
61
|
+
const HMAC_BYTES = 32;
|
|
62
|
+
const CERTIFICATE_BYTES: Readonly<Record<TLinkMode, number>> = {
|
|
63
|
+
spawner: 0,
|
|
64
|
+
owner: SIGNATURE_BYTES,
|
|
65
|
+
};
|
|
66
|
+
const RUNTIME_PROOF_BYTES: Readonly<Record<TLinkMode, number>> = {
|
|
67
|
+
spawner: HMAC_BYTES + SIGNATURE_BYTES,
|
|
68
|
+
owner: SIGNATURE_BYTES,
|
|
69
|
+
};
|
|
70
|
+
const PROOF_BYTES: Readonly<Record<TLinkMode, number>> = {
|
|
71
|
+
spawner: HMAC_BYTES,
|
|
72
|
+
owner: SIGNATURE_BYTES,
|
|
73
|
+
};
|
|
74
|
+
const LENGTH_PREFIX_BYTES = 2;
|
|
75
|
+
const RESPONDER_TAG = utf8('r');
|
|
76
|
+
const INITIATOR_TAG = utf8('i');
|
|
77
|
+
|
|
78
|
+
/** version | mode | e_i | n_i */
|
|
79
|
+
export const LINK_HELLO_BYTES = 2 + KEY_BYTES + LINK_NONCE_BYTES;
|
|
80
|
+
/** The longest challenge either mode can send. */
|
|
81
|
+
export const LINK_CHALLENGE_MAX_BYTES =
|
|
82
|
+
KEY_BYTES +
|
|
83
|
+
LINK_NONCE_BYTES +
|
|
84
|
+
KEY_BYTES +
|
|
85
|
+
3 * LENGTH_PREFIX_BYTES +
|
|
86
|
+
2 * MAX_BINDING_PART_LENGTH +
|
|
87
|
+
Math.max(...Object.values(CERTIFICATE_BYTES)) +
|
|
88
|
+
Math.max(...Object.values(RUNTIME_PROOF_BYTES));
|
|
89
|
+
/** The longest proof either mode can send. */
|
|
90
|
+
export const LINK_PROOF_MAX_BYTES = Math.max(...Object.values(PROOF_BYTES));
|
|
91
|
+
|
|
92
|
+
/** How the initiator proves itself: the spawner by its spawner key, the owner by its handover. */
|
|
93
|
+
export type TLinkInitiatorAuth =
|
|
94
|
+
| {
|
|
95
|
+
readonly mode: 'spawner';
|
|
96
|
+
readonly spawnerKey: SpawnerKey;
|
|
97
|
+
/** The instance key the spawner exchange carried. */
|
|
98
|
+
readonly instancePublicKey: Uint8Array;
|
|
99
|
+
}
|
|
100
|
+
| { readonly mode: 'owner'; readonly handover: IOwnerHandover };
|
|
101
|
+
|
|
102
|
+
/** Everything the transcript hash covers. */
|
|
103
|
+
export interface ILinkTranscript {
|
|
104
|
+
readonly mode: TLinkMode;
|
|
105
|
+
readonly initiatorEphemeral: Uint8Array;
|
|
106
|
+
readonly initiatorNonce: Uint8Array;
|
|
107
|
+
readonly responderEphemeral: Uint8Array;
|
|
108
|
+
readonly responderNonce: Uint8Array;
|
|
109
|
+
readonly instancePublicKey: Uint8Array;
|
|
110
|
+
readonly binding: ILinkBinding;
|
|
111
|
+
/** Empty in spawner mode. */
|
|
112
|
+
readonly certificate: Uint8Array;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function transcriptHash(transcript: ILinkTranscript): Buffer {
|
|
116
|
+
return hashParts([
|
|
117
|
+
utf8(LINK_HANDSHAKE_LABEL),
|
|
118
|
+
Buffer.from([MODE_BYTES[transcript.mode]]),
|
|
119
|
+
transcript.initiatorEphemeral,
|
|
120
|
+
transcript.initiatorNonce,
|
|
121
|
+
transcript.responderEphemeral,
|
|
122
|
+
transcript.responderNonce,
|
|
123
|
+
transcript.instancePublicKey,
|
|
124
|
+
utf8(transcript.binding.runtimeId),
|
|
125
|
+
utf8(transcript.binding.generation),
|
|
126
|
+
transcript.certificate,
|
|
127
|
+
]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One key per direction from the handshake's shared secret, salted with the transcript hash. */
|
|
131
|
+
export function deriveFrameKeys(sharedSecret: Uint8Array, th: Uint8Array): ILinkFrameKeys {
|
|
132
|
+
const keys = hkdf(sharedSecret, th, LINK_FRAME_KEYS_LABEL, 2 * KEY_BYTES);
|
|
133
|
+
try {
|
|
134
|
+
return {
|
|
135
|
+
initiatorToResponder: Buffer.from(keys.subarray(0, KEY_BYTES)),
|
|
136
|
+
responderToInitiator: Buffer.from(keys.subarray(KEY_BYTES)),
|
|
137
|
+
};
|
|
138
|
+
} finally {
|
|
139
|
+
wipe(keys);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function frameKeysFor(
|
|
144
|
+
ephemeral: IKeyPair,
|
|
145
|
+
peerEphemeral: Uint8Array,
|
|
146
|
+
th: Uint8Array,
|
|
147
|
+
): ILinkFrameKeys {
|
|
148
|
+
const shared = agree(ephemeral.privateKey, peerEphemeral);
|
|
149
|
+
try {
|
|
150
|
+
return deriveFrameKeys(shared, th);
|
|
151
|
+
} finally {
|
|
152
|
+
wipe(shared);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
interface IChallenge {
|
|
157
|
+
readonly responderEphemeral: Uint8Array;
|
|
158
|
+
readonly responderNonce: Uint8Array;
|
|
159
|
+
readonly instancePublicKey: Uint8Array;
|
|
160
|
+
readonly binding: ILinkBinding;
|
|
161
|
+
readonly certificate: Uint8Array;
|
|
162
|
+
readonly runtimeProof: Uint8Array;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function encodeChallenge(challenge: IChallenge): Buffer {
|
|
166
|
+
return Buffer.concat([
|
|
167
|
+
challenge.responderEphemeral,
|
|
168
|
+
challenge.responderNonce,
|
|
169
|
+
challenge.instancePublicKey,
|
|
170
|
+
concatLengthPrefixed([
|
|
171
|
+
utf8(challenge.binding.runtimeId),
|
|
172
|
+
utf8(challenge.binding.generation),
|
|
173
|
+
challenge.certificate,
|
|
174
|
+
]),
|
|
175
|
+
challenge.runtimeProof,
|
|
176
|
+
]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The challenge `bytes` encodes for an initiator in `mode`, or `malformed`: every length is exact. */
|
|
180
|
+
function decodeChallenge(bytes: unknown, mode: TLinkMode): IChallenge {
|
|
181
|
+
if (!isBytes(bytes) || bytes.length > LINK_CHALLENGE_MAX_BYTES)
|
|
182
|
+
throw new LinkRefusedError('malformed');
|
|
183
|
+
let offset = 0;
|
|
184
|
+
const take = (length: number): Uint8Array => {
|
|
185
|
+
if (offset + length > bytes.length) throw new LinkRefusedError('malformed');
|
|
186
|
+
const part = Uint8Array.from(bytes.subarray(offset, offset + length));
|
|
187
|
+
offset += length;
|
|
188
|
+
return part;
|
|
189
|
+
};
|
|
190
|
+
const takePrefixed = (): Uint8Array => {
|
|
191
|
+
const prefix = take(LENGTH_PREFIX_BYTES);
|
|
192
|
+
return take((prefix[0]! << 8) | prefix[1]!);
|
|
193
|
+
};
|
|
194
|
+
const responderEphemeral = take(KEY_BYTES);
|
|
195
|
+
const responderNonce = take(LINK_NONCE_BYTES);
|
|
196
|
+
const instancePublicKey = take(KEY_BYTES);
|
|
197
|
+
const runtimeId = decodeBindingPart(takePrefixed());
|
|
198
|
+
const generation = decodeBindingPart(takePrefixed());
|
|
199
|
+
const certificate = takePrefixed();
|
|
200
|
+
const runtimeProof = take(bytes.length - offset);
|
|
201
|
+
if (
|
|
202
|
+
runtimeId === undefined ||
|
|
203
|
+
generation === undefined ||
|
|
204
|
+
certificate.length !== CERTIFICATE_BYTES[mode] ||
|
|
205
|
+
runtimeProof.length !== RUNTIME_PROOF_BYTES[mode]
|
|
206
|
+
) {
|
|
207
|
+
throw new LinkRefusedError('malformed');
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
responderEphemeral,
|
|
211
|
+
responderNonce,
|
|
212
|
+
instancePublicKey,
|
|
213
|
+
binding: { runtimeId, generation },
|
|
214
|
+
certificate,
|
|
215
|
+
runtimeProof,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function decodeHello(bytes: unknown): {
|
|
220
|
+
readonly mode: TLinkMode;
|
|
221
|
+
readonly ephemeral: Uint8Array;
|
|
222
|
+
readonly nonce: Uint8Array;
|
|
223
|
+
} {
|
|
224
|
+
if (!isBytes(bytes) || bytes.length !== LINK_HELLO_BYTES || bytes[0] !== LINK_PROTOCOL_VERSION) {
|
|
225
|
+
throw new LinkRefusedError('malformed');
|
|
226
|
+
}
|
|
227
|
+
const mode =
|
|
228
|
+
bytes[1] === MODE_BYTES.spawner
|
|
229
|
+
? 'spawner'
|
|
230
|
+
: bytes[1] === MODE_BYTES.owner
|
|
231
|
+
? 'owner'
|
|
232
|
+
: undefined;
|
|
233
|
+
if (mode === undefined) throw new LinkRefusedError('malformed');
|
|
234
|
+
return {
|
|
235
|
+
mode,
|
|
236
|
+
ephemeral: Uint8Array.from(bytes.subarray(2, 2 + KEY_BYTES)),
|
|
237
|
+
nonce: Uint8Array.from(bytes.subarray(2 + KEY_BYTES)),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The client side of one handshake. */
|
|
242
|
+
export interface ILinkInitiator {
|
|
243
|
+
readonly mode: TLinkMode;
|
|
244
|
+
/** The first message: the version, the mode, a fresh ephemeral and a fresh nonce. Once. */
|
|
245
|
+
hello(): Uint8Array;
|
|
246
|
+
/**
|
|
247
|
+
* Checks the runtime's challenge and, only if every check passed, returns the proof to send and
|
|
248
|
+
* the established link. Throws `LinkRefusedError` otherwise. Once, whatever the outcome.
|
|
249
|
+
*/
|
|
250
|
+
answer(challenge: Uint8Array): { readonly proof: Uint8Array; readonly link: IInitiatorLink };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
type TInitiatorState =
|
|
254
|
+
| { readonly step: 'new' }
|
|
255
|
+
| {
|
|
256
|
+
readonly step: 'waiting';
|
|
257
|
+
readonly ephemeral: IKeyPair;
|
|
258
|
+
readonly nonce: Uint8Array;
|
|
259
|
+
}
|
|
260
|
+
| { readonly step: 'spent' };
|
|
261
|
+
|
|
262
|
+
class LinkInitiator implements ILinkInitiator {
|
|
263
|
+
readonly mode: TLinkMode;
|
|
264
|
+
readonly #auth: TLinkInitiatorAuth;
|
|
265
|
+
readonly #entropy: ILinkEntropy;
|
|
266
|
+
readonly #expectedInstance: Uint8Array | undefined;
|
|
267
|
+
#state: TInitiatorState = { step: 'new' };
|
|
268
|
+
|
|
269
|
+
constructor(auth: TLinkInitiatorAuth, entropy: ILinkEntropy) {
|
|
270
|
+
if (auth?.mode === 'spawner') {
|
|
271
|
+
spawnerKeyBytes(auth.spawnerKey);
|
|
272
|
+
this.#expectedInstance = Uint8Array.from(requireInstanceKey(auth.instancePublicKey));
|
|
273
|
+
} else if (auth?.mode === 'owner') {
|
|
274
|
+
ownerHandoverSecrets(auth.handover);
|
|
275
|
+
} else {
|
|
276
|
+
throw new KeyringError('invalid-argument', 'A link initiator is a spawner or an owner.');
|
|
277
|
+
}
|
|
278
|
+
this.mode = auth.mode;
|
|
279
|
+
this.#auth = auth;
|
|
280
|
+
this.#entropy = entropy;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
hello(): Uint8Array {
|
|
284
|
+
if (this.#state.step !== 'new') throw new LinkRefusedError('out-of-order');
|
|
285
|
+
const ephemeral = ephemeralKeyPair(this.#entropy);
|
|
286
|
+
const nonce = this.#entropy.nonce();
|
|
287
|
+
this.#state = { step: 'waiting', ephemeral, nonce };
|
|
288
|
+
return Buffer.concat([
|
|
289
|
+
Buffer.from([LINK_PROTOCOL_VERSION, MODE_BYTES[this.mode]]),
|
|
290
|
+
ephemeral.publicKey,
|
|
291
|
+
nonce,
|
|
292
|
+
]);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
answer(challenge: Uint8Array): { readonly proof: Uint8Array; readonly link: IInitiatorLink } {
|
|
296
|
+
const state = this.#state;
|
|
297
|
+
if (state.step !== 'waiting') throw new LinkRefusedError('out-of-order');
|
|
298
|
+
this.#state = { step: 'spent' };
|
|
299
|
+
const answer = decodeChallenge(challenge, this.mode);
|
|
300
|
+
if (
|
|
301
|
+
this.#auth.mode === 'spawner' &&
|
|
302
|
+
!sameBytes(answer.instancePublicKey, this.#expectedInstance!)
|
|
303
|
+
) {
|
|
304
|
+
throw new LinkRefusedError('instance-mismatch');
|
|
305
|
+
}
|
|
306
|
+
if (this.#auth.mode === 'owner') {
|
|
307
|
+
const { attach } = ownerHandoverSecrets(this.#auth.handover);
|
|
308
|
+
const root = { attachPublicKey: attach.publicKey, certificate: answer.certificate };
|
|
309
|
+
if (!certifies(root, answer.instancePublicKey, answer.binding)) {
|
|
310
|
+
throw new LinkRefusedError('certificate-mismatch');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const th = transcriptHash({
|
|
314
|
+
mode: this.mode,
|
|
315
|
+
initiatorEphemeral: state.ephemeral.publicKey,
|
|
316
|
+
initiatorNonce: state.nonce,
|
|
317
|
+
responderEphemeral: answer.responderEphemeral,
|
|
318
|
+
responderNonce: answer.responderNonce,
|
|
319
|
+
instancePublicKey: answer.instancePublicKey,
|
|
320
|
+
binding: answer.binding,
|
|
321
|
+
certificate: answer.certificate,
|
|
322
|
+
});
|
|
323
|
+
const signed = Buffer.concat([RESPONDER_TAG, th]);
|
|
324
|
+
if (this.#auth.mode === 'spawner') {
|
|
325
|
+
const spawnerKey = spawnerKeyBytes(this.#auth.spawnerKey);
|
|
326
|
+
const macMatches = sameBytes(
|
|
327
|
+
hmac(spawnerKey, RESPONDER_TAG, th),
|
|
328
|
+
answer.runtimeProof.subarray(0, HMAC_BYTES),
|
|
329
|
+
);
|
|
330
|
+
const signatureMatches = verifyEd25519(
|
|
331
|
+
answer.instancePublicKey,
|
|
332
|
+
signed,
|
|
333
|
+
answer.runtimeProof.subarray(HMAC_BYTES),
|
|
334
|
+
);
|
|
335
|
+
if (!macMatches || !signatureMatches) throw new LinkRefusedError('bad-runtime-proof');
|
|
336
|
+
} else if (!verifyEd25519(answer.instancePublicKey, signed, answer.runtimeProof)) {
|
|
337
|
+
throw new LinkRefusedError('bad-runtime-proof');
|
|
338
|
+
}
|
|
339
|
+
// Every check passed: from here on the initiator may produce what depends on its secret.
|
|
340
|
+
const keys = frameKeysFor(state.ephemeral, answer.responderEphemeral, th);
|
|
341
|
+
const proof =
|
|
342
|
+
this.#auth.mode === 'spawner'
|
|
343
|
+
? hmac(spawnerKeyBytes(this.#auth.spawnerKey), INITIATOR_TAG, th)
|
|
344
|
+
: signEd25519(
|
|
345
|
+
ownerHandoverSecrets(this.#auth.handover).attach.privateKey,
|
|
346
|
+
Buffer.concat([INITIATOR_TAG, th]),
|
|
347
|
+
);
|
|
348
|
+
const handover = this.#auth.mode === 'owner' ? this.#auth.handover : undefined;
|
|
349
|
+
return {
|
|
350
|
+
proof,
|
|
351
|
+
link: new InitiatorLink(this.mode, answer.binding, answer.instancePublicKey, keys, handover),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** {@link createLinkInitiator} drawing from `entropy`; the package's own tests pass fixed values. */
|
|
357
|
+
export function createLinkInitiatorWith(
|
|
358
|
+
auth: TLinkInitiatorAuth,
|
|
359
|
+
entropy: ILinkEntropy,
|
|
360
|
+
): ILinkInitiator {
|
|
361
|
+
return new LinkInitiator(auth, entropy);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The client side of a handshake. A wiped spawner key or a disposed handover is refused here, before
|
|
366
|
+
* any message exists.
|
|
367
|
+
*/
|
|
368
|
+
export function createLinkInitiator(auth: TLinkInitiatorAuth): ILinkInitiator {
|
|
369
|
+
return createLinkInitiatorWith(auth, SYSTEM_LINK_ENTROPY);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The runtime side of one handshake. */
|
|
373
|
+
export interface ILinkResponder {
|
|
374
|
+
/**
|
|
375
|
+
* Answers a hello, or throws `LinkRefusedError`: a malformed hello, spawner mode without the
|
|
376
|
+
* spawner key, or owner mode without a trust root for this binding. Once.
|
|
377
|
+
*/
|
|
378
|
+
challenge(hello: Uint8Array): Uint8Array;
|
|
379
|
+
/** Checks the proof; the established link and the standing it earned. Once. */
|
|
380
|
+
accept(proof: Uint8Array): { readonly standing: TLinkMode; readonly link: IResponderLink };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
interface IPendingResponse {
|
|
384
|
+
readonly mode: TLinkMode;
|
|
385
|
+
readonly th: Buffer;
|
|
386
|
+
readonly keys: ILinkFrameKeys;
|
|
387
|
+
/** Spawner mode: the proof only a spawner-key holder can make. */
|
|
388
|
+
readonly expectedProof?: Buffer;
|
|
389
|
+
/** Owner mode: the attach key the proof must verify under. */
|
|
390
|
+
readonly attachPublicKey?: Uint8Array;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
class LinkResponder implements ILinkResponder {
|
|
394
|
+
readonly #identity: IRuntimeIdentity;
|
|
395
|
+
readonly #binding: ILinkBinding;
|
|
396
|
+
#step: 'new' | 'waiting' | 'spent' = 'new';
|
|
397
|
+
#pending: IPendingResponse | undefined;
|
|
398
|
+
|
|
399
|
+
constructor(identity: IRuntimeIdentity, binding: ILinkBinding) {
|
|
400
|
+
runtimeIdentitySecrets(identity);
|
|
401
|
+
this.#identity = identity;
|
|
402
|
+
this.#binding = checkLinkBinding(binding);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
challenge(hello: Uint8Array): Uint8Array {
|
|
406
|
+
if (this.#step !== 'new') throw new LinkRefusedError('out-of-order');
|
|
407
|
+
this.#step = 'spent';
|
|
408
|
+
const { mode, ephemeral: initiatorEphemeral, nonce: initiatorNonce } = decodeHello(hello);
|
|
409
|
+
const secrets = runtimeIdentitySecrets(this.#identity);
|
|
410
|
+
const spawnerKey = secrets.spawnerKey();
|
|
411
|
+
const trust = secrets.trust();
|
|
412
|
+
if (mode === 'spawner' && spawnerKey === undefined) {
|
|
413
|
+
throw new LinkRefusedError('spawner-unavailable');
|
|
414
|
+
}
|
|
415
|
+
if (
|
|
416
|
+
mode === 'owner' &&
|
|
417
|
+
(trust === undefined ||
|
|
418
|
+
trust.binding.runtimeId !== this.#binding.runtimeId ||
|
|
419
|
+
trust.binding.generation !== this.#binding.generation)
|
|
420
|
+
) {
|
|
421
|
+
throw new LinkRefusedError('not-certified');
|
|
422
|
+
}
|
|
423
|
+
const ephemeral = ephemeralKeyPair(secrets.entropy);
|
|
424
|
+
const responderNonce = secrets.entropy.nonce();
|
|
425
|
+
const certificate = mode === 'owner' ? trust!.root.certificate : new Uint8Array(0);
|
|
426
|
+
const th = transcriptHash({
|
|
427
|
+
mode,
|
|
428
|
+
initiatorEphemeral,
|
|
429
|
+
initiatorNonce,
|
|
430
|
+
responderEphemeral: ephemeral.publicKey,
|
|
431
|
+
responderNonce,
|
|
432
|
+
instancePublicKey: secrets.instance.publicKey,
|
|
433
|
+
binding: this.#binding,
|
|
434
|
+
certificate,
|
|
435
|
+
});
|
|
436
|
+
const keys = frameKeysFor(ephemeral, initiatorEphemeral, th);
|
|
437
|
+
const signature = signEd25519(secrets.instance.privateKey, Buffer.concat([RESPONDER_TAG, th]));
|
|
438
|
+
const runtimeProof =
|
|
439
|
+
mode === 'spawner'
|
|
440
|
+
? Buffer.concat([hmac(spawnerKey!, RESPONDER_TAG, th), signature])
|
|
441
|
+
: signature;
|
|
442
|
+
this.#pending = {
|
|
443
|
+
mode,
|
|
444
|
+
th,
|
|
445
|
+
keys,
|
|
446
|
+
...(mode === 'spawner'
|
|
447
|
+
? { expectedProof: hmac(spawnerKey!, INITIATOR_TAG, th) }
|
|
448
|
+
: { attachPublicKey: Uint8Array.from(trust!.root.attachPublicKey) }),
|
|
449
|
+
};
|
|
450
|
+
this.#step = 'waiting';
|
|
451
|
+
return encodeChallenge({
|
|
452
|
+
responderEphemeral: ephemeral.publicKey,
|
|
453
|
+
responderNonce,
|
|
454
|
+
instancePublicKey: secrets.instance.publicKey,
|
|
455
|
+
binding: this.#binding,
|
|
456
|
+
certificate,
|
|
457
|
+
runtimeProof,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
accept(proof: Uint8Array): { readonly standing: TLinkMode; readonly link: IResponderLink } {
|
|
462
|
+
const pending = this.#pending;
|
|
463
|
+
if (this.#step !== 'waiting' || pending === undefined)
|
|
464
|
+
throw new LinkRefusedError('out-of-order');
|
|
465
|
+
this.#pending = undefined;
|
|
466
|
+
this.#step = 'spent';
|
|
467
|
+
try {
|
|
468
|
+
if (!isBytes(proof) || proof.length !== PROOF_BYTES[pending.mode]) {
|
|
469
|
+
throw new LinkRefusedError('malformed');
|
|
470
|
+
}
|
|
471
|
+
if (pending.mode === 'spawner') {
|
|
472
|
+
// The spawner key may have been forgotten since the challenge: certification ends spawner standing.
|
|
473
|
+
if (runtimeIdentitySecrets(this.#identity).spawnerKey() === undefined) {
|
|
474
|
+
throw new LinkRefusedError('spawner-unavailable');
|
|
475
|
+
}
|
|
476
|
+
if (!sameBytes(pending.expectedProof!, proof)) throw new LinkRefusedError('bad-proof');
|
|
477
|
+
} else if (
|
|
478
|
+
!verifyEd25519(pending.attachPublicKey!, Buffer.concat([INITIATOR_TAG, pending.th]), proof)
|
|
479
|
+
) {
|
|
480
|
+
throw new LinkRefusedError('bad-proof');
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
wipe(
|
|
484
|
+
pending.keys.initiatorToResponder,
|
|
485
|
+
pending.keys.responderToInitiator,
|
|
486
|
+
pending.expectedProof,
|
|
487
|
+
);
|
|
488
|
+
throw error;
|
|
489
|
+
}
|
|
490
|
+
wipe(pending.expectedProof);
|
|
491
|
+
const instancePublicKey = runtimeIdentitySecrets(this.#identity).instance.publicKey;
|
|
492
|
+
return {
|
|
493
|
+
standing: pending.mode,
|
|
494
|
+
link: new ResponderLink(pending.mode, this.#binding, instancePublicKey, pending.keys),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* The runtime side of a handshake, for `identity` at the runtime start `binding` names. Which
|
|
501
|
+
* standing may then do what is the runtime custody's decision, not this module's.
|
|
502
|
+
*/
|
|
503
|
+
export function createLinkResponder(
|
|
504
|
+
identity: IRuntimeIdentity,
|
|
505
|
+
binding: ILinkBinding,
|
|
506
|
+
): ILinkResponder {
|
|
507
|
+
return new LinkResponder(identity, binding);
|
|
508
|
+
}
|
package/src/read-keys.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What reading needs, and nothing that proves ownership: the X25519 sealing keypair, which opens
|
|
3
|
+
* sealed artifact keys, and the authorship key, which tags and checks what the owner wrote. An
|
|
4
|
+
* unlock yields exactly this, and so does a read grant on an owner link. Neither carries the master
|
|
5
|
+
* key or the attach keypair, so a holder of read keys can read and vouch but can never prove itself
|
|
6
|
+
* the owner to a verifier, certify a runtime or re-wrap the master key.
|
|
7
|
+
*/
|
|
8
|
+
import { authorshipTag, verifyAuthorshipTag } from './authorship.js';
|
|
9
|
+
import { KeyringError } from './errors.js';
|
|
10
|
+
import type { IKeyPair } from './key-pairs.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `lock()` drops this object's references to the keys, zero-fills the authorship key (plain bytes
|
|
14
|
+
* held only here) and makes every accessor and operation throw `keys-locked`. It does NOT zero the
|
|
15
|
+
* sealing keypair: a `KeyObject` offers no way to overwrite its contents, so the private key stays
|
|
16
|
+
* in memory until the garbage collector frees it, and any reference a caller copied out earlier
|
|
17
|
+
* stays valid. Treat `lock()` as "stop handing out keys", not as erasure.
|
|
18
|
+
*/
|
|
19
|
+
export interface IReadKeys {
|
|
20
|
+
readonly locked: boolean;
|
|
21
|
+
/** X25519 keypair that opens sealed artifact keys. */
|
|
22
|
+
readonly sealing: IKeyPair;
|
|
23
|
+
/**
|
|
24
|
+
* The HMAC-SHA256 tag of `message` under the authorship key, which has no public half: what an
|
|
25
|
+
* unlocked process attaches to what it wrote, since a sealed artifact opens for whoever sealed it.
|
|
26
|
+
* The key itself is never handed out.
|
|
27
|
+
*/
|
|
28
|
+
tagAuthorship(message: Uint8Array): Uint8Array;
|
|
29
|
+
/** Whether `tag` is {@link tagAuthorship} of `message`, compared in constant time. */
|
|
30
|
+
verifyAuthorship(message: Uint8Array, tag: Uint8Array): boolean;
|
|
31
|
+
lock(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface IReadKeyMaterial {
|
|
35
|
+
readonly sealing: IKeyPair;
|
|
36
|
+
readonly authorship: Uint8Array;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class ReadKeys implements IReadKeys {
|
|
40
|
+
#material: IReadKeyMaterial | undefined;
|
|
41
|
+
|
|
42
|
+
constructor(sealing: IKeyPair, authorship: Uint8Array) {
|
|
43
|
+
this.#material = { sealing, authorship };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get locked(): boolean {
|
|
47
|
+
return this.#material === undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get sealing(): IKeyPair {
|
|
51
|
+
return this.#require().sealing;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
tagAuthorship(message: Uint8Array): Uint8Array {
|
|
55
|
+
return authorshipTag(this.#require().authorship, message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
verifyAuthorship(message: Uint8Array, tag: Uint8Array): boolean {
|
|
59
|
+
return verifyAuthorshipTag(this.#require().authorship, message, tag);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lock(): void {
|
|
63
|
+
this.#material?.authorship.fill(0);
|
|
64
|
+
this.#material = undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#require(): IReadKeyMaterial {
|
|
68
|
+
if (this.#material === undefined) {
|
|
69
|
+
throw new KeyringError('keys-locked', 'The keys are locked.');
|
|
70
|
+
}
|
|
71
|
+
return this.#material;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The read keys over `sealing` and `authorship`; the authorship bytes are this object's from now on. */
|
|
76
|
+
export function createReadKeys(sealing: IKeyPair, authorship: Uint8Array): IReadKeys {
|
|
77
|
+
return new ReadKeys(sealing, authorship);
|
|
78
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
|
|
3
|
+
import { KeyringError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
/** 160 random bits: far beyond brute force, and exactly 32 base32 symbols with no padding. */
|
|
6
|
+
export const RECOVERY_KEY_BYTES = 20;
|
|
7
|
+
|
|
8
|
+
const CROCKFORD_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
9
|
+
const BITS_PER_SYMBOL = 5;
|
|
10
|
+
const SYMBOL_COUNT = (RECOVERY_KEY_BYTES * 8) / BITS_PER_SYMBOL;
|
|
11
|
+
const GROUP_SIZE = 4;
|
|
12
|
+
const GROUP_SEPARATOR = '-';
|
|
13
|
+
/** Far longer than any spelling of a valid key; stops a pathological input before it is scanned. */
|
|
14
|
+
const MAX_INPUT_LENGTH = 128;
|
|
15
|
+
// ASCII only: `toUpperCase` would otherwise fold characters such as `ß` or `ı` into valid symbols.
|
|
16
|
+
const TYPED_CHARACTERS = /^[0-9A-Za-z\s-]*$/;
|
|
17
|
+
|
|
18
|
+
function invalidRecoveryKey(): KeyringError {
|
|
19
|
+
return new KeyringError(
|
|
20
|
+
'invalid-recovery-key',
|
|
21
|
+
'The recovery key is not in the expected format.',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Crockford base32 of the key bytes in groups of four: `XXXX-XXXX-...` (32 symbols, 8 groups). */
|
|
26
|
+
export function formatRecoveryKey(bytes: Uint8Array): string {
|
|
27
|
+
if (bytes.length !== RECOVERY_KEY_BYTES) {
|
|
28
|
+
throw new KeyringError('invalid-argument', 'A recovery key is exactly 20 bytes.');
|
|
29
|
+
}
|
|
30
|
+
let symbols = '';
|
|
31
|
+
let accumulator = 0;
|
|
32
|
+
let bits = 0;
|
|
33
|
+
for (const byte of bytes) {
|
|
34
|
+
accumulator = (accumulator << 8) | byte;
|
|
35
|
+
bits += 8;
|
|
36
|
+
while (bits >= BITS_PER_SYMBOL) {
|
|
37
|
+
symbols += CROCKFORD_ALPHABET[(accumulator >>> (bits - BITS_PER_SYMBOL)) & 31];
|
|
38
|
+
bits -= BITS_PER_SYMBOL;
|
|
39
|
+
}
|
|
40
|
+
accumulator &= (1 << bits) - 1;
|
|
41
|
+
}
|
|
42
|
+
const groups: string[] = [];
|
|
43
|
+
for (let index = 0; index < symbols.length; index += GROUP_SIZE) {
|
|
44
|
+
groups.push(symbols.slice(index, index + GROUP_SIZE));
|
|
45
|
+
}
|
|
46
|
+
return groups.join(GROUP_SEPARATOR);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reads a recovery key as a person types it: case-insensitive, with or without the group hyphens
|
|
51
|
+
* or spaces, and with Crockford's look-alike aliases (`O` is `0`; `I` and `L` are `1`).
|
|
52
|
+
*/
|
|
53
|
+
export function parseRecoveryKey(text: unknown): Buffer {
|
|
54
|
+
if (typeof text !== 'string' || text.length > MAX_INPUT_LENGTH || !TYPED_CHARACTERS.test(text)) {
|
|
55
|
+
throw invalidRecoveryKey();
|
|
56
|
+
}
|
|
57
|
+
const symbols = text.toUpperCase().replace(/[\s-]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1');
|
|
58
|
+
if (symbols.length !== SYMBOL_COUNT) throw invalidRecoveryKey();
|
|
59
|
+
const bytes = Buffer.alloc(RECOVERY_KEY_BYTES);
|
|
60
|
+
let accumulator = 0;
|
|
61
|
+
let bits = 0;
|
|
62
|
+
let written = 0;
|
|
63
|
+
for (const symbol of symbols) {
|
|
64
|
+
const value = CROCKFORD_ALPHABET.indexOf(symbol);
|
|
65
|
+
if (value < 0) {
|
|
66
|
+
bytes.fill(0);
|
|
67
|
+
throw invalidRecoveryKey();
|
|
68
|
+
}
|
|
69
|
+
accumulator = (accumulator << BITS_PER_SYMBOL) | value;
|
|
70
|
+
bits += BITS_PER_SYMBOL;
|
|
71
|
+
if (bits >= 8) {
|
|
72
|
+
bytes[written] = (accumulator >>> (bits - 8)) & 0xff;
|
|
73
|
+
written += 1;
|
|
74
|
+
bits -= 8;
|
|
75
|
+
accumulator &= (1 << bits) - 1;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return bytes;
|
|
79
|
+
}
|