@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
@@ -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
@@ -43,6 +43,67 @@ 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 { verifyNitroAttestationDocument, NitroAttestationError } from './escrow-nitro-attestation';
81
+ export type { NitroAttestationReason } from './escrow-nitro-attestation';
82
+ export type { EscrowAttestationPolicy, EscrowHoldStatus, EscrowRecoveryStart } from './types';
83
+ export type {
84
+ EscrowEnrollmentOptions,
85
+ EscrowEnrollmentState,
86
+ EscrowBlobStaleReason,
87
+ EscrowPinStatus,
88
+ } from './types';
89
+ export {
90
+ EscrowPinMismatchError,
91
+ EscrowPinLockedError,
92
+ EscrowPinThrottledError,
93
+ EscrowHoldRestartThrottledError,
94
+ EscrowPinUnavailableError,
95
+ } from './types';
96
+ export {
97
+ PIN_MIN_LENGTH,
98
+ PIN_MAX_LENGTH,
99
+ ESCROW_PIN_MAX_ATTEMPTS,
100
+ normalizePin,
101
+ validatePin,
102
+ generatePinSalt,
103
+ derivePinProof,
104
+ constantTimeEqualHex,
105
+ } from './escrow-pin';
106
+
46
107
  export {
47
108
  createPasskeyCredential,
48
109
  deriveKeyFromPasskey,
@@ -60,6 +121,7 @@ export {
60
121
  generateRecoveryPhrase,
61
122
  validateRecoveryPhrase,
62
123
  countWords,
124
+ buildRecoveryPhraseChallengeOptions,
63
125
  } from './recovery-phrase';
64
126
 
65
127
  export type { RecoveryPhraseData } from './recovery-phrase';
@@ -71,6 +133,7 @@ export {
71
133
  atomicRecovery,
72
134
  ShareVerificationError,
73
135
  AtomicUpdateError,
136
+ ShareWriteRejectedError,
74
137
  } from './atomic-operations';
75
138
 
76
139
  export type {
@@ -79,7 +142,11 @@ export type {
79
142
  StorageOperations,
80
143
  } from './atomic-operations';
81
144
 
82
- export { createSSSStrategy } from './sss-strategy';
145
+ export {
146
+ createSSSStrategy,
147
+ IdentityRecoverySessionConsumedError,
148
+ EscrowRequestError,
149
+ } from './sss-strategy';
83
150
 
84
151
  export type { SSSStorageFunctions, SSSStrategyConfig } from './sss-strategy';
85
152
 
@@ -125,6 +192,7 @@ export type {
125
192
  AuthProvider,
126
193
  AuthUser,
127
194
  AuthProviderType,
195
+ DidAuthVpSigner,
128
196
  ContactMethod,
129
197
  ContactMethodType,
130
198
  RecoveryMethod,
@@ -134,6 +202,7 @@ export type {
134
202
  RecoveryResult,
135
203
  RecoverySetupInput,
136
204
  RecoverySetupResult,
205
+ RecoveryConfirmationInput,
137
206
  PasskeyRecoveryMethod,
138
207
  BackupFileRecoveryMethod,
139
208
  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.includes('/attestation?nonce=')) {
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.includes('/attestation?nonce=')) 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
+ });