@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.
@@ -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
+ }