@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,826 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { TRPCError } from '@trpc/server';
|
|
4
|
+
import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
|
|
5
|
+
import {
|
|
6
|
+
ESCROW_PIN_LOCKED_MESSAGE,
|
|
7
|
+
ESCROW_PIN_UNAVAILABLE_MESSAGE,
|
|
8
|
+
escrowPinMismatchMessage,
|
|
9
|
+
} from '@learncard/types';
|
|
10
|
+
import cache from '@cache';
|
|
11
|
+
import { environment } from '@environment';
|
|
12
|
+
import { t, openRoute, didAndChallengeRoute } from '@routes';
|
|
13
|
+
import { createRecoverySession } from '@cache/recoverySessions';
|
|
14
|
+
import { decryptAuthShare } from '@helpers/shareEncryption.helpers';
|
|
15
|
+
import {
|
|
16
|
+
EscrowEnvelopeValidator,
|
|
17
|
+
EscrowBlobValidator,
|
|
18
|
+
EscrowPinSaltValidator,
|
|
19
|
+
reserveEscrowPinAttempt,
|
|
20
|
+
refundEscrowPinAttempt,
|
|
21
|
+
recordEscrowPinFailure,
|
|
22
|
+
disableEscrowPin,
|
|
23
|
+
markClaimedEscrowHoldFailed,
|
|
24
|
+
ServerEncryptedShareValidator,
|
|
25
|
+
findUserKeyByAuthProvider,
|
|
26
|
+
findAuthShareByVersion,
|
|
27
|
+
isRecoveryMethodConfirmed,
|
|
28
|
+
setEscrowBlobByAuthProvider,
|
|
29
|
+
clearEscrowByAuthProvider,
|
|
30
|
+
setEscrowOptInByAuthProvider,
|
|
31
|
+
createEscrowHold,
|
|
32
|
+
createEscrowHoldsIndexes,
|
|
33
|
+
findPendingEscrowHoldByAuthProvider,
|
|
34
|
+
findEscrowHoldById,
|
|
35
|
+
cancelEscrowHold,
|
|
36
|
+
completeEscrowHold,
|
|
37
|
+
hasCompletedEscrowHoldForVersion,
|
|
38
|
+
expireStaleEscrowHolds,
|
|
39
|
+
hashEscrowResumeToken,
|
|
40
|
+
generateEscrowResumeToken,
|
|
41
|
+
type EscrowHold,
|
|
42
|
+
type MongoUserKeyType,
|
|
43
|
+
type AuthProviderMapping,
|
|
44
|
+
} from '@models';
|
|
45
|
+
import {
|
|
46
|
+
AuthInputValidator,
|
|
47
|
+
AuthProviderTypeValidator,
|
|
48
|
+
RecoverySessionTokenValidator,
|
|
49
|
+
verifyAndGetContactMethod,
|
|
50
|
+
requireUserKey,
|
|
51
|
+
assertDidOwner,
|
|
52
|
+
requireRecoverySession,
|
|
53
|
+
} from './keys';
|
|
54
|
+
import {
|
|
55
|
+
getEscrowEnclave,
|
|
56
|
+
getEscrowHoldDurationMs,
|
|
57
|
+
getEscrowHoldRestartMinAgeMs,
|
|
58
|
+
notifyEscrowHoldEvent,
|
|
59
|
+
EscrowPolicyError,
|
|
60
|
+
EscrowBlobError,
|
|
61
|
+
EscrowUnavailableError,
|
|
62
|
+
EscrowPinMismatchError,
|
|
63
|
+
} from '../services/escrow-enclave';
|
|
64
|
+
|
|
65
|
+
const unavailableMessage = 'Automatic recovery is not available for this account.';
|
|
66
|
+
const invalidMessage = 'This recovery request is no longer valid.';
|
|
67
|
+
// Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
|
|
68
|
+
const pinThrottledMessage = 'Please wait before trying again.';
|
|
69
|
+
|
|
70
|
+
// Follow qr-login's Redis INCR/EXPIRE per-IP limiter. keys.ts has only OTP-specific limits.
|
|
71
|
+
const limitPinCompletion = async (
|
|
72
|
+
clientIp: string | undefined,
|
|
73
|
+
operation: 'start' | 'complete' = 'complete'
|
|
74
|
+
): Promise<void> => {
|
|
75
|
+
const redis = cache.redis ?? cache.node;
|
|
76
|
+
const key = `escrow:pin-${operation}:${clientIp ?? 'unknown'}`;
|
|
77
|
+
const count = await redis.incr(key);
|
|
78
|
+
if (count === 1) await redis.expire(key, 60);
|
|
79
|
+
if (count > 20)
|
|
80
|
+
throw new TRPCError({
|
|
81
|
+
code: 'TOO_MANY_REQUESTS',
|
|
82
|
+
message: pinThrottledMessage,
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const lockPin = async (hold: EscrowHold, userKey: MongoUserKeyType): Promise<void> => {
|
|
87
|
+
const disabled = await disableEscrowPin(
|
|
88
|
+
hold.authProvider,
|
|
89
|
+
hold.shareVersion,
|
|
90
|
+
userKey.escrowBlob?.envelope.ciphertext
|
|
91
|
+
);
|
|
92
|
+
// Always burn this request, but never disable/cancel a replacement enrollment.
|
|
93
|
+
const cancelled = await cancelEscrowHold(hold._id, 'system', 'pin-locked');
|
|
94
|
+
if (cancelled) void notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey });
|
|
95
|
+
if (!disabled) return;
|
|
96
|
+
for (const provider of userKey.authProviders) {
|
|
97
|
+
const pending = await findPendingEscrowHoldByAuthProvider(provider, 'pin');
|
|
98
|
+
if (pending?.releasePolicy === 'pin' && pending.shareVersion === hold.shareVersion) {
|
|
99
|
+
const cancelled = await cancelEscrowHold(pending._id, 'system', 'pin-locked');
|
|
100
|
+
if (cancelled)
|
|
101
|
+
void notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
let holdIndexes: Promise<void> | undefined;
|
|
106
|
+
|
|
107
|
+
// Lambda and in-process callers do not run docker-entry's index initialization.
|
|
108
|
+
const ensureHoldIndexes = async (): Promise<void> => {
|
|
109
|
+
holdIndexes ??= createEscrowHoldsIndexes().catch(() => {
|
|
110
|
+
holdIndexes = undefined;
|
|
111
|
+
throw new TRPCError({
|
|
112
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
113
|
+
message: 'Recovery could not be started.',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
await holdIndexes;
|
|
117
|
+
};
|
|
118
|
+
const successValidator = z.object({ success: z.literal(true) });
|
|
119
|
+
const resumeInput = z
|
|
120
|
+
.object({
|
|
121
|
+
holdId: z.string().uuid(),
|
|
122
|
+
resumeToken: z.string().min(1).max(512),
|
|
123
|
+
})
|
|
124
|
+
.strict();
|
|
125
|
+
const holdStatusValidator = z.object({
|
|
126
|
+
holdId: z.string(),
|
|
127
|
+
status: z.enum(['pending', 'cancelled', 'completed', 'expired']),
|
|
128
|
+
requestedAt: z.string(),
|
|
129
|
+
releaseAfter: z.string(),
|
|
130
|
+
releasePolicy: z.enum(['hold', 'pin']),
|
|
131
|
+
cancelledAt: z.string().optional(),
|
|
132
|
+
completedAt: z.string().optional(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> => ({
|
|
136
|
+
holdId: hold._id,
|
|
137
|
+
status: hold.status,
|
|
138
|
+
requestedAt: hold.requestedAt.toISOString(),
|
|
139
|
+
releaseAfter: hold.releaseAfter.toISOString(),
|
|
140
|
+
releasePolicy: hold.releasePolicy ?? 'hold',
|
|
141
|
+
...(hold.cancelledAt ? { cancelledAt: hold.cancelledAt.toISOString() } : {}),
|
|
142
|
+
...(hold.completedAt ? { completedAt: hold.completedAt.toISOString() } : {}),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const resumeTokenMatches = (hold: EscrowHold, token: string): boolean => {
|
|
146
|
+
const actual = Buffer.from(hashEscrowResumeToken(token), 'hex');
|
|
147
|
+
const expected = Buffer.from(hold.resumeTokenHash, 'hex');
|
|
148
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const hasConfirmedEscrow = (userKey: MongoUserKeyType, version: number): boolean =>
|
|
152
|
+
userKey.recoveryMethods.some(
|
|
153
|
+
method =>
|
|
154
|
+
method.type === 'escrow' &&
|
|
155
|
+
method.shareVersion === version &&
|
|
156
|
+
isRecoveryMethodConfirmed(userKey, method)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
/** Never propagate enclave internals (or cryptographic payloads) into tRPC errors. */
|
|
160
|
+
const enclaveOperation = async <T>(operation: () => Promise<T>, enrolling = false): Promise<T> => {
|
|
161
|
+
try {
|
|
162
|
+
return await operation();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
if (error instanceof EscrowUnavailableError) {
|
|
165
|
+
throw new TRPCError({
|
|
166
|
+
code: 'PRECONDITION_FAILED',
|
|
167
|
+
message: 'Escrow recovery is not available.',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (error instanceof EscrowPolicyError) {
|
|
171
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
172
|
+
}
|
|
173
|
+
if (enrolling && error instanceof EscrowBlobError) {
|
|
174
|
+
throw new TRPCError({
|
|
175
|
+
code: 'BAD_REQUEST',
|
|
176
|
+
message: 'Invalid automatic recovery material.',
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
throw new TRPCError({
|
|
180
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
181
|
+
message: 'Recovery material could not be released.',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// Strict objects with exclusive proof fields also remain compatible with OpenAPI's
|
|
187
|
+
// object-only parameter generation (unlike a top-level Zod union).
|
|
188
|
+
const startInput = z
|
|
189
|
+
.object({
|
|
190
|
+
clientEphemeralPublicKey: z.string().min(1).max(512),
|
|
191
|
+
releasePolicy: z.enum(['hold', 'pin']).default('hold'),
|
|
192
|
+
restart: z.boolean().optional(),
|
|
193
|
+
authToken: z.string().optional(),
|
|
194
|
+
providerType: AuthProviderTypeValidator.optional(),
|
|
195
|
+
recoverySessionToken: RecoverySessionTokenValidator.optional(),
|
|
196
|
+
})
|
|
197
|
+
.strict()
|
|
198
|
+
.refine(
|
|
199
|
+
input =>
|
|
200
|
+
input.recoverySessionToken !== undefined
|
|
201
|
+
? input.authToken === undefined && input.providerType === undefined
|
|
202
|
+
: input.authToken !== undefined && input.providerType !== undefined,
|
|
203
|
+
{ message: 'Provide exactly one identity proof.' }
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
// P0-4: this is a GET route, so neither the resume token nor the provider
|
|
207
|
+
// token may travel in the query string (proxy/ALB access logs). Raw-fetch
|
|
208
|
+
// callers send whichever secret applies as the X-Auth-Token header
|
|
209
|
+
// (ctx.providerToken); the optional input fields exist only for native tRPC
|
|
210
|
+
// callers whose batch link always POSTs.
|
|
211
|
+
const statusInput = z
|
|
212
|
+
.object({
|
|
213
|
+
holdId: z.string().uuid().optional(),
|
|
214
|
+
resumeToken: z.string().min(1).max(512).optional(),
|
|
215
|
+
authToken: z.string().optional(),
|
|
216
|
+
providerType: AuthProviderTypeValidator.optional(),
|
|
217
|
+
})
|
|
218
|
+
.strict()
|
|
219
|
+
.refine(
|
|
220
|
+
input =>
|
|
221
|
+
input.holdId !== undefined
|
|
222
|
+
? input.authToken === undefined && input.providerType === undefined
|
|
223
|
+
: input.resumeToken === undefined && input.providerType !== undefined,
|
|
224
|
+
{ message: 'Provide exactly one identity proof.' }
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
export const escrowRouter = t.router({
|
|
228
|
+
getAttestation: openRoute
|
|
229
|
+
.meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
|
|
230
|
+
.input(z.object({}).strict())
|
|
231
|
+
.output(
|
|
232
|
+
z.object({
|
|
233
|
+
attestation: z.object({
|
|
234
|
+
mode: z.enum(['software', 'nitro']),
|
|
235
|
+
keyId: z.string(),
|
|
236
|
+
publicKey: z.string(),
|
|
237
|
+
measurements: EscrowBlobValidator.shape.measurements,
|
|
238
|
+
document: z.string(),
|
|
239
|
+
issuedAt: z.string(),
|
|
240
|
+
}),
|
|
241
|
+
holdDurationMs: z.number(),
|
|
242
|
+
})
|
|
243
|
+
)
|
|
244
|
+
.query(async () => ({
|
|
245
|
+
attestation: await enclaveOperation(() => getEscrowEnclave().getAttestation()),
|
|
246
|
+
holdDurationMs: getEscrowHoldDurationMs(),
|
|
247
|
+
})),
|
|
248
|
+
|
|
249
|
+
enroll: didAndChallengeRoute
|
|
250
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow', tags: ['Keys'] } })
|
|
251
|
+
.input(
|
|
252
|
+
AuthInputValidator.extend({
|
|
253
|
+
envelope: EscrowEnvelopeValidator,
|
|
254
|
+
shareVersion: z.number().int().positive(),
|
|
255
|
+
enclaveKeyId: EscrowBlobValidator.shape.enclaveKeyId,
|
|
256
|
+
pinSalt: EscrowPinSaltValidator.optional(),
|
|
257
|
+
clearPin: z.boolean().optional(),
|
|
258
|
+
}).strict()
|
|
259
|
+
)
|
|
260
|
+
.output(successValidator.extend({ shareVersion: z.number() }))
|
|
261
|
+
.mutation(async ({ input, ctx }) => {
|
|
262
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
263
|
+
const userKey = await requireUserKey(authProvider);
|
|
264
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
265
|
+
if (userKey.escrowOptedOutAt) {
|
|
266
|
+
throw new TRPCError({
|
|
267
|
+
code: 'CONFLICT',
|
|
268
|
+
message: 'Automatic recovery is turned off for this account.',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (input.shareVersion !== userKey.shareVersion) {
|
|
272
|
+
throw new TRPCError({
|
|
273
|
+
code: 'CONFLICT',
|
|
274
|
+
message: 'Key material changed; please retry.',
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
const attestation = await enclaveOperation(() => getEscrowEnclave().getAttestation());
|
|
278
|
+
if (
|
|
279
|
+
input.enclaveKeyId !== attestation.keyId ||
|
|
280
|
+
input.envelope.keyId !== attestation.keyId
|
|
281
|
+
) {
|
|
282
|
+
throw new TRPCError({
|
|
283
|
+
code: 'BAD_REQUEST',
|
|
284
|
+
message: 'Invalid automatic recovery key.',
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
const verification = await enclaveOperation(
|
|
288
|
+
() =>
|
|
289
|
+
getEscrowEnclave().verifyEscrowBlob({
|
|
290
|
+
envelope: input.envelope,
|
|
291
|
+
expectedDid: ctx.user.did,
|
|
292
|
+
expectedShareVersion: input.shareVersion,
|
|
293
|
+
}),
|
|
294
|
+
true
|
|
295
|
+
);
|
|
296
|
+
if (
|
|
297
|
+
!verification.ok ||
|
|
298
|
+
verification.hasPin !== !!input.pinSalt ||
|
|
299
|
+
(input.clearPin && input.pinSalt)
|
|
300
|
+
) {
|
|
301
|
+
throw new TRPCError({
|
|
302
|
+
code: 'BAD_REQUEST',
|
|
303
|
+
message: 'Invalid automatic recovery material.',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
const blob = {
|
|
307
|
+
envelope: input.envelope,
|
|
308
|
+
enclaveKeyId: attestation.keyId,
|
|
309
|
+
enclaveMode: attestation.mode,
|
|
310
|
+
measurements: attestation.measurements,
|
|
311
|
+
shareVersion: input.shareVersion,
|
|
312
|
+
createdAt: new Date(),
|
|
313
|
+
};
|
|
314
|
+
let stored;
|
|
315
|
+
const oldBlob = userKey.escrowBlob;
|
|
316
|
+
const oldPin = userKey.escrowPin;
|
|
317
|
+
if (!input.pinSalt && !input.clearPin && oldPin) {
|
|
318
|
+
if (
|
|
319
|
+
oldBlob &&
|
|
320
|
+
!oldPin.disabledAt &&
|
|
321
|
+
oldPin.shareVersion === oldBlob.shareVersion &&
|
|
322
|
+
oldBlob.enclaveKeyId === attestation.keyId &&
|
|
323
|
+
input.shareVersion > oldBlob.shareVersion &&
|
|
324
|
+
// A released blob's PIN is retired: it was just used, or the user forgot it.
|
|
325
|
+
!(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion))
|
|
326
|
+
) {
|
|
327
|
+
try {
|
|
328
|
+
const carried = await getEscrowEnclave().carryPinVerifier({
|
|
329
|
+
sourceEnvelope: oldBlob.envelope,
|
|
330
|
+
targetEnvelope: input.envelope,
|
|
331
|
+
expectedDid: ctx.user.did,
|
|
332
|
+
sourceShareVersion: oldBlob.shareVersion,
|
|
333
|
+
targetShareVersion: input.shareVersion,
|
|
334
|
+
});
|
|
335
|
+
stored = await setEscrowBlobByAuthProvider(
|
|
336
|
+
authProvider,
|
|
337
|
+
{ ...blob, envelope: carried.envelope },
|
|
338
|
+
input.shareVersion,
|
|
339
|
+
undefined,
|
|
340
|
+
{ blob: oldBlob, pin: oldPin }
|
|
341
|
+
);
|
|
342
|
+
} catch {
|
|
343
|
+
// Never log enclave errors: a backend could include decrypted material.
|
|
344
|
+
console.warn('[LCA escrow/enroll] PIN carry failed; enrolling without PIN');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (!stored) {
|
|
348
|
+
console.warn(
|
|
349
|
+
'[LCA escrow/enroll] PIN carry unavailable; enrolling without PIN'
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
stored ??= await setEscrowBlobByAuthProvider(
|
|
354
|
+
authProvider,
|
|
355
|
+
blob,
|
|
356
|
+
input.shareVersion,
|
|
357
|
+
input.pinSalt ? { salt: input.pinSalt } : undefined
|
|
358
|
+
);
|
|
359
|
+
if (!stored)
|
|
360
|
+
throw new TRPCError({
|
|
361
|
+
code: 'CONFLICT',
|
|
362
|
+
message: 'Key material changed; please retry.',
|
|
363
|
+
});
|
|
364
|
+
return { success: true as const, shareVersion: input.shareVersion };
|
|
365
|
+
}),
|
|
366
|
+
|
|
367
|
+
remove: didAndChallengeRoute
|
|
368
|
+
.meta({ openapi: { method: 'DELETE', path: '/keys/escrow', tags: ['Keys'] } })
|
|
369
|
+
.input(AuthInputValidator.extend({ optOut: z.boolean() }).strict())
|
|
370
|
+
.output(successValidator)
|
|
371
|
+
.mutation(async ({ input, ctx }) => {
|
|
372
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
373
|
+
const userKey = await requireUserKey(authProvider);
|
|
374
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
375
|
+
if (
|
|
376
|
+
input.optOut &&
|
|
377
|
+
!userKey.recoveryMethods.some(
|
|
378
|
+
method =>
|
|
379
|
+
method.type !== 'escrow' &&
|
|
380
|
+
method.shareVersion === userKey.shareVersion &&
|
|
381
|
+
isRecoveryMethodConfirmed(userKey, method)
|
|
382
|
+
)
|
|
383
|
+
) {
|
|
384
|
+
throw new TRPCError({
|
|
385
|
+
code: 'PRECONDITION_FAILED',
|
|
386
|
+
message:
|
|
387
|
+
'Set up another recovery method before turning off automatic recovery.',
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
await clearEscrowByAuthProvider(authProvider, { optOut: input.optOut });
|
|
391
|
+
for (const policy of ['hold', 'pin'] as const) {
|
|
392
|
+
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
|
|
393
|
+
const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
394
|
+
if (cancelled)
|
|
395
|
+
void notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey });
|
|
396
|
+
}
|
|
397
|
+
return { success: true as const };
|
|
398
|
+
}),
|
|
399
|
+
|
|
400
|
+
optIn: didAndChallengeRoute
|
|
401
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/opt-in', tags: ['Keys'] } })
|
|
402
|
+
.input(AuthInputValidator.strict())
|
|
403
|
+
.output(successValidator)
|
|
404
|
+
.mutation(async ({ input, ctx }) => {
|
|
405
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
406
|
+
const userKey = await requireUserKey(authProvider);
|
|
407
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
408
|
+
await setEscrowOptInByAuthProvider(authProvider);
|
|
409
|
+
return { success: true as const };
|
|
410
|
+
}),
|
|
411
|
+
|
|
412
|
+
startRecovery: openRoute
|
|
413
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/recover', tags: ['Keys'] } })
|
|
414
|
+
.input(startInput)
|
|
415
|
+
.output(
|
|
416
|
+
holdStatusValidator
|
|
417
|
+
.pick({ holdId: true, requestedAt: true, releaseAfter: true, releasePolicy: true })
|
|
418
|
+
.extend({
|
|
419
|
+
status: z.literal('pending'),
|
|
420
|
+
resumeToken: z.string().nullable(),
|
|
421
|
+
pinSalt: EscrowPinSaltValidator.optional(),
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
.mutation(async ({ input, ctx }) => {
|
|
425
|
+
let authProvider: AuthProviderMapping;
|
|
426
|
+
if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
|
|
427
|
+
if (input.recoverySessionToken !== undefined) {
|
|
428
|
+
try {
|
|
429
|
+
authProvider = await requireRecoverySession(
|
|
430
|
+
input.recoverySessionToken,
|
|
431
|
+
'recover'
|
|
432
|
+
);
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (!(error instanceof TRPCError) || error.code !== 'UNAUTHORIZED') throw error;
|
|
435
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
436
|
+
}
|
|
437
|
+
} else {
|
|
438
|
+
({ authProvider } = await verifyAndGetContactMethod({
|
|
439
|
+
authToken: input.authToken!,
|
|
440
|
+
providerType: input.providerType!,
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
|
|
444
|
+
if (
|
|
445
|
+
!userKey?.escrowBlob ||
|
|
446
|
+
userKey.escrowOptedOutAt ||
|
|
447
|
+
!hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion)
|
|
448
|
+
) {
|
|
449
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
450
|
+
}
|
|
451
|
+
const now = new Date();
|
|
452
|
+
if (
|
|
453
|
+
input.releasePolicy === 'pin' &&
|
|
454
|
+
(!userKey.escrowPin ||
|
|
455
|
+
userKey.escrowPin.disabledAt ||
|
|
456
|
+
userKey.escrowPin.shareVersion !== userKey.shareVersion)
|
|
457
|
+
) {
|
|
458
|
+
throw new TRPCError({
|
|
459
|
+
code: 'FORBIDDEN',
|
|
460
|
+
message: ESCROW_PIN_UNAVAILABLE_MESSAGE,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
await expireStaleEscrowHolds(now);
|
|
464
|
+
await ensureHoldIndexes();
|
|
465
|
+
// Policies coexist; reuse waiting holds only while their material is current.
|
|
466
|
+
const pending = await findPendingEscrowHoldByAuthProvider(
|
|
467
|
+
authProvider,
|
|
468
|
+
input.releasePolicy
|
|
469
|
+
);
|
|
470
|
+
if (
|
|
471
|
+
pending &&
|
|
472
|
+
input.releasePolicy === 'hold' &&
|
|
473
|
+
(pending.releasePolicy ?? 'hold') === 'hold' &&
|
|
474
|
+
pending.shareVersion === userKey.escrowBlob.shareVersion
|
|
475
|
+
) {
|
|
476
|
+
if (!input.restart)
|
|
477
|
+
return {
|
|
478
|
+
...serializeHold(pending),
|
|
479
|
+
status: 'pending' as const,
|
|
480
|
+
resumeToken: null,
|
|
481
|
+
};
|
|
482
|
+
const minAge = getEscrowHoldRestartMinAgeMs();
|
|
483
|
+
if (now.getTime() - pending.requestedAt.getTime() < minAge) {
|
|
484
|
+
const retryAfter = new Date(
|
|
485
|
+
pending.requestedAt.getTime() + minAge
|
|
486
|
+
).toISOString();
|
|
487
|
+
throw new TRPCError({
|
|
488
|
+
code: 'TOO_MANY_REQUESTS',
|
|
489
|
+
message: `A recovery request was started recently. Try again after ${retryAfter}.`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if (pending) {
|
|
494
|
+
const cancelled = await cancelEscrowHold(pending._id, 'system', 'superseded');
|
|
495
|
+
if (cancelled)
|
|
496
|
+
void notifyEscrowHoldEvent({
|
|
497
|
+
kind: 'cancelled',
|
|
498
|
+
hold: cancelled,
|
|
499
|
+
userKey,
|
|
500
|
+
reason: 'superseded',
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
const resumeToken = generateEscrowResumeToken();
|
|
504
|
+
let hold: EscrowHold;
|
|
505
|
+
try {
|
|
506
|
+
hold = await createEscrowHold({
|
|
507
|
+
authProvider,
|
|
508
|
+
primaryDid: userKey.primaryDid,
|
|
509
|
+
shareVersion: userKey.escrowBlob.shareVersion,
|
|
510
|
+
identityProofType:
|
|
511
|
+
input.recoverySessionToken !== undefined
|
|
512
|
+
? 'recovery-session'
|
|
513
|
+
: 'auth-token',
|
|
514
|
+
requestedAt: now,
|
|
515
|
+
releaseAfter:
|
|
516
|
+
input.releasePolicy === 'pin'
|
|
517
|
+
? now
|
|
518
|
+
: new Date(now.getTime() + getEscrowHoldDurationMs()),
|
|
519
|
+
releasePolicy: input.releasePolicy,
|
|
520
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
521
|
+
resumeTokenHash: hashEscrowResumeToken(resumeToken),
|
|
522
|
+
requestIp: ctx.clientIp,
|
|
523
|
+
});
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (
|
|
526
|
+
typeof error === 'object' &&
|
|
527
|
+
error !== null &&
|
|
528
|
+
'code' in error &&
|
|
529
|
+
error.code === 11000
|
|
530
|
+
) {
|
|
531
|
+
const raced = await findPendingEscrowHoldByAuthProvider(
|
|
532
|
+
authProvider,
|
|
533
|
+
input.releasePolicy
|
|
534
|
+
);
|
|
535
|
+
if (
|
|
536
|
+
raced &&
|
|
537
|
+
!input.restart &&
|
|
538
|
+
input.releasePolicy === 'hold' &&
|
|
539
|
+
(raced.releasePolicy ?? 'hold') === 'hold' &&
|
|
540
|
+
raced.shareVersion === userKey.escrowBlob.shareVersion
|
|
541
|
+
)
|
|
542
|
+
return {
|
|
543
|
+
...serializeHold(raced),
|
|
544
|
+
status: 'pending' as const,
|
|
545
|
+
resumeToken: null,
|
|
546
|
+
};
|
|
547
|
+
throw new TRPCError({
|
|
548
|
+
code: 'CONFLICT',
|
|
549
|
+
message: 'Recovery state changed; please retry.',
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
throw new TRPCError({
|
|
553
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
554
|
+
message: 'Recovery could not be started.',
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
void notifyEscrowHoldEvent({ kind: 'started', hold, userKey });
|
|
558
|
+
return {
|
|
559
|
+
...serializeHold(hold),
|
|
560
|
+
status: 'pending' as const,
|
|
561
|
+
resumeToken,
|
|
562
|
+
...(input.releasePolicy === 'pin' ? { pinSalt: userKey.escrowPin!.salt } : {}),
|
|
563
|
+
};
|
|
564
|
+
}),
|
|
565
|
+
|
|
566
|
+
getStatus: openRoute
|
|
567
|
+
.meta({ openapi: { method: 'GET', path: '/keys/escrow/status', tags: ['Keys'] } })
|
|
568
|
+
.input(statusInput)
|
|
569
|
+
.output(z.object({ hold: holdStatusValidator.nullable() }))
|
|
570
|
+
.query(async ({ input, ctx }) => {
|
|
571
|
+
await expireStaleEscrowHolds(new Date());
|
|
572
|
+
let hold: EscrowHold | null;
|
|
573
|
+
if (input.holdId !== undefined) {
|
|
574
|
+
const resumeToken = input.resumeToken || ctx.providerToken || '';
|
|
575
|
+
hold = resumeToken ? await findEscrowHoldById(input.holdId) : null;
|
|
576
|
+
if (hold && !resumeTokenMatches(hold, resumeToken)) hold = null;
|
|
577
|
+
} else {
|
|
578
|
+
const { authProvider } = await verifyAndGetContactMethod({
|
|
579
|
+
authToken: input.authToken || ctx.providerToken || '',
|
|
580
|
+
providerType: input.providerType!,
|
|
581
|
+
});
|
|
582
|
+
hold = await findPendingEscrowHoldByAuthProvider(authProvider, 'hold');
|
|
583
|
+
}
|
|
584
|
+
return { hold: hold ? serializeHold(hold) : null };
|
|
585
|
+
}),
|
|
586
|
+
|
|
587
|
+
cancelRecovery: didAndChallengeRoute
|
|
588
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/cancel', tags: ['Keys'] } })
|
|
589
|
+
.input(AuthInputValidator.strict())
|
|
590
|
+
.output(successValidator.extend({ cancelled: z.boolean() }))
|
|
591
|
+
.mutation(async ({ input, ctx }) => {
|
|
592
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
593
|
+
const userKey = await requireUserKey(authProvider);
|
|
594
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
595
|
+
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, 'hold');
|
|
596
|
+
const hold = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
597
|
+
if (hold) void notifyEscrowHoldEvent({ kind: 'cancelled', hold, userKey });
|
|
598
|
+
return { success: true as const, cancelled: Boolean(hold) };
|
|
599
|
+
}),
|
|
600
|
+
|
|
601
|
+
completeRecovery: openRoute
|
|
602
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/complete', tags: ['Keys'] } })
|
|
603
|
+
.input(
|
|
604
|
+
resumeInput.extend({
|
|
605
|
+
pinProof: z
|
|
606
|
+
.string()
|
|
607
|
+
.regex(/^[0-9a-f]{64}$/i)
|
|
608
|
+
.optional(),
|
|
609
|
+
})
|
|
610
|
+
)
|
|
611
|
+
.output(
|
|
612
|
+
z.object({
|
|
613
|
+
sealedShare: EscrowEnvelopeValidator,
|
|
614
|
+
authShare: ServerEncryptedShareValidator,
|
|
615
|
+
primaryDid: z.string(),
|
|
616
|
+
shareVersion: z.number(),
|
|
617
|
+
rebindSessionToken: RecoverySessionTokenValidator,
|
|
618
|
+
})
|
|
619
|
+
)
|
|
620
|
+
.mutation(async ({ input, ctx }) => {
|
|
621
|
+
if (input.pinProof !== undefined) await limitPinCompletion(ctx.clientIp);
|
|
622
|
+
const hold = await findEscrowHoldById(input.holdId);
|
|
623
|
+
if (!hold || !resumeTokenMatches(hold, input.resumeToken)) {
|
|
624
|
+
throw new TRPCError({
|
|
625
|
+
code: 'FORBIDDEN',
|
|
626
|
+
message: 'This recovery request is invalid.',
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
if (hold.status === 'cancelled') {
|
|
630
|
+
throw new TRPCError({
|
|
631
|
+
code: 'FORBIDDEN',
|
|
632
|
+
message: 'This recovery request was cancelled.',
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
if (hold.status !== 'pending')
|
|
636
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
637
|
+
const now = new Date();
|
|
638
|
+
if (now < hold.releaseAfter) {
|
|
639
|
+
throw new TRPCError({
|
|
640
|
+
code: 'FORBIDDEN',
|
|
641
|
+
message: 'This recovery request is still in its waiting period.',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
645
|
+
hold.authProvider.type,
|
|
646
|
+
hold.authProvider.id
|
|
647
|
+
);
|
|
648
|
+
if (
|
|
649
|
+
!userKey?.escrowBlob ||
|
|
650
|
+
userKey.escrowOptedOutAt ||
|
|
651
|
+
userKey.escrowBlob.shareVersion !== hold.shareVersion ||
|
|
652
|
+
!hasConfirmedEscrow(userKey, hold.shareVersion)
|
|
653
|
+
) {
|
|
654
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
655
|
+
}
|
|
656
|
+
const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
|
|
657
|
+
if (!encryptedAuthShare || !environment.SEED) {
|
|
658
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
659
|
+
}
|
|
660
|
+
const expectedCiphertext = userKey.escrowBlob.envelope.ciphertext;
|
|
661
|
+
const reserved =
|
|
662
|
+
hold.releasePolicy === 'pin'
|
|
663
|
+
? await reserveEscrowPinAttempt(
|
|
664
|
+
hold.authProvider,
|
|
665
|
+
hold.shareVersion,
|
|
666
|
+
expectedCiphertext
|
|
667
|
+
)
|
|
668
|
+
: undefined;
|
|
669
|
+
if (reserved === null) {
|
|
670
|
+
const current = await findUserKeyByAuthProvider(
|
|
671
|
+
hold.authProvider.type,
|
|
672
|
+
hold.authProvider.id
|
|
673
|
+
);
|
|
674
|
+
if (
|
|
675
|
+
!current?.escrowPin ||
|
|
676
|
+
current.escrowPin.disabledAt ||
|
|
677
|
+
current.escrowPin.shareVersion !== hold.shareVersion ||
|
|
678
|
+
current.shareVersion !== hold.shareVersion ||
|
|
679
|
+
current.escrowBlob?.envelope.ciphertext !== expectedCiphertext
|
|
680
|
+
) {
|
|
681
|
+
throw new TRPCError({
|
|
682
|
+
code: 'FORBIDDEN',
|
|
683
|
+
message: ESCROW_PIN_UNAVAILABLE_MESSAGE,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (current.escrowPin.failedAttempts >= ESCROW_PIN_MAX_ATTEMPTS) {
|
|
687
|
+
throw new TRPCError({
|
|
688
|
+
code: 'TOO_MANY_REQUESTS',
|
|
689
|
+
message: pinThrottledMessage,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
throw new TRPCError({
|
|
693
|
+
code: 'CONFLICT',
|
|
694
|
+
message: 'Recovery state changed; please retry.',
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
// Claim the pending hold BEFORE releasing secrets. The enclave receives the
|
|
698
|
+
// pending snapshot whose CAS we won; release failures burn the hold (fail-closed).
|
|
699
|
+
let completed: Awaited<ReturnType<typeof completeEscrowHold>> = null;
|
|
700
|
+
try {
|
|
701
|
+
completed = await completeEscrowHold(hold._id);
|
|
702
|
+
if (!completed) {
|
|
703
|
+
throw new TRPCError({
|
|
704
|
+
code: 'CONFLICT',
|
|
705
|
+
message: 'Recovery state changed; please retry.',
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
// Revalidate after the claim so a rotation/removal cannot release the old snapshot.
|
|
709
|
+
const current = await findUserKeyByAuthProvider(
|
|
710
|
+
hold.authProvider.type,
|
|
711
|
+
hold.authProvider.id
|
|
712
|
+
);
|
|
713
|
+
if (
|
|
714
|
+
!current?.escrowBlob ||
|
|
715
|
+
current.escrowOptedOutAt ||
|
|
716
|
+
current.shareVersion !== userKey.shareVersion ||
|
|
717
|
+
current.escrowBlob.shareVersion !== hold.shareVersion ||
|
|
718
|
+
current.escrowBlob.envelope.ciphertext !== expectedCiphertext ||
|
|
719
|
+
!hasConfirmedEscrow(current, hold.shareVersion)
|
|
720
|
+
) {
|
|
721
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
722
|
+
}
|
|
723
|
+
} catch (error) {
|
|
724
|
+
// No enclave evaluation occurred, including when Mongo throws rather
|
|
725
|
+
// than returning a lost CAS. Refund before any fallible hold cleanup.
|
|
726
|
+
if (reserved)
|
|
727
|
+
await refundEscrowPinAttempt(
|
|
728
|
+
hold.authProvider,
|
|
729
|
+
hold.shareVersion,
|
|
730
|
+
expectedCiphertext
|
|
731
|
+
);
|
|
732
|
+
if (completed)
|
|
733
|
+
await markClaimedEscrowHoldFailed(
|
|
734
|
+
hold._id,
|
|
735
|
+
'release-failed',
|
|
736
|
+
completed.completedAt!
|
|
737
|
+
);
|
|
738
|
+
throw error;
|
|
739
|
+
}
|
|
740
|
+
const release = await enclaveOperation(async () => {
|
|
741
|
+
try {
|
|
742
|
+
return {
|
|
743
|
+
result: await getEscrowEnclave().releaseEscrow({
|
|
744
|
+
envelope: (reserved ?? userKey).escrowBlob!.envelope,
|
|
745
|
+
hold,
|
|
746
|
+
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
747
|
+
expectedDid: userKey.primaryDid,
|
|
748
|
+
now,
|
|
749
|
+
pinProof: input.pinProof,
|
|
750
|
+
}),
|
|
751
|
+
mismatch: false as const,
|
|
752
|
+
};
|
|
753
|
+
} catch (error) {
|
|
754
|
+
// Keep the typed mismatch inside the wrapper; never expose enclave internals.
|
|
755
|
+
if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError)
|
|
756
|
+
return { mismatch: true as const };
|
|
757
|
+
if (reserved)
|
|
758
|
+
await refundEscrowPinAttempt(
|
|
759
|
+
hold.authProvider,
|
|
760
|
+
hold.shareVersion,
|
|
761
|
+
expectedCiphertext
|
|
762
|
+
);
|
|
763
|
+
await markClaimedEscrowHoldFailed(
|
|
764
|
+
hold._id,
|
|
765
|
+
'release-failed',
|
|
766
|
+
completed.completedAt!
|
|
767
|
+
);
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
if (release.mismatch) {
|
|
772
|
+
const failed = await recordEscrowPinFailure(
|
|
773
|
+
hold.authProvider,
|
|
774
|
+
hold.shareVersion,
|
|
775
|
+
expectedCiphertext
|
|
776
|
+
);
|
|
777
|
+
const attemptsRemaining =
|
|
778
|
+
ESCROW_PIN_MAX_ATTEMPTS -
|
|
779
|
+
(failed?.escrowPin?.failedAttempts ?? reserved!.escrowPin!.failedAttempts);
|
|
780
|
+
const locked = !!failed?.escrowPin?.disabledAt;
|
|
781
|
+
const cancelReason = locked ? 'pin-locked' : 'pin-mismatch';
|
|
782
|
+
await markClaimedEscrowHoldFailed(hold._id, cancelReason, completed.completedAt!);
|
|
783
|
+
void notifyEscrowHoldEvent({
|
|
784
|
+
kind: 'cancelled',
|
|
785
|
+
hold: {
|
|
786
|
+
...completed,
|
|
787
|
+
status: 'cancelled',
|
|
788
|
+
cancelledBy: 'system',
|
|
789
|
+
cancelReason,
|
|
790
|
+
cancelledAt: new Date(),
|
|
791
|
+
},
|
|
792
|
+
userKey,
|
|
793
|
+
});
|
|
794
|
+
if (locked) {
|
|
795
|
+
await lockPin(hold, userKey);
|
|
796
|
+
throw new TRPCError({
|
|
797
|
+
code: 'TOO_MANY_REQUESTS',
|
|
798
|
+
message: ESCROW_PIN_LOCKED_MESSAGE,
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
// No structured error-data convention exists: the client parses this exact text.
|
|
802
|
+
throw new TRPCError({
|
|
803
|
+
code: 'FORBIDDEN',
|
|
804
|
+
message: escrowPinMismatchMessage(attemptsRemaining),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
const { sealed } = release.result;
|
|
808
|
+
// A successful evaluation also consumes the lifetime budget; do not
|
|
809
|
+
// erase other requests' reservations or replenish guesses here.
|
|
810
|
+
const authShare = await enclaveOperation(async () =>
|
|
811
|
+
decryptAuthShare(encryptedAuthShare, environment.SEED)
|
|
812
|
+
);
|
|
813
|
+
const rebindSessionToken = await createRecoverySession({
|
|
814
|
+
scope: 'rebind',
|
|
815
|
+
authProvider: hold.authProvider,
|
|
816
|
+
});
|
|
817
|
+
void notifyEscrowHoldEvent({ kind: 'completed', hold: completed, userKey });
|
|
818
|
+
return {
|
|
819
|
+
sealedShare: sealed,
|
|
820
|
+
authShare,
|
|
821
|
+
primaryDid: userKey.primaryDid,
|
|
822
|
+
shareVersion: hold.shareVersion,
|
|
823
|
+
rebindSessionToken,
|
|
824
|
+
};
|
|
825
|
+
}),
|
|
826
|
+
});
|