@learncard/lca-api-service 1.4.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +52 -0
- package/bootstrapLambda.test.ts +76 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +22 -13
- package/project.json +8 -1
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +174 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- package/serverless.escrow-env.cjs +0 -20
package/src/routes/escrow.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { timingSafeEqual } from 'crypto';
|
|
1
|
+
import { timingSafeEqual, randomUUID } from 'crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { TRPCError } from '@trpc/server';
|
|
4
4
|
import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
|
|
@@ -8,10 +8,15 @@ import {
|
|
|
8
8
|
escrowPinMismatchMessage,
|
|
9
9
|
} from '@learncard/types';
|
|
10
10
|
import cache from '@cache';
|
|
11
|
-
import { environment } from '@environment';
|
|
12
|
-
import { t, openRoute, didAndChallengeRoute } from '@routes';
|
|
11
|
+
import { environment, isEscrowReleaseKillSwitchEnabled } from '@environment';
|
|
12
|
+
import { t, openRoute, didAndChallengeRoute, type Context } from '@routes';
|
|
13
13
|
import { createRecoverySession } from '@cache/recoverySessions';
|
|
14
14
|
import { decryptAuthShare } from '@helpers/shareEncryption.helpers';
|
|
15
|
+
import {
|
|
16
|
+
generateEscrowCancelToken,
|
|
17
|
+
hashEscrowCancelToken,
|
|
18
|
+
escrowCancelTokenMatches,
|
|
19
|
+
} from '@helpers/escrowCancelToken';
|
|
15
20
|
import {
|
|
16
21
|
EscrowEnvelopeValidator,
|
|
17
22
|
EscrowBlobValidator,
|
|
@@ -21,6 +26,7 @@ import {
|
|
|
21
26
|
recordEscrowPinFailure,
|
|
22
27
|
disableEscrowPin,
|
|
23
28
|
markClaimedEscrowHoldFailed,
|
|
29
|
+
retryClaimedEscrowHold,
|
|
24
30
|
ServerEncryptedShareValidator,
|
|
25
31
|
findUserKeyByAuthProvider,
|
|
26
32
|
findAuthShareByVersion,
|
|
@@ -33,6 +39,8 @@ import {
|
|
|
33
39
|
findPendingEscrowHoldByAuthProvider,
|
|
34
40
|
findEscrowHoldById,
|
|
35
41
|
cancelEscrowHold,
|
|
42
|
+
hasUnconfirmedEscrowCancellation,
|
|
43
|
+
cancelEscrowHoldByCancelToken,
|
|
36
44
|
completeEscrowHold,
|
|
37
45
|
hasCompletedEscrowHoldForVersion,
|
|
38
46
|
expireStaleEscrowHolds,
|
|
@@ -55,6 +63,9 @@ import {
|
|
|
55
63
|
getEscrowEnclave,
|
|
56
64
|
getEscrowHoldDurationMs,
|
|
57
65
|
getEscrowHoldRestartMinAgeMs,
|
|
66
|
+
isEscrowRemoteMode,
|
|
67
|
+
activeEscrowClientMode,
|
|
68
|
+
getEnclaveAttestationIdentity,
|
|
58
69
|
notifyEscrowHoldEvent,
|
|
59
70
|
EscrowPolicyError,
|
|
60
71
|
EscrowBlobError,
|
|
@@ -62,8 +73,14 @@ import {
|
|
|
62
73
|
EscrowPinMismatchError,
|
|
63
74
|
} from '../services/escrow-enclave';
|
|
64
75
|
|
|
76
|
+
import { cancelHoldInEnclave } from '../services/escrow-enclave/cancellation';
|
|
65
77
|
const unavailableMessage = 'Automatic recovery is not available for this account.';
|
|
78
|
+
const killSwitchMessage = 'Automatic recovery is temporarily unavailable. Please try again later.';
|
|
66
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;
|
|
67
84
|
// Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
|
|
68
85
|
const pinThrottledMessage = 'Please wait before trying again.';
|
|
69
86
|
|
|
@@ -83,7 +100,25 @@ const limitPinCompletion = async (
|
|
|
83
100
|
});
|
|
84
101
|
};
|
|
85
102
|
|
|
86
|
-
|
|
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> => {
|
|
87
122
|
const disabled = await disableEscrowPin(
|
|
88
123
|
hold.authProvider,
|
|
89
124
|
hold.shareVersion,
|
|
@@ -91,14 +126,35 @@ const lockPin = async (hold: EscrowHold, userKey: MongoUserKeyType): Promise<voi
|
|
|
91
126
|
);
|
|
92
127
|
// Always burn this request, but never disable/cancel a replacement enrollment.
|
|
93
128
|
const cancelled = await cancelEscrowHold(hold._id, 'system', 'pin-locked');
|
|
94
|
-
if (cancelled)
|
|
129
|
+
if (cancelled) {
|
|
130
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
131
|
+
await notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey, tenant });
|
|
132
|
+
}
|
|
95
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 });
|
|
96
145
|
for (const provider of userKey.authProviders) {
|
|
97
146
|
const pending = await findPendingEscrowHoldByAuthProvider(provider, 'pin');
|
|
98
147
|
if (pending?.releasePolicy === 'pin' && pending.shareVersion === hold.shareVersion) {
|
|
99
148
|
const cancelled = await cancelEscrowHold(pending._id, 'system', 'pin-locked');
|
|
100
|
-
if (cancelled)
|
|
101
|
-
|
|
149
|
+
if (cancelled) {
|
|
150
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
151
|
+
await notifyEscrowHoldEvent({
|
|
152
|
+
kind: 'cancelled',
|
|
153
|
+
hold: cancelled,
|
|
154
|
+
userKey,
|
|
155
|
+
tenant,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
102
158
|
}
|
|
103
159
|
}
|
|
104
160
|
};
|
|
@@ -116,6 +172,12 @@ const ensureHoldIndexes = async (): Promise<void> => {
|
|
|
116
172
|
await holdIndexes;
|
|
117
173
|
};
|
|
118
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();
|
|
119
181
|
const resumeInput = z
|
|
120
182
|
.object({
|
|
121
183
|
holdId: z.string().uuid(),
|
|
@@ -157,7 +219,11 @@ const hasConfirmedEscrow = (userKey: MongoUserKeyType, version: number): boolean
|
|
|
157
219
|
);
|
|
158
220
|
|
|
159
221
|
/** Never propagate enclave internals (or cryptographic payloads) into tRPC errors. */
|
|
160
|
-
const enclaveOperation = async <T>(
|
|
222
|
+
const enclaveOperation = async <T>(
|
|
223
|
+
operation: () => Promise<T>,
|
|
224
|
+
enrolling = false,
|
|
225
|
+
policyMessage = invalidMessage
|
|
226
|
+
): Promise<T> => {
|
|
161
227
|
try {
|
|
162
228
|
return await operation();
|
|
163
229
|
} catch (error) {
|
|
@@ -168,7 +234,7 @@ const enclaveOperation = async <T>(operation: () => Promise<T>, enrolling = fals
|
|
|
168
234
|
});
|
|
169
235
|
}
|
|
170
236
|
if (error instanceof EscrowPolicyError) {
|
|
171
|
-
throw new TRPCError({ code: 'FORBIDDEN', message:
|
|
237
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: policyMessage });
|
|
172
238
|
}
|
|
173
239
|
if (enrolling && error instanceof EscrowBlobError) {
|
|
174
240
|
throw new TRPCError({
|
|
@@ -183,6 +249,33 @@ const enclaveOperation = async <T>(operation: () => Promise<T>, enrolling = fals
|
|
|
183
249
|
}
|
|
184
250
|
};
|
|
185
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
|
+
|
|
186
279
|
// Strict objects with exclusive proof fields also remain compatible with OpenAPI's
|
|
187
280
|
// object-only parameter generation (unlike a top-level Zod union).
|
|
188
281
|
const startInput = z
|
|
@@ -227,12 +320,22 @@ const statusInput = z
|
|
|
227
320
|
export const escrowRouter = t.router({
|
|
228
321
|
getAttestation: openRoute
|
|
229
322
|
.meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
|
|
230
|
-
.input(
|
|
323
|
+
.input(
|
|
324
|
+
z
|
|
325
|
+
.object({
|
|
326
|
+
nonce: z
|
|
327
|
+
.string()
|
|
328
|
+
.regex(/^[0-9a-f]{64}$/i)
|
|
329
|
+
.optional(),
|
|
330
|
+
})
|
|
331
|
+
.strict()
|
|
332
|
+
)
|
|
231
333
|
.output(
|
|
232
334
|
z.object({
|
|
233
335
|
attestation: z.object({
|
|
234
336
|
mode: z.enum(['software', 'nitro']),
|
|
235
337
|
keyId: z.string(),
|
|
338
|
+
previousKeyIds: z.array(z.string()).optional(),
|
|
236
339
|
publicKey: z.string(),
|
|
237
340
|
measurements: EscrowBlobValidator.shape.measurements,
|
|
238
341
|
document: z.string(),
|
|
@@ -241,10 +344,24 @@ export const escrowRouter = t.router({
|
|
|
241
344
|
holdDurationMs: z.number(),
|
|
242
345
|
})
|
|
243
346
|
)
|
|
244
|
-
.query(async () =>
|
|
245
|
-
attestation
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
}),
|
|
248
365
|
|
|
249
366
|
enroll: didAndChallengeRoute
|
|
250
367
|
.meta({ openapi: { method: 'POST', path: '/keys/escrow', tags: ['Keys'] } })
|
|
@@ -319,7 +436,11 @@ export const escrowRouter = t.router({
|
|
|
319
436
|
oldBlob &&
|
|
320
437
|
!oldPin.disabledAt &&
|
|
321
438
|
oldPin.shareVersion === oldBlob.shareVersion &&
|
|
322
|
-
|
|
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)) &&
|
|
323
444
|
input.shareVersion > oldBlob.shareVersion &&
|
|
324
445
|
// A released blob's PIN is retired: it was just used, or the user forgot it.
|
|
325
446
|
!(await hasCompletedEscrowHoldForVersion(authProvider, oldBlob.shareVersion))
|
|
@@ -331,6 +452,8 @@ export const escrowRouter = t.router({
|
|
|
331
452
|
expectedDid: ctx.user.did,
|
|
332
453
|
sourceShareVersion: oldBlob.shareVersion,
|
|
333
454
|
targetShareVersion: input.shareVersion,
|
|
455
|
+
sourceEnrollmentEpoch: oldBlob.enrollmentEpoch,
|
|
456
|
+
targetEnrollmentEpoch: oldBlob.enrollmentEpoch + 1,
|
|
334
457
|
});
|
|
335
458
|
stored = await setEscrowBlobByAuthProvider(
|
|
336
459
|
authProvider,
|
|
@@ -391,8 +514,15 @@ export const escrowRouter = t.router({
|
|
|
391
514
|
for (const policy of ['hold', 'pin'] as const) {
|
|
392
515
|
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
|
|
393
516
|
const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
394
|
-
if (cancelled)
|
|
395
|
-
|
|
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
|
+
}
|
|
396
526
|
}
|
|
397
527
|
return { success: true as const };
|
|
398
528
|
}),
|
|
@@ -422,6 +552,12 @@ export const escrowRouter = t.router({
|
|
|
422
552
|
})
|
|
423
553
|
)
|
|
424
554
|
.mutation(async ({ input, ctx }) => {
|
|
555
|
+
if (isEscrowReleaseKillSwitchEnabled()) {
|
|
556
|
+
throw new TRPCError({
|
|
557
|
+
code: 'PRECONDITION_FAILED',
|
|
558
|
+
message: killSwitchMessage,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
425
561
|
let authProvider: AuthProviderMapping;
|
|
426
562
|
if (input.releasePolicy === 'pin') await limitPinCompletion(ctx.clientIp, 'start');
|
|
427
563
|
if (input.recoverySessionToken !== undefined) {
|
|
@@ -448,6 +584,13 @@ export const escrowRouter = t.router({
|
|
|
448
584
|
) {
|
|
449
585
|
throw new TRPCError({ code: 'NOT_FOUND', message: unavailableMessage });
|
|
450
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);
|
|
451
594
|
const now = new Date();
|
|
452
595
|
if (
|
|
453
596
|
input.releasePolicy === 'pin' &&
|
|
@@ -492,19 +635,42 @@ export const escrowRouter = t.router({
|
|
|
492
635
|
}
|
|
493
636
|
if (pending) {
|
|
494
637
|
const cancelled = await cancelEscrowHold(pending._id, 'system', 'superseded');
|
|
495
|
-
if (cancelled)
|
|
496
|
-
|
|
638
|
+
if (cancelled) {
|
|
639
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
640
|
+
await notifyEscrowHoldEvent({
|
|
497
641
|
kind: 'cancelled',
|
|
498
642
|
hold: cancelled,
|
|
499
643
|
userKey,
|
|
500
644
|
reason: 'superseded',
|
|
645
|
+
tenant: ctx.tenant,
|
|
501
646
|
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (await hasUnconfirmedEscrowCancellation(userKey.primaryDid)) {
|
|
650
|
+
throw new TRPCError({
|
|
651
|
+
code: 'PRECONDITION_FAILED',
|
|
652
|
+
message: killSwitchMessage,
|
|
653
|
+
});
|
|
502
654
|
}
|
|
503
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
|
+
);
|
|
504
668
|
let hold: EscrowHold;
|
|
505
669
|
try {
|
|
506
670
|
hold = await createEscrowHold({
|
|
671
|
+
holdRecord,
|
|
507
672
|
authProvider,
|
|
673
|
+
enclaveEnvelope: userKey.escrowBlob.envelope,
|
|
508
674
|
primaryDid: userKey.primaryDid,
|
|
509
675
|
shareVersion: userKey.escrowBlob.shareVersion,
|
|
510
676
|
identityProofType:
|
|
@@ -515,11 +681,18 @@ export const escrowRouter = t.router({
|
|
|
515
681
|
releaseAfter:
|
|
516
682
|
input.releasePolicy === 'pin'
|
|
517
683
|
? now
|
|
518
|
-
: new Date(
|
|
684
|
+
: new Date(
|
|
685
|
+
holdRecord.hold.createdHi +
|
|
686
|
+
holdRecord.holdDurationMs +
|
|
687
|
+
ESCROW_RELEASE_TIME_MARGIN_MS
|
|
688
|
+
),
|
|
519
689
|
releasePolicy: input.releasePolicy,
|
|
520
690
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
521
691
|
resumeTokenHash: hashEscrowResumeToken(resumeToken),
|
|
692
|
+
cancelTokenHash: hashEscrowCancelToken(cancelToken),
|
|
693
|
+
startedNotificationPending: true,
|
|
522
694
|
requestIp: ctx.clientIp,
|
|
695
|
+
tenantId: ctx.tenant?.id,
|
|
523
696
|
});
|
|
524
697
|
} catch (error) {
|
|
525
698
|
if (
|
|
@@ -554,7 +727,13 @@ export const escrowRouter = t.router({
|
|
|
554
727
|
message: 'Recovery could not be started.',
|
|
555
728
|
});
|
|
556
729
|
}
|
|
557
|
-
|
|
730
|
+
await notifyEscrowHoldEvent({
|
|
731
|
+
kind: 'started',
|
|
732
|
+
hold,
|
|
733
|
+
userKey,
|
|
734
|
+
cancelToken,
|
|
735
|
+
tenant: ctx.tenant,
|
|
736
|
+
});
|
|
558
737
|
return {
|
|
559
738
|
...serializeHold(hold),
|
|
560
739
|
status: 'pending' as const,
|
|
@@ -594,10 +773,53 @@ export const escrowRouter = t.router({
|
|
|
594
773
|
assertDidOwner(userKey, ctx.user.did);
|
|
595
774
|
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, 'hold');
|
|
596
775
|
const hold = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
597
|
-
if (hold)
|
|
776
|
+
if (hold) {
|
|
777
|
+
await cancelHoldInEnclave(hold, userKey);
|
|
778
|
+
await notifyEscrowHoldEvent({
|
|
779
|
+
kind: 'cancelled',
|
|
780
|
+
hold,
|
|
781
|
+
userKey,
|
|
782
|
+
tenant: ctx.tenant,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
598
785
|
return { success: true as const, cancelled: Boolean(hold) };
|
|
599
786
|
}),
|
|
600
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
|
+
|
|
601
823
|
completeRecovery: openRoute
|
|
602
824
|
.meta({ openapi: { method: 'POST', path: '/keys/escrow/complete', tags: ['Keys'] } })
|
|
603
825
|
.input(
|
|
@@ -618,6 +840,12 @@ export const escrowRouter = t.router({
|
|
|
618
840
|
})
|
|
619
841
|
)
|
|
620
842
|
.mutation(async ({ input, ctx }) => {
|
|
843
|
+
if (isEscrowReleaseKillSwitchEnabled()) {
|
|
844
|
+
throw new TRPCError({
|
|
845
|
+
code: 'PRECONDITION_FAILED',
|
|
846
|
+
message: killSwitchMessage,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
621
849
|
if (input.pinProof !== undefined) await limitPinCompletion(ctx.clientIp);
|
|
622
850
|
const hold = await findEscrowHoldById(input.holdId);
|
|
623
851
|
if (!hold || !resumeTokenMatches(hold, input.resumeToken)) {
|
|
@@ -632,7 +860,8 @@ export const escrowRouter = t.router({
|
|
|
632
860
|
message: 'This recovery request was cancelled.',
|
|
633
861
|
});
|
|
634
862
|
}
|
|
635
|
-
|
|
863
|
+
// Legacy Mongo rows can predate the required creation-time record.
|
|
864
|
+
if (hold.status !== 'pending' || !hold.holdRecord)
|
|
636
865
|
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
637
866
|
const now = new Date();
|
|
638
867
|
if (now < hold.releaseAfter) {
|
|
@@ -653,6 +882,7 @@ export const escrowRouter = t.router({
|
|
|
653
882
|
) {
|
|
654
883
|
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
655
884
|
}
|
|
885
|
+
await assertFreshEscrowBlob(userKey.escrowBlob);
|
|
656
886
|
const encryptedAuthShare = findAuthShareByVersion(userKey, hold.shareVersion);
|
|
657
887
|
if (!encryptedAuthShare || !environment.SEED) {
|
|
658
888
|
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
@@ -695,7 +925,7 @@ export const escrowRouter = t.router({
|
|
|
695
925
|
});
|
|
696
926
|
}
|
|
697
927
|
// Claim the pending hold BEFORE releasing secrets. The enclave receives the
|
|
698
|
-
// pending snapshot whose CAS we won;
|
|
928
|
+
// pending snapshot whose CAS we won; transient refusals reopen only our claim.
|
|
699
929
|
let completed: Awaited<ReturnType<typeof completeEscrowHold>> = null;
|
|
700
930
|
try {
|
|
701
931
|
completed = await completeEscrowHold(hold._id);
|
|
@@ -729,12 +959,14 @@ export const escrowRouter = t.router({
|
|
|
729
959
|
hold.shareVersion,
|
|
730
960
|
expectedCiphertext
|
|
731
961
|
);
|
|
732
|
-
if (completed)
|
|
962
|
+
if (completed) {
|
|
733
963
|
await markClaimedEscrowHoldFailed(
|
|
734
964
|
hold._id,
|
|
735
965
|
'release-failed',
|
|
736
966
|
completed.completedAt!
|
|
737
967
|
);
|
|
968
|
+
await cancelHoldInEnclave(hold, userKey);
|
|
969
|
+
}
|
|
738
970
|
throw error;
|
|
739
971
|
}
|
|
740
972
|
const release = await enclaveOperation(async () => {
|
|
@@ -742,7 +974,7 @@ export const escrowRouter = t.router({
|
|
|
742
974
|
return {
|
|
743
975
|
result: await getEscrowEnclave().releaseEscrow({
|
|
744
976
|
envelope: (reserved ?? userKey).escrowBlob!.envelope,
|
|
745
|
-
hold,
|
|
977
|
+
hold: hold.holdRecord,
|
|
746
978
|
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
747
979
|
expectedDid: userKey.primaryDid,
|
|
748
980
|
now,
|
|
@@ -760,11 +992,19 @@ export const escrowRouter = t.router({
|
|
|
760
992
|
hold.shareVersion,
|
|
761
993
|
expectedCiphertext
|
|
762
994
|
);
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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
|
+
}
|
|
768
1008
|
throw error;
|
|
769
1009
|
}
|
|
770
1010
|
});
|
|
@@ -780,7 +1020,8 @@ export const escrowRouter = t.router({
|
|
|
780
1020
|
const locked = !!failed?.escrowPin?.disabledAt;
|
|
781
1021
|
const cancelReason = locked ? 'pin-locked' : 'pin-mismatch';
|
|
782
1022
|
await markClaimedEscrowHoldFailed(hold._id, cancelReason, completed.completedAt!);
|
|
783
|
-
|
|
1023
|
+
await cancelHoldInEnclave(hold, userKey);
|
|
1024
|
+
await notifyEscrowHoldEvent({
|
|
784
1025
|
kind: 'cancelled',
|
|
785
1026
|
hold: {
|
|
786
1027
|
...completed,
|
|
@@ -790,9 +1031,10 @@ export const escrowRouter = t.router({
|
|
|
790
1031
|
cancelledAt: new Date(),
|
|
791
1032
|
},
|
|
792
1033
|
userKey,
|
|
1034
|
+
tenant: ctx.tenant,
|
|
793
1035
|
});
|
|
794
1036
|
if (locked) {
|
|
795
|
-
await lockPin(hold, userKey);
|
|
1037
|
+
await lockPin(hold, userKey, ctx.tenant);
|
|
796
1038
|
throw new TRPCError({
|
|
797
1039
|
code: 'TOO_MANY_REQUESTS',
|
|
798
1040
|
message: ESCROW_PIN_LOCKED_MESSAGE,
|
|
@@ -814,7 +1056,12 @@ export const escrowRouter = t.router({
|
|
|
814
1056
|
scope: 'rebind',
|
|
815
1057
|
authProvider: hold.authProvider,
|
|
816
1058
|
});
|
|
817
|
-
|
|
1059
|
+
await notifyEscrowHoldEvent({
|
|
1060
|
+
kind: 'completed',
|
|
1061
|
+
hold: completed,
|
|
1062
|
+
userKey,
|
|
1063
|
+
tenant: ctx.tenant,
|
|
1064
|
+
});
|
|
818
1065
|
return {
|
|
819
1066
|
sealedShare: sealed,
|
|
820
1067
|
authShare,
|
package/src/routes/keys.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { TenantBranding } from '@learncard/email-templates';
|
|
|
12
12
|
|
|
13
13
|
import { t, openRoute, didRoute, didAndChallengeRoute } from '@routes';
|
|
14
14
|
import { getDeliveryService, getFrom } from '../services/delivery';
|
|
15
|
+
import { getEscrowBlobStaleReason } from '../services/escrow-enclave';
|
|
15
16
|
import { verifyAuthToken, getContactMethodFromUser, AuthProviderType } from '@helpers/auth.helpers';
|
|
16
17
|
import { encryptAuthShare, decryptAuthShare } from '@helpers/shareEncryption.helpers';
|
|
17
18
|
import { maskEmail } from '@helpers/maskEmail';
|
|
@@ -636,6 +637,7 @@ export const keysRouter = t.router({
|
|
|
636
637
|
maskedRecoveryEmail: z.string().nullable(),
|
|
637
638
|
escrowOptedOut: z.boolean(),
|
|
638
639
|
escrowPin: EscrowPinStatusValidator,
|
|
640
|
+
escrowStale: z.enum(['mode-mismatch', 'key-rotated']).optional(),
|
|
639
641
|
sssActivationState: z.enum(['provisional', 'active']),
|
|
640
642
|
})
|
|
641
643
|
.nullable()
|
|
@@ -710,6 +712,9 @@ export const keysRouter = t.router({
|
|
|
710
712
|
sssActivationState: getSssActivationState(userKey),
|
|
711
713
|
escrowOptedOut: Boolean(userKey.escrowOptedOutAt),
|
|
712
714
|
escrowPin: getEscrowPinStatus(userKey),
|
|
715
|
+
escrowStale: userKey.escrowBlob
|
|
716
|
+
? await getEscrowBlobStaleReason(userKey.escrowBlob)
|
|
717
|
+
: undefined,
|
|
713
718
|
};
|
|
714
719
|
}),
|
|
715
720
|
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
getSigningAuthorityForDid,
|
|
9
9
|
} from '@accesslayer/signing-authority/read';
|
|
10
10
|
|
|
11
|
-
import { t, didAndChallengeRoute } from '@routes';
|
|
11
|
+
import { t, didAndChallengeRoute, authorizedDidRoute } from '@routes';
|
|
12
12
|
|
|
13
13
|
import { SigningAuthorityResponseValidator } from '@models';
|
|
14
14
|
import type { MongoSigningAuthorityType } from '@models';
|
|
@@ -62,6 +62,44 @@ export const signingAuthorityRouter = t.router({
|
|
|
62
62
|
});
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
return getSigningAuthorityWithEndpoint(
|
|
66
|
+
{ ...signingAuthority, did: signingAuthority.did },
|
|
67
|
+
ctx.domain
|
|
68
|
+
);
|
|
69
|
+
}),
|
|
70
|
+
ensureManagedSigningAuthority: authorizedDidRoute
|
|
71
|
+
.meta({
|
|
72
|
+
openapi: {
|
|
73
|
+
protect: true,
|
|
74
|
+
method: 'POST',
|
|
75
|
+
path: '/signing-authority/managed',
|
|
76
|
+
tags: ['Signing Authority'],
|
|
77
|
+
summary: 'Get or create a managed Signing Authority',
|
|
78
|
+
description:
|
|
79
|
+
'Trusted-service route that returns the named signing authority for ownerDid, creating it if needed',
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
.input(z.object({ name: z.string().min(1), ownerDid: z.string().min(1) }))
|
|
83
|
+
.output(SigningAuthorityResponseValidator)
|
|
84
|
+
.mutation(async ({ input, ctx }) => {
|
|
85
|
+
const { name, ownerDid } = input;
|
|
86
|
+
|
|
87
|
+
// A concurrent caller can win the unique (ownerDid, name) insert, so re-read on failure.
|
|
88
|
+
const signingAuthority =
|
|
89
|
+
(await getSigningAuthorityForDid(ownerDid, name)) ??
|
|
90
|
+
(await createSigningAuthorityForDID(ownerDid, name).then(createdId =>
|
|
91
|
+
createdId
|
|
92
|
+
? getSigningAuthorityById(createdId)
|
|
93
|
+
: getSigningAuthorityForDid(ownerDid, name)
|
|
94
|
+
));
|
|
95
|
+
|
|
96
|
+
if (!signingAuthority?.did) {
|
|
97
|
+
throw new TRPCError({
|
|
98
|
+
code: 'INTERNAL_SERVER_ERROR',
|
|
99
|
+
message: 'Managed Signing Authority could not be created.',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
return getSigningAuthorityWithEndpoint(
|
|
66
104
|
{ ...signingAuthority, did: signingAuthority.did },
|
|
67
105
|
ctx.domain
|