zurik 2.0.0 → 3.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/bin/zurik.js +111 -29
- package/lib/bundle.js +62 -46
- package/lib/crypto.js +38 -75
- package/lib/decrypt.js +5 -2
- package/lib/encrypt.js +32 -30
- package/lib/errors.js +5 -5
- package/lib/index.js +20 -20
- package/lib/init.js +57 -0
- package/lib/paths.js +1 -1
- package/lib/status.js +16 -25
- package/package.json +1 -1
- package/readme.md +61 -26
- package/skills/zurik/SKILL.md +13 -11
package/bin/zurik.js
CHANGED
|
@@ -4,18 +4,19 @@ import fs from "fs";
|
|
|
4
4
|
import path from "path";
|
|
5
5
|
import * as readline from "readline";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
|
-
import { encrypt, decrypt, status,
|
|
7
|
+
import { init, encrypt, decrypt, status, ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH } from "../lib/index.js";
|
|
8
8
|
|
|
9
9
|
const PKG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
10
|
const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_DIR, "package.json"), "utf8")).version;
|
|
11
11
|
|
|
12
12
|
const EXIT = { OK: 0, ERROR: 1, USAGE: 2 };
|
|
13
|
-
const COMMANDS = { encrypt: "encrypt", "-e": "encrypt", decrypt: "decrypt", "-d": "decrypt", status: "status", "-s": "status", skill: "skill", help: "help" };
|
|
13
|
+
const COMMANDS = { init: "init", encrypt: "encrypt", "-e": "encrypt", decrypt: "decrypt", "-d": "decrypt", status: "status", "-s": "status", skill: "skill", help: "help" };
|
|
14
14
|
|
|
15
|
-
const HELP = `zurik ${VERSION} — encrypt project secrets into a single .zurik
|
|
15
|
+
const HELP = `zurik ${VERSION} — encrypt project secrets into a single .zurik file
|
|
16
16
|
|
|
17
17
|
Usage:
|
|
18
|
-
zurik
|
|
18
|
+
zurik init [options] Find .env files and list them in .zurik (creates it)
|
|
19
|
+
zurik encrypt [options] Encrypt the files listed in .zurik (alias: -e)
|
|
19
20
|
zurik decrypt [options] Restore files from .zurik (alias: -d)
|
|
20
21
|
zurik status [options] Show which files are in sync, without decrypting (alias: -s)
|
|
21
22
|
zurik skill Print instructions for AI agents (SKILL.md)
|
|
@@ -27,6 +28,7 @@ Options:
|
|
|
27
28
|
--password-stdin Read the password from the first line of stdin
|
|
28
29
|
--password-file <path> Read the password from a file
|
|
29
30
|
-f, --force Decrypt: overwrite local files that differ from .zurik
|
|
31
|
+
--rekey Encrypt: change the password (new salt, re-encrypts every file)
|
|
30
32
|
-n, --dry-run Report what would happen without writing anything
|
|
31
33
|
--json Print one JSON object on stdout (results and errors)
|
|
32
34
|
--no-input Never prompt; fail with E_NO_PASSWORD instead
|
|
@@ -37,15 +39,19 @@ Password sources, first match wins: --password value, --password-file, --passwor
|
|
|
37
39
|
-p prompt, $ZURIK_PASSWORD, $ZURIK_PASSWORD_FILE, then an interactive prompt if stdin is
|
|
38
40
|
a terminal (never with --json, --no-input or $CI). Encrypting needs ${MIN_PASSWORD_LENGTH}+ characters.
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
init needs no password. In a terminal it lets you pick the files (all selected by default);
|
|
43
|
+
otherwise (--json, --no-input, $CI or piped stdin) it adds every .env file found.
|
|
44
|
+
|
|
45
|
+
.zurik lists one "--- <path>" section per file; add or delete sections to change what is
|
|
46
|
+
encrypted. zurik uses the nearest parent directory containing .zurik.
|
|
47
|
+
Exit codes: 0 success, 1 error, 2 usage error.`;
|
|
42
48
|
|
|
43
49
|
function usageError(message) {
|
|
44
50
|
return new ZurikError("E_USAGE", message);
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
function parseArgs(argv) {
|
|
48
|
-
const opts = { command: null, json: false, force: false, dryRun: false, noInput: false, help: false, version: false };
|
|
54
|
+
const opts = { command: null, json: false, force: false, rekey: false, dryRun: false, noInput: false, help: false, version: false };
|
|
49
55
|
|
|
50
56
|
// Support --flag=value for long options.
|
|
51
57
|
const args = argv.flatMap(a => (a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a]));
|
|
@@ -77,6 +83,7 @@ function parseArgs(argv) {
|
|
|
77
83
|
case "--password-stdin": opts.passwordStdin = true; break;
|
|
78
84
|
case "--password-file": opts.passwordFile = takeValue(i++, a); break;
|
|
79
85
|
case "-f": case "--force": opts.force = true; break;
|
|
86
|
+
case "--rekey": opts.rekey = true; break;
|
|
80
87
|
case "-n": case "--dry-run": opts.dryRun = true; break;
|
|
81
88
|
case "--json": opts.json = true; break;
|
|
82
89
|
case "--no-input": opts.noInput = true; break;
|
|
@@ -149,6 +156,57 @@ function promptPassword() {
|
|
|
149
156
|
});
|
|
150
157
|
}
|
|
151
158
|
|
|
159
|
+
// Checkbox list on stderr; every item starts selected. Resolves to the selected items.
|
|
160
|
+
// ponytail: redraws in place, so a list taller than the terminal scrolls messily; paginate if that bites.
|
|
161
|
+
function selectFiles(items) {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
const stdin = process.stdin;
|
|
164
|
+
const out = process.stderr;
|
|
165
|
+
const selected = items.map(() => true);
|
|
166
|
+
let cursor = 0;
|
|
167
|
+
let drawn = 0;
|
|
168
|
+
|
|
169
|
+
const draw = () => {
|
|
170
|
+
const lines = [
|
|
171
|
+
"Select files to encrypt with zurik (up/down move, space toggle, a all/none, enter confirm):",
|
|
172
|
+
...items.map((p, i) => `${i === cursor ? ">" : " "} [${selected[i] ? "x" : " "}] ${p}`),
|
|
173
|
+
];
|
|
174
|
+
if (drawn) out.write(`\x1b[${drawn}A`);
|
|
175
|
+
out.write(lines.map(l => `\x1b[2K${l}\n`).join(""));
|
|
176
|
+
drawn = lines.length;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const cleanup = () => {
|
|
180
|
+
stdin.removeListener("data", onData);
|
|
181
|
+
stdin.setRawMode(false);
|
|
182
|
+
stdin.pause();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// A chunk may hold several keys (fast typing, paste); arrow keys are 3-byte escapes.
|
|
186
|
+
const onData = (chunk) => {
|
|
187
|
+
for (const key of chunk.toString("utf8").match(/\x1b\[[A-D]|[^]/g)) {
|
|
188
|
+
if (key === "\u0003") {
|
|
189
|
+
cleanup();
|
|
190
|
+
process.exit(130);
|
|
191
|
+
} else if (key === "\r" || key === "\n") {
|
|
192
|
+
cleanup();
|
|
193
|
+
resolve(items.filter((_, i) => selected[i]));
|
|
194
|
+
return;
|
|
195
|
+
} else if (key === " ") selected[cursor] = !selected[cursor];
|
|
196
|
+
else if (key === "a") selected.fill(!selected.every(Boolean));
|
|
197
|
+
else if (key === "\x1b[A" || key === "k") cursor = (cursor + items.length - 1) % items.length;
|
|
198
|
+
else if (key === "\x1b[B" || key === "j") cursor = (cursor + 1) % items.length;
|
|
199
|
+
}
|
|
200
|
+
draw();
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
draw();
|
|
204
|
+
stdin.setRawMode(true);
|
|
205
|
+
stdin.resume();
|
|
206
|
+
stdin.on("data", onData);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
152
210
|
function readPasswordFile(file) {
|
|
153
211
|
try {
|
|
154
212
|
return fs.readFileSync(file, "utf8").replace(/\r?\n$/, "");
|
|
@@ -157,8 +215,10 @@ function readPasswordFile(file) {
|
|
|
157
215
|
}
|
|
158
216
|
}
|
|
159
217
|
|
|
218
|
+
const canPrompt = (opts) => Boolean(process.stdin.isTTY && !opts.noInput && !opts.json && !process.env.CI);
|
|
219
|
+
|
|
160
220
|
async function resolvePassword(opts) {
|
|
161
|
-
const interactive =
|
|
221
|
+
const interactive = canPrompt(opts);
|
|
162
222
|
|
|
163
223
|
if (opts.password) return opts.password;
|
|
164
224
|
if (opts.passwordFile) return readPasswordFile(opts.passwordFile);
|
|
@@ -176,16 +236,21 @@ function printHuman(result) {
|
|
|
176
236
|
const prefix = result.dryRun ? "[dry run] would be " : "";
|
|
177
237
|
const log = (s) => process.stdout.write(s + "\n");
|
|
178
238
|
|
|
179
|
-
if (result.command === "
|
|
180
|
-
|
|
239
|
+
if (result.command === "init") {
|
|
240
|
+
log(!result.created ? "Using existing .zurik" : result.dryRun ? "[dry run] would create .zurik" : "Created .zurik");
|
|
241
|
+
for (const f of result.files) log(` ${f.action === "added" ? prefix : ""}${f.action.padEnd(14)} ${f.path}`);
|
|
242
|
+
if (result.files.length === 0) log('No .env files found. Add a "--- <path>" line to .zurik for each secret file.');
|
|
243
|
+
log("Next: run 'zurik encrypt' and commit .zurik.");
|
|
244
|
+
} else if (result.command === "encrypt") {
|
|
245
|
+
for (const f of result.files) log(` ${f.action === "unchanged" ? "" : prefix}${f.action.padEnd(9)} ${f.path}`);
|
|
181
246
|
if (result.gitignoreAdded.length) log(` ${prefix}added to .gitignore: ${result.gitignoreAdded.join(", ")}`);
|
|
182
|
-
|
|
247
|
+
const changed = result.files.filter(f => f.action === "encrypted").length;
|
|
248
|
+
log(`${result.dryRun ? "Would encrypt" : "Encrypted"} ${changed} file(s) into .zurik, ${result.files.length - changed} unchanged`);
|
|
183
249
|
} else if (result.command === "decrypt") {
|
|
184
250
|
for (const f of result.files) log(` ${f.action === "unchanged" ? "" : prefix}${f.action.padEnd(9)} ${f.path}`);
|
|
185
251
|
log(`${result.dryRun ? "Would decrypt" : "Decrypted"} ${result.files.length} file(s) from .zurik`);
|
|
186
252
|
} else if (result.command === "status") {
|
|
187
|
-
|
|
188
|
-
for (const f of result.files) log(` ${f.state.padEnd(15)} ${f.path}${f.listed ? "" : " (not in .gitencrypt)"}`);
|
|
253
|
+
for (const f of result.files) log(` ${f.state.padEnd(15)} ${f.path}`);
|
|
189
254
|
log(result.inSync ? "All files in sync." : "Out of sync.");
|
|
190
255
|
}
|
|
191
256
|
}
|
|
@@ -195,9 +260,10 @@ function helpSpec() {
|
|
|
195
260
|
name: "zurik",
|
|
196
261
|
version: VERSION,
|
|
197
262
|
commands: {
|
|
198
|
-
|
|
263
|
+
init: { options: ["dryRun", "cwd"], password: false, result: "{ created, files: [{ path, action: 'added'|'already-listed'|'skipped' }] }", note: "Adds every .env file found unless run interactively in a terminal" },
|
|
264
|
+
encrypt: { options: ["password*", "rekey", "dryRun", "cwd"], result: "{ files: [{ path, action: 'encrypted'|'unchanged' }], gitignoreAdded: [path] }" },
|
|
199
265
|
decrypt: { options: ["password*", "force", "dryRun", "cwd"], result: "{ files: [{ path, action: 'created'|'updated'|'unchanged' }] }" },
|
|
200
|
-
status: { options: ["password (only
|
|
266
|
+
status: { options: ["password (only once something is encrypted)", "cwd"], result: "{ inSync, files: [{ path, state: 'in-sync'|'modified'|'missing-locally'|'not-in-bundle'|'missing' }] }" },
|
|
201
267
|
},
|
|
202
268
|
passwordSources: ["--password <value>", "--password-file <path>", "--password-stdin", "$ZURIK_PASSWORD", "$ZURIK_PASSWORD_FILE"],
|
|
203
269
|
minPasswordLength: MIN_PASSWORD_LENGTH,
|
|
@@ -207,6 +273,35 @@ function helpSpec() {
|
|
|
207
273
|
};
|
|
208
274
|
}
|
|
209
275
|
|
|
276
|
+
// init needs no password: preview what would be added, let a terminal user untick files, then write.
|
|
277
|
+
async function runInit(opts, base) {
|
|
278
|
+
const preview = await init({ ...base, dryRun: true });
|
|
279
|
+
const candidates = preview.files.filter(f => f.action === "added").map(f => f.path);
|
|
280
|
+
const files = canPrompt(opts) && candidates.length > 0 ? await selectFiles(candidates) : candidates;
|
|
281
|
+
return init({ ...base, files, dryRun: opts.dryRun });
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function runWithPassword(opts, base) {
|
|
285
|
+
// status only needs a password once something is encrypted; it says so with E_NO_PASSWORD.
|
|
286
|
+
if (opts.command === "status") {
|
|
287
|
+
try {
|
|
288
|
+
return await status(base);
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (err.code !== "E_NO_PASSWORD") throw err;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const password = await resolvePassword(opts);
|
|
294
|
+
if (!password) {
|
|
295
|
+
throw new ZurikError(
|
|
296
|
+
"E_NO_PASSWORD",
|
|
297
|
+
"No password provided. Use --password-file, --password-stdin, $ZURIK_PASSWORD or -p."
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const run = { encrypt, decrypt, status }[opts.command];
|
|
302
|
+
return run({ ...base, password, force: opts.force, rekey: opts.rekey, dryRun: opts.dryRun });
|
|
303
|
+
}
|
|
304
|
+
|
|
210
305
|
// ---------- main ----------
|
|
211
306
|
|
|
212
307
|
async function main() {
|
|
@@ -229,20 +324,7 @@ async function main() {
|
|
|
229
324
|
}
|
|
230
325
|
|
|
231
326
|
const base = { cwd: opts.cwd ? path.resolve(opts.cwd) : process.cwd() };
|
|
232
|
-
|
|
233
|
-
// status only needs a password when there is a bundle to compare against.
|
|
234
|
-
const needsPassword =
|
|
235
|
-
opts.command !== "status" || fs.existsSync(path.join(resolveRoot("status", base), ".zurik"));
|
|
236
|
-
const password = needsPassword ? await resolvePassword(opts) : undefined;
|
|
237
|
-
if (needsPassword && !password) {
|
|
238
|
-
throw new ZurikError(
|
|
239
|
-
"E_NO_PASSWORD",
|
|
240
|
-
"No password provided. Use --password-file, --password-stdin, $ZURIK_PASSWORD or -p."
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
const run = { encrypt, decrypt, status }[opts.command];
|
|
245
|
-
const result = await run({ ...base, password, force: opts.force, dryRun: opts.dryRun });
|
|
327
|
+
const result = opts.command === "init" ? await runInit(opts, base) : await runWithPassword(opts, base);
|
|
246
328
|
|
|
247
329
|
if (opts.json) console.log(JSON.stringify({ ok: true, ...result }));
|
|
248
330
|
else printHuman(result);
|
package/lib/bundle.js
CHANGED
|
@@ -1,58 +1,74 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { deriveKey, open, HEADER_PREFIX } from "./crypto.js";
|
|
4
4
|
import { ZurikError } from "./errors.js";
|
|
5
5
|
import { safeRelativePath } from "./paths.js";
|
|
6
6
|
|
|
7
|
-
export const LIST_FILE = ".gitencrypt";
|
|
8
7
|
export const BUNDLE_FILE = ".zurik";
|
|
8
|
+
const SECTION = "--- ";
|
|
9
|
+
const BASE64 = /^[A-Za-z0-9+/]+=*$/;
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
11
|
+
// .zurik is plain text: a header line (once anything is encrypted), then one section per file.
|
|
12
|
+
// A section's data line is empty until the file is first encrypted.
|
|
13
|
+
//
|
|
14
|
+
// zurik3 scrypt logN=17 r=8 p=1 salt=...
|
|
15
|
+
//
|
|
16
|
+
// --- .env
|
|
17
|
+
// <base64>
|
|
18
|
+
//
|
|
19
|
+
// --- api/.env.production
|
|
20
|
+
//
|
|
21
|
+
// Each file is its own line, so git diffs and merges per file.
|
|
22
|
+
|
|
23
|
+
const corrupt = (why) => new ZurikError("E_CORRUPT", `Invalid ${BUNDLE_FILE}: ${why}`);
|
|
24
|
+
|
|
25
|
+
// { header: string | null, files: [{ path, data }] }, or null if .zurik doesn't exist.
|
|
26
|
+
export function readBundle(root) {
|
|
27
|
+
const file = path.join(root, BUNDLE_FILE);
|
|
28
|
+
if (!fs.existsSync(file)) return null;
|
|
29
|
+
|
|
30
|
+
let header = null;
|
|
31
|
+
const files = [];
|
|
32
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").map(l => l.trim());
|
|
33
|
+
|
|
34
|
+
lines.forEach((line, i) => {
|
|
35
|
+
const last = files[files.length - 1];
|
|
36
|
+
if (line === "") return;
|
|
37
|
+
if (line.startsWith(SECTION)) {
|
|
38
|
+
const p = safeRelativePath(line.slice(SECTION.length).trim(), root);
|
|
39
|
+
if (files.some(f => f.path === p)) throw corrupt(`${p} is listed twice`);
|
|
40
|
+
files.push({ path: p, data: "" });
|
|
41
|
+
} else if (line.startsWith(HEADER_PREFIX) && header === null && !last) {
|
|
42
|
+
header = line;
|
|
43
|
+
} else if (last && last.data === "" && BASE64.test(line)) {
|
|
44
|
+
last.data = line;
|
|
45
|
+
} else if (!last && header === null && (line.startsWith("zurik2:") || BASE64.test(line))) {
|
|
46
|
+
throw new ZurikError(
|
|
47
|
+
"E_OLD_FORMAT",
|
|
48
|
+
`${BUNDLE_FILE} was written by zurik 2.x or earlier. Run 'npx zurik@2 decrypt', delete ${BUNDLE_FILE} ` +
|
|
49
|
+
"and .gitencrypt, then 'zurik init' and 'zurik encrypt'."
|
|
50
|
+
);
|
|
51
|
+
} else {
|
|
52
|
+
throw corrupt(`unexpected content on line ${i + 1}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (header === null && files.some(f => f.data)) throw corrupt("encrypted data without a header line");
|
|
57
|
+
return { header, files };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function writeBundle(root, { header, files }) {
|
|
61
|
+
const sections = files.map(f => `${SECTION}${f.path}\n${f.data ? f.data + "\n" : ""}`);
|
|
62
|
+
const text = [header && header + "\n", ...sections].filter(Boolean).join("\n");
|
|
63
|
+
fs.writeFileSync(path.join(root, BUNDLE_FILE), text);
|
|
28
64
|
}
|
|
29
65
|
|
|
30
|
-
// Decrypts
|
|
31
|
-
export async function
|
|
32
|
-
const
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const plaintext = await decryptData(fs.readFileSync(bundlePath, "utf8"), password);
|
|
38
|
-
|
|
39
|
-
let data;
|
|
40
|
-
try {
|
|
41
|
-
data = JSON.parse(plaintext);
|
|
42
|
-
} catch {
|
|
43
|
-
throw new ZurikError("E_CORRUPT", "Invalid .zurik bundle");
|
|
44
|
-
}
|
|
45
|
-
if (!data || typeof data.files !== "object" || data.files === null) {
|
|
46
|
-
throw new ZurikError("E_CORRUPT", "Invalid .zurik bundle");
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const modes = data.modes && typeof data.modes === "object" ? data.modes : {};
|
|
50
|
-
|
|
51
|
-
return Object.entries(data.files).map(([filePath, base64]) => ({
|
|
52
|
-
path: safeRelativePath(filePath, root),
|
|
53
|
-
content: Buffer.from(base64, "base64"),
|
|
54
|
-
mode: Number.isInteger(modes[filePath]) ? modes[filePath] & 0o777 : DEFAULT_MODE,
|
|
55
|
-
}));
|
|
66
|
+
// Decrypts every encrypted section: [{ path, content: Buffer, mode }]. Empty sections are skipped.
|
|
67
|
+
export async function openBundle(bundle, password) {
|
|
68
|
+
const sealed = bundle.files.filter(f => f.data);
|
|
69
|
+
if (sealed.length === 0) return [];
|
|
70
|
+
const key = await deriveKey(bundle.header, password);
|
|
71
|
+
return sealed.map(f => ({ path: f.path, ...open(key, bundle.header, f.path, f.data) }));
|
|
56
72
|
}
|
|
57
73
|
|
|
58
74
|
// "missing" | "same" | "different" for a local file compared with `content`.
|
package/lib/crypto.js
CHANGED
|
@@ -1,103 +1,66 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { ZurikError } from "./errors.js";
|
|
3
3
|
|
|
4
|
+
// One key per bundle, derived from the password with the scrypt parameters and salt in the header line:
|
|
5
|
+
// zurik3 scrypt logN=17 r=8 p=1 salt=<base64, 16 bytes>
|
|
6
|
+
// Each file is sealed on its own with AES-256-GCM: base64( iv(12) | tag(16) | ciphertext ),
|
|
7
|
+
// where the plaintext is mode (2 bytes, big-endian) | content. The header line and the file's path
|
|
8
|
+
// are authenticated as additional data, so the KDF parameters can't be weakened and a block can't
|
|
9
|
+
// be moved to another path.
|
|
4
10
|
const ALGO = "aes-256-gcm";
|
|
5
|
-
const
|
|
11
|
+
const IV_LENGTH = 12;
|
|
6
12
|
const TAG_LENGTH = 16;
|
|
7
|
-
|
|
8
|
-
// Current format: "zurik2:" + base64( logN | r | p | salt(16) | iv(12) | tag(16) | ciphertext )
|
|
9
|
-
// The KDF params are authenticated as GCM additional data, so they can't be altered.
|
|
10
|
-
const V2_PREFIX = "zurik2:";
|
|
11
|
-
const V2_IV_LENGTH = 12;
|
|
12
13
|
const KDF = { logN: 17, r: 8, p: 1 }; // N = 2^17 ≈ 128 MiB of memory per guess
|
|
13
14
|
|
|
14
15
|
// Upper bounds so a crafted file can't make decryption allocate gigabytes.
|
|
15
16
|
const MAX_KDF = { logN: 20, r: 16, p: 4 };
|
|
17
|
+
const HEADER = /^zurik3 scrypt logN=(\d+) r=(\d+) p=(\d+) salt=([A-Za-z0-9+/]{22}==)$/;
|
|
16
18
|
|
|
19
|
+
export const HEADER_PREFIX = "zurik3 ";
|
|
17
20
|
export const MIN_PASSWORD_LENGTH = 16;
|
|
18
21
|
|
|
19
|
-
function
|
|
22
|
+
export function newHeader() {
|
|
23
|
+
return `zurik3 scrypt logN=${KDF.logN} r=${KDF.r} p=${KDF.p} salt=${crypto.randomBytes(16).toString("base64")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function deriveKey(header, password) {
|
|
27
|
+
const m = HEADER.exec(header);
|
|
28
|
+
const [logN, r, p] = m ? m.slice(1, 4).map(Number) : [];
|
|
29
|
+
if (!m || logN < 1 || logN > MAX_KDF.logN || r < 1 || r > MAX_KDF.r || p < 1 || p > MAX_KDF.p) {
|
|
30
|
+
throw new ZurikError("E_CORRUPT", "Corrupted .zurik file: invalid or unsupported header");
|
|
31
|
+
}
|
|
20
32
|
const N = 2 ** logN;
|
|
21
33
|
return new Promise((resolve, reject) => {
|
|
22
|
-
crypto.scrypt(password,
|
|
34
|
+
crypto.scrypt(password, Buffer.from(m[4], "base64"), 32, { N, r, p, maxmem: 2 * 128 * N * r + 1024 * 1024 }, (err, key) => {
|
|
23
35
|
if (err) reject(err);
|
|
24
36
|
else resolve(key);
|
|
25
37
|
});
|
|
26
38
|
});
|
|
27
39
|
}
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
const params = Buffer.from([KDF.logN, KDF.r, KDF.p]);
|
|
31
|
-
const salt = crypto.randomBytes(SALT_LENGTH);
|
|
32
|
-
const iv = crypto.randomBytes(V2_IV_LENGTH);
|
|
33
|
-
const key = await deriveKey(password, salt, KDF);
|
|
41
|
+
const aad = (header, filePath) => Buffer.from(`${header}\n${filePath}`, "utf8");
|
|
34
42
|
|
|
43
|
+
export function seal(key, header, filePath, { content, mode }) {
|
|
44
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
35
45
|
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
36
|
-
cipher.setAAD(
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return
|
|
46
|
+
cipher.setAAD(aad(header, filePath));
|
|
47
|
+
const modeBytes = Buffer.alloc(2);
|
|
48
|
+
modeBytes.writeUInt16BE(mode & 0o777);
|
|
49
|
+
const encrypted = Buffer.concat([cipher.update(modeBytes), cipher.update(content), cipher.final()]);
|
|
50
|
+
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString("base64");
|
|
41
51
|
}
|
|
42
52
|
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
return text.startsWith(V2_PREFIX)
|
|
46
|
-
? decryptV2(text.slice(V2_PREFIX.length), password)
|
|
47
|
-
: decryptV1(text, password);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function decryptV2(base64, password) {
|
|
51
|
-
const buffer = Buffer.from(base64, "base64");
|
|
52
|
-
const headerLength = 3 + SALT_LENGTH + V2_IV_LENGTH + TAG_LENGTH;
|
|
53
|
-
if (buffer.length < headerLength) {
|
|
54
|
-
throw new ZurikError("E_CORRUPT", "Corrupted .zurik file");
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const params = buffer.subarray(0, 3);
|
|
58
|
-
const kdf = { logN: params[0], r: params[1], p: params[2] };
|
|
59
|
-
if (
|
|
60
|
-
kdf.logN < 1 || kdf.logN > MAX_KDF.logN ||
|
|
61
|
-
kdf.r < 1 || kdf.r > MAX_KDF.r ||
|
|
62
|
-
kdf.p < 1 || kdf.p > MAX_KDF.p
|
|
63
|
-
) {
|
|
64
|
-
throw new ZurikError("E_CORRUPT", "Corrupted .zurik file: unsupported key derivation parameters");
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
let offset = 3;
|
|
68
|
-
const salt = buffer.subarray(offset, (offset += SALT_LENGTH));
|
|
69
|
-
const iv = buffer.subarray(offset, (offset += V2_IV_LENGTH));
|
|
70
|
-
const tag = buffer.subarray(offset, (offset += TAG_LENGTH));
|
|
71
|
-
const ciphertext = buffer.subarray(offset);
|
|
72
|
-
|
|
73
|
-
const key = await deriveKey(password, salt, kdf);
|
|
74
|
-
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
75
|
-
decipher.setAAD(params);
|
|
76
|
-
decipher.setAuthTag(tag);
|
|
77
|
-
return finish(decipher, ciphertext);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Legacy format (zurik <= 1.0.5): base64( salt(16) | iv(16) | tag(16) | ciphertext ), default scrypt params.
|
|
81
|
-
async function decryptV1(base64, password) {
|
|
82
|
-
const buffer = Buffer.from(base64, "base64");
|
|
83
|
-
if (buffer.length < 48) {
|
|
84
|
-
throw new ZurikError("E_CORRUPT", "Corrupted .zurik file");
|
|
85
|
-
}
|
|
86
|
-
const salt = buffer.subarray(0, 16);
|
|
87
|
-
const iv = buffer.subarray(16, 32);
|
|
88
|
-
const tag = buffer.subarray(32, 48);
|
|
89
|
-
const ciphertext = buffer.subarray(48);
|
|
90
|
-
|
|
91
|
-
const key = await deriveKey(password, salt, { logN: 14, r: 8, p: 1 });
|
|
92
|
-
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
93
|
-
decipher.setAuthTag(tag);
|
|
94
|
-
return finish(decipher, ciphertext);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function finish(decipher, ciphertext) {
|
|
53
|
+
export function open(key, header, filePath, data) {
|
|
54
|
+
const buffer = Buffer.from(data, "base64");
|
|
98
55
|
try {
|
|
99
|
-
|
|
56
|
+
const decipher = crypto.createDecipheriv(ALGO, key, buffer.subarray(0, IV_LENGTH));
|
|
57
|
+
decipher.setAAD(aad(header, filePath));
|
|
58
|
+
decipher.setAuthTag(buffer.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH));
|
|
59
|
+
const plain = Buffer.concat([decipher.update(buffer.subarray(IV_LENGTH + TAG_LENGTH)), decipher.final()]);
|
|
60
|
+
return { mode: plain.readUInt16BE(0) & 0o777, content: plain.subarray(2) };
|
|
100
61
|
} catch {
|
|
101
|
-
throw new ZurikError("E_WRONG_PASSWORD",
|
|
62
|
+
throw new ZurikError("E_WRONG_PASSWORD", `Cannot decrypt ${filePath}: wrong password or the .zurik file was modified`, {
|
|
63
|
+
path: filePath,
|
|
64
|
+
});
|
|
102
65
|
}
|
|
103
66
|
}
|
package/lib/decrypt.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { ZurikError } from "./errors.js";
|
|
4
|
-
import { compareLocal, readBundle } from "./bundle.js";
|
|
4
|
+
import { BUNDLE_FILE, compareLocal, openBundle, readBundle } from "./bundle.js";
|
|
5
5
|
|
|
6
6
|
const ACTION = { missing: "created", same: "unchanged", different: "updated" };
|
|
7
7
|
|
|
8
8
|
export default async function decryptFiles({ root, password, force = false, dryRun = false }) {
|
|
9
|
-
const
|
|
9
|
+
const bundle = readBundle(root);
|
|
10
|
+
if (bundle === null) throw new ZurikError("E_NO_BUNDLE", `${BUNDLE_FILE} not found in ${root}`);
|
|
11
|
+
|
|
12
|
+
const entries = (await openBundle(bundle, password)).map(entry => ({
|
|
10
13
|
...entry,
|
|
11
14
|
action: ACTION[compareLocal(root, entry.path, entry.content)],
|
|
12
15
|
}));
|
package/lib/encrypt.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { deriveKey, newHeader, open, seal, MIN_PASSWORD_LENGTH } from "./crypto.js";
|
|
4
4
|
import { ZurikError } from "./errors.js";
|
|
5
|
-
import { BUNDLE_FILE,
|
|
5
|
+
import { BUNDLE_FILE, readBundle, writeBundle } from "./bundle.js";
|
|
6
6
|
|
|
7
|
-
export default async function encryptFiles({ root, password, dryRun = false }) {
|
|
8
|
-
// Only enforced when encrypting, so bundles made with shorter passwords still decrypt.
|
|
7
|
+
export default async function encryptFiles({ root, password, rekey = false, dryRun = false }) {
|
|
9
8
|
if ([...password].length < MIN_PASSWORD_LENGTH) {
|
|
10
9
|
throw new ZurikError(
|
|
11
10
|
"E_WEAK_PASSWORD",
|
|
@@ -14,44 +13,47 @@ export default async function encryptFiles({ root, password, dryRun = false }) {
|
|
|
14
13
|
);
|
|
15
14
|
}
|
|
16
15
|
|
|
17
|
-
const
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
if (fileList.length === 0) {
|
|
22
|
-
throw new ZurikError("E_EMPTY_LIST", `No files listed in ${LIST_FILE}`);
|
|
23
|
-
}
|
|
16
|
+
const bundle = readBundle(root);
|
|
17
|
+
if (bundle === null) throw new ZurikError("E_NO_BUNDLE", `${BUNDLE_FILE} not found in ${root} (run 'zurik init')`);
|
|
18
|
+
if (bundle.files.length === 0) throw new ZurikError("E_EMPTY_LIST", `No files listed in ${BUNDLE_FILE}`);
|
|
24
19
|
|
|
25
|
-
// Refuse to write a partial bundle: it would silently
|
|
26
|
-
const missing =
|
|
20
|
+
// Refuse to write a partial bundle: it would silently drop secrets from .zurik.
|
|
21
|
+
const missing = bundle.files.map(f => f.path).filter(p => !fs.existsSync(path.join(root, p)));
|
|
27
22
|
if (missing.length > 0) {
|
|
28
23
|
throw new ZurikError(
|
|
29
24
|
"E_MISSING_FILES",
|
|
30
|
-
`Missing files listed in ${
|
|
25
|
+
`Missing files listed in ${BUNDLE_FILE} (run 'zurik decrypt' first?):\n ${missing.join("\n ")}`,
|
|
31
26
|
{ files: missing }
|
|
32
27
|
);
|
|
33
28
|
}
|
|
34
29
|
|
|
35
|
-
//
|
|
36
|
-
const
|
|
30
|
+
// Keeping the header (salt) lets unchanged files keep their exact block, so git diffs and merges per file.
|
|
31
|
+
const header = bundle.header && !rekey ? bundle.header : newHeader();
|
|
32
|
+
const key = await deriveKey(header, password);
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
const abs = path.join(root,
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
const files = bundle.files.map(({ path: p, data }) => {
|
|
35
|
+
const abs = path.join(root, p);
|
|
36
|
+
const content = fs.readFileSync(abs);
|
|
37
|
+
// ponytail: a permission-only change keeps the old block (and mode); compare modes too if that matters.
|
|
38
|
+
if (data && header === bundle.header && unseal(key, header, p, data).content.equals(content)) {
|
|
39
|
+
return { path: p, data, action: "unchanged" };
|
|
40
|
+
}
|
|
41
|
+
return { path: p, data: seal(key, header, p, { content, mode: fs.statSync(abs).mode }), action: "encrypted" };
|
|
42
|
+
});
|
|
43
43
|
|
|
44
|
-
const
|
|
45
|
-
|
|
44
|
+
const gitignoreAdded = updateGitignore(root, files.map(f => f.path), dryRun);
|
|
45
|
+
if (!dryRun) writeBundle(root, { header, files });
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
47
|
+
return { files: files.map(({ path, action }) => ({ path, action })), gitignoreAdded };
|
|
48
|
+
}
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
50
|
+
function unseal(key, header, p, data) {
|
|
51
|
+
try {
|
|
52
|
+
return open(key, header, p, data);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
err.message += ". To change the password, rerun with --rekey.";
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
// Appends listed files that .gitignore doesn't already contain as a whole line. Returns what was (or would be) added.
|
package/lib/errors.js
CHANGED
|
@@ -3,13 +3,13 @@ export const ERROR_CODES = {
|
|
|
3
3
|
E_USAGE: "Invalid command line arguments",
|
|
4
4
|
E_NO_PASSWORD: "No password was provided",
|
|
5
5
|
E_WEAK_PASSWORD: "Password is too short to encrypt with",
|
|
6
|
-
E_WRONG_PASSWORD: "Wrong password, or the bundle was modified",
|
|
6
|
+
E_WRONG_PASSWORD: "Wrong password, or the bundle was modified (details.path)",
|
|
7
7
|
E_CORRUPT: "The .zurik file is not a valid bundle",
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
E_EMPTY_LIST: ".
|
|
8
|
+
E_OLD_FORMAT: ".zurik was written by zurik 2.x or earlier; decrypt it with `npx zurik@2 decrypt` and set it up again",
|
|
9
|
+
E_NOT_FOUND: "No .zurik found in this directory or any parent (run `zurik init`)",
|
|
10
|
+
E_EMPTY_LIST: ".zurik lists no files",
|
|
11
11
|
E_NO_BUNDLE: ".zurik is missing",
|
|
12
|
-
E_MISSING_FILES: "Files listed in .
|
|
12
|
+
E_MISSING_FILES: "Files listed in .zurik do not exist locally (details.files)",
|
|
13
13
|
E_WOULD_OVERWRITE: "Local files differ from the bundle; rerun with --force (details.files)",
|
|
14
14
|
E_UNSAFE_PATH: "A path is absolute or outside the project (details.path)",
|
|
15
15
|
E_INTERNAL: "Unexpected internal error",
|
package/lib/index.js
CHANGED
|
@@ -1,32 +1,23 @@
|
|
|
1
1
|
// Programmatic API. Every function returns a plain result object and throws ZurikError
|
|
2
2
|
// (with a stable `code`) on failure. Nothing is printed and process.cwd() is never changed.
|
|
3
3
|
import path from "path";
|
|
4
|
+
import initList from "./init.js";
|
|
4
5
|
import encryptFiles from "./encrypt.js";
|
|
5
6
|
import decryptFiles from "./decrypt.js";
|
|
6
7
|
import statusFiles from "./status.js";
|
|
7
|
-
import { BUNDLE_FILE
|
|
8
|
+
import { BUNDLE_FILE } from "./bundle.js";
|
|
8
9
|
import { ZurikError, ERROR_CODES } from "./errors.js";
|
|
9
10
|
import { findProjectRoot } from "./paths.js";
|
|
10
11
|
import { MIN_PASSWORD_LENGTH } from "./crypto.js";
|
|
11
12
|
|
|
12
13
|
export { ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH };
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
decrypt: [BUNDLE_FILE],
|
|
17
|
-
status: [LIST_FILE, BUNDLE_FILE],
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
// `root` is used as-is; otherwise the nearest parent of `cwd` holding the command's marker file.
|
|
21
|
-
export function resolveRoot(command, { root, cwd = process.cwd() } = {}) {
|
|
15
|
+
// `root` is used as-is; otherwise the nearest parent of `cwd` holding .zurik.
|
|
16
|
+
function resolveRoot({ root, cwd = process.cwd() }) {
|
|
22
17
|
if (root) return path.resolve(root);
|
|
23
|
-
const
|
|
24
|
-
const found = findProjectRoot(markers, cwd);
|
|
18
|
+
const found = findProjectRoot([BUNDLE_FILE], cwd);
|
|
25
19
|
if (!found) {
|
|
26
|
-
throw new ZurikError(
|
|
27
|
-
"E_NOT_FOUND",
|
|
28
|
-
`${markers.join(" or ")} not found in ${path.resolve(cwd)} or any parent directory`
|
|
29
|
-
);
|
|
20
|
+
throw new ZurikError("E_NOT_FOUND", `${BUNDLE_FILE} not found in ${path.resolve(cwd)} or any parent directory (run 'zurik init')`);
|
|
30
21
|
}
|
|
31
22
|
return found;
|
|
32
23
|
}
|
|
@@ -37,10 +28,19 @@ function requirePassword(password) {
|
|
|
37
28
|
}
|
|
38
29
|
}
|
|
39
30
|
|
|
40
|
-
/**
|
|
31
|
+
/** Add .env files (or `files`) to .zurik, creating it if missing. No password needed.
|
|
32
|
+
* Options: { root?, cwd?, files?, dryRun? }. Root: `root`, else the nearest parent with .zurik or .git, else `cwd`. */
|
|
33
|
+
export async function init(options = {}) {
|
|
34
|
+
const cwd = options.cwd ?? process.cwd();
|
|
35
|
+
const root = options.root ? path.resolve(options.root) : findProjectRoot([BUNDLE_FILE, ".git"], cwd) ?? path.resolve(cwd);
|
|
36
|
+
const dryRun = Boolean(options.dryRun);
|
|
37
|
+
return { command: "init", root, dryRun, ...initList({ ...options, root, dryRun }) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Encrypt the files listed in .zurik. Options: { password, root?, cwd?, rekey?, dryRun? } */
|
|
41
41
|
export async function encrypt(options = {}) {
|
|
42
42
|
requirePassword(options.password);
|
|
43
|
-
const root = resolveRoot(
|
|
43
|
+
const root = resolveRoot(options);
|
|
44
44
|
const dryRun = Boolean(options.dryRun);
|
|
45
45
|
return { command: "encrypt", root, dryRun, ...(await encryptFiles({ ...options, root, dryRun })) };
|
|
46
46
|
}
|
|
@@ -48,13 +48,13 @@ export async function encrypt(options = {}) {
|
|
|
48
48
|
/** Decrypt .zurik into the working tree. Options: { password, root?, cwd?, force?, dryRun? } */
|
|
49
49
|
export async function decrypt(options = {}) {
|
|
50
50
|
requirePassword(options.password);
|
|
51
|
-
const root = resolveRoot(
|
|
51
|
+
const root = resolveRoot(options);
|
|
52
52
|
const dryRun = Boolean(options.dryRun);
|
|
53
53
|
return { command: "decrypt", root, dryRun, ...(await decryptFiles({ ...options, root, dryRun })) };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/** Compare .
|
|
56
|
+
/** Compare .zurik with local files. The password is only needed once something is encrypted. */
|
|
57
57
|
export async function status(options = {}) {
|
|
58
|
-
const root = resolveRoot(
|
|
58
|
+
const root = resolveRoot(options);
|
|
59
59
|
return { command: "status", root, ...(await statusFiles({ ...options, root })) };
|
|
60
60
|
}
|
package/lib/init.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { readBundle, writeBundle } from "./bundle.js";
|
|
4
|
+
import { safeRelativePath } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
7
|
+
const ENV_FILE = /^\.env(\..+)?$|\.env$/; // .env, .env.local, prod.env
|
|
8
|
+
const TEMPLATE = /\.(example|sample|template|dist)$/i; // meant to be committed, not secret
|
|
9
|
+
|
|
10
|
+
// .env-style files under `root`, as sorted "/"-separated relative paths.
|
|
11
|
+
export function findEnvFiles(root) {
|
|
12
|
+
const found = [];
|
|
13
|
+
const walk = (dir) => {
|
|
14
|
+
let entries;
|
|
15
|
+
try {
|
|
16
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
17
|
+
} catch {
|
|
18
|
+
return; // unreadable directory: nothing to offer from it
|
|
19
|
+
}
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
const abs = path.join(dir, e.name);
|
|
22
|
+
if (e.isDirectory()) {
|
|
23
|
+
if (!SKIP_DIRS.has(e.name)) walk(abs);
|
|
24
|
+
} else if (e.isFile() && ENV_FILE.test(e.name) && !TEMPLATE.test(e.name)) {
|
|
25
|
+
found.push(path.relative(root, abs).split(path.sep).join("/"));
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
walk(root);
|
|
30
|
+
return found.sort();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Adds `files` (default: every .env file found) to .zurik as not-yet-encrypted sections, creating it if needed.
|
|
34
|
+
export default function initList({ root, files, dryRun = false }) {
|
|
35
|
+
const bundle = readBundle(root);
|
|
36
|
+
const already = new Set((bundle?.files ?? []).map(f => f.path));
|
|
37
|
+
const found = findEnvFiles(root);
|
|
38
|
+
|
|
39
|
+
const chosen = new Set(files ? files.map(f => safeRelativePath(f, root)) : found);
|
|
40
|
+
const added = [...chosen].filter(f => !already.has(f));
|
|
41
|
+
|
|
42
|
+
if (!dryRun && (bundle === null || added.length > 0)) {
|
|
43
|
+
writeBundle(root, {
|
|
44
|
+
header: bundle?.header ?? null,
|
|
45
|
+
files: [...(bundle?.files ?? []), ...added.map(p => ({ path: p, data: "" }))],
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
created: bundle === null,
|
|
51
|
+
files: [
|
|
52
|
+
...found.filter(f => already.has(f)).map(p => ({ path: p, action: "already-listed" })),
|
|
53
|
+
...added.map(p => ({ path: p, action: "added" })),
|
|
54
|
+
...found.filter(f => !already.has(f) && !chosen.has(f)).map(p => ({ path: p, action: "skipped" })),
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
package/lib/paths.js
CHANGED
|
@@ -15,7 +15,7 @@ export function findProjectRoot(markers, start = process.cwd()) {
|
|
|
15
15
|
|
|
16
16
|
// Normalizes a bundle path and ensures it stays inside `root`. Returns a "/"-separated relative path.
|
|
17
17
|
export function safeRelativePath(filePath, root) {
|
|
18
|
-
if (typeof filePath !== "string" || filePath.length === 0 || path.isAbsolute(filePath)) {
|
|
18
|
+
if (typeof filePath !== "string" || filePath.length === 0 || path.isAbsolute(filePath) || /[\r\n]/.test(filePath)) {
|
|
19
19
|
throw new ZurikError("E_UNSAFE_PATH", `Refusing unsafe path: ${filePath}`, { path: filePath });
|
|
20
20
|
}
|
|
21
21
|
|
package/lib/status.js
CHANGED
|
@@ -1,39 +1,30 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import { BUNDLE_FILE, compareLocal,
|
|
3
|
+
import { BUNDLE_FILE, compareLocal, openBundle, readBundle } from "./bundle.js";
|
|
4
4
|
import { ZurikError } from "./errors.js";
|
|
5
5
|
|
|
6
|
-
// Per-file state, comparing .
|
|
6
|
+
// Per-file state, comparing .zurik with the working tree. Never reveals file contents.
|
|
7
7
|
// in-sync local file matches the bundle
|
|
8
8
|
// modified local file differs from the bundle -> encrypt to save it, or decrypt --force to discard
|
|
9
9
|
// missing-locally in the bundle, not on disk -> decrypt
|
|
10
|
-
// not-in-bundle listed and on disk, not
|
|
11
|
-
// missing listed, but neither on disk nor
|
|
12
|
-
//
|
|
10
|
+
// not-in-bundle listed and on disk, not encrypted yet -> encrypt
|
|
11
|
+
// missing listed, but neither on disk nor encrypted
|
|
12
|
+
// The password is only needed once something is encrypted.
|
|
13
13
|
export default async function status({ root, password }) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
if (
|
|
14
|
+
const bundle = readBundle(root);
|
|
15
|
+
if (bundle === null) throw new ZurikError("E_NO_BUNDLE", `${BUNDLE_FILE} not found in ${root}`);
|
|
16
|
+
if (bundle.files.some(f => f.data) && (typeof password !== "string" || password.length === 0)) {
|
|
17
17
|
throw new ZurikError("E_NO_PASSWORD", `A password is required to compare against ${BUNDLE_FILE}`);
|
|
18
18
|
}
|
|
19
|
-
const
|
|
20
|
-
const inBundle = new Map(bundle.map(e => [e.path, e.content]));
|
|
19
|
+
const opened = new Map((await openBundle(bundle, password)).map(e => [e.path, e.content]));
|
|
21
20
|
|
|
22
21
|
const STATE = { missing: "missing-locally", same: "in-sync", different: "modified" };
|
|
23
|
-
const
|
|
22
|
+
const files = bundle.files.map(({ path: p }) => ({
|
|
23
|
+
path: p,
|
|
24
|
+
state: opened.has(p)
|
|
25
|
+
? STATE[compareLocal(root, p, opened.get(p))]
|
|
26
|
+
: fs.existsSync(path.join(root, p)) ? "not-in-bundle" : "missing",
|
|
27
|
+
}));
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
const state = inBundle.has(p)
|
|
27
|
-
? STATE[compareLocal(root, p, inBundle.get(p))]
|
|
28
|
-
: fs.existsSync(path.join(root, p))
|
|
29
|
-
? "not-in-bundle"
|
|
30
|
-
: "missing";
|
|
31
|
-
return { path: p, state, listed: listed.has(p) };
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
bundleExists,
|
|
36
|
-
inSync: files.length > 0 && files.every(f => f.state === "in-sync" && f.listed),
|
|
37
|
-
files,
|
|
38
|
-
};
|
|
29
|
+
return { inSync: files.length > 0 && files.every(f => f.state === "in-sync"), files };
|
|
39
30
|
}
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# zurik
|
|
2
2
|
|
|
3
3
|
A Node.js CLI tool for encrypting and decrypting sensitive project files.
|
|
4
|
-
It
|
|
4
|
+
It encrypts your secret files into a single `.zurik` file that you commit, and decrypts them when needed.
|
|
5
5
|
|
|
6
6
|
## Features
|
|
7
7
|
|
|
8
|
-
- Encrypt multiple files into a **single** `.zurik`
|
|
8
|
+
- Encrypt multiple files into a **single** `.zurik` file, one section per file, so git diffs and merges each secret separately
|
|
9
9
|
- AES-256-GCM authenticated encryption with a scrypt-derived key
|
|
10
10
|
- Automatically appends sensitive files to `.gitignore` when encrypting
|
|
11
11
|
- Preserves file permissions; never overwrites changed local files without `--force`
|
|
@@ -55,26 +55,31 @@ zurik never prompts when stdin is not a terminal, or with `--json`, `--no-input`
|
|
|
55
55
|
|
|
56
56
|
## Usage
|
|
57
57
|
|
|
58
|
-
###
|
|
58
|
+
### Set up a project
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
```sh
|
|
61
|
+
npx zurik init
|
|
62
|
+
```
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
# secrets for local development
|
|
65
|
-
src/config.json
|
|
66
|
-
src/secret.txt
|
|
67
|
-
```
|
|
64
|
+
`init` finds the `.env` files in your project (`.env`, `.env.local`, `prod.env`, ...; skipping `node_modules` and templates like `.env.example`) and lists them with all of them selected. Untick any you don't want (space toggles, enter confirms) and they are listed in `.zurik`, which is created if it doesn't exist. Run it again any time to pick up new `.env` files; files already listed are left alone. It needs no password.
|
|
68
65
|
|
|
69
|
-
|
|
66
|
+
Outside a terminal (`--json`, `--no-input`, `$CI` or piped stdin) it adds every file found without asking. Add `--dry-run` to only see what it would do.
|
|
70
67
|
|
|
71
|
-
|
|
72
|
-
npx zurik encrypt
|
|
73
|
-
```
|
|
68
|
+
To encrypt other files, add a `--- <path>` line for each to `.zurik` (paths must be inside the project). To stop encrypting a file, delete its section.
|
|
74
69
|
|
|
75
|
-
|
|
70
|
+
### Encrypt files
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
```sh
|
|
73
|
+
npx zurik encrypt
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Every file listed in `.zurik` is encrypted into its section, and any that are not already in `.gitignore` are appended to it. Commit `.zurik`.
|
|
77
|
+
|
|
78
|
+
Files that haven't changed keep their exact encrypted text, so a commit only touches the sections of the secrets that changed, and two people changing different secrets merge without a conflict. If two people change the *same* secret, resolve the conflict by keeping one side and running `zurik encrypt` again.
|
|
79
|
+
|
|
80
|
+
Every listed file must exist. If any is missing, nothing is written, so secrets are never dropped from `.zurik`. On a fresh clone, run `zurik decrypt` first.
|
|
81
|
+
|
|
82
|
+
To change the password, run `zurik encrypt --rekey` with the new one; every file is re-encrypted. Without `--rekey`, encrypting with a different password than the one `.zurik` uses fails, so a typo can't lock the team out.
|
|
78
83
|
|
|
79
84
|
### Decrypt files
|
|
80
85
|
|
|
@@ -90,11 +95,11 @@ Files are restored to their original paths with their original permissions. If a
|
|
|
90
95
|
npx zurik status
|
|
91
96
|
```
|
|
92
97
|
|
|
93
|
-
Shows, for every file, whether it is `in-sync`, `modified`, `missing-locally`, `not-in-bundle` or `missing`, without writing anything or printing contents. The password is only needed once
|
|
98
|
+
Shows, for every file, whether it is `in-sync`, `modified`, `missing-locally`, `not-in-bundle` (listed, not encrypted yet) or `missing`, without writing anything or printing contents. The password is only needed once something is encrypted.
|
|
94
99
|
|
|
95
100
|
Add `--dry-run` (`-n`) to `encrypt` or `decrypt` to see what would happen without writing anything.
|
|
96
101
|
|
|
97
|
-
You can run zurik from any subdirectory: it uses the nearest parent directory containing `.
|
|
102
|
+
You can run zurik from any subdirectory: it uses the nearest parent directory containing `.zurik`. `--cwd <dir>` runs it against another directory.
|
|
98
103
|
|
|
99
104
|
The short flags from earlier versions still work: `-e` (encrypt), `-d` (decrypt), `-s` (status).
|
|
100
105
|
|
|
@@ -112,7 +117,7 @@ mkdir -p .claude/skills/zurik && npx zurik skill > .claude/skills/zurik/SKILL.md
|
|
|
112
117
|
For other agents, add the output to `AGENTS.md`, or add this line:
|
|
113
118
|
|
|
114
119
|
```md
|
|
115
|
-
Secrets are managed with zurik (.
|
|
120
|
+
Secrets are managed with zurik (.zurik). Run `npx zurik skill` for instructions; never read or print secret files or the password.
|
|
116
121
|
```
|
|
117
122
|
|
|
118
123
|
**Provide the password** through `ZURIK_PASSWORD` or `ZURIK_PASSWORD_FILE` in the agent's environment, never in the prompt.
|
|
@@ -121,8 +126,8 @@ Secrets are managed with zurik (.gitencrypt / .zurik). Run `npx zurik skill` for
|
|
|
121
126
|
|
|
122
127
|
```sh
|
|
123
128
|
$ npx zurik status --json
|
|
124
|
-
{"ok":true,"command":"status","root":"/repo","
|
|
125
|
-
"files":[{"path":".env","state":"modified"
|
|
129
|
+
{"ok":true,"command":"status","root":"/repo","inSync":false,
|
|
130
|
+
"files":[{"path":".env","state":"modified"}]}
|
|
126
131
|
|
|
127
132
|
$ npx zurik decrypt --json
|
|
128
133
|
{"ok":false,"error":{"code":"E_WOULD_OVERWRITE","message":"...","details":{"files":[".env"]}}}
|
|
@@ -133,7 +138,7 @@ Branch on `error.code`. Exit codes are `0` success, `1` error and `2` usage erro
|
|
|
133
138
|
**Library API** for harnesses and scripts. It returns the same objects as `--json`, throws `ZurikError` with a `code`, prints nothing and never changes `process.cwd()`:
|
|
134
139
|
|
|
135
140
|
```js
|
|
136
|
-
import { encrypt, decrypt, status, ZurikError } from "zurik";
|
|
141
|
+
import { init, encrypt, decrypt, status, ZurikError } from "zurik";
|
|
137
142
|
|
|
138
143
|
const s = await status({ cwd: "/repo", password: process.env.ZURIK_PASSWORD });
|
|
139
144
|
if (!s.inSync) {
|
|
@@ -146,16 +151,46 @@ if (!s.inSync) {
|
|
|
146
151
|
}
|
|
147
152
|
```
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
`init({ cwd, files, dryRun })` lists `files` (default: every `.env` file found) in `.zurik` and needs no password.
|
|
155
|
+
|
|
156
|
+
Options: `password`, `cwd` (search for the project root from here) or `root` (use exactly), `force`, `rekey` and `dryRun`.
|
|
157
|
+
|
|
158
|
+
## File format
|
|
150
159
|
|
|
151
|
-
|
|
160
|
+
`.zurik` is a text file with a header line and one section per file:
|
|
152
161
|
|
|
153
|
-
|
|
162
|
+
```txt
|
|
163
|
+
zurik3 scrypt logN=17 r=8 p=1 salt=<base64>
|
|
154
164
|
|
|
155
|
-
|
|
165
|
+
--- .env
|
|
166
|
+
<base64 of iv (12) | tag (16) | ciphertext>
|
|
167
|
+
|
|
168
|
+
--- api/.env.production
|
|
169
|
+
<base64 ...>
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The key is derived once from the password with the scrypt parameters and salt in the header. Each file is encrypted separately with AES-256-GCM; its plaintext is its permission bits followed by its content. The header line and the file's path are authenticated as additional data, so the scrypt parameters can't be weakened and a section can't be moved to another path. A section with no data line is listed but not encrypted yet.
|
|
173
|
+
|
|
174
|
+
Because files are encrypted separately, anyone who can read `.zurik` can see each file's approximate size and which file changed in each commit (the paths are visible anyway, as they are in `.gitignore`).
|
|
175
|
+
|
|
176
|
+
## Upgrading from zurik 2.x
|
|
177
|
+
|
|
178
|
+
zurik 3 uses a new format and no longer reads `.zurik` files from 2.x or `.gitencrypt`. To migrate a project:
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
npx zurik@2 decrypt # restore the files with the old version
|
|
182
|
+
rm .zurik # keep .gitencrypt for now: it shows which files to list
|
|
183
|
+
npx zurik init # lists the .env files; add a "--- <path>" line for any others
|
|
184
|
+
npx zurik encrypt
|
|
185
|
+
git rm .gitencrypt
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Everyone on the team needs zurik 3 after that; zurik 2.x can't read the new file.
|
|
156
189
|
|
|
157
190
|
## Development
|
|
158
191
|
|
|
159
192
|
```sh
|
|
160
193
|
npm test
|
|
161
194
|
```
|
|
195
|
+
|
|
196
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md).
|
package/skills/zurik/SKILL.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: zurik
|
|
3
|
-
description: Encrypt, decrypt and check project secrets (.env files, keys, config) that are stored in a .zurik
|
|
3
|
+
description: Encrypt, decrypt and check project secrets (.env files, keys, config) that are stored in a .zurik file. Use whenever a repo contains .zurik, when secret files are missing after a clone, when the user asks to encrypt/commit/share/restore secrets, or before editing or committing a file listed in .zurik. Prefer it over reading, copying, printing or hand-encrypting secret files.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# zurik
|
|
7
7
|
|
|
8
|
-
zurik keeps a project's secret files encrypted in one committed file, `.zurik
|
|
8
|
+
zurik keeps a project's secret files encrypted in one committed file, `.zurik`, with one `--- <path>` section per file. The plaintext files are git-ignored. zurik never prints file contents, so you can manage secrets without them entering your context.
|
|
9
9
|
|
|
10
10
|
Run it with `npx zurik` (or `zurik` if it's installed). Always pass `--json` and branch on `ok` / `error.code`, never on message text.
|
|
11
11
|
|
|
@@ -13,13 +13,14 @@ Run it with `npx zurik` (or `zurik` if it's installed). Always pass `--json` and
|
|
|
13
13
|
|
|
14
14
|
| Goal | Command |
|
|
15
15
|
|---|---|
|
|
16
|
+
| Set up a repo without `.zurik` (lists every `.env` file found; no password) | `npx zurik init --dry-run --json`, confirm the list with the user, then `npx zurik init --json` |
|
|
16
17
|
| See what's out of sync (safe, read-only) | `npx zurik status --json` |
|
|
17
18
|
| Restore secrets after a clone | `npx zurik decrypt --json` |
|
|
18
19
|
| Save changes to secret files into `.zurik` | `npx zurik encrypt --json` |
|
|
19
20
|
| Preview without writing anything | add `--dry-run` |
|
|
20
21
|
| Machine-readable spec of all options and codes | `npx zurik help --json` |
|
|
21
22
|
|
|
22
|
-
`--cwd <dir>` runs against another directory. zurik uses the nearest parent directory containing `.
|
|
23
|
+
`--cwd <dir>` runs against another directory. zurik uses the nearest parent directory containing `.zurik`.
|
|
23
24
|
|
|
24
25
|
## Password: never put it in your context
|
|
25
26
|
|
|
@@ -30,22 +31,23 @@ Run it with `npx zurik` (or `zurik` if it's installed). Always pass `--json` and
|
|
|
30
31
|
|
|
31
32
|
## Workflow
|
|
32
33
|
|
|
33
|
-
1. `status --json` first. States: `in-sync`, `modified` (local differs from bundle), `missing-locally` (run decrypt), `not-in-bundle` (run encrypt), `missing` (listed but absent everywhere).
|
|
34
|
+
1. `status --json` first. States: `in-sync`, `modified` (local differs from bundle), `missing-locally` (run decrypt), `not-in-bundle` (listed, not encrypted yet: run encrypt), `missing` (listed but absent everywhere).
|
|
34
35
|
2. After a clone or when files are `missing-locally`: `decrypt --json`.
|
|
35
|
-
3. After you or the user changed a secret file: `encrypt --json`, then commit `.zurik` (and `.
|
|
36
|
-
4. To protect a new secret file: append
|
|
36
|
+
3. After you or the user changed a secret file: `encrypt --json`, then commit `.zurik` (and `.gitignore` if it changed). Never commit the plaintext files.
|
|
37
|
+
4. To protect a new secret file: append a `--- <path>` line to `.zurik`, then `encrypt --json`. zurik adds it to `.gitignore` for you.
|
|
37
38
|
|
|
38
39
|
## Errors
|
|
39
40
|
|
|
40
41
|
| `error.code` | What to do |
|
|
41
42
|
|---|---|
|
|
42
43
|
| `E_NO_PASSWORD` | Ask the user to provide the password via the environment (see above). |
|
|
43
|
-
| `E_WRONG_PASSWORD` | Wrong password or tampered
|
|
44
|
+
| `E_WRONG_PASSWORD` | Wrong password or tampered `.zurik`. Tell the user; don't retry with guesses, and never use `--rekey` unless the user asks to change the password. |
|
|
44
45
|
| `E_WOULD_OVERWRITE` | `error.details.files` have local changes. **Ask the user** before rerunning with `--force`; it discards those changes. If they should be kept, run `encrypt` instead. |
|
|
45
|
-
| `E_MISSING_FILES` | `error.details.files` are listed but absent. Usually run `decrypt` first, or remove
|
|
46
|
+
| `E_MISSING_FILES` | `error.details.files` are listed but absent. Usually run `decrypt` first, or remove their sections from `.zurik` if they're obsolete. |
|
|
46
47
|
| `E_WEAK_PASSWORD` | Encrypting needs 16+ characters. The user must choose a longer password. |
|
|
47
|
-
| `E_NOT_FOUND`, `
|
|
48
|
-
| `
|
|
48
|
+
| `E_NOT_FOUND`, `E_NO_BUNDLE`, `E_EMPTY_LIST` | Nothing to work on at this location; check `--cwd`, or run `init` to set up `.zurik`. |
|
|
49
|
+
| `E_OLD_FORMAT` | `.zurik` is from zurik 2.x. Tell the user; migrating needs `npx zurik@2 decrypt` and the password (see the readme). Don't delete `.zurik` yourself. |
|
|
50
|
+
| `E_UNSAFE_PATH` | A path in `.zurik` is absolute or outside the project. If you added it, fix it; otherwise treat the file as suspicious and tell the user. |
|
|
49
51
|
|
|
50
52
|
Exit codes: `0` success, `1` error, `2` usage error.
|
|
51
53
|
|
|
@@ -53,4 +55,4 @@ Exit codes: `0` success, `1` error, `2` usage error.
|
|
|
53
55
|
|
|
54
56
|
- Don't `cat`, print, summarize or copy decrypted secret files unless the user explicitly asks to see them. Edit them in place when a task requires it.
|
|
55
57
|
- Don't use `--force` without the user's go-ahead.
|
|
56
|
-
- Don't delete `.zurik`, and don't remove
|
|
58
|
+
- Don't delete `.zurik`, and don't remove its sections unless asked.
|