@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishknish/knishio-client-ts",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "productName": "Knish.IO TypeScript SDK Client",
5
5
  "description": "TypeScript implementation of the Knish.IO SDK to consume Knish.IO GraphQL APIs with enhanced type safety.",
6
6
  "license": "GPL-3.0-or-later",
@@ -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 = '1.0.0'
406
+ export const SDK_VERSION = '1.2.0'
393
407
  export const SDK_NAME = 'KnishIO-Client-TS'
394
408
  export const COMPATIBLE_SERVER_VERSIONS = [4, 5]
395
409
 
@@ -55,6 +55,7 @@ import {
55
55
  } from '@urql/core'
56
56
  import { createClient as createWSClient } from 'graphql-ws'
57
57
  import { pipe, subscribe } from 'wonka'
58
+ import { CodeException } from '@/exception'
58
59
  import type {
59
60
  GraphQLClient as IGraphQLClient,
60
61
  GraphQLRequest,
@@ -200,17 +201,29 @@ export default class GraphQLClient implements IGraphQLClient {
200
201
  private async cipherFetch(input: any, init: any): Promise<Response> {
201
202
  const wallet = this.$__wallet
202
203
  const serverPubkey = this.$__pubkey
203
- let encryptedRequest = false
204
+ // Non-null only on the encrypted path, so the response decrypt needs no assertion.
205
+ let cipherWallet: Wallet | null = null
204
206
  let requestInit = init
205
207
 
206
- if (wallet && serverPubkey && init && typeof init.body === 'string' && this.shouldEncrypt(init.body)) {
208
+ // Decide the bypass FIRST, then demand the keys: a bypassed operation (`__schema`,
209
+ // `ContinuId`, `AccessToken`, U-isotope `ProposeMolecule`) must still go out in plaintext or
210
+ // the auth bootstrap would deadlock encrypting to a server pubkey it has not learned yet.
211
+ // Anything else on an encryption-enabled client fails closed rather than silently
212
+ // downgrading to plaintext (matches PHP Cipher.php / Kotlin HttpClient).
213
+ if (init && typeof init.body === 'string' && this.shouldEncrypt(init.body)) {
214
+ if (!wallet) {
215
+ throw new CodeException('Authorized wallet missing.')
216
+ }
217
+ if (!serverPubkey) {
218
+ throw new CodeException('Server public key missing.')
219
+ }
207
220
  const hashVar = await wallet.encryptStringML(init.body, serverPubkey)
208
221
  requestInit = { ...init, body: JSON.stringify({ query: CIPHER_HASH_QUERY, variables: { Hash: hashVar } }) }
209
- encryptedRequest = true
222
+ cipherWallet = wallet
210
223
  }
211
224
 
212
225
  const response = await fetch(input, requestInit)
213
- if (!encryptedRequest) {
226
+ if (!cipherWallet) {
214
227
  return response
215
228
  }
216
229
 
@@ -228,7 +241,7 @@ export default class GraphQLClient implements IGraphQLClient {
228
241
  // Plaintext (e.g. a validator-side error response) — pass through unchanged.
229
242
  return new Response(text, init2)
230
243
  }
231
- const decrypted = await wallet!.decryptMyMessageML(JSON.parse(hash))
244
+ const decrypted = await cipherWallet.decryptMyMessageML(JSON.parse(hash))
232
245
  return new Response(decrypted != null ? decrypted : text, init2)
233
246
  }
234
247
 
@@ -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
+ }
@@ -46,9 +46,15 @@ Please visit https://github.com/WishKnish/KnishIO-Client-TS for information.
46
46
  License: https://github.com/WishKnish/KnishIO-Client-TS/blob/master/LICENSE
47
47
  */
48
48
 
49
- import type { ISecretStorageProvider, SecretStorageMetadata } from '@/types/storage'
49
+ import type {
50
+ ISecretStorageProvider,
51
+ SecretStorageMetadata,
52
+ EncryptedSecretPayload,
53
+ StorageOptions
54
+ } from '@/types/storage'
50
55
  import SecretStorageException from '@/exception/SecretStorageException'
51
- import { withSecureString } from '@/libraries/secureMemory'
56
+ import { withSecureString, zeroizeBytes } from '@/libraries/secureMemory'
57
+ import { sealEnvelope, openEnvelope } from './secretEnvelope'
52
58
 
53
59
  interface MemorySecretEntry {
54
60
  secret: string
@@ -62,6 +68,7 @@ interface MemorySecretEntry {
62
68
  export default class MemorySecretStorageProvider implements ISecretStorageProvider {
63
69
  public readonly providerType = 'memory'
64
70
  private secrets: Map<string, MemorySecretEntry> = new Map()
71
+ private recoverySecrets: Map<string, string> = new Map()
65
72
 
66
73
  /**
67
74
  * Memory storage is not hardware backed
@@ -83,7 +90,7 @@ export default class MemorySecretStorageProvider implements ISecretStorageProvid
83
90
  async storeSecret(
84
91
  bundleHash: string,
85
92
  secret: string,
86
- options?: { label?: string; passphrase?: string }
93
+ options?: StorageOptions
87
94
  ): Promise<void> {
88
95
  if (!bundleHash) {
89
96
  throw new SecretStorageException('Bundle hash cannot be empty')
@@ -101,6 +108,18 @@ export default class MemorySecretStorageProvider implements ISecretStorageProvid
101
108
  }
102
109
 
103
110
  this.secrets.set(bundleHash, { secret, metadata })
111
+
112
+ if (options?.recoveryPassphrase) {
113
+ const recoveryMetadata: SecretStorageMetadata = {
114
+ bundleHash,
115
+ label: options?.label,
116
+ createdAt: Date.now(),
117
+ hardwareBacked: false,
118
+ providerType: 'webcrypto-aes-gcm'
119
+ }
120
+ const recoveryPayload = await sealEnvelope(secret, options.recoveryPassphrase, recoveryMetadata)
121
+ this.recoverySecrets.set(bundleHash, JSON.stringify(recoveryPayload))
122
+ }
104
123
  }
105
124
 
106
125
  /**
@@ -115,6 +134,7 @@ export default class MemorySecretStorageProvider implements ISecretStorageProvid
115
134
  * Delete a stored secret
116
135
  */
117
136
  async deleteSecret(bundleHash: string): Promise<boolean> {
137
+ this.recoverySecrets.delete(bundleHash)
118
138
  return this.secrets.delete(bundleHash)
119
139
  }
120
140
 
@@ -152,5 +172,57 @@ export default class MemorySecretStorageProvider implements ISecretStorageProvid
152
172
  */
153
173
  clear(): void {
154
174
  this.secrets.clear()
175
+ this.recoverySecrets.clear()
176
+ }
177
+
178
+ /**
179
+ * Recover a secret using its recovery envelope and restore it
180
+ */
181
+ async recoverSecret(
182
+ bundleHash: string,
183
+ recoveryPassphrase: string,
184
+ options?: { label?: string }
185
+ ): Promise<void> {
186
+ if (!bundleHash) {
187
+ throw new SecretStorageException('Bundle hash cannot be empty')
188
+ }
189
+ if (!recoveryPassphrase) {
190
+ throw new SecretStorageException('Recovery passphrase cannot be empty')
191
+ }
192
+
193
+ const raw = this.recoverySecrets.get(bundleHash)
194
+ if (!raw) {
195
+ throw SecretStorageException.notFound(bundleHash)
196
+ }
197
+
198
+ let payload: EncryptedSecretPayload
199
+ try {
200
+ payload = JSON.parse(raw)
201
+ } catch {
202
+ throw SecretStorageException.decryptionFailed('Corrupted recovery payload format')
203
+ }
204
+
205
+ let decryptedBytes: Uint8Array
206
+ try {
207
+ decryptedBytes = await openEnvelope(payload, recoveryPassphrase)
208
+ } catch (err: unknown) {
209
+ if (err instanceof SecretStorageException) {
210
+ throw err
211
+ }
212
+ const msg = err instanceof Error ? err.message : String(err)
213
+ throw SecretStorageException.decryptionFailed(msg)
214
+ }
215
+
216
+ let secretStr: string
217
+ try {
218
+ secretStr = new TextDecoder().decode(decryptedBytes)
219
+ } finally {
220
+ zeroizeBytes(decryptedBytes)
221
+ }
222
+
223
+ await this.storeSecret(bundleHash, secretStr, {
224
+ ...options,
225
+ recoveryPassphrase
226
+ })
155
227
  }
156
228
  }