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