@learncard/sss-key-manager 0.1.24 → 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 (75) hide show
  1. package/README.md +25 -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 +12 -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-nitro-attestation.d.ts +32 -0
  13. package/dist/escrow-nitro-attestation.d.ts.map +1 -0
  14. package/dist/escrow-pin.d.ts +22 -0
  15. package/dist/escrow-pin.d.ts.map +1 -0
  16. package/dist/index.d.ts +15 -4
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/key-manager.d.ts +2 -0
  19. package/dist/key-manager.d.ts.map +1 -1
  20. package/dist/qr-login.d.ts.map +1 -1
  21. package/dist/recovery-phrase.d.ts +6 -0
  22. package/dist/recovery-phrase.d.ts.map +1 -1
  23. package/dist/sss-key-manager.cjs.development.js +5564 -353
  24. package/dist/sss-key-manager.cjs.development.js.map +4 -4
  25. package/dist/sss-key-manager.cjs.production.min.js +33 -33
  26. package/dist/sss-key-manager.cjs.production.min.js.map +4 -4
  27. package/dist/sss-key-manager.esm.js +3494 -354
  28. package/dist/sss-key-manager.esm.js.map +4 -4
  29. package/dist/sss-strategy.d.ts +74 -4
  30. package/dist/sss-strategy.d.ts.map +1 -1
  31. package/dist/storage.d.ts +1 -0
  32. package/dist/storage.d.ts.map +1 -1
  33. package/dist/types.d.ts +98 -4
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +7 -3
  36. package/src/__fixtures__/escrow-vectors-rust.json +82 -0
  37. package/src/__fixtures__/escrow-vectors.json +103 -0
  38. package/src/__fixtures__/nitro-attestation/bad-signature.cbor.b64 +1 -0
  39. package/src/__fixtures__/nitro-attestation/debug-zero-pcrs.cbor.b64 +1 -0
  40. package/src/__fixtures__/nitro-attestation/legacy-user-data.cbor.b64 +1 -0
  41. package/src/__fixtures__/nitro-attestation/manifest.json +60 -0
  42. package/src/__fixtures__/nitro-attestation/nonce-mismatch.cbor.b64 +1 -0
  43. package/src/__fixtures__/nitro-attestation/root.pem +12 -0
  44. package/src/__fixtures__/nitro-attestation/stale-timestamp.cbor.b64 +1 -0
  45. package/src/__fixtures__/nitro-attestation/tampered-previous-key-ids.cbor.b64 +1 -0
  46. package/src/__fixtures__/nitro-attestation/truncated-chain.cbor.b64 +1 -0
  47. package/src/__fixtures__/nitro-attestation/valid.cbor.b64 +1 -0
  48. package/src/__fixtures__/nitro-attestation/wrong-pcr0.cbor.b64 +1 -0
  49. package/src/__fixtures__/nitro-attestation/wrong-root.cbor.b64 +1 -0
  50. package/src/api-client.test.ts +122 -0
  51. package/src/api-client.ts +107 -116
  52. package/src/atomic-operations.test.ts +56 -16
  53. package/src/atomic-operations.ts +55 -19
  54. package/src/critical-paths.test.ts +51 -1
  55. package/src/email-relay-crypto.test.ts +87 -0
  56. package/src/email-relay-crypto.ts +293 -0
  57. package/src/escrow-attestation.test.ts +90 -0
  58. package/src/escrow-attestation.ts +125 -0
  59. package/src/escrow-crypto.test.ts +381 -0
  60. package/src/escrow-crypto.ts +319 -0
  61. package/src/escrow-nitro-attestation.test.ts +204 -0
  62. package/src/escrow-nitro-attestation.ts +286 -0
  63. package/src/escrow-pin.test.ts +119 -0
  64. package/src/escrow-pin.ts +83 -0
  65. package/src/escrow-vectors.test.ts +198 -0
  66. package/src/index.ts +70 -1
  67. package/src/key-manager.ts +4 -9
  68. package/src/qr-login.ts +22 -15
  69. package/src/recovery-phrase.ts +40 -0
  70. package/src/sss-session-writes.test.ts +109 -0
  71. package/src/sss-strategy.test.ts +2736 -111
  72. package/src/sss-strategy.ts +2226 -224
  73. package/src/storage.test.ts +66 -8
  74. package/src/storage.ts +41 -29
  75. package/src/types.ts +118 -6
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Atomic Operations Tests
3
- *
3
+ *
4
4
  * Tests for split verification, atomic updates, and rollback behavior.
5
5
  */
6
6
 
@@ -13,13 +13,15 @@ import {
13
13
  atomicRecovery,
14
14
  ShareVerificationError,
15
15
  AtomicUpdateError,
16
+ ShareWriteRejectedError,
16
17
  type StorageOperations,
17
18
  } from './atomic-operations';
18
19
  import { generateEd25519PrivateKey } from './crypto';
19
20
  import { splitPrivateKey, reconstructFromShares } from './sss';
20
21
 
21
- describe('splitAndVerify', () => {
22
+ type MockShareState = { deviceShare: string | null; authShare: string | null };
22
23
 
24
+ describe('splitAndVerify', () => {
23
25
  it('should return verified shares that reconstruct the key', async () => {
24
26
  const privateKey = await generateEd25519PrivateKey();
25
27
 
@@ -56,6 +58,33 @@ describe('splitAndVerify', () => {
56
58
  });
57
59
 
58
60
  describe('atomicShareUpdate', () => {
61
+ it.each([false, true])(
62
+ 'preserves the new share after a lost reply (existing=%s)',
63
+ async existing => {
64
+ const privateKey = await generateEd25519PrivateKey();
65
+ const previous = existing ? (await splitPrivateKey(privateKey)).deviceShare : undefined;
66
+ const storage = createMockStorage();
67
+ storage.deviceShare = previous ?? null;
68
+ storage.clearDevice = vi.fn(async () => {
69
+ storage.deviceShare = null;
70
+ });
71
+ storage.storeAuth = vi.fn(async share => {
72
+ storage.authShare = share;
73
+ throw new TypeError('Connection closed after commit');
74
+ });
75
+
76
+ await expect(
77
+ atomicShareUpdate(privateKey, storage, {
78
+ previousDeviceShare: previous,
79
+ })
80
+ ).rejects.toMatchObject({ phase: 'store_auth', rolledBack: false });
81
+
82
+ expect(storage.clearDevice).not.toHaveBeenCalled();
83
+ expect(await reconstructFromShares([storage.deviceShare!, storage.authShare!])).toBe(
84
+ privateKey
85
+ );
86
+ }
87
+ );
59
88
 
60
89
  const createMockStorage = (): StorageOperations & {
61
90
  deviceShare: string | null;
@@ -64,19 +93,19 @@ describe('atomicShareUpdate', () => {
64
93
  deviceShare: null,
65
94
  authShare: null,
66
95
 
67
- storeDevice: vi.fn(async function(this: any, share: string) {
96
+ storeDevice: vi.fn(async function (this: MockShareState, share: string) {
68
97
  this.deviceShare = share;
69
98
  }),
70
99
 
71
- storeAuth: vi.fn(async function(this: any, share: string) {
100
+ storeAuth: vi.fn(async function (this: MockShareState, share: string) {
72
101
  this.authShare = share;
73
102
  }),
74
103
 
75
- getDevice: vi.fn(async function(this: any) {
104
+ getDevice: vi.fn(async function (this: MockShareState) {
76
105
  return this.deviceShare;
77
106
  }),
78
107
 
79
- getAuth: vi.fn(async function(this: any) {
108
+ getAuth: vi.fn(async function (this: MockShareState) {
80
109
  return this.authShare;
81
110
  }),
82
111
  });
@@ -101,7 +130,7 @@ describe('atomicShareUpdate', () => {
101
130
  storage.deviceShare = previousDeviceShare;
102
131
 
103
132
  // Make auth storage fail
104
- storage.storeAuth = vi.fn().mockRejectedValue(new Error('Network error'));
133
+ storage.storeAuth = vi.fn().mockRejectedValue(new ShareWriteRejectedError('Rejected'));
105
134
 
106
135
  const onRollback = vi.fn();
107
136
 
@@ -117,6 +146,20 @@ describe('atomicShareUpdate', () => {
117
146
  expect(onRollback).toHaveBeenCalled();
118
147
  });
119
148
 
149
+ it('removes a fresh device share after an explicit rejection', async () => {
150
+ const storage = createMockStorage();
151
+ storage.clearDevice = vi.fn(async () => {
152
+ storage.deviceShare = null;
153
+ });
154
+ storage.storeAuth = vi.fn().mockRejectedValue(new ShareWriteRejectedError('Rejected'));
155
+
156
+ await expect(
157
+ atomicShareUpdate(await generateEd25519PrivateKey(), storage)
158
+ ).rejects.toMatchObject({ phase: 'store_auth', rolledBack: true });
159
+ expect(storage.deviceShare).toBeNull();
160
+ expect(storage.clearDevice).toHaveBeenCalledOnce();
161
+ });
162
+
120
163
  it('should throw AtomicUpdateError with correct phase on device storage failure', async () => {
121
164
  const privateKey = await generateEd25519PrivateKey();
122
165
  const storage = createMockStorage();
@@ -138,7 +181,9 @@ describe('atomicShareUpdate', () => {
138
181
  const privateKey = await generateEd25519PrivateKey();
139
182
  const storage = createMockStorage();
140
183
 
141
- storage.storeAuth = vi.fn().mockRejectedValue(new Error('Server error'));
184
+ storage.storeAuth = vi
185
+ .fn()
186
+ .mockRejectedValue(new ShareWriteRejectedError('Server rejection'));
142
187
 
143
188
  try {
144
189
  await atomicShareUpdate(privateKey, storage, {
@@ -160,16 +205,12 @@ describe('atomicShareUpdate', () => {
160
205
  const shares = await atomicShareUpdate(privateKey, storage);
161
206
 
162
207
  // Verify the returned shares can reconstruct the key
163
- const reconstructed = await reconstructFromShares([
164
- shares.deviceShare,
165
- shares.authShare,
166
- ]);
208
+ const reconstructed = await reconstructFromShares([shares.deviceShare, shares.authShare]);
167
209
  expect(reconstructed).toBe(privateKey);
168
210
  });
169
211
  });
170
212
 
171
213
  describe('verifyStoredShares', () => {
172
-
173
214
  it('should return healthy=true when shares match expected DID', async () => {
174
215
  const privateKey = await generateEd25519PrivateKey();
175
216
  const shares = await splitPrivateKey(privateKey);
@@ -266,7 +307,6 @@ describe('verifyStoredShares', () => {
266
307
  });
267
308
 
268
309
  describe('atomicRecovery', () => {
269
-
270
310
  it('should reconstruct key and generate new shares', async () => {
271
311
  const privateKey = await generateEd25519PrivateKey();
272
312
  const originalShares = await splitPrivateKey(privateKey);
@@ -275,11 +315,11 @@ describe('atomicRecovery', () => {
275
315
  deviceShare: null as string | null,
276
316
  authShare: null as string | null,
277
317
 
278
- storeDevice: vi.fn(async function(this: any, share: string) {
318
+ storeDevice: vi.fn(async function (this: MockShareState, share: string) {
279
319
  this.deviceShare = share;
280
320
  }),
281
321
 
282
- storeAuth: vi.fn(async function(this: any, share: string) {
322
+ storeAuth: vi.fn(async function (this: MockShareState, share: string) {
283
323
  this.authShare = share;
284
324
  }),
285
325
  };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Atomic Share Operations
3
- *
3
+ *
4
4
  * Provides verified split operations and atomic updates with rollback capability.
5
5
  * These functions ensure that private keys can NEVER be lost due to partial failures.
6
6
  */
@@ -22,6 +22,7 @@ export interface AtomicUpdateOptions {
22
22
  export interface StorageOperations {
23
23
  storeDevice: (share: string) => Promise<void>;
24
24
  storeAuth: (share: string) => Promise<void>;
25
+ clearDevice?: () => Promise<void>;
25
26
  getDevice?: () => Promise<string | null>;
26
27
  getAuth?: () => Promise<string | null>;
27
28
  }
@@ -50,12 +51,20 @@ export class AtomicUpdateError extends Error {
50
51
  }
51
52
  }
52
53
 
54
+ /** A write was explicitly rejected, rather than its outcome being unknown. */
55
+ export class ShareWriteRejectedError extends Error {
56
+ constructor(message: string) {
57
+ super(message);
58
+ this.name = 'ShareWriteRejectedError';
59
+ }
60
+ }
61
+
53
62
  /**
54
63
  * Split a private key into shares and verify ALL combinations reconstruct correctly.
55
- *
64
+ *
56
65
  * This function will NOT return until verification passes. If verification fails,
57
66
  * it throws an error - no shares are ever returned that don't reconstruct the key.
58
- *
67
+ *
59
68
  * @param privateKey - The private key to split (hex string)
60
69
  * @returns Verified shares that are guaranteed to reconstruct the key
61
70
  * @throws ShareVerificationError if any share combination fails verification
@@ -95,17 +104,20 @@ export async function splitAndVerify(privateKey: string): Promise<AtomicSplitRes
95
104
 
96
105
  /**
97
106
  * Atomically update shares with rollback on failure.
98
- *
107
+ *
99
108
  * This function ensures that either:
100
109
  * 1. Both device and auth shares are updated successfully, OR
101
- * 2. The previous state is restored (rollback)
102
- *
110
+ * 2. A rejected write restores the previous state (rollback), OR
111
+ * 3. An indeterminate write preserves the new device share for reconciliation
112
+ *
103
113
  * The operation flow:
104
114
  * 1. Generate and verify new shares
105
115
  * 2. Store device share locally
106
116
  * 3. Store auth share on server
107
- * 4. If step 3 fails, rollback step 2
108
- *
117
+ * 4. Roll back only if step 3 throws ShareWriteRejectedError. Network failures
118
+ * may happen after commit and must never destroy the new device share.
119
+ * Callers can stage storeDevice in a separate entry to retain both versions.
120
+ *
109
121
  * @param privateKey - The private key to split
110
122
  * @param storage - Storage operations for device and auth shares
111
123
  * @param options - Options including previous shares for rollback
@@ -150,22 +162,46 @@ export async function atomicShareUpdate(
150
162
  // Phase 3: Store auth share on server
151
163
  await storage.storeAuth(newShares.authShare);
152
164
  } catch (e) {
153
- // ROLLBACK: Restore previous device share if we have it
154
- if (options.previousDeviceShare && deviceStored) {
165
+ if (!(e instanceof ShareWriteRejectedError)) {
166
+ throw new AtomicUpdateError(
167
+ 'Auth share write outcome unknown; device share preserved for reconciliation',
168
+ 'store_auth',
169
+ false,
170
+ e instanceof Error ? e : undefined
171
+ );
172
+ }
173
+
174
+ let rolledBack = false;
175
+
176
+ // ROLLBACK: restore the previous share, or remove the newly-written
177
+ // share when this was an initial setup with no previous local state.
178
+ if (deviceStored) {
155
179
  try {
156
- await storage.storeDevice(options.previousDeviceShare);
157
- options.onRollback?.('Server storage failed, restored previous device share');
180
+ if (options.previousDeviceShare !== undefined) {
181
+ await storage.storeDevice(options.previousDeviceShare);
182
+ options.onRollback?.('Server storage failed, restored previous device share');
183
+ rolledBack = true;
184
+ } else if (storage.clearDevice) {
185
+ await storage.clearDevice();
186
+ options.onRollback?.('Server storage failed, removed new device share');
187
+ rolledBack = true;
188
+ }
158
189
  } catch (rollbackError) {
159
190
  // Rollback failed - this is a critical error
160
191
  // The user may be in an inconsistent state
161
- console.error('CRITICAL: Rollback failed after auth share storage failure', rollbackError);
192
+ console.error(
193
+ 'CRITICAL: Rollback failed after auth share storage failure',
194
+ rollbackError
195
+ );
162
196
  }
163
197
  }
164
198
 
165
199
  throw new AtomicUpdateError(
166
- 'Failed to store auth share on server, rolled back device share',
200
+ rolledBack
201
+ ? 'Failed to store auth share on server, rolled back device share'
202
+ : 'Failed to store auth share on server; rollback did not complete',
167
203
  'store_auth',
168
- !!options.previousDeviceShare,
204
+ rolledBack,
169
205
  e instanceof Error ? e : undefined
170
206
  );
171
207
  }
@@ -175,10 +211,10 @@ export async function atomicShareUpdate(
175
211
 
176
212
  /**
177
213
  * Verify that stored shares can reconstruct the expected private key.
178
- *
214
+ *
179
215
  * This is a health check that should be run after recovery or on login
180
216
  * to ensure the user's shares are in a consistent state.
181
- *
217
+ *
182
218
  * @param storage - Storage operations to retrieve shares
183
219
  * @param expectedDid - The expected DID (used to verify the reconstructed key)
184
220
  * @param didFromPrivateKey - Function to derive DID from private key
@@ -243,10 +279,10 @@ export async function verifyStoredShares(
243
279
 
244
280
  /**
245
281
  * Create a recovery operation that atomically updates all shares.
246
- *
282
+ *
247
283
  * This is used during recovery when we reconstruct from recovery+auth shares
248
284
  * and need to generate a new device share.
249
- *
285
+ *
250
286
  * @param recoveryShare - The decrypted recovery share
251
287
  * @param authShareData - The auth share from server
252
288
  * @param storage - Storage operations
@@ -9,7 +9,9 @@
9
9
  * Run these tests before any release that touches SSS code.
10
10
  */
11
11
 
12
- import { describe, it, expect } from 'vitest';
12
+ import 'fake-indexeddb/auto';
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import { createSSSStrategy } from './sss-strategy';
13
15
 
14
16
  import { splitPrivateKey, reconstructFromShares, SSS_THRESHOLD } from './sss';
15
17
  import { generateEd25519PrivateKey, encryptWithPassword, decryptWithPassword } from './crypto';
@@ -20,6 +22,54 @@ import {
20
22
  } from './recovery-phrase';
21
23
 
22
24
  describe('Critical: Key must NEVER be lost', () => {
25
+ it('recovers a fresh account from encrypted IndexedDB after the auth-share reply is lost', async () => {
26
+ const config = { serverUrl: 'https://test.invalid/api' };
27
+ const strategy = createSSSStrategy(config);
28
+ strategy.setActiveUser!('indexeddb-lost-response');
29
+ const privateKey = await generateEd25519PrivateKey();
30
+ const expectedDid = 'did:key:test-owner';
31
+ let authShare = '';
32
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
33
+ if (init?.method === 'PUT') {
34
+ authShare = JSON.parse(String(init.body)).authShare.encryptedData;
35
+ throw new TypeError('Lost reply');
36
+ }
37
+ return new Response(
38
+ JSON.stringify({ authShare, shareVersion: 1, primaryDid: expectedDid })
39
+ );
40
+ });
41
+
42
+ try {
43
+ await expect(
44
+ strategy.atomicUpdateShares!({
45
+ token: 'token',
46
+ providerType: 'firebase',
47
+ privateKey,
48
+ did: expectedDid,
49
+ })
50
+ ).rejects.toMatchObject({ rolledBack: false });
51
+
52
+ const reloaded = createSSSStrategy(config);
53
+ reloaded.setActiveUser!('indexeddb-lost-response');
54
+ expect(await reloaded.hasLocalKey()).toBe(true);
55
+ const result = await reloaded.reconcileShares!({
56
+ token: 'token',
57
+ providerType: 'firebase',
58
+ expectedDid,
59
+ didFromPrivateKey: async key =>
60
+ key === privateKey ? expectedDid : 'did:key:wrong',
61
+ });
62
+ expect(result).toEqual({ privateKey, did: expectedDid });
63
+ expect(await reloaded.getLocalShareVersion!()).toBe(1);
64
+ expect(await reconstructFromShares([(await reloaded.getLocalKey())!, authShare])).toBe(
65
+ privateKey
66
+ );
67
+ } finally {
68
+ fetchMock.mockRestore();
69
+ await strategy.clearLocalKeys();
70
+ }
71
+ });
72
+
23
73
  describe('Share split verification (fuzz test)', () => {
24
74
  it('should verify all 6 share combinations reconstruct the key (100 iterations)', async () => {
25
75
  for (let i = 0; i < 100; i++) {
@@ -0,0 +1,87 @@
1
+ import { beforeAll, describe, expect, it } from 'vitest';
2
+
3
+ import { bufferToBase64 } from './crypto';
4
+ import {
5
+ decryptEmailRelayPayload,
6
+ encryptEmailRelayPayload,
7
+ generateEmailRelayConfirmationCode,
8
+ } from './email-relay-crypto';
9
+
10
+ describe('email relay encryption', () => {
11
+ let publicKeyBase64 = '';
12
+ let privateKeyBase64 = '';
13
+
14
+ beforeAll(async () => {
15
+ const keyPair = await crypto.subtle.generateKey(
16
+ { name: 'ECDH', namedCurve: 'P-256' },
17
+ true,
18
+ ['deriveBits']
19
+ );
20
+ const [publicKey, privateKey] = await Promise.all([
21
+ crypto.subtle.exportKey('spki', keyPair.publicKey),
22
+ crypto.subtle.exportKey('pkcs8', keyPair.privateKey),
23
+ ]);
24
+
25
+ publicKeyBase64 = bufferToBase64(publicKey);
26
+ privateKeyBase64 = bufferToBase64(privateKey);
27
+ });
28
+
29
+ it('sends no raw recovery key and round-trips with the relay private key', async () => {
30
+ const recoveryKey = `0001${'ab'.repeat(48)}`;
31
+ const confirmationCode = '483920';
32
+ const envelope = await encryptEmailRelayPayload(
33
+ {
34
+ targetEmail: 'Recovery@Example.com',
35
+ recoveryKey,
36
+ confirmationCode,
37
+ branding: { brandName: 'Test Tenant', fromDomain: 'example.com' },
38
+ },
39
+ publicKeyBase64,
40
+ 'test-key-1'
41
+ );
42
+
43
+ expect(JSON.stringify(envelope)).not.toContain(recoveryKey);
44
+ expect(JSON.stringify(envelope)).not.toContain(confirmationCode);
45
+ await expect(decryptEmailRelayPayload(envelope, privateKeyBase64)).resolves.toEqual({
46
+ version: 1,
47
+ targetEmail: 'recovery@example.com',
48
+ recoveryKey,
49
+ confirmationCode,
50
+ branding: { brandName: 'Test Tenant', fromDomain: 'example.com' },
51
+ });
52
+ });
53
+
54
+ it('authenticates envelope metadata and ciphertext', async () => {
55
+ const envelope = await encryptEmailRelayPayload(
56
+ {
57
+ targetEmail: 'recovery@example.com',
58
+ recoveryKey: `0002${'cd'.repeat(48)}`,
59
+ confirmationCode: '123456',
60
+ },
61
+ publicKeyBase64,
62
+ 'test-key-1'
63
+ );
64
+
65
+ await expect(
66
+ decryptEmailRelayPayload({ ...envelope, keyId: 'other-key' }, privateKeyBase64)
67
+ ).rejects.toThrow();
68
+
69
+ const lastCharacter = envelope.ciphertext.at(-1);
70
+ const tamperedCiphertext = `${envelope.ciphertext.slice(0, -1)}${
71
+ lastCharacter === 'A' ? 'B' : 'A'
72
+ }`;
73
+
74
+ await expect(
75
+ decryptEmailRelayPayload(
76
+ { ...envelope, ciphertext: tamperedCiphertext },
77
+ privateKeyBase64
78
+ )
79
+ ).rejects.toThrow();
80
+ });
81
+
82
+ it('generates exactly six decimal digits', () => {
83
+ const codes = Array.from({ length: 100 }, generateEmailRelayConfirmationCode);
84
+
85
+ expect(codes.every(code => /^\d{6}$/.test(code))).toBe(true);
86
+ });
87
+ });