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 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, 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;
@@ -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 bundle
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 add them to .gitencrypt (creates it)
19
- 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)
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 uses the nearest parent directory containing .gitencrypt (encrypt), .zurik (decrypt)
45
- or either (status). Exit codes: 0 success, 1 error, 2 usage error.`;
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 add to .gitencrypt (up/down move, space toggle, a all/none, enter confirm):",
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 .gitencrypt" : result.dryRun ? "[dry run] would create .gitencrypt" : "Created .gitencrypt");
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("No .env files found. Add the paths of your secret files to .gitencrypt.");
240
- log("Next: run 'zurik encrypt' and commit .zurik and .gitencrypt.");
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}encrypted ${f.path}`);
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
- 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`);
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
- if (!result.bundleExists) log("No .zurik bundle yet.");
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 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' }] }" },
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 when there is a bundle to compare against.
283
- const needsPassword =
284
- opts.command !== "status" || fs.existsSync(path.join(resolveRoot("status", base), ".zurik"));
285
- const password = needsPassword ? await resolvePassword(opts) : undefined;
286
- if (needsPassword && !password) {
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 { 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,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 initList from "./init.js";
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
- const MARKERS = {
16
- encrypt: [LIST_FILE],
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 markers = MARKERS[command];
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
- /** 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? } */
42
41
  export async function encrypt(options = {}) {
43
42
  requirePassword(options.password);
44
- const root = resolveRoot("encrypt", options);
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("decrypt", options);
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 .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. */
58
57
  export async function status(options = {}) {
59
- const root = resolveRoot("status", options);
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 { LIST_FILE, readFileList } from "./bundle.js";
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 .gitencrypt, creating it if needed.
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 listed = readFileList(root);
38
- const already = new Set(listed ?? []);
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 && (listed === null || added.length > 0)) {
45
- const listPath = path.join(root, LIST_FILE);
46
- const text = listed === null ? HEADER : fs.readFileSync(listPath, "utf8");
47
- const sep = text && !text.endsWith("\n") ? "\n" : "";
48
- fs.writeFileSync(listPath, text + sep + added.map(f => f + "\n").join(""));
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: listed === null,
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, 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.1.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`
@@ -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 added to `.gitencrypt`, which is created if it doesn't exist. Run it again any time to pick up new `.env` files; paths already listed are left alone. It needs no password.
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
- ### Encrypt files
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
- 1. Create a `.gitencrypt` file in the root of your project (or run `zurik init`).
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
- ```txt
74
- # secrets for local development
75
- src/config.json
76
- src/secret.txt
77
- ```
72
+ ```sh
73
+ npx zurik encrypt
74
+ ```
78
75
 
79
- 3. Run:
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
- ```sh
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
- The files are encrypted into `.zurik`, and any that are not already listed in `.gitignore` are appended to it. Commit `.zurik` and `.gitencrypt`.
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
- 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.
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 `.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.
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 `.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.
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 (.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.
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","bundleExists":true,"inSync":false,
135
- "files":[{"path":".env","state":"modified","listed":true}]}
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 })` adds `files` (default: every `.env` file found) to `.gitencrypt` and needs no password.
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
- ## Bundle format
158
+ ## File format
164
159
 
165
- `.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.
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
- 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.
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
 
@@ -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,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 `.gitencrypt` (adds every `.env` file found; no password) | `npx zurik init --dry-run --json`, confirm the list with the user, then `npx zurik init --json` |
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 `.gitencrypt` / `.zurik`.
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). `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).
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 `.gitencrypt` / `.gitignore` if they changed). Never commit the plaintext files.
37
- 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.
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 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. |
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 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. |
47
47
  | `E_WEAK_PASSWORD` | Encrypting needs 16+ characters. The user must choose a longer password. |
48
- | `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`. |
49
- | `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. |
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 entries from `.gitencrypt` unless asked.
58
+ - Don't delete `.zurik`, and don't remove its sections unless asked.