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', {
|
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
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 { 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
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 { 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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user