@learncard/sss-key-manager 0.1.24 → 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 +2891 -355
  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 +2891 -355
  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
package/src/api-client.ts CHANGED
@@ -5,12 +5,21 @@
5
5
  import type {
6
6
  AuthProvider,
7
7
  AuthProviderType,
8
- ContactMethod,
9
8
  ServerEncryptedShare,
10
9
  RecoveryMethodInfo,
11
10
  EncryptedShare,
12
11
  SecurityLevel,
12
+ SssActivationState,
13
+ IdentityRecoverySession,
13
14
  } from './types';
15
+ import type { EmailRelayBranding } from './email-relay-crypto';
16
+
17
+ const DID_CHALLENGE_REQUIRED_ERROR =
18
+ 'This operation requires the DID-challenge flow; use createSSSStrategy';
19
+
20
+ const throwDidChallengeRequired = (): never => {
21
+ throw new Error(DID_CHALLENGE_REQUIRED_ERROR);
22
+ };
14
23
 
15
24
  export interface GetAuthShareResponse {
16
25
  authShare: ServerEncryptedShare | null;
@@ -18,13 +27,16 @@ export interface GetAuthShareResponse {
18
27
  securityLevel: SecurityLevel;
19
28
  recoveryMethods: RecoveryMethodInfo[];
20
29
  keyProvider: 'web3auth' | 'sss';
30
+ shareVersion: number;
21
31
  maskedRecoveryEmail?: string | null;
32
+ sssActivationState: SssActivationState;
22
33
  }
23
34
 
24
35
  export interface StoreAuthShareInput {
25
36
  authShare: ServerEncryptedShare;
26
37
  primaryDid: string;
27
38
  securityLevel?: SecurityLevel;
39
+ sssActivationState?: 'provisional';
28
40
  }
29
41
 
30
42
  export interface StoreRecoveryShareInput {
@@ -34,11 +46,23 @@ export interface StoreRecoveryShareInput {
34
46
  shareVersion?: number;
35
47
  }
36
48
 
49
+ export interface RecoverySessionMaterial {
50
+ authShare: ServerEncryptedShare;
51
+ encryptedShare?: EncryptedShare;
52
+ primaryDid: string;
53
+ shareVersion: number;
54
+ rebindSessionToken: string;
55
+ }
56
+
37
57
  export interface ApiClientConfig {
38
58
  serverUrl: string;
39
59
  authProvider: AuthProvider;
60
+ escrowRelayPublicKey?: string;
61
+ escrowRelayKeyId?: string;
62
+ emailBranding?: EmailRelayBranding;
40
63
  }
41
64
 
65
+ /** @deprecated Use `createSSSStrategy`, which implements the hardened DID-challenge flow. */
42
66
  export class SSSApiClient {
43
67
  private serverUrl: string;
44
68
  private authProvider: AuthProvider;
@@ -52,23 +76,10 @@ export class SSSApiClient {
52
76
  const token = await this.authProvider.getIdToken();
53
77
  return {
54
78
  'Content-Type': 'application/json',
55
- Authorization: `Bearer ${token}`,
79
+ 'X-Auth-Token': token,
56
80
  };
57
81
  }
58
82
 
59
- private async getContactMethodFromUser(): Promise<ContactMethod | null> {
60
- const user = await this.authProvider.getCurrentUser();
61
- if (!user) return null;
62
-
63
- if (user.email) {
64
- return { type: 'email' as const, value: user.email.toLowerCase() };
65
- }
66
- if (user.phone) {
67
- return { type: 'phone' as const, value: user.phone };
68
- }
69
- return null;
70
- }
71
-
72
83
  async getAuthShare(): Promise<GetAuthShareResponse | null> {
73
84
  const headers = await this.getAuthHeaders();
74
85
  const providerType = this.authProvider.getProviderType();
@@ -89,54 +100,28 @@ export class SSSApiClient {
89
100
  return response.json();
90
101
  }
91
102
 
92
- async storeAuthShare(input: StoreAuthShareInput): Promise<void> {
93
- const headers = await this.getAuthHeaders();
94
- const providerType = this.authProvider.getProviderType();
95
-
96
- const response = await fetch(`${this.serverUrl}/keys/auth-share`, {
97
- method: 'PUT',
98
- headers,
99
- body: JSON.stringify({
100
- ...input,
101
- providerType,
102
- }),
103
- });
104
-
105
- if (!response.ok) {
106
- throw new Error(`Failed to store auth share: ${response.statusText}`);
107
- }
103
+ /** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
104
+ async storeAuthShare(_input: StoreAuthShareInput): Promise<void> {
105
+ throwDidChallengeRequired();
108
106
  }
109
107
 
110
- async addRecoveryMethod(input: StoreRecoveryShareInput): Promise<void> {
111
- const headers = await this.getAuthHeaders();
112
- const providerType = this.authProvider.getProviderType();
113
-
114
- const response = await fetch(`${this.serverUrl}/keys/recovery`, {
115
- method: 'POST',
116
- headers,
117
- body: JSON.stringify({
118
- ...input,
119
- providerType,
120
- }),
121
- });
122
-
123
- if (!response.ok) {
124
- throw new Error(`Failed to add recovery method: ${response.statusText}`);
125
- }
108
+ /** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
109
+ async addRecoveryMethod(_input: StoreRecoveryShareInput): Promise<void> {
110
+ throwDidChallengeRequired();
126
111
  }
127
112
 
128
113
  async getRecoveryShare(
129
114
  type: 'passkey' | 'backup' | 'phrase' | 'email',
130
115
  credentialId?: string
131
116
  ): Promise<{ encryptedShare?: EncryptedShare; shareVersion?: number } | null> {
117
+ // P0-4: authToken travels via the X-Auth-Token header — never as a
118
+ // query param, which would land in proxy/ALB access logs. A
119
+ // dedicated header (not Authorization) avoids colliding with
120
+ // clients that send a DID-Auth VP as Authorization for this route.
132
121
  const token = await this.authProvider.getIdToken();
133
122
  const providerType = this.authProvider.getProviderType();
134
123
 
135
- const params = new URLSearchParams({
136
- type,
137
- providerType,
138
- authToken: token,
139
- });
124
+ const params = new URLSearchParams({ type, providerType });
140
125
 
141
126
  if (credentialId) {
142
127
  params.append('credentialId', credentialId);
@@ -144,9 +129,7 @@ export class SSSApiClient {
144
129
 
145
130
  const response = await fetch(`${this.serverUrl}/keys/recovery?${params}`, {
146
131
  method: 'GET',
147
- headers: {
148
- 'Content-Type': 'application/json',
149
- },
132
+ headers: { 'Content-Type': 'application/json', 'X-Auth-Token': token },
150
133
  });
151
134
 
152
135
  if (!response.ok) {
@@ -159,99 +142,107 @@ export class SSSApiClient {
159
142
  return response.json();
160
143
  }
161
144
 
162
- async markMigrated(): Promise<void> {
163
- const headers = await this.getAuthHeaders();
164
- const providerType = this.authProvider.getProviderType();
165
-
166
- const response = await fetch(`${this.serverUrl}/keys/migrate`, {
145
+ async startIdentityRecovery(email: string): Promise<void> {
146
+ const response = await fetch(`${this.serverUrl}/keys/recovery-session/start`, {
167
147
  method: 'POST',
168
- headers,
169
- body: JSON.stringify({ providerType }),
148
+ headers: { 'Content-Type': 'application/json' },
149
+ body: JSON.stringify({ email }),
170
150
  });
171
151
 
172
152
  if (!response.ok) {
173
- throw new Error(`Failed to mark as migrated: ${response.statusText}`);
153
+ throw new Error(`Failed to send recovery code: ${response.statusText}`);
174
154
  }
175
155
  }
176
156
 
177
- async sendEmailBackupShare(emailShare: string, overrideEmail?: string): Promise<void> {
178
- const headers = await this.getAuthHeaders();
179
- const providerType = this.authProvider.getProviderType();
180
-
181
- let email = overrideEmail;
182
-
183
- if (!email) {
184
- const user = await this.authProvider.getCurrentUser();
185
- email = user?.email;
186
- }
187
-
188
- if (!email) {
189
- console.warn('Cannot send email backup share: no email address available');
190
- return;
191
- }
192
-
193
- const response = await fetch(`${this.serverUrl}/keys/email-backup`, {
157
+ async verifyIdentityRecovery(email: string, code: string): Promise<IdentityRecoverySession> {
158
+ const response = await fetch(`${this.serverUrl}/keys/recovery-session/verify`, {
194
159
  method: 'POST',
195
- headers,
196
- body: JSON.stringify({
197
- providerType,
198
- emailShare,
199
- email,
200
- }),
160
+ headers: { 'Content-Type': 'application/json' },
161
+ body: JSON.stringify({ email, code }),
201
162
  });
202
163
 
203
164
  if (!response.ok) {
204
- // Non-fatal: log but don't throw — the user can still use the app
205
- console.warn(`Failed to send email backup share: ${response.statusText}`);
165
+ throw new Error(`Failed to verify recovery code: ${response.statusText}`);
206
166
  }
207
- }
208
167
 
209
- async addRecoveryEmail(email: string): Promise<void> {
210
- const token = await this.authProvider.getIdToken();
211
- const providerType = this.authProvider.getProviderType();
168
+ return response.json();
169
+ }
212
170
 
213
- const response = await fetch(`${this.serverUrl}/keys/recovery-email/add`, {
171
+ async useIdentityRecoverySession(
172
+ recoverySessionToken: string,
173
+ type: StoreRecoveryShareInput['type'],
174
+ credentialId?: string
175
+ ): Promise<RecoverySessionMaterial> {
176
+ const response = await fetch(`${this.serverUrl}/keys/recovery-session/recover`, {
214
177
  method: 'POST',
215
178
  headers: { 'Content-Type': 'application/json' },
216
- body: JSON.stringify({ authToken: token, providerType, email }),
179
+ body: JSON.stringify({ recoverySessionToken, type, credentialId }),
217
180
  });
218
181
 
219
182
  if (!response.ok) {
220
- const data = await response.json().catch(() => ({}));
221
- throw new Error(data?.message || `Failed to add recovery email: ${response.statusText}`);
183
+ throw new Error(`Failed to retrieve recovery data: ${response.statusText}`);
222
184
  }
223
- }
224
185
 
225
- async verifyRecoveryEmail(code: string): Promise<{ maskedEmail: string }> {
226
- const token = await this.authProvider.getIdToken();
227
- const providerType = this.authProvider.getProviderType();
186
+ return response.json();
187
+ }
228
188
 
229
- const response = await fetch(`${this.serverUrl}/keys/recovery-email/verify`, {
189
+ async completeIdentityRecoveryRebind(input: {
190
+ recoverySessionToken: string;
191
+ newAuthToken: string;
192
+ providerType: AuthProviderType;
193
+ primaryDid: string;
194
+ authShare: ServerEncryptedShare;
195
+ didAuthVp: string;
196
+ }): Promise<{ shareVersion: number; recoveryMethodsRequireConfirmation: string[] }> {
197
+ const response = await fetch(`${this.serverUrl}/keys/recovery-session/rebind`, {
230
198
  method: 'POST',
231
- headers: { 'Content-Type': 'application/json' },
232
- body: JSON.stringify({ authToken: token, providerType, code }),
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ Authorization: `Bearer ${input.didAuthVp}`,
202
+ 'X-Auth-Token': input.newAuthToken,
203
+ },
204
+ body: JSON.stringify({
205
+ recoverySessionToken: input.recoverySessionToken,
206
+ providerType: input.providerType,
207
+ primaryDid: input.primaryDid,
208
+ authShare: input.authShare,
209
+ }),
233
210
  });
234
211
 
235
212
  if (!response.ok) {
236
- const data = await response.json().catch(() => ({}));
237
- throw new Error(data?.message || `Failed to verify recovery email: ${response.statusText}`);
213
+ throw new Error(`Failed to bind the new sign-in: ${response.statusText}`);
238
214
  }
239
215
 
240
216
  return response.json();
241
217
  }
242
218
 
243
- async deleteUserKey(): Promise<void> {
244
- const headers = await this.getAuthHeaders();
245
- const providerType = this.authProvider.getProviderType();
219
+ /** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
220
+ async markMigrated(): Promise<void> {
221
+ throwDidChallengeRequired();
222
+ }
246
223
 
247
- const response = await fetch(`${this.serverUrl}/keys`, {
248
- method: 'DELETE',
249
- headers,
250
- body: JSON.stringify({ providerType }),
251
- });
224
+ /** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
225
+ async activate(): Promise<void> {
226
+ throwDidChallengeRequired();
227
+ }
252
228
 
253
- if (!response.ok) {
254
- throw new Error(`Failed to delete user key: ${response.statusText}`);
255
- }
229
+ /** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
230
+ async sendEmailBackupShare(_emailShare: string, _overrideEmail?: string): Promise<void> {
231
+ throwDidChallengeRequired();
232
+ }
233
+
234
+ /** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
235
+ async addRecoveryEmail(_email: string): Promise<void> {
236
+ throwDidChallengeRequired();
237
+ }
238
+
239
+ /** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
240
+ async verifyRecoveryEmail(_code: string): Promise<{ maskedEmail: string }> {
241
+ return throwDidChallengeRequired();
242
+ }
243
+
244
+ /** @deprecated Requires DID-challenge authorization. Use `createSSSStrategy`. */
245
+ async deleteUserKey(): Promise<void> {
246
+ throwDidChallengeRequired();
256
247
  }
257
248
  }
@@ -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