@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/README.md +13 -0
- package/dist/index.cjs +1304 -121
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +152 -18
- package/dist/index.d.ts +152 -18
- package/dist/index.iife.js +3044 -1049
- package/dist/index.iife.js.map +1 -1
- package/dist/index.js +1295 -122
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/exception/SecretStorageException.ts +9 -0
- package/src/index.ts +16 -2
- package/src/libraries/GraphQLClient.ts +18 -5
- package/src/storage/FileStorageBackend.ts +176 -0
- package/src/storage/MemorySecretStorageProvider.ts +75 -3
- package/src/storage/NonExtractableKeySecretStorageProvider.ts +547 -0
- package/src/storage/WebAuthnPrfSecretStorageProvider.ts +679 -0
- package/src/storage/WebCryptoSecretStorageProvider.ts +105 -134
- package/src/storage/WebStorageBackend.ts +102 -0
- package/src/storage/index.ts +26 -2
- package/src/storage/secretEnvelope.ts +200 -0
- package/src/types/storage.ts +22 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wishknish/knishio-client-ts",
|
|
3
|
-
"version": "1.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
+
cipherWallet = wallet
|
|
210
223
|
}
|
|
211
224
|
|
|
212
225
|
const response = await fetch(input, requestInit)
|
|
213
|
-
if (!
|
|
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
|
|
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 {
|
|
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?:
|
|
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
|
}
|