sharenv 1.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/.env.example ADDED
@@ -0,0 +1,5 @@
1
+ # envcrypt configuration (optional)
2
+ # Copy this to .env in your shell profile or set as system environment variable
3
+
4
+ # Your backend API URL (default: https://shareenv-backend.onrender.com/api)
5
+ ENVCRYPT_API=https://your-backend-url.com/api
package/README.md ADDED
@@ -0,0 +1,116 @@
1
+ # envcrypt CLI
2
+
3
+ A zero-dependency Node.js CLI that **encrypts your `.env` file locally** and shares it securely via the [EnvShare](https://github.com) backend.
4
+
5
+ > 🔒 Your passkey **never leaves your machine**. Only the AES-256-GCM ciphertext is stored on the server.
6
+
7
+ ---
8
+
9
+ ## Install (global)
10
+
11
+ ```bash
12
+ cd cli
13
+ npm link
14
+ ```
15
+
16
+ After linking, `envcrypt` is available everywhere in your terminal.
17
+
18
+ ---
19
+
20
+ ## Usage
21
+
22
+ ### `envcrypt set [path]`
23
+
24
+ Encrypts a `.env` file and uploads it. Prints a **Share ID** for the recipient.
25
+
26
+ ```bash
27
+ # Encrypt .env in the current folder
28
+ envcrypt set
29
+
30
+ # Encrypt .env from a specific project
31
+ envcrypt set C:/projects/myapp
32
+ envcrypt set /home/user/backend
33
+ ```
34
+
35
+ You'll be prompted for:
36
+ - **Passkey** (confirmed twice) — stay at 8+ chars
37
+ - **TTL** in minutes (default 1440 = 24 h, max 10080 = 7 days)
38
+ - **Max reads** (default 10, max 100) — link auto-deletes after this many reads
39
+
40
+ Output example:
41
+ ```
42
+ ✓ Encrypted locally
43
+ ✓ Uploaded to backend
44
+
45
+ Share ID
46
+ a1b2c3d4e5f6...
47
+
48
+ Recipient command
49
+ envcrypt get a1b2c3d4e5f6...
50
+ ```
51
+
52
+ ---
53
+
54
+ ### `envcrypt get <share-id> [output-path]`
55
+
56
+ Fetches and decrypts a `.env`. Writes it to the specified folder (or current directory).
57
+
58
+ ```bash
59
+ # Write .env to current folder
60
+ envcrypt get a1b2c3d4e5f6
61
+
62
+ # Write .env to a specific project folder
63
+ envcrypt get a1b2c3d4e5f6 C:/projects/myapp
64
+ envcrypt get a1b2c3d4e5f6 ./backend
65
+ ```
66
+
67
+ You'll be prompted for the passkey. If wrong, decryption fails with an error — nothing is written.
68
+
69
+ ---
70
+
71
+ ### `envcrypt info <share-id>`
72
+
73
+ Peek at a share's metadata without consuming a read.
74
+
75
+ ```bash
76
+ envcrypt info a1b2c3d4e5f6
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Backend URL
82
+
83
+ By default the CLI uses `http://localhost:3001/api`.
84
+
85
+ To point it at your deployed backend:
86
+
87
+ ```bash
88
+ # Windows PowerShell
89
+ $env:ENVCRYPT_API="https://your-backend.onrender.com/api"
90
+ envcrypt set
91
+
92
+ # Linux / macOS
93
+ ENVCRYPT_API=https://your-backend.onrender.com/api envcrypt set
94
+ ```
95
+
96
+ Or set it permanently in your system environment variables.
97
+
98
+ ---
99
+
100
+ ## Security
101
+
102
+ | Property | Value |
103
+ |---|---|
104
+ | Encryption | AES-256-GCM |
105
+ | Key derivation | PBKDF2-SHA256, 310 000 iterations |
106
+ | Salt / IV | Random per upload (16 / 12 bytes) |
107
+ | Compatibility | Works with the EnvShare web UI |
108
+ | Dependencies | **Zero** — uses Node.js built-ins only |
109
+
110
+ Blobs created in the browser can be retrieved from the CLI and vice versa.
111
+
112
+ ---
113
+
114
+ ## Requirements
115
+
116
+ - Node.js ≥ 18
package/bin/sharenv.js ADDED
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * envcrypt — Encrypt & share .env files via your EnvShare backend.
4
+ *
5
+ * Crypto is 100% compatible with the EnvShare web frontend:
6
+ * - PBKDF2 (310,000 iterations, SHA-256) for key derivation
7
+ * - AES-256-GCM for encryption
8
+ * - Base64 for iv / salt / ciphertext encoding
9
+ *
10
+ * Your passkey NEVER leaves this machine — only the ciphertext is uploaded.
11
+ */
12
+
13
+ import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from "crypto";
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
15
+ import { join, resolve } from "path";
16
+ import { createInterface } from "readline";
17
+
18
+ // ─── CONFIG ────────────────────────────────────────────────────────────────────
19
+ const API_BASE = process.env.ENVCRYPT_API || "http://localhost:3001/api";
20
+
21
+ // ─── ANSI COLOURS ─────────────────────────────────────────────────────────────
22
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
23
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
24
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
25
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
26
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
27
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
28
+
29
+ // ─── PROMPT HELPERS ────────────────────────────────────────────────────────────
30
+
31
+ /** Normal visible prompt via readline. */
32
+ function ask(question) {
33
+ return new Promise((res) => {
34
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
35
+ rl.question(question, (answer) => {
36
+ rl.close();
37
+ res(answer.trim());
38
+ });
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Hidden password prompt using raw mode stdin.
44
+ * Reads char by char, prints * for each character, handles backspace.
45
+ * Does NOT use readline at all — avoids the Windows readline conflicts.
46
+ */
47
+ function askPassword(prompt) {
48
+ return new Promise((res) => {
49
+ process.stdout.write(prompt);
50
+ let password = "";
51
+
52
+ process.stdin.setRawMode(true);
53
+ process.stdin.resume();
54
+ process.stdin.setEncoding("utf8");
55
+
56
+ const onData = (key) => {
57
+ // Ctrl+C
58
+ if (key === "\u0003") {
59
+ process.stdin.setRawMode(false);
60
+ process.stdin.pause();
61
+ process.stdin.removeListener("data", onData);
62
+ process.stdout.write("\n");
63
+ process.exit(0);
64
+ }
65
+ // Enter
66
+ if (key === "\r" || key === "\n") {
67
+ process.stdin.setRawMode(false);
68
+ process.stdin.pause();
69
+ process.stdin.removeListener("data", onData);
70
+ process.stdout.write("\n");
71
+ res(password);
72
+ return;
73
+ }
74
+ // Backspace
75
+ if (key === "\b" || key === "\x7f") {
76
+ if (password.length > 0) {
77
+ password = password.slice(0, -1);
78
+ process.stdout.write("\b \b"); // erase last *
79
+ }
80
+ return;
81
+ }
82
+ // Normal character
83
+ password += key;
84
+ process.stdout.write("*");
85
+ };
86
+
87
+ process.stdin.on("data", onData);
88
+ });
89
+ }
90
+
91
+ // ─── CRYPTO (matches frontend WebCrypto exactly) ──────────────────────────────
92
+
93
+ function deriveKey(password, saltBuf) {
94
+ return pbkdf2Sync(password, saltBuf, 310_000, 32, "sha256");
95
+ }
96
+
97
+ function encryptEnv(plaintext, password) {
98
+ const saltBuf = randomBytes(16);
99
+ const ivBuf = randomBytes(12);
100
+ const key = deriveKey(password, saltBuf);
101
+
102
+ const cipher = createCipheriv("aes-256-gcm", key, ivBuf);
103
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
104
+ const authTag = cipher.getAuthTag();
105
+
106
+ return {
107
+ encryptedData: Buffer.concat([enc, authTag]).toString("base64"),
108
+ iv: ivBuf.toString("base64"),
109
+ salt: saltBuf.toString("base64"),
110
+ };
111
+ }
112
+
113
+ function decryptEnv(encryptedDataB64, ivB64, saltB64, password) {
114
+ const saltBuf = Buffer.from(saltB64, "base64");
115
+ const ivBuf = Buffer.from(ivB64, "base64");
116
+ const combined = Buffer.from(encryptedDataB64, "base64");
117
+ const key = deriveKey(password, saltBuf);
118
+
119
+ const authTag = combined.slice(combined.length - 16);
120
+ const encBuf = combined.slice(0, combined.length - 16);
121
+
122
+ const decipher = createDecipheriv("aes-256-gcm", key, ivBuf);
123
+ decipher.setAuthTag(authTag);
124
+ return Buffer.concat([decipher.update(encBuf), decipher.final()]).toString("utf8");
125
+ }
126
+
127
+ // ─── API ───────────────────────────────────────────────────────────────────────
128
+
129
+ async function apiPost(path, body) {
130
+ const res = await fetch(`${API_BASE}${path}`, {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify(body),
134
+ });
135
+ const json = await res.json();
136
+ if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
137
+ return json;
138
+ }
139
+
140
+ async function apiGet(path) {
141
+ const res = await fetch(`${API_BASE}${path}`);
142
+ const json = await res.json();
143
+ if (!res.ok) throw new Error(json.error || `HTTP ${res.status}`);
144
+ return json;
145
+ }
146
+
147
+ // ─── RESOLVE .env PATH ────────────────────────────────────────────────────────
148
+
149
+ function resolveEnvPath(arg) {
150
+ if (!arg) {
151
+ const p = join(process.cwd(), ".env");
152
+ if (!existsSync(p)) {
153
+ console.error(red(`\n ✗ No .env found in current directory.`));
154
+ console.error(dim(` Run inside a project folder or give a path:`));
155
+ console.error(dim(` envcrypt set C:/projects/myapp\n`));
156
+ process.exit(1);
157
+ }
158
+ return p;
159
+ }
160
+ const given = resolve(arg);
161
+ if (given.endsWith(".env") && existsSync(given)) return given;
162
+ const candidate = join(given, ".env");
163
+ if (!existsSync(candidate)) {
164
+ console.error(red(`\n ✗ No .env found at: ${candidate}\n`));
165
+ process.exit(1);
166
+ }
167
+ return candidate;
168
+ }
169
+
170
+ // ─── COMMAND: set ─────────────────────────────────────────────────────────────
171
+
172
+ async function cmdSet(args) {
173
+ console.log(`\n ${bold(cyan("🔐 envcrypt set"))}\n`);
174
+
175
+ const envPath = resolveEnvPath(args[0]);
176
+ console.log(dim(` Source: ${envPath}\n`));
177
+
178
+ const envContent = readFileSync(envPath, "utf8");
179
+ if (!envContent.trim()) {
180
+ console.error(yellow(" ⚠ .env file is empty — nothing to upload.\n"));
181
+ process.exit(1);
182
+ }
183
+
184
+ // ── Step 1: Passkey (hidden with ***) ──
185
+ const passkey = await askPassword(bold(" Enter passkey: "));
186
+ if (!passkey || passkey.length < 4) {
187
+ console.error(red("\n ✗ Passkey must be at least 4 characters.\n"));
188
+ process.exit(1);
189
+ }
190
+
191
+ const passkey2 = await askPassword(bold(" Confirm passkey: "));
192
+ if (passkey !== passkey2) {
193
+ console.error(red("\n ✗ Passkeys do not match.\n"));
194
+ process.exit(1);
195
+ }
196
+
197
+ // ── Step 2: TTL & max reads ──
198
+ const ttlInput = await ask(dim(" TTL in minutes [default 1440 = 24h]: "));
199
+ const readsInput = await ask(dim(" Max reads [default 10]: "));
200
+
201
+ const ttlMinutes = (ttlInput && !isNaN(ttlInput)) ? parseInt(ttlInput) : 1440;
202
+ const maxReads = (readsInput && !isNaN(readsInput)) ? parseInt(readsInput) : 10;
203
+
204
+ // ── Step 3: Encrypt locally ──
205
+ console.log(dim("\n Encrypting locally..."));
206
+ let encResult;
207
+ try {
208
+ encResult = encryptEnv(envContent, passkey);
209
+ console.log(green(" ✓ Encrypted (passkey stays on your machine)"));
210
+ } catch (e) {
211
+ console.error(red(` ✗ Encryption error: ${e.message}`));
212
+ process.exit(1);
213
+ }
214
+
215
+ // ── Step 4: Upload ──
216
+ console.log(dim(" Uploading encrypted blob..."));
217
+ let result;
218
+ try {
219
+ result = await apiPost("/store", { ...encResult, maxReads, ttlMinutes });
220
+ console.log(green(" ✓ Uploaded to backend"));
221
+ } catch (e) {
222
+ console.error(red(`\n ✗ Upload failed: ${e.message}`));
223
+ console.error(dim(` Is the backend running? API: ${API_BASE}\n`));
224
+ process.exit(1);
225
+ }
226
+
227
+ // ── Result ──
228
+ console.log(`
229
+ ${bold(green("✓ Done!"))}
230
+
231
+ ${bold("Share ID:")}
232
+ ${cyan(result.id)}
233
+
234
+ ${bold("Recipient runs:")}
235
+ ${yellow("envcrypt get " + result.id)}
236
+
237
+ ${dim(`TTL: ${result.ttlMinutes} min · Max reads: ${result.maxReads}`)}
238
+ ${dim("Share your passkey separately — NOT in the same message as the ID.")}
239
+ `);
240
+ }
241
+
242
+ // ─── COMMAND: get ─────────────────────────────────────────────────────────────
243
+
244
+ async function cmdGet(args) {
245
+ console.log(`\n ${bold(cyan("📥 envcrypt get"))}\n`);
246
+
247
+ const shareId = args[0];
248
+ if (!shareId) {
249
+ console.error(red(" ✗ Usage: envcrypt get <share-id> [output-path]\n"));
250
+ process.exit(1);
251
+ }
252
+
253
+ const outDir = args[1] ? resolve(args[1]) : process.cwd();
254
+ const outFile = join(outDir, ".env");
255
+
256
+ if (existsSync(outFile)) {
257
+ const ans = await ask(yellow(` ⚠ .env already exists at ${outFile}\n Overwrite? (y/N): `));
258
+ if (!ans.toLowerCase().startsWith("y")) {
259
+ console.log(dim("\n Aborted.\n"));
260
+ process.exit(0);
261
+ }
262
+ }
263
+
264
+ // ── Passkey (hidden with ***) ──
265
+ const passkey = await askPassword(bold(" Enter passkey: "));
266
+ if (!passkey) {
267
+ console.error(red(" ✗ Passkey cannot be empty.\n"));
268
+ process.exit(1);
269
+ }
270
+
271
+ // ── Fetch ──
272
+ console.log(dim("\n Fetching encrypted blob..."));
273
+ let data;
274
+ try {
275
+ data = await apiGet(`/retrieve/${shareId}`);
276
+ console.log(green(" ✓ Fetched"));
277
+ } catch (e) {
278
+ console.error(red(` ✗ Fetch failed: ${e.message}`));
279
+ process.exit(1);
280
+ }
281
+
282
+ // ── Decrypt ──
283
+ console.log(dim(" Decrypting locally..."));
284
+ let envContent;
285
+ try {
286
+ envContent = decryptEnv(data.encryptedData, data.iv, data.salt, passkey);
287
+ console.log(green(" ✓ Decrypted"));
288
+ } catch {
289
+ console.error(red(" ✗ Decryption failed — wrong passkey or corrupted data."));
290
+ process.exit(1);
291
+ }
292
+
293
+ // ── Write ──
294
+ if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
295
+ writeFileSync(outFile, envContent, "utf8");
296
+
297
+ const varCount = envContent
298
+ .split("\n")
299
+ .filter((l) => l.trim() && !l.startsWith("#") && l.includes("="))
300
+ .length;
301
+
302
+ console.log(`
303
+ ${bold(green("✓ .env created!"))}
304
+
305
+ ${dim("Location : ")} ${outFile}
306
+ ${dim("Variables: ")} ${varCount}
307
+ ${dim("Reads : ")} ${data.readsUsed} / ${data.maxReads}
308
+ `);
309
+ }
310
+
311
+ // ─── COMMAND: info ────────────────────────────────────────────────────────────
312
+
313
+ async function cmdInfo(args) {
314
+ const shareId = args[0];
315
+ if (!shareId) {
316
+ console.error(red(" ✗ Usage: envcrypt info <share-id>\n"));
317
+ process.exit(1);
318
+ }
319
+ let data;
320
+ try {
321
+ data = await apiGet(`/info/${shareId}`);
322
+ } catch (e) {
323
+ console.error(red(`\n ✗ ${e.message}\n`));
324
+ process.exit(1);
325
+ }
326
+ console.log(`
327
+ ${bold(cyan("📊 Share Info"))}
328
+
329
+ ${dim("ID: ")} ${cyan(shareId)}
330
+ ${dim("Reads left: ")} ${bold(String(data.readsLeft))}
331
+ ${dim("Max reads: ")} ${data.maxReads}
332
+ ${dim("TTL left: ")} ${Math.ceil(data.ttlSeconds / 60)} min
333
+ `);
334
+ }
335
+
336
+ // ─── HELP ─────────────────────────────────────────────────────────────────────
337
+
338
+ function printHelp() {
339
+ console.log(`
340
+ ${bold(cyan("sharenv"))} — Encrypt & share .env files securely
341
+
342
+ ${bold("Usage:")}
343
+ ${yellow("sharenv set")} ${dim("[path]")} Encrypt .env and upload
344
+ ${yellow("sharenv get")} ${dim("<id> [path]")} Download & decrypt a .env
345
+ ${yellow("sharenv info")} ${dim("<id>")} Check share status
346
+
347
+ ${bold("Examples:")}
348
+ ${green("sharenv set")} # encrypts .env in current folder
349
+ ${green("sharenv set C:/projects/myapp")} # encrypts .env from that project
350
+ ${green("sharenv get abc123def456")} # write .env to current folder
351
+ ${green("sharenv get abc123 ./myapp")} # write .env to ./myapp folder
352
+
353
+
354
+ ${bold("Security:")}
355
+ • AES-256-GCM + PBKDF2-SHA256 (310k iterations)
356
+ • Compatible with senvsecure web frontend
357
+ • Your passkey ${bold("never")} leaves this machine
358
+ `);
359
+ }
360
+
361
+ // ─── MAIN ─────────────────────────────────────────────────────────────────────
362
+
363
+ const [,, cmd, ...rest] = process.argv;
364
+
365
+ switch (cmd) {
366
+ case "set":
367
+ cmdSet(rest).catch((e) => { console.error(red(`\n ✗ ${e.message}\n`)); process.exit(1); });
368
+ break;
369
+ case "get":
370
+ cmdGet(rest).catch((e) => { console.error(red(`\n ✗ ${e.message}\n`)); process.exit(1); });
371
+ break;
372
+ case "info":
373
+ cmdInfo(rest).catch((e) => { console.error(red(`\n ✗ ${e.message}\n`)); process.exit(1); });
374
+ break;
375
+ case "--help":
376
+ case "-h":
377
+ case "help":
378
+ case undefined:
379
+ printHelp();
380
+ break;
381
+ default:
382
+ console.error(red(`\n ✗ Unknown command: ${cmd}`));
383
+ printHelp();
384
+ process.exit(1);
385
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "sharenv",
3
+ "version": "1.0.0",
4
+ "description": "Encrypt & share .env files securely via the EnvShare backend",
5
+ "type": "module",
6
+ "bin": {
7
+ "sharenv": "./bin/sharenv.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node bin/sharenv.js"
11
+ },
12
+ "keywords": [
13
+ "env",
14
+ "sharenv",
15
+ "cli",
16
+ "dotenv",
17
+ "share",
18
+ "aes",
19
+ "pbkdf2"
20
+ ],
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=18.0.0"
24
+ }
25
+ }
Binary file