@learncard/lca-api-service 1.5.1 → 1.5.2
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 +14 -0
- package/config/config.dev.json +1 -1
- package/dist/app.d.ts +220 -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/escrow.d.ts +218 -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/types.d.ts +38 -2
- package/package.json +9 -9
- 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/escrow.ts +371 -51
- 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/remoteEnclave.test.ts +216 -3
- package/src/services/escrow-enclave/remoteEnclave.ts +129 -23
- package/src/services/escrow-enclave/types.ts +51 -3
- package/test/jobs/escrowHoldReminders.spec.ts +195 -3
- package/test/keys-escrow.spec.ts +522 -11
- package/test/models/escrow.spec.ts +13 -0
|
@@ -7,7 +7,10 @@ import {
|
|
|
7
7
|
getEnclaveAttestationIdentity,
|
|
8
8
|
getEscrowEnclave,
|
|
9
9
|
isEscrowEnabled,
|
|
10
|
+
isEscrowRemoteMode,
|
|
10
11
|
} from '../services/escrow-enclave';
|
|
12
|
+
import { randomBytes } from 'crypto';
|
|
13
|
+
import { EscrowUnavailableError } from '../services/escrow-enclave/types';
|
|
11
14
|
|
|
12
15
|
const DEFAULT_REWRAP_BATCH_LIMIT = 200;
|
|
13
16
|
|
|
@@ -90,7 +93,46 @@ export const runEscrowBlobRewrap = async (
|
|
|
90
93
|
skippedPendingHold += 1;
|
|
91
94
|
continue;
|
|
92
95
|
}
|
|
93
|
-
const
|
|
96
|
+
const enclave = getEscrowEnclave();
|
|
97
|
+
if (isEscrowRemoteMode()) {
|
|
98
|
+
if (!enclave.getEnrollmentState || !blob.authorityGeneration)
|
|
99
|
+
throw new EscrowUnavailableError();
|
|
100
|
+
const snapshot = await enclave.getEnrollmentState({
|
|
101
|
+
expectedDid: userKey.primaryDid,
|
|
102
|
+
nonce: randomBytes(32).toString('hex'),
|
|
103
|
+
});
|
|
104
|
+
const current = snapshot.state;
|
|
105
|
+
if (
|
|
106
|
+
current.status !== 'active' ||
|
|
107
|
+
!current.envelope ||
|
|
108
|
+
current.authorityGeneration !== blob.authorityGeneration ||
|
|
109
|
+
current.did !== userKey.primaryDid ||
|
|
110
|
+
current.shareVersion !== blob.shareVersion ||
|
|
111
|
+
current.enrollmentEpoch !== blob.enrollmentEpoch
|
|
112
|
+
)
|
|
113
|
+
throw new EscrowUnavailableError();
|
|
114
|
+
if (current.envelope.keyId === attestation.keyId) {
|
|
115
|
+
const stored = await rewrapEscrowBlobByAuthProvider(
|
|
116
|
+
authProvider,
|
|
117
|
+
blob,
|
|
118
|
+
userKey.escrowPin,
|
|
119
|
+
current.envelope,
|
|
120
|
+
{
|
|
121
|
+
enclaveKeyId: attestation.keyId,
|
|
122
|
+
enclaveMode: attestation.mode,
|
|
123
|
+
measurements: attestation.measurements,
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
authorityGeneration: current.authorityGeneration,
|
|
127
|
+
enrollmentVersion: current.enrollmentVersion,
|
|
128
|
+
stateProof: snapshot.proof,
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
if (stored) rewrapped += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const { envelope, authority } = await enclave.rewrapEscrowBlob({
|
|
94
136
|
envelope: blob.envelope,
|
|
95
137
|
expectedDid: userKey.primaryDid,
|
|
96
138
|
expectedShareVersion: blob.shareVersion,
|
|
@@ -105,7 +147,8 @@ export const runEscrowBlobRewrap = async (
|
|
|
105
147
|
enclaveKeyId: attestation.keyId,
|
|
106
148
|
enclaveMode: attestation.mode,
|
|
107
149
|
measurements: attestation.measurements,
|
|
108
|
-
}
|
|
150
|
+
},
|
|
151
|
+
authority
|
|
109
152
|
);
|
|
110
153
|
if (stored) rewrapped += 1;
|
|
111
154
|
// else: lost the compare-and-swap race (a concurrent re-enroll,
|
|
@@ -9,13 +9,19 @@ import {
|
|
|
9
9
|
findEscrowHoldsPendingStartNotification,
|
|
10
10
|
releaseEscrowReminderClaim,
|
|
11
11
|
recordEscrowStartNotificationAttempt,
|
|
12
|
+
recordEscrowHoldNotification,
|
|
13
|
+
computeEffectiveReleaseAfter,
|
|
12
14
|
} from '@models';
|
|
13
15
|
import { environment } from '@environment';
|
|
14
16
|
import {
|
|
15
17
|
cancelHoldInEnclave,
|
|
16
18
|
logEscrowCancellationPending,
|
|
17
19
|
} from '../services/escrow-enclave/cancellation';
|
|
18
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
getEscrowEnclave,
|
|
22
|
+
isEscrowEnabled,
|
|
23
|
+
notifyEscrowHoldEvent,
|
|
24
|
+
} from '../services/escrow-enclave';
|
|
19
25
|
|
|
20
26
|
// Two queues of ten 3-second deliveries leave half the 120-second Lambda
|
|
21
27
|
// budget for database work. Least-recently-attempted ordering prevents starvation.
|
|
@@ -148,6 +154,11 @@ export const runEscrowHoldReminders = async (
|
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
156
|
|
|
157
|
+
// P9.1 (attack C): `sendReleaseWarning` is defined only by the remote
|
|
158
|
+
// (nitro/authority) backend — software mode never produces a hold the
|
|
159
|
+
// enclave itself must warn about, since it has no confirmation step to
|
|
160
|
+
// have gated release behind in the first place.
|
|
161
|
+
const sendReleaseWarning = getEscrowEnclave().sendReleaseWarning;
|
|
151
162
|
for (const candidate of candidates) {
|
|
152
163
|
let claimed: Awaited<ReturnType<typeof claimEscrowHoldForReminder>> = null;
|
|
153
164
|
try {
|
|
@@ -155,6 +166,44 @@ export const runEscrowHoldReminders = async (
|
|
|
155
166
|
claimed = await claimEscrowHoldForReminder(candidate._id, now);
|
|
156
167
|
if (!claimed) continue;
|
|
157
168
|
|
|
169
|
+
// P9.1 (attack C): an authority-mode Hold-policy hold's security
|
|
170
|
+
// channel is the enclave's own sealed-blob-sourced email, not this
|
|
171
|
+
// Mongo-recipient-sourced one, which an attacker who controls the
|
|
172
|
+
// suppressed-notification side of the threat model could silence
|
|
173
|
+
// or redirect. send-release-warning both notifies the real owner
|
|
174
|
+
// and records the timestamp release is independently gated on.
|
|
175
|
+
if (sendReleaseWarning && claimed.releasePolicy !== 'pin' && claimed.holdRecord) {
|
|
176
|
+
const { releaseWarningSentAtMs } = await sendReleaseWarning({
|
|
177
|
+
holdId: claimed.holdRecord.hold.holdId,
|
|
178
|
+
expectedDid: claimed.primaryDid,
|
|
179
|
+
});
|
|
180
|
+
// P2-a: the enclave call above is idempotent (a repeat never
|
|
181
|
+
// resends or moves its recorded timestamp), so persisting
|
|
182
|
+
// this acknowledgment AFTER it succeeds is safe to retry: if
|
|
183
|
+
// this write is lost (crash, Mongo failure), the hold stays
|
|
184
|
+
// selected next run, the enclave call is a no-op, and this
|
|
185
|
+
// write finally lands. Without it, every run re-called the
|
|
186
|
+
// enclave for every hold in the 24h window forever.
|
|
187
|
+
// [B]: also persist the effective availability from the
|
|
188
|
+
// authoritative confirmation/warning times — a late
|
|
189
|
+
// (catch-up) warning can push real availability later than
|
|
190
|
+
// the hold's existing stored `releaseAfter` estimate, which
|
|
191
|
+
// otherwise keeps advertising an already-passed deadline.
|
|
192
|
+
await recordEscrowHoldNotification(
|
|
193
|
+
claimed._id,
|
|
194
|
+
'reminder',
|
|
195
|
+
claimed.confirmedAt
|
|
196
|
+
? computeEffectiveReleaseAfter({
|
|
197
|
+
confirmedAtMs: claimed.confirmedAt.getTime(),
|
|
198
|
+
holdDurationMs: claimed.holdRecord.holdDurationMs,
|
|
199
|
+
releaseWarningSentAtMs,
|
|
200
|
+
})
|
|
201
|
+
: undefined
|
|
202
|
+
);
|
|
203
|
+
reminded += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
158
207
|
const userKey = await findUserKeyByAuthProvider(
|
|
159
208
|
claimed.authProvider.type,
|
|
160
209
|
claimed.authProvider.id
|
package/src/models/EscrowHold.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes } from 'crypto';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
+
import {
|
|
4
|
+
AuthorityCommitValidator,
|
|
5
|
+
type AuthorityRecord,
|
|
6
|
+
} from '../services/escrow-enclave/authorityContract';
|
|
3
7
|
import type { Collection } from 'mongodb';
|
|
4
8
|
import mongodb from '@mongo';
|
|
5
9
|
import { environment } from '@environment';
|
|
@@ -13,6 +17,40 @@ export const ESCROW_HOLDS_COLLECTION = 'escrowholds';
|
|
|
13
17
|
export const ESCROW_HOLD_STALE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
|
14
18
|
export const ESCROW_HOLD_RESTART_MIN_AGE_MS = environment.ESCROW_HOLD_RESTART_MIN_AGE_MS;
|
|
15
19
|
export const ESCROW_HOLD_REMINDER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
// Roughtime's release lower bound can lag its creation/confirmation upper
|
|
21
|
+
// bound (see escrow-enclave-app's D2); advertise an additional minute on top
|
|
22
|
+
// of both enclave release gates below, and still allow retries when trusted
|
|
23
|
+
// time is briefly unavailable.
|
|
24
|
+
export const ESCROW_RELEASE_TIME_MARGIN_MS = 60_000;
|
|
25
|
+
// Mirrors `RELEASE_WARNING_MIN_GAP_MS` in policy/authority.rs.
|
|
26
|
+
export const ESCROW_RELEASE_WARNING_GAP_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The enclave gates release on BOTH `confirmed_hi_ms + holdDuration` AND
|
|
30
|
+
* `release_warning_sent_hi_ms + 24h` (see `authority_release_with_receipt`,
|
|
31
|
+
* policy/authority.rs ~663-671) — a hold is only actually releasable at the
|
|
32
|
+
* LATER of the two. A warning sent after the 7-day window has already
|
|
33
|
+
* elapsed (a late/catch-up send, P2-b) pushes real availability later than
|
|
34
|
+
* `releaseAfter` computed from confirmation alone; callers must recompute
|
|
35
|
+
* and persist this whenever either authoritative timestamp changes, using
|
|
36
|
+
* the enclave's own `confirmed_hi_ms`/`release_warning_sent_hi_ms` — never a
|
|
37
|
+
* locally observed `now` (see [A]/[A2]).
|
|
38
|
+
*/
|
|
39
|
+
export const computeEffectiveReleaseAfter = (params: {
|
|
40
|
+
confirmedAtMs: number;
|
|
41
|
+
holdDurationMs: number;
|
|
42
|
+
releaseWarningSentAtMs?: number;
|
|
43
|
+
}): Date => {
|
|
44
|
+
const confirmedAvailabilityMs =
|
|
45
|
+
params.confirmedAtMs + params.holdDurationMs + ESCROW_RELEASE_TIME_MARGIN_MS;
|
|
46
|
+
const warningAvailabilityMs =
|
|
47
|
+
params.releaseWarningSentAtMs === undefined
|
|
48
|
+
? undefined
|
|
49
|
+
: params.releaseWarningSentAtMs +
|
|
50
|
+
ESCROW_RELEASE_WARNING_GAP_MS +
|
|
51
|
+
ESCROW_RELEASE_TIME_MARGIN_MS;
|
|
52
|
+
return new Date(Math.max(confirmedAvailabilityMs, warningAvailabilityMs ?? 0));
|
|
53
|
+
};
|
|
16
54
|
// Opaque JSON validated for shape, not signature authenticity. Preserve signed extensions.
|
|
17
55
|
export const EscrowHoldRecordValidator = z
|
|
18
56
|
.object({
|
|
@@ -51,6 +89,8 @@ export const EscrowHoldValidator = z.object({
|
|
|
51
89
|
cancelReason: z.enum(['pin-mismatch', 'pin-locked', 'superseded', 'release-failed']).optional(),
|
|
52
90
|
cancelledAt: z.date().optional(),
|
|
53
91
|
enclaveCancelPendingAt: z.date().optional(),
|
|
92
|
+
enclaveCancelReceipt: AuthorityCommitValidator.optional(),
|
|
93
|
+
enclaveReleaseReceipt: AuthorityCommitValidator.optional(),
|
|
54
94
|
enclaveCancelConfirmedAt: z.date().optional(),
|
|
55
95
|
enclaveCancelAttempts: z.number().int().nonnegative().optional(),
|
|
56
96
|
// Retain ciphertext for revocation even after account removal or re-enrollment.
|
|
@@ -69,6 +109,14 @@ export const EscrowHoldValidator = z.object({
|
|
|
69
109
|
.max(4)
|
|
70
110
|
.optional(),
|
|
71
111
|
startedNotificationPending: z.boolean().optional(),
|
|
112
|
+
// P9.1 (attack C): set at creation only for an authority-mode Hold-policy
|
|
113
|
+
// hold (the enclave gates its own release on a confirmed email code, not
|
|
114
|
+
// just this flag). `confirmedAt` is set once `confirmRecovery` succeeds,
|
|
115
|
+
// at which point `releaseAfter` is also recomputed from the confirmation
|
|
116
|
+
// time (not the original creation time) so Mongo's own stale-expiry and
|
|
117
|
+
// reminder-window queries stay consistent with the enclave's real gate.
|
|
118
|
+
requiresConfirmation: z.boolean().optional(),
|
|
119
|
+
confirmedAt: z.date().optional(),
|
|
72
120
|
reminderClaimedUntil: z.date().optional(),
|
|
73
121
|
startedNotificationAttemptedAt: z.date().optional(),
|
|
74
122
|
reminderAttemptedAt: z.date().optional(),
|
|
@@ -259,7 +307,10 @@ export const recordEscrowEnclaveCancelAttempt = async (id: string): Promise<bool
|
|
|
259
307
|
return result.modifiedCount > 0;
|
|
260
308
|
};
|
|
261
309
|
|
|
262
|
-
export const confirmEscrowEnclaveCancellation = async (
|
|
310
|
+
export const confirmEscrowEnclaveCancellation = async (
|
|
311
|
+
id: string,
|
|
312
|
+
receipt?: AuthorityRecord
|
|
313
|
+
): Promise<void> => {
|
|
263
314
|
const now = new Date();
|
|
264
315
|
await getEscrowHoldsCollection().updateOne(
|
|
265
316
|
{
|
|
@@ -269,12 +320,28 @@ export const confirmEscrowEnclaveCancellation = async (id: string): Promise<void
|
|
|
269
320
|
enclaveCancelConfirmedAt: { $exists: false },
|
|
270
321
|
},
|
|
271
322
|
{
|
|
272
|
-
$set: {
|
|
323
|
+
$set: {
|
|
324
|
+
enclaveCancelConfirmedAt: now,
|
|
325
|
+
updatedAt: now,
|
|
326
|
+
...(receipt
|
|
327
|
+
? { enclaveCancelReceipt: AuthorityCommitValidator.parse(receipt) }
|
|
328
|
+
: {}),
|
|
329
|
+
},
|
|
273
330
|
$unset: { enclaveCancelPendingAt: '', enclaveEnvelope: '' },
|
|
274
331
|
}
|
|
275
332
|
);
|
|
276
333
|
};
|
|
277
334
|
|
|
335
|
+
export const recordEscrowReleaseReceipt = async (
|
|
336
|
+
id: string,
|
|
337
|
+
receipt: AuthorityRecord
|
|
338
|
+
): Promise<void> => {
|
|
339
|
+
await getEscrowHoldsCollection().updateOne(
|
|
340
|
+
{ _id: id, status: 'completed' },
|
|
341
|
+
{ $set: { enclaveReleaseReceipt: AuthorityCommitValidator.parse(receipt) } }
|
|
342
|
+
);
|
|
343
|
+
};
|
|
344
|
+
|
|
278
345
|
export const cancelEscrowHold = async (
|
|
279
346
|
id: string,
|
|
280
347
|
cancelledBy: 'did' | 'system',
|
|
@@ -330,6 +397,22 @@ export const cancelEscrowHoldByCancelToken = async (
|
|
|
330
397
|
{ returnDocument: 'after' }
|
|
331
398
|
);
|
|
332
399
|
};
|
|
400
|
+
/**
|
|
401
|
+
* Records a successful confirmRecovery and corrects releaseAfter to the
|
|
402
|
+
* confirmation time (the enclave's own release gate is confirmation-based,
|
|
403
|
+
* not creation-based). Never overwrites an already-confirmed row.
|
|
404
|
+
*/
|
|
405
|
+
export const recordEscrowHoldConfirmation = async (
|
|
406
|
+
id: string,
|
|
407
|
+
confirmedAt: Date,
|
|
408
|
+
releaseAfter: Date
|
|
409
|
+
): Promise<EscrowHold | null> =>
|
|
410
|
+
getEscrowHoldsCollection().findOneAndUpdate(
|
|
411
|
+
{ _id: id, status: 'pending', confirmedAt: { $exists: false } },
|
|
412
|
+
{ $set: { confirmedAt, releaseAfter, updatedAt: confirmedAt } },
|
|
413
|
+
{ returnDocument: 'after' }
|
|
414
|
+
);
|
|
415
|
+
|
|
333
416
|
export const completeEscrowHold = async (id: string): Promise<EscrowHold | null> => {
|
|
334
417
|
const now = new Date();
|
|
335
418
|
return getEscrowHoldsCollection().findOneAndUpdate(
|
|
@@ -437,7 +520,24 @@ export const recordEscrowStartNotificationAttempt = async (
|
|
|
437
520
|
* excludes them in practice within a few ms of job execution; the explicit
|
|
438
521
|
* releasePolicy filter documents that exclusion instead of relying on
|
|
439
522
|
* timing alone. Legacy rows predating `releasePolicy` are treated as `hold`,
|
|
440
|
-
* matching `findPendingEscrowHoldByAuthProvider`'s own `$or`.
|
|
523
|
+
* matching `findPendingEscrowHoldByAuthProvider`'s own `$or`. An
|
|
524
|
+
* authority-mode hold still awaiting its email confirmation is excluded
|
|
525
|
+
* outright: its stored `releaseAfter` is only a rough creation-time estimate
|
|
526
|
+
* until confirmation recomputes it, and the enclave's own
|
|
527
|
+
* `authority_send_release_warning` would reject a pre-confirmation hold
|
|
528
|
+
* anyway (see policy/authority.rs), so excluding it here avoids a
|
|
529
|
+
* predictable, recurring no-op failure every run.
|
|
530
|
+
*
|
|
531
|
+
* Deliberately has no `releaseAfter > now` lower bound (catch-up, P2-b): the
|
|
532
|
+
* mandatory warning gate means release can never complete without a
|
|
533
|
+
* successfully recorded warning, regardless of how stale Mongo's own
|
|
534
|
+
* `releaseAfter` estimate is. An outage (SES down, this job not running)
|
|
535
|
+
* spanning an entire 24h reminder window must not permanently strand the
|
|
536
|
+
* hold — it stays selected, however overdue, until a warning is finally
|
|
537
|
+
* recorded (`notifications.kind` gains `'reminder'`). Real availability is
|
|
538
|
+
* then governed by the enclave's own gate, `release_warning_sent_hi_ms +
|
|
539
|
+
* 24h` (see `authority_release_with_receipt` in policy/authority.rs), not
|
|
540
|
+
* by this stale estimate.
|
|
441
541
|
*/
|
|
442
542
|
export const findEscrowHoldsDueForReminder = async (
|
|
443
543
|
now: Date,
|
|
@@ -446,11 +546,21 @@ export const findEscrowHoldsDueForReminder = async (
|
|
|
446
546
|
getEscrowHoldsCollection()
|
|
447
547
|
.find({
|
|
448
548
|
status: 'pending',
|
|
449
|
-
$
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
549
|
+
$and: [
|
|
550
|
+
{
|
|
551
|
+
$or: [
|
|
552
|
+
{ releasePolicy: 'hold' as const },
|
|
553
|
+
{ releasePolicy: { $exists: false } },
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
$or: [
|
|
558
|
+
{ requiresConfirmation: { $ne: true } },
|
|
559
|
+
{ confirmedAt: { $exists: true } },
|
|
560
|
+
],
|
|
561
|
+
},
|
|
562
|
+
],
|
|
563
|
+
releaseAfter: { $lte: new Date(now.getTime() + ESCROW_HOLD_REMINDER_WINDOW_MS) },
|
|
454
564
|
'notifications.kind': { $ne: 'reminder' },
|
|
455
565
|
})
|
|
456
566
|
.sort({ reminderAttemptedAt: 1, releaseAfter: 1 })
|
|
@@ -494,10 +604,17 @@ export const hashEscrowResumeToken = (token: string): string =>
|
|
|
494
604
|
createHash('sha256').update(token).digest('hex');
|
|
495
605
|
export const generateEscrowResumeToken = (): string => randomBytes(32).toString('hex');
|
|
496
606
|
|
|
497
|
-
/**
|
|
607
|
+
/**
|
|
608
|
+
* Appends a delivery record; a missing holdId is a no-op rather than a throw.
|
|
609
|
+
* `releaseAfter` lets a `'reminder'` notification also persist the hold's
|
|
610
|
+
* effective availability (see `computeEffectiveReleaseAfter`) — a late
|
|
611
|
+
* release-warning send can push real availability later than the stored
|
|
612
|
+
* estimate [B]; omit it for notification kinds with no such recomputation.
|
|
613
|
+
*/
|
|
498
614
|
export const recordEscrowHoldNotification = async (
|
|
499
615
|
holdId: string,
|
|
500
|
-
kind: EscrowHold['notifications'][number]['kind']
|
|
616
|
+
kind: EscrowHold['notifications'][number]['kind'],
|
|
617
|
+
releaseAfter?: Date
|
|
501
618
|
): Promise<void> => {
|
|
502
619
|
const now = new Date();
|
|
503
620
|
await getEscrowHoldsCollection().updateOne(
|
|
@@ -507,6 +624,7 @@ export const recordEscrowHoldNotification = async (
|
|
|
507
624
|
$set: {
|
|
508
625
|
updatedAt: now,
|
|
509
626
|
...(kind === 'started' ? { startedNotificationPending: false } : {}),
|
|
627
|
+
...(releaseAfter ? { releaseAfter } : {}),
|
|
510
628
|
},
|
|
511
629
|
}
|
|
512
630
|
);
|
package/src/models/UserKey.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { escrowBlobIdentity } from '../services/escrow-enclave/blobIdentity';
|
|
3
|
+
import {
|
|
4
|
+
AuthorityCommitValidator,
|
|
5
|
+
AuthorityRecordValidator,
|
|
6
|
+
type AuthorityMutationResult,
|
|
7
|
+
type AuthorityRecord,
|
|
8
|
+
} from '../services/escrow-enclave/authorityContract';
|
|
3
9
|
import type { Filter } from 'mongodb';
|
|
4
10
|
import { ESCROW_PIN_MAX_ATTEMPTS } from '@learncard/sss-key-manager';
|
|
5
11
|
import { EscrowPinStatusValidator as SharedEscrowPinStatusValidator } from '@learncard/types';
|
|
@@ -64,6 +70,12 @@ export const EscrowEnvelopeValidator = z
|
|
|
64
70
|
.strict();
|
|
65
71
|
|
|
66
72
|
export const EscrowBlobValidator = z.object({
|
|
73
|
+
authorityGeneration: z.string().min(1).max(128).optional(),
|
|
74
|
+
enrollmentVersion: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional(),
|
|
75
|
+
commitReceipt: AuthorityCommitValidator.optional(),
|
|
76
|
+
authorityStateProof: AuthorityRecordValidator.extend({
|
|
77
|
+
kind: z.literal('snapshot'),
|
|
78
|
+
}).optional(),
|
|
67
79
|
enrollmentEpoch: z.number().int().positive(),
|
|
68
80
|
blobHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
69
81
|
envelope: EscrowEnvelopeValidator,
|
|
@@ -738,7 +750,12 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
738
750
|
blob: Omit<EscrowBlob, 'enrollmentEpoch' | 'blobHash'>,
|
|
739
751
|
expectedShareVersion: number,
|
|
740
752
|
pin?: { salt: string },
|
|
741
|
-
carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> }
|
|
753
|
+
carryFrom?: { blob: EscrowBlob; pin: NonNullable<MongoUserKeyType['escrowPin']> },
|
|
754
|
+
authority?: {
|
|
755
|
+
enrollmentEpoch: number;
|
|
756
|
+
expectedBlob?: EscrowBlob;
|
|
757
|
+
expectedPin?: MongoUserKeyType['escrowPin'];
|
|
758
|
+
}
|
|
742
759
|
): Promise<MongoUserKeyType | null> => {
|
|
743
760
|
const now = new Date();
|
|
744
761
|
const parsed = EscrowBlobValidator.omit({ enrollmentEpoch: true, blobHash: true }).safeParse(
|
|
@@ -749,7 +766,7 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
749
766
|
const assembled = EscrowBlobValidator.safeParse({
|
|
750
767
|
...parsed.data,
|
|
751
768
|
blobHash,
|
|
752
|
-
enrollmentEpoch: 1,
|
|
769
|
+
enrollmentEpoch: authority?.enrollmentEpoch ?? 1,
|
|
753
770
|
});
|
|
754
771
|
if (!assembled.success) throw new Error('Invalid escrow payload');
|
|
755
772
|
const { enrollmentEpoch: _initialEpoch, ...validated } = assembled.data;
|
|
@@ -764,6 +781,12 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
764
781
|
shareVersion: expectedShareVersion,
|
|
765
782
|
escrowOptedOutAt: { $exists: false },
|
|
766
783
|
...(carryFrom ? { escrowBlob: carryFrom.blob, escrowPin: carryFrom.pin } : {}),
|
|
784
|
+
...(authority
|
|
785
|
+
? {
|
|
786
|
+
escrowBlob: authority.expectedBlob ?? { $exists: false },
|
|
787
|
+
escrowPin: authority.expectedPin ?? { $exists: false },
|
|
788
|
+
}
|
|
789
|
+
: {}),
|
|
767
790
|
},
|
|
768
791
|
[
|
|
769
792
|
{
|
|
@@ -772,9 +795,14 @@ export const setEscrowBlobByAuthProvider = async (
|
|
|
772
795
|
$mergeObjects: [
|
|
773
796
|
{ $literal: validated },
|
|
774
797
|
{
|
|
775
|
-
enrollmentEpoch:
|
|
776
|
-
|
|
777
|
-
|
|
798
|
+
enrollmentEpoch: authority
|
|
799
|
+
? authority.enrollmentEpoch
|
|
800
|
+
: {
|
|
801
|
+
$add: [
|
|
802
|
+
{ $ifNull: ['$escrowBlob.enrollmentEpoch', 0] },
|
|
803
|
+
1,
|
|
804
|
+
],
|
|
805
|
+
},
|
|
778
806
|
},
|
|
779
807
|
],
|
|
780
808
|
},
|
|
@@ -837,7 +865,11 @@ export const rewrapEscrowBlobByAuthProvider = async (
|
|
|
837
865
|
oldBlob: EscrowBlob,
|
|
838
866
|
oldPin: MongoUserKeyType['escrowPin'],
|
|
839
867
|
newEnvelope: EscrowBlob['envelope'],
|
|
840
|
-
identity: Pick<EscrowBlob, 'enclaveKeyId' | 'enclaveMode' | 'measurements'
|
|
868
|
+
identity: Pick<EscrowBlob, 'enclaveKeyId' | 'enclaveMode' | 'measurements'>,
|
|
869
|
+
authority?: Pick<AuthorityMutationResult, 'authorityGeneration' | 'enrollmentVersion'> & {
|
|
870
|
+
receipt?: AuthorityRecord;
|
|
871
|
+
stateProof?: AuthorityRecord;
|
|
872
|
+
}
|
|
841
873
|
): Promise<MongoUserKeyType | null> => {
|
|
842
874
|
const now = new Date();
|
|
843
875
|
const blobHash = escrowBlobIdentity(newEnvelope);
|
|
@@ -850,6 +882,14 @@ export const rewrapEscrowBlobByAuthProvider = async (
|
|
|
850
882
|
blobHash,
|
|
851
883
|
enrollmentEpoch: oldBlob.enrollmentEpoch,
|
|
852
884
|
createdAt: now,
|
|
885
|
+
...(authority
|
|
886
|
+
? {
|
|
887
|
+
authorityGeneration: authority.authorityGeneration,
|
|
888
|
+
enrollmentVersion: authority.enrollmentVersion,
|
|
889
|
+
commitReceipt: authority.receipt,
|
|
890
|
+
authorityStateProof: authority.stateProof,
|
|
891
|
+
}
|
|
892
|
+
: {}),
|
|
853
893
|
});
|
|
854
894
|
if (!parsed.success || parsed.data.enclaveKeyId !== parsed.data.envelope.keyId) return null;
|
|
855
895
|
const filter: Filter<MongoUserKeyType> = {
|
|
@@ -885,11 +925,22 @@ export const findUserKeysWithEscrowBlobKeyId = async (
|
|
|
885
925
|
|
|
886
926
|
export const clearEscrowByAuthProvider = async (
|
|
887
927
|
authProvider: AuthProviderMapping,
|
|
888
|
-
{
|
|
928
|
+
{
|
|
929
|
+
optOut,
|
|
930
|
+
expected,
|
|
931
|
+
}: { optOut: boolean; expected?: { blob?: EscrowBlob; pin?: MongoUserKeyType['escrowPin'] } }
|
|
889
932
|
): Promise<MongoUserKeyType | null> => {
|
|
890
933
|
const now = new Date();
|
|
891
934
|
return getUserKeysCollection().findOneAndUpdate(
|
|
892
|
-
|
|
935
|
+
{
|
|
936
|
+
...getAuthProviderFilter(authProvider),
|
|
937
|
+
...(expected
|
|
938
|
+
? {
|
|
939
|
+
escrowBlob: expected.blob ?? { $exists: false },
|
|
940
|
+
escrowPin: expected.pin ?? { $exists: false },
|
|
941
|
+
}
|
|
942
|
+
: {}),
|
|
943
|
+
},
|
|
893
944
|
{
|
|
894
945
|
$unset: { escrowBlob: '', escrowPin: '' },
|
|
895
946
|
$pull: { recoveryMethods: { type: 'escrow' } },
|