@learncard/lca-api-service 1.6.1 → 1.6.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/AGENTS.md +21 -0
- package/CHANGELOG.md +35 -0
- package/dist/app.d.ts +41 -0
- package/dist/cache/recoverySessions.d.ts +5 -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 +10 -10
- 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 +17 -3
- package/src/models/UserKey.escrowPin.test.ts +21 -0
- package/src/models/UserKey.ts +540 -48
- package/src/routes/escrow.ts +337 -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/keys-escrow.spec.ts +22 -0
- package/test/keys-pin-lease.spec.ts +1132 -0
- package/test/keys-pin-sign-in.spec.ts +1184 -0
|
@@ -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(),
|
package/test/keys-escrow.spec.ts
CHANGED
|
@@ -1971,6 +1971,24 @@ describe('escrow PIN release', () => {
|
|
|
1971
1971
|
expect((await record())?.escrowPin?.failedAttempts).toBe(2);
|
|
1972
1972
|
});
|
|
1973
1973
|
|
|
1974
|
+
it('never reports "Incorrect PIN." for a mismatch whose wrong entry was not recorded', async () => {
|
|
1975
|
+
// The record changed under the compare (re-enrolled/rotated elsewhere): clients
|
|
1976
|
+
// count every "Incorrect PIN." as their own recorded entry (own-typos rule).
|
|
1977
|
+
await enrollPin();
|
|
1978
|
+
const hold = await startPin();
|
|
1979
|
+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
1980
|
+
vi.spyOn(models, 'recordEscrowPinFailure').mockResolvedValueOnce(null);
|
|
1981
|
+
await expect(completePin(hold, wrongProof)).rejects.toMatchObject({
|
|
1982
|
+
code: 'CONFLICT',
|
|
1983
|
+
message: 'Automatic recovery changed; please refresh.',
|
|
1984
|
+
});
|
|
1985
|
+
expect(await findEscrowHoldById(hold.holdId)).toMatchObject({
|
|
1986
|
+
status: 'cancelled',
|
|
1987
|
+
cancelReason: 'pin-mismatch',
|
|
1988
|
+
});
|
|
1989
|
+
expect((await record())?.escrowPin?.verifiedFailedAttempts ?? 0).toBe(0);
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1974
1992
|
it('locks after ten mismatches and still permits delayed recovery', async () => {
|
|
1975
1993
|
await enrollPin();
|
|
1976
1994
|
let lockingHoldId = '';
|
|
@@ -2499,11 +2517,13 @@ describe('escrow PIN release', () => {
|
|
|
2499
2517
|
enabled: true,
|
|
2500
2518
|
attemptsRemaining: 10,
|
|
2501
2519
|
salt: pinSalt,
|
|
2520
|
+
wrongAttempts: 0,
|
|
2502
2521
|
});
|
|
2503
2522
|
await getUserKeysCollection().updateOne(
|
|
2504
2523
|
{ 'authProviders.id': authProvider.id },
|
|
2505
2524
|
{ $set: { 'escrowPin.failedAttempts': 10, 'escrowPin.disabledAt': new Date() } }
|
|
2506
2525
|
);
|
|
2526
|
+
// Legacy charged history without the verified counter: no wrong-entry count.
|
|
2507
2527
|
expect((await owner().keys.getAuthShare(auth))?.escrowPin).toEqual({
|
|
2508
2528
|
state: 'locked',
|
|
2509
2529
|
enabled: false,
|
|
@@ -2518,6 +2538,7 @@ describe('escrow PIN release', () => {
|
|
|
2518
2538
|
state: 'stale',
|
|
2519
2539
|
enabled: false,
|
|
2520
2540
|
attemptsRemaining: 10,
|
|
2541
|
+
wrongAttempts: 0,
|
|
2521
2542
|
});
|
|
2522
2543
|
await expect(startPin()).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
|
2523
2544
|
});
|
|
@@ -2580,6 +2601,7 @@ describe('escrow PIN release', () => {
|
|
|
2580
2601
|
enabled: true,
|
|
2581
2602
|
attemptsRemaining: 10,
|
|
2582
2603
|
salt: pinSalt,
|
|
2604
|
+
wrongAttempts: 0,
|
|
2583
2605
|
});
|
|
2584
2606
|
// Required for a no-provider Nitro escrow start/restart to bind to this account.
|
|
2585
2607
|
expect(result.primaryDid).toBe(did);
|