@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,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A runtime's identity: a memory-only instance key, the spawner key from its one exchange, and,
|
|
3
|
+
* once its spawner certified it, the trust root. Installing the trust root checks the certificate
|
|
4
|
+
* against this instance and the given binding, and forgets the spawner key for good.
|
|
5
|
+
*/
|
|
6
|
+
import { Buffer } from 'node:buffer';
|
|
7
|
+
import { createPublicKey } from 'node:crypto';
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createKeyring,
|
|
15
|
+
createRuntimeIdentity,
|
|
16
|
+
openOwnerHandover,
|
|
17
|
+
parseKeyring,
|
|
18
|
+
startSpawnerExchange,
|
|
19
|
+
type IOwnerHandover,
|
|
20
|
+
type IRuntimeIdentity,
|
|
21
|
+
} from '../index.js';
|
|
22
|
+
import { runtimeIdentitySecrets } from '../runtime-identity.js';
|
|
23
|
+
import { FIXTURE_RECOVERY_KEY, KAT_BINDING, codeOf, hex, reachableValues } from './helpers.js';
|
|
24
|
+
import { refusalOf } from './link-helpers.js';
|
|
25
|
+
|
|
26
|
+
const fixture = parseKeyring(
|
|
27
|
+
readFileSync(fileURLToPath(new URL('./fixtures/keyring-v1.json', import.meta.url)), 'utf8'),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
let owner: IOwnerHandover;
|
|
31
|
+
let stranger: IOwnerHandover;
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
owner = await openOwnerHandover(fixture, {
|
|
35
|
+
type: 'recoveryKey',
|
|
36
|
+
recoveryKey: FIXTURE_RECOVERY_KEY,
|
|
37
|
+
});
|
|
38
|
+
const other = await createKeyring({ passphrase: 'another keyring entirely' });
|
|
39
|
+
stranger = await openOwnerHandover(other.keyring, {
|
|
40
|
+
type: 'recoveryKey',
|
|
41
|
+
recoveryKey: other.recoveryKey,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function exchanged(): IRuntimeIdentity {
|
|
46
|
+
const identity = createRuntimeIdentity();
|
|
47
|
+
startSpawnerExchange().complete(identity.answerSpawnerExchange(startSpawnerExchange().message));
|
|
48
|
+
return identity;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('createRuntimeIdentity', () => {
|
|
52
|
+
it('makes a fresh Ed25519 instance key for every runtime, held only in memory', () => {
|
|
53
|
+
const first = createRuntimeIdentity();
|
|
54
|
+
const second = createRuntimeIdentity();
|
|
55
|
+
expect(first.instancePublicKey).toHaveLength(32);
|
|
56
|
+
expect(hex(first.instancePublicKey)).not.toBe(hex(second.instancePublicKey));
|
|
57
|
+
const { instance } = runtimeIdentitySecrets(first);
|
|
58
|
+
expect(instance.privateKey.asymmetricKeyType).toBe('ed25519');
|
|
59
|
+
expect(
|
|
60
|
+
hex(
|
|
61
|
+
createPublicKey(instance.privateKey).export({ format: 'der', type: 'spki' }).subarray(12),
|
|
62
|
+
),
|
|
63
|
+
).toBe(hex(first.instancePublicKey));
|
|
64
|
+
expect(first.certified).toBe(false);
|
|
65
|
+
expect(first.spawnerKeyHeld).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('hands out no key: only the instance public key is reachable', () => {
|
|
69
|
+
const identity = exchanged();
|
|
70
|
+
const bytes = reachableValues(identity).filter((value) => ArrayBuffer.isView(value));
|
|
71
|
+
expect(bytes.map((value) => hex(value as Uint8Array))).toEqual([
|
|
72
|
+
hex(identity.instancePublicKey),
|
|
73
|
+
]);
|
|
74
|
+
expect(Object.keys(JSON.parse(JSON.stringify(identity)) as object)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('installTrustRoot', () => {
|
|
79
|
+
it('keeps a root that certifies this instance for the binding, and forgets the spawner key', () => {
|
|
80
|
+
const identity = exchanged();
|
|
81
|
+
const spawnerKey = runtimeIdentitySecrets(identity).spawnerKey();
|
|
82
|
+
expect(spawnerKey).toBeDefined();
|
|
83
|
+
identity.installTrustRoot(owner.certify(identity.instancePublicKey, KAT_BINDING), KAT_BINDING);
|
|
84
|
+
expect(identity.certified).toBe(true);
|
|
85
|
+
expect(identity.spawnerKeyHeld).toBe(false);
|
|
86
|
+
expect(runtimeIdentitySecrets(identity).spawnerKey()).toBeUndefined();
|
|
87
|
+
expect(spawnerKey!.every((byte) => byte === 0)).toBe(true);
|
|
88
|
+
expect(runtimeIdentitySecrets(identity).trust()?.binding).toEqual(KAT_BINDING);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('refuses a certificate for another instance, another binding or under another attach key', () => {
|
|
92
|
+
const identity = exchanged();
|
|
93
|
+
const other = createRuntimeIdentity();
|
|
94
|
+
const forOther = owner.certify(other.instancePublicKey, KAT_BINDING);
|
|
95
|
+
const forThis = owner.certify(identity.instancePublicKey, KAT_BINDING);
|
|
96
|
+
const flipped = Buffer.from(forThis.certificate);
|
|
97
|
+
flipped[0] ^= 1;
|
|
98
|
+
const cases = [
|
|
99
|
+
[forOther, KAT_BINDING],
|
|
100
|
+
[forThis, { ...KAT_BINDING, generation: 'another-start' }],
|
|
101
|
+
[forThis, { ...KAT_BINDING, runtimeId: 'another-runtime' }],
|
|
102
|
+
[
|
|
103
|
+
{
|
|
104
|
+
...forThis,
|
|
105
|
+
attachPublicKey: stranger.certify(identity.instancePublicKey, KAT_BINDING)
|
|
106
|
+
.attachPublicKey,
|
|
107
|
+
},
|
|
108
|
+
KAT_BINDING,
|
|
109
|
+
],
|
|
110
|
+
[{ ...forThis, certificate: flipped }, KAT_BINDING],
|
|
111
|
+
] as const;
|
|
112
|
+
for (const [root, binding] of cases) {
|
|
113
|
+
expect(refusalOf(() => identity.installTrustRoot(root, binding))).toBe(
|
|
114
|
+
'certificate-mismatch',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
// A refused root changes nothing: the spawner can still certify the runtime.
|
|
118
|
+
expect(identity.certified).toBe(false);
|
|
119
|
+
expect(identity.spawnerKeyHeld).toBe(true);
|
|
120
|
+
identity.installTrustRoot(forThis, KAT_BINDING);
|
|
121
|
+
expect(identity.certified).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('accepts a root another keyring made for this instance: who may install is the spawner link’s', () => {
|
|
125
|
+
const identity = exchanged();
|
|
126
|
+
identity.installTrustRoot(
|
|
127
|
+
stranger.certify(identity.instancePublicKey, KAT_BINDING),
|
|
128
|
+
KAT_BINDING,
|
|
129
|
+
);
|
|
130
|
+
expect(identity.certified).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('refuses a malformed root or binding as a caller bug', () => {
|
|
134
|
+
const identity = exchanged();
|
|
135
|
+
const root = owner.certify(identity.instancePublicKey, KAT_BINDING);
|
|
136
|
+
expect(
|
|
137
|
+
codeOf(() =>
|
|
138
|
+
identity.installTrustRoot(
|
|
139
|
+
{ ...root, certificate: root.certificate.subarray(1) },
|
|
140
|
+
KAT_BINDING,
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
).toBe('invalid-argument');
|
|
144
|
+
expect(
|
|
145
|
+
codeOf(() =>
|
|
146
|
+
identity.installTrustRoot({ ...root, attachPublicKey: new Uint8Array(31) }, KAT_BINDING),
|
|
147
|
+
),
|
|
148
|
+
).toBe('invalid-argument');
|
|
149
|
+
expect(
|
|
150
|
+
codeOf(() => identity.installTrustRoot(root, { runtimeId: 'a b', generation: 'g' })),
|
|
151
|
+
).toBe('invalid-argument');
|
|
152
|
+
expect(identity.spawnerKeyHeld).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('installs once, and only while the spawner key is held', () => {
|
|
156
|
+
const identity = exchanged();
|
|
157
|
+
const root = owner.certify(identity.instancePublicKey, KAT_BINDING);
|
|
158
|
+
identity.installTrustRoot(root, KAT_BINDING);
|
|
159
|
+
expect(refusalOf(() => identity.installTrustRoot(root, KAT_BINDING))).toBe('already-certified');
|
|
160
|
+
const neverExchanged = createRuntimeIdentity();
|
|
161
|
+
expect(
|
|
162
|
+
refusalOf(() =>
|
|
163
|
+
neverExchanged.installTrustRoot(
|
|
164
|
+
owner.certify(neverExchanged.instancePublicKey, KAT_BINDING),
|
|
165
|
+
KAT_BINDING,
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
).toBe('spawner-unavailable');
|
|
169
|
+
expect(neverExchanged.certified).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Known-answer vectors for the three sealed wire formats: a sealed artifact key, a content record
|
|
3
|
+
* and a blob, each sealed to the sealing public key of FIXED_MASTER_KEY under a fixed AAD.
|
|
4
|
+
*
|
|
5
|
+
* Sealing draws a fresh ephemeral key, IV and nonce every time, so the vectors cannot be recomputed;
|
|
6
|
+
* they were produced once with `sealArtifactKey`, `encryptRecord` and `sealBlob` from this package
|
|
7
|
+
* (run under tsx against `src/`) and checked in. What they pin is that bytes written by that build
|
|
8
|
+
* still open, to the same plaintext, in this one: a change to the framing, the labels, the AAD
|
|
9
|
+
* binding or the key derivation fails here even though every round trip still passes.
|
|
10
|
+
*/
|
|
11
|
+
import { Buffer } from 'node:buffer';
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
|
|
15
|
+
import { describe, expect, it } from 'vitest';
|
|
16
|
+
|
|
17
|
+
import { decryptRecord, openArtifactKey, openBlob, splitSealedBlob } from '../index.js';
|
|
18
|
+
import { deriveKeyPairs } from '../key-pairs.js';
|
|
19
|
+
import { codeOf, FIXED_MASTER_KEY, hex, tamper } from './helpers.js';
|
|
20
|
+
|
|
21
|
+
interface ISealedVectors {
|
|
22
|
+
readonly aad: string;
|
|
23
|
+
readonly plaintext: string;
|
|
24
|
+
readonly artifactKey: string;
|
|
25
|
+
readonly sealedKey: string;
|
|
26
|
+
readonly record: string;
|
|
27
|
+
readonly blob: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const vectors = JSON.parse(
|
|
31
|
+
readFileSync(fileURLToPath(new URL('./fixtures/sealed-v1.json', import.meta.url)), 'utf8'),
|
|
32
|
+
) as ISealedVectors;
|
|
33
|
+
const bytes = (field: string): Buffer => Buffer.from(field, 'hex');
|
|
34
|
+
const owner = deriveKeyPairs(FIXED_MASTER_KEY);
|
|
35
|
+
const aad = bytes(vectors.aad);
|
|
36
|
+
|
|
37
|
+
describe('sealed format version 1 — known answers', () => {
|
|
38
|
+
it('opens the checked-in sealed artifact key to the known key', () => {
|
|
39
|
+
expect(hex(openArtifactKey(owner.sealing.privateKey, bytes(vectors.sealedKey), aad))).toBe(
|
|
40
|
+
vectors.artifactKey,
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('opens the checked-in record to the known plaintext', () => {
|
|
45
|
+
expect(hex(decryptRecord(bytes(vectors.artifactKey), bytes(vectors.record), aad))).toBe(
|
|
46
|
+
vectors.plaintext,
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('opens the checked-in blob to the known plaintext', () => {
|
|
51
|
+
expect(hex(openBlob(owner.sealing.privateKey, bytes(vectors.blob), aad))).toBe(
|
|
52
|
+
vectors.plaintext,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('splits the checked-in blob into a sealed key and a record that open separately', () => {
|
|
57
|
+
const { sealedKey, record } = splitSealedBlob(bytes(vectors.blob));
|
|
58
|
+
const artifactKey = openArtifactKey(owner.sealing.privateKey, sealedKey, aad);
|
|
59
|
+
expect(hex(decryptRecord(artifactKey, record, aad))).toBe(vectors.plaintext);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('refuses each of them with one byte changed, and under a different AAD', () => {
|
|
63
|
+
const sealedKey = bytes(vectors.sealedKey);
|
|
64
|
+
const record = bytes(vectors.record);
|
|
65
|
+
const blob = bytes(vectors.blob);
|
|
66
|
+
const key = bytes(vectors.artifactKey);
|
|
67
|
+
const otherAad = tamper(aad, 0);
|
|
68
|
+
for (const index of [1, 40, sealedKey.length - 1]) {
|
|
69
|
+
expect(
|
|
70
|
+
codeOf(() => openArtifactKey(owner.sealing.privateKey, tamper(sealedKey, index), aad)),
|
|
71
|
+
).toBe('decryption-failed');
|
|
72
|
+
}
|
|
73
|
+
for (const index of [1, 20, record.length - 1]) {
|
|
74
|
+
expect(codeOf(() => decryptRecord(key, tamper(record, index), aad))).toBe(
|
|
75
|
+
'decryption-failed',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
for (const index of [1, 100, blob.length - 1]) {
|
|
79
|
+
expect(codeOf(() => openBlob(owner.sealing.privateKey, tamper(blob, index), aad))).toBe(
|
|
80
|
+
'decryption-failed',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
expect(codeOf(() => openArtifactKey(owner.sealing.privateKey, sealedKey, otherAad))).toBe(
|
|
84
|
+
'decryption-failed',
|
|
85
|
+
);
|
|
86
|
+
expect(codeOf(() => decryptRecord(key, record, otherAad))).toBe('decryption-failed');
|
|
87
|
+
expect(codeOf(() => openBlob(owner.sealing.privateKey, blob, otherAad))).toBe(
|
|
88
|
+
'decryption-failed',
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { createPublicKey, randomBytes } from 'node:crypto';
|
|
3
|
+
import { runInNewContext } from 'node:vm';
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
KeyringError,
|
|
9
|
+
decryptRecord,
|
|
10
|
+
encryptRecord,
|
|
11
|
+
generateArtifactKey,
|
|
12
|
+
openArtifactKey,
|
|
13
|
+
openBlob,
|
|
14
|
+
sealArtifactKey,
|
|
15
|
+
sealBlob,
|
|
16
|
+
splitSealedBlob,
|
|
17
|
+
} from '../index.js';
|
|
18
|
+
import { deriveKeyPairs } from '../key-pairs.js';
|
|
19
|
+
import { RECORD_OVERHEAD_BYTES, SEALED_KEY_BYTES } from '../sealing.js';
|
|
20
|
+
import { bytesOf, codeOf, FIXED_MASTER_KEY, hex, tamper } from './helpers.js';
|
|
21
|
+
|
|
22
|
+
const owner = deriveKeyPairs(FIXED_MASTER_KEY);
|
|
23
|
+
const stranger = deriveKeyPairs(Buffer.alloc(32, 7));
|
|
24
|
+
// A locked process holds only this: 32 public bytes.
|
|
25
|
+
const sealingPublicKey = new Uint8Array(owner.sealing.publicKey);
|
|
26
|
+
const aad = bytesOf('artifact:7f3a|seq:0');
|
|
27
|
+
const plaintext = bytesOf('{"role":"user","text":"the launch code is 0000"}\n');
|
|
28
|
+
|
|
29
|
+
describe('sealed artifact keys', () => {
|
|
30
|
+
it('seals with only the public key and opens only with the matching private key', () => {
|
|
31
|
+
const artifactKey = generateArtifactKey();
|
|
32
|
+
expect(artifactKey).toHaveLength(32);
|
|
33
|
+
|
|
34
|
+
const sealed = sealArtifactKey(sealingPublicKey, artifactKey, aad);
|
|
35
|
+
expect(sealed).toHaveLength(SEALED_KEY_BYTES);
|
|
36
|
+
expect(sealed[0]).toBe(1);
|
|
37
|
+
expect(hex(openArtifactKey(owner.sealing.privateKey, sealed, aad))).toBe(hex(artifactKey));
|
|
38
|
+
|
|
39
|
+
// The key material never appears in the sealed bytes.
|
|
40
|
+
expect(Buffer.from(sealed).includes(artifactKey)).toBe(false);
|
|
41
|
+
|
|
42
|
+
expect(codeOf(() => openArtifactKey(stranger.sealing.privateKey, sealed, aad))).toBe(
|
|
43
|
+
'decryption-failed',
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('refuses a key that is not the X25519 sealing private key', () => {
|
|
48
|
+
const sealed = sealArtifactKey(sealingPublicKey, generateArtifactKey(), aad);
|
|
49
|
+
// The attach key is Ed25519; a public key object cannot open anything.
|
|
50
|
+
expect(codeOf(() => openArtifactKey(owner.attach.privateKey, sealed, aad))).toBe(
|
|
51
|
+
'invalid-argument',
|
|
52
|
+
);
|
|
53
|
+
const publicOnly = createPublicKey(owner.sealing.privateKey);
|
|
54
|
+
expect(codeOf(() => openArtifactKey(publicOnly, sealed, aad))).toBe('invalid-argument');
|
|
55
|
+
expect(codeOf(() => openArtifactKey(undefined as never, sealed, aad))).toBe('invalid-argument');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('is randomized: sealing the same key twice differs everywhere that matters', () => {
|
|
59
|
+
const artifactKey = generateArtifactKey();
|
|
60
|
+
const first = sealArtifactKey(sealingPublicKey, artifactKey, aad);
|
|
61
|
+
const second = sealArtifactKey(sealingPublicKey, artifactKey, aad);
|
|
62
|
+
expect(first.equals(second)).toBe(false);
|
|
63
|
+
expect(first.subarray(1, 33).equals(second.subarray(1, 33))).toBe(false); // ephemeral key
|
|
64
|
+
expect(first.subarray(33, 45).equals(second.subarray(33, 45))).toBe(false); // IV
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('binds the AAD', () => {
|
|
68
|
+
const sealed = sealArtifactKey(sealingPublicKey, generateArtifactKey(), aad);
|
|
69
|
+
expect(
|
|
70
|
+
codeOf(() => openArtifactKey(owner.sealing.privateKey, sealed, bytesOf('artifact:other'))),
|
|
71
|
+
).toBe('decryption-failed');
|
|
72
|
+
expect(codeOf(() => openArtifactKey(owner.sealing.privateKey, sealed, new Uint8Array(0)))).toBe(
|
|
73
|
+
'decryption-failed',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('fails on a change to any single byte, and never returns a key', () => {
|
|
78
|
+
const sealed = sealArtifactKey(sealingPublicKey, generateArtifactKey(), aad);
|
|
79
|
+
for (let index = 0; index < sealed.length; index += 1) {
|
|
80
|
+
const expected = index === 0 ? 'unsupported-version' : 'decryption-failed';
|
|
81
|
+
expect(
|
|
82
|
+
codeOf(() => openArtifactKey(owner.sealing.privateKey, tamper(sealed, index), aad)),
|
|
83
|
+
).toBe(expected);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('authenticates the ephemeral key even where X25519 ignores a bit of it', () => {
|
|
88
|
+
// RFC 7748 masks the top bit of the u-coordinate, so flipping it leaves the ECDH secret
|
|
89
|
+
// unchanged. The sender's key is also mixed into the HKDF salt, so it is still caught.
|
|
90
|
+
const sealed = sealArtifactKey(sealingPublicKey, generateArtifactKey(), aad);
|
|
91
|
+
expect(
|
|
92
|
+
codeOf(() => openArtifactKey(owner.sealing.privateKey, tamper(sealed, 32, 0x80), aad)),
|
|
93
|
+
).toBe('decryption-failed');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('fails on truncated or extended sealed keys', () => {
|
|
97
|
+
const sealed = sealArtifactKey(sealingPublicKey, generateArtifactKey(), aad);
|
|
98
|
+
expect(
|
|
99
|
+
codeOf(() => openArtifactKey(owner.sealing.privateKey, sealed.subarray(0, 92), aad)),
|
|
100
|
+
).toBe('malformed-ciphertext');
|
|
101
|
+
expect(
|
|
102
|
+
codeOf(() => openArtifactKey(owner.sealing.privateKey, sealed.subarray(0, 1), aad)),
|
|
103
|
+
).toBe('malformed-ciphertext');
|
|
104
|
+
expect(
|
|
105
|
+
codeOf(() =>
|
|
106
|
+
openArtifactKey(owner.sealing.privateKey, Buffer.concat([sealed, Buffer.alloc(1)]), aad),
|
|
107
|
+
),
|
|
108
|
+
).toBe('malformed-ciphertext');
|
|
109
|
+
expect(codeOf(() => openArtifactKey(owner.sealing.privateKey, new Uint8Array(0), aad))).toBe(
|
|
110
|
+
'malformed-ciphertext',
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects arguments of the wrong type or size', () => {
|
|
115
|
+
const artifactKey = generateArtifactKey();
|
|
116
|
+
expect(codeOf(() => sealArtifactKey(sealingPublicKey.subarray(1), artifactKey, aad))).toBe(
|
|
117
|
+
'invalid-argument',
|
|
118
|
+
);
|
|
119
|
+
expect(codeOf(() => sealArtifactKey(sealingPublicKey, artifactKey.subarray(1), aad))).toBe(
|
|
120
|
+
'invalid-argument',
|
|
121
|
+
);
|
|
122
|
+
expect(codeOf(() => sealArtifactKey(sealingPublicKey, artifactKey, 'text' as never))).toBe(
|
|
123
|
+
'invalid-argument',
|
|
124
|
+
);
|
|
125
|
+
expect(
|
|
126
|
+
codeOf(() => sealArtifactKey(sealingPublicKey, artifactKey, new Uint8Array(65_536))),
|
|
127
|
+
).toBe('invalid-argument');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('refuses a sealing public key that has no usable ECDH secret', () => {
|
|
131
|
+
// An all-zero key is a low-order point: the shared secret would be all zeros for any sender.
|
|
132
|
+
expect(codeOf(() => sealArtifactKey(new Uint8Array(32), generateArtifactKey(), aad))).toBe(
|
|
133
|
+
'invalid-argument',
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('content records', () => {
|
|
139
|
+
const artifactKey = generateArtifactKey();
|
|
140
|
+
|
|
141
|
+
it('round-trips and has the documented layout', () => {
|
|
142
|
+
const record = encryptRecord(artifactKey, plaintext, aad);
|
|
143
|
+
expect(record).toHaveLength(RECORD_OVERHEAD_BYTES + plaintext.length);
|
|
144
|
+
expect(record[0]).toBe(1);
|
|
145
|
+
expect(hex(decryptRecord(artifactKey, record, aad))).toBe(hex(plaintext));
|
|
146
|
+
expect(record.includes(plaintext)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('round-trips empty and large plaintexts', () => {
|
|
150
|
+
const empty = encryptRecord(artifactKey, new Uint8Array(0), aad);
|
|
151
|
+
expect(empty).toHaveLength(RECORD_OVERHEAD_BYTES);
|
|
152
|
+
expect(decryptRecord(artifactKey, empty, aad)).toHaveLength(0);
|
|
153
|
+
|
|
154
|
+
const large = randomBytes(1024 * 1024);
|
|
155
|
+
expect(hex(decryptRecord(artifactKey, encryptRecord(artifactKey, large, aad), aad))).toBe(
|
|
156
|
+
hex(large),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('uses a fresh nonce for every record under one key', () => {
|
|
161
|
+
const nonces = new Set<string>();
|
|
162
|
+
for (let index = 0; index < 500; index += 1) {
|
|
163
|
+
nonces.add(hex(encryptRecord(artifactKey, plaintext, aad).subarray(1, 13)));
|
|
164
|
+
}
|
|
165
|
+
expect(nonces.size).toBe(500);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('binds the AAD, so a record cannot move to another artifact or sequence number', () => {
|
|
169
|
+
const record = encryptRecord(artifactKey, plaintext, bytesOf('artifact:7f3a|seq:1'));
|
|
170
|
+
expect(codeOf(() => decryptRecord(artifactKey, record, bytesOf('artifact:7f3a|seq:2')))).toBe(
|
|
171
|
+
'decryption-failed',
|
|
172
|
+
);
|
|
173
|
+
expect(codeOf(() => decryptRecord(artifactKey, record, bytesOf('artifact:9999|seq:1')))).toBe(
|
|
174
|
+
'decryption-failed',
|
|
175
|
+
);
|
|
176
|
+
expect(hex(decryptRecord(artifactKey, record, bytesOf('artifact:7f3a|seq:1')))).toBe(
|
|
177
|
+
hex(plaintext),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('fails under another key', () => {
|
|
182
|
+
const record = encryptRecord(artifactKey, plaintext, aad);
|
|
183
|
+
expect(codeOf(() => decryptRecord(generateArtifactKey(), record, aad))).toBe(
|
|
184
|
+
'decryption-failed',
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('fails on a change to any single byte', () => {
|
|
189
|
+
const record = encryptRecord(artifactKey, plaintext, aad);
|
|
190
|
+
for (let index = 0; index < record.length; index += 1) {
|
|
191
|
+
const expected = index === 0 ? 'unsupported-version' : 'decryption-failed';
|
|
192
|
+
expect(codeOf(() => decryptRecord(artifactKey, tamper(record, index), aad))).toBe(expected);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('fails on truncated or extended records', () => {
|
|
197
|
+
const record = encryptRecord(artifactKey, plaintext, aad);
|
|
198
|
+
expect(codeOf(() => decryptRecord(artifactKey, record.subarray(0, 5), aad))).toBe(
|
|
199
|
+
'malformed-ciphertext',
|
|
200
|
+
);
|
|
201
|
+
expect(
|
|
202
|
+
codeOf(() => decryptRecord(artifactKey, record.subarray(0, RECORD_OVERHEAD_BYTES - 1), aad)),
|
|
203
|
+
).toBe('malformed-ciphertext');
|
|
204
|
+
expect(codeOf(() => decryptRecord(artifactKey, new Uint8Array(0), aad))).toBe(
|
|
205
|
+
'malformed-ciphertext',
|
|
206
|
+
);
|
|
207
|
+
expect(
|
|
208
|
+
codeOf(() => decryptRecord(artifactKey, record.subarray(0, record.length - 1), aad)),
|
|
209
|
+
).toBe('decryption-failed');
|
|
210
|
+
expect(codeOf(() => decryptRecord(artifactKey, record.subarray(1), aad))).toBe(
|
|
211
|
+
'unsupported-version',
|
|
212
|
+
);
|
|
213
|
+
expect(
|
|
214
|
+
codeOf(() => decryptRecord(artifactKey, Buffer.concat([record, Buffer.alloc(1)]), aad)),
|
|
215
|
+
).toBe('decryption-failed');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('reports an unknown version byte as unsupported, not as tampering', () => {
|
|
219
|
+
const record = encryptRecord(artifactKey, plaintext, aad);
|
|
220
|
+
expect(codeOf(() => decryptRecord(artifactKey, tamper(record, 0, 0x02), aad))).toBe(
|
|
221
|
+
'unsupported-version',
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('rejects a key or record of the wrong type or size', () => {
|
|
226
|
+
expect(codeOf(() => encryptRecord(artifactKey.subarray(1), plaintext, aad))).toBe(
|
|
227
|
+
'invalid-argument',
|
|
228
|
+
);
|
|
229
|
+
expect(codeOf(() => encryptRecord(artifactKey, 'text' as never, aad))).toBe('invalid-argument');
|
|
230
|
+
expect(codeOf(() => decryptRecord(artifactKey, 'text' as never, aad))).toBe('invalid-argument');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('does not reveal that two records hold the same plaintext', () => {
|
|
234
|
+
// 4 KiB of zeros sealed twice: the two ciphertexts must look independent. Each ciphertext is
|
|
235
|
+
// ~half ones, and they differ in ~half their bits (18 standard deviations of slack).
|
|
236
|
+
const zeros = new Uint8Array(4096);
|
|
237
|
+
const first = encryptRecord(artifactKey, zeros, aad).subarray(13, 13 + 4096);
|
|
238
|
+
const second = encryptRecord(artifactKey, zeros, aad).subarray(13, 13 + 4096);
|
|
239
|
+
const bits = (bytes: Uint8Array) =>
|
|
240
|
+
bytes.reduce((total, byte) => total + byte.toString(2).replaceAll('0', '').length, 0);
|
|
241
|
+
const totalBits = 4096 * 8;
|
|
242
|
+
expect(bits(first) / totalBits).toBeGreaterThan(0.45);
|
|
243
|
+
expect(bits(first) / totalBits).toBeLessThan(0.55);
|
|
244
|
+
const differing = bits(first.map((byte, index) => byte ^ second[index]));
|
|
245
|
+
expect(differing / totalBits).toBeGreaterThan(0.45);
|
|
246
|
+
expect(differing / totalBits).toBeLessThan(0.55);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('sealed blobs', () => {
|
|
251
|
+
it('seals with only the public key and opens with the private key', () => {
|
|
252
|
+
const blob = sealBlob(sealingPublicKey, plaintext, aad);
|
|
253
|
+
expect(blob).toHaveLength(SEALED_KEY_BYTES + RECORD_OVERHEAD_BYTES + plaintext.length);
|
|
254
|
+
expect(blob.includes(plaintext)).toBe(false);
|
|
255
|
+
expect(hex(openBlob(owner.sealing.privateKey, blob, aad))).toBe(hex(plaintext));
|
|
256
|
+
expect(codeOf(() => openBlob(stranger.sealing.privateKey, blob, aad))).toBe(
|
|
257
|
+
'decryption-failed',
|
|
258
|
+
);
|
|
259
|
+
expect(codeOf(() => openBlob(owner.attach.privateKey, blob, aad))).toBe('invalid-argument');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('seals the same plaintext to different blobs', () => {
|
|
263
|
+
const blobs = new Set<string>();
|
|
264
|
+
for (let index = 0; index < 100; index += 1) {
|
|
265
|
+
blobs.add(hex(sealBlob(sealingPublicKey, plaintext, aad)));
|
|
266
|
+
}
|
|
267
|
+
expect(blobs.size).toBe(100);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('binds the AAD', () => {
|
|
271
|
+
const blob = sealBlob(sealingPublicKey, plaintext, aad);
|
|
272
|
+
expect(codeOf(() => openBlob(owner.sealing.privateKey, blob, bytesOf('artifact:other')))).toBe(
|
|
273
|
+
'decryption-failed',
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('fails on a change to any single byte, never returning plaintext', () => {
|
|
278
|
+
const blob = sealBlob(sealingPublicKey, plaintext, aad);
|
|
279
|
+
for (let index = 0; index < blob.length; index += 1) {
|
|
280
|
+
// Two version bytes: the sealed key header's and the record's.
|
|
281
|
+
const isVersionByte = index === 0 || index === SEALED_KEY_BYTES;
|
|
282
|
+
const expected = isVersionByte ? 'unsupported-version' : 'decryption-failed';
|
|
283
|
+
expect(codeOf(() => openBlob(owner.sealing.privateKey, tamper(blob, index), aad))).toBe(
|
|
284
|
+
expected,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('fails on truncated or extended blobs', () => {
|
|
290
|
+
const blob = sealBlob(sealingPublicKey, plaintext, aad);
|
|
291
|
+
expect(codeOf(() => openBlob(owner.sealing.privateKey, blob.subarray(0, 50), aad))).toBe(
|
|
292
|
+
'malformed-ciphertext',
|
|
293
|
+
);
|
|
294
|
+
expect(
|
|
295
|
+
codeOf(() => openBlob(owner.sealing.privateKey, blob.subarray(0, SEALED_KEY_BYTES), aad)),
|
|
296
|
+
).toBe('malformed-ciphertext');
|
|
297
|
+
expect(codeOf(() => openBlob(owner.sealing.privateKey, new Uint8Array(0), aad))).toBe(
|
|
298
|
+
'malformed-ciphertext',
|
|
299
|
+
);
|
|
300
|
+
for (const cut of [1, 5, 16, 17, plaintext.length]) {
|
|
301
|
+
expect(
|
|
302
|
+
codeOf(() => openBlob(owner.sealing.privateKey, blob.subarray(0, blob.length - cut), aad)),
|
|
303
|
+
).toBe('decryption-failed');
|
|
304
|
+
}
|
|
305
|
+
expect(
|
|
306
|
+
codeOf(() => openBlob(owner.sealing.privateKey, Buffer.concat([blob, Buffer.alloc(1)]), aad)),
|
|
307
|
+
).toBe('decryption-failed');
|
|
308
|
+
expect(codeOf(() => openBlob(owner.sealing.privateKey, Buffer.concat([blob, blob]), aad))).toBe(
|
|
309
|
+
'decryption-failed',
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('splitting a sealed blob', () => {
|
|
315
|
+
it('yields the sealed key and the record, each of which opens on its own', () => {
|
|
316
|
+
const blob = sealBlob(sealingPublicKey, plaintext, aad);
|
|
317
|
+
const { sealedKey, record } = splitSealedBlob(blob);
|
|
318
|
+
expect(sealedKey).toHaveLength(SEALED_KEY_BYTES);
|
|
319
|
+
expect(record).toHaveLength(RECORD_OVERHEAD_BYTES + plaintext.length);
|
|
320
|
+
expect(hex(Buffer.concat([sealedKey, record]))).toBe(hex(blob));
|
|
321
|
+
const artifactKey = openArtifactKey(owner.sealing.privateKey, sealedKey, aad);
|
|
322
|
+
expect(hex(decryptRecord(artifactKey, record, aad))).toBe(hex(plaintext));
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('splits a blob of the smallest possible size', () => {
|
|
326
|
+
const blob = sealBlob(sealingPublicKey, new Uint8Array(0), aad);
|
|
327
|
+
expect(splitSealedBlob(blob).record).toHaveLength(RECORD_OVERHEAD_BYTES);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('refuses a blob shorter than its fixed overhead, and an unknown version, as openBlob does', () => {
|
|
331
|
+
const blob = sealBlob(sealingPublicKey, plaintext, aad);
|
|
332
|
+
for (const short of [
|
|
333
|
+
new Uint8Array(0),
|
|
334
|
+
blob.subarray(0, 50),
|
|
335
|
+
blob.subarray(0, SEALED_KEY_BYTES),
|
|
336
|
+
blob.subarray(0, SEALED_KEY_BYTES + RECORD_OVERHEAD_BYTES - 1),
|
|
337
|
+
]) {
|
|
338
|
+
expect(codeOf(() => splitSealedBlob(short))).toBe('malformed-ciphertext');
|
|
339
|
+
}
|
|
340
|
+
expect(codeOf(() => splitSealedBlob(tamper(blob, 0)))).toBe('unsupported-version');
|
|
341
|
+
expect(codeOf(() => splitSealedBlob('blob' as never))).toBe('invalid-argument');
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it('carries no part of the blob in its errors', () => {
|
|
345
|
+
const blob = sealBlob(sealingPublicKey, plaintext, aad);
|
|
346
|
+
for (const bad of [blob.subarray(0, 50), tamper(blob, 0)]) {
|
|
347
|
+
let refusal: unknown;
|
|
348
|
+
try {
|
|
349
|
+
splitSealedBlob(bad);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
refusal = error;
|
|
352
|
+
}
|
|
353
|
+
expect(refusal).toBeInstanceOf(KeyringError);
|
|
354
|
+
const text = `${(refusal as Error).message} ${JSON.stringify(refusal)}`;
|
|
355
|
+
expect(text).not.toContain(hex(bad.subarray(1, 9)));
|
|
356
|
+
expect(text).not.toContain(Buffer.from(bad.subarray(1, 9)).toString('base64'));
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
describe('byte arrays from another realm', () => {
|
|
362
|
+
/** A copy of `bytes` whose Uint8Array constructor is another realm's, as a worker or vm context hands over. */
|
|
363
|
+
function foreign(bytes: Uint8Array): Uint8Array {
|
|
364
|
+
const copy = runInNewContext(`new Uint8Array(${bytes.length})`) as Uint8Array;
|
|
365
|
+
copy.set(bytes);
|
|
366
|
+
expect(copy instanceof Uint8Array).toBe(false);
|
|
367
|
+
return copy;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
it('seals to a foreign-realm public key and opens a foreign-realm blob', () => {
|
|
371
|
+
const blob = sealBlob(foreign(sealingPublicKey), plaintext, foreign(aad));
|
|
372
|
+
expect(hex(openBlob(owner.sealing.privateKey, foreign(blob), foreign(aad)))).toBe(
|
|
373
|
+
hex(plaintext),
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
});
|