-
Notifications
You must be signed in to change notification settings - Fork 44
Expand file tree
/
Copy pathstore.ts
More file actions
326 lines (284 loc) · 9.35 KB
/
Copy pathstore.ts
File metadata and controls
326 lines (284 loc) · 9.35 KB
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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
/**
* Credential store core implementation.
* AES-256-GCM encrypted credential storage with PBKDF2 key derivation.
*
* Storage: ~/.aibtc/credentials.json
* Encryption: AES-256-GCM, PBKDF2-SHA256 (100k iterations), per-credential salt + IV
* Dependencies: Node.js crypto module only — no external packages, no src/lib imports
*/
import crypto from "crypto";
import fs from "fs/promises";
import path from "path";
import os from "os";
import type {
CredentialStore,
EncryptedCredential,
CredentialMeta,
DecryptedCredential,
} from "./types.js";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const STORAGE_DIR = path.join(os.homedir(), ".aibtc");
const CREDENTIALS_FILE = path.join(STORAGE_DIR, "credentials.json");
const PBKDF2_ITERATIONS = 100_000;
const PBKDF2_KEY_LEN = 32; // 256 bits for AES-256
const PBKDF2_DIGEST = "sha256";
const IV_BYTES = 12; // GCM recommended IV length (96 bits)
const SALT_BYTES = 32; // 256-bit salt per credential
const STORE_VERSION = 1 as const;
// ---------------------------------------------------------------------------
// Key derivation
// ---------------------------------------------------------------------------
/**
* Derive a 32-byte AES-256 key from a password and base64-encoded salt.
* Uses PBKDF2-SHA256 with 100,000 iterations.
*/
function deriveKey(password: string, saltBase64: string): Buffer {
const salt = Buffer.from(saltBase64, "base64");
return crypto.pbkdf2Sync(
password,
salt,
PBKDF2_ITERATIONS,
PBKDF2_KEY_LEN,
PBKDF2_DIGEST
);
}
// ---------------------------------------------------------------------------
// Encrypt / Decrypt
// ---------------------------------------------------------------------------
/**
* Encrypt a plaintext value with AES-256-GCM.
* Generates a fresh random IV and salt for each call.
*
* Returns the base64-encoded encrypted, iv, salt, and tag fields
* suitable for merging into an EncryptedCredential.
*/
function encryptValue(
value: string,
password: string
): Pick<EncryptedCredential, "encrypted" | "iv" | "salt" | "tag"> {
const salt = crypto.randomBytes(SALT_BYTES);
const iv = crypto.randomBytes(IV_BYTES);
const saltBase64 = salt.toString("base64");
const key = deriveKey(password, saltBase64);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const encryptedBuf = Buffer.concat([
cipher.update(value, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return {
encrypted: encryptedBuf.toString("base64"),
iv: iv.toString("base64"),
salt: saltBase64,
tag: tag.toString("base64"),
};
}
/**
* Decrypt an EncryptedCredential's value using the provided password.
* Throws on wrong password or corrupted data (GCM auth tag mismatch).
*/
function decryptValue(cred: EncryptedCredential, password: string): string {
const key = deriveKey(password, cred.salt);
const iv = Buffer.from(cred.iv, "base64");
const ciphertext = Buffer.from(cred.encrypted, "base64");
const tag = Buffer.from(cred.tag, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
decipher.setAuthTag(tag);
try {
const decrypted = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]);
return decrypted.toString("utf8");
} catch {
throw new Error(
"Decryption failed — invalid password or corrupted credential data"
);
}
}
// ---------------------------------------------------------------------------
// Store I/O
// ---------------------------------------------------------------------------
/**
* Read the credential store from disk.
* Returns an empty store (version: 1, credentials: {}) if the file does not exist.
*/
export async function readStore(): Promise<CredentialStore> {
try {
const content = await fs.readFile(CREDENTIALS_FILE, "utf8");
return JSON.parse(content) as CredentialStore;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return { version: STORE_VERSION, credentials: {} };
}
throw err;
}
}
/**
* Write the credential store to disk atomically.
* Uses a temp file + rename to avoid partial writes.
* File is written with mode 0o600 (owner read/write only).
*/
async function writeStore(store: CredentialStore): Promise<void> {
// Ensure storage directory exists
await fs.mkdir(STORAGE_DIR, { recursive: true });
const tempFile = `${CREDENTIALS_FILE}.tmp`;
await fs.writeFile(tempFile, JSON.stringify(store, null, 2), {
mode: 0o600,
});
await fs.rename(tempFile, CREDENTIALS_FILE);
}
// ---------------------------------------------------------------------------
// ID normalization
// ---------------------------------------------------------------------------
/**
* Normalize a credential ID: lowercase, replace spaces/underscores with hyphens,
* strip any character that is not alphanumeric or a hyphen.
*/
export function normalizeId(id: string): string {
return id
.toLowerCase()
.replace(/[\s_]+/g, "-")
.replace(/[^a-z0-9-]/g, "");
}
// ---------------------------------------------------------------------------
// CRUD operations
// ---------------------------------------------------------------------------
/**
* Add or update a credential in the store.
* Encrypts the value with AES-256-GCM using the provided password.
*
* If a credential with the same normalized ID already exists, it is replaced.
* The createdAt timestamp is preserved on update; updatedAt is always set to now.
*/
export async function addCredential(
id: string,
value: string,
password: string,
label?: string,
category?: string
): Promise<EncryptedCredential> {
const normalId = normalizeId(id);
if (!normalId) {
throw new Error("Credential ID must contain at least one alphanumeric character");
}
const store = await readStore();
const existing = store.credentials[normalId];
const now = new Date().toISOString();
const encrypted = encryptValue(value, password);
const credential: EncryptedCredential = {
id: normalId,
label: label ?? existing?.label ?? normalId,
category: category ?? existing?.category ?? "secret",
...encrypted,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
store.credentials[normalId] = credential;
await writeStore(store);
return credential;
}
/**
* Retrieve and decrypt a credential by ID.
* Throws if the credential does not exist or the password is wrong.
*/
export async function getCredential(
id: string,
password: string
): Promise<DecryptedCredential> {
const normalId = normalizeId(id);
const store = await readStore();
const cred = store.credentials[normalId];
if (!cred) {
throw new Error(`Credential not found: ${normalId}`);
}
const value = decryptValue(cred, password);
return {
id: cred.id,
label: cred.label,
category: cred.category,
value,
createdAt: cred.createdAt,
updatedAt: cred.updatedAt,
};
}
/**
* List all credentials as metadata (no decryption, no sensitive values).
* Returns an array sorted by createdAt ascending.
*/
export async function listCredentials(): Promise<CredentialMeta[]> {
const store = await readStore();
return Object.values(store.credentials)
.map(({ id, label, category, createdAt, updatedAt }) => ({
id,
label,
category,
createdAt,
updatedAt,
}))
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
}
/**
* Delete a credential by ID.
* Verifies the password by successfully decrypting the credential first,
* then removes it from the store.
*
* Throws if the credential does not exist or the password is wrong.
*/
export async function deleteCredential(
id: string,
password: string
): Promise<void> {
const normalId = normalizeId(id);
const store = await readStore();
const cred = store.credentials[normalId];
if (!cred) {
throw new Error(`Credential not found: ${normalId}`);
}
// Verify password before deleting
decryptValue(cred, password);
delete store.credentials[normalId];
await writeStore(store);
}
/**
* Rotate the master password by re-encrypting all credentials.
* Decrypts every credential with the old password and re-encrypts with the new one.
* Writes atomically — if any step fails, the original store is preserved.
*
* Returns the count of credentials re-encrypted.
*/
export async function rotatePassword(
oldPassword: string,
newPassword: string
): Promise<number> {
if (!newPassword || newPassword.length < 8) {
throw new Error("New password must be at least 8 characters");
}
const store = await readStore();
const ids = Object.keys(store.credentials);
if (ids.length === 0) {
return 0;
}
// Decrypt all with old password first — validates oldPassword before mutating
const decrypted: Array<{ id: string; value: string }> = [];
for (const id of ids) {
const cred = store.credentials[id];
const value = decryptValue(cred, oldPassword); // throws on wrong password
decrypted.push({ id, value });
}
// Re-encrypt all with new password
const now = new Date().toISOString();
for (const { id, value } of decrypted) {
const cred = store.credentials[id];
const reEncrypted = encryptValue(value, newPassword);
store.credentials[id] = {
...cred,
...reEncrypted,
updatedAt: now,
};
}
await writeStore(store);
return ids.length;
}