@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.
@@ -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, /^[A-Za-z0-9_-]{1,128}$/));
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 { EscrowEvidenceError } from './escrow-authority.types';
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 { escrowAuthorityMeasurements, type EscrowHoldRecordLike } from './escrow-authority';
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 (holdRecord: {
40
- hold: {
41
- holdId: string;
42
- did: string;
43
- shareVersion: number;
44
- blobHash: string;
45
- enrollmentEpoch: number;
46
- releasePolicy: string;
47
- clientEphemeralPublicKey: string;
48
- createdLo: number;
49
- createdHi: number;
50
- policyVersion: number;
51
- };
52
- holdDurationMs: number;
53
- ledgerSeq: number;
54
- }): Promise<number[]> => {
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
- 'learncard',
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
- const setup = async (legacyEnrolled = false) => {
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: 'learncard',
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<{ pinSalt?: unknown; clearPin?: unknown; pinVerifier?: string }> = [];
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: 'learncard',
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
- if (signed.intent.action === 'carry')
368
- plaintext.pinVerifier = (
369
- await decryptEscrowBlob(state.envelope!, keys.privateKey)
370
- ).pinVerifier;
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: 'learncard',
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: 'learncard',
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: 'learncard',
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
+ });