@wishknish/knishio-client-ts 0.9.8 → 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.
@@ -52,8 +52,17 @@ import { generateBundleHash, generateSecret, shake256, generateBatchId } from '@
52
52
  import WalletCredentialException from '@/exception/WalletCredentialException'
53
53
  import { isBundleHash } from '@/types'
54
54
  import TokenUnit from '@/core/TokenUnit'
55
- // Post-quantum cryptography for ML-KEM768 key encapsulation
56
- import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'
55
+ // Post-quantum cryptography for ML-KEM key encapsulation
56
+ import { ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem.js'
57
+
58
+ const ML_KEM_PARAMS = {
59
+ 1024: { kem: ml_kem1024, pkBytes: 1568, skBytes: 3168, ctBytes: 1568 },
60
+ 768: { kem: ml_kem768, pkBytes: 1184, skBytes: 2400, ctBytes: 1088 }
61
+ } as const
62
+ const DEFAULT_ML_KEM_PARAMETER_SET = 1024
63
+
64
+ type MlKemParams = (typeof ML_KEM_PARAMS)[1024 | 768]
65
+ type MlKemKeypair = { pubkey: string; privkey: Uint8Array; params: MlKemParams }
57
66
 
58
67
  /**
59
68
  * Wallet class - Identity and key management for KnishIO DLT
@@ -73,6 +82,7 @@ export default class Wallet {
73
82
  public tokenUnits: any[]
74
83
  public tradeRates: Record<string, any>
75
84
  public molecules: Record<string, any>
85
+ public mlKemParameterSet: 1024 | 768
76
86
 
77
87
  // Token metadata (populated from query responses)
78
88
  public tokenName?: string
@@ -88,7 +98,8 @@ export default class Wallet {
88
98
  address = null,
89
99
  position = null,
90
100
  batchId = null,
91
- characters = null
101
+ characters = null,
102
+ mlKemParameterSet = DEFAULT_ML_KEM_PARAMETER_SET
92
103
  }: {
93
104
  secret?: string | null
94
105
  bundle?: string | null
@@ -97,7 +108,13 @@ export default class Wallet {
97
108
  position?: string | null
98
109
  batchId?: string | null
99
110
  characters?: string | null
111
+ mlKemParameterSet?: 1024 | 768
100
112
  } = {}) {
113
+ const paramSetNum = Number(mlKemParameterSet) as 1024 | 768
114
+ if (!ML_KEM_PARAMS[paramSetNum]) {
115
+ throw new Error(`KnishIO: unsupported ML-KEM parameter set ${mlKemParameterSet}; expected 1024 or 768.`)
116
+ }
117
+ this.mlKemParameterSet = paramSetNum
101
118
  this.token = token
102
119
  this.balance = '0'
103
120
  this.molecules = {}
@@ -133,7 +150,7 @@ export default class Wallet {
133
150
  // Set characters
134
151
  this.characters = this.characters || 'BASE64'
135
152
 
136
- // Initialize ML-KEM768 keys (matches JavaScript SDK)
153
+ // Initialize ML-KEM keys (matches JavaScript SDK)
137
154
  this.initializeMLKEM()
138
155
  }
139
156
  }
@@ -147,13 +164,15 @@ export default class Wallet {
147
164
  bundle = null,
148
165
  token = 'USER',
149
166
  batchId = null,
150
- characters = null
167
+ characters = null,
168
+ mlKemParameterSet = DEFAULT_ML_KEM_PARAMETER_SET
151
169
  }: {
152
170
  secret?: string | null
153
171
  bundle?: string | null
154
172
  token?: string
155
173
  batchId?: string | null
156
174
  characters?: string | null
175
+ mlKemParameterSet?: 1024 | 768
157
176
  }): Wallet {
158
177
  let position: string | null = null
159
178
 
@@ -176,7 +195,8 @@ export default class Wallet {
176
195
  token,
177
196
  position,
178
197
  batchId,
179
- characters
198
+ characters,
199
+ mlKemParameterSet
180
200
  })
181
201
  }
182
202
 
@@ -408,26 +428,90 @@ export default class Wallet {
408
428
  }
409
429
 
410
430
  // =============================================================================
411
- // POST-QUANTUM CRYPTOGRAPHY - ML-KEM768 INTEGRATION
431
+ // POST-QUANTUM CRYPTOGRAPHY - ML-KEM INTEGRATION
412
432
  // =============================================================================
413
433
 
414
434
  /**
415
- * Initializes the ML-KEM key pair (matches JavaScript SDK exactly)
435
+ * Derive an ML-KEM keypair for an arbitrary parameter set from the wallet's key seed,
436
+ * without mutating the wallet. The 64-byte `d‖z` seed takes no parameter-set input — only
437
+ * the final `keygen` call differs — so one KnishIO wallet owns both an ML-KEM-768 and an
438
+ * ML-KEM-1024 identity and either can be reconstructed on demand.
439
+ *
440
+ * Returns `null` when the wallet holds no key — a secret-less wallet, which is what a molecule
441
+ * deserializer builds for validation context. `generateSecret(null, …)` does NOT throw, so
442
+ * without this the wallet would derive a plausible-looking identity from a bogus seed and fail
443
+ * three layers down at AES-GCM instead of at the missing key. The guard lives here rather than
444
+ * at each call site so a new caller cannot miss it.
445
+ *
446
+ * @param parameterSet - 1024 or 768
416
447
  */
417
- initializeMLKEM(): void {
448
+ private deriveMlKemKeypair(parameterSet: 1024 | 768): MlKemKeypair | null {
449
+ const params = ML_KEM_PARAMS[parameterSet]
450
+ if (!params) {
451
+ throw new Error(`KnishIO: unsupported ML-KEM parameter set ${parameterSet}; expected 1024 or 768.`)
452
+ }
453
+ if (!this.key) {
454
+ return null
455
+ }
418
456
  // Generate a 64-byte (512-bit) seed from the Knish.IO private key
419
457
  // Use deterministic approach: generateSecret(key, 128) → 128 hex chars = 64 bytes
420
- const seedHex = generateSecret(this.key!, 128) // 128 hex chars = 64 bytes, matches JS SDK
421
-
422
- // Convert the hex string to a Uint8Array
458
+ const seedHex = generateSecret(this.key, 128) // 128 hex chars = 64 bytes, matches JS SDK
459
+
460
+ // Convert the hex string to a Uint8Array
423
461
  const seed = new Uint8Array(64)
424
462
  for (let i = 0; i < 64; i++) {
425
463
  seed[i] = parseInt(seedHex.substr(i * 2, 2), 16)
426
464
  }
427
-
428
- const { publicKey, secretKey } = ml_kem768.keygen(seed)
429
- this.pubkey = this.serializeKey(publicKey)
430
- this.privkey = secretKey // Note: We're keeping privkey as UInt8Array for security
465
+
466
+ const { publicKey, secretKey } = params.kem.keygen(seed)
467
+ return {
468
+ pubkey: this.serializeKey(publicKey),
469
+ privkey: secretKey,
470
+ params
471
+ }
472
+ }
473
+
474
+ /**
475
+ * ML-KEM parameter set implied by a serialized public key's raw byte length. FIPS 203's key
476
+ * lengths are disjoint (1568 bytes → ML-KEM-1024, 1184 bytes → ML-KEM-768), so a stored peer
477
+ * key recovers the parameter set of the session it belongs to without a wire-format change.
478
+ * Used by AuthToken.restore to resolve a snapshot that predates the field.
479
+ *
480
+ * @param pubkey - Base64-serialized ML-KEM public key
481
+ * @return 1024, 768, or null when the length matches neither
482
+ */
483
+ static mlKemParameterSetFromPubkey(pubkey: string | null | undefined): 1024 | 768 | null {
484
+ if (!pubkey) {
485
+ return null
486
+ }
487
+ let byteLength: number
488
+ try {
489
+ byteLength = typeof Buffer !== 'undefined'
490
+ ? Buffer.from(pubkey, 'base64').length
491
+ : atob(pubkey).length
492
+ } catch {
493
+ return null
494
+ }
495
+ if (byteLength === ML_KEM_PARAMS[1024].pkBytes) {
496
+ return 1024
497
+ }
498
+ if (byteLength === ML_KEM_PARAMS[768].pkBytes) {
499
+ return 768
500
+ }
501
+ return null
502
+ }
503
+
504
+ /**
505
+ * Initializes the ML-KEM key pair (matches JavaScript SDK exactly). Only ever reached from the
506
+ * constructor's `secret` branch, so the derivation cannot come back empty here.
507
+ */
508
+ initializeMLKEM(): void {
509
+ const derived = this.deriveMlKemKeypair(this.mlKemParameterSet)
510
+ if (!derived) {
511
+ return
512
+ }
513
+ this.pubkey = derived.pubkey
514
+ this.privkey = derived.privkey // Note: We're keeping privkey as UInt8Array for security
431
515
  }
432
516
 
433
517
 
@@ -439,19 +523,20 @@ export default class Wallet {
439
523
  const messageString = JSON.stringify(message)
440
524
  const messageUint8 = new TextEncoder().encode(messageString)
441
525
  const deserializedPubkey = this.deserializeKey(recipientPubkey)
442
- // ML-KEM-768 public keys are exactly 1184 bytes. A wrong-length key here almost always means the
443
- // node did not advertise an ML-KEM public key in its auth `key` field (e.g. a validator predating
444
- // the PQ-transport build). Fail with an actionable message rather than @noble's cryptic
445
- // `"publicKey" expected Uint8Array of length 1184, got length=N` assertion.
446
- const ML_KEM_768_PUBLIC_KEY_BYTES = 1184
447
- if (deserializedPubkey.length !== ML_KEM_768_PUBLIC_KEY_BYTES) {
526
+ // ML-KEM public keys are exactly the configured parameter set's length — 1568 bytes for
527
+ // ML-KEM-1024, 1184 bytes for ML-KEM-768. A wrong-length key here almost always means the
528
+ // node did not advertise an ML-KEM public key in its auth `key` field (e.g. a validator
529
+ // predating the PQ-transport build). Fail with an actionable message rather than @noble's
530
+ // cryptic `"publicKey" expected Uint8Array of length N, got length=M` assertion.
531
+ const params = ML_KEM_PARAMS[this.mlKemParameterSet]
532
+ if (deserializedPubkey.length !== params.pkBytes) {
448
533
  throw new Error(
449
534
  `KnishIO: cannot ML-KEM-encrypt — recipient public key is ${deserializedPubkey.length} bytes, ` +
450
- `expected ${ML_KEM_768_PUBLIC_KEY_BYTES} (ML-KEM-768). The node likely did not advertise an ML-KEM ` +
451
- `public key (upgrade the validator to a PQ-transport build), or authenticate with { encrypt: false }.`
535
+ `expected ${params.pkBytes} (ML-KEM-${this.mlKemParameterSet}). The peer is not running ML-KEM-${this.mlKemParameterSet}; ` +
536
+ 'upgrade the peer, or step this client back to the other parameter set.'
452
537
  )
453
538
  }
454
- const { cipherText, sharedSecret } = ml_kem768.encapsulate(deserializedPubkey)
539
+ const { cipherText, sharedSecret } = params.kem.encapsulate(deserializedPubkey)
455
540
  const encryptedMessage = await this.encryptWithSharedSecret(messageUint8, sharedSecret)
456
541
  return {
457
542
  cipherText: this.serializeKey(cipherText),
@@ -465,15 +550,46 @@ export default class Wallet {
465
550
  }
466
551
 
467
552
  /**
468
- * ML-KEM768 decapsulate + AES-256-GCM decrypt → the RAW decrypted UTF-8 string (no JSON.parse).
553
+ * ML-KEM decapsulate + AES-256-GCM decrypt → the RAW decrypted UTF-8 string (no JSON.parse).
469
554
  * Shared by {@link decryptMessage} (which JSON.parses the result) and the PQ CipherHash transport
470
- * ({@link decryptMyMessageML768}, which needs the raw response JSON text). PQ-transport Phase E.
555
+ * ({@link decryptMyMessageML}, which needs the raw response JSON text). PQ-transport Phase E.
471
556
  */
472
557
  async _mlkemDecryptToString(encryptedData: { cipherText: string; encryptedMessage: string }): Promise<string | null> {
473
558
  const { cipherText, encryptedMessage } = encryptedData
559
+ const configuredParams = ML_KEM_PARAMS[this.mlKemParameterSet]
560
+ const otherSet: 1024 | 768 = this.mlKemParameterSet === 1024 ? 768 : 1024
561
+ const deserializedCipherText = this.deserializeKey(cipherText)
562
+
563
+ // Inbound is PERMISSIVE: a ciphertext at either parameter set decrypts, provided it is addressed
564
+ // to one of THIS wallet's own ML-KEM identities. The 64-byte seed is parameter-set-independent,
565
+ // so the other identity is derived on demand and its private key is released with this call's
566
+ // scope — never cached on the wallet. Outbound encapsulation stays STRICT (see encryptMessage);
567
+ // reading a 768 record we own downgrades nothing, but encapsulating at 768 would.
568
+ let params: MlKemParams = configuredParams
569
+ let decapsPrivkey: Uint8Array = this.privkey
570
+ if (deserializedCipherText.length !== configuredParams.ctBytes) {
571
+ if (deserializedCipherText.length !== ML_KEM_PARAMS[otherSet].ctBytes) {
572
+ console.error(
573
+ `Wallet::decryptMessage() - Ciphertext length mismatch: got ${deserializedCipherText.length}, expected ${configuredParams.ctBytes}`
574
+ )
575
+ return null
576
+ }
577
+ // `null` here means the wallet holds no key to derive from (a secret-less validation
578
+ // wallet); preserve the existing failure observable rather than decapsulating with nothing.
579
+ const derived = this.deriveMlKemKeypair(otherSet)
580
+ if (!derived) {
581
+ console.error(
582
+ `Wallet::decryptMessage() - cannot derive the ML-KEM-${otherSet} identity: wallet has no key`
583
+ )
584
+ return null
585
+ }
586
+ params = derived.params
587
+ decapsPrivkey = derived.privkey
588
+ }
589
+
474
590
  let sharedSecret
475
591
  try {
476
- sharedSecret = ml_kem768.decapsulate(this.deserializeKey(cipherText), this.privkey)
592
+ sharedSecret = params.kem.decapsulate(deserializedCipherText, decapsPrivkey)
477
593
  } catch (e) {
478
594
  console.error('Wallet::decryptMessage() - Decapsulation failed', e)
479
595
  console.info('Wallet::decryptMessage() - my public key', this.pubkey)
@@ -525,11 +641,11 @@ export default class Wallet {
525
641
  }
526
642
 
527
643
  /**
528
- * Post-quantum (ML-KEM768) CipherHash request envelope: a stringified single-recipient map
644
+ * Post-quantum (ML-KEM) CipherHash request envelope: a stringified single-recipient map
529
645
  * `{ "<hashShare(recipientPubkey)>": {cipherText, encryptedMessage} }` (object-valued, via
530
646
  * {@link encryptMessage}). Matches the Rust validator's CipherHash handler. PQ-transport Phase E.
531
647
  */
532
- async encryptStringML768(message: any, recipientPubkey: string): Promise<string> {
648
+ async encryptStringML(message: any, recipientPubkey: string): Promise<string> {
533
649
  const envelope = await this.encryptMessage(message, recipientPubkey)
534
650
  return JSON.stringify({ [this.hashShare(recipientPubkey)]: envelope })
535
651
  }
@@ -538,9 +654,22 @@ export default class Wallet {
538
654
  * Decrypt a CipherHash response map addressed to THIS wallet's ML-KEM pubkey
539
655
  * (`hashShare(this.pubkey)`) → the RAW decrypted GraphQL response JSON text (NOT JSON.parsed;
540
656
  * it replaces the HTTP response body for the normal parser). `null` if no entry / decrypt fails.
657
+ *
658
+ * A pre-bump peer addressed its envelope to `hashShare(our_768_pubkey)`, which a wallet
659
+ * configured at ML-KEM-1024 would never find — so the other identity's share is tried too.
660
+ * Without this, the permissive length dispatch in {@link _mlkemDecryptToString} is
661
+ * unreachable on the transport path.
541
662
  */
542
- async decryptMyMessageML768(map: Record<string, { cipherText: string; encryptedMessage: string }>): Promise<string | null> {
543
- const envelope = map[this.hashShare(this.pubkey)]
663
+ async decryptMyMessageML(map: Record<string, { cipherText: string; encryptedMessage: string }>): Promise<string | null> {
664
+ let envelope = map[this.hashShare(this.pubkey)]
665
+ if (!envelope) {
666
+ // A secret-less wallet derives nothing, so the lookup is simply skipped.
667
+ const otherSet: 1024 | 768 = this.mlKemParameterSet === 1024 ? 768 : 1024
668
+ const other = this.deriveMlKemKeypair(otherSet)
669
+ if (other) {
670
+ envelope = map[this.hashShare(other.pubkey)]
671
+ }
672
+ }
544
673
  if (!envelope) {
545
674
  return null
546
675
  }
@@ -94,4 +94,13 @@ export default class SecretStorageException extends BaseException {
94
94
  }
95
95
  )
96
96
  }
97
+
98
+ /**
99
+ * Validation error for storage options or parameters
100
+ */
101
+ static validationError(message: string): SecretStorageException {
102
+ return new SecretStorageException(message, {
103
+ code: 'VALIDATION_ERROR'
104
+ })
105
+ }
97
106
  }
package/src/index.ts CHANGED
@@ -275,10 +275,23 @@ export {
275
275
  export {
276
276
  MemorySecretStorageProvider,
277
277
  WebCryptoSecretStorageProvider,
278
+ WebAuthnPrfSecretStorageProvider,
279
+ NonExtractableKeySecretStorageProvider,
280
+ IndexedDbKeyStore,
281
+ MemoryKeyStore,
278
282
  MemoryStorageBackend,
283
+ FileStorageBackend,
284
+ WebStorageBackend,
279
285
  createDefaultSecretStorage,
286
+ sealEnvelope,
287
+ openEnvelope,
288
+ SECRET_KEY_PREFIX,
289
+ RECOVERY_KEY_PREFIX,
280
290
  type IStorageBackend,
281
- type CreateSecretStorageOptions
291
+ type IKeyStore,
292
+ type CreateSecretStorageOptions,
293
+ type WebAuthnPrfSecretStorageOptions,
294
+ type NonExtractableKeyStorageOptions
282
295
  } from './storage'
283
296
 
284
297
  export {
@@ -291,6 +304,7 @@ export {
291
304
  export type {
292
305
  SecretStorageMetadata,
293
306
  EncryptedSecretPayload,
307
+ StorageOptions,
294
308
  ISecretStorageProvider
295
309
  } from './types/storage'
296
310
 
@@ -389,7 +403,7 @@ export {
389
403
  // MUST equal package.json's "version". The CI `version consistency` job enforces this via
390
404
  // .github/scripts/check-version.sh, which parses this exact line — keep the literal form
391
405
  // `export const SDK_VERSION = '<semver>'` intact so the gate can read it.
392
- export const SDK_VERSION = '0.9.8'
406
+ export const SDK_VERSION = '1.1.0'
393
407
  export const SDK_NAME = 'KnishIO-Client-TS'
394
408
  export const COMPATIBLE_SERVER_VERSIONS = [4, 5]
395
409
 
@@ -402,7 +416,7 @@ export const SDK_INFO = {
402
416
  description: 'TypeScript SDK for Knish.IO post-blockchain distributed ledger',
403
417
  compatibleServerVersions: COMPATIBLE_SERVER_VERSIONS,
404
418
  features: [
405
- 'Post-quantum cryptography (XMSS, ML-KEM768)',
419
+ 'Post-quantum cryptography (XMSS, ML-KEM-1024)',
406
420
  'Cross-platform compatibility',
407
421
  'Type-safe APIs',
408
422
  'DAG-based transaction processing',
@@ -204,7 +204,7 @@ export default class GraphQLClient implements IGraphQLClient {
204
204
  let requestInit = init
205
205
 
206
206
  if (wallet && serverPubkey && init && typeof init.body === 'string' && this.shouldEncrypt(init.body)) {
207
- const hashVar = await wallet.encryptStringML768(init.body, serverPubkey)
207
+ const hashVar = await wallet.encryptStringML(init.body, serverPubkey)
208
208
  requestInit = { ...init, body: JSON.stringify({ query: CIPHER_HASH_QUERY, variables: { Hash: hashVar } }) }
209
209
  encryptedRequest = true
210
210
  }
@@ -228,7 +228,7 @@ export default class GraphQLClient implements IGraphQLClient {
228
228
  // Plaintext (e.g. a validator-side error response) — pass through unchanged.
229
229
  return new Response(text, init2)
230
230
  }
231
- const decrypted = await wallet!.decryptMyMessageML768(JSON.parse(hash))
231
+ const decrypted = await wallet!.decryptMyMessageML(JSON.parse(hash))
232
232
  return new Response(decrypted != null ? decrypted : text, init2)
233
233
  }
234
234
 
@@ -208,7 +208,8 @@ export const KnishIOClientConfigSchema = z.object({
208
208
  serverSdkVersion: z.number().int().min(1).optional(),
209
209
  logging: z.boolean().optional(),
210
210
  defaultRequestPolicy: z.enum(['cache-first', 'cache-only', 'network-only', 'cache-and-network']).nullable().optional(),
211
- secretStorage: z.unknown().optional()
211
+ secretStorage: z.unknown().optional(),
212
+ mlKemParameterSet: z.union([z.literal(1024), z.literal(768)]).optional()
212
213
  }).strict()
213
214
 
214
215
  // =============================================================================
@@ -0,0 +1,176 @@
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 { IStorageBackend } from './WebCryptoSecretStorageProvider'
50
+ import SecretStorageException from '@/exception/SecretStorageException'
51
+
52
+ /**
53
+ * Node-only persistent file storage backend.
54
+ * Stores key-value entries in a JSON file with restrictive permissions (0o600).
55
+ * Uses temporary file writing followed by atomic rename to prevent corruption.
56
+ */
57
+ export default class FileStorageBackend implements IStorageBackend {
58
+ readonly filePath: string
59
+ private store: Map<string, string> = new Map()
60
+ private loaded = false
61
+
62
+ constructor(filePath: string) {
63
+ if (!filePath) {
64
+ throw new SecretStorageException('Storage file path cannot be empty')
65
+ }
66
+ this.filePath = filePath
67
+ }
68
+
69
+ private async getFs() {
70
+ try {
71
+ // Platform-specific: node:fs and node:path do not exist in browser runtimes
72
+ // and cannot be statically imported without breaking browser bundles.
73
+ const fs = await import('node:fs/promises')
74
+ const path = await import('node:path')
75
+ return { fs, path }
76
+ } catch {
77
+ throw SecretStorageException.unavailable(
78
+ 'file-storage',
79
+ 'FileStorageBackend is only supported in Node.js environments with node:fs access'
80
+ )
81
+ }
82
+ }
83
+
84
+ private async ensureLoaded(): Promise<Map<string, string>> {
85
+ if (this.loaded) {
86
+ return this.store
87
+ }
88
+
89
+ const { fs } = await this.getFs()
90
+
91
+ try {
92
+ const content = await fs.readFile(this.filePath, 'utf8')
93
+ let parsed: Record<string, unknown>
94
+ try {
95
+ parsed = JSON.parse(content)
96
+ } catch {
97
+ throw SecretStorageException.decryptionFailed('Corrupted storage file format')
98
+ }
99
+
100
+ if (parsed && typeof parsed === 'object') {
101
+ this.store = new Map(Object.entries(parsed).map(([k, v]) => [k, String(v)]))
102
+ }
103
+ } catch (err: unknown) {
104
+ if (err instanceof SecretStorageException) {
105
+ throw err
106
+ }
107
+ // If file does not exist (ENOENT), treat as empty map
108
+ const nodeErr = err as { code?: string }
109
+ if (nodeErr?.code !== 'ENOENT') {
110
+ const msg = err instanceof Error ? err.message : String(err)
111
+ throw new SecretStorageException(`Failed to read storage file: ${msg}`)
112
+ }
113
+ this.store = new Map()
114
+ }
115
+
116
+ this.loaded = true
117
+ return this.store
118
+ }
119
+
120
+ private async persist(): Promise<void> {
121
+ const { fs, path } = await this.getFs()
122
+
123
+ const dir = path.dirname(this.filePath)
124
+ if (dir && dir !== '.') {
125
+ await fs.mkdir(dir, { recursive: true })
126
+ }
127
+
128
+ const tmpPath = `${this.filePath}.tmp.${Date.now()}_${Math.random().toString(36).slice(2)}`
129
+ const data = JSON.stringify(Object.fromEntries(this.store), null, 2)
130
+
131
+ try {
132
+ await fs.writeFile(tmpPath, data, { mode: 0o600, encoding: 'utf8' })
133
+ if (typeof process !== 'undefined' && process.platform !== 'win32') {
134
+ try {
135
+ await fs.chmod(tmpPath, 0o600)
136
+ } catch {
137
+ // Ignore chmod errors if file system does not support it
138
+ }
139
+ }
140
+ await fs.rename(tmpPath, this.filePath)
141
+ } catch (err: unknown) {
142
+ try {
143
+ await fs.unlink(tmpPath)
144
+ } catch {
145
+ // Ignore unlink cleanup error
146
+ }
147
+ const msg = err instanceof Error ? err.message : String(err)
148
+ throw new SecretStorageException(`Failed to persist storage file: ${msg}`)
149
+ }
150
+ }
151
+
152
+ async getItem(key: string): Promise<string | null> {
153
+ await this.ensureLoaded()
154
+ return this.store.get(key) ?? null
155
+ }
156
+
157
+ async setItem(key: string, value: string): Promise<void> {
158
+ await this.ensureLoaded()
159
+ this.store.set(key, value)
160
+ await this.persist()
161
+ }
162
+
163
+ async removeItem(key: string): Promise<boolean> {
164
+ await this.ensureLoaded()
165
+ const existed = this.store.delete(key)
166
+ if (existed) {
167
+ await this.persist()
168
+ }
169
+ return existed
170
+ }
171
+
172
+ async keys(): Promise<string[]> {
173
+ await this.ensureLoaded()
174
+ return Array.from(this.store.keys())
175
+ }
176
+ }