@zerodev/wallet-core 0.0.3 → 0.0.4

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 (124) 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/signingUtils.js +23 -3
  9. package/dist/_cjs/actions/wallet/signingUtils.js.map +1 -1
  10. package/dist/_cjs/adapters/viem.js +26 -9
  11. package/dist/_cjs/adapters/viem.js.map +1 -1
  12. package/dist/_cjs/client/decorators/client.js +1 -0
  13. package/dist/_cjs/client/decorators/client.js.map +1 -1
  14. package/dist/_cjs/client/transports/rest.js.map +1 -1
  15. package/dist/_cjs/core/createZeroDevWalletCore.js +297 -168
  16. package/dist/_cjs/core/createZeroDevWalletCore.js.map +1 -1
  17. package/dist/_cjs/index.js +2 -1
  18. package/dist/_cjs/index.js.map +1 -1
  19. package/dist/_cjs/index.native.js +2 -1
  20. package/dist/_cjs/index.native.js.map +1 -1
  21. package/dist/_cjs/native/stampers/secureStore.js +94 -17
  22. package/dist/_cjs/native/stampers/secureStore.js.map +1 -1
  23. package/dist/_cjs/stampers/indexedDbStamper.js +34 -0
  24. package/dist/_cjs/stampers/indexedDbStamper.js.map +1 -1
  25. package/dist/_cjs/storage/manager.js +144 -17
  26. package/dist/_cjs/storage/manager.js.map +1 -1
  27. package/dist/_cjs/utils/buildClientSignature.js +4 -1
  28. package/dist/_cjs/utils/buildClientSignature.js.map +1 -1
  29. package/dist/_cjs/utils/derToRawSignature.js +12 -0
  30. package/dist/_cjs/utils/derToRawSignature.js.map +1 -1
  31. package/dist/_cjs/utils/exportPrivateKey.js +1 -1
  32. package/dist/_cjs/utils/exportPrivateKey.js.map +1 -1
  33. package/dist/_cjs/utils/utils.js +27 -6
  34. package/dist/_cjs/utils/utils.js.map +1 -1
  35. package/dist/_esm/actions/auth/getAuthenticators.js +3 -2
  36. package/dist/_esm/actions/auth/getAuthenticators.js.map +1 -1
  37. package/dist/_esm/actions/auth/index.js +1 -0
  38. package/dist/_esm/actions/auth/index.js.map +1 -1
  39. package/dist/_esm/actions/auth/logout.js +21 -0
  40. package/dist/_esm/actions/auth/logout.js.map +1 -0
  41. package/dist/_esm/actions/index.js +1 -1
  42. package/dist/_esm/actions/index.js.map +1 -1
  43. package/dist/_esm/actions/wallet/signingUtils.js +24 -4
  44. package/dist/_esm/actions/wallet/signingUtils.js.map +1 -1
  45. package/dist/_esm/adapters/viem.js +27 -10
  46. package/dist/_esm/adapters/viem.js.map +1 -1
  47. package/dist/_esm/client/decorators/client.js +2 -1
  48. package/dist/_esm/client/decorators/client.js.map +1 -1
  49. package/dist/_esm/client/transports/rest.js.map +1 -1
  50. package/dist/_esm/core/createZeroDevWalletCore.js +320 -186
  51. package/dist/_esm/core/createZeroDevWalletCore.js.map +1 -1
  52. package/dist/_esm/index.js +1 -1
  53. package/dist/_esm/index.js.map +1 -1
  54. package/dist/_esm/index.native.js +1 -1
  55. package/dist/_esm/index.native.js.map +1 -1
  56. package/dist/_esm/native/stampers/secureStore.js +111 -18
  57. package/dist/_esm/native/stampers/secureStore.js.map +1 -1
  58. package/dist/_esm/stampers/indexedDbStamper.js +36 -0
  59. package/dist/_esm/stampers/indexedDbStamper.js.map +1 -1
  60. package/dist/_esm/storage/manager.js +150 -22
  61. package/dist/_esm/storage/manager.js.map +1 -1
  62. package/dist/_esm/utils/buildClientSignature.js +4 -1
  63. package/dist/_esm/utils/buildClientSignature.js.map +1 -1
  64. package/dist/_esm/utils/derToRawSignature.js +12 -0
  65. package/dist/_esm/utils/derToRawSignature.js.map +1 -1
  66. package/dist/_esm/utils/exportPrivateKey.js +1 -1
  67. package/dist/_esm/utils/exportPrivateKey.js.map +1 -1
  68. package/dist/_esm/utils/utils.js +27 -6
  69. package/dist/_esm/utils/utils.js.map +1 -1
  70. package/dist/_types/actions/auth/getAuthenticators.d.ts +19 -2
  71. package/dist/_types/actions/auth/getAuthenticators.d.ts.map +1 -1
  72. package/dist/_types/actions/auth/index.d.ts +2 -1
  73. package/dist/_types/actions/auth/index.d.ts.map +1 -1
  74. package/dist/_types/actions/auth/logout.d.ts +11 -0
  75. package/dist/_types/actions/auth/logout.d.ts.map +1 -0
  76. package/dist/_types/actions/index.d.ts +1 -1
  77. package/dist/_types/actions/index.d.ts.map +1 -1
  78. package/dist/_types/actions/wallet/signingUtils.d.ts.map +1 -1
  79. package/dist/_types/adapters/viem.d.ts.map +1 -1
  80. package/dist/_types/client/decorators/client.d.ts +3 -1
  81. package/dist/_types/client/decorators/client.d.ts.map +1 -1
  82. package/dist/_types/client/transports/rest.d.ts +3 -3
  83. package/dist/_types/client/transports/rest.d.ts.map +1 -1
  84. package/dist/_types/core/createZeroDevWalletCore.d.ts +8 -1
  85. package/dist/_types/core/createZeroDevWalletCore.d.ts.map +1 -1
  86. package/dist/_types/index.d.ts +2 -2
  87. package/dist/_types/index.d.ts.map +1 -1
  88. package/dist/_types/index.native.d.ts +2 -2
  89. package/dist/_types/index.native.d.ts.map +1 -1
  90. package/dist/_types/native/stampers/secureStore.d.ts.map +1 -1
  91. package/dist/_types/stampers/indexedDbStamper.d.ts.map +1 -1
  92. package/dist/_types/stampers/types.d.ts +6 -0
  93. package/dist/_types/stampers/types.d.ts.map +1 -1
  94. package/dist/_types/storage/manager.d.ts +3 -0
  95. package/dist/_types/storage/manager.d.ts.map +1 -1
  96. package/dist/_types/types/session.d.ts +2 -0
  97. package/dist/_types/types/session.d.ts.map +1 -1
  98. package/dist/_types/utils/buildClientSignature.d.ts +1 -1
  99. package/dist/_types/utils/buildClientSignature.d.ts.map +1 -1
  100. package/dist/_types/utils/derToRawSignature.d.ts.map +1 -1
  101. package/dist/_types/utils/utils.d.ts +1 -1
  102. package/dist/_types/utils/utils.d.ts.map +1 -1
  103. package/dist/tsconfig.build.tsbuildinfo +1 -1
  104. package/package.json +1 -1
  105. package/src/actions/auth/getAuthenticators.ts +20 -2
  106. package/src/actions/auth/index.ts +6 -0
  107. package/src/actions/auth/logout.ts +35 -0
  108. package/src/actions/index.ts +4 -0
  109. package/src/actions/wallet/signingUtils.ts +43 -4
  110. package/src/adapters/viem.ts +40 -12
  111. package/src/client/decorators/client.ts +7 -0
  112. package/src/client/transports/rest.ts +4 -4
  113. package/src/core/createZeroDevWalletCore.ts +393 -205
  114. package/src/index.native.ts +4 -0
  115. package/src/index.ts +4 -0
  116. package/src/native/stampers/secureStore.ts +119 -24
  117. package/src/stampers/indexedDbStamper.ts +47 -0
  118. package/src/stampers/types.ts +6 -0
  119. package/src/storage/manager.ts +205 -25
  120. package/src/types/session.ts +2 -0
  121. package/src/utils/buildClientSignature.ts +4 -2
  122. package/src/utils/derToRawSignature.ts +16 -2
  123. package/src/utils/exportPrivateKey.ts +1 -1
  124. package/src/utils/utils.ts +30 -9
@@ -27,11 +27,14 @@ export type {
27
27
  GetWhoamiReturnType,
28
28
  LoginWithOTPParameters,
29
29
  LoginWithOTPReturnType,
30
+ LogoutParameters,
31
+ LogoutReturnType,
30
32
  OAuthAuthenticator,
31
33
  OtpContact,
32
34
  PasskeyAuthenticator,
33
35
  RegisterWithOTPParameters,
34
36
  RegisterWithOTPReturnType,
37
+ SessionKeyAuthenticator,
35
38
  Sign7702AuthorizationParameters,
36
39
  Sign7702AuthorizationReturnType,
37
40
  SignMessageParameters,
@@ -50,6 +53,7 @@ export {
50
53
  getUserWallet,
51
54
  getWhoami,
52
55
  loginWithOTP,
56
+ logout,
53
57
  registerWithOTP,
54
58
  sign7702Authorization,
55
59
  signMessage,
package/src/index.ts CHANGED
@@ -24,11 +24,14 @@ export type {
24
24
  GetWhoamiReturnType,
25
25
  LoginWithOTPParameters,
26
26
  LoginWithOTPReturnType,
27
+ LogoutParameters,
28
+ LogoutReturnType,
27
29
  OAuthAuthenticator,
28
30
  OtpContact,
29
31
  PasskeyAuthenticator,
30
32
  RegisterWithOTPParameters,
31
33
  RegisterWithOTPReturnType,
34
+ SessionKeyAuthenticator,
32
35
  Sign7702AuthorizationParameters,
33
36
  Sign7702AuthorizationReturnType,
34
37
  SignMessageParameters,
@@ -51,6 +54,7 @@ export {
51
54
  getUserWallet,
52
55
  getWhoami,
53
56
  loginWithOTP,
57
+ logout,
54
58
  registerWithOTP,
55
59
  sign7702Authorization,
56
60
  signMessage,
@@ -1,22 +1,78 @@
1
1
  import { ApiKeyStamper, SignatureFormat } from '@turnkey/api-key-stamper'
2
- import { generateP256KeyPair } from '@turnkey/crypto'
2
+ import { generateP256KeyPair, verifyStampSignature } from '@turnkey/crypto'
3
3
  import * as SecureStore from 'expo-secure-store'
4
4
  import type { ApiKeyStamper as ZDApiKeyStamper } from '../../stampers/types.js'
5
5
 
6
- const PUBLIC_KEY = 'zerodev.publicKey'
7
- const PRIVATE_KEY = 'zerodev.privateKey'
6
+ const KEY_PAIR = 'zerodev.keyPair'
7
+ const LEGACY_PUBLIC_KEY = 'zerodev.publicKey'
8
+ const LEGACY_PRIVATE_KEY = 'zerodev.privateKey'
9
+
10
+ type StoredKeyPair = { publicKey: string; privateKey: string }
11
+ const KEY_PAIR_CHECK = 'ZeroDev Wallet key-pair ownership check'
12
+
13
+ function parseKeyPair(value: string | null): StoredKeyPair | null {
14
+ if (!value) return null
15
+ try {
16
+ const pair = JSON.parse(value) as Partial<StoredKeyPair>
17
+ if (
18
+ typeof pair.publicKey !== 'string' ||
19
+ !pair.publicKey ||
20
+ typeof pair.privateKey !== 'string' ||
21
+ !pair.privateKey
22
+ ) {
23
+ return null
24
+ }
25
+ return pair as StoredKeyPair
26
+ } catch {
27
+ return null
28
+ }
29
+ }
8
30
 
9
31
  class SecureStoreStamperInner {
10
32
  private publicKeyHex: string | null = null
11
33
 
12
34
  async init(): Promise<void> {
13
- const publicKey = await SecureStore.getItemAsync(PUBLIC_KEY)
14
- const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY)
35
+ const storedPair = parseKeyPair(await SecureStore.getItemAsync(KEY_PAIR))
36
+ if (storedPair && (await this.isUsable(storedPair))) {
37
+ this.publicKeyHex = storedPair.publicKey
38
+ return
39
+ }
40
+
41
+ const publicKey = await SecureStore.getItemAsync(LEGACY_PUBLIC_KEY)
42
+ const privateKey = await SecureStore.getItemAsync(LEGACY_PRIVATE_KEY)
43
+ if (
44
+ publicKey &&
45
+ privateKey &&
46
+ (await this.isUsable({ publicKey, privateKey }))
47
+ ) {
48
+ await this.persistKeyPair({ publicKey, privateKey })
49
+ await Promise.all([
50
+ SecureStore.deleteItemAsync(LEGACY_PUBLIC_KEY),
51
+ SecureStore.deleteItemAsync(LEGACY_PRIVATE_KEY),
52
+ ])
53
+ return
54
+ }
15
55
 
16
- if (publicKey && privateKey) {
17
- this.publicKeyHex = publicKey
18
- } else {
19
- await this.resetKeyPair()
56
+ // No usable stored credential — missing, corrupt, or mismatched (e.g. a
57
+ // broken key written before this validation existed). Mint a fresh pair
58
+ // rather than throwing: a key that fails the ownership check can't produce
59
+ // a valid stamp anyway, and a thrown init would brick the app — it could
60
+ // neither log out nor reset the stamper (both need a constructed SDK). The
61
+ // user simply re-authenticates from a clean state. Only validation failures
62
+ // land here; SecureStore I/O errors still propagate (we never discard a key
63
+ // just because a read/write transiently failed).
64
+ await this.resetKeyPair()
65
+ }
66
+
67
+ // True if the stored pair passes the ownership check. Isolates validation
68
+ // (pure crypto) from I/O so init() can distinguish an unusable key (reset)
69
+ // from a transient SecureStore failure (propagate).
70
+ private async isUsable(pair: StoredKeyPair): Promise<boolean> {
71
+ try {
72
+ await this.validateKeyPair(pair)
73
+ return true
74
+ } catch {
75
+ return false
20
76
  }
21
77
  }
22
78
 
@@ -24,20 +80,36 @@ class SecureStoreStamperInner {
24
80
  return this.publicKeyHex
25
81
  }
26
82
 
27
- async resetKeyPair(externalKeyPair?: {
28
- publicKey: string
29
- privateKey: string
30
- }): Promise<void> {
31
- await this.clear()
32
-
83
+ async resetKeyPair(externalKeyPair?: StoredKeyPair): Promise<void> {
33
84
  const pair = externalKeyPair ?? generateP256KeyPair()
85
+ await this.persistKeyPair(pair)
86
+ }
34
87
 
35
- await SecureStore.setItemAsync(PUBLIC_KEY, pair.publicKey)
36
- await SecureStore.setItemAsync(PRIVATE_KEY, pair.privateKey)
37
-
88
+ private async persistKeyPair(pair: StoredKeyPair): Promise<void> {
89
+ await this.validateKeyPair(pair)
90
+ // SecureStore replaces one value atomically. Update memory only after the
91
+ // durable write succeeds, so a failed rotation preserves the live pair.
92
+ await SecureStore.setItemAsync(KEY_PAIR, JSON.stringify(pair))
38
93
  this.publicKeyHex = pair.publicKey
39
94
  }
40
95
 
96
+ private async validateKeyPair(pair: StoredKeyPair): Promise<void> {
97
+ try {
98
+ const signature = await new ApiKeyStamper({
99
+ apiPublicKey: pair.publicKey,
100
+ apiPrivateKey: pair.privateKey,
101
+ }).sign(KEY_PAIR_CHECK, SignatureFormat.Der)
102
+ if (
103
+ await verifyStampSignature(pair.publicKey, signature, KEY_PAIR_CHECK)
104
+ ) {
105
+ return
106
+ }
107
+ } catch {
108
+ // Normalize invalid encoding and mismatched-key failures below.
109
+ }
110
+ throw new Error('Stored public and private keys do not match.')
111
+ }
112
+
41
113
  private async getTurnkeyApiKeyStamper(): Promise<ApiKeyStamper> {
42
114
  if (!this.publicKeyHex) {
43
115
  throw new Error(
@@ -45,14 +117,14 @@ class SecureStoreStamperInner {
45
117
  )
46
118
  }
47
119
 
48
- const privateKey = await SecureStore.getItemAsync(PRIVATE_KEY)
49
- if (!privateKey) {
50
- throw new Error('No private key found in secure store.')
120
+ const pair = parseKeyPair(await SecureStore.getItemAsync(KEY_PAIR))
121
+ if (!pair || pair.publicKey !== this.publicKeyHex) {
122
+ throw new Error('No matching key pair found in secure store.')
51
123
  }
52
124
 
53
125
  return new ApiKeyStamper({
54
126
  apiPublicKey: this.publicKeyHex,
55
- apiPrivateKey: privateKey,
127
+ apiPrivateKey: pair.privateKey,
56
128
  })
57
129
  }
58
130
 
@@ -70,8 +142,13 @@ class SecureStoreStamperInner {
70
142
  }
71
143
 
72
144
  async clear(): Promise<void> {
73
- await SecureStore.deleteItemAsync(PUBLIC_KEY)
74
- await SecureStore.deleteItemAsync(PRIVATE_KEY)
145
+ // Remove legacy recovery sources first. If this fails, keep the atomic
146
+ // record authoritative so a later init cannot resurrect a revoked key.
147
+ await Promise.all([
148
+ SecureStore.deleteItemAsync(LEGACY_PUBLIC_KEY),
149
+ SecureStore.deleteItemAsync(LEGACY_PRIVATE_KEY),
150
+ ])
151
+ await SecureStore.deleteItemAsync(KEY_PAIR)
75
152
  this.publicKeyHex = null
76
153
  }
77
154
  }
@@ -117,6 +194,7 @@ export async function createSecureStoreStamper(): Promise<ZDApiKeyStamper> {
117
194
  return inner.sign(payload)
118
195
  },
119
196
  async clear() {
197
+ pendingKeyPair = null
120
198
  await inner.clear()
121
199
  },
122
200
  async resetKeyPair() {
@@ -128,6 +206,20 @@ export async function createSecureStoreStamper(): Promise<ZDApiKeyStamper> {
128
206
  pendingKeyPair = keyPair
129
207
  return keyPair.publicKey
130
208
  },
209
+ async stampPending(payload: string) {
210
+ if (!pendingKeyPair) throw new Error('No pending key rotation')
211
+ return new ApiKeyStamper({
212
+ apiPublicKey: pendingKeyPair.publicKey,
213
+ apiPrivateKey: pendingKeyPair.privateKey,
214
+ }).stamp(payload)
215
+ },
216
+ async signPending(payload: string) {
217
+ if (!pendingKeyPair) throw new Error('No pending key rotation')
218
+ return new ApiKeyStamper({
219
+ apiPublicKey: pendingKeyPair.publicKey,
220
+ apiPrivateKey: pendingKeyPair.privateKey,
221
+ }).sign(payload, SignatureFormat.Der)
222
+ },
131
223
  async commitKeyRotation() {
132
224
  if (!pendingKeyPair) {
133
225
  throw new Error('No pending key rotation to commit')
@@ -135,5 +227,8 @@ export async function createSecureStoreStamper(): Promise<ZDApiKeyStamper> {
135
227
  await inner.resetKeyPair(pendingKeyPair)
136
228
  pendingKeyPair = null
137
229
  },
230
+ async discardKeyRotation() {
231
+ pendingKeyPair = null
232
+ },
138
233
  }
139
234
  }
@@ -1,13 +1,44 @@
1
+ import { p256 } from '@noble/curves/nist.js'
1
2
  import { IndexedDbStamper as TurnkeyIndexedDbStamper } from '@turnkey/indexed-db-stamper'
2
3
  import { generateCompressedPublicKeyFromKeyPair } from '../utils/utils.js'
3
4
  import type { ApiKeyStamper } from './types.js'
4
5
 
6
+ async function signWithKeyPair(
7
+ keyPair: CryptoKeyPair,
8
+ payload: string,
9
+ ): Promise<string> {
10
+ const rawSignature = new Uint8Array(
11
+ await crypto.subtle.sign(
12
+ { name: 'ECDSA', hash: 'SHA-256' },
13
+ keyPair.privateKey,
14
+ new TextEncoder().encode(payload),
15
+ ),
16
+ )
17
+ return p256.Signature.fromBytes(rawSignature, 'compact').toHex('der')
18
+ }
19
+
20
+ function encodeStamp(publicKey: string, signature: string): string {
21
+ const json = JSON.stringify({
22
+ publicKey,
23
+ scheme: 'SIGNATURE_SCHEME_TK_API_P256',
24
+ signature,
25
+ })
26
+ return btoa(json).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
27
+ }
28
+
5
29
  export async function createIndexedDbStamper(): Promise<ApiKeyStamper> {
6
30
  const inner = new TurnkeyIndexedDbStamper()
7
31
  await inner.init()
8
32
 
9
33
  let pendingKeyPair: CryptoKeyPair | null = null
10
34
 
35
+ // A rotation must be prepared before it can be stamped/signed with; centralize
36
+ // the guard so the pending-key paths don't each repeat it.
37
+ const requirePending = (): CryptoKeyPair => {
38
+ if (!pendingKeyPair) throw new Error('No pending key rotation')
39
+ return pendingKeyPair
40
+ }
41
+
11
42
  return {
12
43
  async getPublicKey() {
13
44
  return await inner.getPublicKey()
@@ -19,6 +50,7 @@ export async function createIndexedDbStamper(): Promise<ApiKeyStamper> {
19
50
  return await inner.sign(payload)
20
51
  },
21
52
  async clear() {
53
+ pendingKeyPair = null
22
54
  await inner.clear()
23
55
  },
24
56
  async resetKeyPair() {
@@ -34,6 +66,18 @@ export async function createIndexedDbStamper(): Promise<ApiKeyStamper> {
34
66
  pendingKeyPair = keyPair
35
67
  return await generateCompressedPublicKeyFromKeyPair(keyPair)
36
68
  },
69
+ async stampPending(payload: string) {
70
+ const keyPair = requirePending()
71
+ const publicKey = await generateCompressedPublicKeyFromKeyPair(keyPair)
72
+ const signature = await signWithKeyPair(keyPair, payload)
73
+ return {
74
+ stampHeaderName: 'X-Stamp',
75
+ stampHeaderValue: encodeStamp(publicKey, signature),
76
+ }
77
+ },
78
+ async signPending(payload: string) {
79
+ return signWithKeyPair(requirePending(), payload)
80
+ },
37
81
  async commitKeyRotation() {
38
82
  if (!pendingKeyPair) {
39
83
  throw new Error('No pending key rotation to commit')
@@ -41,5 +85,8 @@ export async function createIndexedDbStamper(): Promise<ApiKeyStamper> {
41
85
  await inner.resetKeyPair(pendingKeyPair)
42
86
  pendingKeyPair = null
43
87
  },
88
+ async discardKeyRotation() {
89
+ pendingKeyPair = null
90
+ },
44
91
  }
45
92
  }
@@ -37,8 +37,14 @@ export type ApiKeyStamper = Stamper & {
37
37
  resetKeyPair: () => Promise<void>
38
38
  /** Generate a new key pair internally, return its compressed public key, but keep the OLD key active for stamp(). */
39
39
  prepareKeyRotation: () => Promise<string>
40
+ /** Stamp with the prepared key without making it active. */
41
+ stampPending: (payload: string) => Promise<Stamp>
42
+ /** Sign with the prepared key without making it active. */
43
+ signPending: (payload: string) => Promise<string>
40
44
  /** Promote the pending key to active. Call after the server accepts the new key. */
41
45
  commitKeyRotation: () => Promise<void>
46
+ /** Forget the prepared key while keeping the active key untouched. */
47
+ discardKeyRotation: () => Promise<void>
42
48
  /**
43
49
  * Sign `payload` with the currently active key. Returns a hex-encoded
44
50
  * ECDSA-P256 / SHA-256 signature in ASN.1 DER form.
@@ -20,31 +20,114 @@ export type StorageManager = {
20
20
  setActiveSession(sessionKey: string): Promise<void>
21
21
  clearSession(sessionKey: string): Promise<void>
22
22
  clearAllSessions(): Promise<void>
23
+ stageSessionTransition(
24
+ sessionData: ZeroDevWalletSession,
25
+ publicKey: string,
26
+ ): Promise<void>
27
+ commitSessionTransition(): Promise<ZeroDevWalletSession | undefined>
28
+ recoverSessionTransition(
29
+ activePublicKey: string | null,
30
+ ): Promise<ZeroDevWalletSession | undefined>
31
+ }
32
+
33
+ type SessionTransition = {
34
+ sessionData: ZeroDevWalletSession
35
+ publicKey: string
36
+ }
37
+
38
+ // Process-wide serialization queue for storage mutations. Every mutating op
39
+ // runs through `withMutation`, which chains onto this tail so read-modify-write
40
+ // sequences over the shared active-session pointer and session list can't
41
+ // interleave and corrupt each other. Module-level (not per-manager) so
42
+ // concurrent managers over the same storage still serialize; the tail swallows
43
+ // each outcome so one failed mutation never jams the queue.
44
+ let mutationTail = Promise.resolve()
45
+
46
+ function isStoredSession(value: unknown): value is ZeroDevWalletSession {
47
+ if (!value || typeof value !== 'object') return false
48
+ const session = value as Partial<ZeroDevWalletSession>
49
+ return (
50
+ typeof session.id === 'string' &&
51
+ typeof session.userId === 'string' &&
52
+ typeof session.organizationId === 'string' &&
53
+ (session.stamperType === 'apiKey' || session.stamperType === 'passkey') &&
54
+ typeof session.token === 'string' &&
55
+ session.token.length > 0 &&
56
+ typeof session.expiry === 'number' &&
57
+ Number.isFinite(session.expiry) &&
58
+ session.expiry > 0 &&
59
+ typeof session.createdAt === 'number' &&
60
+ Number.isFinite(session.createdAt)
61
+ )
23
62
  }
24
63
 
25
64
  export function createStorageManager(adapter: StorageAdapter): StorageManager {
26
65
  const ACTIVE_SESSION_KEY = '@zerodev/active_session'
27
66
  const ALL_SESSIONS_KEY = '@zerodev/sessions'
67
+ const SESSION_TRANSITION_KEY = '@zerodev/session_transition'
28
68
 
29
- const storeSession = async (
69
+ const withMutation = async <T>(mutation: () => Promise<T>): Promise<T> => {
70
+ const result = mutationTail.then(mutation)
71
+ mutationTail = result.then(
72
+ () => undefined,
73
+ () => undefined,
74
+ )
75
+ return result
76
+ }
77
+
78
+ const parseSessionKeys = (value: string | null): string[] | undefined => {
79
+ if (!value) return []
80
+ try {
81
+ const parsed: unknown = JSON.parse(value)
82
+ if (
83
+ !Array.isArray(parsed) ||
84
+ !parsed.every((key): key is string => typeof key === 'string')
85
+ ) {
86
+ return undefined
87
+ }
88
+ return parsed
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ const restoreItem = async (key: string, value: string | null) => {
95
+ if (value === null) await adapter.removeItem(key)
96
+ else await adapter.setItem(key, value)
97
+ }
98
+
99
+ const storeSessionUnlocked = async (
30
100
  sessionData: ZeroDevWalletSession,
31
101
  sessionKey: string,
32
102
  ): Promise<void> => {
33
- // Store the session data
34
- await adapter.setItem(sessionKey, JSON.stringify(sessionData))
35
-
36
- // Add to sessions list if not already present
37
- const sessionsStr = await adapter.getItem(ALL_SESSIONS_KEY)
38
- const sessions = JSON.parse(sessionsStr || '[]')
39
- if (!sessions.includes(sessionKey)) {
40
- sessions.push(sessionKey)
103
+ const recordKey = sessionKey
104
+ const [previousRecord, previousIndex, previousActive] = await Promise.all([
105
+ adapter.getItem(recordKey),
106
+ adapter.getItem(ALL_SESSIONS_KEY),
107
+ adapter.getItem(ACTIVE_SESSION_KEY),
108
+ ])
109
+ try {
110
+ await adapter.setItem(recordKey, JSON.stringify(sessionData))
111
+ const sessions = parseSessionKeys(previousIndex) ?? []
112
+ if (!sessions.includes(sessionKey)) sessions.push(sessionKey)
41
113
  await adapter.setItem(ALL_SESSIONS_KEY, JSON.stringify(sessions))
114
+ await adapter.setItem(ACTIVE_SESSION_KEY, sessionKey)
115
+ } catch (error) {
116
+ await Promise.allSettled([
117
+ restoreItem(recordKey, previousRecord),
118
+ restoreItem(ALL_SESSIONS_KEY, previousIndex),
119
+ restoreItem(ACTIVE_SESSION_KEY, previousActive),
120
+ ])
121
+ throw error
42
122
  }
43
-
44
- // Set as active session
45
- await adapter.setItem(ACTIVE_SESSION_KEY, sessionKey)
46
123
  }
47
124
 
125
+ const storeSession = async (
126
+ sessionData: ZeroDevWalletSession,
127
+ sessionKey: string,
128
+ ): Promise<void> =>
129
+ withMutation(() => storeSessionUnlocked(sessionData, sessionKey))
130
+
48
131
  const getActiveSession = async (): Promise<
49
132
  ZeroDevWalletSession | undefined
50
133
  > => {
@@ -66,10 +149,14 @@ export function createStorageManager(adapter: StorageAdapter): StorageManager {
66
149
  if (!sessionStr) return undefined
67
150
 
68
151
  try {
69
- const session: ZeroDevWalletSession = JSON.parse(sessionStr)
152
+ const session: unknown = JSON.parse(sessionStr)
153
+ if (!isStoredSession(session)) {
154
+ await clearSession(sessionKey)
155
+ return undefined
156
+ }
70
157
 
71
158
  // Check if session is expired
72
- if (session.expiry && normalizeTimestamp(session.expiry) < Date.now()) {
159
+ if (normalizeTimestamp(session.expiry) < Date.now()) {
73
160
  await clearSession(sessionKey)
74
161
  return undefined
75
162
  }
@@ -82,13 +169,17 @@ export function createStorageManager(adapter: StorageAdapter): StorageManager {
82
169
  }
83
170
  }
84
171
 
85
- const listSessionKeys = async (): Promise<string[]> => {
172
+ const listSessionKeysUnlocked = async (): Promise<string[]> => {
86
173
  const sessionsStr = await adapter.getItem(ALL_SESSIONS_KEY)
87
- const sessionKeys = JSON.parse(sessionsStr || '[]')
174
+ const parsedKeys = parseSessionKeys(sessionsStr)
175
+ if (!parsedKeys) {
176
+ await adapter.removeItem(ALL_SESSIONS_KEY)
177
+ return []
178
+ }
88
179
 
89
180
  // Clean up any keys that don't have corresponding sessions
90
181
  const validKeys: string[] = []
91
- for (const key of sessionKeys) {
182
+ for (const key of parsedKeys) {
92
183
  const exists = await adapter.getItem(key)
93
184
  if (exists) {
94
185
  validKeys.push(key)
@@ -96,13 +187,16 @@ export function createStorageManager(adapter: StorageAdapter): StorageManager {
96
187
  }
97
188
 
98
189
  // Update the list if we found invalid keys
99
- if (validKeys.length !== sessionKeys.length) {
190
+ if (validKeys.length !== parsedKeys.length) {
100
191
  await adapter.setItem(ALL_SESSIONS_KEY, JSON.stringify(validKeys))
101
192
  }
102
193
 
103
194
  return validKeys
104
195
  }
105
196
 
197
+ const listSessionKeys = async (): Promise<string[]> =>
198
+ withMutation(listSessionKeysUnlocked)
199
+
106
200
  const listSessions = async (): Promise<ZeroDevWalletSession[]> => {
107
201
  const sessionKeys = await listSessionKeys()
108
202
  const sessions: ZeroDevWalletSession[] = []
@@ -124,15 +218,17 @@ export function createStorageManager(adapter: StorageAdapter): StorageManager {
124
218
  throw new Error(`Session not found: ${sessionKey}`)
125
219
  }
126
220
 
127
- await adapter.setItem(ACTIVE_SESSION_KEY, sessionKey)
221
+ await withMutation(async () => {
222
+ await adapter.setItem(ACTIVE_SESSION_KEY, sessionKey)
223
+ })
128
224
  }
129
225
 
130
- const clearSession = async (sessionKey: string): Promise<void> => {
226
+ const clearSessionUnlocked = async (sessionKey: string): Promise<void> => {
131
227
  // Remove the session data
132
228
  await adapter.removeItem(sessionKey)
133
229
 
134
230
  // Remove from sessions list
135
- const sessions = await listSessionKeys()
231
+ const sessions = await listSessionKeysUnlocked()
136
232
  const updated = sessions.filter((k) => k !== sessionKey)
137
233
  await adapter.setItem(ALL_SESSIONS_KEY, JSON.stringify(updated))
138
234
 
@@ -143,19 +239,100 @@ export function createStorageManager(adapter: StorageAdapter): StorageManager {
143
239
  }
144
240
  }
145
241
 
146
- const clearAllSessions = async (): Promise<void> => {
147
- const sessions = await listSessionKeys()
242
+ const clearSession = async (sessionKey: string): Promise<void> =>
243
+ withMutation(() => clearSessionUnlocked(sessionKey))
244
+
245
+ const clearAllSessions = async (): Promise<void> =>
246
+ withMutation(async () => {
247
+ await clearAllSessionsUnlocked()
248
+ await adapter.removeItem(SESSION_TRANSITION_KEY)
249
+ })
250
+
251
+ async function clearAllSessionsUnlocked(): Promise<void> {
252
+ const sessions = await listSessionKeysUnlocked()
148
253
 
149
- // Remove all session data
150
254
  for (const key of sessions) {
151
255
  await adapter.removeItem(key)
152
256
  }
153
257
 
154
- // Clear the metadata
155
258
  await adapter.removeItem(ALL_SESSIONS_KEY)
156
259
  await adapter.removeItem(ACTIVE_SESSION_KEY)
157
260
  }
158
261
 
262
+ const parseTransition = (value: string | null): SessionTransition | null => {
263
+ if (!value) return null
264
+ try {
265
+ const parsed = JSON.parse(value) as Partial<SessionTransition>
266
+ if (
267
+ !isStoredSession(parsed.sessionData) ||
268
+ typeof parsed.publicKey !== 'string' ||
269
+ !parsed.publicKey
270
+ ) {
271
+ return null
272
+ }
273
+ return parsed as SessionTransition
274
+ } catch {
275
+ return null
276
+ }
277
+ }
278
+
279
+ const stageSessionTransition = async (
280
+ sessionData: ZeroDevWalletSession,
281
+ publicKey: string,
282
+ ): Promise<void> =>
283
+ withMutation(async () => {
284
+ await adapter.setItem(
285
+ SESSION_TRANSITION_KEY,
286
+ JSON.stringify({ sessionData, publicKey }),
287
+ )
288
+ })
289
+
290
+ const commitSessionTransition = async (): Promise<
291
+ ZeroDevWalletSession | undefined
292
+ > =>
293
+ withMutation(async () => {
294
+ const transition = parseTransition(
295
+ await adapter.getItem(SESSION_TRANSITION_KEY),
296
+ )
297
+ if (!transition) {
298
+ await adapter.removeItem(SESSION_TRANSITION_KEY)
299
+ return undefined
300
+ }
301
+
302
+ await clearAllSessionsUnlocked()
303
+ await storeSessionUnlocked(
304
+ transition.sessionData,
305
+ transition.sessionData.id,
306
+ )
307
+ await adapter.removeItem(SESSION_TRANSITION_KEY)
308
+ return transition.sessionData
309
+ })
310
+
311
+ const normalizeKey = (key: string) => key.replace(/^0x/, '').toLowerCase()
312
+
313
+ const recoverSessionTransition = async (
314
+ activePublicKey: string | null,
315
+ ): Promise<ZeroDevWalletSession | undefined> => {
316
+ const transition = parseTransition(
317
+ await adapter.getItem(SESSION_TRANSITION_KEY),
318
+ )
319
+ if (!transition) {
320
+ if (await adapter.getItem(SESSION_TRANSITION_KEY)) {
321
+ await adapter.removeItem(SESSION_TRANSITION_KEY)
322
+ }
323
+ return undefined
324
+ }
325
+ if (!activePublicKey) {
326
+ await adapter.removeItem(SESSION_TRANSITION_KEY)
327
+ return undefined
328
+ }
329
+ if (normalizeKey(activePublicKey) !== normalizeKey(transition.publicKey)) {
330
+ await adapter.removeItem(SESSION_TRANSITION_KEY)
331
+ return undefined
332
+ }
333
+ return commitSessionTransition()
334
+ }
335
+
159
336
  return {
160
337
  storeSession,
161
338
  getActiveSession,
@@ -166,5 +343,8 @@ export function createStorageManager(adapter: StorageAdapter): StorageManager {
166
343
  setActiveSession,
167
344
  clearSession,
168
345
  clearAllSessions,
346
+ stageSessionTransition,
347
+ commitSessionTransition,
348
+ recoverSessionTransition,
169
349
  }
170
350
  }
@@ -12,6 +12,8 @@ export type ZeroDevWalletSession = {
12
12
  stamperType: StamperType
13
13
  sessionType?: SessionType
14
14
  token: string
15
+ /** Public key bound into the session JWT; persisted for crash recovery. */
16
+ publicKey?: string
15
17
  expiry: number
16
18
  createdAt: number
17
19
  }
@@ -7,7 +7,7 @@ export type BuildClientSignatureParams = {
7
7
  /** The compressed public key hex */
8
8
  publicKey: string
9
9
  /** The API key stamper for signing */
10
- stamper: ApiKeyStamper
10
+ stamper: Pick<ApiKeyStamper, 'stamp'>
11
11
  }
12
12
 
13
13
  /**
@@ -64,7 +64,9 @@ function extractTokenIdFromJwt(jwt: string): string {
64
64
  throw new Error('Invalid JWT format')
65
65
  }
66
66
 
67
- const payload = JSON.parse(base64UrlDecode(parts[1]!))
67
+ const encodedPayload = parts[1]
68
+ if (!encodedPayload) throw new Error('Invalid JWT format')
69
+ const payload = JSON.parse(base64UrlDecode(encodedPayload))
68
70
  if (!payload.id) {
69
71
  throw new Error('JWT payload missing id field')
70
72
  }