Add a hash-based password bypass link
All checks were successful
Docker / build-and-push (push) Successful in 3m8s
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:
@@ -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', {
|
||||
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<boolean> {
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
6
client/src/hash.ts
Normal file
6
client/src/hash.ts
Normal 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('')
|
||||
}
|
||||
@@ -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=<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 = ''
|
||||
renderHeader(root)
|
||||
|
||||
@@ -32,16 +50,19 @@ export function renderUploadPage(root: HTMLElement): void {
|
||||
const errorEl = card.querySelector<HTMLElement>('#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 = `<div class="error-banner">Wrong password.</div>`
|
||||
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<HTMLInputElement>('#file')!
|
||||
const fileLabel = card.querySelector<HTMLElement>('#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 = `<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}`
|
||||
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
24
server/src/auth.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user