@wishknish/knishio-client-js 1.0.0 → 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.
- package/README.md +13 -0
- package/dist/client.cjs.js +25 -25
- package/dist/client.cjs.js.map +1 -1
- package/dist/client.es.mjs +1544 -839
- package/dist/client.es.mjs.map +1 -1
- package/dist/client.iife.js +25 -25
- package/dist/client.iife.js.map +1 -1
- package/package.json +1 -1
- package/src/exception/SecretStorageException.js +10 -0
- package/src/index.js +11 -1
- package/src/storage/FileStorageBackend.js +199 -0
- package/src/storage/MemorySecretStorageProvider.js +69 -1
- package/src/storage/NonExtractableKeySecretStorageProvider.js +514 -0
- package/src/storage/WebAuthnPrfSecretStorageProvider.js +646 -0
- package/src/storage/WebCryptoSecretStorageProvider.js +107 -129
- package/src/storage/WebStorageBackend.js +117 -0
- package/src/storage/index.js +25 -3
- package/src/storage/secretEnvelope.js +208 -0
|
@@ -0,0 +1,514 @@
|
|
|
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
|
+
const KEY_PREFIX = SECRET_KEY_PREFIX
|
|
61
|
+
const GCM_IV_LENGTH = 12
|
|
62
|
+
|
|
63
|
+
const textEncoder = new TextEncoder()
|
|
64
|
+
const textDecoder = new TextDecoder()
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* In-memory key store for tests and non-browser environments
|
|
68
|
+
*/
|
|
69
|
+
export class MemoryKeyStore {
|
|
70
|
+
constructor () {
|
|
71
|
+
this.keys = new Map()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async get (name) {
|
|
75
|
+
return this.keys.get(name)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async put (name, key) {
|
|
79
|
+
this.keys.set(name, key)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async delete (name) {
|
|
83
|
+
return this.keys.delete(name)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* IndexedDB key store for browser environments
|
|
89
|
+
*/
|
|
90
|
+
export class IndexedDbKeyStore {
|
|
91
|
+
constructor (dbName = 'knishio-secret-storage') {
|
|
92
|
+
this.dbName = dbName
|
|
93
|
+
this.storeName = 'keys'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async getDb () {
|
|
97
|
+
if (typeof globalThis.indexedDB === 'undefined') {
|
|
98
|
+
throw SecretStorageException.unavailable(
|
|
99
|
+
'webcrypto-nonextractable',
|
|
100
|
+
'IndexedDB is not available'
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const request = globalThis.indexedDB.open(this.dbName, 1)
|
|
106
|
+
request.onupgradeneeded = () => {
|
|
107
|
+
const db = request.result
|
|
108
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
109
|
+
db.createObjectStore(this.storeName)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
request.onsuccess = () => resolve(request.result)
|
|
113
|
+
request.onerror = () => reject(request.error)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async get (name) {
|
|
118
|
+
const db = await this.getDb()
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const tx = db.transaction(this.storeName, 'readonly')
|
|
121
|
+
const store = tx.objectStore(this.storeName)
|
|
122
|
+
const request = store.get(name)
|
|
123
|
+
request.onsuccess = () => resolve(request.result)
|
|
124
|
+
request.onerror = () => reject(request.error)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async put (name, key) {
|
|
129
|
+
const db = await this.getDb()
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const tx = db.transaction(this.storeName, 'readwrite')
|
|
132
|
+
const store = tx.objectStore(this.storeName)
|
|
133
|
+
const request = store.put(key, name)
|
|
134
|
+
request.onsuccess = () => resolve()
|
|
135
|
+
request.onerror = () => reject(request.error)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async delete (name) {
|
|
140
|
+
const db = await this.getDb()
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const tx = db.transaction(this.storeName, 'readwrite')
|
|
143
|
+
const store = tx.objectStore(this.storeName)
|
|
144
|
+
const request = store.delete(name)
|
|
145
|
+
request.onsuccess = () => resolve(true)
|
|
146
|
+
request.onerror = () => reject(request.error)
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Secret storage provider backed by a non-extractable CryptoKey stored in IndexedDB.
|
|
153
|
+
* The KEK cannot be exported from the browser's WebCrypto context.
|
|
154
|
+
*/
|
|
155
|
+
export default class NonExtractableKeySecretStorageProvider {
|
|
156
|
+
/**
|
|
157
|
+
* @param {object} options
|
|
158
|
+
* @param {object} options.backend
|
|
159
|
+
* @param {object} [options.keyStore]
|
|
160
|
+
* @param {string} [options.alias]
|
|
161
|
+
*/
|
|
162
|
+
constructor (options) {
|
|
163
|
+
this.providerType = 'webcrypto-nonextractable'
|
|
164
|
+
this.backend = options.backend
|
|
165
|
+
this.keyStore = options.keyStore || new IndexedDbKeyStore()
|
|
166
|
+
this.alias = options.alias || 'default'
|
|
167
|
+
this.cachedPassphrase = undefined
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
get recordKey () {
|
|
171
|
+
return `knishio:kek:webcrypto-nonextractable:${this.alias}`
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
get kekStoreKey () {
|
|
175
|
+
return `knishio:kek:${this.alias}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
isHardwareBacked () {
|
|
179
|
+
// Non-extractable WebCrypto keys prevent JS extraction, but are not verified
|
|
180
|
+
// hardware-enclave keys.
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async isAvailable () {
|
|
185
|
+
return (
|
|
186
|
+
typeof globalThis.crypto !== 'undefined' &&
|
|
187
|
+
typeof globalThis.crypto.subtle !== 'undefined'
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Unlock or initialize the device passphrase using the non-extractable KEK
|
|
193
|
+
*/
|
|
194
|
+
async unlock () {
|
|
195
|
+
if (this.cachedPassphrase) {
|
|
196
|
+
return this.cachedPassphrase
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!await this.isAvailable()) {
|
|
200
|
+
throw SecretStorageException.unavailable(
|
|
201
|
+
this.providerType,
|
|
202
|
+
'WebCrypto API is not available'
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const rawRecord = await this.backend.getItem(this.recordKey)
|
|
207
|
+
if (!rawRecord) {
|
|
208
|
+
// First use: create non-extractable KEK and wrap new device passphrase
|
|
209
|
+
let kek = await this.keyStore.get(this.kekStoreKey)
|
|
210
|
+
if (!kek) {
|
|
211
|
+
kek = await globalThis.crypto.subtle.generateKey(
|
|
212
|
+
{ name: 'AES-GCM', length: 256 },
|
|
213
|
+
false,
|
|
214
|
+
['encrypt', 'decrypt']
|
|
215
|
+
)
|
|
216
|
+
await this.keyStore.put(this.kekStoreKey, kek)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const devicePassphraseBytes = new Uint8Array(32)
|
|
220
|
+
globalThis.crypto.getRandomValues(devicePassphraseBytes)
|
|
221
|
+
const devicePassphrase = uint8ArrayToBase64(devicePassphraseBytes)
|
|
222
|
+
|
|
223
|
+
const iv = new Uint8Array(GCM_IV_LENGTH)
|
|
224
|
+
globalThis.crypto.getRandomValues(iv)
|
|
225
|
+
|
|
226
|
+
const passphraseBytes = textEncoder.encode(devicePassphrase)
|
|
227
|
+
try {
|
|
228
|
+
const encryptedBuffer = await globalThis.crypto.subtle.encrypt(
|
|
229
|
+
{
|
|
230
|
+
name: 'AES-GCM',
|
|
231
|
+
iv
|
|
232
|
+
},
|
|
233
|
+
kek,
|
|
234
|
+
passphraseBytes
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
const record = {
|
|
238
|
+
version: 1,
|
|
239
|
+
iv: uint8ArrayToBase64(iv),
|
|
240
|
+
ciphertext: uint8ArrayToBase64(new Uint8Array(encryptedBuffer))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
await this.backend.setItem(this.recordKey, JSON.stringify(record))
|
|
244
|
+
this.cachedPassphrase = devicePassphrase
|
|
245
|
+
return devicePassphrase
|
|
246
|
+
} finally {
|
|
247
|
+
zeroizeBytes(passphraseBytes)
|
|
248
|
+
zeroizeBytes(devicePassphraseBytes)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Subsequent use: unwrap device passphrase with stored KEK
|
|
253
|
+
let record
|
|
254
|
+
try {
|
|
255
|
+
record = JSON.parse(rawRecord)
|
|
256
|
+
} catch {
|
|
257
|
+
throw SecretStorageException.decryptionFailed('Corrupted key record format')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const kek = await this.keyStore.get(this.kekStoreKey)
|
|
261
|
+
if (!kek) {
|
|
262
|
+
throw SecretStorageException.unavailable(
|
|
263
|
+
this.providerType,
|
|
264
|
+
`no non-extractable key found for alias '${this.alias}'`
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const iv = base64ToUint8Array(record.iv)
|
|
269
|
+
const ciphertext = base64ToUint8Array(record.ciphertext)
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
const decryptedBuffer = await globalThis.crypto.subtle.decrypt(
|
|
273
|
+
{
|
|
274
|
+
name: 'AES-GCM',
|
|
275
|
+
iv
|
|
276
|
+
},
|
|
277
|
+
kek,
|
|
278
|
+
ciphertext
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
const decryptedBytes = new Uint8Array(decryptedBuffer)
|
|
282
|
+
try {
|
|
283
|
+
this.cachedPassphrase = textDecoder.decode(decryptedBytes)
|
|
284
|
+
return this.cachedPassphrase
|
|
285
|
+
} finally {
|
|
286
|
+
zeroizeBytes(decryptedBytes)
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
throw SecretStorageException.decryptionFailed(
|
|
290
|
+
'wrapped device passphrase failed authentication under non-extractable key'
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Lock the provider by clearing cached passphrase material
|
|
297
|
+
*/
|
|
298
|
+
lock () {
|
|
299
|
+
this.cachedPassphrase = undefined
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Unenroll the non-extractable key, removing the stored wrapped record and KEK
|
|
304
|
+
*
|
|
305
|
+
* @returns {Promise<void>}
|
|
306
|
+
*/
|
|
307
|
+
async unenroll () {
|
|
308
|
+
this.lock()
|
|
309
|
+
await this.backend.removeItem(this.recordKey)
|
|
310
|
+
await this.keyStore.delete(this.kekStoreKey)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async storeSecret (bundleHash, secret, options = {}) {
|
|
314
|
+
if (!bundleHash) {
|
|
315
|
+
throw new SecretStorageException('Bundle hash cannot be empty')
|
|
316
|
+
}
|
|
317
|
+
if (!secret) {
|
|
318
|
+
throw new SecretStorageException('Secret cannot be empty')
|
|
319
|
+
}
|
|
320
|
+
if (options.passphrase) {
|
|
321
|
+
throw new SecretStorageException(
|
|
322
|
+
'NonExtractableKeySecretStorageProvider derives its passphrase from the non-extractable device key; options.passphrase is not accepted'
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (!options.recoveryPassphrase && !options.allowUnrecoverable) {
|
|
327
|
+
throw SecretStorageException.validationError(
|
|
328
|
+
'Recovery passphrase required for non-exportable hardware key unless allowUnrecoverable is true'
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const passphrase = await this.unlock()
|
|
333
|
+
const metadata = {
|
|
334
|
+
bundleHash,
|
|
335
|
+
label: options.label,
|
|
336
|
+
createdAt: Date.now(),
|
|
337
|
+
hardwareBacked: false,
|
|
338
|
+
providerType: this.providerType
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const payload = await sealEnvelope(secret, passphrase, metadata)
|
|
342
|
+
await this.backend.setItem(`${KEY_PREFIX}${bundleHash}`, JSON.stringify(payload))
|
|
343
|
+
|
|
344
|
+
if (options.recoveryPassphrase) {
|
|
345
|
+
const recoveryMetadata = {
|
|
346
|
+
bundleHash,
|
|
347
|
+
label: options.label,
|
|
348
|
+
createdAt: Date.now(),
|
|
349
|
+
hardwareBacked: false,
|
|
350
|
+
providerType: 'webcrypto-aes-gcm'
|
|
351
|
+
}
|
|
352
|
+
const recoveryPayload = await sealEnvelope(secret, options.recoveryPassphrase, recoveryMetadata)
|
|
353
|
+
await this.backend.setItem(`${RECOVERY_KEY_PREFIX}${bundleHash}`, JSON.stringify(recoveryPayload))
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async retrieveSecret (bundleHash, options = {}) {
|
|
358
|
+
if (options.passphrase) {
|
|
359
|
+
throw new SecretStorageException(
|
|
360
|
+
'NonExtractableKeySecretStorageProvider derives its passphrase from the non-extractable device key; options.passphrase is not accepted'
|
|
361
|
+
)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const raw = await this.backend.getItem(`${KEY_PREFIX}${bundleHash}`)
|
|
365
|
+
if (!raw) {
|
|
366
|
+
return null
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let payload
|
|
370
|
+
try {
|
|
371
|
+
payload = JSON.parse(raw)
|
|
372
|
+
} catch {
|
|
373
|
+
throw SecretStorageException.decryptionFailed('Corrupted payload format')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const passphrase = await this.unlock()
|
|
377
|
+
try {
|
|
378
|
+
const decryptedBytes = await openEnvelope(payload, passphrase)
|
|
379
|
+
try {
|
|
380
|
+
return textDecoder.decode(decryptedBytes)
|
|
381
|
+
} finally {
|
|
382
|
+
zeroizeBytes(decryptedBytes)
|
|
383
|
+
}
|
|
384
|
+
} catch (err) {
|
|
385
|
+
if (err instanceof SecretStorageException) {
|
|
386
|
+
throw err
|
|
387
|
+
}
|
|
388
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
389
|
+
throw SecretStorageException.decryptionFailed(msg)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async withSecret (bundleHash, fn, options = {}) {
|
|
394
|
+
if (options.passphrase) {
|
|
395
|
+
throw new SecretStorageException(
|
|
396
|
+
'NonExtractableKeySecretStorageProvider derives its passphrase from the non-extractable device key; options.passphrase is not accepted'
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const raw = await this.backend.getItem(`${KEY_PREFIX}${bundleHash}`)
|
|
401
|
+
if (!raw) {
|
|
402
|
+
throw SecretStorageException.notFound(bundleHash)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let payload
|
|
406
|
+
try {
|
|
407
|
+
payload = JSON.parse(raw)
|
|
408
|
+
} catch {
|
|
409
|
+
throw SecretStorageException.decryptionFailed('Corrupted payload format')
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const passphrase = await this.unlock()
|
|
413
|
+
try {
|
|
414
|
+
const decryptedBytes = await openEnvelope(payload, passphrase)
|
|
415
|
+
return await withSecureBytes(decryptedBytes, async (bytes) => {
|
|
416
|
+
const secretString = textDecoder.decode(bytes)
|
|
417
|
+
return await fn(secretString)
|
|
418
|
+
})
|
|
419
|
+
} catch (err) {
|
|
420
|
+
if (err instanceof SecretStorageException) {
|
|
421
|
+
throw err
|
|
422
|
+
}
|
|
423
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
424
|
+
throw SecretStorageException.decryptionFailed(msg)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async deleteSecret (bundleHash) {
|
|
429
|
+
const key = `${KEY_PREFIX}${bundleHash}`
|
|
430
|
+
const recoveryKey = `${RECOVERY_KEY_PREFIX}${bundleHash}`
|
|
431
|
+
const result = await this.backend.removeItem(key)
|
|
432
|
+
await this.backend.removeItem(recoveryKey)
|
|
433
|
+
return result !== false
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async hasSecret (bundleHash) {
|
|
437
|
+
const raw = await this.backend.getItem(`${KEY_PREFIX}${bundleHash}`)
|
|
438
|
+
return raw !== null
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async listSecrets () {
|
|
442
|
+
const keys = await this.backend.keys()
|
|
443
|
+
const matchingKeys = keys.filter(k => k.startsWith(KEY_PREFIX) && !k.startsWith(RECOVERY_KEY_PREFIX))
|
|
444
|
+
const results = []
|
|
445
|
+
|
|
446
|
+
for (const key of matchingKeys) {
|
|
447
|
+
const raw = await this.backend.getItem(key)
|
|
448
|
+
if (raw) {
|
|
449
|
+
try {
|
|
450
|
+
const payload = JSON.parse(raw)
|
|
451
|
+
if (payload.metadata) {
|
|
452
|
+
results.push(payload.metadata)
|
|
453
|
+
}
|
|
454
|
+
} catch {
|
|
455
|
+
// Ignore unparseable entries
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return results
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Recover a secret using its recovery envelope and re-enroll it under a fresh non-extractable KEK
|
|
465
|
+
*
|
|
466
|
+
* @param {string} bundleHash
|
|
467
|
+
* @param {string} recoveryPassphrase
|
|
468
|
+
* @param {{ label?: string }} [options]
|
|
469
|
+
* @returns {Promise<void>}
|
|
470
|
+
*/
|
|
471
|
+
async recoverSecret (bundleHash, recoveryPassphrase, options = {}) {
|
|
472
|
+
if (!bundleHash) {
|
|
473
|
+
throw new SecretStorageException('Bundle hash cannot be empty')
|
|
474
|
+
}
|
|
475
|
+
if (!recoveryPassphrase) {
|
|
476
|
+
throw new SecretStorageException('Recovery passphrase cannot be empty')
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const raw = await this.backend.getItem(`${RECOVERY_KEY_PREFIX}${bundleHash}`)
|
|
480
|
+
if (!raw) {
|
|
481
|
+
throw SecretStorageException.notFound(bundleHash)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
let payload
|
|
485
|
+
try {
|
|
486
|
+
payload = JSON.parse(raw)
|
|
487
|
+
} catch {
|
|
488
|
+
throw SecretStorageException.decryptionFailed('Corrupted recovery payload format')
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
let decryptedBytes
|
|
492
|
+
try {
|
|
493
|
+
decryptedBytes = await openEnvelope(payload, recoveryPassphrase)
|
|
494
|
+
} catch (err) {
|
|
495
|
+
if (err instanceof SecretStorageException) {
|
|
496
|
+
throw err
|
|
497
|
+
}
|
|
498
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
499
|
+
throw SecretStorageException.decryptionFailed(msg)
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let secretStr
|
|
503
|
+
try {
|
|
504
|
+
secretStr = textDecoder.decode(decryptedBytes)
|
|
505
|
+
} finally {
|
|
506
|
+
zeroizeBytes(decryptedBytes)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
await this.storeSecret(bundleHash, secretStr, {
|
|
510
|
+
...options,
|
|
511
|
+
recoveryPassphrase
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
}
|