From 121dbe81f1b98b6d92f0cf5c511dfc2939f443ed Mon Sep 17 00:00:00 2001 From: explewd Date: Thu, 9 Jul 2026 19:48:56 +0200 Subject: [PATCH] Add a hash-based password bypass link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upload password gate can now be skipped with a bookmarkable #psst= fragment instead of typing the password each time. The server accepts either the plaintext password (X-Upload-Password) or its SHA-256 hash (X-Upload-Password-Hash), compared timing-safely. The client computes the hash locally after a normal password entry, so the bypass link never contains the real password — only a one-way digest of it, kept out of the URL query string (and thus server logs) by living in the fragment, same as the decryption key. --- client/src/api.ts | 16 ++++++++++---- client/src/hash.ts | 6 +++++ client/src/upload.ts | 52 +++++++++++++++++++++++++++++++++++++++----- server/src/auth.ts | 24 ++++++++++++++++++++ server/src/routes.ts | 9 +++----- 5 files changed, 91 insertions(+), 16 deletions(-) create mode 100644 client/src/hash.ts create mode 100644 server/src/auth.ts diff --git a/client/src/api.ts b/client/src/api.ts index 5493fe1..62809ee 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -1,7 +1,15 @@ -export async function checkPassword(password: string): Promise { +export type Credential = { kind: 'password' | 'hash'; value: string } + +function credentialHeaders(cred: Credential): Record { + return cred.kind === 'password' + ? { 'X-Upload-Password': cred.value } + : { 'X-Upload-Password-Hash': cred.value } +} + +export async function checkCredential(cred: Credential): Promise { const res = await fetch('/api/auth/check', { method: 'POST', - headers: { 'X-Upload-Password': password }, + headers: credentialHeaders(cred), }) const body = (await res.json()) as { ok: boolean } return body.ok @@ -9,13 +17,13 @@ export async function checkPassword(password: string): Promise { export async function uploadDrop( ciphertext: Uint8Array, - password: string, + cred: Credential, ): Promise<{ id: string; ttl: number }> { const res = await fetch('/api/drops', { method: 'POST', headers: { 'Content-Type': 'application/octet-stream', - 'X-Upload-Password': password, + ...credentialHeaders(cred), }, body: ciphertext.slice().buffer, }) diff --git a/client/src/hash.ts b/client/src/hash.ts new file mode 100644 index 0000000..d821a1b --- /dev/null +++ b/client/src/hash.ts @@ -0,0 +1,6 @@ +export async function sha256Hex(input: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input)) + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} diff --git a/client/src/upload.ts b/client/src/upload.ts index e45a4ef..6f06ef4 100644 --- a/client/src/upload.ts +++ b/client/src/upload.ts @@ -1,6 +1,7 @@ import { encryptFile } from './crypto' -import { checkPassword, uploadDrop } from './api' +import { checkCredential, uploadDrop, type Credential } from './api' import { renderHelpButton } from './help' +import { sha256Hex } from './hash' function renderHeader(root: HTMLElement): void { const header = document.createElement('div') @@ -10,7 +11,24 @@ function renderHeader(root: HTMLElement): void { root.appendChild(header) } -export function renderUploadPage(root: HTMLElement): void { +// A bookmarkable "magic link" that skips the password screen: #psst=. +// It carries a SHA-256 hash of the password, not the password itself — so a +// leaked bookmark only compromises this bypass, never the real password. +async function tryFragmentUnlock(root: HTMLElement): Promise { + const psst = new URLSearchParams(location.hash.slice(1)).get('psst') + if (!psst) return false + + const cred: Credential = { kind: 'hash', value: psst } + if (await checkCredential(cred)) { + renderUploadForm(root, cred) + return true + } + return false +} + +export async function renderUploadPage(root: HTMLElement): Promise { + if (await tryFragmentUnlock(root)) return + root.innerHTML = '' renderHeader(root) @@ -32,16 +50,19 @@ export function renderUploadPage(root: HTMLElement): void { const errorEl = card.querySelector('#password-error')! errorEl.innerHTML = '' - const ok = await checkPassword(password) + const cred: Credential = { kind: 'password', value: password } + const ok = await checkCredential(cred) if (!ok) { errorEl.innerHTML = `
Wrong password.
` return } - renderUploadForm(root, password) + renderUploadForm(root, cred, password) }) } -function renderUploadForm(root: HTMLElement, password: string): void { +// `typedPassword` is only present right after manual entry — it's what lets +// us offer a bypass-link, computed locally and never sent to the server. +function renderUploadForm(root: HTMLElement, cred: Credential, typedPassword?: string): void { root.innerHTML = '' renderHeader(root) @@ -60,6 +81,8 @@ function renderUploadForm(root: HTMLElement, password: string): void { ` root.appendChild(card) + if (typedPassword) renderBypassLink(card, typedPassword) + const fileInput = card.querySelector('#file')! const fileLabel = card.querySelector('#file-label')! fileInput.addEventListener('change', () => { @@ -79,7 +102,7 @@ function renderUploadForm(root: HTMLElement, password: string): void { try { const { ciphertext, keyB64 } = await encryptFile(file) status.innerHTML = `
uploading…
` - const { id } = await uploadDrop(ciphertext, password) + const { id } = await uploadDrop(ciphertext, cred) const link = `${location.origin}/d/${id}#k=${keyB64}` status.innerHTML = '' @@ -107,3 +130,20 @@ function renderUploadForm(root: HTMLElement, password: string): void { } }) } + +function renderBypassLink(card: HTMLElement, password: string): void { + const box = document.createElement('div') + box.className = 'stack' + box.style.marginBottom = '16px' + box.innerHTML = `` + card.prepend(box) + + box.querySelector('#gen-bypass')!.addEventListener('click', async () => { + const hash = await sha256Hex(password) + const link = `${location.origin}${location.pathname}#psst=${hash}` + box.innerHTML = ` +

bookmark this — it never contains your actual password

+ + ` + }) +} diff --git a/server/src/auth.ts b/server/src/auth.ts new file mode 100644 index 0000000..7c994cf --- /dev/null +++ b/server/src/auth.ts @@ -0,0 +1,24 @@ +import { createHash, timingSafeEqual } from 'node:crypto' +import type { Request } from 'express' +import { config } from './config.js' + +const expectedHash = createHash('sha256').update(config.uploadPassword).digest('hex') + +function timingSafeStringEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a) + const bufB = Buffer.from(b) + return bufA.length === bufB.length && timingSafeEqual(bufA, bufB) +} + +// Accepts either the plaintext password (typed-in flow) or its SHA-256 hash +// (the #psst= bypass-link flow) — the hash lets a bookmarked link work +// without the plaintext password ever appearing in the URL. +export function checkUploadCredential(req: Request): boolean { + const plain = req.get('X-Upload-Password') + if (plain !== undefined) return timingSafeStringEqual(plain, config.uploadPassword) + + const hash = req.get('X-Upload-Password-Hash') + if (hash !== undefined) return timingSafeStringEqual(hash.toLowerCase(), expectedHash) + + return false +} diff --git a/server/src/routes.ts b/server/src/routes.ts index bfb00c7..c75546e 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -3,21 +3,18 @@ import fs from 'node:fs' import { config } from './config.js' import { db, blobPath } from './db.js' import { newBlobId, newConfirmToken } from './ids.js' +import { checkUploadCredential } from './auth.js' export const router = Router() -function checkUploadPassword(req: import('express').Request): boolean { - return req.get('X-Upload-Password') === config.uploadPassword -} - // Lets the client gate the upload UI behind a password without uploading // anything yet. router.post('/auth/check', (req, res) => { - res.json({ ok: checkUploadPassword(req) }) + res.json({ ok: checkUploadCredential(req) }) }) router.post('/drops', (req, res) => { - if (!checkUploadPassword(req)) { + if (!checkUploadCredential(req)) { res.status(401).json({ error: 'bad password' }) return }