@learncard/sss-key-manager 0.2.7 → 0.3.0
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/README.md +9 -0
- package/dist/escrow-authority.d.ts +4 -3
- package/dist/escrow-authority.d.ts.map +1 -1
- package/dist/escrow-authority.types.d.ts +15 -0
- package/dist/escrow-authority.types.d.ts.map +1 -1
- package/dist/escrow-crypto.d.ts +19 -1
- package/dist/escrow-crypto.d.ts.map +1 -1
- package/dist/escrow-enrollment-intent.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/sss-key-manager.cjs.development.js +720 -73
- package/dist/sss-key-manager.cjs.development.js.map +3 -3
- package/dist/sss-key-manager.cjs.production.min.js +30 -30
- package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
- package/dist/sss-key-manager.esm.js +722 -73
- package/dist/sss-key-manager.esm.js.map +3 -3
- package/dist/sss-strategy.d.ts +26 -0
- package/dist/sss-strategy.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__fixtures__/escrow-authority-brand-names-v1.json +29 -0
- package/src/__fixtures__/escrow-carry-contact-contract-v1.json +25 -1
- package/src/__fixtures__/escrow-email-brand-contract-v1.json +225 -0
- package/src/escrow-authority.test.ts +2 -0
- package/src/escrow-authority.ts +24 -7
- package/src/escrow-authority.types.ts +27 -0
- package/src/escrow-crypto.test.ts +75 -0
- package/src/escrow-crypto.ts +43 -1
- package/src/escrow-enrollment-intent.ts +2 -1
- package/src/index.ts +8 -1
- package/src/sss-authority.test.ts +392 -32
- package/src/sss-strategy.ts +66 -8
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ExtendedPoint, getPublicKeyAsync, signAsync } from '@noble/ed25519';
|
|
2
2
|
import { base64ToBuffer, bufferToBase64, bytesToHex, hexToBytes } from './crypto';
|
|
3
3
|
import { parseEscrowEnvelope } from './escrow-crypto';
|
|
4
|
+
import { ESCROW_AUTHORITY_TENANT_PATTERN } from './escrow-authority.types';
|
|
4
5
|
import type { EscrowEnvelope } from './escrow-crypto';
|
|
5
6
|
import type {
|
|
6
7
|
EscrowEnrollmentIntent,
|
|
@@ -101,7 +102,7 @@ export const encodeEscrowEnrollmentIntent = (
|
|
|
101
102
|
FIELDS.every(field => Object.prototype.hasOwnProperty.call(intent, field))
|
|
102
103
|
);
|
|
103
104
|
requireIntent(intent.version === 2 && typeof intent.allowRewrap === 'boolean');
|
|
104
|
-
requireIntent(matches(intent.tenant,
|
|
105
|
+
requireIntent(matches(intent.tenant, ESCROW_AUTHORITY_TENANT_PATTERN));
|
|
105
106
|
requireIntent(matches(intent.authorityGeneration, /^[A-Za-z0-9_-]{1,128}$/));
|
|
106
107
|
requireIntent(matches(intent.requestId, /^[A-Za-z0-9._-]{1,112}$/));
|
|
107
108
|
didPublicKey(intent.did);
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,9 @@ export {
|
|
|
68
68
|
parseEscrowEnvelope,
|
|
69
69
|
parseEscrowBlobPlaintext,
|
|
70
70
|
parseEscrowReleasePlaintext,
|
|
71
|
+
canonicalEscrowEmailBrand,
|
|
72
|
+
MAX_ESCROW_EMAIL_BRAND_LENGTH,
|
|
73
|
+
MAX_ESCROW_EMAIL_BRAND_DIGITS,
|
|
71
74
|
generateEscrowKeyPair,
|
|
72
75
|
encryptEscrowBlob,
|
|
73
76
|
decryptEscrowBlob,
|
|
@@ -87,7 +90,11 @@ export type {
|
|
|
87
90
|
SignedEscrowEnrollmentIntent,
|
|
88
91
|
} from './escrow-enrollment-intent.types';
|
|
89
92
|
export { verifyEnclaveAttestation } from './escrow-attestation';
|
|
90
|
-
export {
|
|
93
|
+
export {
|
|
94
|
+
EscrowEvidenceError,
|
|
95
|
+
EscrowAuthorityTenantMismatchError,
|
|
96
|
+
ESCROW_AUTHORITY_TENANT_PATTERN,
|
|
97
|
+
} from './escrow-authority.types';
|
|
91
98
|
export type { HistoricalEscrowEvidence } from './escrow-authority.types';
|
|
92
99
|
export type { EscrowRecoveryExpectations } from '@learncard/types';
|
|
93
100
|
export {
|
|
@@ -7,8 +7,13 @@ import {
|
|
|
7
7
|
type SSSStorageFunctions,
|
|
8
8
|
type SSSStrategyConfig,
|
|
9
9
|
} from './sss-strategy';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
escrowAuthorityMeasurements,
|
|
12
|
+
violatesCarryContactRule,
|
|
13
|
+
type EscrowHoldRecordLike,
|
|
14
|
+
} from './escrow-authority';
|
|
11
15
|
import type { EscrowRecoveryStart } from './types';
|
|
16
|
+
import { EscrowAuthorityTenantMismatchError } from './escrow-authority.types';
|
|
12
17
|
import { verifyEnclaveAttestation } from './escrow-attestation';
|
|
13
18
|
import {
|
|
14
19
|
encryptEscrowBlob,
|
|
@@ -36,29 +41,32 @@ const digest = async (input: Uint8Array): Promise<number[]> =>
|
|
|
36
41
|
// [D] Mirrors `hold_hash` in escrow-enclave-app's policy.rs: SHA-256 of
|
|
37
42
|
// compact JSON of this fixed-order array, which the client independently
|
|
38
43
|
// recomputes to bind a SignedHoldRecord to its own request.
|
|
39
|
-
const holdTupleDigest = async (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
44
|
+
const holdTupleDigest = async (
|
|
45
|
+
holdRecord: {
|
|
46
|
+
hold: {
|
|
47
|
+
holdId: string;
|
|
48
|
+
did: string;
|
|
49
|
+
shareVersion: number;
|
|
50
|
+
blobHash: string;
|
|
51
|
+
enrollmentEpoch: number;
|
|
52
|
+
releasePolicy: string;
|
|
53
|
+
clientEphemeralPublicKey: string;
|
|
54
|
+
createdLo: number;
|
|
55
|
+
createdHi: number;
|
|
56
|
+
policyVersion: number;
|
|
57
|
+
};
|
|
58
|
+
holdDurationMs: number;
|
|
59
|
+
ledgerSeq: number;
|
|
60
|
+
},
|
|
61
|
+
tenant = 'learncard'
|
|
62
|
+
): Promise<number[]> => {
|
|
55
63
|
const h = holdRecord.hold;
|
|
56
64
|
const didHashHex = Buffer.from(await digest(Buffer.from(h.did))).toString('hex');
|
|
57
65
|
return digest(
|
|
58
66
|
Buffer.from(
|
|
59
67
|
JSON.stringify([
|
|
60
68
|
'learncard-hold-v1',
|
|
61
|
-
|
|
69
|
+
tenant,
|
|
62
70
|
h.holdId,
|
|
63
71
|
didHashHex,
|
|
64
72
|
h.shareVersion,
|
|
@@ -83,7 +91,27 @@ const frame = (value: Buffer): Buffer => {
|
|
|
83
91
|
const response = (value: unknown, status = 200): Response =>
|
|
84
92
|
new Response(JSON.stringify(value), { status });
|
|
85
93
|
|
|
86
|
-
|
|
94
|
+
/**
|
|
95
|
+
* `enclaveTenant` is the fake enclave's measured authority label (what it signs);
|
|
96
|
+
* `appTenant` is the client's X-Tenant-Id; `authorityTenant` is the client's configured
|
|
97
|
+
* `escrow.authorityTenant` (omitted = default to the app tenant).
|
|
98
|
+
*/
|
|
99
|
+
const setup = async (
|
|
100
|
+
legacyEnrolled = false,
|
|
101
|
+
tenants: {
|
|
102
|
+
appTenant?: string;
|
|
103
|
+
authorityTenant?: string;
|
|
104
|
+
enclaveTenant?: string;
|
|
105
|
+
/** D21: the client's `escrow.emailBrand`. */
|
|
106
|
+
emailBrand?: string;
|
|
107
|
+
} = {}
|
|
108
|
+
) => {
|
|
109
|
+
const {
|
|
110
|
+
appTenant = 'learncard',
|
|
111
|
+
authorityTenant,
|
|
112
|
+
enclaveTenant = 'learncard',
|
|
113
|
+
emailBrand,
|
|
114
|
+
} = tenants;
|
|
87
115
|
const pins = [
|
|
88
116
|
{ pcr0: '01'.repeat(48), pcr1: '02'.repeat(48), pcr2: '03'.repeat(48) },
|
|
89
117
|
{ pcr0: '04'.repeat(48), pcr1: '02'.repeat(48), pcr2: '03'.repeat(48) },
|
|
@@ -139,7 +167,7 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
139
167
|
};
|
|
140
168
|
};
|
|
141
169
|
let state: EscrowAuthorityState = {
|
|
142
|
-
tenant:
|
|
170
|
+
tenant: enclaveTenant,
|
|
143
171
|
authorityGeneration: 'fresh-g2',
|
|
144
172
|
did: owner.intent.did,
|
|
145
173
|
enrollmentVersion: 0,
|
|
@@ -181,9 +209,19 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
181
209
|
},
|
|
182
210
|
};
|
|
183
211
|
const calls: string[] = [];
|
|
212
|
+
/** Every X-Tenant-Id the client sent (must always be the app tenant). */
|
|
213
|
+
const tenantHeaders = new Set<string | undefined>();
|
|
184
214
|
const nonces: string[] = [];
|
|
185
215
|
const intents: SignedEscrowEnrollmentIntent[] = [];
|
|
186
|
-
const enrollments: Array<{
|
|
216
|
+
const enrollments: Array<{
|
|
217
|
+
pinSalt?: unknown;
|
|
218
|
+
clearPin?: unknown;
|
|
219
|
+
pinVerifier?: string;
|
|
220
|
+
action?: string;
|
|
221
|
+
/** D21: the target blob plaintext exactly as the client sealed it. */
|
|
222
|
+
recoveryEmail?: string;
|
|
223
|
+
recoveryBrand?: string;
|
|
224
|
+
}> = [];
|
|
187
225
|
const faults: {
|
|
188
226
|
snapshotNonce?: boolean;
|
|
189
227
|
returnedEnvelope?: boolean;
|
|
@@ -221,7 +259,7 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
221
259
|
commit: {
|
|
222
260
|
schemaVersion: 1,
|
|
223
261
|
binding: {
|
|
224
|
-
tenant:
|
|
262
|
+
tenant: enclaveTenant,
|
|
225
263
|
generation: faults.generation ?? state.authorityGeneration,
|
|
226
264
|
didHash: await digest(Buffer.from(h.did)),
|
|
227
265
|
},
|
|
@@ -230,7 +268,7 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
230
268
|
transactionDigest: Array.from({ length: 32 }, () => 2),
|
|
231
269
|
},
|
|
232
270
|
eventHash: Array.from({ length: 32 }, () => 3),
|
|
233
|
-
requestDigest: await holdTupleDigest(record),
|
|
271
|
+
requestDigest: await holdTupleDigest(record, enclaveTenant),
|
|
234
272
|
resultBlobIdentity: Array.from(Buffer.from(h.blobHash, 'hex')),
|
|
235
273
|
enrollmentEpoch: h.enrollmentEpoch,
|
|
236
274
|
});
|
|
@@ -244,6 +282,9 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
244
282
|
vi.fn(async (url: string, init?: RequestInit) => {
|
|
245
283
|
const path = new URL(url).pathname;
|
|
246
284
|
calls.push(`${init?.method}:${path}`);
|
|
285
|
+
tenantHeaders.add(
|
|
286
|
+
(init?.headers as Record<string, string> | undefined)?.['X-Tenant-Id']
|
|
287
|
+
);
|
|
247
288
|
const body: Record<string, unknown> = JSON.parse(String(init?.body ?? '{}'));
|
|
248
289
|
if (path === '/keys/challenge') return response({ challenge: crypto.randomUUID() });
|
|
249
290
|
if (path === '/keys/auth-share') {
|
|
@@ -363,11 +404,23 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
363
404
|
pinSalt: body.pinSalt,
|
|
364
405
|
clearPin: body.clearPin,
|
|
365
406
|
pinVerifier: plaintext.pinVerifier,
|
|
407
|
+
action: signed.intent.action,
|
|
408
|
+
recoveryEmail: plaintext.recoveryEmail,
|
|
409
|
+
recoveryBrand: plaintext.recoveryBrand,
|
|
366
410
|
});
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
)
|
|
411
|
+
// Mirrors the enclave's Carry rule: refuse a target claiming
|
|
412
|
+
// contact/brand, then inherit them (and the PIN) from the source.
|
|
413
|
+
if (violatesCarryContactRule(signed.intent.action, plaintext))
|
|
414
|
+
return response({ message: 'Carry target claims contact' }, 400);
|
|
415
|
+
if (signed.intent.action === 'carry') {
|
|
416
|
+
const source = await decryptEscrowBlob(state.envelope!, keys.privateKey);
|
|
417
|
+
plaintext.pinVerifier = source.pinVerifier;
|
|
418
|
+
if (source.recoveryEmail) {
|
|
419
|
+
plaintext.recoveryEmail = source.recoveryEmail;
|
|
420
|
+
plaintext.recoveryLanguage = source.recoveryLanguage;
|
|
421
|
+
}
|
|
422
|
+
if (source.recoveryBrand) plaintext.recoveryBrand = source.recoveryBrand;
|
|
423
|
+
}
|
|
371
424
|
output = await encryptEscrowBlob(plaintext, keys.publicKey, identity.keyId);
|
|
372
425
|
}
|
|
373
426
|
const pinChange = signed.intent.pinAction !== 'keep';
|
|
@@ -441,7 +494,7 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
441
494
|
commit: {
|
|
442
495
|
schemaVersion: 1,
|
|
443
496
|
binding: {
|
|
444
|
-
tenant:
|
|
497
|
+
tenant: enclaveTenant,
|
|
445
498
|
generation: state.authorityGeneration,
|
|
446
499
|
didHash: await digest(Buffer.from(h.did)),
|
|
447
500
|
},
|
|
@@ -450,7 +503,7 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
450
503
|
transactionDigest: Array.from({ length: 32 }, () => 2),
|
|
451
504
|
},
|
|
452
505
|
eventHash: Array.from({ length: 32 }, () => 3),
|
|
453
|
-
requestDigest: await holdTupleDigest(holdRecord),
|
|
506
|
+
requestDigest: await holdTupleDigest(holdRecord, enclaveTenant),
|
|
454
507
|
resultBlobIdentity: Array.from(Buffer.from(h.blobHash, 'hex')),
|
|
455
508
|
enrollmentEpoch: h.enrollmentEpoch,
|
|
456
509
|
});
|
|
@@ -507,7 +560,7 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
507
560
|
? 'learncard-authority-hold-snapshot-v1'
|
|
508
561
|
: 'learncard-authority-hold-snapshot-v2',
|
|
509
562
|
h.holdId,
|
|
510
|
-
await holdTupleDigest(record),
|
|
563
|
+
await holdTupleDigest(record, enclaveTenant),
|
|
511
564
|
'pending',
|
|
512
565
|
Date.now(),
|
|
513
566
|
null,
|
|
@@ -527,7 +580,7 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
527
580
|
commit: {
|
|
528
581
|
schemaVersion: 1,
|
|
529
582
|
binding: {
|
|
530
|
-
tenant:
|
|
583
|
+
tenant: enclaveTenant,
|
|
531
584
|
generation:
|
|
532
585
|
faults.confirmationGeneration ?? state.authorityGeneration,
|
|
533
586
|
didHash: await digest(Buffer.from(h.did)),
|
|
@@ -590,13 +643,15 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
590
643
|
const config: SSSStrategyConfig = {
|
|
591
644
|
serverUrl: 'https://example.test',
|
|
592
645
|
storage,
|
|
593
|
-
tenantId:
|
|
646
|
+
tenantId: appTenant,
|
|
594
647
|
escrow: {
|
|
595
648
|
enabled: true,
|
|
596
649
|
attestation: {
|
|
597
650
|
mode: 'nitro',
|
|
598
651
|
pinnedMeasurements: pins,
|
|
599
652
|
},
|
|
653
|
+
...(authorityTenant ? { authorityTenant } : {}),
|
|
654
|
+
...(emailBrand !== undefined ? { emailBrand } : {}),
|
|
600
655
|
},
|
|
601
656
|
};
|
|
602
657
|
const strategy = createSSSStrategy(config);
|
|
@@ -612,6 +667,7 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
612
667
|
strategy,
|
|
613
668
|
params,
|
|
614
669
|
calls,
|
|
670
|
+
tenantHeaders,
|
|
615
671
|
nonces,
|
|
616
672
|
intents,
|
|
617
673
|
enrollments,
|
|
@@ -622,6 +678,9 @@ const setup = async (legacyEnrolled = false) => {
|
|
|
622
678
|
reload: () => createSSSStrategy(config),
|
|
623
679
|
emailShare: () => formatVersionedEmailShare(recoveryShares.emailShare, version),
|
|
624
680
|
state: () => state,
|
|
681
|
+
/** D21: the plaintext of the authority's current blob. */
|
|
682
|
+
sealed: async () =>
|
|
683
|
+
state.envelope ? decryptEscrowBlob(state.envelope, keys.privateKey) : null,
|
|
625
684
|
advance: () => {
|
|
626
685
|
version++;
|
|
627
686
|
enrolled = false;
|
|
@@ -1556,3 +1615,304 @@ describe('Nitro owner enrollment flow', () => {
|
|
|
1556
1615
|
expect(f.intents).toHaveLength(0);
|
|
1557
1616
|
});
|
|
1558
1617
|
});
|
|
1618
|
+
|
|
1619
|
+
describe('Escrow authority tenant decoupled from the app tenant (VetPass on the LearnCard enclave)', () => {
|
|
1620
|
+
// The enclave is single-tenant: it signs everything as its measured label, 'learncard'.
|
|
1621
|
+
const vetpass = { appTenant: 'vetpass', authorityTenant: 'learncard' } as const;
|
|
1622
|
+
const startHold = (f: Awaited<ReturnType<typeof setup>>) =>
|
|
1623
|
+
f.strategy.startEscrowRecovery!({
|
|
1624
|
+
token: 'token',
|
|
1625
|
+
providerType: 'firebase',
|
|
1626
|
+
expectedDid: owner.intent.did,
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
it('enrolls with a learncard-tenant intent while every request carries X-Tenant-Id vetpass', async () => {
|
|
1630
|
+
const f = await setup(false, vetpass);
|
|
1631
|
+
await expect(
|
|
1632
|
+
f.strategy.ensureEscrowEnrollment!({
|
|
1633
|
+
...f.params,
|
|
1634
|
+
options: { ...f.params.options, pin: '135790' },
|
|
1635
|
+
})
|
|
1636
|
+
).resolves.toMatchObject({ enrolled: true, changed: true });
|
|
1637
|
+
expect(f.intents.at(-1)?.intent).toMatchObject({ tenant: 'learncard', action: 'enroll' });
|
|
1638
|
+
expect(f.state()).toMatchObject({ tenant: 'learncard', status: 'active', hasPin: true });
|
|
1639
|
+
expect([...f.tenantHeaders]).toEqual(['vetpass']);
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
it('runs a PIN sign-in that keeps the account', async () => {
|
|
1643
|
+
const f = await setup(false, vetpass);
|
|
1644
|
+
await f.strategy.ensureEscrowEnrollment!({
|
|
1645
|
+
...f.params,
|
|
1646
|
+
options: { ...f.params.options, pin: '135790' },
|
|
1647
|
+
});
|
|
1648
|
+
f.faults.wrongPinAttempts = 0;
|
|
1649
|
+
await expect(
|
|
1650
|
+
f.strategy.executeRecovery({
|
|
1651
|
+
token: 'token',
|
|
1652
|
+
providerType: 'firebase',
|
|
1653
|
+
input: { method: 'escrow-pin', pin: '135790', expectedDid: owner.intent.did },
|
|
1654
|
+
didFromPrivateKey: async () => owner.intent.did,
|
|
1655
|
+
signDidAuthVp: f.params.signDidAuthVp,
|
|
1656
|
+
options: f.params.options,
|
|
1657
|
+
})
|
|
1658
|
+
).resolves.toMatchObject({
|
|
1659
|
+
escrowRecovery: true,
|
|
1660
|
+
pinSignIn: { mode: 'kept', newPinRequired: false },
|
|
1661
|
+
});
|
|
1662
|
+
expect(f.intents.every(signed => signed.intent.tenant === 'learncard')).toBe(true);
|
|
1663
|
+
expect([...f.tenantHeaders]).toEqual(['vetpass']);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
it('starts, confirms, persists/reloads and restores a 7-day hold bound to the authority tenant', async () => {
|
|
1667
|
+
const f = await setup(false, vetpass);
|
|
1668
|
+
const saved: EscrowRecoveryStart = JSON.parse(JSON.stringify(await startHold(f)));
|
|
1669
|
+
expect(saved.expectations).toMatchObject({ tenant: 'learncard', holdId: 'hold' });
|
|
1670
|
+
const proof = {
|
|
1671
|
+
holdId: saved.holdId,
|
|
1672
|
+
resumeToken: saved.resumeToken!,
|
|
1673
|
+
expectations: saved.expectations,
|
|
1674
|
+
};
|
|
1675
|
+
await expect(
|
|
1676
|
+
f.reload().confirmEscrowRecovery!({ ...proof, code: '123456' })
|
|
1677
|
+
).resolves.toEqual({ success: true });
|
|
1678
|
+
await expect(f.reload().getEscrowRecoveryStatus!(proof)).resolves.toMatchObject({
|
|
1679
|
+
holdId: 'hold',
|
|
1680
|
+
});
|
|
1681
|
+
await expect(
|
|
1682
|
+
f.reload().executeRecovery({
|
|
1683
|
+
token: '',
|
|
1684
|
+
providerType: 'firebase',
|
|
1685
|
+
input: {
|
|
1686
|
+
method: 'escrow',
|
|
1687
|
+
...proof,
|
|
1688
|
+
clientEphemeralPrivateKey: saved.clientEphemeralPrivateKey,
|
|
1689
|
+
},
|
|
1690
|
+
didFromPrivateKey: async () => owner.intent.did,
|
|
1691
|
+
})
|
|
1692
|
+
).resolves.toEqual({ privateKey: owner.seedHex, did: owner.intent.did });
|
|
1693
|
+
expect([...f.tenantHeaders]).toEqual(['vetpass']);
|
|
1694
|
+
});
|
|
1695
|
+
|
|
1696
|
+
it('keeps LearnCard unchanged: the authority tenant defaults to the app tenant', async () => {
|
|
1697
|
+
const f = await setup();
|
|
1698
|
+
await f.strategy.ensureEscrowEnrollment!(f.params);
|
|
1699
|
+
expect(f.intents.at(-1)?.intent.tenant).toBe('learncard');
|
|
1700
|
+
expect((await startHold(f)).expectations?.tenant).toBe('learncard');
|
|
1701
|
+
expect([...f.tenantHeaders]).toEqual(['learncard']);
|
|
1702
|
+
});
|
|
1703
|
+
|
|
1704
|
+
it('fails closed with a clear error when the app tenant is used against a learncard enclave', async () => {
|
|
1705
|
+
// Misconfiguration: VetPass without `authorityTenant` defaults to 'vetpass'.
|
|
1706
|
+
const f = await setup(false, { appTenant: 'vetpass' });
|
|
1707
|
+
const enrolling = f.strategy.ensureEscrowEnrollment!(f.params);
|
|
1708
|
+
await expect(enrolling).rejects.toBeInstanceOf(EscrowAuthorityTenantMismatchError);
|
|
1709
|
+
await expect(enrolling).rejects.toMatchObject({
|
|
1710
|
+
name: 'EscrowAuthorityTenantMismatchError',
|
|
1711
|
+
expectedTenant: 'vetpass',
|
|
1712
|
+
signedTenant: 'learncard',
|
|
1713
|
+
message: expect.stringContaining('escrowAuthorityTenant'),
|
|
1714
|
+
});
|
|
1715
|
+
expect(f.intents).toHaveLength(0);
|
|
1716
|
+
expect(f.calls).not.toContain('PUT:/keys/auth-share');
|
|
1717
|
+
await expect(startHold(f)).rejects.toBeInstanceOf(EscrowAuthorityTenantMismatchError);
|
|
1718
|
+
expect(f.calls).not.toContain('POST:/keys/escrow/confirm');
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
it('fails closed when the configured authority tenant differs from the enclave label', async () => {
|
|
1722
|
+
const f = await setup(false, { ...vetpass, enclaveTenant: 'scouts' });
|
|
1723
|
+
await expect(f.strategy.ensureEscrowEnrollment!(f.params)).rejects.toMatchObject({
|
|
1724
|
+
name: 'EscrowAuthorityTenantMismatchError',
|
|
1725
|
+
expectedTenant: 'learncard',
|
|
1726
|
+
signedTenant: 'scouts',
|
|
1727
|
+
});
|
|
1728
|
+
expect(f.intents).toHaveLength(0);
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
it('refuses persisted expectations bound to another authority tenant without network calls', async () => {
|
|
1732
|
+
const f = await setup(false, vetpass);
|
|
1733
|
+
const saved: EscrowRecoveryStart = JSON.parse(JSON.stringify(await startHold(f)));
|
|
1734
|
+
f.calls.length = 0;
|
|
1735
|
+
const misconfigured = createSSSStrategy({
|
|
1736
|
+
...f.config,
|
|
1737
|
+
escrow: { ...f.config.escrow!, authorityTenant: undefined },
|
|
1738
|
+
});
|
|
1739
|
+
await expect(
|
|
1740
|
+
misconfigured.confirmEscrowRecovery!({
|
|
1741
|
+
holdId: saved.holdId,
|
|
1742
|
+
resumeToken: saved.resumeToken!,
|
|
1743
|
+
expectations: saved.expectations,
|
|
1744
|
+
code: '123456',
|
|
1745
|
+
})
|
|
1746
|
+
).rejects.toMatchObject({ name: 'EscrowEvidenceError' });
|
|
1747
|
+
expect(f.calls).toHaveLength(0);
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
it('rejects an invalid authority tenant label at construction', () => {
|
|
1751
|
+
expect(() =>
|
|
1752
|
+
createSSSStrategy({
|
|
1753
|
+
serverUrl: 'https://example.test',
|
|
1754
|
+
tenantId: 'vetpass',
|
|
1755
|
+
escrow: {
|
|
1756
|
+
enabled: true,
|
|
1757
|
+
attestation: { mode: 'software', pinnedPublicKeys: [] },
|
|
1758
|
+
authorityTenant: 'learn card',
|
|
1759
|
+
},
|
|
1760
|
+
})
|
|
1761
|
+
).toThrow('Invalid escrow authority tenant');
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
it('shares one enrollment per DID across LearnCard and VetPass instead of colliding', async () => {
|
|
1765
|
+
// One Firebase user = one UserKey = one DID = one authority record
|
|
1766
|
+
// ('learncard', generation, DID), whichever app enrolled it.
|
|
1767
|
+
const f = await setup();
|
|
1768
|
+
await f.strategy.ensureEscrowEnrollment!({
|
|
1769
|
+
...f.params,
|
|
1770
|
+
options: { ...f.params.options, pin: '135790' },
|
|
1771
|
+
});
|
|
1772
|
+
const enrolledVersion = f.state().enrollmentVersion;
|
|
1773
|
+
const intents = f.intents.length;
|
|
1774
|
+
const vetpassStrategy = createSSSStrategy({
|
|
1775
|
+
...f.config,
|
|
1776
|
+
tenantId: 'vetpass',
|
|
1777
|
+
escrow: { ...f.config.escrow!, authorityTenant: 'learncard' },
|
|
1778
|
+
});
|
|
1779
|
+
// VetPass sees the LearnCard enrollment as current and signs nothing.
|
|
1780
|
+
await expect(vetpassStrategy.ensureEscrowEnrollment!(f.params)).resolves.toMatchObject({
|
|
1781
|
+
enrolled: true,
|
|
1782
|
+
changed: false,
|
|
1783
|
+
});
|
|
1784
|
+
expect(f.intents).toHaveLength(intents);
|
|
1785
|
+
expect(f.state().enrollmentVersion).toBe(enrolledVersion);
|
|
1786
|
+
// The PIN set from LearnCard signs in from VetPass, on the same record.
|
|
1787
|
+
f.faults.wrongPinAttempts = 0;
|
|
1788
|
+
await expect(
|
|
1789
|
+
vetpassStrategy.executeRecovery({
|
|
1790
|
+
token: 'token',
|
|
1791
|
+
providerType: 'firebase',
|
|
1792
|
+
input: { method: 'escrow-pin', pin: '135790', expectedDid: owner.intent.did },
|
|
1793
|
+
didFromPrivateKey: async () => owner.intent.did,
|
|
1794
|
+
signDidAuthVp: f.params.signDidAuthVp,
|
|
1795
|
+
options: f.params.options,
|
|
1796
|
+
})
|
|
1797
|
+
).resolves.toMatchObject({ escrowRecovery: true, pinSignIn: { mode: 'kept' } });
|
|
1798
|
+
expect(f.intents.every(signed => signed.intent.tenant === 'learncard')).toBe(true);
|
|
1799
|
+
expect(f.tenantHeaders).toEqual(new Set(['learncard', 'vetpass']));
|
|
1800
|
+
});
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
// D21: the owner-sealed email brand comes from one strategy config value, so
|
|
1804
|
+
// every enrollment path that seals the recovery contact also seals the brand,
|
|
1805
|
+
// and no path that carries (or seals no contact) ever sends one.
|
|
1806
|
+
describe('Owner-sealed escrow email brand (D21)', () => {
|
|
1807
|
+
const vetpass = {
|
|
1808
|
+
appTenant: 'vetpass',
|
|
1809
|
+
authorityTenant: 'learncard',
|
|
1810
|
+
emailBrand: ' Vet Pass ',
|
|
1811
|
+
} as const;
|
|
1812
|
+
const pinEnroll = (f: Awaited<ReturnType<typeof setup>>) =>
|
|
1813
|
+
f.strategy.ensureEscrowEnrollment!({
|
|
1814
|
+
...f.params,
|
|
1815
|
+
options: { ...f.params.options, pin: '135790' },
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
it('rejects an invalid email brand at construction', () => {
|
|
1819
|
+
for (const emailBrand of ['', 'vetpass.com', 'Vet\nPass', 'Call 5551234', 'a'.repeat(33)])
|
|
1820
|
+
expect(() =>
|
|
1821
|
+
createSSSStrategy({
|
|
1822
|
+
serverUrl: 'https://example.test',
|
|
1823
|
+
tenantId: 'vetpass',
|
|
1824
|
+
escrow: {
|
|
1825
|
+
enabled: true,
|
|
1826
|
+
attestation: { mode: 'software', pinnedPublicKeys: [] },
|
|
1827
|
+
emailBrand,
|
|
1828
|
+
},
|
|
1829
|
+
})
|
|
1830
|
+
).toThrow('Invalid escrow email brand');
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
it('seals the canonical brand with the contact on first enroll, PIN change and clear', async () => {
|
|
1834
|
+
const f = await setup(false, vetpass);
|
|
1835
|
+
await pinEnroll(f);
|
|
1836
|
+
expect(f.enrollments.at(-1)).toMatchObject({
|
|
1837
|
+
action: 'enroll',
|
|
1838
|
+
recoveryEmail: 'owner@example.com',
|
|
1839
|
+
recoveryBrand: 'Vet Pass',
|
|
1840
|
+
});
|
|
1841
|
+
await f.strategy.setEscrowPin!({ ...f.params, pin: '824957' });
|
|
1842
|
+
expect(f.enrollments.at(-1)).toMatchObject({
|
|
1843
|
+
action: 'replace',
|
|
1844
|
+
recoveryBrand: 'Vet Pass',
|
|
1845
|
+
});
|
|
1846
|
+
await f.strategy.clearEscrowPin!(f.params);
|
|
1847
|
+
expect(f.enrollments.at(-1)).toMatchObject({
|
|
1848
|
+
action: 'replace',
|
|
1849
|
+
recoveryBrand: 'Vet Pass',
|
|
1850
|
+
});
|
|
1851
|
+
// The brand is not part of the signed intent (bound via the target identity).
|
|
1852
|
+
expect(f.intents.at(-1)!.intent).not.toHaveProperty('recoveryBrand');
|
|
1853
|
+
expect((await f.sealed())?.recoveryBrand).toBe('Vet Pass');
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1856
|
+
it('never sends the brand on a carry; the authority inherits the sealed one', async () => {
|
|
1857
|
+
const f = await setup(false, vetpass);
|
|
1858
|
+
await f.strategy.setEscrowPin!({ ...f.params, pin: '593827' });
|
|
1859
|
+
f.advance();
|
|
1860
|
+
await f.strategy.ensureEscrowEnrollment!(f.params);
|
|
1861
|
+
expect(f.intents.at(-1)?.intent).toMatchObject({ action: 'carry', pinAction: 'keep' });
|
|
1862
|
+
expect(f.enrollments.at(-1)).toMatchObject({ action: 'carry' });
|
|
1863
|
+
expect(f.enrollments.at(-1)!.recoveryBrand).toBeUndefined();
|
|
1864
|
+
expect(f.enrollments.at(-1)!.recoveryEmail).toBeUndefined();
|
|
1865
|
+
expect((await f.sealed())?.recoveryBrand).toBe('Vet Pass');
|
|
1866
|
+
});
|
|
1867
|
+
|
|
1868
|
+
it('seals the brand on the PIN sign-in keep/refresh re-set', async () => {
|
|
1869
|
+
const f = await setup(false, vetpass);
|
|
1870
|
+
await pinEnroll(f);
|
|
1871
|
+
f.spend();
|
|
1872
|
+
f.faults.wrongPinAttempts = 0;
|
|
1873
|
+
const before = f.enrollments.length;
|
|
1874
|
+
await expect(
|
|
1875
|
+
f.strategy.executeRecovery({
|
|
1876
|
+
token: 'token',
|
|
1877
|
+
providerType: 'firebase',
|
|
1878
|
+
input: { method: 'escrow-pin', pin: '135790', expectedDid: owner.intent.did },
|
|
1879
|
+
didFromPrivateKey: async () => owner.intent.did,
|
|
1880
|
+
signDidAuthVp: f.params.signDidAuthVp,
|
|
1881
|
+
options: f.params.options,
|
|
1882
|
+
})
|
|
1883
|
+
).resolves.toMatchObject({ pinSignIn: { mode: 'kept', pinRefreshed: true } });
|
|
1884
|
+
expect(f.enrollments.length).toBeGreaterThan(before);
|
|
1885
|
+
expect(f.enrollments.at(-1)).toMatchObject({
|
|
1886
|
+
action: 'replace',
|
|
1887
|
+
recoveryBrand: 'Vet Pass',
|
|
1888
|
+
});
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
it('seals the brand on the post-recovery rebind enrollment', async () => {
|
|
1892
|
+
const f = await setup(false, vetpass);
|
|
1893
|
+
const hold = await f.strategy.startEscrowRecovery!({
|
|
1894
|
+
recoverySessionToken: 'otp-session',
|
|
1895
|
+
expectedDid: owner.intent.did,
|
|
1896
|
+
});
|
|
1897
|
+
await f.strategy.executeRecovery({
|
|
1898
|
+
token: '',
|
|
1899
|
+
providerType: 'firebase',
|
|
1900
|
+
input: { method: 'escrow', ...hold, resumeToken: hold.resumeToken! },
|
|
1901
|
+
didFromPrivateKey: async () => owner.intent.did,
|
|
1902
|
+
});
|
|
1903
|
+
await f.strategy.completeIdentityRecovery!(f.params);
|
|
1904
|
+
expect(f.enrollments.at(-1)).toMatchObject({
|
|
1905
|
+
action: 'enroll',
|
|
1906
|
+
recoveryBrand: 'Vet Pass',
|
|
1907
|
+
});
|
|
1908
|
+
});
|
|
1909
|
+
|
|
1910
|
+
it('seals no brand when the config leaves it unset', async () => {
|
|
1911
|
+
const f = await setup(false, { appTenant: 'vetpass', authorityTenant: 'learncard' });
|
|
1912
|
+
await pinEnroll(f);
|
|
1913
|
+
await f.strategy.setEscrowPin!({ ...f.params, pin: '824957' });
|
|
1914
|
+
expect(f.enrollments.length).toBeGreaterThan(0);
|
|
1915
|
+
expect(f.enrollments.every(e => e.recoveryBrand === undefined)).toBe(true);
|
|
1916
|
+
expect(await f.sealed()).not.toHaveProperty('recoveryBrand');
|
|
1917
|
+
});
|
|
1918
|
+
});
|