kentucky-signer-viem 0.1.1 → 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,417 @@
1
+ /**
2
+ * Secure Kentucky Signer Client with Ephemeral Key Signing
3
+ *
4
+ * This client automatically:
5
+ * - Generates an ephemeral key pair on initialization
6
+ * - Sends the public key during authentication
7
+ * - Signs all request payloads with the ephemeral private key
8
+ */
9
+
10
+ import type {
11
+ ClientOptions,
12
+ ChallengeResponse,
13
+ AuthResponse,
14
+ AccountInfoResponse,
15
+ AccountInfoExtendedResponse,
16
+ EvmSignatureResponse,
17
+ ApiErrorResponse,
18
+ PasskeyCredential,
19
+ AccountCreationResponse,
20
+ SignEvmRequest,
21
+ CreatePasswordAccountRequest,
22
+ PasswordAuthRequest,
23
+ AddPasswordRequest,
24
+ AddPasswordResponse,
25
+ AddPasskeyRequest,
26
+ AddPasskeyResponse,
27
+ RemovePasskeyResponse,
28
+ AuthResponseWithEphemeral,
29
+ } from './types'
30
+ import type { Hex } from 'viem'
31
+ import {
32
+ EphemeralKeyManager,
33
+ type SignedPayload,
34
+ MemoryEphemeralKeyStorage,
35
+ type EphemeralKeyStorage,
36
+ } from './ephemeral'
37
+ import { KentuckySignerError } from './client'
38
+
39
+ /**
40
+ * Options for the secure client
41
+ */
42
+ export interface SecureClientOptions extends ClientOptions {
43
+ /** Custom ephemeral key storage (defaults to memory storage) */
44
+ ephemeralKeyStorage?: EphemeralKeyStorage
45
+ /** Shared ephemeral key manager (takes precedence over storage) */
46
+ ephemeralKeyManager?: EphemeralKeyManager
47
+ /** Whether to require ephemeral key verification (default: true) */
48
+ requireEphemeralSigning?: boolean
49
+ }
50
+
51
+ /**
52
+ * Secure Kentucky Signer API client with ephemeral key signing
53
+ *
54
+ * All requests to authenticated endpoints are automatically signed
55
+ * with the ephemeral private key bound during authentication.
56
+ */
57
+ export class SecureKentuckySignerClient {
58
+ private baseUrl: string
59
+ private fetchImpl: typeof fetch
60
+ private timeout: number
61
+ private keyManager: EphemeralKeyManager
62
+ private requireSigning: boolean
63
+
64
+ constructor(options: SecureClientOptions) {
65
+ this.baseUrl = options.baseUrl.replace(/\/$/, '')
66
+ this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis)
67
+ this.timeout = options.timeout ?? 30000
68
+ // Use shared manager if provided, otherwise create one with the storage
69
+ this.keyManager = options.ephemeralKeyManager ?? new EphemeralKeyManager(
70
+ options.ephemeralKeyStorage ?? new MemoryEphemeralKeyStorage()
71
+ )
72
+ this.requireSigning = options.requireEphemeralSigning ?? true
73
+ }
74
+
75
+ /**
76
+ * Get the ephemeral key manager for advanced usage
77
+ */
78
+ getKeyManager(): EphemeralKeyManager {
79
+ return this.keyManager
80
+ }
81
+
82
+ /**
83
+ * Make a signed request to the API
84
+ */
85
+ private async request<T>(
86
+ path: string,
87
+ options: RequestInit & { token?: string; skipSigning?: boolean } = {}
88
+ ): Promise<T> {
89
+ const { token, skipSigning, ...fetchOptions } = options
90
+
91
+ const headers: HeadersInit = {
92
+ 'Content-Type': 'application/json',
93
+ ...options.headers,
94
+ }
95
+
96
+ if (token) {
97
+ ;(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`
98
+ }
99
+
100
+ let body = options.body
101
+
102
+ // Sign the request body if token is present and signing is required
103
+ if (token && !skipSigning && this.requireSigning && body) {
104
+ const signedPayload = await this.keyManager.signPayload(body as string)
105
+
106
+ // Add signature headers - body is NOT modified
107
+ // The enclave verifies the signature against the original body
108
+ ;(headers as Record<string, string>)['X-Ephemeral-Signature'] =
109
+ signedPayload.signature
110
+ ;(headers as Record<string, string>)['X-Ephemeral-Timestamp'] =
111
+ signedPayload.timestamp.toString()
112
+ }
113
+
114
+ const controller = new AbortController()
115
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout)
116
+
117
+ try {
118
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
119
+ ...fetchOptions,
120
+ headers,
121
+ body,
122
+ signal: controller.signal,
123
+ })
124
+
125
+ const data = await response.json()
126
+
127
+ if (!response.ok || data.success === false) {
128
+ const error = data as ApiErrorResponse
129
+ throw new KentuckySignerError(
130
+ error.error?.message ?? 'Unknown error',
131
+ error.error?.code ?? 'UNKNOWN_ERROR',
132
+ error.error?.details
133
+ )
134
+ }
135
+
136
+ return data as T
137
+ } finally {
138
+ clearTimeout(timeoutId)
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Get a challenge for passkey authentication
144
+ */
145
+ async getChallenge(accountId: string): Promise<ChallengeResponse> {
146
+ return this.request<ChallengeResponse>('/api/auth/challenge', {
147
+ method: 'POST',
148
+ body: JSON.stringify({ account_id: accountId }),
149
+ skipSigning: true, // No token yet
150
+ })
151
+ }
152
+
153
+ /**
154
+ * Authenticate with a passkey credential
155
+ *
156
+ * Automatically sends the ephemeral public key for binding.
157
+ */
158
+ async authenticatePasskey(
159
+ accountId: string,
160
+ credential: PasskeyCredential
161
+ ): Promise<AuthResponseWithEphemeral> {
162
+ // Get ephemeral public key for binding
163
+ const ephemeralPublicKey = await this.keyManager.getPublicKey()
164
+
165
+ return this.request<AuthResponseWithEphemeral>('/api/auth/passkey', {
166
+ method: 'POST',
167
+ body: JSON.stringify({
168
+ account_id: accountId,
169
+ credential_id: credential.credentialId,
170
+ client_data_json: credential.clientDataJSON,
171
+ authenticator_data: credential.authenticatorData,
172
+ signature: credential.signature,
173
+ user_handle: credential.userHandle,
174
+ ephemeral_public_key: ephemeralPublicKey,
175
+ }),
176
+ skipSigning: true, // No token yet
177
+ })
178
+ }
179
+
180
+ /**
181
+ * Authenticate with password
182
+ *
183
+ * Automatically sends the ephemeral public key for binding.
184
+ */
185
+ async authenticatePassword(
186
+ request: PasswordAuthRequest
187
+ ): Promise<AuthResponseWithEphemeral> {
188
+ // Get ephemeral public key for binding
189
+ const ephemeralPublicKey = await this.keyManager.getPublicKey()
190
+
191
+ return this.request<AuthResponseWithEphemeral>('/api/auth/password', {
192
+ method: 'POST',
193
+ body: JSON.stringify({
194
+ ...request,
195
+ ephemeral_public_key: ephemeralPublicKey,
196
+ }),
197
+ skipSigning: true, // No token yet
198
+ })
199
+ }
200
+
201
+ /**
202
+ * Refresh an authentication token
203
+ *
204
+ * Generates a new ephemeral key pair and binds it to the new token.
205
+ */
206
+ async refreshToken(token: string): Promise<AuthResponseWithEphemeral> {
207
+ // Rotate ephemeral key on refresh
208
+ await this.keyManager.rotate()
209
+ const ephemeralPublicKey = await this.keyManager.getPublicKey()
210
+
211
+ return this.request<AuthResponseWithEphemeral>('/api/auth/refresh', {
212
+ method: 'POST',
213
+ token,
214
+ body: JSON.stringify({
215
+ ephemeral_public_key: ephemeralPublicKey,
216
+ }),
217
+ skipSigning: true, // Use old token, but send new key
218
+ })
219
+ }
220
+
221
+ /**
222
+ * Logout and invalidate token
223
+ *
224
+ * Clears the ephemeral key pair.
225
+ */
226
+ async logout(token: string): Promise<void> {
227
+ await this.request('/api/auth/logout', {
228
+ method: 'POST',
229
+ token,
230
+ skipSigning: true,
231
+ })
232
+ await this.keyManager.clear()
233
+ }
234
+
235
+ /**
236
+ * Get account information (signed request)
237
+ */
238
+ async getAccountInfo(
239
+ accountId: string,
240
+ token: string
241
+ ): Promise<AccountInfoResponse> {
242
+ // GET requests don't have a body, so we can't sign them the normal way
243
+ // For now, skip signing for GET requests
244
+ return this.request<AccountInfoResponse>(`/api/accounts/${accountId}`, {
245
+ method: 'GET',
246
+ token,
247
+ skipSigning: true,
248
+ })
249
+ }
250
+
251
+ /**
252
+ * Get extended account information (signed request)
253
+ */
254
+ async getAccountInfoExtended(
255
+ accountId: string,
256
+ token: string
257
+ ): Promise<AccountInfoExtendedResponse> {
258
+ return this.request<AccountInfoExtendedResponse>(
259
+ `/api/accounts/${accountId}`,
260
+ {
261
+ method: 'GET',
262
+ token,
263
+ skipSigning: true,
264
+ }
265
+ )
266
+ }
267
+
268
+ /**
269
+ * Sign an EVM transaction hash (signed request)
270
+ */
271
+ async signEvmTransaction(
272
+ request: SignEvmRequest,
273
+ token: string
274
+ ): Promise<EvmSignatureResponse> {
275
+ return this.request<EvmSignatureResponse>('/api/sign/evm', {
276
+ method: 'POST',
277
+ token,
278
+ body: JSON.stringify(request),
279
+ })
280
+ }
281
+
282
+ /**
283
+ * Sign an EVM transaction hash with 2FA (signed request)
284
+ */
285
+ async signEvmTransactionWith2FA(
286
+ request: SignEvmRequest & { totp_code?: string; pin?: string },
287
+ token: string
288
+ ): Promise<EvmSignatureResponse> {
289
+ return this.request<EvmSignatureResponse>('/api/sign/evm', {
290
+ method: 'POST',
291
+ token,
292
+ body: JSON.stringify(request),
293
+ })
294
+ }
295
+
296
+ /**
297
+ * Sign a raw hash for EVM (signed request)
298
+ */
299
+ async signHash(hash: Hex, chainId: number, token: string): Promise<Hex> {
300
+ const response = await this.signEvmTransaction(
301
+ { tx_hash: hash, chain_id: chainId },
302
+ token
303
+ )
304
+ return response.signature.full
305
+ }
306
+
307
+ /**
308
+ * Add password to account (signed request)
309
+ */
310
+ async addPassword(
311
+ accountId: string,
312
+ request: AddPasswordRequest,
313
+ token: string
314
+ ): Promise<AddPasswordResponse> {
315
+ return this.request<AddPasswordResponse>(
316
+ `/api/accounts/${accountId}/password`,
317
+ {
318
+ method: 'POST',
319
+ token,
320
+ body: JSON.stringify(request),
321
+ }
322
+ )
323
+ }
324
+
325
+ /**
326
+ * Add passkey to account (signed request)
327
+ */
328
+ async addPasskey(
329
+ accountId: string,
330
+ request: AddPasskeyRequest,
331
+ token: string
332
+ ): Promise<AddPasskeyResponse> {
333
+ return this.request<AddPasskeyResponse>(
334
+ `/api/accounts/${accountId}/passkeys`,
335
+ {
336
+ method: 'POST',
337
+ token,
338
+ body: JSON.stringify(request),
339
+ }
340
+ )
341
+ }
342
+
343
+ /**
344
+ * Remove passkey from account (signed request)
345
+ */
346
+ async removePasskey(
347
+ accountId: string,
348
+ passkeyIndex: number,
349
+ token: string
350
+ ): Promise<RemovePasskeyResponse> {
351
+ return this.request<RemovePasskeyResponse>(
352
+ `/api/accounts/${accountId}/passkeys/${passkeyIndex}`,
353
+ {
354
+ method: 'DELETE',
355
+ token,
356
+ body: JSON.stringify({ passkey_index: passkeyIndex }),
357
+ }
358
+ )
359
+ }
360
+
361
+ /**
362
+ * Create account with passkey (public endpoint, no signing)
363
+ */
364
+ async createAccountWithPasskey(
365
+ attestationObject: string,
366
+ label?: string
367
+ ): Promise<AccountCreationResponse> {
368
+ const body: Record<string, string> = {
369
+ attestation_object: attestationObject,
370
+ }
371
+ if (label) {
372
+ body.label = label
373
+ }
374
+ return this.request<AccountCreationResponse>('/api/accounts/create/passkey', {
375
+ method: 'POST',
376
+ body: JSON.stringify(body),
377
+ skipSigning: true,
378
+ })
379
+ }
380
+
381
+ /**
382
+ * Create account with password (public endpoint, no signing)
383
+ */
384
+ async createAccountWithPassword(
385
+ request: CreatePasswordAccountRequest
386
+ ): Promise<AccountCreationResponse> {
387
+ return this.request<AccountCreationResponse>('/api/accounts/create/password', {
388
+ method: 'POST',
389
+ body: JSON.stringify(request),
390
+ skipSigning: true,
391
+ })
392
+ }
393
+
394
+ /**
395
+ * Health check (public endpoint)
396
+ */
397
+ async healthCheck(): Promise<boolean> {
398
+ try {
399
+ const response = await this.request<{ status: string }>('/api/health', {
400
+ method: 'GET',
401
+ skipSigning: true,
402
+ })
403
+ return response.status === 'ok'
404
+ } catch {
405
+ return false
406
+ }
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Create a new secure Kentucky Signer client
412
+ */
413
+ export function createSecureClient(
414
+ options: SecureClientOptions
415
+ ): SecureKentuckySignerClient {
416
+ return new SecureKentuckySignerClient(options)
417
+ }