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 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, resolveRoot, ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH } from "../lib/index.js";
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 bundle
15
+ const HELP = `zurik ${VERSION} — encrypt project secrets into a single .zurik file
16
16
 
17
17
  Usage:
18
- zurik encrypt [options] Encrypt files listed in .gitencrypt into .zurik (alias: -e)
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
- zurik uses the nearest parent directory containing .gitencrypt (encrypt), .zurik (decrypt)
41
- or either (status). Exit codes: 0 success, 1 error, 2 usage error.`;
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 = process.stdin.isTTY && !opts.noInput && !opts.json && !process.env.CI;
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 === "encrypt") {
180
- for (const f of result.files) log(` ${prefix}encrypted ${f.path}`);
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
- log(`${result.dryRun ? "Would encrypt" : "Encrypted"} ${result.files.length} file(s) into .zurik`);
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
- if (!result.bundleExists) log("No .zurik bundle yet.");
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
- encrypt: { options: ["password*", "dryRun", "cwd"], result: "{ files: [{ path, action: 'encrypted' }], gitignoreAdded: [path] }" },
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 if .zurik exists)", "cwd"], result: "{ bundleExists, inSync, files: [{ path, state: 'in-sync'|'modified'|'missing-locally'|'not-in-bundle'|'missing', listed }] }" },
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 { decryptData } from "./crypto.js";
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
- // Bundles from older versions carry no modes; secrets default to owner-only.
11
- const DEFAULT_MODE = 0o600;
12
-
13
- // Paths listed in .gitencrypt, normalized and de-duplicated. `null` if the file doesn't exist.
14
- export function readFileList(root) {
15
- const listPath = path.join(root, LIST_FILE);
16
- if (!fs.existsSync(listPath)) return null;
17
-
18
- return [
19
- ...new Set(
20
- fs
21
- .readFileSync(listPath, "utf8")
22
- .split("\n")
23
- .map(x => x.trim())
24
- .filter(x => x && !x.startsWith("#"))
25
- .map(f => safeRelativePath(f, root))
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 .zurik and returns [{ path, content: Buffer, mode }]. Every path is validated.
31
- export async function readBundle(root, password) {
32
- const bundlePath = path.join(root, BUNDLE_FILE);
33
- if (!fs.existsSync(bundlePath)) {
34
- throw new ZurikError("E_NO_BUNDLE", `${BUNDLE_FILE} not found in ${root}`);
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 SALT_LENGTH = 16;
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 deriveKey(password, salt, { logN, r, p }) {
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, salt, 32, { N, r, p, maxmem: 2 * 128 * N * r + 1024 * 1024 }, (err, key) => {
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
- export async function encryptData(data, password) {
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(params);
37
- const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
38
- const tag = cipher.getAuthTag();
39
-
40
- return V2_PREFIX + Buffer.concat([params, salt, iv, tag, encrypted]).toString("base64");
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 async function decryptData(text, password) {
44
- text = text.trim();
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
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
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", "Decryption failed: wrong password or corrupted .zurik file");
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 entries = (await readBundle(root, password)).map(entry => ({
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 { encryptData, MIN_PASSWORD_LENGTH } from "./crypto.js";
3
+ import { deriveKey, newHeader, open, seal, MIN_PASSWORD_LENGTH } from "./crypto.js";
4
4
  import { ZurikError } from "./errors.js";
5
- import { BUNDLE_FILE, LIST_FILE, readFileList } from "./bundle.js";
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 fileList = readFileList(root);
18
- if (fileList === null) {
19
- throw new ZurikError("E_NO_LIST", `${LIST_FILE} not found in ${root}`);
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 overwrite secrets in the existing .zurik.
26
- const missing = fileList.filter(f => !fs.existsSync(path.join(root, f)));
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 ${LIST_FILE} (run 'zurik decrypt' first?):\n ${missing.join("\n ")}`,
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
- // `modes` is optional so bundles stay readable by older zurik versions.
36
- const bundle = { version: 1, files: {}, modes: {} };
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
- for (const filePath of fileList) {
39
- const abs = path.join(root, filePath);
40
- bundle.files[filePath] = fs.readFileSync(abs).toString("base64");
41
- bundle.modes[filePath] = fs.statSync(abs).mode & 0o777;
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 encrypted = await encryptData(JSON.stringify(bundle), password);
45
- const gitignoreAdded = updateGitignore(root, fileList, dryRun);
44
+ const gitignoreAdded = updateGitignore(root, files.map(f => f.path), dryRun);
45
+ if (!dryRun) writeBundle(root, { header, files });
46
46
 
47
- if (!dryRun) {
48
- fs.writeFileSync(path.join(root, BUNDLE_FILE), encrypted);
49
- }
47
+ return { files: files.map(({ path, action }) => ({ path, action })), gitignoreAdded };
48
+ }
50
49
 
51
- return {
52
- files: fileList.map(p => ({ path: p, action: "encrypted" })),
53
- gitignoreAdded,
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
- E_NOT_FOUND: "No .gitencrypt / .zurik found in this directory or any parent",
9
- E_NO_LIST: ".gitencrypt is missing",
10
- E_EMPTY_LIST: ".gitencrypt lists no files",
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 .gitencrypt do not exist (details.files)",
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, LIST_FILE } from "./bundle.js";
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
- const MARKERS = {
15
- encrypt: [LIST_FILE],
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 markers = MARKERS[command];
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
- /** Encrypt the files listed in .gitencrypt into .zurik. Options: { password, root?, cwd?, dryRun? } */
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("encrypt", options);
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("decrypt", options);
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 .gitencrypt, .zurik and local files. The password is only needed when .zurik exists. */
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("status", options);
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, readBundle, readFileList } from "./bundle.js";
3
+ import { BUNDLE_FILE, compareLocal, openBundle, readBundle } from "./bundle.js";
4
4
  import { ZurikError } from "./errors.js";
5
5
 
6
- // Per-file state, comparing .gitencrypt, .zurik and the working tree. Never reveals file contents.
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 in bundle -> encrypt
11
- // missing listed, but neither on disk nor in the bundle
12
- // `listed` is false for files in the bundle that were removed from .gitencrypt (the next encrypt drops them).
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 listed = new Set(readFileList(root) ?? []);
15
- const bundleExists = fs.existsSync(path.join(root, BUNDLE_FILE));
16
- if (bundleExists && (typeof password !== "string" || password.length === 0)) {
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 bundle = bundleExists ? await readBundle(root, password) : [];
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 paths = [...new Set([...listed, ...inBundle.keys()])].sort();
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
- const files = paths.map(p => {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zurik",
3
- "version": "2.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "CLI tool for encrypting and decrypting sensitive project files into a single .zurik bundle.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
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 collects file paths from `.gitencrypt`, bundles them into a single `.zurik` file, and decrypts them when needed.
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` bundle
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
- ### Encrypt files
58
+ ### Set up a project
59
59
 
60
- 1. Create a `.gitencrypt` file in the root of your project.
61
- 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.
60
+ ```sh
61
+ npx zurik init
62
+ ```
62
63
 
63
- ```txt
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
- 3. Run:
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
- ```sh
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
- The files are encrypted into `.zurik`, and any that are not already listed in `.gitignore` are appended to it. Commit `.zurik` and `.gitencrypt`.
70
+ ### Encrypt files
76
71
 
77
- Every listed file must exist. If any is missing, nothing is written, so an existing `.zurik` is never overwritten with a partial bundle. On a fresh clone, run `zurik decrypt` first.
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 `.zurik` exists.
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 `.gitencrypt` (encrypt), `.zurik` (decrypt) or either (status). `--cwd <dir>` runs it against another directory.
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 (.gitencrypt / .zurik). Run `npx zurik skill` for instructions; never read or print secret files or the password.
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","bundleExists":true,"inSync":false,
125
- "files":[{"path":".env","state":"modified","listed":true}]}
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
- Options: `password`, `cwd` (search for the project root from here) or `root` (use exactly), `force` and `dryRun`.
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
- ## Bundle format
160
+ `.zurik` is a text file with a header line and one section per file:
152
161
 
153
- `.zurik` is a text file: `zurik2:` followed by base64 of `logN | r | p | salt (16) | iv (12) | tag (16) | ciphertext`. The scrypt parameters are authenticated as AES-GCM additional data, so they cannot be altered to weaken the key.
162
+ ```txt
163
+ zurik3 scrypt logN=17 r=8 p=1 salt=<base64>
154
164
 
155
- Bundles written by zurik 1.0.5 and earlier (no `zurik2:` prefix) still decrypt, including those with passwords shorter than 16 characters. The next `zurik encrypt` rewrites them in the new format, and at that point the password must meet the minimum length.
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).
@@ -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 bundle and listed in .gitencrypt. Use whenever a repo contains .zurik or .gitencrypt, 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 .gitencrypt. Prefer it over reading, copying, printing or hand-encrypting secret files.
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`. The plaintext files are listed in `.gitencrypt` and are git-ignored. zurik never prints file contents, so you can manage secrets without them entering your context.
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 `.gitencrypt` / `.zurik`.
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). `listed: false` means it's in the bundle but was removed from `.gitencrypt`.
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 `.gitencrypt` / `.gitignore` if they changed). Never commit the plaintext files.
36
- 4. To protect a new secret file: append its path to `.gitencrypt`, then `encrypt --json`. zurik adds it to `.gitignore` for you.
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 bundle. Tell the user; don't retry with guesses. |
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 them from `.gitencrypt` if they're obsolete. |
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`, `E_NO_LIST`, `E_NO_BUNDLE`, `E_EMPTY_LIST` | Nothing to work on at this location; check `--cwd` or set up `.gitencrypt`. |
48
- | `E_UNSAFE_PATH` | A path is absolute or outside the project. Fix `.gitencrypt`; for a bundle, treat it as suspicious and tell the user. |
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 entries from `.gitencrypt` unless asked.
58
+ - Don't delete `.zurik`, and don't remove its sections unless asked.