diff --git a/README.md b/README.md index 9c701b5..86a1ca8 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Example metadata record (`storage/metadata/.json`): ```json { - "id": "YOWJyhDXxhe3-L8MpEFBbA", + "id": "nkj2p0ulkijp", "originalName": "example.zip", "size": 123456, "mimeType": "application/zip", @@ -137,9 +137,9 @@ curl -F "password=hunter2" -F "file=@secret.zip" http://localhost:3000/api/uploa } ``` -`id` and `key` are the same 12-character base64url token (~72 bits of entropy); -the short length keeps share URLs tidy, and an optional password can add a -second factor of protection. +`id` and `key` are the same 12-character lowercase alphanumeric token (~62 bits +of entropy); the short length keeps share URLs tidy, and an optional password +can add a second factor of protection. ### `GET /:key` (also `HEAD`) @@ -177,7 +177,7 @@ Permanently deletes the file and its metadata. The correct download key must be sent in the `X-Delete-Key` header — proving you hold the secret. ```bash -curl -X DELETE -H "X-Delete-Key: YOWJyh...7OQ" http://localhost:3000/api/files/YOWJyhDXxhe3-L8MpEFBbA +curl -X DELETE -H "X-Delete-Key: nkj2p0ulkijp" http://localhost:3000/api/files/nkj2p0ulkijp ``` ### `GET /api/health` @@ -194,18 +194,18 @@ paths or stack traces. ## Security model -- **Secrets.** Each file gets a fresh 72-bit (9 random bytes) base64url key via - `crypto.randomBytes`, used as both the share id and the download key. Keys are - encoded base64url; guessing one is infeasible at scale, and an optional - password can be an added second factor. No timestamps, incrementing ids, - `Math.random()`, or filenames are ever used as secrets. +- **Secrets.** Each file gets a fresh 12-character lowercase alphanumeric key + (`crypto.randomBytes` mapped onto `a-z0-9`, ~62 bits of entropy), used as both + the share id and the download key. Guessing one is infeasible at scale, and an + optional password can be an added second factor. No timestamps, incrementing + ids, `Math.random()`, or filenames are ever used as secrets. - **At-rest.** Only a SHA-256 hash of the key is persisted. Comparisons use constant-time `timingSafeEqual`. - **Passwords.** A file's password (if any) is stored only as an **scrypt** hash (salted), never in plaintext. Successful entry mints a short-lived HMAC-signed cookie, so access is granted by the server and cannot be forged. - **Path safety.** Filesystem paths are built only from server-generated, - regex-validated ids (`^[A-Za-z0-9_-]{22}$`). The original filename is metadata + regex-validated ids (`^[a-z0-9]{12}$`). The original filename is metadata only and is sanitized (basename, control/null bytes stripped) before being stored or emitted in `Content-Disposition`. Path traversal, `../`, encoded traversal, null bytes, and symlink injection via user input are not possible. diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..2c500ea --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/auth.ts b/src/auth.ts index 05268e3..b3a9987 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -60,6 +60,7 @@ export function renderPasswordForm( width: 100%; max-width: 380px; background: #171a21; border: 1px solid #2a2f3a; border-radius: 14px; padding: 26px; box-shadow: 0 10px 40px rgba(0,0,0,.35); } + .brand-logo { display: block; width: 64px; height: 64px; margin: 0 auto 12px; } h1 { margin: 0 0 4px; font-size: 19px; } p { margin: 6px 0 16px; color: #9aa3b2; font-size: 14px; word-break: break-all; } .err { margin: 0 0 14px; color: #e05252; background: rgba(224,82,82,.08); @@ -75,6 +76,19 @@ export function renderPasswordForm(
+

Protected file

Enter the password to download ${escapeHtml(filename)}.

${message} diff --git a/src/public/index.html b/src/public/index.html index 577c8c7..69c59ad 100644 --- a/src/public/index.html +++ b/src/public/index.html @@ -4,7 +4,7 @@ - + File Drop @@ -12,7 +12,7 @@
- +

File Drop

Private file hosting diff --git a/src/public/style.css b/src/public/style.css index bb4b3d5..6f86879 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -59,13 +59,7 @@ button { .logo { width: 40px; height: 40px; - border-radius: var(--radius); - background: linear-gradient(135deg, var(--accent), var(--accent-strong)); - color: #fff; - font-weight: 700; - display: grid; - place-items: center; - letter-spacing: 0.5px; + display: block; } .brand-text h1 { diff --git a/src/security.ts b/src/security.ts index d36dbdb..58a0e01 100644 --- a/src/security.ts +++ b/src/security.ts @@ -1,18 +1,27 @@ import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; -export const KEY_BYTES = 9; // 9 bytes base64url -> 12 chars export const KEY_LENGTH = 12; -export const KEY_PATTERN = /^[A-Za-z0-9_-]{12}$/; +// Keys are lowercase alphanumeric only (a-z0-9), 12 characters. +export const KEY_PATTERN = /^[a-z0-9]{12}$/; // The id and the download key are the same 12-char token. export const ID_PATTERN = KEY_PATTERN; -export function randomToken(byteCount: number): string { - return randomBytes(byteCount).toString("base64url"); -} +const KEY_CHARSET = "abcdefghijklmnopqrstuvwxyz0123456789"; +const CHARSET_SIZE = KEY_CHARSET.length; +const MAX_BYTE = Math.floor(256 / CHARSET_SIZE) * CHARSET_SIZE; export function newFileId(): string { - return randomToken(KEY_BYTES); + let out = ""; + const bytes = randomBytes(KEY_LENGTH * 2); + let i = 0; + while (out.length < KEY_LENGTH) { + const b = bytes[i++]; + if (b === undefined) throw new Error("random source exhausted"); + if (b >= MAX_BYTE) continue; + out += KEY_CHARSET[b % CHARSET_SIZE]; + } + return out; } export function hashSecret(value: string): string { diff --git a/src/server.ts b/src/server.ts index c452cdd..d0374fc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -42,6 +42,7 @@ const STATIC_ROUTES: Record = { "/index.html": { file: "./public/index.html", type: "text/html; charset=utf-8" }, "/style.css": { file: "./public/style.css", type: "text/css; charset=utf-8" }, "/app.js": { file: "./public/app.js", type: "text/javascript; charset=utf-8" }, + "/logo.svg": { file: "../assets/logo.svg", type: "image/svg+xml" }, }; const staticContent = new Map();