mdenc 0.1.3 → 2.0.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 +10 -11
- package/dist/cli.js +1007 -372
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +144 -126
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -4
- package/dist/index.d.ts +2 -4
- package/dist/index.js +144 -126
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/dist/chunk-DQSJGHST.js +0 -688
- package/dist/chunk-DQSJGHST.js.map +0 -1
- package/dist/hooks-7DUHI6MG.js +0 -16
- package/dist/hooks-7DUHI6MG.js.map +0 -1
package/dist/cli.js
CHANGED
|
@@ -1,35 +1,404 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
constantTimeEqual,
|
|
4
|
-
decrypt,
|
|
5
|
-
deriveKeys,
|
|
6
|
-
deriveMasterKey,
|
|
7
|
-
encrypt,
|
|
8
|
-
findGitRoot,
|
|
9
|
-
findMarkedDirs,
|
|
10
|
-
fromBase64,
|
|
11
|
-
getHooksDir,
|
|
12
|
-
getMdFilesInDir,
|
|
13
|
-
getMdencFilesInDir,
|
|
14
|
-
gitAdd,
|
|
15
|
-
gitRmCached,
|
|
16
|
-
isFileTracked,
|
|
17
|
-
parseHeader,
|
|
18
|
-
postCheckoutHook,
|
|
19
|
-
postMergeHook,
|
|
20
|
-
postRewriteHook,
|
|
21
|
-
preCommitHook,
|
|
22
|
-
resolvePassword,
|
|
23
|
-
verifyHeader,
|
|
24
|
-
zeroize
|
|
25
|
-
} from "./chunk-DQSJGHST.js";
|
|
26
2
|
|
|
27
3
|
// src/cli.ts
|
|
28
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
|
|
29
5
|
|
|
30
|
-
// src/
|
|
6
|
+
// src/crypto/encrypt.ts
|
|
7
|
+
import { hmac as hmac3 } from "@noble/hashes/hmac";
|
|
8
|
+
import { sha256 as sha2564 } from "@noble/hashes/sha256";
|
|
9
|
+
|
|
10
|
+
// src/crypto/aead.ts
|
|
11
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
|
31
12
|
import { hmac } from "@noble/hashes/hmac";
|
|
32
13
|
import { sha256 } from "@noble/hashes/sha256";
|
|
14
|
+
var NONCE_LENGTH = 24;
|
|
15
|
+
function buildAAD(fileId) {
|
|
16
|
+
const fileIdHex = bytesToHex(fileId);
|
|
17
|
+
const aadString = `mdenc:v1
|
|
18
|
+
${fileIdHex}`;
|
|
19
|
+
return new TextEncoder().encode(aadString);
|
|
20
|
+
}
|
|
21
|
+
function deriveNonce(nonceKey, plaintext) {
|
|
22
|
+
const full = hmac(sha256, nonceKey, plaintext);
|
|
23
|
+
return full.slice(0, NONCE_LENGTH);
|
|
24
|
+
}
|
|
25
|
+
function encryptChunk(encKey, nonceKey, plaintext, fileId) {
|
|
26
|
+
const nonce = deriveNonce(nonceKey, plaintext);
|
|
27
|
+
const aad = buildAAD(fileId);
|
|
28
|
+
const cipher = xchacha20poly1305(encKey, nonce, aad);
|
|
29
|
+
const ciphertext = cipher.encrypt(plaintext);
|
|
30
|
+
const result = new Uint8Array(NONCE_LENGTH + ciphertext.length);
|
|
31
|
+
result.set(nonce, 0);
|
|
32
|
+
result.set(ciphertext, NONCE_LENGTH);
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
function decryptChunk(encKey, payload, fileId) {
|
|
36
|
+
if (payload.length < NONCE_LENGTH + 16) {
|
|
37
|
+
throw new Error("Chunk payload too short");
|
|
38
|
+
}
|
|
39
|
+
const nonce = payload.slice(0, NONCE_LENGTH);
|
|
40
|
+
const ciphertext = payload.slice(NONCE_LENGTH);
|
|
41
|
+
const aad = buildAAD(fileId);
|
|
42
|
+
const cipher = xchacha20poly1305(encKey, nonce, aad);
|
|
43
|
+
try {
|
|
44
|
+
return cipher.decrypt(ciphertext);
|
|
45
|
+
} catch {
|
|
46
|
+
throw new Error("Chunk authentication failed");
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function bytesToHex(bytes) {
|
|
50
|
+
let hex = "";
|
|
51
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
52
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
53
|
+
}
|
|
54
|
+
return hex;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/crypto/chunking.ts
|
|
58
|
+
var DEFAULT_MAX_CHUNK_SIZE = 65536;
|
|
59
|
+
function chunkByParagraph(text, maxSize = DEFAULT_MAX_CHUNK_SIZE) {
|
|
60
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
61
|
+
if (normalized.length === 0) {
|
|
62
|
+
return [""];
|
|
63
|
+
}
|
|
64
|
+
const chunks = [];
|
|
65
|
+
const boundary = /\n{2,}/g;
|
|
66
|
+
let lastEnd = 0;
|
|
67
|
+
let match;
|
|
68
|
+
while ((match = boundary.exec(normalized)) !== null) {
|
|
69
|
+
const chunkEnd = match.index + match[0].length;
|
|
70
|
+
chunks.push(normalized.slice(lastEnd, chunkEnd));
|
|
71
|
+
lastEnd = chunkEnd;
|
|
72
|
+
}
|
|
73
|
+
if (lastEnd < normalized.length) {
|
|
74
|
+
chunks.push(normalized.slice(lastEnd));
|
|
75
|
+
} else if (chunks.length === 0) {
|
|
76
|
+
chunks.push(normalized);
|
|
77
|
+
}
|
|
78
|
+
const result = [];
|
|
79
|
+
for (const chunk of chunks) {
|
|
80
|
+
if (byteLength(chunk) <= maxSize) {
|
|
81
|
+
result.push(chunk);
|
|
82
|
+
} else {
|
|
83
|
+
result.push(...splitAtByteLimit(chunk, maxSize));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
function chunkByFixedSize(text, size) {
|
|
89
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
90
|
+
if (normalized.length === 0) {
|
|
91
|
+
return [""];
|
|
92
|
+
}
|
|
93
|
+
const bytes = new TextEncoder().encode(normalized);
|
|
94
|
+
if (bytes.length <= size) {
|
|
95
|
+
return [normalized];
|
|
96
|
+
}
|
|
97
|
+
const chunks = [];
|
|
98
|
+
const decoder = new TextDecoder();
|
|
99
|
+
let offset = 0;
|
|
100
|
+
while (offset < bytes.length) {
|
|
101
|
+
const end = Math.min(offset + size, bytes.length);
|
|
102
|
+
let adjusted = end;
|
|
103
|
+
if (adjusted < bytes.length) {
|
|
104
|
+
while (adjusted > offset && (bytes[adjusted] & 192) === 128) {
|
|
105
|
+
adjusted--;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
chunks.push(decoder.decode(bytes.slice(offset, adjusted)));
|
|
109
|
+
offset = adjusted;
|
|
110
|
+
}
|
|
111
|
+
return chunks;
|
|
112
|
+
}
|
|
113
|
+
function byteLength(s) {
|
|
114
|
+
return new TextEncoder().encode(s).length;
|
|
115
|
+
}
|
|
116
|
+
function splitAtByteLimit(text, maxSize) {
|
|
117
|
+
const bytes = new TextEncoder().encode(text);
|
|
118
|
+
const decoder = new TextDecoder();
|
|
119
|
+
const parts = [];
|
|
120
|
+
let offset = 0;
|
|
121
|
+
while (offset < bytes.length) {
|
|
122
|
+
let end = Math.min(offset + maxSize, bytes.length);
|
|
123
|
+
if (end < bytes.length) {
|
|
124
|
+
while (end > offset && (bytes[end] & 192) === 128) {
|
|
125
|
+
end--;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
parts.push(decoder.decode(bytes.slice(offset, end)));
|
|
129
|
+
offset = end;
|
|
130
|
+
}
|
|
131
|
+
return parts;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/crypto/crypto-utils.ts
|
|
135
|
+
function constantTimeEqual(a, b) {
|
|
136
|
+
if (a.length !== b.length) return false;
|
|
137
|
+
let diff = 0;
|
|
138
|
+
for (let i = 0; i < a.length; i++) {
|
|
139
|
+
diff |= a[i] ^ b[i];
|
|
140
|
+
}
|
|
141
|
+
return diff === 0;
|
|
142
|
+
}
|
|
143
|
+
function zeroize(...arrays) {
|
|
144
|
+
for (const arr of arrays) {
|
|
145
|
+
arr.fill(0);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/crypto/header.ts
|
|
150
|
+
import { randomBytes } from "@noble/ciphers/webcrypto";
|
|
151
|
+
import { hmac as hmac2 } from "@noble/hashes/hmac";
|
|
152
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha256";
|
|
153
|
+
|
|
154
|
+
// src/crypto/types.ts
|
|
155
|
+
var DEFAULT_SCRYPT_PARAMS = {
|
|
156
|
+
N: 16384,
|
|
157
|
+
r: 8,
|
|
158
|
+
p: 1
|
|
159
|
+
};
|
|
160
|
+
var SCRYPT_BOUNDS = {
|
|
161
|
+
N: { min: 1024, max: 1048576 },
|
|
162
|
+
// 2^10 – 2^20
|
|
163
|
+
r: { min: 1, max: 64 },
|
|
164
|
+
p: { min: 1, max: 16 }
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// src/crypto/header.ts
|
|
168
|
+
function generateSalt() {
|
|
169
|
+
return randomBytes(16);
|
|
170
|
+
}
|
|
171
|
+
function generateFileId() {
|
|
172
|
+
return randomBytes(16);
|
|
173
|
+
}
|
|
174
|
+
function serializeHeader(header) {
|
|
175
|
+
const saltB64 = toBase64(header.salt);
|
|
176
|
+
const fileIdB64 = toBase64(header.fileId);
|
|
177
|
+
const { N, r, p } = header.scrypt;
|
|
178
|
+
return `mdenc:v1 salt_b64=${saltB64} file_id_b64=${fileIdB64} scrypt=N=${N},r=${r},p=${p}`;
|
|
179
|
+
}
|
|
180
|
+
function parseHeader(line) {
|
|
181
|
+
if (!line.startsWith("mdenc:v1 ")) {
|
|
182
|
+
throw new Error("Invalid header: missing mdenc:v1 prefix");
|
|
183
|
+
}
|
|
184
|
+
const saltMatch = line.match(/salt_b64=([A-Za-z0-9+/=]+)/);
|
|
185
|
+
if (!saltMatch?.[1]) throw new Error("Invalid header: missing salt_b64");
|
|
186
|
+
const salt = fromBase64(saltMatch[1]);
|
|
187
|
+
if (salt.length !== 16) throw new Error("Invalid header: salt must be 16 bytes");
|
|
188
|
+
const fileIdMatch = line.match(/file_id_b64=([A-Za-z0-9+/=]+)/);
|
|
189
|
+
if (!fileIdMatch?.[1]) throw new Error("Invalid header: missing file_id_b64");
|
|
190
|
+
const fileId = fromBase64(fileIdMatch[1]);
|
|
191
|
+
if (fileId.length !== 16) throw new Error("Invalid header: file_id must be 16 bytes");
|
|
192
|
+
const scryptMatch = line.match(/scrypt=N=(\d+),r=(\d+),p=(\d+)/);
|
|
193
|
+
if (!scryptMatch?.[1] || !scryptMatch[2] || !scryptMatch[3])
|
|
194
|
+
throw new Error("Invalid header: missing scrypt parameters");
|
|
195
|
+
const scryptParams = {
|
|
196
|
+
N: parseInt(scryptMatch[1], 10),
|
|
197
|
+
r: parseInt(scryptMatch[2], 10),
|
|
198
|
+
p: parseInt(scryptMatch[3], 10)
|
|
199
|
+
};
|
|
200
|
+
validateScryptParams(scryptParams);
|
|
201
|
+
return { version: "v1", salt, fileId, scrypt: scryptParams };
|
|
202
|
+
}
|
|
203
|
+
function validateScryptParams(params) {
|
|
204
|
+
const { N, r, p } = SCRYPT_BOUNDS;
|
|
205
|
+
if (params.N < N.min || params.N > N.max) {
|
|
206
|
+
throw new Error(`Invalid scrypt N: ${params.N} (must be ${N.min}\u2013${N.max})`);
|
|
207
|
+
}
|
|
208
|
+
if ((params.N & params.N - 1) !== 0) {
|
|
209
|
+
throw new Error(`Invalid scrypt N: ${params.N} (must be a power of 2)`);
|
|
210
|
+
}
|
|
211
|
+
if (params.r < r.min || params.r > r.max) {
|
|
212
|
+
throw new Error(`Invalid scrypt r: ${params.r} (must be ${r.min}\u2013${r.max})`);
|
|
213
|
+
}
|
|
214
|
+
if (params.p < p.min || params.p > p.max) {
|
|
215
|
+
throw new Error(`Invalid scrypt p: ${params.p} (must be ${p.min}\u2013${p.max})`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function authenticateHeader(headerKey, headerLine) {
|
|
219
|
+
const headerBytes = new TextEncoder().encode(headerLine);
|
|
220
|
+
return hmac2(sha2562, headerKey, headerBytes);
|
|
221
|
+
}
|
|
222
|
+
function verifyHeader(headerKey, headerLine, hmacBytes) {
|
|
223
|
+
const computed = authenticateHeader(headerKey, headerLine);
|
|
224
|
+
return constantTimeEqual(computed, hmacBytes);
|
|
225
|
+
}
|
|
226
|
+
function toBase64(bytes) {
|
|
227
|
+
let binary = "";
|
|
228
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
229
|
+
binary += String.fromCharCode(bytes[i]);
|
|
230
|
+
}
|
|
231
|
+
return btoa(binary);
|
|
232
|
+
}
|
|
233
|
+
function fromBase64(b64) {
|
|
234
|
+
const binary = atob(b64);
|
|
235
|
+
const bytes = new Uint8Array(binary.length);
|
|
236
|
+
for (let i = 0; i < binary.length; i++) {
|
|
237
|
+
bytes[i] = binary.charCodeAt(i);
|
|
238
|
+
}
|
|
239
|
+
return bytes;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/crypto/kdf.ts
|
|
243
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
244
|
+
import { scrypt } from "@noble/hashes/scrypt";
|
|
245
|
+
import { sha256 as sha2563 } from "@noble/hashes/sha256";
|
|
246
|
+
var ENC_INFO = new TextEncoder().encode("mdenc-v1-enc");
|
|
247
|
+
var HDR_INFO = new TextEncoder().encode("mdenc-v1-hdr");
|
|
248
|
+
var NONCE_INFO = new TextEncoder().encode("mdenc-v1-nonce");
|
|
249
|
+
function normalizePassword(password) {
|
|
250
|
+
const normalized = password.normalize("NFKC");
|
|
251
|
+
return new TextEncoder().encode(normalized);
|
|
252
|
+
}
|
|
253
|
+
function deriveMasterKey(password, salt, params = DEFAULT_SCRYPT_PARAMS) {
|
|
254
|
+
const passwordBytes = normalizePassword(password);
|
|
255
|
+
try {
|
|
256
|
+
return scrypt(passwordBytes, salt, {
|
|
257
|
+
N: params.N,
|
|
258
|
+
r: params.r,
|
|
259
|
+
p: params.p,
|
|
260
|
+
dkLen: 32
|
|
261
|
+
});
|
|
262
|
+
} finally {
|
|
263
|
+
zeroize(passwordBytes);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function deriveKeys(masterKey) {
|
|
267
|
+
const encKey = hkdf(sha2563, masterKey, void 0, ENC_INFO, 32);
|
|
268
|
+
const headerKey = hkdf(sha2563, masterKey, void 0, HDR_INFO, 32);
|
|
269
|
+
const nonceKey = hkdf(sha2563, masterKey, void 0, NONCE_INFO, 32);
|
|
270
|
+
return { encKey, headerKey, nonceKey };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// src/crypto/encrypt.ts
|
|
274
|
+
async function encrypt(plaintext, password, options) {
|
|
275
|
+
const chunking = options?.chunking ?? "paragraph" /* Paragraph */;
|
|
276
|
+
const maxChunkSize = options?.maxChunkSize ?? 65536;
|
|
277
|
+
const scryptParams = options?.scrypt ?? DEFAULT_SCRYPT_PARAMS;
|
|
278
|
+
let chunks;
|
|
279
|
+
if (chunking === "fixed-size" /* FixedSize */) {
|
|
280
|
+
const fixedSize = options?.fixedChunkSize ?? 4096;
|
|
281
|
+
chunks = chunkByFixedSize(plaintext, fixedSize);
|
|
282
|
+
} else {
|
|
283
|
+
chunks = chunkByParagraph(plaintext, maxChunkSize);
|
|
284
|
+
}
|
|
285
|
+
let salt;
|
|
286
|
+
let fileId;
|
|
287
|
+
let masterKey;
|
|
288
|
+
const prev = options?.previousFile ? parsePreviousFileHeader(options.previousFile, password) : void 0;
|
|
289
|
+
if (prev) {
|
|
290
|
+
salt = prev.salt;
|
|
291
|
+
fileId = prev.fileId;
|
|
292
|
+
masterKey = prev.masterKey;
|
|
293
|
+
} else {
|
|
294
|
+
salt = generateSalt();
|
|
295
|
+
fileId = generateFileId();
|
|
296
|
+
masterKey = deriveMasterKey(password, salt, scryptParams);
|
|
297
|
+
}
|
|
298
|
+
const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
|
|
299
|
+
try {
|
|
300
|
+
const header = { version: "v1", salt, fileId, scrypt: scryptParams };
|
|
301
|
+
const headerLine = serializeHeader(header);
|
|
302
|
+
const headerHmac = authenticateHeader(headerKey, headerLine);
|
|
303
|
+
const headerAuthLine = `hdrauth_b64=${toBase64(headerHmac)}`;
|
|
304
|
+
const chunkLines = [];
|
|
305
|
+
for (const chunkText of chunks) {
|
|
306
|
+
const chunkBytes = new TextEncoder().encode(chunkText);
|
|
307
|
+
const payload = encryptChunk(encKey, nonceKey, chunkBytes, fileId);
|
|
308
|
+
chunkLines.push(toBase64(payload));
|
|
309
|
+
}
|
|
310
|
+
const sealInput = `${headerLine}
|
|
311
|
+
${headerAuthLine}
|
|
312
|
+
${chunkLines.join("\n")}`;
|
|
313
|
+
const sealData = new TextEncoder().encode(sealInput);
|
|
314
|
+
const sealHmac = hmac3(sha2564, headerKey, sealData);
|
|
315
|
+
const sealLine = `seal_b64=${toBase64(sealHmac)}`;
|
|
316
|
+
return [headerLine, headerAuthLine, ...chunkLines, sealLine, ""].join("\n");
|
|
317
|
+
} finally {
|
|
318
|
+
zeroize(masterKey, encKey, headerKey, nonceKey);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async function decrypt(fileContent, password) {
|
|
322
|
+
const lines = fileContent.split("\n");
|
|
323
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
324
|
+
lines.pop();
|
|
325
|
+
}
|
|
326
|
+
if (lines.length < 3) {
|
|
327
|
+
throw new Error("Invalid mdenc file: too few lines");
|
|
328
|
+
}
|
|
329
|
+
const headerLine = lines[0];
|
|
330
|
+
const header = parseHeader(headerLine);
|
|
331
|
+
const authLine = lines[1];
|
|
332
|
+
const authMatch = authLine.match(/^hdrauth_b64=([A-Za-z0-9+/=]+)$/);
|
|
333
|
+
if (!authMatch?.[1]) {
|
|
334
|
+
throw new Error("Invalid mdenc file: missing hdrauth_b64 line");
|
|
335
|
+
}
|
|
336
|
+
const headerHmac = fromBase64(authMatch[1]);
|
|
337
|
+
const masterKey = deriveMasterKey(password, header.salt, header.scrypt);
|
|
338
|
+
const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
|
|
339
|
+
try {
|
|
340
|
+
if (!verifyHeader(headerKey, headerLine, headerHmac)) {
|
|
341
|
+
throw new Error("Header authentication failed (wrong password or tampered header)");
|
|
342
|
+
}
|
|
343
|
+
const remaining = lines.slice(2);
|
|
344
|
+
const sealIndex = remaining.findIndex((l) => l.startsWith("seal_b64="));
|
|
345
|
+
if (sealIndex < 0) {
|
|
346
|
+
throw new Error("Invalid mdenc file: missing seal");
|
|
347
|
+
}
|
|
348
|
+
const chunkLines = remaining.slice(0, sealIndex);
|
|
349
|
+
if (chunkLines.length === 0) {
|
|
350
|
+
throw new Error("Invalid mdenc file: no chunk lines");
|
|
351
|
+
}
|
|
352
|
+
const sealLine = remaining[sealIndex];
|
|
353
|
+
const sealMatch = sealLine.match(/^seal_b64=([A-Za-z0-9+/=]+)$/);
|
|
354
|
+
if (!sealMatch?.[1]) throw new Error("Invalid mdenc file: malformed seal line");
|
|
355
|
+
const storedSealHmac = fromBase64(sealMatch[1]);
|
|
356
|
+
const sealInput = `${headerLine}
|
|
357
|
+
${authLine}
|
|
358
|
+
${chunkLines.join("\n")}`;
|
|
359
|
+
const sealData = new TextEncoder().encode(sealInput);
|
|
360
|
+
const computedSealHmac = hmac3(sha2564, headerKey, sealData);
|
|
361
|
+
if (!constantTimeEqual(computedSealHmac, storedSealHmac)) {
|
|
362
|
+
throw new Error("Seal verification failed (file tampered or chunks reordered)");
|
|
363
|
+
}
|
|
364
|
+
const plaintextParts = [];
|
|
365
|
+
for (const line of chunkLines) {
|
|
366
|
+
const payload = fromBase64(line);
|
|
367
|
+
const decrypted = decryptChunk(encKey, payload, header.fileId);
|
|
368
|
+
plaintextParts.push(new TextDecoder().decode(decrypted));
|
|
369
|
+
}
|
|
370
|
+
return plaintextParts.join("");
|
|
371
|
+
} finally {
|
|
372
|
+
zeroize(masterKey, encKey, headerKey, nonceKey);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function parsePreviousFileHeader(fileContent, password) {
|
|
376
|
+
try {
|
|
377
|
+
const lines = fileContent.split("\n");
|
|
378
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
379
|
+
if (lines.length < 3) return void 0;
|
|
380
|
+
const headerLine = lines[0];
|
|
381
|
+
const header = parseHeader(headerLine);
|
|
382
|
+
const authLine = lines[1];
|
|
383
|
+
const authMatch = authLine.match(/^hdrauth_b64=([A-Za-z0-9+/=]+)$/);
|
|
384
|
+
if (!authMatch?.[1]) return void 0;
|
|
385
|
+
const headerHmac = fromBase64(authMatch[1]);
|
|
386
|
+
const masterKey = deriveMasterKey(password, header.salt, header.scrypt);
|
|
387
|
+
const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
|
|
388
|
+
if (!verifyHeader(headerKey, headerLine, headerHmac)) {
|
|
389
|
+
zeroize(masterKey, encKey, headerKey, nonceKey);
|
|
390
|
+
return void 0;
|
|
391
|
+
}
|
|
392
|
+
zeroize(encKey, headerKey, nonceKey);
|
|
393
|
+
return { salt: header.salt, fileId: header.fileId, masterKey };
|
|
394
|
+
} catch {
|
|
395
|
+
return void 0;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/crypto/seal.ts
|
|
400
|
+
import { hmac as hmac4 } from "@noble/hashes/hmac";
|
|
401
|
+
import { sha256 as sha2565 } from "@noble/hashes/sha256";
|
|
33
402
|
async function verifySeal(fileContent, password) {
|
|
34
403
|
const lines = fileContent.split("\n");
|
|
35
404
|
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
@@ -38,10 +407,10 @@ async function verifySeal(fileContent, password) {
|
|
|
38
407
|
const header = parseHeader(headerLine);
|
|
39
408
|
const authLine = lines[1];
|
|
40
409
|
const authMatch = authLine.match(/^hdrauth_b64=([A-Za-z0-9+/=]+)$/);
|
|
41
|
-
if (!authMatch) throw new Error("Invalid mdenc file: missing hdrauth_b64 line");
|
|
410
|
+
if (!authMatch?.[1]) throw new Error("Invalid mdenc file: missing hdrauth_b64 line");
|
|
42
411
|
const headerHmac = fromBase64(authMatch[1]);
|
|
43
412
|
const masterKey = deriveMasterKey(password, header.salt, header.scrypt);
|
|
44
|
-
const { headerKey, nonceKey } = deriveKeys(masterKey);
|
|
413
|
+
const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
|
|
45
414
|
try {
|
|
46
415
|
if (!verifyHeader(headerKey, headerLine, headerHmac)) {
|
|
47
416
|
throw new Error("Header authentication failed");
|
|
@@ -52,194 +421,536 @@ async function verifySeal(fileContent, password) {
|
|
|
52
421
|
throw new Error("File is not sealed: no seal_b64 line found");
|
|
53
422
|
}
|
|
54
423
|
const chunkLines = chunkAndSealLines.slice(0, sealIndex);
|
|
55
|
-
const
|
|
56
|
-
|
|
424
|
+
const sealLine = chunkAndSealLines[sealIndex];
|
|
425
|
+
const sealMatch = sealLine.match(/^seal_b64=([A-Za-z0-9+/=]+)$/);
|
|
426
|
+
if (!sealMatch?.[1]) throw new Error("Invalid seal line");
|
|
57
427
|
const storedHmac = fromBase64(sealMatch[1]);
|
|
58
|
-
const sealInput = headerLine
|
|
428
|
+
const sealInput = `${headerLine}
|
|
429
|
+
${authLine}
|
|
430
|
+
${chunkLines.join("\n")}`;
|
|
59
431
|
const sealData = new TextEncoder().encode(sealInput);
|
|
60
|
-
const computed =
|
|
432
|
+
const computed = hmac4(sha2565, headerKey, sealData);
|
|
61
433
|
return constantTimeEqual(computed, storedHmac);
|
|
62
434
|
} finally {
|
|
63
|
-
zeroize(masterKey, headerKey, nonceKey);
|
|
435
|
+
zeroize(masterKey, encKey, headerKey, nonceKey);
|
|
64
436
|
}
|
|
65
437
|
}
|
|
66
438
|
|
|
67
|
-
// src/git/
|
|
68
|
-
import {
|
|
439
|
+
// src/git/diff-driver.ts
|
|
440
|
+
import { execFileSync } from "child_process";
|
|
441
|
+
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
442
|
+
import { tmpdir } from "os";
|
|
69
443
|
import { join } from "path";
|
|
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
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
444
|
+
async function diffDriverCommand(args) {
|
|
445
|
+
const [path, oldFile, oldHex, , newFile, newHex] = args;
|
|
446
|
+
if (!path || !oldFile || !newFile) {
|
|
447
|
+
process.stderr.write("mdenc diff-driver: insufficient arguments\n");
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
const oldEnc = catBlob(oldHex);
|
|
451
|
+
const newEnc = catBlob(newHex);
|
|
452
|
+
if (oldEnc !== null || newEnc !== null) {
|
|
453
|
+
const tmp = mkdtempSync(join(tmpdir(), "mdenc-diff-"));
|
|
454
|
+
try {
|
|
455
|
+
const oldTmp = join(tmp, "old");
|
|
456
|
+
const newTmp = join(tmp, "new");
|
|
457
|
+
writeFileSync(oldTmp, oldEnc ?? "");
|
|
458
|
+
writeFileSync(newTmp, newEnc ?? "");
|
|
459
|
+
const encDiff = unifiedDiff(oldTmp, newTmp, `a/${path}`, `b/${path}`);
|
|
460
|
+
if (encDiff) process.stdout.write(encDiff);
|
|
461
|
+
} finally {
|
|
462
|
+
rmSync(tmp, { recursive: true });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const plainDiff = unifiedDiff(oldFile, newFile, `a/${path}`, `b/${path}`);
|
|
466
|
+
if (plainDiff) {
|
|
467
|
+
const annotated = plainDiff.replace(
|
|
468
|
+
/^(@@ .+ @@)(.*)/gm,
|
|
469
|
+
"$1 decrypted \u2014 not stored in repository"
|
|
470
|
+
);
|
|
471
|
+
process.stdout.write(annotated);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function catBlob(hex) {
|
|
475
|
+
if (!hex || hex === "." || /^0+$/.test(hex)) return null;
|
|
476
|
+
try {
|
|
477
|
+
return execFileSync("git", ["cat-file", "blob", hex], { encoding: "utf-8" });
|
|
478
|
+
} catch {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function unifiedDiff(oldFile, newFile, oldLabel, newLabel) {
|
|
483
|
+
try {
|
|
484
|
+
return execFileSync("diff", ["-u", "--label", oldLabel, "--label", newLabel, oldFile, newFile], {
|
|
485
|
+
encoding: "utf-8"
|
|
486
|
+
}) || null;
|
|
487
|
+
} catch (e) {
|
|
488
|
+
const err = e;
|
|
489
|
+
if (err.status === 1 && err.stdout) return err.stdout;
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
110
493
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
494
|
+
// src/git/password.ts
|
|
495
|
+
import { readFileSync } from "fs";
|
|
496
|
+
import { join as join2 } from "path";
|
|
497
|
+
var PASSWORD_FILE = ".mdenc-password";
|
|
498
|
+
function resolvePassword(repoRoot) {
|
|
499
|
+
const envPassword = process.env["MDENC_PASSWORD"];
|
|
500
|
+
if (envPassword) return envPassword;
|
|
501
|
+
try {
|
|
502
|
+
const content = readFileSync(join2(repoRoot, PASSWORD_FILE), "utf-8").trim();
|
|
503
|
+
if (content.length > 0) return content;
|
|
504
|
+
} catch {
|
|
505
|
+
}
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
117
508
|
|
|
118
|
-
|
|
119
|
-
|
|
509
|
+
// src/git/utils.ts
|
|
510
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
511
|
+
import { readdirSync, statSync } from "fs";
|
|
512
|
+
import { join as join3 } from "path";
|
|
513
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
514
|
+
var MARKER_FILE = ".mdenc.conf";
|
|
515
|
+
function findGitRoot() {
|
|
516
|
+
try {
|
|
517
|
+
return execFileSync2("git", ["rev-parse", "--show-toplevel"], {
|
|
518
|
+
encoding: "utf-8",
|
|
519
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
520
|
+
}).trim();
|
|
521
|
+
} catch {
|
|
522
|
+
throw new Error("Not a git repository");
|
|
523
|
+
}
|
|
120
524
|
}
|
|
121
|
-
|
|
525
|
+
function gitShow(repoRoot, ref, path) {
|
|
526
|
+
try {
|
|
527
|
+
return execFileSync2("git", ["show", `${ref}:${path}`], {
|
|
528
|
+
cwd: repoRoot,
|
|
529
|
+
encoding: "utf-8",
|
|
530
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
531
|
+
});
|
|
532
|
+
} catch {
|
|
533
|
+
return void 0;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function findMarkedDirs(repoRoot) {
|
|
537
|
+
const results = [];
|
|
538
|
+
walkForMarker(repoRoot, results);
|
|
539
|
+
return results;
|
|
540
|
+
}
|
|
541
|
+
function walkForMarker(dir, results) {
|
|
542
|
+
let entries;
|
|
543
|
+
try {
|
|
544
|
+
entries = readdirSync(dir);
|
|
545
|
+
} catch {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (entries.includes(MARKER_FILE)) {
|
|
549
|
+
results.push(dir);
|
|
550
|
+
}
|
|
551
|
+
for (const entry of entries) {
|
|
552
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
553
|
+
const full = join3(dir, entry);
|
|
554
|
+
try {
|
|
555
|
+
if (statSync(full).isDirectory()) {
|
|
556
|
+
walkForMarker(full, results);
|
|
557
|
+
}
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function getMdFilesInDir(dir) {
|
|
563
|
+
try {
|
|
564
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md") && statSync(join3(dir, f)).isFile());
|
|
565
|
+
} catch {
|
|
566
|
+
return [];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function gitAdd(repoRoot, files) {
|
|
570
|
+
if (files.length === 0) return;
|
|
571
|
+
execFileSync2("git", ["add", "--", ...files], {
|
|
572
|
+
cwd: repoRoot,
|
|
573
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// src/git/filter.ts
|
|
578
|
+
async function cleanFilter(pathname, plaintext, password, repoRoot) {
|
|
579
|
+
const previousFile = gitShow(repoRoot, "HEAD", pathname);
|
|
580
|
+
return encrypt(plaintext, password, previousFile ? { previousFile } : {});
|
|
581
|
+
}
|
|
582
|
+
async function smudgeFilter(content, password) {
|
|
583
|
+
if (!password || !content.startsWith("mdenc:v1")) {
|
|
584
|
+
return content;
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
return await decrypt(content, password);
|
|
588
|
+
} catch {
|
|
589
|
+
return content;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async function simpleCleanFilter(pathname) {
|
|
593
|
+
const repoRoot = findGitRoot();
|
|
594
|
+
const password = resolvePassword(repoRoot);
|
|
595
|
+
if (!password) {
|
|
596
|
+
process.stderr.write("mdenc: no password available, cannot encrypt\n");
|
|
597
|
+
process.exit(1);
|
|
598
|
+
}
|
|
599
|
+
const input = await readStdin();
|
|
600
|
+
const encrypted = await cleanFilter(pathname, input, password, repoRoot);
|
|
601
|
+
process.stdout.write(encrypted);
|
|
602
|
+
}
|
|
603
|
+
async function simpleSmudgeFilter() {
|
|
122
604
|
const repoRoot = findGitRoot();
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
605
|
+
const password = resolvePassword(repoRoot);
|
|
606
|
+
const input = await readStdin();
|
|
607
|
+
const output = await smudgeFilter(input, password);
|
|
608
|
+
process.stdout.write(output);
|
|
609
|
+
}
|
|
610
|
+
function readStdin() {
|
|
611
|
+
return new Promise((resolve2) => {
|
|
612
|
+
const chunks = [];
|
|
613
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
614
|
+
process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
615
|
+
process.stdin.resume();
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/git/filter-process.ts
|
|
620
|
+
var FLUSH = Buffer.from("0000", "ascii");
|
|
621
|
+
var MAX_PKT_DATA = 65516;
|
|
622
|
+
function writePktLine(data) {
|
|
623
|
+
const payload = Buffer.from(data, "utf-8");
|
|
624
|
+
const len = (payload.length + 4).toString(16).padStart(4, "0");
|
|
625
|
+
process.stdout.write(len, "ascii");
|
|
626
|
+
process.stdout.write(payload);
|
|
627
|
+
}
|
|
628
|
+
function writeFlush() {
|
|
629
|
+
process.stdout.write(FLUSH);
|
|
630
|
+
}
|
|
631
|
+
function writeBinaryPktLines(data) {
|
|
632
|
+
let offset = 0;
|
|
633
|
+
while (offset < data.length) {
|
|
634
|
+
const chunk = data.subarray(offset, offset + MAX_PKT_DATA);
|
|
635
|
+
const len = (chunk.length + 4).toString(16).padStart(4, "0");
|
|
636
|
+
process.stdout.write(len, "ascii");
|
|
637
|
+
process.stdout.write(chunk);
|
|
638
|
+
offset += chunk.length;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
var PktLineReader = class {
|
|
642
|
+
buf = Buffer.alloc(0);
|
|
643
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: standard Promise resolve signature
|
|
644
|
+
resolveWait = null;
|
|
645
|
+
ended = false;
|
|
646
|
+
constructor(stream) {
|
|
647
|
+
stream.on("data", (chunk) => {
|
|
648
|
+
this.buf = Buffer.concat([this.buf, chunk]);
|
|
649
|
+
if (this.resolveWait) {
|
|
650
|
+
const r = this.resolveWait;
|
|
651
|
+
this.resolveWait = null;
|
|
652
|
+
r();
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
stream.on("end", () => {
|
|
656
|
+
this.ended = true;
|
|
657
|
+
if (this.resolveWait) {
|
|
658
|
+
const r = this.resolveWait;
|
|
659
|
+
this.resolveWait = null;
|
|
660
|
+
r();
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
stream.resume();
|
|
664
|
+
}
|
|
665
|
+
async waitForData() {
|
|
666
|
+
if (this.buf.length > 0 || this.ended) return;
|
|
667
|
+
return new Promise((resolve2) => {
|
|
668
|
+
this.resolveWait = resolve2;
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
async readExact(n) {
|
|
672
|
+
while (this.buf.length < n) {
|
|
673
|
+
if (this.ended) return null;
|
|
674
|
+
await this.waitForData();
|
|
131
675
|
}
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
676
|
+
const result = this.buf.subarray(0, n);
|
|
677
|
+
this.buf = this.buf.subarray(n);
|
|
678
|
+
return result;
|
|
679
|
+
}
|
|
680
|
+
/** Read one pkt-line. Returns string for data, null for flush, undefined for EOF. */
|
|
681
|
+
async readPacket() {
|
|
682
|
+
const lenBuf = await this.readExact(4);
|
|
683
|
+
if (!lenBuf) return void 0;
|
|
684
|
+
const lenStr = lenBuf.toString("ascii");
|
|
685
|
+
const len = parseInt(lenStr, 16);
|
|
686
|
+
if (len === 0) return null;
|
|
687
|
+
if (len <= 4) throw new Error(`Invalid pkt-line length: ${len}`);
|
|
688
|
+
const payload = await this.readExact(len - 4);
|
|
689
|
+
if (!payload) throw new Error("Unexpected EOF in pkt-line payload");
|
|
690
|
+
return payload.toString("utf-8");
|
|
691
|
+
}
|
|
692
|
+
/** Read lines until flush. Returns array of strings (newlines stripped). */
|
|
693
|
+
async readUntilFlush() {
|
|
694
|
+
const lines = [];
|
|
695
|
+
while (true) {
|
|
696
|
+
const pkt = await this.readPacket();
|
|
697
|
+
if (pkt === null || pkt === void 0) break;
|
|
698
|
+
lines.push(pkt.replace(/\n$/, ""));
|
|
136
699
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
700
|
+
return lines;
|
|
701
|
+
}
|
|
702
|
+
/** Read binary content until flush. Returns concatenated buffer. */
|
|
703
|
+
async readContentUntilFlush() {
|
|
704
|
+
const chunks = [];
|
|
705
|
+
while (true) {
|
|
706
|
+
const lenBuf = await this.readExact(4);
|
|
707
|
+
if (!lenBuf) break;
|
|
708
|
+
const len = parseInt(lenBuf.toString("ascii"), 16);
|
|
709
|
+
if (len === 0) break;
|
|
710
|
+
if (len <= 4) throw new Error(`Invalid pkt-line length: ${len}`);
|
|
711
|
+
const payload = await this.readExact(len - 4);
|
|
712
|
+
if (!payload) throw new Error("Unexpected EOF in pkt-line content");
|
|
713
|
+
chunks.push(payload);
|
|
144
714
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
715
|
+
return Buffer.concat(chunks);
|
|
716
|
+
}
|
|
717
|
+
};
|
|
718
|
+
async function filterProcessMain() {
|
|
719
|
+
const repoRoot = findGitRoot();
|
|
720
|
+
const password = resolvePassword(repoRoot);
|
|
721
|
+
const reader = new PktLineReader(process.stdin);
|
|
722
|
+
const welcome = await reader.readUntilFlush();
|
|
723
|
+
if (!welcome.includes("git-filter-client") || !welcome.includes("version=2")) {
|
|
724
|
+
process.stderr.write("mdenc: invalid filter protocol handshake\n");
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
writePktLine("git-filter-server\n");
|
|
728
|
+
writePktLine("version=2\n");
|
|
729
|
+
writeFlush();
|
|
730
|
+
const caps = await reader.readUntilFlush();
|
|
731
|
+
if (caps.includes("capability=clean")) writePktLine("capability=clean\n");
|
|
732
|
+
if (caps.includes("capability=smudge")) writePktLine("capability=smudge\n");
|
|
733
|
+
writeFlush();
|
|
734
|
+
while (true) {
|
|
735
|
+
const commandLines = await reader.readUntilFlush();
|
|
736
|
+
if (commandLines.length === 0) break;
|
|
737
|
+
let cmd = "";
|
|
738
|
+
let pathname = "";
|
|
739
|
+
for (const line of commandLines) {
|
|
740
|
+
if (line.startsWith("command=")) cmd = line.slice("command=".length);
|
|
741
|
+
if (line.startsWith("pathname=")) pathname = line.slice("pathname=".length);
|
|
152
742
|
}
|
|
153
|
-
|
|
743
|
+
const content = await reader.readContentUntilFlush();
|
|
744
|
+
const contentStr = content.toString("utf-8");
|
|
745
|
+
try {
|
|
746
|
+
let result;
|
|
747
|
+
if (cmd === "clean") {
|
|
748
|
+
if (!password) {
|
|
749
|
+
writePktLine("status=error\n");
|
|
750
|
+
writeFlush();
|
|
751
|
+
writeFlush();
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
result = await cleanFilter(pathname, contentStr, password, repoRoot);
|
|
755
|
+
} else if (cmd === "smudge") {
|
|
756
|
+
result = await smudgeFilter(contentStr, password);
|
|
757
|
+
} else {
|
|
758
|
+
process.stderr.write(`mdenc: unknown filter command: ${cmd}
|
|
759
|
+
`);
|
|
760
|
+
writePktLine("status=error\n");
|
|
761
|
+
writeFlush();
|
|
762
|
+
writeFlush();
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
const resultBuf = Buffer.from(result, "utf-8");
|
|
766
|
+
writePktLine("status=success\n");
|
|
767
|
+
writeFlush();
|
|
768
|
+
writeBinaryPktLines(resultBuf);
|
|
769
|
+
writeFlush();
|
|
770
|
+
writeFlush();
|
|
771
|
+
} catch (err) {
|
|
154
772
|
process.stderr.write(
|
|
155
|
-
`mdenc: ${
|
|
773
|
+
`mdenc: filter error for ${pathname}: ${err instanceof Error ? err.message : err}
|
|
156
774
|
`
|
|
157
775
|
);
|
|
158
|
-
|
|
159
|
-
|
|
776
|
+
writePktLine("status=error\n");
|
|
777
|
+
writeFlush();
|
|
778
|
+
writeFlush();
|
|
160
779
|
}
|
|
161
|
-
writeFileSync(hookPath, content.trimEnd() + "\n" + hookBlock(hookName) + "\n");
|
|
162
|
-
chmodSync(hookPath, 493);
|
|
163
|
-
console.log(`Appended mdenc to existing ${hookName} hook`);
|
|
164
780
|
}
|
|
165
|
-
|
|
166
|
-
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/git/genpass.ts
|
|
784
|
+
import { existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
785
|
+
import { join as join4 } from "path";
|
|
786
|
+
import { randomBytes as randomBytes2 } from "@noble/ciphers/webcrypto";
|
|
787
|
+
var PASSWORD_FILE2 = ".mdenc-password";
|
|
788
|
+
function genpassCommand(force) {
|
|
789
|
+
const repoRoot = findGitRoot();
|
|
790
|
+
const passwordPath = join4(repoRoot, PASSWORD_FILE2);
|
|
791
|
+
if (existsSync(passwordPath) && !force) {
|
|
792
|
+
console.error(`${PASSWORD_FILE2} already exists. Use --force to overwrite.`);
|
|
793
|
+
process.exit(1);
|
|
794
|
+
}
|
|
795
|
+
const password = Buffer.from(randomBytes2(32)).toString("base64url");
|
|
796
|
+
writeFileSync2(passwordPath, `${password}
|
|
797
|
+
`, { mode: 384 });
|
|
798
|
+
console.error(`Generated password and wrote to ${PASSWORD_FILE2}`);
|
|
799
|
+
console.error(password);
|
|
800
|
+
const gitignorePath = join4(repoRoot, ".gitignore");
|
|
801
|
+
const entry = PASSWORD_FILE2;
|
|
167
802
|
if (existsSync(gitignorePath)) {
|
|
168
|
-
const content =
|
|
803
|
+
const content = readFileSync2(gitignorePath, "utf-8");
|
|
804
|
+
const lines = content.split("\n").map((l) => l.trim());
|
|
805
|
+
if (!lines.includes(entry)) {
|
|
806
|
+
writeFileSync2(gitignorePath, `${content.trimEnd()}
|
|
807
|
+
${entry}
|
|
808
|
+
`);
|
|
809
|
+
console.error("Added .mdenc-password to .gitignore");
|
|
810
|
+
}
|
|
811
|
+
} else {
|
|
812
|
+
writeFileSync2(gitignorePath, `${entry}
|
|
813
|
+
`);
|
|
814
|
+
console.error("Created .gitignore with .mdenc-password");
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// src/git/init.ts
|
|
819
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
820
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
821
|
+
import { join as join5 } from "path";
|
|
822
|
+
var FILTER_CONFIGS = [
|
|
823
|
+
["filter.mdenc.process", "mdenc filter-process"],
|
|
824
|
+
["filter.mdenc.clean", "mdenc filter-clean %f"],
|
|
825
|
+
["filter.mdenc.smudge", "mdenc filter-smudge %f"],
|
|
826
|
+
["filter.mdenc.required", "true"],
|
|
827
|
+
["diff.mdenc.command", "mdenc diff-driver"]
|
|
828
|
+
];
|
|
829
|
+
function configureGitFilter(repoRoot) {
|
|
830
|
+
for (const [key, value] of FILTER_CONFIGS) {
|
|
831
|
+
execFileSync3("git", ["config", "--local", key, value], {
|
|
832
|
+
cwd: repoRoot,
|
|
833
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
function isFilterConfigured(repoRoot) {
|
|
838
|
+
try {
|
|
839
|
+
const val = execFileSync3("git", ["config", "--get", "filter.mdenc.process"], {
|
|
840
|
+
cwd: repoRoot,
|
|
841
|
+
encoding: "utf-8",
|
|
842
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
843
|
+
}).trim();
|
|
844
|
+
return val.length > 0;
|
|
845
|
+
} catch {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
async function initCommand() {
|
|
850
|
+
const repoRoot = findGitRoot();
|
|
851
|
+
if (isFilterConfigured(repoRoot)) {
|
|
852
|
+
console.log("Git filter already configured (skipped)");
|
|
853
|
+
} else {
|
|
854
|
+
configureGitFilter(repoRoot);
|
|
855
|
+
console.log("Configured git filter (filter.mdenc + diff.mdenc)");
|
|
856
|
+
}
|
|
857
|
+
const gitignorePath = join5(repoRoot, ".gitignore");
|
|
858
|
+
const entry = ".mdenc-password";
|
|
859
|
+
if (existsSync2(gitignorePath)) {
|
|
860
|
+
const content = readFileSync3(gitignorePath, "utf-8");
|
|
169
861
|
const lines = content.split("\n").map((l) => l.trim());
|
|
170
862
|
if (!lines.includes(entry)) {
|
|
171
|
-
|
|
863
|
+
writeFileSync3(gitignorePath, `${content.trimEnd()}
|
|
864
|
+
${entry}
|
|
865
|
+
`);
|
|
172
866
|
console.log("Added .mdenc-password to .gitignore");
|
|
173
867
|
} else {
|
|
174
868
|
console.log(".mdenc-password already in .gitignore (skipped)");
|
|
175
869
|
}
|
|
176
870
|
} else {
|
|
177
|
-
|
|
871
|
+
writeFileSync3(gitignorePath, `${entry}
|
|
872
|
+
`);
|
|
178
873
|
console.log("Created .gitignore with .mdenc-password");
|
|
179
874
|
}
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
875
|
+
const markedDirs = findMarkedDirs(repoRoot);
|
|
876
|
+
if (markedDirs.length > 0) {
|
|
877
|
+
const { relative: relative3 } = await import("path");
|
|
878
|
+
for (const dir of markedDirs) {
|
|
879
|
+
const relDir = relative3(repoRoot, dir) || ".";
|
|
880
|
+
try {
|
|
881
|
+
execFileSync3("git", ["checkout", "HEAD", "--", `${relDir}/*.md`], {
|
|
882
|
+
cwd: repoRoot,
|
|
883
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
884
|
+
});
|
|
885
|
+
} catch {
|
|
886
|
+
}
|
|
887
|
+
}
|
|
184
888
|
}
|
|
185
889
|
console.log("mdenc git integration initialized.");
|
|
186
890
|
}
|
|
187
|
-
function
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
continue;
|
|
197
|
-
}
|
|
198
|
-
const lines = content.split("\n");
|
|
199
|
-
const filtered = [];
|
|
200
|
-
let inBlock = false;
|
|
201
|
-
for (const line of lines) {
|
|
202
|
-
if (line.trim() === MARKER) {
|
|
203
|
-
inBlock = true;
|
|
204
|
-
continue;
|
|
205
|
-
}
|
|
206
|
-
if (inBlock) {
|
|
207
|
-
if (line.trim() === "fi") {
|
|
208
|
-
inBlock = false;
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
filtered.push(line);
|
|
214
|
-
}
|
|
215
|
-
const result = filtered.join("\n");
|
|
216
|
-
const isEmpty = result.split("\n").every((l) => l.trim() === "" || l.startsWith("#!"));
|
|
217
|
-
if (isEmpty) {
|
|
218
|
-
unlinkSync(hookPath);
|
|
219
|
-
console.log(`Removed ${hookName} hook (was mdenc-only)`);
|
|
220
|
-
} else {
|
|
221
|
-
writeFileSync(hookPath, result);
|
|
222
|
-
console.log(`Removed mdenc block from ${hookName} hook`);
|
|
891
|
+
function removeFilterCommand() {
|
|
892
|
+
const repoRoot = findGitRoot();
|
|
893
|
+
for (const section of ["filter.mdenc", "diff.mdenc"]) {
|
|
894
|
+
try {
|
|
895
|
+
execFileSync3("git", ["config", "--local", "--remove-section", section], {
|
|
896
|
+
cwd: repoRoot,
|
|
897
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
898
|
+
});
|
|
899
|
+
} catch {
|
|
223
900
|
}
|
|
224
|
-
removedCount++;
|
|
225
|
-
}
|
|
226
|
-
if (removedCount === 0) {
|
|
227
|
-
console.log("No mdenc hooks found to remove.");
|
|
228
|
-
} else {
|
|
229
|
-
console.log("mdenc hooks removed.");
|
|
230
901
|
}
|
|
902
|
+
console.log("Removed git filter configuration.");
|
|
231
903
|
}
|
|
232
904
|
|
|
233
905
|
// src/git/mark.ts
|
|
234
|
-
import {
|
|
235
|
-
import {
|
|
236
|
-
|
|
906
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
907
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
908
|
+
import { join as join6, relative, resolve } from "path";
|
|
909
|
+
var MARKER_FILE2 = ".mdenc.conf";
|
|
237
910
|
var MARKER_CONTENT = "# mdenc: .md files in this directory are automatically encrypted\n";
|
|
238
|
-
var
|
|
911
|
+
var GITATTR_PATTERN = "*.md filter=mdenc diff=mdenc";
|
|
912
|
+
function isFilterConfigured2(repoRoot) {
|
|
913
|
+
try {
|
|
914
|
+
const val = execFileSync4("git", ["config", "--get", "filter.mdenc.process"], {
|
|
915
|
+
cwd: repoRoot,
|
|
916
|
+
encoding: "utf-8",
|
|
917
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
918
|
+
}).trim();
|
|
919
|
+
return val.length > 0;
|
|
920
|
+
} catch {
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function markUsage() {
|
|
925
|
+
console.error(`Usage: mdenc mark <directory>
|
|
926
|
+
|
|
927
|
+
Mark a directory so that all *.md files inside it are automatically
|
|
928
|
+
encrypted when staged (git add) and decrypted when checked out.
|
|
929
|
+
|
|
930
|
+
What it does:
|
|
931
|
+
1. Creates <directory>/.mdenc.conf Marker file for mdenc to discover
|
|
932
|
+
2. Creates <directory>/.gitattributes Assigns the mdenc filter to *.md files
|
|
933
|
+
3. Stages both files in git
|
|
934
|
+
|
|
935
|
+
Prerequisites:
|
|
936
|
+
Run "mdenc init" first to configure the git filter in this clone.
|
|
937
|
+
Run "mdenc genpass" to generate a password (or set MDENC_PASSWORD).
|
|
938
|
+
|
|
939
|
+
Example:
|
|
940
|
+
mdenc init
|
|
941
|
+
mdenc genpass
|
|
942
|
+
mdenc mark docs/private
|
|
943
|
+
echo "# Secret" > docs/private/notes.md
|
|
944
|
+
git add docs/private/notes.md # encrypted automatically`);
|
|
945
|
+
process.exit(1);
|
|
946
|
+
}
|
|
239
947
|
function markCommand(dirArg) {
|
|
948
|
+
if (dirArg === "--help" || dirArg === "-h") {
|
|
949
|
+
markUsage();
|
|
950
|
+
}
|
|
240
951
|
const repoRoot = findGitRoot();
|
|
241
952
|
const dir = resolve(dirArg);
|
|
242
|
-
if (!
|
|
953
|
+
if (!existsSync3(dir)) {
|
|
243
954
|
console.error(`Error: directory "${dirArg}" does not exist`);
|
|
244
955
|
process.exit(1);
|
|
245
956
|
}
|
|
@@ -249,54 +960,62 @@ function markCommand(dirArg) {
|
|
|
249
960
|
process.exit(1);
|
|
250
961
|
}
|
|
251
962
|
const relDir = rel || ".";
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
963
|
+
const filterReady = isFilterConfigured2(repoRoot);
|
|
964
|
+
const confPath = join6(dir, MARKER_FILE2);
|
|
965
|
+
if (!existsSync3(confPath)) {
|
|
966
|
+
writeFileSync4(confPath, MARKER_CONTENT);
|
|
967
|
+
console.log(`Created ${relDir}/${MARKER_FILE2}`);
|
|
256
968
|
} else {
|
|
257
|
-
console.log(`${relDir}/${
|
|
969
|
+
console.log(`${relDir}/${MARKER_FILE2} already exists (skipped)`);
|
|
258
970
|
}
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
261
|
-
const content =
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
writeFileSync2(gitignorePath, content.trimEnd() + "\n" + GITIGNORE_PATTERN + "\n");
|
|
265
|
-
console.log(`Updated ${relDir}/.gitignore (added ${GITIGNORE_PATTERN})`);
|
|
971
|
+
const gitattrsPath = join6(dir, ".gitattributes");
|
|
972
|
+
if (existsSync3(gitattrsPath)) {
|
|
973
|
+
const content = readFileSync4(gitattrsPath, "utf-8");
|
|
974
|
+
if (content.includes("filter=mdenc")) {
|
|
975
|
+
console.log(`${relDir}/.gitattributes already has filter=mdenc (skipped)`);
|
|
266
976
|
} else {
|
|
267
|
-
|
|
977
|
+
writeFileSync4(gitattrsPath, `${content.trimEnd()}
|
|
978
|
+
${GITATTR_PATTERN}
|
|
979
|
+
`);
|
|
980
|
+
console.log(`Updated ${relDir}/.gitattributes`);
|
|
268
981
|
}
|
|
269
982
|
} else {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const mdFiles = getMdFilesInDir(dir);
|
|
274
|
-
const trackedMd = [];
|
|
275
|
-
for (const f of mdFiles) {
|
|
276
|
-
const relPath = relative(repoRoot, join2(dir, f));
|
|
277
|
-
if (isFileTracked(repoRoot, relPath)) {
|
|
278
|
-
trackedMd.push(relPath);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
if (trackedMd.length > 0) {
|
|
282
|
-
gitRmCached(repoRoot, trackedMd);
|
|
283
|
-
for (const f of trackedMd) {
|
|
284
|
-
console.log(`Untracked ${f} from git (still exists locally)`);
|
|
285
|
-
}
|
|
983
|
+
writeFileSync4(gitattrsPath, `${GITATTR_PATTERN}
|
|
984
|
+
`);
|
|
985
|
+
console.log(`Created ${relDir}/.gitattributes`);
|
|
286
986
|
}
|
|
287
|
-
const toStage = [
|
|
288
|
-
relative(repoRoot, confPath),
|
|
289
|
-
relative(repoRoot, gitignorePath)
|
|
290
|
-
];
|
|
987
|
+
const toStage = [relative(repoRoot, confPath), relative(repoRoot, gitattrsPath)];
|
|
291
988
|
gitAdd(repoRoot, toStage);
|
|
292
989
|
console.log(`Marked ${relDir}/ for mdenc encryption`);
|
|
990
|
+
if (!filterReady) {
|
|
991
|
+
console.log(`
|
|
992
|
+
Warning: git filter not configured yet. Run "mdenc init" to enable encryption.`);
|
|
993
|
+
}
|
|
293
994
|
}
|
|
294
995
|
|
|
295
996
|
// src/git/status.ts
|
|
296
|
-
import {
|
|
297
|
-
import {
|
|
298
|
-
|
|
299
|
-
|
|
997
|
+
import { execFileSync as execFileSync5 } from "child_process";
|
|
998
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
|
|
999
|
+
import { join as join7, relative as relative2 } from "path";
|
|
1000
|
+
function getFilterConfig(repoRoot) {
|
|
1001
|
+
const get = (key) => {
|
|
1002
|
+
try {
|
|
1003
|
+
return execFileSync5("git", ["config", "--get", key], {
|
|
1004
|
+
cwd: repoRoot,
|
|
1005
|
+
encoding: "utf-8",
|
|
1006
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1007
|
+
}).trim();
|
|
1008
|
+
} catch {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
return {
|
|
1013
|
+
process: get("filter.mdenc.process"),
|
|
1014
|
+
clean: get("filter.mdenc.clean"),
|
|
1015
|
+
smudge: get("filter.mdenc.smudge"),
|
|
1016
|
+
required: get("filter.mdenc.required") === "true"
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
300
1019
|
function statusCommand() {
|
|
301
1020
|
const repoRoot = findGitRoot();
|
|
302
1021
|
const password = resolvePassword(repoRoot);
|
|
@@ -310,36 +1029,24 @@ function statusCommand() {
|
|
|
310
1029
|
const relDir = relative2(repoRoot, dir) || ".";
|
|
311
1030
|
console.log(` ${relDir}/`);
|
|
312
1031
|
const mdFiles = getMdFilesInDir(dir);
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (mdencBases.has(base)) {
|
|
318
|
-
const mdPath = join3(dir, `${base}.md`);
|
|
319
|
-
const mdencPath = join3(dir, `${base}.mdenc`);
|
|
320
|
-
const mdMtime = statSync(mdPath).mtimeMs;
|
|
321
|
-
const mdencMtime = statSync(mdencPath).mtimeMs;
|
|
322
|
-
if (mdMtime > mdencMtime) {
|
|
323
|
-
console.log(` ${base}.md [needs re-encryption]`);
|
|
324
|
-
} else {
|
|
325
|
-
console.log(` ${base}.md [up to date]`);
|
|
326
|
-
}
|
|
1032
|
+
for (const f of mdFiles) {
|
|
1033
|
+
const content = readFileSync5(join7(dir, f), "utf-8");
|
|
1034
|
+
if (content.startsWith("mdenc:v1")) {
|
|
1035
|
+
console.log(` ${f} [encrypted \u2014 needs smudge]`);
|
|
327
1036
|
} else {
|
|
328
|
-
console.log(` ${
|
|
1037
|
+
console.log(` ${f} [plaintext]`);
|
|
329
1038
|
}
|
|
330
1039
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
console.log(` ${base}.mdenc [needs decryption]`);
|
|
334
|
-
}
|
|
1040
|
+
if (mdFiles.length === 0) {
|
|
1041
|
+
console.log(" (no .md files)");
|
|
335
1042
|
}
|
|
336
|
-
const
|
|
337
|
-
if (!
|
|
338
|
-
console.log(
|
|
1043
|
+
const gitattrsPath = join7(dir, ".gitattributes");
|
|
1044
|
+
if (!existsSync4(gitattrsPath)) {
|
|
1045
|
+
console.log(" WARNING: no .gitattributes in this directory");
|
|
339
1046
|
} else {
|
|
340
|
-
const content =
|
|
341
|
-
if (!content.
|
|
342
|
-
console.log(
|
|
1047
|
+
const content = readFileSync5(gitattrsPath, "utf-8");
|
|
1048
|
+
if (!content.includes("filter=mdenc")) {
|
|
1049
|
+
console.log(" WARNING: .gitattributes missing filter=mdenc pattern");
|
|
343
1050
|
}
|
|
344
1051
|
}
|
|
345
1052
|
console.log();
|
|
@@ -351,127 +1058,37 @@ function statusCommand() {
|
|
|
351
1058
|
} else {
|
|
352
1059
|
console.log("Password: available");
|
|
353
1060
|
}
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (!existsSync3(hookPath)) {
|
|
359
|
-
missing.push(name);
|
|
360
|
-
} else {
|
|
361
|
-
const content = readFileSync3(hookPath, "utf-8");
|
|
362
|
-
if (!content.includes(MARKER2)) {
|
|
363
|
-
missing.push(name);
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
if (missing.length > 0) {
|
|
368
|
-
console.log(`Hooks: MISSING (${missing.join(", ")})`);
|
|
369
|
-
console.log(' Run "mdenc init" to install hooks');
|
|
1061
|
+
const config = getFilterConfig(repoRoot);
|
|
1062
|
+
if (!config.process && !config.clean) {
|
|
1063
|
+
console.log("Filter: NOT CONFIGURED");
|
|
1064
|
+
console.log(' Run "mdenc init" to configure');
|
|
370
1065
|
} else {
|
|
371
|
-
console.log("
|
|
1066
|
+
console.log("Filter: configured");
|
|
1067
|
+
if (!config.required) {
|
|
1068
|
+
console.log(" WARNING: filter.mdenc.required is not set to true");
|
|
1069
|
+
}
|
|
372
1070
|
}
|
|
373
1071
|
}
|
|
374
1072
|
|
|
375
|
-
// src/git/
|
|
376
|
-
import { readFileSync as
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
1073
|
+
// src/git/textconv.ts
|
|
1074
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
1075
|
+
async function textconvCommand(filePath) {
|
|
1076
|
+
const content = readFileSync6(filePath, "utf-8");
|
|
1077
|
+
if (!content.startsWith("mdenc:v1")) {
|
|
1078
|
+
process.stdout.write(content);
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
380
1081
|
const repoRoot = findGitRoot();
|
|
381
1082
|
const password = resolvePassword(repoRoot);
|
|
382
1083
|
if (!password) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
);
|
|
386
|
-
process.exit(1);
|
|
1084
|
+
process.stdout.write(content);
|
|
1085
|
+
return;
|
|
387
1086
|
}
|
|
388
|
-
const markedDirs = findMarkedDirs(repoRoot);
|
|
389
|
-
if (markedDirs.length === 0) {
|
|
390
|
-
console.error("mdenc: no marked directories found");
|
|
391
|
-
process.exit(1);
|
|
392
|
-
}
|
|
393
|
-
for (const dir of markedDirs) {
|
|
394
|
-
const mdFiles = getMdFilesInDir(dir);
|
|
395
|
-
for (const mdFile of mdFiles) {
|
|
396
|
-
await encryptFile(dir, mdFile, repoRoot, password);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
const pending = /* @__PURE__ */ new Set();
|
|
400
|
-
const watcher = watch(
|
|
401
|
-
markedDirs.map((dir) => join4(dir, "*.md")),
|
|
402
|
-
{ ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 100 } }
|
|
403
|
-
);
|
|
404
|
-
watcher.on("change", (filePath) => {
|
|
405
|
-
handleFileEvent(filePath, repoRoot, password, pending);
|
|
406
|
-
});
|
|
407
|
-
watcher.on("add", (filePath) => {
|
|
408
|
-
handleFileEvent(filePath, repoRoot, password, pending);
|
|
409
|
-
});
|
|
410
|
-
for (const dir of markedDirs) {
|
|
411
|
-
const relDir = relative3(repoRoot, dir) || ".";
|
|
412
|
-
console.error(`mdenc: watching ${relDir}/`);
|
|
413
|
-
}
|
|
414
|
-
console.error("mdenc: watching for changes (Ctrl+C to stop)");
|
|
415
|
-
}
|
|
416
|
-
function handleFileEvent(filePath, repoRoot, password, pending) {
|
|
417
|
-
if (pending.has(filePath)) return;
|
|
418
|
-
pending.add(filePath);
|
|
419
|
-
const dir = join4(filePath, "..");
|
|
420
|
-
const filename = filePath.slice(dir.length + 1);
|
|
421
|
-
encryptFile(dir, filename, repoRoot, password).finally(() => {
|
|
422
|
-
pending.delete(filePath);
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
async function encryptFile(dir, mdFile, repoRoot, password) {
|
|
426
|
-
const mdPath = join4(dir, mdFile);
|
|
427
|
-
const mdencPath = mdPath.replace(/\.md$/, ".mdenc");
|
|
428
|
-
const relMdPath = relative3(repoRoot, mdPath);
|
|
429
|
-
if (!existsSync4(mdPath)) return;
|
|
430
1087
|
try {
|
|
431
|
-
const plaintext =
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
const encrypted = await encrypt(plaintext, password, { previousFile });
|
|
437
|
-
if (encrypted === previousFile) return;
|
|
438
|
-
writeFileSync3(mdencPath, encrypted);
|
|
439
|
-
console.error(`mdenc: encrypted ${relMdPath}`);
|
|
440
|
-
} catch (err) {
|
|
441
|
-
console.error(
|
|
442
|
-
`mdenc: failed to encrypt ${relMdPath}: ${err instanceof Error ? err.message : err}`
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// src/git/genpass.ts
|
|
448
|
-
import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
|
|
449
|
-
import { join as join5 } from "path";
|
|
450
|
-
import { randomBytes } from "@noble/ciphers/webcrypto";
|
|
451
|
-
var PASSWORD_FILE = ".mdenc-password";
|
|
452
|
-
function genpassCommand(force) {
|
|
453
|
-
const repoRoot = findGitRoot();
|
|
454
|
-
const passwordPath = join5(repoRoot, PASSWORD_FILE);
|
|
455
|
-
if (existsSync5(passwordPath) && !force) {
|
|
456
|
-
console.error(`${PASSWORD_FILE} already exists. Use --force to overwrite.`);
|
|
457
|
-
process.exit(1);
|
|
458
|
-
}
|
|
459
|
-
const password = Buffer.from(randomBytes(32)).toString("base64url");
|
|
460
|
-
writeFileSync4(passwordPath, password + "\n", { mode: 384 });
|
|
461
|
-
console.error(`Generated password and wrote to ${PASSWORD_FILE}`);
|
|
462
|
-
console.error(password);
|
|
463
|
-
const gitignorePath = join5(repoRoot, ".gitignore");
|
|
464
|
-
const entry = PASSWORD_FILE;
|
|
465
|
-
if (existsSync5(gitignorePath)) {
|
|
466
|
-
const content = readFileSync5(gitignorePath, "utf-8");
|
|
467
|
-
const lines = content.split("\n").map((l) => l.trim());
|
|
468
|
-
if (!lines.includes(entry)) {
|
|
469
|
-
writeFileSync4(gitignorePath, content.trimEnd() + "\n" + entry + "\n");
|
|
470
|
-
console.error("Added .mdenc-password to .gitignore");
|
|
471
|
-
}
|
|
472
|
-
} else {
|
|
473
|
-
writeFileSync4(gitignorePath, entry + "\n");
|
|
474
|
-
console.error("Created .gitignore with .mdenc-password");
|
|
1088
|
+
const plaintext = await decrypt(content, password);
|
|
1089
|
+
process.stdout.write(plaintext);
|
|
1090
|
+
} catch {
|
|
1091
|
+
process.stdout.write(content);
|
|
475
1092
|
}
|
|
476
1093
|
}
|
|
477
1094
|
|
|
@@ -544,25 +1161,39 @@ async function getPasswordWithConfirmation() {
|
|
|
544
1161
|
}
|
|
545
1162
|
return password;
|
|
546
1163
|
}
|
|
547
|
-
|
|
548
|
-
console.error(`Usage:
|
|
1164
|
+
var USAGE = `Usage:
|
|
549
1165
|
mdenc encrypt <file> [-o output] Encrypt a markdown file
|
|
550
1166
|
mdenc decrypt <file> [-o output] Decrypt an mdenc file
|
|
551
1167
|
mdenc verify <file> Verify file integrity
|
|
552
1168
|
|
|
553
1169
|
Git integration:
|
|
554
|
-
mdenc init Set up git
|
|
1170
|
+
mdenc init Set up git filter for automatic encryption
|
|
555
1171
|
mdenc mark <directory> Mark a directory for encryption
|
|
556
1172
|
mdenc status Show encryption status
|
|
557
|
-
mdenc
|
|
558
|
-
mdenc
|
|
559
|
-
|
|
560
|
-
|
|
1173
|
+
mdenc remove-filter Remove git filter configuration
|
|
1174
|
+
mdenc genpass [--force] Generate a random password into .mdenc-password
|
|
1175
|
+
|
|
1176
|
+
Internal (called by git):
|
|
1177
|
+
mdenc filter-process Long-running filter process
|
|
1178
|
+
mdenc filter-clean <path> Single-file clean filter
|
|
1179
|
+
mdenc filter-smudge <path> Single-file smudge filter
|
|
1180
|
+
mdenc textconv <file> Output plaintext for git diff
|
|
1181
|
+
mdenc diff-driver <path> ... Custom diff driver (encrypted + plaintext)`;
|
|
1182
|
+
function usage(exitCode = 1) {
|
|
1183
|
+
console.error(USAGE);
|
|
1184
|
+
process.exit(exitCode);
|
|
561
1185
|
}
|
|
562
1186
|
async function main() {
|
|
563
1187
|
const args = process.argv.slice(2);
|
|
564
1188
|
if (args.length === 0) usage();
|
|
565
1189
|
const command = args[0];
|
|
1190
|
+
if (command === "--help" || command === "-h") usage(0);
|
|
1191
|
+
if (command === "--version" || command === "-v") {
|
|
1192
|
+
const pkgPath = new URL("../package.json", import.meta.url);
|
|
1193
|
+
const pkg = JSON.parse(readFileSync7(pkgPath, "utf-8"));
|
|
1194
|
+
console.log(pkg.version);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
566
1197
|
try {
|
|
567
1198
|
switch (command) {
|
|
568
1199
|
case "encrypt": {
|
|
@@ -571,7 +1202,7 @@ async function main() {
|
|
|
571
1202
|
const outputIdx = args.indexOf("-o");
|
|
572
1203
|
const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : void 0;
|
|
573
1204
|
const password = await getPasswordWithConfirmation();
|
|
574
|
-
const plaintext =
|
|
1205
|
+
const plaintext = readFileSync7(inputFile, "utf-8");
|
|
575
1206
|
const encrypted = await encrypt(plaintext, password);
|
|
576
1207
|
if (outputFile) {
|
|
577
1208
|
writeFileSync5(outputFile, encrypted);
|
|
@@ -586,7 +1217,7 @@ async function main() {
|
|
|
586
1217
|
const outputIdx = args.indexOf("-o");
|
|
587
1218
|
const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : void 0;
|
|
588
1219
|
const password = await getPassword();
|
|
589
|
-
const fileContent =
|
|
1220
|
+
const fileContent = readFileSync7(inputFile, "utf-8");
|
|
590
1221
|
const decrypted = await decrypt(fileContent, password);
|
|
591
1222
|
if (outputFile) {
|
|
592
1223
|
writeFileSync5(outputFile, decrypted);
|
|
@@ -599,7 +1230,7 @@ async function main() {
|
|
|
599
1230
|
if (!args[1]) usage();
|
|
600
1231
|
const inputFile = args[1];
|
|
601
1232
|
const password = await getPassword();
|
|
602
|
-
const fileContent =
|
|
1233
|
+
const fileContent = readFileSync7(inputFile, "utf-8");
|
|
603
1234
|
const valid = await verifySeal(fileContent, password);
|
|
604
1235
|
if (valid) {
|
|
605
1236
|
console.error("Seal verified: OK");
|
|
@@ -615,7 +1246,7 @@ async function main() {
|
|
|
615
1246
|
break;
|
|
616
1247
|
case "mark": {
|
|
617
1248
|
if (!args[1]) {
|
|
618
|
-
console.error(
|
|
1249
|
+
console.error('Usage: mdenc mark <directory>\n\nRun "mdenc mark --help" for details.');
|
|
619
1250
|
process.exit(1);
|
|
620
1251
|
}
|
|
621
1252
|
markCommand(args[1]);
|
|
@@ -624,27 +1255,31 @@ async function main() {
|
|
|
624
1255
|
case "status":
|
|
625
1256
|
statusCommand();
|
|
626
1257
|
break;
|
|
627
|
-
case "
|
|
628
|
-
|
|
629
|
-
break;
|
|
630
|
-
case "remove-hooks":
|
|
631
|
-
removeHooksCommand();
|
|
1258
|
+
case "remove-filter":
|
|
1259
|
+
removeFilterCommand();
|
|
632
1260
|
break;
|
|
633
1261
|
case "genpass":
|
|
634
1262
|
genpassCommand(args.includes("--force"));
|
|
635
1263
|
break;
|
|
636
|
-
// Git
|
|
637
|
-
case "
|
|
638
|
-
await
|
|
1264
|
+
// Git filter commands (called by git, not directly by user)
|
|
1265
|
+
case "filter-process":
|
|
1266
|
+
await filterProcessMain();
|
|
639
1267
|
break;
|
|
640
|
-
case "
|
|
641
|
-
await
|
|
1268
|
+
case "filter-clean":
|
|
1269
|
+
await simpleCleanFilter(args[1] ?? "");
|
|
642
1270
|
break;
|
|
643
|
-
case "
|
|
644
|
-
await
|
|
1271
|
+
case "filter-smudge":
|
|
1272
|
+
await simpleSmudgeFilter();
|
|
645
1273
|
break;
|
|
646
|
-
case "
|
|
647
|
-
await
|
|
1274
|
+
case "diff-driver":
|
|
1275
|
+
await diffDriverCommand(args.slice(1));
|
|
1276
|
+
break;
|
|
1277
|
+
case "textconv":
|
|
1278
|
+
if (!args[1]) {
|
|
1279
|
+
console.error("Usage: mdenc textconv <file>");
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
await textconvCommand(args[1]);
|
|
648
1283
|
break;
|
|
649
1284
|
default:
|
|
650
1285
|
console.error(`Unknown command: ${command}`);
|