@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,89 @@
1
+ /**
2
+ * A `node:crypto` call that throws (an OpenSSL build without a curve, memory, a host refusing an
3
+ * operation) must still surface as a `KeyringError` with a code from the closed vocabulary, and the
4
+ * original error, which may describe its inputs, must not be chained. Kept apart because it replaces
5
+ * `node:crypto` for the whole file.
6
+ */
7
+ import { Buffer } from 'node:buffer';
8
+
9
+ import { afterEach, describe, expect, it, vi } from 'vitest';
10
+
11
+ const broken = vi.hoisted(() => new Set<string>());
12
+
13
+ vi.mock('node:crypto', async (importOriginal) => {
14
+ const actual = await importOriginal<typeof import('node:crypto')>();
15
+ const breakable =
16
+ <TArgs extends unknown[], TResult>(name: string, original: (...args: TArgs) => TResult) =>
17
+ (...args: TArgs): TResult => {
18
+ if (broken.has(name)) throw new Error(`${name} failed: SECRET-INPUT-DESCRIPTION`);
19
+ return original(...args);
20
+ };
21
+ return {
22
+ ...actual,
23
+ createCipheriv: breakable('createCipheriv', actual.createCipheriv),
24
+ createPrivateKey: breakable('createPrivateKey', actual.createPrivateKey),
25
+ generateKeyPairSync: breakable('generateKeyPairSync', actual.generateKeyPairSync),
26
+ hkdfSync: breakable('hkdfSync', actual.hkdfSync),
27
+ };
28
+ });
29
+
30
+ const {
31
+ KeyringError,
32
+ encryptRecord,
33
+ generateArtifactKey,
34
+ sealBlob,
35
+ unlockWithRecoveryKey,
36
+ parseKeyring,
37
+ } = await import('../index.js');
38
+ const { readFileSync } = await import('node:fs');
39
+ const { fileURLToPath } = await import('node:url');
40
+ const { FIXTURE_RECOVERY_KEY, bytesOf } = await import('./helpers.js');
41
+ const { KNOWN_SEALING_PUBLIC_KEY_HEX } = await import('./helpers.js');
42
+
43
+ const fixture = parseKeyring(
44
+ readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
45
+ );
46
+ const sealingPublicKey = Buffer.from(KNOWN_SEALING_PUBLIC_KEY_HEX, 'hex');
47
+
48
+ afterEach(() => broken.clear());
49
+
50
+ async function failureOf(action: () => unknown): Promise<InstanceType<typeof KeyringError>> {
51
+ try {
52
+ await action();
53
+ } catch (error) {
54
+ expect(error).toBeInstanceOf(KeyringError);
55
+ const keyringError = error as InstanceType<typeof KeyringError>;
56
+ expect(keyringError.message).not.toContain('SECRET-INPUT-DESCRIPTION');
57
+ expect(keyringError.cause).toBeUndefined();
58
+ return keyringError;
59
+ }
60
+ throw new Error('expected the call to fail');
61
+ }
62
+
63
+ describe('node:crypto failures become KeyringErrors', () => {
64
+ it('the AEAD cipher', async () => {
65
+ broken.add('createCipheriv');
66
+ const error = await failureOf(() =>
67
+ encryptRecord(generateArtifactKey(), bytesOf('x'), bytesOf('a')),
68
+ );
69
+ expect(error.code).toBe('invalid-argument');
70
+ });
71
+
72
+ it('HKDF', async () => {
73
+ broken.add('hkdfSync');
74
+ const error = await failureOf(() => sealBlob(sealingPublicKey, bytesOf('x'), bytesOf('a')));
75
+ expect(error.code).toBe('kdf-failed');
76
+ });
77
+
78
+ it('the ephemeral keypair of a seal', async () => {
79
+ broken.add('generateKeyPairSync');
80
+ const error = await failureOf(() => sealBlob(sealingPublicKey, bytesOf('x'), bytesOf('a')));
81
+ expect(error.code).toBe('kdf-failed');
82
+ });
83
+
84
+ it('the keypairs derived at unlock', async () => {
85
+ broken.add('createPrivateKey');
86
+ const error = await failureOf(() => unlockWithRecoveryKey(fixture, FIXTURE_RECOVERY_KEY));
87
+ expect(error.code).toBe('kdf-failed');
88
+ });
89
+ });
@@ -0,0 +1,25 @@
1
+ {
2
+ "version": 1,
3
+ "sealingPublicKey": "S3PDVnZjG2MH-yij9wqvR8MxuGraSZbOLzyfXATNuno",
4
+ "slots": [
5
+ {
6
+ "id": "BSuNhJoNIIOA2MM1VUsXYg",
7
+ "type": "passphrase",
8
+ "kdf": {
9
+ "algorithm": "scrypt",
10
+ "N": 131072,
11
+ "r": 8,
12
+ "p": 1,
13
+ "salt": "0Qv4qMa3OvgcDF9eFqExDQ"
14
+ },
15
+ "iv": "pkFJv5arSD8useGU",
16
+ "wrappedKey": "c7imvonyCA9GAwCOf8BmFWtQaiDBLm1Farv9S3D5YpFegST_YV7w87ws0u69ppN9"
17
+ },
18
+ {
19
+ "id": "HTws5i75-WV7H-xswoQbEQ",
20
+ "type": "recovery",
21
+ "iv": "c61f5DrELmrz6FsB",
22
+ "wrappedKey": "-FvFhexFSR2bVxkb72LURxYZ6b7PWk0jItxcCtaYkl1NPD1ObjJJC1-JGkGIPmdG"
23
+ }
24
+ ]
25
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "aad": "726f626f74612f6b657972696e672f6b61742f76317c61727469666163742d376633617c7365713a30",
3
+ "plaintext": "7b22726f6c65223a2275736572222c2274657874223a226b6e6f776e2d616e7377657220706c61696e746578742c207365616c656420666f726d6174207631227d0a",
4
+ "artifactKey": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
5
+ "sealedKey": "0181711a3372cb1cccc7e5828edc725f341b68679f70e6e3ba3e9e9fac41bd782423a151416fbcce1afac3ec7c9b847365baeb35e694672d55794e07e751a969719bf77347b76b919a21dab8422d5567173746c218e183a40c3b1ae7c7",
6
+ "record": "01dff68c3272f950c78f87cd8fc01efa25e70bfcb0414df457d83c8741c3968ff2e4cca7213026193356906a9aec4886510bf1ad52fe8e4ada62bd88d13ef564fefc304436acc20f73bd0ca36829c44b91b10a6823c0ca4873ca93d3e58ecf",
7
+ "blob": "01253a771bda2b0182d5f913104593e94038619858dfcc9b17b7d7dbb2cbbe4b35174311bf50de606ef73bce6befcaed3d43668a203cc3bdf91bc757f407b8d28f5ad450495b50937ab50837291d92f7936aa1f3390807273c5d70009101b0ff154935fce36115c2d523de58ae3c4db6b0a70314ceada305d92594e1ddefc28e1099159e7b7a6f18f24c66ff7037929426e3f8206b77d4dbdc33d4874d2f6f084f9df88de2da534bed1d510f24a593d2d1d04d3d7b55af268284a25f"
8
+ }
@@ -0,0 +1,130 @@
1
+ import { Buffer } from 'node:buffer';
2
+
3
+ import { KeyringError, type TKeyringErrorCode } from '../index.js';
4
+
5
+ /** Bytes 0x00..0x1f. A fixed master key makes derived keys comparable with the known-answer values. */
6
+ export const FIXED_MASTER_KEY: Buffer = Buffer.from(Array.from({ length: 32 }, (_, i) => i));
7
+
8
+ /** Public keys derived from FIXED_MASTER_KEY, cross-checked outside this package (HMAC-based HKDF in Python, public keys from the openssl CLI). */
9
+ export const KNOWN_SEALING_PUBLIC_KEY_HEX =
10
+ '4b73c35676631b6307fb28a3f70aaf47c331b86ada4996ce2f3c9f5c04cdba7a';
11
+ export const KNOWN_ATTACH_PUBLIC_KEY_HEX =
12
+ '92ffeb9a3ccccde96ea227ff3e2f467989d8fdc5b234e8fdcf1d159a7f56ad54';
13
+ /** The authorship HMAC key FIXED_MASTER_KEY derives, and its tag of {@link KNOWN_AUTHORSHIP_MESSAGE}, cross-checked in Python (hmac, hashlib). */
14
+ export const KNOWN_AUTHORSHIP_KEY_HEX =
15
+ '645f0686ee7ef6c5073ec8930c220db556c495be7dfdbd07f56226a0bf34e4a1';
16
+ export const KNOWN_AUTHORSHIP_MESSAGE = 'robota/keyring known-answer authorship message v1';
17
+ export const KNOWN_AUTHORSHIP_TAG_HEX =
18
+ '5067021c80974a4875b70314442ef0f6cfffe90dad155dfa9bcbe72644f0749d';
19
+
20
+ /**
21
+ * A runtime instance for the owner-link known answers: the Ed25519 key from seed bytes 0x40..0x5f,
22
+ * a binding shaped like a supervised runtime's, and the certificate the fixture keyring's attach key
23
+ * makes for them. Cross-checked outside this package (HKDF in Python, the public key and the
24
+ * signature from the openssl CLI).
25
+ */
26
+ export const KAT_INSTANCE_SEED: Buffer = Buffer.from(
27
+ Array.from({ length: 32 }, (_, i) => 0x40 + i),
28
+ );
29
+ export const KAT_INSTANCE_PUBLIC_KEY_HEX =
30
+ '2543b92ff1095511476adc8369db6ddc933665a11978dda1404ee1066ca9559d';
31
+ export const KAT_BINDING = {
32
+ runtimeId: '6f1c2d3e-4b5a-4c6d-8e9f-0a1b2c3d4e5f',
33
+ generation: 'AAECAwQFBgcICQoLDA0ODw',
34
+ } as const;
35
+ export const KAT_CERTIFICATE_HEX =
36
+ '9d76a228a4824881c96050ff31eaa9287dc99efe238c592aab67189ef16fe5b6' +
37
+ 'ad90e67b84f5334e771534ec0496d9a5edeae8568c0f92d73a3ae252638e1302';
38
+ /** The certificate for the same instance and runtime id at the next generation. */
39
+ export const KAT_CERTIFICATE_NEXT_GENERATION_HEX =
40
+ 'fad24766d3f13d910825aa39016bc203bd5a7237d77e4037189389f28735b1ac' +
41
+ 'ca9f41b16a9bdfba3fdc61b0b4bc2dc58bf1cd787834c7232d164ed4bd6b8c06';
42
+ export const KAT_NEXT_GENERATION = 'AAECAwQFBgcICQoLDA0OEA';
43
+
44
+ export const FIXTURE_RECOVERY_KEY_BYTES: Buffer = Buffer.from(
45
+ Array.from({ length: 20 }, (_, i) => 0xa0 + i),
46
+ );
47
+ export const FIXTURE_RECOVERY_KEY = 'M2GT-58X4-MPKA-FA59-NANT-SBDE-NYRB-3CNK';
48
+ /** Decomposed letters plus compatibility characters; NFKC turns it into `Pässwörd-fixture-1`. */
49
+ export const FIXTURE_PASSPHRASE_AS_TYPED = 'Pässwörd-fixture-①';
50
+
51
+ export function hex(bytes: Uint8Array): string {
52
+ return Buffer.from(bytes).toString('hex');
53
+ }
54
+
55
+ export function bytesOf(text: string): Buffer {
56
+ return Buffer.from(text, 'utf8');
57
+ }
58
+
59
+ /** A copy of `bytes` with `mask` XOR-ed into the byte at `index`. */
60
+ export function tamper(bytes: Uint8Array, index: number, mask = 0x01): Buffer {
61
+ const copy = Buffer.from(bytes);
62
+ copy[index] ^= mask;
63
+ return copy;
64
+ }
65
+
66
+ /** Joins parts with a 2-byte big-endian length before each, written here apart from the package's own. */
67
+ export function lengthPrefixed(...parts: readonly Uint8Array[]): Buffer {
68
+ return Buffer.concat(
69
+ parts.flatMap((part) => {
70
+ const prefix = Buffer.alloc(2);
71
+ prefix.writeUInt16BE(part.length);
72
+ return [prefix, Buffer.from(part)];
73
+ }),
74
+ );
75
+ }
76
+
77
+ /**
78
+ * Every value reachable from `root` through its own properties and the getters of its prototype
79
+ * chain, two levels deep: what a holder of the object can get at without a package-private accessor.
80
+ */
81
+ export function reachableValues(root: object): unknown[] {
82
+ const found: unknown[] = [];
83
+ const visit = (value: unknown, depth: number): void => {
84
+ found.push(value);
85
+ if (depth === 0 || typeof value !== 'object' || value === null || ArrayBuffer.isView(value)) {
86
+ return;
87
+ }
88
+ const names = new Set<string | symbol>();
89
+ for (
90
+ let prototype: object | null = value;
91
+ prototype !== null && prototype !== Object.prototype;
92
+ prototype = Object.getPrototypeOf(prototype)
93
+ ) {
94
+ for (const name of Reflect.ownKeys(prototype)) names.add(name);
95
+ }
96
+ for (const name of names) {
97
+ if (name === 'constructor') continue;
98
+ let child: unknown;
99
+ try {
100
+ child = (value as Record<string | symbol, unknown>)[name];
101
+ } catch {
102
+ continue;
103
+ }
104
+ if (typeof child !== 'function') visit(child, depth - 1);
105
+ }
106
+ };
107
+ visit(root, 2);
108
+ return found;
109
+ }
110
+
111
+ /** The `code` of the KeyringError a call throws; any other throw (or none) fails the test loudly. */
112
+ export function codeOf(call: () => unknown): TKeyringErrorCode {
113
+ try {
114
+ call();
115
+ } catch (error) {
116
+ if (error instanceof KeyringError) return error.code;
117
+ throw error;
118
+ }
119
+ throw new Error('Expected a KeyringError but nothing was thrown.');
120
+ }
121
+
122
+ export async function codeOfAsync(promise: Promise<unknown>): Promise<TKeyringErrorCode> {
123
+ try {
124
+ await promise;
125
+ } catch (error) {
126
+ if (error instanceof KeyringError) return error.code;
127
+ throw error;
128
+ }
129
+ throw new Error('Expected a KeyringError but nothing was thrown.');
130
+ }
@@ -0,0 +1,154 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ // Counts calls into `crypto.scrypt` while still running the real thing. The bounds check has to
7
+ // refuse BEFORE any derivation, and a call count is the only direct evidence of that.
8
+ const scryptCalls = vi.hoisted(() => ({ count: 0 }));
9
+
10
+ vi.mock('node:crypto', async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import('node:crypto')>();
12
+ const counted = ((...args: Parameters<typeof actual.scrypt>) => {
13
+ scryptCalls.count += 1;
14
+ return (actual.scrypt as (...rest: unknown[]) => void)(...args);
15
+ }) as typeof actual.scrypt;
16
+ return { ...actual, scrypt: counted, default: { ...actual, scrypt: counted } };
17
+ });
18
+
19
+ import {
20
+ createKeyring,
21
+ parseKeyring,
22
+ unlockWithPassphrase,
23
+ unlockWithRecoveryKey,
24
+ type IKeyring,
25
+ } from '../index.js';
26
+ import {
27
+ assertScryptParametersInBounds,
28
+ DEFAULT_SCRYPT_PARAMETERS,
29
+ SCRYPT_BOUNDS,
30
+ } from '../kdf.js';
31
+ import { codeOf, codeOfAsync, FIXTURE_PASSPHRASE_AS_TYPED } from './helpers.js';
32
+
33
+ const fixtureText = readFileSync(
34
+ fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)),
35
+ 'utf8',
36
+ );
37
+ const fixture = parseKeyring(fixtureText);
38
+
39
+ function withKdf(change: Record<string, number>): IKeyring {
40
+ const [passphrase, recovery] = fixture.slots;
41
+ if (passphrase.type !== 'passphrase') throw new Error('fixture layout changed');
42
+ return {
43
+ ...fixture,
44
+ slots: [{ ...passphrase, kdf: { ...passphrase.kdf, ...change } }, recovery],
45
+ };
46
+ }
47
+
48
+ beforeEach(() => {
49
+ scryptCalls.count = 0;
50
+ });
51
+
52
+ describe('scrypt cost bounds', () => {
53
+ it('accepts the default cost and the inclusive edges of the range', () => {
54
+ expect(() => assertScryptParametersInBounds(DEFAULT_SCRYPT_PARAMETERS)).not.toThrow();
55
+ for (const [N, r] of [
56
+ [SCRYPT_BOUNDS.minN, SCRYPT_BOUNDS.minR],
57
+ [SCRYPT_BOUNDS.minN, SCRYPT_BOUNDS.maxR],
58
+ [SCRYPT_BOUNDS.maxN, SCRYPT_BOUNDS.minR], // exactly the 512 MiB ceiling
59
+ ]) {
60
+ expect(() => assertScryptParametersInBounds({ N, r, p: 1 })).not.toThrow();
61
+ }
62
+ });
63
+
64
+ it('caps the memory a keyring may request, whatever N and r are combined', () => {
65
+ // Each of N and r is in range on its own; together they would need 1 GiB.
66
+ expect(
67
+ codeOf(() =>
68
+ assertScryptParametersInBounds({ N: SCRYPT_BOUNDS.maxN, r: SCRYPT_BOUNDS.maxR, p: 1 }),
69
+ ),
70
+ ).toBe('kdf-parameters-out-of-bounds');
71
+ });
72
+
73
+ it('refuses a cost outside the range, with its own error code', () => {
74
+ const outside = [
75
+ { N: 2 ** 14, r: 8, p: 1 },
76
+ { N: 2 ** 20, r: 8, p: 1 }, // above the ceiling
77
+ { N: 2 ** 21, r: 8, p: 1 },
78
+ { N: 3 * 2 ** 15, r: 8, p: 1 }, // in range but not a power of two
79
+ { N: 2 ** 17, r: 7, p: 1 },
80
+ { N: 2 ** 17, r: 17, p: 1 },
81
+ { N: 2 ** 17, r: 8, p: 2 },
82
+ { N: 2 ** 17, r: 8, p: 0 },
83
+ { N: 2 ** 17 + 0.5, r: 8, p: 1 },
84
+ { N: Number.NaN, r: 8, p: 1 },
85
+ ];
86
+ for (const parameters of outside) {
87
+ expect(codeOf(() => assertScryptParametersInBounds(parameters))).toBe(
88
+ 'kdf-parameters-out-of-bounds',
89
+ );
90
+ }
91
+ });
92
+ });
93
+
94
+ describe('unlock refuses hostile KDF parameters before doing any work', () => {
95
+ it('runs no scrypt for any out-of-range slot', async () => {
96
+ const hostile = [
97
+ withKdf({ N: 2 ** 14 }), // a downgrade
98
+ withKdf({ N: 2 ** 21 }), // a memory bomb
99
+ withKdf({ N: 2 ** 19, r: 16 }), // a 1 GiB request, each value in range on its own
100
+ withKdf({ N: 2 ** 20, r: 17 }),
101
+ withKdf({ r: 7 }),
102
+ withKdf({ p: 2 }),
103
+ withKdf({ N: 3 * 2 ** 15 }),
104
+ ];
105
+ for (const keyring of hostile) {
106
+ expect(await codeOfAsync(unlockWithPassphrase(keyring, FIXTURE_PASSPHRASE_AS_TYPED))).toBe(
107
+ 'kdf-parameters-out-of-bounds',
108
+ );
109
+ }
110
+ expect(scryptCalls.count).toBe(0);
111
+ });
112
+
113
+ it('checks every passphrase slot before deriving for the first', async () => {
114
+ const [passphrase, recovery] = fixture.slots;
115
+ if (passphrase.type !== 'passphrase') throw new Error('fixture layout changed');
116
+ const hostileSecond = withKdf({ N: 2 ** 21 }).slots[0];
117
+ const keyring: IKeyring = {
118
+ ...fixture,
119
+ slots: [passphrase, { ...hostileSecond, id: 'AAAAAAAAAAAAAAAAAAAAAA' }, recovery],
120
+ };
121
+ expect(await codeOfAsync(unlockWithPassphrase(keyring, FIXTURE_PASSPHRASE_AS_TYPED))).toBe(
122
+ 'kdf-parameters-out-of-bounds',
123
+ );
124
+ expect(scryptCalls.count).toBe(0);
125
+ });
126
+
127
+ it('does run scrypt for an in-range slot (so the counter is not vacuous)', async () => {
128
+ // The lowest accepted cost with the fixture's wrapped key: derives a different key, then fails.
129
+ expect(
130
+ await codeOfAsync(unlockWithPassphrase(withKdf({ N: 2 ** 15 }), FIXTURE_PASSPHRASE_AS_TYPED)),
131
+ ).toBe('wrong-credential');
132
+ expect(scryptCalls.count).toBe(1);
133
+ });
134
+
135
+ it('does not refuse the recovery path because of a hostile passphrase slot', async () => {
136
+ // Nothing to unlock with here (the fixture's recovery key is not under test); only that no
137
+ // scrypt ran and no KDF error was raised for a credential that never reaches the passphrase slot.
138
+ expect(
139
+ await codeOfAsync(
140
+ unlockWithRecoveryKey(withKdf({ N: 2 ** 21 }), 'ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ-ZZZZ'),
141
+ ),
142
+ ).toBe('wrong-credential');
143
+ expect(scryptCalls.count).toBe(0);
144
+ });
145
+ });
146
+
147
+ describe('createKeyring', () => {
148
+ it('runs scrypt once, at the default cost', async () => {
149
+ const { keyring } = await createKeyring({ passphrase: 'a passphrase' });
150
+ expect(scryptCalls.count).toBe(1);
151
+ const slot = keyring.slots[0];
152
+ expect(slot.type === 'passphrase' && slot.kdf).toMatchObject(DEFAULT_SCRYPT_PARAMETERS);
153
+ });
154
+ });
@@ -0,0 +1,251 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import {
7
+ getSealingPublicKey,
8
+ KeyringError,
9
+ parseKeyring,
10
+ serializeKeyring,
11
+ type IKeyring,
12
+ } from '../index.js';
13
+ import { MAX_KEYRING_JSON_CHARS, MAX_KEYSLOTS } from '../keyring-document.js';
14
+ import { codeOf, KNOWN_SEALING_PUBLIC_KEY_HEX, hex } from './helpers.js';
15
+
16
+ const fixtureText = readFileSync(
17
+ fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)),
18
+ 'utf8',
19
+ );
20
+ // A loosely typed JSON view: these tests deliberately build documents that break the schema.
21
+ type TLooseDocument = Record<string, any>;
22
+ const fixture = JSON.parse(fixtureText) as TLooseDocument;
23
+
24
+ function documentWith(change: (copy: TLooseDocument) => void): string {
25
+ const copy = structuredClone(fixture);
26
+ change(copy);
27
+ return JSON.stringify(copy);
28
+ }
29
+
30
+ describe('parseKeyring and serializeKeyring', () => {
31
+ it('reads the fixed schema and writes it back identically', () => {
32
+ const keyring = parseKeyring(fixtureText);
33
+ expect(keyring.version).toBe(1);
34
+ expect(keyring.slots).toHaveLength(2);
35
+ expect(serializeKeyring(keyring)).toBe(fixtureText.trimEnd());
36
+ expect(Object.keys(JSON.parse(serializeKeyring(keyring)))).toEqual([
37
+ 'version',
38
+ 'sealingPublicKey',
39
+ 'slots',
40
+ ]);
41
+ expect(hex(getSealingPublicKey(keyring))).toBe(KNOWN_SEALING_PUBLIC_KEY_HEX);
42
+ });
43
+
44
+ it('returns a frozen copy that shares nothing with its input', () => {
45
+ const keyring = parseKeyring(fixtureText);
46
+ expect(Object.isFrozen(keyring)).toBe(true);
47
+ expect(Object.isFrozen(keyring.slots)).toBe(true);
48
+ expect(Object.isFrozen(keyring.slots[0])).toBe(true);
49
+ });
50
+
51
+ it('reports a document of another version as unsupported-version, whatever else it contains', () => {
52
+ expect(codeOf(() => parseKeyring(documentWith((copy) => (copy.version = 2))))).toBe(
53
+ 'unsupported-version',
54
+ );
55
+ expect(codeOf(() => parseKeyring(JSON.stringify({ version: 0 })))).toBe('unsupported-version');
56
+ expect(codeOf(() => parseKeyring(JSON.stringify({ version: 2, somethingNew: [1, 2] })))).toBe(
57
+ 'unsupported-version',
58
+ );
59
+ });
60
+
61
+ it('refuses a missing, mistyped or fractional version', () => {
62
+ for (const version of [undefined, '1', 1.5, null, true, [], {}]) {
63
+ const text = documentWith((copy) => {
64
+ copy.version = version;
65
+ });
66
+ expect(codeOf(() => parseKeyring(text))).toBe('malformed-keyring');
67
+ }
68
+ });
69
+
70
+ it('has no field for an attach public key, and refuses any extra field', () => {
71
+ expect(
72
+ codeOf(() => parseKeyring(documentWith((copy) => (copy.attachPublicKey = 'AAAA')))),
73
+ ).toBe('malformed-keyring');
74
+ expect(codeOf(() => parseKeyring(documentWith((copy) => (copy.slots[0].extra = 1))))).toBe(
75
+ 'malformed-keyring',
76
+ );
77
+ expect(codeOf(() => parseKeyring(documentWith((copy) => (copy.slots[0].kdf.extra = 1))))).toBe(
78
+ 'malformed-keyring',
79
+ );
80
+ });
81
+
82
+ it('refuses missing fields at every level', () => {
83
+ const removals: Array<(copy: TLooseDocument) => void> = [
84
+ (copy) => delete copy.sealingPublicKey,
85
+ (copy) => delete copy.slots,
86
+ (copy) => delete copy.slots[0].id,
87
+ (copy) => delete copy.slots[0].type,
88
+ (copy) => delete copy.slots[0].iv,
89
+ (copy) => delete copy.slots[0].wrappedKey,
90
+ (copy) => delete copy.slots[0].kdf,
91
+ (copy) => delete copy.slots[0].kdf.salt,
92
+ (copy) => delete copy.slots[0].kdf.N,
93
+ (copy) => delete copy.slots[1].wrappedKey,
94
+ ];
95
+ for (const remove of removals) {
96
+ expect(codeOf(() => parseKeyring(documentWith(remove)))).toBe('malformed-keyring');
97
+ }
98
+ });
99
+
100
+ it('refuses wrong types and wrong lengths', () => {
101
+ const changes: Array<(copy: TLooseDocument) => void> = [
102
+ (copy) => (copy.sealingPublicKey = 42),
103
+ (copy) => (copy.sealingPublicKey = copy.sealingPublicKey.slice(0, -2)),
104
+ (copy) => (copy.sealingPublicKey = `${copy.sealingPublicKey}AA`),
105
+ (copy) => (copy.slots = {}),
106
+ (copy) => (copy.slots = []),
107
+ (copy) => (copy.slots = [null]),
108
+ (copy) => (copy.slots = ['x']),
109
+ (copy) => (copy.slots[0].id = 'short'),
110
+ (copy) => (copy.slots[0].iv = copy.slots[0].iv.slice(2)),
111
+ (copy) => (copy.slots[0].wrappedKey = copy.slots[0].wrappedKey.slice(2)),
112
+ (copy) => (copy.slots[0].type = 'unknown'),
113
+ (copy) => (copy.slots[0].type = 'recovery'), // carries a kdf, which a recovery slot has no field for
114
+ (copy) => (copy.slots[1].type = 'passphrase'), // has no kdf
115
+ (copy) => (copy.slots[0].kdf = 'scrypt'),
116
+ (copy) => (copy.slots[0].kdf.algorithm = 'argon2id'),
117
+ (copy) => (copy.slots[0].kdf.N = '131072'),
118
+ (copy) => (copy.slots[0].kdf.N = 131072.5),
119
+ (copy) => (copy.slots[0].kdf.r = 0),
120
+ (copy) => (copy.slots[0].kdf.p = -1),
121
+ (copy) => (copy.slots[0].kdf.N = Number.MAX_SAFE_INTEGER + 2),
122
+ (copy) => (copy.slots[0].kdf.salt = copy.slots[0].kdf.salt.slice(1)),
123
+ ];
124
+ for (const change of changes) {
125
+ expect(codeOf(() => parseKeyring(documentWith(change)))).toBe('malformed-keyring');
126
+ }
127
+ });
128
+
129
+ it('accepts only canonical, unpadded base64url', () => {
130
+ const publicKey: string = fixture.sealingPublicKey;
131
+ const variants = [
132
+ `${publicKey}=`, // padding
133
+ publicKey.replace(/[-_]/, '+'), // standard alphabet (the fixture's key contains a - or _)
134
+ `${publicKey.slice(0, -1)}${publicKey.endsWith('o') ? 'p' : 'o'}`, // different trailing bits
135
+ ` ${publicKey}`,
136
+ publicKey.replace(/./, 'é'),
137
+ ];
138
+ for (const variant of variants) {
139
+ if (variant === publicKey) continue;
140
+ expect(
141
+ codeOf(() => parseKeyring(documentWith((copy) => (copy.sealingPublicKey = variant)))),
142
+ ).toBe('malformed-keyring');
143
+ }
144
+ });
145
+
146
+ it('refuses duplicate slot ids, too many slots and an oversized document', () => {
147
+ expect(
148
+ codeOf(() => parseKeyring(documentWith((copy) => (copy.slots[1].id = copy.slots[0].id)))),
149
+ ).toBe('malformed-keyring');
150
+ expect(
151
+ codeOf(() =>
152
+ parseKeyring(
153
+ documentWith((copy) => {
154
+ copy.slots = Array.from({ length: MAX_KEYSLOTS + 1 }, (_, index) => ({
155
+ ...copy.slots[1],
156
+ id: Buffer.alloc(16, index).toString('base64url'),
157
+ }));
158
+ }),
159
+ ),
160
+ ),
161
+ ).toBe('malformed-keyring');
162
+ const padded = `${fixtureText}${' '.repeat(MAX_KEYRING_JSON_CHARS)}`;
163
+ expect(codeOf(() => parseKeyring(padded))).toBe('malformed-keyring');
164
+ });
165
+
166
+ it('refuses input that is not a JSON object document', () => {
167
+ for (const text of ['', 'not json', 'null', '[]', '1', '"x"', '{', '[[[[[[[[[[', 'true']) {
168
+ expect(codeOf(() => parseKeyring(text))).toBe('malformed-keyring');
169
+ }
170
+ expect(codeOf(() => parseKeyring(undefined as never))).toBe('malformed-keyring');
171
+ expect(codeOf(() => parseKeyring(Buffer.from(fixtureText) as never))).toBe('malformed-keyring');
172
+ expect(codeOf(() => parseKeyring(`[${'['.repeat(8000)}${']'.repeat(8000)}]`))).toBe(
173
+ 'malformed-keyring',
174
+ );
175
+ });
176
+
177
+ it('is not steered by prototype-shaped keys', () => {
178
+ const polluted = [
179
+ `{"__proto__": {"polluted": true}, "version": 1, "sealingPublicKey": "${fixture.sealingPublicKey}", "slots": ${JSON.stringify(fixture.slots)}}`,
180
+ `{"version": 1, "__proto__": {"version": 1}, "sealingPublicKey": "${fixture.sealingPublicKey}", "slots": ${JSON.stringify(fixture.slots)}}`,
181
+ `{"constructor": {"prototype": {"polluted": true}}, "version": 1}`,
182
+ `{"version": 1, "sealingPublicKey": "${fixture.sealingPublicKey}", "slots": [{"__proto__": {"type": "recovery"}}]}`,
183
+ documentWith((copy) => (copy.slots[0].constructor = 'x')),
184
+ // A plain assignment would set the prototype; defineProperty makes `__proto__` an own, enumerable key.
185
+ documentWith((copy) =>
186
+ Object.defineProperty(copy.slots[0].kdf, '__proto__', {
187
+ value: { N: 1 },
188
+ enumerable: true,
189
+ }),
190
+ ),
191
+ ];
192
+ for (const text of polluted) {
193
+ expect(codeOf(() => parseKeyring(text))).toBe('malformed-keyring');
194
+ }
195
+ expect(({} as Record<string, unknown>).polluted).toBeUndefined();
196
+ expect(Object.prototype.hasOwnProperty.call(Object.prototype, 'polluted')).toBe(false);
197
+ });
198
+
199
+ it('applies the same validation to hand-built objects', () => {
200
+ const keyring = parseKeyring(fixtureText);
201
+ expect(
202
+ codeOf(() => serializeKeyring({ ...keyring, attachPublicKey: 'x' } as unknown as IKeyring)),
203
+ ).toBe('malformed-keyring');
204
+ expect(codeOf(() => serializeKeyring(Object.create(keyring) as IKeyring))).toBe(
205
+ 'malformed-keyring',
206
+ );
207
+ expect(codeOf(() => getSealingPublicKey({ ...keyring, slots: [] }))).toBe('malformed-keyring');
208
+ });
209
+
210
+ it('survives a seeded mutation fuzz: always a KeyringError or a document that round-trips', () => {
211
+ // mulberry32: a small deterministic PRNG, so a failure reproduces.
212
+ let state = 0x5eed1234;
213
+ const random = () => {
214
+ state = (state + 0x6d2b79f5) | 0;
215
+ let t = Math.imul(state ^ (state >>> 15), 1 | state);
216
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
217
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
218
+ };
219
+ const pick = (limit: number) => Math.floor(random() * limit);
220
+ const alphabet = 'AZaz09-_+/=",:{}[] \né';
221
+ let accepted = 0;
222
+ let rejected = 0;
223
+ for (let round = 0; round < 1500; round += 1) {
224
+ let text = fixtureText;
225
+ for (let edits = 1 + pick(3); edits > 0; edits -= 1) {
226
+ const at = pick(text.length);
227
+ const kind = pick(4);
228
+ if (kind === 0)
229
+ text = text.slice(0, at) + alphabet[pick(alphabet.length)] + text.slice(at + 1);
230
+ else if (kind === 1) text = text.slice(0, at) + text.slice(at + 1 + pick(8));
231
+ else if (kind === 2)
232
+ text = text.slice(0, at) + alphabet[pick(alphabet.length)] + text.slice(at);
233
+ else text = text.slice(0, at) + text.slice(at, at + 1 + pick(40)) + text.slice(at);
234
+ }
235
+ try {
236
+ const keyring = parseKeyring(text);
237
+ accepted += 1;
238
+ expect(parseKeyring(serializeKeyring(keyring))).toEqual(keyring);
239
+ } catch (error) {
240
+ rejected += 1;
241
+ expect(error).toBeInstanceOf(KeyringError);
242
+ expect(['malformed-keyring', 'unsupported-version']).toContain(
243
+ (error as KeyringError).code,
244
+ );
245
+ }
246
+ }
247
+ // The fuzz must exercise both outcomes, or it proves nothing.
248
+ expect(rejected).toBeGreaterThan(500);
249
+ expect(accepted).toBeGreaterThan(0);
250
+ });
251
+ });