@wishknish/knishio-client-js 0.9.4 → 1.1.0

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,646 @@
1
+ /*
2
+ (
3
+ (/(
4
+ (//(
5
+ (///(
6
+ (/////(
7
+ (//////( )
8
+ (////////( (/)
9
+ (////////( (///)
10
+ (//////////( (////)
11
+ (//////////( (//////)
12
+ (////////////( (///////)
13
+ (/////////////( (/////////)
14
+ (//////////////( (///////////)
15
+ (///////////////( (/////////////)
16
+ (////////////////( (//////////////)
17
+ ((((((((((((((((((( (((((((((((((((
18
+ ((((((((((((((((((( ((((((((((((((
19
+ ((((((((((((((((((( ((((((((((((((
20
+ (((((((((((((((((((( (((((((((((((
21
+ (((((((((((((((((((( ((((((((((((
22
+ ((((((((((((((((((( ((((((((((((
23
+ ((((((((((((((((((( ((((((((((
24
+ ((((((((((((((((((/ (((((((((
25
+ (((((((((((((((((( ((((((((
26
+ ((((((((((((((((( (((((((
27
+ (((((((((((((((((( (((((
28
+ ################# ##
29
+ ################ #
30
+ ################# ##
31
+ %################ ###
32
+ ###############( ####
33
+ ############### ####
34
+ ############### ######
35
+ %#############( (#######
36
+ %############# #########
37
+ ############( ##########
38
+ ########### #############
39
+ ######### ##############
40
+ %######
41
+
42
+ Powered by Knish.IO: Connecting a Decentralized World
43
+
44
+ Please visit https://github.com/WishKnish/KnishIO-Client-JS for information.
45
+
46
+ License: https://github.com/WishKnish/KnishIO-Client-JS/blob/master/LICENSE
47
+ */
48
+
49
+ import SecretStorageException from '../exception/SecretStorageException.js'
50
+ import { zeroizeBytes, withSecureBytes } from '../libraries/secureMemory.js'
51
+ import {
52
+ sealEnvelope,
53
+ openEnvelope,
54
+ uint8ArrayToBase64,
55
+ base64ToUint8Array,
56
+ SECRET_KEY_PREFIX,
57
+ RECOVERY_KEY_PREFIX
58
+ } from './secretEnvelope.js'
59
+
60
+ export const PRF_SALT_LABEL = 'knishio:secret-storage:webauthn-prf:v1'
61
+ export const KEK_INFO = 'knishio:secret-storage:kek:v1'
62
+ const KEY_PREFIX = SECRET_KEY_PREFIX
63
+ const GCM_IV_LENGTH = 12
64
+
65
+ const textEncoder = new TextEncoder()
66
+ const textDecoder = new TextDecoder()
67
+
68
+ function base64UrlEncode (bytes) {
69
+ return uint8ArrayToBase64(bytes)
70
+ .replace(/\+/g, '-')
71
+ .replace(/\//g, '_')
72
+ .replace(/=+$/, '')
73
+ }
74
+
75
+ function base64UrlDecode (str) {
76
+ let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
77
+ while (base64.length % 4 !== 0) {
78
+ base64 += '='
79
+ }
80
+ return base64ToUint8Array(base64)
81
+ }
82
+
83
+ function toUint8Array (buf) {
84
+ if (buf instanceof Uint8Array) {
85
+ return buf
86
+ }
87
+ if (ArrayBuffer.isView(buf)) {
88
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
89
+ }
90
+ return new Uint8Array(buf)
91
+ }
92
+
93
+ async function computePrfSalt () {
94
+ const hash = await globalThis.crypto.subtle.digest('SHA-256', textEncoder.encode(PRF_SALT_LABEL))
95
+ return new Uint8Array(hash)
96
+ }
97
+
98
+ async function deriveKekFromPrf (prfOutput, prfSalt) {
99
+ const hkdfKey = await globalThis.crypto.subtle.importKey(
100
+ 'raw',
101
+ prfOutput,
102
+ 'HKDF',
103
+ false,
104
+ ['deriveKey']
105
+ )
106
+
107
+ return await globalThis.crypto.subtle.deriveKey(
108
+ {
109
+ name: 'HKDF',
110
+ hash: 'SHA-256',
111
+ salt: prfSalt,
112
+ info: textEncoder.encode(KEK_INFO)
113
+ },
114
+ hkdfKey,
115
+ { name: 'AES-GCM', length: 256 },
116
+ false,
117
+ ['encrypt', 'decrypt']
118
+ )
119
+ }
120
+
121
+ /**
122
+ * Hardware-attested passkey PRF secret storage provider.
123
+ * Wraps a random device passphrase under a KEK derived from the WebAuthn PRF extension.
124
+ */
125
+ export default class WebAuthnPrfSecretStorageProvider {
126
+ /**
127
+ * @param {object} options
128
+ * @param {object} options.backend
129
+ * @param {{ id?: string, name: string }} options.rp
130
+ * @param {{ id: Uint8Array, name: string, displayName: string }} options.user
131
+ * @param {object} [options.credentials]
132
+ * @param {string} [options.alias]
133
+ */
134
+ constructor (options) {
135
+ this.providerType = 'webauthn-prf'
136
+ this.backend = options.backend
137
+ this.rp = options.rp
138
+ this.user = options.user
139
+ this.credentialsContainer = options.credentials
140
+ this.alias = options.alias || 'default'
141
+ this.cachedPassphrase = undefined
142
+ }
143
+
144
+ get credentials () {
145
+ if (this.credentialsContainer) {
146
+ return this.credentialsContainer
147
+ }
148
+ if (typeof globalThis.navigator !== 'undefined' && globalThis.navigator.credentials) {
149
+ return globalThis.navigator.credentials
150
+ }
151
+ throw SecretStorageException.unavailable(
152
+ this.providerType,
153
+ 'WebAuthn credentials container is not available'
154
+ )
155
+ }
156
+
157
+ get recordKey () {
158
+ return `knishio:webauthn-prf:${this.alias}`
159
+ }
160
+
161
+ isHardwareBacked () {
162
+ // The PRF secret is bound to the authenticator hardware, but the derived KEK
163
+ // material is handled by page JS and passkey attestation is not verified here.
164
+ return false
165
+ }
166
+
167
+ async isAvailable () {
168
+ const hasWebCrypto =
169
+ typeof globalThis.crypto !== 'undefined' &&
170
+ typeof globalThis.crypto.subtle !== 'undefined'
171
+ const hasCredentials = Boolean(
172
+ this.credentialsContainer ||
173
+ (typeof globalThis.navigator !== 'undefined' &&
174
+ globalThis.navigator.credentials &&
175
+ typeof globalThis.PublicKeyCredential !== 'undefined')
176
+ )
177
+ return hasWebCrypto && hasCredentials
178
+ }
179
+
180
+ /**
181
+ * Enroll a new passkey credential with PRF support and wrap a random device passphrase
182
+ */
183
+ async enroll () {
184
+ const existing = await this.backend.getItem(this.recordKey)
185
+ if (existing) {
186
+ return
187
+ }
188
+
189
+ if (!await this.isAvailable()) {
190
+ throw SecretStorageException.unavailable(this.providerType, 'WebAuthn PRF is not available')
191
+ }
192
+
193
+ const challenge = new Uint8Array(32)
194
+ globalThis.crypto.getRandomValues(challenge)
195
+
196
+ const credential = await this.credentials.create({
197
+ publicKey: {
198
+ rp: this.rp,
199
+ user: {
200
+ id: this.user.id,
201
+ name: this.user.name,
202
+ displayName: this.user.displayName
203
+ },
204
+ challenge,
205
+ pubKeyCredParams: [
206
+ { type: 'public-key', alg: -7 },
207
+ { type: 'public-key', alg: -257 }
208
+ ],
209
+ authenticatorSelection: {
210
+ residentKey: 'required',
211
+ userVerification: 'required'
212
+ },
213
+ extensions: {
214
+ prf: {}
215
+ }
216
+ }
217
+ })
218
+
219
+ if (!credential) {
220
+ throw SecretStorageException.unavailable(this.providerType, 'Authenticator creation returned null')
221
+ }
222
+
223
+ const extResults = credential.getClientExtensionResults?.()
224
+ if (extResults?.prf?.enabled !== true) {
225
+ throw SecretStorageException.unavailable(
226
+ this.providerType,
227
+ 'authenticator does not support the PRF extension'
228
+ )
229
+ }
230
+
231
+ const credentialIdBytes = new Uint8Array(credential.rawId)
232
+ const prfSalt = await computePrfSalt()
233
+
234
+ const getChallenge = new Uint8Array(32)
235
+ globalThis.crypto.getRandomValues(getChallenge)
236
+
237
+ let assertion
238
+ try {
239
+ assertion = await this.credentials.get({
240
+ publicKey: {
241
+ challenge: getChallenge,
242
+ rpId: this.rp.id,
243
+ allowCredentials: [
244
+ {
245
+ type: 'public-key',
246
+ id: credentialIdBytes
247
+ }
248
+ ],
249
+ userVerification: 'required',
250
+ extensions: {
251
+ prf: {
252
+ eval: {
253
+ first: prfSalt
254
+ }
255
+ }
256
+ }
257
+ }
258
+ })
259
+ } catch (err) {
260
+ if (err?.name === 'NotAllowedError') {
261
+ throw SecretStorageException.unavailable(
262
+ this.providerType,
263
+ 'authenticator refused or credential missing'
264
+ )
265
+ }
266
+ throw err
267
+ }
268
+
269
+ if (!assertion) {
270
+ throw SecretStorageException.unavailable(
271
+ this.providerType,
272
+ 'authenticator refused or credential missing'
273
+ )
274
+ }
275
+
276
+ const getExtResults = assertion?.getClientExtensionResults?.()
277
+ const firstOutput = getExtResults?.prf?.results?.first
278
+ if (!firstOutput) {
279
+ throw SecretStorageException.unavailable(
280
+ this.providerType,
281
+ 'authenticator returned no PRF result'
282
+ )
283
+ }
284
+
285
+ const prfBytes = toUint8Array(firstOutput)
286
+ const kek = await deriveKekFromPrf(prfBytes, prfSalt)
287
+
288
+ const devicePassphraseBytes = new Uint8Array(32)
289
+ globalThis.crypto.getRandomValues(devicePassphraseBytes)
290
+ const devicePassphrase = uint8ArrayToBase64(devicePassphraseBytes)
291
+
292
+ const iv = new Uint8Array(GCM_IV_LENGTH)
293
+ globalThis.crypto.getRandomValues(iv)
294
+
295
+ const passphraseBytes = textEncoder.encode(devicePassphrase)
296
+ try {
297
+ const encryptedBuffer = await globalThis.crypto.subtle.encrypt(
298
+ {
299
+ name: 'AES-GCM',
300
+ iv
301
+ },
302
+ kek,
303
+ passphraseBytes
304
+ )
305
+
306
+ const record = {
307
+ version: 1,
308
+ credentialId: base64UrlEncode(credentialIdBytes),
309
+ iv: uint8ArrayToBase64(iv),
310
+ ciphertext: uint8ArrayToBase64(new Uint8Array(encryptedBuffer))
311
+ }
312
+
313
+ await this.backend.setItem(this.recordKey, JSON.stringify(record))
314
+ this.cachedPassphrase = devicePassphrase
315
+ } finally {
316
+ zeroizeBytes(passphraseBytes)
317
+ zeroizeBytes(devicePassphraseBytes)
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Unlock the device passphrase using the enrolled WebAuthn PRF credential
323
+ */
324
+ async unlock () {
325
+ if (this.cachedPassphrase) {
326
+ return this.cachedPassphrase
327
+ }
328
+
329
+ const rawRecord = await this.backend.getItem(this.recordKey)
330
+ if (!rawRecord) {
331
+ throw SecretStorageException.unavailable(
332
+ this.providerType,
333
+ 'no enrolled credential; call enroll() first'
334
+ )
335
+ }
336
+
337
+ let record
338
+ try {
339
+ record = JSON.parse(rawRecord)
340
+ } catch {
341
+ throw SecretStorageException.decryptionFailed('Corrupted PRF record format')
342
+ }
343
+
344
+ const credentialIdBytes = base64UrlDecode(record.credentialId)
345
+ const prfSalt = await computePrfSalt()
346
+
347
+ const challenge = new Uint8Array(32)
348
+ globalThis.crypto.getRandomValues(challenge)
349
+
350
+ let assertion
351
+ try {
352
+ assertion = await this.credentials.get({
353
+ publicKey: {
354
+ challenge,
355
+ rpId: this.rp.id,
356
+ allowCredentials: [
357
+ {
358
+ type: 'public-key',
359
+ id: credentialIdBytes
360
+ }
361
+ ],
362
+ userVerification: 'required',
363
+ extensions: {
364
+ prf: {
365
+ eval: {
366
+ first: prfSalt
367
+ }
368
+ }
369
+ }
370
+ }
371
+ })
372
+ } catch (err) {
373
+ if (err?.name === 'NotAllowedError') {
374
+ throw SecretStorageException.unavailable(
375
+ this.providerType,
376
+ 'authenticator refused or credential missing'
377
+ )
378
+ }
379
+ throw err
380
+ }
381
+
382
+ if (!assertion) {
383
+ throw SecretStorageException.unavailable(
384
+ this.providerType,
385
+ 'authenticator refused or credential missing'
386
+ )
387
+ }
388
+
389
+ const extResults = assertion?.getClientExtensionResults?.()
390
+ const firstOutput = extResults?.prf?.results?.first
391
+ if (!firstOutput) {
392
+ throw SecretStorageException.unavailable(
393
+ this.providerType,
394
+ 'authenticator returned no PRF result'
395
+ )
396
+ }
397
+
398
+ const prfBytes = toUint8Array(firstOutput)
399
+ const kek = await deriveKekFromPrf(prfBytes, prfSalt)
400
+
401
+ const iv = base64ToUint8Array(record.iv)
402
+ const ciphertext = base64ToUint8Array(record.ciphertext)
403
+
404
+ try {
405
+ const decryptedBuffer = await globalThis.crypto.subtle.decrypt(
406
+ {
407
+ name: 'AES-GCM',
408
+ iv
409
+ },
410
+ kek,
411
+ ciphertext
412
+ )
413
+
414
+ const decryptedBytes = new Uint8Array(decryptedBuffer)
415
+ try {
416
+ this.cachedPassphrase = textDecoder.decode(decryptedBytes)
417
+ return this.cachedPassphrase
418
+ } finally {
419
+ zeroizeBytes(decryptedBytes)
420
+ }
421
+ } catch {
422
+ throw SecretStorageException.decryptionFailed(
423
+ 'wrapped device passphrase failed authentication under the enrolled credential'
424
+ )
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Lock the provider by clearing cached passphrase material
430
+ */
431
+ lock () {
432
+ this.cachedPassphrase = undefined
433
+ }
434
+
435
+ /**
436
+ * Unenroll the current credential, removing the stored PRF record and clearing cached passphrase
437
+ *
438
+ * @returns {Promise<void>}
439
+ */
440
+ async unenroll () {
441
+ this.lock()
442
+ await this.backend.removeItem(this.recordKey)
443
+ }
444
+
445
+ async storeSecret (bundleHash, secret, options = {}) {
446
+ if (!bundleHash) {
447
+ throw new SecretStorageException('Bundle hash cannot be empty')
448
+ }
449
+ if (!secret) {
450
+ throw new SecretStorageException('Secret cannot be empty')
451
+ }
452
+ if (options.passphrase) {
453
+ throw new SecretStorageException(
454
+ 'WebAuthnPrfSecretStorageProvider derives its passphrase from the authenticator; options.passphrase is not accepted'
455
+ )
456
+ }
457
+
458
+ if (!options.recoveryPassphrase && !options.allowUnrecoverable) {
459
+ throw SecretStorageException.validationError(
460
+ 'Recovery passphrase required for non-exportable hardware key unless allowUnrecoverable is true'
461
+ )
462
+ }
463
+
464
+ const passphrase = await this.unlock()
465
+ const metadata = {
466
+ bundleHash,
467
+ label: options.label,
468
+ createdAt: Date.now(),
469
+ hardwareBacked: false,
470
+ providerType: this.providerType
471
+ }
472
+
473
+ const payload = await sealEnvelope(secret, passphrase, metadata)
474
+ await this.backend.setItem(`${KEY_PREFIX}${bundleHash}`, JSON.stringify(payload))
475
+
476
+ if (options.recoveryPassphrase) {
477
+ const recoveryMetadata = {
478
+ bundleHash,
479
+ label: options.label,
480
+ createdAt: Date.now(),
481
+ hardwareBacked: false,
482
+ providerType: 'webcrypto-aes-gcm'
483
+ }
484
+ const recoveryPayload = await sealEnvelope(secret, options.recoveryPassphrase, recoveryMetadata)
485
+ await this.backend.setItem(`${RECOVERY_KEY_PREFIX}${bundleHash}`, JSON.stringify(recoveryPayload))
486
+ }
487
+ }
488
+
489
+ async retrieveSecret (bundleHash, options = {}) {
490
+ if (options.passphrase) {
491
+ throw new SecretStorageException(
492
+ 'WebAuthnPrfSecretStorageProvider derives its passphrase from the authenticator; options.passphrase is not accepted'
493
+ )
494
+ }
495
+
496
+ const raw = await this.backend.getItem(`${KEY_PREFIX}${bundleHash}`)
497
+ if (!raw) {
498
+ return null
499
+ }
500
+
501
+ let payload
502
+ try {
503
+ payload = JSON.parse(raw)
504
+ } catch {
505
+ throw SecretStorageException.decryptionFailed('Corrupted payload format')
506
+ }
507
+
508
+ const passphrase = await this.unlock()
509
+ try {
510
+ const decryptedBytes = await openEnvelope(payload, passphrase)
511
+ try {
512
+ return textDecoder.decode(decryptedBytes)
513
+ } finally {
514
+ zeroizeBytes(decryptedBytes)
515
+ }
516
+ } catch (err) {
517
+ if (err instanceof SecretStorageException) {
518
+ throw err
519
+ }
520
+ const msg = err instanceof Error ? err.message : String(err)
521
+ throw SecretStorageException.decryptionFailed(msg)
522
+ }
523
+ }
524
+
525
+ async withSecret (bundleHash, fn, options = {}) {
526
+ if (options.passphrase) {
527
+ throw new SecretStorageException(
528
+ 'WebAuthnPrfSecretStorageProvider derives its passphrase from the authenticator; options.passphrase is not accepted'
529
+ )
530
+ }
531
+
532
+ const raw = await this.backend.getItem(`${KEY_PREFIX}${bundleHash}`)
533
+ if (!raw) {
534
+ throw SecretStorageException.notFound(bundleHash)
535
+ }
536
+
537
+ let payload
538
+ try {
539
+ payload = JSON.parse(raw)
540
+ } catch {
541
+ throw SecretStorageException.decryptionFailed('Corrupted payload format')
542
+ }
543
+
544
+ const passphrase = await this.unlock()
545
+ try {
546
+ const decryptedBytes = await openEnvelope(payload, passphrase)
547
+ return await withSecureBytes(decryptedBytes, async (bytes) => {
548
+ const secretString = textDecoder.decode(bytes)
549
+ return await fn(secretString)
550
+ })
551
+ } catch (err) {
552
+ if (err instanceof SecretStorageException) {
553
+ throw err
554
+ }
555
+ const msg = err instanceof Error ? err.message : String(err)
556
+ throw SecretStorageException.decryptionFailed(msg)
557
+ }
558
+ }
559
+
560
+ async deleteSecret (bundleHash) {
561
+ const key = `${KEY_PREFIX}${bundleHash}`
562
+ const recoveryKey = `${RECOVERY_KEY_PREFIX}${bundleHash}`
563
+ const result = await this.backend.removeItem(key)
564
+ await this.backend.removeItem(recoveryKey)
565
+ return result !== false
566
+ }
567
+
568
+ async hasSecret (bundleHash) {
569
+ const raw = await this.backend.getItem(`${KEY_PREFIX}${bundleHash}`)
570
+ return raw !== null
571
+ }
572
+
573
+ async listSecrets () {
574
+ const keys = await this.backend.keys()
575
+ const matchingKeys = keys.filter(k => k.startsWith(KEY_PREFIX) && !k.startsWith(RECOVERY_KEY_PREFIX))
576
+ const results = []
577
+
578
+ for (const key of matchingKeys) {
579
+ const raw = await this.backend.getItem(key)
580
+ if (raw) {
581
+ try {
582
+ const payload = JSON.parse(raw)
583
+ if (payload.metadata) {
584
+ results.push(payload.metadata)
585
+ }
586
+ } catch {
587
+ // Ignore unparseable entries
588
+ }
589
+ }
590
+ }
591
+
592
+ return results
593
+ }
594
+
595
+ /**
596
+ * Recover a secret using its recovery envelope and re-enroll it under a fresh WebAuthn PRF credential
597
+ *
598
+ * @param {string} bundleHash
599
+ * @param {string} recoveryPassphrase
600
+ * @param {{ label?: string }} [options]
601
+ * @returns {Promise<void>}
602
+ */
603
+ async recoverSecret (bundleHash, recoveryPassphrase, options = {}) {
604
+ if (!bundleHash) {
605
+ throw new SecretStorageException('Bundle hash cannot be empty')
606
+ }
607
+ if (!recoveryPassphrase) {
608
+ throw new SecretStorageException('Recovery passphrase cannot be empty')
609
+ }
610
+
611
+ const raw = await this.backend.getItem(`${RECOVERY_KEY_PREFIX}${bundleHash}`)
612
+ if (!raw) {
613
+ throw SecretStorageException.notFound(bundleHash)
614
+ }
615
+
616
+ let payload
617
+ try {
618
+ payload = JSON.parse(raw)
619
+ } catch {
620
+ throw SecretStorageException.decryptionFailed('Corrupted recovery payload format')
621
+ }
622
+
623
+ let decryptedBytes
624
+ try {
625
+ decryptedBytes = await openEnvelope(payload, recoveryPassphrase)
626
+ } catch (err) {
627
+ if (err instanceof SecretStorageException) {
628
+ throw err
629
+ }
630
+ const msg = err instanceof Error ? err.message : String(err)
631
+ throw SecretStorageException.decryptionFailed(msg)
632
+ }
633
+
634
+ let secretStr
635
+ try {
636
+ secretStr = textDecoder.decode(decryptedBytes)
637
+ } finally {
638
+ zeroizeBytes(decryptedBytes)
639
+ }
640
+
641
+ await this.storeSecret(bundleHash, secretStr, {
642
+ ...options,
643
+ recoveryPassphrase
644
+ })
645
+ }
646
+ }