@learncard/lca-api-service 1.5.1 → 1.5.3
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 +2 -2
- package/CHANGELOG.md +32 -0
- package/config/config.dev.json +1 -1
- package/dist/app.d.ts +239 -1
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +30 -2
- package/dist/models/UserKey.d.ts +89 -5
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +237 -0
- package/dist/routes/index.d.ts +4 -4
- package/dist/routes/keys.d.ts +2 -1
- package/dist/services/escrow-enclave/authorityContract.d.ts +170 -0
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +55 -3
- package/esbuildPlugins.cjs +9 -6
- package/package.json +10 -10
- package/src/config/environment.ts +1 -1
- package/src/config/escrowDeployment.test.ts +8 -0
- package/src/jobs/escrowBlobRewrap.ts +45 -2
- package/src/jobs/escrowHoldReminders.ts +50 -1
- package/src/models/EscrowHold.ts +128 -10
- package/src/models/UserKey.ts +59 -8
- package/src/routes/ai.test.ts +103 -0
- package/src/routes/ai.ts +2 -1
- package/src/routes/escrow.ts +386 -53
- package/src/routes/index.ts +1 -1
- package/src/routes/keys.ts +5 -0
- package/src/services/escrow-enclave/README.md +31 -0
- package/src/services/escrow-enclave/authorityContract.ts +126 -0
- package/src/services/escrow-enclave/cancellation.ts +2 -2
- package/src/services/escrow-enclave/logFailure.test.ts +64 -0
- package/src/services/escrow-enclave/logFailure.ts +29 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +315 -4
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +182 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +202 -31
- package/src/services/escrow-enclave/types.ts +77 -5
- package/test/jobs/escrowHoldReminders.spec.ts +195 -3
- package/test/keys-escrow.spec.ts +587 -11
- package/test/models/escrow.spec.ts +13 -0
package/src/routes/ai.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
|
|
5
5
|
|
|
6
6
|
import { t, didAndChallengeRoute } from '@routes';
|
|
7
7
|
import { TRPCError } from '@trpc/server';
|
|
8
|
+
import { GenerateSkillIconsInputValidator } from '@learncard/types';
|
|
8
9
|
|
|
9
10
|
import * as filestack from 'filestack-js';
|
|
10
11
|
import { BoostSkillHierarchy, BoostSkillHierarchyValidator } from 'types/skills';
|
|
@@ -204,7 +205,7 @@ export const aiRouter = t.router({
|
|
|
204
205
|
'Given a list of skill names, returns a mapping of each name to a single unicode emoji that best represents it. No skin tones, flags, or multi-character sequences.',
|
|
205
206
|
},
|
|
206
207
|
})
|
|
207
|
-
.input(
|
|
208
|
+
.input(GenerateSkillIconsInputValidator)
|
|
208
209
|
.output(IconMapValidator)
|
|
209
210
|
.query(async ({ input, ctx }) => {
|
|
210
211
|
const {
|
package/src/routes/escrow.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import { timingSafeEqual, randomUUID } from 'crypto';
|
|
1
|
+
import { timingSafeEqual, randomUUID, randomBytes } from 'crypto';
|
|
2
|
+
import { logEscrowFailure } from '../services/escrow-enclave/logFailure';
|
|
3
|
+
import {
|
|
4
|
+
AuthorityCommitValidator,
|
|
5
|
+
AuthorityHoldReceiptValidator,
|
|
6
|
+
AuthorityStateResponseValidator,
|
|
7
|
+
SignedEnrollmentIntentValidator,
|
|
8
|
+
} from '../services/escrow-enclave/authorityContract';
|
|
2
9
|
import { z } from 'zod';
|
|
3
10
|
import { TRPCError } from '@trpc/server';
|
|
4
11
|
import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
|
|
@@ -42,10 +49,15 @@ import {
|
|
|
42
49
|
hasUnconfirmedEscrowCancellation,
|
|
43
50
|
cancelEscrowHoldByCancelToken,
|
|
44
51
|
completeEscrowHold,
|
|
52
|
+
recordEscrowHoldConfirmation,
|
|
53
|
+
recordEscrowReleaseReceipt,
|
|
45
54
|
hasCompletedEscrowHoldForVersion,
|
|
46
55
|
expireStaleEscrowHolds,
|
|
47
56
|
hashEscrowResumeToken,
|
|
48
57
|
generateEscrowResumeToken,
|
|
58
|
+
computeEffectiveReleaseAfter,
|
|
59
|
+
ESCROW_RELEASE_TIME_MARGIN_MS,
|
|
60
|
+
EscrowHoldRecordValidator,
|
|
49
61
|
type EscrowHold,
|
|
50
62
|
type MongoUserKeyType,
|
|
51
63
|
type AuthProviderMapping,
|
|
@@ -68,6 +80,7 @@ import {
|
|
|
68
80
|
getEnclaveAttestationIdentity,
|
|
69
81
|
notifyEscrowHoldEvent,
|
|
70
82
|
EscrowPolicyError,
|
|
83
|
+
EscrowCodeError,
|
|
71
84
|
EscrowBlobError,
|
|
72
85
|
EscrowUnavailableError,
|
|
73
86
|
EscrowPinMismatchError,
|
|
@@ -78,9 +91,6 @@ const unavailableMessage = 'Automatic recovery is not available for this account
|
|
|
78
91
|
const killSwitchMessage = 'Automatic recovery is temporarily unavailable. Please try again later.';
|
|
79
92
|
const invalidMessage = 'This recovery request is no longer valid.';
|
|
80
93
|
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
94
|
// Keep throttling distinct: clients reserve the locked message for lifetime PIN exhaustion.
|
|
85
95
|
const pinThrottledMessage = 'Please wait before trying again.';
|
|
86
96
|
|
|
@@ -192,6 +202,14 @@ const holdStatusValidator = z.object({
|
|
|
192
202
|
releasePolicy: z.enum(['hold', 'pin']),
|
|
193
203
|
cancelledAt: z.string().optional(),
|
|
194
204
|
completedAt: z.string().optional(),
|
|
205
|
+
// P9.1 (attack C): lets the client decide whether to show the
|
|
206
|
+
// code-entry step; `confirmed` only ever appears once `requiresConfirmation` does.
|
|
207
|
+
requiresConfirmation: z.boolean().optional(),
|
|
208
|
+
confirmed: z.boolean().optional(),
|
|
209
|
+
// [D] Opaque enclave-signed SignedHoldRecord (authority/nitro mode only).
|
|
210
|
+
// The client independently verifies this (verifyEscrowHoldRecord) before
|
|
211
|
+
// trusting any field inside it — see escrow-authority.ts.
|
|
212
|
+
holdRecord: EscrowHoldRecordValidator.optional(),
|
|
195
213
|
});
|
|
196
214
|
|
|
197
215
|
const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> => ({
|
|
@@ -202,6 +220,10 @@ const serializeHold = (hold: EscrowHold): z.infer<typeof holdStatusValidator> =>
|
|
|
202
220
|
releasePolicy: hold.releasePolicy ?? 'hold',
|
|
203
221
|
...(hold.cancelledAt ? { cancelledAt: hold.cancelledAt.toISOString() } : {}),
|
|
204
222
|
...(hold.completedAt ? { completedAt: hold.completedAt.toISOString() } : {}),
|
|
223
|
+
...(hold.requiresConfirmation
|
|
224
|
+
? { requiresConfirmation: true, confirmed: Boolean(hold.confirmedAt) }
|
|
225
|
+
: {}),
|
|
226
|
+
...(hold.holdRecord ? { holdRecord: hold.holdRecord } : {}),
|
|
205
227
|
});
|
|
206
228
|
|
|
207
229
|
const resumeTokenMatches = (hold: EscrowHold, token: string): boolean => {
|
|
@@ -227,6 +249,10 @@ const enclaveOperation = async <T>(
|
|
|
227
249
|
try {
|
|
228
250
|
return await operation();
|
|
229
251
|
} catch (error) {
|
|
252
|
+
logEscrowFailure(error);
|
|
253
|
+
if (error instanceof EscrowCodeError) {
|
|
254
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: error.reason });
|
|
255
|
+
}
|
|
230
256
|
if (error instanceof EscrowUnavailableError) {
|
|
231
257
|
throw new TRPCError({
|
|
232
258
|
code: 'PRECONDITION_FAILED',
|
|
@@ -317,6 +343,85 @@ const statusInput = z
|
|
|
317
343
|
{ message: 'Provide exactly one identity proof.' }
|
|
318
344
|
);
|
|
319
345
|
|
|
346
|
+
const removalInput = AuthInputValidator.extend({
|
|
347
|
+
optOut: z.boolean(),
|
|
348
|
+
signedIntent: SignedEnrollmentIntentValidator.optional(),
|
|
349
|
+
sourceEnvelope: EscrowEnvelopeValidator.optional(),
|
|
350
|
+
}).strict();
|
|
351
|
+
const removalOutput = successValidator.extend({ receipt: AuthorityCommitValidator.optional() });
|
|
352
|
+
|
|
353
|
+
const removeEscrow = async (
|
|
354
|
+
input: z.infer<typeof removalInput>,
|
|
355
|
+
did: string,
|
|
356
|
+
tenant: Context['tenant']
|
|
357
|
+
): Promise<z.infer<typeof removalOutput>> => {
|
|
358
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
359
|
+
const userKey = await requireUserKey(authProvider);
|
|
360
|
+
assertDidOwner(userKey, did);
|
|
361
|
+
if (
|
|
362
|
+
input.optOut &&
|
|
363
|
+
!userKey.recoveryMethods.some(
|
|
364
|
+
method =>
|
|
365
|
+
method.type !== 'escrow' &&
|
|
366
|
+
method.shareVersion === userKey.shareVersion &&
|
|
367
|
+
isRecoveryMethodConfirmed(userKey, method)
|
|
368
|
+
)
|
|
369
|
+
) {
|
|
370
|
+
throw new TRPCError({
|
|
371
|
+
code: 'PRECONDITION_FAILED',
|
|
372
|
+
message: 'Set up another recovery method before turning off automatic recovery.',
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
let receipt;
|
|
376
|
+
if (isEscrowRemoteMode()) {
|
|
377
|
+
const enclave = getEscrowEnclave();
|
|
378
|
+
const signed = input.signedIntent;
|
|
379
|
+
if (
|
|
380
|
+
!signed ||
|
|
381
|
+
!enclave.applyEnrollmentIntent ||
|
|
382
|
+
signed.intent.action !== 'disable' ||
|
|
383
|
+
signed.intent.did !== did ||
|
|
384
|
+
signed.intent.tenant !== tenant.id
|
|
385
|
+
)
|
|
386
|
+
throw new TRPCError({
|
|
387
|
+
code: 'BAD_REQUEST',
|
|
388
|
+
message: 'Signed disable intent is required.',
|
|
389
|
+
});
|
|
390
|
+
const result = await enclaveOperation(() =>
|
|
391
|
+
enclave.applyEnrollmentIntent!({
|
|
392
|
+
signedIntent: signed,
|
|
393
|
+
sourceEnvelope: input.sourceEnvelope ?? userKey.escrowBlob?.envelope ?? null,
|
|
394
|
+
targetEnvelope: null,
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
receipt = result.receipt;
|
|
398
|
+
} else if (input.signedIntent)
|
|
399
|
+
throw new TRPCError({
|
|
400
|
+
code: 'PRECONDITION_FAILED',
|
|
401
|
+
message: 'Signed authority operations require remote mode.',
|
|
402
|
+
});
|
|
403
|
+
const cleared = await clearEscrowByAuthProvider(authProvider, {
|
|
404
|
+
optOut: input.optOut,
|
|
405
|
+
...(isEscrowRemoteMode()
|
|
406
|
+
? { expected: { blob: userKey.escrowBlob, pin: userKey.escrowPin } }
|
|
407
|
+
: {}),
|
|
408
|
+
});
|
|
409
|
+
if (!cleared)
|
|
410
|
+
throw new TRPCError({
|
|
411
|
+
code: 'CONFLICT',
|
|
412
|
+
message: 'Automatic recovery changed; please refresh.',
|
|
413
|
+
});
|
|
414
|
+
for (const policy of ['hold', 'pin'] as const) {
|
|
415
|
+
const pending = await findPendingEscrowHoldByAuthProvider(authProvider, policy);
|
|
416
|
+
const cancelled = pending && (await cancelEscrowHold(pending._id, 'did'));
|
|
417
|
+
if (cancelled) {
|
|
418
|
+
await cancelHoldInEnclave(cancelled, userKey);
|
|
419
|
+
await notifyEscrowHoldEvent({ kind: 'cancelled', hold: cancelled, userKey, tenant });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { success: true, ...(receipt ? { receipt } : {}) };
|
|
423
|
+
};
|
|
424
|
+
|
|
320
425
|
export const escrowRouter = t.router({
|
|
321
426
|
getAttestation: openRoute
|
|
322
427
|
.meta({ openapi: { method: 'GET', path: '/keys/escrow/attestation', tags: ['Keys'] } })
|
|
@@ -363,6 +468,25 @@ export const escrowRouter = t.router({
|
|
|
363
468
|
};
|
|
364
469
|
}),
|
|
365
470
|
|
|
471
|
+
getAuthorityState: didAndChallengeRoute
|
|
472
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/authority-state', tags: ['Keys'] } })
|
|
473
|
+
.input(AuthInputValidator.extend({ nonce: z.string().regex(/^[0-9a-f]{64}$/) }).strict())
|
|
474
|
+
.output(AuthorityStateResponseValidator)
|
|
475
|
+
.mutation(async ({ input, ctx }) => {
|
|
476
|
+
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
477
|
+
const userKey = await requireUserKey(authProvider);
|
|
478
|
+
assertDidOwner(userKey, ctx.user.did);
|
|
479
|
+
const enclave = getEscrowEnclave();
|
|
480
|
+
if (!isEscrowRemoteMode() || !enclave.getEnrollmentState)
|
|
481
|
+
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: unavailableMessage });
|
|
482
|
+
const result = await enclaveOperation(() =>
|
|
483
|
+
enclave.getEnrollmentState!({ expectedDid: ctx.user.did, nonce: input.nonce })
|
|
484
|
+
);
|
|
485
|
+
if (result.state.did !== ctx.user.did || result.state.tenant !== ctx.tenant.id)
|
|
486
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
487
|
+
return result;
|
|
488
|
+
}),
|
|
489
|
+
|
|
366
490
|
enroll: didAndChallengeRoute
|
|
367
491
|
.meta({ openapi: { method: 'POST', path: '/keys/escrow', tags: ['Keys'] } })
|
|
368
492
|
.input(
|
|
@@ -372,9 +496,17 @@ export const escrowRouter = t.router({
|
|
|
372
496
|
enclaveKeyId: EscrowBlobValidator.shape.enclaveKeyId,
|
|
373
497
|
pinSalt: EscrowPinSaltValidator.optional(),
|
|
374
498
|
clearPin: z.boolean().optional(),
|
|
499
|
+
signedIntent: SignedEnrollmentIntentValidator.optional(),
|
|
500
|
+
sourceEnvelope: EscrowEnvelopeValidator.nullable().optional(),
|
|
375
501
|
}).strict()
|
|
376
502
|
)
|
|
377
|
-
.output(
|
|
503
|
+
.output(
|
|
504
|
+
successValidator.extend({
|
|
505
|
+
shareVersion: z.number(),
|
|
506
|
+
envelope: EscrowEnvelopeValidator.optional(),
|
|
507
|
+
receipt: AuthorityCommitValidator.optional(),
|
|
508
|
+
})
|
|
509
|
+
)
|
|
378
510
|
.mutation(async ({ input, ctx }) => {
|
|
379
511
|
const { authProvider } = await verifyAndGetContactMethod(input);
|
|
380
512
|
const userKey = await requireUserKey(authProvider);
|
|
@@ -391,6 +523,111 @@ export const escrowRouter = t.router({
|
|
|
391
523
|
message: 'Key material changed; please retry.',
|
|
392
524
|
});
|
|
393
525
|
}
|
|
526
|
+
if (isEscrowRemoteMode()) {
|
|
527
|
+
const enclave = getEscrowEnclave();
|
|
528
|
+
const signed = input.signedIntent;
|
|
529
|
+
if (!signed || !enclave.applyEnrollmentIntent || !enclave.getEnrollmentState)
|
|
530
|
+
throw new TRPCError({
|
|
531
|
+
code: 'BAD_REQUEST',
|
|
532
|
+
message: 'Signed enrollment intent is required.',
|
|
533
|
+
});
|
|
534
|
+
const intent = signed.intent;
|
|
535
|
+
if (
|
|
536
|
+
intent.did !== ctx.user.did ||
|
|
537
|
+
intent.tenant !== ctx.tenant.id ||
|
|
538
|
+
intent.action === 'disable' ||
|
|
539
|
+
intent.shareVersion !== input.shareVersion ||
|
|
540
|
+
!!input.pinSalt !==
|
|
541
|
+
(intent.pinAction === 'set' || intent.pinAction === 'change') ||
|
|
542
|
+
!!input.clearPin !== (intent.pinAction === 'clear')
|
|
543
|
+
)
|
|
544
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: invalidMessage });
|
|
545
|
+
const current = await enclaveOperation(() =>
|
|
546
|
+
enclave.getEnrollmentState!({
|
|
547
|
+
expectedDid: ctx.user.did,
|
|
548
|
+
nonce: randomBytes(32).toString('hex'),
|
|
549
|
+
})
|
|
550
|
+
);
|
|
551
|
+
if (
|
|
552
|
+
current.state.enrollmentVersion !== intent.expectedEnrollmentVersion ||
|
|
553
|
+
current.state.authorityGeneration !== intent.authorityGeneration
|
|
554
|
+
)
|
|
555
|
+
throw new TRPCError({
|
|
556
|
+
code: 'CONFLICT',
|
|
557
|
+
message: 'Automatic recovery changed; please refresh.',
|
|
558
|
+
});
|
|
559
|
+
const preservePin = intent.pinAction === 'keep' && current.state.hasPin;
|
|
560
|
+
const oldBlob = userKey.escrowBlob;
|
|
561
|
+
const oldPin = userKey.escrowPin;
|
|
562
|
+
if (
|
|
563
|
+
preservePin &&
|
|
564
|
+
(!oldBlob || !oldPin || oldBlob.blobHash !== intent.sourceBlobIdentity)
|
|
565
|
+
)
|
|
566
|
+
throw new TRPCError({
|
|
567
|
+
code: 'PRECONDITION_FAILED',
|
|
568
|
+
message: 'Set or clear your recovery PIN before continuing.',
|
|
569
|
+
});
|
|
570
|
+
const attestation = await enclaveOperation(() => enclave.getAttestation());
|
|
571
|
+
if (
|
|
572
|
+
attestation.mode !== 'nitro' ||
|
|
573
|
+
input.envelope.keyId !== attestation.keyId ||
|
|
574
|
+
input.enclaveKeyId !== attestation.keyId
|
|
575
|
+
)
|
|
576
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: invalidMessage });
|
|
577
|
+
const result = await enclaveOperation(() =>
|
|
578
|
+
enclave.applyEnrollmentIntent!({
|
|
579
|
+
signedIntent: signed,
|
|
580
|
+
sourceEnvelope:
|
|
581
|
+
input.sourceEnvelope === undefined
|
|
582
|
+
? current.state.envelope
|
|
583
|
+
: input.sourceEnvelope,
|
|
584
|
+
targetEnvelope: input.envelope,
|
|
585
|
+
})
|
|
586
|
+
);
|
|
587
|
+
if (!result.envelope)
|
|
588
|
+
throw new TRPCError({
|
|
589
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
590
|
+
message: unavailableMessage,
|
|
591
|
+
});
|
|
592
|
+
const stored = await setEscrowBlobByAuthProvider(
|
|
593
|
+
authProvider,
|
|
594
|
+
{
|
|
595
|
+
envelope: result.envelope,
|
|
596
|
+
enclaveKeyId: result.envelope.keyId,
|
|
597
|
+
enclaveMode: 'nitro',
|
|
598
|
+
measurements: attestation.measurements,
|
|
599
|
+
shareVersion: input.shareVersion,
|
|
600
|
+
createdAt: new Date(),
|
|
601
|
+
authorityGeneration: result.authorityGeneration,
|
|
602
|
+
enrollmentVersion: result.enrollmentVersion,
|
|
603
|
+
commitReceipt: result.receipt,
|
|
604
|
+
},
|
|
605
|
+
input.shareVersion,
|
|
606
|
+
input.pinSalt ? { salt: input.pinSalt } : undefined,
|
|
607
|
+
preservePin && oldBlob && oldPin ? { blob: oldBlob, pin: oldPin } : undefined,
|
|
608
|
+
{
|
|
609
|
+
enrollmentEpoch: result.enrollmentEpoch,
|
|
610
|
+
expectedBlob: oldBlob,
|
|
611
|
+
expectedPin: oldPin,
|
|
612
|
+
}
|
|
613
|
+
);
|
|
614
|
+
if (!stored)
|
|
615
|
+
throw new TRPCError({
|
|
616
|
+
code: 'CONFLICT',
|
|
617
|
+
message: 'Automatic recovery changed; refresh its authoritative state.',
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
success: true as const,
|
|
621
|
+
shareVersion: input.shareVersion,
|
|
622
|
+
envelope: result.envelope,
|
|
623
|
+
receipt: result.receipt,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (input.signedIntent)
|
|
627
|
+
throw new TRPCError({
|
|
628
|
+
code: 'PRECONDITION_FAILED',
|
|
629
|
+
message: 'Signed authority operations require remote mode.',
|
|
630
|
+
});
|
|
394
631
|
const attestation = await enclaveOperation(() => getEscrowEnclave().getAttestation());
|
|
395
632
|
if (
|
|
396
633
|
input.enclaveKeyId !== attestation.keyId ||
|
|
@@ -490,42 +727,15 @@ export const escrowRouter = t.router({
|
|
|
490
727
|
remove: didAndChallengeRoute
|
|
491
728
|
.meta({ openapi: { method: 'DELETE', path: '/keys/escrow', tags: ['Keys'] } })
|
|
492
729
|
.input(AuthInputValidator.extend({ optOut: z.boolean() }).strict())
|
|
493
|
-
.output(
|
|
494
|
-
.mutation(
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
-
}),
|
|
730
|
+
.output(removalOutput)
|
|
731
|
+
.mutation(({ input, ctx }) => removeEscrow(input, ctx.user.did, ctx.tenant)),
|
|
732
|
+
|
|
733
|
+
// Signed capabilities and encrypted sources belong in a body, never a DELETE query URL.
|
|
734
|
+
disable: didAndChallengeRoute
|
|
735
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/disable', tags: ['Keys'] } })
|
|
736
|
+
.input(removalInput.extend({ signedIntent: SignedEnrollmentIntentValidator }))
|
|
737
|
+
.output(removalOutput)
|
|
738
|
+
.mutation(({ input, ctx }) => removeEscrow(input, ctx.user.did, ctx.tenant)),
|
|
529
739
|
|
|
530
740
|
optIn: didAndChallengeRoute
|
|
531
741
|
.meta({ openapi: { method: 'POST', path: '/keys/escrow/opt-in', tags: ['Keys'] } })
|
|
@@ -544,11 +754,22 @@ export const escrowRouter = t.router({
|
|
|
544
754
|
.input(startInput)
|
|
545
755
|
.output(
|
|
546
756
|
holdStatusValidator
|
|
547
|
-
.pick({
|
|
757
|
+
.pick({
|
|
758
|
+
holdId: true,
|
|
759
|
+
requestedAt: true,
|
|
760
|
+
releaseAfter: true,
|
|
761
|
+
releasePolicy: true,
|
|
762
|
+
requiresConfirmation: true,
|
|
763
|
+
holdRecord: true,
|
|
764
|
+
})
|
|
548
765
|
.extend({
|
|
549
766
|
status: z.literal('pending'),
|
|
550
767
|
resumeToken: z.string().nullable(),
|
|
551
768
|
pinSalt: EscrowPinSaltValidator.optional(),
|
|
769
|
+
codeSent: z.boolean().optional(),
|
|
770
|
+
delivery: z
|
|
771
|
+
.object({ codeSent: z.boolean(), proof: AuthorityHoldReceiptValidator })
|
|
772
|
+
.optional(),
|
|
552
773
|
})
|
|
553
774
|
)
|
|
554
775
|
.mutation(async ({ input, ctx }) => {
|
|
@@ -654,7 +875,14 @@ export const escrowRouter = t.router({
|
|
|
654
875
|
}
|
|
655
876
|
const resumeToken = generateEscrowResumeToken();
|
|
656
877
|
const cancelToken = generateEscrowCancelToken();
|
|
657
|
-
|
|
878
|
+
// P9.1 (attack C): `confirmHold` is defined only by the remote
|
|
879
|
+
// (nitro/authority) backend. A Hold-policy hold there always goes
|
|
880
|
+
// through the enclave's own code-then-confirmation email flow, so
|
|
881
|
+
// lca-api's own Mongo-recipient-sourced 'started' notification is
|
|
882
|
+
// suppressed entirely rather than racing/duplicating it.
|
|
883
|
+
const isAuthorityManagedHold =
|
|
884
|
+
input.releasePolicy === 'hold' && Boolean(getEscrowEnclave().confirmHold);
|
|
885
|
+
const { holdRecord, delivery } = await enclaveOperation(() =>
|
|
658
886
|
getEscrowEnclave().createHold({
|
|
659
887
|
envelope: userKey.escrowBlob!.envelope,
|
|
660
888
|
holdId: randomUUID(),
|
|
@@ -690,7 +918,8 @@ export const escrowRouter = t.router({
|
|
|
690
918
|
clientEphemeralPublicKey: input.clientEphemeralPublicKey,
|
|
691
919
|
resumeTokenHash: hashEscrowResumeToken(resumeToken),
|
|
692
920
|
cancelTokenHash: hashEscrowCancelToken(cancelToken),
|
|
693
|
-
startedNotificationPending:
|
|
921
|
+
startedNotificationPending: !isAuthorityManagedHold,
|
|
922
|
+
requiresConfirmation: isAuthorityManagedHold,
|
|
694
923
|
requestIp: ctx.clientIp,
|
|
695
924
|
tenantId: ctx.tenant?.id,
|
|
696
925
|
});
|
|
@@ -727,21 +956,120 @@ export const escrowRouter = t.router({
|
|
|
727
956
|
message: 'Recovery could not be started.',
|
|
728
957
|
});
|
|
729
958
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
959
|
+
if (!isAuthorityManagedHold) {
|
|
960
|
+
await notifyEscrowHoldEvent({
|
|
961
|
+
kind: 'started',
|
|
962
|
+
hold,
|
|
963
|
+
userKey,
|
|
964
|
+
cancelToken,
|
|
965
|
+
tenant: ctx.tenant,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
737
968
|
return {
|
|
738
969
|
...serializeHold(hold),
|
|
739
970
|
status: 'pending' as const,
|
|
740
971
|
resumeToken,
|
|
972
|
+
...(delivery ? { delivery, codeSent: delivery.codeSent } : {}),
|
|
741
973
|
...(input.releasePolicy === 'pin' ? { pinSalt: userKey.escrowPin!.salt } : {}),
|
|
742
974
|
};
|
|
743
975
|
}),
|
|
744
976
|
|
|
977
|
+
// P9.1 (attack C): no DID session exists yet at this point in the flow
|
|
978
|
+
// (same stage as completeRecovery) — resumeToken is the only credential.
|
|
979
|
+
// The enclave is the sole source of truth for the code; this route never
|
|
980
|
+
// reads or stores it, only relays the guess and its enclave-returned result.
|
|
981
|
+
confirmRecovery: openRoute
|
|
982
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/confirm', tags: ['Keys'] } })
|
|
983
|
+
.input(resumeInput.extend({ code: z.string().regex(/^[0-9]{8}$/) }))
|
|
984
|
+
.output(successValidator.extend({ receipt: AuthorityHoldReceiptValidator.optional() }))
|
|
985
|
+
.mutation(async ({ input }) => {
|
|
986
|
+
if (isEscrowReleaseKillSwitchEnabled()) {
|
|
987
|
+
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: killSwitchMessage });
|
|
988
|
+
}
|
|
989
|
+
const hold = await findEscrowHoldById(input.holdId);
|
|
990
|
+
if (
|
|
991
|
+
!hold ||
|
|
992
|
+
!resumeTokenMatches(hold, input.resumeToken) ||
|
|
993
|
+
hold.status !== 'pending'
|
|
994
|
+
) {
|
|
995
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
996
|
+
}
|
|
997
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
998
|
+
hold.authProvider.type,
|
|
999
|
+
hold.authProvider.id
|
|
1000
|
+
);
|
|
1001
|
+
const envelope = hold.enclaveEnvelope ?? userKey?.escrowBlob?.envelope;
|
|
1002
|
+
if (!hold.holdRecord || !envelope) {
|
|
1003
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1004
|
+
}
|
|
1005
|
+
const confirmHold = getEscrowEnclave().confirmHold;
|
|
1006
|
+
if (!confirmHold) throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1007
|
+
const { receipt, confirmedAtMs } = await enclaveOperation(() =>
|
|
1008
|
+
confirmHold({
|
|
1009
|
+
envelope,
|
|
1010
|
+
hold: hold.holdRecord!,
|
|
1011
|
+
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
1012
|
+
expectedDid: hold.primaryDid,
|
|
1013
|
+
code: input.code,
|
|
1014
|
+
})
|
|
1015
|
+
);
|
|
1016
|
+
// enclaveOperation re-throws on a code mismatch (the enclave
|
|
1017
|
+
// itself returns an error there); reaching this line means the
|
|
1018
|
+
// code matched. releaseAfter is recomputed from the enclave's
|
|
1019
|
+
// own authoritative confirmation time (`confirmedAtMs`), never a
|
|
1020
|
+
// locally observed `now` — a retried confirm (lost response,
|
|
1021
|
+
// idempotent snapshot) must not add another day on top of a
|
|
1022
|
+
// confirmation that already happened [A2].
|
|
1023
|
+
await recordEscrowHoldConfirmation(
|
|
1024
|
+
hold._id,
|
|
1025
|
+
new Date(confirmedAtMs),
|
|
1026
|
+
computeEffectiveReleaseAfter({
|
|
1027
|
+
confirmedAtMs,
|
|
1028
|
+
holdDurationMs: hold.holdRecord.holdDurationMs,
|
|
1029
|
+
})
|
|
1030
|
+
);
|
|
1031
|
+
// [D] Returned so the client can verify this confirmation
|
|
1032
|
+
// commits to the SAME hold it started (verifyEscrowHoldConfirmation).
|
|
1033
|
+
return { success: true as const, receipt };
|
|
1034
|
+
}),
|
|
1035
|
+
|
|
1036
|
+
resendRecoveryCode: openRoute
|
|
1037
|
+
.meta({ openapi: { method: 'POST', path: '/keys/escrow/resend', tags: ['Keys'] } })
|
|
1038
|
+
.input(resumeInput)
|
|
1039
|
+
.output(successValidator)
|
|
1040
|
+
.mutation(async ({ input }) => {
|
|
1041
|
+
if (isEscrowReleaseKillSwitchEnabled()) {
|
|
1042
|
+
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: killSwitchMessage });
|
|
1043
|
+
}
|
|
1044
|
+
const hold = await findEscrowHoldById(input.holdId);
|
|
1045
|
+
if (
|
|
1046
|
+
!hold ||
|
|
1047
|
+
!resumeTokenMatches(hold, input.resumeToken) ||
|
|
1048
|
+
hold.status !== 'pending'
|
|
1049
|
+
) {
|
|
1050
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1051
|
+
}
|
|
1052
|
+
const userKey = await findUserKeyByAuthProvider(
|
|
1053
|
+
hold.authProvider.type,
|
|
1054
|
+
hold.authProvider.id
|
|
1055
|
+
);
|
|
1056
|
+
const envelope = hold.enclaveEnvelope ?? userKey?.escrowBlob?.envelope;
|
|
1057
|
+
if (!hold.holdRecord || !envelope) {
|
|
1058
|
+
throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1059
|
+
}
|
|
1060
|
+
const resendCode = getEscrowEnclave().resendCode;
|
|
1061
|
+
if (!resendCode) throw new TRPCError({ code: 'FORBIDDEN', message: invalidMessage });
|
|
1062
|
+
await enclaveOperation(() =>
|
|
1063
|
+
resendCode({
|
|
1064
|
+
envelope,
|
|
1065
|
+
hold: hold.holdRecord!,
|
|
1066
|
+
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
1067
|
+
expectedDid: hold.primaryDid,
|
|
1068
|
+
})
|
|
1069
|
+
);
|
|
1070
|
+
return { success: true as const };
|
|
1071
|
+
}),
|
|
1072
|
+
|
|
745
1073
|
getStatus: openRoute
|
|
746
1074
|
.meta({ openapi: { method: 'GET', path: '/keys/escrow/status', tags: ['Keys'] } })
|
|
747
1075
|
.input(statusInput)
|
|
@@ -973,6 +1301,7 @@ export const escrowRouter = t.router({
|
|
|
973
1301
|
try {
|
|
974
1302
|
return {
|
|
975
1303
|
result: await getEscrowEnclave().releaseEscrow({
|
|
1304
|
+
requestId: hold._id,
|
|
976
1305
|
envelope: (reserved ?? userKey).escrowBlob!.envelope,
|
|
977
1306
|
hold: hold.holdRecord,
|
|
978
1307
|
clientEphemeralPublicKey: hold.clientEphemeralPublicKey,
|
|
@@ -984,9 +1313,11 @@ export const escrowRouter = t.router({
|
|
|
984
1313
|
};
|
|
985
1314
|
} catch (error) {
|
|
986
1315
|
// Keep the typed mismatch inside the wrapper; never expose enclave internals.
|
|
987
|
-
if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError)
|
|
1316
|
+
if (hold.releasePolicy === 'pin' && error instanceof EscrowPinMismatchError) {
|
|
1317
|
+
logEscrowFailure(error);
|
|
988
1318
|
return { mismatch: true as const };
|
|
989
|
-
|
|
1319
|
+
}
|
|
1320
|
+
if (reserved && hold.holdRecord.hold.policyVersion !== 2)
|
|
990
1321
|
await refundEscrowPinAttempt(
|
|
991
1322
|
hold.authProvider,
|
|
992
1323
|
hold.shareVersion,
|
|
@@ -1047,6 +1378,8 @@ export const escrowRouter = t.router({
|
|
|
1047
1378
|
});
|
|
1048
1379
|
}
|
|
1049
1380
|
const { sealed } = release.result;
|
|
1381
|
+
if (release.result.receipt)
|
|
1382
|
+
await recordEscrowReleaseReceipt(hold._id, release.result.receipt);
|
|
1050
1383
|
// A successful evaluation also consumes the lifetime budget; do not
|
|
1051
1384
|
// erase other requests' reservations or replenish guesses here.
|
|
1052
1385
|
const authShare = await enclaveOperation(async () =>
|
package/src/routes/index.ts
CHANGED
|
@@ -167,7 +167,7 @@ export const createContext = async (
|
|
|
167
167
|
// request, this strips anything secret-shaped before it can leave the
|
|
168
168
|
// process.
|
|
169
169
|
const SECRET_FIELD_RE =
|
|
170
|
-
/share|token|seed|recoverykey|blob|envelope|sealed|ephemeral|resumetoken|pinproof|pinverifier/i;
|
|
170
|
+
/share|token|seed|recoverykey|blob|envelope|sealed|ephemeral|resumetoken|pinproof|pinverifier|signedintent|receipt|authoritystateproof/i;
|
|
171
171
|
|
|
172
172
|
/**
|
|
173
173
|
* Recursively replaces values whose key matches SECRET_FIELD_RE with
|
package/src/routes/keys.ts
CHANGED
|
@@ -349,6 +349,10 @@ export const keysRouter = t.router({
|
|
|
349
349
|
recoverySessionToken: RecoverySessionTokenValidator,
|
|
350
350
|
recoveryMethods: z.array(RecoveryMethodResponseValidator),
|
|
351
351
|
escrowPin: EscrowPinStatusValidator,
|
|
352
|
+
// Same source as the key-status `primaryDid` (getAuthShare): lets
|
|
353
|
+
// the client bind a no-provider Nitro escrow hold start/restart to
|
|
354
|
+
// the verified account without a signed-in identity proof.
|
|
355
|
+
primaryDid: z.string().nullable(),
|
|
352
356
|
})
|
|
353
357
|
)
|
|
354
358
|
.mutation(async ({ input }) => {
|
|
@@ -407,6 +411,7 @@ export const keysRouter = t.router({
|
|
|
407
411
|
recoverySessionToken,
|
|
408
412
|
recoveryMethods,
|
|
409
413
|
escrowPin: getEscrowPinStatus(userKey),
|
|
414
|
+
primaryDid: userKey.primaryDid ?? null,
|
|
410
415
|
};
|
|
411
416
|
}),
|
|
412
417
|
|
|
@@ -25,6 +25,37 @@ PIN holds have zero duration. Mongo remains responsible for single-use/cancellat
|
|
|
25
25
|
|
|
26
26
|
## Nitro requirement
|
|
27
27
|
|
|
28
|
+
### Authority protocol (PR 2 item e)
|
|
29
|
+
|
|
30
|
+
Remote enrollment/removal now requires the account's signed intent; failures never
|
|
31
|
+
fall back to unsigned or PIN-less enrollment. `/v1/apply-enrollment` verifies intent
|
|
32
|
+
inside the enclave and returns the persisted, potentially resealed envelope plus a
|
|
33
|
+
commit receipt. Mongo caches that returned envelope and authoritative generation,
|
|
34
|
+
enrollment version and epoch—not the submitted ciphertext or a Mongo-derived epoch.
|
|
35
|
+
Signed disablement uses `POST /keys/escrow/disable` so authorization and ciphertext
|
|
36
|
+
stay in a request body, not DELETE query parameters. The legacy DELETE endpoint
|
|
37
|
+
remains available only to software mode.
|
|
38
|
+
The cache update is conditional; losing it does not roll back the authority commit.
|
|
39
|
+
|
|
40
|
+
`/v1/enrollment-state` returns fresh nonce-bound signed state. The authenticated API
|
|
41
|
+
route is `POST /keys/escrow/authority-state`; clients must verify the signature and
|
|
42
|
+
nonce against an attested ledger key. API validators check shapes, not enclave trust.
|
|
43
|
+
`/v1/authority-rewrap` requires delegated rewrap authorization. The background job
|
|
44
|
+
reconciles a previously persisted output via fresh state if the earlier response was
|
|
45
|
+
lost. No state is bootstrapped from Mongo or the legacy ledger.
|
|
46
|
+
|
|
47
|
+
Policy-v2 holds retain the opaque signed record; release and cancellation return
|
|
48
|
+
Commit artifacts. Cancellation confirmation persists that receipt before clearing
|
|
49
|
+
`enclaveCancelPendingAt`. Old pending holds remain cancellable after rotation or
|
|
50
|
+
disablement. Release attempts use a stable hold-bound request ID; ambiguous v2
|
|
51
|
+
evaluations never refund the host's defense-in-depth counter. The authoritative
|
|
52
|
+
counter is always in the enclave's conditional store.
|
|
53
|
+
|
|
54
|
+
Do not roll out until client signing/verification and the independent authority
|
|
55
|
+
monitor are integrated. Email confirmation remains PR 3; notifications are unchanged.
|
|
56
|
+
|
|
57
|
+
### Legacy protocol background (unconfigured/compatibility path)
|
|
58
|
+
|
|
28
59
|
In `remote` mode, the enclave-owned reserve → verify → commit ledger is the
|
|
29
60
|
intended security boundary (`services/escrow-enclave-app/src/policy.rs` `release_pin`
|
|
30
61
|
and `ledger.rs` `verify_pin`), pending full P1.8/P3.3 production wiring. The host-owned
|