@learncard/sss-key-manager 0.2.0 → 0.2.1

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.
Files changed (41) hide show
  1. package/README.md +13 -0
  2. package/dist/escrow-attestation.d.ts +5 -1
  3. package/dist/escrow-attestation.d.ts.map +1 -1
  4. package/dist/escrow-nitro-attestation.d.ts +32 -0
  5. package/dist/escrow-nitro-attestation.d.ts.map +1 -0
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/sss-key-manager.cjs.development.js +2995 -320
  9. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  10. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  11. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  12. package/dist/sss-key-manager.esm.js +774 -170
  13. package/dist/sss-key-manager.esm.js.map +4 -4
  14. package/dist/sss-strategy.d.ts.map +1 -1
  15. package/dist/types.d.ts +15 -4
  16. package/dist/types.d.ts.map +1 -1
  17. package/package.json +7 -3
  18. package/src/__fixtures__/escrow-vectors-rust.json +82 -0
  19. package/src/__fixtures__/escrow-vectors.json +103 -0
  20. package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
  21. package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
  22. package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
  23. package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
  24. package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
  25. package/src/__fixtures__/nitro-attestation/root.pem +12 -0
  26. package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
  27. package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
  28. package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
  29. package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
  30. package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
  31. package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
  32. package/src/escrow-attestation.test.ts +7 -2
  33. package/src/escrow-attestation.ts +71 -10
  34. package/src/escrow-nitro-attestation.test.ts +204 -0
  35. package/src/escrow-nitro-attestation.ts +286 -0
  36. package/src/escrow-vectors.test.ts +198 -0
  37. package/src/index.ts +8 -1
  38. package/src/sss-session-writes.test.ts +2 -2
  39. package/src/sss-strategy.test.ts +95 -2
  40. package/src/sss-strategy.ts +128 -88
  41. package/src/types.ts +12 -1
@@ -0,0 +1,198 @@
1
+ // @vitest-environment node
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { expect, it } from 'vitest';
5
+ import { base64ToBuffer } from './crypto';
6
+
7
+ import {
8
+ decryptEscrowBlob,
9
+ ESCROW_BLOB_INFO,
10
+ ESCROW_RELEASE_INFO,
11
+ encryptEscrowBlob,
12
+ generateEscrowKeyPair,
13
+ openEscrowRelease,
14
+ sealEscrowRelease,
15
+ } from './escrow-crypto';
16
+ import type { EscrowBlobPlaintext, EscrowEnvelope, EscrowReleasePlaintext } from './escrow-crypto';
17
+
18
+ type KeyPair = Awaited<ReturnType<typeof generateEscrowKeyPair>>;
19
+ interface Vectors {
20
+ // Public test-only keys, never used for real recovery data.
21
+ enclave: KeyPair;
22
+ client: KeyPair;
23
+ blobs: { plaintext: EscrowBlobPlaintext; envelope: EscrowEnvelope }[];
24
+ releases: { plaintext: EscrowReleasePlaintext; envelope: EscrowEnvelope }[];
25
+ }
26
+
27
+ it('decrypts shared TS/Rust escrow vectors (ESCROW_WRITE_VECTORS=1 regenerates)', async () => {
28
+ const directory = fileURLToPath(new URL('./__fixtures__/', import.meta.url));
29
+ const path = fileURLToPath(new URL('./__fixtures__/escrow-vectors.json', import.meta.url));
30
+
31
+ if (process.env.ESCROW_WRITE_VECTORS === '1') {
32
+ const enclave = await generateEscrowKeyPair();
33
+ const client = await generateEscrowKeyPair();
34
+ const inputs = [
35
+ { recoveryShare: 'ABCDE', did: 'did:example:legacy', shareVersion: 1 },
36
+ {
37
+ recoveryShare: `0001${'AB'.repeat(48)}`,
38
+ did: 'did:example:pin',
39
+ shareVersion: 42,
40
+ pinVerifier: 'CD'.repeat(32),
41
+ },
42
+ {
43
+ recoveryShare: 'ef'.repeat(2048),
44
+ did: `did:${'x'.repeat(2042)}😀`,
45
+ shareVersion: 9_007_199_254_740_992,
46
+ },
47
+ ];
48
+ const blobs = await Promise.all(
49
+ inputs.map(async (input, index) => {
50
+ const envelope = await encryptEscrowBlob(
51
+ input,
52
+ enclave.publicKey,
53
+ `test-key.${index}`
54
+ );
55
+ return {
56
+ plaintext: await decryptEscrowBlob(envelope, enclave.privateKey),
57
+ envelope,
58
+ };
59
+ })
60
+ );
61
+ const releases = await Promise.all(
62
+ inputs.slice(0, 2).map(async (input, index) => {
63
+ const envelope = await sealEscrowRelease(
64
+ { ...input, holdId: `hold-${index}.test_1` },
65
+ client.publicKey
66
+ );
67
+ return {
68
+ plaintext: await openEscrowRelease(envelope, client.privateKey),
69
+ envelope,
70
+ };
71
+ })
72
+ );
73
+ const vectors: Vectors = { enclave, client, blobs, releases };
74
+ await mkdir(directory, { recursive: true });
75
+ await writeFile(path, `${JSON.stringify(vectors, null, 4)}\n`);
76
+ }
77
+
78
+ const vectors: Vectors = JSON.parse(await readFile(path, 'utf8'));
79
+ expect(vectors.blobs).toHaveLength(3);
80
+ expect(vectors.releases).toHaveLength(2);
81
+ for (const { plaintext, envelope } of vectors.blobs) {
82
+ await expect(decryptEscrowBlob(envelope, vectors.enclave.privateKey)).resolves.toEqual(
83
+ plaintext
84
+ );
85
+ }
86
+ for (const { plaintext, envelope } of vectors.releases) {
87
+ await expect(openEscrowRelease(envelope, vectors.client.privateKey)).resolves.toEqual(
88
+ plaintext
89
+ );
90
+ }
91
+ });
92
+
93
+ // Independent WebCrypto decryption exposes raw JSON before the production validator
94
+ // normalizes it, so a numeric 1.0 cannot silently satisfy the integral-token assertion.
95
+ const decryptRawJson = async (
96
+ envelope: EscrowEnvelope,
97
+ privateKey: string,
98
+ info: string
99
+ ): Promise<string> => {
100
+ const recipient = await crypto.subtle.importKey(
101
+ 'pkcs8',
102
+ base64ToBuffer(privateKey),
103
+ { name: 'ECDH', namedCurve: 'P-256' },
104
+ false,
105
+ ['deriveBits']
106
+ );
107
+ const ephemeral = await crypto.subtle.importKey(
108
+ 'raw',
109
+ base64ToBuffer(envelope.ephemeralPublicKey),
110
+ { name: 'ECDH', namedCurve: 'P-256' },
111
+ false,
112
+ []
113
+ );
114
+ const shared = await crypto.subtle.deriveBits(
115
+ { name: 'ECDH', public: ephemeral },
116
+ recipient,
117
+ 256
118
+ );
119
+ const material = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
120
+ const key = await crypto.subtle.deriveKey(
121
+ {
122
+ name: 'HKDF',
123
+ hash: 'SHA-256',
124
+ salt: base64ToBuffer(envelope.salt),
125
+ info: new TextEncoder().encode(info),
126
+ },
127
+ material,
128
+ { name: 'AES-GCM', length: 256 },
129
+ false,
130
+ ['decrypt']
131
+ );
132
+ const plaintext = await crypto.subtle.decrypt(
133
+ {
134
+ name: 'AES-GCM',
135
+ iv: base64ToBuffer(envelope.iv),
136
+ additionalData: new TextEncoder().encode(
137
+ `${envelope.version}|${envelope.algorithm}|${envelope.keyId}`
138
+ ),
139
+ },
140
+ key,
141
+ base64ToBuffer(envelope.ciphertext)
142
+ );
143
+ return new TextDecoder().decode(plaintext);
144
+ };
145
+
146
+ it('opens Rust blobs and releases in WebCrypto and emits integral JSON share versions', async () => {
147
+ const ts: Vectors = JSON.parse(
148
+ await readFile(
149
+ fileURLToPath(new URL('./__fixtures__/escrow-vectors.json', import.meta.url)),
150
+ 'utf8'
151
+ )
152
+ );
153
+ const rust: Omit<Vectors, 'client'> = JSON.parse(
154
+ await readFile(
155
+ fileURLToPath(new URL('./__fixtures__/escrow-vectors-rust.json', import.meta.url)),
156
+ 'utf8'
157
+ )
158
+ );
159
+ expect(rust.blobs).toHaveLength(2);
160
+ expect(rust.releases).toHaveLength(2);
161
+
162
+ // Import Rust SPKI as well as PKCS#8, and verify the two exports describe one key.
163
+ const publicKey = await crypto.subtle.importKey(
164
+ 'spki',
165
+ base64ToBuffer(rust.enclave.publicKey),
166
+ { name: 'ECDH', namedCurve: 'P-256' },
167
+ true,
168
+ []
169
+ );
170
+ const privateKey = await crypto.subtle.importKey(
171
+ 'pkcs8',
172
+ base64ToBuffer(rust.enclave.privateKey),
173
+ { name: 'ECDH', namedCurve: 'P-256' },
174
+ true,
175
+ ['deriveBits']
176
+ );
177
+ const publicJwk = await crypto.subtle.exportKey('jwk', publicKey);
178
+ expect(await crypto.subtle.exportKey('jwk', privateKey)).toMatchObject({
179
+ crv: 'P-256',
180
+ x: publicJwk.x,
181
+ y: publicJwk.y,
182
+ });
183
+
184
+ for (const { plaintext, envelope } of rust.blobs) {
185
+ await expect(decryptEscrowBlob(envelope, rust.enclave.privateKey)).resolves.toEqual(
186
+ plaintext
187
+ );
188
+ const raw = await decryptRawJson(envelope, rust.enclave.privateKey, ESCROW_BLOB_INFO);
189
+ expect(raw).toMatch(new RegExp(`"shareVersion":${plaintext.shareVersion}[,}]`));
190
+ expect(JSON.parse(raw)).toEqual(plaintext);
191
+ }
192
+ for (const { plaintext, envelope } of rust.releases) {
193
+ await expect(openEscrowRelease(envelope, ts.client.privateKey)).resolves.toEqual(plaintext);
194
+ const raw = await decryptRawJson(envelope, ts.client.privateKey, ESCROW_RELEASE_INFO);
195
+ expect(raw).toMatch(new RegExp(`"shareVersion":${plaintext.shareVersion}[,}]`));
196
+ expect(JSON.parse(raw)).toEqual(plaintext);
197
+ }
198
+ });
package/src/index.ts CHANGED
@@ -77,8 +77,15 @@ export {
77
77
 
78
78
  export type { EscrowEnvelope, EscrowBlobPlaintext, EscrowReleasePlaintext } from './escrow-crypto';
79
79
  export { verifyEnclaveAttestation } from './escrow-attestation';
80
+ export { verifyNitroAttestationDocument, NitroAttestationError } from './escrow-nitro-attestation';
81
+ export type { NitroAttestationReason } from './escrow-nitro-attestation';
80
82
  export type { EscrowAttestationPolicy, EscrowHoldStatus, EscrowRecoveryStart } from './types';
81
- export type { EscrowEnrollmentOptions, EscrowEnrollmentState, EscrowPinStatus } from './types';
83
+ export type {
84
+ EscrowEnrollmentOptions,
85
+ EscrowEnrollmentState,
86
+ EscrowBlobStaleReason,
87
+ EscrowPinStatus,
88
+ } from './types';
82
89
  export {
83
90
  EscrowPinMismatchError,
84
91
  EscrowPinLockedError,
@@ -61,7 +61,7 @@ describe('session-scoped enrollment writes', () => {
61
61
  vi.stubGlobal(
62
62
  'fetch',
63
63
  vi.fn(async (url: string) => {
64
- if (url.endsWith('/attestation')) {
64
+ if (url.includes('/attestation?nonce=')) {
65
65
  started.resolve();
66
66
  return attestation.promise;
67
67
  }
@@ -91,7 +91,7 @@ describe('session-scoped enrollment writes', () => {
91
91
  return authWrite.promise;
92
92
  }
93
93
  if (url.endsWith('/challenge')) return response({ challenge: 'nonce' });
94
- if (url.endsWith('/attestation')) return response({ attestation: {} });
94
+ if (url.includes('/attestation?nonce=')) return response({ attestation: {} });
95
95
  return response(keyStatus);
96
96
  })
97
97
  );
@@ -160,6 +160,7 @@ describe('escrow strategy', () => {
160
160
  let releasePolicy: 'hold' | 'pin';
161
161
  let consumed: boolean;
162
162
  let burned: string[];
163
+ let escrowStale: 'mode-mismatch' | 'key-rotated' | undefined;
163
164
  const hold = () => ({
164
165
  holdId: holdNumber <= 1 ? 'escrow-hold' : `escrow-hold-${holdNumber}`,
165
166
  status: cancelled ? 'cancelled' : 'pending',
@@ -190,6 +191,7 @@ describe('escrow strategy', () => {
190
191
  releasePolicy = 'hold';
191
192
  consumed = false;
192
193
  burned = [];
194
+ escrowStale = undefined;
193
195
  config = {
194
196
  serverUrl: 'https://test.example/api',
195
197
  storage,
@@ -225,6 +227,7 @@ describe('escrow strategy', () => {
225
227
  attemptsRemaining,
226
228
  ...(pinSalt ? { salt: pinSalt } : {}),
227
229
  },
230
+ ...(escrowStale ? { escrowStale } : {}),
228
231
  });
229
232
  }
230
233
  if (path === '/keys/recovery' || path === '/keys/recovery/confirm')
@@ -266,6 +269,7 @@ describe('escrow strategy', () => {
266
269
  blob = await decryptEscrowBlob(body.envelope, enclaveKeys.privateKey);
267
270
  pinSalt = body.pinSalt;
268
271
  attemptsRemaining = 10;
272
+ escrowStale = undefined;
269
273
  expect(blob.did).toBe(did);
270
274
  expect(blob.shareVersion).toBe(version);
271
275
  expect(
@@ -480,7 +484,7 @@ describe('escrow strategy', () => {
480
484
  });
481
485
  methods[0].shareVersion = 1;
482
486
  expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
483
- state: 'not-enrolled',
487
+ state: 'stale',
484
488
  });
485
489
  methods[0].shareVersion = version;
486
490
  methods[0].confirmedAt = undefined;
@@ -543,7 +547,7 @@ describe('escrow strategy', () => {
543
547
  await strategy.setupRecoveryMethod!({ ...params, input: { method: 'phrase' } });
544
548
  expect(config.onEscrowError).toHaveBeenCalledWith(
545
549
  expect.objectContaining({
546
- message: 'Nitro attestation verification is not implemented yet',
550
+ message: 'Escrow attestation mode mismatch',
547
551
  })
548
552
  );
549
553
  });
@@ -571,6 +575,77 @@ describe('escrow strategy', () => {
571
575
  });
572
576
  expect(calls).toHaveLength(0);
573
577
  });
578
+ it('getEscrowEnrollmentState reports stale with a reason when the server flags the blob', async () => {
579
+ await strategy.ensureEscrowEnrollment!(params);
580
+ escrowStale = 'mode-mismatch';
581
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
582
+ state: 'stale',
583
+ staleReason: 'mode-mismatch',
584
+ });
585
+ escrowStale = 'key-rotated';
586
+ expect(await strategy.getEscrowEnrollmentState!(params)).toMatchObject({
587
+ state: 'stale',
588
+ staleReason: 'key-rotated',
589
+ });
590
+ });
591
+ it('does not trigger the stale-repair path when the server reports no staleness', async () => {
592
+ await strategy.ensureEscrowEnrollment!(params);
593
+ calls = [];
594
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
595
+ enrolled: true,
596
+ changed: false,
597
+ });
598
+ expect(calls.some(call => call.path === '/keys/escrow/attestation')).toBe(false);
599
+ });
600
+ it('rotates a stale enrollment through the normal rotation path, at most once per session', async () => {
601
+ await strategy.ensureEscrowEnrollment!(params);
602
+ escrowStale = 'mode-mismatch';
603
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
604
+ enrolled: true,
605
+ changed: true,
606
+ shareVersion: 3,
607
+ });
608
+ expect(version).toBe(3);
609
+ expect(blob?.shareVersion).toBe(3);
610
+ // A real re-enroll clears server-side staleness (the mock's '/keys/escrow'
611
+ // handler already reset it); force it stale again so this assertion proves
612
+ // the session-scoped rate limit — not the mock's own healing — stops the retry.
613
+ escrowStale = 'mode-mismatch';
614
+ calls = [];
615
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
616
+ enrolled: true,
617
+ changed: false,
618
+ });
619
+ expect(calls.some(call => call.path === '/keys/escrow/attestation')).toBe(false);
620
+ expect(version).toBe(3);
621
+ // A fresh strategy instance (the next app session) attempts the repair again.
622
+ const nextSession = createSSSStrategy(config);
623
+ await expect(nextSession.ensureEscrowEnrollment!(params)).resolves.toEqual({
624
+ enrolled: true,
625
+ changed: true,
626
+ shareVersion: 4,
627
+ });
628
+ expect(version).toBe(4);
629
+ });
630
+ it('swallows a failed automatic stale-blob repair instead of throwing to the caller', async () => {
631
+ await strategy.ensureEscrowEnrollment!(params);
632
+ escrowStale = 'key-rotated';
633
+ attestationFailure = true;
634
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
635
+ enrolled: true,
636
+ changed: false,
637
+ });
638
+ expect(version).toBe(2);
639
+ expect(config.onEscrowError).toHaveBeenCalledOnce();
640
+ vi.mocked(config.onEscrowError!).mockClear();
641
+ // Still rate-limited to one attempt this session, even though it failed.
642
+ await expect(strategy.ensureEscrowEnrollment!(params)).resolves.toEqual({
643
+ enrolled: true,
644
+ changed: false,
645
+ });
646
+ expect(config.onEscrowError).not.toHaveBeenCalled();
647
+ expect(version).toBe(2);
648
+ });
574
649
  it('requires DID verification before attempting PIN recovery', async () => {
575
650
  await expect(
576
651
  strategy.executeRecovery({
@@ -585,6 +660,24 @@ describe('escrow strategy', () => {
585
660
  expect(storage.clearAllShares).not.toHaveBeenCalled();
586
661
  expect(strategy.hasPendingIdentityRecovery!()).toBe(false);
587
662
  });
663
+ it.each(['set', 'clear'] as const)(
664
+ 'rejects explicit %s PIN on a stale account when the enclave is unreachable',
665
+ async action => {
666
+ await strategy.ensureEscrowEnrollment!(params);
667
+ escrowStale = 'key-rotated';
668
+ attestationFailure = true;
669
+ await expect(
670
+ action === 'set'
671
+ ? strategy.setEscrowPin!({ ...params, pin: '135790' })
672
+ : strategy.clearEscrowPin!(params)
673
+ ).rejects.toThrow();
674
+ expect(version).toBe(2);
675
+ expect(config.onEscrowError).not.toHaveBeenCalled();
676
+ // Explicit actions must not consume the automatic repair attempt.
677
+ await strategy.ensureEscrowEnrollment!(params);
678
+ expect(config.onEscrowError).toHaveBeenCalledOnce();
679
+ }
680
+ );
588
681
  const recoverPin = (pin = '135790') =>
589
682
  strategy.executeRecovery({
590
683
  ...params,
@@ -1054,6 +1054,10 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1054
1054
  } | null = null;
1055
1055
 
1056
1056
  let escrowEnrollmentInFlight: Promise<EscrowEnrollmentResult> | undefined;
1057
+ // A stale-blob repair is attempted at most once per strategy instance
1058
+ // (i.e. once per app session); a failed attempt is logged and left for
1059
+ // the next session rather than retried on every subsequent call.
1060
+ let staleEscrowReenrollAttempted = false;
1057
1061
 
1058
1062
  // Material from a rotation whose escrow POST failed. Retained in memory so a
1059
1063
  // retry within this session re-posts instead of burning another share version.
@@ -1143,11 +1147,16 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1143
1147
 
1144
1148
  const fetchVerifiedEnclaveKey = async (): Promise<{ publicKey: string; keyId: string }> => {
1145
1149
  if (!config.escrow?.enabled) throw new Error('Escrow enrollment is disabled');
1146
- const { attestation } = await escrowRequest<{ attestation: unknown }>('/attestation', {
1147
- method: 'GET',
1148
- headers: buildHeaders('', undefined, tenantId),
1149
- });
1150
- return verifyEnclaveAttestation(attestation, config.escrow.attestation);
1150
+ const nonce = crypto.getRandomValues(new Uint8Array(32));
1151
+ const nonceHex = Array.from(nonce, byte => byte.toString(16).padStart(2, '0')).join('');
1152
+ const { attestation } = await escrowRequest<{ attestation: unknown }>(
1153
+ `/attestation?nonce=${nonceHex}`,
1154
+ {
1155
+ method: 'GET',
1156
+ headers: buildHeaders('', undefined, tenantId),
1157
+ }
1158
+ );
1159
+ return verifyEnclaveAttestation(attestation, config.escrow.attestation, nonce);
1151
1160
  };
1152
1161
 
1153
1162
  const enrollEscrow = async (
@@ -1231,9 +1240,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1231
1240
  }
1232
1241
  const status = await self.fetchServerKeyStatus(params.token, params.providerType);
1233
1242
  if (status.escrowOptedOut) return { enrolled: false, reason: 'opted-out' };
1234
- if (
1235
- !forceRotate &&
1236
- pin === undefined &&
1243
+ const currentlyEnrolled =
1237
1244
  status.shareVersion !== null &&
1238
1245
  status.recoveryMethods.some(
1239
1246
  method =>
@@ -1242,87 +1249,113 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1242
1249
  (Boolean(method.confirmedAt) ||
1243
1250
  ('confirmationStatus' in method &&
1244
1251
  method.confirmationStatus === 'confirmed'))
1245
- )
1246
- )
1252
+ );
1253
+ // A confirmed enrollment whose sealed blob no longer matches the active
1254
+ // enclave (P6 mode migration/rollback, or a rotated enclave key) still
1255
+ // needs the same rotation a missing enrollment would get — but only one
1256
+ // automatic attempt per session, so a still-unreachable enclave doesn't
1257
+ // retry on every ensureEscrowEnrollment call within the same session.
1258
+ const repairingStaleBlob =
1259
+ pin === undefined &&
1260
+ !forceRotate &&
1261
+ currentlyEnrolled &&
1262
+ !!status.escrowStale &&
1263
+ !staleEscrowReenrollAttempted;
1264
+ if (!forceRotate && pin === undefined && currentlyEnrolled && !repairingStaleBlob)
1247
1265
  // Automatic repair preserves current enrollment regardless of PIN status.
1248
1266
  // The enclave can carry an existing verifier when shares rotate.
1249
1267
  return { enrolled: true, changed: false };
1268
+ if (repairingStaleBlob) staleEscrowReenrollAttempted = true;
1250
1269
  if (!status.primaryDid) throw new Error('Cannot enroll escrow without a primary DID');
1251
1270
  if (!params.signDidAuthVp) {
1252
1271
  throw new Error('DID proof signing is required for escrow enrollment');
1253
1272
  }
1254
- if (
1255
- !forceRotate &&
1256
- pin === undefined &&
1257
- unenrolledRotation &&
1258
- unenrolledRotation.shareVersion === status.shareVersion &&
1259
- unenrolledRotation.primaryDid === status.primaryDid
1260
- ) {
1261
- const retry = unenrolledRotation;
1273
+ try {
1274
+ if (
1275
+ !forceRotate &&
1276
+ pin === undefined &&
1277
+ unenrolledRotation &&
1278
+ unenrolledRotation.shareVersion === status.shareVersion &&
1279
+ unenrolledRotation.primaryDid === status.primaryDid
1280
+ ) {
1281
+ const retry = unenrolledRotation;
1282
+ await enrollEscrow(
1283
+ params.token,
1284
+ params.providerType,
1285
+ params.privateKey,
1286
+ retry.primaryDid,
1287
+ retry.shares,
1288
+ retry.shareVersion,
1289
+ params.signDidAuthVp,
1290
+ undefined,
1291
+ undefined,
1292
+ retry.clearPin
1293
+ );
1294
+ unenrolledRotation = undefined;
1295
+ return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1296
+ }
1297
+ // Verify the enclave before rotating: a bad attestation must not burn
1298
+ // a share version (and, repeated, the retained auth-share history).
1299
+ const verifiedKey = await fetchVerifiedEnclaveKey();
1300
+ const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
1301
+ const pinMaterial =
1302
+ pin !== undefined && pinSalt !== undefined
1303
+ ? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
1304
+ : undefined;
1305
+ const storageId = activeStorageId;
1306
+ const primaryDid = status.primaryDid;
1307
+ const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
1308
+ persistSharesWithoutLock(
1309
+ params.privateKey,
1310
+ serverUrl,
1311
+ params.token,
1312
+ params.providerType,
1313
+ primaryDid,
1314
+ storage,
1315
+ storageId,
1316
+ undefined,
1317
+ params.signDidAuthVp,
1318
+ tenantId
1319
+ )
1320
+ );
1321
+ lastEmailShare = shares.emailShare;
1322
+ lastShareVersion = shareVersion;
1323
+ lastServerSnapshot = {
1324
+ currentVersion: shareVersion,
1325
+ resolvedVersion: shareVersion,
1326
+ authShare: shares.authShare,
1327
+ primaryDid: status.primaryDid,
1328
+ };
1329
+ unenrolledRotation = pinMaterial
1330
+ ? undefined
1331
+ : { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
1262
1332
  await enrollEscrow(
1263
1333
  params.token,
1264
1334
  params.providerType,
1265
1335
  params.privateKey,
1266
- retry.primaryDid,
1267
- retry.shares,
1268
- retry.shareVersion,
1336
+ status.primaryDid,
1337
+ shares,
1338
+ shareVersion,
1269
1339
  params.signDidAuthVp,
1270
- undefined,
1271
- undefined,
1272
- retry.clearPin
1340
+ verifiedKey,
1341
+ pinMaterial,
1342
+ forceRotate
1273
1343
  );
1274
1344
  unenrolledRotation = undefined;
1275
- return { enrolled: true, changed: true, shareVersion: retry.shareVersion };
1345
+ return { enrolled: true, changed: true, shareVersion };
1346
+ } catch (err) {
1347
+ // A background repair the caller never asked for must not surface as
1348
+ // a hard failure (mirrors tryEnrollEscrow's existing swallow-and-report
1349
+ // shape); an explicit not-yet-enrolled/PIN/forced rotation still throws.
1350
+ if (!repairingStaleBlob) throw err;
1351
+ try {
1352
+ if (config.onEscrowError) config.onEscrowError(err);
1353
+ else console.warn('SSS: escrow enrollment failed');
1354
+ } catch {
1355
+ console.warn('SSS: escrow error reporting failed');
1356
+ }
1357
+ return { enrolled: true, changed: false };
1276
1358
  }
1277
- // Verify the enclave before rotating: a bad attestation must not burn
1278
- // a share version (and, repeated, the retained auth-share history).
1279
- const verifiedKey = await fetchVerifiedEnclaveKey();
1280
- const pinSalt = pin !== undefined ? generatePinSalt() : undefined;
1281
- const pinMaterial =
1282
- pin !== undefined && pinSalt !== undefined
1283
- ? { pinSalt, pinVerifier: await derivePinProof(pin, pinSalt) }
1284
- : undefined;
1285
- const storageId = activeStorageId;
1286
- const primaryDid = status.primaryDid;
1287
- const { shares, shareVersion } = await withShareUpdateLock(storage, storageId, () =>
1288
- persistSharesWithoutLock(
1289
- params.privateKey,
1290
- serverUrl,
1291
- params.token,
1292
- params.providerType,
1293
- primaryDid,
1294
- storage,
1295
- storageId,
1296
- undefined,
1297
- params.signDidAuthVp,
1298
- tenantId
1299
- )
1300
- );
1301
- lastEmailShare = shares.emailShare;
1302
- lastShareVersion = shareVersion;
1303
- lastServerSnapshot = {
1304
- currentVersion: shareVersion,
1305
- resolvedVersion: shareVersion,
1306
- authShare: shares.authShare,
1307
- primaryDid: status.primaryDid,
1308
- };
1309
- unenrolledRotation = pinMaterial
1310
- ? undefined
1311
- : { shares, shareVersion, primaryDid: status.primaryDid, clearPin: forceRotate };
1312
- await enrollEscrow(
1313
- params.token,
1314
- params.providerType,
1315
- params.privateKey,
1316
- status.primaryDid,
1317
- shares,
1318
- shareVersion,
1319
- params.signDidAuthVp,
1320
- verifiedKey,
1321
- pinMaterial,
1322
- forceRotate
1323
- );
1324
- unenrolledRotation = undefined;
1325
- return { enrolled: true, changed: true, shareVersion };
1326
1359
  };
1327
1360
 
1328
1361
  const recordPromotion =
@@ -1740,6 +1773,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1740
1773
  maskedRecoveryEmail: data.maskedRecoveryEmail ?? null,
1741
1774
  escrowOptedOut: data.escrowOptedOut === true,
1742
1775
  escrowPin: data.escrowPin,
1776
+ escrowStale: data.escrowStale,
1743
1777
  sssActivationState: data.sssActivationState ?? 'active',
1744
1778
  };
1745
1779
  },
@@ -1810,19 +1844,25 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1810
1844
  if (!config.escrow?.enabled) return { state: 'disabled' };
1811
1845
  const status = await this.fetchServerKeyStatus(params.token, params.providerType);
1812
1846
  if (status.escrowOptedOut) return { state: 'opted-out', escrowPin: status.escrowPin };
1813
- const state =
1847
+ const confirmedEscrow = status.recoveryMethods.filter(
1848
+ method =>
1849
+ method.type === 'escrow' &&
1850
+ (Boolean(method.confirmedAt) ||
1851
+ ('confirmationStatus' in method &&
1852
+ method.confirmationStatus === 'confirmed'))
1853
+ );
1854
+ const enrolled =
1814
1855
  status.shareVersion !== null &&
1815
- status.recoveryMethods.some(
1816
- method =>
1817
- method.type === 'escrow' &&
1818
- method.shareVersion === status.shareVersion &&
1819
- (Boolean(method.confirmedAt) ||
1820
- ('confirmationStatus' in method &&
1821
- method.confirmationStatus === 'confirmed'))
1822
- )
1823
- ? 'enrolled'
1824
- : 'not-enrolled';
1825
- return { state, escrowPin: status.escrowPin };
1856
+ confirmedEscrow.some(method => method.shareVersion === status.shareVersion);
1857
+ // Previously enrolled accounts must bypass the new-enrollment rollout
1858
+ // even when a rotation succeeded but resealing the escrow blob failed.
1859
+ if (confirmedEscrow.length > 0 && (!enrolled || status.escrowStale))
1860
+ return {
1861
+ state: 'stale',
1862
+ escrowPin: status.escrowPin,
1863
+ staleReason: status.escrowStale,
1864
+ };
1865
+ return { state: enrolled ? 'enrolled' : 'not-enrolled', escrowPin: status.escrowPin };
1826
1866
  },
1827
1867
 
1828
1868
  async disableEscrowRecovery(params): Promise<void> {
@@ -1878,7 +1918,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1878
1918
  const result = await withRotationLock(() =>
1879
1919
  ensureEscrowEnrollmentUnguarded(this, { ...params, options: { pin } }, storage)
1880
1920
  );
1881
- if (!result.enrolled) {
1921
+ if (!result.enrolled || !result.changed) {
1882
1922
  throw new Error('Automatic recovery is not available for this account.');
1883
1923
  }
1884
1924
  },
@@ -1888,7 +1928,7 @@ export function createSSSStrategy(config: SSSStrategyConfig): SSSKeyDerivationSt
1888
1928
  const result = await withRotationLock(() =>
1889
1929
  ensureEscrowEnrollmentUnguarded(this, params, storage, true)
1890
1930
  );
1891
- if (!result.enrolled) {
1931
+ if (!result.enrolled || !result.changed) {
1892
1932
  throw new Error('Automatic recovery is not available for this account.');
1893
1933
  }
1894
1934
  },