zurik 1.0.3 → 2.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 +233 -67
- package/lib/bundle.js +63 -0
- package/lib/crypto.js +78 -16
- package/lib/decrypt.js +28 -12
- package/lib/encrypt.js +50 -32
- package/lib/errors.js +25 -0
- package/lib/index.js +60 -0
- package/lib/paths.js +32 -0
- package/lib/status.js +39 -0
- package/package.json +24 -4
- package/readme.md +136 -27
- package/skills/zurik/SKILL.md +56 -0
- package/.gitencrypt +0 -0
- package/.github/workflows/publish.yml +0 -31
package/bin/zurik.js
CHANGED
|
@@ -1,95 +1,261 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import path from "path";
|
|
3
5
|
import * as readline from "readline";
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { encrypt, decrypt, status, resolveRoot, ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH } from "../lib/index.js";
|
|
6
8
|
|
|
7
|
-
const
|
|
9
|
+
const PKG_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const VERSION = JSON.parse(fs.readFileSync(path.join(PKG_DIR, "package.json"), "utf8")).version;
|
|
8
11
|
|
|
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" };
|
|
9
14
|
|
|
10
|
-
|
|
11
|
-
return new Promise((resolve) => {
|
|
12
|
-
const rl = readline.createInterface({
|
|
13
|
-
input: process.stdin,
|
|
14
|
-
output: process.stdout,
|
|
15
|
-
terminal: false
|
|
16
|
-
});
|
|
15
|
+
const HELP = `zurik ${VERSION} — encrypt project secrets into a single .zurik bundle
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
Usage:
|
|
18
|
+
zurik encrypt [options] Encrypt files listed in .gitencrypt into .zurik (alias: -e)
|
|
19
|
+
zurik decrypt [options] Restore files from .zurik (alias: -d)
|
|
20
|
+
zurik status [options] Show which files are in sync, without decrypting (alias: -s)
|
|
21
|
+
zurik skill Print instructions for AI agents (SKILL.md)
|
|
22
|
+
zurik help [--json] Show this help; --json prints a machine-readable spec
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
Options:
|
|
25
|
+
-p, --password [value] Prompt for the password (or read a line from piped stdin);
|
|
26
|
+
with a value, use it inline (visible in ps / shell history)
|
|
27
|
+
--password-stdin Read the password from the first line of stdin
|
|
28
|
+
--password-file <path> Read the password from a file
|
|
29
|
+
-f, --force Decrypt: overwrite local files that differ from .zurik
|
|
30
|
+
-n, --dry-run Report what would happen without writing anything
|
|
31
|
+
--json Print one JSON object on stdout (results and errors)
|
|
32
|
+
--no-input Never prompt; fail with E_NO_PASSWORD instead
|
|
33
|
+
-C, --cwd <dir> Run as if started in <dir>
|
|
34
|
+
-v, --version Print the version
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
Password sources, first match wins: --password value, --password-file, --password-stdin,
|
|
37
|
+
-p prompt, $ZURIK_PASSWORD, $ZURIK_PASSWORD_FILE, then an interactive prompt if stdin is
|
|
38
|
+
a terminal (never with --json, --no-input or $CI). Encrypting needs ${MIN_PASSWORD_LENGTH}+ characters.
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
|
|
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.`;
|
|
28
42
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
function usageError(message) {
|
|
44
|
+
return new ZurikError("E_USAGE", message);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const opts = { command: null, json: false, force: false, dryRun: false, noInput: false, help: false, version: false };
|
|
49
|
+
|
|
50
|
+
// Support --flag=value for long options.
|
|
51
|
+
const args = argv.flatMap(a => (a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a]));
|
|
52
|
+
|
|
53
|
+
const takeValue = (i, flag) => {
|
|
54
|
+
if (args[i + 1] === undefined) throw usageError(`${flag} requires a value`);
|
|
55
|
+
return args[i + 1];
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < args.length; i++) {
|
|
59
|
+
const a = args[i];
|
|
60
|
+
if (COMMANDS[a]) {
|
|
61
|
+
if (opts.command && opts.command !== COMMANDS[a]) throw usageError("Specify only one command");
|
|
62
|
+
opts.command = COMMANDS[a];
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
switch (a) {
|
|
66
|
+
case "-p":
|
|
67
|
+
case "--password": {
|
|
68
|
+
const next = args[i + 1];
|
|
69
|
+
if (next !== undefined && !next.startsWith("-") && !COMMANDS[next]) {
|
|
70
|
+
opts.password = next;
|
|
71
|
+
i++;
|
|
72
|
+
} else {
|
|
73
|
+
opts.promptPassword = true;
|
|
44
74
|
}
|
|
45
|
-
|
|
46
|
-
// Ctrl+C
|
|
47
|
-
console.log();
|
|
48
|
-
process.exit(0);
|
|
49
|
-
} else {
|
|
50
|
-
password += char;
|
|
51
|
-
process.stdout.write("*");
|
|
75
|
+
break;
|
|
52
76
|
}
|
|
77
|
+
case "--password-stdin": opts.passwordStdin = true; break;
|
|
78
|
+
case "--password-file": opts.passwordFile = takeValue(i++, a); break;
|
|
79
|
+
case "-f": case "--force": opts.force = true; break;
|
|
80
|
+
case "-n": case "--dry-run": opts.dryRun = true; break;
|
|
81
|
+
case "--json": opts.json = true; break;
|
|
82
|
+
case "--no-input": opts.noInput = true; break;
|
|
83
|
+
case "-C": case "--cwd": opts.cwd = takeValue(i++, a); break;
|
|
84
|
+
case "-h": case "--help": opts.help = true; break;
|
|
85
|
+
case "-v": case "--version": opts.version = true; break;
|
|
86
|
+
default: throw usageError(`Unknown argument: ${a}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return opts;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------- password input ----------
|
|
93
|
+
|
|
94
|
+
function readLineFromStdin() {
|
|
95
|
+
return new Promise((resolve) => {
|
|
96
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
97
|
+
let done = false;
|
|
98
|
+
rl.once("line", (line) => {
|
|
99
|
+
done = true;
|
|
100
|
+
rl.close();
|
|
101
|
+
resolve(line.replace(/\r$/, ""));
|
|
102
|
+
});
|
|
103
|
+
rl.once("close", () => {
|
|
104
|
+
if (!done) resolve("");
|
|
53
105
|
});
|
|
54
106
|
});
|
|
55
107
|
}
|
|
56
108
|
|
|
109
|
+
// Prompt goes to stderr so stdout stays clean for --json.
|
|
110
|
+
function promptPassword() {
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
const stdin = process.stdin;
|
|
113
|
+
const out = process.stderr;
|
|
114
|
+
let password = "";
|
|
57
115
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
116
|
+
const cleanup = () => {
|
|
117
|
+
stdin.removeListener("data", onData);
|
|
118
|
+
stdin.setRawMode(false);
|
|
119
|
+
stdin.pause();
|
|
120
|
+
out.write("\n");
|
|
121
|
+
};
|
|
64
122
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
123
|
+
const onData = (chunk) => {
|
|
124
|
+
// A chunk may contain several characters (e.g. when pasting).
|
|
125
|
+
for (const char of chunk.toString("utf8")) {
|
|
126
|
+
if (char === "\n" || char === "\r" || char === "\u0004") {
|
|
127
|
+
cleanup();
|
|
128
|
+
resolve(password);
|
|
129
|
+
return;
|
|
130
|
+
} else if (char === "\u0003") {
|
|
131
|
+
cleanup();
|
|
132
|
+
process.exit(130);
|
|
133
|
+
} else if (char === "\u007f" || char === "\b") {
|
|
134
|
+
if (password.length > 0) {
|
|
135
|
+
password = [...password].slice(0, -1).join("");
|
|
136
|
+
out.write("\b \b");
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
password += char;
|
|
140
|
+
out.write("*");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
out.write("Password: ");
|
|
146
|
+
stdin.setRawMode(true);
|
|
147
|
+
stdin.resume();
|
|
148
|
+
stdin.on("data", onData);
|
|
149
|
+
});
|
|
68
150
|
}
|
|
69
151
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
152
|
+
function readPasswordFile(file) {
|
|
153
|
+
try {
|
|
154
|
+
return fs.readFileSync(file, "utf8").replace(/\r?\n$/, "");
|
|
155
|
+
} catch (err) {
|
|
156
|
+
throw new ZurikError("E_NO_PASSWORD", `Cannot read password file ${file}: ${err.code ?? err.message}`);
|
|
75
157
|
}
|
|
158
|
+
}
|
|
76
159
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
160
|
+
async function resolvePassword(opts) {
|
|
161
|
+
const interactive = process.stdin.isTTY && !opts.noInput && !opts.json && !process.env.CI;
|
|
162
|
+
|
|
163
|
+
if (opts.password) return opts.password;
|
|
164
|
+
if (opts.passwordFile) return readPasswordFile(opts.passwordFile);
|
|
165
|
+
if (opts.passwordStdin) return readLineFromStdin();
|
|
166
|
+
if (opts.promptPassword) return process.stdin.isTTY ? promptPassword() : readLineFromStdin();
|
|
167
|
+
if (process.env.ZURIK_PASSWORD) return process.env.ZURIK_PASSWORD;
|
|
168
|
+
if (process.env.ZURIK_PASSWORD_FILE) return readPasswordFile(process.env.ZURIK_PASSWORD_FILE);
|
|
169
|
+
if (interactive) return promptPassword();
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
81
172
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
173
|
+
// ---------- output ----------
|
|
174
|
+
|
|
175
|
+
function printHuman(result) {
|
|
176
|
+
const prefix = result.dryRun ? "[dry run] would be " : "";
|
|
177
|
+
const log = (s) => process.stdout.write(s + "\n");
|
|
178
|
+
|
|
179
|
+
if (result.command === "encrypt") {
|
|
180
|
+
for (const f of result.files) log(` ${prefix}encrypted ${f.path}`);
|
|
181
|
+
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`);
|
|
183
|
+
} else if (result.command === "decrypt") {
|
|
184
|
+
for (const f of result.files) log(` ${f.action === "unchanged" ? "" : prefix}${f.action.padEnd(9)} ${f.path}`);
|
|
185
|
+
log(`${result.dryRun ? "Would decrypt" : "Decrypted"} ${result.files.length} file(s) from .zurik`);
|
|
186
|
+
} 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)"}`);
|
|
189
|
+
log(result.inSync ? "All files in sync." : "Out of sync.");
|
|
85
190
|
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function helpSpec() {
|
|
194
|
+
return {
|
|
195
|
+
name: "zurik",
|
|
196
|
+
version: VERSION,
|
|
197
|
+
commands: {
|
|
198
|
+
encrypt: { options: ["password*", "dryRun", "cwd"], result: "{ files: [{ path, action: 'encrypted' }], gitignoreAdded: [path] }" },
|
|
199
|
+
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 }] }" },
|
|
201
|
+
},
|
|
202
|
+
passwordSources: ["--password <value>", "--password-file <path>", "--password-stdin", "$ZURIK_PASSWORD", "$ZURIK_PASSWORD_FILE"],
|
|
203
|
+
minPasswordLength: MIN_PASSWORD_LENGTH,
|
|
204
|
+
output: "With --json, stdout is exactly one JSON object: { ok: true, command, root, ... } or { ok: false, error: { code, message, details } }",
|
|
205
|
+
exitCodes: { 0: "success", 1: "error (see error.code)", 2: "usage error" },
|
|
206
|
+
errorCodes: ERROR_CODES,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------- main ----------
|
|
86
211
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
212
|
+
async function main() {
|
|
213
|
+
let opts = { json: process.argv.includes("--json") };
|
|
214
|
+
try {
|
|
215
|
+
opts = parseArgs(process.argv.slice(2));
|
|
216
|
+
|
|
217
|
+
if (opts.version) return void console.log(VERSION);
|
|
218
|
+
if (opts.help || opts.command === "help") {
|
|
219
|
+
return void console.log(opts.json ? JSON.stringify(helpSpec(), null, 2) : HELP);
|
|
220
|
+
}
|
|
221
|
+
if (opts.command === "skill") {
|
|
222
|
+
return void process.stdout.write(fs.readFileSync(path.join(PKG_DIR, "skills", "zurik", "SKILL.md"), "utf8"));
|
|
223
|
+
}
|
|
224
|
+
if (!opts.command) {
|
|
225
|
+
if (opts.json) throw usageError("No command given");
|
|
226
|
+
console.log(HELP);
|
|
227
|
+
process.exitCode = EXIT.USAGE;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
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 });
|
|
246
|
+
|
|
247
|
+
if (opts.json) console.log(JSON.stringify({ ok: true, ...result }));
|
|
248
|
+
else printHuman(result);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const error =
|
|
251
|
+
err instanceof ZurikError
|
|
252
|
+
? { code: err.code, message: err.message, details: err.details }
|
|
253
|
+
: { code: "E_INTERNAL", message: err.message, details: {} };
|
|
254
|
+
|
|
255
|
+
if (opts.json) console.log(JSON.stringify({ ok: false, error }));
|
|
256
|
+
else console.error(`zurik: ${error.message}`);
|
|
257
|
+
process.exitCode = error.code === "E_USAGE" ? EXIT.USAGE : EXIT.ERROR;
|
|
258
|
+
}
|
|
90
259
|
}
|
|
91
260
|
|
|
92
|
-
|
|
93
|
-
console.error(err.message);
|
|
94
|
-
process.exit(1);
|
|
95
|
-
});
|
|
261
|
+
main();
|
package/lib/bundle.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { decryptData } from "./crypto.js";
|
|
4
|
+
import { ZurikError } from "./errors.js";
|
|
5
|
+
import { safeRelativePath } from "./paths.js";
|
|
6
|
+
|
|
7
|
+
export const LIST_FILE = ".gitencrypt";
|
|
8
|
+
export const BUNDLE_FILE = ".zurik";
|
|
9
|
+
|
|
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
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
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
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// "missing" | "same" | "different" for a local file compared with `content`.
|
|
59
|
+
export function compareLocal(root, relPath, content) {
|
|
60
|
+
const abs = path.join(root, relPath);
|
|
61
|
+
if (!fs.existsSync(abs)) return "missing";
|
|
62
|
+
return fs.readFileSync(abs).equals(content) ? "same" : "different";
|
|
63
|
+
}
|
package/lib/crypto.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
|
+
import { ZurikError } from "./errors.js";
|
|
2
3
|
|
|
3
4
|
const ALGO = "aes-256-gcm";
|
|
4
|
-
const
|
|
5
|
+
const SALT_LENGTH = 16;
|
|
6
|
+
const TAG_LENGTH = 16;
|
|
5
7
|
|
|
6
|
-
|
|
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
|
+
const KDF = { logN: 17, r: 8, p: 1 }; // N = 2^17 ≈ 128 MiB of memory per guess
|
|
13
|
+
|
|
14
|
+
// Upper bounds so a crafted file can't make decryption allocate gigabytes.
|
|
15
|
+
const MAX_KDF = { logN: 20, r: 16, p: 4 };
|
|
16
|
+
|
|
17
|
+
export const MIN_PASSWORD_LENGTH = 16;
|
|
18
|
+
|
|
19
|
+
function deriveKey(password, salt, { logN, r, p }) {
|
|
20
|
+
const N = 2 ** logN;
|
|
7
21
|
return new Promise((resolve, reject) => {
|
|
8
|
-
crypto.scrypt(password,
|
|
22
|
+
crypto.scrypt(password, salt, 32, { N, r, p, maxmem: 2 * 128 * N * r + 1024 * 1024 }, (err, key) => {
|
|
9
23
|
if (err) reject(err);
|
|
10
24
|
else resolve(key);
|
|
11
25
|
});
|
|
@@ -13,29 +27,77 @@ function deriveKey(password) {
|
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
export async function encryptData(data, password) {
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
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);
|
|
19
34
|
|
|
35
|
+
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
36
|
+
cipher.setAAD(params);
|
|
20
37
|
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
21
38
|
const tag = cipher.getAuthTag();
|
|
22
39
|
|
|
23
|
-
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
|
40
|
+
return V2_PREFIX + Buffer.concat([params, salt, iv, tag, encrypted]).toString("base64");
|
|
41
|
+
}
|
|
42
|
+
|
|
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);
|
|
24
48
|
}
|
|
25
49
|
|
|
26
|
-
|
|
50
|
+
async function decryptV2(base64, password) {
|
|
27
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
|
+
}
|
|
28
66
|
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
const
|
|
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);
|
|
32
72
|
|
|
33
|
-
const key = await deriveKey(password);
|
|
73
|
+
const key = await deriveKey(password, salt, kdf);
|
|
34
74
|
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
75
|
+
decipher.setAAD(params);
|
|
35
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
|
+
}
|
|
36
96
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
decipher.final()
|
|
40
|
-
|
|
97
|
+
function finish(decipher, ciphertext) {
|
|
98
|
+
try {
|
|
99
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new ZurikError("E_WRONG_PASSWORD", "Decryption failed: wrong password or corrupted .zurik file");
|
|
102
|
+
}
|
|
41
103
|
}
|
package/lib/decrypt.js
CHANGED
|
@@ -1,19 +1,35 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import path from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { ZurikError } from "./errors.js";
|
|
4
|
+
import { compareLocal, readBundle } from "./bundle.js";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
const ACTION = { missing: "created", same: "unchanged", different: "updated" };
|
|
7
|
+
|
|
8
|
+
export default async function decryptFiles({ root, password, force = false, dryRun = false }) {
|
|
9
|
+
const entries = (await readBundle(root, password)).map(entry => ({
|
|
10
|
+
...entry,
|
|
11
|
+
action: ACTION[compareLocal(root, entry.path, entry.content)],
|
|
12
|
+
}));
|
|
9
13
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
const conflicts = entries.filter(e => e.action === "updated").map(e => e.path);
|
|
15
|
+
if (conflicts.length > 0 && !force) {
|
|
16
|
+
throw new ZurikError(
|
|
17
|
+
"E_WOULD_OVERWRITE",
|
|
18
|
+
`These local files differ from .zurik and would be overwritten (use --force):\n ${conflicts.join("\n ")}`,
|
|
19
|
+
{ files: conflicts }
|
|
20
|
+
);
|
|
21
|
+
}
|
|
13
22
|
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
if (!dryRun) {
|
|
24
|
+
for (const { path: relPath, content, mode, action } of entries) {
|
|
25
|
+
const abs = path.join(root, relPath);
|
|
26
|
+
if (action !== "unchanged") {
|
|
27
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
28
|
+
fs.writeFileSync(abs, content, { mode });
|
|
29
|
+
}
|
|
30
|
+
fs.chmodSync(abs, mode); // `mode` above only applies when the file is created
|
|
31
|
+
}
|
|
18
32
|
}
|
|
33
|
+
|
|
34
|
+
return { files: entries.map(({ path, action }) => ({ path, action })) };
|
|
19
35
|
}
|
package/lib/encrypt.js
CHANGED
|
@@ -1,50 +1,68 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
|
-
import
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { encryptData, MIN_PASSWORD_LENGTH } from "./crypto.js";
|
|
4
|
+
import { ZurikError } from "./errors.js";
|
|
5
|
+
import { BUNDLE_FILE, LIST_FILE, readFileList } from "./bundle.js";
|
|
3
6
|
|
|
4
|
-
export default async function encryptFiles(password) {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
export default async function encryptFiles({ root, password, dryRun = false }) {
|
|
8
|
+
// Only enforced when encrypting, so bundles made with shorter passwords still decrypt.
|
|
9
|
+
if ([...password].length < MIN_PASSWORD_LENGTH) {
|
|
10
|
+
throw new ZurikError(
|
|
11
|
+
"E_WEAK_PASSWORD",
|
|
12
|
+
`Password must be at least ${MIN_PASSWORD_LENGTH} characters. ` +
|
|
13
|
+
"Use a generated passphrase, e.g. from your password manager or `openssl rand -base64 24`."
|
|
14
|
+
);
|
|
9
15
|
}
|
|
10
16
|
|
|
11
|
-
const fileList =
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
.filter(Boolean);
|
|
16
|
-
|
|
17
|
+
const fileList = readFileList(root);
|
|
18
|
+
if (fileList === null) {
|
|
19
|
+
throw new ZurikError("E_NO_LIST", `${LIST_FILE} not found in ${root}`);
|
|
20
|
+
}
|
|
17
21
|
if (fileList.length === 0) {
|
|
18
|
-
throw new
|
|
22
|
+
throw new ZurikError("E_EMPTY_LIST", `No files listed in ${LIST_FILE}`);
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
|
|
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)));
|
|
27
|
+
if (missing.length > 0) {
|
|
28
|
+
throw new ZurikError(
|
|
29
|
+
"E_MISSING_FILES",
|
|
30
|
+
`Missing files listed in ${LIST_FILE} (run 'zurik decrypt' first?):\n ${missing.join("\n ")}`,
|
|
31
|
+
{ files: missing }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
22
34
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
console.warn(`Skipping missing file: ${filePath}`);
|
|
26
|
-
continue;
|
|
27
|
-
}
|
|
35
|
+
// `modes` is optional so bundles stay readable by older zurik versions.
|
|
36
|
+
const bundle = { version: 1, files: {}, modes: {} };
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
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;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
const encrypted = await encryptData(JSON.stringify(bundle), password);
|
|
34
|
-
|
|
45
|
+
const gitignoreAdded = updateGitignore(root, fileList, dryRun);
|
|
46
|
+
|
|
47
|
+
if (!dryRun) {
|
|
48
|
+
fs.writeFileSync(path.join(root, BUNDLE_FILE), encrypted);
|
|
49
|
+
}
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
return {
|
|
52
|
+
files: fileList.map(p => ({ path: p, action: "encrypted" })),
|
|
53
|
+
gitignoreAdded,
|
|
54
|
+
};
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
57
|
+
// Appends listed files that .gitignore doesn't already contain as a whole line. Returns what was (or would be) added.
|
|
58
|
+
function updateGitignore(root, fileList, dryRun) {
|
|
59
|
+
const gi = path.join(root, ".gitignore");
|
|
60
|
+
const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
|
|
61
|
+
const lines = new Set(text.split("\n").map(x => x.trim()));
|
|
42
62
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
63
|
+
const toAdd = fileList.filter(f => !lines.has(f) && !lines.has(`/${f}`));
|
|
64
|
+
if (toAdd.length > 0 && !dryRun) {
|
|
65
|
+
fs.writeFileSync(gi, [text.trimEnd(), ...toAdd].filter(Boolean).join("\n") + "\n");
|
|
47
66
|
}
|
|
48
|
-
|
|
49
|
-
fs.writeFileSync(gi, text.trim() + "\n");
|
|
67
|
+
return toAdd;
|
|
50
68
|
}
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Stable error codes. Agents and scripts should branch on `code`, not on `message`.
|
|
2
|
+
export const ERROR_CODES = {
|
|
3
|
+
E_USAGE: "Invalid command line arguments",
|
|
4
|
+
E_NO_PASSWORD: "No password was provided",
|
|
5
|
+
E_WEAK_PASSWORD: "Password is too short to encrypt with",
|
|
6
|
+
E_WRONG_PASSWORD: "Wrong password, or the bundle was modified",
|
|
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",
|
|
11
|
+
E_NO_BUNDLE: ".zurik is missing",
|
|
12
|
+
E_MISSING_FILES: "Files listed in .gitencrypt do not exist (details.files)",
|
|
13
|
+
E_WOULD_OVERWRITE: "Local files differ from the bundle; rerun with --force (details.files)",
|
|
14
|
+
E_UNSAFE_PATH: "A path is absolute or outside the project (details.path)",
|
|
15
|
+
E_INTERNAL: "Unexpected internal error",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export class ZurikError extends Error {
|
|
19
|
+
constructor(code, message, details = {}) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "ZurikError";
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.details = details;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Programmatic API. Every function returns a plain result object and throws ZurikError
|
|
2
|
+
// (with a stable `code`) on failure. Nothing is printed and process.cwd() is never changed.
|
|
3
|
+
import path from "path";
|
|
4
|
+
import encryptFiles from "./encrypt.js";
|
|
5
|
+
import decryptFiles from "./decrypt.js";
|
|
6
|
+
import statusFiles from "./status.js";
|
|
7
|
+
import { BUNDLE_FILE, LIST_FILE } from "./bundle.js";
|
|
8
|
+
import { ZurikError, ERROR_CODES } from "./errors.js";
|
|
9
|
+
import { findProjectRoot } from "./paths.js";
|
|
10
|
+
import { MIN_PASSWORD_LENGTH } from "./crypto.js";
|
|
11
|
+
|
|
12
|
+
export { ZurikError, ERROR_CODES, MIN_PASSWORD_LENGTH };
|
|
13
|
+
|
|
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() } = {}) {
|
|
22
|
+
if (root) return path.resolve(root);
|
|
23
|
+
const markers = MARKERS[command];
|
|
24
|
+
const found = findProjectRoot(markers, cwd);
|
|
25
|
+
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
|
+
);
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function requirePassword(password) {
|
|
35
|
+
if (typeof password !== "string" || password.length === 0) {
|
|
36
|
+
throw new ZurikError("E_NO_PASSWORD", "A password is required");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Encrypt the files listed in .gitencrypt into .zurik. Options: { password, root?, cwd?, dryRun? } */
|
|
41
|
+
export async function encrypt(options = {}) {
|
|
42
|
+
requirePassword(options.password);
|
|
43
|
+
const root = resolveRoot("encrypt", options);
|
|
44
|
+
const dryRun = Boolean(options.dryRun);
|
|
45
|
+
return { command: "encrypt", root, dryRun, ...(await encryptFiles({ ...options, root, dryRun })) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Decrypt .zurik into the working tree. Options: { password, root?, cwd?, force?, dryRun? } */
|
|
49
|
+
export async function decrypt(options = {}) {
|
|
50
|
+
requirePassword(options.password);
|
|
51
|
+
const root = resolveRoot("decrypt", options);
|
|
52
|
+
const dryRun = Boolean(options.dryRun);
|
|
53
|
+
return { command: "decrypt", root, dryRun, ...(await decryptFiles({ ...options, root, dryRun })) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Compare .gitencrypt, .zurik and local files. The password is only needed when .zurik exists. */
|
|
57
|
+
export async function status(options = {}) {
|
|
58
|
+
const root = resolveRoot("status", options);
|
|
59
|
+
return { command: "status", root, ...(await statusFiles({ ...options, root })) };
|
|
60
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { ZurikError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
// Walks up from `start` to the nearest directory containing any of `markers`; null if none.
|
|
6
|
+
export function findProjectRoot(markers, start = process.cwd()) {
|
|
7
|
+
let dir = path.resolve(start);
|
|
8
|
+
for (;;) {
|
|
9
|
+
if (markers.some(m => fs.existsSync(path.join(dir, m)))) return dir;
|
|
10
|
+
const parent = path.dirname(dir);
|
|
11
|
+
if (parent === dir) return null;
|
|
12
|
+
dir = parent;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Normalizes a bundle path and ensures it stays inside `root`. Returns a "/"-separated relative path.
|
|
17
|
+
export function safeRelativePath(filePath, root) {
|
|
18
|
+
if (typeof filePath !== "string" || filePath.length === 0 || path.isAbsolute(filePath)) {
|
|
19
|
+
throw new ZurikError("E_UNSAFE_PATH", `Refusing unsafe path: ${filePath}`, { path: filePath });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const resolved = path.resolve(root, filePath);
|
|
23
|
+
const rel = path.relative(root, resolved);
|
|
24
|
+
|
|
25
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
26
|
+
throw new ZurikError("E_UNSAFE_PATH", `Refusing path outside project directory: ${filePath}`, {
|
|
27
|
+
path: filePath,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return rel.split(path.sep).join("/");
|
|
32
|
+
}
|
package/lib/status.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { BUNDLE_FILE, compareLocal, readBundle, readFileList } from "./bundle.js";
|
|
4
|
+
import { ZurikError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
// Per-file state, comparing .gitencrypt, .zurik and the working tree. Never reveals file contents.
|
|
7
|
+
// in-sync local file matches the bundle
|
|
8
|
+
// modified local file differs from the bundle -> encrypt to save it, or decrypt --force to discard
|
|
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).
|
|
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)) {
|
|
17
|
+
throw new ZurikError("E_NO_PASSWORD", `A password is required to compare against ${BUNDLE_FILE}`);
|
|
18
|
+
}
|
|
19
|
+
const bundle = bundleExists ? await readBundle(root, password) : [];
|
|
20
|
+
const inBundle = new Map(bundle.map(e => [e.path, e.content]));
|
|
21
|
+
|
|
22
|
+
const STATE = { missing: "missing-locally", same: "in-sync", different: "modified" };
|
|
23
|
+
const paths = [...new Set([...listed, ...inBundle.keys()])].sort();
|
|
24
|
+
|
|
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
|
+
};
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zurik",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "CLI tool for encrypting and decrypting sensitive project files into a single .zurik bundle.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./package.json": "./package.json"
|
|
10
|
+
},
|
|
6
11
|
"bin": {
|
|
7
|
-
"zurik": "
|
|
12
|
+
"zurik": "bin/zurik.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin",
|
|
16
|
+
"lib",
|
|
17
|
+
"skills"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
8
24
|
},
|
|
9
25
|
"keywords": [
|
|
10
26
|
"encryption",
|
|
@@ -13,12 +29,16 @@
|
|
|
13
29
|
"aes",
|
|
14
30
|
"gcm",
|
|
15
31
|
"git",
|
|
16
|
-
"files"
|
|
32
|
+
"files",
|
|
33
|
+
"secrets",
|
|
34
|
+
"dotenv",
|
|
35
|
+
"ai-agents",
|
|
36
|
+
"automation"
|
|
17
37
|
],
|
|
18
38
|
"author": "Safi",
|
|
19
39
|
"repository": {
|
|
20
40
|
"type": "git",
|
|
21
|
-
"url": "git@github.com
|
|
41
|
+
"url": "git+ssh://git@github.com/apotox/zurik.git"
|
|
22
42
|
},
|
|
23
43
|
"license": "MIT"
|
|
24
44
|
}
|
package/readme.md
CHANGED
|
@@ -6,47 +6,156 @@ It collects file paths from `.gitencrypt`, bundles them into a single `.zurik` f
|
|
|
6
6
|
## Features
|
|
7
7
|
|
|
8
8
|
- Encrypt multiple files into a **single** `.zurik` bundle
|
|
9
|
-
- AES-256-GCM authenticated encryption
|
|
10
|
-
- Automatically
|
|
9
|
+
- AES-256-GCM authenticated encryption with a scrypt-derived key
|
|
10
|
+
- Automatically appends sensitive files to `.gitignore` when encrypting
|
|
11
|
+
- Preserves file permissions; never overwrites changed local files without `--force`
|
|
12
|
+
- Built for automation and AI agents: `--json` output, stable error codes, `status` and `--dry-run`, never blocks on a prompt, and a library API
|
|
11
13
|
|
|
12
14
|
## Installation
|
|
13
15
|
|
|
14
|
-
|
|
16
|
+
Requires Node.js 18 or newer. Run it directly with `npx`, or install it globally:
|
|
15
17
|
|
|
16
18
|
```sh
|
|
17
|
-
|
|
18
|
-
npx zurik -e
|
|
19
|
-
|
|
20
|
-
# or install it globally
|
|
19
|
+
npx zurik help
|
|
21
20
|
npm install -g zurik
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Choosing a password
|
|
24
|
+
|
|
25
|
+
Anyone who obtains `.zurik` can try passwords offline, as fast as their hardware allows. zurik makes each guess expensive (scrypt with N=2¹⁷, ~128 MiB of memory per guess), but only a strong password actually protects the files.
|
|
22
26
|
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
- Passwords must be at least **16 characters** to encrypt.
|
|
28
|
+
- Use a randomly generated one, stored in a password manager or your CI secret store:
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
```sh
|
|
31
|
+
openssl rand -base64 24
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Providing the password
|
|
35
|
+
|
|
36
|
+
In order of preference:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
# 1. interactive prompt (zurik prompts automatically in a terminal when no other source is set)
|
|
40
|
+
npx zurik encrypt
|
|
41
|
+
|
|
42
|
+
# 2. environment variable or a file, e.g. in CI or an agent's environment
|
|
43
|
+
export ZURIK_PASSWORD="your-secret" # or: export ZURIK_PASSWORD_FILE=~/.secrets/zurik
|
|
44
|
+
npx zurik encrypt
|
|
45
|
+
npx zurik encrypt --password-file ~/.secrets/zurik
|
|
46
|
+
|
|
47
|
+
# 3. piped from a password manager or secret store
|
|
48
|
+
pass show my-project/zurik | npx zurik decrypt --password-stdin
|
|
49
|
+
|
|
50
|
+
# 4. inline (avoid: visible in `ps` and saved in shell history)
|
|
51
|
+
npx zurik encrypt -p "your-password"
|
|
28
52
|
```
|
|
29
53
|
|
|
54
|
+
zurik never prompts when stdin is not a terminal, or with `--json`, `--no-input` or `$CI` set; it fails with `E_NO_PASSWORD` instead of hanging.
|
|
55
|
+
|
|
30
56
|
## Usage
|
|
57
|
+
|
|
31
58
|
### Encrypt files
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
src/
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
62
|
+
|
|
63
|
+
```txt
|
|
64
|
+
# secrets for local development
|
|
65
|
+
src/config.json
|
|
66
|
+
src/secret.txt
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
3. Run:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npx zurik encrypt
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The files are encrypted into `.zurik`, and any that are not already listed in `.gitignore` are appended to it. Commit `.zurik` and `.gitencrypt`.
|
|
76
|
+
|
|
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.
|
|
78
|
+
|
|
79
|
+
### Decrypt files
|
|
80
|
+
|
|
41
81
|
```sh
|
|
42
|
-
npx zurik
|
|
82
|
+
npx zurik decrypt
|
|
43
83
|
```
|
|
44
|
-
- the encrypted files will be saved in `.zurik` file
|
|
45
|
-
- it will also add the encrypted files to `.gitignore` if they are not already ignored
|
|
46
84
|
|
|
47
|
-
|
|
48
|
-
|
|
85
|
+
Files are restored to their original paths with their original permissions. If a local file has changed since it was encrypted, zurik lists the affected files and stops; pass `--force` to overwrite them.
|
|
86
|
+
|
|
87
|
+
### Check status
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
npx zurik status
|
|
91
|
+
```
|
|
92
|
+
|
|
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.
|
|
94
|
+
|
|
95
|
+
Add `--dry-run` (`-n`) to `encrypt` or `decrypt` to see what would happen without writing anything.
|
|
96
|
+
|
|
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.
|
|
98
|
+
|
|
99
|
+
The short flags from earlier versions still work: `-e` (encrypt), `-d` (decrypt), `-s` (status).
|
|
100
|
+
|
|
101
|
+
## Use with AI agents and automation
|
|
102
|
+
|
|
103
|
+
zurik lets an agent manage secrets without the secrets, or the password, ever entering its context: it never prints file contents, and the password comes from the environment.
|
|
104
|
+
|
|
105
|
+
**Give your agent the skill.** `zurik skill` prints a `SKILL.md` that tells an agent when to use zurik, how to supply the password safely, and how to handle each error (including asking before `--force`):
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
# Claude Code (project-level; use ~/.claude/skills for all projects)
|
|
109
|
+
mkdir -p .claude/skills/zurik && npx zurik skill > .claude/skills/zurik/SKILL.md
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For other agents, add the output to `AGENTS.md`, or add this line:
|
|
113
|
+
|
|
114
|
+
```md
|
|
115
|
+
Secrets are managed with zurik (.gitencrypt / .zurik). Run `npx zurik skill` for instructions; never read or print secret files or the password.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Provide the password** through `ZURIK_PASSWORD` or `ZURIK_PASSWORD_FILE` in the agent's environment, never in the prompt.
|
|
119
|
+
|
|
120
|
+
**Machine-readable interface.** With `--json`, stdout is exactly one JSON object:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
$ npx zurik status --json
|
|
124
|
+
{"ok":true,"command":"status","root":"/repo","bundleExists":true,"inSync":false,
|
|
125
|
+
"files":[{"path":".env","state":"modified","listed":true}]}
|
|
126
|
+
|
|
127
|
+
$ npx zurik decrypt --json
|
|
128
|
+
{"ok":false,"error":{"code":"E_WOULD_OVERWRITE","message":"...","details":{"files":[".env"]}}}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Branch on `error.code`. Exit codes are `0` success, `1` error and `2` usage error. `npx zurik help --json` prints the full spec: commands, result shapes, password sources and every error code.
|
|
132
|
+
|
|
133
|
+
**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
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { encrypt, decrypt, status, ZurikError } from "zurik";
|
|
137
|
+
|
|
138
|
+
const s = await status({ cwd: "/repo", password: process.env.ZURIK_PASSWORD });
|
|
139
|
+
if (!s.inSync) {
|
|
140
|
+
try {
|
|
141
|
+
await decrypt({ root: "/repo", password: process.env.ZURIK_PASSWORD });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err instanceof ZurikError && err.code === "E_WOULD_OVERWRITE") console.log(err.details.files);
|
|
144
|
+
else throw err;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Options: `password`, `cwd` (search for the project root from here) or `root` (use exactly), `force` and `dryRun`.
|
|
150
|
+
|
|
151
|
+
## Bundle format
|
|
152
|
+
|
|
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.
|
|
154
|
+
|
|
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.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
49
159
|
```sh
|
|
50
|
-
|
|
160
|
+
npm test
|
|
51
161
|
```
|
|
52
|
-
- the decrypted files will be saved in the same directory as the original files
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
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.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# zurik
|
|
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.
|
|
9
|
+
|
|
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
|
+
|
|
12
|
+
## Commands
|
|
13
|
+
|
|
14
|
+
| Goal | Command |
|
|
15
|
+
|---|---|
|
|
16
|
+
| See what's out of sync (safe, read-only) | `npx zurik status --json` |
|
|
17
|
+
| Restore secrets after a clone | `npx zurik decrypt --json` |
|
|
18
|
+
| Save changes to secret files into `.zurik` | `npx zurik encrypt --json` |
|
|
19
|
+
| Preview without writing anything | add `--dry-run` |
|
|
20
|
+
| Machine-readable spec of all options and codes | `npx zurik help --json` |
|
|
21
|
+
|
|
22
|
+
`--cwd <dir>` runs against another directory. zurik uses the nearest parent directory containing `.gitencrypt` / `.zurik`.
|
|
23
|
+
|
|
24
|
+
## Password: never put it in your context
|
|
25
|
+
|
|
26
|
+
- Do **not** ask the user to paste the password into the chat. Do **not** use `-p <password>` or `--password <value>`: that puts it in the transcript, the shell history and the process list.
|
|
27
|
+
- Use what the environment already provides, in this order: `$ZURIK_PASSWORD`, `$ZURIK_PASSWORD_FILE`, or `--password-file <path>` / `--password-stdin` piped from a secret manager the user names (e.g. `op read ... | npx zurik decrypt --password-stdin --json`).
|
|
28
|
+
- On `E_NO_PASSWORD`, stop and tell the user to set `ZURIK_PASSWORD` (or `ZURIK_PASSWORD_FILE`) in the environment the agent runs in. Don't guess or generate one for an existing bundle.
|
|
29
|
+
- zurik never prompts when stdin isn't a terminal or when `--json` is set, so it won't hang.
|
|
30
|
+
|
|
31
|
+
## Workflow
|
|
32
|
+
|
|
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
|
+
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.
|
|
37
|
+
|
|
38
|
+
## Errors
|
|
39
|
+
|
|
40
|
+
| `error.code` | What to do |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `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_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_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. |
|
|
49
|
+
|
|
50
|
+
Exit codes: `0` success, `1` error, `2` usage error.
|
|
51
|
+
|
|
52
|
+
## Rules
|
|
53
|
+
|
|
54
|
+
- 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
|
+
- Don't use `--force` without the user's go-ahead.
|
|
56
|
+
- Don't delete `.zurik`, and don't remove entries from `.gitencrypt` unless asked.
|
package/.gitencrypt
DELETED
|
File without changes
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
name: Publish to npm
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
tags:
|
|
6
|
-
- 'v*.*.*'
|
|
7
|
-
|
|
8
|
-
jobs:
|
|
9
|
-
publish:
|
|
10
|
-
runs-on: ubuntu-latest
|
|
11
|
-
|
|
12
|
-
steps:
|
|
13
|
-
- name: Checkout
|
|
14
|
-
uses: actions/checkout@v4
|
|
15
|
-
|
|
16
|
-
- name: Set up Node
|
|
17
|
-
uses: actions/setup-node@v4
|
|
18
|
-
with:
|
|
19
|
-
node-version: 20
|
|
20
|
-
registry-url: 'https://registry.npmjs.org'
|
|
21
|
-
|
|
22
|
-
- name: Install deps
|
|
23
|
-
run: npm ci
|
|
24
|
-
|
|
25
|
-
- name: Build
|
|
26
|
-
run: npm run build --if-present
|
|
27
|
-
|
|
28
|
-
- name: Publish
|
|
29
|
-
env:
|
|
30
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
31
|
-
run: npm publish --access public
|