clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

feat: refine source copy and password security

Dami committed c745c85
Rename the source recovery command to cp while retaining download as a compatibility alias. Revoke unlock cookies when password policy changes, add migration coverage, and make the committed Wrangler config the safe self-hosting template.
commit
c745c85560301c73a56516a4089edb24c2a5be61
parent
2c2fce25e3dcfdc3a80027333905768a7169666d
author
Dami <dami.etoile@gmail.com>
16 files changed, +131 -76
MREADME.md19+++++++------
Mcli/README.md10+++---
Rcli/commands/download.ts → cli/commands/cp.ts10+++---
Rcli/commands/download_test.ts → cli/commands/cp_test.ts4+-
Mcli/main.ts9+++---
Aworker/migrations/0001_auth_version.sql2+
Mworker/package.json4++-
Mworker/schema.sql1+
Mworker/setup.md10++++++-
Mworker/src/api.ts1+
Mworker/src/db.ts4++-
Mworker/src/password.ts39++++++++++++++++++++------
Mworker/src/serve.ts7+++-
Aworker/test/password.test.mjs46++++++++++++++++++++++++++++++
Mworker/wrangler.jsonc8+++---
Dworker/wrangler.local.example.jsonc33----------------------
M README.md +10 -9
@@ -52,7 +52,8 @@ three target forms:
5252 them, and the revert itself is recorded as a push.5353 - **Password-protectable.** `nzip push ./demo work:demo --password …` publishes the content and5454 password policy atomically, then gates the site behind an unlock form (PBKDF2 hashing, signed55- per-site cookie, 7 days). Use `nzip site` to change protection later.55+ per-site cookie, 7 days). Changing the password policy revokes existing cookies immediately. Use56+ `nzip site` to change protection later.5657 - **Single-user by design.** One bearer token, stored as a Worker secret and in5758 `~/.config/nzip/config.json` (mode 0600).5859 - **Locates itself.** `nzip where <target>` prints the local directory this machine pushed a site
@@ -71,7 +72,7 @@ nzip auth [--server URL] [--token T] authenticate and save config
7172 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)7273 nzip vault ls | default <name> list vaults / set the default7374 nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]74-nzip download <target> [dir] [--overwrite] recover the current hosted bundle75+nzip cp <target> [dir] [--overwrite] copy the current hosted bundle locally7576 nzip site <target> [--ttl …] [--password PW | --no-password]7677 nzip ls [vault] list sites7778 nzip where <target> print the local dir this machine pushed from
@@ -82,19 +83,19 @@ nzip revert <target> [--to N] [--list] repoint to a previous push
8283 8384 Password and TTL are committed with the content. On a new site, omitting `--password` creates an8485 unprotected site; on an existing target, omission preserves its current password. Pass85-`--no-password` to clear protection explicitly. The former `nzip share` command remains available86-as a compatibility alias for `nzip site`.86+`--no-password` to clear protection explicitly. The former `nzip share` command remains available as87+a compatibility alias for `nzip site`.8788 8889 Pushing a single `page.html` stores it as the site's `index.html`. Directory pushes skip dotfiles8990 and `node_modules`, and honor a `.nzipignore` (one glob per line). Single-file sites serve directly9091 at the bare address (`/2a3f`); multi-file bundles redirect to `/2a3f/` so relative asset URLs9192 resolve.9293 93-`nzip download work:demo ./recovered-demo` recovers the exact current bundle from the authenticated94-server when the original local directory is unavailable. It refuses non-empty destinations unless94+`nzip cp work:demo ./recovered-demo` recovers the exact current bundle from the authenticated server95+when the original local directory is unavailable. It refuses non-empty destinations unless9596 `--overwrite` is passed, and verifies every downloaded file against the hosted manifest. It can only9697 restore uploaded files—not dotfiles, `.nzipignore`, or other local project metadata excluded from a97-push.98+push. The former `nzip download` command remains available as a compatibility alias.9899 99100 ## Architecture100101
@@ -121,7 +122,7 @@ revalidation and a 60-second cache. Metadata lives in three tables:
121122 | table | keys |122123 | -------- | ------------------------------------------------------------------------ |123124 | `vaults` | slot (0–15), name |124-| `sites` | address (0–65535), vault, alias, current manifest, expiry, password hash |125+| `sites` | address (0–65535), vault, alias, current manifest, expiry, password hash/version |125126 | `pushes` | per-site history (seq, manifest, note), capped at 10, powers `revert` |126127 127128 **GC safety rule:** an R2 object is deleted only if no live site or retained history entry
@@ -173,7 +174,7 @@ See [`worker/setup.md`](worker/setup.md) for the full checklist. In short:
173174 ```sh174175 cd worker175176 npx wrangler login176-cp wrangler.local.example.jsonc wrangler.local.jsonc177+cp wrangler.jsonc wrangler.local.jsonc177178 # edit wrangler.local.jsonc:178179 # - routes[0].pattern: your hostname, such as share.example.com179180 # - vars.PUBLIC_BASE: https://<that hostname>
M cli/README.md +5 -5
@@ -41,7 +41,7 @@ nzip auth [--server URL] [--token T] authenticate and save config
4141 nzip vault add <name> [--slot N] register a vault (16 slots, 0x0–0xf)4242 nzip vault ls | default <name> list vaults / set the default4343 nzip push <dir|file> [target] [--ttl …] [--password PW | --no-password]44-nzip download <target> [dir] [--overwrite]44+nzip cp <target> [dir] [--overwrite]4545 nzip site <target> [--ttl …] [--password PW | --no-password]4646 nzip ls [vault] list sites4747 nzip where <target> print the local dir this machine pushed from
@@ -55,10 +55,10 @@ unprotected site; on an existing target, omission preserves its current password
5555 `--no-password` to clear protection explicitly. The former `nzip share` command remains available as5656 a compatibility alias for `nzip site`.5757 58-`nzip download work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty59-directory. It uses the configured bearer token, verifies file hashes, and never exposes source via60-the public page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were61-never stored.58+`nzip cp work:demo ./recovered-demo` reconstructs the current hosted bundle into an empty directory.59+It uses the configured bearer token, verifies file hashes, and never exposes source via the public60+page URL. Only uploaded files can be restored; ignored dotfiles and local metadata were never61+stored. The former `nzip download` command remains available as a compatibility alias.6262 6363 See the [project README](https://github.com/FelineStateMachine/nzip) for how addresses work,6464 self-hosting setup, and architecture. MIT licensed.
R cli/commands/download.ts → cli/commands/cp.ts +5 -5
@@ -14,7 +14,7 @@ interface SourceApi {
1414 downloadSourceBlob(target: string, hash: string): ReturnType<ApiClient["downloadSourceBlob"]>;1515 }1616 17-export interface DownloadResult {17+export interface CopyResult {1818 address: string;1919 manifestHash: string;2020 destination: string;
@@ -23,7 +23,7 @@ export interface DownloadResult {
2323 }2424 2525 function targetOrFail(raw: string | undefined, config: Config): string {26- if (!raw) fail("usage: nzip download <target> [dir] [--overwrite]");26+ if (!raw) fail("usage: nzip cp <target> [dir] [--overwrite]");2727 try {2828 return resolveCliTarget(raw, config);2929 } catch (e) {
@@ -69,7 +69,7 @@ async function writeFile(path: string, bytes: Uint8Array, overwrite: boolean): P
6969 }7070 }7171 72-export async function cmdDownload(72+export async function cmdCp(7373 config: Config,7474 raw: string | undefined,7575 destinationRaw: string | undefined,
@@ -82,7 +82,7 @@ export async function cmdDownload(
8282 emit(8383 () =>8484 console.log(85- `${green("✓")} downloaded ${bold(result.address)} → ${85+ `${green("✓")} copied ${bold(result.address)} → ${8686 cyan(result.destination)8787 } (${result.files} files, ${formatBytes(result.bytes)})`,8888 ),
@@ -96,7 +96,7 @@ export async function downloadSource(
9696 target: string,9797 destinationRaw: string | undefined,9898 overwrite: boolean,99-): Promise<DownloadResult> {99+): Promise<CopyResult> {100100 const source = await api.source(target);101101 const destination = destinationRaw ?? `${source.address}-source`;102102 await prepareDestination(destination, overwrite);
R cli/commands/download_test.ts → cli/commands/cp_test.ts +2 -2
M cli/main.ts +5 -4
A worker/migrations/0001_auth_version.sql +2 -0
M worker/package.json +3 -1
M worker/schema.sql +1 -0
M worker/setup.md +9 -1
M worker/src/api.ts +1 -0
M worker/src/db.ts +3 -1
M worker/src/password.ts +30 -9
@@ -1,10 +1,12 @@
11 // Password hashing + unlock-cookie signing for protected shares.22 3-import type { Env } from "./env.ts";4-53 const PBKDF2_ITERATIONS = 100_000;64 const COOKIE_TTL_S = 7 * 24 * 3600;75 6+interface UnlockSecrets {7+ NZIP_TOKEN: string;8+}9+810 const hex = (bytes: ArrayBuffer | Uint8Array) =>911 [...new Uint8Array(bytes as ArrayBuffer)].map((b) => b.toString(16).padStart(2, "0")).join("");1012
@@ -48,7 +50,7 @@ export async function verifyPassword(password: string, stored: string): Promise<
4850 return diff === 0;4951 }5052 51-async function hmacKey(env: Env): Promise<CryptoKey> {53+async function hmacKey(env: UnlockSecrets): Promise<CryptoKey> {5254 return await crypto.subtle.importKey(5355 "raw",5456 new TextEncoder().encode(`nzip-unlock:${env.NZIP_TOKEN}`),
@@ -63,28 +65,47 @@ function cookieName(address: string): string {
6365 }6466 6567 /** Set-Cookie value granting access to one site for COOKIE_TTL_S. */66-export async function makeUnlockCookie(env: Env, address: string): Promise<string> {68+export async function makeUnlockCookie(69+ env: UnlockSecrets,70+ address: string,71+ authVersion: number,72+): Promise<string> {6773 const exp = Math.floor(Date.now() / 1000) + COOKIE_TTL_S;6874 const sig = hex(6975 await crypto.subtle.sign(7076 "HMAC",7177 await hmacKey(env),72- new TextEncoder().encode(`${address}.${exp}`),78+ new TextEncoder().encode(`${address}.${authVersion}.${exp}`),7379 ),7480 );7581 // Path without trailing slash so the cookie also matches the bare /{address} URL.76- return `${cookieName(address)}=${exp}.${sig}; Max-Age=${COOKIE_TTL_S}; Path=/${address}; HttpOnly; Secure; SameSite=Lax`;82+ return `${cookieName(address)}=${authVersion}.${exp}.${sig}; Max-Age=${COOKIE_TTL_S}; Path=/${address}; HttpOnly; Secure; SameSite=Lax`;7783 }7884 7985 export async function hasValidUnlockCookie(8086 req: Request,81- env: Env,87+ env: UnlockSecrets,8288 address: string,89+ authVersion: number,8390 ): Promise<boolean> {8491 const cookies = req.headers.get("cookie") ?? "";8592 const match = cookies.match(new RegExp(`(?:^|;\\s*)${cookieName(address)}=([^;]+)`));8693 if (!match) return false;87- const [expStr, sig] = match[1].split(".");94+ const parts = match[1].split(".");95+ let expStr: string;96+ let sig: string;97+ let payload: string;98+ if (parts.length === 2 && authVersion === 1) {99+ // Cookies issued before auth_version existed remain valid until the site's100+ // password policy first changes and increments the version.101+ [expStr, sig] = parts;102+ payload = `${address}.${expStr}`;103+ } else if (parts.length === 3 && parts[0] === String(authVersion)) {104+ [, expStr, sig] = parts;105+ payload = `${address}.${authVersion}.${expStr}`;106+ } else {107+ return false;108+ }88109 const exp = parseInt(expStr, 10);89110 if (!Number.isFinite(exp) || exp < Math.floor(Date.now() / 1000)) return false;90111 try {
@@ -92,7 +113,7 @@ export async function hasValidUnlockCookie(
92113 "HMAC",93114 await hmacKey(env),94115 hexToBytes(sig) as BufferSource,95- new TextEncoder().encode(`${address}.${exp}`),116+ new TextEncoder().encode(payload),96117 );97118 } catch {98119 return false;
M worker/src/serve.ts +5 -2
A worker/test/password.test.mjs +46 -0
@@ -0,0 +1,46 @@
1+import assert from "node:assert/strict";2+import test from "node:test";3+4+import { hasValidUnlockCookie, makeUnlockCookie } from "../src/password.ts";5+6+function requestWith(cookie) {7+ return new Request("https://n.zip/12d8", { headers: { cookie: cookie.split(";", 1)[0] } });8+}9+10+const hex = (bytes) =>11+ [...new Uint8Array(bytes)].map((byte) => byte.toString(16).padStart(2, "0")).join("");12+13+async function legacyCookie(env, address) {14+ const exp = Math.floor(Date.now() / 1000) + 3600;15+ const key = await crypto.subtle.importKey(16+ "raw",17+ new TextEncoder().encode(`nzip-unlock:${env.NZIP_TOKEN}`),18+ { name: "HMAC", hash: "SHA-256" },19+ false,20+ ["sign"],21+ );22+ const signature = await crypto.subtle.sign(23+ "HMAC",24+ key,25+ new TextEncoder().encode(`${address}.${exp}`),26+ );27+ return `nzip_a${address}=${exp}.${hex(signature)}`;28+}29+30+test("unlock cookies are scoped to the current auth version", async () => {31+ const env = { NZIP_TOKEN: "test-owner-token" };32+ const cookie = await makeUnlockCookie(env, "12d8", 3);33+ const req = requestWith(cookie);34+35+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 3), true);36+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 4), false);37+ assert.equal(await hasValidUnlockCookie(req, env, "ffff", 3), false);38+});39+40+test("legacy unlock cookies survive migration until the auth version changes", async () => {41+ const env = { NZIP_TOKEN: "test-owner-token" };42+ const req = requestWith(await legacyCookie(env, "12d8"));43+44+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 1), true);45+ assert.equal(await hasValidUnlockCookie(req, env, "12d8", 2), false);46+});
M worker/wrangler.jsonc +4 -4
D worker/wrangler.local.example.jsonc +0 -33