@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,127 @@
1
+ /**
2
+ * The spawner exchange: the spawner writes its ephemeral first, the runtime answers with its own and
3
+ * its instance public key, and both sides derive the spawner key from the one shared secret. Pinned
4
+ * from fixed ephemerals, and strict about every byte it reads.
5
+ */
6
+ import { Buffer } from 'node:buffer';
7
+
8
+ import { describe, expect, it } from 'vitest';
9
+
10
+ import { SpawnerKey, createRuntimeIdentity, startSpawnerExchange } from '../index.js';
11
+ import { createRuntimeIdentityWith, runtimeIdentitySecrets } from '../runtime-identity.js';
12
+ import { spawnerKeyBytes, startSpawnerExchangeWith } from '../spawner-exchange.js';
13
+ import {
14
+ KAT_INSTANCE_PUBLIC_KEY_HEX,
15
+ KAT_INSTANCE_SEED,
16
+ codeOf,
17
+ hex,
18
+ reachableValues,
19
+ } from './helpers.js';
20
+ import {
21
+ KAT_EXCHANGE,
22
+ KAT_RUNTIME_EXCHANGE_SEED,
23
+ KAT_SPAWNER_EPHEMERAL_SEED,
24
+ fixedEntropy,
25
+ refusalOf,
26
+ } from './link-helpers.js';
27
+
28
+ function katPair() {
29
+ const spawner = startSpawnerExchangeWith(fixedEntropy([KAT_SPAWNER_EPHEMERAL_SEED]));
30
+ const runtime = createRuntimeIdentityWith(
31
+ fixedEntropy([KAT_INSTANCE_SEED, KAT_RUNTIME_EXCHANGE_SEED]),
32
+ );
33
+ return { spawner, runtime };
34
+ }
35
+
36
+ describe('the spawner exchange', () => {
37
+ it('derives the pinned spawner key on both sides from fixed ephemerals', () => {
38
+ const { spawner, runtime } = katPair();
39
+ expect(hex(spawner.message)).toBe(KAT_EXCHANGE.spawnerEphemeral);
40
+ const reply = runtime.answerSpawnerExchange(spawner.message);
41
+ expect(hex(reply)).toBe(KAT_EXCHANGE.runtimeEphemeral + KAT_INSTANCE_PUBLIC_KEY_HEX);
42
+ const result = spawner.complete(reply);
43
+ expect(hex(result.instancePublicKey)).toBe(KAT_INSTANCE_PUBLIC_KEY_HEX);
44
+ expect(hex(spawnerKeyBytes(result.spawnerKey))).toBe(KAT_EXCHANGE.spawnerKey);
45
+ expect(hex(runtimeIdentitySecrets(runtime).spawnerKey() ?? Buffer.alloc(0))).toBe(
46
+ KAT_EXCHANGE.spawnerKey,
47
+ );
48
+ });
49
+
50
+ it('draws fresh ephemerals every time, so no two exchanges share a key', () => {
51
+ const keys = new Set<string>();
52
+ for (let index = 0; index < 4; index += 1) {
53
+ const spawner = startSpawnerExchange();
54
+ expect(spawner.message).toHaveLength(32);
55
+ const runtime = createRuntimeIdentity();
56
+ const { spawnerKey, instancePublicKey } = spawner.complete(
57
+ runtime.answerSpawnerExchange(spawner.message),
58
+ );
59
+ expect(hex(instancePublicKey)).toBe(hex(runtime.instancePublicKey));
60
+ keys.add(hex(spawnerKeyBytes(spawnerKey)));
61
+ }
62
+ expect(keys.size).toBe(4);
63
+ });
64
+
65
+ it('completes once, and only with a reply of exactly an ephemeral and an instance key', () => {
66
+ const lowOrder = Buffer.concat([Buffer.alloc(32), Buffer.alloc(32, 1)]);
67
+ for (const reply of [Buffer.alloc(63, 9), Buffer.alloc(65, 9), lowOrder, 'reply' as never]) {
68
+ expect(refusalOf(() => startSpawnerExchange().complete(reply))).toBe('malformed');
69
+ }
70
+ const { spawner, runtime } = katPair();
71
+ const reply = runtime.answerSpawnerExchange(spawner.message);
72
+ spawner.complete(reply);
73
+ expect(refusalOf(() => spawner.complete(reply))).toBe('out-of-order');
74
+ const spent = startSpawnerExchange();
75
+ expect(refusalOf(() => spent.complete(Buffer.alloc(10)))).toBe('malformed');
76
+ expect(refusalOf(() => spent.complete(reply))).toBe('out-of-order');
77
+ });
78
+
79
+ it('is answered once by a runtime, and only for exactly an ephemeral', () => {
80
+ for (const message of [
81
+ Buffer.alloc(31, 9),
82
+ Buffer.alloc(33, 9),
83
+ Buffer.alloc(32),
84
+ 7 as never,
85
+ ]) {
86
+ expect(refusalOf(() => createRuntimeIdentity().answerSpawnerExchange(message))).toBe(
87
+ 'malformed',
88
+ );
89
+ }
90
+ const runtime = createRuntimeIdentity();
91
+ expect(runtime.spawnerKeyHeld).toBe(false);
92
+ runtime.answerSpawnerExchange(startSpawnerExchange().message);
93
+ expect(runtime.spawnerKeyHeld).toBe(true);
94
+ expect(refusalOf(() => runtime.answerSpawnerExchange(startSpawnerExchange().message))).toBe(
95
+ 'out-of-order',
96
+ );
97
+ const refusedOnce = createRuntimeIdentity();
98
+ expect(refusalOf(() => refusedOnce.answerSpawnerExchange(Buffer.alloc(3)))).toBe('malformed');
99
+ expect(refusalOf(() => refusedOnce.answerSpawnerExchange(startSpawnerExchange().message))).toBe(
100
+ 'out-of-order',
101
+ );
102
+ expect(refusedOnce.spawnerKeyHeld).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe('a spawner key', () => {
107
+ it('is opaque: nothing reachable from it is key bytes', () => {
108
+ const { spawner, runtime } = katPair();
109
+ const { spawnerKey } = spawner.complete(runtime.answerSpawnerExchange(spawner.message));
110
+ expect(spawnerKey).toBeInstanceOf(SpawnerKey);
111
+ expect(reachableValues(spawnerKey).some((value) => ArrayBuffer.isView(value))).toBe(false);
112
+ expect(JSON.stringify(spawnerKey)).toBe('{}');
113
+ });
114
+
115
+ it('is zero-filled by wipe and refused afterwards; wipe is idempotent', () => {
116
+ const { spawner, runtime } = katPair();
117
+ const { spawnerKey } = spawner.complete(runtime.answerSpawnerExchange(spawner.message));
118
+ const bytes = spawnerKeyBytes(spawnerKey);
119
+ expect(spawnerKey.wiped).toBe(false);
120
+ spawnerKey.wipe();
121
+ expect(spawnerKey.wiped).toBe(true);
122
+ expect(bytes.every((byte) => byte === 0)).toBe(true);
123
+ expect(codeOf(() => spawnerKeyBytes(spawnerKey))).toBe('keys-locked');
124
+ spawnerKey.wipe();
125
+ expect(spawnerKey.wiped).toBe(true);
126
+ });
127
+ });
@@ -0,0 +1,36 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createPublicKey } from 'node:crypto';
3
+
4
+ import { describe, expect, it } from 'vitest';
5
+
6
+ import { generateTransientSealingKeyPair, openBlob, sealBlob } from '../index.js';
7
+ import { rawPublicKey } from '../key-pairs.js';
8
+ import { bytesOf, codeOf, hex } from './helpers.js';
9
+
10
+ const aad = bytesOf('robota/artifact/v1|tool-result-spill|token');
11
+ const plaintext = bytesOf('a tool result only this process reads back');
12
+
13
+ describe('a transient sealing keypair', () => {
14
+ it('is an X25519 pair whose public half is the one its private key has', () => {
15
+ const pair = generateTransientSealingKeyPair();
16
+ expect(pair.privateKey.type).toBe('private');
17
+ expect(pair.privateKey.asymmetricKeyType).toBe('x25519');
18
+ expect(pair.publicKey).toHaveLength(32);
19
+ expect(hex(pair.publicKey)).toBe(hex(rawPublicKey(createPublicKey(pair.privateKey))));
20
+ });
21
+
22
+ it('opens what is sealed to its public key, and nothing sealed to another pair', () => {
23
+ const pair = generateTransientSealingKeyPair();
24
+ const other = generateTransientSealingKeyPair();
25
+ const blob = sealBlob(pair.publicKey, plaintext, aad);
26
+ expect(Buffer.from(openBlob(pair.privateKey, blob, aad)).equals(plaintext)).toBe(true);
27
+ expect(codeOf(() => openBlob(other.privateKey, blob, aad))).toBe('decryption-failed');
28
+ });
29
+
30
+ it('is new every time: no two calls give the same key', () => {
31
+ const keys = new Set(
32
+ Array.from({ length: 8 }, () => hex(generateTransientSealingKeyPair().publicKey)),
33
+ );
34
+ expect(keys.size).toBe(8);
35
+ });
36
+ });
package/src/aead.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createCipheriv, createDecipheriv } from 'node:crypto';
3
+
4
+ import { wipe } from './bytes.js';
5
+ import { KeyringError } from './errors.js';
6
+
7
+ export const GCM_IV_BYTES = 12;
8
+ export const GCM_TAG_BYTES = 16;
9
+ const AES_256_GCM = 'aes-256-gcm';
10
+
11
+ /**
12
+ * AES-256-GCM seal. Returns `ciphertext || tag`. The cipher is AES-GCM everywhere because it is the
13
+ * AEAD both Node and Bun implement (ChaCha20-Poly1305 is not available on Bun).
14
+ */
15
+ export function gcmEncrypt(
16
+ key: Uint8Array,
17
+ iv: Uint8Array,
18
+ plaintext: Uint8Array,
19
+ aad: Uint8Array,
20
+ ): Buffer {
21
+ try {
22
+ const cipher = createCipheriv(AES_256_GCM, key, iv, { authTagLength: GCM_TAG_BYTES });
23
+ cipher.setAAD(aad);
24
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
25
+ return Buffer.concat([body, cipher.getAuthTag()]);
26
+ } catch {
27
+ // The inputs are checked before they get here; what is left is a key or IV the cipher refused.
28
+ throw new KeyringError('invalid-argument', 'The key or IV cannot be used for encryption.');
29
+ }
30
+ }
31
+
32
+ /**
33
+ * AES-256-GCM open of `ciphertext || tag`. Plaintext is returned only after the tag verified; any
34
+ * failure — wrong key, wrong AAD, modified or truncated bytes — is one `decryption-failed`.
35
+ */
36
+ export function gcmDecrypt(
37
+ key: Uint8Array,
38
+ iv: Uint8Array,
39
+ sealed: Uint8Array,
40
+ aad: Uint8Array,
41
+ ): Buffer {
42
+ if (sealed.length < GCM_TAG_BYTES) {
43
+ throw new KeyringError('decryption-failed', 'Authentication of the ciphertext failed.');
44
+ }
45
+ const bodyLength = sealed.length - GCM_TAG_BYTES;
46
+ const body = sealed.subarray(0, bodyLength);
47
+ const tag = sealed.subarray(bodyLength);
48
+ const parts: Buffer[] = [];
49
+ try {
50
+ const decipher = createDecipheriv(AES_256_GCM, key, iv, { authTagLength: GCM_TAG_BYTES });
51
+ decipher.setAAD(aad);
52
+ decipher.setAuthTag(tag);
53
+ parts.push(decipher.update(body));
54
+ parts.push(decipher.final());
55
+ return Buffer.concat(parts);
56
+ } catch {
57
+ throw new KeyringError('decryption-failed', 'Authentication of the ciphertext failed.');
58
+ } finally {
59
+ // `update` hands out unauthenticated plaintext before `final` verifies the tag; never keep it.
60
+ wipe(...parts);
61
+ }
62
+ }
@@ -0,0 +1,61 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHmac, timingSafeEqual } from 'node:crypto';
3
+
4
+ import { isBytes } from './bytes.js';
5
+ import { KeyringError } from './errors.js';
6
+ import { hkdf32 } from './kdf.js';
7
+
8
+ /**
9
+ * The HKDF `info` label of the authorship key, versioned and part of the on-disk contract like the
10
+ * keypairs' labels: a tag written under one label never verifies under another.
11
+ */
12
+ export const AUTHORSHIP_KEY_LABEL = 'robota/keyring/v1/authorship-hmac';
13
+
14
+ /** An HMAC-SHA256 tag. */
15
+ export const AUTHORSHIP_TAG_BYTES = 32;
16
+
17
+ /**
18
+ * The authorship key: an HMAC-SHA256 key from its own HKDF-SHA256 label (empty salt), so it says
19
+ * nothing about the keypairs and they say nothing about it. Unlike the sealing key it has no public
20
+ * half, so only a process that recovered the master key can make a tag that verifies.
21
+ */
22
+ export function deriveAuthorshipKey(masterKey: Uint8Array): Buffer {
23
+ return hkdf32(masterKey, AUTHORSHIP_KEY_LABEL);
24
+ }
25
+
26
+ function asBuffer(bytes: Uint8Array): Buffer {
27
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
28
+ }
29
+
30
+ /** HMAC-SHA256 of `message` under `key`; a non-byte argument is a caller bug (`invalid-argument`). */
31
+ export function authorshipTag(key: Uint8Array, message: Uint8Array): Buffer {
32
+ if (!isBytes(key) || !isBytes(message)) {
33
+ throw new KeyringError('invalid-argument', 'An authorship tag is made over bytes.');
34
+ }
35
+ try {
36
+ return createHmac('sha256', asBuffer(key)).update(asBuffer(message)).digest();
37
+ } catch {
38
+ throw new KeyringError('invalid-argument', 'The authorship tag could not be computed.');
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Whether `tag` is the authorship tag of `message` under `key`, compared in constant time. A tag of
44
+ * another length is simply not one (the length is public), so it is refused before any comparison.
45
+ */
46
+ export function verifyAuthorshipTag(
47
+ key: Uint8Array,
48
+ message: Uint8Array,
49
+ tag: Uint8Array,
50
+ ): boolean {
51
+ if (!isBytes(tag)) {
52
+ throw new KeyringError('invalid-argument', 'An authorship tag is bytes.');
53
+ }
54
+ const expected = authorshipTag(key, message);
55
+ try {
56
+ if (tag.length !== expected.length) return false;
57
+ return timingSafeEqual(expected, asBuffer(tag));
58
+ } finally {
59
+ expected.fill(0);
60
+ }
61
+ }
package/src/bytes.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { Buffer } from 'node:buffer';
2
+
3
+ import { KeyringError } from './errors.js';
4
+
5
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
6
+ const LENGTH_PREFIX_BYTES = 2;
7
+ const MAX_PART_BYTES = 0xffff;
8
+
9
+ /** A byte array from any realm (a `Buffer` qualifies); `instanceof` fails across realms. */
10
+ export function isBytes(value: unknown): value is Uint8Array {
11
+ return Object.prototype.toString.call(value) === '[object Uint8Array]';
12
+ }
13
+
14
+ export function toBase64Url(bytes: Uint8Array): string {
15
+ return Buffer.from(bytes).toString('base64url');
16
+ }
17
+
18
+ /**
19
+ * Decodes canonical, unpadded base64url of exactly `expectedLength` bytes, or returns `undefined`.
20
+ * `Buffer` alone is lenient (it skips junk characters and accepts non-canonical trailing bits), so a
21
+ * document could carry two spellings of one value; the round-trip check keeps the encoding unique.
22
+ */
23
+ export function fromBase64Url(text: unknown, expectedLength: number): Buffer | undefined {
24
+ if (typeof text !== 'string' || !BASE64URL_PATTERN.test(text)) return undefined;
25
+ const decoded = Buffer.from(text, 'base64url');
26
+ if (decoded.length !== expectedLength) return undefined;
27
+ return decoded.toString('base64url') === text ? decoded : undefined;
28
+ }
29
+
30
+ /** {@link fromBase64Url} for keyring fields, where a bad encoding means a malformed document. */
31
+ export function decodeKeyringField(text: unknown, expectedLength: number): Buffer {
32
+ const decoded = fromBase64Url(text, expectedLength);
33
+ if (!decoded) {
34
+ throw new KeyringError(
35
+ 'malformed-keyring',
36
+ 'The keyring is malformed: a field is not decodable.',
37
+ );
38
+ }
39
+ return decoded;
40
+ }
41
+
42
+ export function utf8(text: string): Buffer {
43
+ return Buffer.from(text, 'utf8');
44
+ }
45
+
46
+ /**
47
+ * Joins parts so that no two different part lists produce the same bytes (each part is preceded by
48
+ * its 2-byte big-endian length). Used wherever several values are bound into one AEAD AAD or one
49
+ * HKDF input.
50
+ */
51
+ export function concatLengthPrefixed(parts: readonly Uint8Array[]): Buffer {
52
+ const chunks: Buffer[] = [];
53
+ for (const part of parts) {
54
+ if (part.length > MAX_PART_BYTES) {
55
+ throw new KeyringError('invalid-argument', 'A value bound into the AAD exceeds 65535 bytes.');
56
+ }
57
+ const prefix = Buffer.alloc(LENGTH_PREFIX_BYTES);
58
+ prefix.writeUInt16BE(part.length);
59
+ chunks.push(prefix, Buffer.from(part.buffer, part.byteOffset, part.byteLength));
60
+ }
61
+ return Buffer.concat(chunks);
62
+ }
63
+
64
+ /** Zeroes buffers in place. Best effort: it cannot reach copies the JavaScript engine made. */
65
+ export function wipe(...buffers: ReadonlyArray<Uint8Array | undefined>): void {
66
+ for (const buffer of buffers) buffer?.fill(0);
67
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Why an operation was refused. The union is the whole error vocabulary: a caller decides on `code`,
3
+ * never on the message.
4
+ *
5
+ * - `wrong-credential` — the passphrase or recovery key did not open the keyslot. A mistyped
6
+ * credential and a tampered slot are indistinguishable by design (both fail authentication).
7
+ * - `malformed-keyring` / `unsupported-version` — the keyring document is not a valid version-1
8
+ * document, or names a version this build does not know. `unsupported-version` also covers an
9
+ * unknown version byte on a sealed key, record or blob.
10
+ * - `kdf-parameters-out-of-bounds` — a slot asks for a scrypt cost outside the accepted range; raised
11
+ * before any key derivation runs. `kdf-failed` means a key derivation or key generation could not
12
+ * run on this host (for example memory, or a crypto build without the curve), with inputs that
13
+ * were in range.
14
+ * - `empty-passphrase` / `invalid-recovery-key` — the credential has the wrong form; no key
15
+ * derivation ran.
16
+ * - `slot-not-found` — the keyring has no slot of the type the operation needs.
17
+ * - `malformed-ciphertext` — a sealed key, record or blob is structurally impossible (too short).
18
+ * - `decryption-failed` — authentication failed: wrong key, wrong AAD, or modified bytes.
19
+ * - `invalid-argument` — a key or buffer argument has the wrong type or length, or a key the
20
+ * cipher or key import refuses.
21
+ * - `keys-locked` — the unlocked key set was locked and no longer hands out keys.
22
+ * - `link-refused` — an owner-link step was refused; the error is a {@link LinkRefusedError}, whose
23
+ * `reason` names the check that failed. The other end of a link is never told the reason.
24
+ * - `link-closed` — the link was closed, by `close()` or by a frame that did not open, and protects
25
+ * nothing more.
26
+ */
27
+ export type TKeyringErrorCode =
28
+ | 'wrong-credential'
29
+ | 'malformed-keyring'
30
+ | 'unsupported-version'
31
+ | 'kdf-parameters-out-of-bounds'
32
+ | 'kdf-failed'
33
+ | 'empty-passphrase'
34
+ | 'invalid-recovery-key'
35
+ | 'slot-not-found'
36
+ | 'malformed-ciphertext'
37
+ | 'decryption-failed'
38
+ | 'invalid-argument'
39
+ | 'keys-locked'
40
+ | 'link-refused'
41
+ | 'link-closed';
42
+
43
+ /**
44
+ * The only error this package throws on purpose. Messages are fixed text that names a field or a
45
+ * rule; no message or property ever carries a passphrase, a key, a recovery key or any value derived
46
+ * from them, and the original `node:crypto` error is deliberately not chained.
47
+ */
48
+ export class KeyringError extends Error {
49
+ readonly code: TKeyringErrorCode;
50
+
51
+ constructor(code: TKeyringErrorCode, message: string) {
52
+ super(message);
53
+ this.name = 'KeyringError';
54
+ this.code = code;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Why an owner-link step was refused. Each names the one check that failed, for logs and for the
60
+ * caller's fixed wording; none says anything about a key.
61
+ *
62
+ * - `malformed` — a message or value of the wrong length, version, mode or encoding.
63
+ * - `out-of-order` — a step taken twice, or before its turn.
64
+ * - `spawner-unavailable` — spawner standing was asked of a runtime without a spawner key: it never
65
+ * ran its exchange, or its spawner key was forgotten when its trust root was installed.
66
+ * - `not-certified` — owner mode was asked of a runtime with no trust root.
67
+ * - `already-certified` — a second trust root.
68
+ * - `certificate-mismatch` — the certificate is not the attach key's signature over this instance
69
+ * and binding: made by another keyring, or for another runtime or start.
70
+ * - `instance-mismatch` — the runtime answered with an instance key other than its exchange's.
71
+ * - `bad-runtime-proof` — the runtime's proof of the transcript did not verify.
72
+ * - `bad-proof` — the initiator's proof of the transcript did not verify.
73
+ */
74
+ export type TLinkRefusalReason =
75
+ | 'malformed'
76
+ | 'out-of-order'
77
+ | 'spawner-unavailable'
78
+ | 'not-certified'
79
+ | 'already-certified'
80
+ | 'certificate-mismatch'
81
+ | 'instance-mismatch'
82
+ | 'bad-runtime-proof'
83
+ | 'bad-proof';
84
+
85
+ const LINK_REFUSAL_MESSAGES: Readonly<Record<TLinkRefusalReason, string>> = {
86
+ malformed: 'The owner link refused a malformed message.',
87
+ 'out-of-order': 'The owner link refused a step out of order.',
88
+ 'spawner-unavailable': 'The runtime holds no spawner key.',
89
+ 'not-certified': 'The runtime holds no trust root.',
90
+ 'already-certified': 'The runtime already holds a trust root.',
91
+ 'certificate-mismatch': 'The certificate does not certify this runtime for this owner.',
92
+ 'instance-mismatch': 'The runtime answered with another instance key.',
93
+ 'bad-runtime-proof': 'The runtime did not prove the transcript.',
94
+ 'bad-proof': 'The initiator did not prove the transcript.',
95
+ };
96
+
97
+ /** An owner-link refusal: a `KeyringError` with code `link-refused` and the failed check as `reason`. */
98
+ export class LinkRefusedError extends KeyringError {
99
+ readonly reason: TLinkRefusalReason;
100
+
101
+ constructor(reason: TLinkRefusalReason) {
102
+ super('link-refused', LINK_REFUSAL_MESSAGES[reason]);
103
+ this.name = 'LinkRefusedError';
104
+ this.reason = reason;
105
+ }
106
+ }
@@ -0,0 +1,32 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { isBytes } from './bytes.js';
4
+ import { KeyringError } from './errors.js';
5
+ import { KEY_BYTES } from './kdf.js';
6
+
7
+ const FINGERPRINT_BYTES = 10;
8
+ const GROUP_HEX_CHARS = 4;
9
+
10
+ /**
11
+ * A short string two people can read to each other to confirm they hold the same public key: the
12
+ * first 10 bytes of SHA-256 of the raw 32-byte key, as upper-case hex in groups of four
13
+ * (`A1B2-C3D4-E5F6-0718-293A`). 80 bits is for a human comparing out loud or on screen; it is a
14
+ * check against accidents and casual swaps, not a collision-resistant identifier.
15
+ */
16
+ export function fingerprint(publicKey: Uint8Array): string {
17
+ if (!isBytes(publicKey) || publicKey.length !== KEY_BYTES) {
18
+ throw new KeyringError(
19
+ 'invalid-argument',
20
+ 'A public key must be a Uint8Array of exactly 32 bytes.',
21
+ );
22
+ }
23
+ const hex = createHash('sha256')
24
+ .update(publicKey)
25
+ .digest('hex')
26
+ .slice(0, FINGERPRINT_BYTES * 2);
27
+ const groups: string[] = [];
28
+ for (let index = 0; index < hex.length; index += GROUP_HEX_CHARS) {
29
+ groups.push(hex.slice(index, index + GROUP_HEX_CHARS).toUpperCase());
30
+ }
31
+ return groups.join('-');
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `@robota-sdk/agent-keyring` — key custody and sealing primitives for secure conversations, on
3
+ * `node:crypto` only. A passphrase and a recovery key each unwrap one master key; the master key
4
+ * derives an X25519 sealing keypair, an Ed25519 attach keypair and an HMAC authorship key; artifacts
5
+ * are encrypted under per-artifact keys that are sealed to the public sealing key, so a locked process
6
+ * can still write, and only an unlocked one can tag what it wrote. An unlock yields the read keys;
7
+ * the attach key stays inside a short-lived owner handover, which certifies runtimes.
8
+ */
9
+ export { KeyringError, LinkRefusedError } from './errors.js';
10
+ export type { TKeyringErrorCode, TLinkRefusalReason } from './errors.js';
11
+
12
+ export type {
13
+ IKeyring,
14
+ IPassphraseKeyslot,
15
+ IRecoveryKeyslot,
16
+ IScryptKdfParameters,
17
+ TKeyslot,
18
+ TKeyslotType,
19
+ } from './keyring-types.js';
20
+ export { KEYRING_VERSION } from './keyring-types.js';
21
+ export { getSealingPublicKey, parseKeyring, serializeKeyring } from './keyring-document.js';
22
+
23
+ export {
24
+ changePassphrase,
25
+ checkSealingPublicKey,
26
+ createKeyring,
27
+ repairSealingPublicKey,
28
+ unlockWithPassphrase,
29
+ unlockWithRecoveryKey,
30
+ } from './keyring.js';
31
+ export type { ICreateKeyringOptions, ICreatedKeyring, TKeyringCredential } from './keyring.js';
32
+
33
+ export type { IReadKeys } from './read-keys.js';
34
+ export { openOwnerHandover } from './owner-handover.js';
35
+ export type { IOwnerHandover } from './owner-handover.js';
36
+ export type { ILinkBinding, ITrustRoot } from './runtime-certificate.js';
37
+ export { SpawnerKey, startSpawnerExchange } from './spawner-exchange.js';
38
+ export type { ISpawnerExchange, ISpawnerExchangeResult } from './spawner-exchange.js';
39
+ export { createRuntimeIdentity } from './runtime-identity.js';
40
+ export type { IRuntimeIdentity } from './runtime-identity.js';
41
+ export {
42
+ LINK_CHALLENGE_MAX_BYTES,
43
+ LINK_HELLO_BYTES,
44
+ LINK_PROOF_MAX_BYTES,
45
+ createLinkInitiator,
46
+ createLinkResponder,
47
+ } from './owner-link.js';
48
+ export type { ILinkInitiator, ILinkResponder, TLinkInitiatorAuth } from './owner-link.js';
49
+ export {
50
+ LINK_FRAME_MAX_PLAINTEXT_BYTES,
51
+ LINK_FRAME_OVERHEAD_BYTES,
52
+ LINK_GRANT_FRAME_BYTES,
53
+ linkFrameKind,
54
+ } from './link-frames.js';
55
+ export type {
56
+ IEstablishedLink,
57
+ IInitiatorLink,
58
+ IResponderLink,
59
+ TLinkDataFrameKind,
60
+ TLinkFrameKind,
61
+ TLinkMode,
62
+ } from './link-frames.js';
63
+ export type { IKeyPair } from './key-pairs.js';
64
+ export { generateTransientSealingKeyPair } from './key-pairs.js';
65
+
66
+ export { fingerprint } from './fingerprint.js';
67
+
68
+ export {
69
+ decryptRecord,
70
+ encryptRecord,
71
+ generateArtifactKey,
72
+ openArtifactKey,
73
+ openBlob,
74
+ sealArtifactKey,
75
+ sealBlob,
76
+ splitSealedBlob,
77
+ } from './sealing.js';
package/src/kdf.ts ADDED
@@ -0,0 +1,111 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { hkdfSync, scrypt } from 'node:crypto';
3
+
4
+ import { utf8 } from './bytes.js';
5
+ import { KeyringError } from './errors.js';
6
+
7
+ export const KEY_BYTES = 32;
8
+ export const SCRYPT_SALT_BYTES = 16;
9
+
10
+ export interface IScryptParameters {
11
+ readonly N: number;
12
+ readonly r: number;
13
+ readonly p: number;
14
+ }
15
+
16
+ /** The cost a new passphrase slot is written with (OWASP's scrypt baseline, ~128 MiB, ~160 ms). */
17
+ export const DEFAULT_SCRYPT_PARAMETERS: IScryptParameters = { N: 2 ** 17, r: 8, p: 1 };
18
+
19
+ /**
20
+ * The cost range an unlock accepts. The floor stops a hostile keyring from downgrading the work
21
+ * factor; the ceiling stops one from making unlock allocate gigabytes or run for minutes. The file
22
+ * is writable by every program of the user, so the memory a keyring may ask for is capped at 512 MiB
23
+ * (four times the default) however N and r are combined. All of it is checked before any key
24
+ * derivation starts.
25
+ */
26
+ export const SCRYPT_BOUNDS = {
27
+ minN: 2 ** 15,
28
+ maxN: 2 ** 19,
29
+ minR: 8,
30
+ maxR: 16,
31
+ p: 1,
32
+ maxMemoryBytes: 2 ** 29,
33
+ } as const;
34
+
35
+ const SCRYPT_BLOCK_BYTES = 128;
36
+ const SCRYPT_MEMORY_HEADROOM_BYTES = 1024 * 1024;
37
+
38
+ export function assertScryptParametersInBounds(parameters: IScryptParameters): void {
39
+ const { N, r, p } = parameters;
40
+ const nInBounds =
41
+ Number.isInteger(N) &&
42
+ N >= SCRYPT_BOUNDS.minN &&
43
+ N <= SCRYPT_BOUNDS.maxN &&
44
+ (N & (N - 1)) === 0;
45
+ const rInBounds = Number.isInteger(r) && r >= SCRYPT_BOUNDS.minR && r <= SCRYPT_BOUNDS.maxR;
46
+ const memoryInBounds = SCRYPT_BLOCK_BYTES * r * N <= SCRYPT_BOUNDS.maxMemoryBytes;
47
+ if (!nInBounds || !rInBounds || !memoryInBounds || p !== SCRYPT_BOUNDS.p) {
48
+ throw new KeyringError(
49
+ 'kdf-parameters-out-of-bounds',
50
+ 'A passphrase slot requests scrypt parameters outside the accepted range.',
51
+ );
52
+ }
53
+ }
54
+
55
+ /**
56
+ * NFKC then UTF-8, so the same typed text unlocks on every platform and keyboard layout. Only the
57
+ * empty string is refused; length and strength policy belong to the caller. The returned buffer is
58
+ * the caller's to wipe (the JavaScript string it came from cannot be wiped).
59
+ */
60
+ export function normalizePassphrase(passphrase: unknown): Buffer {
61
+ if (typeof passphrase !== 'string') {
62
+ throw new KeyringError('invalid-argument', 'The passphrase must be a string.');
63
+ }
64
+ const normalized = passphrase.normalize('NFKC');
65
+ if (normalized.length === 0) {
66
+ throw new KeyringError('empty-passphrase', 'The passphrase is empty.');
67
+ }
68
+ return utf8(normalized);
69
+ }
70
+
71
+ /**
72
+ * Derives the passphrase slot's key-encryption key. Asynchronous (`crypto.scrypt` runs on the
73
+ * libuv thread pool) so ~160 ms of work never blocks the caller's event loop.
74
+ */
75
+ export function deriveKeyFromPassphrase(
76
+ passphrase: Uint8Array,
77
+ salt: Uint8Array,
78
+ parameters: IScryptParameters,
79
+ ): Promise<Buffer> {
80
+ assertScryptParametersInBounds(parameters);
81
+ const { N, r, p } = parameters;
82
+ // scrypt needs ~128*r*(N+p+2) bytes; Node refuses unless maxmem is above that.
83
+ const maxmem = SCRYPT_BLOCK_BYTES * r * (N + p + 2) + SCRYPT_MEMORY_HEADROOM_BYTES;
84
+ return new Promise<Buffer>((resolve, reject) => {
85
+ try {
86
+ scrypt(passphrase, salt, KEY_BYTES, { N, r, p, maxmem }, (error, derivedKey) => {
87
+ if (error) reject(kdfFailed());
88
+ else resolve(derivedKey);
89
+ });
90
+ } catch {
91
+ reject(kdfFailed());
92
+ }
93
+ });
94
+ }
95
+
96
+ function kdfFailed(): KeyringError {
97
+ return new KeyringError('kdf-failed', 'The key derivation could not run on this host.');
98
+ }
99
+
100
+ /** HKDF-SHA256 to 32 bytes. An empty salt is RFC 5869's "no salt" (a string of zero bytes). */
101
+ export function hkdf32(
102
+ inputKeyMaterial: Uint8Array,
103
+ info: string | Uint8Array,
104
+ salt: Uint8Array = new Uint8Array(0),
105
+ ): Buffer {
106
+ try {
107
+ return Buffer.from(hkdfSync('sha256', inputKeyMaterial, salt, info, KEY_BYTES));
108
+ } catch {
109
+ throw kdfFailed();
110
+ }
111
+ }