@zerodev/wallet-core 0.0.3 → 0.0.5

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 (130) hide show
  1. package/dist/_cjs/actions/auth/getAuthenticators.js.map +1 -1
  2. package/dist/_cjs/actions/auth/index.js +3 -1
  3. package/dist/_cjs/actions/auth/index.js.map +1 -1
  4. package/dist/_cjs/actions/auth/logout.js +23 -0
  5. package/dist/_cjs/actions/auth/logout.js.map +1 -0
  6. package/dist/_cjs/actions/index.js +2 -1
  7. package/dist/_cjs/actions/index.js.map +1 -1
  8. package/dist/_cjs/actions/wallet/getUserWallet.js +1 -1
  9. package/dist/_cjs/actions/wallet/getUserWallet.js.map +1 -1
  10. package/dist/_cjs/actions/wallet/signingUtils.js +23 -3
  11. package/dist/_cjs/actions/wallet/signingUtils.js.map +1 -1
  12. package/dist/_cjs/adapters/viem.js +26 -9
  13. package/dist/_cjs/adapters/viem.js.map +1 -1
  14. package/dist/_cjs/client/decorators/client.js +1 -0
  15. package/dist/_cjs/client/decorators/client.js.map +1 -1
  16. package/dist/_cjs/client/transports/rest.js.map +1 -1
  17. package/dist/_cjs/core/createZeroDevWalletCore.js +297 -168
  18. package/dist/_cjs/core/createZeroDevWalletCore.js.map +1 -1
  19. package/dist/_cjs/index.js +2 -1
  20. package/dist/_cjs/index.js.map +1 -1
  21. package/dist/_cjs/index.native.js +2 -1
  22. package/dist/_cjs/index.native.js.map +1 -1
  23. package/dist/_cjs/native/stampers/secureStore.js +94 -17
  24. package/dist/_cjs/native/stampers/secureStore.js.map +1 -1
  25. package/dist/_cjs/stampers/indexedDbStamper.js +34 -0
  26. package/dist/_cjs/stampers/indexedDbStamper.js.map +1 -1
  27. package/dist/_cjs/storage/manager.js +144 -17
  28. package/dist/_cjs/storage/manager.js.map +1 -1
  29. package/dist/_cjs/utils/buildClientSignature.js +4 -1
  30. package/dist/_cjs/utils/buildClientSignature.js.map +1 -1
  31. package/dist/_cjs/utils/derToRawSignature.js +12 -0
  32. package/dist/_cjs/utils/derToRawSignature.js.map +1 -1
  33. package/dist/_cjs/utils/exportPrivateKey.js +1 -1
  34. package/dist/_cjs/utils/exportPrivateKey.js.map +1 -1
  35. package/dist/_cjs/utils/utils.js +27 -6
  36. package/dist/_cjs/utils/utils.js.map +1 -1
  37. package/dist/_esm/actions/auth/getAuthenticators.js +3 -2
  38. package/dist/_esm/actions/auth/getAuthenticators.js.map +1 -1
  39. package/dist/_esm/actions/auth/index.js +1 -0
  40. package/dist/_esm/actions/auth/index.js.map +1 -1
  41. package/dist/_esm/actions/auth/logout.js +21 -0
  42. package/dist/_esm/actions/auth/logout.js.map +1 -0
  43. package/dist/_esm/actions/index.js +1 -1
  44. package/dist/_esm/actions/index.js.map +1 -1
  45. package/dist/_esm/actions/wallet/getUserWallet.js +3 -1
  46. package/dist/_esm/actions/wallet/getUserWallet.js.map +1 -1
  47. package/dist/_esm/actions/wallet/signingUtils.js +24 -4
  48. package/dist/_esm/actions/wallet/signingUtils.js.map +1 -1
  49. package/dist/_esm/adapters/viem.js +27 -10
  50. package/dist/_esm/adapters/viem.js.map +1 -1
  51. package/dist/_esm/client/decorators/client.js +2 -1
  52. package/dist/_esm/client/decorators/client.js.map +1 -1
  53. package/dist/_esm/client/transports/rest.js.map +1 -1
  54. package/dist/_esm/core/createZeroDevWalletCore.js +320 -186
  55. package/dist/_esm/core/createZeroDevWalletCore.js.map +1 -1
  56. package/dist/_esm/index.js +1 -1
  57. package/dist/_esm/index.js.map +1 -1
  58. package/dist/_esm/index.native.js +1 -1
  59. package/dist/_esm/index.native.js.map +1 -1
  60. package/dist/_esm/native/stampers/secureStore.js +111 -18
  61. package/dist/_esm/native/stampers/secureStore.js.map +1 -1
  62. package/dist/_esm/stampers/indexedDbStamper.js +36 -0
  63. package/dist/_esm/stampers/indexedDbStamper.js.map +1 -1
  64. package/dist/_esm/storage/manager.js +150 -22
  65. package/dist/_esm/storage/manager.js.map +1 -1
  66. package/dist/_esm/utils/buildClientSignature.js +4 -1
  67. package/dist/_esm/utils/buildClientSignature.js.map +1 -1
  68. package/dist/_esm/utils/derToRawSignature.js +12 -0
  69. package/dist/_esm/utils/derToRawSignature.js.map +1 -1
  70. package/dist/_esm/utils/exportPrivateKey.js +1 -1
  71. package/dist/_esm/utils/exportPrivateKey.js.map +1 -1
  72. package/dist/_esm/utils/utils.js +27 -6
  73. package/dist/_esm/utils/utils.js.map +1 -1
  74. package/dist/_types/actions/auth/getAuthenticators.d.ts +19 -2
  75. package/dist/_types/actions/auth/getAuthenticators.d.ts.map +1 -1
  76. package/dist/_types/actions/auth/index.d.ts +2 -1
  77. package/dist/_types/actions/auth/index.d.ts.map +1 -1
  78. package/dist/_types/actions/auth/logout.d.ts +11 -0
  79. package/dist/_types/actions/auth/logout.d.ts.map +1 -0
  80. package/dist/_types/actions/index.d.ts +1 -1
  81. package/dist/_types/actions/index.d.ts.map +1 -1
  82. package/dist/_types/actions/wallet/getUserWallet.d.ts.map +1 -1
  83. package/dist/_types/actions/wallet/signingUtils.d.ts.map +1 -1
  84. package/dist/_types/adapters/viem.d.ts.map +1 -1
  85. package/dist/_types/client/decorators/client.d.ts +3 -1
  86. package/dist/_types/client/decorators/client.d.ts.map +1 -1
  87. package/dist/_types/client/transports/rest.d.ts +3 -3
  88. package/dist/_types/client/transports/rest.d.ts.map +1 -1
  89. package/dist/_types/core/createZeroDevWalletCore.d.ts +8 -1
  90. package/dist/_types/core/createZeroDevWalletCore.d.ts.map +1 -1
  91. package/dist/_types/index.d.ts +2 -2
  92. package/dist/_types/index.d.ts.map +1 -1
  93. package/dist/_types/index.native.d.ts +2 -2
  94. package/dist/_types/index.native.d.ts.map +1 -1
  95. package/dist/_types/native/stampers/secureStore.d.ts.map +1 -1
  96. package/dist/_types/stampers/indexedDbStamper.d.ts.map +1 -1
  97. package/dist/_types/stampers/types.d.ts +6 -0
  98. package/dist/_types/stampers/types.d.ts.map +1 -1
  99. package/dist/_types/storage/manager.d.ts +3 -0
  100. package/dist/_types/storage/manager.d.ts.map +1 -1
  101. package/dist/_types/types/session.d.ts +2 -0
  102. package/dist/_types/types/session.d.ts.map +1 -1
  103. package/dist/_types/utils/buildClientSignature.d.ts +1 -1
  104. package/dist/_types/utils/buildClientSignature.d.ts.map +1 -1
  105. package/dist/_types/utils/derToRawSignature.d.ts.map +1 -1
  106. package/dist/_types/utils/utils.d.ts +1 -1
  107. package/dist/_types/utils/utils.d.ts.map +1 -1
  108. package/dist/tsconfig.build.tsbuildinfo +1 -1
  109. package/package.json +1 -1
  110. package/src/actions/auth/getAuthenticators.ts +20 -2
  111. package/src/actions/auth/index.ts +6 -0
  112. package/src/actions/auth/logout.ts +35 -0
  113. package/src/actions/index.ts +4 -0
  114. package/src/actions/wallet/getUserWallet.ts +3 -1
  115. package/src/actions/wallet/signingUtils.ts +43 -4
  116. package/src/adapters/viem.ts +40 -12
  117. package/src/client/decorators/client.ts +7 -0
  118. package/src/client/transports/rest.ts +4 -4
  119. package/src/core/createZeroDevWalletCore.ts +393 -205
  120. package/src/index.native.ts +4 -0
  121. package/src/index.ts +4 -0
  122. package/src/native/stampers/secureStore.ts +119 -24
  123. package/src/stampers/indexedDbStamper.ts +47 -0
  124. package/src/stampers/types.ts +6 -0
  125. package/src/storage/manager.ts +205 -25
  126. package/src/types/session.ts +2 -0
  127. package/src/utils/buildClientSignature.ts +4 -2
  128. package/src/utils/derToRawSignature.ts +16 -2
  129. package/src/utils/exportPrivateKey.ts +1 -1
  130. package/src/utils/utils.ts +30 -9
@@ -7,11 +7,8 @@ import {
7
7
  type ZeroDevWalletClient,
8
8
  zeroDevWalletTransport,
9
9
  } from '../client/index.js'
10
- import {
11
- DEFAULT_ORGANIZATION_ID,
12
- DEFAULT_SESSION_EXPIRATION_IN_SECONDS,
13
- KMS_SERVER_URL,
14
- } from '../constants.js'
10
+ import { DEFAULT_ORGANIZATION_ID, KMS_SERVER_URL } from '../constants.js'
11
+ import { RestRequestError } from '../errors/request.js'
15
12
  import { createNoopPasskeyStamper } from '../stampers/noopPasskeyStamper.js'
16
13
  import type { ApiKeyStamper, PasskeyStamper } from '../stampers/types.js'
17
14
  import {
@@ -102,11 +99,20 @@ export interface ZeroDevWalletSDK {
102
99
  sessionId?: string,
103
100
  ) => Promise<ZeroDevWalletSession | undefined>
104
101
 
105
- logout: () => Promise<boolean>
102
+ /**
103
+ * Revoke the active remote key, then erase local state. `force` still tries
104
+ * remote revocation first but permits local recovery if that attempt fails;
105
+ * use it only when accepting that the remote key may remain until expiry.
106
+ */
107
+ logout: (options?: { force?: boolean }) => Promise<boolean>
106
108
 
107
109
  toAccount: () => Promise<LocalAccount>
108
110
  }
109
111
 
112
+ // The browser/native key vault is a single physical slot shared by SDK
113
+ // instances, so key transitions must be serialized across projects.
114
+ let keyTransitionTail = Promise.resolve()
115
+
110
116
  export async function createZeroDevWalletCore(
111
117
  config: ZeroDevWalletConfigCore,
112
118
  ): Promise<ZeroDevWalletSDK> {
@@ -137,12 +143,119 @@ export async function createZeroDevWalletCore(
137
143
  fallback: DEFAULT_ORGANIZATION_ID,
138
144
  })
139
145
 
146
+ let preparedOAuthPublicKey: string | undefined
147
+
148
+ const prepareFreshKeyRotation = async () => {
149
+ if (preparedOAuthPublicKey) {
150
+ await client.apiKeyStamper.discardKeyRotation()
151
+ preparedOAuthPublicKey = undefined
152
+ }
153
+ return client.apiKeyStamper.prepareKeyRotation()
154
+ }
155
+
156
+ const withKeyTransition = async <T>(transition: () => Promise<T>) => {
157
+ const result = keyTransitionTail.then(transition)
158
+ keyTransitionTail = result.then(
159
+ () => undefined,
160
+ () => undefined,
161
+ )
162
+ return result
163
+ }
164
+
165
+ const normalizeKey = (key: string) => key.replace(/^0x/, '').toLowerCase()
166
+
167
+ const createReplacementSession = (
168
+ token: string,
169
+ expectedPublicKey: string,
170
+ idPrefix: 'indexedDb' | 'oauth' | 'otp',
171
+ sessionType?: SessionType,
172
+ ): ZeroDevWalletSession => {
173
+ const parsed = parseSession(token)
174
+ if (
175
+ !parsed.publicKey ||
176
+ normalizeKey(parsed.publicKey) !== normalizeKey(expectedPublicKey)
177
+ ) {
178
+ throw new Error(
179
+ 'Session public key does not match the locally prepared signing key',
180
+ )
181
+ }
182
+ const now = Date.now()
183
+ return {
184
+ id: `session_${idPrefix}_${now}`,
185
+ userId: parsed.userId,
186
+ organizationId: parsed.organizationId,
187
+ stamperType: 'apiKey',
188
+ sessionType: sessionType ?? parsed.sessionType ?? SessionType.READ_WRITE,
189
+ token,
190
+ publicKey: expectedPublicKey,
191
+ expiry: parsed.expiry,
192
+ createdAt: now,
193
+ }
194
+ }
195
+
196
+ const commitReplacementSession = async (
197
+ session: ZeroDevWalletSession,
198
+ targetPublicKey: string,
199
+ ) => {
200
+ await sessionStorageManager.stageSessionTransition(session, targetPublicKey)
201
+ try {
202
+ await client.apiKeyStamper.commitKeyRotation()
203
+ } catch (error) {
204
+ await client.apiKeyStamper.discardKeyRotation()
205
+ const activePublicKey = await client.apiKeyStamper.getPublicKey()
206
+ const recovered =
207
+ await sessionStorageManager.recoverSessionTransition(activePublicKey)
208
+ if (
209
+ !activePublicKey ||
210
+ normalizeKey(activePublicKey) !== normalizeKey(targetPublicKey) ||
211
+ !recovered
212
+ ) {
213
+ throw error
214
+ }
215
+ return
216
+ }
217
+
218
+ try {
219
+ const committed = await sessionStorageManager.commitSessionTransition()
220
+ if (!committed) {
221
+ throw new Error('Session transition journal disappeared before commit')
222
+ }
223
+ } catch (error) {
224
+ const recovered = await sessionStorageManager.recoverSessionTransition(
225
+ await client.apiKeyStamper.getPublicKey(),
226
+ )
227
+ if (!recovered) throw error
228
+ }
229
+ }
230
+
231
+ await withKeyTransition(async () => {
232
+ const activePublicKey = await client.apiKeyStamper.getPublicKey()
233
+ await sessionStorageManager.recoverSessionTransition(activePublicKey)
234
+ const restoredSession = await sessionStorageManager.getActiveSession()
235
+ if (restoredSession) {
236
+ try {
237
+ const restoredPublicKey = parseSession(restoredSession.token).publicKey
238
+ if (
239
+ !activePublicKey ||
240
+ !restoredPublicKey ||
241
+ normalizeKey(activePublicKey) !== normalizeKey(restoredPublicKey)
242
+ ) {
243
+ await sessionStorageManager.clearAllSessions()
244
+ }
245
+ } catch {
246
+ await sessionStorageManager.clearAllSessions()
247
+ }
248
+ }
249
+ })
250
+
140
251
  return {
141
252
  client,
142
253
  async getPublicKey() {
143
- await client.apiKeyStamper.resetKeyPair()
144
- const compressedPublicKey = await client.apiKeyStamper.getPublicKey()
145
- return compressedPublicKey
254
+ return withKeyTransition(async () => {
255
+ if (preparedOAuthPublicKey) return preparedOAuthPublicKey
256
+ preparedOAuthPublicKey = await client.apiKeyStamper.prepareKeyRotation()
257
+ return preparedOAuthPublicKey
258
+ })
146
259
  },
147
260
 
148
261
  async getSession() {
@@ -159,87 +272,113 @@ export async function createZeroDevWalletCore(
159
272
  },
160
273
 
161
274
  async switchSession(sessionId: string) {
162
- await sessionStorageManager.setActiveSession(sessionId)
163
- const session = await sessionStorageManager.getActiveSession()
164
-
165
- return session
275
+ const activeSessionKey = await sessionStorageManager.getActiveSessionKey()
276
+ if (sessionId !== activeSessionKey) {
277
+ throw new Error(
278
+ 'Session switching is not supported with a single active key vault',
279
+ )
280
+ }
281
+ return sessionStorageManager.getActiveSession()
166
282
  },
167
283
 
168
284
  async clearSession(sessionId: string) {
285
+ if ((await sessionStorageManager.getActiveSessionKey()) === sessionId) {
286
+ throw new Error(
287
+ 'Refusing to clear the active session locally; use logout() to revoke its remote key first.',
288
+ )
289
+ }
169
290
  await sessionStorageManager.clearSession(sessionId)
170
291
  },
171
292
 
172
293
  async clearAllSessions() {
294
+ if (await sessionStorageManager.getActiveSession()) {
295
+ throw new Error(
296
+ 'Refusing to clear the active session locally; use logout() to revoke its remote key first.',
297
+ )
298
+ }
173
299
  await sessionStorageManager.clearAllSessions()
174
300
  },
175
301
 
176
302
  async refreshSession(sessionId?: string) {
177
- const activeSession = sessionId
178
- ? await sessionStorageManager.getSession(sessionId)
179
- : await sessionStorageManager.getActiveSession()
180
- if (!activeSession) {
181
- throw new Error('No active session')
182
- }
183
- if (activeSession.stamperType === 'apiKey') {
184
- const compressedPublicKeyHex =
185
- await client.apiKeyStamper.prepareKeyRotation()
186
- const data = await client.loginWithStamp({
187
- targetPublicKey: compressedPublicKeyHex,
188
- projectId,
189
- // Stamp-login is signed against the Turnkey parent org; the backend
190
- // resolves the sub-org from the stamped credential. Signing the
191
- // sub-org here makes the relayed payload's org mismatch the
192
- // signature → Turnkey SIGNATURE_INVALID.
193
- organizationId: await resolveOrganizationId(),
194
- stampWith: 'apiKey',
195
- })
196
- await client.apiKeyStamper.commitKeyRotation()
197
- const parsedSession = parseSession(data.session)
198
- const session: ZeroDevWalletSession = {
199
- id: `session_indexedDb_${Date.now()}`,
200
- userId: parsedSession.userId,
201
- organizationId: parsedSession.organizationId,
202
- stamperType: 'apiKey',
203
- sessionType: SessionType.READ_WRITE,
204
- token: data.session,
205
- expiry: parsedSession.expiry,
206
- createdAt: Date.now(),
303
+ return withKeyTransition(async () => {
304
+ const activeSessionKey =
305
+ await sessionStorageManager.getActiveSessionKey()
306
+ if (sessionId && sessionId !== activeSessionKey) {
307
+ throw new Error(
308
+ 'Refreshing an inactive session is not supported with a single active key vault',
309
+ )
207
310
  }
208
- await sessionStorageManager.clearSession(activeSession.id)
209
- await sessionStorageManager.storeSession(session, session.id)
210
- return session
211
- }
212
- throw new Error('Invalid session type')
311
+ const activeSession = await sessionStorageManager.getActiveSession()
312
+ if (!activeSession) {
313
+ throw new Error('No active session')
314
+ }
315
+ if (activeSession.stamperType !== 'apiKey') {
316
+ throw new Error('Invalid session type')
317
+ }
318
+
319
+ const targetPublicKey = await prepareFreshKeyRotation()
320
+ try {
321
+ const data = await client.loginWithStamp({
322
+ targetPublicKey,
323
+ projectId,
324
+ // Stamp-login is signed against the Turnkey parent org; the backend
325
+ // resolves the sub-org from the stamped credential. Signing the
326
+ // sub-org here makes the relayed payload's org mismatch the
327
+ // signature → Turnkey SIGNATURE_INVALID.
328
+ organizationId: await resolveOrganizationId(),
329
+ stampWith: 'apiKey',
330
+ })
331
+ const session = createReplacementSession(
332
+ data.session,
333
+ targetPublicKey,
334
+ 'indexedDb',
335
+ SessionType.READ_WRITE,
336
+ )
337
+ await commitReplacementSession(session, targetPublicKey)
338
+ return session
339
+ } catch (error) {
340
+ await client.apiKeyStamper.discardKeyRotation()
341
+ throw error
342
+ }
343
+ })
213
344
  },
214
345
 
215
346
  // [TODO] refactor to smaller utils/actions
216
347
  async auth(params: AuthParams) {
217
348
  switch (params.type) {
218
349
  case 'oauth': {
219
- const popSignature = await client.apiKeyStamper.sign(params.sessionId)
220
- const data = await client.authenticateWithOAuth({
221
- provider: params.provider,
222
- projectId,
223
- sessionId: params.sessionId,
224
- popSignature,
225
- })
350
+ return withKeyTransition(async () => {
351
+ const targetPublicKey =
352
+ preparedOAuthPublicKey ??
353
+ (await client.apiKeyStamper.prepareKeyRotation())
354
+ preparedOAuthPublicKey = undefined
355
+ try {
356
+ const popSignature = await client.apiKeyStamper.signPending(
357
+ params.sessionId,
358
+ )
359
+ const data = await client.authenticateWithOAuth({
360
+ provider: params.provider,
361
+ projectId,
362
+ sessionId: params.sessionId,
363
+ popSignature,
364
+ })
226
365
 
227
- if (data.session) {
228
- // Parse the JWT to get session data
229
- const parsedSession = parseSession(data.session)
230
- const session: ZeroDevWalletSession = {
231
- id: `session_oauth_${Date.now()}`,
232
- userId: parsedSession.userId,
233
- organizationId: parsedSession.organizationId,
234
- stamperType: 'apiKey',
235
- sessionType: parsedSession.sessionType || SessionType.READ_WRITE,
236
- token: data.session,
237
- expiry: parsedSession.expiry,
238
- createdAt: Date.now(),
366
+ if (!data.session) {
367
+ await client.apiKeyStamper.discardKeyRotation()
368
+ return data
369
+ }
370
+ const session = createReplacementSession(
371
+ data.session,
372
+ targetPublicKey,
373
+ 'oauth',
374
+ )
375
+ await commitReplacementSession(session, targetPublicKey)
376
+ return data
377
+ } catch (error) {
378
+ await client.apiKeyStamper.discardKeyRotation()
379
+ throw error
239
380
  }
240
- await sessionStorageManager.storeSession(session, session.id)
241
- }
242
- return data
381
+ })
243
382
  }
244
383
  case 'passkey': {
245
384
  const { type } = params
@@ -248,83 +387,80 @@ export async function createZeroDevWalletCore(
248
387
  'mode' in params &&
249
388
  params.mode === 'register'
250
389
  ) {
251
- await client.apiKeyStamper.resetKeyPair()
252
- const tempPublicKey = await client.apiKeyStamper.getPublicKey()
253
- if (!tempPublicKey) {
254
- throw new Error('Failed to get public key')
255
- }
256
- const name = `ZeroDevWallet-${humanReadableDateTime()}`
257
- const { attestation, encodedChallenge } =
258
- await passkeyStamper.register({
259
- rp: { id: rpId, name: '' },
260
- userName: name,
261
- })
262
- const data = await client.registerWithPasskey({
263
- attestation,
264
- challenge: encodedChallenge,
265
- projectId,
266
- encodedPublicKey: tempPublicKey,
267
- })
268
- const compressedPublicKeyHex =
269
- await client.apiKeyStamper.prepareKeyRotation()
270
- const loginData = await client.loginWithStamp({
271
- projectId,
272
- targetPublicKey: compressedPublicKeyHex,
273
- // Sign against the parent org (see refreshSession note) — the
274
- // backend derives the sub-org from the stamped credential.
275
- organizationId: await resolveOrganizationId(),
390
+ return withKeyTransition(async () => {
391
+ if (await sessionStorageManager.getActiveSession()) {
392
+ throw new Error(
393
+ 'Passkey registration requires an unauthenticated wallet; logout before registering a new wallet.',
394
+ )
395
+ }
396
+ const registrationPublicKey = await prepareFreshKeyRotation()
397
+ try {
398
+ const name = `ZeroDevWallet-${humanReadableDateTime()}`
399
+ const { attestation, encodedChallenge } =
400
+ await passkeyStamper.register({
401
+ rp: { id: rpId, name: 'ZeroDev Wallet' },
402
+ userName: name,
403
+ })
404
+ const data = await client.registerWithPasskey({
405
+ attestation,
406
+ challenge: encodedChallenge,
407
+ projectId,
408
+ encodedPublicKey: registrationPublicKey,
409
+ })
410
+
411
+ // Registration proves the temporary key exists remotely. Make
412
+ // it active so it can authorize the durable replacement key.
413
+ await client.apiKeyStamper.commitKeyRotation()
414
+ const targetPublicKey = await prepareFreshKeyRotation()
415
+ const loginData = await client.loginWithStamp({
416
+ projectId,
417
+ targetPublicKey,
418
+ // Sign against the parent org (see refreshSession note) — the
419
+ // backend derives the sub-org from the stamped credential.
420
+ organizationId: await resolveOrganizationId(),
421
+ })
422
+ const session = createReplacementSession(
423
+ loginData.session,
424
+ targetPublicKey,
425
+ 'indexedDb',
426
+ )
427
+ await commitReplacementSession(session, targetPublicKey)
428
+ return data
429
+ } catch (error) {
430
+ await client.apiKeyStamper.discardKeyRotation()
431
+ throw error
432
+ }
276
433
  })
277
- await client.apiKeyStamper.commitKeyRotation()
278
- const parsedSession = parseSession(loginData.session)
279
- const session: ZeroDevWalletSession = {
280
- id: `session_indexedDb_${Date.now()}`,
281
- stamperType: 'apiKey',
282
- createdAt: Date.now(),
283
- sessionType: SessionType.READ_WRITE,
284
- userId: parsedSession.userId,
285
- organizationId: parsedSession.organizationId,
286
- expiry:
287
- Date.now() +
288
- Number(DEFAULT_SESSION_EXPIRATION_IN_SECONDS) * 1000,
289
- token: loginData.session,
290
- }
291
- await sessionStorageManager.storeSession(session, session.id)
292
- return data
293
434
  }
294
435
  if (
295
436
  type === 'passkey' &&
296
437
  'mode' in params &&
297
438
  params.mode === 'login'
298
439
  ) {
299
- await client.apiKeyStamper.resetKeyPair()
300
- const generatedPublicKey = await client.apiKeyStamper.getPublicKey()
301
- if (!generatedPublicKey) {
302
- throw new Error('Failed to get public key')
303
- }
304
- const loginData = await client.loginWithStamp({
305
- targetPublicKey: generatedPublicKey,
306
- projectId,
307
- // Sign against the parent org, not the user's sub-org (see the
308
- // refreshSession note). The backend derives the sub-org from the
309
- // stamped passkey credential.
310
- organizationId: await resolveOrganizationId(),
311
- stampWith: 'passkey',
440
+ return withKeyTransition(async () => {
441
+ const targetPublicKey = await prepareFreshKeyRotation()
442
+ try {
443
+ const loginData = await client.loginWithStamp({
444
+ targetPublicKey,
445
+ projectId,
446
+ // Sign against the parent org, not the user's sub-org (see the
447
+ // refreshSession note). The backend derives the sub-org from the
448
+ // stamped passkey credential.
449
+ organizationId: await resolveOrganizationId(),
450
+ stampWith: 'passkey',
451
+ })
452
+ const session = createReplacementSession(
453
+ loginData.session,
454
+ targetPublicKey,
455
+ 'indexedDb',
456
+ )
457
+ await commitReplacementSession(session, targetPublicKey)
458
+ return loginData
459
+ } catch (error) {
460
+ await client.apiKeyStamper.discardKeyRotation()
461
+ throw error
462
+ }
312
463
  })
313
- const parsedSession = parseSession(loginData.session)
314
- const session: ZeroDevWalletSession = {
315
- id: `session_indexedDb_${Date.now()}`,
316
- stamperType: 'apiKey',
317
- createdAt: Date.now(),
318
- sessionType: SessionType.READ_WRITE,
319
- userId: parsedSession.userId,
320
- organizationId: parsedSession.organizationId,
321
- expiry:
322
- Date.now() +
323
- Number(DEFAULT_SESSION_EXPIRATION_IN_SECONDS) * 1000,
324
- token: loginData.session,
325
- }
326
- await sessionStorageManager.storeSession(session, session.id)
327
- return loginData
328
464
  }
329
465
  throw new Error('Passkey authentication requires passkey parameter')
330
466
  }
@@ -369,69 +505,64 @@ export async function createZeroDevWalletCore(
369
505
  }
370
506
 
371
507
  if (otpParams.mode === 'verifyOtp') {
372
- const { otpId, otpCode, otpEncryptionTargetBundle } = otpParams
373
-
374
- // Step 1: Generate new key pair
375
- await client.apiKeyStamper.resetKeyPair()
376
- const targetPublicKey = await client.apiKeyStamper.getPublicKey()
377
-
378
- if (!targetPublicKey) {
379
- throw new Error('Failed to get public key')
380
- }
381
-
382
- // Step 2a: HPKE-seal the OTP attempt to the enclave's per-session
383
- // target key. The auth proxy never sees the plaintext OTP code.
384
- const encryptedOtpBundle = await encryptOtpAttempt({
385
- otpCode,
386
- publicKey: targetPublicKey,
387
- encryptionTargetBundle: otpEncryptionTargetBundle,
388
- })
389
-
390
- // Step 2b: Verify OTP via Auth Proxy
391
- if (!cachedAuthProxyConfigId) {
392
- const { authProxyConfigId } = await client.getAuthProxyConfigId()
393
- cachedAuthProxyConfigId = authProxyConfigId
394
- }
395
- const authProxyClient = createAuthProxyClient({
396
- authProxyConfigId: cachedAuthProxyConfigId,
397
- })
398
-
399
- const { verificationToken } = await authProxyClient.verifyOtp({
400
- otpId,
401
- encryptedOtpBundle,
402
- })
403
-
404
- // Step 3: Build client signature
405
- const clientSignature = await buildClientSignature({
406
- verificationToken,
407
- publicKey: targetPublicKey,
408
- stamper: client.apiKeyStamper,
409
- })
410
-
411
- // Step 4: Login via backend (not Auth Proxy!)
412
- const data = await client.loginWithOTP({
413
- verificationToken,
414
- clientSignature,
415
- projectId,
416
- })
417
-
418
- if (data.session) {
419
- // Parse the JWT to get session data
420
- const parsedSession = parseSession(data.session)
421
- const session: ZeroDevWalletSession = {
422
- id: `session_otp_${Date.now()}`,
423
- userId: parsedSession.userId,
424
- organizationId: parsedSession.organizationId,
425
- stamperType: 'apiKey',
426
- sessionType:
427
- parsedSession.sessionType || SessionType.READ_WRITE,
428
- token: data.session,
429
- expiry: parsedSession.expiry,
430
- createdAt: Date.now(),
508
+ return withKeyTransition(async () => {
509
+ const { otpId, otpCode, otpEncryptionTargetBundle } = otpParams
510
+ const targetPublicKey = await prepareFreshKeyRotation()
511
+
512
+ try {
513
+ // HPKE-seal the OTP attempt to the enclave's per-session target
514
+ // key. The auth proxy never sees the plaintext OTP code.
515
+ const encryptedOtpBundle = await encryptOtpAttempt({
516
+ otpCode,
517
+ publicKey: targetPublicKey,
518
+ encryptionTargetBundle: otpEncryptionTargetBundle,
519
+ })
520
+
521
+ if (!cachedAuthProxyConfigId) {
522
+ const { authProxyConfigId } =
523
+ await client.getAuthProxyConfigId()
524
+ cachedAuthProxyConfigId = authProxyConfigId
525
+ }
526
+ const authProxyClient = createAuthProxyClient({
527
+ authProxyConfigId: cachedAuthProxyConfigId,
528
+ })
529
+
530
+ const { verificationToken } = await authProxyClient.verifyOtp({
531
+ otpId,
532
+ encryptedOtpBundle,
533
+ })
534
+
535
+ const clientSignature = await buildClientSignature({
536
+ verificationToken,
537
+ publicKey: targetPublicKey,
538
+ stamper: {
539
+ stamp: (payload) =>
540
+ client.apiKeyStamper.stampPending(payload),
541
+ },
542
+ })
543
+
544
+ const data = await client.loginWithOTP({
545
+ verificationToken,
546
+ clientSignature,
547
+ projectId,
548
+ })
549
+
550
+ if (!data.session) {
551
+ await client.apiKeyStamper.discardKeyRotation()
552
+ return data
553
+ }
554
+ const session = createReplacementSession(
555
+ data.session,
556
+ targetPublicKey,
557
+ 'otp',
558
+ )
559
+ await commitReplacementSession(session, targetPublicKey)
560
+ return data
561
+ } catch (error) {
562
+ await client.apiKeyStamper.discardKeyRotation()
563
+ throw error
431
564
  }
432
- await sessionStorageManager.storeSession(session, session.id)
433
- }
434
- return data
565
+ })
435
566
  }
436
567
 
437
568
  throw new Error('OTP authentication requires mode parameter')
@@ -441,10 +572,67 @@ export async function createZeroDevWalletCore(
441
572
  }
442
573
  },
443
574
 
444
- async logout() {
445
- await sessionStorageManager.clearAllSessions()
446
- await client.apiKeyStamper.resetKeyPair()
447
- return true
575
+ async logout(options) {
576
+ return withKeyTransition(async () => {
577
+ const session = await sessionStorageManager.getActiveSession()
578
+ if (session) {
579
+ const publicKey = await client.apiKeyStamper.getPublicKey()
580
+ if (publicKey) {
581
+ try {
582
+ const authenticators = await client.getAuthenticators({
583
+ subOrganizationId: session.organizationId,
584
+ projectId,
585
+ token: session.token,
586
+ })
587
+ const sessionKey = authenticators.sessionKeys?.find((key) => {
588
+ const candidate = key.ApiKey ?? key.apiKey
589
+ return (
590
+ candidate !== undefined &&
591
+ normalizeKey(candidate) === normalizeKey(publicKey)
592
+ )
593
+ })
594
+ const apiKeyId = sessionKey?.TurnkeyId ?? sessionKey?.turnkeyId
595
+ if (!apiKeyId) {
596
+ throw new Error(
597
+ 'Current session key was not found in the authenticated key list; refusing to erase local credentials without confirmed remote revocation.',
598
+ )
599
+ }
600
+ await client.logout({
601
+ projectId,
602
+ organizationId: session.organizationId,
603
+ userId: session.userId,
604
+ apiKeyId,
605
+ })
606
+ } catch (error) {
607
+ // Only a confirmed rejection (401) or an explicit `force` clears
608
+ // local credentials below. Any other failure (403, 5xx, network)
609
+ // is ambiguous — erasing the only key on an inconclusive signal
610
+ // could lock the user out unrecoverably — so preserve it and
611
+ // return without throwing, so callers (e.g. a logout button)
612
+ // don't crash. Pass `logout({ force: true })` to clear locally
613
+ // regardless when the caller knows the remote is gone.
614
+ const credentialIsRejected =
615
+ error instanceof RestRequestError && error.status === 401
616
+ if (!options?.force && !credentialIsRejected) return false
617
+ }
618
+ }
619
+ }
620
+
621
+ preparedOAuthPublicKey = undefined
622
+ let cleanupError: unknown
623
+ try {
624
+ await client.apiKeyStamper.clear()
625
+ } catch (error) {
626
+ cleanupError = error
627
+ }
628
+ try {
629
+ await sessionStorageManager.clearAllSessions()
630
+ } catch (error) {
631
+ cleanupError ??= error
632
+ }
633
+ if (cleanupError) throw cleanupError
634
+ return true
635
+ })
448
636
  },
449
637
 
450
638
  async toAccount(): Promise<LocalAccount> {