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