@learncard/sss-key-manager 0.1.23 → 0.2.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.
Files changed (56) hide show
  1. package/README.md +12 -95
  2. package/dist/api-client.d.ts +44 -7
  3. package/dist/api-client.d.ts.map +1 -1
  4. package/dist/atomic-operations.d.ts +10 -2
  5. package/dist/atomic-operations.d.ts.map +1 -1
  6. package/dist/email-relay-crypto.d.ts +49 -0
  7. package/dist/email-relay-crypto.d.ts.map +1 -0
  8. package/dist/escrow-attestation.d.ts +8 -0
  9. package/dist/escrow-attestation.d.ts.map +1 -0
  10. package/dist/escrow-crypto.d.ts +52 -0
  11. package/dist/escrow-crypto.d.ts.map +1 -0
  12. package/dist/escrow-pin.d.ts +22 -0
  13. package/dist/escrow-pin.d.ts.map +1 -0
  14. package/dist/index.d.ts +13 -4
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/key-manager.d.ts +2 -0
  17. package/dist/key-manager.d.ts.map +1 -1
  18. package/dist/qr-login.d.ts.map +1 -1
  19. package/dist/recovery-phrase.d.ts +6 -0
  20. package/dist/recovery-phrase.d.ts.map +1 -1
  21. package/dist/sss-key-manager.cjs.development.js +3163 -440
  22. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  23. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  24. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  25. package/dist/sss-key-manager.esm.js +3163 -440
  26. package/dist/sss-key-manager.esm.js.map +4 -4
  27. package/dist/sss-strategy.d.ts +74 -4
  28. package/dist/sss-strategy.d.ts.map +1 -1
  29. package/dist/storage.d.ts +1 -0
  30. package/dist/storage.d.ts.map +1 -1
  31. package/dist/types.d.ts +87 -4
  32. package/dist/types.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/api-client.test.ts +122 -0
  35. package/src/api-client.ts +107 -116
  36. package/src/atomic-operations.test.ts +56 -16
  37. package/src/atomic-operations.ts +55 -19
  38. package/src/critical-paths.test.ts +51 -1
  39. package/src/email-relay-crypto.test.ts +87 -0
  40. package/src/email-relay-crypto.ts +293 -0
  41. package/src/escrow-attestation.test.ts +85 -0
  42. package/src/escrow-attestation.ts +64 -0
  43. package/src/escrow-crypto.test.ts +381 -0
  44. package/src/escrow-crypto.ts +319 -0
  45. package/src/escrow-pin.test.ts +119 -0
  46. package/src/escrow-pin.ts +83 -0
  47. package/src/index.ts +63 -1
  48. package/src/key-manager.ts +4 -9
  49. package/src/qr-login.ts +22 -15
  50. package/src/recovery-phrase.ts +40 -0
  51. package/src/sss-session-writes.test.ts +109 -0
  52. package/src/sss-strategy.test.ts +2642 -110
  53. package/src/sss-strategy.ts +2186 -224
  54. package/src/storage.test.ts +66 -8
  55. package/src/storage.ts +41 -29
  56. package/src/types.ts +107 -6
@@ -0,0 +1,119 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { base64ToBuffer, bufferToBase64, bytesToHex, deriveKeyFromPassword } from './crypto';
4
+ import {
5
+ ESCROW_PIN_MAX_ATTEMPTS,
6
+ PIN_MAX_LENGTH,
7
+ PIN_MIN_LENGTH,
8
+ constantTimeEqualHex,
9
+ derivePinProof,
10
+ generatePinSalt,
11
+ normalizePin,
12
+ validatePin,
13
+ } from './escrow-pin';
14
+
15
+ describe('escrow PIN rules', () => {
16
+ it('exports the policy limits', (): void => {
17
+ expect(PIN_MIN_LENGTH).toBe(6);
18
+ expect(PIN_MAX_LENGTH).toBe(12);
19
+ expect(ESCROW_PIN_MAX_ATTEMPTS).toBe(10);
20
+ });
21
+
22
+ it.each([
23
+ [' 135790\n', '135790'],
24
+ ['13-57 90abc', '135790'],
25
+ ['135790', '135790'],
26
+ ['¹³⁵⁷⁹⁰', '135790'],
27
+ ['001357', '001357'],
28
+ ['abc! ', ''],
29
+ ])('normalizes %j to %j', (raw, expected): void => {
30
+ expect(normalizePin(raw)).toBe(expected);
31
+ });
32
+
33
+ it.each(['', 'abc', '13579', '1357902468135', '12345'])(
34
+ 'rejects invalid normalized length %j',
35
+ (pin): void => {
36
+ expect(validatePin(pin)).toEqual({ ok: false, reason: 'length' });
37
+ }
38
+ );
39
+
40
+ it.each([
41
+ '123456',
42
+ '654321',
43
+ '000000',
44
+ '111111',
45
+ '121212',
46
+ '112233',
47
+ '123123',
48
+ '696969',
49
+ '123321',
50
+ '999999999999',
51
+ '2222222',
52
+ '012345',
53
+ '2345678',
54
+ '0123456789',
55
+ '9876543210',
56
+ '8765432',
57
+ '123456',
58
+ ' 12-12-12 ',
59
+ ])('rejects trivial PIN %j', (pin): void => {
60
+ expect(validatePin(pin)).toEqual({ ok: false, reason: 'trivial' });
61
+ });
62
+
63
+ it.each(['135790', '001357', '135790246813', '1234568', '6543217', '135790'])(
64
+ 'accepts nontrivial PIN %j',
65
+ (pin): void => {
66
+ expect(validatePin(pin)).toEqual({ ok: true });
67
+ }
68
+ );
69
+ });
70
+
71
+ describe('escrow PIN proof', () => {
72
+ it('generates independent 16-byte base64 salts', (): void => {
73
+ const salt = generatePinSalt();
74
+ expect(base64ToBuffer(salt)).toHaveLength(16);
75
+ expect(bufferToBase64(base64ToBuffer(salt).buffer)).toBe(salt);
76
+ expect(generatePinSalt()).not.toBe(salt);
77
+ });
78
+
79
+ it('is deterministic, normalized, salt-sensitive and uses the default password KDF', async (): Promise<void> => {
80
+ const saltBytes = new Uint8Array(16).fill(1);
81
+ const salt = bufferToBase64(saltBytes.buffer);
82
+ const proof = await derivePinProof('135790', salt);
83
+ expect(proof).toMatch(/^[0-9a-f]{64}$/);
84
+ expect(await derivePinProof(' 13-5790 ', salt)).toBe(proof);
85
+ expect(proof).toBe(bytesToHex(await deriveKeyFromPassword('135790', saltBytes)));
86
+ expect(
87
+ await derivePinProof('135790', bufferToBase64(new Uint8Array(16).fill(2).buffer))
88
+ ).not.toBe(proof);
89
+ expect(await derivePinProof('135791', salt)).not.toBe(proof);
90
+ });
91
+
92
+ it.each([0, 15, 17])('rejects a %i-byte salt', async (length): Promise<void> => {
93
+ await expect(
94
+ derivePinProof('135790', bufferToBase64(new Uint8Array(length).buffer))
95
+ ).rejects.toThrow('salt length');
96
+ });
97
+
98
+ it('rejects malformed base64', async (): Promise<void> => {
99
+ await expect(derivePinProof('135790', '!')).rejects.toThrow();
100
+ });
101
+ });
102
+
103
+ describe('constantTimeEqualHex', () => {
104
+ it.each([
105
+ ['ab'.repeat(32), 'ab'.repeat(32), true],
106
+ ['AB'.repeat(32), 'ab'.repeat(32), true],
107
+ ['00', '00', true],
108
+ ['00'.repeat(32), `01${'00'.repeat(31)}`, false],
109
+ ['00'.repeat(32), `${'00'.repeat(31)}01`, false],
110
+ ['00', '0000', false],
111
+ ['', '', false],
112
+ ['a', 'a', false],
113
+ ['gg', 'gg', false],
114
+ ['0g', '00', false],
115
+ ['00', '0g', false],
116
+ ])('compares %j and %j', (a, b, expected): void => {
117
+ expect(constantTimeEqualHex(a, b)).toBe(expected);
118
+ });
119
+ });
@@ -0,0 +1,83 @@
1
+ import {
2
+ base64ToBuffer,
3
+ bufferToBase64,
4
+ bytesToHex,
5
+ deriveKeyFromPassword,
6
+ hexToBytes,
7
+ } from './crypto';
8
+
9
+ export const PIN_MIN_LENGTH = 6;
10
+ export const PIN_MAX_LENGTH = 12;
11
+ export const ESCROW_PIN_MAX_ATTEMPTS = 10;
12
+
13
+ const TRIVIAL_PINS = new Set([
14
+ '123456',
15
+ '654321',
16
+ '000000',
17
+ '111111',
18
+ '121212',
19
+ '112233',
20
+ '123123',
21
+ '696969',
22
+ '123321',
23
+ ]);
24
+
25
+ /** Normalize compatibility digits before removing formatting and non-digits. */
26
+ export const normalizePin = (raw: string): string =>
27
+ raw.trim().normalize('NFKD').replace(/\D/g, '');
28
+
29
+ /** Validate the normalized PIN, rejecting full-length sequential and repeated digits. */
30
+ export const validatePin = (
31
+ pin: string
32
+ ): { ok: true } | { ok: false; reason: 'length' | 'trivial' } => {
33
+ const normalized = normalizePin(pin);
34
+ if (normalized.length < PIN_MIN_LENGTH || normalized.length > PIN_MAX_LENGTH) {
35
+ return { ok: false, reason: 'length' };
36
+ }
37
+
38
+ if (
39
+ /^(\d)\1+$/.test(normalized) ||
40
+ '0123456789'.includes(normalized) ||
41
+ '9876543210'.includes(normalized) ||
42
+ TRIVIAL_PINS.has(normalized)
43
+ ) {
44
+ return { ok: false, reason: 'trivial' };
45
+ }
46
+
47
+ return { ok: true };
48
+ };
49
+
50
+ /** Generate a public, random 16-byte salt encoded as standard base64. */
51
+ export const generatePinSalt = (): string =>
52
+ bufferToBase64(crypto.getRandomValues(new Uint8Array(16)).buffer);
53
+
54
+ /** Derive the enclave proof using the shared default Argon2id parameters. */
55
+ export const derivePinProof = async (pin: string, pinSaltBase64: string): Promise<string> => {
56
+ const salt = base64ToBuffer(pinSaltBase64);
57
+ if (salt.length !== 16) throw new Error('Invalid escrow PIN salt length');
58
+
59
+ const key = await deriveKeyFromPassword(normalizePin(pin), salt);
60
+ try {
61
+ return bytesToHex(key);
62
+ } finally {
63
+ key.fill(0);
64
+ }
65
+ };
66
+
67
+ /**
68
+ * Compare valid hex bytes without an early exit on differing contents.
69
+ * Format and length are public; JavaScript runtimes cannot guarantee constant-time execution.
70
+ */
71
+ export const constantTimeEqualHex = (a: string, b: string): boolean => {
72
+ if (a.length !== b.length || !/^(?:[0-9a-f]{2})+$/i.test(a) || !/^(?:[0-9a-f]{2})+$/i.test(b)) {
73
+ return false;
74
+ }
75
+
76
+ const aBytes = hexToBytes(a);
77
+ const bBytes = hexToBytes(b);
78
+ let difference = 0;
79
+ for (let i = 0; i < aBytes.length; i++) {
80
+ difference |= aBytes[i]! ^ bBytes[i]!;
81
+ }
82
+ return difference === 0;
83
+ };
package/src/index.ts CHANGED
@@ -43,6 +43,60 @@ export {
43
43
  DEFAULT_KDF_PARAMS,
44
44
  } from './crypto';
45
45
 
46
+ export {
47
+ EMAIL_RELAY_ALGORITHM,
48
+ EMAIL_RELAY_ENVELOPE_VERSION,
49
+ encryptEmailRelayPayload,
50
+ decryptEmailRelayPayload,
51
+ generateEmailRelayConfirmationCode,
52
+ parseEmailRelayEnvelope,
53
+ parseEmailRelayPlaintext,
54
+ } from './email-relay-crypto';
55
+
56
+ export type {
57
+ EmailRelayBranding,
58
+ EmailRelayEnvelope,
59
+ EmailRelayPlaintext,
60
+ } from './email-relay-crypto';
61
+
62
+ export {
63
+ ESCROW_ENVELOPE_VERSION,
64
+ ESCROW_ALGORITHM,
65
+ ESCROW_BLOB_INFO,
66
+ ESCROW_RELEASE_INFO,
67
+ ESCROW_CLIENT_KEY_ID,
68
+ parseEscrowEnvelope,
69
+ parseEscrowBlobPlaintext,
70
+ parseEscrowReleasePlaintext,
71
+ generateEscrowKeyPair,
72
+ encryptEscrowBlob,
73
+ decryptEscrowBlob,
74
+ sealEscrowRelease,
75
+ openEscrowRelease,
76
+ } from './escrow-crypto';
77
+
78
+ export type { EscrowEnvelope, EscrowBlobPlaintext, EscrowReleasePlaintext } from './escrow-crypto';
79
+ export { verifyEnclaveAttestation } from './escrow-attestation';
80
+ export type { EscrowAttestationPolicy, EscrowHoldStatus, EscrowRecoveryStart } from './types';
81
+ export type { EscrowEnrollmentOptions, EscrowEnrollmentState, EscrowPinStatus } from './types';
82
+ export {
83
+ EscrowPinMismatchError,
84
+ EscrowPinLockedError,
85
+ EscrowPinThrottledError,
86
+ EscrowHoldRestartThrottledError,
87
+ EscrowPinUnavailableError,
88
+ } from './types';
89
+ export {
90
+ PIN_MIN_LENGTH,
91
+ PIN_MAX_LENGTH,
92
+ ESCROW_PIN_MAX_ATTEMPTS,
93
+ normalizePin,
94
+ validatePin,
95
+ generatePinSalt,
96
+ derivePinProof,
97
+ constantTimeEqualHex,
98
+ } from './escrow-pin';
99
+
46
100
  export {
47
101
  createPasskeyCredential,
48
102
  deriveKeyFromPasskey,
@@ -60,6 +114,7 @@ export {
60
114
  generateRecoveryPhrase,
61
115
  validateRecoveryPhrase,
62
116
  countWords,
117
+ buildRecoveryPhraseChallengeOptions,
63
118
  } from './recovery-phrase';
64
119
 
65
120
  export type { RecoveryPhraseData } from './recovery-phrase';
@@ -71,6 +126,7 @@ export {
71
126
  atomicRecovery,
72
127
  ShareVerificationError,
73
128
  AtomicUpdateError,
129
+ ShareWriteRejectedError,
74
130
  } from './atomic-operations';
75
131
 
76
132
  export type {
@@ -79,7 +135,11 @@ export type {
79
135
  StorageOperations,
80
136
  } from './atomic-operations';
81
137
 
82
- export { createSSSStrategy } from './sss-strategy';
138
+ export {
139
+ createSSSStrategy,
140
+ IdentityRecoverySessionConsumedError,
141
+ EscrowRequestError,
142
+ } from './sss-strategy';
83
143
 
84
144
  export type { SSSStorageFunctions, SSSStrategyConfig } from './sss-strategy';
85
145
 
@@ -125,6 +185,7 @@ export type {
125
185
  AuthProvider,
126
186
  AuthUser,
127
187
  AuthProviderType,
188
+ DidAuthVpSigner,
128
189
  ContactMethod,
129
190
  ContactMethodType,
130
191
  RecoveryMethod,
@@ -134,6 +195,7 @@ export type {
134
195
  RecoveryResult,
135
196
  RecoverySetupInput,
136
197
  RecoverySetupResult,
198
+ RecoveryConfirmationInput,
137
199
  PasskeyRecoveryMethod,
138
200
  BackupFileRecoveryMethod,
139
201
  RecoveryPhraseRecoveryMethod,
@@ -21,11 +21,7 @@ import {
21
21
  deleteDeviceShare,
22
22
  clearAllShares,
23
23
  } from './storage';
24
- import {
25
- encryptWithPassword,
26
- decryptWithPassword,
27
- generateEd25519PrivateKey,
28
- } from './crypto';
24
+ import { encryptWithPassword, decryptWithPassword, generateEd25519PrivateKey } from './crypto';
29
25
 
30
26
  import {
31
27
  createPasskeyCredential,
@@ -33,11 +29,9 @@ import {
33
29
  decryptShareWithPasskey,
34
30
  } from './passkey';
35
31
 
36
- import {
37
- shareToRecoveryPhrase,
38
- recoveryPhraseToShare,
39
- } from './recovery-phrase';
32
+ import { shareToRecoveryPhrase, recoveryPhraseToShare } from './recovery-phrase';
40
33
 
34
+ /** @deprecated Use `createSSSStrategy` with AuthCoordinator. */
41
35
  export class SSSKeyManager implements SSSKeyDerivationProvider {
42
36
  readonly name = 'sss';
43
37
 
@@ -318,6 +312,7 @@ export class SSSKeyManager implements SSSKeyDerivationProvider {
318
312
  }
319
313
  }
320
314
 
315
+ /** @deprecated Use `createSSSStrategy` with AuthCoordinator. */
321
316
  export function createSSSKeyManager(config: SSSKeyManagerConfig): SSSKeyManager {
322
317
  return new SSSKeyManager(config);
323
318
  }
package/src/qr-login.ts CHANGED
@@ -55,21 +55,19 @@ export interface QrPayload {
55
55
  /** Result of polling — either still waiting or the device share is ready */
56
56
  export type PollResult =
57
57
  | { status: 'pending' }
58
- | { status: 'approved'; deviceShare: string; approverDid: string; accountHint?: string; shareVersion?: number };
58
+ | {
59
+ status: 'approved';
60
+ deviceShare: string;
61
+ approverDid: string;
62
+ accountHint?: string;
63
+ shareVersion?: number;
64
+ };
59
65
 
60
66
  // ---------------------------------------------------------------------------
61
67
  // API helpers
62
68
  // ---------------------------------------------------------------------------
63
69
 
64
- const buildHeaders = (token?: string): Record<string, string> => {
65
- const headers: Record<string, string> = { 'Content-Type': 'application/json' };
66
-
67
- if (token) {
68
- headers.Authorization = `Bearer ${token}`;
69
- }
70
-
71
- return headers;
72
- };
70
+ const buildHeaders = (): Record<string, string> => ({ 'Content-Type': 'application/json' });
73
71
 
74
72
  // ---------------------------------------------------------------------------
75
73
  // Requester (Device B — the new device)
@@ -180,7 +178,12 @@ export const pollUntilApproved = async (
180
178
  onPoll?: (attempt: number) => void;
181
179
  signal?: AbortSignal;
182
180
  }
183
- ): Promise<{ deviceShare: string; approverDid: string; accountHint?: string; shareVersion?: number }> => {
181
+ ): Promise<{
182
+ deviceShare: string;
183
+ approverDid: string;
184
+ accountHint?: string;
185
+ shareVersion?: number;
186
+ }> => {
184
187
  const intervalMs = options?.intervalMs ?? 2000;
185
188
  const timeoutMs = options?.timeoutMs ?? 120_000;
186
189
 
@@ -211,10 +214,14 @@ export const pollUntilApproved = async (
211
214
  const timer = setTimeout(resolve, intervalMs);
212
215
 
213
216
  if (options?.signal) {
214
- options.signal.addEventListener('abort', () => {
215
- clearTimeout(timer);
216
- reject(new Error('QR login polling aborted'));
217
- }, { once: true });
217
+ options.signal.addEventListener(
218
+ 'abort',
219
+ () => {
220
+ clearTimeout(timer);
221
+ reject(new Error('QR login polling aborted'));
222
+ },
223
+ { once: true }
224
+ );
218
225
  }
219
226
  });
220
227
  }
@@ -132,3 +132,43 @@ export function countWords(phrase: string): number {
132
132
  .split(/\s+/)
133
133
  .filter(w => w.length > 0).length;
134
134
  }
135
+
136
+ /** Uniform random integer in [0, max) without modulo bias. */
137
+ function randomIndex(max: number): number {
138
+ const limit = 0x1_0000_0000 - (0x1_0000_0000 % max);
139
+ for (;;) {
140
+ const value = crypto.getRandomValues(new Uint32Array(1))[0];
141
+ if (value === undefined) throw new Error('Failed to choose recovery phrase words');
142
+ if (value < limit) return value % max;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Builds tap-to-confirm choices for each challenged word: the correct word plus
148
+ * `optionCount - 1` decoys, shuffled. Decoys never appear anywhere in the phrase,
149
+ * so a choice can only be right for its own position.
150
+ */
151
+ export async function buildRecoveryPhraseChallengeOptions(
152
+ phrase: string,
153
+ challengeWordIndices: number[],
154
+ optionCount = 6
155
+ ): Promise<string[][]> {
156
+ const words = phrase.trim().split(/\s+/);
157
+ const phraseWords = new Set(words);
158
+ const decoyPool = (await getWordlist()).filter(word => !phraseWords.has(word));
159
+
160
+ return challengeWordIndices.map(wordIndex => {
161
+ const answer = words[wordIndex];
162
+ if (!answer) throw new Error('Recovery phrase challenge index is out of range');
163
+
164
+ const options = new Set<string>([answer]);
165
+ while (options.size < optionCount) options.add(decoyPool[randomIndex(decoyPool.length)]!);
166
+
167
+ const shuffled = [...options];
168
+ for (let i = shuffled.length - 1; i > 0; i--) {
169
+ const j = randomIndex(i + 1);
170
+ [shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!];
171
+ }
172
+ return shuffled;
173
+ });
174
+ }
@@ -0,0 +1,109 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { createSSSStrategy, type SSSStorageFunctions } from './sss-strategy';
3
+
4
+ vi.mock('./escrow-attestation', () => ({
5
+ verifyEnclaveAttestation: async () => ({ publicKey: 'test-key', keyId: 'test-key' }),
6
+ }));
7
+ vi.mock('./escrow-crypto', () => ({ encryptEscrowBlob: async () => ({}) }));
8
+
9
+ const deferred = <T>() => {
10
+ let resolve!: (value: T) => void;
11
+ const promise = new Promise<T>(done => {
12
+ resolve = done;
13
+ });
14
+ return { promise, resolve };
15
+ };
16
+
17
+ const setup = () => {
18
+ const devices = new Map<string | undefined, string>([[undefined, 'previous-share']]);
19
+ const storage: SSSStorageFunctions = {
20
+ getDeviceShare: async id => devices.get(id) ?? null,
21
+ hasDeviceShare: async id => devices.has(id),
22
+ storeDeviceShare: vi.fn(async (share, id) => {
23
+ devices.set(id, share);
24
+ }),
25
+ clearAllShares: vi.fn(async id => {
26
+ devices.delete(id);
27
+ }),
28
+ getShareVersion: async () => 1,
29
+ storeShareVersion: vi.fn(async () => {}),
30
+ };
31
+ const strategy = createSSSStrategy({
32
+ serverUrl: 'https://example.test',
33
+ storage,
34
+ escrow: {
35
+ enabled: true,
36
+ attestation: { mode: 'software', pinnedPublicKeys: ['test-key'] },
37
+ },
38
+ });
39
+ const enroll = () =>
40
+ strategy.ensureEscrowEnrollment!({
41
+ token: 'token',
42
+ providerType: 'firebase',
43
+ privateKey: 'a'.repeat(64),
44
+ signDidAuthVp: async () => 'proof',
45
+ });
46
+ return { strategy, storage, enroll };
47
+ };
48
+
49
+ const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
50
+ const keyStatus = { primaryDid: 'did:key:test', shareVersion: 1, recoveryMethods: [] };
51
+
52
+ afterEach(() => {
53
+ vi.unstubAllGlobals();
54
+ });
55
+
56
+ describe('session-scoped enrollment writes', () => {
57
+ it('does not write a device share when attestation resumes after forgetting the device', async () => {
58
+ const { strategy, storage, enroll } = setup();
59
+ const started = deferred<void>();
60
+ const attestation = deferred<Response>();
61
+ vi.stubGlobal(
62
+ 'fetch',
63
+ vi.fn(async (url: string) => {
64
+ if (url.endsWith('/attestation')) {
65
+ started.resolve();
66
+ return attestation.promise;
67
+ }
68
+ return response(keyStatus);
69
+ })
70
+ );
71
+ const pending = enroll();
72
+ await started.promise;
73
+ await strategy.clearLocalKeys();
74
+ attestation.resolve(response({ attestation: {} }));
75
+ await expect(pending).rejects.toThrow();
76
+ expect(storage.storeDeviceShare).not.toHaveBeenCalled();
77
+ expect(await strategy.getLocalKey()).toBeNull();
78
+ });
79
+
80
+ it.each([200, 500])(
81
+ 'blocks late version writes and rollback after server response %s',
82
+ async status => {
83
+ const { strategy, storage, enroll } = setup();
84
+ const started = deferred<void>();
85
+ const authWrite = deferred<Response>();
86
+ vi.stubGlobal(
87
+ 'fetch',
88
+ vi.fn(async (url: string, init?: RequestInit) => {
89
+ if (url.endsWith('/auth-share') && init?.method === 'PUT') {
90
+ started.resolve();
91
+ return authWrite.promise;
92
+ }
93
+ if (url.endsWith('/challenge')) return response({ challenge: 'nonce' });
94
+ if (url.endsWith('/attestation')) return response({ attestation: {} });
95
+ return response(keyStatus);
96
+ })
97
+ );
98
+ const pending = enroll();
99
+ await started.promise;
100
+ expect(storage.storeDeviceShare).toHaveBeenCalledTimes(1);
101
+ await strategy.clearLocalKeys();
102
+ authWrite.resolve(response({ shareVersion: 2 }, status));
103
+ await expect(pending).rejects.toThrow();
104
+ expect(storage.storeDeviceShare).toHaveBeenCalledTimes(1);
105
+ expect(storage.storeShareVersion).not.toHaveBeenCalled();
106
+ expect(await strategy.getLocalKey()).toBeNull();
107
+ }
108
+ );
109
+ });