@learncard/lca-api-service 1.6.0 → 1.6.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/AGENTS.md +21 -0
- package/CHANGELOG.md +31 -0
- package/dist/app.d.ts +41 -0
- package/dist/cache/recoverySessions.d.ts +5 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/accountSecureLink.d.ts +4 -0
- package/dist/helpers/accountSecureLink.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/EscrowHold.d.ts +4 -1
- package/dist/models/UserKey.d.ts +199 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/escrow.d.ts +4 -0
- package/dist/routes/keys.d.ts +47 -1
- package/dist/services/escrow-enclave/enrollmentLease.d.ts +8 -0
- package/dist/services/escrow-enclave/notifications.d.ts +1 -1
- package/package.json +11 -11
- package/project.json +1 -1
- package/src/cache/recoverySessions.ts +38 -0
- package/src/helpers/accountSecureLink.test.ts +26 -0
- package/src/helpers/accountSecureLink.ts +16 -0
- package/src/jobs/escrowBlobRewrap.ts +95 -3
- package/src/models/EscrowHold.ts +23 -6
- package/src/models/UserKey.escrowPin.test.ts +21 -0
- package/src/models/UserKey.ts +553 -47
- package/src/routes/escrow.ts +326 -67
- package/src/routes/keys.ts +416 -7
- package/src/services/delivery/adapters/postmark.adapter.ts +11 -0
- package/src/services/escrow-enclave/README.md +8 -3
- package/src/services/escrow-enclave/enrollmentLease.ts +124 -0
- package/src/services/escrow-enclave/notifications.test.ts +69 -0
- package/src/services/escrow-enclave/notifications.ts +19 -1
- package/test/jobs/escrowHoldReminders.spec.ts +88 -4
- package/test/keys-escrow.spec.ts +4 -0
- package/test/keys-lifecycle-invariants.spec.ts +76 -0
- package/test/keys-pin-lease.spec.ts +1132 -0
- package/test/keys-pin-sign-in.spec.ts +1152 -0
- package/tsconfig.json +13 -14
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import {
|
|
3
|
+
commitEscrowEnrollmentLease,
|
|
4
|
+
findUserKeyByAuthProvider,
|
|
5
|
+
releaseEscrowEnrollmentLease,
|
|
6
|
+
type AuthProviderMapping,
|
|
7
|
+
type EscrowEnrollmentLease,
|
|
8
|
+
} from '@models';
|
|
9
|
+
import { getEscrowEnclave, isEscrowRemoteMode } from './index';
|
|
10
|
+
import {
|
|
11
|
+
EscrowBlobError,
|
|
12
|
+
EscrowCodeError,
|
|
13
|
+
EscrowPolicyError,
|
|
14
|
+
EscrowUnavailableError,
|
|
15
|
+
} from './types';
|
|
16
|
+
import { escrowBlobIdentity } from './blobIdentity';
|
|
17
|
+
import type { AuthorityMutationResult, AuthorityStateResponse } from './authorityContract';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A definite authority refusal: the operation never committed, so its lease can be
|
|
21
|
+
* released at once. Anything else (timeout, lost or malformed response) may have.
|
|
22
|
+
*/
|
|
23
|
+
export const isDefiniteAuthorityRefusal = (error: unknown): boolean =>
|
|
24
|
+
error instanceof EscrowPolicyError ||
|
|
25
|
+
error instanceof EscrowBlobError ||
|
|
26
|
+
error instanceof EscrowCodeError;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* True when the authority's current state is exactly the commit `lease` was claimed
|
|
30
|
+
* for: one enrollment version past the read, the signed epoch and share version, the
|
|
31
|
+
* PIN generation the intent's action implies and, when the caller holds the returned
|
|
32
|
+
* mutation, the same envelope identity.
|
|
33
|
+
*/
|
|
34
|
+
export const isLeaseCommitted = (
|
|
35
|
+
lease: EscrowEnrollmentLease,
|
|
36
|
+
{ state }: AuthorityStateResponse,
|
|
37
|
+
received?: AuthorityMutationResult
|
|
38
|
+
): boolean => {
|
|
39
|
+
const pinChanged = lease.pinAction !== 'keep';
|
|
40
|
+
if (
|
|
41
|
+
state.authorityGeneration !== lease.authorityGeneration ||
|
|
42
|
+
state.enrollmentVersion !== lease.expectedEnrollmentVersion + 1 ||
|
|
43
|
+
state.enrollmentEpoch !== lease.enrollmentEpoch ||
|
|
44
|
+
(lease.expectedPinGeneration !== undefined &&
|
|
45
|
+
state.pinGeneration !== lease.expectedPinGeneration + (pinChanged ? 1 : 0)) ||
|
|
46
|
+
(pinChanged && state.hasPin !== (lease.pinAction !== 'clear'))
|
|
47
|
+
)
|
|
48
|
+
return false;
|
|
49
|
+
if (received && received.enrollmentVersion !== state.enrollmentVersion) return false;
|
|
50
|
+
if (lease.action === 'disable') return state.status === 'disabled' && !state.envelope;
|
|
51
|
+
return (
|
|
52
|
+
state.status === 'active' &&
|
|
53
|
+
!!state.envelope &&
|
|
54
|
+
state.shareVersion === lease.shareVersion &&
|
|
55
|
+
(!received?.envelope ||
|
|
56
|
+
escrowBlobIdentity(received.envelope) === escrowBlobIdentity(state.envelope))
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Converge Mongo to the authority after a PIN-operation lease did not complete
|
|
62
|
+
* normally (lost response, crash, or the lease's own write missed). Reads the
|
|
63
|
+
* authority's state: if it is the lease's commit, persists the authority's envelope,
|
|
64
|
+
* epoch and version with the PIN record the lease recorded (`adopted`, also when
|
|
65
|
+
* another request already persisted that same commit); otherwise drops the lease
|
|
66
|
+
* when `releaseIfUncommitted` (only safe once no call under it can still land: an
|
|
67
|
+
* expired lease, or a definite refusal), else leaves it to expire (`kept`).
|
|
68
|
+
* Idempotent: the adopt write is conditioned on the lease id (or, with the returned
|
|
69
|
+
* mutation in hand, on the lease being gone) and on the blob it replaced.
|
|
70
|
+
*/
|
|
71
|
+
export const settleEscrowEnrollmentLease = async (
|
|
72
|
+
authProvider: AuthProviderMapping,
|
|
73
|
+
primaryDid: string,
|
|
74
|
+
lease: EscrowEnrollmentLease,
|
|
75
|
+
{
|
|
76
|
+
received,
|
|
77
|
+
releaseIfUncommitted,
|
|
78
|
+
}: { received?: AuthorityMutationResult; releaseIfUncommitted: boolean }
|
|
79
|
+
): Promise<'adopted' | 'released' | 'kept'> => {
|
|
80
|
+
const enclave = getEscrowEnclave();
|
|
81
|
+
if (!isEscrowRemoteMode() || !enclave.getEnrollmentState) {
|
|
82
|
+
await releaseEscrowEnrollmentLease(authProvider, lease.id);
|
|
83
|
+
return 'released';
|
|
84
|
+
}
|
|
85
|
+
const current = await enclave.getEnrollmentState({
|
|
86
|
+
expectedDid: primaryDid,
|
|
87
|
+
nonce: randomBytes(32).toString('hex'),
|
|
88
|
+
});
|
|
89
|
+
if (current.state.did !== primaryDid) throw new EscrowUnavailableError('invalid_response');
|
|
90
|
+
if (isLeaseCommitted(lease, current, received)) {
|
|
91
|
+
const adopted = await commitEscrowEnrollmentLease(
|
|
92
|
+
authProvider,
|
|
93
|
+
lease,
|
|
94
|
+
{
|
|
95
|
+
envelope: current.state.envelope,
|
|
96
|
+
authorityGeneration: current.state.authorityGeneration,
|
|
97
|
+
enrollmentVersion: current.state.enrollmentVersion,
|
|
98
|
+
enrollmentEpoch: current.state.enrollmentEpoch,
|
|
99
|
+
...(received ? { receipt: received.receipt } : {}),
|
|
100
|
+
stateProof: current.proof,
|
|
101
|
+
},
|
|
102
|
+
{ adoptWithoutLease: received !== undefined }
|
|
103
|
+
);
|
|
104
|
+
if (adopted) return 'adopted';
|
|
105
|
+
// Another request (an expired-lease settle) may already have persisted this
|
|
106
|
+
// exact commit: Mongo then serves the authority's copy, nothing is left to do.
|
|
107
|
+
// Only on the lease's own split: a security rotation also drops the lease and
|
|
108
|
+
// the blob, but never wrote this commit (or a disable's opt-out) into Mongo.
|
|
109
|
+
const stored = await findUserKeyByAuthProvider(authProvider.type, authProvider.id);
|
|
110
|
+
const persisted =
|
|
111
|
+
stored !== null &&
|
|
112
|
+
stored.shareVersion === lease.shareVersion &&
|
|
113
|
+
stored.escrowEnrollmentLease?.id !== lease.id &&
|
|
114
|
+
(current.state.envelope
|
|
115
|
+
? stored.escrowBlob?.blobHash === escrowBlobIdentity(current.state.envelope)
|
|
116
|
+
: !stored.escrowBlob && (!lease.optOut || stored.escrowOptedOutAt !== undefined));
|
|
117
|
+
await releaseEscrowEnrollmentLease(authProvider, lease.id);
|
|
118
|
+
// Otherwise Mongo moved past the blob this lease replaced (a rotation).
|
|
119
|
+
return persisted ? 'adopted' : 'released';
|
|
120
|
+
}
|
|
121
|
+
if (!releaseIfUncommitted) return 'kept';
|
|
122
|
+
await releaseEscrowEnrollmentLease(authProvider, lease.id);
|
|
123
|
+
return 'released';
|
|
124
|
+
};
|
|
@@ -238,6 +238,75 @@ describe('notifyEscrowHoldEvent', () => {
|
|
|
238
238
|
expect(sent).toHaveLength(0);
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
+
it('pin-sign-in emails a secure-account link (no token) and sends its own push copy', async () => {
|
|
242
|
+
const userKey = baseUserKey();
|
|
243
|
+
const completedAt = new Date('2026-03-04T05:06:07.000Z');
|
|
244
|
+
const hold = baseHold({ releasePolicy: 'pin', status: 'completed', completedAt });
|
|
245
|
+
const rotateCancelToken = vi.fn(async () => 'e'.repeat(64));
|
|
246
|
+
const { deps, sent, pushCalls, recorded } = makeDeps({ rotateCancelToken });
|
|
247
|
+
|
|
248
|
+
await expect(
|
|
249
|
+
notifyEscrowHoldEvent({ kind: 'pin-sign-in', hold, userKey }, deps)
|
|
250
|
+
).resolves.toBe(true);
|
|
251
|
+
|
|
252
|
+
expect(rotateCancelToken).not.toHaveBeenCalled();
|
|
253
|
+
expect(sent).toHaveLength(1);
|
|
254
|
+
expect(sent[0]!.templateAlias).toBe('escrow-pin-sign-in');
|
|
255
|
+
expect(sent[0]!.templateModel).toEqual({
|
|
256
|
+
signedInAt: completedAt.toISOString(),
|
|
257
|
+
secureUrl: 'https://learncard.app/account/secure',
|
|
258
|
+
});
|
|
259
|
+
expect(pushCalls).toEqual([
|
|
260
|
+
expect.objectContaining({
|
|
261
|
+
message: {
|
|
262
|
+
title: 'New device signed in with your PIN',
|
|
263
|
+
body: 'Not you? Open the app to secure your account.',
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
expect(recorded).toEqual([{ holdId: hold._id, kind: 'pin-sign-in' }]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('pin-sign-in links to the tenant app and sends tenant branding', async () => {
|
|
271
|
+
const userKey = baseUserKey();
|
|
272
|
+
const hold = baseHold({ releasePolicy: 'pin', status: 'completed' });
|
|
273
|
+
const { deps, sent } = makeDeps();
|
|
274
|
+
const emailBranding = { brandName: 'VetPass', appUrl: 'https://vetpass.app' };
|
|
275
|
+
|
|
276
|
+
await notifyEscrowHoldEvent(
|
|
277
|
+
{
|
|
278
|
+
kind: 'pin-sign-in',
|
|
279
|
+
hold,
|
|
280
|
+
userKey,
|
|
281
|
+
tenant: { id: 'vetpass', emailBranding, resolvedVia: 'header' },
|
|
282
|
+
},
|
|
283
|
+
deps
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(sent[0]!.templateModel.secureUrl).toBe('https://vetpass.app/account/secure');
|
|
287
|
+
expect(sent[0]!.branding).toEqual(emailBranding);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('pin-sign-in never throws when delivery fails', async () => {
|
|
291
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
292
|
+
const userKey = baseUserKey();
|
|
293
|
+
const hold = baseHold({ releasePolicy: 'pin', status: 'completed' });
|
|
294
|
+
const { deps } = makeDeps({
|
|
295
|
+
send: async () => {
|
|
296
|
+
throw new Error('postmark down owner@example.com');
|
|
297
|
+
},
|
|
298
|
+
sendPush: async () => {
|
|
299
|
+
throw new Error('fcm down');
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await expect(
|
|
304
|
+
notifyEscrowHoldEvent({ kind: 'pin-sign-in', hold, userKey }, deps)
|
|
305
|
+
).resolves.toBe(false);
|
|
306
|
+
expect(JSON.stringify(errorSpy.mock.calls)).not.toContain('owner@example.com');
|
|
307
|
+
errorSpy.mockRestore();
|
|
308
|
+
});
|
|
309
|
+
|
|
241
310
|
it('sends a push notification to the account DID for every kind', async () => {
|
|
242
311
|
const userKey = baseUserKey({ contactMethod: { type: 'phone', value: '+15555550100' } });
|
|
243
312
|
const hold = baseHold();
|
|
@@ -10,8 +10,15 @@ import { resolveBranding } from '@learncard/email-templates';
|
|
|
10
10
|
import type { EscrowHold, MongoUserKeyType } from '@models';
|
|
11
11
|
import { getDeliveryService, getFrom, type DeliveryService } from '../delivery';
|
|
12
12
|
import { buildEscrowCancelUrl } from '@helpers/escrowCancelLink';
|
|
13
|
+
import { buildAccountSecureUrl } from '@helpers/accountSecureLink';
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
/**
|
|
16
|
+
* `'pin-sign-in'` replaces `'completed'` for PIN releases: a PIN release now
|
|
17
|
+
* signs a new device in without signing out other devices, so the notice links
|
|
18
|
+
* to "Secure my account" instead of only confirming a recovery.
|
|
19
|
+
*/
|
|
20
|
+
export type EscrowHoldEventKind =
|
|
21
|
+
'started' | 'reminder' | 'completed' | 'cancelled' | 'pin-locked' | 'pin-sign-in';
|
|
15
22
|
|
|
16
23
|
export interface EscrowHoldEvent {
|
|
17
24
|
kind: EscrowHoldEventKind;
|
|
@@ -79,6 +86,7 @@ const EMAIL_TEMPLATE_ALIAS: Record<EscrowHoldEventKind, TemplateId> = {
|
|
|
79
86
|
completed: 'escrow-hold-released',
|
|
80
87
|
cancelled: 'escrow-hold-cancelled',
|
|
81
88
|
'pin-locked': 'escrow-pin-locked',
|
|
89
|
+
'pin-sign-in': 'escrow-pin-sign-in',
|
|
82
90
|
};
|
|
83
91
|
|
|
84
92
|
const PUSH_MESSAGES: Record<EscrowHoldEventKind, { title: string; body: string }> = {
|
|
@@ -102,6 +110,10 @@ const PUSH_MESSAGES: Record<EscrowHoldEventKind, { title: string; body: string }
|
|
|
102
110
|
title: 'Recovery PIN locked',
|
|
103
111
|
body: 'Your recovery PIN was locked after too many attempts.',
|
|
104
112
|
},
|
|
113
|
+
'pin-sign-in': {
|
|
114
|
+
title: 'New device signed in with your PIN',
|
|
115
|
+
body: 'Not you? Open the app to secure your account.',
|
|
116
|
+
},
|
|
105
117
|
};
|
|
106
118
|
|
|
107
119
|
/**
|
|
@@ -207,6 +219,12 @@ const buildEmailTemplateModel = async (
|
|
|
207
219
|
case 'completed':
|
|
208
220
|
return { completedAt: (hold.completedAt ?? deps.now()).toISOString() };
|
|
209
221
|
|
|
222
|
+
case 'pin-sign-in':
|
|
223
|
+
return {
|
|
224
|
+
signedInAt: (hold.completedAt ?? deps.now()).toISOString(),
|
|
225
|
+
secureUrl: buildAccountSecureUrl({ baseUrl }),
|
|
226
|
+
};
|
|
227
|
+
|
|
210
228
|
case 'cancelled':
|
|
211
229
|
return {
|
|
212
230
|
cancelledAt: (hold.cancelledAt ?? deps.now()).toISOString(),
|
|
@@ -15,6 +15,13 @@ import {
|
|
|
15
15
|
hashEscrowResumeToken,
|
|
16
16
|
recordEscrowHoldNotification,
|
|
17
17
|
computeEffectiveReleaseAfter,
|
|
18
|
+
findEscrowHoldsDueForReminder,
|
|
19
|
+
findEscrowHoldsPendingStartNotification,
|
|
20
|
+
findEscrowHoldsPendingEnclaveCancellation,
|
|
21
|
+
claimEscrowHoldForReminder,
|
|
22
|
+
releaseEscrowReminderClaim,
|
|
23
|
+
recordEscrowStartNotificationAttempt,
|
|
24
|
+
recordEscrowEnclaveCancelAttempt,
|
|
18
25
|
ESCROW_RELEASE_TIME_MARGIN_MS,
|
|
19
26
|
ESCROW_RELEASE_WARNING_GAP_MS,
|
|
20
27
|
type AuthProviderMapping,
|
|
@@ -41,7 +48,11 @@ const makeProvider = (): AuthProviderMapping => {
|
|
|
41
48
|
|
|
42
49
|
/** Every seed() call gets its own authProvider (see test/models/escrow.spec.ts
|
|
43
50
|
* for why: only one pending hold-policy hold is allowed per authProvider). */
|
|
44
|
-
const seed = async (
|
|
51
|
+
const seed = async (
|
|
52
|
+
releaseAfter: Date,
|
|
53
|
+
releasePolicy: 'hold' | 'pin' = 'hold',
|
|
54
|
+
holdId = randomUUID()
|
|
55
|
+
) => {
|
|
45
56
|
const provider = makeProvider();
|
|
46
57
|
await upsertUserKeyByAuthProvider(
|
|
47
58
|
{ type: 'email', value: `${provider.id}@example.com` },
|
|
@@ -54,7 +65,7 @@ const seed = async (releaseAfter: Date, releasePolicy: 'hold' | 'pin' = 'hold')
|
|
|
54
65
|
return createEscrowHold({
|
|
55
66
|
holdRecord: {
|
|
56
67
|
hold: {
|
|
57
|
-
holdId
|
|
68
|
+
holdId,
|
|
58
69
|
did: 'did:key:test',
|
|
59
70
|
shareVersion: 1,
|
|
60
71
|
blobHash: 'ab'.repeat(32),
|
|
@@ -117,6 +128,75 @@ afterAll(async () => {
|
|
|
117
128
|
});
|
|
118
129
|
|
|
119
130
|
describe('runEscrowHoldReminders', () => {
|
|
131
|
+
describe.each(['reminder', 'started', 'cancelled'] as const)('%s queue ordering', queue => {
|
|
132
|
+
it.each([false, true])(
|
|
133
|
+
'keeps tied batches stable and rotates retries (previously attempted: %s)',
|
|
134
|
+
async previouslyAttempted => {
|
|
135
|
+
const now = new Date();
|
|
136
|
+
const previousAttempt = new Date(now.getTime() - HOUR);
|
|
137
|
+
// More than a default delivery batch. Insert backwards so physical
|
|
138
|
+
// insertion order cannot accidentally satisfy the expected ID order.
|
|
139
|
+
const ids = Array.from(
|
|
140
|
+
{ length: 13 },
|
|
141
|
+
(_, index) => `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`
|
|
142
|
+
);
|
|
143
|
+
for (const id of [...ids].reverse()) {
|
|
144
|
+
await seed(new Date(now.getTime() + HOUR), 'hold', id);
|
|
145
|
+
}
|
|
146
|
+
await getEscrowHoldsCollection().updateMany(
|
|
147
|
+
{},
|
|
148
|
+
{
|
|
149
|
+
$set: {
|
|
150
|
+
requestedAt: previousAttempt,
|
|
151
|
+
...(queue === 'started' ? { startedNotificationPending: true } : {}),
|
|
152
|
+
...(queue === 'cancelled'
|
|
153
|
+
? {
|
|
154
|
+
status: 'cancelled' as const,
|
|
155
|
+
enclaveCancelPendingAt: previousAttempt,
|
|
156
|
+
enclaveCancelAttempts: previouslyAttempted ? 2 : 0,
|
|
157
|
+
}
|
|
158
|
+
: previouslyAttempted
|
|
159
|
+
? queue === 'reminder'
|
|
160
|
+
? { reminderAttemptedAt: previousAttempt }
|
|
161
|
+
: { startedNotificationAttemptedAt: previousAttempt }
|
|
162
|
+
: {}),
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
);
|
|
166
|
+
const readBatch = () =>
|
|
167
|
+
queue === 'reminder'
|
|
168
|
+
? findEscrowHoldsDueForReminder(now, 5)
|
|
169
|
+
: queue === 'started'
|
|
170
|
+
? findEscrowHoldsPendingStartNotification(5)
|
|
171
|
+
: findEscrowHoldsPendingEnclaveCancellation(5);
|
|
172
|
+
|
|
173
|
+
for (let repeat = 0; repeat < 3; repeat += 1) {
|
|
174
|
+
expect((await readBatch()).map(hold => hold._id)).toEqual(ids.slice(0, 5));
|
|
175
|
+
}
|
|
176
|
+
// Failed deliveries remain eligible. Updating their real attempt
|
|
177
|
+
// markers must put untouched rows first, even with tied timestamps.
|
|
178
|
+
for (let offset = 0; offset < ids.length; offset += 5) {
|
|
179
|
+
const batch = await readBatch();
|
|
180
|
+
const remaining = Math.min(5, ids.length - offset);
|
|
181
|
+
expect(batch.slice(0, remaining).map(hold => hold._id)).toEqual(
|
|
182
|
+
ids.slice(offset, offset + remaining)
|
|
183
|
+
);
|
|
184
|
+
for (const hold of batch) {
|
|
185
|
+
if (queue === 'reminder') {
|
|
186
|
+
const claimed = await claimEscrowHoldForReminder(hold._id, now);
|
|
187
|
+
expect(claimed).not.toBeNull();
|
|
188
|
+
if (claimed) await releaseEscrowReminderClaim(claimed);
|
|
189
|
+
} else if (queue === 'started') {
|
|
190
|
+
await recordEscrowStartNotificationAttempt(hold._id, now);
|
|
191
|
+
} else {
|
|
192
|
+
expect(await recordEscrowEnclaveCancelAttempt(hold._id)).toBe(true);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
120
200
|
it('bounds cancellation retries and gives unattempted cancellations priority', async () => {
|
|
121
201
|
const now = new Date();
|
|
122
202
|
const holds = await Promise.all(
|
|
@@ -129,9 +209,13 @@ describe('runEscrowHoldReminders', () => {
|
|
|
129
209
|
await runEscrowHoldReminders({ now, limit: 200 });
|
|
130
210
|
const firstRun = await Promise.all(holds.map(hold => findEscrowHoldById(hold._id)));
|
|
131
211
|
expect(firstRun.map(hold => hold?.enclaveCancelAttempts).sort()).toEqual([0, 0, 1, 1]);
|
|
132
|
-
|
|
212
|
+
// UUIDs are ASCII: default string ordering matches Mongo's byte ordering.
|
|
213
|
+
const unattemptedId = firstRun
|
|
214
|
+
.filter(hold => hold?.enclaveCancelAttempts === 0)
|
|
215
|
+
.map(hold => hold!._id)
|
|
216
|
+
.sort()[0]!;
|
|
133
217
|
await runEscrowHoldReminders({ now, limit: 1 });
|
|
134
|
-
expect((await findEscrowHoldById(
|
|
218
|
+
expect((await findEscrowHoldById(unattemptedId))?.enclaveCancelAttempts).toBe(1);
|
|
135
219
|
for (const hold of holds) {
|
|
136
220
|
const stored = await findEscrowHoldById(hold._id);
|
|
137
221
|
expect(stored?.enclaveCancelPendingAt).toBeInstanceOf(Date);
|
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -2499,11 +2499,13 @@ describe('escrow PIN release', () => {
|
|
|
2499
2499
|
enabled: true,
|
|
2500
2500
|
attemptsRemaining: 10,
|
|
2501
2501
|
salt: pinSalt,
|
|
2502
|
+
wrongAttempts: 0,
|
|
2502
2503
|
});
|
|
2503
2504
|
await getUserKeysCollection().updateOne(
|
|
2504
2505
|
{ 'authProviders.id': authProvider.id },
|
|
2505
2506
|
{ $set: { 'escrowPin.failedAttempts': 10, 'escrowPin.disabledAt': new Date() } }
|
|
2506
2507
|
);
|
|
2508
|
+
// Legacy charged history without the verified counter: no wrong-entry count.
|
|
2507
2509
|
expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
|
|
2508
2510
|
state: 'locked',
|
|
2509
2511
|
enabled: false,
|
|
@@ -2518,6 +2520,7 @@ describe('escrow PIN release', () => {
|
|
|
2518
2520
|
state: 'stale',
|
|
2519
2521
|
enabled: false,
|
|
2520
2522
|
attemptsRemaining: 10,
|
|
2523
|
+
wrongAttempts: 0,
|
|
2521
2524
|
});
|
|
2522
2525
|
await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
2523
2526
|
});
|
|
@@ -2580,6 +2583,7 @@ describe('escrow PIN release', () => {
|
|
|
2580
2583
|
enabled: true,
|
|
2581
2584
|
attemptsRemaining: 10,
|
|
2582
2585
|
salt: pinSalt,
|
|
2586
|
+
wrongAttempts: 0,
|
|
2583
2587
|
});
|
|
2584
2588
|
// Required for a no-provider Nitro escrow start/restart to bind to this account.
|
|
2585
2589
|
expect(result.primaryDid).toBe(did);
|
|
@@ -676,6 +676,82 @@ describe('P0-1 provisional SSS activation', () => {
|
|
|
676
676
|
});
|
|
677
677
|
});
|
|
678
678
|
|
|
679
|
+
describe('P0-1 provisional purge spares escrow-enrolled migrations', () => {
|
|
680
|
+
const expiredMigration = (
|
|
681
|
+
email: string,
|
|
682
|
+
uid: string,
|
|
683
|
+
did: string,
|
|
684
|
+
escrow: { blob: boolean; method?: 'confirmed' | 'pending' }
|
|
685
|
+
): MongoUserKeyType => {
|
|
686
|
+
const key = makeUserKey(email, uid, did, 'migrated-share');
|
|
687
|
+
|
|
688
|
+
Object.assign(key, {
|
|
689
|
+
keyProvider: 'web3auth' as const,
|
|
690
|
+
sssActivationState: 'provisional' as const,
|
|
691
|
+
provisionalCreatedAt: new Date(Date.now() - PROVISIONAL_MIGRATION_TTL_MS - 1_000),
|
|
692
|
+
recoveryMethods: escrow.method
|
|
693
|
+
? [
|
|
694
|
+
{
|
|
695
|
+
type: 'escrow' as const,
|
|
696
|
+
createdAt: new Date(),
|
|
697
|
+
confirmationStatus: escrow.method,
|
|
698
|
+
...(escrow.method === 'confirmed' ? { confirmedAt: new Date() } : {}),
|
|
699
|
+
shareVersion: 1,
|
|
700
|
+
},
|
|
701
|
+
]
|
|
702
|
+
: [],
|
|
703
|
+
// Only presence matters to the purge filter.
|
|
704
|
+
...(escrow.blob
|
|
705
|
+
? { escrowBlob: { shareVersion: 1 } as unknown as MongoUserKeyType['escrowBlob'] }
|
|
706
|
+
: {}),
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
return key;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
it.each([
|
|
713
|
+
['confirmed escrow enrollment', false, { blob: true, method: 'confirmed' as const }],
|
|
714
|
+
[
|
|
715
|
+
'escrow blob without a confirmed method',
|
|
716
|
+
true,
|
|
717
|
+
{ blob: true, method: 'pending' as const },
|
|
718
|
+
],
|
|
719
|
+
['confirmed method without a blob', true, { blob: false, method: 'confirmed' as const }],
|
|
720
|
+
])('%s → purged: %s', async (_name, purged, escrow) => {
|
|
721
|
+
const suffix = `${Date.now()}-${randomUUID()}`;
|
|
722
|
+
const email = `escrow-purge-${suffix}@example.com`;
|
|
723
|
+
const uid = `uid-${suffix}`;
|
|
724
|
+
const { learnCard } = await getUser('5'.repeat(64));
|
|
725
|
+
const collection = getUserKeysCollection();
|
|
726
|
+
|
|
727
|
+
await collection.insertOne(expiredMigration(email, uid, learnCard.id.did(), escrow));
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
const result = await getClient().keys.getAuthShare({
|
|
731
|
+
authToken: makeMockToken(email, uid),
|
|
732
|
+
providerType: 'firebase',
|
|
733
|
+
});
|
|
734
|
+
const stored = await collection.findOne({
|
|
735
|
+
authProviders: { $elemMatch: { type: 'firebase', id: uid } },
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
if (purged) {
|
|
739
|
+
expect(result?.authShare).toBeNull();
|
|
740
|
+
expect(stored?.authShare).toBeUndefined();
|
|
741
|
+
expect(stored?.escrowBlob).toBeUndefined();
|
|
742
|
+
expect(stored?.sssActivationState).toBeUndefined();
|
|
743
|
+
} else {
|
|
744
|
+
expect(stored?.authShare?.encryptedData).toBe('migrated-share');
|
|
745
|
+
expect(stored?.escrowBlob).toBeDefined();
|
|
746
|
+
expect(stored?.sssActivationState).toBe('provisional');
|
|
747
|
+
expect(stored?.keyProvider).toBe('web3auth');
|
|
748
|
+
}
|
|
749
|
+
} finally {
|
|
750
|
+
await collection.deleteMany({ 'contactMethod.value': email });
|
|
751
|
+
}
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
|
|
679
755
|
describe('P0-6 fresh DID authorization', () => {
|
|
680
756
|
it('issues short-lived challenges that can only be consumed once', async () => {
|
|
681
757
|
const did = `did:key:${randomUUID()}`;
|