@robota-sdk/agent-keyring 0.0.0-stage → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +661 -0
- package/README.md +91 -2
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +470 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +470 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +74 -3
- package/src/__tests__/authorship-constant-time.test.ts +46 -0
- package/src/__tests__/authorship.test.ts +65 -0
- package/src/__tests__/crypto-failures.test.ts +89 -0
- package/src/__tests__/fixtures/keyring-v1.json +25 -0
- package/src/__tests__/fixtures/sealed-v1.json +8 -0
- package/src/__tests__/helpers.ts +130 -0
- package/src/__tests__/kdf-bounds.test.ts +154 -0
- package/src/__tests__/keyring-document.test.ts +251 -0
- package/src/__tests__/keyring.test.ts +329 -0
- package/src/__tests__/keyslot.test.ts +98 -0
- package/src/__tests__/known-answers.test.ts +110 -0
- package/src/__tests__/link-helpers.ts +228 -0
- package/src/__tests__/owner-handover-derivation.test.ts +50 -0
- package/src/__tests__/owner-handover-dispose.test.ts +64 -0
- package/src/__tests__/owner-handover.test.ts +145 -0
- package/src/__tests__/owner-link-adversarial.test.ts +712 -0
- package/src/__tests__/owner-link-grant-wipe.test.ts +75 -0
- package/src/__tests__/owner-link-known-answers.test.ts +117 -0
- package/src/__tests__/owner-link-secret-wipe.test.ts +116 -0
- package/src/__tests__/owner-link.test.ts +229 -0
- package/src/__tests__/read-keys.test.ts +67 -0
- package/src/__tests__/recovery-key.test.ts +76 -0
- package/src/__tests__/runtime-identity.test.ts +171 -0
- package/src/__tests__/sealed-format-vectors.test.ts +91 -0
- package/src/__tests__/sealing.test.ts +376 -0
- package/src/__tests__/spawner-exchange.test.ts +127 -0
- package/src/__tests__/transient-key-pair.test.ts +36 -0
- package/src/aead.ts +62 -0
- package/src/authorship.ts +61 -0
- package/src/bytes.ts +67 -0
- package/src/errors.ts +106 -0
- package/src/fingerprint.ts +32 -0
- package/src/index.ts +77 -0
- package/src/kdf.ts +111 -0
- package/src/key-pairs.ts +148 -0
- package/src/keyring-document.ts +168 -0
- package/src/keyring-types.ts +51 -0
- package/src/keyring.ts +307 -0
- package/src/keyslot.ts +66 -0
- package/src/link-entropy.ts +24 -0
- package/src/link-frames.ts +271 -0
- package/src/link-primitives.ts +77 -0
- package/src/owner-handover.ts +155 -0
- package/src/owner-link.ts +508 -0
- package/src/read-keys.ts +78 -0
- package/src/recovery-key.ts +79 -0
- package/src/runtime-certificate.ts +129 -0
- package/src/runtime-identity.ts +166 -0
- package/src/sealing.ts +281 -0
- package/src/signatures.ts +45 -0
- package/src/spawner-exchange.ts +155 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a runtime is, as its owner vouches for it: an instance key bound to one runtime start.
|
|
3
|
+
*
|
|
4
|
+
* A runtime cannot prove it belongs to the owner by anything it can show on its own, because every
|
|
5
|
+
* same-user program can show the same things: a file, a socket path, a registration. So the owner
|
|
6
|
+
* signs, with the attach key only the passphrase reaches, the runtime's memory-only instance key
|
|
7
|
+
* together with the start it belongs to (the binding). A client that later checks this certificate
|
|
8
|
+
* under the attach key it derived itself knows the runtime is one its owner certified, for exactly
|
|
9
|
+
* this start; a certificate made by any other keyring, or for any other instance or start, fails.
|
|
10
|
+
*/
|
|
11
|
+
import { Buffer } from 'node:buffer';
|
|
12
|
+
|
|
13
|
+
import { concatLengthPrefixed, isBytes, utf8 } from './bytes.js';
|
|
14
|
+
import { KeyringError } from './errors.js';
|
|
15
|
+
import { KEY_BYTES } from './kdf.js';
|
|
16
|
+
import { SIGNATURE_BYTES, verifyEd25519 } from './signatures.js';
|
|
17
|
+
|
|
18
|
+
/** The runtime start a link and a certificate are bound to. Public values. */
|
|
19
|
+
export interface ILinkBinding {
|
|
20
|
+
readonly runtimeId: string;
|
|
21
|
+
readonly generation: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a runtime is told about its owner by its spawner: the owner's attach public key and the
|
|
26
|
+
* certificate of the runtime's own instance key. Public values, held only in runtime memory: a
|
|
27
|
+
* same-user-writable file is never a trust anchor.
|
|
28
|
+
*/
|
|
29
|
+
export interface ITrustRoot {
|
|
30
|
+
readonly attachPublicKey: Uint8Array;
|
|
31
|
+
/** Ed25519 under the attach key over the certificate message (see {@link runtimeCertificateMessage}). */
|
|
32
|
+
readonly certificate: Uint8Array;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Versioned like every derivation label: a certificate of one version never verifies as another. */
|
|
36
|
+
export const RUNTIME_CERTIFICATE_LABEL = 'robota/link/v1/runtime-cert';
|
|
37
|
+
|
|
38
|
+
/** The longest runtime id or generation a binding carries. */
|
|
39
|
+
export const MAX_BINDING_PART_LENGTH = 128;
|
|
40
|
+
|
|
41
|
+
// Printable ASCII without separators: what a registration's ids are made of, and nothing a
|
|
42
|
+
// terminal, a log line or a length prefix could read twice.
|
|
43
|
+
const BINDING_PART_PATTERN = /^[A-Za-z0-9._:-]+$/u;
|
|
44
|
+
const BINDING_KEYS = ['generation', 'runtimeId'];
|
|
45
|
+
|
|
46
|
+
function isBindingPart(value: unknown): value is string {
|
|
47
|
+
return (
|
|
48
|
+
typeof value === 'string' &&
|
|
49
|
+
value.length > 0 &&
|
|
50
|
+
value.length <= MAX_BINDING_PART_LENGTH &&
|
|
51
|
+
BINDING_PART_PATTERN.test(value)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A fresh binding built from `value`, or `invalid-argument`. Exactly the two fields, each 1 to
|
|
57
|
+
* {@link MAX_BINDING_PART_LENGTH} characters of `[A-Za-z0-9._:-]`, so every binding has one byte
|
|
58
|
+
* spelling and a decoder never meets a value it would have to reinterpret.
|
|
59
|
+
*/
|
|
60
|
+
export function checkLinkBinding(value: unknown): ILinkBinding {
|
|
61
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
62
|
+
throw new KeyringError('invalid-argument', 'A link binding is a runtime id and a generation.');
|
|
63
|
+
}
|
|
64
|
+
const record = value as Record<string, unknown>;
|
|
65
|
+
const keys = Object.keys(record).sort();
|
|
66
|
+
if (
|
|
67
|
+
keys.length !== BINDING_KEYS.length ||
|
|
68
|
+
keys.some((key, index) => key !== BINDING_KEYS[index]) ||
|
|
69
|
+
!isBindingPart(record['runtimeId']) ||
|
|
70
|
+
!isBindingPart(record['generation'])
|
|
71
|
+
) {
|
|
72
|
+
throw new KeyringError(
|
|
73
|
+
'invalid-argument',
|
|
74
|
+
`A link binding's runtime id and generation are 1-${MAX_BINDING_PART_LENGTH} characters of [A-Za-z0-9._:-].`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return { runtimeId: record['runtimeId'], generation: record['generation'] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The bytes of a binding part as it is decoded from the wire, or `undefined` when it is not one. */
|
|
81
|
+
export function decodeBindingPart(bytes: Uint8Array): string | undefined {
|
|
82
|
+
if (bytes.length === 0 || bytes.length > MAX_BINDING_PART_LENGTH) return undefined;
|
|
83
|
+
// ASCII only, so latin1 maps each byte to exactly the character the pattern then checks.
|
|
84
|
+
const text = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('latin1');
|
|
85
|
+
return isBindingPart(text) ? text : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function requireInstanceKey(value: unknown): Uint8Array {
|
|
89
|
+
if (!isBytes(value) || value.length !== KEY_BYTES) {
|
|
90
|
+
throw new KeyringError('invalid-argument', 'An instance public key is exactly 32 bytes.');
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `label | instance key | runtime id | generation`, each part length-prefixed. */
|
|
96
|
+
export function runtimeCertificateMessage(
|
|
97
|
+
instancePublicKey: Uint8Array,
|
|
98
|
+
binding: ILinkBinding,
|
|
99
|
+
): Buffer {
|
|
100
|
+
const instance = requireInstanceKey(instancePublicKey);
|
|
101
|
+
const checked = checkLinkBinding(binding);
|
|
102
|
+
return concatLengthPrefixed([
|
|
103
|
+
utf8(RUNTIME_CERTIFICATE_LABEL),
|
|
104
|
+
instance,
|
|
105
|
+
utf8(checked.runtimeId),
|
|
106
|
+
utf8(checked.generation),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Whether `root` certifies `instancePublicKey` for `binding` under the attach key `root` names. */
|
|
111
|
+
export function certifies(
|
|
112
|
+
root: ITrustRoot,
|
|
113
|
+
instancePublicKey: Uint8Array,
|
|
114
|
+
binding: ILinkBinding,
|
|
115
|
+
): boolean {
|
|
116
|
+
if (
|
|
117
|
+
!isBytes(root.attachPublicKey) ||
|
|
118
|
+
root.attachPublicKey.length !== KEY_BYTES ||
|
|
119
|
+
!isBytes(root.certificate) ||
|
|
120
|
+
root.certificate.length !== SIGNATURE_BYTES
|
|
121
|
+
) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return verifyEd25519(
|
|
125
|
+
root.attachPublicKey,
|
|
126
|
+
runtimeCertificateMessage(instancePublicKey, binding),
|
|
127
|
+
root.certificate,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A runtime's identity on the owner link: the crypto state a served runtime holds, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* - An Ed25519 instance key made at start, in memory only, so no file or other process can stand in
|
|
5
|
+
* for this runtime; its public half is what the owner certifies.
|
|
6
|
+
* - The spawner key from the one exchange over the inherited pair: spawner standing, until the trust
|
|
7
|
+
* root is installed.
|
|
8
|
+
* - The trust root, once its spawner installed it: the owner's attach public key and the certificate
|
|
9
|
+
* of this instance for this runtime start. From then on spawner standing is gone for good, and only
|
|
10
|
+
* a client that proves the attach key is admitted.
|
|
11
|
+
*
|
|
12
|
+
* Installing a root checks that it certifies this instance for the given binding, under the attach
|
|
13
|
+
* key it names; which link may install it, and when, is the runtime custody's decision.
|
|
14
|
+
*/
|
|
15
|
+
import type { Buffer } from 'node:buffer';
|
|
16
|
+
|
|
17
|
+
import { isBytes, wipe } from './bytes.js';
|
|
18
|
+
import { KeyringError, LinkRefusedError } from './errors.js';
|
|
19
|
+
import { ed25519KeyPairFromSeed, type IKeyPair } from './key-pairs.js';
|
|
20
|
+
import { KEY_BYTES } from './kdf.js';
|
|
21
|
+
import { SYSTEM_LINK_ENTROPY, type ILinkEntropy } from './link-entropy.js';
|
|
22
|
+
import {
|
|
23
|
+
certifies,
|
|
24
|
+
checkLinkBinding,
|
|
25
|
+
type ILinkBinding,
|
|
26
|
+
type ITrustRoot,
|
|
27
|
+
} from './runtime-certificate.js';
|
|
28
|
+
import { SIGNATURE_BYTES } from './signatures.js';
|
|
29
|
+
import { answerSpawnerExchange } from './spawner-exchange.js';
|
|
30
|
+
|
|
31
|
+
export interface IRuntimeIdentity {
|
|
32
|
+
/** The instance public key, the value the owner certifies. */
|
|
33
|
+
readonly instancePublicKey: Uint8Array;
|
|
34
|
+
/** Whether the spawner exchange ran and its key is still held. */
|
|
35
|
+
readonly spawnerKeyHeld: boolean;
|
|
36
|
+
/** Whether a trust root is installed. */
|
|
37
|
+
readonly certified: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Answers the spawner's first message with this runtime's ephemeral and instance public key, and
|
|
40
|
+
* keeps the spawner key. Once: a second call is refused, even after a refused first one.
|
|
41
|
+
*/
|
|
42
|
+
answerSpawnerExchange(message: Uint8Array): Uint8Array;
|
|
43
|
+
/**
|
|
44
|
+
* Keeps `root` when it certifies this instance for `binding`, and forgets the spawner key. Once,
|
|
45
|
+
* and only while the spawner key is held. A refused root changes nothing.
|
|
46
|
+
*/
|
|
47
|
+
installTrustRoot(root: ITrustRoot, binding: ILinkBinding): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The root a runtime was certified with, and the binding it certifies. */
|
|
51
|
+
export interface IRuntimeTrust {
|
|
52
|
+
readonly root: ITrustRoot;
|
|
53
|
+
readonly binding: ILinkBinding;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What only the link responder reaches. */
|
|
57
|
+
export interface IRuntimeIdentitySecrets {
|
|
58
|
+
readonly instance: IKeyPair;
|
|
59
|
+
/** Where the responder draws its ephemerals and nonces: the identity's own source. */
|
|
60
|
+
readonly entropy: ILinkEntropy;
|
|
61
|
+
spawnerKey(): Buffer | undefined;
|
|
62
|
+
trust(): IRuntimeTrust | undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const SECRETS = new WeakMap<IRuntimeIdentity, IRuntimeIdentitySecrets>();
|
|
66
|
+
|
|
67
|
+
function checkTrustRoot(value: unknown): ITrustRoot {
|
|
68
|
+
const root = value as Partial<ITrustRoot> | null;
|
|
69
|
+
if (
|
|
70
|
+
typeof root !== 'object' ||
|
|
71
|
+
root === null ||
|
|
72
|
+
!isBytes(root.attachPublicKey) ||
|
|
73
|
+
root.attachPublicKey.length !== KEY_BYTES ||
|
|
74
|
+
!isBytes(root.certificate) ||
|
|
75
|
+
root.certificate.length !== SIGNATURE_BYTES
|
|
76
|
+
) {
|
|
77
|
+
throw new KeyringError(
|
|
78
|
+
'invalid-argument',
|
|
79
|
+
'A trust root is a 32-byte attach public key and a 64-byte certificate.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
attachPublicKey: Uint8Array.from(root.attachPublicKey),
|
|
84
|
+
certificate: Uint8Array.from(root.certificate),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
class RuntimeIdentity implements IRuntimeIdentity {
|
|
89
|
+
readonly #instance: IKeyPair;
|
|
90
|
+
readonly #entropy: ILinkEntropy;
|
|
91
|
+
#exchanged = false;
|
|
92
|
+
#spawnerKey: Buffer | undefined;
|
|
93
|
+
#trust: IRuntimeTrust | undefined;
|
|
94
|
+
|
|
95
|
+
constructor(entropy: ILinkEntropy) {
|
|
96
|
+
this.#entropy = entropy;
|
|
97
|
+
const seed = entropy.seed();
|
|
98
|
+
try {
|
|
99
|
+
this.#instance = ed25519KeyPairFromSeed(seed);
|
|
100
|
+
} finally {
|
|
101
|
+
wipe(seed);
|
|
102
|
+
}
|
|
103
|
+
SECRETS.set(this, {
|
|
104
|
+
instance: this.#instance,
|
|
105
|
+
entropy,
|
|
106
|
+
spawnerKey: () => this.#spawnerKey,
|
|
107
|
+
trust: () => this.#trust,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get instancePublicKey(): Uint8Array {
|
|
112
|
+
return Uint8Array.from(this.#instance.publicKey);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get spawnerKeyHeld(): boolean {
|
|
116
|
+
return this.#spawnerKey !== undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
get certified(): boolean {
|
|
120
|
+
return this.#trust !== undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
answerSpawnerExchange(message: Uint8Array): Uint8Array {
|
|
124
|
+
if (this.#exchanged) throw new LinkRefusedError('out-of-order');
|
|
125
|
+
this.#exchanged = true;
|
|
126
|
+
const { reply, spawnerKey } = answerSpawnerExchange(
|
|
127
|
+
message,
|
|
128
|
+
this.#instance.publicKey,
|
|
129
|
+
this.#entropy,
|
|
130
|
+
);
|
|
131
|
+
this.#spawnerKey = spawnerKey;
|
|
132
|
+
return reply;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
installTrustRoot(root: ITrustRoot, binding: ILinkBinding): void {
|
|
136
|
+
const checkedRoot = checkTrustRoot(root);
|
|
137
|
+
const checkedBinding = checkLinkBinding(binding);
|
|
138
|
+
if (this.#trust !== undefined) throw new LinkRefusedError('already-certified');
|
|
139
|
+
if (this.#spawnerKey === undefined) throw new LinkRefusedError('spawner-unavailable');
|
|
140
|
+
if (!certifies(checkedRoot, this.#instance.publicKey, checkedBinding)) {
|
|
141
|
+
throw new LinkRefusedError('certificate-mismatch');
|
|
142
|
+
}
|
|
143
|
+
this.#trust = { root: checkedRoot, binding: checkedBinding };
|
|
144
|
+
wipe(this.#spawnerKey);
|
|
145
|
+
this.#spawnerKey = undefined;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** {@link createRuntimeIdentity} drawing from `entropy`; the package's own tests pass fixed values. */
|
|
150
|
+
export function createRuntimeIdentityWith(entropy: ILinkEntropy): IRuntimeIdentity {
|
|
151
|
+
return new RuntimeIdentity(entropy);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** A new runtime identity with a fresh instance key, uncertified and before its spawner exchange. */
|
|
155
|
+
export function createRuntimeIdentity(): IRuntimeIdentity {
|
|
156
|
+
return createRuntimeIdentityWith(SYSTEM_LINK_ENTROPY);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Package-private: what the link responder needs of an identity this package made. */
|
|
160
|
+
export function runtimeIdentitySecrets(identity: IRuntimeIdentity): IRuntimeIdentitySecrets {
|
|
161
|
+
const secrets = SECRETS.get(identity);
|
|
162
|
+
if (secrets === undefined) {
|
|
163
|
+
throw new KeyringError('invalid-argument', 'This is not a runtime identity this package made.');
|
|
164
|
+
}
|
|
165
|
+
return secrets;
|
|
166
|
+
}
|
package/src/sealing.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { diffieHellman, generateKeyPairSync, randomBytes, type KeyObject } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { gcmDecrypt, gcmEncrypt, GCM_IV_BYTES, GCM_TAG_BYTES } from './aead.js';
|
|
5
|
+
import { concatLengthPrefixed, isBytes, wipe } from './bytes.js';
|
|
6
|
+
import { KeyringError } from './errors.js';
|
|
7
|
+
import { rawPublicKey, x25519PublicKeyFromRaw } from './key-pairs.js';
|
|
8
|
+
import { hkdf32, KEY_BYTES } from './kdf.js';
|
|
9
|
+
|
|
10
|
+
/** The first byte of every sealed key, record and blob. Authenticated, so it cannot be downgraded. */
|
|
11
|
+
export const SEALED_FORMAT_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
/** `info` of the HKDF that turns the ECDH secret into the key that wraps an artifact key. */
|
|
14
|
+
export const ARTIFACT_KEY_WRAP_LABEL = 'robota/keyring/v1/dek-wrap';
|
|
15
|
+
|
|
16
|
+
const VERSION_BYTES = 1;
|
|
17
|
+
/** version | ephemeral X25519 public key | IV | wrapped key + tag */
|
|
18
|
+
export const SEALED_KEY_BYTES =
|
|
19
|
+
VERSION_BYTES + KEY_BYTES + GCM_IV_BYTES + KEY_BYTES + GCM_TAG_BYTES;
|
|
20
|
+
/** version | nonce | tag, around the ciphertext */
|
|
21
|
+
export const RECORD_OVERHEAD_BYTES = VERSION_BYTES + GCM_IV_BYTES + GCM_TAG_BYTES;
|
|
22
|
+
|
|
23
|
+
function requireBytes(value: unknown, what: string, length?: number): Uint8Array {
|
|
24
|
+
if (!isBytes(value) || (length !== undefined && value.length !== length)) {
|
|
25
|
+
throw new KeyringError(
|
|
26
|
+
'invalid-argument',
|
|
27
|
+
length === undefined
|
|
28
|
+
? `The ${what} must be a Uint8Array.`
|
|
29
|
+
: `The ${what} must be a Uint8Array of exactly ${length} bytes.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requireSealingPrivateKey(value: unknown): KeyObject {
|
|
36
|
+
const key = value as KeyObject | undefined;
|
|
37
|
+
if (
|
|
38
|
+
typeof key !== 'object' ||
|
|
39
|
+
key === null ||
|
|
40
|
+
key.type !== 'private' ||
|
|
41
|
+
key.asymmetricKeyType !== 'x25519'
|
|
42
|
+
) {
|
|
43
|
+
throw new KeyringError(
|
|
44
|
+
'invalid-argument',
|
|
45
|
+
'The sealing private key must be an X25519 private KeyObject.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return key;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The AAD the AEAD actually authenticates: the format version plus the caller's context. */
|
|
52
|
+
function boundAad(callerAad: unknown): Buffer {
|
|
53
|
+
return concatLengthPrefixed([
|
|
54
|
+
Buffer.from([SEALED_FORMAT_VERSION]),
|
|
55
|
+
requireBytes(callerAad, 'aad'),
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Both ends of the ECDH mix both public keys into the HKDF salt, so the wrapping key is bound to
|
|
61
|
+
* this exact sender/recipient pair and not only to the shared secret.
|
|
62
|
+
*/
|
|
63
|
+
function deriveWrapKey(
|
|
64
|
+
sharedSecret: Buffer,
|
|
65
|
+
ephemeralPublicKey: Uint8Array,
|
|
66
|
+
recipientPublicKey: Uint8Array,
|
|
67
|
+
): Buffer {
|
|
68
|
+
const salt = Buffer.concat([ephemeralPublicKey, recipientPublicKey]);
|
|
69
|
+
try {
|
|
70
|
+
return hkdf32(sharedSecret, ARTIFACT_KEY_WRAP_LABEL, salt);
|
|
71
|
+
} finally {
|
|
72
|
+
wipe(sharedSecret);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A fresh ephemeral X25519 keypair; a host that cannot make one fails as `kdf-failed`. */
|
|
77
|
+
function generateEphemeralKeyPair(): {
|
|
78
|
+
readonly privateKey: KeyObject;
|
|
79
|
+
readonly publicKey: Buffer;
|
|
80
|
+
} {
|
|
81
|
+
try {
|
|
82
|
+
const ephemeral = generateKeyPairSync('x25519');
|
|
83
|
+
return { privateKey: ephemeral.privateKey, publicKey: rawPublicKey(ephemeral.publicKey) };
|
|
84
|
+
} catch {
|
|
85
|
+
throw new KeyringError('kdf-failed', 'The key derivation could not run on this host.');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A fresh random 256-bit data-encryption key (DEK) for one artifact. The caller wipes it after use. */
|
|
90
|
+
export function generateArtifactKey(): Buffer {
|
|
91
|
+
return randomBytes(KEY_BYTES);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Seals `artifactKey` to the sealing PUBLIC key (ECIES: fresh ephemeral X25519 key, ECDH, HKDF-SHA256,
|
|
96
|
+
* AES-256-GCM). Only the public key is needed, so a process that never unlocked the keyring can
|
|
97
|
+
* still produce artifacts that only the owner can open. Every call uses a new ephemeral key and IV,
|
|
98
|
+
* so sealing the same key twice yields different bytes.
|
|
99
|
+
*
|
|
100
|
+
* `aad` is authenticated, not stored: the caller must present the same bytes to open it. Put the
|
|
101
|
+
* artifact id in it, so a sealed key cannot be moved to another artifact.
|
|
102
|
+
*/
|
|
103
|
+
export function sealArtifactKey(
|
|
104
|
+
sealingPublicKey: Uint8Array,
|
|
105
|
+
artifactKey: Uint8Array,
|
|
106
|
+
aad: Uint8Array,
|
|
107
|
+
): Buffer {
|
|
108
|
+
const recipientRaw = requireBytes(sealingPublicKey, 'sealing public key', KEY_BYTES);
|
|
109
|
+
requireBytes(artifactKey, 'artifact key', KEY_BYTES);
|
|
110
|
+
const bound = boundAad(aad);
|
|
111
|
+
const recipient = x25519PublicKeyFromRaw(recipientRaw);
|
|
112
|
+
const ephemeral = generateEphemeralKeyPair();
|
|
113
|
+
const ephemeralPublic = ephemeral.publicKey;
|
|
114
|
+
let sharedSecret: Buffer;
|
|
115
|
+
try {
|
|
116
|
+
sharedSecret = diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipient });
|
|
117
|
+
} catch {
|
|
118
|
+
// A low-order point (for example an all-zero key written into the file) has no usable secret.
|
|
119
|
+
throw new KeyringError(
|
|
120
|
+
'invalid-argument',
|
|
121
|
+
'The sealing public key cannot be used for key agreement.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const wrapKey = deriveWrapKey(sharedSecret, ephemeralPublic, recipientRaw);
|
|
125
|
+
try {
|
|
126
|
+
const iv = randomBytes(GCM_IV_BYTES);
|
|
127
|
+
const wrapped = gcmEncrypt(wrapKey, iv, artifactKey, bound);
|
|
128
|
+
return Buffer.concat([Buffer.from([SEALED_FORMAT_VERSION]), ephemeralPublic, iv, wrapped]);
|
|
129
|
+
} finally {
|
|
130
|
+
wipe(wrapKey);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function requireSealedVersion(bytes: Uint8Array): void {
|
|
135
|
+
if (bytes.length === 0) {
|
|
136
|
+
throw new KeyringError('malformed-ciphertext', 'The sealed data is empty.');
|
|
137
|
+
}
|
|
138
|
+
if (bytes[0] !== SEALED_FORMAT_VERSION) {
|
|
139
|
+
throw new KeyringError(
|
|
140
|
+
'unsupported-version',
|
|
141
|
+
'The sealed data uses a format this build does not support.',
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Opens a key produced by {@link sealArtifactKey}. Needs the unlocked sealing private key. The
|
|
148
|
+
* caller owns and wipes the returned key.
|
|
149
|
+
*/
|
|
150
|
+
export function openArtifactKey(
|
|
151
|
+
sealingPrivateKey: KeyObject,
|
|
152
|
+
sealed: Uint8Array,
|
|
153
|
+
aad: Uint8Array,
|
|
154
|
+
): Buffer {
|
|
155
|
+
const privateKey = requireSealingPrivateKey(sealingPrivateKey);
|
|
156
|
+
requireBytes(sealed, 'sealed key');
|
|
157
|
+
const bound = boundAad(aad);
|
|
158
|
+
requireSealedVersion(sealed);
|
|
159
|
+
if (sealed.length !== SEALED_KEY_BYTES) {
|
|
160
|
+
throw new KeyringError('malformed-ciphertext', 'The sealed key has the wrong length.');
|
|
161
|
+
}
|
|
162
|
+
const ephemeralPublic = sealed.subarray(VERSION_BYTES, VERSION_BYTES + KEY_BYTES);
|
|
163
|
+
const ivStart = VERSION_BYTES + KEY_BYTES;
|
|
164
|
+
const iv = sealed.subarray(ivStart, ivStart + GCM_IV_BYTES);
|
|
165
|
+
const wrapped = sealed.subarray(ivStart + GCM_IV_BYTES);
|
|
166
|
+
let sharedSecret: Buffer;
|
|
167
|
+
try {
|
|
168
|
+
sharedSecret = diffieHellman({
|
|
169
|
+
privateKey,
|
|
170
|
+
publicKey: x25519PublicKeyFromRaw(ephemeralPublic),
|
|
171
|
+
});
|
|
172
|
+
} catch {
|
|
173
|
+
throw new KeyringError('decryption-failed', 'Authentication of the ciphertext failed.');
|
|
174
|
+
}
|
|
175
|
+
const wrapKey = deriveWrapKey(sharedSecret, ephemeralPublic, rawPublicKey(privateKey));
|
|
176
|
+
try {
|
|
177
|
+
return gcmDecrypt(wrapKey, iv, wrapped, bound);
|
|
178
|
+
} finally {
|
|
179
|
+
wipe(wrapKey);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One content record: `version | 12-byte random nonce | ciphertext | 16-byte tag`, AES-256-GCM
|
|
185
|
+
* under the artifact key. Use it as often as needed with one key — a JSONL log appends one record
|
|
186
|
+
* per batch of lines, a whole-file blob is one record — but keep a key's record count far below
|
|
187
|
+
* 2^32, the safe limit for random 96-bit nonces.
|
|
188
|
+
*
|
|
189
|
+
* `aad` is authenticated, not stored. Put the artifact id and the record's sequence number in it:
|
|
190
|
+
* that is what stops records from being swapped between files or reordered. Detecting that
|
|
191
|
+
* TRAILING records were cut off is not something one record can know — a log truncated at a record
|
|
192
|
+
* boundary is a valid log — so the caller must keep the expected record count (or a final marker
|
|
193
|
+
* record) somewhere it trusts.
|
|
194
|
+
*/
|
|
195
|
+
export function encryptRecord(
|
|
196
|
+
artifactKey: Uint8Array,
|
|
197
|
+
plaintext: Uint8Array,
|
|
198
|
+
aad: Uint8Array,
|
|
199
|
+
): Buffer {
|
|
200
|
+
requireBytes(artifactKey, 'artifact key', KEY_BYTES);
|
|
201
|
+
requireBytes(plaintext, 'plaintext');
|
|
202
|
+
const bound = boundAad(aad);
|
|
203
|
+
const nonce = randomBytes(GCM_IV_BYTES);
|
|
204
|
+
const sealed = gcmEncrypt(artifactKey, nonce, plaintext, bound);
|
|
205
|
+
return Buffer.concat([Buffer.from([SEALED_FORMAT_VERSION]), nonce, sealed]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Opens a record from {@link encryptRecord}; returns plaintext only if the whole record authenticates. */
|
|
209
|
+
export function decryptRecord(
|
|
210
|
+
artifactKey: Uint8Array,
|
|
211
|
+
record: Uint8Array,
|
|
212
|
+
aad: Uint8Array,
|
|
213
|
+
): Buffer {
|
|
214
|
+
requireBytes(artifactKey, 'artifact key', KEY_BYTES);
|
|
215
|
+
requireBytes(record, 'record');
|
|
216
|
+
const bound = boundAad(aad);
|
|
217
|
+
requireSealedVersion(record);
|
|
218
|
+
if (record.length < RECORD_OVERHEAD_BYTES) {
|
|
219
|
+
throw new KeyringError(
|
|
220
|
+
'malformed-ciphertext',
|
|
221
|
+
'The record is shorter than its fixed overhead.',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
const nonceEnd = VERSION_BYTES + GCM_IV_BYTES;
|
|
225
|
+
return gcmDecrypt(
|
|
226
|
+
artifactKey,
|
|
227
|
+
record.subarray(VERSION_BYTES, nonceEnd),
|
|
228
|
+
record.subarray(nonceEnd),
|
|
229
|
+
bound,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Seals a whole value in one step: a sealed key header followed by one record under that key.
|
|
235
|
+
* Like {@link sealArtifactKey} it needs only the sealing public key.
|
|
236
|
+
*/
|
|
237
|
+
export function sealBlob(
|
|
238
|
+
sealingPublicKey: Uint8Array,
|
|
239
|
+
plaintext: Uint8Array,
|
|
240
|
+
aad: Uint8Array,
|
|
241
|
+
): Buffer {
|
|
242
|
+
const artifactKey = generateArtifactKey();
|
|
243
|
+
try {
|
|
244
|
+
return Buffer.concat([
|
|
245
|
+
sealArtifactKey(sealingPublicKey, artifactKey, aad),
|
|
246
|
+
encryptRecord(artifactKey, plaintext, aad),
|
|
247
|
+
]);
|
|
248
|
+
} finally {
|
|
249
|
+
wipe(artifactKey);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The two halves of a blob from {@link sealBlob}: the sealed key and the one record under it, as
|
|
255
|
+
* views into `blob`. A holder that keeps opened keys by sealed key splits with this, so the blob
|
|
256
|
+
* layout stays in this package. Checks the layout only; nothing is authenticated until each half is
|
|
257
|
+
* opened.
|
|
258
|
+
*/
|
|
259
|
+
export function splitSealedBlob(blob: Uint8Array): {
|
|
260
|
+
readonly sealedKey: Uint8Array;
|
|
261
|
+
readonly record: Uint8Array;
|
|
262
|
+
} {
|
|
263
|
+
requireBytes(blob, 'blob');
|
|
264
|
+
requireSealedVersion(blob);
|
|
265
|
+
if (blob.length < SEALED_KEY_BYTES + RECORD_OVERHEAD_BYTES) {
|
|
266
|
+
throw new KeyringError('malformed-ciphertext', 'The blob is shorter than its fixed overhead.');
|
|
267
|
+
}
|
|
268
|
+
return { sealedKey: blob.subarray(0, SEALED_KEY_BYTES), record: blob.subarray(SEALED_KEY_BYTES) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Opens a blob from {@link sealBlob}; any truncation, extension or edit fails as a whole. */
|
|
272
|
+
export function openBlob(sealingPrivateKey: KeyObject, blob: Uint8Array, aad: Uint8Array): Buffer {
|
|
273
|
+
requireSealingPrivateKey(sealingPrivateKey);
|
|
274
|
+
const { sealedKey, record } = splitSealedBlob(blob);
|
|
275
|
+
const artifactKey = openArtifactKey(sealingPrivateKey, sealedKey, aad);
|
|
276
|
+
try {
|
|
277
|
+
return decryptRecord(artifactKey, record, aad);
|
|
278
|
+
} finally {
|
|
279
|
+
wipe(artifactKey);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 signing and checking, in the one form every link check uses: a check answers yes or no and
|
|
3
|
+
* never throws, so a malformed signature or key from the other end is just a refusal, and a failed
|
|
4
|
+
* signature surfaces as a fixed error that says nothing about the key.
|
|
5
|
+
*/
|
|
6
|
+
import type { Buffer } from 'node:buffer';
|
|
7
|
+
import { sign, verify, type KeyObject } from 'node:crypto';
|
|
8
|
+
|
|
9
|
+
import { isBytes } from './bytes.js';
|
|
10
|
+
import { KeyringError } from './errors.js';
|
|
11
|
+
import { ed25519PublicKeyFromRaw } from './key-pairs.js';
|
|
12
|
+
|
|
13
|
+
/** An Ed25519 signature. */
|
|
14
|
+
export const SIGNATURE_BYTES = 64;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ed25519 over `message` (pure Ed25519: the message is signed as it is, with no pre-hash). A host
|
|
18
|
+
* that cannot sign fails as `kdf-failed`, whatever it threw, so nothing about the key surfaces.
|
|
19
|
+
*/
|
|
20
|
+
export function signEd25519(privateKey: KeyObject, message: Uint8Array): Buffer {
|
|
21
|
+
try {
|
|
22
|
+
return sign(null, message, privateKey);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new KeyringError('kdf-failed', 'The key operation could not run on this host.');
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Whether `signature` is the Ed25519 signature of `message` under the raw 32-byte `publicKey`. A
|
|
30
|
+
* signature of another length, a public key that is not one, or a host that refuses the check are
|
|
31
|
+
* all simply "not a valid signature": a verifier decides on this answer alone.
|
|
32
|
+
*/
|
|
33
|
+
export function verifyEd25519(
|
|
34
|
+
publicKey: Uint8Array,
|
|
35
|
+
message: Uint8Array,
|
|
36
|
+
signature: Uint8Array,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (!isBytes(signature) || signature.length !== SIGNATURE_BYTES || !isBytes(message))
|
|
39
|
+
return false;
|
|
40
|
+
try {
|
|
41
|
+
return verify(null, message, ed25519PublicKeyFromRaw(publicKey), signature);
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|