@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.
- package/README.md +13 -0
- package/dist/client.cjs.js +38 -40
- package/dist/client.cjs.js.map +1 -1
- package/dist/client.es.mjs +6660 -9256
- package/dist/client.es.mjs.map +1 -1
- package/dist/client.iife.js +38 -40
- package/dist/client.iife.js.map +1 -1
- package/package.json +10 -11
- package/src/AuthToken.js +25 -3
- package/src/KnishIOClient.js +54 -15
- package/src/Molecule.js +15 -7
- package/src/Wallet.js +137 -30
- package/src/exception/SecretStorageException.js +10 -0
- package/src/index.js +11 -1
- package/src/libraries/urql/UrqlClientWrapper.js +7 -2
- 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,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
|
+
}
|