@dbarjs/dead-drop 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eduardo Barros
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # @dbarjs/dead-drop
2
+
3
+ The CLI for the [Dead Drop](https://github.com/dbarjs/dead-drop-app) privacy-first relay. Encrypts a project directory end-to-end with a passphrase, uploads it as a single-use Drop, and lets the recipient retrieve it exactly once.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm i -g @dbarjs/dead-drop
9
+ # or
10
+ npm i -g @dbarjs/dead-drop
11
+ ```
12
+
13
+ Requires Node.js ≥ 20.
14
+
15
+ ## Copy a project (sender)
16
+
17
+ ```sh
18
+ dead-drop copy ./my-project --api https://drops.example.com
19
+ ```
20
+
21
+ You'll be prompted for a passphrase. The CLI derives an AES-256 key from it, encrypts each file in-place, and uploads to the Dead Drop API. On success it prints the Drop URL — share that and the passphrase out-of-band with the recipient.
22
+
23
+ ## Cut a Drop (recipient)
24
+
25
+ ```sh
26
+ dead-drop cut https://drops.example.com/api/drops/01ABC.../index --out ./recovered
27
+ ```
28
+
29
+ You can also pass a bare ULID and an `--api` flag:
30
+
31
+ ```sh
32
+ dead-drop cut 01ABC... --out ./recovered --api https://drops.example.com
33
+ ```
34
+
35
+ Enter the same passphrase the sender used. Each file is fetched, decrypted, and written under `--out`. **Reading consumes the file** — the second cut sees `404` for any file that was already taken.
36
+
37
+ ## Configuration
38
+
39
+ | Env var | Effect |
40
+ | --- | --- |
41
+ | `DEAD_DROP_API_URL` | Default base URL when `--api` is omitted. Falls back to `http://localhost:3000`. |
42
+
43
+ ## Local development (inside this monorepo)
44
+
45
+ From the repo root, run the CLI directly via the workspace filter:
46
+
47
+ ```sh
48
+ pnpm -F @dbarjs/dead-drop dev copy ./test-fixture
49
+ pnpm -F @dbarjs/dead-drop dev cut https://localhost:3000/api/drops/<ulid>/index --out ./recovered
50
+ pnpm -F @dbarjs/dead-drop build
51
+ ```
52
+
53
+ The `dev` script runs `tsx src/cli.ts <args>` directly from source — no build step needed during iteration.
54
+
55
+ ## Security notes
56
+
57
+ - Your passphrase never leaves your machine. It's run through PBKDF2-SHA256 (600k iterations) to derive the AES-GCM key. The server stores ciphertext only.
58
+ - Lose the passphrase and the Drop is unrecoverable. There is no reset.
59
+ - The server *can* see file metadata (paths, sizes, content hashes). It cannot see file contents.
60
+ - Each file burns on first download. A mid-download network failure means that file is permanently lost.
61
+ - Drops expire 24 hours after creation regardless of read state.
package/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+
package/dist/cli.mjs ADDED
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ import { fileURLToPath } from 'node:url';
3
+ import { defineCommand, runMain } from 'citty';
4
+ import { readPackageJSON } from 'pkg-types';
5
+ import { consola } from 'consola';
6
+ import { join, relative, resolve, sep, dirname } from 'pathe';
7
+ import { stat, readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
8
+ import { ofetch } from 'ofetch';
9
+
10
+ const SALT_BYTES = 16;
11
+ const NONCE_BYTES = 12;
12
+ const PBKDF2_ITERATIONS = 6e5;
13
+ async function deriveKey(passphrase, salt) {
14
+ const baseKey = await crypto.subtle.importKey(
15
+ "raw",
16
+ new TextEncoder().encode(passphrase),
17
+ "PBKDF2",
18
+ false,
19
+ ["deriveKey"]
20
+ );
21
+ return crypto.subtle.deriveKey(
22
+ { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
23
+ baseKey,
24
+ { name: "AES-GCM", length: 256 },
25
+ false,
26
+ ["encrypt", "decrypt"]
27
+ );
28
+ }
29
+ async function encryptBytes(plaintext, key) {
30
+ const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
31
+ const cipher = await crypto.subtle.encrypt(
32
+ { name: "AES-GCM", iv: nonce },
33
+ key,
34
+ plaintext
35
+ );
36
+ return { ciphertext: new Uint8Array(cipher), nonce };
37
+ }
38
+ async function decryptBytes(ciphertext, key, nonce) {
39
+ const plain = await crypto.subtle.decrypt(
40
+ { name: "AES-GCM", iv: nonce },
41
+ key,
42
+ ciphertext
43
+ );
44
+ return new Uint8Array(plain);
45
+ }
46
+ async function sha256Hex(bytes) {
47
+ const hash = await crypto.subtle.digest("SHA-256", bytes);
48
+ return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
49
+ }
50
+ function randomSalt() {
51
+ return crypto.getRandomValues(new Uint8Array(SALT_BYTES));
52
+ }
53
+ function toBase64(bytes) {
54
+ return Buffer.from(bytes).toString("base64");
55
+ }
56
+ function fromBase64(b64) {
57
+ return new Uint8Array(Buffer.from(b64, "base64"));
58
+ }
59
+
60
+ const IGNORED_DIRS = /* @__PURE__ */ new Set([
61
+ "node_modules",
62
+ ".git",
63
+ ".nuxt",
64
+ ".output",
65
+ ".nitro",
66
+ ".cache",
67
+ "dist"
68
+ ]);
69
+ async function* walkDirectory(rootDir) {
70
+ const root = await stat(rootDir);
71
+ if (!root.isDirectory()) {
72
+ throw new Error(`${rootDir} is not a directory`);
73
+ }
74
+ yield* walkInto(rootDir, rootDir);
75
+ }
76
+ async function* walkInto(rootDir, current) {
77
+ const entries = await readdir(current, { withFileTypes: true });
78
+ for (const entry of entries) {
79
+ if (IGNORED_DIRS.has(entry.name)) continue;
80
+ const absPath = join(current, entry.name);
81
+ if (entry.isDirectory()) {
82
+ yield* walkInto(rootDir, absPath);
83
+ } else if (entry.isFile()) {
84
+ const relPath = relative(rootDir, absPath);
85
+ const bytes = new Uint8Array(await readFile(absPath));
86
+ yield { absPath, relPath, bytes };
87
+ }
88
+ }
89
+ }
90
+
91
+ function createApi(baseURL) {
92
+ return ofetch.create({ baseURL });
93
+ }
94
+ async function createDrop(api, body) {
95
+ return api("/api/drops", {
96
+ method: "POST",
97
+ body
98
+ });
99
+ }
100
+ async function appendFile(api, dropId, body) {
101
+ return api(`/api/drops/${dropId}/files`, {
102
+ method: "POST",
103
+ body
104
+ });
105
+ }
106
+ async function getIndex(api, dropId) {
107
+ return api(`/api/drops/${dropId}`);
108
+ }
109
+ async function burnFile(api, dropId, fileId) {
110
+ try {
111
+ const res = await api(`/api/drops/${dropId}/files/${fileId}`);
112
+ return { burned: true, data: res.data };
113
+ } catch (err) {
114
+ if (err?.response?.status === 404) {
115
+ return { burned: false };
116
+ }
117
+ throw err;
118
+ }
119
+ }
120
+
121
+ const CTRL_C = "";
122
+ const BACKSPACE = "\x7F";
123
+ const ENTER = "\r";
124
+ const NEWLINE = "\n";
125
+ const EOT = "";
126
+ async function promptPassphrase(label = "Passphrase") {
127
+ const stdin = process.stdin;
128
+ if (!stdin.isTTY) {
129
+ let buf = "";
130
+ stdin.setEncoding("utf8");
131
+ for await (const chunk of stdin) {
132
+ buf += chunk;
133
+ }
134
+ return buf.replace(/\r?\n$/, "");
135
+ }
136
+ process.stdout.write(`${label}: `);
137
+ return new Promise((resolve, reject) => {
138
+ let buf = "";
139
+ const cleanup = () => {
140
+ stdin.setRawMode(false);
141
+ stdin.pause();
142
+ stdin.off("data", onData);
143
+ };
144
+ const onData = (chunk) => {
145
+ const text = chunk.toString("utf8");
146
+ for (const ch of text) {
147
+ if (ch === ENTER || ch === NEWLINE) {
148
+ cleanup();
149
+ process.stdout.write("\n");
150
+ resolve(buf);
151
+ return;
152
+ }
153
+ if (ch === CTRL_C) {
154
+ cleanup();
155
+ process.stdout.write("\n");
156
+ reject(new Error("Interrupted"));
157
+ return;
158
+ }
159
+ if (ch === EOT) {
160
+ cleanup();
161
+ process.stdout.write("\n");
162
+ resolve(buf);
163
+ return;
164
+ }
165
+ if (ch === BACKSPACE || ch === "\b") {
166
+ buf = buf.slice(0, -1);
167
+ continue;
168
+ }
169
+ if (ch.charCodeAt(0) < 32) continue;
170
+ buf += ch;
171
+ }
172
+ };
173
+ stdin.setRawMode(true);
174
+ stdin.resume();
175
+ stdin.on("data", onData);
176
+ });
177
+ }
178
+
179
+ const DEFAULT_BASE_URL = "http://localhost:3000";
180
+ function resolveBaseUrl(flag) {
181
+ const raw = flag ?? process.env.DEAD_DROP_API_URL ?? DEFAULT_BASE_URL;
182
+ return raw.replace(/\/+$/, "");
183
+ }
184
+ const ULID_RE = /[0-9A-HJKMNP-TV-Z]{26}/;
185
+ function parseDropTarget(input, apiFlag) {
186
+ if (/^https?:\/\//i.test(input)) {
187
+ const url = new URL(input);
188
+ const match = url.pathname.match(/\/api\/drops\/([0-9A-HJKMNP-TV-Z]{26})(?:\/index)?\/?$/);
189
+ if (!match) {
190
+ throw new Error(`Cannot parse dropId from URL: ${input}`);
191
+ }
192
+ return {
193
+ dropId: match[1],
194
+ baseUrl: `${url.protocol}//${url.host}`
195
+ };
196
+ }
197
+ if (!ULID_RE.test(input)) {
198
+ throw new Error(`Not a valid dropId or Drop URL: ${input}`);
199
+ }
200
+ return { dropId: input, baseUrl: resolveBaseUrl(apiFlag) };
201
+ }
202
+
203
+ const copy = defineCommand({
204
+ meta: {
205
+ name: "copy",
206
+ description: "Encrypt a directory and upload it as a Drop."
207
+ },
208
+ args: {
209
+ directory: {
210
+ type: "positional",
211
+ required: true,
212
+ description: "Path to the directory to copy."
213
+ },
214
+ api: {
215
+ type: "string",
216
+ description: "API base URL (overrides $DEAD_DROP_API_URL, default http://localhost:3000)."
217
+ }
218
+ },
219
+ async run({ args }) {
220
+ const rootDir = resolve(args.directory);
221
+ const baseUrl = resolveBaseUrl(args.api);
222
+ const api = createApi(baseUrl);
223
+ const passphrase = await promptPassphrase();
224
+ if (!passphrase) {
225
+ consola.error("Passphrase required.");
226
+ process.exit(2);
227
+ }
228
+ const salt = randomSalt();
229
+ const key = await deriveKey(passphrase, salt);
230
+ const kdf = {
231
+ algorithm: "PBKDF2-SHA256",
232
+ iterations: PBKDF2_ITERATIONS,
233
+ salt: toBase64(salt)
234
+ };
235
+ let dropId = null;
236
+ let count = 0;
237
+ for await (const file of walkDirectory(rootDir)) {
238
+ const { ciphertext, nonce } = await encryptBytes(file.bytes, key);
239
+ const contentHash = await sha256Hex(file.bytes);
240
+ const entry = {
241
+ path: file.relPath,
242
+ rawSize: file.bytes.byteLength,
243
+ ciphertextSize: ciphertext.byteLength,
244
+ contentHash,
245
+ nonce: toBase64(nonce)
246
+ };
247
+ const data = toBase64(ciphertext);
248
+ if (dropId === null) {
249
+ const res = await createDrop(api, { kdf, file: entry, data });
250
+ dropId = res.dropId;
251
+ consola.log(`+ ${file.relPath} (drop ${dropId})`);
252
+ } else {
253
+ await appendFile(api, dropId, { file: entry, data });
254
+ consola.log(`+ ${file.relPath}`);
255
+ }
256
+ count++;
257
+ }
258
+ if (count === 0 || dropId === null) {
259
+ consola.error("No files found to upload.");
260
+ process.exit(1);
261
+ }
262
+ consola.log("");
263
+ consola.success(`Copied ${count} file${count === 1 ? "" : "s"}.`);
264
+ consola.log(`Drop ID: ${dropId}`);
265
+ consola.log(`Index: ${baseUrl}/api/drops/${dropId}`);
266
+ consola.log("");
267
+ consola.log(`To cut: dead-drop cut ${baseUrl}/api/drops/${dropId} --out <dir>`);
268
+ }
269
+ });
270
+
271
+ function safeJoin(outDir, relPath) {
272
+ const outAbs = resolve(outDir);
273
+ const target = resolve(outAbs, relPath);
274
+ if (target !== outAbs && !target.startsWith(outAbs + sep)) {
275
+ throw new Error(`unsafe path escapes output directory: ${relPath}`);
276
+ }
277
+ return target;
278
+ }
279
+
280
+ const cut = defineCommand({
281
+ meta: {
282
+ name: "cut",
283
+ description: "Fetch a Drop, decrypt, and write to disk (burns each file as it goes)."
284
+ },
285
+ args: {
286
+ target: {
287
+ type: "positional",
288
+ required: true,
289
+ description: "Drop URL or bare dropId (ULID)."
290
+ },
291
+ out: {
292
+ type: "string",
293
+ required: true,
294
+ description: "Output directory where decrypted files are written."
295
+ },
296
+ api: {
297
+ type: "string",
298
+ description: "API base URL when passing a bare dropId (overrides $DEAD_DROP_API_URL)."
299
+ }
300
+ },
301
+ async run({ args }) {
302
+ const { dropId, baseUrl } = parseDropTarget(args.target, args.api);
303
+ const outDir = resolve(args.out);
304
+ const api = createApi(baseUrl);
305
+ const passphrase = await promptPassphrase();
306
+ if (!passphrase) {
307
+ consola.error("Passphrase required.");
308
+ process.exit(2);
309
+ }
310
+ const index = await getIndex(api, dropId);
311
+ const salt = fromBase64(index.kdf.salt);
312
+ const key = await deriveKey(passphrase, salt);
313
+ let written = 0;
314
+ let alreadyGone = 0;
315
+ let failed = 0;
316
+ for (const entry of index.files) {
317
+ const burn = await burnFile(api, dropId, entry.fileId);
318
+ if (!burn.burned) {
319
+ consola.log(`- ${entry.path} (already cut)`);
320
+ alreadyGone++;
321
+ continue;
322
+ }
323
+ try {
324
+ const ciphertext = fromBase64(burn.data);
325
+ const nonce = fromBase64(entry.nonce);
326
+ const plaintext = await decryptBytes(ciphertext, key, nonce);
327
+ const actualHash = await sha256Hex(plaintext);
328
+ if (actualHash !== entry.contentHash) {
329
+ consola.fail(`${entry.path} (content hash mismatch)`);
330
+ failed++;
331
+ continue;
332
+ }
333
+ const target = safeJoin(outDir, entry.path);
334
+ await mkdir(dirname(target), { recursive: true });
335
+ await writeFile(target, plaintext);
336
+ consola.log(`+ ${entry.path}`);
337
+ written++;
338
+ } catch (err) {
339
+ const msg = err instanceof Error ? err.message : String(err);
340
+ if (msg.includes("decrypt")) {
341
+ consola.fail(`${entry.path} (decryption failed \u2014 wrong passphrase?)`);
342
+ } else {
343
+ consola.fail(`${entry.path} (${msg})`);
344
+ }
345
+ failed++;
346
+ }
347
+ }
348
+ consola.log("");
349
+ consola.success(
350
+ `Cut ${written} file${written === 1 ? "" : "s"}` + (alreadyGone ? `, ${alreadyGone} already cut` : "") + (failed ? `, ${failed} failed` : "") + "."
351
+ );
352
+ if (failed > 0) process.exit(1);
353
+ }
354
+ });
355
+
356
+ const pkg = await readPackageJSON(fileURLToPath(new URL("../package.json", import.meta.url)));
357
+ const main = defineCommand({
358
+ meta: {
359
+ name: "dead-drop",
360
+ version: pkg.version,
361
+ description: pkg.description
362
+ },
363
+ subCommands: { copy, cut }
364
+ });
365
+ runMain(main);
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@dbarjs/dead-drop",
3
+ "version": "0.1.0",
4
+ "description": "Privacy-first dead-drop CLI: copy a project directory; the recipient cuts it once.",
5
+ "type": "module",
6
+ "bin": {
7
+ "dead-drop": "./dist/cli.mjs"
8
+ },
9
+ "main": "./dist/cli.mjs",
10
+ "types": "./dist/cli.d.mts",
11
+ "files": [
12
+ "dist",
13
+ "README.md"
14
+ ],
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "dependencies": {
19
+ "citty": "^0.1.6",
20
+ "consola": "^3.4.2",
21
+ "ofetch": "^1.5.1",
22
+ "pathe": "^1.1.2",
23
+ "pkg-types": "^1.3.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^25.8.0",
27
+ "tsx": "^4.21.0",
28
+ "typescript": "^5.9.3",
29
+ "unbuild": "^3.5.0",
30
+ "vitest": "^4.1.6"
31
+ },
32
+ "scripts": {
33
+ "dev": "tsx src/cli.ts",
34
+ "build": "unbuild",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest"
37
+ }
38
+ }