memoir-cli 3.1.1 → 3.2.1

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.
@@ -1,7 +1,10 @@
1
1
  import crypto from 'crypto';
2
+ import { promisify } from 'util';
2
3
  import fs from 'fs-extra';
3
4
  import path from 'path';
4
5
 
6
+ const scryptAsync = promisify(crypto.scrypt);
7
+
5
8
  // --- Constants ---
6
9
  const ALGORITHM = 'aes-256-gcm';
7
10
  const IV_LENGTH = 12; // 96 bits, recommended for GCM
@@ -14,11 +17,11 @@ const MAGIC = Buffer.from('MEMOIR01'); // 8-byte header for format versioning
14
17
  // --- Key Derivation ---
15
18
 
16
19
  /**
17
- * Derive a 256-bit key from a passphrase using scrypt.
20
+ * Derive a 256-bit key from a passphrase using scrypt (async, non-blocking).
18
21
  */
19
- export function deriveKey(passphrase, salt = null) {
22
+ export async function deriveKey(passphrase, salt = null) {
20
23
  if (!salt) salt = crypto.randomBytes(SALT_LENGTH);
21
- const key = crypto.scryptSync(passphrase, salt, KEY_LENGTH, {
24
+ const key = await scryptAsync(passphrase, salt, KEY_LENGTH, {
22
25
  N: SCRYPT_COST,
23
26
  r: 8,
24
27
  p: 1,
@@ -32,8 +35,8 @@ export function deriveKey(passphrase, salt = null) {
32
35
  * Encrypt a buffer with AES-256-GCM.
33
36
  * Output format: MEMOIR01 | salt (32) | iv (12) | authTag (16) | ciphertext
34
37
  */
35
- export function encryptBuffer(plaintext, passphrase) {
36
- const { key, salt } = deriveKey(passphrase);
38
+ export async function encryptBuffer(plaintext, passphrase) {
39
+ const { key, salt } = await deriveKey(passphrase);
37
40
  const iv = crypto.randomBytes(IV_LENGTH);
38
41
 
39
42
  const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
@@ -46,7 +49,7 @@ export function encryptBuffer(plaintext, passphrase) {
46
49
  /**
47
50
  * Decrypt a buffer. Throws on wrong passphrase or tampered data.
48
51
  */
49
- export function decryptBuffer(data, passphrase) {
52
+ export async function decryptBuffer(data, passphrase) {
50
53
  const magic = data.subarray(0, 8);
51
54
  if (!magic.equals(MAGIC)) {
52
55
  throw new Error('Not a memoir-encrypted file (bad header)');
@@ -58,7 +61,7 @@ export function decryptBuffer(data, passphrase) {
58
61
  const tag = data.subarray(offset, offset + TAG_LENGTH); offset += TAG_LENGTH;
59
62
  const ciphertext = data.subarray(offset);
60
63
 
61
- const { key } = deriveKey(passphrase, salt);
64
+ const { key } = await deriveKey(passphrase, salt);
62
65
 
63
66
  const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
64
67
  decipher.setAuthTag(tag);
@@ -72,76 +75,116 @@ export function decryptBuffer(data, passphrase) {
72
75
  * Encrypt all files in srcDir → destDir.
73
76
  * File names are HMAC-hashed (hidden). Manifest maps hashes → real paths.
74
77
  */
75
- export async function encryptDirectory(srcDir, destDir, passphrase) {
76
- const { key, salt } = deriveKey(passphrase);
78
+ export async function encryptDirectory(srcDir, destDir, passphrase, spinner = null) {
79
+ const startTime = Date.now();
80
+
81
+ // Phase 1: Derive encryption key
82
+ if (spinner) spinner.text = 'Deriving encryption key (scrypt)...';
83
+ const { key, salt } = await deriveKey(passphrase);
84
+
77
85
  const dataDir = path.join(destDir, 'data');
78
86
  await fs.ensureDir(dataDir);
79
87
 
80
- const manifest = {};
81
- let count = 0;
82
-
83
- async function walk(dir, relBase = '') {
88
+ // Phase 2: Index files
89
+ if (spinner) spinner.text = 'Indexing files...';
90
+ const fileList = [];
91
+ async function index(dir, relBase = '') {
84
92
  const entries = await fs.readdir(dir, { withFileTypes: true });
85
93
  for (const entry of entries) {
86
94
  const fullPath = path.join(dir, entry.name);
87
95
  const relPath = path.join(relBase, entry.name);
88
96
  if (entry.isDirectory()) {
89
- await walk(fullPath, relPath);
97
+ await index(fullPath, relPath);
90
98
  } else {
91
- // Hash filename so it's opaque
92
- const hashedName = crypto
93
- .createHmac('sha256', key)
94
- .update(relPath)
95
- .digest('hex')
96
- .slice(0, 24);
97
-
98
- const plaintext = await fs.readFile(fullPath);
99
- const iv = crypto.randomBytes(IV_LENGTH);
100
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
101
- const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
102
- const tag = cipher.getAuthTag();
103
-
104
- // Write: iv | tag | ciphertext (salt shared via manifest file)
105
- await fs.writeFile(
106
- path.join(dataDir, `${hashedName}.enc`),
107
- Buffer.concat([iv, tag, encrypted])
108
- );
109
-
110
- manifest[hashedName] = relPath;
111
- count++;
99
+ const stat = await fs.stat(fullPath);
100
+ fileList.push({ fullPath, relPath, size: stat.size });
112
101
  }
113
102
  }
114
103
  }
104
+ await index(srcDir);
105
+
106
+ const totalFiles = fileList.length;
107
+ const totalBytes = fileList.reduce((sum, f) => sum + f.size, 0);
108
+
109
+ // Phase 3: Encrypt files
110
+ const manifest = {};
111
+ let count = 0;
112
+ let bytesProcessed = 0;
113
+
114
+ for (const { fullPath, relPath, size } of fileList) {
115
+ const hashedName = crypto
116
+ .createHmac('sha256', key)
117
+ .update(relPath)
118
+ .digest('hex')
119
+ .slice(0, 24);
120
+
121
+ const plaintext = await fs.readFile(fullPath);
122
+ const iv = crypto.randomBytes(IV_LENGTH);
123
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
124
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
125
+ const tag = cipher.getAuthTag();
126
+
127
+ await fs.writeFile(
128
+ path.join(dataDir, `${hashedName}.enc`),
129
+ Buffer.concat([iv, tag, encrypted])
130
+ );
131
+
132
+ manifest[hashedName] = relPath;
133
+ count++;
134
+ bytesProcessed += size;
115
135
 
116
- await walk(srcDir);
136
+ if (spinner) {
137
+ const pct = Math.round((count / totalFiles) * 100);
138
+ const sizeStr = formatBytes(bytesProcessed);
139
+ const totalStr = formatBytes(totalBytes);
140
+ spinner.text = `Encrypting (AES-256-GCM) ${count}/${totalFiles} files — ${sizeStr}/${totalStr} [${pct}%]`;
141
+ }
142
+ }
117
143
 
118
- // Encrypt the manifest (it contains real file names)
144
+ // Phase 4: Encrypt manifest
145
+ if (spinner) spinner.text = 'Encrypting file manifest...';
119
146
  const manifestJson = Buffer.from(JSON.stringify(manifest));
120
- const manifestEncrypted = encryptBuffer(manifestJson, passphrase);
147
+ const manifestEncrypted = await encryptBuffer(manifestJson, passphrase);
121
148
  await fs.writeFile(path.join(destDir, 'manifest.enc'), manifestEncrypted);
122
149
 
123
150
  // Salt is not secret — store it so decrypt can re-derive the same key
124
151
  await fs.writeFile(path.join(destDir, 'salt'), salt);
125
152
 
153
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
154
+ if (spinner) spinner.text = `Encrypted ${count} files (${formatBytes(totalBytes)}) in ${elapsed}s`;
155
+
126
156
  return count;
127
157
  }
128
158
 
159
+ function formatBytes(bytes) {
160
+ if (bytes < 1024) return `${bytes} B`;
161
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
162
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
163
+ }
164
+
129
165
  /**
130
166
  * Decrypt an encrypted directory back to plaintext.
131
167
  */
132
- export async function decryptDirectory(encDir, destDir, passphrase) {
133
- // Decrypt manifest first
168
+ export async function decryptDirectory(encDir, destDir, passphrase, spinner = null) {
169
+ const startTime = Date.now();
170
+
171
+ // Phase 1: Decrypt manifest
172
+ if (spinner) spinner.text = 'Decrypting file manifest...';
134
173
  const manifestData = await fs.readFile(path.join(encDir, 'manifest.enc'));
135
- const manifestJson = decryptBuffer(manifestData, passphrase);
174
+ const manifestJson = await decryptBuffer(manifestData, passphrase);
136
175
  const manifest = JSON.parse(manifestJson.toString('utf8'));
137
176
 
138
- // Re-derive key with stored salt
177
+ // Phase 2: Derive key
178
+ if (spinner) spinner.text = 'Deriving decryption key (scrypt)...';
139
179
  const salt = await fs.readFile(path.join(encDir, 'salt'));
140
- const { key } = deriveKey(passphrase, salt);
180
+ const { key } = await deriveKey(passphrase, salt);
141
181
 
142
182
  const dataDir = path.join(encDir, 'data');
183
+ const totalFiles = Object.keys(manifest).length;
143
184
  let count = 0;
185
+ let bytesProcessed = 0;
144
186
 
187
+ // Phase 3: Decrypt files
145
188
  for (const [hashedName, relPath] of Object.entries(manifest)) {
146
189
  const encFilePath = path.join(dataDir, `${hashedName}.enc`);
147
190
  if (!(await fs.pathExists(encFilePath))) continue;
@@ -159,8 +202,17 @@ export async function decryptDirectory(encDir, destDir, passphrase) {
159
202
  await fs.ensureDir(path.dirname(outPath));
160
203
  await fs.writeFile(outPath, decrypted);
161
204
  count++;
205
+ bytesProcessed += decrypted.length;
206
+
207
+ if (spinner) {
208
+ const pct = Math.round((count / totalFiles) * 100);
209
+ spinner.text = `Decrypting ${count}/${totalFiles} files — ${formatBytes(bytesProcessed)} [${pct}%]`;
210
+ }
162
211
  }
163
212
 
213
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
214
+ if (spinner) spinner.text = `Decrypted ${count} files (${formatBytes(bytesProcessed)}) in ${elapsed}s`;
215
+
164
216
  return count;
165
217
  }
166
218
 
@@ -168,13 +220,13 @@ export async function decryptDirectory(encDir, destDir, passphrase) {
168
220
  * Quick passphrase verification token — encrypt a known string,
169
221
  * try to decrypt it to check if passphrase is correct before decrypting everything.
170
222
  */
171
- export function createVerifyToken(passphrase) {
223
+ export async function createVerifyToken(passphrase) {
172
224
  return encryptBuffer(Buffer.from('memoir-ok'), passphrase);
173
225
  }
174
226
 
175
- export function verifyPassphrase(token, passphrase) {
227
+ export async function verifyPassphrase(token, passphrase) {
176
228
  try {
177
- const result = decryptBuffer(token, passphrase);
229
+ const result = await decryptBuffer(token, passphrase);
178
230
  return result.toString('utf8') === 'memoir-ok';
179
231
  } catch {
180
232
  return false;
@@ -293,23 +293,23 @@ async function getProjectInfo(dir) {
293
293
  info.hasGit = true;
294
294
  try {
295
295
  const remote = execFileSync('git', ['remote', 'get-url', 'origin'], {
296
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
296
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
297
297
  }).toString().trim();
298
298
  info.gitRemote = remote;
299
299
  } catch {}
300
300
 
301
301
  try {
302
302
  info.branch = execFileSync('git', ['branch', '--show-current'], {
303
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
303
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
304
304
  }).toString().trim();
305
305
  } catch {}
306
306
 
307
307
  try {
308
308
  info.lastCommit = execFileSync('git', ['log', '-1', '--format=%H'], {
309
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
309
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
310
310
  }).toString().trim();
311
311
  info.lastCommitMessage = execFileSync('git', ['log', '-1', '--format=%s'], {
312
- cwd: dir, stdio: ['pipe', 'pipe', 'ignore']
312
+ cwd: dir, stdio: ['pipe', 'pipe', 'ignore'], timeout: 5000
313
313
  }).toString().trim();
314
314
  } catch {}
315
315