kentucky-signer-viem 0.1.1 → 0.1.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.
@@ -1,16 +1,20 @@
1
- import React, {
1
+ import {
2
2
  createContext,
3
3
  useContext,
4
4
  useState,
5
5
  useCallback,
6
6
  useEffect,
7
7
  useMemo,
8
+ useRef,
8
9
  type ReactNode,
9
10
  } from 'react'
10
11
  import type { AuthSession } from '../types'
12
+ import type { TwoFactorCodes } from '../account'
11
13
  import { KentuckySignerClient } from '../client'
14
+ import { SecureKentuckySignerClient } from '../secure-client'
12
15
  import {
13
16
  authenticateWithPasskey,
17
+ authenticateWithPassword as authWithPassword,
14
18
  isSessionValid,
15
19
  refreshSessionIfNeeded,
16
20
  LocalStorageTokenStorage,
@@ -19,6 +23,27 @@ import {
19
23
  createKentuckySignerAccount,
20
24
  type KentuckySignerAccount,
21
25
  } from '../account'
26
+ import {
27
+ EphemeralKeyManager,
28
+ IndexedDBEphemeralKeyStorage,
29
+ MemoryEphemeralKeyStorage,
30
+ } from '../ephemeral'
31
+
32
+ /**
33
+ * 2FA prompt requirements
34
+ */
35
+ export interface TwoFactorPromptState {
36
+ /** Whether the 2FA prompt is visible */
37
+ isVisible: boolean
38
+ /** Whether TOTP is required */
39
+ totpRequired: boolean
40
+ /** Whether PIN is required */
41
+ pinRequired: boolean
42
+ /** Expected PIN length (4 or 6) */
43
+ pinLength: number
44
+ /** Callback to resolve with codes */
45
+ resolve?: (codes: TwoFactorCodes | null) => void
46
+ }
22
47
 
23
48
  /**
24
49
  * Kentucky Signer context state
@@ -34,20 +59,40 @@ export interface KentuckySignerState {
34
59
  account: KentuckySignerAccount | null
35
60
  /** Last error (if any) */
36
61
  error: Error | null
62
+ /** Whether ephemeral key is bound to the token */
63
+ ephemeralKeyBound: boolean
64
+ /** Whether secure mode (ephemeral key signing) is enabled */
65
+ secureMode: boolean
66
+ /** Whether ephemeral keys are persisted in IndexedDB (survives refresh) */
67
+ persistEphemeralKeys: boolean
68
+ /** 2FA prompt state */
69
+ twoFactorPrompt: TwoFactorPromptState
37
70
  }
38
71
 
39
72
  /**
40
73
  * Kentucky Signer context actions
41
74
  */
42
75
  export interface KentuckySignerActions {
43
- /** Authenticate with passkey */
44
- authenticate: (accountId: string, options?: { rpId?: string }) => Promise<void>
76
+ /** Authenticate with passkey or set an existing session */
77
+ authenticate: (accountId: string, options?: { rpId?: string; session?: AuthSession }) => Promise<void>
78
+ /** Authenticate with password */
79
+ authenticatePassword: (accountId: string, password: string) => Promise<void>
45
80
  /** Logout and clear session */
46
81
  logout: () => Promise<void>
47
82
  /** Refresh session if needed */
48
83
  refreshSession: () => Promise<void>
49
84
  /** Clear any errors */
50
85
  clearError: () => void
86
+ /** Toggle secure mode (ephemeral key signing) */
87
+ setSecureMode: (enabled: boolean) => void
88
+ /** Toggle ephemeral key persistence (IndexedDB vs memory) */
89
+ setPersistEphemeralKeys: (enabled: boolean) => void
90
+ /** Get the ephemeral public key (for secure mode binding during external auth) */
91
+ getEphemeralPublicKey: () => Promise<string | undefined>
92
+ /** Submit 2FA codes (called by 2FA prompt UI) */
93
+ submit2FA: (codes: TwoFactorCodes) => void
94
+ /** Cancel 2FA prompt */
95
+ cancel2FA: () => void
51
96
  }
52
97
 
53
98
  /**
@@ -69,6 +114,8 @@ export interface KentuckySignerProviderProps {
69
114
  storageKeyPrefix?: string
70
115
  /** Whether to persist session in localStorage */
71
116
  persistSession?: boolean
117
+ /** Whether to use ephemeral key signing for enhanced security */
118
+ useEphemeralKeys?: boolean
72
119
  /** Children */
73
120
  children: ReactNode
74
121
  }
@@ -90,33 +137,119 @@ export function KentuckySignerProvider({
90
137
  defaultChainId = 1,
91
138
  storageKeyPrefix = 'kentucky_signer',
92
139
  persistSession = true,
140
+ useEphemeralKeys = false,
93
141
  children,
94
142
  }: KentuckySignerProviderProps) {
143
+ // Load persist ephemeral keys setting from localStorage, default to IndexedDB if available
144
+ const getInitialPersistSetting = (): boolean => {
145
+ if (typeof localStorage !== 'undefined') {
146
+ const saved = localStorage.getItem(`${storageKeyPrefix}_persist_ephemeral_keys`)
147
+ if (saved !== null) {
148
+ return saved === 'true'
149
+ }
150
+ }
151
+ // Default to IndexedDB persistence if available
152
+ return typeof indexedDB !== 'undefined'
153
+ }
154
+
155
+ // Load secure mode setting from localStorage, falling back to useEphemeralKeys prop
156
+ const getInitialSecureModeSetting = (): boolean => {
157
+ if (typeof localStorage !== 'undefined') {
158
+ const saved = localStorage.getItem(`${storageKeyPrefix}_secure_mode`)
159
+ if (saved !== null) {
160
+ return saved === 'true'
161
+ }
162
+ }
163
+ // Fall back to prop value
164
+ return useEphemeralKeys
165
+ }
166
+
167
+ // Default 2FA prompt state
168
+ const defaultTwoFactorPrompt: TwoFactorPromptState = {
169
+ isVisible: false,
170
+ totpRequired: false,
171
+ pinRequired: false,
172
+ pinLength: 6,
173
+ }
174
+
95
175
  const [state, setState] = useState<KentuckySignerState>({
96
176
  isAuthenticating: false,
97
177
  isAuthenticated: false,
98
178
  session: null,
99
179
  account: null,
100
180
  error: null,
181
+ ephemeralKeyBound: false,
182
+ secureMode: getInitialSecureModeSetting(),
183
+ persistEphemeralKeys: getInitialPersistSetting(),
184
+ twoFactorPrompt: defaultTwoFactorPrompt,
101
185
  })
102
186
 
187
+ // Ephemeral key storage instances - we keep both and switch between them
188
+ const indexedDBStorage = useMemo(
189
+ () => typeof indexedDB !== 'undefined' ? new IndexedDBEphemeralKeyStorage() : null,
190
+ []
191
+ )
192
+ const memoryStorage = useMemo(() => new MemoryEphemeralKeyStorage(), [])
193
+
194
+ // Current active storage based on persist setting
195
+ const ephemeralKeyStorage = state.persistEphemeralKeys && indexedDBStorage
196
+ ? indexedDBStorage
197
+ : memoryStorage
198
+
199
+ // Single ephemeral key manager instance that we update storage on
200
+ const ephemeralKeyManagerRef = useRef<EphemeralKeyManager | null>(null)
201
+ if (!ephemeralKeyManagerRef.current) {
202
+ ephemeralKeyManagerRef.current = new EphemeralKeyManager(ephemeralKeyStorage)
203
+ }
204
+ const ephemeralKeyManager = ephemeralKeyManagerRef.current
205
+
103
206
  const client = useMemo(
104
207
  () => new KentuckySignerClient({ baseUrl }),
105
208
  [baseUrl]
106
209
  )
107
210
 
211
+ // Secure client for ephemeral key signing - uses shared key manager
212
+ const secureClient = useMemo(
213
+ () => new SecureKentuckySignerClient({
214
+ baseUrl,
215
+ ephemeralKeyManager,
216
+ }),
217
+ [baseUrl, ephemeralKeyManager]
218
+ )
219
+
108
220
  const storage = useMemo(
109
221
  () => (persistSession ? new LocalStorageTokenStorage(storageKeyPrefix) : null),
110
222
  [persistSession, storageKeyPrefix]
111
223
  )
112
224
 
225
+ // 2FA callback handler - shows prompt and waits for user input
226
+ const handle2FARequired = useCallback(async (requirements: {
227
+ totpRequired: boolean
228
+ pinRequired: boolean
229
+ pinLength: number
230
+ }): Promise<TwoFactorCodes | null> => {
231
+ return new Promise((resolve) => {
232
+ setState((s) => ({
233
+ ...s,
234
+ twoFactorPrompt: {
235
+ isVisible: true,
236
+ totpRequired: requirements.totpRequired,
237
+ pinRequired: requirements.pinRequired,
238
+ pinLength: requirements.pinLength,
239
+ resolve,
240
+ },
241
+ }))
242
+ })
243
+ }, [])
244
+
113
245
  // Create account from session
114
246
  const createAccount = useCallback(
115
- (session: AuthSession): KentuckySignerAccount => {
247
+ (session: AuthSession, useSecureClient: boolean = false): KentuckySignerAccount => {
116
248
  return createKentuckySignerAccount({
117
249
  config: { baseUrl, accountId: session.accountId },
118
250
  session,
119
251
  defaultChainId,
252
+ secureClient: useSecureClient ? secureClient : undefined,
120
253
  onSessionExpired: async () => {
121
254
  // Try to refresh the session
122
255
  const newSession = await refreshSessionIfNeeded(session, baseUrl, 0)
@@ -129,9 +262,10 @@ export function KentuckySignerProvider({
129
262
  }))
130
263
  return newSession
131
264
  },
265
+ on2FARequired: handle2FARequired,
132
266
  })
133
267
  },
134
- [baseUrl, defaultChainId]
268
+ [baseUrl, defaultChainId, secureClient, handle2FARequired]
135
269
  )
136
270
 
137
271
  // Restore session from storage on mount
@@ -148,17 +282,23 @@ export function KentuckySignerProvider({
148
282
  // Try to refresh
149
283
  try {
150
284
  const refreshed = await refreshSessionIfNeeded(session, baseUrl, 0)
151
- const account = createAccount(refreshed)
152
285
  localStorage.setItem(
153
286
  `${storageKeyPrefix}_session`,
154
287
  JSON.stringify(refreshed)
155
288
  )
156
- setState({
157
- isAuthenticating: false,
158
- isAuthenticated: true,
159
- session: refreshed,
160
- account,
161
- error: null,
289
+ setState((s) => {
290
+ const account = createAccount(refreshed, s.secureMode)
291
+ return {
292
+ isAuthenticating: false,
293
+ isAuthenticated: true,
294
+ session: refreshed,
295
+ account,
296
+ error: null,
297
+ ephemeralKeyBound: false,
298
+ secureMode: s.secureMode,
299
+ persistEphemeralKeys: s.persistEphemeralKeys,
300
+ twoFactorPrompt: s.twoFactorPrompt,
301
+ }
162
302
  })
163
303
  } catch {
164
304
  // Clear invalid session
@@ -167,13 +307,19 @@ export function KentuckySignerProvider({
167
307
  return
168
308
  }
169
309
 
170
- const account = createAccount(session)
171
- setState({
172
- isAuthenticating: false,
173
- isAuthenticated: true,
174
- session,
175
- account,
176
- error: null,
310
+ setState((s) => {
311
+ const account = createAccount(session, s.secureMode)
312
+ return {
313
+ isAuthenticating: false,
314
+ isAuthenticated: true,
315
+ session,
316
+ account,
317
+ error: null,
318
+ ephemeralKeyBound: false,
319
+ secureMode: s.secureMode,
320
+ persistEphemeralKeys: s.persistEphemeralKeys,
321
+ twoFactorPrompt: s.twoFactorPrompt,
322
+ }
177
323
  })
178
324
  } catch {
179
325
  localStorage.removeItem(`${storageKeyPrefix}_session`)
@@ -183,19 +329,35 @@ export function KentuckySignerProvider({
183
329
  restoreSession()
184
330
  }, [storage, storageKeyPrefix, baseUrl, createAccount])
185
331
 
186
- // Authenticate with passkey
332
+ // Authenticate with passkey or existing session
187
333
  const authenticate = useCallback(
188
- async (accountId: string, options?: { rpId?: string }) => {
334
+ async (accountId: string, options?: { rpId?: string; session?: AuthSession }) => {
189
335
  setState((s) => ({ ...s, isAuthenticating: true, error: null }))
190
336
 
191
337
  try {
192
- const session = await authenticateWithPasskey({
193
- baseUrl,
194
- accountId,
195
- rpId: options?.rpId,
196
- })
338
+ let session: AuthSession
339
+ let ephemeralKeyBound = false
340
+
341
+ if (options?.session) {
342
+ // Use provided session (no ephemeral binding possible)
343
+ session = options.session
344
+ } else {
345
+ // Get ephemeral public key if secure mode is enabled
346
+ let ephemeralPublicKey: string | undefined
347
+ if (state.secureMode) {
348
+ ephemeralPublicKey = await ephemeralKeyManager.getPublicKey()
349
+ }
350
+
351
+ // Authenticate with passkey
352
+ session = await authenticateWithPasskey({
353
+ baseUrl,
354
+ accountId,
355
+ rpId: options?.rpId,
356
+ ephemeralPublicKey,
357
+ })
197
358
 
198
- const account = createAccount(session)
359
+ ephemeralKeyBound = !!ephemeralPublicKey
360
+ }
199
361
 
200
362
  // Persist session
201
363
  if (storage) {
@@ -205,12 +367,19 @@ export function KentuckySignerProvider({
205
367
  )
206
368
  }
207
369
 
208
- setState({
209
- isAuthenticating: false,
210
- isAuthenticated: true,
211
- session,
212
- account,
213
- error: null,
370
+ setState((s) => {
371
+ const account = createAccount(session, s.secureMode)
372
+ return {
373
+ isAuthenticating: false,
374
+ isAuthenticated: true,
375
+ session,
376
+ account,
377
+ error: null,
378
+ ephemeralKeyBound,
379
+ secureMode: s.secureMode,
380
+ persistEphemeralKeys: s.persistEphemeralKeys,
381
+ twoFactorPrompt: s.twoFactorPrompt,
382
+ }
214
383
  })
215
384
  } catch (error) {
216
385
  setState((s) => ({
@@ -221,7 +390,7 @@ export function KentuckySignerProvider({
221
390
  throw error
222
391
  }
223
392
  },
224
- [baseUrl, createAccount, storage, storageKeyPrefix]
393
+ [baseUrl, createAccount, storage, storageKeyPrefix, state.secureMode, ephemeralKeyManager]
225
394
  )
226
395
 
227
396
  // Logout
@@ -239,14 +408,23 @@ export function KentuckySignerProvider({
239
408
  localStorage.removeItem(`${storageKeyPrefix}_session`)
240
409
  }
241
410
 
242
- setState({
411
+ // Clear ephemeral keys if using secure mode
412
+ if (ephemeralKeyManager) {
413
+ await ephemeralKeyManager.clear()
414
+ }
415
+
416
+ setState((s) => ({
243
417
  isAuthenticating: false,
244
418
  isAuthenticated: false,
245
419
  session: null,
246
420
  account: null,
247
421
  error: null,
248
- })
249
- }, [client, state.session, storage, storageKeyPrefix])
422
+ ephemeralKeyBound: false,
423
+ secureMode: s.secureMode,
424
+ persistEphemeralKeys: s.persistEphemeralKeys,
425
+ twoFactorPrompt: defaultTwoFactorPrompt,
426
+ }))
427
+ }, [client, state.session, storage, storageKeyPrefix, ephemeralKeyManager])
250
428
 
251
429
  // Refresh session
252
430
  const refreshSession = useCallback(async () => {
@@ -256,8 +434,6 @@ export function KentuckySignerProvider({
256
434
  const refreshed = await refreshSessionIfNeeded(state.session, baseUrl)
257
435
 
258
436
  if (refreshed !== state.session) {
259
- const account = createAccount(refreshed)
260
-
261
437
  if (storage) {
262
438
  localStorage.setItem(
263
439
  `${storageKeyPrefix}_session`,
@@ -265,11 +441,14 @@ export function KentuckySignerProvider({
265
441
  )
266
442
  }
267
443
 
268
- setState((s) => ({
269
- ...s,
270
- session: refreshed,
271
- account,
272
- }))
444
+ setState((s) => {
445
+ const account = createAccount(refreshed, s.secureMode)
446
+ return {
447
+ ...s,
448
+ session: refreshed,
449
+ account,
450
+ }
451
+ })
273
452
  }
274
453
  } catch (error) {
275
454
  setState((s) => ({ ...s, error: error as Error }))
@@ -281,15 +460,151 @@ export function KentuckySignerProvider({
281
460
  setState((s) => ({ ...s, error: null }))
282
461
  }, [])
283
462
 
463
+ // Toggle secure mode - recreates account with new client
464
+ const setSecureMode = useCallback((enabled: boolean) => {
465
+ // Persist the setting to localStorage
466
+ if (typeof localStorage !== 'undefined') {
467
+ localStorage.setItem(`${storageKeyPrefix}_secure_mode`, String(enabled))
468
+ }
469
+
470
+ setState((s) => {
471
+ // Recreate account with new secure mode setting if authenticated
472
+ const newAccount = s.session ? createAccount(s.session, enabled) : null
473
+ return {
474
+ ...s,
475
+ secureMode: enabled,
476
+ account: newAccount,
477
+ }
478
+ })
479
+ }, [createAccount, storageKeyPrefix])
480
+
481
+ // Toggle ephemeral key persistence - migrates key between IndexedDB and memory storage
482
+ const setPersistEphemeralKeys = useCallback(async (enabled: boolean) => {
483
+ // Determine the new storage backend
484
+ const newStorage = enabled && indexedDBStorage
485
+ ? indexedDBStorage
486
+ : memoryStorage
487
+
488
+ // Migrate the key to the new storage (preserves existing key)
489
+ await ephemeralKeyManager.migrateStorage(newStorage)
490
+
491
+ // Persist the setting to localStorage
492
+ if (typeof localStorage !== 'undefined') {
493
+ localStorage.setItem(`${storageKeyPrefix}_persist_ephemeral_keys`, String(enabled))
494
+ }
495
+
496
+ // Update state
497
+ setState((s) => ({
498
+ ...s,
499
+ persistEphemeralKeys: enabled,
500
+ }))
501
+ }, [ephemeralKeyManager, storageKeyPrefix, indexedDBStorage, memoryStorage])
502
+
503
+ // Get ephemeral public key for external auth flows
504
+ const getEphemeralPublicKey = useCallback(async (): Promise<string | undefined> => {
505
+ if (!state.secureMode) {
506
+ return undefined
507
+ }
508
+ return ephemeralKeyManager.getPublicKey()
509
+ }, [state.secureMode, ephemeralKeyManager])
510
+
511
+ // Authenticate with password
512
+ const authenticatePassword = useCallback(
513
+ async (accountId: string, password: string) => {
514
+ setState((s) => ({ ...s, isAuthenticating: true, error: null }))
515
+
516
+ try {
517
+ // Get ephemeral public key if secure mode is enabled
518
+ let ephemeralPublicKey: string | undefined
519
+ console.log('[KentuckySigner] authenticatePassword - secureMode:', state.secureMode)
520
+ if (state.secureMode) {
521
+ ephemeralPublicKey = await ephemeralKeyManager.getPublicKey()
522
+ console.log('[KentuckySigner] Got ephemeral public key for binding:', ephemeralPublicKey?.substring(0, 50) + '...')
523
+ }
524
+
525
+ // Authenticate with password
526
+ const session = await authWithPassword({
527
+ baseUrl,
528
+ accountId,
529
+ password,
530
+ ephemeralPublicKey,
531
+ })
532
+
533
+ const ephemeralKeyBound = !!ephemeralPublicKey
534
+
535
+ // Persist session
536
+ if (storage) {
537
+ localStorage.setItem(
538
+ `${storageKeyPrefix}_session`,
539
+ JSON.stringify(session)
540
+ )
541
+ }
542
+
543
+ setState((s) => {
544
+ const account = createAccount(session, s.secureMode)
545
+ return {
546
+ isAuthenticating: false,
547
+ isAuthenticated: true,
548
+ session,
549
+ account,
550
+ error: null,
551
+ ephemeralKeyBound,
552
+ secureMode: s.secureMode,
553
+ persistEphemeralKeys: s.persistEphemeralKeys,
554
+ twoFactorPrompt: s.twoFactorPrompt,
555
+ }
556
+ })
557
+ } catch (error) {
558
+ setState((s) => ({
559
+ ...s,
560
+ isAuthenticating: false,
561
+ error: error as Error,
562
+ }))
563
+ throw error
564
+ }
565
+ },
566
+ [baseUrl, createAccount, storage, storageKeyPrefix, state.secureMode, ephemeralKeyManager]
567
+ )
568
+
569
+ // Submit 2FA codes
570
+ const submit2FA = useCallback((codes: TwoFactorCodes) => {
571
+ const { resolve } = state.twoFactorPrompt
572
+ if (resolve) {
573
+ resolve(codes)
574
+ }
575
+ setState((s) => ({
576
+ ...s,
577
+ twoFactorPrompt: defaultTwoFactorPrompt,
578
+ }))
579
+ }, [state.twoFactorPrompt])
580
+
581
+ // Cancel 2FA prompt
582
+ const cancel2FA = useCallback(() => {
583
+ const { resolve } = state.twoFactorPrompt
584
+ if (resolve) {
585
+ resolve(null)
586
+ }
587
+ setState((s) => ({
588
+ ...s,
589
+ twoFactorPrompt: defaultTwoFactorPrompt,
590
+ }))
591
+ }, [state.twoFactorPrompt])
592
+
284
593
  const value: KentuckySignerContextValue = useMemo(
285
594
  () => ({
286
595
  ...state,
287
596
  authenticate,
597
+ authenticatePassword,
288
598
  logout,
289
599
  refreshSession,
290
600
  clearError,
601
+ setSecureMode,
602
+ setPersistEphemeralKeys,
603
+ getEphemeralPublicKey,
604
+ submit2FA,
605
+ cancel2FA,
291
606
  }),
292
- [state, authenticate, logout, refreshSession, clearError]
607
+ [state, authenticate, authenticatePassword, logout, refreshSession, clearError, setSecureMode, setPersistEphemeralKeys, getEphemeralPublicKey, submit2FA, cancel2FA]
293
608
  )
294
609
 
295
610
  return (
@@ -34,10 +34,21 @@ export function useKentuckySigner() {
34
34
  session: context.session,
35
35
  account: context.account,
36
36
  error: context.error,
37
+ ephemeralKeyBound: context.ephemeralKeyBound,
37
38
  authenticate: context.authenticate,
39
+ authenticatePassword: context.authenticatePassword,
38
40
  logout: context.logout,
39
41
  refreshSession: context.refreshSession,
40
42
  clearError: context.clearError,
43
+ secureMode: context.secureMode,
44
+ setSecureMode: context.setSecureMode,
45
+ persistEphemeralKeys: context.persistEphemeralKeys,
46
+ setPersistEphemeralKeys: context.setPersistEphemeralKeys,
47
+ getEphemeralPublicKey: context.getEphemeralPublicKey,
48
+ // 2FA support
49
+ twoFactorPrompt: context.twoFactorPrompt,
50
+ submit2FA: context.submit2FA,
51
+ cancel2FA: context.cancel2FA,
41
52
  }
42
53
  }
43
54
 
@@ -15,6 +15,7 @@ export {
15
15
  type KentuckySignerState,
16
16
  type KentuckySignerActions,
17
17
  type KentuckySignerContextValue,
18
+ type TwoFactorPromptState,
18
19
  } from './context'
19
20
 
20
21
  // Hooks