@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,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared pieces of the owner-link tests: entropy that hands out fixed values in order, so a whole
|
|
3
|
+
* exchange or handshake reproduces byte for byte, and the known answers those fixed values give.
|
|
4
|
+
* The answers were computed outside this package (`hashlib`/`hmac` in Python for SHA-256, HMAC and
|
|
5
|
+
* HKDF; the openssl CLI for X25519 and Ed25519), never by the code under test.
|
|
6
|
+
*/
|
|
7
|
+
import { Buffer } from 'node:buffer';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
LinkRefusedError,
|
|
11
|
+
createLinkResponder,
|
|
12
|
+
createRuntimeIdentity,
|
|
13
|
+
startSpawnerExchange,
|
|
14
|
+
type ILinkBinding,
|
|
15
|
+
type IOwnerHandover,
|
|
16
|
+
type IRuntimeIdentity,
|
|
17
|
+
type SpawnerKey,
|
|
18
|
+
type TLinkRefusalReason,
|
|
19
|
+
} from '../index.js';
|
|
20
|
+
import type { ILinkEntropy } from '../link-entropy.js';
|
|
21
|
+
import { createLinkInitiatorWith } from '../owner-link.js';
|
|
22
|
+
import { createRuntimeIdentityWith } from '../runtime-identity.js';
|
|
23
|
+
import { startSpawnerExchangeWith } from '../spawner-exchange.js';
|
|
24
|
+
import { KAT_BINDING, KAT_INSTANCE_SEED } from './helpers.js';
|
|
25
|
+
|
|
26
|
+
/** 32 bytes counting up from `start`, wrapping at 0xff. */
|
|
27
|
+
export function sequence(start: number): Buffer {
|
|
28
|
+
return Buffer.from(Array.from({ length: 32 }, (_, i) => (start + i) & 0xff));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Entropy that hands out `seeds` and `nonces` in order and fails loudly when one more is asked. */
|
|
32
|
+
export function fixedEntropy(
|
|
33
|
+
seeds: readonly Uint8Array[],
|
|
34
|
+
nonces: readonly Uint8Array[] = [],
|
|
35
|
+
): ILinkEntropy {
|
|
36
|
+
const pendingSeeds = [...seeds];
|
|
37
|
+
const pendingNonces = [...nonces];
|
|
38
|
+
return {
|
|
39
|
+
seed: () => {
|
|
40
|
+
const next = pendingSeeds.shift();
|
|
41
|
+
if (next === undefined) throw new Error('The test entropy has no seed left.');
|
|
42
|
+
return Buffer.from(next);
|
|
43
|
+
},
|
|
44
|
+
nonce: () => {
|
|
45
|
+
const next = pendingNonces.shift();
|
|
46
|
+
if (next === undefined) throw new Error('The test entropy has no nonce left.');
|
|
47
|
+
return Buffer.from(next);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The reason of the LinkRefusedError a call throws; anything else (or nothing) fails the test. */
|
|
53
|
+
export function refusalOf(call: () => unknown): TLinkRefusalReason {
|
|
54
|
+
try {
|
|
55
|
+
call();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof LinkRefusedError) return error.reason;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
throw new Error('Expected a LinkRefusedError but nothing was thrown.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const KAT_SPAWNER_EPHEMERAL_SEED = sequence(0x60);
|
|
64
|
+
export const KAT_RUNTIME_EXCHANGE_SEED = sequence(0x80);
|
|
65
|
+
export const KAT_EXCHANGE = {
|
|
66
|
+
spawnerEphemeral: '675dd574ed7789310b3d2e7681f3790b466c773b1521fecf36577958371ea52f',
|
|
67
|
+
runtimeEphemeral: '493e82fc74464a59268817623d2053c5eb8e2cc4a988b4fee179ec6b010d531d',
|
|
68
|
+
spawnerKey: 'e8d7dcca8e52899522ec48f0bd50f0734bc8627b140585247d9d1b91ba68c653',
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
/** The handshake known answers: one spawner-mode link and one owner-mode link, from fixed values. */
|
|
72
|
+
export const KAT_SPAWNER_LINK = {
|
|
73
|
+
initiatorSeed: sequence(0xa0),
|
|
74
|
+
initiatorNonce: sequence(0xc0),
|
|
75
|
+
responderSeed: sequence(0xe0),
|
|
76
|
+
responderNonce: sequence(0x20),
|
|
77
|
+
hello:
|
|
78
|
+
'0101605a725d2a4adfeeb1a29e17edd621c1b7593ee8cdbc44ac6c4ab6e2f805d23c' +
|
|
79
|
+
'c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf',
|
|
80
|
+
challenge:
|
|
81
|
+
'736845d54e87de09d6bb114aa7042c50a4a015bd9901d1a0026f5956533a1519' +
|
|
82
|
+
'202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f' +
|
|
83
|
+
'2543b92ff1095511476adc8369db6ddc933665a11978dda1404ee1066ca9559d' +
|
|
84
|
+
'002436663163326433652d346235612d346336642d386539662d306131623263336434653566' +
|
|
85
|
+
'001641414543417751464267634943516f4c4441304f4477' +
|
|
86
|
+
'0000' +
|
|
87
|
+
'5cd5b97e480b37dd79b6d9cf1d98de416062dd858b8920f264554b51d7481b74' +
|
|
88
|
+
'5d22d0b218d98cf0a8f3e686ce72e0fd042ef84c54e1f89e11da385a5daf7b3d' +
|
|
89
|
+
'ee8c83b2f8015e67bfb94d3e12814c3b005df22f176e33912e8aa035c17df70a',
|
|
90
|
+
transcript: '06c80cc6fd3a098978bcb57586d9ba0966890a19703cf45a18cdb2388a296fbf',
|
|
91
|
+
proof: '539839f64666e0b3b330be6249bbd8d59e599f05d96ac678df0c5f047593450f',
|
|
92
|
+
sharedSecret: '51a02c31bd599b54edea2134e80353bfca478ae1f0fb1ba591fbf897b05c405f',
|
|
93
|
+
initiatorToResponder: 'cb434544cffa05f05912c2e690a535309d8496af4cc4f0329818464c1de8830f',
|
|
94
|
+
responderToInitiator: '1d6c37e831cbbb60301ed4e54b61f15e8218c34c626855ffa6003f262914cea5',
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
export const KAT_OWNER_LINK = {
|
|
98
|
+
initiatorSeed: sequence(0xa1),
|
|
99
|
+
initiatorNonce: sequence(0xc1),
|
|
100
|
+
responderSeed: sequence(0xe1),
|
|
101
|
+
responderNonce: sequence(0x21),
|
|
102
|
+
hello:
|
|
103
|
+
'0102ad438bfae31f6c093d61d4339255ea798092c9fadd07b97827f4b0ae9dee7c1c' +
|
|
104
|
+
'c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0',
|
|
105
|
+
challenge:
|
|
106
|
+
'489cebba7a166c619835fe12eedc581dbfc1b36de10c830e4f0bc1c48734864b' +
|
|
107
|
+
'2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40' +
|
|
108
|
+
'2543b92ff1095511476adc8369db6ddc933665a11978dda1404ee1066ca9559d' +
|
|
109
|
+
'002436663163326433652d346235612d346336642d386539662d306131623263336434653566' +
|
|
110
|
+
'001641414543417751464267634943516f4c4441304f4477' +
|
|
111
|
+
'0040' +
|
|
112
|
+
'9d76a228a4824881c96050ff31eaa9287dc99efe238c592aab67189ef16fe5b6' +
|
|
113
|
+
'ad90e67b84f5334e771534ec0496d9a5edeae8568c0f92d73a3ae252638e1302' +
|
|
114
|
+
'595382521207ed1d43178e1b95191cb8676dc5bbaaed2453326c7df7cba87b88' +
|
|
115
|
+
'de84006bbb2d1121519dbeba596f20ac2bb8fbf4dd063196174c22d613ce0103',
|
|
116
|
+
transcript: 'b9d0be57c48f0cfeeba4c004672f324e5e2083f3b504bd1d44ab5495cae97f81',
|
|
117
|
+
proof:
|
|
118
|
+
'66cd836dcf12769b9f8004fe3ba3c5a3fdfd63e1cc2706fb95bc5b4dbfbd38b2' +
|
|
119
|
+
'c61b2cd152139e17f3a83cefc666dfbf6ba442ffd77d288d76853fe1996c4e03',
|
|
120
|
+
sharedSecret: '0eb805a3d105741c8625a1f551ba6b02071b0dacf60ada2af52f437afc4c454b',
|
|
121
|
+
initiatorToResponder: '1e49410e7705745b120eb65fc7675e7f2a3b6176eb13bb6e8be0aa9fab1b77f4',
|
|
122
|
+
responderToInitiator: '6115ebe525f12f555191035f1ee5dbe1c46640d2fae6970268449dd0efe7dcb8',
|
|
123
|
+
} as const;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sealed frames under the links above, at the counters given, computed with raw `node:crypto` under
|
|
127
|
+
* both Node (OpenSSL) and Bun (BoringSSL), which agree byte for byte.
|
|
128
|
+
*/
|
|
129
|
+
export const KAT_FRAMES = {
|
|
130
|
+
/** Spawner link, initiator to responder, counter 0. */
|
|
131
|
+
session: {
|
|
132
|
+
plaintext: 'robota/link known-answer session frame',
|
|
133
|
+
sealed:
|
|
134
|
+
'01fc58f7ba9cc8b2cb80becfc868e2ead7e47f01bcc7dc33456bb63489e1ec3b5bbed14e99ab1c00e23e9360be228a55cd3ef7d57f453a',
|
|
135
|
+
},
|
|
136
|
+
/** Spawner link, initiator to responder, counter 1. */
|
|
137
|
+
owner: {
|
|
138
|
+
plaintext: 'robota/link known-answer owner frame',
|
|
139
|
+
sealed:
|
|
140
|
+
'021eef447be2054082373721097e4c4e19fa759dea43806cda8bd33cd7b3839fca4c04e1777e85abed456627fb222805debbba501b',
|
|
141
|
+
},
|
|
142
|
+
/** Spawner link, responder to initiator, counter 0. */
|
|
143
|
+
ownerReply: {
|
|
144
|
+
plaintext: 'robota/link known-answer reply',
|
|
145
|
+
sealed:
|
|
146
|
+
'0290ecb3b79642a2f4645e6e2bae50f0bf0bf54d951f4588f77ce85e672a171dd845754e42ea8615740dec5253e0e0',
|
|
147
|
+
},
|
|
148
|
+
/** Owner link, initiator to responder, counter 0: the fixture keyring's read grant. */
|
|
149
|
+
grant: {
|
|
150
|
+
sealed:
|
|
151
|
+
'03eb5c10891695ed702816ead24afe1b6f2feb114b1a146adb8b0f32304e2b289bba493055e01e173516c2912e527b1d148d7d11ba240792e1b5d042dd010a169677552aa2a64e6f8d51c256d3ed4d90406b',
|
|
152
|
+
},
|
|
153
|
+
} as const;
|
|
154
|
+
|
|
155
|
+
/** The KAT runtime: fixed instance key, exchange and handshake ephemerals, and handshake nonce. */
|
|
156
|
+
export function katRuntime(link: typeof KAT_SPAWNER_LINK | typeof KAT_OWNER_LINK) {
|
|
157
|
+
const identity = createRuntimeIdentityWith(
|
|
158
|
+
fixedEntropy(
|
|
159
|
+
[KAT_INSTANCE_SEED, KAT_RUNTIME_EXCHANGE_SEED, link.responderSeed],
|
|
160
|
+
[link.responderNonce],
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
const exchange = startSpawnerExchangeWith(fixedEntropy([KAT_SPAWNER_EPHEMERAL_SEED]));
|
|
164
|
+
const result = exchange.complete(identity.answerSpawnerExchange(exchange.message));
|
|
165
|
+
return { identity, ...result };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The fixed-value spawner link, established: every message of its handshake and both ends. */
|
|
169
|
+
export function katSpawnerLink() {
|
|
170
|
+
const { identity, spawnerKey, instancePublicKey } = katRuntime(KAT_SPAWNER_LINK);
|
|
171
|
+
const initiator = createLinkInitiatorWith(
|
|
172
|
+
{ mode: 'spawner', spawnerKey, instancePublicKey },
|
|
173
|
+
fixedEntropy([KAT_SPAWNER_LINK.initiatorSeed], [KAT_SPAWNER_LINK.initiatorNonce]),
|
|
174
|
+
);
|
|
175
|
+
const responder = createLinkResponder(identity, KAT_BINDING);
|
|
176
|
+
const hello = initiator.hello();
|
|
177
|
+
const challenge = responder.challenge(hello);
|
|
178
|
+
const { proof, link } = initiator.answer(challenge);
|
|
179
|
+
const accepted = responder.accept(proof);
|
|
180
|
+
return { hello, challenge, proof, initiatorLink: link, ...accepted };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The fixed-value owner link, established between the KAT runtime and `owner`. */
|
|
184
|
+
export function katOwnerLink(owner: IOwnerHandover) {
|
|
185
|
+
const { identity, spawnerKey } = katRuntime(KAT_OWNER_LINK);
|
|
186
|
+
identity.installTrustRoot(owner.certify(identity.instancePublicKey, KAT_BINDING), KAT_BINDING);
|
|
187
|
+
spawnerKey.wipe();
|
|
188
|
+
const initiator = createLinkInitiatorWith(
|
|
189
|
+
{ mode: 'owner', handover: owner },
|
|
190
|
+
fixedEntropy([KAT_OWNER_LINK.initiatorSeed], [KAT_OWNER_LINK.initiatorNonce]),
|
|
191
|
+
);
|
|
192
|
+
const responder = createLinkResponder(identity, KAT_BINDING);
|
|
193
|
+
const hello = initiator.hello();
|
|
194
|
+
const challenge = responder.challenge(hello);
|
|
195
|
+
const { proof, link } = initiator.answer(challenge);
|
|
196
|
+
const accepted = responder.accept(proof);
|
|
197
|
+
return { hello, challenge, proof, initiatorLink: link, ...accepted };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The fixture keyring's sealing seed, the grant's first secret (HKDF in Python). */
|
|
201
|
+
export const KAT_FIXTURE_SEALING_SEED_HEX =
|
|
202
|
+
'f5ae2510a79efe891ac6c38c9fc81f27059ed1f73ebef34fd76afb6671830fad';
|
|
203
|
+
|
|
204
|
+
/** A runtime after its spawner exchange, and what its spawner holds from it. */
|
|
205
|
+
export interface ISpawnedRuntime {
|
|
206
|
+
readonly identity: IRuntimeIdentity;
|
|
207
|
+
readonly spawnerKey: SpawnerKey;
|
|
208
|
+
readonly instancePublicKey: Uint8Array;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A fresh runtime whose exchange with its spawner ran, as at a real start. */
|
|
212
|
+
export function spawnedRuntime(
|
|
213
|
+
identity: IRuntimeIdentity = createRuntimeIdentity(),
|
|
214
|
+
): ISpawnedRuntime {
|
|
215
|
+
const exchange = startSpawnerExchange();
|
|
216
|
+
const { spawnerKey, instancePublicKey } = exchange.complete(
|
|
217
|
+
identity.answerSpawnerExchange(exchange.message),
|
|
218
|
+
);
|
|
219
|
+
return { identity, spawnerKey, instancePublicKey };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A fresh runtime certified by `owner` for `binding`, as after its first unlock. */
|
|
223
|
+
export function certifiedRuntime(owner: IOwnerHandover, binding: ILinkBinding): IRuntimeIdentity {
|
|
224
|
+
const { identity, spawnerKey } = spawnedRuntime();
|
|
225
|
+
identity.installTrustRoot(owner.certify(identity.instancePublicKey, binding), binding);
|
|
226
|
+
spawnerKey.wipe();
|
|
227
|
+
return identity;
|
|
228
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A handover costs one passphrase derivation, the same as an unlock: the attach pair and the read
|
|
3
|
+
* keys come from the one master key it recovers, which is zero-filled before the handover is
|
|
4
|
+
* returned. Kept apart because it replaces the derivation module for the whole file.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
const observed = vi.hoisted(() => ({ derivations: 0, masterKeys: [] as Uint8Array[] }));
|
|
12
|
+
|
|
13
|
+
vi.mock('../kdf.js', async (importOriginal) => {
|
|
14
|
+
const actual = await importOriginal<typeof import('../kdf.js')>();
|
|
15
|
+
return {
|
|
16
|
+
...actual,
|
|
17
|
+
deriveKeyFromPassphrase: (...args: Parameters<typeof actual.deriveKeyFromPassphrase>) => {
|
|
18
|
+
observed.derivations += 1;
|
|
19
|
+
return actual.deriveKeyFromPassphrase(...args);
|
|
20
|
+
},
|
|
21
|
+
hkdf32: (...args: Parameters<typeof actual.hkdf32>) => {
|
|
22
|
+
// The master key is the input of every keyring label's derivation.
|
|
23
|
+
if (typeof args[1] === 'string' && args[1].startsWith('robota/keyring/v1/')) {
|
|
24
|
+
observed.masterKeys.push(args[0]);
|
|
25
|
+
}
|
|
26
|
+
return actual.hkdf32(...args);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const { openOwnerHandover, parseKeyring } = await import('../index.js');
|
|
32
|
+
const { FIXTURE_PASSPHRASE_AS_TYPED } = await import('./helpers.js');
|
|
33
|
+
|
|
34
|
+
const fixture = parseKeyring(
|
|
35
|
+
readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
describe('the handover derivation', () => {
|
|
39
|
+
it('runs one passphrase derivation and wipes the master key it recovered', async () => {
|
|
40
|
+
const handover = await openOwnerHandover(fixture, {
|
|
41
|
+
type: 'passphrase',
|
|
42
|
+
passphrase: FIXTURE_PASSPHRASE_AS_TYPED,
|
|
43
|
+
});
|
|
44
|
+
expect(observed.derivations).toBe(1);
|
|
45
|
+
const masterKeys = observed.masterKeys.filter((key) => key.length === 32);
|
|
46
|
+
expect(masterKeys.length).toBeGreaterThanOrEqual(3);
|
|
47
|
+
for (const key of masterKeys) expect(key.every((byte) => byte === 0)).toBe(true);
|
|
48
|
+
handover.dispose();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disposing a handover zero-fills the two secrets it holds besides the attach keypair: the sealing
|
|
3
|
+
* seed and the authorship key. Kept apart because it wraps the two derivations for the whole file,
|
|
4
|
+
* to see the very buffers the handover keeps.
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
const made = vi.hoisted(() => ({
|
|
12
|
+
sealingSeeds: [] as Uint8Array[],
|
|
13
|
+
authorshipKeys: [] as Uint8Array[],
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('../key-pairs.js', async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import('../key-pairs.js')>();
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
deriveSealingSeed: (...args: Parameters<typeof actual.deriveSealingSeed>) => {
|
|
21
|
+
const seed = actual.deriveSealingSeed(...args);
|
|
22
|
+
made.sealingSeeds.push(seed);
|
|
23
|
+
return seed;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock('../authorship.js', async (importOriginal) => {
|
|
29
|
+
const actual = await importOriginal<typeof import('../authorship.js')>();
|
|
30
|
+
return {
|
|
31
|
+
...actual,
|
|
32
|
+
deriveAuthorshipKey: (...args: Parameters<typeof actual.deriveAuthorshipKey>) => {
|
|
33
|
+
const key = actual.deriveAuthorshipKey(...args);
|
|
34
|
+
made.authorshipKeys.push(key);
|
|
35
|
+
return key;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const { openOwnerHandover, parseKeyring } = await import('../index.js');
|
|
41
|
+
const { FIXTURE_RECOVERY_KEY } = await import('./helpers.js');
|
|
42
|
+
|
|
43
|
+
const fixture = parseKeyring(
|
|
44
|
+
readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
describe('disposing a handover', () => {
|
|
48
|
+
it('zero-fills the sealing seed and the authorship key it holds', async () => {
|
|
49
|
+
const handover = await openOwnerHandover(fixture, {
|
|
50
|
+
type: 'recoveryKey',
|
|
51
|
+
recoveryKey: FIXTURE_RECOVERY_KEY,
|
|
52
|
+
});
|
|
53
|
+
expect(made.sealingSeeds).toHaveLength(1);
|
|
54
|
+
expect(made.authorshipKeys).toHaveLength(1);
|
|
55
|
+
const held = [made.sealingSeeds[0]!, made.authorshipKeys[0]!];
|
|
56
|
+
for (const secret of held) {
|
|
57
|
+
expect(secret).toHaveLength(32);
|
|
58
|
+
expect(secret.some((byte) => byte !== 0)).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
handover.dispose();
|
|
62
|
+
for (const secret of held) expect(secret.every((byte) => byte === 0)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner handover: one unlock that holds the attach key for as long as a command needs it and
|
|
3
|
+
* hands out operations, never keys. Certifying a runtime instance is its one public operation; the
|
|
4
|
+
* certificate is pinned against a signature made outside this package.
|
|
5
|
+
*/
|
|
6
|
+
import { Buffer } from 'node:buffer';
|
|
7
|
+
import { createPublicKey, KeyObject, verify } from 'node:crypto';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { fingerprint, openOwnerHandover, parseKeyring, type IKeyring } from '../index.js';
|
|
14
|
+
import { toBase64Url } from '../bytes.js';
|
|
15
|
+
import { deriveKeyPairs } from '../key-pairs.js';
|
|
16
|
+
import {
|
|
17
|
+
FIXTURE_PASSPHRASE_AS_TYPED,
|
|
18
|
+
FIXTURE_RECOVERY_KEY,
|
|
19
|
+
KAT_BINDING,
|
|
20
|
+
KAT_CERTIFICATE_HEX,
|
|
21
|
+
KAT_CERTIFICATE_NEXT_GENERATION_HEX,
|
|
22
|
+
KAT_INSTANCE_PUBLIC_KEY_HEX,
|
|
23
|
+
KAT_NEXT_GENERATION,
|
|
24
|
+
KNOWN_ATTACH_PUBLIC_KEY_HEX,
|
|
25
|
+
bytesOf,
|
|
26
|
+
codeOf,
|
|
27
|
+
codeOfAsync,
|
|
28
|
+
hex,
|
|
29
|
+
lengthPrefixed,
|
|
30
|
+
reachableValues,
|
|
31
|
+
} from './helpers.js';
|
|
32
|
+
|
|
33
|
+
const fixture = parseKeyring(
|
|
34
|
+
readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
|
|
35
|
+
);
|
|
36
|
+
const recovery = { type: 'recoveryKey', recoveryKey: FIXTURE_RECOVERY_KEY } as const;
|
|
37
|
+
const instance = Buffer.from(KAT_INSTANCE_PUBLIC_KEY_HEX, 'hex');
|
|
38
|
+
|
|
39
|
+
function ed25519FromRaw(raw: Uint8Array): KeyObject {
|
|
40
|
+
return createPublicKey({
|
|
41
|
+
key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), raw]),
|
|
42
|
+
format: 'der',
|
|
43
|
+
type: 'spki',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('openOwnerHandover', () => {
|
|
48
|
+
it('certifies an instance for a binding with the attach key the master key derives', async () => {
|
|
49
|
+
const handover = await openOwnerHandover(fixture, recovery);
|
|
50
|
+
const root = handover.certify(instance, KAT_BINDING);
|
|
51
|
+
expect(hex(root.attachPublicKey)).toBe(KNOWN_ATTACH_PUBLIC_KEY_HEX);
|
|
52
|
+
expect(hex(root.certificate)).toBe(KAT_CERTIFICATE_HEX);
|
|
53
|
+
const message = lengthPrefixed(
|
|
54
|
+
bytesOf('robota/link/v1/runtime-cert'),
|
|
55
|
+
instance,
|
|
56
|
+
bytesOf(KAT_BINDING.runtimeId),
|
|
57
|
+
bytesOf(KAT_BINDING.generation),
|
|
58
|
+
);
|
|
59
|
+
expect(verify(null, message, ed25519FromRaw(root.attachPublicKey), root.certificate)).toBe(
|
|
60
|
+
true,
|
|
61
|
+
);
|
|
62
|
+
const next = handover.certify(instance, { ...KAT_BINDING, generation: KAT_NEXT_GENERATION });
|
|
63
|
+
expect(hex(next.certificate)).toBe(KAT_CERTIFICATE_NEXT_GENERATION_HEX);
|
|
64
|
+
handover.dispose();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('opens with the passphrase too, and refuses a wrong credential as an unlock does', async () => {
|
|
68
|
+
const handover = await openOwnerHandover(fixture, {
|
|
69
|
+
type: 'passphrase',
|
|
70
|
+
passphrase: FIXTURE_PASSPHRASE_AS_TYPED,
|
|
71
|
+
});
|
|
72
|
+
expect(hex(handover.certify(instance, KAT_BINDING).certificate)).toBe(KAT_CERTIFICATE_HEX);
|
|
73
|
+
handover.dispose();
|
|
74
|
+
expect(
|
|
75
|
+
await codeOfAsync(openOwnerHandover(fixture, { type: 'passphrase', passphrase: 'wrong' })),
|
|
76
|
+
).toBe('wrong-credential');
|
|
77
|
+
expect(await codeOfAsync(openOwnerHandover(fixture, { type: 'other' } as never))).toBe(
|
|
78
|
+
'invalid-argument',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('says whether the file holds the derived sealing key, and shows its fingerprint', async () => {
|
|
83
|
+
const intact = await openOwnerHandover(fixture, recovery);
|
|
84
|
+
expect(intact.sealingKeyMatches).toBe(true);
|
|
85
|
+
expect(intact.fingerprint).toBe('3A2E-D129-B98E-02D9-DF09');
|
|
86
|
+
intact.dispose();
|
|
87
|
+
|
|
88
|
+
const attacker = deriveKeyPairs(Buffer.alloc(32, 0xee)).sealing.publicKey;
|
|
89
|
+
const swapped: IKeyring = { ...fixture, sealingPublicKey: toBase64Url(attacker) };
|
|
90
|
+
const handover = await openOwnerHandover(swapped, recovery);
|
|
91
|
+
expect(handover.sealingKeyMatches).toBe(false);
|
|
92
|
+
// The fingerprint is the derived key's, so the person compares what the passphrase opens.
|
|
93
|
+
expect(handover.fingerprint).toBe('3A2E-D129-B98E-02D9-DF09');
|
|
94
|
+
expect(handover.fingerprint).not.toBe(fingerprint(attacker));
|
|
95
|
+
handover.dispose();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('hands out no key: nothing reachable from it is a key object or key bytes', async () => {
|
|
99
|
+
const handover = await openOwnerHandover(fixture, recovery);
|
|
100
|
+
const reachable = reachableValues(handover);
|
|
101
|
+
expect(reachable.some((value) => value instanceof KeyObject)).toBe(false);
|
|
102
|
+
expect(reachable.some((value) => ArrayBuffer.isView(value))).toBe(false);
|
|
103
|
+
expect(JSON.stringify(handover)).toBe('{}');
|
|
104
|
+
handover.dispose();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('refuses an instance key or binding it cannot certify unambiguously', async () => {
|
|
108
|
+
const handover = await openOwnerHandover(fixture, recovery);
|
|
109
|
+
const refused = (key: Uint8Array, binding: unknown): string =>
|
|
110
|
+
codeOf(() => handover.certify(key, binding as typeof KAT_BINDING));
|
|
111
|
+
expect(refused(instance.subarray(0, 31), KAT_BINDING)).toBe('invalid-argument');
|
|
112
|
+
expect(refused('key' as never, KAT_BINDING)).toBe('invalid-argument');
|
|
113
|
+
for (const binding of [
|
|
114
|
+
undefined,
|
|
115
|
+
{ runtimeId: '', generation: 'g' },
|
|
116
|
+
{ runtimeId: 'r', generation: '' },
|
|
117
|
+
{ runtimeId: 'a b', generation: 'g' },
|
|
118
|
+
{ runtimeId: 'r', generation: 'g\n' },
|
|
119
|
+
{ runtimeId: 'ré', generation: 'g' },
|
|
120
|
+
{ runtimeId: 'r'.repeat(129), generation: 'g' },
|
|
121
|
+
{ runtimeId: 'r', generation: 7 },
|
|
122
|
+
{ runtimeId: 'r', generation: 'g', extra: 'x' },
|
|
123
|
+
]) {
|
|
124
|
+
expect(refused(instance, binding)).toBe('invalid-argument');
|
|
125
|
+
}
|
|
126
|
+
expect(
|
|
127
|
+
hex(
|
|
128
|
+
handover.certify(instance, { runtimeId: 'r'.repeat(128), generation: 'g' }).attachPublicKey,
|
|
129
|
+
),
|
|
130
|
+
).toBe(KNOWN_ATTACH_PUBLIC_KEY_HEX);
|
|
131
|
+
handover.dispose();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('drops everything on dispose, after which it certifies nothing', async () => {
|
|
135
|
+
const handover = await openOwnerHandover(fixture, recovery);
|
|
136
|
+
expect(handover.disposed).toBe(false);
|
|
137
|
+
handover.dispose();
|
|
138
|
+
expect(handover.disposed).toBe(true);
|
|
139
|
+
expect(codeOf(() => handover.certify(instance, KAT_BINDING))).toBe('keys-locked');
|
|
140
|
+
expect(codeOf(() => handover.sealingKeyMatches)).toBe('keys-locked');
|
|
141
|
+
expect(codeOf(() => handover.fingerprint)).toBe('keys-locked');
|
|
142
|
+
handover.dispose();
|
|
143
|
+
expect(handover.disposed).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
});
|