@learncard/lca-api-service 1.3.2 → 1.5.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 +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -0
- 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/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- 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/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -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/learnCard.signingAuthority.test.d.ts +1 -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/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/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/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- 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 +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- 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 +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- 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/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -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 +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- 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 +13 -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/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/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/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/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/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- 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 +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- 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 +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -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 +2328 -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/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- 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-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
import { timingSafeEqual, randomUUID } 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, isEscrowReleaseKillSwitchEnabled } from '@environment';
|
|
12
|
+
import { t, openRoute, didAndChallengeRoute, type Context } from '@routes';
|
|
13
|
+
import { createRecoverySession } from '@cache/recoverySessions';
|
|
14
|
+
import { decryptAuthShare } from '@helpers/shareEncryption.helpers';
|
|
15
|
+
import {
|
|
16
|
+
generateEscrowCancelToken,
|
|
17
|
+
hashEscrowCancelToken,
|
|
18
|
+
escrowCancelTokenMatches,
|
|
19
|
+
} from '@helpers/escrowCancelToken';
|
|
20
|
+
import {
|
|
21
|
+
EscrowEnvelopeValidator,
|
|
22
|
+
EscrowBlobValidator,
|
|
23
|
+
EscrowPinSaltValidator,
|
|
24
|
+
reserveEscrowPinAttempt,
|
|
25
|
+
refundEscrowPinAttempt,
|
|
26
|
+
recordEscrowPinFailure,
|
|
27
|
+
disableEscrowPin,
|
|
28
|
+
markClaimedEscrowHoldFailed,
|
|
29
|
+
retryClaimedEscrowHold,
|
|
30
|
+
ServerEncryptedShareValidator,
|
|
31
|
+
findUserKeyByAuthProvider,
|
|
32
|
+
findAuthShareByVersion,
|
|
33
|
+
isRecoveryMethodConfirmed,
|
|
34
|
+
setEscrowBlobByAuthProvider,
|
|
35
|
+
clearEscrowByAuthProvider,
|
|
36
|
+
setEscrowOptInByAuthProvider,
|
|
37
|
+
createEscrowHold,
|
|
38
|
+
createEscrowHoldsIndexes,
|
|
39
|
+
findPendingEscrowHoldByAuthProvider,
|
|
40
|
+
findEscrowHoldById,
|
|
41
|
+
cancelEscrowHold,
|
|
42
|
+
hasUnconfirmedEscrowCancellation,
|
|
43
|
+
cancelEscrowHoldByCancelToken,
|
|
44
|
+
completeEscrowHold,
|
|
45
|
+
hasCompletedEscrowHoldForVersion,
|
|
46
|
+
expireStaleEscrowHolds,
|
|
47
|
+
hashEscrowResumeToken,
|
|
48
|
+
generateEscrowResumeToken,
|
|
49
|
+
type EscrowHold,
|
|
50
|
+
type MongoUserKeyType,
|
|
51
|
+
type AuthProviderMapping,
|
|
52
|
+
} from '@models';
|
|
53
|
+
import {
|
|
54
|
+
AuthInputValidator,
|
|
55
|
+
AuthProviderTypeValidator,
|
|
56
|
+
RecoverySessionTokenValidator,
|
|
57
|
+
verifyAndGetContactMethod,
|
|
58
|
+
requireUserKey,
|
|
59
|
+
assertDidOwner,
|
|
60
|
+
requireRecoverySession,
|
|
61
|
+
} from './keys';
|
|
62
|
+
import {
|
|
63
|
+
getEscrowEnclave,
|
|
64
|
+
getEscrowHoldDurationMs,
|
|
65
|
+
getEscrowHoldRestartMinAgeMs,
|
|
66
|
+
isEscrowRemoteMode,
|
|
67
|
+
activeEscrowClientMode,
|
|
68
|
+
getEnclaveAttestationIdentity,
|
|
69
|
+
notifyEscrowHoldEvent,
|
|
70
|
+
EscrowPolicyError,
|
|
71
|
+
EscrowBlobError,
|
|
72
|
+
EscrowUnavailableError,
|
|
73
|
+
EscrowPinMismatchError,
|
|
74
|
+
} from '../services/escrow-enclave';
|
|
75
|
+
|
|
76
|
+
import { cancelHoldInEnclave } from '../services/escrow-enclave/cancellation';
|
|
77
|
+
const unavailableMessage = 'Automatic recovery is not available for this account.';
|
|
78
|
+
const killSwitchMessage = 'Automatic recovery is temporarily unavailable. Please try again later.';
|
|
79
|
+
const invalidMessage = 'This recovery request is no longer valid.';
|
|
80
|
+
const staleEscrowMessage = 'Automatic recovery needs to be set up again on a signed-in device.';
|
|
81
|
+
// Roughtime's release lower bound can lag its creation upper bound. Advertise
|
|
82
|
+
// an additional minute, and still allow retries when trusted time is unavailable.
|
|
83
|
+
const ESCROW_RELEASE_TIME_MARGIN_MS = 60_000;
|
|
84
|
+
// Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
|
|
85
|
+
const pinThrottledMessage = 'Please wait before trying again.';
|
|
86
|
+
|
|
87
|
+
// Follow qr-login's Redis INCR/EXPIRE per-IP limiter. keys.ts has only OTP-specific limits.
|
|
88
|
+
const limitPinCompletion = async (
|
|
89
|
+
clientIp: string | undefined,
|
|
90
|
+
operation: 'start' | 'complete' = 'complete'
|
|
91
|
+
): Promise<void> => {
|
|
92
|
+
const redis = cache.redis ?? cache.node;
|
|
93
|
+
const key = `escrow:pin-${operation}:${clientIp ?? 'unknown'}`;
|
|
94
|
+
const count = await redis.incr(key);
|
|
95
|
+
if (count === 1) await redis.expire(key, 60);
|
|
96
|
+
if (count > 20)
|
|
97
|
+
throw new TRPCError({
|
|
98
|
+
code: 'TOO_MANY_REQUESTS',
|
|
99
|
+
message: pinThrottledMessage,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Same Redis INCR/EXPIRE per-IP shape as limitPinCompletion, scoped under its
|
|
104
|
+
// own key so cancel-link attempts never share (or exhaust) the PIN budget.
|
|
105
|
+
const limitCancelLinkAttempts = async (clientIp: string | undefined): Promise<void> => {
|
|
106
|
+
const redis = cache.redis ?? cache.node;
|
|
107
|
+
const key = `escrow:cancel-link:${clientIp ?? 'unknown'}`;
|
|
108
|
+
const count = await redis.incr(key);
|
|
109
|
+
if (count === 1) await redis.expire(key, 60);
|
|
110
|
+
if (count > 20)
|
|
111
|
+
throw new TRPCError({
|
|
112
|
+
code: 'TOO_MANY_REQUESTS',
|
|
113
|
+
message: pinThrottledMessage,
|
|
114
|
+
});
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const lockPin = async (
|
|
118
|
+
hold: EscrowHold,
|
|
119
|
+
userKey: MongoUserKeyType,
|
|
120
|
+
tenant: Context['tenant']
|
|
121
|
+
): Promise<void> => {
|
|
122
|
+
const disabled = await disableEscrowPin(
|
|
123
|
+
hold.authProvider,
|
|
124
|
+
hold.shareVersion,
|
|
125
|
+
userKey.escrowBlob?.envelope.ciphertext
|
|
126
|
+
);
|
|
127
|
+
// Always burn this request, but never disable/cancel a replacement enrollment.
|
|
128
|
+
const cancelled = await cancelEscrowHold(hold._id, 'system', 'pin-locked');
|
|
129
|
+
if (cancelled) {
|
|
130
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
131
|
+
await notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey, tenant });
|
|
132
|
+
}
|
|
133
|
+
if (!disabled) return;
|
|
134
|
+
// The pin-locked notice's cancel link must point at the account's still-open
|
|
135
|
+
// waiting-period hold (if any) — `hold` itself is already terminal by this
|
|
136
|
+
// point, so a link rooted there could never authorize a cancel.
|
|
137
|
+
let activeHold: EscrowHold | undefined;
|
|
138
|
+
for (const provider of userKey.authProviders) {
|
|
139
|
+
activeHold = (await findPendingEscrowHoldByAuthProvider(provider, 'hold')) ?? undefined;
|
|
140
|
+
if (activeHold) break;
|
|
141
|
+
}
|
|
142
|
+
// One lockout notice per lock event, not per cancelled hold below — the PIN
|
|
143
|
+
// capability is account-wide, not per-hold.
|
|
144
|
+
await notifyEscrowHoldEvent({ kind: 'pin-locked', hold, userKey, tenant, activeHold });
|
|
145
|
+
for (const provider of userKey.authProviders) {
|
|
146
|
+
const pending = await findPendingEscrowHoldByAuthProvider(provider, 'pin');
|
|
147
|
+
if (pending?.releasePolicy === 'pin' && pending.shareVersion === hold.shareVersion) {
|
|
148
|
+
const cancelled = await cancelEscrowHold(pending._id, 'system', 'pin-locked');
|
|
149
|
+
if (cancelled) {
|
|
150
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
151
|
+
await notifyEscrowHoldEvent({
|
|
152
|
+
kind: 'cancelled',
|
|
153
|
+
hold: cancelled,
|
|
154
|
+
userKey,
|
|
155
|
+
tenant,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
let holdIndexes: Promise<void> | undefined;
|
|
162
|
+
|
|
163
|
+
// Lambda and in-process callers do not run docker-entry's index initialization.
|
|
164
|
+
const ensureHoldIndexes = async (): Promise<void> => {
|
|
165
|
+
holdIndexes ??= createEscrowHoldsIndexes().catch(() => {
|
|
166
|
+
holdIndexes = undefined;
|
|
167
|
+
throw new TRPCError({
|
|
168
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
169
|
+
message: 'Recovery could not be started.',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
await holdIndexes;
|
|
173
|
+
};
|
|
174
|
+
const successValidator = z.object({ success: z.literal(true) });
|
|
175
|
+
const cancelByLinkInput = z
|
|
176
|
+
.object({
|
|
177
|
+
holdId: z.string().uuid(),
|
|
178
|
+
token: z.string().regex(/^[0-9a-f]{64}$/),
|
|
179
|
+
})
|
|
180
|
+
.strict();
|
|
181
|
+
const resumeInput = z
|
|
182
|
+
.object({
|
|
183
|
+
holdId: z.string().uuid(),
|
|
184
|
+
resumeToken: z.string().min(1).max(512),
|
|
185
|
+
})
|
|
186
|
+
.strict();
|
|
187
|
+
const holdStatusValidator = z.object({
|
|
188
|
+
holdId: z.string(),
|
|
189
|
+
status: z.enum(['pending', 'cancelled', 'completed', 'expired']),
|
|
190
|
+
requestedAt: z.string(),
|
|
191
|
+
releaseAfter: z.string(),
|
|
192
|
+
releasePolicy: z.enum(['hold', 'pin']),
|
|
193
|
+
cancelledAt: z.string().optional(),
|
|
194
|
+
completedAt: z.string().optional(),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> => ({
|
|
198
|
+
holdId: hold._id,
|
|
199
|
+
status: hold.status,
|
|
200
|
+
requestedAt: hold.requestedAt.toISOString(),
|
|
201
|
+
releaseAfter: hold.releaseAfter.toISOString(),
|
|
202
|
+
releasePolicy: hold.releasePolicy ?? 'hold',
|
|
203
|
+
...(hold.cancelledAt ? { cancelledAt: hold.cancelledAt.toISOString() } : {}),
|
|
204
|
+
...(hold.completedAt ? { completedAt: hold.completedAt.toISOString() } : {}),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const resumeTokenMatches = (hold: EscrowHold, token: string): boolean => {
|
|
208
|
+
const actual = Buffer.from(hashEscrowResumeToken(token), 'hex');
|
|
209
|
+
const expected = Buffer.from(hold.resumeTokenHash, 'hex');
|
|
210
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const hasConfirmedEscrow = (userKey: MongoUserKeyType, version: number): boolean =>
|
|
214
|
+
userKey.recoveryMethods.some(
|
|
215
|
+
method =>
|
|
216
|
+
method.type === 'escrow' &&
|
|
217
|
+
method.shareVersion === version &&
|
|
218
|
+
isRecoveryMethodConfirmed(userKey, method)
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
/** Never propagate enclave internals (or cryptographic payloads) into tRPC errors. */
|
|
222
|
+
const enclaveOperation = async <T>(
|
|
223
|
+
operation: () => Promise<T>,
|
|
224
|
+
enrolling = false,
|
|
225
|
+
policyMessage = invalidMessage
|
|
226
|
+
): Promise<T> => {
|
|
227
|
+
try {
|
|
228
|
+
return await operation();
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof EscrowUnavailableError) {
|
|
231
|
+
throw new TRPCError({
|
|
232
|
+
code: 'PRECONDITION_FAILED',
|
|
233
|
+
message: 'Escrow recovery is not available.',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (error instanceof EscrowPolicyError) {
|
|
237
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: policyMessage });
|
|
238
|
+
}
|
|
239
|
+
if (enrolling && error instanceof EscrowBlobError) {
|
|
240
|
+
throw new TRPCError({
|
|
241
|
+
code: 'BAD_REQUEST',
|
|
242
|
+
message: 'Invalid automatic recovery material.',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
throw new TRPCError({
|
|
246
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
247
|
+
message: 'Recovery material could not be released.',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// A stored blob is stale when it was sealed for a different enclave backend
|
|
253
|
+
// (P6 software<->nitro migration/rollback) or a since-rotated enclave key
|
|
254
|
+
// (measurement rotation). Never forward a stale blob to createHold/releaseEscrow
|
|
255
|
+
// UNLESS the blob's keyId is a recognised previous key (P9.1/P9.2): the enclave
|
|
256
|
+
// can still decrypt/release it, so only an UNKNOWN keyId blocks the operation.
|
|
257
|
+
const assertFreshEscrowBlob = (blob: {
|
|
258
|
+
enclaveMode: 'software' | 'nitro';
|
|
259
|
+
enclaveKeyId: string;
|
|
260
|
+
}): Promise<void> =>
|
|
261
|
+
enclaveOperation(
|
|
262
|
+
async () => {
|
|
263
|
+
if (blob.enclaveMode !== activeEscrowClientMode()) throw new EscrowPolicyError();
|
|
264
|
+
// Fail-closed identity lookup: uses a warm cache when available,
|
|
265
|
+
// otherwise makes a real (normally-timed-out) enclave call and lets
|
|
266
|
+
// failure propagate — never treats an unreachable enclave as fresh.
|
|
267
|
+
const identity = await getEnclaveAttestationIdentity();
|
|
268
|
+
if (
|
|
269
|
+
blob.enclaveKeyId !== identity.keyId &&
|
|
270
|
+
!identity.previousKeyIds.includes(blob.enclaveKeyId)
|
|
271
|
+
) {
|
|
272
|
+
throw new EscrowPolicyError();
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
false,
|
|
276
|
+
staleEscrowMessage
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// Strict objects with exclusive proof fields also remain compatible with OpenAPI's
|
|
280
|
+
// object-only parameter generation (unlike a top-level Zod union).
|
|
281
|
+
const startInput = z
|
|
282
|
+
.object({
|
|
283
|
+
clientEphemeralPublicKey: z.string().min(1).max(512),
|
|
284
|
+
releasePolicy: z.enum(['hold', 'pin']).default('hold'),
|
|
285
|
+
restart: z.boolean().optional(),
|
|
286
|
+
authToken: z.string().optional(),
|
|
287
|
+
providerType: AuthProviderTypeValidator.optional(),
|
|
288
|
+
recoverySessionToken: RecoverySessionTokenValidator.optional(),
|
|
289
|
+
})
|
|
290
|
+
.strict()
|
|
291
|
+
.refine(
|
|
292
|
+
input =>
|
|
293
|
+
input.recoverySessionToken !== undefined
|
|
294
|
+
? input.authToken === undefined && input.providerType === undefined
|
|
295
|
+
: input.authToken !== undefined && input.providerType !== undefined,
|
|
296
|
+
{ message: 'Provide exactly one identity proof.' }
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// P0-4: this is a GET route, so neither the resume token nor the provider
|
|
300
|
+
// token may travel in the query string (proxy/ALB access logs). Raw-fetch
|
|
301
|
+
// callers send whichever secret applies as the X-Auth-Token header
|
|
302
|
+
// (ctx.providerToken); the optional input fields exist only for native tRPC
|
|
303
|
+
// callers whose batch link always POSTs.
|
|
304
|
+
const statusInput = z
|
|
305
|
+
.object({
|
|
306
|
+
holdId: z.string().uuid().optional(),
|
|
307
|
+
resumeToken: z.string().min(1).max(512).optional(),
|
|
308
|
+
authToken: z.string().optional(),
|
|
309
|
+
providerType: AuthProviderTypeValidator.optional(),
|
|
310
|
+
})
|
|
311
|
+
.strict()
|
|
312
|
+
.refine(
|
|
313
|
+
input =>
|
|
314
|
+
input.holdId !== undefined
|
|
315
|
+
? input.authToken === undefined && input.providerType === undefined
|
|
316
|
+
: input.resumeToken === undefined && input.providerType !== undefined,
|
|
317
|
+
{ message: 'Provide exactly one identity proof.' }
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
export const escrowRouter = t.router({
|
|
321
|
+
getAttestation: openRoute
|
|
322
|
+
.meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
|
|
323
|
+
.input(
|
|
324
|
+
z
|
|
325
|
+
.object({
|
|
326
|
+
nonce: z
|
|
327
|
+
.string()
|
|
328
|
+
.regex(/^[0-9a-f]{64}$/i)
|
|
329
|
+
.optional(),
|
|
330
|
+
})
|
|
331
|
+
.strict()
|
|
332
|
+
)
|
|
333
|
+
.output(
|
|
334
|
+
z.object({
|
|
335
|
+
attestation: z.object({
|
|
336
|
+
mode: z.enum(['software', 'nitro']),
|
|
337
|
+
keyId: z.string(),
|
|
338
|
+
previousKeyIds: z.array(z.string()).optional(),
|
|
339
|
+
publicKey: z.string(),
|
|
340
|
+
measurements: EscrowBlobValidator.shape.measurements,
|
|
341
|
+
document: z.string(),
|
|
342
|
+
issuedAt: z.string(),
|
|
343
|
+
}),
|
|
344
|
+
holdDurationMs: z.number(),
|
|
345
|
+
})
|
|
346
|
+
)
|
|
347
|
+
.query(async ({ input }) => {
|
|
348
|
+
// Nitro attestation is only meaningful bound to a fresh client nonce
|
|
349
|
+
// (sss-key-manager always sends one: GET .../attestation?nonce=<64 hex>);
|
|
350
|
+
// the software backend has no freshness story and never required one.
|
|
351
|
+
if (isEscrowRemoteMode() && !input.nonce) {
|
|
352
|
+
throw new TRPCError({
|
|
353
|
+
code: 'BAD_REQUEST',
|
|
354
|
+
message: 'A nonce is required for attestation.',
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const nonce = input.nonce
|
|
358
|
+
? Uint8Array.from(Buffer.from(input.nonce, 'hex'))
|
|
359
|
+
: undefined;
|
|
360
|
+
return {
|
|
361
|
+
attestation: await enclaveOperation(() => getEscrowEnclave().getAttestation(nonce)),
|
|
362
|
+
holdDurationMs: getEscrowHoldDurationMs(),
|
|
363
|
+
};
|
|
364
|
+
}),
|
|
365
|
+
|
|
366
|
+
enroll: didAndChallengeRoute
|
|
367
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow', tags: ['Keys'] } })
|
|
368
|
+
.input(
|
|
369
|
+
AuthInputValidator.extend({
|
|
370
|
+
envelope: EscrowEnvelopeValidator,
|
|
371
|
+
shareVersion: z.number().int().positive(),
|
|
372
|
+
enclaveKeyId: EscrowBlobValidator.shape.enclaveKeyId,
|
|
373
|
+
pinSalt: EscrowPinSaltValidator.optional(),
|
|
374
|
+
clearPin: z.boolean().optional(),
|
|
375
|
+
}).strict()
|
|
376
|
+
)
|
|
377
|
+
.output(successValidator.extend({ shareVersion: z.number() }))
|
|
378
|
+
.mutation(async ({ input, ctx }) => {
|
|
379
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
380
|
+
const userKey = await requireUserKey(authProvider);
|
|
381
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
382
|
+
if (userKey.escrowOptedOutAt) {
|
|
383
|
+
throw new TRPCError({
|
|
384
|
+
code: 'CONFLICT',
|
|
385
|
+
message: 'Automatic recovery is turned off for this account.',
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
if (input.shareVersion !== userKey.shareVersion) {
|
|
389
|
+
throw new TRPCError({
|
|
390
|
+
code: 'CONFLICT',
|
|
391
|
+
message: 'Key material changed; please retry.',
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const attestation = await enclaveOperation(() => getEscrowEnclave().getAttestation());
|
|
395
|
+
if (
|
|
396
|
+
input.enclaveKeyId !== attestation.keyId ||
|
|
397
|
+
input.envelope.keyId !== attestation.keyId
|
|
398
|
+
) {
|
|
399
|
+
throw new TRPCError({
|
|
400
|
+
code: 'BAD_REQUEST',
|
|
401
|
+
message: 'Invalid automatic recovery key.',
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
const verification = await enclaveOperation(
|
|
405
|
+
() =>
|
|
406
|
+
getEscrowEnclave().verifyEscrowBlob({
|
|
407
|
+
envelope: input.envelope,
|
|
408
|
+
expectedDid: ctx.user.did,
|
|
409
|
+
expectedShareVersion: input.shareVersion,
|
|
410
|
+
}),
|
|
411
|
+
true
|
|
412
|
+
);
|
|
413
|
+
if (
|
|
414
|
+
!verification.ok ||
|
|
415
|
+
verification.hasPin !== !!input.pinSalt ||
|
|
416
|
+
(input.clearPin && input.pinSalt)
|
|
417
|
+
) {
|
|
418
|
+
throw new TRPCError({
|
|
419
|
+
code: 'BAD_REQUEST',
|
|
420
|
+
message: 'Invalid automatic recovery material.',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
const blob = {
|
|
424
|
+
envelope: input.envelope,
|
|
425
|
+
enclaveKeyId: attestation.keyId,
|
|
426
|
+
enclaveMode: attestation.mode,
|
|
427
|
+
measurements: attestation.measurements,
|
|
428
|
+
shareVersion: input.shareVersion,
|
|
429
|
+
createdAt: new Date(),
|
|
430
|
+
};
|
|
431
|
+
let stored;
|
|
432
|
+
const oldBlob = userKey.escrowBlob;
|
|
433
|
+
const oldPin = userKey.escrowPin;
|
|
434
|
+
if (!input.pinSalt && !input.clearPin && oldPin) {
|
|
435
|
+
if (
|
|
436
|
+
oldBlob &&
|
|
437
|
+
!oldPin.disabledAt &&
|
|
438
|
+
oldPin.shareVersion === oldBlob.shareVersion &&
|
|
439
|
+
// P9.2: carry from the current key OR a still-accepted previous
|
|
440
|
+
// key (deliberate rotation / lost-sealed-key recovery) — only a
|
|
441
|
+
// fully unrecognised keyId drops the PIN (see assertFreshEscrowBlob).
|
|
442
|
+
(oldBlob.enclaveKeyId === attestation.keyId ||
|
|
443
|
+
(attestation.previousKeyIds ?? []).includes(oldBlob.enclaveKeyId)) &&
|
|
444
|
+
input.shareVersion > oldBlob.shareVersion &&
|
|
445
|
+
// A released blob's PIN is retired: it was just used, or the user forgot it.
|
|
446
|
+
!(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion))
|
|
447
|
+
) {
|
|
448
|
+
try {
|
|
449
|
+
const carried = await getEscrowEnclave().carryPinVerifier({
|
|
450
|
+
sourceEnvelope: oldBlob.envelope,
|
|
451
|
+
targetEnvelope: input.envelope,
|
|
452
|
+
expectedDid: ctx.user.did,
|
|
453
|
+
sourceShareVersion: oldBlob.shareVersion,
|
|
454
|
+
targetShareVersion: input.shareVersion,
|
|
455
|
+
sourceEnrollmentEpoch: oldBlob.enrollmentEpoch,
|
|
456
|
+
targetEnrollmentEpoch: oldBlob.enrollmentEpoch + 1,
|
|
457
|
+
});
|
|
458
|
+
stored = await setEscrowBlobByAuthProvider(
|
|
459
|
+
authProvider,
|
|
460
|
+
{ ...blob, envelope: carried.envelope },
|
|
461
|
+
input.shareVersion,
|
|
462
|
+
undefined,
|
|
463
|
+
{ blob: oldBlob, pin: oldPin }
|
|
464
|
+
);
|
|
465
|
+
} catch {
|
|
466
|
+
// Never log enclave errors: a backend could include decrypted material.
|
|
467
|
+
console.warn('[LCA escrow/enroll] PIN carry failed; enrolling without PIN');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (!stored) {
|
|
471
|
+
console.warn(
|
|
472
|
+
'[LCA escrow/enroll] PIN carry unavailable; enrolling without PIN'
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
stored ??= await setEscrowBlobByAuthProvider(
|
|
477
|
+
authProvider,
|
|
478
|
+
blob,
|
|
479
|
+
input.shareVersion,
|
|
480
|
+
input.pinSalt ? { salt: input.pinSalt } : undefined
|
|
481
|
+
);
|
|
482
|
+
if (!stored)
|
|
483
|
+
throw new TRPCError({
|
|
484
|
+
code: 'CONFLICT',
|
|
485
|
+
message: 'Key material changed; please retry.',
|
|
486
|
+
});
|
|
487
|
+
return { success: true as const, shareVersion: input.shareVersion };
|
|
488
|
+
}),
|
|
489
|
+
|
|
490
|
+
remove: didAndChallengeRoute
|
|
491
|
+
.meta({ openapi: { method: 'DELETE', path: '/keys/escrow', tags: ['Keys'] } })
|
|
492
|
+
.input(AuthInputValidator.extend({ optOut: z.boolean() }).strict())
|
|
493
|
+
.output(successValidator)
|
|
494
|
+
.mutation(async ({ input, ctx }) => {
|
|
495
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
496
|
+
const userKey = await requireUserKey(authProvider);
|
|
497
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
498
|
+
if (
|
|
499
|
+
input.optOut &&
|
|
500
|
+
!userKey.recoveryMethods.some(
|
|
501
|
+
method =>
|
|
502
|
+
method.type !== 'escrow' &&
|
|
503
|
+
method.shareVersion === userKey.shareVersion &&
|
|
504
|
+
isRecoveryMethodConfirmed(userKey, method)
|
|
505
|
+
)
|
|
506
|
+
) {
|
|
507
|
+
throw new TRPCError({
|
|
508
|
+
code: 'PRECONDITION_FAILED',
|
|
509
|
+
message:
|
|
510
|
+
'Set up another recovery method before turning off automatic recovery.',
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
await clearEscrowByAuthProvider(authProvider, { optOut: input.optOut });
|
|
514
|
+
for (const policy of ['hold', 'pin'] as const) {
|
|
515
|
+
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
|
|
516
|
+
const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
517
|
+
if (cancelled) {
|
|
518
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
519
|
+
await notifyEscrowHoldEvent({
|
|
520
|
+
kind: 'cancelled',
|
|
521
|
+
hold: cancelled,
|
|
522
|
+
userKey,
|
|
523
|
+
tenant: ctx.tenant,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return { success: true as const };
|
|
528
|
+
}),
|
|
529
|
+
|
|
530
|
+
optIn: didAndChallengeRoute
|
|
531
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/opt-in', tags: ['Keys'] } })
|
|
532
|
+
.input(AuthInputValidator.strict())
|
|
533
|
+
.output(successValidator)
|
|
534
|
+
.mutation(async ({ input, ctx }) => {
|
|
535
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
536
|
+
const userKey = await requireUserKey(authProvider);
|
|
537
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
538
|
+
await setEscrowOptInByAuthProvider(authProvider);
|
|
539
|
+
return { success: true as const };
|
|
540
|
+
}),
|
|
541
|
+
|
|
542
|
+
startRecovery: openRoute
|
|
543
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/recover', tags: ['Keys'] } })
|
|
544
|
+
.input(startInput)
|
|
545
|
+
.output(
|
|
546
|
+
holdStatusValidator
|
|
547
|
+
.pick({ holdId: true, requestedAt: true, releaseAfter: true, releasePolicy: true })
|
|
548
|
+
.extend({
|
|
549
|
+
status: z.literal('pending'),
|
|
550
|
+
resumeToken: z.string().nullable(),
|
|
551
|
+
pinSalt: EscrowPinSaltValidator.optional(),
|
|
552
|
+
})
|
|
553
|
+
)
|
|
554
|
+
.mutation(async ({ input, ctx }) => {
|
|
555
|
+
if (isEscrowReleaseKillSwitchEnabled()) {
|
|
556
|
+
throw new TRPCError({
|
|
557
|
+
code: 'PRECONDITION_FAILED',
|
|
558
|
+
message: killSwitchMessage,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
let authProvider: AuthProviderMapping;
|
|
562
|
+
if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
|
|
563
|
+
if (input.recoverySessionToken !== undefined) {
|
|
564
|
+
try {
|
|
565
|
+
authProvider = await requireRecoverySession(
|
|
566
|
+
input.recoverySessionToken,
|
|
567
|
+
'recover'
|
|
568
|
+
);
|
|
569
|
+
} catch (error) {
|
|
570
|
+
if (!(error instanceof TRPCError) || error.code !== 'UNAUTHORIZED') throw error;
|
|
571
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
572
|
+
}
|
|
573
|
+
} else {
|
|
574
|
+
({ authProvider } = await verifyAndGetContactMethod({
|
|
575
|
+
authToken: input.authToken!,
|
|
576
|
+
providerType: input.providerType!,
|
|
577
|
+
}));
|
|
578
|
+
}
|
|
579
|
+
const userKey = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
|
|
580
|
+
if (
|
|
581
|
+
!userKey?.escrowBlob ||
|
|
582
|
+
userKey.escrowOptedOutAt ||
|
|
583
|
+
!hasConfirmedEscrow(userKey, userKey.escrowBlob.shareVersion)
|
|
584
|
+
) {
|
|
585
|
+
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
586
|
+
}
|
|
587
|
+
if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
|
|
588
|
+
throw new TRPCError({
|
|
589
|
+
code: 'PRECONDITION_FAILED',
|
|
590
|
+
message: killSwitchMessage,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
await assertFreshEscrowBlob(userKey.escrowBlob);
|
|
594
|
+
const now = new Date();
|
|
595
|
+
if (
|
|
596
|
+
input.releasePolicy === 'pin' &&
|
|
597
|
+
(!userKey.escrowPin ||
|
|
598
|
+
userKey.escrowPin.disabledAt ||
|
|
599
|
+
userKey.escrowPin.shareVersion !== userKey.shareVersion)
|
|
600
|
+
) {
|
|
601
|
+
throw new TRPCError({
|
|
602
|
+
code: 'FORBIDDEN',
|
|
603
|
+
message: ESCROW_PIN_UNAVAILABLE_MESSAGE,
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
await expireStaleEscrowHolds(now);
|
|
607
|
+
await ensureHoldIndexes();
|
|
608
|
+
// Policies coexist; reuse waiting holds only while their material is current.
|
|
609
|
+
const pending = await findPendingEscrowHoldByAuthProvider(
|
|
610
|
+
authProvider,
|
|
611
|
+
input.releasePolicy
|
|
612
|
+
);
|
|
613
|
+
if (
|
|
614
|
+
pending &&
|
|
615
|
+
input.releasePolicy === 'hold' &&
|
|
616
|
+
(pending.releasePolicy ?? 'hold') === 'hold' &&
|
|
617
|
+
pending.shareVersion === userKey.escrowBlob.shareVersion
|
|
618
|
+
) {
|
|
619
|
+
if (!input.restart)
|
|
620
|
+
return {
|
|
621
|
+
...serializeHold(pending),
|
|
622
|
+
status: 'pending' as const,
|
|
623
|
+
resumeToken: null,
|
|
624
|
+
};
|
|
625
|
+
const minAge = getEscrowHoldRestartMinAgeMs();
|
|
626
|
+
if (now.getTime() - pending.requestedAt.getTime() < minAge) {
|
|
627
|
+
const retryAfter = new Date(
|
|
628
|
+
pending.requestedAt.getTime() + minAge
|
|
629
|
+
).toISOString();
|
|
630
|
+
throw new TRPCError({
|
|
631
|
+
code: 'TOO_MANY_REQUESTS',
|
|
632
|
+
message: `A recovery request was started recently. Try again after ${retryAfter}.`,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (pending) {
|
|
637
|
+
const cancelled = await cancelEscrowHold(pending._id, 'system', 'superseded');
|
|
638
|
+
if (cancelled) {
|
|
639
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
640
|
+
await notifyEscrowHoldEvent({
|
|
641
|
+
kind: 'cancelled',
|
|
642
|
+
hold: cancelled,
|
|
643
|
+
userKey,
|
|
644
|
+
reason: 'superseded',
|
|
645
|
+
tenant: ctx.tenant,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
|
|
650
|
+
throw new TRPCError({
|
|
651
|
+
code: 'PRECONDITION_FAILED',
|
|
652
|
+
message: killSwitchMessage,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
const resumeToken = generateEscrowResumeToken();
|
|
656
|
+
const cancelToken = generateEscrowCancelToken();
|
|
657
|
+
const { holdRecord } = await enclaveOperation(() =>
|
|
658
|
+
getEscrowEnclave().createHold({
|
|
659
|
+
envelope: userKey.escrowBlob!.envelope,
|
|
660
|
+
holdId: randomUUID(),
|
|
661
|
+
expectedDid: userKey.primaryDid,
|
|
662
|
+
expectedShareVersion: userKey.escrowBlob!.shareVersion,
|
|
663
|
+
enrollmentEpoch: userKey.escrowBlob!.enrollmentEpoch,
|
|
664
|
+
releasePolicy: input.releasePolicy,
|
|
665
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
666
|
+
})
|
|
667
|
+
);
|
|
668
|
+
let hold: EscrowHold;
|
|
669
|
+
try {
|
|
670
|
+
hold = await createEscrowHold({
|
|
671
|
+
holdRecord,
|
|
672
|
+
authProvider,
|
|
673
|
+
enclaveEnvelope: userKey.escrowBlob.envelope,
|
|
674
|
+
primaryDid: userKey.primaryDid,
|
|
675
|
+
shareVersion: userKey.escrowBlob.shareVersion,
|
|
676
|
+
identityProofType:
|
|
677
|
+
input.recoverySessionToken !== undefined
|
|
678
|
+
? 'recovery-session'
|
|
679
|
+
: 'auth-token',
|
|
680
|
+
requestedAt: now,
|
|
681
|
+
releaseAfter:
|
|
682
|
+
input.releasePolicy === 'pin'
|
|
683
|
+
? now
|
|
684
|
+
: new Date(
|
|
685
|
+
holdRecord.hold.createdHi +
|
|
686
|
+
holdRecord.holdDurationMs +
|
|
687
|
+
ESCROW_RELEASE_TIME_MARGIN_MS
|
|
688
|
+
),
|
|
689
|
+
releasePolicy: input.releasePolicy,
|
|
690
|
+
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
691
|
+
resumeTokenHash: hashEscrowResumeToken(resumeToken),
|
|
692
|
+
cancelTokenHash: hashEscrowCancelToken(cancelToken),
|
|
693
|
+
startedNotificationPending: true,
|
|
694
|
+
requestIp: ctx.clientIp,
|
|
695
|
+
tenantId: ctx.tenant?.id,
|
|
696
|
+
});
|
|
697
|
+
} catch (error) {
|
|
698
|
+
if (
|
|
699
|
+
typeof error === 'object' &&
|
|
700
|
+
error !== null &&
|
|
701
|
+
'code' in error &&
|
|
702
|
+
error.code === 11000
|
|
703
|
+
) {
|
|
704
|
+
const raced = await findPendingEscrowHoldByAuthProvider(
|
|
705
|
+
authProvider,
|
|
706
|
+
input.releasePolicy
|
|
707
|
+
);
|
|
708
|
+
if (
|
|
709
|
+
raced &&
|
|
710
|
+
!input.restart &&
|
|
711
|
+
input.releasePolicy === 'hold' &&
|
|
712
|
+
(raced.releasePolicy ?? 'hold') === 'hold' &&
|
|
713
|
+
raced.shareVersion === userKey.escrowBlob.shareVersion
|
|
714
|
+
)
|
|
715
|
+
return {
|
|
716
|
+
...serializeHold(raced),
|
|
717
|
+
status: 'pending' as const,
|
|
718
|
+
resumeToken: null,
|
|
719
|
+
};
|
|
720
|
+
throw new TRPCError({
|
|
721
|
+
code: 'CONFLICT',
|
|
722
|
+
message: 'Recovery state changed; please retry.',
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
throw new TRPCError({
|
|
726
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
727
|
+
message: 'Recovery could not be started.',
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
await notifyEscrowHoldEvent({
|
|
731
|
+
kind: 'started',
|
|
732
|
+
hold,
|
|
733
|
+
userKey,
|
|
734
|
+
cancelToken,
|
|
735
|
+
tenant: ctx.tenant,
|
|
736
|
+
});
|
|
737
|
+
return {
|
|
738
|
+
...serializeHold(hold),
|
|
739
|
+
status: 'pending' as const,
|
|
740
|
+
resumeToken,
|
|
741
|
+
...(input.releasePolicy === 'pin' ? { pinSalt: userKey.escrowPin!.salt } : {}),
|
|
742
|
+
};
|
|
743
|
+
}),
|
|
744
|
+
|
|
745
|
+
getStatus: openRoute
|
|
746
|
+
.meta({ openapi: { method: 'GET', path: '/keys/escrow/status', tags: ['Keys'] } })
|
|
747
|
+
.input(statusInput)
|
|
748
|
+
.output(z.object({ hold: holdStatusValidator.nullable() }))
|
|
749
|
+
.query(async ({ input, ctx }) => {
|
|
750
|
+
await expireStaleEscrowHolds(new Date());
|
|
751
|
+
let hold: EscrowHold | null;
|
|
752
|
+
if (input.holdId !== undefined) {
|
|
753
|
+
const resumeToken = input.resumeToken || ctx.providerToken || '';
|
|
754
|
+
hold = resumeToken ? await findEscrowHoldById(input.holdId) : null;
|
|
755
|
+
if (hold && !resumeTokenMatches(hold, resumeToken)) hold = null;
|
|
756
|
+
} else {
|
|
757
|
+
const { authProvider } = await verifyAndGetContactMethod({
|
|
758
|
+
authToken: input.authToken || ctx.providerToken || '',
|
|
759
|
+
providerType: input.providerType!,
|
|
760
|
+
});
|
|
761
|
+
hold = await findPendingEscrowHoldByAuthProvider(authProvider, 'hold');
|
|
762
|
+
}
|
|
763
|
+
return { hold: hold ? serializeHold(hold) : null };
|
|
764
|
+
}),
|
|
765
|
+
|
|
766
|
+
cancelRecovery: didAndChallengeRoute
|
|
767
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/cancel', tags: ['Keys'] } })
|
|
768
|
+
.input(AuthInputValidator.strict())
|
|
769
|
+
.output(successValidator.extend({ cancelled: z.boolean() }))
|
|
770
|
+
.mutation(async ({ input, ctx }) => {
|
|
771
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
772
|
+
const userKey = await requireUserKey(authProvider);
|
|
773
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
774
|
+
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, 'hold');
|
|
775
|
+
const hold = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
776
|
+
if (hold) {
|
|
777
|
+
await cancelHoldInEnclave(hold, userKey);
|
|
778
|
+
await notifyEscrowHoldEvent({
|
|
779
|
+
kind: 'cancelled',
|
|
780
|
+
hold,
|
|
781
|
+
userKey,
|
|
782
|
+
tenant: ctx.tenant,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
return { success: true as const, cancelled: Boolean(hold) };
|
|
786
|
+
}),
|
|
787
|
+
|
|
788
|
+
// No DID auth is possible from an email link, so this is deliberately an
|
|
789
|
+
// openRoute guarded only by the single-use cancelToken. Every failure
|
|
790
|
+
// path — missing hold, wrong token, already used, not pending — returns
|
|
791
|
+
// the identical { cancelled: false } shape so a link can't be used to
|
|
792
|
+
// probe hold state (mirrors resumeToken's uniform-failure behaviour in
|
|
793
|
+
// getStatus/completeRecovery above).
|
|
794
|
+
cancelRecoveryByLink: openRoute
|
|
795
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/cancel-link', tags: ['Keys'] } })
|
|
796
|
+
.input(cancelByLinkInput)
|
|
797
|
+
.output(z.object({ cancelled: z.boolean() }))
|
|
798
|
+
.mutation(async ({ input, ctx }) => {
|
|
799
|
+
await limitCancelLinkAttempts(ctx.clientIp);
|
|
800
|
+
const hold = await findEscrowHoldById(input.holdId);
|
|
801
|
+
if (!hold || !escrowCancelTokenMatches(hold, input.token)) return { cancelled: false };
|
|
802
|
+
const cancelled = await cancelEscrowHoldByCancelToken(
|
|
803
|
+
hold._id,
|
|
804
|
+
hashEscrowCancelToken(input.token)
|
|
805
|
+
);
|
|
806
|
+
if (!cancelled) return { cancelled: false };
|
|
807
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
808
|
+
cancelled.authProvider.type,
|
|
809
|
+
cancelled.authProvider.id
|
|
810
|
+
);
|
|
811
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
812
|
+
if (userKey) {
|
|
813
|
+
await notifyEscrowHoldEvent({
|
|
814
|
+
kind: 'cancelled',
|
|
815
|
+
hold: cancelled,
|
|
816
|
+
userKey,
|
|
817
|
+
tenant: ctx.tenant,
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
return { cancelled: true };
|
|
821
|
+
}),
|
|
822
|
+
|
|
823
|
+
completeRecovery: openRoute
|
|
824
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/complete', tags: ['Keys'] } })
|
|
825
|
+
.input(
|
|
826
|
+
resumeInput.extend({
|
|
827
|
+
pinProof: z
|
|
828
|
+
.string()
|
|
829
|
+
.regex(/^[0-9a-f]{64}$/i)
|
|
830
|
+
.optional(),
|
|
831
|
+
})
|
|
832
|
+
)
|
|
833
|
+
.output(
|
|
834
|
+
z.object({
|
|
835
|
+
sealedShare: EscrowEnvelopeValidator,
|
|
836
|
+
authShare: ServerEncryptedShareValidator,
|
|
837
|
+
primaryDid: z.string(),
|
|
838
|
+
shareVersion: z.number(),
|
|
839
|
+
rebindSessionToken: RecoverySessionTokenValidator,
|
|
840
|
+
})
|
|
841
|
+
)
|
|
842
|
+
.mutation(async ({ input, ctx }) => {
|
|
843
|
+
if (isEscrowReleaseKillSwitchEnabled()) {
|
|
844
|
+
throw new TRPCError({
|
|
845
|
+
code: 'PRECONDITION_FAILED',
|
|
846
|
+
message: killSwitchMessage,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
if (input.pinProof !== undefined) await limitPinCompletion(ctx.clientIp);
|
|
850
|
+
const hold = await findEscrowHoldById(input.holdId);
|
|
851
|
+
if (!hold || !resumeTokenMatches(hold, input.resumeToken)) {
|
|
852
|
+
throw new TRPCError({
|
|
853
|
+
code: 'FORBIDDEN',
|
|
854
|
+
message: 'This recovery request is invalid.',
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
if (hold.status === 'cancelled') {
|
|
858
|
+
throw new TRPCError({
|
|
859
|
+
code: 'FORBIDDEN',
|
|
860
|
+
message: 'This recovery request was cancelled.',
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
// Legacy Mongo rows can predate the required creation-time record.
|
|
864
|
+
if (hold.status !== 'pending' || !hold.holdRecord)
|
|
865
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
866
|
+
const now = new Date();
|
|
867
|
+
if (now < hold.releaseAfter) {
|
|
868
|
+
throw new TRPCError({
|
|
869
|
+
code: 'FORBIDDEN',
|
|
870
|
+
message: 'This recovery request is still in its waiting period.',
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
874
|
+
hold.authProvider.type,
|
|
875
|
+
hold.authProvider.id
|
|
876
|
+
);
|
|
877
|
+
if (
|
|
878
|
+
!userKey?.escrowBlob ||
|
|
879
|
+
userKey.escrowOptedOutAt ||
|
|
880
|
+
userKey.escrowBlob.shareVersion !== hold.shareVersion ||
|
|
881
|
+
!hasConfirmedEscrow(userKey, hold.shareVersion)
|
|
882
|
+
) {
|
|
883
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
884
|
+
}
|
|
885
|
+
await assertFreshEscrowBlob(userKey.escrowBlob);
|
|
886
|
+
const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
|
|
887
|
+
if (!encryptedAuthShare || !environment.SEED) {
|
|
888
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
889
|
+
}
|
|
890
|
+
const expectedCiphertext = userKey.escrowBlob.envelope.ciphertext;
|
|
891
|
+
const reserved =
|
|
892
|
+
hold.releasePolicy === 'pin'
|
|
893
|
+
? await reserveEscrowPinAttempt(
|
|
894
|
+
hold.authProvider,
|
|
895
|
+
hold.shareVersion,
|
|
896
|
+
expectedCiphertext
|
|
897
|
+
)
|
|
898
|
+
: undefined;
|
|
899
|
+
if (reserved === null) {
|
|
900
|
+
const current = await findUserKeyByAuthProvider(
|
|
901
|
+
hold.authProvider.type,
|
|
902
|
+
hold.authProvider.id
|
|
903
|
+
);
|
|
904
|
+
if (
|
|
905
|
+
!current?.escrowPin ||
|
|
906
|
+
current.escrowPin.disabledAt ||
|
|
907
|
+
current.escrowPin.shareVersion !== hold.shareVersion ||
|
|
908
|
+
current.shareVersion !== hold.shareVersion ||
|
|
909
|
+
current.escrowBlob?.envelope.ciphertext !== expectedCiphertext
|
|
910
|
+
) {
|
|
911
|
+
throw new TRPCError({
|
|
912
|
+
code: 'FORBIDDEN',
|
|
913
|
+
message: ESCROW_PIN_UNAVAILABLE_MESSAGE,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
if (current.escrowPin.failedAttempts >= ESCROW_PIN_MAX_ATTEMPTS) {
|
|
917
|
+
throw new TRPCError({
|
|
918
|
+
code: 'TOO_MANY_REQUESTS',
|
|
919
|
+
message: pinThrottledMessage,
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
throw new TRPCError({
|
|
923
|
+
code: 'CONFLICT',
|
|
924
|
+
message: 'Recovery state changed; please retry.',
|
|
925
|
+
});
|
|
926
|
+
}
|
|
927
|
+
// Claim the pending hold BEFORE releasing secrets. The enclave receives the
|
|
928
|
+
// pending snapshot whose CAS we won; transient refusals reopen only our claim.
|
|
929
|
+
let completed: Awaited<ReturnType<typeof completeEscrowHold>> = null;
|
|
930
|
+
try {
|
|
931
|
+
completed = await completeEscrowHold(hold._id);
|
|
932
|
+
if (!completed) {
|
|
933
|
+
throw new TRPCError({
|
|
934
|
+
code: 'CONFLICT',
|
|
935
|
+
message: 'Recovery state changed; please retry.',
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
// Revalidate after the claim so a rotation/removal cannot release the old snapshot.
|
|
939
|
+
const current = await findUserKeyByAuthProvider(
|
|
940
|
+
hold.authProvider.type,
|
|
941
|
+
hold.authProvider.id
|
|
942
|
+
);
|
|
943
|
+
if (
|
|
944
|
+
!current?.escrowBlob ||
|
|
945
|
+
current.escrowOptedOutAt ||
|
|
946
|
+
current.shareVersion !== userKey.shareVersion ||
|
|
947
|
+
current.escrowBlob.shareVersion !== hold.shareVersion ||
|
|
948
|
+
current.escrowBlob.envelope.ciphertext !== expectedCiphertext ||
|
|
949
|
+
!hasConfirmedEscrow(current, hold.shareVersion)
|
|
950
|
+
) {
|
|
951
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
952
|
+
}
|
|
953
|
+
} catch (error) {
|
|
954
|
+
// No enclave evaluation occurred, including when Mongo throws rather
|
|
955
|
+
// than returning a lost CAS. Refund before any fallible hold cleanup.
|
|
956
|
+
if (reserved)
|
|
957
|
+
await refundEscrowPinAttempt(
|
|
958
|
+
hold.authProvider,
|
|
959
|
+
hold.shareVersion,
|
|
960
|
+
expectedCiphertext
|
|
961
|
+
);
|
|
962
|
+
if (completed) {
|
|
963
|
+
await markClaimedEscrowHoldFailed(
|
|
964
|
+
hold._id,
|
|
965
|
+
'release-failed',
|
|
966
|
+
completed.completedAt!
|
|
967
|
+
);
|
|
968
|
+
await cancelHoldInEnclave(hold, userKey);
|
|
969
|
+
}
|
|
970
|
+
throw error;
|
|
971
|
+
}
|
|
972
|
+
const release = await enclaveOperation(async () => {
|
|
973
|
+
try {
|
|
974
|
+
return {
|
|
975
|
+
result: await getEscrowEnclave().releaseEscrow({
|
|
976
|
+
envelope: (reserved ?? userKey).escrowBlob!.envelope,
|
|
977
|
+
hold: hold.holdRecord,
|
|
978
|
+
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
979
|
+
expectedDid: userKey.primaryDid,
|
|
980
|
+
now,
|
|
981
|
+
pinProof: input.pinProof,
|
|
982
|
+
}),
|
|
983
|
+
mismatch: false as const,
|
|
984
|
+
};
|
|
985
|
+
} catch (error) {
|
|
986
|
+
// Keep the typed mismatch inside the wrapper; never expose enclave internals.
|
|
987
|
+
if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError)
|
|
988
|
+
return { mismatch: true as const };
|
|
989
|
+
if (reserved)
|
|
990
|
+
await refundEscrowPinAttempt(
|
|
991
|
+
hold.authProvider,
|
|
992
|
+
hold.shareVersion,
|
|
993
|
+
expectedCiphertext
|
|
994
|
+
);
|
|
995
|
+
// Policy includes "not yet releasable"; time/ledger/transport
|
|
996
|
+
// refusals map to unavailable. Only malformed blobs are terminal.
|
|
997
|
+
// Retries still require the enclave's single-use ledger.
|
|
998
|
+
if (error instanceof EscrowBlobError) {
|
|
999
|
+
await markClaimedEscrowHoldFailed(
|
|
1000
|
+
hold._id,
|
|
1001
|
+
'release-failed',
|
|
1002
|
+
completed.completedAt!
|
|
1003
|
+
);
|
|
1004
|
+
await cancelHoldInEnclave(hold, userKey);
|
|
1005
|
+
} else {
|
|
1006
|
+
await retryClaimedEscrowHold(hold._id, completed.completedAt!);
|
|
1007
|
+
}
|
|
1008
|
+
throw error;
|
|
1009
|
+
}
|
|
1010
|
+
});
|
|
1011
|
+
if (release.mismatch) {
|
|
1012
|
+
const failed = await recordEscrowPinFailure(
|
|
1013
|
+
hold.authProvider,
|
|
1014
|
+
hold.shareVersion,
|
|
1015
|
+
expectedCiphertext
|
|
1016
|
+
);
|
|
1017
|
+
const attemptsRemaining =
|
|
1018
|
+
ESCROW_PIN_MAX_ATTEMPTS -
|
|
1019
|
+
(failed?.escrowPin?.failedAttempts ?? reserved!.escrowPin!.failedAttempts);
|
|
1020
|
+
const locked = !!failed?.escrowPin?.disabledAt;
|
|
1021
|
+
const cancelReason = locked ? 'pin-locked' : 'pin-mismatch';
|
|
1022
|
+
await markClaimedEscrowHoldFailed(hold._id, cancelReason, completed.completedAt!);
|
|
1023
|
+
await cancelHoldInEnclave(hold, userKey);
|
|
1024
|
+
await notifyEscrowHoldEvent({
|
|
1025
|
+
kind: 'cancelled',
|
|
1026
|
+
hold: {
|
|
1027
|
+
...completed,
|
|
1028
|
+
status: 'cancelled',
|
|
1029
|
+
cancelledBy: 'system',
|
|
1030
|
+
cancelReason,
|
|
1031
|
+
cancelledAt: new Date(),
|
|
1032
|
+
},
|
|
1033
|
+
userKey,
|
|
1034
|
+
tenant: ctx.tenant,
|
|
1035
|
+
});
|
|
1036
|
+
if (locked) {
|
|
1037
|
+
await lockPin(hold, userKey, ctx.tenant);
|
|
1038
|
+
throw new TRPCError({
|
|
1039
|
+
code: 'TOO_MANY_REQUESTS',
|
|
1040
|
+
message: ESCROW_PIN_LOCKED_MESSAGE,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
// No structured error-data convention exists: the client parses this exact text.
|
|
1044
|
+
throw new TRPCError({
|
|
1045
|
+
code: 'FORBIDDEN',
|
|
1046
|
+
message: escrowPinMismatchMessage(attemptsRemaining),
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
const { sealed } = release.result;
|
|
1050
|
+
// A successful evaluation also consumes the lifetime budget; do not
|
|
1051
|
+
// erase other requests' reservations or replenish guesses here.
|
|
1052
|
+
const authShare = await enclaveOperation(async () =>
|
|
1053
|
+
decryptAuthShare(encryptedAuthShare, environment.SEED)
|
|
1054
|
+
);
|
|
1055
|
+
const rebindSessionToken = await createRecoverySession({
|
|
1056
|
+
scope: 'rebind',
|
|
1057
|
+
authProvider: hold.authProvider,
|
|
1058
|
+
});
|
|
1059
|
+
await notifyEscrowHoldEvent({
|
|
1060
|
+
kind: 'completed',
|
|
1061
|
+
hold: completed,
|
|
1062
|
+
userKey,
|
|
1063
|
+
tenant: ctx.tenant,
|
|
1064
|
+
});
|
|
1065
|
+
return {
|
|
1066
|
+
sealedShare: sealed,
|
|
1067
|
+
authShare,
|
|
1068
|
+
primaryDid: userKey.primaryDid,
|
|
1069
|
+
shareVersion: hold.shareVersion,
|
|
1070
|
+
rebindSessionToken,
|
|
1071
|
+
};
|
|
1072
|
+
}),
|
|
1073
|
+
});
|