@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,430 @@
|
|
|
1
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type { Db, Document, Filter } from 'mongodb';
|
|
3
|
+
|
|
4
|
+
import { SIGNING_AUTHORITIES_COLLECTION } from '@models';
|
|
5
|
+
import {
|
|
6
|
+
getSeedIdentity,
|
|
7
|
+
logSeedEncryptionFailure,
|
|
8
|
+
parseSeedEnvelope,
|
|
9
|
+
SeedEncryptionError,
|
|
10
|
+
seedEncryption,
|
|
11
|
+
} from '@helpers/seedEncryption.helpers';
|
|
12
|
+
import type { EncryptedSigningAuthoritySeed } from 'types/seed-encryption';
|
|
13
|
+
import type {
|
|
14
|
+
MigrationSigningAuthority,
|
|
15
|
+
SeedMigrationCounts,
|
|
16
|
+
SeedMigrationReceipt,
|
|
17
|
+
SeedMigrationRequest,
|
|
18
|
+
SeedMigrationResult,
|
|
19
|
+
SeedMigrationState,
|
|
20
|
+
} from 'types/seed-migration';
|
|
21
|
+
|
|
22
|
+
export const SEED_MIGRATION_STATE_COLLECTION = 'signingauthorityseedmigration';
|
|
23
|
+
export const SEED_MIGRATION_RECEIPTS_COLLECTION = 'signingauthorityseedverification';
|
|
24
|
+
const STATE_ID = 'sa-seeds-v1';
|
|
25
|
+
const LEASE_MS = 16 * 60 * 1000; // Longer than the Lambda's maximum lifetime.
|
|
26
|
+
const ENVELOPE_FIELDS = ['encryptedSeed', 'encryptedDek', 'keyVersion'] as const;
|
|
27
|
+
const RECEIPT_FIELDS = [...ENVELOPE_FIELDS, 'ownerDid', 'name', 'did'] as const;
|
|
28
|
+
|
|
29
|
+
export class SeedMigrationError extends Error {
|
|
30
|
+
constructor(public readonly category: string) {
|
|
31
|
+
super(`Signing authority seed migration stopped: ${category}`);
|
|
32
|
+
this.name = 'SeedMigrationError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hasField = (field: string): Document => ({ $ne: [{ $type: `$${field}` }, 'missing'] });
|
|
37
|
+
const isString = (field: string): Document => ({ $eq: [{ $type: `$${field}` }, 'string'] });
|
|
38
|
+
|
|
39
|
+
/** One aggregation reconciles the collection; cryptographic validity is checked in verify/purge. */
|
|
40
|
+
export const countSeedMigrationDocuments = async (db: Db): Promise<SeedMigrationCounts> => {
|
|
41
|
+
const encrypted = {
|
|
42
|
+
$and: [
|
|
43
|
+
{ $in: ['$keyVersion', ['kms-v1', 'local-v1']] },
|
|
44
|
+
isString('encryptedSeed'),
|
|
45
|
+
isString('encryptedDek'),
|
|
46
|
+
{ $ne: ['$encryptedSeed', ''] },
|
|
47
|
+
{ $ne: ['$encryptedDek', ''] },
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
const legacy = {
|
|
51
|
+
$and: [
|
|
52
|
+
...ENVELOPE_FIELDS.map(field => ({ $eq: [{ $type: `$${field}` }, 'missing'] })),
|
|
53
|
+
isString('seed'),
|
|
54
|
+
{ $ne: ['$seed', ''] },
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
const result = await db
|
|
58
|
+
.collection(SIGNING_AUTHORITIES_COLLECTION)
|
|
59
|
+
.aggregate<SeedMigrationCounts>([
|
|
60
|
+
{
|
|
61
|
+
$group: {
|
|
62
|
+
_id: null,
|
|
63
|
+
total: { $sum: 1 },
|
|
64
|
+
encrypted: { $sum: { $cond: [encrypted, 1, 0] } },
|
|
65
|
+
legacyOnly: { $sum: { $cond: [legacy, 1, 0] } },
|
|
66
|
+
malformed: { $sum: { $cond: [{ $or: [encrypted, legacy] }, 0, 1] } },
|
|
67
|
+
plaintextRemaining: { $sum: { $cond: [hasField('seed'), 1, 0] } },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{ $project: { _id: 0 } },
|
|
71
|
+
])
|
|
72
|
+
.next();
|
|
73
|
+
return result ?? { total: 0, encrypted: 0, legacyOnly: 0, malformed: 0, plaintextRemaining: 0 };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const snapshotFilter = (record: MigrationSigningAuthority): Filter<MigrationSigningAuthority> => {
|
|
77
|
+
const filter: Document = { _id: record._id };
|
|
78
|
+
for (const field of [...RECEIPT_FIELDS, 'seed']) {
|
|
79
|
+
filter[field] = Object.hasOwn(record, field) ? { $eq: record[field] } : { $exists: false };
|
|
80
|
+
}
|
|
81
|
+
return filter;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const sameSeed = (actual: string, expected: string): boolean => {
|
|
85
|
+
const left = Buffer.from(actual);
|
|
86
|
+
const right = Buffer.from(expected);
|
|
87
|
+
try {
|
|
88
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
89
|
+
} finally {
|
|
90
|
+
left.fill(0);
|
|
91
|
+
right.fill(0);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const verifyRecord = async (
|
|
96
|
+
record: MigrationSigningAuthority
|
|
97
|
+
): Promise<EncryptedSigningAuthoritySeed> => {
|
|
98
|
+
const envelope = parseSeedEnvelope(record);
|
|
99
|
+
if (!envelope) throw new SeedMigrationError('legacy_record_remaining');
|
|
100
|
+
const seed = await seedEncryption.decrypt(envelope, getSeedIdentity(record));
|
|
101
|
+
if (
|
|
102
|
+
Object.hasOwn(record, 'seed') &&
|
|
103
|
+
(typeof record.seed !== 'string' || !sameSeed(seed, record.seed))
|
|
104
|
+
) {
|
|
105
|
+
throw new SeedMigrationError('seed_mismatch');
|
|
106
|
+
}
|
|
107
|
+
return envelope;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const receiptFor = (
|
|
111
|
+
record: MigrationSigningAuthority,
|
|
112
|
+
envelope: EncryptedSigningAuthoritySeed,
|
|
113
|
+
epoch: string
|
|
114
|
+
): SeedMigrationReceipt => ({
|
|
115
|
+
_id: record._id,
|
|
116
|
+
epoch,
|
|
117
|
+
...envelope,
|
|
118
|
+
ownerDid: record.ownerDid,
|
|
119
|
+
name: record.name,
|
|
120
|
+
did: record.did ?? null,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Compare receipts with CURRENT documents. The final full sweep catches inserts and changes
|
|
124
|
+
// behind the batch cursor before granting permission to purge.
|
|
125
|
+
const pendingVerification = (epoch: string): Document[] => [
|
|
126
|
+
{
|
|
127
|
+
$lookup: {
|
|
128
|
+
from: SEED_MIGRATION_RECEIPTS_COLLECTION,
|
|
129
|
+
localField: '_id',
|
|
130
|
+
foreignField: '_id',
|
|
131
|
+
as: '_verification',
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
$match: {
|
|
136
|
+
$expr: {
|
|
137
|
+
$not: [
|
|
138
|
+
{
|
|
139
|
+
$and: [
|
|
140
|
+
{ $eq: [{ $arrayElemAt: ['$_verification.epoch', 0] }, epoch] },
|
|
141
|
+
...RECEIPT_FIELDS.map(field => ({
|
|
142
|
+
$eq: [
|
|
143
|
+
{
|
|
144
|
+
$ifNull: [
|
|
145
|
+
{ $arrayElemAt: [`$_verification.${field}`, 0] },
|
|
146
|
+
null,
|
|
147
|
+
],
|
|
148
|
+
},
|
|
149
|
+
{ $ifNull: [`$${field}`, null] },
|
|
150
|
+
],
|
|
151
|
+
})),
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{ $project: { _verification: 0 } },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
/** Mongo comparisons are type-bracketed; strings sort before historical ObjectId IDs. */
|
|
162
|
+
const afterCursor = (
|
|
163
|
+
cursor?: MigrationSigningAuthority['_id']
|
|
164
|
+
): Filter<MigrationSigningAuthority> => {
|
|
165
|
+
if (cursor === undefined) return {};
|
|
166
|
+
if (typeof cursor === 'string') {
|
|
167
|
+
return { $or: [{ _id: { $gt: cursor } }, { _id: { $type: 'objectId' } }] };
|
|
168
|
+
}
|
|
169
|
+
return { _id: { $gt: cursor } };
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Process one idempotent batch. The caller repeats until done. No seed is returned or checkpointed.
|
|
174
|
+
* All writes use majority acknowledgement; purge requires a completed verification epoch.
|
|
175
|
+
*/
|
|
176
|
+
export const runSeedMigrationBatch = async (
|
|
177
|
+
db: Db,
|
|
178
|
+
request: SeedMigrationRequest,
|
|
179
|
+
options: { encryptedWritesEnabled: boolean; remainingTime?: () => number }
|
|
180
|
+
): Promise<SeedMigrationResult> => {
|
|
181
|
+
if (
|
|
182
|
+
!['dry-run', 'prepare', 'verify', 'purge'].includes(request.phase) ||
|
|
183
|
+
(request.batchSize !== undefined &&
|
|
184
|
+
(!Number.isInteger(request.batchSize) ||
|
|
185
|
+
request.batchSize < 1 ||
|
|
186
|
+
request.batchSize > 100))
|
|
187
|
+
) {
|
|
188
|
+
throw new SeedMigrationError('invalid_request');
|
|
189
|
+
}
|
|
190
|
+
const phase = request.phase;
|
|
191
|
+
const batchSize = request.batchSize ?? 50;
|
|
192
|
+
if (phase === 'dry-run') {
|
|
193
|
+
const result = {
|
|
194
|
+
phase,
|
|
195
|
+
done: true,
|
|
196
|
+
processed: 0,
|
|
197
|
+
counts: await countSeedMigrationDocuments(db),
|
|
198
|
+
countsReconciled: true,
|
|
199
|
+
};
|
|
200
|
+
console.info({ event: 'signing_authority_seed_migration', ...result });
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
if (!options.encryptedWritesEnabled) throw new SeedMigrationError('encrypted_writes_required');
|
|
204
|
+
|
|
205
|
+
const states = db.collection<SeedMigrationState>(SEED_MIGRATION_STATE_COLLECTION);
|
|
206
|
+
const receipts = db.collection<SeedMigrationReceipt>(SEED_MIGRATION_RECEIPTS_COLLECTION);
|
|
207
|
+
const authorities = db.collection<MigrationSigningAuthority>(SIGNING_AUTHORITIES_COLLECTION);
|
|
208
|
+
const writeConcern = { w: 'majority' as const };
|
|
209
|
+
// Initialize separately so a held lease never makes an upsert overwrite/duplicate the singleton.
|
|
210
|
+
try {
|
|
211
|
+
await states.updateOne(
|
|
212
|
+
{ _id: STATE_ID },
|
|
213
|
+
{ $setOnInsert: { _id: STATE_ID } },
|
|
214
|
+
{ upsert: true, writeConcern }
|
|
215
|
+
);
|
|
216
|
+
} catch {
|
|
217
|
+
throw new SeedMigrationError('checkpoint_unavailable');
|
|
218
|
+
}
|
|
219
|
+
const token = randomUUID();
|
|
220
|
+
const state = await states.findOneAndUpdate(
|
|
221
|
+
{
|
|
222
|
+
_id: STATE_ID,
|
|
223
|
+
$or: [{ leaseOwner: { $exists: false } }, { leaseExpiresAt: { $lte: new Date() } }],
|
|
224
|
+
},
|
|
225
|
+
{ $set: { leaseOwner: token, leaseExpiresAt: new Date(Date.now() + LEASE_MS) } },
|
|
226
|
+
{
|
|
227
|
+
returnDocument: 'after',
|
|
228
|
+
writeConcern,
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
if (!state) throw new SeedMigrationError('worker_busy');
|
|
232
|
+
const lease = { _id: STATE_ID, leaseOwner: token };
|
|
233
|
+
let current: MigrationSigningAuthority | undefined;
|
|
234
|
+
let processed = 0;
|
|
235
|
+
try {
|
|
236
|
+
const resuming = state.phase === phase && state.status === 'running' && !!state.counts;
|
|
237
|
+
const countsBefore = resuming ? state.counts! : await countSeedMigrationDocuments(db);
|
|
238
|
+
if (countsBefore.malformed) throw new SeedMigrationError('malformed_records');
|
|
239
|
+
if (
|
|
240
|
+
phase === 'purge' &&
|
|
241
|
+
(!state.verifiedEpoch || countsBefore.encrypted !== countsBefore.total)
|
|
242
|
+
) {
|
|
243
|
+
throw new SeedMigrationError('verification_required');
|
|
244
|
+
}
|
|
245
|
+
const epoch =
|
|
246
|
+
phase === 'verify'
|
|
247
|
+
? ((state.phase === 'verify' && state.status !== 'failed'
|
|
248
|
+
? state.epoch
|
|
249
|
+
: undefined) ?? randomUUID())
|
|
250
|
+
: (state.verifiedEpoch ?? '');
|
|
251
|
+
const checkpoint: Partial<SeedMigrationState> = {
|
|
252
|
+
phase,
|
|
253
|
+
status: 'running',
|
|
254
|
+
counts: countsBefore,
|
|
255
|
+
updatedAt: new Date(),
|
|
256
|
+
};
|
|
257
|
+
let cursor = resuming ? state.cursor : undefined;
|
|
258
|
+
if (phase === 'verify') checkpoint.epoch = epoch;
|
|
259
|
+
await states.updateOne(
|
|
260
|
+
lease,
|
|
261
|
+
{
|
|
262
|
+
$set: checkpoint,
|
|
263
|
+
// A killed worker or failed checkpoint write must not leave purge authorized.
|
|
264
|
+
// A successful purge batch restores its epoch so the next batch can resume.
|
|
265
|
+
$unset: { verifiedEpoch: '', ...(!resuming ? { cursor: '' } : {}) },
|
|
266
|
+
},
|
|
267
|
+
{ writeConcern }
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const records =
|
|
271
|
+
phase === 'verify'
|
|
272
|
+
? await authorities
|
|
273
|
+
.aggregate<MigrationSigningAuthority>([
|
|
274
|
+
{ $match: afterCursor(cursor) },
|
|
275
|
+
{ $sort: { _id: 1 } },
|
|
276
|
+
...pendingVerification(epoch),
|
|
277
|
+
{ $limit: batchSize },
|
|
278
|
+
])
|
|
279
|
+
.toArray()
|
|
280
|
+
: await authorities
|
|
281
|
+
.find({
|
|
282
|
+
...afterCursor(cursor),
|
|
283
|
+
...(phase === 'prepare'
|
|
284
|
+
? { keyVersion: { $exists: false } }
|
|
285
|
+
: { seed: { $exists: true } }),
|
|
286
|
+
})
|
|
287
|
+
.sort({ _id: 1 })
|
|
288
|
+
.limit(batchSize)
|
|
289
|
+
.toArray();
|
|
290
|
+
|
|
291
|
+
for (const record of records) {
|
|
292
|
+
if (options.remainingTime && options.remainingTime() < 15_000) break;
|
|
293
|
+
current = record;
|
|
294
|
+
if (phase === 'prepare') {
|
|
295
|
+
if (parseSeedEnvelope(record) || typeof record.seed !== 'string') {
|
|
296
|
+
throw new SeedMigrationError('invalid_legacy_record');
|
|
297
|
+
}
|
|
298
|
+
const envelope = await seedEncryption.encrypt(record.seed, getSeedIdentity(record));
|
|
299
|
+
const updated = await authorities.updateOne(
|
|
300
|
+
snapshotFilter(record),
|
|
301
|
+
{ $set: envelope },
|
|
302
|
+
{ writeConcern }
|
|
303
|
+
);
|
|
304
|
+
if (updated.matchedCount !== 1) throw new SeedMigrationError('document_changed');
|
|
305
|
+
const persisted = await authorities.findOne(
|
|
306
|
+
snapshotFilter({ ...record, ...envelope })
|
|
307
|
+
);
|
|
308
|
+
if (!persisted) throw new SeedMigrationError('document_changed');
|
|
309
|
+
await verifyRecord(persisted);
|
|
310
|
+
} else if (phase === 'verify') {
|
|
311
|
+
const envelope = await verifyRecord(record);
|
|
312
|
+
if (
|
|
313
|
+
!(await authorities.findOne(snapshotFilter(record), { projection: { _id: 1 } }))
|
|
314
|
+
) {
|
|
315
|
+
throw new SeedMigrationError('document_changed');
|
|
316
|
+
}
|
|
317
|
+
await receipts.replaceOne(
|
|
318
|
+
{ _id: record._id },
|
|
319
|
+
receiptFor(record, envelope, epoch),
|
|
320
|
+
{
|
|
321
|
+
upsert: true,
|
|
322
|
+
writeConcern,
|
|
323
|
+
}
|
|
324
|
+
);
|
|
325
|
+
} else {
|
|
326
|
+
const envelope = parseSeedEnvelope(record);
|
|
327
|
+
if (!envelope || !(await receipts.findOne(receiptFor(record, envelope, epoch)))) {
|
|
328
|
+
throw new SeedMigrationError('reverification_required');
|
|
329
|
+
}
|
|
330
|
+
await verifyRecord(record);
|
|
331
|
+
const updated = await authorities.updateOne(
|
|
332
|
+
snapshotFilter(record),
|
|
333
|
+
{ $unset: { seed: '' } },
|
|
334
|
+
{ writeConcern }
|
|
335
|
+
);
|
|
336
|
+
if (updated.matchedCount !== 1) throw new SeedMigrationError('document_changed');
|
|
337
|
+
}
|
|
338
|
+
processed += 1;
|
|
339
|
+
cursor = record._id;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Avoid collection-wide reconciliation on every batch. Reconcile after exhausting
|
|
343
|
+
// the ordered scan; an interrupted/time-limited batch continues at its last success.
|
|
344
|
+
const countsReconciled = processed === records.length && records.length < batchSize;
|
|
345
|
+
let counts = countsBefore;
|
|
346
|
+
let done = false;
|
|
347
|
+
let rescanRequired = false;
|
|
348
|
+
if (countsReconciled) {
|
|
349
|
+
counts = await countSeedMigrationDocuments(db);
|
|
350
|
+
if (counts.malformed || (phase !== 'prepare' && counts.encrypted !== counts.total)) {
|
|
351
|
+
throw new SeedMigrationError('reconciliation_failed');
|
|
352
|
+
}
|
|
353
|
+
if (phase === 'verify') {
|
|
354
|
+
rescanRequired = Boolean(
|
|
355
|
+
await authorities
|
|
356
|
+
.aggregate([...pendingVerification(epoch), { $limit: 1 }])
|
|
357
|
+
.next()
|
|
358
|
+
);
|
|
359
|
+
} else {
|
|
360
|
+
rescanRequired =
|
|
361
|
+
phase === 'prepare' ? counts.legacyOnly > 0 : counts.plaintextRemaining > 0;
|
|
362
|
+
}
|
|
363
|
+
done = !rescanRequired;
|
|
364
|
+
cursor = undefined;
|
|
365
|
+
}
|
|
366
|
+
await states.updateOne(
|
|
367
|
+
lease,
|
|
368
|
+
{
|
|
369
|
+
$set: {
|
|
370
|
+
counts,
|
|
371
|
+
status: done ? 'complete' : 'running',
|
|
372
|
+
updatedAt: new Date(),
|
|
373
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
374
|
+
...((phase === 'verify' && done) || phase === 'purge'
|
|
375
|
+
? { verifiedEpoch: epoch }
|
|
376
|
+
: {}),
|
|
377
|
+
},
|
|
378
|
+
$inc: { processed },
|
|
379
|
+
...(cursor === undefined ? { $unset: { cursor: '' } } : {}),
|
|
380
|
+
},
|
|
381
|
+
{ writeConcern }
|
|
382
|
+
);
|
|
383
|
+
const result = { phase, done, processed, counts, countsReconciled, rescanRequired };
|
|
384
|
+
console.info({ event: 'signing_authority_seed_migration', ...result });
|
|
385
|
+
return result;
|
|
386
|
+
} catch (error) {
|
|
387
|
+
// Clear the purge gate even after partial success; rerun verify before continuing purge.
|
|
388
|
+
const failure = new SeedMigrationError(
|
|
389
|
+
error instanceof SeedMigrationError
|
|
390
|
+
? error.category
|
|
391
|
+
: error instanceof SeedEncryptionError
|
|
392
|
+
? error.category
|
|
393
|
+
: 'operation_failed'
|
|
394
|
+
);
|
|
395
|
+
try {
|
|
396
|
+
await states.updateOne(
|
|
397
|
+
lease,
|
|
398
|
+
{
|
|
399
|
+
$set: { status: 'failed', updatedAt: new Date() },
|
|
400
|
+
$unset: { verifiedEpoch: '', cursor: '' },
|
|
401
|
+
},
|
|
402
|
+
{ writeConcern }
|
|
403
|
+
);
|
|
404
|
+
} catch (checkpointError) {
|
|
405
|
+
logSeedEncryptionFailure(checkpointError, 'migration_failure_checkpoint', current);
|
|
406
|
+
}
|
|
407
|
+
if (error instanceof SeedMigrationError) {
|
|
408
|
+
console.error({
|
|
409
|
+
event: 'signing_authority_seed_migration_failure',
|
|
410
|
+
operation: `migration_${phase}`,
|
|
411
|
+
recordId: current?._id.toString(),
|
|
412
|
+
category: failure.category,
|
|
413
|
+
});
|
|
414
|
+
} else {
|
|
415
|
+
logSeedEncryptionFailure(error, `migration_${phase}`, current);
|
|
416
|
+
}
|
|
417
|
+
throw failure;
|
|
418
|
+
} finally {
|
|
419
|
+
try {
|
|
420
|
+
await states.updateOne(
|
|
421
|
+
lease,
|
|
422
|
+
{ $unset: { leaseOwner: '', leaseExpiresAt: '' } },
|
|
423
|
+
{ writeConcern }
|
|
424
|
+
);
|
|
425
|
+
} catch (releaseError) {
|
|
426
|
+
// Preserve the operation's result/error. The lease expires after 16 minutes.
|
|
427
|
+
logSeedEncryptionFailure(releaseError, 'migration_lease_release', current);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
const script = fileURLToPath(
|
|
9
|
+
new URL('../../scripts/migrate-signing-authority-seeds.mjs', import.meta.url)
|
|
10
|
+
);
|
|
11
|
+
let directory: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
directory = mkdtempSync(join(tmpdir(), 'sa-cli-test-'));
|
|
15
|
+
});
|
|
16
|
+
afterEach(() => rmSync(directory, { recursive: true, force: true }));
|
|
17
|
+
|
|
18
|
+
const invoke = () =>
|
|
19
|
+
spawnSync(
|
|
20
|
+
process.execPath,
|
|
21
|
+
[script, '--function-name', 'test-migration', '--region', 'us-east-1'],
|
|
22
|
+
{
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
env: {
|
|
25
|
+
NODE_ENV: 'test',
|
|
26
|
+
PATH: `${directory}:${process.env.PATH}`,
|
|
27
|
+
SA_CLI_TEST_DIRECTORY: directory,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
);
|
|
31
|
+
const mockAws = (body: string): void => {
|
|
32
|
+
writeFileSync(join(directory, 'aws'), `#!/usr/bin/env node\n${body}`, { mode: 0o700 });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe('seed migration operator CLI', () => {
|
|
36
|
+
it.each(['ExpiredTokenException', 'TooManyRequestsException', 'Read timeout on endpoint URL'])(
|
|
37
|
+
'shows AWS CLI stderr for %s without dumping stdout or the exec error',
|
|
38
|
+
diagnostic => {
|
|
39
|
+
mockAws(
|
|
40
|
+
`process.stdout.write('do-not-log-stdout'); process.stderr.write(${JSON.stringify(diagnostic)}); process.exit(1);`
|
|
41
|
+
);
|
|
42
|
+
const result = invoke();
|
|
43
|
+
expect(result.status).toBe(1);
|
|
44
|
+
expect(result.stderr).toContain(diagnostic);
|
|
45
|
+
expect(result.stderr).toContain('rerun to resume');
|
|
46
|
+
expect(result.stderr).not.toContain('do-not-log-stdout');
|
|
47
|
+
expect(result.stdout).toBe('');
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
it('continues a requested rescan even when the boundary batch processed zero records', () => {
|
|
52
|
+
mockAws(`
|
|
53
|
+
const fs = require('node:fs');
|
|
54
|
+
const marker = process.env.SA_CLI_TEST_DIRECTORY + '/invoked';
|
|
55
|
+
const done = fs.existsSync(marker);
|
|
56
|
+
fs.writeFileSync(marker, '1');
|
|
57
|
+
fs.writeFileSync(process.argv.at(-1), JSON.stringify({ done, processed: done ? 1 : 0, rescanRequired: !done }));
|
|
58
|
+
process.stdout.write(JSON.stringify({ StatusCode: 200 }));
|
|
59
|
+
`);
|
|
60
|
+
const result = invoke();
|
|
61
|
+
expect(result.status).toBe(0);
|
|
62
|
+
expect(result.stderr).toBe('');
|
|
63
|
+
const batches = result.stdout
|
|
64
|
+
.trim()
|
|
65
|
+
.split('\n')
|
|
66
|
+
.map(line => JSON.parse(line));
|
|
67
|
+
expect(batches).toHaveLength(2);
|
|
68
|
+
expect(batches[1].done).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
});
|