@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,97 @@
|
|
|
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
|
+
import type { SeedVault } from "./vault/types.js";
|
|
6
|
+
import { openSeedVault } from "./vault.js";
|
|
7
|
+
|
|
8
|
+
const SEED = "jvhbyzjinlyutyuhsweuxiwootqoevjqwqmdhjeohrytxjxidpbcfyg";
|
|
9
|
+
|
|
10
|
+
let currentDir: string | undefined;
|
|
11
|
+
let vaults: SeedVault[] = [];
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
for (const vault of vaults) {
|
|
15
|
+
await vault.close();
|
|
16
|
+
}
|
|
17
|
+
vaults = [];
|
|
18
|
+
if (currentDir) {
|
|
19
|
+
await rm(currentDir, { recursive: true, force: true });
|
|
20
|
+
currentDir = undefined;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("seed vault", () => {
|
|
25
|
+
it("creates a vault, stores a seed, and reloads it", async () => {
|
|
26
|
+
currentDir = await mkdtemp(join(tmpdir(), "sdk-vault-"));
|
|
27
|
+
const vaultPath = join(currentDir, "vault.json");
|
|
28
|
+
|
|
29
|
+
const vault = await openSeedVault({ path: vaultPath, passphrase: "secret", create: true });
|
|
30
|
+
vaults.push(vault);
|
|
31
|
+
const entry = await vault.addSeed({ name: "main", seed: SEED });
|
|
32
|
+
|
|
33
|
+
expect(entry.name).toBe("main");
|
|
34
|
+
expect(entry.identity.length).toBe(60);
|
|
35
|
+
|
|
36
|
+
const seed = await vault.getSeed("main");
|
|
37
|
+
expect(seed).toBe(SEED);
|
|
38
|
+
|
|
39
|
+
await vault.close();
|
|
40
|
+
const reopened = await openSeedVault({ path: vaultPath, passphrase: "secret" });
|
|
41
|
+
vaults.push(reopened);
|
|
42
|
+
const seedReloaded = await reopened.getSeed(entry.identity);
|
|
43
|
+
expect(seedReloaded).toBe(SEED);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rotates the passphrase", async () => {
|
|
47
|
+
currentDir = await mkdtemp(join(tmpdir(), "sdk-vault-"));
|
|
48
|
+
const vaultPath = join(currentDir, "vault.json");
|
|
49
|
+
|
|
50
|
+
const vault = await openSeedVault({ path: vaultPath, passphrase: "secret", create: true });
|
|
51
|
+
vaults.push(vault);
|
|
52
|
+
await vault.addSeed({ name: "main", seed: SEED });
|
|
53
|
+
|
|
54
|
+
await vault.rotatePassphrase("new-secret");
|
|
55
|
+
|
|
56
|
+
await vault.close();
|
|
57
|
+
const reopened = await openSeedVault({ path: vaultPath, passphrase: "new-secret" });
|
|
58
|
+
vaults.push(reopened);
|
|
59
|
+
const seedReloaded = await reopened.getSeed("main");
|
|
60
|
+
expect(seedReloaded).toBe(SEED);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("imports encrypted exports from another vault", async () => {
|
|
64
|
+
currentDir = await mkdtemp(join(tmpdir(), "sdk-vault-"));
|
|
65
|
+
const vaultPath = join(currentDir, "vault.json");
|
|
66
|
+
const vaultPathTwo = join(currentDir, "vault-two.json");
|
|
67
|
+
|
|
68
|
+
const vault = await openSeedVault({ path: vaultPath, passphrase: "secret", create: true });
|
|
69
|
+
vaults.push(vault);
|
|
70
|
+
await vault.addSeed({ name: "main", seed: SEED });
|
|
71
|
+
|
|
72
|
+
const exported = vault.exportJson();
|
|
73
|
+
|
|
74
|
+
const vaultTwo = await openSeedVault({
|
|
75
|
+
path: vaultPathTwo,
|
|
76
|
+
passphrase: "secret",
|
|
77
|
+
create: true,
|
|
78
|
+
});
|
|
79
|
+
vaults.push(vaultTwo);
|
|
80
|
+
await vaultTwo.importEncrypted(exported, { mode: "merge", sourcePassphrase: "secret" });
|
|
81
|
+
|
|
82
|
+
const seedReloaded = await vaultTwo.getSeed("main");
|
|
83
|
+
expect(seedReloaded).toBe(SEED);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("supports signer helper", async () => {
|
|
87
|
+
currentDir = await mkdtemp(join(tmpdir(), "sdk-vault-"));
|
|
88
|
+
const vaultPath = join(currentDir, "vault.json");
|
|
89
|
+
|
|
90
|
+
const vault = await openSeedVault({ path: vaultPath, passphrase: "secret", create: true });
|
|
91
|
+
vaults.push(vault);
|
|
92
|
+
await vault.addSeed({ name: "main", seed: SEED });
|
|
93
|
+
|
|
94
|
+
const signer = vault.signer("main");
|
|
95
|
+
expect(signer.fromVault).toBe("main");
|
|
96
|
+
});
|
|
97
|
+
});
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from "node:crypto";
|
|
2
|
+
import { access, open, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { identityFromSeed } from "@qubic.ts/core";
|
|
4
|
+
import type {
|
|
5
|
+
OpenSeedVaultInput,
|
|
6
|
+
ScryptKdfParams,
|
|
7
|
+
SeedVault,
|
|
8
|
+
VaultEntry,
|
|
9
|
+
VaultEntryEncrypted,
|
|
10
|
+
VaultExport,
|
|
11
|
+
VaultHeader,
|
|
12
|
+
VaultSummary,
|
|
13
|
+
} from "./vault/types.js";
|
|
14
|
+
import {
|
|
15
|
+
VaultEntryExistsError,
|
|
16
|
+
VaultEntryNotFoundError,
|
|
17
|
+
VaultError,
|
|
18
|
+
VaultInvalidPassphraseError,
|
|
19
|
+
VaultNotFoundError,
|
|
20
|
+
} from "./vault/types.js";
|
|
21
|
+
|
|
22
|
+
const scryptAsync = (
|
|
23
|
+
password: string,
|
|
24
|
+
salt: Buffer,
|
|
25
|
+
keylen: number,
|
|
26
|
+
options: Readonly<{ N: number; r: number; p: number }>,
|
|
27
|
+
): Promise<Buffer> =>
|
|
28
|
+
new Promise((resolve, reject) => {
|
|
29
|
+
scrypt(password, salt, keylen, options, (error, derivedKey) => {
|
|
30
|
+
if (error) {
|
|
31
|
+
reject(error);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
resolve(derivedKey as Buffer);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const DEFAULT_SCRYPT_PARAMS = Object.freeze({
|
|
39
|
+
N: 1 << 13,
|
|
40
|
+
r: 8,
|
|
41
|
+
p: 1,
|
|
42
|
+
dkLen: 32,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const AES_GCM_NONCE_BYTES = 12;
|
|
46
|
+
const VAULT_VERSION = 1;
|
|
47
|
+
|
|
48
|
+
type VaultFile = Omit<VaultHeader, "kdf"> &
|
|
49
|
+
Readonly<{
|
|
50
|
+
kdf: Readonly<{
|
|
51
|
+
name: "scrypt";
|
|
52
|
+
params: ScryptKdfParams;
|
|
53
|
+
}>;
|
|
54
|
+
entries: readonly VaultEntry[];
|
|
55
|
+
}>;
|
|
56
|
+
|
|
57
|
+
export async function openSeedVault(input: OpenSeedVaultInput): Promise<SeedVault> {
|
|
58
|
+
const { path, passphrase } = input;
|
|
59
|
+
const autoSave = input.autoSave ?? true;
|
|
60
|
+
const lockEnabled = input.lock ?? true;
|
|
61
|
+
const lockTimeoutMs = input.lockTimeoutMs ?? 0;
|
|
62
|
+
const lockPath = `${path}.lock`;
|
|
63
|
+
|
|
64
|
+
let file: VaultFile | undefined;
|
|
65
|
+
let lockHandle: Awaited<ReturnType<typeof open>> | undefined;
|
|
66
|
+
let closed = false;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
if (lockEnabled) {
|
|
70
|
+
lockHandle = await acquireLock(lockPath, lockTimeoutMs);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const raw = await readFile(path, "utf8");
|
|
75
|
+
file = parseVaultFile(raw);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (!isNotFoundError(error)) {
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
if (!input.create) {
|
|
81
|
+
throw new VaultNotFoundError(path);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
await releaseLock(lockHandle, lockPath);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!file) {
|
|
90
|
+
const params = createKdfParams(input.kdfParams);
|
|
91
|
+
file = createEmptyVault(params);
|
|
92
|
+
await writeVaultFile(path, file);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (file.vaultVersion !== VAULT_VERSION) {
|
|
96
|
+
throw new VaultError(`Unsupported vault version: ${file.vaultVersion}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let key = await deriveKey(passphrase, file.kdf.params);
|
|
100
|
+
const entries = new Map(file.entries.map((entry) => [entry.name, entry]));
|
|
101
|
+
|
|
102
|
+
const findEntry = (ref: string): VaultEntry => {
|
|
103
|
+
const direct = entries.get(ref);
|
|
104
|
+
if (direct) return direct;
|
|
105
|
+
for (const entry of entries.values()) {
|
|
106
|
+
if (entry.identity === ref) return entry;
|
|
107
|
+
}
|
|
108
|
+
throw new VaultEntryNotFoundError(ref);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const list = (): readonly VaultSummary[] => {
|
|
112
|
+
return Array.from(entries.values()).map((entry) => ({
|
|
113
|
+
name: entry.name,
|
|
114
|
+
identity: entry.identity,
|
|
115
|
+
seedIndex: entry.seedIndex,
|
|
116
|
+
createdAt: entry.createdAt,
|
|
117
|
+
updatedAt: entry.updatedAt,
|
|
118
|
+
}));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const save = async (): Promise<void> => {
|
|
122
|
+
if (!file) {
|
|
123
|
+
throw new VaultError("Vault not initialized");
|
|
124
|
+
}
|
|
125
|
+
const updated: VaultFile = { ...file, entries: Array.from(entries.values()) };
|
|
126
|
+
await writeVaultFile(path, updated);
|
|
127
|
+
file = updated;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const addSeed = async ({
|
|
131
|
+
name,
|
|
132
|
+
seed,
|
|
133
|
+
seedIndex = 0,
|
|
134
|
+
overwrite = false,
|
|
135
|
+
}: Readonly<{
|
|
136
|
+
name: string;
|
|
137
|
+
seed: string;
|
|
138
|
+
seedIndex?: number;
|
|
139
|
+
overwrite?: boolean;
|
|
140
|
+
}>): Promise<VaultSummary> => {
|
|
141
|
+
if (!overwrite && entries.has(name)) {
|
|
142
|
+
throw new VaultEntryExistsError(name);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const identity = await identityFromSeed(seed, seedIndex);
|
|
146
|
+
const encrypted = encryptSeed(seed, key);
|
|
147
|
+
const now = new Date().toISOString();
|
|
148
|
+
|
|
149
|
+
const entry: VaultEntry = {
|
|
150
|
+
name,
|
|
151
|
+
identity,
|
|
152
|
+
seedIndex,
|
|
153
|
+
createdAt: entries.get(name)?.createdAt ?? now,
|
|
154
|
+
updatedAt: now,
|
|
155
|
+
encrypted,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
entries.set(name, entry);
|
|
159
|
+
if (autoSave) await save();
|
|
160
|
+
return {
|
|
161
|
+
name: entry.name,
|
|
162
|
+
identity: entry.identity,
|
|
163
|
+
seedIndex: entry.seedIndex,
|
|
164
|
+
createdAt: entry.createdAt,
|
|
165
|
+
updatedAt: entry.updatedAt,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const getSeed = async (ref: string): Promise<string> => {
|
|
170
|
+
const entry = findEntry(ref);
|
|
171
|
+
try {
|
|
172
|
+
return decryptSeed(entry.encrypted, key);
|
|
173
|
+
} catch {
|
|
174
|
+
throw new VaultInvalidPassphraseError();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const getIdentity = (ref: string): string => {
|
|
179
|
+
return findEntry(ref).identity;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const signer = (ref: string): Readonly<{ fromVault: string }> => {
|
|
183
|
+
findEntry(ref);
|
|
184
|
+
return { fromVault: ref };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const remove = async (ref: string): Promise<void> => {
|
|
188
|
+
const entry = findEntry(ref);
|
|
189
|
+
entries.delete(entry.name);
|
|
190
|
+
if (autoSave) await save();
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const rotatePassphrase = async (newPassphrase: string): Promise<void> => {
|
|
194
|
+
const params = createKdfParams(input.kdfParams);
|
|
195
|
+
const nextKey = await deriveKey(newPassphrase, params);
|
|
196
|
+
const now = new Date().toISOString();
|
|
197
|
+
|
|
198
|
+
for (const entry of entries.values()) {
|
|
199
|
+
const seed = decryptSeed(entry.encrypted, key);
|
|
200
|
+
entries.set(entry.name, {
|
|
201
|
+
...entry,
|
|
202
|
+
encrypted: encryptSeed(seed, nextKey),
|
|
203
|
+
updatedAt: now,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
file = {
|
|
208
|
+
vaultVersion: VAULT_VERSION,
|
|
209
|
+
kdf: { name: "scrypt", params },
|
|
210
|
+
entries: Array.from(entries.values()),
|
|
211
|
+
};
|
|
212
|
+
key = nextKey;
|
|
213
|
+
await writeVaultFile(path, file);
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const exportEncrypted = (): VaultExport => {
|
|
217
|
+
if (!file) throw new VaultError("Vault not initialized");
|
|
218
|
+
return { ...file, entries: Array.from(entries.values()) };
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const exportJson = (): string => {
|
|
222
|
+
return JSON.stringify(exportEncrypted(), null, 2);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const importEncrypted = async (
|
|
226
|
+
inputExport: VaultExport | string,
|
|
227
|
+
options?: Readonly<{ mode?: "merge" | "replace"; sourcePassphrase?: string }>,
|
|
228
|
+
): Promise<void> => {
|
|
229
|
+
const source = typeof inputExport === "string" ? parseVaultFile(inputExport) : inputExport;
|
|
230
|
+
if (source.kdf.name !== "scrypt") {
|
|
231
|
+
throw new VaultError("Unsupported KDF");
|
|
232
|
+
}
|
|
233
|
+
const sourceKey = await deriveKey(options?.sourcePassphrase ?? passphrase, source.kdf.params);
|
|
234
|
+
const mode = options?.mode ?? "merge";
|
|
235
|
+
const now = new Date().toISOString();
|
|
236
|
+
|
|
237
|
+
const nextEntries = mode === "replace" ? new Map<string, VaultEntry>() : new Map(entries);
|
|
238
|
+
|
|
239
|
+
for (const entry of source.entries) {
|
|
240
|
+
const seed = decryptSeed(entry.encrypted, sourceKey);
|
|
241
|
+
const encrypted = encryptSeed(seed, key);
|
|
242
|
+
nextEntries.set(entry.name, {
|
|
243
|
+
name: entry.name,
|
|
244
|
+
identity: entry.identity,
|
|
245
|
+
seedIndex: entry.seedIndex,
|
|
246
|
+
createdAt: entry.createdAt,
|
|
247
|
+
updatedAt: now,
|
|
248
|
+
encrypted,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
entries.clear();
|
|
253
|
+
for (const [name, entry] of nextEntries.entries()) {
|
|
254
|
+
entries.set(name, entry);
|
|
255
|
+
}
|
|
256
|
+
if (autoSave) await save();
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const getSeedSource = async (ref: string): Promise<Readonly<{ fromSeed: string }>> => {
|
|
260
|
+
const fromSeed = await getSeed(ref);
|
|
261
|
+
return { fromSeed };
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const close = async (): Promise<void> => {
|
|
265
|
+
if (closed) return;
|
|
266
|
+
closed = true;
|
|
267
|
+
await releaseLock(lockHandle, lockPath);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (lockHandle) {
|
|
271
|
+
registerExitHandler(async () => {
|
|
272
|
+
await releaseLock(lockHandle, lockPath);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
path,
|
|
278
|
+
list,
|
|
279
|
+
getEntry: findEntry,
|
|
280
|
+
getIdentity,
|
|
281
|
+
signer,
|
|
282
|
+
getSeed,
|
|
283
|
+
addSeed,
|
|
284
|
+
remove,
|
|
285
|
+
rotatePassphrase,
|
|
286
|
+
exportEncrypted,
|
|
287
|
+
exportJson,
|
|
288
|
+
importEncrypted,
|
|
289
|
+
getSeedSource,
|
|
290
|
+
save,
|
|
291
|
+
close,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function createKdfParams(
|
|
296
|
+
overrides?: Readonly<{ N?: number; r?: number; p?: number; dkLen?: number }>,
|
|
297
|
+
): ScryptKdfParams {
|
|
298
|
+
const params = {
|
|
299
|
+
N: overrides?.N ?? DEFAULT_SCRYPT_PARAMS.N,
|
|
300
|
+
r: overrides?.r ?? DEFAULT_SCRYPT_PARAMS.r,
|
|
301
|
+
p: overrides?.p ?? DEFAULT_SCRYPT_PARAMS.p,
|
|
302
|
+
dkLen: overrides?.dkLen ?? DEFAULT_SCRYPT_PARAMS.dkLen,
|
|
303
|
+
};
|
|
304
|
+
const salt = randomBytes(16);
|
|
305
|
+
return {
|
|
306
|
+
...params,
|
|
307
|
+
saltBase64: salt.toString("base64"),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function createEmptyVault(params: ScryptKdfParams): VaultFile {
|
|
312
|
+
return {
|
|
313
|
+
vaultVersion: VAULT_VERSION,
|
|
314
|
+
kdf: {
|
|
315
|
+
name: "scrypt",
|
|
316
|
+
params,
|
|
317
|
+
},
|
|
318
|
+
entries: [],
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function deriveKey(passphrase: string, params: ScryptKdfParams): Promise<Buffer> {
|
|
323
|
+
const salt = Buffer.from(params.saltBase64, "base64");
|
|
324
|
+
const derived = await scryptAsync(passphrase, salt, params.dkLen, {
|
|
325
|
+
N: params.N,
|
|
326
|
+
r: params.r,
|
|
327
|
+
p: params.p,
|
|
328
|
+
});
|
|
329
|
+
return Buffer.isBuffer(derived) ? derived : Buffer.from(derived as ArrayBuffer);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function encryptSeed(seed: string, key: Buffer): VaultEntryEncrypted {
|
|
333
|
+
const nonce = randomBytes(AES_GCM_NONCE_BYTES);
|
|
334
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce);
|
|
335
|
+
const ciphertext = Buffer.concat([cipher.update(seed, "utf8"), cipher.final()]);
|
|
336
|
+
const tag = cipher.getAuthTag();
|
|
337
|
+
return {
|
|
338
|
+
nonceBase64: nonce.toString("base64"),
|
|
339
|
+
ciphertextBase64: ciphertext.toString("base64"),
|
|
340
|
+
tagBase64: tag.toString("base64"),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function decryptSeed(encrypted: VaultEntryEncrypted, key: Buffer): string {
|
|
345
|
+
const nonce = Buffer.from(encrypted.nonceBase64, "base64");
|
|
346
|
+
const ciphertext = Buffer.from(encrypted.ciphertextBase64, "base64");
|
|
347
|
+
const tag = Buffer.from(encrypted.tagBase64, "base64");
|
|
348
|
+
const decipher = createDecipheriv("aes-256-gcm", key, nonce);
|
|
349
|
+
decipher.setAuthTag(tag);
|
|
350
|
+
const clear = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
351
|
+
return clear.toString("utf8");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function writeVaultFile(path: string, file: VaultFile): Promise<void> {
|
|
355
|
+
const tmpPath = `${path}.tmp`;
|
|
356
|
+
await writeFile(tmpPath, JSON.stringify(file, null, 2), "utf8");
|
|
357
|
+
await rename(tmpPath, path);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function parseVaultFile(raw: string): VaultFile {
|
|
361
|
+
const parsed = JSON.parse(raw) as VaultFile;
|
|
362
|
+
if (!parsed || typeof parsed !== "object") {
|
|
363
|
+
throw new VaultError("Invalid vault file");
|
|
364
|
+
}
|
|
365
|
+
if (!parsed.kdf || parsed.kdf.name !== "scrypt") {
|
|
366
|
+
throw new VaultError("Unsupported KDF");
|
|
367
|
+
}
|
|
368
|
+
return parsed;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isNotFoundError(error: unknown): error is { code: string } {
|
|
372
|
+
if (!error || typeof error !== "object" || !("code" in error)) return false;
|
|
373
|
+
const code = (error as { code?: unknown }).code;
|
|
374
|
+
return typeof code === "string" && code === "ENOENT";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function vaultExists(path: string): Promise<boolean> {
|
|
378
|
+
try {
|
|
379
|
+
await access(path);
|
|
380
|
+
return true;
|
|
381
|
+
} catch {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function acquireLock(
|
|
387
|
+
path: string,
|
|
388
|
+
timeoutMs: number,
|
|
389
|
+
): Promise<Awaited<ReturnType<typeof open>>> {
|
|
390
|
+
const start = Date.now();
|
|
391
|
+
const retryMs = 200;
|
|
392
|
+
while (true) {
|
|
393
|
+
try {
|
|
394
|
+
const handle = await open(path, "wx");
|
|
395
|
+
await handle.writeFile(
|
|
396
|
+
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
|
|
397
|
+
"utf8",
|
|
398
|
+
);
|
|
399
|
+
return handle;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
if (!isLockExistsError(error)) throw error;
|
|
402
|
+
if (timeoutMs <= 0 || Date.now() - start >= timeoutMs) {
|
|
403
|
+
throw new VaultError(`Vault is locked: ${path}`);
|
|
404
|
+
}
|
|
405
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function releaseLock(
|
|
411
|
+
handle: Awaited<ReturnType<typeof open>> | undefined,
|
|
412
|
+
path: string,
|
|
413
|
+
): Promise<void> {
|
|
414
|
+
if (!handle) return;
|
|
415
|
+
try {
|
|
416
|
+
await handle.close();
|
|
417
|
+
} finally {
|
|
418
|
+
await unlink(path).catch(() => undefined);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isLockExistsError(error: unknown): boolean {
|
|
423
|
+
if (!error || typeof error !== "object" || !("code" in error)) return false;
|
|
424
|
+
const code = (error as { code?: unknown }).code;
|
|
425
|
+
return typeof code === "string" && code === "EEXIST";
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const exitHandlers = new Set<() => Promise<void>>();
|
|
429
|
+
|
|
430
|
+
function registerExitHandler(fn: () => Promise<void>): void {
|
|
431
|
+
exitHandlers.add(fn);
|
|
432
|
+
if (exitHandlers.size === 1) {
|
|
433
|
+
process.on("exit", () => {
|
|
434
|
+
for (const handler of exitHandlers) {
|
|
435
|
+
handler().catch(() => undefined);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|