kentucky-signer-viem 0.1.0 → 0.1.3

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.
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Ephemeral Key Management for Kentucky Signer
3
+ *
4
+ * Implements ephemeral ECDSA keys for request signing.
5
+ * The ephemeral public key is bound to the JWT token during authentication,
6
+ * and the private key is used to sign all subsequent request payloads.
7
+ *
8
+ * Security Properties:
9
+ * - Ephemeral keys are generated fresh for each session
10
+ * - Private keys never leave the client
11
+ * - SGX verifies payload signatures before processing
12
+ * - Prevents replay attacks and token theft
13
+ */
14
+
15
+ import { base64UrlEncode, base64UrlDecode } from './utils'
16
+
17
+ /**
18
+ * Ephemeral key pair for request signing
19
+ */
20
+ export interface EphemeralKeyPair {
21
+ /** Public key in SPKI format (base64url encoded) */
22
+ publicKey: string
23
+ /** Private CryptoKey for signing (not exportable) */
24
+ privateKey: CryptoKey
25
+ /** Algorithm used (ES256 = P-256 with SHA-256) */
26
+ algorithm: 'ES256'
27
+ /** Creation timestamp */
28
+ createdAt: number
29
+ }
30
+
31
+ /**
32
+ * Signed request payload
33
+ */
34
+ export interface SignedPayload {
35
+ /** Original payload (JSON string) */
36
+ payload: string
37
+ /** Signature (base64url encoded) */
38
+ signature: string
39
+ /** Timestamp when signature was created */
40
+ timestamp: number
41
+ }
42
+
43
+ /**
44
+ * Check if WebCrypto is available
45
+ */
46
+ export function isWebCryptoAvailable(): boolean {
47
+ return (
48
+ typeof crypto !== 'undefined' &&
49
+ typeof crypto.subtle !== 'undefined' &&
50
+ typeof crypto.getRandomValues !== 'undefined'
51
+ )
52
+ }
53
+
54
+ /**
55
+ * Generate an ephemeral ECDSA key pair using WebCrypto
56
+ *
57
+ * Uses P-256 curve (secp256r1) with SHA-256 for ES256 signatures.
58
+ *
59
+ * @returns Generated key pair
60
+ * @throws Error if WebCrypto is not available
61
+ */
62
+ export async function generateEphemeralKeyPair(): Promise<EphemeralKeyPair> {
63
+ if (!isWebCryptoAvailable()) {
64
+ throw new Error('WebCrypto is not available in this environment')
65
+ }
66
+
67
+ // Generate ECDSA key pair on P-256 curve
68
+ const keyPair = await crypto.subtle.generateKey(
69
+ {
70
+ name: 'ECDSA',
71
+ namedCurve: 'P-256',
72
+ },
73
+ true, // extractable (only for public key export)
74
+ ['sign', 'verify']
75
+ )
76
+
77
+ // Export public key in SPKI format
78
+ const publicKeyBuffer = await crypto.subtle.exportKey('spki', keyPair.publicKey)
79
+ const publicKeyBase64 = base64UrlEncode(new Uint8Array(publicKeyBuffer))
80
+
81
+ return {
82
+ publicKey: publicKeyBase64,
83
+ privateKey: keyPair.privateKey,
84
+ algorithm: 'ES256',
85
+ createdAt: Date.now(),
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Sign a request payload with the ephemeral private key
91
+ *
92
+ * The signature covers:
93
+ * - The JSON payload string
94
+ * - A timestamp to prevent replay attacks
95
+ *
96
+ * @param payload - Request payload (will be JSON stringified if object)
97
+ * @param keyPair - Ephemeral key pair
98
+ * @returns Signed payload with signature
99
+ */
100
+ export async function signPayload(
101
+ payload: string | object,
102
+ keyPair: EphemeralKeyPair
103
+ ): Promise<SignedPayload> {
104
+ const payloadString = typeof payload === 'string' ? payload : JSON.stringify(payload)
105
+ const timestamp = Date.now()
106
+
107
+ // Create message to sign: timestamp + payload
108
+ // This prevents replay attacks
109
+ const message = `${timestamp}.${payloadString}`
110
+ const messageBytes = new TextEncoder().encode(message)
111
+
112
+ // Sign with ECDSA P-256 + SHA-256
113
+ const signatureBuffer = await crypto.subtle.sign(
114
+ {
115
+ name: 'ECDSA',
116
+ hash: 'SHA-256',
117
+ },
118
+ keyPair.privateKey,
119
+ messageBytes
120
+ )
121
+
122
+ const signatureBase64 = base64UrlEncode(new Uint8Array(signatureBuffer))
123
+
124
+ return {
125
+ payload: payloadString,
126
+ signature: signatureBase64,
127
+ timestamp,
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Verify a signed payload (for testing purposes)
133
+ *
134
+ * @param signedPayload - The signed payload to verify
135
+ * @param publicKeyBase64 - Public key in SPKI format (base64url)
136
+ * @returns True if signature is valid
137
+ */
138
+ export async function verifyPayload(
139
+ signedPayload: SignedPayload,
140
+ publicKeyBase64: string
141
+ ): Promise<boolean> {
142
+ try {
143
+ // Import public key
144
+ const publicKeyBytes = base64UrlDecode(publicKeyBase64)
145
+ const publicKey = await crypto.subtle.importKey(
146
+ 'spki',
147
+ publicKeyBytes.buffer as ArrayBuffer,
148
+ {
149
+ name: 'ECDSA',
150
+ namedCurve: 'P-256',
151
+ },
152
+ false,
153
+ ['verify']
154
+ )
155
+
156
+ // Reconstruct message
157
+ const message = `${signedPayload.timestamp}.${signedPayload.payload}`
158
+ const messageBytes = new TextEncoder().encode(message)
159
+
160
+ // Decode signature
161
+ const signatureBytes = base64UrlDecode(signedPayload.signature)
162
+
163
+ // Verify signature
164
+ return await crypto.subtle.verify(
165
+ {
166
+ name: 'ECDSA',
167
+ hash: 'SHA-256',
168
+ },
169
+ publicKey,
170
+ signatureBytes.buffer as ArrayBuffer,
171
+ messageBytes
172
+ )
173
+ } catch {
174
+ return false
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Storage key for ephemeral key pair
180
+ */
181
+ const EPHEMERAL_KEY_STORAGE_KEY = 'kentucky_signer_ephemeral'
182
+
183
+ /**
184
+ * Ephemeral key storage interface
185
+ */
186
+ export interface EphemeralKeyStorage {
187
+ save(keyPair: EphemeralKeyPair): Promise<void>
188
+ load(): Promise<EphemeralKeyPair | null>
189
+ clear(): Promise<void>
190
+ }
191
+
192
+ /**
193
+ * In-memory ephemeral key storage
194
+ *
195
+ * Keys are lost when the page is refreshed.
196
+ */
197
+ export class MemoryEphemeralKeyStorage implements EphemeralKeyStorage {
198
+ private keyPair: EphemeralKeyPair | null = null
199
+
200
+ async save(keyPair: EphemeralKeyPair): Promise<void> {
201
+ this.keyPair = keyPair
202
+ }
203
+
204
+ async load(): Promise<EphemeralKeyPair | null> {
205
+ return this.keyPair
206
+ }
207
+
208
+ async clear(): Promise<void> {
209
+ this.keyPair = null
210
+ }
211
+ }
212
+
213
+ /**
214
+ * IndexedDB ephemeral key storage
215
+ *
216
+ * Persists keys across page refreshes but not browser restarts.
217
+ * Uses non-extractable keys for security.
218
+ */
219
+ export class IndexedDBEphemeralKeyStorage implements EphemeralKeyStorage {
220
+ private dbName = 'kentucky_signer_ephemeral_keys'
221
+ private storeName = 'keys'
222
+
223
+ private async getDB(): Promise<IDBDatabase> {
224
+ return new Promise((resolve, reject) => {
225
+ const request = indexedDB.open(this.dbName, 1)
226
+
227
+ request.onerror = () => reject(request.error)
228
+ request.onsuccess = () => resolve(request.result)
229
+
230
+ request.onupgradeneeded = () => {
231
+ const db = request.result
232
+ if (!db.objectStoreNames.contains(this.storeName)) {
233
+ db.createObjectStore(this.storeName)
234
+ }
235
+ }
236
+ })
237
+ }
238
+
239
+ async save(keyPair: EphemeralKeyPair): Promise<void> {
240
+ const db = await this.getDB()
241
+
242
+ // Store the CryptoKey directly (IndexedDB supports structured clone of CryptoKey)
243
+ return new Promise((resolve, reject) => {
244
+ const tx = db.transaction(this.storeName, 'readwrite')
245
+ const store = tx.objectStore(this.storeName)
246
+
247
+ const data = {
248
+ publicKey: keyPair.publicKey,
249
+ privateKey: keyPair.privateKey,
250
+ algorithm: keyPair.algorithm,
251
+ createdAt: keyPair.createdAt,
252
+ }
253
+
254
+ const request = store.put(data, EPHEMERAL_KEY_STORAGE_KEY)
255
+ request.onerror = () => reject(request.error)
256
+ request.onsuccess = () => resolve()
257
+ })
258
+ }
259
+
260
+ async load(): Promise<EphemeralKeyPair | null> {
261
+ const db = await this.getDB()
262
+
263
+ return new Promise((resolve, reject) => {
264
+ const tx = db.transaction(this.storeName, 'readonly')
265
+ const store = tx.objectStore(this.storeName)
266
+
267
+ const request = store.get(EPHEMERAL_KEY_STORAGE_KEY)
268
+ request.onerror = () => reject(request.error)
269
+ request.onsuccess = () => {
270
+ if (request.result) {
271
+ resolve({
272
+ publicKey: request.result.publicKey,
273
+ privateKey: request.result.privateKey,
274
+ algorithm: request.result.algorithm,
275
+ createdAt: request.result.createdAt,
276
+ })
277
+ } else {
278
+ resolve(null)
279
+ }
280
+ }
281
+ })
282
+ }
283
+
284
+ async clear(): Promise<void> {
285
+ const db = await this.getDB()
286
+
287
+ return new Promise((resolve, reject) => {
288
+ const tx = db.transaction(this.storeName, 'readwrite')
289
+ const store = tx.objectStore(this.storeName)
290
+
291
+ const request = store.delete(EPHEMERAL_KEY_STORAGE_KEY)
292
+ request.onerror = () => reject(request.error)
293
+ request.onsuccess = () => resolve()
294
+ })
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Create an ephemeral key manager for a session
300
+ *
301
+ * The manager handles:
302
+ * - Key generation on first use
303
+ * - Key storage and retrieval
304
+ * - Automatic key rotation (optional)
305
+ * - Storage migration (switching between IndexedDB and memory)
306
+ */
307
+ export class EphemeralKeyManager {
308
+ private keyPair: EphemeralKeyPair | null = null
309
+ private storage: EphemeralKeyStorage
310
+
311
+ constructor(storage?: EphemeralKeyStorage) {
312
+ this.storage = storage ?? new MemoryEphemeralKeyStorage()
313
+ }
314
+
315
+ /**
316
+ * Get or generate ephemeral key pair
317
+ */
318
+ async getKeyPair(): Promise<EphemeralKeyPair> {
319
+ // Try to load from storage first
320
+ if (!this.keyPair) {
321
+ this.keyPair = await this.storage.load()
322
+ }
323
+
324
+ // Generate new key pair if not found
325
+ if (!this.keyPair) {
326
+ this.keyPair = await generateEphemeralKeyPair()
327
+ await this.storage.save(this.keyPair)
328
+ }
329
+
330
+ return this.keyPair
331
+ }
332
+
333
+ /**
334
+ * Get the public key for authentication
335
+ */
336
+ async getPublicKey(): Promise<string> {
337
+ const keyPair = await this.getKeyPair()
338
+ return keyPair.publicKey
339
+ }
340
+
341
+ /**
342
+ * Sign a request payload
343
+ */
344
+ async signPayload(payload: string | object): Promise<SignedPayload> {
345
+ const keyPair = await this.getKeyPair()
346
+ return signPayload(payload, keyPair)
347
+ }
348
+
349
+ /**
350
+ * Rotate the key pair (generate new keys)
351
+ */
352
+ async rotate(): Promise<EphemeralKeyPair> {
353
+ await this.storage.clear()
354
+ this.keyPair = await generateEphemeralKeyPair()
355
+ await this.storage.save(this.keyPair)
356
+ return this.keyPair
357
+ }
358
+
359
+ /**
360
+ * Clear the key pair (logout)
361
+ */
362
+ async clear(): Promise<void> {
363
+ await this.storage.clear()
364
+ this.keyPair = null
365
+ }
366
+
367
+ /**
368
+ * Check if a key pair exists
369
+ */
370
+ async hasKeyPair(): Promise<boolean> {
371
+ if (this.keyPair) return true
372
+ const loaded = await this.storage.load()
373
+ return loaded !== null
374
+ }
375
+
376
+ /**
377
+ * Migrate key pair to a new storage backend
378
+ *
379
+ * This preserves the existing key pair while switching storage.
380
+ * The key is saved to the new storage and removed from the old storage.
381
+ *
382
+ * @param newStorage - The new storage backend to migrate to
383
+ */
384
+ async migrateStorage(newStorage: EphemeralKeyStorage): Promise<void> {
385
+ // Get current key pair (from memory or old storage)
386
+ const currentKeyPair = this.keyPair ?? await this.storage.load()
387
+
388
+ // Clear old storage
389
+ await this.storage.clear()
390
+
391
+ // Switch to new storage
392
+ this.storage = newStorage
393
+
394
+ // Save key pair to new storage if we had one
395
+ if (currentKeyPair) {
396
+ await this.storage.save(currentKeyPair)
397
+ this.keyPair = currentKeyPair
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Get the current storage backend
403
+ */
404
+ getStorage(): EphemeralKeyStorage {
405
+ return this.storage
406
+ }
407
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,8 @@ export {
13
13
  createServerAccount,
14
14
  type KentuckySignerAccount,
15
15
  type KentuckySignerAccountOptions,
16
+ type TwoFactorCodes,
17
+ type TwoFactorCallback,
16
18
  } from './account'
17
19
 
18
20
  // Client
@@ -22,6 +24,27 @@ export {
22
24
  createClient,
23
25
  } from './client'
24
26
 
27
+ // Secure Client (with ephemeral key signing)
28
+ export {
29
+ SecureKentuckySignerClient,
30
+ createSecureClient,
31
+ type SecureClientOptions,
32
+ } from './secure-client'
33
+
34
+ // Ephemeral Key Management
35
+ export {
36
+ generateEphemeralKeyPair,
37
+ signPayload,
38
+ verifyPayload,
39
+ isWebCryptoAvailable,
40
+ EphemeralKeyManager,
41
+ MemoryEphemeralKeyStorage,
42
+ IndexedDBEphemeralKeyStorage,
43
+ type EphemeralKeyPair,
44
+ type SignedPayload,
45
+ type EphemeralKeyStorage,
46
+ } from './ephemeral'
47
+
25
48
  // Authentication
26
49
  export {
27
50
  authenticateWithPasskey,
@@ -43,6 +66,8 @@ export type {
43
66
  ChallengeResponse,
44
67
  AuthResponse,
45
68
  AccountInfoResponse,
69
+ AccountInfoExtendedResponse,
70
+ AuthConfig,
46
71
  EvmSignatureResponse,
47
72
  ApiErrorResponse,
48
73
  PasskeyCredential,
@@ -56,6 +81,37 @@ export type {
56
81
  AccountCreationResponse,
57
82
  CreatePasswordAccountRequest,
58
83
  PasswordAuthRequest,
84
+ AddPasswordRequest,
85
+ AddPasswordResponse,
86
+ AddPasskeyRequest,
87
+ AddPasskeyResponse,
88
+ RemovePasskeyResponse,
89
+ AuthResponseWithEphemeral,
90
+ // Guardian types
91
+ GuardianInfo,
92
+ AddGuardianRequest,
93
+ AddGuardianResponse,
94
+ RemoveGuardianResponse,
95
+ GetGuardiansResponse,
96
+ // Recovery types
97
+ InitiateRecoveryRequest,
98
+ InitiateRecoveryResponse,
99
+ VerifyGuardianRequest,
100
+ VerifyGuardianResponse,
101
+ RecoveryStatusRequest,
102
+ RecoveryStatusResponse,
103
+ CompleteRecoveryRequest,
104
+ CompleteRecoveryResponse,
105
+ CancelRecoveryResponse,
106
+ // 2FA types
107
+ TwoFactorStatusResponse,
108
+ TotpSetupResponse,
109
+ TotpEnableRequest,
110
+ TwoFactorResponse,
111
+ TwoFactorVerifyResponse,
112
+ PinSetupRequest,
113
+ PinSetupResponse,
114
+ SignEvmRequestWith2FA,
59
115
  } from './types'
60
116
 
61
117
  // Utilities