zurik 2.1.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 +31 -23
- 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 -30
- package/lib/init.js +10 -12
- package/lib/paths.js +1 -1
- package/lib/status.js +16 -25
- package/package.json +1 -1
- package/readme.md +48 -27
- package/skills/zurik/SKILL.md +13 -12
package/bin/zurik.js
CHANGED
|
@@ -4,7 +4,7 @@ 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 { init, 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;
|
|
@@ -12,11 +12,11 @@ const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_DIR, "package.json"), "
|
|
|
12
12
|
const EXIT = { OK: 0, ERROR: 1, USAGE: 2 };
|
|
13
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 init [options] Find .env files and
|
|
19
|
-
zurik encrypt [options] Encrypt files listed in .
|
|
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)
|
|
20
20
|
zurik decrypt [options] Restore files from .zurik (alias: -d)
|
|
21
21
|
zurik status [options] Show which files are in sync, without decrypting (alias: -s)
|
|
22
22
|
zurik skill Print instructions for AI agents (SKILL.md)
|
|
@@ -28,6 +28,7 @@ Options:
|
|
|
28
28
|
--password-stdin Read the password from the first line of stdin
|
|
29
29
|
--password-file <path> Read the password from a file
|
|
30
30
|
-f, --force Decrypt: overwrite local files that differ from .zurik
|
|
31
|
+
--rekey Encrypt: change the password (new salt, re-encrypts every file)
|
|
31
32
|
-n, --dry-run Report what would happen without writing anything
|
|
32
33
|
--json Print one JSON object on stdout (results and errors)
|
|
33
34
|
--no-input Never prompt; fail with E_NO_PASSWORD instead
|
|
@@ -41,15 +42,16 @@ a terminal (never with --json, --no-input or $CI). Encrypting needs ${MIN_PASSWO
|
|
|
41
42
|
init needs no password. In a terminal it lets you pick the files (all selected by default);
|
|
42
43
|
otherwise (--json, --no-input, $CI or piped stdin) it adds every .env file found.
|
|
43
44
|
|
|
44
|
-
zurik
|
|
45
|
-
|
|
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.`;
|
|
46
48
|
|
|
47
49
|
function usageError(message) {
|
|
48
50
|
return new ZurikError("E_USAGE", message);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
function parseArgs(argv) {
|
|
52
|
-
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 };
|
|
53
55
|
|
|
54
56
|
// Support --flag=value for long options.
|
|
55
57
|
const args = argv.flatMap(a => (a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a]));
|
|
@@ -81,6 +83,7 @@ function parseArgs(argv) {
|
|
|
81
83
|
case "--password-stdin": opts.passwordStdin = true; break;
|
|
82
84
|
case "--password-file": opts.passwordFile = takeValue(i++, a); break;
|
|
83
85
|
case "-f": case "--force": opts.force = true; break;
|
|
86
|
+
case "--rekey": opts.rekey = true; break;
|
|
84
87
|
case "-n": case "--dry-run": opts.dryRun = true; break;
|
|
85
88
|
case "--json": opts.json = true; break;
|
|
86
89
|
case "--no-input": opts.noInput = true; break;
|
|
@@ -165,7 +168,7 @@ function selectFiles(items) {
|
|
|
165
168
|
|
|
166
169
|
const draw = () => {
|
|
167
170
|
const lines = [
|
|
168
|
-
"Select files to
|
|
171
|
+
"Select files to encrypt with zurik (up/down move, space toggle, a all/none, enter confirm):",
|
|
169
172
|
...items.map((p, i) => `${i === cursor ? ">" : " "} [${selected[i] ? "x" : " "}] ${p}`),
|
|
170
173
|
];
|
|
171
174
|
if (drawn) out.write(`\x1b[${drawn}A`);
|
|
@@ -234,20 +237,20 @@ function printHuman(result) {
|
|
|
234
237
|
const log = (s) => process.stdout.write(s + "\n");
|
|
235
238
|
|
|
236
239
|
if (result.command === "init") {
|
|
237
|
-
log(!result.created ? "Using existing .
|
|
240
|
+
log(!result.created ? "Using existing .zurik" : result.dryRun ? "[dry run] would create .zurik" : "Created .zurik");
|
|
238
241
|
for (const f of result.files) log(` ${f.action === "added" ? prefix : ""}${f.action.padEnd(14)} ${f.path}`);
|
|
239
|
-
if (result.files.length === 0) log(
|
|
240
|
-
log("Next: run 'zurik encrypt' and commit .zurik
|
|
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.");
|
|
241
244
|
} else if (result.command === "encrypt") {
|
|
242
|
-
for (const f of result.files) log(` ${prefix}
|
|
245
|
+
for (const f of result.files) log(` ${f.action === "unchanged" ? "" : prefix}${f.action.padEnd(9)} ${f.path}`);
|
|
243
246
|
if (result.gitignoreAdded.length) log(` ${prefix}added to .gitignore: ${result.gitignoreAdded.join(", ")}`);
|
|
244
|
-
|
|
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`);
|
|
245
249
|
} else if (result.command === "decrypt") {
|
|
246
250
|
for (const f of result.files) log(` ${f.action === "unchanged" ? "" : prefix}${f.action.padEnd(9)} ${f.path}`);
|
|
247
251
|
log(`${result.dryRun ? "Would decrypt" : "Decrypted"} ${result.files.length} file(s) from .zurik`);
|
|
248
252
|
} else if (result.command === "status") {
|
|
249
|
-
|
|
250
|
-
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}`);
|
|
251
254
|
log(result.inSync ? "All files in sync." : "Out of sync.");
|
|
252
255
|
}
|
|
253
256
|
}
|
|
@@ -258,9 +261,9 @@ function helpSpec() {
|
|
|
258
261
|
version: VERSION,
|
|
259
262
|
commands: {
|
|
260
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" },
|
|
261
|
-
encrypt: { options: ["password*", "dryRun", "cwd"], result: "{ files: [{ path, action: 'encrypted' }], gitignoreAdded: [path] }" },
|
|
264
|
+
encrypt: { options: ["password*", "rekey", "dryRun", "cwd"], result: "{ files: [{ path, action: 'encrypted'|'unchanged' }], gitignoreAdded: [path] }" },
|
|
262
265
|
decrypt: { options: ["password*", "force", "dryRun", "cwd"], result: "{ files: [{ path, action: 'created'|'updated'|'unchanged' }] }" },
|
|
263
|
-
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' }] }" },
|
|
264
267
|
},
|
|
265
268
|
passwordSources: ["--password <value>", "--password-file <path>", "--password-stdin", "$ZURIK_PASSWORD", "$ZURIK_PASSWORD_FILE"],
|
|
266
269
|
minPasswordLength: MIN_PASSWORD_LENGTH,
|
|
@@ -279,11 +282,16 @@ async function runInit(opts, base) {
|
|
|
279
282
|
}
|
|
280
283
|
|
|
281
284
|
async function runWithPassword(opts, base) {
|
|
282
|
-
// status only needs a password
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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) {
|
|
287
295
|
throw new ZurikError(
|
|
288
296
|
"E_NO_PASSWORD",
|
|
289
297
|
"No password provided. Use --password-file, --password-stdin, $ZURIK_PASSWORD or -p."
|
|
@@ -291,7 +299,7 @@ async function runWithPassword(opts, base) {
|
|
|
291
299
|
}
|
|
292
300
|
|
|
293
301
|
const run = { encrypt, decrypt, status }[opts.command];
|
|
294
|
-
return run({ ...base, password, force: opts.force, dryRun: opts.dryRun });
|
|
302
|
+
return run({ ...base, password, force: opts.force, rekey: opts.rekey, dryRun: opts.dryRun });
|
|
295
303
|
}
|
|
296
304
|
|
|
297
305
|
// ---------- main ----------
|
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,33 +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
|
|
8
|
-
import { BUNDLE_FILE, LIST_FILE } from "./bundle.js";
|
|
8
|
+
import { BUNDLE_FILE } from "./bundle.js";
|
|
9
9
|
import { ZurikError, ERROR_CODES } from "./errors.js";
|
|
10
10
|
import { findProjectRoot } from "./paths.js";
|
|
11
11
|
import { MIN_PASSWORD_LENGTH } from "./crypto.js";
|
|
12
12
|
|
|
13
13
|
export { ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH };
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
decrypt: [BUNDLE_FILE],
|
|
18
|
-
status: [LIST_FILE, BUNDLE_FILE],
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
// `root` is used as-is; otherwise the nearest parent of `cwd` holding the command's marker file.
|
|
22
|
-
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() }) {
|
|
23
17
|
if (root) return path.resolve(root);
|
|
24
|
-
const
|
|
25
|
-
const found = findProjectRoot(markers, cwd);
|
|
18
|
+
const found = findProjectRoot([BUNDLE_FILE], cwd);
|
|
26
19
|
if (!found) {
|
|
27
|
-
throw new ZurikError(
|
|
28
|
-
"E_NOT_FOUND",
|
|
29
|
-
`${markers.join(" or ")} not found in ${path.resolve(cwd)} or any parent directory`
|
|
30
|
-
);
|
|
20
|
+
throw new ZurikError("E_NOT_FOUND", `${BUNDLE_FILE} not found in ${path.resolve(cwd)} or any parent directory (run 'zurik init')`);
|
|
31
21
|
}
|
|
32
22
|
return found;
|
|
33
23
|
}
|
|
@@ -38,10 +28,19 @@ function requirePassword(password) {
|
|
|
38
28
|
}
|
|
39
29
|
}
|
|
40
30
|
|
|
41
|
-
/**
|
|
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? } */
|
|
42
41
|
export async function encrypt(options = {}) {
|
|
43
42
|
requirePassword(options.password);
|
|
44
|
-
const root = resolveRoot(
|
|
43
|
+
const root = resolveRoot(options);
|
|
45
44
|
const dryRun = Boolean(options.dryRun);
|
|
46
45
|
return { command: "encrypt", root, dryRun, ...(await encryptFiles({ ...options, root, dryRun })) };
|
|
47
46
|
}
|
|
@@ -49,22 +48,13 @@ export async function encrypt(options = {}) {
|
|
|
49
48
|
/** Decrypt .zurik into the working tree. Options: { password, root?, cwd?, force?, dryRun? } */
|
|
50
49
|
export async function decrypt(options = {}) {
|
|
51
50
|
requirePassword(options.password);
|
|
52
|
-
const root = resolveRoot(
|
|
51
|
+
const root = resolveRoot(options);
|
|
53
52
|
const dryRun = Boolean(options.dryRun);
|
|
54
53
|
return { command: "decrypt", root, dryRun, ...(await decryptFiles({ ...options, root, dryRun })) };
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
/** Compare .
|
|
56
|
+
/** Compare .zurik with local files. The password is only needed once something is encrypted. */
|
|
58
57
|
export async function status(options = {}) {
|
|
59
|
-
const root = resolveRoot(
|
|
58
|
+
const root = resolveRoot(options);
|
|
60
59
|
return { command: "status", root, ...(await statusFiles({ ...options, root })) };
|
|
61
60
|
}
|
|
62
|
-
|
|
63
|
-
/** Add .env files (or `files`) to .gitencrypt, creating it if missing. No password needed.
|
|
64
|
-
* Options: { root?, cwd?, files?, dryRun? }. Root: `root`, else the nearest parent with .gitencrypt or .git, else `cwd`. */
|
|
65
|
-
export async function init(options = {}) {
|
|
66
|
-
const cwd = options.cwd ?? process.cwd();
|
|
67
|
-
const root = options.root ? path.resolve(options.root) : findProjectRoot([LIST_FILE, ".git"], cwd) ?? path.resolve(cwd);
|
|
68
|
-
const dryRun = Boolean(options.dryRun);
|
|
69
|
-
return { command: "init", root, dryRun, ...initList({ ...options, root, dryRun }) };
|
|
70
|
-
}
|
package/lib/init.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { readBundle, writeBundle } from "./bundle.js";
|
|
4
4
|
import { safeRelativePath } from "./paths.js";
|
|
5
5
|
|
|
6
6
|
const SKIP_DIRS = new Set(["node_modules", ".git"]);
|
|
7
7
|
const ENV_FILE = /^\.env(\..+)?$|\.env$/; // .env, .env.local, prod.env
|
|
8
8
|
const TEMPLATE = /\.(example|sample|template|dist)$/i; // meant to be committed, not secret
|
|
9
9
|
|
|
10
|
-
const HEADER = "# Files to encrypt into .zurik with `zurik encrypt`, one path per line.\n";
|
|
11
|
-
|
|
12
10
|
// .env-style files under `root`, as sorted "/"-separated relative paths.
|
|
13
11
|
export function findEnvFiles(root) {
|
|
14
12
|
const found = [];
|
|
@@ -32,24 +30,24 @@ export function findEnvFiles(root) {
|
|
|
32
30
|
return found.sort();
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
// Adds `files` (default: every .env file found) to .
|
|
33
|
+
// Adds `files` (default: every .env file found) to .zurik as not-yet-encrypted sections, creating it if needed.
|
|
36
34
|
export default function initList({ root, files, dryRun = false }) {
|
|
37
|
-
const
|
|
38
|
-
const already = new Set(
|
|
35
|
+
const bundle = readBundle(root);
|
|
36
|
+
const already = new Set((bundle?.files ?? []).map(f => f.path));
|
|
39
37
|
const found = findEnvFiles(root);
|
|
40
38
|
|
|
41
39
|
const chosen = new Set(files ? files.map(f => safeRelativePath(f, root)) : found);
|
|
42
40
|
const added = [...chosen].filter(f => !already.has(f));
|
|
43
41
|
|
|
44
|
-
if (!dryRun && (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
+
});
|
|
49
47
|
}
|
|
50
48
|
|
|
51
49
|
return {
|
|
52
|
-
created:
|
|
50
|
+
created: bundle === null,
|
|
53
51
|
files: [
|
|
54
52
|
...found.filter(f => already.has(f)).map(p => ({ path: p, action: "already-listed" })),
|
|
55
53
|
...added.map(p => ({ path: p, action: "added" })),
|
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`
|
|
@@ -61,30 +61,25 @@ zurik never prompts when stdin is not a terminal, or with `--json`, `--no-input`
|
|
|
61
61
|
npx zurik init
|
|
62
62
|
```
|
|
63
63
|
|
|
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
|
|
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.
|
|
65
65
|
|
|
66
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.
|
|
67
67
|
|
|
68
|
-
|
|
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.
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
2. Add the paths of the files you want to encrypt, one per line. Lines starting with `#` are ignored, and paths must be inside the project.
|
|
70
|
+
### Encrypt files
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
src/secret.txt
|
|
77
|
-
```
|
|
72
|
+
```sh
|
|
73
|
+
npx zurik encrypt
|
|
74
|
+
```
|
|
78
75
|
|
|
79
|
-
|
|
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`.
|
|
80
77
|
|
|
81
|
-
|
|
82
|
-
npx zurik encrypt
|
|
83
|
-
```
|
|
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.
|
|
84
79
|
|
|
85
|
-
|
|
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.
|
|
86
81
|
|
|
87
|
-
|
|
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.
|
|
88
83
|
|
|
89
84
|
### Decrypt files
|
|
90
85
|
|
|
@@ -100,11 +95,11 @@ Files are restored to their original paths with their original permissions. If a
|
|
|
100
95
|
npx zurik status
|
|
101
96
|
```
|
|
102
97
|
|
|
103
|
-
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.
|
|
104
99
|
|
|
105
100
|
Add `--dry-run` (`-n`) to `encrypt` or `decrypt` to see what would happen without writing anything.
|
|
106
101
|
|
|
107
|
-
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.
|
|
108
103
|
|
|
109
104
|
The short flags from earlier versions still work: `-e` (encrypt), `-d` (decrypt), `-s` (status).
|
|
110
105
|
|
|
@@ -122,7 +117,7 @@ mkdir -p .claude/skills/zurik && npx zurik skill > .claude/skills/zurik/SKILL.md
|
|
|
122
117
|
For other agents, add the output to `AGENTS.md`, or add this line:
|
|
123
118
|
|
|
124
119
|
```md
|
|
125
|
-
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.
|
|
126
121
|
```
|
|
127
122
|
|
|
128
123
|
**Provide the password** through `ZURIK_PASSWORD` or `ZURIK_PASSWORD_FILE` in the agent's environment, never in the prompt.
|
|
@@ -131,8 +126,8 @@ Secrets are managed with zurik (.gitencrypt / .zurik). Run `npx zurik skill` for
|
|
|
131
126
|
|
|
132
127
|
```sh
|
|
133
128
|
$ npx zurik status --json
|
|
134
|
-
{"ok":true,"command":"status","root":"/repo","
|
|
135
|
-
"files":[{"path":".env","state":"modified"
|
|
129
|
+
{"ok":true,"command":"status","root":"/repo","inSync":false,
|
|
130
|
+
"files":[{"path":".env","state":"modified"}]}
|
|
136
131
|
|
|
137
132
|
$ npx zurik decrypt --json
|
|
138
133
|
{"ok":false,"error":{"code":"E_WOULD_OVERWRITE","message":"...","details":{"files":[".env"]}}}
|
|
@@ -156,15 +151,41 @@ if (!s.inSync) {
|
|
|
156
151
|
}
|
|
157
152
|
```
|
|
158
153
|
|
|
159
|
-
`init({ cwd, files, dryRun })`
|
|
154
|
+
`init({ cwd, files, dryRun })` lists `files` (default: every `.env` file found) in `.zurik` and needs no password.
|
|
160
155
|
|
|
161
|
-
Options: `password`, `cwd` (search for the project root from here) or `root` (use exactly), `force` and `dryRun`.
|
|
156
|
+
Options: `password`, `cwd` (search for the project root from here) or `root` (use exactly), `force`, `rekey` and `dryRun`.
|
|
162
157
|
|
|
163
|
-
##
|
|
158
|
+
## File format
|
|
164
159
|
|
|
165
|
-
`.zurik` is a text file
|
|
160
|
+
`.zurik` is a text file with a header line and one section per file:
|
|
161
|
+
|
|
162
|
+
```txt
|
|
163
|
+
zurik3 scrypt logN=17 r=8 p=1 salt=<base64>
|
|
164
|
+
|
|
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
|
+
```
|
|
166
187
|
|
|
167
|
-
|
|
188
|
+
Everyone on the team needs zurik 3 after that; zurik 2.x can't read the new file.
|
|
168
189
|
|
|
169
190
|
## Development
|
|
170
191
|
|
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,14 +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 `.
|
|
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` |
|
|
17
17
|
| See what's out of sync (safe, read-only) | `npx zurik status --json` |
|
|
18
18
|
| Restore secrets after a clone | `npx zurik decrypt --json` |
|
|
19
19
|
| Save changes to secret files into `.zurik` | `npx zurik encrypt --json` |
|
|
20
20
|
| Preview without writing anything | add `--dry-run` |
|
|
21
21
|
| Machine-readable spec of all options and codes | `npx zurik help --json` |
|
|
22
22
|
|
|
23
|
-
`--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`.
|
|
24
24
|
|
|
25
25
|
## Password: never put it in your context
|
|
26
26
|
|
|
@@ -31,22 +31,23 @@ Run it with `npx zurik` (or `zurik` if it's installed). Always pass `--json` and
|
|
|
31
31
|
|
|
32
32
|
## Workflow
|
|
33
33
|
|
|
34
|
-
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).
|
|
35
35
|
2. After a clone or when files are `missing-locally`: `decrypt --json`.
|
|
36
|
-
3. After you or the user changed a secret file: `encrypt --json`, then commit `.zurik` (and `.
|
|
37
|
-
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.
|
|
38
38
|
|
|
39
39
|
## Errors
|
|
40
40
|
|
|
41
41
|
| `error.code` | What to do |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `E_NO_PASSWORD` | Ask the user to provide the password via the environment (see above). |
|
|
44
|
-
| `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. |
|
|
45
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. |
|
|
46
|
-
| `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. |
|
|
47
47
|
| `E_WEAK_PASSWORD` | Encrypting needs 16+ characters. The user must choose a longer password. |
|
|
48
|
-
| `E_NOT_FOUND`, `
|
|
49
|
-
| `
|
|
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. |
|
|
50
51
|
|
|
51
52
|
Exit codes: `0` success, `1` error, `2` usage error.
|
|
52
53
|
|
|
@@ -54,4 +55,4 @@ Exit codes: `0` success, `1` error, `2` usage error.
|
|
|
54
55
|
|
|
55
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.
|
|
56
57
|
- Don't use `--force` without the user's go-ahead.
|
|
57
|
-
- Don't delete `.zurik`, and don't remove
|
|
58
|
+
- Don't delete `.zurik`, and don't remove its sections unless asked.
|