@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
package/src/key-pairs.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createPrivateKey, createPublicKey, randomBytes, type KeyObject } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { isBytes, wipe } from './bytes.js';
|
|
5
|
+
import { KeyringError } from './errors.js';
|
|
6
|
+
import { hkdf32, KEY_BYTES } from './kdf.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The HKDF `info` labels. They are versioned and part of the on-disk contract: changing a byte
|
|
10
|
+
* changes every derived key, so the known-answer tests pin the public keys they produce.
|
|
11
|
+
*/
|
|
12
|
+
export const SEALING_KEY_LABEL = 'robota/keyring/v1/sealing-x25519';
|
|
13
|
+
export const ATTACH_KEY_LABEL = 'robota/keyring/v1/attach-ed25519';
|
|
14
|
+
|
|
15
|
+
// `node:crypto` has no "private key from raw seed" import that Bun and Node both accept, so the raw
|
|
16
|
+
// 32-byte seed is wrapped in the fixed PKCS#8 / SPKI headers of RFC 8410.
|
|
17
|
+
const PKCS8_X25519_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex');
|
|
18
|
+
const PKCS8_ED25519_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
19
|
+
const SPKI_X25519_PREFIX = Buffer.from('302a300506032b656e032100', 'hex');
|
|
20
|
+
const SPKI_ED25519_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
21
|
+
const SPKI_PREFIX_BYTES = SPKI_X25519_PREFIX.length;
|
|
22
|
+
|
|
23
|
+
export interface IKeyPair {
|
|
24
|
+
/** The raw 32-byte public key. */
|
|
25
|
+
readonly publicKey: Uint8Array;
|
|
26
|
+
readonly privateKey: KeyObject;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface IDerivedKeyPairs {
|
|
30
|
+
readonly sealing: IKeyPair;
|
|
31
|
+
readonly attach: IKeyPair;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The raw 32-byte public key of an X25519 or Ed25519 key object. */
|
|
35
|
+
export function rawPublicKey(key: KeyObject): Buffer {
|
|
36
|
+
try {
|
|
37
|
+
const spki = (key.type === 'public' ? key : createPublicKey(key)).export({
|
|
38
|
+
format: 'der',
|
|
39
|
+
type: 'spki',
|
|
40
|
+
});
|
|
41
|
+
return Buffer.from(spki.subarray(SPKI_PREFIX_BYTES));
|
|
42
|
+
} catch {
|
|
43
|
+
throw new KeyringError('invalid-argument', 'The key has no exportable public key.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An X25519 public key object from its raw 32 bytes, from any realm (see `isBytes`). */
|
|
48
|
+
export function x25519PublicKeyFromRaw(raw: Uint8Array): KeyObject {
|
|
49
|
+
if (!isBytes(raw) || raw.length !== KEY_BYTES) {
|
|
50
|
+
throw new KeyringError('invalid-argument', 'A public key must be exactly 32 bytes.');
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return createPublicKey({
|
|
54
|
+
key: Buffer.concat([SPKI_X25519_PREFIX, raw]),
|
|
55
|
+
format: 'der',
|
|
56
|
+
type: 'spki',
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
throw new KeyringError('invalid-argument', 'The public key is not a valid X25519 key.');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An Ed25519 public key object from its raw 32 bytes, from any realm (see `isBytes`). */
|
|
64
|
+
export function ed25519PublicKeyFromRaw(raw: Uint8Array): KeyObject {
|
|
65
|
+
if (!isBytes(raw) || raw.length !== KEY_BYTES) {
|
|
66
|
+
throw new KeyringError('invalid-argument', 'A public key must be exactly 32 bytes.');
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return createPublicKey({
|
|
70
|
+
key: Buffer.concat([SPKI_ED25519_PREFIX, raw]),
|
|
71
|
+
format: 'der',
|
|
72
|
+
type: 'spki',
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
throw new KeyringError('invalid-argument', 'The public key is not a valid Ed25519 key.');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A keypair from a derived seed; a host that cannot build one fails as `kdf-failed`, whatever it threw. */
|
|
80
|
+
function keyPairFromSeed(prefix: Buffer, seed: Uint8Array): IKeyPair {
|
|
81
|
+
const pkcs8 = Buffer.concat([prefix, seed]);
|
|
82
|
+
try {
|
|
83
|
+
const privateKey = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' });
|
|
84
|
+
return { publicKey: rawPublicKey(privateKey), privateKey };
|
|
85
|
+
} catch {
|
|
86
|
+
throw new KeyringError('kdf-failed', 'The key derivation could not run on this host.');
|
|
87
|
+
} finally {
|
|
88
|
+
wipe(pkcs8);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The X25519 keypair a 32-byte seed makes: what a sealing seed, derived or granted, becomes. */
|
|
93
|
+
export function x25519KeyPairFromSeed(seed: Uint8Array): IKeyPair {
|
|
94
|
+
return keyPairFromSeed(PKCS8_X25519_PREFIX, seed);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The Ed25519 keypair a 32-byte seed makes. */
|
|
98
|
+
export function ed25519KeyPairFromSeed(seed: Uint8Array): IKeyPair {
|
|
99
|
+
return keyPairFromSeed(PKCS8_ED25519_PREFIX, seed);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The sealing keypair's seed; the caller wipes it. */
|
|
103
|
+
export function deriveSealingSeed(masterKey: Uint8Array): Buffer {
|
|
104
|
+
return hkdf32(masterKey, SEALING_KEY_LABEL);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The attach keypair alone, for the one holder that may have it (the owner handover). */
|
|
108
|
+
export function deriveAttachKeyPair(masterKey: Uint8Array): IKeyPair {
|
|
109
|
+
const attachSeed = hkdf32(masterKey, ATTACH_KEY_LABEL);
|
|
110
|
+
try {
|
|
111
|
+
return keyPairFromSeed(PKCS8_ED25519_PREFIX, attachSeed);
|
|
112
|
+
} finally {
|
|
113
|
+
wipe(attachSeed);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Derives the two long-lived keypairs from the master key: an X25519 sealing keypair and an Ed25519
|
|
119
|
+
* attach keypair, each from its own HKDF-SHA256 seed (empty salt, versioned `info` label), so
|
|
120
|
+
* neither key says anything about the other.
|
|
121
|
+
*/
|
|
122
|
+
export function deriveKeyPairs(masterKey: Uint8Array): IDerivedKeyPairs {
|
|
123
|
+
const sealingSeed = hkdf32(masterKey, SEALING_KEY_LABEL);
|
|
124
|
+
const attachSeed = hkdf32(masterKey, ATTACH_KEY_LABEL);
|
|
125
|
+
try {
|
|
126
|
+
return {
|
|
127
|
+
sealing: keyPairFromSeed(PKCS8_X25519_PREFIX, sealingSeed),
|
|
128
|
+
attach: keyPairFromSeed(PKCS8_ED25519_PREFIX, attachSeed),
|
|
129
|
+
};
|
|
130
|
+
} finally {
|
|
131
|
+
wipe(sealingSeed, attachSeed);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A new X25519 sealing keypair from a random seed, for a key that lives only in the memory of the
|
|
137
|
+
* process that made it: nothing derives it again, so whatever is sealed to it opens only while that
|
|
138
|
+
* process keeps the private key. Built from the seed as the derived pairs are, so it has their shape
|
|
139
|
+
* on every runtime.
|
|
140
|
+
*/
|
|
141
|
+
export function generateTransientSealingKeyPair(): IKeyPair {
|
|
142
|
+
const seed = randomBytes(KEY_BYTES);
|
|
143
|
+
try {
|
|
144
|
+
return keyPairFromSeed(PKCS8_X25519_PREFIX, seed);
|
|
145
|
+
} finally {
|
|
146
|
+
wipe(seed);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
|
|
3
|
+
import { fromBase64Url } from './bytes.js';
|
|
4
|
+
import { KeyringError } from './errors.js';
|
|
5
|
+
import { GCM_IV_BYTES, GCM_TAG_BYTES } from './aead.js';
|
|
6
|
+
import { KEY_BYTES, SCRYPT_SALT_BYTES } from './kdf.js';
|
|
7
|
+
import {
|
|
8
|
+
KEYRING_VERSION,
|
|
9
|
+
type IKeyring,
|
|
10
|
+
type IPassphraseKeyslot,
|
|
11
|
+
type IRecoveryKeyslot,
|
|
12
|
+
type TKeyslot,
|
|
13
|
+
} from './keyring-types.js';
|
|
14
|
+
|
|
15
|
+
export const SLOT_ID_BYTES = 16;
|
|
16
|
+
export const WRAPPED_KEY_BYTES = KEY_BYTES + GCM_TAG_BYTES;
|
|
17
|
+
/** A keyring is a few hundred bytes; anything near this size is not one. */
|
|
18
|
+
export const MAX_KEYRING_JSON_CHARS = 16 * 1024;
|
|
19
|
+
export const MAX_KEYSLOTS = 8;
|
|
20
|
+
|
|
21
|
+
const KEYRING_KEYS = ['version', 'sealingPublicKey', 'slots'];
|
|
22
|
+
const SLOT_COMMON_KEYS = ['id', 'type', 'iv', 'wrappedKey'];
|
|
23
|
+
const PASSPHRASE_SLOT_KEYS = [...SLOT_COMMON_KEYS, 'kdf'];
|
|
24
|
+
const KDF_KEYS = ['algorithm', 'N', 'r', 'p', 'salt'];
|
|
25
|
+
|
|
26
|
+
function malformed(detail: string): KeyringError {
|
|
27
|
+
return new KeyringError('malformed-keyring', `The keyring is malformed: ${detail}.`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A plain data object (JSON-shaped), not an array, class instance or null-prototype oddity. */
|
|
31
|
+
function asRecord(value: unknown, what: string): Record<string, unknown> {
|
|
32
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
33
|
+
throw malformed(`${what} must be an object`);
|
|
34
|
+
}
|
|
35
|
+
const prototype = Object.getPrototypeOf(value);
|
|
36
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
37
|
+
throw malformed(`${what} must be a plain object`);
|
|
38
|
+
}
|
|
39
|
+
return value as Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Exactly the allowed own keys: an unknown key (including `__proto__` as data) is refused. */
|
|
43
|
+
function requireExactKeys(record: Record<string, unknown>, allowed: string[], what: string): void {
|
|
44
|
+
const keys = Object.keys(record);
|
|
45
|
+
if (keys.length !== allowed.length || keys.some((key) => !allowed.includes(key))) {
|
|
46
|
+
throw malformed(`${what} must have exactly the fields ${allowed.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requireBytes(value: unknown, length: number, what: string): string {
|
|
51
|
+
if (fromBase64Url(value, length) === undefined) {
|
|
52
|
+
throw malformed(`${what} must be canonical base64url of ${length} bytes`);
|
|
53
|
+
}
|
|
54
|
+
return value as string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function requirePositiveInteger(value: unknown, what: string): number {
|
|
58
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
|
59
|
+
throw malformed(`${what} must be a positive integer`);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readPassphraseSlot(record: Record<string, unknown>, what: string): IPassphraseKeyslot {
|
|
65
|
+
requireExactKeys(record, PASSPHRASE_SLOT_KEYS, what);
|
|
66
|
+
const kdf = asRecord(record.kdf, `${what}.kdf`);
|
|
67
|
+
requireExactKeys(kdf, KDF_KEYS, `${what}.kdf`);
|
|
68
|
+
if (kdf.algorithm !== 'scrypt') throw malformed(`${what}.kdf.algorithm must be "scrypt"`);
|
|
69
|
+
// Built field by field in the document's fixed order, so serialization is deterministic.
|
|
70
|
+
return {
|
|
71
|
+
id: requireBytes(record.id, SLOT_ID_BYTES, `${what}.id`),
|
|
72
|
+
type: 'passphrase',
|
|
73
|
+
kdf: Object.freeze({
|
|
74
|
+
algorithm: 'scrypt',
|
|
75
|
+
N: requirePositiveInteger(kdf.N, `${what}.kdf.N`),
|
|
76
|
+
r: requirePositiveInteger(kdf.r, `${what}.kdf.r`),
|
|
77
|
+
p: requirePositiveInteger(kdf.p, `${what}.kdf.p`),
|
|
78
|
+
salt: requireBytes(kdf.salt, SCRYPT_SALT_BYTES, `${what}.kdf.salt`),
|
|
79
|
+
} as const),
|
|
80
|
+
iv: requireBytes(record.iv, GCM_IV_BYTES, `${what}.iv`),
|
|
81
|
+
wrappedKey: requireBytes(record.wrappedKey, WRAPPED_KEY_BYTES, `${what}.wrappedKey`),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readRecoverySlot(record: Record<string, unknown>, what: string): IRecoveryKeyslot {
|
|
86
|
+
requireExactKeys(record, SLOT_COMMON_KEYS, what);
|
|
87
|
+
return {
|
|
88
|
+
id: requireBytes(record.id, SLOT_ID_BYTES, `${what}.id`),
|
|
89
|
+
type: 'recovery',
|
|
90
|
+
iv: requireBytes(record.iv, GCM_IV_BYTES, `${what}.iv`),
|
|
91
|
+
wrappedKey: requireBytes(record.wrappedKey, WRAPPED_KEY_BYTES, `${what}.wrappedKey`),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readSlot(value: unknown, index: number): TKeyslot {
|
|
96
|
+
const what = `slots[${index}]`;
|
|
97
|
+
const record = asRecord(value, what);
|
|
98
|
+
switch (record.type) {
|
|
99
|
+
case 'passphrase':
|
|
100
|
+
return Object.freeze(readPassphraseSlot(record, what));
|
|
101
|
+
case 'recovery':
|
|
102
|
+
return Object.freeze(readRecoverySlot(record, what));
|
|
103
|
+
default:
|
|
104
|
+
// Not "ignore and carry on": dropping an unknown slot on the next write would destroy it.
|
|
105
|
+
throw malformed(`${what}.type is not a known slot type`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Validates a version-1 keyring document and returns a fresh, frozen copy built only from
|
|
111
|
+
* validated primitives — nothing from the input object (and nothing from its prototype chain)
|
|
112
|
+
* is carried over. Used by `parseKeyring` and by every operation that accepts a keyring, so a
|
|
113
|
+
* hand-built object gets the same scrutiny as a parsed file.
|
|
114
|
+
*/
|
|
115
|
+
export function validateKeyring(value: unknown): IKeyring {
|
|
116
|
+
const record = asRecord(value, 'the document');
|
|
117
|
+
// Version first: a future document may legitimately have a different shape.
|
|
118
|
+
if (record.version !== KEYRING_VERSION) {
|
|
119
|
+
if (typeof record.version === 'number' && Number.isSafeInteger(record.version)) {
|
|
120
|
+
throw new KeyringError(
|
|
121
|
+
'unsupported-version',
|
|
122
|
+
'The keyring was written by a version this build does not support.',
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
throw malformed('version must be an integer');
|
|
126
|
+
}
|
|
127
|
+
requireExactKeys(record, KEYRING_KEYS, 'the document');
|
|
128
|
+
const sealingPublicKey = requireBytes(record.sealingPublicKey, KEY_BYTES, 'sealingPublicKey');
|
|
129
|
+
if (
|
|
130
|
+
!Array.isArray(record.slots) ||
|
|
131
|
+
record.slots.length < 1 ||
|
|
132
|
+
record.slots.length > MAX_KEYSLOTS
|
|
133
|
+
) {
|
|
134
|
+
throw malformed(`slots must be an array of 1 to ${MAX_KEYSLOTS} entries`);
|
|
135
|
+
}
|
|
136
|
+
const slots = record.slots.map((slot, index) => readSlot(slot, index));
|
|
137
|
+
if (new Set(slots.map((slot) => slot.id)).size !== slots.length) {
|
|
138
|
+
throw malformed('slot ids must be unique');
|
|
139
|
+
}
|
|
140
|
+
return Object.freeze({
|
|
141
|
+
version: KEYRING_VERSION,
|
|
142
|
+
sealingPublicKey,
|
|
143
|
+
slots: Object.freeze(slots),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Parses keyring JSON strictly; see {@link validateKeyring}. */
|
|
148
|
+
export function parseKeyring(json: string): IKeyring {
|
|
149
|
+
if (typeof json !== 'string') throw malformed('the document must be a JSON string');
|
|
150
|
+
if (json.length > MAX_KEYRING_JSON_CHARS) throw malformed('the document is too large');
|
|
151
|
+
let value: unknown;
|
|
152
|
+
try {
|
|
153
|
+
value = JSON.parse(json);
|
|
154
|
+
} catch {
|
|
155
|
+
throw malformed('the document is not valid JSON');
|
|
156
|
+
}
|
|
157
|
+
return validateKeyring(value);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The keyring as JSON with a fixed field order, ready to write to disk. */
|
|
161
|
+
export function serializeKeyring(keyring: IKeyring): string {
|
|
162
|
+
return JSON.stringify(validateKeyring(keyring), null, 2);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The raw 32-byte sealing public key — all a locked process needs to seal new artifacts. */
|
|
166
|
+
export function getSealingPublicKey(keyring: IKeyring): Buffer {
|
|
167
|
+
return Buffer.from(validateKeyring(keyring).sealingPublicKey, 'base64url');
|
|
168
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** The only keyring document version this build reads and writes. */
|
|
2
|
+
export const KEYRING_VERSION = 1;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How a passphrase slot stretches the passphrase. All fields are stored in the slot so a keyring
|
|
6
|
+
* written at one cost still unlocks after the default cost changes.
|
|
7
|
+
*/
|
|
8
|
+
export interface IScryptKdfParameters {
|
|
9
|
+
readonly algorithm: 'scrypt';
|
|
10
|
+
readonly N: number;
|
|
11
|
+
readonly r: number;
|
|
12
|
+
readonly p: number;
|
|
13
|
+
/** base64url, 16 bytes. */
|
|
14
|
+
readonly salt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface IKeyslotBase {
|
|
18
|
+
/** base64url, 16 random bytes; bound into the wrapping AAD. */
|
|
19
|
+
readonly id: string;
|
|
20
|
+
/** base64url, 12 bytes. */
|
|
21
|
+
readonly iv: string;
|
|
22
|
+
/** base64url, the AES-256-GCM-wrapped master key plus its 16-byte tag (48 bytes). */
|
|
23
|
+
readonly wrappedKey: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IPassphraseKeyslot extends IKeyslotBase {
|
|
27
|
+
readonly type: 'passphrase';
|
|
28
|
+
readonly kdf: IScryptKdfParameters;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface IRecoveryKeyslot extends IKeyslotBase {
|
|
32
|
+
readonly type: 'recovery';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A keyslot is a tagged union on `type`: a future way to unlock (a device secret, for example) adds
|
|
37
|
+
* a member here and a branch in the parser, without touching the existing slots.
|
|
38
|
+
*/
|
|
39
|
+
export type TKeyslot = IPassphraseKeyslot | IRecoveryKeyslot;
|
|
40
|
+
export type TKeyslotType = TKeyslot['type'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The persisted keyring document. There is deliberately no field for the attach public key: the
|
|
44
|
+
* file lets anyone seal to the owner, but nothing in it lets a program claim to be the owner.
|
|
45
|
+
*/
|
|
46
|
+
export interface IKeyring {
|
|
47
|
+
readonly version: typeof KEYRING_VERSION;
|
|
48
|
+
/** base64url, the raw 32-byte X25519 sealing public key. */
|
|
49
|
+
readonly sealingPublicKey: string;
|
|
50
|
+
readonly slots: readonly TKeyslot[];
|
|
51
|
+
}
|
package/src/keyring.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { Buffer } from 'node:buffer';
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { deriveAuthorshipKey } from './authorship.js';
|
|
5
|
+
import { decodeKeyringField, toBase64Url, wipe } from './bytes.js';
|
|
6
|
+
import { KeyringError } from './errors.js';
|
|
7
|
+
import {
|
|
8
|
+
deriveKeyPairs,
|
|
9
|
+
deriveSealingSeed,
|
|
10
|
+
x25519KeyPairFromSeed,
|
|
11
|
+
type IDerivedKeyPairs,
|
|
12
|
+
} from './key-pairs.js';
|
|
13
|
+
import {
|
|
14
|
+
assertScryptParametersInBounds,
|
|
15
|
+
DEFAULT_SCRYPT_PARAMETERS,
|
|
16
|
+
deriveKeyFromPassphrase,
|
|
17
|
+
KEY_BYTES,
|
|
18
|
+
normalizePassphrase,
|
|
19
|
+
SCRYPT_SALT_BYTES,
|
|
20
|
+
} from './kdf.js';
|
|
21
|
+
import { getSealingPublicKey, validateKeyring } from './keyring-document.js';
|
|
22
|
+
import {
|
|
23
|
+
KEYRING_VERSION,
|
|
24
|
+
type IKeyring,
|
|
25
|
+
type IPassphraseKeyslot,
|
|
26
|
+
type IRecoveryKeyslot,
|
|
27
|
+
type TKeyslot,
|
|
28
|
+
} from './keyring-types.js';
|
|
29
|
+
import { deriveRecoveryKek, newSlotId, unwrapMasterKey, wrapMasterKey } from './keyslot.js';
|
|
30
|
+
import { formatRecoveryKey, parseRecoveryKey, RECOVERY_KEY_BYTES } from './recovery-key.js';
|
|
31
|
+
import { createReadKeys, type IReadKeys } from './read-keys.js';
|
|
32
|
+
|
|
33
|
+
/** What proves the right to open a keyring. A tagged union so a future credential is one more member. */
|
|
34
|
+
export type TKeyringCredential =
|
|
35
|
+
| { readonly type: 'passphrase'; readonly passphrase: string }
|
|
36
|
+
| { readonly type: 'recoveryKey'; readonly recoveryKey: string };
|
|
37
|
+
|
|
38
|
+
export interface ICreateKeyringOptions {
|
|
39
|
+
/** Any non-empty string; minimum length and strength are the caller's policy. */
|
|
40
|
+
readonly passphrase: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ICreatedKeyring {
|
|
44
|
+
readonly keyring: IKeyring;
|
|
45
|
+
/**
|
|
46
|
+
* The recovery key, formatted for a person to write down. It exists only here: it is not stored
|
|
47
|
+
* anywhere and cannot be shown again, so the caller must present it exactly once.
|
|
48
|
+
*/
|
|
49
|
+
readonly recoveryKey: string;
|
|
50
|
+
readonly unlocked: IReadKeys;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface IAssembleKeyringInput {
|
|
54
|
+
readonly masterKey: Uint8Array;
|
|
55
|
+
/** Normalized passphrase bytes (see `normalizePassphrase`). */
|
|
56
|
+
readonly passphrase: Uint8Array;
|
|
57
|
+
readonly recoveryKeyBytes: Uint8Array;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isPassphraseSlot(slot: TKeyslot): slot is IPassphraseKeyslot {
|
|
61
|
+
return slot.type === 'passphrase';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isRecoverySlot(slot: TKeyslot): slot is IRecoveryKeyslot {
|
|
65
|
+
return slot.type === 'recovery';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function makePassphraseSlot(
|
|
69
|
+
masterKey: Uint8Array,
|
|
70
|
+
passphrase: Uint8Array,
|
|
71
|
+
slotId: string,
|
|
72
|
+
): Promise<IPassphraseKeyslot> {
|
|
73
|
+
const salt = randomBytes(SCRYPT_SALT_BYTES);
|
|
74
|
+
const kek = await deriveKeyFromPassphrase(passphrase, salt, DEFAULT_SCRYPT_PARAMETERS);
|
|
75
|
+
try {
|
|
76
|
+
const wrapped = wrapMasterKey(kek, masterKey, slotId, 'passphrase');
|
|
77
|
+
return {
|
|
78
|
+
id: slotId,
|
|
79
|
+
type: 'passphrase',
|
|
80
|
+
kdf: { algorithm: 'scrypt', ...DEFAULT_SCRYPT_PARAMETERS, salt: toBase64Url(salt) },
|
|
81
|
+
...wrapped,
|
|
82
|
+
};
|
|
83
|
+
} finally {
|
|
84
|
+
wipe(kek);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeRecoverySlot(masterKey: Uint8Array, recoveryKeyBytes: Uint8Array): IRecoveryKeyslot {
|
|
89
|
+
const slotId = newSlotId();
|
|
90
|
+
const kek = deriveRecoveryKek(recoveryKeyBytes);
|
|
91
|
+
try {
|
|
92
|
+
return { id: slotId, type: 'recovery', ...wrapMasterKey(kek, masterKey, slotId, 'recovery') };
|
|
93
|
+
} finally {
|
|
94
|
+
wipe(kek);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wraps `masterKey` into a fresh keyring with one passphrase slot and one recovery slot. Exposed to
|
|
100
|
+
* the package's own tests (a fixed master key makes the known-answer vectors possible); it is not
|
|
101
|
+
* part of the public entry point, whose only way to make a keyring draws the master key at random.
|
|
102
|
+
*/
|
|
103
|
+
export async function assembleKeyring(
|
|
104
|
+
input: IAssembleKeyringInput,
|
|
105
|
+
): Promise<{ keyring: IKeyring; pairs: IDerivedKeyPairs }> {
|
|
106
|
+
const pairs = deriveKeyPairs(input.masterKey);
|
|
107
|
+
const passphraseSlot = await makePassphraseSlot(input.masterKey, input.passphrase, newSlotId());
|
|
108
|
+
const recoverySlot = makeRecoverySlot(input.masterKey, input.recoveryKeyBytes);
|
|
109
|
+
const keyring = validateKeyring({
|
|
110
|
+
version: KEYRING_VERSION,
|
|
111
|
+
sealingPublicKey: toBase64Url(pairs.sealing.publicKey),
|
|
112
|
+
slots: [passphraseSlot, recoverySlot],
|
|
113
|
+
});
|
|
114
|
+
return { keyring, pairs };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Creates a keyring around a new random 256-bit master key, protected by the passphrase and by a
|
|
119
|
+
* new recovery key, and returns it already unlocked. The master key is wiped before this returns.
|
|
120
|
+
*/
|
|
121
|
+
export async function createKeyring(options: ICreateKeyringOptions): Promise<ICreatedKeyring> {
|
|
122
|
+
const passphrase = normalizePassphrase(options.passphrase);
|
|
123
|
+
const masterKey = randomBytes(KEY_BYTES);
|
|
124
|
+
const recoveryKeyBytes = randomBytes(RECOVERY_KEY_BYTES);
|
|
125
|
+
try {
|
|
126
|
+
const { keyring, pairs } = await assembleKeyring({ masterKey, passphrase, recoveryKeyBytes });
|
|
127
|
+
return {
|
|
128
|
+
keyring,
|
|
129
|
+
recoveryKey: formatRecoveryKey(recoveryKeyBytes),
|
|
130
|
+
unlocked: createReadKeys(pairs.sealing, deriveAuthorshipKey(masterKey)),
|
|
131
|
+
};
|
|
132
|
+
} finally {
|
|
133
|
+
wipe(passphrase, masterKey, recoveryKeyBytes);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface IRecoveredMasterKey {
|
|
138
|
+
readonly masterKey: Buffer;
|
|
139
|
+
readonly slotId: string;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isWrongCredential(error: unknown): boolean {
|
|
143
|
+
return error instanceof KeyringError && error.code === 'wrong-credential';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function recoverWithPassphrase(
|
|
147
|
+
keyring: IKeyring,
|
|
148
|
+
passphraseText: unknown,
|
|
149
|
+
): Promise<IRecoveredMasterKey> {
|
|
150
|
+
const passphrase = normalizePassphrase(passphraseText);
|
|
151
|
+
try {
|
|
152
|
+
const slots = keyring.slots.filter(isPassphraseSlot);
|
|
153
|
+
if (slots.length === 0) {
|
|
154
|
+
throw new KeyringError('slot-not-found', 'The keyring has no passphrase slot.');
|
|
155
|
+
}
|
|
156
|
+
// Every slot's cost is checked before the first derivation, so a hostile keyring cannot make
|
|
157
|
+
// an earlier, in-range slot burn CPU on the way to a refusal.
|
|
158
|
+
for (const slot of slots) assertScryptParametersInBounds(slot.kdf);
|
|
159
|
+
for (const slot of slots) {
|
|
160
|
+
const salt = decodeKeyringField(slot.kdf.salt, SCRYPT_SALT_BYTES);
|
|
161
|
+
const kek = await deriveKeyFromPassphrase(passphrase, salt, slot.kdf);
|
|
162
|
+
try {
|
|
163
|
+
return { masterKey: unwrapMasterKey(kek, slot), slotId: slot.id };
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (!isWrongCredential(error)) throw error;
|
|
166
|
+
} finally {
|
|
167
|
+
wipe(kek);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
throw new KeyringError('wrong-credential', 'The credential did not open the keyring.');
|
|
171
|
+
} finally {
|
|
172
|
+
wipe(passphrase);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function recoverWithRecoveryKey(keyring: IKeyring, recoveryKeyText: unknown): IRecoveredMasterKey {
|
|
177
|
+
const recoveryKeyBytes = parseRecoveryKey(recoveryKeyText);
|
|
178
|
+
try {
|
|
179
|
+
const slots = keyring.slots.filter(isRecoverySlot);
|
|
180
|
+
if (slots.length === 0) {
|
|
181
|
+
throw new KeyringError('slot-not-found', 'The keyring has no recovery slot.');
|
|
182
|
+
}
|
|
183
|
+
for (const slot of slots) {
|
|
184
|
+
const kek = deriveRecoveryKek(recoveryKeyBytes);
|
|
185
|
+
try {
|
|
186
|
+
return { masterKey: unwrapMasterKey(kek, slot), slotId: slot.id };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!isWrongCredential(error)) throw error;
|
|
189
|
+
} finally {
|
|
190
|
+
wipe(kek);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
throw new KeyringError('wrong-credential', 'The credential did not open the keyring.');
|
|
194
|
+
} finally {
|
|
195
|
+
wipe(recoveryKeyBytes);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The master key `credential` opens, and the slot it opened. The caller owns and wipes it; nothing
|
|
201
|
+
* outside this package ever sees one.
|
|
202
|
+
*/
|
|
203
|
+
export async function recoverMasterKey(
|
|
204
|
+
keyring: IKeyring,
|
|
205
|
+
credential: TKeyringCredential,
|
|
206
|
+
): Promise<IRecoveredMasterKey> {
|
|
207
|
+
switch (credential?.type) {
|
|
208
|
+
case 'passphrase':
|
|
209
|
+
return recoverWithPassphrase(keyring, credential.passphrase);
|
|
210
|
+
case 'recoveryKey':
|
|
211
|
+
return recoverWithRecoveryKey(keyring, credential.recoveryKey);
|
|
212
|
+
default:
|
|
213
|
+
throw new KeyringError('invalid-argument', 'The credential type is not recognised.');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function unlock(keyring: IKeyring, credential: TKeyringCredential): Promise<IReadKeys> {
|
|
218
|
+
const document = validateKeyring(keyring);
|
|
219
|
+
const { masterKey } = await recoverMasterKey(document, credential);
|
|
220
|
+
const sealingSeed = deriveSealingSeed(masterKey);
|
|
221
|
+
try {
|
|
222
|
+
return createReadKeys(x25519KeyPairFromSeed(sealingSeed), deriveAuthorshipKey(masterKey));
|
|
223
|
+
} finally {
|
|
224
|
+
wipe(masterKey, sealingSeed);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Unlocks with the passphrase, for reading: the read keys and nothing that proves ownership (see
|
|
230
|
+
* `openOwnerHandover` for that). Costs one scrypt run per passphrase slot (~160 ms at the default
|
|
231
|
+
* cost, off the event loop). The stored sealing public key is NOT consulted — see
|
|
232
|
+
* {@link checkSealingPublicKey}.
|
|
233
|
+
*/
|
|
234
|
+
export function unlockWithPassphrase(keyring: IKeyring, passphrase: string): Promise<IReadKeys> {
|
|
235
|
+
return unlock(keyring, { type: 'passphrase', passphrase });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Unlocks with the recovery key shown at creation (case-insensitive, hyphens optional). */
|
|
239
|
+
export function unlockWithRecoveryKey(keyring: IKeyring, recoveryKey: string): Promise<IReadKeys> {
|
|
240
|
+
return unlock(keyring, { type: 'recoveryKey', recoveryKey });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Replaces the passphrase slot with one that wraps the same master key under `newPassphrase`, and
|
|
245
|
+
* returns the new keyring; the input is untouched. Everything encrypted under the keys stays
|
|
246
|
+
* readable because the master key does not change. The caller must prove the right to change it by
|
|
247
|
+
* supplying a working credential — the current passphrase, or the recovery key to reset a forgotten
|
|
248
|
+
* one. The new slot is written at the current default scrypt cost with a fresh salt and IV, and
|
|
249
|
+
* keeps the old slot's id. The stored sealing public key and the recovery slot are carried over
|
|
250
|
+
* as they are.
|
|
251
|
+
*/
|
|
252
|
+
export async function changePassphrase(
|
|
253
|
+
keyring: IKeyring,
|
|
254
|
+
credential: TKeyringCredential,
|
|
255
|
+
newPassphrase: string,
|
|
256
|
+
): Promise<IKeyring> {
|
|
257
|
+
const document = validateKeyring(keyring);
|
|
258
|
+
const replacement = normalizePassphrase(newPassphrase);
|
|
259
|
+
let recovered: IRecoveredMasterKey | undefined;
|
|
260
|
+
try {
|
|
261
|
+
recovered = await recoverMasterKey(document, credential);
|
|
262
|
+
const targetId =
|
|
263
|
+
credential.type === 'passphrase'
|
|
264
|
+
? recovered.slotId
|
|
265
|
+
: document.slots.find(isPassphraseSlot)?.id;
|
|
266
|
+
if (targetId === undefined) {
|
|
267
|
+
throw new KeyringError('slot-not-found', 'The keyring has no passphrase slot.');
|
|
268
|
+
}
|
|
269
|
+
const slot = await makePassphraseSlot(recovered.masterKey, replacement, targetId);
|
|
270
|
+
return validateKeyring({
|
|
271
|
+
...document,
|
|
272
|
+
slots: document.slots.map((existing) => (existing.id === targetId ? slot : existing)),
|
|
273
|
+
});
|
|
274
|
+
} finally {
|
|
275
|
+
wipe(replacement, recovered?.masterKey);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Whether the sealing public key stored in the keyring is the one the master key derives. A
|
|
281
|
+
* mismatch means the file was altered (an attacker who can write it could substitute their own key
|
|
282
|
+
* so new artifacts are sealed to them); the stored key cannot be authenticated without the master
|
|
283
|
+
* key, so this check is only possible after an unlock.
|
|
284
|
+
*/
|
|
285
|
+
export function checkSealingPublicKey(
|
|
286
|
+
keyring: IKeyring,
|
|
287
|
+
keys: Pick<IReadKeys, 'sealing'>,
|
|
288
|
+
): { matches: boolean } {
|
|
289
|
+
return { matches: sealingPublicKeyMatches(keyring, keys.sealing.publicKey) };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Whether the keyring's stored sealing public key is `derived`, compared in constant time. */
|
|
293
|
+
export function sealingPublicKeyMatches(keyring: IKeyring, derived: Uint8Array): boolean {
|
|
294
|
+
const stored = getSealingPublicKey(keyring);
|
|
295
|
+
return stored.length === derived.length && timingSafeEqual(stored, derived);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A copy of the keyring whose stored sealing public key is the one derived from the read keys. */
|
|
299
|
+
export function repairSealingPublicKey(
|
|
300
|
+
keyring: IKeyring,
|
|
301
|
+
keys: Pick<IReadKeys, 'sealing'>,
|
|
302
|
+
): IKeyring {
|
|
303
|
+
return validateKeyring({
|
|
304
|
+
...validateKeyring(keyring),
|
|
305
|
+
sealingPublicKey: toBase64Url(keys.sealing.publicKey),
|
|
306
|
+
});
|
|
307
|
+
}
|