@learncard/lca-api-service 1.3.1 → 1.4.0
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/.env.example +26 -2
- package/AGENTS.md +59 -44
- package/CHANGELOG.md +57 -0
- package/compose.env +5 -0
- package/dist/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +297 -8
- package/dist/cache/challenges.d.ts +2 -0
- package/dist/cache/recoverySessions.d.ts +42 -0
- package/dist/config/environment.d.ts +152 -2
- package/dist/config/environment.test.d.ts +1 -0
- package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
- package/dist/helpers/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/EscrowHold.d.ts +75 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +316 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/routes/escrow.d.ts +150 -0
- package/dist/routes/firebase.test.d.ts +1 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +181 -7
- package/dist/routes/qr-login.d.ts +1 -1
- package/dist/services/escrow-enclave/index.d.ts +9 -0
- package/dist/services/escrow-enclave/notifications.d.ts +9 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +67 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/lambda.ts +8 -3
- package/package.json +17 -11
- package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -0
- package/seedMigrationLambda.test.ts +145 -0
- package/seedMigrationLambda.ts +50 -0
- package/serverless-local.yml +3 -0
- package/serverless.escrow-env.cjs +20 -0
- package/serverless.yml +133 -0
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/app.ts +2 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/environment.test.ts +116 -0
- package/src/config/environment.ts +97 -0
- package/src/docker-entry.ts +4 -0
- package/src/helpers/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
- package/src/helpers/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- package/src/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/EscrowHold.ts +225 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1024 -40
- package/src/models/index.ts +1 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +826 -0
- package/src/routes/firebase.test.ts +94 -0
- package/src/routes/firebase.ts +99 -56
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1017 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
- package/src/services/escrow-enclave/README.md +51 -0
- package/src/services/escrow-enclave/index.ts +36 -0
- package/src/services/escrow-enclave/notifications.ts +14 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
- package/src/services/escrow-enclave/types.ts +76 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/credentials.spec.ts +2 -0
- package/test/keys-escrow.spec.ts +1389 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/escrow.spec.ts +210 -0
- package/test/signing-authority-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +40 -0
- package/vitest.config.ts +1 -1
- package/vitest.integration.config.ts +5 -0
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
import { DecryptCommand, GenerateDataKeyCommand, KMSClient } from '@aws-sdk/client-kms';
|
|
3
|
+
import { MongoServerError } from 'mongodb';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { environment } from '@environment';
|
|
7
|
+
import {
|
|
8
|
+
createSeedEncryption,
|
|
9
|
+
decryptSigningAuthoritySeed,
|
|
10
|
+
logSeedEncryptionFailure,
|
|
11
|
+
parseSeedEnvelope,
|
|
12
|
+
SeedEncryptionError,
|
|
13
|
+
} from './seedEncryption.helpers';
|
|
14
|
+
|
|
15
|
+
const seed = 'a'.repeat(64);
|
|
16
|
+
const identity = {
|
|
17
|
+
_id: 'authority-1',
|
|
18
|
+
ownerDid: 'did:example:owner',
|
|
19
|
+
name: 'main',
|
|
20
|
+
did: 'did:key:example',
|
|
21
|
+
};
|
|
22
|
+
const local = { allowLocal: true, localKek: 'f'.repeat(64) };
|
|
23
|
+
const arn = 'arn:aws:kms:us-east-1:123456789012:key/11111111-1111-1111-1111-111111111111';
|
|
24
|
+
const originalLegacyRead = environment.SA_SEED_ALLOW_LEGACY_READ;
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
environment.SA_SEED_ALLOW_LEGACY_READ = originalLegacyRead;
|
|
28
|
+
vi.unstubAllEnvs();
|
|
29
|
+
vi.restoreAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Exercise the default client's real retry middleware, replacing only the transport. */
|
|
33
|
+
const mockKmsTransport = (response: () => Promise<object>): void => {
|
|
34
|
+
vi.stubEnv('AWS_REGION', 'us-east-1');
|
|
35
|
+
// Exercise real SDK retries without random backoff slowing the suite down.
|
|
36
|
+
vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
37
|
+
const originalSend: (command: Parameters<KMSClient['send']>[0]) => Promise<object> =
|
|
38
|
+
KMSClient.prototype.send;
|
|
39
|
+
vi.spyOn(KMSClient.prototype, 'send').mockImplementation(function (this: KMSClient, command) {
|
|
40
|
+
this.config.region = async () => 'us-east-1';
|
|
41
|
+
this.config.credentials = async () => ({ accessKeyId: 'test', secretAccessKey: 'test' });
|
|
42
|
+
this.middlewareStack.addRelativeTo(
|
|
43
|
+
() => async () => ({ response: {}, output: { $metadata: {}, ...(await response()) } }),
|
|
44
|
+
{
|
|
45
|
+
name: 'testTransport',
|
|
46
|
+
relation: 'after',
|
|
47
|
+
toMiddleware: 'retryMiddleware',
|
|
48
|
+
override: true,
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
return originalSend.call(this, command);
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
describe('SA seed envelopes', () => {
|
|
56
|
+
it.each(['ThrottlingException', 'TimeoutError'])(
|
|
57
|
+
'recovers a transient %s during encryption and decryption using SDK retries',
|
|
58
|
+
async name => {
|
|
59
|
+
const response = vi
|
|
60
|
+
.fn()
|
|
61
|
+
.mockRejectedValueOnce({ name })
|
|
62
|
+
.mockResolvedValueOnce({
|
|
63
|
+
Plaintext: Buffer.alloc(32, 7),
|
|
64
|
+
CiphertextBlob: Buffer.from('wrapped'),
|
|
65
|
+
KeyId: arn,
|
|
66
|
+
})
|
|
67
|
+
.mockRejectedValueOnce({ name })
|
|
68
|
+
.mockResolvedValueOnce({ Plaintext: Buffer.alloc(32, 7), KeyId: arn });
|
|
69
|
+
mockKmsTransport(response);
|
|
70
|
+
const encryption = createSeedEncryption({ kmsKeyArn: arn, allowLocal: false });
|
|
71
|
+
const envelope = await encryption.encrypt(seed, identity);
|
|
72
|
+
expect(await encryption.decrypt(envelope, identity)).toBe(seed);
|
|
73
|
+
expect(response).toHaveBeenCalledTimes(4);
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
it.each([
|
|
78
|
+
['ThrottlingException', 3, 'kms_unavailable'],
|
|
79
|
+
['TimeoutError', 3, 'kms_unavailable'],
|
|
80
|
+
['AccessDeniedException', 1, 'kms_access_denied'],
|
|
81
|
+
['InvalidCiphertextException', 1, 'authentication_failed'],
|
|
82
|
+
])('bounds attempts for %s and fails closed', async (name, attempts, category) => {
|
|
83
|
+
const response = vi.fn().mockRejectedValue({ name, message: seed });
|
|
84
|
+
mockKmsTransport(response);
|
|
85
|
+
const encryption = createSeedEncryption({ ...local, kmsKeyArn: arn });
|
|
86
|
+
const result = encryption.encrypt(seed, identity).catch(error => error);
|
|
87
|
+
expect(await result).toBeInstanceOf(SeedEncryptionError);
|
|
88
|
+
expect(await result).toMatchObject({ category });
|
|
89
|
+
expect(String(await result)).not.toContain(seed);
|
|
90
|
+
expect(response).toHaveBeenCalledTimes(attempts);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('logs Mongo error codes without messages, key values, or document contents', () => {
|
|
94
|
+
const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
95
|
+
const error = new MongoServerError({ message: seed, code: 11000, keyValue: { seed } });
|
|
96
|
+
logSeedEncryptionFailure(error, 'create', identity);
|
|
97
|
+
expect(logger).toHaveBeenCalledWith(
|
|
98
|
+
expect.objectContaining({
|
|
99
|
+
operation: 'create',
|
|
100
|
+
errorName: 'MongoServerError',
|
|
101
|
+
mongoCode: 11000,
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
|
|
105
|
+
// Arbitrary exceptions cannot smuggle document contents through name/code.
|
|
106
|
+
logSeedEncryptionFailure({ name: seed, code: seed, message: seed }, 'create', identity);
|
|
107
|
+
expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
|
|
108
|
+
});
|
|
109
|
+
it.each(['TypeError', 'RuntimeError', 'MongoNetworkError', 'MongoServerSelectionError'])(
|
|
110
|
+
'logs the safe %s name without sensitive exception details',
|
|
111
|
+
name => {
|
|
112
|
+
const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
113
|
+
const error = Object.assign(new Error(seed), { name, cause: { seed } });
|
|
114
|
+
logSeedEncryptionFailure(error, 'create', identity);
|
|
115
|
+
expect(logger).toHaveBeenCalledWith(
|
|
116
|
+
expect.objectContaining({ category: 'operation_failed', errorName: name })
|
|
117
|
+
);
|
|
118
|
+
expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
|
|
119
|
+
}
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
it('omits arbitrary names even on Error instances', () => {
|
|
123
|
+
const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
124
|
+
logSeedEncryptionFailure(
|
|
125
|
+
Object.assign(new Error(seed), { name: seed }),
|
|
126
|
+
'create',
|
|
127
|
+
identity
|
|
128
|
+
);
|
|
129
|
+
expect(logger).toHaveBeenCalledOnce();
|
|
130
|
+
expect(logger.mock.calls[0]?.[0]).not.toHaveProperty('errorName');
|
|
131
|
+
expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('round trips locally with fresh DEKs, IVs, and ciphertext for each authority', async () => {
|
|
135
|
+
const encryption = createSeedEncryption(local);
|
|
136
|
+
const first = await encryption.encrypt(seed, identity);
|
|
137
|
+
const second = await encryption.encrypt(seed, identity);
|
|
138
|
+
expect(first).not.toEqual(second);
|
|
139
|
+
expect(first.encryptedSeed).not.toBe(second.encryptedSeed);
|
|
140
|
+
expect(first.encryptedDek).not.toBe(second.encryptedDek);
|
|
141
|
+
expect(Buffer.from(first.encryptedSeed, 'base64').length).toBe(12 + 64 + 16);
|
|
142
|
+
expect(await encryption.decrypt(first, identity)).toBe(seed);
|
|
143
|
+
expect(await encryption.decrypt(second, identity)).toBe(seed);
|
|
144
|
+
expect(JSON.stringify(first)).not.toContain(seed);
|
|
145
|
+
expect(parseSeedEnvelope({ ...identity, seed, ...first })).toEqual(first);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it.each(['success', 'encryptedDek', 'encryptedSeed'] as const)(
|
|
149
|
+
'clears intermediate plaintext buffers after decryption: %s',
|
|
150
|
+
async outcome => {
|
|
151
|
+
const encryption = createSeedEncryption(local);
|
|
152
|
+
const envelope = await encryption.encrypt(seed, identity);
|
|
153
|
+
if (outcome !== 'success') {
|
|
154
|
+
const bytes = Buffer.from(envelope[outcome], 'base64');
|
|
155
|
+
bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 1;
|
|
156
|
+
envelope[outcome] = bytes.toString('base64');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Call through to real AES-GCM and retain references to its original output buffers.
|
|
160
|
+
const decipherPrototype: ReturnType<typeof createDecipheriv> = Object.getPrototypeOf(
|
|
161
|
+
createDecipheriv('aes-256-gcm', Buffer.alloc(32), Buffer.alloc(12))
|
|
162
|
+
);
|
|
163
|
+
const update = vi.spyOn(decipherPrototype, 'update');
|
|
164
|
+
const final = vi.spyOn(decipherPrototype, 'final');
|
|
165
|
+
if (outcome === 'success') {
|
|
166
|
+
await expect(encryption.decrypt(envelope, identity)).resolves.toBe(seed);
|
|
167
|
+
} else {
|
|
168
|
+
await expect(encryption.decrypt(envelope, identity)).rejects.toMatchObject({
|
|
169
|
+
category: 'authentication_failed',
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
expect(update).toHaveBeenCalledTimes(outcome === 'encryptedDek' ? 1 : 2);
|
|
174
|
+
for (const result of [...update.mock.results, ...final.mock.results]) {
|
|
175
|
+
if (result.type !== 'return') continue;
|
|
176
|
+
const chunk = result.value;
|
|
177
|
+
expect(Buffer.isBuffer(chunk)).toBe(true);
|
|
178
|
+
expect(chunk).toEqual(Buffer.alloc(chunk.length));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
it('reads both envelope versions after enabling KMS while retaining the local key', async () => {
|
|
184
|
+
const localEnvelope = await createSeedEncryption(local).encrypt(seed, identity);
|
|
185
|
+
const kms = new KMSClient({ region: 'us-east-1' });
|
|
186
|
+
const key = randomBytes(32);
|
|
187
|
+
const send = vi.spyOn(kms, 'send').mockImplementation(async command => {
|
|
188
|
+
if (command instanceof GenerateDataKeyCommand) {
|
|
189
|
+
return {
|
|
190
|
+
Plaintext: Uint8Array.from(key),
|
|
191
|
+
CiphertextBlob: Buffer.from('wrapped'),
|
|
192
|
+
KeyId: arn,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { Plaintext: Uint8Array.from(key), KeyId: arn };
|
|
196
|
+
});
|
|
197
|
+
const encryption = createSeedEncryption({ ...local, kmsKeyArn: arn }, kms);
|
|
198
|
+
|
|
199
|
+
await expect(encryption.decrypt(localEnvelope, identity)).resolves.toBe(seed);
|
|
200
|
+
expect(send).not.toHaveBeenCalled();
|
|
201
|
+
|
|
202
|
+
const kmsEnvelope = await encryption.encrypt(seed, identity);
|
|
203
|
+
expect(kmsEnvelope.keyVersion).toBe('kms-v1');
|
|
204
|
+
await expect(encryption.decrypt(kmsEnvelope, identity)).resolves.toBe(seed);
|
|
205
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
206
|
+
expect(send.mock.calls[1]![0]).toBeInstanceOf(DecryptCommand);
|
|
207
|
+
|
|
208
|
+
// Removing KMS configuration still permits local reads, but cannot unlock KMS data.
|
|
209
|
+
const localOnly = createSeedEncryption(local, kms);
|
|
210
|
+
await expect(localOnly.decrypt(localEnvelope, identity)).resolves.toBe(seed);
|
|
211
|
+
await expect(localOnly.decrypt(kmsEnvelope, identity)).rejects.toMatchObject({
|
|
212
|
+
category: 'configuration',
|
|
213
|
+
});
|
|
214
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it.each([
|
|
218
|
+
{ allowLocal: false, localKek: local.localKek },
|
|
219
|
+
{ allowLocal: true },
|
|
220
|
+
{ allowLocal: true, localKek: 'short' },
|
|
221
|
+
])('rejects local reads without an allowed, configured local key: %j', async config => {
|
|
222
|
+
const envelope = await createSeedEncryption(local).encrypt(seed, identity);
|
|
223
|
+
const kms = new KMSClient({ region: 'us-east-1' });
|
|
224
|
+
const send = vi.spyOn(kms, 'send');
|
|
225
|
+
await expect(
|
|
226
|
+
createSeedEncryption({ ...config, kmsKeyArn: arn }, kms).decrypt(envelope, identity)
|
|
227
|
+
).rejects.toMatchObject({ category: 'configuration' });
|
|
228
|
+
expect(send).not.toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('does not try KMS if a local envelope fails authentication', async () => {
|
|
232
|
+
const envelope = await createSeedEncryption(local).encrypt(seed, identity);
|
|
233
|
+
const kms = new KMSClient({ region: 'us-east-1' });
|
|
234
|
+
const send = vi.spyOn(kms, 'send');
|
|
235
|
+
await expect(
|
|
236
|
+
createSeedEncryption(
|
|
237
|
+
{ ...local, localKek: 'e'.repeat(64), kmsKeyArn: arn },
|
|
238
|
+
kms
|
|
239
|
+
).decrypt(envelope, identity)
|
|
240
|
+
).rejects.toMatchObject({ category: 'authentication_failed' });
|
|
241
|
+
expect(send).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('authenticates the envelope version even when both providers are configured', async () => {
|
|
245
|
+
const envelope = await createSeedEncryption(local).encrypt(seed, identity);
|
|
246
|
+
const kms = new KMSClient({ region: 'us-east-1' });
|
|
247
|
+
const send = vi
|
|
248
|
+
.spyOn(kms, 'send')
|
|
249
|
+
.mockRejectedValue({ name: 'InvalidCiphertextException' });
|
|
250
|
+
await expect(
|
|
251
|
+
createSeedEncryption({ ...local, kmsKeyArn: arn }, kms).decrypt(
|
|
252
|
+
{ ...envelope, keyVersion: 'kms-v1' },
|
|
253
|
+
identity
|
|
254
|
+
)
|
|
255
|
+
).rejects.toMatchObject({ category: 'authentication_failed' });
|
|
256
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
257
|
+
expect((send.mock.calls[0]![0] as DecryptCommand).input.EncryptionContext).toMatchObject({
|
|
258
|
+
keyVersion: 'kms-v1',
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('binds ciphertext to the record ID, owner, name, and DID', async () => {
|
|
263
|
+
const encryption = createSeedEncryption(local);
|
|
264
|
+
const envelope = await encryption.encrypt(seed, identity);
|
|
265
|
+
for (const field of ['_id', 'ownerDid', 'name', 'did'] as const) {
|
|
266
|
+
await expect(
|
|
267
|
+
encryption.decrypt(envelope, { ...identity, [field]: 'other' })
|
|
268
|
+
).rejects.toMatchObject({ category: 'authentication_failed' });
|
|
269
|
+
}
|
|
270
|
+
await expect(
|
|
271
|
+
createSeedEncryption({ ...local, localKek: 'e'.repeat(64) }).decrypt(envelope, identity)
|
|
272
|
+
).rejects.toMatchObject({ category: 'authentication_failed' });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('rejects damaged IVs, ciphertext, authentication tags, and wrapped DEKs', async () => {
|
|
276
|
+
const encryption = createSeedEncryption(local);
|
|
277
|
+
const envelope = await encryption.encrypt(seed, identity);
|
|
278
|
+
for (const field of ['encryptedSeed', 'encryptedDek'] as const) {
|
|
279
|
+
for (const position of [0, 15, Buffer.from(envelope[field], 'base64').length - 1]) {
|
|
280
|
+
const bytes = Buffer.from(envelope[field], 'base64');
|
|
281
|
+
bytes[position] = bytes[position]! ^ 1;
|
|
282
|
+
await expect(
|
|
283
|
+
encryption.decrypt({ ...envelope, [field]: bytes.toString('base64') }, identity)
|
|
284
|
+
).rejects.toBeInstanceOf(SeedEncryptionError);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('rejects partial/unknown envelopes and noncanonical base64 instead of downgrading', async () => {
|
|
290
|
+
environment.SA_SEED_ALLOW_LEGACY_READ = true;
|
|
291
|
+
for (const fields of [
|
|
292
|
+
{ keyVersion: 'kms-v2' },
|
|
293
|
+
{ keyVersion: null },
|
|
294
|
+
{ encryptedSeed: 'abc' },
|
|
295
|
+
{ encryptedDek: 'abc' },
|
|
296
|
+
{ keyVersion: 'local-v1', encryptedSeed: '!', encryptedDek: '!' },
|
|
297
|
+
]) {
|
|
298
|
+
await expect(
|
|
299
|
+
decryptSigningAuthoritySeed({ ...identity, seed, ...fields })
|
|
300
|
+
).rejects.toMatchObject({ category: 'invalid_envelope' });
|
|
301
|
+
}
|
|
302
|
+
expect(parseSeedEnvelope({ seed })).toBeUndefined();
|
|
303
|
+
await expect(decryptSigningAuthoritySeed({ ...identity, seed })).resolves.toBe(seed);
|
|
304
|
+
environment.SA_SEED_ALLOW_LEGACY_READ = false;
|
|
305
|
+
await expect(decryptSigningAuthoritySeed({ ...identity, seed })).rejects.toMatchObject({
|
|
306
|
+
category: 'legacy_disabled',
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('never reads a retained plaintext seed after an envelope fails authentication', async () => {
|
|
311
|
+
environment.SA_SEED_ALLOW_LEGACY_READ = true;
|
|
312
|
+
const envelope = await createSeedEncryption(local).encrypt(seed, identity);
|
|
313
|
+
const bytes = Buffer.from(envelope.encryptedSeed, 'base64');
|
|
314
|
+
bytes[20] = bytes[20]! ^ 1;
|
|
315
|
+
await expect(
|
|
316
|
+
decryptSigningAuthoritySeed({
|
|
317
|
+
...identity,
|
|
318
|
+
seed,
|
|
319
|
+
...envelope,
|
|
320
|
+
encryptedSeed: bytes.toString('base64'),
|
|
321
|
+
})
|
|
322
|
+
).rejects.toMatchObject({ category: 'authentication_failed' });
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('requires an explicit KEK and permitted local environment', async () => {
|
|
326
|
+
for (const config of [
|
|
327
|
+
{ allowLocal: false, localKek: local.localKek },
|
|
328
|
+
{ allowLocal: true },
|
|
329
|
+
{ allowLocal: true, localKek: 'short' },
|
|
330
|
+
]) {
|
|
331
|
+
await expect(
|
|
332
|
+
createSeedEncryption(config).encrypt(seed, identity)
|
|
333
|
+
).rejects.toMatchObject({ category: 'configuration' });
|
|
334
|
+
}
|
|
335
|
+
await expect(
|
|
336
|
+
createSeedEncryption(local).encrypt('invalid seed', identity)
|
|
337
|
+
).rejects.toMatchObject({ category: 'invalid_record' });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('uses exactly one GenerateDataKey and one Decrypt, pins KeyId/context, and zeros KMS plaintext', async () => {
|
|
341
|
+
const kms = new KMSClient({ region: 'us-east-1' });
|
|
342
|
+
const key = randomBytes(32);
|
|
343
|
+
const generated = Uint8Array.from(key);
|
|
344
|
+
const decrypted = Uint8Array.from(key);
|
|
345
|
+
const send = vi
|
|
346
|
+
.spyOn(kms, 'send')
|
|
347
|
+
.mockImplementationOnce(async () => ({
|
|
348
|
+
Plaintext: generated,
|
|
349
|
+
CiphertextBlob: Buffer.from('wrapped'),
|
|
350
|
+
KeyId: arn,
|
|
351
|
+
}))
|
|
352
|
+
.mockImplementationOnce(async () => ({ Plaintext: decrypted, KeyId: arn }));
|
|
353
|
+
const encryption = createSeedEncryption({ ...local, kmsKeyArn: arn }, kms);
|
|
354
|
+
const envelope = await encryption.encrypt(seed, identity);
|
|
355
|
+
expect(envelope.keyVersion).toBe('kms-v1');
|
|
356
|
+
expect(generated.every(byte => byte === 0)).toBe(true);
|
|
357
|
+
expect(await encryption.decrypt(envelope, identity)).toBe(seed);
|
|
358
|
+
expect(decrypted.every(byte => byte === 0)).toBe(true);
|
|
359
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
360
|
+
const generate = send.mock.calls[0]![0] as GenerateDataKeyCommand;
|
|
361
|
+
const decrypt = send.mock.calls[1]![0] as DecryptCommand;
|
|
362
|
+
expect(generate).toBeInstanceOf(GenerateDataKeyCommand);
|
|
363
|
+
expect(decrypt).toBeInstanceOf(DecryptCommand);
|
|
364
|
+
expect(generate.input).toMatchObject({ KeyId: arn, KeySpec: 'AES_256' });
|
|
365
|
+
expect(decrypt.input.KeyId).toBe(arn);
|
|
366
|
+
expect(decrypt.input.EncryptionContext).toEqual(generate.input.EncryptionContext);
|
|
367
|
+
expect(JSON.stringify(generate.input.EncryptionContext)).not.toContain(seed);
|
|
368
|
+
expect(JSON.stringify(generate.input.EncryptionContext)).not.toContain(identity.ownerDid);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it.each(['AccessDeniedException', 'TimeoutError', 'InvalidCiphertextException'])(
|
|
372
|
+
'fails closed on %s and logs no SDK payload',
|
|
373
|
+
async name => {
|
|
374
|
+
const kms = new KMSClient({ region: 'us-east-1' });
|
|
375
|
+
const send = vi.spyOn(kms, 'send').mockRejectedValue({
|
|
376
|
+
name,
|
|
377
|
+
message: seed,
|
|
378
|
+
Plaintext: seed,
|
|
379
|
+
$metadata: { requestId: 'request-1' },
|
|
380
|
+
});
|
|
381
|
+
const encryption = createSeedEncryption({ ...local, kmsKeyArn: arn }, kms);
|
|
382
|
+
const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
383
|
+
const error = await encryption.encrypt(seed, identity).catch(value => value);
|
|
384
|
+
expect(error).toBeInstanceOf(SeedEncryptionError);
|
|
385
|
+
logSeedEncryptionFailure(error, 'create', identity);
|
|
386
|
+
expect(JSON.stringify(logger.mock.calls)).not.toContain(seed);
|
|
387
|
+
expect(logger).toHaveBeenCalledWith(
|
|
388
|
+
expect.objectContaining({ awsRequestId: 'request-1' })
|
|
389
|
+
);
|
|
390
|
+
await expect(
|
|
391
|
+
encryption.decrypt(
|
|
392
|
+
{
|
|
393
|
+
encryptedSeed: Buffer.alloc(92).toString('base64'),
|
|
394
|
+
encryptedDek: Buffer.from('wrapped').toString('base64'),
|
|
395
|
+
keyVersion: 'kms-v1',
|
|
396
|
+
},
|
|
397
|
+
identity
|
|
398
|
+
)
|
|
399
|
+
).rejects.toBeInstanceOf(SeedEncryptionError);
|
|
400
|
+
expect(send).toHaveBeenCalledTimes(2);
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
it('rejects malformed KMS responses and wipes even invalid key lengths', async () => {
|
|
405
|
+
const kms = new KMSClient({ region: 'us-east-1' });
|
|
406
|
+
const badPlaintext = Uint8Array.from([1, 2, 3]);
|
|
407
|
+
vi.spyOn(kms, 'send').mockImplementation(async () => ({
|
|
408
|
+
Plaintext: badPlaintext,
|
|
409
|
+
KeyId: arn,
|
|
410
|
+
}));
|
|
411
|
+
const encryption = createSeedEncryption({ kmsKeyArn: arn, allowLocal: false }, kms);
|
|
412
|
+
await expect(encryption.encrypt(seed, identity)).rejects.toMatchObject({
|
|
413
|
+
category: 'kms_failed',
|
|
414
|
+
});
|
|
415
|
+
expect([...badPlaintext]).toEqual([0, 0, 0]);
|
|
416
|
+
await expect(
|
|
417
|
+
encryption.decrypt(
|
|
418
|
+
{
|
|
419
|
+
encryptedSeed: Buffer.alloc(92).toString('base64'),
|
|
420
|
+
encryptedDek: Buffer.from('wrapped').toString('base64'),
|
|
421
|
+
keyVersion: 'kms-v1',
|
|
422
|
+
},
|
|
423
|
+
identity
|
|
424
|
+
)
|
|
425
|
+
).rejects.toMatchObject({ category: 'kms_failed' });
|
|
426
|
+
});
|
|
427
|
+
});
|