@learncard/lca-api-service 1.4.0 → 1.5.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/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +52 -0
- package/bootstrapLambda.test.ts +76 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +22 -13
- package/project.json +8 -1
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +174 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- package/serverless.escrow-env.cjs +0 -20
package/src/models/EscrowHold.ts
CHANGED
|
@@ -1,14 +1,44 @@
|
|
|
1
|
-
import { createHash,
|
|
1
|
+
import { createHash, createHmac, randomBytes } from 'crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { Collection } from 'mongodb';
|
|
4
4
|
import mongodb from '@mongo';
|
|
5
5
|
import { environment } from '@environment';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
AuthProviderMappingValidator,
|
|
8
|
+
EscrowEnvelopeValidator,
|
|
9
|
+
type AuthProviderMapping,
|
|
10
|
+
} from './UserKey';
|
|
7
11
|
|
|
8
12
|
export const ESCROW_HOLDS_COLLECTION = 'escrowholds';
|
|
9
13
|
export const ESCROW_HOLD_STALE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
10
14
|
export const ESCROW_HOLD_RESTART_MIN_AGE_MS = environment.ESCROW_HOLD_RESTART_MIN_AGE_MS;
|
|
15
|
+
export const ESCROW_HOLD_REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
// Opaque JSON validated for shape, not signature authenticity. Preserve signed extensions.
|
|
17
|
+
export const EscrowHoldRecordValidator = z
|
|
18
|
+
.object({
|
|
19
|
+
hold: z
|
|
20
|
+
.object({
|
|
21
|
+
holdId: z.string().min(1),
|
|
22
|
+
did: z.string().min(1),
|
|
23
|
+
shareVersion: z.number().int().positive(),
|
|
24
|
+
blobHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
25
|
+
enrollmentEpoch: z.number().int().positive(),
|
|
26
|
+
releasePolicy: z.enum(['hold', 'pin']),
|
|
27
|
+
clientEphemeralPublicKey: z.string().min(1),
|
|
28
|
+
createdLo: z.number().int().nonnegative(),
|
|
29
|
+
createdHi: z.number().int().nonnegative(),
|
|
30
|
+
policyVersion: z.number().int().positive(),
|
|
31
|
+
signature: z.string().min(1),
|
|
32
|
+
})
|
|
33
|
+
.passthrough(),
|
|
34
|
+
holdDurationMs: z.number().int().nonnegative(),
|
|
35
|
+
ledgerSeq: z.number().int().nonnegative(),
|
|
36
|
+
})
|
|
37
|
+
.passthrough();
|
|
11
38
|
export const EscrowHoldValidator = z.object({
|
|
39
|
+
// Required for new holds before escrow production launch. Raw Mongo reads can
|
|
40
|
+
// still return legacy rows without it; cancellation/release must check at runtime.
|
|
41
|
+
holdRecord: EscrowHoldRecordValidator,
|
|
12
42
|
_id: z.string().uuid(),
|
|
13
43
|
authProvider: AuthProviderMappingValidator,
|
|
14
44
|
primaryDid: z.string(),
|
|
@@ -20,19 +50,44 @@ export const EscrowHoldValidator = z.object({
|
|
|
20
50
|
releasePolicy: z.enum(['hold', 'pin']).default('hold'),
|
|
21
51
|
cancelReason: z.enum(['pin-mismatch', 'pin-locked', 'superseded', 'release-failed']).optional(),
|
|
22
52
|
cancelledAt: z.date().optional(),
|
|
23
|
-
|
|
53
|
+
enclaveCancelPendingAt: z.date().optional(),
|
|
54
|
+
enclaveCancelConfirmedAt: z.date().optional(),
|
|
55
|
+
enclaveCancelAttempts: z.number().int().nonnegative().optional(),
|
|
56
|
+
// Retain ciphertext for revocation even after account removal or re-enrollment.
|
|
57
|
+
enclaveEnvelope: EscrowEnvelopeValidator.optional(),
|
|
58
|
+
cancelledBy: z.enum(['did', 'system', 'link']).optional(),
|
|
24
59
|
completedAt: z.date().optional(),
|
|
25
60
|
clientEphemeralPublicKey: z.string().min(1).max(512),
|
|
26
61
|
resumeTokenHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
62
|
+
cancelTokenHash: z
|
|
63
|
+
.string()
|
|
64
|
+
.regex(/^[0-9a-f]{64}$/)
|
|
65
|
+
.optional(),
|
|
66
|
+
cancelTokenUsedAt: z.date().optional(),
|
|
67
|
+
cancelTokenHashes: z
|
|
68
|
+
.array(z.string().regex(/^[0-9a-f]{64}$/))
|
|
69
|
+
.max(4)
|
|
70
|
+
.optional(),
|
|
71
|
+
startedNotificationPending: z.boolean().optional(),
|
|
72
|
+
reminderClaimedUntil: z.date().optional(),
|
|
73
|
+
startedNotificationAttemptedAt: z.date().optional(),
|
|
74
|
+
reminderAttemptedAt: z.date().optional(),
|
|
27
75
|
notifications: z
|
|
28
76
|
.array(
|
|
29
77
|
z.object({
|
|
30
|
-
kind: z.enum(['started', '
|
|
78
|
+
kind: z.enum(['started', 'reminder', 'completed', 'cancelled', 'pin-locked']),
|
|
31
79
|
sentAt: z.date(),
|
|
32
80
|
})
|
|
33
81
|
)
|
|
34
82
|
.default([]),
|
|
35
83
|
requestIp: z.string().optional(),
|
|
84
|
+
/**
|
|
85
|
+
* Resolved tenant id (`ResolvedTenant.id`) at the moment `startRecovery`
|
|
86
|
+
* created this hold. Optional because holds created before this field
|
|
87
|
+
* existed have none; the reminders job (P5.5) falls back to the default
|
|
88
|
+
* tenant via the email-templates registry when absent.
|
|
89
|
+
*/
|
|
90
|
+
tenantId: z.string().optional(),
|
|
36
91
|
createdAt: z.date(),
|
|
37
92
|
updatedAt: z.date(),
|
|
38
93
|
});
|
|
@@ -48,6 +103,10 @@ export type CreateEscrowHoldInput = Omit<
|
|
|
48
103
|
| 'cancelledBy'
|
|
49
104
|
| 'completedAt'
|
|
50
105
|
| 'cancelReason'
|
|
106
|
+
| 'cancelTokenUsedAt'
|
|
107
|
+
| 'enclaveCancelPendingAt'
|
|
108
|
+
| 'enclaveCancelConfirmedAt'
|
|
109
|
+
| 'enclaveCancelAttempts'
|
|
51
110
|
>;
|
|
52
111
|
|
|
53
112
|
export const getEscrowHoldsCollection = (): Collection<EscrowHold> =>
|
|
@@ -108,13 +167,26 @@ export const createEscrowHoldsIndexes = async (): Promise<void> => {
|
|
|
108
167
|
collection.createIndex({ 'authProvider.type': 1, 'authProvider.id': 1, status: 1 })
|
|
109
168
|
);
|
|
110
169
|
await runIndexMigrationOperation(() => collection.createIndex({ releaseAfter: 1 }));
|
|
170
|
+
// Backs findEscrowHoldsDueForReminder's { status:'pending', releaseAfter: {$gt,$lte} } scan.
|
|
171
|
+
await collection.createIndex(
|
|
172
|
+
{ status: 1, releaseAfter: 1 },
|
|
173
|
+
{ name: 'pending_escrow_hold_reminder_lookup' }
|
|
174
|
+
);
|
|
175
|
+
await collection.createIndex(
|
|
176
|
+
{ status: 1, enclaveCancelConfirmedAt: 1, enclaveCancelAttempts: 1, releaseAfter: 1 },
|
|
177
|
+
{ name: 'escrow_enclave_cancel_retry_lookup' }
|
|
178
|
+
);
|
|
179
|
+
await collection.createIndex(
|
|
180
|
+
{ primaryDid: 1, status: 1, enclaveCancelConfirmedAt: 1 },
|
|
181
|
+
{ name: 'escrow_enclave_cancel_identity_lookup' }
|
|
182
|
+
);
|
|
111
183
|
};
|
|
112
184
|
|
|
113
185
|
export const createEscrowHold = async (input: CreateEscrowHoldInput): Promise<EscrowHold> => {
|
|
114
186
|
const now = new Date();
|
|
115
187
|
const parsed = EscrowHoldValidator.safeParse({
|
|
116
188
|
...input,
|
|
117
|
-
_id:
|
|
189
|
+
_id: input.holdRecord?.hold?.holdId,
|
|
118
190
|
status: 'pending',
|
|
119
191
|
notifications: [],
|
|
120
192
|
createdAt: now,
|
|
@@ -141,6 +213,68 @@ export const findPendingEscrowHoldByAuthProvider = async (
|
|
|
141
213
|
});
|
|
142
214
|
export const findEscrowHoldById = async (id: string): Promise<EscrowHold | null> =>
|
|
143
215
|
getEscrowHoldsCollection().findOne({ _id: id });
|
|
216
|
+
/** Fail closed across linked providers and enrollment changes until revocation is confirmed. */
|
|
217
|
+
export const hasUnconfirmedEscrowCancellation = async (primaryDid: string): Promise<boolean> =>
|
|
218
|
+
(await getEscrowHoldsCollection().countDocuments(
|
|
219
|
+
{
|
|
220
|
+
primaryDid,
|
|
221
|
+
status: 'cancelled',
|
|
222
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
223
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
224
|
+
},
|
|
225
|
+
{ limit: 1 }
|
|
226
|
+
)) > 0;
|
|
227
|
+
|
|
228
|
+
export const findEscrowHoldsPendingEnclaveCancellation = async (
|
|
229
|
+
limit: number
|
|
230
|
+
): Promise<EscrowHold[]> =>
|
|
231
|
+
getEscrowHoldsCollection()
|
|
232
|
+
.find({
|
|
233
|
+
status: 'cancelled',
|
|
234
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
235
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
236
|
+
})
|
|
237
|
+
.sort({ enclaveCancelAttempts: 1, releaseAfter: 1 })
|
|
238
|
+
.limit(limit)
|
|
239
|
+
.toArray();
|
|
240
|
+
|
|
241
|
+
export const countEscrowCancellationsNearRelease = async (now: Date): Promise<number> =>
|
|
242
|
+
getEscrowHoldsCollection().countDocuments({
|
|
243
|
+
status: 'cancelled',
|
|
244
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
245
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
246
|
+
releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
export const recordEscrowEnclaveCancelAttempt = async (id: string): Promise<boolean> => {
|
|
250
|
+
const result = await getEscrowHoldsCollection().updateOne(
|
|
251
|
+
{
|
|
252
|
+
_id: id,
|
|
253
|
+
status: 'cancelled',
|
|
254
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
255
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
256
|
+
},
|
|
257
|
+
{ $inc: { enclaveCancelAttempts: 1 }, $set: { updatedAt: new Date() } }
|
|
258
|
+
);
|
|
259
|
+
return result.modifiedCount > 0;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
export const confirmEscrowEnclaveCancellation = async (id: string): Promise<void> => {
|
|
263
|
+
const now = new Date();
|
|
264
|
+
await getEscrowHoldsCollection().updateOne(
|
|
265
|
+
{
|
|
266
|
+
_id: id,
|
|
267
|
+
status: 'cancelled',
|
|
268
|
+
enclaveCancelPendingAt: { $exists: true },
|
|
269
|
+
enclaveCancelConfirmedAt: { $exists: false },
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
$set: { enclaveCancelConfirmedAt: now, updatedAt: now },
|
|
273
|
+
$unset: { enclaveCancelPendingAt: '', enclaveEnvelope: '' },
|
|
274
|
+
}
|
|
275
|
+
);
|
|
276
|
+
};
|
|
277
|
+
|
|
144
278
|
export const cancelEscrowHold = async (
|
|
145
279
|
id: string,
|
|
146
280
|
cancelledBy: 'did' | 'system',
|
|
@@ -154,6 +288,8 @@ export const cancelEscrowHold = async (
|
|
|
154
288
|
status: 'cancelled',
|
|
155
289
|
cancelledBy,
|
|
156
290
|
cancelledAt: now,
|
|
291
|
+
enclaveCancelPendingAt: now,
|
|
292
|
+
enclaveCancelAttempts: 0,
|
|
157
293
|
updatedAt: now,
|
|
158
294
|
...(cancelReason ? { cancelReason } : {}),
|
|
159
295
|
},
|
|
@@ -161,6 +297,39 @@ export const cancelEscrowHold = async (
|
|
|
161
297
|
{ returnDocument: 'after' }
|
|
162
298
|
);
|
|
163
299
|
};
|
|
300
|
+
/**
|
|
301
|
+
* Cancels a hold via its single-use cancel-link token. The status transition
|
|
302
|
+
* and burning the token happen in one conditional update — matching on the
|
|
303
|
+
* exact `cancelTokenHash` plus `cancelTokenUsedAt` being unset — so two
|
|
304
|
+
* concurrent clicks on the same link can never both succeed; only the first
|
|
305
|
+
* `findOneAndUpdate` observes the pre-burn state and wins the CAS.
|
|
306
|
+
*/
|
|
307
|
+
export const cancelEscrowHoldByCancelToken = async (
|
|
308
|
+
id: string,
|
|
309
|
+
cancelTokenHash: string
|
|
310
|
+
): Promise<EscrowHold | null> => {
|
|
311
|
+
const now = new Date();
|
|
312
|
+
return getEscrowHoldsCollection().findOneAndUpdate(
|
|
313
|
+
{
|
|
314
|
+
_id: id,
|
|
315
|
+
status: 'pending',
|
|
316
|
+
$or: [{ cancelTokenHash }, { cancelTokenHashes: cancelTokenHash }],
|
|
317
|
+
cancelTokenUsedAt: { $exists: false },
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
$set: {
|
|
321
|
+
status: 'cancelled',
|
|
322
|
+
cancelledBy: 'link',
|
|
323
|
+
cancelledAt: now,
|
|
324
|
+
enclaveCancelPendingAt: now,
|
|
325
|
+
enclaveCancelAttempts: 0,
|
|
326
|
+
cancelTokenUsedAt: now,
|
|
327
|
+
updatedAt: now,
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
{ returnDocument: 'after' }
|
|
331
|
+
);
|
|
332
|
+
};
|
|
164
333
|
export const completeEscrowHold = async (id: string): Promise<EscrowHold | null> => {
|
|
165
334
|
const now = new Date();
|
|
166
335
|
return getEscrowHoldsCollection().findOneAndUpdate(
|
|
@@ -215,11 +384,158 @@ export const markClaimedEscrowHoldFailed = async (
|
|
|
215
384
|
cancelledBy: 'system',
|
|
216
385
|
cancelReason: reason,
|
|
217
386
|
cancelledAt: now,
|
|
387
|
+
enclaveCancelPendingAt: now,
|
|
388
|
+
enclaveCancelAttempts: 0,
|
|
218
389
|
updatedAt: now,
|
|
219
390
|
},
|
|
220
391
|
}
|
|
221
392
|
);
|
|
222
393
|
};
|
|
394
|
+
/** Reopen only our own failed release claim, never another request's terminal row. */
|
|
395
|
+
export const retryClaimedEscrowHold = async (id: string, completedAt: Date): Promise<void> => {
|
|
396
|
+
try {
|
|
397
|
+
await getEscrowHoldsCollection().updateOne(
|
|
398
|
+
{ _id: id, status: 'completed', completedAt },
|
|
399
|
+
{ $set: { status: 'pending', updatedAt: new Date() }, $unset: { completedAt: '' } }
|
|
400
|
+
);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
// A replacement hold may have won the pending unique index while we released.
|
|
403
|
+
if (!(
|
|
404
|
+
typeof error === 'object' &&
|
|
405
|
+
error !== null &&
|
|
406
|
+
'code' in error &&
|
|
407
|
+
error.code === 11000
|
|
408
|
+
))
|
|
409
|
+
throw error;
|
|
410
|
+
await markClaimedEscrowHoldFailed(id, 'release-failed', completedAt);
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export const findEscrowHoldsPendingStartNotification = async (
|
|
415
|
+
limit: number
|
|
416
|
+
): Promise<EscrowHold[]> =>
|
|
417
|
+
getEscrowHoldsCollection()
|
|
418
|
+
.find({ status: 'pending', startedNotificationPending: true })
|
|
419
|
+
.sort({ startedNotificationAttemptedAt: 1, requestedAt: 1 })
|
|
420
|
+
.limit(limit)
|
|
421
|
+
.toArray();
|
|
422
|
+
|
|
423
|
+
export const recordEscrowStartNotificationAttempt = async (
|
|
424
|
+
id: string,
|
|
425
|
+
now: Date
|
|
426
|
+
): Promise<void> => {
|
|
427
|
+
await getEscrowHoldsCollection().updateOne(
|
|
428
|
+
{ _id: id, status: 'pending', startedNotificationPending: true },
|
|
429
|
+
{ $set: { startedNotificationAttemptedAt: now } }
|
|
430
|
+
);
|
|
431
|
+
};
|
|
432
|
+
/**
|
|
433
|
+
* Pending, hold-policy holds whose waiting period ends within the next 24h
|
|
434
|
+
* (and hasn't ended yet) that have never been reminded. `pin`-policy holds
|
|
435
|
+
* release immediately at creation (`releaseAfter` set to the creation time,
|
|
436
|
+
* not a future date — see `startRecovery`), so `releaseAfter > now` already
|
|
437
|
+
* excludes them in practice within a few ms of job execution; the explicit
|
|
438
|
+
* releasePolicy filter documents that exclusion instead of relying on
|
|
439
|
+
* timing alone. Legacy rows predating `releasePolicy` are treated as `hold`,
|
|
440
|
+
* matching `findPendingEscrowHoldByAuthProvider`'s own `$or`.
|
|
441
|
+
*/
|
|
442
|
+
export const findEscrowHoldsDueForReminder = async (
|
|
443
|
+
now: Date,
|
|
444
|
+
limit: number
|
|
445
|
+
): Promise<EscrowHold[]> =>
|
|
446
|
+
getEscrowHoldsCollection()
|
|
447
|
+
.find({
|
|
448
|
+
status: 'pending',
|
|
449
|
+
$or: [{ releasePolicy: 'hold' as const }, { releasePolicy: { $exists: false } }],
|
|
450
|
+
releaseAfter: {
|
|
451
|
+
$gt: now,
|
|
452
|
+
$lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS),
|
|
453
|
+
},
|
|
454
|
+
'notifications.kind': { $ne: 'reminder' },
|
|
455
|
+
})
|
|
456
|
+
.sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
|
|
457
|
+
.limit(limit)
|
|
458
|
+
.toArray();
|
|
459
|
+
|
|
460
|
+
/** A short delivery lease avoids concurrent sends without recording false success.
|
|
461
|
+
* A crashed Lambda's lease expires before the next hourly run. */
|
|
462
|
+
export const claimEscrowHoldForReminder = async (
|
|
463
|
+
id: string,
|
|
464
|
+
now: Date
|
|
465
|
+
): Promise<EscrowHold | null> =>
|
|
466
|
+
getEscrowHoldsCollection().findOneAndUpdate(
|
|
467
|
+
{
|
|
468
|
+
_id: id,
|
|
469
|
+
status: 'pending',
|
|
470
|
+
'notifications.kind': { $ne: 'reminder' },
|
|
471
|
+
$or: [
|
|
472
|
+
{ reminderClaimedUntil: { $exists: false } },
|
|
473
|
+
{ reminderClaimedUntil: { $lte: now } },
|
|
474
|
+
],
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
$set: {
|
|
478
|
+
reminderClaimedUntil: new Date(now.getTime() + 5 * 60_000),
|
|
479
|
+
reminderAttemptedAt: now,
|
|
480
|
+
updatedAt: now,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{ returnDocument: 'after' }
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
export const releaseEscrowReminderClaim = async (hold: EscrowHold): Promise<void> => {
|
|
487
|
+
await getEscrowHoldsCollection().updateOne(
|
|
488
|
+
{ _id: hold._id, reminderClaimedUntil: hold.reminderClaimedUntil },
|
|
489
|
+
{ $unset: { reminderClaimedUntil: '' } }
|
|
490
|
+
);
|
|
491
|
+
};
|
|
492
|
+
|
|
223
493
|
export const hashEscrowResumeToken = (token: string): string =>
|
|
224
494
|
createHash('sha256').update(token).digest('hex');
|
|
225
495
|
export const generateEscrowResumeToken = (): string => randomBytes(32).toString('hex');
|
|
496
|
+
|
|
497
|
+
/** Appends a delivery record; a missing holdId is a no-op rather than a throw. */
|
|
498
|
+
export const recordEscrowHoldNotification = async (
|
|
499
|
+
holdId: string,
|
|
500
|
+
kind: EscrowHold['notifications'][number]['kind']
|
|
501
|
+
): Promise<void> => {
|
|
502
|
+
const now = new Date();
|
|
503
|
+
await getEscrowHoldsCollection().updateOne(
|
|
504
|
+
{ _id: holdId, 'notifications.kind': { $ne: kind } },
|
|
505
|
+
{
|
|
506
|
+
$push: { notifications: { kind, sentAt: now } },
|
|
507
|
+
$set: {
|
|
508
|
+
updatedAt: now,
|
|
509
|
+
...(kind === 'started' ? { startedNotificationPending: false } : {}),
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Adds a retry-stable, domain-separated token without revoking earlier links.
|
|
517
|
+
* HMAC allows delivery retries without storing plaintext or exhausting the
|
|
518
|
+
* bounded hash set. The original random hash remains valid until terminal.
|
|
519
|
+
* At most four additional hashes (plus the legacy original) are accepted;
|
|
520
|
+
* never evict a previously delivered link, even after a server seed rotation.
|
|
521
|
+
*/
|
|
522
|
+
export const rotateEscrowCancelToken = async (holdId: string): Promise<string | null> => {
|
|
523
|
+
const token = createHmac('sha256', environment.SEED)
|
|
524
|
+
.update(`escrow-cancel-notification:v1:${holdId}`)
|
|
525
|
+
.digest('hex');
|
|
526
|
+
const cancelTokenHash = createHash('sha256').update(token).digest('hex');
|
|
527
|
+
const now = new Date();
|
|
528
|
+
const result = await getEscrowHoldsCollection().findOneAndUpdate(
|
|
529
|
+
{
|
|
530
|
+
_id: holdId,
|
|
531
|
+
status: 'pending',
|
|
532
|
+
$or: [
|
|
533
|
+
{ cancelTokenHashes: cancelTokenHash },
|
|
534
|
+
{ 'cancelTokenHashes.3': { $exists: false } },
|
|
535
|
+
],
|
|
536
|
+
},
|
|
537
|
+
{ $addToSet: { cancelTokenHashes: cancelTokenHash }, $set: { updatedAt: now } },
|
|
538
|
+
{ returnDocument: 'after' }
|
|
539
|
+
);
|
|
540
|
+
return result ? token : null;
|
|
541
|
+
};
|
package/src/models/UserKey.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
import { escrowBlobIdentity } from '../services/escrow-enclave/blobIdentity';
|
|
2
3
|
import type { Filter } from 'mongodb';
|
|
3
4
|
import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
|
|
4
5
|
import { EscrowPinStatusValidator as SharedEscrowPinStatusValidator } from '@learncard/types';
|
|
@@ -63,6 +64,8 @@ export const EscrowEnvelopeValidator = z
|
|
|
63
64
|
.strict();
|
|
64
65
|
|
|
65
66
|
export const EscrowBlobValidator = z.object({
|
|
67
|
+
enrollmentEpoch: z.number().int().positive(),
|
|
68
|
+
blobHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
66
69
|
envelope: EscrowEnvelopeValidator,
|
|
67
70
|
enclaveKeyId: z.string().regex(/^[A-Za-z0-9._-]{1,128}$/),
|
|
68
71
|
enclaveMode: z.enum(['software', 'nitro']),
|
|
@@ -732,21 +735,29 @@ const getAuthProviderFilter = (authProvider: AuthProviderMapping): Filter<MongoU
|
|
|
732
735
|
/** Persist verified escrow and its confirmed descriptor atomically. */
|
|
733
736
|
export const setEscrowBlobByAuthProvider = async (
|
|
734
737
|
authProvider: AuthProviderMapping,
|
|
735
|
-
blob: EscrowBlob,
|
|
738
|
+
blob: Omit<EscrowBlob, 'enrollmentEpoch' | 'blobHash'>,
|
|
736
739
|
expectedShareVersion: number,
|
|
737
740
|
pin?: { salt: string },
|
|
738
741
|
carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> }
|
|
739
742
|
): Promise<MongoUserKeyType | null> => {
|
|
740
743
|
const now = new Date();
|
|
741
|
-
const parsed = EscrowBlobValidator.safeParse(
|
|
744
|
+
const parsed = EscrowBlobValidator.omit({ enrollmentEpoch: true, blobHash: true }).safeParse(
|
|
745
|
+
blob
|
|
746
|
+
);
|
|
742
747
|
if (!parsed.success) throw new Error('Invalid escrow payload');
|
|
743
|
-
const
|
|
748
|
+
const blobHash = escrowBlobIdentity(parsed.data.envelope);
|
|
749
|
+
const assembled = EscrowBlobValidator.safeParse({
|
|
750
|
+
...parsed.data,
|
|
751
|
+
blobHash,
|
|
752
|
+
enrollmentEpoch: 1,
|
|
753
|
+
});
|
|
754
|
+
if (!assembled.success) throw new Error('Invalid escrow payload');
|
|
755
|
+
const { enrollmentEpoch: _initialEpoch, ...validated } = assembled.data;
|
|
744
756
|
if (
|
|
745
757
|
validated.shareVersion !== expectedShareVersion ||
|
|
746
758
|
validated.enclaveKeyId !== validated.envelope.keyId
|
|
747
|
-
)
|
|
759
|
+
)
|
|
748
760
|
return null;
|
|
749
|
-
}
|
|
750
761
|
return getUserKeysCollection().findOneAndUpdate(
|
|
751
762
|
{
|
|
752
763
|
...getAuthProviderFilter(authProvider),
|
|
@@ -757,7 +768,16 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
757
768
|
[
|
|
758
769
|
{
|
|
759
770
|
$set: {
|
|
760
|
-
escrowBlob: {
|
|
771
|
+
escrowBlob: {
|
|
772
|
+
$mergeObjects: [
|
|
773
|
+
{ $literal: validated },
|
|
774
|
+
{
|
|
775
|
+
enrollmentEpoch: {
|
|
776
|
+
$add: [{ $ifNull: ['$escrowBlob.enrollmentEpoch', 0] }, 1],
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
],
|
|
780
|
+
},
|
|
761
781
|
escrowPin: carryFrom
|
|
762
782
|
? { $literal: { ...carryFrom.pin, shareVersion: expectedShareVersion } }
|
|
763
783
|
: pin
|
|
@@ -798,6 +818,71 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
798
818
|
);
|
|
799
819
|
};
|
|
800
820
|
|
|
821
|
+
/**
|
|
822
|
+
* P9.3: migrates an escrow blob sealed under a previous enclave key onto the
|
|
823
|
+
* current one. Atomic compare-and-swap against the OLD envelope/blob object
|
|
824
|
+
* AND the unchanged `escrowPin` (read at the same time as `oldBlob`, so ANY
|
|
825
|
+
* concurrent write to this account since — a re-enroll, a PIN change, an
|
|
826
|
+
* opt-out — loses the race safely rather than overwriting a stale
|
|
827
|
+
* assumption), even though this write never touches `escrowPin` itself.
|
|
828
|
+
* Writes the new envelope under the CURRENT `enclaveKeyId`, a fresh
|
|
829
|
+
* Rust-order `blobHash` (identical algorithm to `setEscrowBlobByAuthProvider`),
|
|
830
|
+
* the SAME `enrollmentEpoch` and `shareVersion` — `escrowPin` is left
|
|
831
|
+
* exactly as read. Lost race (version bump, opt-out, or any other change
|
|
832
|
+
* since `oldBlob`/`oldPin` were read) -> null, no-op; callers retry on a
|
|
833
|
+
* later job run rather than treating this as an error.
|
|
834
|
+
*/
|
|
835
|
+
export const rewrapEscrowBlobByAuthProvider = async (
|
|
836
|
+
authProvider: AuthProviderMapping,
|
|
837
|
+
oldBlob: EscrowBlob,
|
|
838
|
+
oldPin: MongoUserKeyType['escrowPin'],
|
|
839
|
+
newEnvelope: EscrowBlob['envelope'],
|
|
840
|
+
identity: Pick<EscrowBlob, 'enclaveKeyId' | 'enclaveMode' | 'measurements'>
|
|
841
|
+
): Promise<MongoUserKeyType | null> => {
|
|
842
|
+
const now = new Date();
|
|
843
|
+
const blobHash = escrowBlobIdentity(newEnvelope);
|
|
844
|
+
const parsed = EscrowBlobValidator.safeParse({
|
|
845
|
+
envelope: newEnvelope,
|
|
846
|
+
enclaveKeyId: identity.enclaveKeyId,
|
|
847
|
+
enclaveMode: identity.enclaveMode,
|
|
848
|
+
measurements: identity.measurements,
|
|
849
|
+
shareVersion: oldBlob.shareVersion,
|
|
850
|
+
blobHash,
|
|
851
|
+
enrollmentEpoch: oldBlob.enrollmentEpoch,
|
|
852
|
+
createdAt: now,
|
|
853
|
+
});
|
|
854
|
+
if (!parsed.success || parsed.data.enclaveKeyId !== parsed.data.envelope.keyId) return null;
|
|
855
|
+
const filter: Filter<MongoUserKeyType> = {
|
|
856
|
+
...getAuthProviderFilter(authProvider),
|
|
857
|
+
escrowBlob: oldBlob,
|
|
858
|
+
escrowOptedOutAt: { $exists: false },
|
|
859
|
+
...(oldPin ? { escrowPin: oldPin } : { escrowPin: { $exists: false } }),
|
|
860
|
+
};
|
|
861
|
+
return getUserKeysCollection().findOneAndUpdate(
|
|
862
|
+
filter,
|
|
863
|
+
{ $set: { escrowBlob: parsed.data, updatedAt: now } },
|
|
864
|
+
{ returnDocument: 'after' }
|
|
865
|
+
);
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* P9.3 migration job candidates: escrow blobs still sealed under any of the
|
|
870
|
+
* given (previous) enclave keys. Never returns opted-out accounts — escrow
|
|
871
|
+
* is fully disabled for them, so there is nothing to migrate. Bounded by
|
|
872
|
+
* `limit`, mirroring `findEscrowHoldsDueForReminder`'s own batch shape.
|
|
873
|
+
*/
|
|
874
|
+
export const findUserKeysWithEscrowBlobKeyId = async (
|
|
875
|
+
keyIds: string[],
|
|
876
|
+
limit: number
|
|
877
|
+
): Promise<MongoUserKeyType[]> =>
|
|
878
|
+
getUserKeysCollection()
|
|
879
|
+
.find({
|
|
880
|
+
'escrowBlob.enclaveKeyId': { $in: keyIds },
|
|
881
|
+
escrowOptedOutAt: { $exists: false },
|
|
882
|
+
})
|
|
883
|
+
.limit(limit)
|
|
884
|
+
.toArray();
|
|
885
|
+
|
|
801
886
|
export const clearEscrowByAuthProvider = async (
|
|
802
887
|
authProvider: AuthProviderMapping,
|
|
803
888
|
{ optOut }: { optOut: boolean }
|
package/src/models/index.ts
CHANGED