@qubic.ts/sdk 0.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.
- package/package.json +38 -0
- package/src/assets.test.ts +114 -0
- package/src/assets.ts +175 -0
- package/src/bob/client.test.ts +109 -0
- package/src/bob/client.ts +393 -0
- package/src/bob/log-stream.test.ts +55 -0
- package/src/bob/log-stream.ts +241 -0
- package/src/browser.ts +43 -0
- package/src/contracts.test.ts +90 -0
- package/src/contracts.ts +140 -0
- package/src/errors.ts +15 -0
- package/src/http.ts +1 -0
- package/src/index.ts +141 -0
- package/src/node.ts +1 -0
- package/src/retry.ts +61 -0
- package/src/rpc/client.test.ts +322 -0
- package/src/rpc/client.ts +688 -0
- package/src/sdk.test.ts +34 -0
- package/src/sdk.ts +113 -0
- package/src/tick.test.ts +69 -0
- package/src/tick.ts +47 -0
- package/src/transactions.queue.test.ts +102 -0
- package/src/transactions.test.ts +149 -0
- package/src/transactions.ts +234 -0
- package/src/transfers.test.ts +59 -0
- package/src/transfers.ts +132 -0
- package/src/tx/confirm.test.ts +149 -0
- package/src/tx/confirm.ts +147 -0
- package/src/tx/tx-queue.test.ts +146 -0
- package/src/tx/tx-queue.ts +214 -0
- package/src/tx/tx.ts +36 -0
- package/src/vault/types.ts +131 -0
- package/src/vault-browser.test.ts +77 -0
- package/src/vault-browser.ts +449 -0
- package/src/vault-cli.test.ts +63 -0
- package/src/vault-cli.ts +123 -0
- package/src/vault.test.ts +97 -0
- package/src/vault.ts +439 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createLocalStorageVaultStore,
|
|
4
|
+
createMemoryVaultStore,
|
|
5
|
+
openSeedVaultBrowser,
|
|
6
|
+
} from "./vault-browser.js";
|
|
7
|
+
import type { SeedVault } from "./vault/types.js";
|
|
8
|
+
|
|
9
|
+
const SEED = "jvhbyzjinlyutyuhsweuxiwootqoevjqwqmdhjeohrytxjxidpbcfyg";
|
|
10
|
+
|
|
11
|
+
let vaults: SeedVault[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
for (const vault of vaults) {
|
|
15
|
+
await vault.close();
|
|
16
|
+
}
|
|
17
|
+
vaults = [];
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("seed vault browser", () => {
|
|
21
|
+
it("stores and reloads encrypted seeds using memory store", async () => {
|
|
22
|
+
const store = createMemoryVaultStore("vault-browser");
|
|
23
|
+
|
|
24
|
+
const vault = await openSeedVaultBrowser({
|
|
25
|
+
store,
|
|
26
|
+
passphrase: "secret",
|
|
27
|
+
create: true,
|
|
28
|
+
});
|
|
29
|
+
vaults.push(vault);
|
|
30
|
+
|
|
31
|
+
await vault.addSeed({ name: "main", seed: SEED });
|
|
32
|
+
expect(await vault.getSeed("main")).toBe(SEED);
|
|
33
|
+
|
|
34
|
+
const reopened = await openSeedVaultBrowser({
|
|
35
|
+
store,
|
|
36
|
+
passphrase: "secret",
|
|
37
|
+
});
|
|
38
|
+
vaults.push(reopened);
|
|
39
|
+
|
|
40
|
+
expect(await reopened.getSeed("main")).toBe(SEED);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("creates a localStorage-backed store from an injected storage object", async () => {
|
|
44
|
+
const backing = new Map<string, string>();
|
|
45
|
+
const storage = {
|
|
46
|
+
getItem(key: string): string | null {
|
|
47
|
+
return backing.has(key) ? backing.get(key) ?? null : null;
|
|
48
|
+
},
|
|
49
|
+
setItem(key: string, value: string): void {
|
|
50
|
+
backing.set(key, value);
|
|
51
|
+
},
|
|
52
|
+
removeItem(key: string): void {
|
|
53
|
+
backing.delete(key);
|
|
54
|
+
},
|
|
55
|
+
clear(): void {
|
|
56
|
+
backing.clear();
|
|
57
|
+
},
|
|
58
|
+
key(index: number): string | null {
|
|
59
|
+
return Array.from(backing.keys())[index] ?? null;
|
|
60
|
+
},
|
|
61
|
+
get length(): number {
|
|
62
|
+
return backing.size;
|
|
63
|
+
},
|
|
64
|
+
} as Storage;
|
|
65
|
+
|
|
66
|
+
const store = createLocalStorageVaultStore("vault-browser-ls", storage);
|
|
67
|
+
const vault = await openSeedVaultBrowser({
|
|
68
|
+
store,
|
|
69
|
+
passphrase: "secret",
|
|
70
|
+
create: true,
|
|
71
|
+
});
|
|
72
|
+
vaults.push(vault);
|
|
73
|
+
|
|
74
|
+
await vault.addSeed({ name: "main", seed: SEED });
|
|
75
|
+
expect(vault.list().length).toBe(1);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { identityFromSeed } from "@qubic.ts/core";
|
|
2
|
+
import type {
|
|
3
|
+
Pbkdf2Hash,
|
|
4
|
+
Pbkdf2KdfParams,
|
|
5
|
+
SeedVault,
|
|
6
|
+
VaultEntry,
|
|
7
|
+
VaultEntryEncrypted,
|
|
8
|
+
VaultExport,
|
|
9
|
+
VaultHeader,
|
|
10
|
+
VaultSummary,
|
|
11
|
+
} from "./vault/types.js";
|
|
12
|
+
import {
|
|
13
|
+
VaultEntryExistsError,
|
|
14
|
+
VaultEntryNotFoundError,
|
|
15
|
+
VaultError,
|
|
16
|
+
VaultInvalidPassphraseError,
|
|
17
|
+
VaultNotFoundError,
|
|
18
|
+
} from "./vault/types.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PBKDF2_PARAMS = Object.freeze({
|
|
21
|
+
iterations: 200_000,
|
|
22
|
+
hash: "SHA-256" as Pbkdf2Hash,
|
|
23
|
+
dkLen: 32,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const AES_GCM_NONCE_BYTES = 12;
|
|
27
|
+
const AES_GCM_TAG_BYTES = 16;
|
|
28
|
+
const VAULT_VERSION = 1;
|
|
29
|
+
|
|
30
|
+
type VaultFile = Omit<VaultHeader, "kdf"> &
|
|
31
|
+
Readonly<{
|
|
32
|
+
kdf: Readonly<{
|
|
33
|
+
name: "pbkdf2";
|
|
34
|
+
params: Pbkdf2KdfParams;
|
|
35
|
+
}>;
|
|
36
|
+
entries: readonly VaultEntry[];
|
|
37
|
+
}>;
|
|
38
|
+
|
|
39
|
+
export type VaultStore = Readonly<{
|
|
40
|
+
read(): Promise<string | null>;
|
|
41
|
+
write(value: string): Promise<void>;
|
|
42
|
+
remove?(): Promise<void>;
|
|
43
|
+
label?: string;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
46
|
+
export type OpenSeedVaultBrowserInput = Readonly<{
|
|
47
|
+
passphrase: string;
|
|
48
|
+
create?: boolean;
|
|
49
|
+
autoSave?: boolean;
|
|
50
|
+
kdfParams?: Readonly<{
|
|
51
|
+
iterations?: number;
|
|
52
|
+
hash?: Pbkdf2Hash;
|
|
53
|
+
dkLen?: number;
|
|
54
|
+
}>;
|
|
55
|
+
store: VaultStore;
|
|
56
|
+
path?: string;
|
|
57
|
+
}>;
|
|
58
|
+
|
|
59
|
+
export async function openSeedVaultBrowser(input: OpenSeedVaultBrowserInput): Promise<SeedVault> {
|
|
60
|
+
const { store, passphrase } = input;
|
|
61
|
+
const autoSave = input.autoSave ?? true;
|
|
62
|
+
const path = input.path ?? store.label ?? "vault";
|
|
63
|
+
|
|
64
|
+
let file: VaultFile | undefined;
|
|
65
|
+
const raw = await store.read();
|
|
66
|
+
if (raw) file = parseVaultFile(raw);
|
|
67
|
+
|
|
68
|
+
if (!file) {
|
|
69
|
+
if (!input.create) {
|
|
70
|
+
throw new VaultNotFoundError(path);
|
|
71
|
+
}
|
|
72
|
+
const params = createKdfParams(input.kdfParams);
|
|
73
|
+
file = createEmptyVault(params);
|
|
74
|
+
await store.write(JSON.stringify(file, null, 2));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (file.vaultVersion !== VAULT_VERSION) {
|
|
78
|
+
throw new VaultError(`Unsupported vault version: ${file.vaultVersion}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let key = await deriveKey(passphrase, file.kdf.params);
|
|
82
|
+
const entries = new Map(file.entries.map((entry) => [entry.name, entry]));
|
|
83
|
+
|
|
84
|
+
const findEntry = (ref: string): VaultEntry => {
|
|
85
|
+
const direct = entries.get(ref);
|
|
86
|
+
if (direct) return direct;
|
|
87
|
+
for (const entry of entries.values()) {
|
|
88
|
+
if (entry.identity === ref) return entry;
|
|
89
|
+
}
|
|
90
|
+
throw new VaultEntryNotFoundError(ref);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const list = (): readonly VaultSummary[] => {
|
|
94
|
+
return Array.from(entries.values()).map((entry) => ({
|
|
95
|
+
name: entry.name,
|
|
96
|
+
identity: entry.identity,
|
|
97
|
+
seedIndex: entry.seedIndex,
|
|
98
|
+
createdAt: entry.createdAt,
|
|
99
|
+
updatedAt: entry.updatedAt,
|
|
100
|
+
}));
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const save = async (): Promise<void> => {
|
|
104
|
+
if (!file) {
|
|
105
|
+
throw new VaultError("Vault not initialized");
|
|
106
|
+
}
|
|
107
|
+
const updated: VaultFile = { ...file, entries: Array.from(entries.values()) };
|
|
108
|
+
await store.write(JSON.stringify(updated, null, 2));
|
|
109
|
+
file = updated;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const addSeed = async ({
|
|
113
|
+
name,
|
|
114
|
+
seed,
|
|
115
|
+
seedIndex = 0,
|
|
116
|
+
overwrite = false,
|
|
117
|
+
}: Readonly<{
|
|
118
|
+
name: string;
|
|
119
|
+
seed: string;
|
|
120
|
+
seedIndex?: number;
|
|
121
|
+
overwrite?: boolean;
|
|
122
|
+
}>): Promise<VaultSummary> => {
|
|
123
|
+
if (!overwrite && entries.has(name)) {
|
|
124
|
+
throw new VaultEntryExistsError(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const identity = await identityFromSeed(seed, seedIndex);
|
|
128
|
+
const encrypted = await encryptSeed(seed, key);
|
|
129
|
+
const now = new Date().toISOString();
|
|
130
|
+
|
|
131
|
+
const entry: VaultEntry = {
|
|
132
|
+
name,
|
|
133
|
+
identity,
|
|
134
|
+
seedIndex,
|
|
135
|
+
createdAt: entries.get(name)?.createdAt ?? now,
|
|
136
|
+
updatedAt: now,
|
|
137
|
+
encrypted,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
entries.set(name, entry);
|
|
141
|
+
if (autoSave) await save();
|
|
142
|
+
return {
|
|
143
|
+
name: entry.name,
|
|
144
|
+
identity: entry.identity,
|
|
145
|
+
seedIndex: entry.seedIndex,
|
|
146
|
+
createdAt: entry.createdAt,
|
|
147
|
+
updatedAt: entry.updatedAt,
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const getSeed = async (ref: string): Promise<string> => {
|
|
152
|
+
const entry = findEntry(ref);
|
|
153
|
+
try {
|
|
154
|
+
return await decryptSeed(entry.encrypted, key);
|
|
155
|
+
} catch {
|
|
156
|
+
throw new VaultInvalidPassphraseError();
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const getIdentity = (ref: string): string => {
|
|
161
|
+
return findEntry(ref).identity;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const signer = (ref: string): Readonly<{ fromVault: string }> => {
|
|
165
|
+
findEntry(ref);
|
|
166
|
+
return { fromVault: ref };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const remove = async (ref: string): Promise<void> => {
|
|
170
|
+
const entry = findEntry(ref);
|
|
171
|
+
entries.delete(entry.name);
|
|
172
|
+
if (autoSave) await save();
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const rotatePassphrase = async (newPassphrase: string): Promise<void> => {
|
|
176
|
+
const params = createKdfParams(input.kdfParams);
|
|
177
|
+
const nextKey = await deriveKey(newPassphrase, params);
|
|
178
|
+
const now = new Date().toISOString();
|
|
179
|
+
|
|
180
|
+
for (const entry of entries.values()) {
|
|
181
|
+
const seed = await decryptSeed(entry.encrypted, key);
|
|
182
|
+
entries.set(entry.name, {
|
|
183
|
+
...entry,
|
|
184
|
+
encrypted: await encryptSeed(seed, nextKey),
|
|
185
|
+
updatedAt: now,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
file = {
|
|
190
|
+
vaultVersion: VAULT_VERSION,
|
|
191
|
+
kdf: { name: "pbkdf2", params },
|
|
192
|
+
entries: Array.from(entries.values()),
|
|
193
|
+
};
|
|
194
|
+
key = nextKey;
|
|
195
|
+
await save();
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const exportEncrypted = (): VaultExport => {
|
|
199
|
+
if (!file) throw new VaultError("Vault not initialized");
|
|
200
|
+
return { ...file, entries: Array.from(entries.values()) };
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const exportJson = (): string => {
|
|
204
|
+
return JSON.stringify(exportEncrypted(), null, 2);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const importEncrypted = async (
|
|
208
|
+
inputExport: VaultExport | string,
|
|
209
|
+
options?: Readonly<{ mode?: "merge" | "replace"; sourcePassphrase?: string }>,
|
|
210
|
+
): Promise<void> => {
|
|
211
|
+
const source = typeof inputExport === "string" ? parseVaultFile(inputExport) : inputExport;
|
|
212
|
+
if (source.kdf.name !== "pbkdf2") {
|
|
213
|
+
throw new VaultError("Unsupported KDF");
|
|
214
|
+
}
|
|
215
|
+
const sourceKey = await deriveKey(options?.sourcePassphrase ?? passphrase, source.kdf.params);
|
|
216
|
+
const mode = options?.mode ?? "merge";
|
|
217
|
+
const now = new Date().toISOString();
|
|
218
|
+
|
|
219
|
+
const nextEntries = mode === "replace" ? new Map<string, VaultEntry>() : new Map(entries);
|
|
220
|
+
|
|
221
|
+
for (const entry of source.entries) {
|
|
222
|
+
const seed = await decryptSeed(entry.encrypted, sourceKey);
|
|
223
|
+
const encrypted = await encryptSeed(seed, key);
|
|
224
|
+
nextEntries.set(entry.name, {
|
|
225
|
+
name: entry.name,
|
|
226
|
+
identity: entry.identity,
|
|
227
|
+
seedIndex: entry.seedIndex,
|
|
228
|
+
createdAt: entry.createdAt,
|
|
229
|
+
updatedAt: now,
|
|
230
|
+
encrypted,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
entries.clear();
|
|
235
|
+
for (const [name, entry] of nextEntries.entries()) {
|
|
236
|
+
entries.set(name, entry);
|
|
237
|
+
}
|
|
238
|
+
if (autoSave) await save();
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const getSeedSource = async (ref: string): Promise<Readonly<{ fromSeed: string }>> => {
|
|
242
|
+
const fromSeed = await getSeed(ref);
|
|
243
|
+
return { fromSeed };
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const close = async (): Promise<void> => {
|
|
247
|
+
return;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
path,
|
|
252
|
+
list,
|
|
253
|
+
getEntry: findEntry,
|
|
254
|
+
getIdentity,
|
|
255
|
+
signer,
|
|
256
|
+
getSeed,
|
|
257
|
+
addSeed,
|
|
258
|
+
remove,
|
|
259
|
+
rotatePassphrase,
|
|
260
|
+
exportEncrypted,
|
|
261
|
+
exportJson,
|
|
262
|
+
importEncrypted,
|
|
263
|
+
getSeedSource,
|
|
264
|
+
save,
|
|
265
|
+
close,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function createMemoryVaultStore(label = "memory-vault"): VaultStore {
|
|
270
|
+
let value: string | null = null;
|
|
271
|
+
return {
|
|
272
|
+
label,
|
|
273
|
+
async read() {
|
|
274
|
+
return value;
|
|
275
|
+
},
|
|
276
|
+
async write(next: string) {
|
|
277
|
+
value = next;
|
|
278
|
+
},
|
|
279
|
+
async remove() {
|
|
280
|
+
value = null;
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function createLocalStorageVaultStore(key: string, storage?: Storage): VaultStore {
|
|
286
|
+
const store = storage ?? getDefaultStorage();
|
|
287
|
+
if (!store) {
|
|
288
|
+
throw new VaultError("localStorage is not available in this environment");
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
label: key,
|
|
292
|
+
async read() {
|
|
293
|
+
return store.getItem(key);
|
|
294
|
+
},
|
|
295
|
+
async write(next) {
|
|
296
|
+
store.setItem(key, next);
|
|
297
|
+
},
|
|
298
|
+
async remove() {
|
|
299
|
+
store.removeItem(key);
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function getDefaultStorage(): Storage | undefined {
|
|
305
|
+
const anyGlobal = globalThis as typeof globalThis & { localStorage?: Storage };
|
|
306
|
+
return anyGlobal.localStorage;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function createKdfParams(
|
|
310
|
+
overrides?: Readonly<{ iterations?: number; hash?: Pbkdf2Hash; dkLen?: number }>,
|
|
311
|
+
): Pbkdf2KdfParams {
|
|
312
|
+
const params = {
|
|
313
|
+
iterations: overrides?.iterations ?? DEFAULT_PBKDF2_PARAMS.iterations,
|
|
314
|
+
hash: overrides?.hash ?? DEFAULT_PBKDF2_PARAMS.hash,
|
|
315
|
+
dkLen: overrides?.dkLen ?? DEFAULT_PBKDF2_PARAMS.dkLen,
|
|
316
|
+
};
|
|
317
|
+
const salt = getRandomBytes(16);
|
|
318
|
+
return {
|
|
319
|
+
...params,
|
|
320
|
+
saltBase64: bytesToBase64(salt),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function createEmptyVault(params: Pbkdf2KdfParams): VaultFile {
|
|
325
|
+
return {
|
|
326
|
+
vaultVersion: VAULT_VERSION,
|
|
327
|
+
kdf: {
|
|
328
|
+
name: "pbkdf2",
|
|
329
|
+
params,
|
|
330
|
+
},
|
|
331
|
+
entries: [],
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function deriveKey(passphrase: string, params: Pbkdf2KdfParams): Promise<CryptoKey> {
|
|
336
|
+
const crypto = await getCrypto();
|
|
337
|
+
const salt = base64ToBytes(params.saltBase64);
|
|
338
|
+
const baseKey = await crypto.subtle.importKey(
|
|
339
|
+
"raw",
|
|
340
|
+
toArrayBuffer(utf8ToBytes(passphrase)),
|
|
341
|
+
"PBKDF2",
|
|
342
|
+
false,
|
|
343
|
+
["deriveKey"],
|
|
344
|
+
);
|
|
345
|
+
return crypto.subtle.deriveKey(
|
|
346
|
+
{
|
|
347
|
+
name: "PBKDF2",
|
|
348
|
+
salt: toArrayBuffer(salt),
|
|
349
|
+
iterations: params.iterations,
|
|
350
|
+
hash: params.hash,
|
|
351
|
+
},
|
|
352
|
+
baseKey,
|
|
353
|
+
{ name: "AES-GCM", length: params.dkLen * 8 },
|
|
354
|
+
false,
|
|
355
|
+
["encrypt", "decrypt"],
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function encryptSeed(seed: string, key: CryptoKey): Promise<VaultEntryEncrypted> {
|
|
360
|
+
const crypto = await getCrypto();
|
|
361
|
+
const nonce = getRandomBytes(AES_GCM_NONCE_BYTES);
|
|
362
|
+
const ciphertextAndTag = await crypto.subtle.encrypt(
|
|
363
|
+
{ name: "AES-GCM", iv: toArrayBuffer(nonce), tagLength: 128 },
|
|
364
|
+
key,
|
|
365
|
+
toArrayBuffer(utf8ToBytes(seed)),
|
|
366
|
+
);
|
|
367
|
+
const bytes = new Uint8Array(ciphertextAndTag);
|
|
368
|
+
const ciphertext = bytes.slice(0, bytes.length - AES_GCM_TAG_BYTES);
|
|
369
|
+
const tag = bytes.slice(bytes.length - AES_GCM_TAG_BYTES);
|
|
370
|
+
return {
|
|
371
|
+
nonceBase64: bytesToBase64(nonce),
|
|
372
|
+
ciphertextBase64: bytesToBase64(ciphertext),
|
|
373
|
+
tagBase64: bytesToBase64(tag),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function decryptSeed(encrypted: VaultEntryEncrypted, key: CryptoKey): Promise<string> {
|
|
378
|
+
const crypto = await getCrypto();
|
|
379
|
+
const nonce = base64ToBytes(encrypted.nonceBase64);
|
|
380
|
+
const ciphertext = base64ToBytes(encrypted.ciphertextBase64);
|
|
381
|
+
const tag = base64ToBytes(encrypted.tagBase64);
|
|
382
|
+
const combined = new Uint8Array(ciphertext.length + tag.length);
|
|
383
|
+
combined.set(ciphertext, 0);
|
|
384
|
+
combined.set(tag, ciphertext.length);
|
|
385
|
+
const clear = await crypto.subtle.decrypt(
|
|
386
|
+
{ name: "AES-GCM", iv: toArrayBuffer(nonce), tagLength: 128 },
|
|
387
|
+
key,
|
|
388
|
+
toArrayBuffer(combined),
|
|
389
|
+
);
|
|
390
|
+
return bytesToUtf8(new Uint8Array(clear));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function parseVaultFile(raw: string): VaultFile {
|
|
394
|
+
const parsed = JSON.parse(raw) as VaultFile;
|
|
395
|
+
if (!parsed || typeof parsed !== "object") {
|
|
396
|
+
throw new VaultError("Invalid vault file");
|
|
397
|
+
}
|
|
398
|
+
if (!parsed.kdf || parsed.kdf.name !== "pbkdf2") {
|
|
399
|
+
throw new VaultError("Unsupported KDF");
|
|
400
|
+
}
|
|
401
|
+
return parsed;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function getCrypto(): Promise<Crypto> {
|
|
405
|
+
if (globalThis.crypto?.subtle) return globalThis.crypto;
|
|
406
|
+
throw new VaultError("WebCrypto is not available in this environment");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getRandomBytes(length: number): Uint8Array<ArrayBuffer> {
|
|
410
|
+
const crypto = globalThis.crypto;
|
|
411
|
+
if (!crypto?.getRandomValues) {
|
|
412
|
+
throw new VaultError("crypto.getRandomValues is not available");
|
|
413
|
+
}
|
|
414
|
+
const bytes = new Uint8Array(new ArrayBuffer(length));
|
|
415
|
+
crypto.getRandomValues(bytes);
|
|
416
|
+
return bytes;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
420
|
+
if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
|
|
421
|
+
let binary = "";
|
|
422
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
423
|
+
return btoa(binary);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function base64ToBytes(base64: string): Uint8Array<ArrayBuffer> {
|
|
427
|
+
if (typeof Buffer !== "undefined") {
|
|
428
|
+
const buf = Buffer.from(base64, "base64");
|
|
429
|
+
return new Uint8Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
430
|
+
}
|
|
431
|
+
const binary = atob(base64);
|
|
432
|
+
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
|
433
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
434
|
+
return bytes;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function bytesToUtf8(bytes: Uint8Array): string {
|
|
438
|
+
return new TextDecoder().decode(bytes);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function utf8ToBytes(value: string): Uint8Array {
|
|
442
|
+
return new TextEncoder().encode(value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
|
446
|
+
const out = new Uint8Array(new ArrayBuffer(bytes.byteLength));
|
|
447
|
+
out.set(bytes);
|
|
448
|
+
return out.buffer;
|
|
449
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const SEED = "jvhbyzjinlyutyuhsweuxiwootqoevjqwqmdhjeohrytxjxidpbcfyg";
|
|
7
|
+
|
|
8
|
+
let currentDir: string | undefined;
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
if (currentDir) {
|
|
12
|
+
await rm(currentDir, { recursive: true, force: true });
|
|
13
|
+
currentDir = undefined;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("vault cli", () => {
|
|
18
|
+
it("supports init/add/list flow", async () => {
|
|
19
|
+
currentDir = await mkdtemp(join(tmpdir(), "sdk-vault-cli-"));
|
|
20
|
+
const vaultPath = join(currentDir, "vault.json");
|
|
21
|
+
|
|
22
|
+
await runCli(["init", "--path", vaultPath, "--passphrase", "secret"]);
|
|
23
|
+
await runCli([
|
|
24
|
+
"add",
|
|
25
|
+
"--path",
|
|
26
|
+
vaultPath,
|
|
27
|
+
"--passphrase",
|
|
28
|
+
"secret",
|
|
29
|
+
"--name",
|
|
30
|
+
"main",
|
|
31
|
+
"--seed",
|
|
32
|
+
SEED,
|
|
33
|
+
]);
|
|
34
|
+
const listed = await runCli(["list", "--path", vaultPath, "--passphrase", "secret"]);
|
|
35
|
+
|
|
36
|
+
const entries = JSON.parse(listed.stdout) as Array<{ name: string; identity: string }>;
|
|
37
|
+
expect(entries.length).toBe(1);
|
|
38
|
+
expect(entries[0]?.name).toBe("main");
|
|
39
|
+
expect(entries[0]?.identity.length).toBe(60);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
async function runCli(args: readonly string[]): Promise<{ stdout: string; stderr: string }> {
|
|
44
|
+
const scriptPath = new URL("./vault-cli.ts", import.meta.url).pathname;
|
|
45
|
+
const proc = Bun.spawn({
|
|
46
|
+
cmd: [process.execPath, scriptPath, ...args],
|
|
47
|
+
cwd: process.cwd(),
|
|
48
|
+
stdout: "pipe",
|
|
49
|
+
stderr: "pipe",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
53
|
+
new Response(proc.stdout).text(),
|
|
54
|
+
new Response(proc.stderr).text(),
|
|
55
|
+
proc.exited,
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
if (exitCode !== 0) {
|
|
59
|
+
throw new Error(`CLI failed with code ${exitCode}\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { stdout: stdout.trim(), stderr: stderr.trim() };
|
|
63
|
+
}
|
package/src/vault-cli.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { openSeedVault } from "./vault.js";
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const command = args[0];
|
|
7
|
+
|
|
8
|
+
if (!command || command === "--help" || command === "-h") {
|
|
9
|
+
printHelp();
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const path = getArg(args, "--path") ?? "./vault.json";
|
|
14
|
+
const passphrase = getArg(args, "--passphrase");
|
|
15
|
+
|
|
16
|
+
if (!passphrase) {
|
|
17
|
+
console.error("Missing --passphrase");
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const vault = await openSeedVault({ path, passphrase, create: command === "init" });
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
switch (command) {
|
|
25
|
+
case "init":
|
|
26
|
+
console.log(`Vault ready at ${path}`);
|
|
27
|
+
break;
|
|
28
|
+
case "list":
|
|
29
|
+
console.log(JSON.stringify(vault.list(), null, 2));
|
|
30
|
+
break;
|
|
31
|
+
case "add": {
|
|
32
|
+
const name = requiredArg(args, "--name");
|
|
33
|
+
const seed = requiredArg(args, "--seed");
|
|
34
|
+
const seedIndex = parseNumberArg(args, "--index");
|
|
35
|
+
const overwrite = hasFlag(args, "--overwrite");
|
|
36
|
+
const entry = await vault.addSeed({ name, seed, seedIndex, overwrite });
|
|
37
|
+
console.log(JSON.stringify(entry, null, 2));
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
case "remove": {
|
|
41
|
+
const name = requiredArg(args, "--name");
|
|
42
|
+
await vault.remove(name);
|
|
43
|
+
console.log(`Removed ${name}`);
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
case "rotate": {
|
|
47
|
+
const newPassphrase = requiredArg(args, "--new-passphrase");
|
|
48
|
+
await vault.rotatePassphrase(newPassphrase);
|
|
49
|
+
console.log("Passphrase rotated");
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
case "export": {
|
|
53
|
+
const outPath = getArg(args, "--out");
|
|
54
|
+
const json = vault.exportJson();
|
|
55
|
+
if (outPath) {
|
|
56
|
+
await writeFile(outPath, json, "utf8");
|
|
57
|
+
console.log(`Exported to ${outPath}`);
|
|
58
|
+
} else {
|
|
59
|
+
console.log(json);
|
|
60
|
+
}
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
case "import": {
|
|
64
|
+
const importPath = requiredArg(args, "--file");
|
|
65
|
+
const mode = (getArg(args, "--mode") as "merge" | "replace" | undefined) ?? "merge";
|
|
66
|
+
const sourcePassphrase = getArg(args, "--source-passphrase") ?? passphrase;
|
|
67
|
+
const json = await readFile(importPath, "utf8");
|
|
68
|
+
await vault.importEncrypted(json, { mode, sourcePassphrase });
|
|
69
|
+
console.log(`Imported ${importPath}`);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
default:
|
|
73
|
+
console.error(`Unknown command: ${command}`);
|
|
74
|
+
printHelp();
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
} finally {
|
|
78
|
+
await vault.close();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getArg(argsList: readonly string[], name: string): string | undefined {
|
|
82
|
+
const index = argsList.indexOf(name);
|
|
83
|
+
if (index === -1) return undefined;
|
|
84
|
+
return argsList[index + 1];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function requiredArg(argsList: readonly string[], name: string): string {
|
|
88
|
+
const value = getArg(argsList, name);
|
|
89
|
+
if (!value) {
|
|
90
|
+
console.error(`Missing ${name}`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseNumberArg(argsList: readonly string[], name: string): number | undefined {
|
|
97
|
+
const value = getArg(argsList, name);
|
|
98
|
+
if (!value) return undefined;
|
|
99
|
+
const parsed = Number(value);
|
|
100
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
101
|
+
console.error(`${name} must be an integer`);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
return parsed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function hasFlag(argsList: readonly string[], name: string): boolean {
|
|
108
|
+
return argsList.includes(name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function printHelp() {
|
|
112
|
+
console.log(`Seed vault CLI
|
|
113
|
+
|
|
114
|
+
Usage:
|
|
115
|
+
qubic-vault init --path ./vault.json --passphrase "secret"
|
|
116
|
+
qubic-vault list --path ./vault.json --passphrase "secret"
|
|
117
|
+
qubic-vault add --path ./vault.json --passphrase "secret" --name main --seed "..." [--index 0] [--overwrite]
|
|
118
|
+
qubic-vault remove --path ./vault.json --passphrase "secret" --name main
|
|
119
|
+
qubic-vault rotate --path ./vault.json --passphrase "secret" --new-passphrase "next"
|
|
120
|
+
qubic-vault export --path ./vault.json --passphrase "secret" [--out backup.json]
|
|
121
|
+
qubic-vault import --path ./vault.json --passphrase "secret" --file backup.json [--mode merge|replace] [--source-passphrase "secret"]
|
|
122
|
+
`);
|
|
123
|
+
}
|