mdenc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +141 -0
- package/dist/chunk-FFRUPAVV.js +669 -0
- package/dist/chunk-FFRUPAVV.js.map +1 -0
- package/dist/cli.js +548 -0
- package/dist/cli.js.map +1 -0
- package/dist/hooks-ZO2DIE5U.js +16 -0
- package/dist/hooks-ZO2DIE5U.js.map +1 -0
- package/dist/index.cjs +455 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +42 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +424 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/git/hooks.ts
|
|
4
|
+
import { readFileSync as readFileSync2, writeFileSync, existsSync, statSync as statSync2 } from "fs";
|
|
5
|
+
import { join as join3, relative } from "path";
|
|
6
|
+
|
|
7
|
+
// src/encrypt.ts
|
|
8
|
+
import { hmac as hmac3 } from "@noble/hashes/hmac";
|
|
9
|
+
import { sha256 as sha2564 } from "@noble/hashes/sha256";
|
|
10
|
+
|
|
11
|
+
// src/chunking.ts
|
|
12
|
+
var DEFAULT_MAX_CHUNK_SIZE = 65536;
|
|
13
|
+
function chunkByParagraph(text, maxSize = DEFAULT_MAX_CHUNK_SIZE) {
|
|
14
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
15
|
+
if (normalized.length === 0) {
|
|
16
|
+
return [""];
|
|
17
|
+
}
|
|
18
|
+
const chunks = [];
|
|
19
|
+
const boundary = /\n{2,}/g;
|
|
20
|
+
let lastEnd = 0;
|
|
21
|
+
let match;
|
|
22
|
+
while ((match = boundary.exec(normalized)) !== null) {
|
|
23
|
+
const chunkEnd = match.index + match[0].length;
|
|
24
|
+
chunks.push(normalized.slice(lastEnd, chunkEnd));
|
|
25
|
+
lastEnd = chunkEnd;
|
|
26
|
+
}
|
|
27
|
+
if (lastEnd < normalized.length) {
|
|
28
|
+
chunks.push(normalized.slice(lastEnd));
|
|
29
|
+
} else if (chunks.length === 0) {
|
|
30
|
+
chunks.push(normalized);
|
|
31
|
+
}
|
|
32
|
+
const result = [];
|
|
33
|
+
for (const chunk of chunks) {
|
|
34
|
+
if (byteLength(chunk) <= maxSize) {
|
|
35
|
+
result.push(chunk);
|
|
36
|
+
} else {
|
|
37
|
+
result.push(...splitAtByteLimit(chunk, maxSize));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
function chunkByFixedSize(text, size) {
|
|
43
|
+
const normalized = text.replace(/\r\n/g, "\n");
|
|
44
|
+
if (normalized.length === 0) {
|
|
45
|
+
return [""];
|
|
46
|
+
}
|
|
47
|
+
const bytes = new TextEncoder().encode(normalized);
|
|
48
|
+
if (bytes.length <= size) {
|
|
49
|
+
return [normalized];
|
|
50
|
+
}
|
|
51
|
+
const chunks = [];
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let offset = 0;
|
|
54
|
+
while (offset < bytes.length) {
|
|
55
|
+
const end = Math.min(offset + size, bytes.length);
|
|
56
|
+
let adjusted = end;
|
|
57
|
+
if (adjusted < bytes.length) {
|
|
58
|
+
while (adjusted > offset && (bytes[adjusted] & 192) === 128) {
|
|
59
|
+
adjusted--;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
chunks.push(decoder.decode(bytes.slice(offset, adjusted)));
|
|
63
|
+
offset = adjusted;
|
|
64
|
+
}
|
|
65
|
+
return chunks;
|
|
66
|
+
}
|
|
67
|
+
function byteLength(s) {
|
|
68
|
+
return new TextEncoder().encode(s).length;
|
|
69
|
+
}
|
|
70
|
+
function splitAtByteLimit(text, maxSize) {
|
|
71
|
+
const bytes = new TextEncoder().encode(text);
|
|
72
|
+
const decoder = new TextDecoder();
|
|
73
|
+
const parts = [];
|
|
74
|
+
let offset = 0;
|
|
75
|
+
while (offset < bytes.length) {
|
|
76
|
+
let end = Math.min(offset + maxSize, bytes.length);
|
|
77
|
+
if (end < bytes.length) {
|
|
78
|
+
while (end > offset && (bytes[end] & 192) === 128) {
|
|
79
|
+
end--;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
parts.push(decoder.decode(bytes.slice(offset, end)));
|
|
83
|
+
offset = end;
|
|
84
|
+
}
|
|
85
|
+
return parts;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// src/kdf.ts
|
|
89
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
90
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
91
|
+
import { scrypt } from "@noble/hashes/scrypt";
|
|
92
|
+
|
|
93
|
+
// src/types.ts
|
|
94
|
+
var DEFAULT_SCRYPT_PARAMS = {
|
|
95
|
+
N: 16384,
|
|
96
|
+
r: 8,
|
|
97
|
+
p: 1
|
|
98
|
+
};
|
|
99
|
+
var SCRYPT_BOUNDS = {
|
|
100
|
+
N: { min: 1024, max: 1048576 },
|
|
101
|
+
// 2^10 – 2^20
|
|
102
|
+
r: { min: 1, max: 64 },
|
|
103
|
+
p: { min: 1, max: 16 }
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// src/crypto-utils.ts
|
|
107
|
+
function constantTimeEqual(a, b) {
|
|
108
|
+
if (a.length !== b.length) return false;
|
|
109
|
+
let diff = 0;
|
|
110
|
+
for (let i = 0; i < a.length; i++) {
|
|
111
|
+
diff |= a[i] ^ b[i];
|
|
112
|
+
}
|
|
113
|
+
return diff === 0;
|
|
114
|
+
}
|
|
115
|
+
function zeroize(...arrays) {
|
|
116
|
+
for (const arr of arrays) {
|
|
117
|
+
arr.fill(0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/kdf.ts
|
|
122
|
+
var ENC_INFO = new TextEncoder().encode("mdenc-v1-enc");
|
|
123
|
+
var HDR_INFO = new TextEncoder().encode("mdenc-v1-hdr");
|
|
124
|
+
var NONCE_INFO = new TextEncoder().encode("mdenc-v1-nonce");
|
|
125
|
+
function normalizePassword(password) {
|
|
126
|
+
const normalized = password.normalize("NFKC");
|
|
127
|
+
return new TextEncoder().encode(normalized);
|
|
128
|
+
}
|
|
129
|
+
function deriveMasterKey(password, salt, params = DEFAULT_SCRYPT_PARAMS) {
|
|
130
|
+
const passwordBytes = normalizePassword(password);
|
|
131
|
+
try {
|
|
132
|
+
return scrypt(passwordBytes, salt, {
|
|
133
|
+
N: params.N,
|
|
134
|
+
r: params.r,
|
|
135
|
+
p: params.p,
|
|
136
|
+
dkLen: 32
|
|
137
|
+
});
|
|
138
|
+
} finally {
|
|
139
|
+
zeroize(passwordBytes);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function deriveKeys(masterKey) {
|
|
143
|
+
const encKey = hkdf(sha256, masterKey, void 0, ENC_INFO, 32);
|
|
144
|
+
const headerKey = hkdf(sha256, masterKey, void 0, HDR_INFO, 32);
|
|
145
|
+
const nonceKey = hkdf(sha256, masterKey, void 0, NONCE_INFO, 32);
|
|
146
|
+
return { encKey, headerKey, nonceKey };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/aead.ts
|
|
150
|
+
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
|
151
|
+
import { hmac } from "@noble/hashes/hmac";
|
|
152
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha256";
|
|
153
|
+
var NONCE_LENGTH = 24;
|
|
154
|
+
function buildAAD(fileId) {
|
|
155
|
+
const fileIdHex = bytesToHex(fileId);
|
|
156
|
+
const aadString = `mdenc:v1
|
|
157
|
+
${fileIdHex}`;
|
|
158
|
+
return new TextEncoder().encode(aadString);
|
|
159
|
+
}
|
|
160
|
+
function deriveNonce(nonceKey, plaintext) {
|
|
161
|
+
const full = hmac(sha2562, nonceKey, plaintext);
|
|
162
|
+
return full.slice(0, NONCE_LENGTH);
|
|
163
|
+
}
|
|
164
|
+
function encryptChunk(encKey, nonceKey, plaintext, fileId) {
|
|
165
|
+
const nonce = deriveNonce(nonceKey, plaintext);
|
|
166
|
+
const aad = buildAAD(fileId);
|
|
167
|
+
const cipher = xchacha20poly1305(encKey, nonce, aad);
|
|
168
|
+
const ciphertext = cipher.encrypt(plaintext);
|
|
169
|
+
const result = new Uint8Array(NONCE_LENGTH + ciphertext.length);
|
|
170
|
+
result.set(nonce, 0);
|
|
171
|
+
result.set(ciphertext, NONCE_LENGTH);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
function decryptChunk(encKey, payload, fileId) {
|
|
175
|
+
if (payload.length < NONCE_LENGTH + 16) {
|
|
176
|
+
throw new Error("Chunk payload too short");
|
|
177
|
+
}
|
|
178
|
+
const nonce = payload.slice(0, NONCE_LENGTH);
|
|
179
|
+
const ciphertext = payload.slice(NONCE_LENGTH);
|
|
180
|
+
const aad = buildAAD(fileId);
|
|
181
|
+
const cipher = xchacha20poly1305(encKey, nonce, aad);
|
|
182
|
+
try {
|
|
183
|
+
return cipher.decrypt(ciphertext);
|
|
184
|
+
} catch {
|
|
185
|
+
throw new Error("Chunk authentication failed");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function bytesToHex(bytes) {
|
|
189
|
+
let hex = "";
|
|
190
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
191
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
192
|
+
}
|
|
193
|
+
return hex;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/header.ts
|
|
197
|
+
import { hmac as hmac2 } from "@noble/hashes/hmac";
|
|
198
|
+
import { sha256 as sha2563 } from "@noble/hashes/sha256";
|
|
199
|
+
import { randomBytes } from "@noble/ciphers/webcrypto";
|
|
200
|
+
function generateSalt() {
|
|
201
|
+
return randomBytes(16);
|
|
202
|
+
}
|
|
203
|
+
function generateFileId() {
|
|
204
|
+
return randomBytes(16);
|
|
205
|
+
}
|
|
206
|
+
function serializeHeader(header) {
|
|
207
|
+
const saltB64 = toBase64(header.salt);
|
|
208
|
+
const fileIdB64 = toBase64(header.fileId);
|
|
209
|
+
const { N, r, p } = header.scrypt;
|
|
210
|
+
return `mdenc:v1 salt_b64=${saltB64} file_id_b64=${fileIdB64} scrypt=N=${N},r=${r},p=${p}`;
|
|
211
|
+
}
|
|
212
|
+
function parseHeader(line) {
|
|
213
|
+
if (!line.startsWith("mdenc:v1 ")) {
|
|
214
|
+
throw new Error("Invalid header: missing mdenc:v1 prefix");
|
|
215
|
+
}
|
|
216
|
+
const saltMatch = line.match(/salt_b64=([A-Za-z0-9+/=]+)/);
|
|
217
|
+
if (!saltMatch) throw new Error("Invalid header: missing salt_b64");
|
|
218
|
+
const salt = fromBase64(saltMatch[1]);
|
|
219
|
+
if (salt.length !== 16) throw new Error("Invalid header: salt must be 16 bytes");
|
|
220
|
+
const fileIdMatch = line.match(/file_id_b64=([A-Za-z0-9+/=]+)/);
|
|
221
|
+
if (!fileIdMatch) throw new Error("Invalid header: missing file_id_b64");
|
|
222
|
+
const fileId = fromBase64(fileIdMatch[1]);
|
|
223
|
+
if (fileId.length !== 16) throw new Error("Invalid header: file_id must be 16 bytes");
|
|
224
|
+
const scryptMatch = line.match(/scrypt=N=(\d+),r=(\d+),p=(\d+)/);
|
|
225
|
+
if (!scryptMatch) throw new Error("Invalid header: missing scrypt parameters");
|
|
226
|
+
const scryptParams = {
|
|
227
|
+
N: parseInt(scryptMatch[1], 10),
|
|
228
|
+
r: parseInt(scryptMatch[2], 10),
|
|
229
|
+
p: parseInt(scryptMatch[3], 10)
|
|
230
|
+
};
|
|
231
|
+
validateScryptParams(scryptParams);
|
|
232
|
+
return { version: "v1", salt, fileId, scrypt: scryptParams };
|
|
233
|
+
}
|
|
234
|
+
function validateScryptParams(params) {
|
|
235
|
+
const { N, r, p } = SCRYPT_BOUNDS;
|
|
236
|
+
if (params.N < N.min || params.N > N.max) {
|
|
237
|
+
throw new Error(`Invalid scrypt N: ${params.N} (must be ${N.min}\u2013${N.max})`);
|
|
238
|
+
}
|
|
239
|
+
if (params.r < r.min || params.r > r.max) {
|
|
240
|
+
throw new Error(`Invalid scrypt r: ${params.r} (must be ${r.min}\u2013${r.max})`);
|
|
241
|
+
}
|
|
242
|
+
if (params.p < p.min || params.p > p.max) {
|
|
243
|
+
throw new Error(`Invalid scrypt p: ${params.p} (must be ${p.min}\u2013${p.max})`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function authenticateHeader(headerKey, headerLine) {
|
|
247
|
+
const headerBytes = new TextEncoder().encode(headerLine);
|
|
248
|
+
return hmac2(sha2563, headerKey, headerBytes);
|
|
249
|
+
}
|
|
250
|
+
function verifyHeader(headerKey, headerLine, hmacBytes) {
|
|
251
|
+
const computed = authenticateHeader(headerKey, headerLine);
|
|
252
|
+
return constantTimeEqual(computed, hmacBytes);
|
|
253
|
+
}
|
|
254
|
+
function toBase64(bytes) {
|
|
255
|
+
return Buffer.from(bytes).toString("base64");
|
|
256
|
+
}
|
|
257
|
+
function fromBase64(b64) {
|
|
258
|
+
return new Uint8Array(Buffer.from(b64, "base64"));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/encrypt.ts
|
|
262
|
+
async function encrypt(plaintext, password, options) {
|
|
263
|
+
const chunking = options?.chunking ?? "paragraph" /* Paragraph */;
|
|
264
|
+
const maxChunkSize = options?.maxChunkSize ?? 65536;
|
|
265
|
+
const scryptParams = options?.scrypt ?? DEFAULT_SCRYPT_PARAMS;
|
|
266
|
+
let chunks;
|
|
267
|
+
if (chunking === "fixed-size" /* FixedSize */) {
|
|
268
|
+
const fixedSize = options?.fixedChunkSize ?? 4096;
|
|
269
|
+
chunks = chunkByFixedSize(plaintext, fixedSize);
|
|
270
|
+
} else {
|
|
271
|
+
chunks = chunkByParagraph(plaintext, maxChunkSize);
|
|
272
|
+
}
|
|
273
|
+
let salt;
|
|
274
|
+
let fileId;
|
|
275
|
+
let masterKey;
|
|
276
|
+
const prev = options?.previousFile ? parsePreviousFileHeader(options.previousFile, password) : void 0;
|
|
277
|
+
if (prev) {
|
|
278
|
+
salt = prev.salt;
|
|
279
|
+
fileId = prev.fileId;
|
|
280
|
+
masterKey = prev.masterKey;
|
|
281
|
+
} else {
|
|
282
|
+
salt = generateSalt();
|
|
283
|
+
fileId = generateFileId();
|
|
284
|
+
masterKey = deriveMasterKey(password, salt, scryptParams);
|
|
285
|
+
}
|
|
286
|
+
const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
|
|
287
|
+
try {
|
|
288
|
+
const header = { version: "v1", salt, fileId, scrypt: scryptParams };
|
|
289
|
+
const headerLine = serializeHeader(header);
|
|
290
|
+
const headerHmac = authenticateHeader(headerKey, headerLine);
|
|
291
|
+
const headerAuthLine = `hdrauth_b64=${toBase64(headerHmac)}`;
|
|
292
|
+
const chunkLines = [];
|
|
293
|
+
for (const chunkText of chunks) {
|
|
294
|
+
const chunkBytes = new TextEncoder().encode(chunkText);
|
|
295
|
+
const payload = encryptChunk(encKey, nonceKey, chunkBytes, fileId);
|
|
296
|
+
chunkLines.push(toBase64(payload));
|
|
297
|
+
}
|
|
298
|
+
const sealInput = headerLine + "\n" + headerAuthLine + "\n" + chunkLines.join("\n");
|
|
299
|
+
const sealData = new TextEncoder().encode(sealInput);
|
|
300
|
+
const sealHmac = hmac3(sha2564, headerKey, sealData);
|
|
301
|
+
const sealLine = `seal_b64=${toBase64(sealHmac)}`;
|
|
302
|
+
return [headerLine, headerAuthLine, ...chunkLines, sealLine, ""].join("\n");
|
|
303
|
+
} finally {
|
|
304
|
+
zeroize(masterKey, encKey, headerKey, nonceKey);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async function decrypt(fileContent, password) {
|
|
308
|
+
const lines = fileContent.split("\n");
|
|
309
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
310
|
+
lines.pop();
|
|
311
|
+
}
|
|
312
|
+
if (lines.length < 3) {
|
|
313
|
+
throw new Error("Invalid mdenc file: too few lines");
|
|
314
|
+
}
|
|
315
|
+
const headerLine = lines[0];
|
|
316
|
+
const header = parseHeader(headerLine);
|
|
317
|
+
const authLine = lines[1];
|
|
318
|
+
const authMatch = authLine.match(/^hdrauth_b64=([A-Za-z0-9+/=]+)$/);
|
|
319
|
+
if (!authMatch) {
|
|
320
|
+
throw new Error("Invalid mdenc file: missing hdrauth_b64 line");
|
|
321
|
+
}
|
|
322
|
+
const headerHmac = fromBase64(authMatch[1]);
|
|
323
|
+
const masterKey = deriveMasterKey(password, header.salt, header.scrypt);
|
|
324
|
+
const { encKey, headerKey, nonceKey } = deriveKeys(masterKey);
|
|
325
|
+
try {
|
|
326
|
+
if (!verifyHeader(headerKey, headerLine, headerHmac)) {
|
|
327
|
+
throw new Error("Header authentication failed (wrong password or tampered header)");
|
|
328
|
+
}
|
|
329
|
+
const remaining = lines.slice(2);
|
|
330
|
+
const sealIndex = remaining.findIndex((l) => l.startsWith("seal_b64="));
|
|
331
|
+
if (sealIndex < 0) {
|
|
332
|
+
throw new Error("Invalid mdenc file: missing seal");
|
|
333
|
+
}
|
|
334
|
+
const chunkLines = remaining.slice(0, sealIndex);
|
|
335
|
+
if (chunkLines.length === 0) {
|
|
336
|
+
throw new Error("Invalid mdenc file: no chunk lines");
|
|
337
|
+
}
|
|
338
|
+
const sealMatch = remaining[sealIndex].match(/^seal_b64=([A-Za-z0-9+/=]+)$/);
|
|
339
|
+
if (!sealMatch) throw new Error("Invalid mdenc file: malformed seal line");
|
|
340
|
+
const storedSealHmac = fromBase64(sealMatch[1]);
|
|
341
|
+
const sealInput = headerLine + "\n" + authLine + "\n" + chunkLines.join("\n");
|
|
342
|
+
const sealData = new TextEncoder().encode(sealInput);
|
|
343
|
+
const computedSealHmac = hmac3(sha2564, headerKey, sealData);
|
|
344
|
+
if (!constantTimeEqual(computedSealHmac, storedSealHmac)) {
|
|
345
|
+
throw new Error("Seal verification failed (file tampered or chunks reordered)");
|
|
346
|
+
}
|
|
347
|
+
const plaintextParts = [];
|
|
348
|
+
for (const line of chunkLines) {
|
|
349
|
+
const payload = fromBase64(line);
|
|
350
|
+
const decrypted = decryptChunk(encKey, payload, header.fileId);
|
|
351
|
+
plaintextParts.push(new TextDecoder().decode(decrypted));
|
|
352
|
+
}
|
|
353
|
+
return plaintextParts.join("");
|
|
354
|
+
} finally {
|
|
355
|
+
zeroize(masterKey, encKey, headerKey, nonceKey);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function parsePreviousFileHeader(fileContent, password) {
|
|
359
|
+
try {
|
|
360
|
+
const lines = fileContent.split("\n");
|
|
361
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
|
362
|
+
if (lines.length < 3) return void 0;
|
|
363
|
+
const headerLine = lines[0];
|
|
364
|
+
const header = parseHeader(headerLine);
|
|
365
|
+
const authLine = lines[1];
|
|
366
|
+
const authMatch = authLine.match(/^hdrauth_b64=([A-Za-z0-9+/=]+)$/);
|
|
367
|
+
if (!authMatch) return void 0;
|
|
368
|
+
const headerHmac = fromBase64(authMatch[1]);
|
|
369
|
+
const masterKey = deriveMasterKey(password, header.salt, header.scrypt);
|
|
370
|
+
const { headerKey } = deriveKeys(masterKey);
|
|
371
|
+
if (!verifyHeader(headerKey, headerLine, headerHmac)) {
|
|
372
|
+
zeroize(masterKey, headerKey);
|
|
373
|
+
return void 0;
|
|
374
|
+
}
|
|
375
|
+
zeroize(headerKey);
|
|
376
|
+
return { salt: header.salt, fileId: header.fileId, masterKey };
|
|
377
|
+
} catch {
|
|
378
|
+
return void 0;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/git/password.ts
|
|
383
|
+
import { readFileSync } from "fs";
|
|
384
|
+
import { join } from "path";
|
|
385
|
+
var PASSWORD_FILE = ".mdenc-password";
|
|
386
|
+
function resolvePassword(repoRoot) {
|
|
387
|
+
const envPassword = process.env["MDENC_PASSWORD"];
|
|
388
|
+
if (envPassword) return envPassword;
|
|
389
|
+
try {
|
|
390
|
+
const content = readFileSync(join(repoRoot, PASSWORD_FILE), "utf-8").trim();
|
|
391
|
+
if (content.length > 0) return content;
|
|
392
|
+
} catch {
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/git/utils.ts
|
|
398
|
+
import { execFileSync } from "child_process";
|
|
399
|
+
import { readdirSync, statSync } from "fs";
|
|
400
|
+
import { join as join2, resolve } from "path";
|
|
401
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", ".hg", ".svn"]);
|
|
402
|
+
var MARKER_FILE = ".mdenc.conf";
|
|
403
|
+
function findGitRoot() {
|
|
404
|
+
try {
|
|
405
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
406
|
+
encoding: "utf-8",
|
|
407
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
408
|
+
}).trim();
|
|
409
|
+
} catch {
|
|
410
|
+
throw new Error("Not a git repository");
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
function getHooksDir() {
|
|
414
|
+
try {
|
|
415
|
+
const gitDir = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
|
|
416
|
+
encoding: "utf-8",
|
|
417
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
418
|
+
}).trim();
|
|
419
|
+
return resolve(gitDir);
|
|
420
|
+
} catch {
|
|
421
|
+
throw new Error("Could not determine git hooks directory");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function findMarkedDirs(repoRoot) {
|
|
425
|
+
const results = [];
|
|
426
|
+
walkForMarker(repoRoot, results);
|
|
427
|
+
return results;
|
|
428
|
+
}
|
|
429
|
+
function walkForMarker(dir, results) {
|
|
430
|
+
let entries;
|
|
431
|
+
try {
|
|
432
|
+
entries = readdirSync(dir);
|
|
433
|
+
} catch {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
if (entries.includes(MARKER_FILE)) {
|
|
437
|
+
results.push(dir);
|
|
438
|
+
}
|
|
439
|
+
for (const entry of entries) {
|
|
440
|
+
if (SKIP_DIRS.has(entry) || entry.startsWith(".")) continue;
|
|
441
|
+
const full = join2(dir, entry);
|
|
442
|
+
try {
|
|
443
|
+
if (statSync(full).isDirectory()) {
|
|
444
|
+
walkForMarker(full, results);
|
|
445
|
+
}
|
|
446
|
+
} catch {
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function getMdFilesInDir(dir) {
|
|
451
|
+
try {
|
|
452
|
+
return readdirSync(dir).filter(
|
|
453
|
+
(f) => f.endsWith(".md") && statSync(join2(dir, f)).isFile()
|
|
454
|
+
);
|
|
455
|
+
} catch {
|
|
456
|
+
return [];
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function getMdencFilesInDir(dir) {
|
|
460
|
+
try {
|
|
461
|
+
return readdirSync(dir).filter(
|
|
462
|
+
(f) => f.endsWith(".mdenc") && statSync(join2(dir, f)).isFile()
|
|
463
|
+
);
|
|
464
|
+
} catch {
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function gitAdd(repoRoot, files) {
|
|
469
|
+
if (files.length === 0) return;
|
|
470
|
+
execFileSync("git", ["add", "--", ...files], {
|
|
471
|
+
cwd: repoRoot,
|
|
472
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function gitRmCached(repoRoot, files) {
|
|
476
|
+
if (files.length === 0) return;
|
|
477
|
+
execFileSync("git", ["rm", "--cached", "--", ...files], {
|
|
478
|
+
cwd: repoRoot,
|
|
479
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
function isFileStaged(repoRoot, file) {
|
|
483
|
+
try {
|
|
484
|
+
const output = execFileSync(
|
|
485
|
+
"git",
|
|
486
|
+
["diff", "--cached", "--name-only", "--", file],
|
|
487
|
+
{ cwd: repoRoot, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
|
|
488
|
+
);
|
|
489
|
+
return output.trim().length > 0;
|
|
490
|
+
} catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
function isFileTracked(repoRoot, file) {
|
|
495
|
+
try {
|
|
496
|
+
execFileSync("git", ["ls-files", "--error-unmatch", "--", file], {
|
|
497
|
+
cwd: repoRoot,
|
|
498
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
499
|
+
});
|
|
500
|
+
return true;
|
|
501
|
+
} catch {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// src/git/hooks.ts
|
|
507
|
+
function needsReEncryption(mdPath, mdencPath) {
|
|
508
|
+
if (!existsSync(mdencPath)) return true;
|
|
509
|
+
const mdMtime = statSync2(mdPath).mtimeMs;
|
|
510
|
+
const mdencMtime = statSync2(mdencPath).mtimeMs;
|
|
511
|
+
return mdMtime > mdencMtime;
|
|
512
|
+
}
|
|
513
|
+
async function preCommitHook() {
|
|
514
|
+
const repoRoot = findGitRoot();
|
|
515
|
+
const password = resolvePassword(repoRoot);
|
|
516
|
+
if (!password) {
|
|
517
|
+
process.stderr.write(
|
|
518
|
+
"mdenc: no password available (set MDENC_PASSWORD or create .mdenc-password). Skipping encryption.\n"
|
|
519
|
+
);
|
|
520
|
+
process.exit(0);
|
|
521
|
+
}
|
|
522
|
+
const markedDirs = findMarkedDirs(repoRoot);
|
|
523
|
+
if (markedDirs.length === 0) process.exit(0);
|
|
524
|
+
let encryptedCount = 0;
|
|
525
|
+
let skippedCount = 0;
|
|
526
|
+
let errorCount = 0;
|
|
527
|
+
for (const dir of markedDirs) {
|
|
528
|
+
const mdFiles = getMdFilesInDir(dir);
|
|
529
|
+
for (const mdFile of mdFiles) {
|
|
530
|
+
const mdPath = join3(dir, mdFile);
|
|
531
|
+
const mdencPath = mdPath.replace(/\.md$/, ".mdenc");
|
|
532
|
+
const relMdPath = relative(repoRoot, mdPath);
|
|
533
|
+
const relMdencPath = relative(repoRoot, mdencPath);
|
|
534
|
+
if (!needsReEncryption(mdPath, mdencPath)) {
|
|
535
|
+
skippedCount++;
|
|
536
|
+
if (existsSync(mdencPath)) {
|
|
537
|
+
gitAdd(repoRoot, [relMdencPath]);
|
|
538
|
+
}
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const plaintext = readFileSync2(mdPath, "utf-8");
|
|
543
|
+
let previousFile;
|
|
544
|
+
if (existsSync(mdencPath)) {
|
|
545
|
+
previousFile = readFileSync2(mdencPath, "utf-8");
|
|
546
|
+
}
|
|
547
|
+
const encrypted = await encrypt(plaintext, password, { previousFile });
|
|
548
|
+
writeFileSync(mdencPath, encrypted);
|
|
549
|
+
gitAdd(repoRoot, [relMdencPath]);
|
|
550
|
+
if (isFileStaged(repoRoot, relMdPath)) {
|
|
551
|
+
gitRmCached(repoRoot, [relMdPath]);
|
|
552
|
+
}
|
|
553
|
+
encryptedCount++;
|
|
554
|
+
} catch (err) {
|
|
555
|
+
process.stderr.write(
|
|
556
|
+
`mdenc: failed to encrypt ${relMdPath}: ${err instanceof Error ? err.message : err}
|
|
557
|
+
`
|
|
558
|
+
);
|
|
559
|
+
errorCount++;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
if (encryptedCount > 0) {
|
|
564
|
+
process.stderr.write(`mdenc: encrypted ${encryptedCount} file(s)
|
|
565
|
+
`);
|
|
566
|
+
}
|
|
567
|
+
if (errorCount > 0) {
|
|
568
|
+
process.stderr.write(
|
|
569
|
+
`mdenc: ${errorCount} file(s) failed to encrypt. Aborting commit.
|
|
570
|
+
`
|
|
571
|
+
);
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
process.exit(0);
|
|
575
|
+
}
|
|
576
|
+
async function decryptAll() {
|
|
577
|
+
const repoRoot = findGitRoot();
|
|
578
|
+
const password = resolvePassword(repoRoot);
|
|
579
|
+
if (!password) {
|
|
580
|
+
process.stderr.write(
|
|
581
|
+
"mdenc: no password available. Skipping decryption.\n"
|
|
582
|
+
);
|
|
583
|
+
return 0;
|
|
584
|
+
}
|
|
585
|
+
const markedDirs = findMarkedDirs(repoRoot);
|
|
586
|
+
let count = 0;
|
|
587
|
+
for (const dir of markedDirs) {
|
|
588
|
+
const mdencFiles = getMdencFilesInDir(dir);
|
|
589
|
+
for (const mdencFile of mdencFiles) {
|
|
590
|
+
const mdencPath = join3(dir, mdencFile);
|
|
591
|
+
const mdPath = mdencPath.replace(/\.mdenc$/, ".md");
|
|
592
|
+
const relMdPath = relative(repoRoot, mdPath);
|
|
593
|
+
if (existsSync(mdPath)) {
|
|
594
|
+
const mdMtime = statSync2(mdPath).mtimeMs;
|
|
595
|
+
const mdencMtime = statSync2(mdencPath).mtimeMs;
|
|
596
|
+
if (mdMtime > mdencMtime) {
|
|
597
|
+
process.stderr.write(
|
|
598
|
+
`mdenc: skipping ${relMdPath} (local .md is newer than .mdenc)
|
|
599
|
+
`
|
|
600
|
+
);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
const encrypted = readFileSync2(mdencPath, "utf-8");
|
|
606
|
+
const plaintext = await decrypt(encrypted, password);
|
|
607
|
+
writeFileSync(mdPath, plaintext);
|
|
608
|
+
count++;
|
|
609
|
+
} catch (err) {
|
|
610
|
+
process.stderr.write(
|
|
611
|
+
`mdenc: failed to decrypt ${relative(repoRoot, mdencPath)}: ${err instanceof Error ? err.message : err}
|
|
612
|
+
`
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return count;
|
|
618
|
+
}
|
|
619
|
+
async function postCheckoutHook() {
|
|
620
|
+
const count = await decryptAll();
|
|
621
|
+
if (count > 0) {
|
|
622
|
+
process.stderr.write(`mdenc: decrypted ${count} file(s)
|
|
623
|
+
`);
|
|
624
|
+
}
|
|
625
|
+
process.exit(0);
|
|
626
|
+
}
|
|
627
|
+
async function postMergeHook() {
|
|
628
|
+
const count = await decryptAll();
|
|
629
|
+
if (count > 0) {
|
|
630
|
+
process.stderr.write(`mdenc: decrypted ${count} file(s)
|
|
631
|
+
`);
|
|
632
|
+
}
|
|
633
|
+
process.exit(0);
|
|
634
|
+
}
|
|
635
|
+
async function postRewriteHook() {
|
|
636
|
+
const count = await decryptAll();
|
|
637
|
+
if (count > 0) {
|
|
638
|
+
process.stderr.write(`mdenc: decrypted ${count} file(s)
|
|
639
|
+
`);
|
|
640
|
+
}
|
|
641
|
+
process.exit(0);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export {
|
|
645
|
+
constantTimeEqual,
|
|
646
|
+
zeroize,
|
|
647
|
+
deriveMasterKey,
|
|
648
|
+
deriveKeys,
|
|
649
|
+
parseHeader,
|
|
650
|
+
verifyHeader,
|
|
651
|
+
fromBase64,
|
|
652
|
+
encrypt,
|
|
653
|
+
decrypt,
|
|
654
|
+
findGitRoot,
|
|
655
|
+
getHooksDir,
|
|
656
|
+
findMarkedDirs,
|
|
657
|
+
getMdFilesInDir,
|
|
658
|
+
getMdencFilesInDir,
|
|
659
|
+
gitAdd,
|
|
660
|
+
gitRmCached,
|
|
661
|
+
isFileTracked,
|
|
662
|
+
resolvePassword,
|
|
663
|
+
preCommitHook,
|
|
664
|
+
decryptAll,
|
|
665
|
+
postCheckoutHook,
|
|
666
|
+
postMergeHook,
|
|
667
|
+
postRewriteHook
|
|
668
|
+
};
|
|
669
|
+
//# sourceMappingURL=chunk-FFRUPAVV.js.map
|