Add a hash-based password bypass link
All checks were successful
Docker / build-and-push (push) Successful in 3m8s

The upload password gate can now be skipped with a bookmarkable
#psst=<sha256-hash> 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.
This commit is contained in:
explewd
2026-07-09 19:48:56 +02:00
parent 22619ebd11
commit 121dbe81f1
5 changed files with 91 additions and 16 deletions

View File

@@ -1,7 +1,15 @@
export async function checkPassword(password: string): Promise<boolean> { export type Credential = { kind: 'password' | 'hash'; value: string }
function credentialHeaders(cred: Credential): Record<string, string> {
return cred.kind === 'password'
? { 'X-Upload-Password': cred.value }
: { 'X-Upload-Password-Hash': cred.value }
}
export async function checkCredential(cred: Credential): Promise<boolean> {
const res = await fetch('/api/auth/check', { const res = await fetch('/api/auth/check', {
method: 'POST', method: 'POST',
headers: { 'X-Upload-Password': password }, headers: credentialHeaders(cred),
}) })
const body = (await res.json()) as { ok: boolean } const body = (await res.json()) as { ok: boolean }
return body.ok return body.ok
@@ -9,13 +17,13 @@ export async function checkPassword(password: string): Promise<boolean> {
export async function uploadDrop( export async function uploadDrop(
ciphertext: Uint8Array, ciphertext: Uint8Array,
password: string, cred: Credential,
): Promise<{ id: string; ttl: number }> { ): Promise<{ id: string; ttl: number }> {
const res = await fetch('/api/drops', { const res = await fetch('/api/drops', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
'X-Upload-Password': password, ...credentialHeaders(cred),
}, },
body: ciphertext.slice().buffer, body: ciphertext.slice().buffer,
}) })

6
client/src/hash.ts Normal file
View File

@@ -0,0 +1,6 @@
export async function sha256Hex(input: string): Promise<string> {
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('')
}

View File

@@ -1,6 +1,7 @@
import { encryptFile } from './crypto' import { encryptFile } from './crypto'
import { checkPassword, uploadDrop } from './api' import { checkCredential, uploadDrop, type Credential } from './api'
import { renderHelpButton } from './help' import { renderHelpButton } from './help'
import { sha256Hex } from './hash'
function renderHeader(root: HTMLElement): void { function renderHeader(root: HTMLElement): void {
const header = document.createElement('div') const header = document.createElement('div')
@@ -10,7 +11,24 @@ function renderHeader(root: HTMLElement): void {
root.appendChild(header) root.appendChild(header)
} }
export function renderUploadPage(root: HTMLElement): void { // A bookmarkable "magic link" that skips the password screen: #psst=<hash>.
// 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<boolean> {
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<void> {
if (await tryFragmentUnlock(root)) return
root.innerHTML = '' root.innerHTML = ''
renderHeader(root) renderHeader(root)
@@ -32,16 +50,19 @@ export function renderUploadPage(root: HTMLElement): void {
const errorEl = card.querySelector<HTMLElement>('#password-error')! const errorEl = card.querySelector<HTMLElement>('#password-error')!
errorEl.innerHTML = '' errorEl.innerHTML = ''
const ok = await checkPassword(password) const cred: Credential = { kind: 'password', value: password }
const ok = await checkCredential(cred)
if (!ok) { if (!ok) {
errorEl.innerHTML = `<div class="error-banner">Wrong password.</div>` errorEl.innerHTML = `<div class="error-banner">Wrong password.</div>`
return 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 = '' root.innerHTML = ''
renderHeader(root) renderHeader(root)
@@ -60,6 +81,8 @@ function renderUploadForm(root: HTMLElement, password: string): void {
` `
root.appendChild(card) root.appendChild(card)
if (typedPassword) renderBypassLink(card, typedPassword)
const fileInput = card.querySelector<HTMLInputElement>('#file')! const fileInput = card.querySelector<HTMLInputElement>('#file')!
const fileLabel = card.querySelector<HTMLElement>('#file-label')! const fileLabel = card.querySelector<HTMLElement>('#file-label')!
fileInput.addEventListener('change', () => { fileInput.addEventListener('change', () => {
@@ -79,7 +102,7 @@ function renderUploadForm(root: HTMLElement, password: string): void {
try { try {
const { ciphertext, keyB64 } = await encryptFile(file) const { ciphertext, keyB64 } = await encryptFile(file)
status.innerHTML = `<div class="status-row"><span class="spinner"></span>uploading…</div>` status.innerHTML = `<div class="status-row"><span class="spinner"></span>uploading…</div>`
const { id } = await uploadDrop(ciphertext, password) const { id } = await uploadDrop(ciphertext, cred)
const link = `${location.origin}/d/${id}#k=${keyB64}` const link = `${location.origin}/d/${id}#k=${keyB64}`
status.innerHTML = '' 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 = `<button type="button" id="gen-bypass" class="btn">bookmark a skip-the-password link</button>`
card.prepend(box)
box.querySelector<HTMLButtonElement>('#gen-bypass')!.addEventListener('click', async () => {
const hash = await sha256Hex(password)
const link = `${location.origin}${location.pathname}#psst=${hash}`
box.innerHTML = `
<p class="hint">bookmark this — it never contains your actual password</p>
<div class="link-box">${link}</div>
`
})
}

24
server/src/auth.ts Normal file
View File

@@ -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
}

View File

@@ -3,21 +3,18 @@ import fs from 'node:fs'
import { config } from './config.js' import { config } from './config.js'
import { db, blobPath } from './db.js' import { db, blobPath } from './db.js'
import { newBlobId, newConfirmToken } from './ids.js' import { newBlobId, newConfirmToken } from './ids.js'
import { checkUploadCredential } from './auth.js'
export const router = Router() 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 // Lets the client gate the upload UI behind a password without uploading
// anything yet. // anything yet.
router.post('/auth/check', (req, res) => { router.post('/auth/check', (req, res) => {
res.json({ ok: checkUploadPassword(req) }) res.json({ ok: checkUploadCredential(req) })
}) })
router.post('/drops', (req, res) => { router.post('/drops', (req, res) => {
if (!checkUploadPassword(req)) { if (!checkUploadCredential(req)) {
res.status(401).json({ error: 'bad password' }) res.status(401).json({ error: 'bad password' })
return return
} }