@zerodev/wallet-core 0.0.5 → 0.0.6
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.
- package/dist/_cjs/core/createZeroDevWalletCore.js +75 -38
- package/dist/_cjs/core/createZeroDevWalletCore.js.map +1 -1
- package/dist/_cjs/stampers/indexedDbStamper.js +5 -1
- package/dist/_cjs/stampers/indexedDbStamper.js.map +1 -1
- package/dist/_cjs/utils/crossTabLock.js +10 -0
- package/dist/_cjs/utils/crossTabLock.js.map +1 -0
- package/dist/_esm/core/createZeroDevWalletCore.js +98 -43
- package/dist/_esm/core/createZeroDevWalletCore.js.map +1 -1
- package/dist/_esm/stampers/indexedDbStamper.js +8 -1
- package/dist/_esm/stampers/indexedDbStamper.js.map +1 -1
- package/dist/_esm/utils/crossTabLock.js +17 -0
- package/dist/_esm/utils/crossTabLock.js.map +1 -0
- package/dist/_types/core/createZeroDevWalletCore.d.ts.map +1 -1
- package/dist/_types/stampers/indexedDbStamper.d.ts.map +1 -1
- package/dist/_types/stampers/types.d.ts +5 -0
- package/dist/_types/stampers/types.d.ts.map +1 -1
- package/dist/_types/utils/crossTabLock.d.ts +3 -0
- package/dist/_types/utils/crossTabLock.d.ts.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/core/createZeroDevWalletCore.ts +172 -94
- package/src/stampers/indexedDbStamper.ts +8 -1
- package/src/stampers/types.ts +5 -0
- package/src/utils/crossTabLock.ts +19 -0
package/package.json
CHANGED
|
@@ -17,9 +17,14 @@ import {
|
|
|
17
17
|
} from '../storage/manager.js'
|
|
18
18
|
import { SessionType, type ZeroDevWalletSession } from '../types/session.js'
|
|
19
19
|
import { buildClientSignature } from '../utils/buildClientSignature.js'
|
|
20
|
+
import { withCrossTabLock } from '../utils/crossTabLock.js'
|
|
20
21
|
import { encryptOtpAttempt } from '../utils/encryptOtpAttempt.js'
|
|
21
22
|
import { createOrganizationIdResolver } from '../utils/resolveOrganizationId.js'
|
|
22
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
humanReadableDateTime,
|
|
25
|
+
normalizeTimestamp,
|
|
26
|
+
parseSession,
|
|
27
|
+
} from '../utils/utils.js'
|
|
23
28
|
export interface ZeroDevWalletConfigCore {
|
|
24
29
|
organizationId?: string
|
|
25
30
|
proxyBaseUrl?: string
|
|
@@ -193,7 +198,8 @@ export async function createZeroDevWalletCore(
|
|
|
193
198
|
}
|
|
194
199
|
}
|
|
195
200
|
|
|
196
|
-
|
|
201
|
+
// Callers must hold the cross-tab lock; see commitReplacementSession.
|
|
202
|
+
const commitReplacementSessionLocked = async (
|
|
197
203
|
session: ZeroDevWalletSession,
|
|
198
204
|
targetPublicKey: string,
|
|
199
205
|
) => {
|
|
@@ -227,26 +233,41 @@ export async function createZeroDevWalletCore(
|
|
|
227
233
|
if (!recovered) throw error
|
|
228
234
|
}
|
|
229
235
|
}
|
|
236
|
+
const commitReplacementSession = (
|
|
237
|
+
session: ZeroDevWalletSession,
|
|
238
|
+
targetPublicKey: string,
|
|
239
|
+
) =>
|
|
240
|
+
withCrossTabLock(() =>
|
|
241
|
+
commitReplacementSessionLocked(session, targetPublicKey),
|
|
242
|
+
)
|
|
230
243
|
|
|
231
|
-
await withKeyTransition(
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
await withKeyTransition(() =>
|
|
245
|
+
withCrossTabLock(async () => {
|
|
246
|
+
// The stamper read the vault before this tab got the lock; another tab
|
|
247
|
+
// may have committed a new key since. A stale key here would look like a
|
|
248
|
+
// mismatch and clear that tab's valid session.
|
|
249
|
+
await client.apiKeyStamper.reload?.()
|
|
250
|
+
const activePublicKey = await client.apiKeyStamper.getPublicKey()
|
|
251
|
+
await sessionStorageManager.recoverSessionTransition(activePublicKey)
|
|
252
|
+
const restoredSession = await sessionStorageManager.getActiveSession()
|
|
253
|
+
if (restoredSession) {
|
|
254
|
+
try {
|
|
255
|
+
const restoredPublicKey = parseSession(
|
|
256
|
+
restoredSession.token,
|
|
257
|
+
).publicKey
|
|
258
|
+
if (
|
|
259
|
+
!activePublicKey ||
|
|
260
|
+
!restoredPublicKey ||
|
|
261
|
+
normalizeKey(activePublicKey) !== normalizeKey(restoredPublicKey)
|
|
262
|
+
) {
|
|
263
|
+
await sessionStorageManager.clearAllSessions()
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
243
266
|
await sessionStorageManager.clearAllSessions()
|
|
244
267
|
}
|
|
245
|
-
} catch {
|
|
246
|
-
await sessionStorageManager.clearAllSessions()
|
|
247
268
|
}
|
|
248
|
-
}
|
|
249
|
-
|
|
269
|
+
}),
|
|
270
|
+
)
|
|
250
271
|
|
|
251
272
|
return {
|
|
252
273
|
client,
|
|
@@ -291,56 +312,94 @@ export async function createZeroDevWalletCore(
|
|
|
291
312
|
},
|
|
292
313
|
|
|
293
314
|
async clearAllSessions() {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
315
|
+
// Under the lock, so it cannot remove another tab's in-flight journal.
|
|
316
|
+
return withKeyTransition(() =>
|
|
317
|
+
withCrossTabLock(async () => {
|
|
318
|
+
if (await sessionStorageManager.getActiveSession()) {
|
|
319
|
+
throw new Error(
|
|
320
|
+
'Refusing to clear the active session locally; use logout() to revoke its remote key first.',
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
await sessionStorageManager.clearAllSessions()
|
|
324
|
+
}),
|
|
325
|
+
)
|
|
300
326
|
},
|
|
301
327
|
|
|
302
328
|
async refreshSession(sessionId?: string) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
329
|
+
// Without an id, refresh the session active when called, so a tab that
|
|
330
|
+
// then waits behind another tab's refresh adopts it instead of rotating
|
|
331
|
+
// again. Read now but awaited inside, so the call keeps its queue place.
|
|
332
|
+
const expected =
|
|
333
|
+
sessionId === undefined
|
|
334
|
+
? sessionStorageManager.getActiveSessionKey()
|
|
335
|
+
: Promise.resolve(sessionId)
|
|
336
|
+
expected.catch(() => undefined)
|
|
337
|
+
return withKeyTransition(() =>
|
|
338
|
+
withCrossTabLock(async () => {
|
|
339
|
+
const expectedId = await expected
|
|
340
|
+
const activeSessionKey =
|
|
341
|
+
await sessionStorageManager.getActiveSessionKey()
|
|
342
|
+
const activeSession = await sessionStorageManager.getActiveSession()
|
|
343
|
+
if (!activeSession) {
|
|
344
|
+
throw new Error('No active session')
|
|
345
|
+
}
|
|
346
|
+
if (expectedId && expectedId !== activeSessionKey) {
|
|
347
|
+
// Another tab replaced the session while this one waited. Adopt it
|
|
348
|
+
// only when that tab also replaced the key under this one. Within
|
|
349
|
+
// one tab, a call without an id refreshes the current session and
|
|
350
|
+
// an inactive id stays an error. Callers should still compare the
|
|
351
|
+
// identity: the other tab may have signed in to a different account.
|
|
352
|
+
if (activeSession.stamperType === 'apiKey') {
|
|
353
|
+
const keyBefore = await client.apiKeyStamper.getPublicKey()
|
|
354
|
+
await client.apiKeyStamper.reload?.()
|
|
355
|
+
const activePublicKey = await client.apiKeyStamper.getPublicKey()
|
|
356
|
+
if (
|
|
357
|
+
activePublicKey &&
|
|
358
|
+
normalizeKey(activePublicKey) !==
|
|
359
|
+
normalizeKey(keyBefore ?? '') &&
|
|
360
|
+
normalizeKey(activePublicKey) ===
|
|
361
|
+
normalizeKey(activeSession.publicKey ?? '') &&
|
|
362
|
+
normalizeTimestamp(activeSession.expiry) > Date.now()
|
|
363
|
+
) {
|
|
364
|
+
return activeSession
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (sessionId !== undefined) {
|
|
368
|
+
throw new Error(
|
|
369
|
+
'Refreshing an inactive session is not supported with a single active key vault',
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
if (activeSession.stamperType !== 'apiKey') {
|
|
374
|
+
throw new Error('Invalid session type')
|
|
375
|
+
}
|
|
318
376
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
377
|
+
const targetPublicKey = await prepareFreshKeyRotation()
|
|
378
|
+
try {
|
|
379
|
+
const data = await client.loginWithStamp({
|
|
380
|
+
targetPublicKey,
|
|
381
|
+
projectId,
|
|
382
|
+
// Stamp-login is signed against the Turnkey parent org; the backend
|
|
383
|
+
// resolves the sub-org from the stamped credential. Signing the
|
|
384
|
+
// sub-org here makes the relayed payload's org mismatch the
|
|
385
|
+
// signature → Turnkey SIGNATURE_INVALID.
|
|
386
|
+
organizationId: await resolveOrganizationId(),
|
|
387
|
+
stampWith: 'apiKey',
|
|
388
|
+
})
|
|
389
|
+
const session = createReplacementSession(
|
|
390
|
+
data.session,
|
|
391
|
+
targetPublicKey,
|
|
392
|
+
'indexedDb',
|
|
393
|
+
SessionType.READ_WRITE,
|
|
394
|
+
)
|
|
395
|
+
await commitReplacementSessionLocked(session, targetPublicKey)
|
|
396
|
+
return session
|
|
397
|
+
} catch (error) {
|
|
398
|
+
await client.apiKeyStamper.discardKeyRotation()
|
|
399
|
+
throw error
|
|
400
|
+
}
|
|
401
|
+
}),
|
|
402
|
+
)
|
|
344
403
|
},
|
|
345
404
|
|
|
346
405
|
// [TODO] refactor to smaller utils/actions
|
|
@@ -408,24 +467,28 @@ export async function createZeroDevWalletCore(
|
|
|
408
467
|
encodedPublicKey: registrationPublicKey,
|
|
409
468
|
})
|
|
410
469
|
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
await
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
targetPublicKey
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
470
|
+
// From here the shared vault changes, so hold the cross-tab
|
|
471
|
+
// lock; the passkey prompt above stays outside it.
|
|
472
|
+
return await withCrossTabLock(async () => {
|
|
473
|
+
// Registration proves the temporary key exists remotely. Make
|
|
474
|
+
// it active so it can authorize the durable replacement key.
|
|
475
|
+
await client.apiKeyStamper.commitKeyRotation()
|
|
476
|
+
const targetPublicKey = await prepareFreshKeyRotation()
|
|
477
|
+
const loginData = await client.loginWithStamp({
|
|
478
|
+
projectId,
|
|
479
|
+
targetPublicKey,
|
|
480
|
+
// Sign against the parent org (see refreshSession note) — the
|
|
481
|
+
// backend derives the sub-org from the stamped credential.
|
|
482
|
+
organizationId: await resolveOrganizationId(),
|
|
483
|
+
})
|
|
484
|
+
const session = createReplacementSession(
|
|
485
|
+
loginData.session,
|
|
486
|
+
targetPublicKey,
|
|
487
|
+
'indexedDb',
|
|
488
|
+
)
|
|
489
|
+
await commitReplacementSessionLocked(session, targetPublicKey)
|
|
490
|
+
return data
|
|
421
491
|
})
|
|
422
|
-
const session = createReplacementSession(
|
|
423
|
-
loginData.session,
|
|
424
|
-
targetPublicKey,
|
|
425
|
-
'indexedDb',
|
|
426
|
-
)
|
|
427
|
-
await commitReplacementSession(session, targetPublicKey)
|
|
428
|
-
return data
|
|
429
492
|
} catch (error) {
|
|
430
493
|
await client.apiKeyStamper.discardKeyRotation()
|
|
431
494
|
throw error
|
|
@@ -619,19 +682,34 @@ export async function createZeroDevWalletCore(
|
|
|
619
682
|
}
|
|
620
683
|
|
|
621
684
|
preparedOAuthPublicKey = undefined
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
685
|
+
return withCrossTabLock(async () => {
|
|
686
|
+
// Another tab may have signed in while the revoke was in flight. Keep
|
|
687
|
+
// a session for a different account, or one that appeared after a
|
|
688
|
+
// signed-out start; a same-account refresh is still ours to clear.
|
|
689
|
+
const current = await sessionStorageManager.getActiveSession()
|
|
690
|
+
if (
|
|
691
|
+
current &&
|
|
692
|
+
(!session ||
|
|
693
|
+
current.organizationId !== session.organizationId ||
|
|
694
|
+
current.userId !== session.userId)
|
|
695
|
+
) {
|
|
696
|
+
await client.apiKeyStamper.discardKeyRotation()
|
|
697
|
+
return true
|
|
698
|
+
}
|
|
699
|
+
let cleanupError: unknown
|
|
700
|
+
try {
|
|
701
|
+
await client.apiKeyStamper.clear()
|
|
702
|
+
} catch (error) {
|
|
703
|
+
cleanupError = error
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
await sessionStorageManager.clearAllSessions()
|
|
707
|
+
} catch (error) {
|
|
708
|
+
cleanupError ??= error
|
|
709
|
+
}
|
|
710
|
+
if (cleanupError) throw cleanupError
|
|
711
|
+
return true
|
|
712
|
+
})
|
|
635
713
|
})
|
|
636
714
|
},
|
|
637
715
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { p256 } from '@noble/curves/nist.js'
|
|
2
2
|
import { IndexedDbStamper as TurnkeyIndexedDbStamper } from '@turnkey/indexed-db-stamper'
|
|
3
|
+
import { withCrossTabLock } from '../utils/crossTabLock.js'
|
|
3
4
|
import { generateCompressedPublicKeyFromKeyPair } from '../utils/utils.js'
|
|
4
5
|
import type { ApiKeyStamper } from './types.js'
|
|
5
6
|
|
|
@@ -28,7 +29,9 @@ function encodeStamp(publicKey: string, signature: string): string {
|
|
|
28
29
|
|
|
29
30
|
export async function createIndexedDbStamper(): Promise<ApiKeyStamper> {
|
|
30
31
|
const inner = new TurnkeyIndexedDbStamper()
|
|
31
|
-
|
|
32
|
+
// On an empty vault init() generates and stores a key; under the lock it
|
|
33
|
+
// cannot overwrite a key another tab is committing.
|
|
34
|
+
await withCrossTabLock(() => inner.init())
|
|
32
35
|
|
|
33
36
|
let pendingKeyPair: CryptoKeyPair | null = null
|
|
34
37
|
|
|
@@ -88,5 +91,9 @@ export async function createIndexedDbStamper(): Promise<ApiKeyStamper> {
|
|
|
88
91
|
async discardKeyRotation() {
|
|
89
92
|
pendingKeyPair = null
|
|
90
93
|
},
|
|
94
|
+
// Unlocked: its callers already hold the cross-tab lock.
|
|
95
|
+
async reload() {
|
|
96
|
+
await inner.init()
|
|
97
|
+
},
|
|
91
98
|
}
|
|
92
99
|
}
|
package/src/stampers/types.ts
CHANGED
|
@@ -45,6 +45,11 @@ export type ApiKeyStamper = Stamper & {
|
|
|
45
45
|
commitKeyRotation: () => Promise<void>
|
|
46
46
|
/** Forget the prepared key while keeping the active key untouched. */
|
|
47
47
|
discardKeyRotation: () => Promise<void>
|
|
48
|
+
/**
|
|
49
|
+
* Re-read the active key from the shared vault, which another tab may have
|
|
50
|
+
* rotated. Omit when each app has its own vault (React Native).
|
|
51
|
+
*/
|
|
52
|
+
reload?: () => Promise<void>
|
|
48
53
|
/**
|
|
49
54
|
* Sign `payload` with the currently active key. Returns a hex-encoded
|
|
50
55
|
* ECDSA-P256 / SHA-256 signature in ASN.1 DER form.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Tabs of one origin share the key vault and session storage but not the
|
|
2
|
+
// in-process key-transition queue in createZeroDevWalletCore. A Web Lock spans
|
|
3
|
+
// tabs. It covers a whole refresh, so every tab's timer firing at the same
|
|
4
|
+
// instant sends one stamp login (Turnkey fails concurrent ones for a sub-org,
|
|
5
|
+
// DPL-798), and every write of the shared vault and session, including the
|
|
6
|
+
// stamper's first key on an empty vault, so one tab cannot overwrite another's
|
|
7
|
+
// key or consume its transition journal. It never spans a passkey prompt or
|
|
8
|
+
// OAuth popup: an abandoned prompt would block every other tab. It is not
|
|
9
|
+
// re-entrant, so code running under it must not take it again. Without Web
|
|
10
|
+
// Locks (React Native, very old browsers) there is one instance per app or the
|
|
11
|
+
// in-process queue is all we have.
|
|
12
|
+
export const KEY_TRANSITION_LOCK = '@zerodev/key_transition'
|
|
13
|
+
|
|
14
|
+
export const withCrossTabLock = async <T>(
|
|
15
|
+
task: () => Promise<T>,
|
|
16
|
+
): Promise<T> => {
|
|
17
|
+
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks
|
|
18
|
+
return locks ? await locks.request(KEY_TRANSITION_LOCK, task) : task()
|
|
19
|
+
}
|