@learncard/lca-api-service 1.3.2 → 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.
Files changed (97) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +41 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/index.d.ts +6 -0
  25. package/dist/routes/keys.d.ts +181 -7
  26. package/dist/routes/qr-login.d.ts +1 -1
  27. package/dist/services/escrow-enclave/index.d.ts +9 -0
  28. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  29. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  31. package/dist/services/escrow-enclave/types.d.ts +67 -0
  32. package/dist/types/seed-encryption.d.ts +31 -0
  33. package/dist/types/seed-migration.d.ts +49 -0
  34. package/lambda.ts +8 -3
  35. package/package.json +17 -11
  36. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  37. package/scripts/check-signing-authority-redis.mjs +93 -0
  38. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  39. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  40. package/seedMigrationLambda.test.ts +145 -0
  41. package/seedMigrationLambda.ts +50 -0
  42. package/serverless-local.yml +3 -0
  43. package/serverless.escrow-env.cjs +20 -0
  44. package/serverless.yml +133 -0
  45. package/src/accesslayer/signing-authority/create.ts +11 -7
  46. package/src/app.ts +2 -0
  47. package/src/cache/challenges.ts +12 -2
  48. package/src/cache/in-memory-lru.ts +1 -0
  49. package/src/cache/recoverySessions.ts +252 -0
  50. package/src/config/environment.test.ts +116 -0
  51. package/src/config/environment.ts +97 -0
  52. package/src/docker-entry.ts +4 -0
  53. package/src/helpers/learnCard.helpers.test.ts +11 -1
  54. package/src/helpers/learnCard.helpers.ts +57 -45
  55. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  56. package/src/helpers/rateLimit.helpers.ts +112 -0
  57. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  58. package/src/helpers/seedEncryption.helpers.ts +372 -0
  59. package/src/helpers/signingAuthority.helpers.ts +4 -1
  60. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  61. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  62. package/src/models/EscrowHold.ts +225 -0
  63. package/src/models/SigningAuthority.ts +14 -3
  64. package/src/models/UserKey.escrowPin.test.ts +49 -0
  65. package/src/models/UserKey.ts +1024 -40
  66. package/src/models/index.ts +1 -0
  67. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  68. package/src/routes/credentials.ts +8 -0
  69. package/src/routes/escrow.ts +826 -0
  70. package/src/routes/firebase.ts +90 -48
  71. package/src/routes/index.ts +98 -5
  72. package/src/routes/keys.ts +1017 -174
  73. package/src/routes/qr-login.ts +51 -53
  74. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  75. package/src/services/escrow-enclave/README.md +51 -0
  76. package/src/services/escrow-enclave/index.ts +36 -0
  77. package/src/services/escrow-enclave/notifications.ts +14 -0
  78. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  79. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  80. package/src/services/escrow-enclave/types.ts +76 -0
  81. package/src/types/seed-encryption.ts +51 -0
  82. package/src/types/seed-migration.ts +61 -0
  83. package/test/credentials.spec.ts +2 -0
  84. package/test/keys-escrow.spec.ts +1389 -0
  85. package/test/keys-identity-rebind.spec.ts +456 -0
  86. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  87. package/test/keys-recovery-confirmation.spec.ts +586 -0
  88. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  89. package/test/keys-secret-leakage.spec.ts +265 -0
  90. package/test/models/escrow.spec.ts +210 -0
  91. package/test/signing-authority-http.spec.ts +161 -0
  92. package/test/signing-authority-seed-migration.spec.ts +414 -0
  93. package/test/signing-authority.spec.ts +40 -0
  94. package/vitest.config.ts +1 -1
  95. package/vitest.integration.config.ts +5 -0
  96. package/dist/cache/signing-authority.d.ts +0 -6
  97. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,161 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { APIGatewayProxyEventV2, Context as LambdaContext } from 'aws-lambda';
3
+ import { client, mongodb } from '@mongo';
4
+ import { SigningAuthorities } from '@accesslayer/signing-authority';
5
+ import {
6
+ decryptSigningAuthoritySeed,
7
+ seedEncryption,
8
+ SeedEncryptionError,
9
+ } from '@helpers/seedEncryption.helpers';
10
+ import { createOpenApiAwsLambdaHandler } from '@helpers/shim';
11
+ import { appRouter } from '../src/app';
12
+ import { getUser } from './helpers/getClient';
13
+ import {
14
+ runSeedMigrationBatch,
15
+ SEED_MIGRATION_RECEIPTS_COLLECTION,
16
+ SEED_MIGRATION_STATE_COLLECTION,
17
+ } from '../src/migrations/signingAuthoritySeeds';
18
+
19
+ let user: Awaited<ReturnType<typeof getUser>>;
20
+ const handler = createOpenApiAwsLambdaHandler({
21
+ router: appRouter,
22
+ createContext: async () => ({
23
+ domain: 'localhost:3000',
24
+ tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' as const },
25
+ user: { did: user.learnCard.id.did(), isChallengeValid: true, authorizedDid: true },
26
+ }),
27
+ });
28
+ const request = async (path: string, body: unknown) =>
29
+ handler(
30
+ {
31
+ version: '2.0',
32
+ routeKey: 'ANY /api/{trpc+}',
33
+ rawPath: `/api${path}`,
34
+ rawQueryString: '',
35
+ headers: { 'content-type': 'application/json' },
36
+ pathParameters: { trpc: path.slice(1) },
37
+ requestContext: {
38
+ accountId: 'test',
39
+ apiId: 'test',
40
+ domainName: 'localhost',
41
+ domainPrefix: 'localhost',
42
+ http: {
43
+ method: 'POST',
44
+ path,
45
+ protocol: 'HTTP/1.1',
46
+ sourceIp: '127.0.0.1',
47
+ userAgent: 'test',
48
+ },
49
+ requestId: 'test-request',
50
+ routeKey: 'ANY /api/{trpc+}',
51
+ stage: 'test',
52
+ time: '',
53
+ timeEpoch: 0,
54
+ },
55
+ body: JSON.stringify(body),
56
+ isBase64Encoded: false,
57
+ } satisfies APIGatewayProxyEventV2,
58
+ {} as LambdaContext
59
+ );
60
+
61
+ beforeAll(async () => {
62
+ await client.connect();
63
+ user = await getUser();
64
+ });
65
+ afterAll(async () => {
66
+ await client.close();
67
+ });
68
+ beforeEach(async () => {
69
+ await SigningAuthorities.deleteMany({});
70
+ // Spec files share one database; another file may leave a migration lease or receipts behind.
71
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).deleteMany({});
72
+ await mongodb.collection(SEED_MIGRATION_RECEIPTS_COLLECTION).deleteMany({});
73
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
74
+ });
75
+ afterEach(() => vi.restoreAllMocks());
76
+
77
+ describe('encrypted signing-authority OpenAPI routes', () => {
78
+ it('keeps the same signing identity after migrating and purging a legacy seed', async () => {
79
+ const created = await request('/signing-authority/create', { name: 'migrated-http' });
80
+ const authority = JSON.parse(created.body);
81
+ const stored = await SigningAuthorities.findOne({ _id: authority._id });
82
+ const seed = await decryptSigningAuthoritySeed(stored!);
83
+ await SigningAuthorities.updateOne(
84
+ { _id: authority._id },
85
+ {
86
+ $set: { seed },
87
+ $unset: { encryptedSeed: '', encryptedDek: '', keyVersion: '' },
88
+ }
89
+ );
90
+ for (const phase of ['prepare', 'verify', 'purge'] as const) {
91
+ const result = await runSeedMigrationBatch(
92
+ mongodb,
93
+ { phase },
94
+ { encryptedWritesEnabled: true }
95
+ );
96
+ expect(result.done).toBe(true);
97
+ }
98
+ expect(await SigningAuthorities.findOne({ _id: authority._id })).not.toHaveProperty('seed');
99
+ const response = await request('/credentials/issue', {
100
+ credential: user.learnCard.invoke.newCredential(),
101
+ signingAuthority: authority,
102
+ });
103
+ expect(response.statusCode).toBe(200);
104
+ const signed = JSON.parse(response.body);
105
+ expect(signed.issuer).toBe(authority.did);
106
+ expect((await user.learnCard.invoke.verifyCredential(signed)).errors).toHaveLength(0);
107
+ });
108
+
109
+ it('creates ciphertext-only records and issues a verifiable credential over HTTP', async () => {
110
+ const created = await request('/signing-authority/create', { name: 'http' });
111
+ expect(created.statusCode).toBe(200);
112
+ const authority = JSON.parse(created.body);
113
+ expect(authority).not.toHaveProperty('seed');
114
+ expect(authority).not.toHaveProperty('encryptedSeed');
115
+ expect(await SigningAuthorities.findOne({ name: 'http' })).not.toHaveProperty('seed');
116
+ const response = await request('/credentials/issue', {
117
+ credential: user.learnCard.invoke.newCredential(),
118
+ signingAuthority: authority,
119
+ });
120
+ expect(response.statusCode).toBe(200);
121
+ const verification = await user.learnCard.invoke.verifyCredential(
122
+ JSON.parse(response.body)
123
+ );
124
+ expect(verification.errors).toHaveLength(0);
125
+ });
126
+
127
+ it.each(['kms_access_denied', 'kms_unavailable', 'authentication_failed'] as const)(
128
+ 'returns a generic 500 and structured logs on %s',
129
+ async category => {
130
+ const created = await request('/signing-authority/create', { name: category });
131
+ const authority = JSON.parse(created.body);
132
+ vi.spyOn(seedEncryption, 'decrypt').mockRejectedValueOnce(
133
+ new SeedEncryptionError(category, 'request-1')
134
+ );
135
+ const response = await request('/credentials/issue', {
136
+ credential: user.learnCard.invoke.newCredential(),
137
+ signingAuthority: authority,
138
+ });
139
+ expect(response.statusCode).toBe(500);
140
+ expect(response.body).toContain('Signing authority key is unavailable');
141
+ expect(response.body).not.toContain(category);
142
+ expect(response.body).not.toContain('request-1');
143
+ expect(console.error).toHaveBeenCalledWith(
144
+ expect.objectContaining({
145
+ event: 'signing_authority_seed_failure',
146
+ operation: 'decrypt',
147
+ category,
148
+ })
149
+ );
150
+ }
151
+ );
152
+
153
+ it('does not insert a record when encryption fails during creation', async () => {
154
+ vi.spyOn(seedEncryption, 'encrypt').mockRejectedValueOnce(
155
+ new SeedEncryptionError('kms_unavailable')
156
+ );
157
+ const response = await request('/signing-authority/create', { name: 'failed-create' });
158
+ expect(response.statusCode).toBe(500);
159
+ expect(await SigningAuthorities.countDocuments()).toBe(0);
160
+ });
161
+ });
@@ -0,0 +1,414 @@
1
+ import { Collection, ObjectId } from 'mongodb';
2
+ import { GenerateDataKeyCommand, KMSClient } from '@aws-sdk/client-kms';
3
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { client, mongodb } from '@mongo';
5
+ import { SIGNING_AUTHORITIES_COLLECTION } from '@models';
6
+ import {
7
+ createSeedEncryption,
8
+ seedEncryption,
9
+ getSeedIdentity,
10
+ SeedEncryptionError,
11
+ } from '@helpers/seedEncryption.helpers';
12
+ import {
13
+ countSeedMigrationDocuments,
14
+ runSeedMigrationBatch,
15
+ SEED_MIGRATION_STATE_COLLECTION,
16
+ SEED_MIGRATION_RECEIPTS_COLLECTION,
17
+ } from '../src/migrations/signingAuthoritySeeds';
18
+ import type { MigrationSigningAuthority, SeedMigrationPhase } from '../src/types/seed-migration';
19
+
20
+ const authorities = mongodb.collection<MigrationSigningAuthority>(SIGNING_AUTHORITIES_COLLECTION);
21
+ const seed = 'a'.repeat(64);
22
+ const legacy = (id: string | ObjectId = 'legacy'): MigrationSigningAuthority => ({
23
+ _id: id,
24
+ ownerDid: 'did:example:owner',
25
+ name: String(id),
26
+ did: 'did:key:example',
27
+ seed,
28
+ });
29
+ const batch = (phase: SeedMigrationPhase) =>
30
+ runSeedMigrationBatch(
31
+ mongodb,
32
+ { phase, batchSize: 1 },
33
+ {
34
+ encryptedWritesEnabled: true,
35
+ }
36
+ );
37
+ const finish = async (phase: SeedMigrationPhase): Promise<void> => {
38
+ for (let attempts = 0; attempts < 20; attempts += 1) {
39
+ if ((await batch(phase)).done) return;
40
+ }
41
+ throw new Error('Migration did not finish');
42
+ };
43
+ const insertEncrypted = async (id: string): Promise<void> => {
44
+ const { seed: original, ...identity } = legacy(id);
45
+ await authorities.insertOne({
46
+ ...identity,
47
+ ...(await seedEncryption.encrypt(original!, getSeedIdentity(identity))),
48
+ });
49
+ };
50
+
51
+ beforeAll(async () => {
52
+ await client.connect();
53
+ });
54
+ afterAll(async () => {
55
+ await client.close();
56
+ });
57
+ beforeEach(async () => {
58
+ await authorities.deleteMany({});
59
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).deleteMany({});
60
+ await mongodb.collection(SEED_MIGRATION_RECEIPTS_COLLECTION).deleteMany({});
61
+ vi.spyOn(console, 'info').mockImplementation(() => undefined);
62
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
63
+ });
64
+ afterEach(() => vi.restoreAllMocks());
65
+
66
+ describe('signing-authority seed migration', () => {
67
+ it('verifies and purges mixed provider records after offline KMS opt-in', async () => {
68
+ const localConfig = { allowLocal: true, localKek: 'f'.repeat(64) };
69
+ const localRecord = legacy('local');
70
+ await authorities.insertOne({
71
+ ...localRecord,
72
+ ...(await createSeedEncryption(localConfig).encrypt(
73
+ seed,
74
+ getSeedIdentity(localRecord)
75
+ )),
76
+ });
77
+ await authorities.insertOne(legacy());
78
+
79
+ const kmsKeyArn = 'arn:aws:kms:us-east-1:123456789012:key/test';
80
+ const kms = new KMSClient({ region: 'us-east-1' });
81
+ vi.spyOn(kms, 'send').mockImplementation(async command => ({
82
+ Plaintext: new Uint8Array(32).fill(1),
83
+ KeyId: kmsKeyArn,
84
+ ...(command instanceof GenerateDataKeyCommand
85
+ ? { CiphertextBlob: Buffer.from('wrapped') }
86
+ : {}),
87
+ }));
88
+ const dualProvider = createSeedEncryption({ ...localConfig, kmsKeyArn }, kms);
89
+ vi.spyOn(seedEncryption, 'encrypt').mockImplementation(dualProvider.encrypt);
90
+ vi.spyOn(seedEncryption, 'decrypt').mockImplementation(dualProvider.decrypt);
91
+ await insertEncrypted('new-kms');
92
+
93
+ await finish('prepare');
94
+ await finish('verify');
95
+ await finish('purge');
96
+
97
+ expect(await countSeedMigrationDocuments(mongodb)).toEqual({
98
+ total: 3,
99
+ encrypted: 3,
100
+ legacyOnly: 0,
101
+ malformed: 0,
102
+ plaintextRemaining: 0,
103
+ });
104
+ // Prepare migrates plaintext; it does not rotate already-encrypted records.
105
+ expect((await authorities.findOne({ _id: 'local' }))?.keyVersion).toBe('local-v1');
106
+ expect((await authorities.findOne({ _id: 'legacy' }))?.keyVersion).toBe('kms-v1');
107
+ expect((await authorities.findOne({ _id: 'new-kms' }))?.keyVersion).toBe('kms-v1');
108
+ });
109
+
110
+ it('dry-runs without writes and rejects migration while plaintext writers are enabled', async () => {
111
+ await authorities.insertOne(legacy());
112
+ expect(await batch('dry-run')).toMatchObject({
113
+ done: true,
114
+ counts: { total: 1, legacyOnly: 1 },
115
+ });
116
+ expect(await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).countDocuments()).toBe(0);
117
+ await expect(
118
+ runSeedMigrationBatch(mongodb, { phase: 'prepare' }, { encryptedWritesEnabled: false })
119
+ ).rejects.toMatchObject({ category: 'encrypted_writes_required' });
120
+ expect(await authorities.findOne({ _id: 'legacy' })).toEqual(legacy());
121
+ });
122
+
123
+ it('migrates mixed rows idempotently, preserves BSON IDs/metadata, and removes plaintext only after verification', async () => {
124
+ const id = new ObjectId();
125
+ const missingDid = legacy(id);
126
+ delete missingDid.did;
127
+ await authorities.insertMany([
128
+ legacy(),
129
+ { ...missingDid, endpoint: 'https://example.com' },
130
+ ]);
131
+ await insertEncrypted('new');
132
+ await finish('prepare');
133
+ const prepared = await authorities.find().toArray();
134
+ expect(await countSeedMigrationDocuments(mongodb)).toEqual({
135
+ total: 3,
136
+ encrypted: 3,
137
+ legacyOnly: 0,
138
+ malformed: 0,
139
+ plaintextRemaining: 2,
140
+ });
141
+ await finish('prepare');
142
+ expect(await authorities.find().toArray()).toEqual(prepared);
143
+ await expect(batch('purge')).rejects.toMatchObject({ category: 'verification_required' });
144
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(2);
145
+ await finish('verify');
146
+ const checkpoint = await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).findOne({});
147
+ expect(checkpoint?.counts.encrypted).toBe(checkpoint?.counts.total);
148
+ const receipts = await mongodb
149
+ .collection(SEED_MIGRATION_RECEIPTS_COLLECTION)
150
+ .find()
151
+ .toArray();
152
+ expect(JSON.stringify(receipts)).not.toContain(seed);
153
+ expect((await batch('purge')).done).toBe(false);
154
+ await finish('purge');
155
+ await finish('purge');
156
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(0);
157
+ expect(await authorities.findOne({ _id: id })).toMatchObject({
158
+ _id: id,
159
+ endpoint: 'https://example.com',
160
+ });
161
+ for (const row of await authorities.find().toArray()) {
162
+ expect(Object.hasOwn(row, 'seed')).toBe(false);
163
+ expect(row.keyVersion).toBe('local-v1');
164
+ expect(
165
+ await seedEncryption.decrypt(
166
+ {
167
+ encryptedSeed: row.encryptedSeed,
168
+ encryptedDek: row.encryptedDek,
169
+ keyVersion: row.keyVersion,
170
+ },
171
+ getSeedIdentity(row)
172
+ )
173
+ ).toBe(seed);
174
+ }
175
+ expect(JSON.stringify(vi.mocked(console.info).mock.calls)).not.toContain(seed);
176
+ });
177
+
178
+ it('recovers when a batch stops after persisting ciphertext but before readback verification', async () => {
179
+ await authorities.insertOne(legacy());
180
+ vi.spyOn(seedEncryption, 'decrypt').mockRejectedValueOnce(
181
+ new SeedEncryptionError('kms_unavailable')
182
+ );
183
+ await expect(batch('prepare')).rejects.toMatchObject({ category: 'kms_unavailable' });
184
+ expect(await authorities.findOne({ _id: 'legacy' })).toMatchObject({
185
+ seed,
186
+ keyVersion: 'local-v1',
187
+ });
188
+ await finish('prepare');
189
+ await finish('verify');
190
+ await finish('purge');
191
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(0);
192
+ });
193
+
194
+ it('blocks deletion when verification detects a seed mismatch or malformed envelope', async () => {
195
+ await authorities.insertOne(legacy());
196
+ await finish('prepare');
197
+ await authorities.updateOne({ _id: 'legacy' }, { $set: { seed: 'b'.repeat(64) } });
198
+ await expect(batch('verify')).rejects.toMatchObject({ category: 'seed_mismatch' });
199
+ await expect(batch('purge')).rejects.toMatchObject({ category: 'verification_required' });
200
+ await authorities.updateOne({ _id: 'legacy' }, { $set: { keyVersion: 'unknown' } });
201
+ await expect(batch('verify')).rejects.toMatchObject({ category: 'malformed_records' });
202
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(1);
203
+ });
204
+
205
+ it('requires reverification after a verified envelope changes', async () => {
206
+ const original = legacy();
207
+ await authorities.insertOne(original);
208
+ await finish('prepare');
209
+ await finish('verify');
210
+ const replacement = await seedEncryption.encrypt(seed, getSeedIdentity(original));
211
+ await authorities.updateOne({ _id: original._id }, { $set: replacement });
212
+ await expect(batch('purge')).rejects.toMatchObject({ category: 'reverification_required' });
213
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(1);
214
+ await finish('verify');
215
+ await finish('purge');
216
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(0);
217
+ });
218
+
219
+ it('verifies concurrent encrypted inserts and resumes verification without skipping records', async () => {
220
+ await authorities.insertMany([legacy('one'), legacy('two')]);
221
+ await finish('prepare');
222
+ expect((await batch('verify')).done).toBe(false);
223
+ await insertEncrypted('inserted-before-cursor');
224
+ await finish('verify');
225
+ expect(await mongodb.collection(SEED_MIGRATION_RECEIPTS_COLLECTION).countDocuments()).toBe(
226
+ 3
227
+ );
228
+ await insertEncrypted('created-after-verification');
229
+ await finish('purge');
230
+ expect(await countSeedMigrationDocuments(mongodb)).toMatchObject({
231
+ total: 4,
232
+ encrypted: 4,
233
+ plaintextRemaining: 0,
234
+ });
235
+ });
236
+
237
+ it('does not unset a seed when the row changes between decryption and the conditional write', async () => {
238
+ await authorities.insertOne(legacy());
239
+ await finish('prepare');
240
+ await finish('verify');
241
+ const decrypt = seedEncryption.decrypt;
242
+ vi.spyOn(seedEncryption, 'decrypt').mockImplementationOnce(async (...args) => {
243
+ const result = await decrypt(...args);
244
+ await authorities.updateOne(
245
+ { _id: 'legacy' },
246
+ { $set: { name: 'changed-concurrently' } }
247
+ );
248
+ return result;
249
+ });
250
+ await expect(batch('purge')).rejects.toMatchObject({ category: 'document_changed' });
251
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(1);
252
+ expect(
253
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).findOne({})
254
+ ).not.toHaveProperty('verifiedEpoch');
255
+ });
256
+
257
+ it('excludes competing workers and recovers an expired lease', async () => {
258
+ await authorities.insertOne(legacy());
259
+ await batch('prepare');
260
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).updateMany(
261
+ {},
262
+ {
263
+ $set: { leaseOwner: 'other-worker', leaseExpiresAt: new Date(Date.now() + 60_000) },
264
+ }
265
+ );
266
+ await expect(batch('verify')).rejects.toMatchObject({ category: 'worker_busy' });
267
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).updateMany(
268
+ {},
269
+ {
270
+ $set: { leaseExpiresAt: new Date(0) },
271
+ }
272
+ );
273
+ await finish('verify');
274
+ await finish('purge');
275
+ });
276
+
277
+ it('reconciles only at scan boundaries and resumes across string and BSON IDs', async () => {
278
+ await authorities.insertMany([
279
+ legacy('a'),
280
+ legacy('b'),
281
+ legacy(new ObjectId()),
282
+ legacy(new ObjectId()),
283
+ ]);
284
+ const aggregate = vi.spyOn(Collection.prototype, 'aggregate');
285
+ for (const phase of ['prepare', 'verify', 'purge'] as const) {
286
+ aggregate.mockClear();
287
+ const first = await batch(phase);
288
+ expect(first).toMatchObject({ done: false, processed: 1, countsReconciled: false });
289
+ expect(
290
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).findOne({})
291
+ ).toHaveProperty('cursor', 'a');
292
+ let result = first;
293
+ while (!result.done) result = await batch(phase);
294
+ expect(result.countsReconciled).toBe(true);
295
+ const pipelines = aggregate.mock.calls.map(([pipeline]) => pipeline!);
296
+ expect(pipelines.filter(pipeline => pipeline[0]?.$group)).toHaveLength(2);
297
+ expect(pipelines.filter(pipeline => pipeline[0]?.$lookup)).toHaveLength(
298
+ phase === 'verify' ? 1 : 0
299
+ );
300
+ }
301
+ expect(await countSeedMigrationDocuments(mongodb)).toMatchObject({
302
+ total: 4,
303
+ encrypted: 4,
304
+ plaintextRemaining: 0,
305
+ });
306
+ });
307
+
308
+ it('rechecks inserts and modified envelopes behind the verification cursor', async () => {
309
+ await authorities.insertMany([legacy('b'), legacy('c')]);
310
+ await finish('prepare');
311
+ await batch('verify');
312
+ await insertEncrypted('a');
313
+ const changed = { ...legacy('b'), name: 'renamed' };
314
+ await authorities.updateOne(
315
+ { _id: 'b' },
316
+ {
317
+ $set: {
318
+ name: changed.name,
319
+ ...(await seedEncryption.encrypt(seed, getSeedIdentity(changed))),
320
+ },
321
+ }
322
+ );
323
+ await batch('verify');
324
+ expect(await batch('verify')).toMatchObject({
325
+ done: false,
326
+ processed: 0,
327
+ countsReconciled: true,
328
+ rescanRequired: true,
329
+ });
330
+ expect(
331
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).findOne({})
332
+ ).not.toHaveProperty('verifiedEpoch');
333
+ await finish('verify');
334
+ expect(
335
+ await mongodb
336
+ .collection(SEED_MIGRATION_RECEIPTS_COLLECTION)
337
+ .findOne({ _id: 'b' } as never)
338
+ ).toHaveProperty('name', 'renamed');
339
+ await finish('purge');
340
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(0);
341
+ });
342
+
343
+ it('resumes after yielding to the invocation time budget', async () => {
344
+ await authorities.insertMany([legacy('a'), legacy('b')]);
345
+ const remainingTime = vi.fn().mockReturnValueOnce(60_000).mockReturnValue(10_000);
346
+ expect(
347
+ await runSeedMigrationBatch(
348
+ mongodb,
349
+ { phase: 'prepare', batchSize: 10 },
350
+ { encryptedWritesEnabled: true, remainingTime }
351
+ )
352
+ ).toMatchObject({ done: false, processed: 1, countsReconciled: false });
353
+ expect(
354
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).findOne({})
355
+ ).toHaveProperty('cursor', 'a');
356
+ await finish('prepare');
357
+ await finish('verify');
358
+ await finish('purge');
359
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(0);
360
+ });
361
+
362
+ it.each(['checkpoint', 'lease', 'both'])(
363
+ 'preserves the original failure when %s cleanup fails',
364
+ async failing => {
365
+ await authorities.insertOne(legacy());
366
+ await finish('prepare');
367
+ await finish('verify');
368
+ vi.spyOn(seedEncryption, 'decrypt').mockRejectedValueOnce(
369
+ new SeedEncryptionError('kms_unavailable', 'request-123')
370
+ );
371
+ const updateOne = Collection.prototype.updateOne;
372
+ vi.spyOn(Collection.prototype, 'updateOne').mockImplementation(function (...args) {
373
+ const update = args[1];
374
+ if (
375
+ this.collectionName === SEED_MIGRATION_STATE_COLLECTION &&
376
+ ((failing !== 'lease' && update.$set?.status === 'failed') ||
377
+ (failing !== 'checkpoint' && update.$unset?.leaseOwner !== undefined))
378
+ ) {
379
+ throw new Error(seed);
380
+ }
381
+ return updateOne.apply(this, args);
382
+ });
383
+ await expect(batch('purge')).rejects.toMatchObject({ category: 'kms_unavailable' });
384
+ const state = await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).findOne({});
385
+ expect(state).not.toHaveProperty('verifiedEpoch');
386
+ expect(await authorities.countDocuments({ seed: { $exists: true } })).toBe(1);
387
+ expect(console.error).toHaveBeenCalledWith(
388
+ expect.objectContaining({
389
+ category: 'kms_unavailable',
390
+ awsRequestId: 'request-123',
391
+ })
392
+ );
393
+ expect(JSON.stringify(vi.mocked(console.error).mock.calls)).not.toContain(seed);
394
+ }
395
+ );
396
+
397
+ it('logs a failed lease release without replacing a successful batch result', async () => {
398
+ await authorities.insertOne(legacy());
399
+ const updateOne = Collection.prototype.updateOne;
400
+ vi.spyOn(Collection.prototype, 'updateOne').mockImplementation(function (...args) {
401
+ if (
402
+ this.collectionName === SEED_MIGRATION_STATE_COLLECTION &&
403
+ args[1].$unset?.leaseOwner !== undefined
404
+ )
405
+ throw new Error(seed);
406
+ return updateOne.apply(this, args);
407
+ });
408
+ await expect(batch('prepare')).resolves.toMatchObject({ processed: 1 });
409
+ expect(console.error).toHaveBeenCalledWith(
410
+ expect.objectContaining({ operation: 'migration_lease_release' })
411
+ );
412
+ await expect(batch('prepare')).rejects.toMatchObject({ category: 'worker_busy' });
413
+ });
414
+ });
@@ -2,6 +2,7 @@ import { vi } from 'vitest';
2
2
 
3
3
  import { getClient, getUser } from './helpers/getClient';
4
4
  import { SigningAuthorities } from '@accesslayer/signing-authority';
5
+ import { createSigningAuthorityForDID } from '@accesslayer/signing-authority/create';
5
6
 
6
7
  /**
7
8
  * More info on Signing Authorities:
@@ -63,6 +64,16 @@ describe('Signing Authority', () => {
63
64
  did: expect.stringMatching(/^did:/),
64
65
  endpoint: expect.stringContaining('/api'),
65
66
  });
67
+ const stored = await SigningAuthorities.findOne({ _id: signingAuthority._id });
68
+ expect(stored).not.toHaveProperty('seed');
69
+ expect(stored).toMatchObject({
70
+ encryptedSeed: expect.any(String),
71
+ encryptedDek: expect.any(String),
72
+ keyVersion: 'local-v1',
73
+ });
74
+ for (const field of ['seed', 'encryptedSeed', 'encryptedDek', 'keyVersion']) {
75
+ expect(signingAuthority).not.toHaveProperty(field);
76
+ }
66
77
  });
67
78
 
68
79
  it('should prevent creating a signing authority with the same name', async () => {
@@ -75,9 +86,38 @@ describe('Signing Authority', () => {
75
86
  ).rejects.toThrow();
76
87
  });
77
88
 
89
+ it('logs the Mongo code for a duplicate insert without exposing keys or duplicate values', async () => {
90
+ await SigningAuthorities.createIndex({ ownerDid: 1, name: 1 }, { unique: true });
91
+ const logger = vi.spyOn(console, 'error').mockImplementation(() => undefined);
92
+ const name = 'private-duplicate-name';
93
+ const owner = userA.learnCard.id.did();
94
+ try {
95
+ expect(await createSigningAuthorityForDID(owner, name)).toEqual(expect.any(String));
96
+ expect(await createSigningAuthorityForDID(owner, name)).toBe(false);
97
+ expect(logger).toHaveBeenCalledWith(
98
+ expect.objectContaining({
99
+ operation: 'create',
100
+ errorName: 'MongoServerError',
101
+ mongoCode: 11000,
102
+ })
103
+ );
104
+ const logs = JSON.stringify(logger.mock.calls);
105
+ expect(logs).not.toContain('e'.repeat(64));
106
+ expect(logs).not.toContain(name);
107
+ expect(logs).not.toContain(owner);
108
+ } finally {
109
+ logger.mockRestore();
110
+ }
111
+ });
112
+
78
113
  it('should allow you to retrieve your signing authorities after creation', async () => {
79
114
  await userA.clients.fullAuth.signingAuthority.createSigningAuthority({ name: 'mysa' });
80
115
  const sas = await userA.clients.fullAuth.signingAuthority.signingAuthorities();
116
+ for (const authority of sas) {
117
+ for (const field of ['seed', 'encryptedSeed', 'encryptedDek', 'keyVersion']) {
118
+ expect(authority).not.toHaveProperty(field);
119
+ }
120
+ }
81
121
  if (sas && sas[0]) {
82
122
  expect(sas[0].name).toBe('mysa');
83
123
  expect(sas[0].ownerDid).toBe(userA.learnCard.id.did());
package/vitest.config.ts CHANGED
@@ -2,6 +2,6 @@ import { createVitestConfig, nodePreset } from '../../../vitest.shared';
2
2
 
3
3
  export default createVitestConfig(nodePreset, {
4
4
  test: {
5
- include: ['src/**/*.test.ts'],
5
+ include: ['src/**/*.test.ts', '*Lambda.test.ts'],
6
6
  },
7
7
  });
@@ -8,6 +8,11 @@ export default createVitestConfig(serviceIntegrationPreset, {
8
8
  test: {
9
9
  globalSetup: './vitest-setup.ts',
10
10
  include: ['test/**/*.spec.ts'],
11
+ env: {
12
+ IS_E2E_TEST: 'true',
13
+ ESCROW_RELAY_URL: 'https://escrow-relay.example',
14
+ ESCROW_RELAY_AUTH_TOKEN: 'relay-auth-token',
15
+ },
11
16
  alias: { '@mongo': require.resolve('./test/helpers/mock-mongo.ts') },
12
17
  },
13
18
  });
@@ -1,6 +0,0 @@
1
- import { SigningAuthorityMeta } from 'types/signing-authority';
2
- export declare const getSigningAuthorityKey: (did: string, name: string) => string;
3
- export declare const getSigningAuthorityForDid: (did: string, name: string) => Promise<string | null | undefined>;
4
- export declare const getSigningAuthoritiesForDid: (did: string) => Promise<SigningAuthorityMeta[]>;
5
- export declare const addSigningAuthorityForDid: (did: string, name: string, seed: string) => Promise<"OK" | undefined>;
6
- export declare const deleteSigningAuthorityForDid: (did: string, name: string) => Promise<number | undefined>;