mdenc 0.1.2 → 0.1.5
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/dist/cli.js +939 -371
- 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 -3
- package/dist/index.d.ts +2 -3
- package/dist/index.js +144 -126
- package/dist/index.js.map +1 -1
- package/package.json +10 -9
- package/dist/chunk-KNJGGFI4.js +0 -672
- package/dist/chunk-KNJGGFI4.js.map +0 -1
- package/dist/hooks-FL46SI4A.js +0 -16
- package/dist/hooks-FL46SI4A.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-KNJGGFI4.js";
|
|
26
2
|
|
|
27
3
|
// src/cli.ts
|
|
28
|
-
import { readFileSync as
|
|
4
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync4 } 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,480 @@ 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 { readFileSync
|
|
439
|
+
// src/git/password.ts
|
|
440
|
+
import { readFileSync } from "fs";
|
|
69
441
|
import { join } from "path";
|
|
70
|
-
var
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
mdenc ${hookName}
|
|
82
|
-
elif [ -x "./node_modules/.bin/mdenc" ]; then
|
|
83
|
-
./node_modules/.bin/mdenc ${hookName}
|
|
84
|
-
else
|
|
85
|
-
echo "mdenc: not found, skipping ${hookName} hook" >&2
|
|
86
|
-
fi`;
|
|
87
|
-
}
|
|
88
|
-
function newHookScript(hookName) {
|
|
89
|
-
return `#!/bin/sh${hookBlock(hookName)}
|
|
90
|
-
`;
|
|
91
|
-
}
|
|
92
|
-
function isBinary(content) {
|
|
93
|
-
return content.slice(0, 512).includes("\0");
|
|
94
|
-
}
|
|
95
|
-
function hasShellShebang(content) {
|
|
96
|
-
const firstLine = content.split("\n")[0];
|
|
97
|
-
return /^#!.*\b(sh|bash|zsh|dash)\b/.test(firstLine);
|
|
98
|
-
}
|
|
99
|
-
function looksLikeFrameworkHook(content) {
|
|
100
|
-
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
|
|
101
|
-
if (lines.length <= 2) {
|
|
102
|
-
return lines.some((l) => /^\.\s+"/.test(l.trim()) || /^exec\s+/.test(l.trim()));
|
|
103
|
-
}
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
function printManualInstructions(hookName) {
|
|
107
|
-
process.stderr.write(
|
|
108
|
-
`mdenc: ${hookName} hook exists but has an unrecognized format.
|
|
109
|
-
Add the following to your hook manually:
|
|
442
|
+
var PASSWORD_FILE = ".mdenc-password";
|
|
443
|
+
function resolvePassword(repoRoot) {
|
|
444
|
+
const envPassword = process.env["MDENC_PASSWORD"];
|
|
445
|
+
if (envPassword) return envPassword;
|
|
446
|
+
try {
|
|
447
|
+
const content = readFileSync(join(repoRoot, PASSWORD_FILE), "utf-8").trim();
|
|
448
|
+
if (content.length > 0) return content;
|
|
449
|
+
} catch {
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
110
453
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
454
|
+
// src/git/utils.ts
|
|
455
|
+
import { execFileSync } from "child_process";
|
|
456
|
+
import { readdirSync, statSync } from "fs";
|
|
457
|
+
import { join as join2 } from "path";
|
|
458
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
459
|
+
var MARKER_FILE = ".mdenc.conf";
|
|
460
|
+
function findGitRoot() {
|
|
461
|
+
try {
|
|
462
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
463
|
+
encoding: "utf-8",
|
|
464
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
465
|
+
}).trim();
|
|
466
|
+
} catch {
|
|
467
|
+
throw new Error("Not a git repository");
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function gitShow(repoRoot, ref, path) {
|
|
471
|
+
try {
|
|
472
|
+
return execFileSync("git", ["show", `${ref}:${path}`], {
|
|
473
|
+
cwd: repoRoot,
|
|
474
|
+
encoding: "utf-8",
|
|
475
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
476
|
+
});
|
|
477
|
+
} catch {
|
|
478
|
+
return void 0;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
function findMarkedDirs(repoRoot) {
|
|
482
|
+
const results = [];
|
|
483
|
+
walkForMarker(repoRoot, results);
|
|
484
|
+
return results;
|
|
485
|
+
}
|
|
486
|
+
function walkForMarker(dir, results) {
|
|
487
|
+
let entries;
|
|
488
|
+
try {
|
|
489
|
+
entries = readdirSync(dir);
|
|
490
|
+
} catch {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (entries.includes(MARKER_FILE)) {
|
|
494
|
+
results.push(dir);
|
|
495
|
+
}
|
|
496
|
+
for (const entry of entries) {
|
|
497
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
498
|
+
const full = join2(dir, entry);
|
|
499
|
+
try {
|
|
500
|
+
if (statSync(full).isDirectory()) {
|
|
501
|
+
walkForMarker(full, results);
|
|
502
|
+
}
|
|
503
|
+
} catch {
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function getMdFilesInDir(dir) {
|
|
508
|
+
try {
|
|
509
|
+
return readdirSync(dir).filter((f) => f.endsWith(".md") && statSync(join2(dir, f)).isFile());
|
|
510
|
+
} catch {
|
|
511
|
+
return [];
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function gitAdd(repoRoot, files) {
|
|
515
|
+
if (files.length === 0) return;
|
|
516
|
+
execFileSync("git", ["add", "--", ...files], {
|
|
517
|
+
cwd: repoRoot,
|
|
518
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
519
|
+
});
|
|
520
|
+
}
|
|
117
521
|
|
|
118
|
-
|
|
119
|
-
|
|
522
|
+
// src/git/filter.ts
|
|
523
|
+
async function cleanFilter(pathname, plaintext, password, repoRoot) {
|
|
524
|
+
const previousFile = gitShow(repoRoot, "HEAD", pathname);
|
|
525
|
+
return encrypt(plaintext, password, previousFile ? { previousFile } : {});
|
|
120
526
|
}
|
|
121
|
-
async function
|
|
527
|
+
async function smudgeFilter(content, password) {
|
|
528
|
+
if (!password || !content.startsWith("mdenc:v1")) {
|
|
529
|
+
return content;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
return await decrypt(content, password);
|
|
533
|
+
} catch {
|
|
534
|
+
return content;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async function simpleCleanFilter(pathname) {
|
|
122
538
|
const repoRoot = findGitRoot();
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
539
|
+
const password = resolvePassword(repoRoot);
|
|
540
|
+
if (!password) {
|
|
541
|
+
process.stderr.write("mdenc: no password available, cannot encrypt\n");
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
const input = await readStdin();
|
|
545
|
+
const encrypted = await cleanFilter(pathname, input, password, repoRoot);
|
|
546
|
+
process.stdout.write(encrypted);
|
|
547
|
+
}
|
|
548
|
+
async function simpleSmudgeFilter() {
|
|
549
|
+
const repoRoot = findGitRoot();
|
|
550
|
+
const password = resolvePassword(repoRoot);
|
|
551
|
+
const input = await readStdin();
|
|
552
|
+
const output = await smudgeFilter(input, password);
|
|
553
|
+
process.stdout.write(output);
|
|
554
|
+
}
|
|
555
|
+
function readStdin() {
|
|
556
|
+
return new Promise((resolve2) => {
|
|
557
|
+
const chunks = [];
|
|
558
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
559
|
+
process.stdin.on("end", () => resolve2(Buffer.concat(chunks).toString("utf-8")));
|
|
560
|
+
process.stdin.resume();
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/git/filter-process.ts
|
|
565
|
+
var FLUSH = Buffer.from("0000", "ascii");
|
|
566
|
+
var MAX_PKT_DATA = 65516;
|
|
567
|
+
function writePktLine(data) {
|
|
568
|
+
const payload = Buffer.from(data, "utf-8");
|
|
569
|
+
const len = (payload.length + 4).toString(16).padStart(4, "0");
|
|
570
|
+
process.stdout.write(len, "ascii");
|
|
571
|
+
process.stdout.write(payload);
|
|
572
|
+
}
|
|
573
|
+
function writeFlush() {
|
|
574
|
+
process.stdout.write(FLUSH);
|
|
575
|
+
}
|
|
576
|
+
function writeBinaryPktLines(data) {
|
|
577
|
+
let offset = 0;
|
|
578
|
+
while (offset < data.length) {
|
|
579
|
+
const chunk = data.subarray(offset, offset + MAX_PKT_DATA);
|
|
580
|
+
const len = (chunk.length + 4).toString(16).padStart(4, "0");
|
|
581
|
+
process.stdout.write(len, "ascii");
|
|
582
|
+
process.stdout.write(chunk);
|
|
583
|
+
offset += chunk.length;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
var PktLineReader = class {
|
|
587
|
+
buf = Buffer.alloc(0);
|
|
588
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: standard Promise resolve signature
|
|
589
|
+
resolveWait = null;
|
|
590
|
+
ended = false;
|
|
591
|
+
constructor(stream) {
|
|
592
|
+
stream.on("data", (chunk) => {
|
|
593
|
+
this.buf = Buffer.concat([this.buf, chunk]);
|
|
594
|
+
if (this.resolveWait) {
|
|
595
|
+
const r = this.resolveWait;
|
|
596
|
+
this.resolveWait = null;
|
|
597
|
+
r();
|
|
598
|
+
}
|
|
599
|
+
});
|
|
600
|
+
stream.on("end", () => {
|
|
601
|
+
this.ended = true;
|
|
602
|
+
if (this.resolveWait) {
|
|
603
|
+
const r = this.resolveWait;
|
|
604
|
+
this.resolveWait = null;
|
|
605
|
+
r();
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
stream.resume();
|
|
609
|
+
}
|
|
610
|
+
async waitForData() {
|
|
611
|
+
if (this.buf.length > 0 || this.ended) return;
|
|
612
|
+
return new Promise((resolve2) => {
|
|
613
|
+
this.resolveWait = resolve2;
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
async readExact(n) {
|
|
617
|
+
while (this.buf.length < n) {
|
|
618
|
+
if (this.ended) return null;
|
|
619
|
+
await this.waitForData();
|
|
131
620
|
}
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
621
|
+
const result = this.buf.subarray(0, n);
|
|
622
|
+
this.buf = this.buf.subarray(n);
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
/** Read one pkt-line. Returns string for data, null for flush, undefined for EOF. */
|
|
626
|
+
async readPacket() {
|
|
627
|
+
const lenBuf = await this.readExact(4);
|
|
628
|
+
if (!lenBuf) return void 0;
|
|
629
|
+
const lenStr = lenBuf.toString("ascii");
|
|
630
|
+
const len = parseInt(lenStr, 16);
|
|
631
|
+
if (len === 0) return null;
|
|
632
|
+
if (len <= 4) throw new Error(`Invalid pkt-line length: ${len}`);
|
|
633
|
+
const payload = await this.readExact(len - 4);
|
|
634
|
+
if (!payload) throw new Error("Unexpected EOF in pkt-line payload");
|
|
635
|
+
return payload.toString("utf-8");
|
|
636
|
+
}
|
|
637
|
+
/** Read lines until flush. Returns array of strings (newlines stripped). */
|
|
638
|
+
async readUntilFlush() {
|
|
639
|
+
const lines = [];
|
|
640
|
+
while (true) {
|
|
641
|
+
const pkt = await this.readPacket();
|
|
642
|
+
if (pkt === null || pkt === void 0) break;
|
|
643
|
+
lines.push(pkt.replace(/\n$/, ""));
|
|
136
644
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
645
|
+
return lines;
|
|
646
|
+
}
|
|
647
|
+
/** Read binary content until flush. Returns concatenated buffer. */
|
|
648
|
+
async readContentUntilFlush() {
|
|
649
|
+
const chunks = [];
|
|
650
|
+
while (true) {
|
|
651
|
+
const lenBuf = await this.readExact(4);
|
|
652
|
+
if (!lenBuf) break;
|
|
653
|
+
const len = parseInt(lenBuf.toString("ascii"), 16);
|
|
654
|
+
if (len === 0) break;
|
|
655
|
+
if (len <= 4) throw new Error(`Invalid pkt-line length: ${len}`);
|
|
656
|
+
const payload = await this.readExact(len - 4);
|
|
657
|
+
if (!payload) throw new Error("Unexpected EOF in pkt-line content");
|
|
658
|
+
chunks.push(payload);
|
|
144
659
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
660
|
+
return Buffer.concat(chunks);
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
async function filterProcessMain() {
|
|
664
|
+
const repoRoot = findGitRoot();
|
|
665
|
+
const password = resolvePassword(repoRoot);
|
|
666
|
+
const reader = new PktLineReader(process.stdin);
|
|
667
|
+
const welcome = await reader.readUntilFlush();
|
|
668
|
+
if (!welcome.includes("git-filter-client") || !welcome.includes("version=2")) {
|
|
669
|
+
process.stderr.write("mdenc: invalid filter protocol handshake\n");
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
writePktLine("git-filter-server\n");
|
|
673
|
+
writePktLine("version=2\n");
|
|
674
|
+
writeFlush();
|
|
675
|
+
const caps = await reader.readUntilFlush();
|
|
676
|
+
if (caps.includes("capability=clean")) writePktLine("capability=clean\n");
|
|
677
|
+
if (caps.includes("capability=smudge")) writePktLine("capability=smudge\n");
|
|
678
|
+
writeFlush();
|
|
679
|
+
while (true) {
|
|
680
|
+
const commandLines = await reader.readUntilFlush();
|
|
681
|
+
if (commandLines.length === 0) break;
|
|
682
|
+
let cmd = "";
|
|
683
|
+
let pathname = "";
|
|
684
|
+
for (const line of commandLines) {
|
|
685
|
+
if (line.startsWith("command=")) cmd = line.slice("command=".length);
|
|
686
|
+
if (line.startsWith("pathname=")) pathname = line.slice("pathname=".length);
|
|
152
687
|
}
|
|
153
|
-
|
|
688
|
+
const content = await reader.readContentUntilFlush();
|
|
689
|
+
const contentStr = content.toString("utf-8");
|
|
690
|
+
try {
|
|
691
|
+
let result;
|
|
692
|
+
if (cmd === "clean") {
|
|
693
|
+
if (!password) {
|
|
694
|
+
writePktLine("status=error\n");
|
|
695
|
+
writeFlush();
|
|
696
|
+
writeFlush();
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
result = await cleanFilter(pathname, contentStr, password, repoRoot);
|
|
700
|
+
} else if (cmd === "smudge") {
|
|
701
|
+
result = await smudgeFilter(contentStr, password);
|
|
702
|
+
} else {
|
|
703
|
+
process.stderr.write(`mdenc: unknown filter command: ${cmd}
|
|
704
|
+
`);
|
|
705
|
+
writePktLine("status=error\n");
|
|
706
|
+
writeFlush();
|
|
707
|
+
writeFlush();
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const resultBuf = Buffer.from(result, "utf-8");
|
|
711
|
+
writePktLine("status=success\n");
|
|
712
|
+
writeFlush();
|
|
713
|
+
writeBinaryPktLines(resultBuf);
|
|
714
|
+
writeFlush();
|
|
715
|
+
} catch (err) {
|
|
154
716
|
process.stderr.write(
|
|
155
|
-
`mdenc: ${
|
|
717
|
+
`mdenc: filter error for ${pathname}: ${err instanceof Error ? err.message : err}
|
|
156
718
|
`
|
|
157
719
|
);
|
|
158
|
-
|
|
159
|
-
|
|
720
|
+
writePktLine("status=error\n");
|
|
721
|
+
writeFlush();
|
|
722
|
+
writeFlush();
|
|
160
723
|
}
|
|
161
|
-
writeFileSync(hookPath, content.trimEnd() + "\n" + hookBlock(hookName) + "\n");
|
|
162
|
-
chmodSync(hookPath, 493);
|
|
163
|
-
console.log(`Appended mdenc to existing ${hookName} hook`);
|
|
164
724
|
}
|
|
165
|
-
|
|
166
|
-
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// src/git/genpass.ts
|
|
728
|
+
import { existsSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
729
|
+
import { join as join3 } from "path";
|
|
730
|
+
import { randomBytes as randomBytes2 } from "@noble/ciphers/webcrypto";
|
|
731
|
+
var PASSWORD_FILE2 = ".mdenc-password";
|
|
732
|
+
function genpassCommand(force) {
|
|
733
|
+
const repoRoot = findGitRoot();
|
|
734
|
+
const passwordPath = join3(repoRoot, PASSWORD_FILE2);
|
|
735
|
+
if (existsSync(passwordPath) && !force) {
|
|
736
|
+
console.error(`${PASSWORD_FILE2} already exists. Use --force to overwrite.`);
|
|
737
|
+
process.exit(1);
|
|
738
|
+
}
|
|
739
|
+
const password = Buffer.from(randomBytes2(32)).toString("base64url");
|
|
740
|
+
writeFileSync(passwordPath, `${password}
|
|
741
|
+
`, { mode: 384 });
|
|
742
|
+
console.error(`Generated password and wrote to ${PASSWORD_FILE2}`);
|
|
743
|
+
console.error(password);
|
|
744
|
+
const gitignorePath = join3(repoRoot, ".gitignore");
|
|
745
|
+
const entry = PASSWORD_FILE2;
|
|
167
746
|
if (existsSync(gitignorePath)) {
|
|
168
|
-
const content =
|
|
747
|
+
const content = readFileSync2(gitignorePath, "utf-8");
|
|
169
748
|
const lines = content.split("\n").map((l) => l.trim());
|
|
170
749
|
if (!lines.includes(entry)) {
|
|
171
|
-
writeFileSync(gitignorePath, content.trimEnd()
|
|
750
|
+
writeFileSync(gitignorePath, `${content.trimEnd()}
|
|
751
|
+
${entry}
|
|
752
|
+
`);
|
|
753
|
+
console.error("Added .mdenc-password to .gitignore");
|
|
754
|
+
}
|
|
755
|
+
} else {
|
|
756
|
+
writeFileSync(gitignorePath, `${entry}
|
|
757
|
+
`);
|
|
758
|
+
console.error("Created .gitignore with .mdenc-password");
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// src/git/init.ts
|
|
763
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
764
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
765
|
+
import { join as join4 } from "path";
|
|
766
|
+
var FILTER_CONFIGS = [
|
|
767
|
+
["filter.mdenc.process", "mdenc filter-process"],
|
|
768
|
+
["filter.mdenc.clean", "mdenc filter-clean %f"],
|
|
769
|
+
["filter.mdenc.smudge", "mdenc filter-smudge %f"],
|
|
770
|
+
["filter.mdenc.required", "true"],
|
|
771
|
+
["diff.mdenc.textconv", "mdenc textconv"]
|
|
772
|
+
];
|
|
773
|
+
function configureGitFilter(repoRoot) {
|
|
774
|
+
for (const [key, value] of FILTER_CONFIGS) {
|
|
775
|
+
execFileSync2("git", ["config", "--local", key, value], {
|
|
776
|
+
cwd: repoRoot,
|
|
777
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
function isFilterConfigured(repoRoot) {
|
|
782
|
+
try {
|
|
783
|
+
const val = execFileSync2("git", ["config", "--get", "filter.mdenc.process"], {
|
|
784
|
+
cwd: repoRoot,
|
|
785
|
+
encoding: "utf-8",
|
|
786
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
787
|
+
}).trim();
|
|
788
|
+
return val.length > 0;
|
|
789
|
+
} catch {
|
|
790
|
+
return false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
async function initCommand() {
|
|
794
|
+
const repoRoot = findGitRoot();
|
|
795
|
+
if (isFilterConfigured(repoRoot)) {
|
|
796
|
+
console.log("Git filter already configured (skipped)");
|
|
797
|
+
} else {
|
|
798
|
+
configureGitFilter(repoRoot);
|
|
799
|
+
console.log("Configured git filter (filter.mdenc + diff.mdenc)");
|
|
800
|
+
}
|
|
801
|
+
const gitignorePath = join4(repoRoot, ".gitignore");
|
|
802
|
+
const entry = ".mdenc-password";
|
|
803
|
+
if (existsSync2(gitignorePath)) {
|
|
804
|
+
const content = readFileSync3(gitignorePath, "utf-8");
|
|
805
|
+
const lines = content.split("\n").map((l) => l.trim());
|
|
806
|
+
if (!lines.includes(entry)) {
|
|
807
|
+
writeFileSync2(gitignorePath, `${content.trimEnd()}
|
|
808
|
+
${entry}
|
|
809
|
+
`);
|
|
172
810
|
console.log("Added .mdenc-password to .gitignore");
|
|
173
811
|
} else {
|
|
174
812
|
console.log(".mdenc-password already in .gitignore (skipped)");
|
|
175
813
|
}
|
|
176
814
|
} else {
|
|
177
|
-
|
|
815
|
+
writeFileSync2(gitignorePath, `${entry}
|
|
816
|
+
`);
|
|
178
817
|
console.log("Created .gitignore with .mdenc-password");
|
|
179
818
|
}
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
819
|
+
const markedDirs = findMarkedDirs(repoRoot);
|
|
820
|
+
if (markedDirs.length > 0) {
|
|
821
|
+
const { relative: relative3 } = await import("path");
|
|
822
|
+
for (const dir of markedDirs) {
|
|
823
|
+
const relDir = relative3(repoRoot, dir) || ".";
|
|
824
|
+
try {
|
|
825
|
+
execFileSync2("git", ["checkout", "HEAD", "--", `${relDir}/*.md`], {
|
|
826
|
+
cwd: repoRoot,
|
|
827
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
828
|
+
});
|
|
829
|
+
} catch {
|
|
830
|
+
}
|
|
831
|
+
}
|
|
184
832
|
}
|
|
185
833
|
console.log("mdenc git integration initialized.");
|
|
186
834
|
}
|
|
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`);
|
|
835
|
+
function removeFilterCommand() {
|
|
836
|
+
const repoRoot = findGitRoot();
|
|
837
|
+
for (const section of ["filter.mdenc", "diff.mdenc"]) {
|
|
838
|
+
try {
|
|
839
|
+
execFileSync2("git", ["config", "--local", "--remove-section", section], {
|
|
840
|
+
cwd: repoRoot,
|
|
841
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
842
|
+
});
|
|
843
|
+
} catch {
|
|
223
844
|
}
|
|
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
845
|
}
|
|
846
|
+
console.log("Removed git filter configuration.");
|
|
231
847
|
}
|
|
232
848
|
|
|
233
849
|
// src/git/mark.ts
|
|
234
|
-
import {
|
|
235
|
-
import {
|
|
236
|
-
|
|
850
|
+
import { execFileSync as execFileSync3 } from "child_process";
|
|
851
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
852
|
+
import { join as join5, relative, resolve } from "path";
|
|
853
|
+
var MARKER_FILE2 = ".mdenc.conf";
|
|
237
854
|
var MARKER_CONTENT = "# mdenc: .md files in this directory are automatically encrypted\n";
|
|
238
|
-
var
|
|
855
|
+
var GITATTR_PATTERN = "*.md filter=mdenc diff=mdenc";
|
|
856
|
+
function isFilterConfigured2(repoRoot) {
|
|
857
|
+
try {
|
|
858
|
+
const val = execFileSync3("git", ["config", "--get", "filter.mdenc.process"], {
|
|
859
|
+
cwd: repoRoot,
|
|
860
|
+
encoding: "utf-8",
|
|
861
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
862
|
+
}).trim();
|
|
863
|
+
return val.length > 0;
|
|
864
|
+
} catch {
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
function markUsage() {
|
|
869
|
+
console.error(`Usage: mdenc mark <directory>
|
|
870
|
+
|
|
871
|
+
Mark a directory so that all *.md files inside it are automatically
|
|
872
|
+
encrypted when staged (git add) and decrypted when checked out.
|
|
873
|
+
|
|
874
|
+
What it does:
|
|
875
|
+
1. Creates <directory>/.mdenc.conf Marker file for mdenc to discover
|
|
876
|
+
2. Creates <directory>/.gitattributes Assigns the mdenc filter to *.md files
|
|
877
|
+
3. Stages both files in git
|
|
878
|
+
|
|
879
|
+
Prerequisites:
|
|
880
|
+
Run "mdenc init" first to configure the git filter in this clone.
|
|
881
|
+
Run "mdenc genpass" to generate a password (or set MDENC_PASSWORD).
|
|
882
|
+
|
|
883
|
+
Example:
|
|
884
|
+
mdenc init
|
|
885
|
+
mdenc genpass
|
|
886
|
+
mdenc mark docs/private
|
|
887
|
+
echo "# Secret" > docs/private/notes.md
|
|
888
|
+
git add docs/private/notes.md # encrypted automatically`);
|
|
889
|
+
process.exit(1);
|
|
890
|
+
}
|
|
239
891
|
function markCommand(dirArg) {
|
|
892
|
+
if (dirArg === "--help" || dirArg === "-h") {
|
|
893
|
+
markUsage();
|
|
894
|
+
}
|
|
240
895
|
const repoRoot = findGitRoot();
|
|
241
896
|
const dir = resolve(dirArg);
|
|
242
|
-
if (!
|
|
897
|
+
if (!existsSync3(dir)) {
|
|
243
898
|
console.error(`Error: directory "${dirArg}" does not exist`);
|
|
244
899
|
process.exit(1);
|
|
245
900
|
}
|
|
@@ -249,54 +904,63 @@ function markCommand(dirArg) {
|
|
|
249
904
|
process.exit(1);
|
|
250
905
|
}
|
|
251
906
|
const relDir = rel || ".";
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
907
|
+
const filterReady = isFilterConfigured2(repoRoot);
|
|
908
|
+
const confPath = join5(dir, MARKER_FILE2);
|
|
909
|
+
if (!existsSync3(confPath)) {
|
|
910
|
+
writeFileSync3(confPath, MARKER_CONTENT);
|
|
911
|
+
console.log(`Created ${relDir}/${MARKER_FILE2}`);
|
|
256
912
|
} else {
|
|
257
|
-
console.log(`${relDir}/${
|
|
913
|
+
console.log(`${relDir}/${MARKER_FILE2} already exists (skipped)`);
|
|
258
914
|
}
|
|
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})`);
|
|
915
|
+
const gitattrsPath = join5(dir, ".gitattributes");
|
|
916
|
+
if (existsSync3(gitattrsPath)) {
|
|
917
|
+
const content = readFileSync4(gitattrsPath, "utf-8");
|
|
918
|
+
if (content.includes("filter=mdenc")) {
|
|
919
|
+
console.log(`${relDir}/.gitattributes already has filter=mdenc (skipped)`);
|
|
266
920
|
} else {
|
|
267
|
-
|
|
921
|
+
writeFileSync3(gitattrsPath, `${content.trimEnd()}
|
|
922
|
+
${GITATTR_PATTERN}
|
|
923
|
+
`);
|
|
924
|
+
console.log(`Updated ${relDir}/.gitattributes`);
|
|
268
925
|
}
|
|
269
926
|
} 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
|
-
}
|
|
927
|
+
writeFileSync3(gitattrsPath, `${GITATTR_PATTERN}
|
|
928
|
+
`);
|
|
929
|
+
console.log(`Created ${relDir}/.gitattributes`);
|
|
286
930
|
}
|
|
287
|
-
const toStage = [
|
|
288
|
-
relative(repoRoot, confPath),
|
|
289
|
-
relative(repoRoot, gitignorePath)
|
|
290
|
-
];
|
|
931
|
+
const toStage = [relative(repoRoot, confPath), relative(repoRoot, gitattrsPath)];
|
|
291
932
|
gitAdd(repoRoot, toStage);
|
|
292
933
|
console.log(`Marked ${relDir}/ for mdenc encryption`);
|
|
934
|
+
if (!filterReady) {
|
|
935
|
+
console.log(`
|
|
936
|
+
Warning: git filter not configured yet. Run "mdenc init" to enable encryption.`);
|
|
937
|
+
}
|
|
293
938
|
}
|
|
294
939
|
|
|
295
940
|
// src/git/status.ts
|
|
296
|
-
import {
|
|
297
|
-
import {
|
|
298
|
-
|
|
299
|
-
|
|
941
|
+
import { execFileSync as execFileSync4 } from "child_process";
|
|
942
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
|
|
943
|
+
import { join as join6, relative as relative2 } from "path";
|
|
944
|
+
function getFilterConfig(repoRoot) {
|
|
945
|
+
const get = (key) => {
|
|
946
|
+
try {
|
|
947
|
+
return execFileSync4("git", ["config", "--get", key], {
|
|
948
|
+
cwd: repoRoot,
|
|
949
|
+
encoding: "utf-8",
|
|
950
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
951
|
+
}).trim();
|
|
952
|
+
} catch {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
return {
|
|
957
|
+
process: get("filter.mdenc.process"),
|
|
958
|
+
clean: get("filter.mdenc.clean"),
|
|
959
|
+
smudge: get("filter.mdenc.smudge"),
|
|
960
|
+
required: get("filter.mdenc.required") === "true",
|
|
961
|
+
textconv: get("diff.mdenc.textconv")
|
|
962
|
+
};
|
|
963
|
+
}
|
|
300
964
|
function statusCommand() {
|
|
301
965
|
const repoRoot = findGitRoot();
|
|
302
966
|
const password = resolvePassword(repoRoot);
|
|
@@ -310,36 +974,24 @@ function statusCommand() {
|
|
|
310
974
|
const relDir = relative2(repoRoot, dir) || ".";
|
|
311
975
|
console.log(` ${relDir}/`);
|
|
312
976
|
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
|
-
}
|
|
977
|
+
for (const f of mdFiles) {
|
|
978
|
+
const content = readFileSync5(join6(dir, f), "utf-8");
|
|
979
|
+
if (content.startsWith("mdenc:v1")) {
|
|
980
|
+
console.log(` ${f} [encrypted \u2014 needs smudge]`);
|
|
327
981
|
} else {
|
|
328
|
-
console.log(` ${
|
|
982
|
+
console.log(` ${f} [plaintext]`);
|
|
329
983
|
}
|
|
330
984
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
console.log(` ${base}.mdenc [needs decryption]`);
|
|
334
|
-
}
|
|
985
|
+
if (mdFiles.length === 0) {
|
|
986
|
+
console.log(" (no .md files)");
|
|
335
987
|
}
|
|
336
|
-
const
|
|
337
|
-
if (!
|
|
338
|
-
console.log(
|
|
988
|
+
const gitattrsPath = join6(dir, ".gitattributes");
|
|
989
|
+
if (!existsSync4(gitattrsPath)) {
|
|
990
|
+
console.log(" WARNING: no .gitattributes in this directory");
|
|
339
991
|
} else {
|
|
340
|
-
const content =
|
|
341
|
-
if (!content.
|
|
342
|
-
console.log(
|
|
992
|
+
const content = readFileSync5(gitattrsPath, "utf-8");
|
|
993
|
+
if (!content.includes("filter=mdenc")) {
|
|
994
|
+
console.log(" WARNING: .gitattributes missing filter=mdenc pattern");
|
|
343
995
|
}
|
|
344
996
|
}
|
|
345
997
|
console.log();
|
|
@@ -351,127 +1003,37 @@ function statusCommand() {
|
|
|
351
1003
|
} else {
|
|
352
1004
|
console.log("Password: available");
|
|
353
1005
|
}
|
|
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');
|
|
1006
|
+
const config = getFilterConfig(repoRoot);
|
|
1007
|
+
if (!config.process && !config.clean) {
|
|
1008
|
+
console.log("Filter: NOT CONFIGURED");
|
|
1009
|
+
console.log(' Run "mdenc init" to configure');
|
|
370
1010
|
} else {
|
|
371
|
-
console.log("
|
|
1011
|
+
console.log("Filter: configured");
|
|
1012
|
+
if (!config.required) {
|
|
1013
|
+
console.log(" WARNING: filter.mdenc.required is not set to true");
|
|
1014
|
+
}
|
|
372
1015
|
}
|
|
373
1016
|
}
|
|
374
1017
|
|
|
375
|
-
// src/git/
|
|
376
|
-
import { readFileSync as
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
1018
|
+
// src/git/textconv.ts
|
|
1019
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
1020
|
+
async function textconvCommand(filePath) {
|
|
1021
|
+
const content = readFileSync6(filePath, "utf-8");
|
|
1022
|
+
if (!content.startsWith("mdenc:v1")) {
|
|
1023
|
+
process.stdout.write(content);
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
380
1026
|
const repoRoot = findGitRoot();
|
|
381
1027
|
const password = resolvePassword(repoRoot);
|
|
382
1028
|
if (!password) {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
);
|
|
386
|
-
process.exit(1);
|
|
387
|
-
}
|
|
388
|
-
const markedDirs = findMarkedDirs(repoRoot);
|
|
389
|
-
if (markedDirs.length === 0) {
|
|
390
|
-
console.error("mdenc: no marked directories found");
|
|
391
|
-
process.exit(1);
|
|
1029
|
+
process.stdout.write(content);
|
|
1030
|
+
return;
|
|
392
1031
|
}
|
|
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
1032
|
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");
|
|
1033
|
+
const plaintext = await decrypt(content, password);
|
|
1034
|
+
process.stdout.write(plaintext);
|
|
1035
|
+
} catch {
|
|
1036
|
+
process.stdout.write(content);
|
|
475
1037
|
}
|
|
476
1038
|
}
|
|
477
1039
|
|
|
@@ -551,12 +1113,17 @@ function usage() {
|
|
|
551
1113
|
mdenc verify <file> Verify file integrity
|
|
552
1114
|
|
|
553
1115
|
Git integration:
|
|
554
|
-
mdenc init Set up git
|
|
1116
|
+
mdenc init Set up git filter for automatic encryption
|
|
555
1117
|
mdenc mark <directory> Mark a directory for encryption
|
|
556
1118
|
mdenc status Show encryption status
|
|
557
|
-
mdenc
|
|
558
|
-
mdenc
|
|
559
|
-
|
|
1119
|
+
mdenc remove-filter Remove git filter configuration
|
|
1120
|
+
mdenc genpass [--force] Generate a random password into .mdenc-password
|
|
1121
|
+
|
|
1122
|
+
Internal (called by git):
|
|
1123
|
+
mdenc filter-process Long-running filter process
|
|
1124
|
+
mdenc filter-clean <path> Single-file clean filter
|
|
1125
|
+
mdenc filter-smudge <path> Single-file smudge filter
|
|
1126
|
+
mdenc textconv <file> Output plaintext for git diff`);
|
|
560
1127
|
process.exit(1);
|
|
561
1128
|
}
|
|
562
1129
|
async function main() {
|
|
@@ -571,10 +1138,10 @@ async function main() {
|
|
|
571
1138
|
const outputIdx = args.indexOf("-o");
|
|
572
1139
|
const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : void 0;
|
|
573
1140
|
const password = await getPasswordWithConfirmation();
|
|
574
|
-
const plaintext =
|
|
1141
|
+
const plaintext = readFileSync7(inputFile, "utf-8");
|
|
575
1142
|
const encrypted = await encrypt(plaintext, password);
|
|
576
1143
|
if (outputFile) {
|
|
577
|
-
|
|
1144
|
+
writeFileSync4(outputFile, encrypted);
|
|
578
1145
|
} else {
|
|
579
1146
|
process.stdout.write(encrypted);
|
|
580
1147
|
}
|
|
@@ -586,10 +1153,10 @@ async function main() {
|
|
|
586
1153
|
const outputIdx = args.indexOf("-o");
|
|
587
1154
|
const outputFile = outputIdx >= 0 ? args[outputIdx + 1] : void 0;
|
|
588
1155
|
const password = await getPassword();
|
|
589
|
-
const fileContent =
|
|
1156
|
+
const fileContent = readFileSync7(inputFile, "utf-8");
|
|
590
1157
|
const decrypted = await decrypt(fileContent, password);
|
|
591
1158
|
if (outputFile) {
|
|
592
|
-
|
|
1159
|
+
writeFileSync4(outputFile, decrypted);
|
|
593
1160
|
} else {
|
|
594
1161
|
process.stdout.write(decrypted);
|
|
595
1162
|
}
|
|
@@ -599,7 +1166,7 @@ async function main() {
|
|
|
599
1166
|
if (!args[1]) usage();
|
|
600
1167
|
const inputFile = args[1];
|
|
601
1168
|
const password = await getPassword();
|
|
602
|
-
const fileContent =
|
|
1169
|
+
const fileContent = readFileSync7(inputFile, "utf-8");
|
|
603
1170
|
const valid = await verifySeal(fileContent, password);
|
|
604
1171
|
if (valid) {
|
|
605
1172
|
console.error("Seal verified: OK");
|
|
@@ -615,7 +1182,7 @@ async function main() {
|
|
|
615
1182
|
break;
|
|
616
1183
|
case "mark": {
|
|
617
1184
|
if (!args[1]) {
|
|
618
|
-
console.error(
|
|
1185
|
+
console.error('Usage: mdenc mark <directory>\n\nRun "mdenc mark --help" for details.');
|
|
619
1186
|
process.exit(1);
|
|
620
1187
|
}
|
|
621
1188
|
markCommand(args[1]);
|
|
@@ -624,27 +1191,28 @@ async function main() {
|
|
|
624
1191
|
case "status":
|
|
625
1192
|
statusCommand();
|
|
626
1193
|
break;
|
|
627
|
-
case "
|
|
628
|
-
|
|
629
|
-
break;
|
|
630
|
-
case "remove-hooks":
|
|
631
|
-
removeHooksCommand();
|
|
1194
|
+
case "remove-filter":
|
|
1195
|
+
removeFilterCommand();
|
|
632
1196
|
break;
|
|
633
1197
|
case "genpass":
|
|
634
1198
|
genpassCommand(args.includes("--force"));
|
|
635
1199
|
break;
|
|
636
|
-
// Git
|
|
637
|
-
case "
|
|
638
|
-
await
|
|
1200
|
+
// Git filter commands (called by git, not directly by user)
|
|
1201
|
+
case "filter-process":
|
|
1202
|
+
await filterProcessMain();
|
|
639
1203
|
break;
|
|
640
|
-
case "
|
|
641
|
-
await
|
|
1204
|
+
case "filter-clean":
|
|
1205
|
+
await simpleCleanFilter(args[1] ?? "");
|
|
642
1206
|
break;
|
|
643
|
-
case "
|
|
644
|
-
await
|
|
1207
|
+
case "filter-smudge":
|
|
1208
|
+
await simpleSmudgeFilter();
|
|
645
1209
|
break;
|
|
646
|
-
case "
|
|
647
|
-
|
|
1210
|
+
case "textconv":
|
|
1211
|
+
if (!args[1]) {
|
|
1212
|
+
console.error("Usage: mdenc textconv <file>");
|
|
1213
|
+
process.exit(1);
|
|
1214
|
+
}
|
|
1215
|
+
await textconvCommand(args[1]);
|
|
648
1216
|
break;
|
|
649
1217
|
default:
|
|
650
1218
|
console.error(`Unknown command: ${command}`);
|