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.
- package/README.md +195 -218
- package/dist/index.d.mts +802 -7
- package/dist/index.d.ts +802 -7
- package/dist/index.js +964 -37
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +955 -37
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.d.mts +61 -3
- package/dist/react/index.d.ts +61 -3
- package/dist/react/index.js +1286 -173
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +1288 -174
- package/dist/react/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/account.ts +111 -22
- package/src/auth.ts +16 -6
- package/src/client.ts +438 -18
- package/src/ephemeral.ts +407 -0
- package/src/index.ts +56 -0
- package/src/react/context.tsx +360 -45
- package/src/react/hooks.ts +11 -0
- package/src/react/index.ts +1 -0
- package/src/secure-client.ts +417 -0
- package/src/types.ts +332 -0
|
@@ -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
|
+
}
|