From af3b28e7f4e630baf0d291499110f82435aeec33 Mon Sep 17 00:00:00 2001 From: Thomas Date: Fri, 21 Aug 2026 15:42:52 +0200 Subject: [PATCH] feat(pastes): require proof of the key to spend a read --- SECURITY.md | 13 +- apps/app/app/pages/index.vue | 6 +- apps/app/app/pages/p/[id].vue | 16 +- apps/app/app/utils/crypto.ts | 46 +- .../app/server/api/pastes/[id]/reveal.post.ts | 23 +- apps/app/server/api/pastes/index.post.ts | 4 + .../migrations/0002_stormy_blue_marvel.sql | 1 + .../migrations/meta/0002_snapshot.json | 1234 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + apps/app/server/database/schema/paste.ts | 5 + apps/app/tests/crypto.test.ts | 34 + .../1.getting-started/1.introduction.md | 9 +- .../docs/content/2.self-hosting/3.security.md | 2 - .../3.using-shhh/1.creating-a-paste.md | 7 +- 14 files changed, 1374 insertions(+), 33 deletions(-) create mode 100644 apps/app/server/database/migrations/0002_stormy_blue_marvel.sql create mode 100644 apps/app/server/database/migrations/meta/0002_snapshot.json diff --git a/SECURITY.md b/SECURITY.md index ac9c729..50c47bd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,9 +13,13 @@ server never receives the password and only records that one is required. Filenames are encrypted alongside file contents. MIME types are stored in clear. -**Consequence to be aware of:** a wrong password still consumes a read. The server cannot verify a -password it never sees, so the failure only surfaces client-side when GCM authentication fails. This -is inherent to the design, not a bug, and the UI warns before you reveal. +Spending a read takes proof. The browser sends the server a SHA-256 of the AES key it derived, which +the server compares against the value stored at creation — inside the same statement that increments +the counter, so a failed match moves nothing. Holding a paste id is therefore not enough to destroy +it, and a mistyped password costs nothing. + +Handing over that hash gives up no secrecy: SHA-256 of 256 uniformly random bits is not reversible, +and anyone holding the ciphertext could already test passwords offline at the same Argon2id cost. ## The one exception: email sharing @@ -49,7 +53,8 @@ Independent layers, all active: - Size validation server-side, never trusting client-declared sizes. - Automatic banning of IPs requesting known probe paths (`wp-admin`, `.git`, …) or identified as untrusted bots, with an admin-managed allowlist and blocklist. -- Atomic decrement of the read counter, so concurrent requests cannot over-read a one-read link. +- Atomic decrement of the read counter, so concurrent requests cannot over-read a one-read link, and + a caller who cannot prove possession of the key never moves it at all. - A Content-Security-Policy restricting outbound requests to the instance itself and Turnstile. It is the layer that matters most here: an XSS in a zero-knowledge app does not leak a session, it leaks the decryption key of every paste opened while it runs. The policy still allows inline diff --git a/apps/app/app/pages/index.vue b/apps/app/app/pages/index.vue index de2b0e1..285039e 100644 --- a/apps/app/app/pages/index.vue +++ b/apps/app/app/pages/index.vue @@ -76,10 +76,14 @@ async function submit() { submitting.value = true try { const fragmentKeyBytes = generateFragmentKey() - const aesKey = await deriveAesKey(fragmentKeyBytes, passwordProtected.value ? password.value : undefined) + const keyMaterial = await deriveKeyMaterial(fragmentKeyBytes, passwordProtected.value ? password.value : undefined) + const aesKey = await importAesKey(keyMaterial) const payload: Record = { passwordProtected: passwordProtected.value, + // Lets the server refuse a reveal from someone who has the id but not the key or the password, + // so neither can spend a read. It learns nothing from the hash itself. + unlockHash: bytesToBase64(await deriveUnlockHash(keyMaterial)), turnstileToken: turnstileToken.value, maxReads: unlimitedReads.value ? null : (maxReadsInput.value ?? undefined), expiresInDays: expiresInDaysInput.value ?? undefined diff --git a/apps/app/app/pages/p/[id].vue b/apps/app/app/pages/p/[id].vue index 5cbfd93..61eb858 100644 --- a/apps/app/app/pages/p/[id].vue +++ b/apps/app/app/pages/p/[id].vue @@ -60,8 +60,17 @@ async function reveal() { revealError.value = '' revealing.value = true try { - const data = await $fetch(`/api/pastes/${pasteId}/reveal`, { method: 'POST' }) - const aesKey = await deriveAesKey(fragmentKey.value!, data.passwordProtected ? password.value : undefined) + // Derived before the request, not after: the server checks the resulting hash inside the same + // statement that increments the counter, so a wrong password is refused without spending a read. + // Argon2id runs here, which is why revealing a protected paste pauses for a moment. + const keyMaterial = await deriveKeyMaterial(fragmentKey.value!, meta.value?.passwordProtected ? password.value : undefined) + const unlockHash = bytesToBase64(await deriveUnlockHash(keyMaterial)) + + const data = await $fetch(`/api/pastes/${pasteId}/reveal`, { + method: 'POST', + body: { unlockHash } + }) + const aesKey = await importAesKey(keyMaterial) if (data.kind === 'text') { const plaintext = await decryptBytes(aesKey, base64ToBytes(data.ciphertext), base64ToBytes(data.iv)) @@ -73,7 +82,8 @@ async function reveal() { decryptedFile.value = { url: URL.createObjectURL(blob), name: new TextDecoder().decode(nameBytes) } } } catch { - // Covers a failed request and a failed decrypt alike: either way the read may already be consumed server-side, and there is nothing to roll back. + // A rejected reveal and a failed decrypt look the same here on purpose: both mean the key or the + // password is wrong, and neither consumed a read — the server refused before incrementing. revealError.value = t('read.errors.decryptFailed') } finally { revealing.value = false diff --git a/apps/app/app/utils/crypto.ts b/apps/app/app/utils/crypto.ts index 9f70d91..1f27a97 100644 --- a/apps/app/app/utils/crypto.ts +++ b/apps/app/app/utils/crypto.ts @@ -39,20 +39,42 @@ export function base64UrlToBytes(base64url: string): Bytes { // Without a password the fragment key IS the AES key: it is already a random 256-bit secret, and deriving further would cost without buying anything. // With one, the fragment key doubles as the Argon2id salt — unique per paste, so two pastes sharing a password still derive different keys. +export async function deriveKeyMaterial(fragmentKeyBytes: Bytes, password?: string): Promise { + if (!password) return fragmentKeyBytes + + return argon2id({ + password, + salt: fragmentKeyBytes, + memorySize: ARGON2_MEMORY_KIB, + iterations: ARGON2_ITERATIONS, + parallelism: ARGON2_PARALLELISM, + hashLength: AES_KEY_LENGTH_BYTES, + outputType: 'binary' + }) as Promise +} + +// Kept non-extractable: the raw material is hashed before it ever becomes a CryptoKey, so nothing +// downstream needs to read it back out. +export function importAesKey(keyMaterial: Bytes): Promise { + return crypto.subtle.importKey('raw', keyMaterial, 'AES-GCM', false, ['encrypt', 'decrypt']) +} + export async function deriveAesKey(fragmentKeyBytes: Bytes, password?: string): Promise { - const keyBytes = password - ? await argon2id({ - password, - salt: fragmentKeyBytes, - memorySize: ARGON2_MEMORY_KIB, - iterations: ARGON2_ITERATIONS, - parallelism: ARGON2_PARALLELISM, - hashLength: AES_KEY_LENGTH_BYTES, - outputType: 'binary' - }) - : fragmentKeyBytes + return importAesKey(await deriveKeyMaterial(fragmentKeyBytes, password)) +} - return crypto.subtle.importKey('raw', keyBytes as Bytes, 'AES-GCM', false, ['encrypt', 'decrypt']) +/** + * The proof a reader hands the server to spend a read, computed from the AES key it never sees. + * + * It answers both "do you hold the link" and "do you know the password" at once, because without a + * password the AES key is the fragment key and with one it is the Argon2id derivation. A wrong + * password therefore fails here, server-side, instead of consuming a read and failing at decryption. + * + * Handing this over costs no secrecy: SHA-256 of 256 uniformly random bits is not reversible, and + * anyone holding the ciphertext could already test passwords offline at the same Argon2id cost. + */ +export async function deriveUnlockHash(keyMaterial: Bytes): Promise { + return new Uint8Array(await crypto.subtle.digest('SHA-256', keyMaterial)) } export async function encryptBytes(key: CryptoKey, plaintext: Bytes): Promise<{ ciphertext: Bytes, iv: Bytes }> { diff --git a/apps/app/server/api/pastes/[id]/reveal.post.ts b/apps/app/server/api/pastes/[id]/reveal.post.ts index b0845ea..e0751fd 100644 --- a/apps/app/server/api/pastes/[id]/reveal.post.ts +++ b/apps/app/server/api/pastes/[id]/reveal.post.ts @@ -1,10 +1,26 @@ +import { z } from 'zod' + +const revealSchema = z.object({ + // sha256 of the AES key the reader derived. Proves possession of the link and, when the paste is + // password protected, of the password — see `deriveUnlockHash` in app/utils/crypto.ts. + unlockHash: z.string().base64().optional() +}) + export default defineEventHandler(async (event) => { const parsedId = pasteIdParamSchema.safeParse(getRouterParam(event, 'id')) if (!parsedId.success) { throw createError({ statusCode: 400, statusMessage: 'Invalid paste id' }) } - // Atomic: the WHERE re-checks expiry and exhaustion, so two concurrent reveals can't both win. + const body = await readValidatedBody(event, revealSchema.parse) + const unlockHash = body.unlockHash ? Buffer.from(body.unlockHash, 'base64') : null + + // Atomic, and the proof is part of the match rather than a check before it: a reader without the + // key — or with the wrong password — updates no row, so the read counter never moves. Knowing a + // paste id is not enough to destroy it, and a mistyped password costs nothing. + // + // `isNull` covers pastes created before unlock hashes existed; they keep the old behaviour until + // they expire, which the retention cap bounds. const [paste] = await db .update(schema.pastes) .set({ readCount: sql`${schema.pastes.readCount} + 1`, lastReadAt: new Date() }) @@ -12,12 +28,15 @@ export default defineEventHandler(async (event) => { and( eq(schema.pastes.id, parsedId.data), gt(schema.pastes.expiresAt, new Date()), - or(isNull(schema.pastes.maxReads), lt(schema.pastes.readCount, schema.pastes.maxReads)) + or(isNull(schema.pastes.maxReads), lt(schema.pastes.readCount, schema.pastes.maxReads)), + or(isNull(schema.pastes.unlockHash), eq(schema.pastes.unlockHash, unlockHash!)) ) ) .returning() if (!paste) { + // Deliberately the same 404 whether the paste is gone, exhausted, or the proof was wrong: an + // unauthenticated caller must not be able to tell an existing paste from a missing one. throw createError({ statusCode: 404, statusMessage: 'Paste not found' }) } diff --git a/apps/app/server/api/pastes/index.post.ts b/apps/app/server/api/pastes/index.post.ts index 086b974..178939f 100644 --- a/apps/app/server/api/pastes/index.post.ts +++ b/apps/app/server/api/pastes/index.post.ts @@ -12,6 +12,9 @@ const shareSchema = z.object({ const baseFields = { passwordProtected: z.boolean().default(false), + // sha256 of the AES key. Optional so a client that predates it still works, but without one the + // paste can be spent by anyone who learns its id. + unlockHash: z.string().base64().optional(), maxReads: z.number().int().positive().nullable().optional(), expiresInDays: z.number().int().positive().optional(), turnstileToken: z.string(), @@ -150,6 +153,7 @@ export default defineEventHandler(async (event) => { ownerId: session?.user.id ?? null, kind: body.kind, passwordProtected: body.passwordProtected, + unlockHash: body.unlockHash ? Buffer.from(body.unlockHash, 'base64') : null, maxReads, expiresAt: new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) } diff --git a/apps/app/server/database/migrations/0002_stormy_blue_marvel.sql b/apps/app/server/database/migrations/0002_stormy_blue_marvel.sql new file mode 100644 index 0000000..7650f59 --- /dev/null +++ b/apps/app/server/database/migrations/0002_stormy_blue_marvel.sql @@ -0,0 +1 @@ +ALTER TABLE "pastes" ADD COLUMN "unlock_hash" "bytea"; \ No newline at end of file diff --git a/apps/app/server/database/migrations/meta/0002_snapshot.json b/apps/app/server/database/migrations/meta/0002_snapshot.json new file mode 100644 index 0000000..bdbcc09 --- /dev/null +++ b/apps/app/server/database/migrations/meta/0002_snapshot.json @@ -0,0 +1,1234 @@ +{ + "id": "9b6a94b9-4343-4adc-a978-260a5c5e167f", + "prevId": "c92c5d0a-44b9-40e8-876a-4eed57f3da7b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "role": { + "name": "role", + "type": "role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factors": { + "name": "two_factors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "two_factors_secret_idx": { + "name": "two_factors_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "two_factors_user_id_idx": { + "name": "two_factors_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factors_user_id_users_id_fk": { + "name": "two_factors_user_id_users_id_fk", + "tableFrom": "two_factors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pastes": { + "name": "pastes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "paste_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_blob": { + "name": "file_blob", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_iv": { + "name": "file_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_name_enc": { + "name": "file_name_enc", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_name_iv": { + "name": "file_name_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_mime": { + "name": "file_mime", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "password_protected": { + "name": "password_protected", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "unlock_hash": { + "name": "unlock_hash", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "max_reads": { + "name": "max_reads", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "read_count": { + "name": "read_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pastes_owner_id_idx": { + "name": "pastes_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pastes_expires_at_idx": { + "name": "pastes_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pastes_owner_id_users_id_fk": { + "name": "pastes_owner_id_users_id_fk", + "tableFrom": "pastes", + "tableTo": "users", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pastes_kind_payload_check": { + "name": "pastes_kind_payload_check", + "value": "(\"pastes\".\"kind\" = 'text' AND \"pastes\".\"ciphertext\" IS NOT NULL AND \"pastes\".\"file_blob\" IS NULL)\n OR (\"pastes\".\"kind\" = 'file' AND \"pastes\".\"file_blob\" IS NOT NULL AND \"pastes\".\"ciphertext\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.paste_email_recipients": { + "name": "paste_email_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "paste_id": { + "name": "paste_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "email_recipient_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "paste_email_recipients_paste_id_idx": { + "name": "paste_email_recipients_paste_id_idx", + "columns": [ + { + "expression": "paste_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paste_email_recipients_paste_id_pastes_id_fk": { + "name": "paste_email_recipients_paste_id_pastes_id_fk", + "tableFrom": "paste_email_recipients", + "tableTo": "pastes", + "columnsFrom": [ + "paste_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "active_pastes_count": { + "name": "active_pastes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active_bytes_stored": { + "name": "active_bytes_stored", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_users_id_fk": { + "name": "user_stats_user_id_users_id_fk", + "tableFrom": "user_stats", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_stats": { + "name": "app_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "active_pastes_count_anonymous": { + "name": "active_pastes_count_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active_pastes_count_authenticated": { + "name": "active_pastes_count_authenticated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active_bytes_stored": { + "name": "active_bytes_stored", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_stats_singleton_check": { + "name": "app_stats_singleton_check", + "value": "\"app_stats\".\"id\" = 1" + } + }, + "isRLSEnabled": false + }, + "public.app_settings": { + "name": "app_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_settings_updated_by_users_id_fk": { + "name": "app_settings_updated_by_users_id_fk", + "tableFrom": "app_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "invitations_pending_email_idx": { + "name": "invitations_pending_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitations\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_invited_by_users_id_fk": { + "name": "invitations_invited_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_audit_log_actor_id_users_id_fk": { + "name": "admin_audit_log_actor_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.banned_ips": { + "name": "banned_ips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "banned_at": { + "name": "banned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "banned_ips_ip_unique": { + "name": "banned_ips_ip_unique", + "nullsNotDistinct": false, + "columns": [ + "ip" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.allowed_ips": { + "name": "allowed_ips", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "allowed_ips_added_by_users_id_fk": { + "name": "allowed_ips_added_by_users_id_fk", + "tableFrom": "allowed_ips", + "tableTo": "users", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "allowed_ips_ip_unique": { + "name": "allowed_ips_ip_unique", + "nullsNotDistinct": false, + "columns": [ + "ip" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.role": { + "name": "role", + "schema": "public", + "values": [ + "user", + "admin", + "super_admin" + ] + }, + "public.paste_kind": { + "name": "paste_kind", + "schema": "public", + "values": [ + "text", + "file" + ] + }, + "public.email_recipient_status": { + "name": "email_recipient_status", + "schema": "public", + "values": [ + "sent", + "failed" + ] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "expired", + "revoked" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/app/server/database/migrations/meta/_journal.json b/apps/app/server/database/migrations/meta/_journal.json index 9437815..e3ce588 100644 --- a/apps/app/server/database/migrations/meta/_journal.json +++ b/apps/app/server/database/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1783524507908, "tag": "0001_fair_gressill", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1787318867404, + "tag": "0002_stormy_blue_marvel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/app/server/database/schema/paste.ts b/apps/app/server/database/schema/paste.ts index 16ba427..a4a8334 100644 --- a/apps/app/server/database/schema/paste.ts +++ b/apps/app/server/database/schema/paste.ts @@ -43,6 +43,11 @@ export const pastes = pgTable( fileSize: integer('file_size'), passwordProtected: boolean('password_protected').notNull().default(false), + + // sha256 of the AES key, which the reader recomputes to spend a read. Nullable only because + // pastes created before this existed have none; those keep the old behaviour until they expire, + // which the retention cap bounds. See `deriveUnlockHash` in app/utils/crypto.ts. + unlockHash: bytea('unlock_hash'), maxReads: integer('max_reads'), readCount: integer('read_count').notNull().default(0), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), diff --git a/apps/app/tests/crypto.test.ts b/apps/app/tests/crypto.test.ts index ca2f416..4f3cf78 100644 --- a/apps/app/tests/crypto.test.ts +++ b/apps/app/tests/crypto.test.ts @@ -6,6 +6,8 @@ import { bytesToBase64Url, decryptBytes, deriveAesKey, + deriveKeyMaterial, + deriveUnlockHash, encryptBytes, generateFragmentKey } from '../app/utils/crypto' @@ -144,3 +146,35 @@ describe('realistic payloads', () => { expect(decoder.decode(await decryptBytes(key, name.ciphertext, name.iv))).toBe('secret.txt') }) }) + +describe('deriveUnlockHash', () => { + it('is 32 bytes', async () => { + expect((await deriveUnlockHash(generateFragmentKey())).byteLength).toBe(32) + }) + + it('is stable for the same key, so a reader can recompute it', async () => { + const key = generateFragmentKey() + expect(await deriveUnlockHash(key)).toEqual(await deriveUnlockHash(key)) + }) + + it('differs for two different keys', async () => { + expect(await deriveUnlockHash(generateFragmentKey())) + .not.toEqual(await deriveUnlockHash(generateFragmentKey())) + }) + + // The reason a mistyped password no longer costs a read: different material, different hash, and + // the server refuses the reveal before its counter moves. + it('differs when the password differs, for the same fragment key', async () => { + const fragment = generateFragmentKey() + const right = await deriveUnlockHash(await deriveKeyMaterial(fragment, 'correct horse')) + const wrong = await deriveUnlockHash(await deriveKeyMaterial(fragment, 'correct hors')) + expect(right).not.toEqual(wrong) + }) + + // Without a password the fragment key is the AES key, so holding the link is the whole proof. + it('is the hash of the fragment key itself when no password is set', async () => { + const fragment = generateFragmentKey() + expect(await deriveUnlockHash(await deriveKeyMaterial(fragment))) + .toEqual(await deriveUnlockHash(fragment)) + }) +}) diff --git a/apps/docs/content/1.getting-started/1.introduction.md b/apps/docs/content/1.getting-started/1.introduction.md index d75415b..1e4a3ed 100644 --- a/apps/docs/content/1.getting-started/1.introduction.md +++ b/apps/docs/content/1.getting-started/1.introduction.md @@ -38,11 +38,10 @@ You can add a password on top. It derives a second key with **Argon2id** (OWASP parameters), salted with the fragment key itself. Both the password *and* the link are needed to decrypt — the server never sees the password and only records that one is required. -::warning -Because the server never sees the password, it cannot check it before handing over the ciphertext. -**A wrong password still consumes a read.** The failure is only detected in your browser, when GCM -authentication fails. This is inherent to zero-knowledge, not a bug — the interface warns you before -you reveal. +::note +A wrong password costs nothing. Your browser proves it derived the right key by sending a SHA-256 of +it, which the server checks in the same statement that spends the read — so a mistyped password is +refused before the counter moves. The server still never sees the password itself. :: ## The two tiers diff --git a/apps/docs/content/2.self-hosting/3.security.md b/apps/docs/content/2.self-hosting/3.security.md index 898f551..3cf3396 100644 --- a/apps/docs/content/2.self-hosting/3.security.md +++ b/apps/docs/content/2.self-hosting/3.security.md @@ -114,8 +114,6 @@ policy. - **Anyone with the full link can read the paste.** That is the point, and it means the link should be treated as the secret itself. -- **A wrong password still consumes a read**, because the server cannot verify a password it never - sees. Inherent to the design. - **No protection against a malicious operator.** A modified server could serve altered JavaScript that exfiltrates keys. Self-host the instance you trust. diff --git a/apps/docs/content/3.using-shhh/1.creating-a-paste.md b/apps/docs/content/3.using-shhh/1.creating-a-paste.md index 93f6712..b7b33b0 100644 --- a/apps/docs/content/3.using-shhh/1.creating-a-paste.md +++ b/apps/docs/content/3.using-shhh/1.creating-a-paste.md @@ -20,10 +20,9 @@ one-read link cannot be read twice even under concurrent requests. **Password protection** — adds a second key layer derived from your password with Argon2id. The recipient needs both the link and the password. -::warning -A wrong password still consumes a read. The server never sees your password and cannot check it -before handing over the ciphertext — the failure only surfaces in the recipient's browser. Warn -whoever you send it to. +::note +Getting the password wrong costs nothing: the browser proves it derived the right key before the +server spends a read, so the recipient can try again. The server still never sees the password. :: ## Files -- 2.54.0