From a54d872b46a44a3a2a740bc9f8173d0847d8f870 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 7 Dec 2025 20:12:38 +0000 Subject: [PATCH] Various improvements to remote link login. PoW algorithm tuned. Added base64url and helper modules. --- frontend/src/components/PairingCodeEntry.vue | 149 ++++-- .../src/components/RemoteAuthComplete.vue | 10 +- .../src/components/RemoteAuthLinkModal.vue | 10 +- frontend/src/utils/base64url.js | 33 ++ frontend/src/utils/pow-test.js | 25 +- frontend/src/utils/pow.js | 156 ++---- paskia/fastapi/remote.py | 335 +++++++++++++ paskia/fastapi/ws.py | 466 +----------------- paskia/fastapi/wsutil.py | 89 ++++ paskia/remoteauth.py | 17 +- paskia/util/pow.py | 68 +-- paskia/util/tokens.py | 10 +- 12 files changed, 665 insertions(+), 703 deletions(-) create mode 100644 frontend/src/utils/base64url.js create mode 100644 paskia/fastapi/remote.py create mode 100644 paskia/fastapi/wsutil.py diff --git a/frontend/src/components/PairingCodeEntry.vue b/frontend/src/components/PairingCodeEntry.vue index 1ca1c21..8428a4a 100644 --- a/frontend/src/components/PairingCodeEntry.vue +++ b/frontend/src/components/PairingCodeEntry.vue @@ -75,9 +75,13 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue' import { startAuthentication } from '@simplewebauthn/browser' import aWebSocket from '@/utils/awaitable-websocket' +import { dec as b64dec, enc as b64enc } from '@/utils/base64url' import { getSettings } from '@/utils/settings' import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist' import { solvePoW } from '@/utils/pow' +import { useAuthStore } from '@/stores/auth' + +const authStore = useAuthStore() const props = defineProps({ title: { type: String, default: 'Help Another Device Sign In' }, @@ -106,9 +110,9 @@ const cursorPos = ref(0) // Track cursor position for autocomplete let ws = null let wsConnecting = false let currentChallenge = null // Current PoW challenge from server -let currentBits = null +let currentWork = null let powPromise = null // Promise for background PoW computation -let powNonce = null // Solved PoW nonce ready to use +let powSolution = null // Solved PoW solution ready to use let lookupTimeout = null let lastLookedUpCode = null // Track last code we looked up to avoid duplicates @@ -143,7 +147,7 @@ function getCurrentWord(input) { return match ? match[0] : '' } -// Get all words from input +// Get all words from input (normalizes whitespace) function getWords(input) { return input.trim().split(/[.\s]+/).filter(w => w.length > 0) } @@ -253,40 +257,40 @@ function normalizeCode(input) { function startPowSolving() { if (!currentChallenge || powPromise) return - const challenge = Uint8Array.from(atob(currentChallenge), c => c.charCodeAt(0)) - powPromise = solvePoW(challenge, currentBits).then(nonce => { - powNonce = nonce + const challenge = b64dec(currentChallenge) + powPromise = solvePoW(challenge, currentWork).then(solution => { + powSolution = solution powPromise = null }) } -// Get the solved PoW nonce, waiting if necessary -async function getPowNonce() { - if (powNonce) { - const nonce = powNonce - powNonce = null - return nonce +// Get the solved PoW solution, waiting if necessary +async function getPowSolution() { + if (powSolution) { + const solution = powSolution + powSolution = null + return solution } if (powPromise) { await powPromise - const nonce = powNonce - powNonce = null - return nonce + const solution = powSolution + powSolution = null + return solution } // Need to solve now if (!currentChallenge) { throw new Error('No PoW challenge available') } - const challenge = Uint8Array.from(atob(currentChallenge), c => c.charCodeAt(0)) - return await solvePoW(challenge, currentBits) + const challenge = b64dec(currentChallenge) + return await solvePoW(challenge, currentWork) } // Update challenge from server response and start solving function updateChallenge(pow) { if (pow?.challenge) { currentChallenge = pow.challenge - currentBits = pow.bits - powNonce = null + currentWork = pow.work + powSolution = null powPromise = null // Start solving in background immediately startPowSolving() @@ -310,10 +314,18 @@ async function ensureConnection() { // First message is PoW challenge const msg = await ws.receive_json() + if (msg.status && msg.detail) { + throw new Error(msg.detail) + } + if (!msg.pow?.challenge) { + throw new Error('Server did not send PoW challenge') + } updateChallenge(msg.pow) } catch (err) { console.error('WebSocket connection error:', err) + authStore.showMessage('Pairing service: ' + (err.message || 'Connection failed'), 'error', 4000) ws = null + throw err // Re-throw so caller knows connection failed } finally { wsConnecting = false } @@ -352,13 +364,20 @@ function applyAutocomplete() { const { word, start, end } = getWordAtCursor(code.value, cursorPos.value) if (!word) return false - // Replace the current word with the full word + space + // Count how many complete words we have before this one const before = code.value.slice(0, start) - const after = code.value.slice(end) - code.value = before + autocompleteHint.value + ' ' + after + const wordsBefore = getWords(before).length - // Move cursor to after the inserted word + space - const newPos = start + autocompleteHint.value.length + 1 + // Don't add space after the 3rd word (wordsBefore is 0-indexed count of words before current) + const isThirdWord = wordsBefore === 2 + const suffix = isThirdWord ? '' : ' ' + + // Replace the current word with the full word (+ space if not 3rd word) + const after = code.value.slice(end) + code.value = before + autocompleteHint.value + suffix + after.trimStart() + + // Move cursor to after the inserted word + const newPos = start + autocompleteHint.value.length + suffix.length nextTick(() => { inputRef.value?.setSelectionRange(newPos, newPos) cursorPos.value = newPos @@ -369,6 +388,24 @@ function applyAutocomplete() { } function handleInput() { + // Normalize: trim trailing whitespace if we have 3 complete words + const words = getWords(code.value) + if (words.length >= 3) { + // Normalize to exactly 3 words with single spaces, no trailing space + const normalized = words.slice(0, 3).join(' ') + if (code.value !== normalized) { + const cursorWasAtEnd = cursorPos.value >= code.value.length + code.value = normalized + // Keep cursor at end if it was there + if (cursorWasAtEnd) { + nextTick(() => { + inputRef.value?.setSelectionRange(normalized.length, normalized.length) + cursorPos.value = normalized.length + }) + } + } + } + // Update autocomplete on input updateAutocomplete() @@ -386,15 +423,16 @@ function handleInput() { // Check if any word is invalid (not a valid prefix or complete word) hasInvalidWord.value = !checkWordsValidity(code.value) - const words = getWords(code.value) + // Re-get words after normalization + const currentWords = getWords(code.value) // Connect to WebSocket on first meaningful input (start solving PoW early) - if (words.length >= 1 && !ws && !wsConnecting) { + if (currentWords.length >= 1 && !ws && !wsConnecting) { ensureConnection() } // Check for invalid words when we have 3 words - if (words.length === 3) { + if (currentWords.length === 3) { if (!allWordsValid(code.value)) { // Don't show error message - the red input is enough feedback return @@ -435,8 +473,8 @@ async function lookupDeviceInfo() { } // Get PoW solution (may wait for background computation) - const nonce = await getPowNonce() - const powB64 = btoa(String.fromCharCode(...nonce)) + const solution = await getPowSolution() + const powB64 = b64enc(solution) // Take the field value NOW (after PoW is solved) - user may have changed it const currentCode = normalizeCode(code.value) @@ -459,16 +497,20 @@ async function lookupDeviceInfo() { // Receive response const res = await ws.receive_json() - // Update challenge for next request + // Update challenge for next request (included in all responses) updateChallenge(res.pow) - if (res.status === 'error') { + // Check for error response (HTTP status code format) + if (typeof res.status === 'number' && res.status >= 400) { + authStore.showMessage(res.detail || 'Request failed', 'error', 4000) serverError.value = true deviceInfo.value = null lastLookedUpCode = null - // Don't select text - let user continue editing - // User needs to change the code before we retry - } else if (res.status === 'found' && res.host) { + // Keep connection open for retries (challenge was updated above) + return + } + + if (res.status === 'found' && res.host) { // Success! Set field to normalized value code.value = currentCode.replace(/\./g, ' ') deviceInfo.value = { @@ -481,12 +523,20 @@ async function lookupDeviceInfo() { submitBtnRef.value?.focus() }) } else { + authStore.showMessage('Unexpected response from server', 'error', 3000) serverError.value = true deviceInfo.value = null lastLookedUpCode = null } } catch (err) { console.error('Lookup error:', err) + // Show appropriate message for connection errors + const isDisconnect = err.message?.includes('closed') + authStore.showMessage( + isDisconnect ? 'Connection lost. Please try again.' : (err.message || 'Lookup failed'), + 'error', + 4000 + ) serverError.value = true deviceInfo.value = null lastLookedUpCode = null @@ -508,6 +558,8 @@ function handleKeydown(event) { const applied = applyAutocomplete() if (applied) { event.preventDefault() + // Trigger input handling since programmatic changes don't fire input event + handleInput() return } } @@ -523,6 +575,8 @@ function handleKeydown(event) { const applied = applyAutocomplete() if (applied) { event.preventDefault() + // Trigger input handling since programmatic changes don't fire input event + handleInput() } } } @@ -544,8 +598,8 @@ async function submitCode() { } // Get PoW solution for authenticate request - const nonce = await getPowNonce() - const powB64 = btoa(String.fromCharCode(...nonce)) + const solution = await getPowSolution() + const powB64 = b64enc(solution) // Send authenticate request with PoW ws.send_json({ @@ -556,8 +610,9 @@ async function submitCode() { // Receive authentication options const res = await ws.receive_json() - if (res.status === 'error') { - throw new Error(res.error || 'Authentication failed') + // Check for server error response + if (typeof res.status === 'number' && res.status >= 400) { + throw new Error(res.detail || 'Authentication failed') } if (!res.optionsJSON) { @@ -571,19 +626,29 @@ async function submitCode() { // Wait for confirmation const result = await ws.receive_json() + // Check for server error response + if (typeof result.status === 'number' && result.status >= 400) { + throw new Error(result.detail || 'Authentication failed') + } + if (result.status === 'success') { completed.value = true completedMessage.value = result.message || 'The other device is now logged in.' + authStore.showMessage('Device authenticated successfully!', 'success', 3000) emit('completed') } else { - throw new Error(result.detail || result.error || 'Authentication failed') + throw new Error(result.detail || 'Authentication failed') } } catch (err) { console.error('Pairing error:', err) + const isDisconnect = err.message?.includes('closed') const message = err.name === 'NotAllowedError' ? 'Passkey authentication was cancelled' - : (err.message || 'Failed to connect') + : isDisconnect + ? 'Connection lost. Please try again.' + : (err.message || 'Failed to connect') error.value = message + authStore.showMessage(message, 'error', 4000) emit('error', message) } finally { loading.value = false @@ -613,9 +678,9 @@ function reset() { ws = null } currentChallenge = null - currentBits = null + currentWork = null powPromise = null - powNonce = null + powSolution = null } onMounted(() => { diff --git a/frontend/src/components/RemoteAuthComplete.vue b/frontend/src/components/RemoteAuthComplete.vue index cb168a8..e225c24 100644 --- a/frontend/src/components/RemoteAuthComplete.vue +++ b/frontend/src/components/RemoteAuthComplete.vue @@ -60,6 +60,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue' import { startAuthentication } from '@simplewebauthn/browser' import aWebSocket from '@/utils/awaitable-websocket' +import { dec as b64dec, enc as b64enc } from '@/utils/base64url' import { getSettings, uiBasePath } from '@/utils/settings' import { solvePoW } from '@/utils/pow' @@ -122,13 +123,12 @@ async function authenticate() { // First message is PoW challenge const powChallenge = await ws.receive_json() if (powChallenge.pow) { - const challenge = Uint8Array.from(atob(powChallenge.pow.challenge), c => c.charCodeAt(0)) + const challenge = b64dec(powChallenge.pow.challenge) const powStart = performance.now() - const nonce = await solvePoW(challenge, powChallenge.pow.bits) + const nonces = await solvePoW(challenge, powChallenge.pow.work) const powTime = performance.now() - powStart - const nonceVal = nonce.reduce((acc, b, i) => acc + BigInt(b) * (1n << BigInt(i * 8)), 0n) - console.log(`PoW solved: ${Number(nonceVal)} iterations in ${(powTime / 1000).toFixed(3)}s (${(powTime / Number(nonceVal)).toFixed(3)}ms/iter)`) - ws.send_json({ pow: btoa(String.fromCharCode(...nonce)) }) + console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`) + ws.send_json({ pow: b64enc(nonces) }) } // Receive authentication options diff --git a/frontend/src/components/RemoteAuthLinkModal.vue b/frontend/src/components/RemoteAuthLinkModal.vue index 8168ed6..d3bcf89 100644 --- a/frontend/src/components/RemoteAuthLinkModal.vue +++ b/frontend/src/components/RemoteAuthLinkModal.vue @@ -106,6 +106,7 @@ import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue' import QRCode from 'qrcode/lib/browser' import aWebSocket from '@/utils/awaitable-websocket' +import { dec as b64dec, enc as b64enc } from '@/utils/base64url' import { getSettings } from '@/utils/settings' import { formatDate } from '@/utils/helpers' import { solvePoW } from '@/utils/pow' @@ -156,13 +157,12 @@ async function startRemoteAuth() { // First message is PoW challenge const powChallenge = await ws.receive_json() if (powChallenge.pow) { - const challenge = Uint8Array.from(atob(powChallenge.pow.challenge), c => c.charCodeAt(0)) + const challenge = b64dec(powChallenge.pow.challenge) const powStart = performance.now() - const nonce = await solvePoW(challenge, powChallenge.pow.bits) + const nonces = await solvePoW(challenge, powChallenge.pow.work) const powTime = performance.now() - powStart - const nonceVal = nonce.reduce((acc, b, i) => acc + BigInt(b) * (1n << BigInt(i * 8)), 0n) - console.log(`PoW solved: ${Number(nonceVal)} iterations in ${(powTime / 1000).toFixed(3)}s (${(powTime / Number(nonceVal)).toFixed(3)}ms/iter)`) - ws.send_json({ pow: btoa(String.fromCharCode(...nonce)) }) + console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`) + ws.send_json({ pow: b64enc(nonces) }) } // Receive the remote auth token, pairing code, and URL diff --git a/frontend/src/utils/base64url.js b/frontend/src/utils/base64url.js new file mode 100644 index 0000000..097ba63 --- /dev/null +++ b/frontend/src/utils/base64url.js @@ -0,0 +1,33 @@ +/** + * URL-safe Base64 encoding/decoding utilities. + * + * These functions handle base64url format (RFC 4648) which uses: + * - '-' instead of '+' + * - '_' instead of '/' + * - No padding '=' characters + */ + +/** + * Decode a base64url string to Uint8Array. + * Handles both standard base64 and URL-safe base64 (with or without padding). + * @param {string} str - Base64url encoded string + * @returns {Uint8Array} - Decoded bytes + */ +export function dec(str) { + // Convert URL-safe characters to standard base64 + const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + // Add padding if needed + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) + return Uint8Array.from(atob(padded), c => c.charCodeAt(0)) +} + +/** + * Encode a Uint8Array to base64url string. + * @param {Uint8Array} bytes - Bytes to encode + * @returns {string} - Base64url encoded string (no padding) + */ +export function enc(bytes) { + const base64 = btoa(String.fromCharCode(...bytes)) + // Convert to URL-safe and remove padding + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} diff --git a/frontend/src/utils/pow-test.js b/frontend/src/utils/pow-test.js index ea67adf..4d5cab2 100644 --- a/frontend/src/utils/pow-test.js +++ b/frontend/src/utils/pow-test.js @@ -1,46 +1,37 @@ -import { solvePoW, verifyPoW, nonceToNumber } from './pow.js' +import { solvePoW, verifyPoW } from './pow.js' -const TRIALS = 20 -const REQUIRED_BITS = 18 +const TRIALS = 5 +const WORK = 10 async function test() { - console.log(`Running ${TRIALS} trials with ${REQUIRED_BITS} required zero bits...\n`) + console.log(`Running ${TRIALS} trials with ${WORK} work units...\n`) const times = [] - const iterations = [] for (let trial = 1; trial <= TRIALS; trial++) { const challenge = crypto.getRandomValues(new Uint8Array(8)) const start = performance.now() - let lastIterations = 0 - const nonce = await solvePoW(challenge, REQUIRED_BITS, { - onProgress: (n) => { lastIterations = n } - }) + const solution = await solvePoW(challenge, WORK) const elapsed = performance.now() - start - const valid = await verifyPoW(challenge, nonce, REQUIRED_BITS) - const nonceVal = nonceToNumber(nonce) + const valid = await verifyPoW(challenge, solution, WORK) times.push(elapsed) - iterations.push(Number(nonceVal)) - console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, nonce=${nonceVal}, valid=${valid}`) + console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, valid=${valid}`) } const avgTime = times.reduce((a, b) => a + b, 0) / times.length - const avgIter = iterations.reduce((a, b) => a + b, 0) / iterations.length const minTime = Math.min(...times) const maxTime = Math.max(...times) console.log('\n--- Summary ---') console.log(`Trials: ${TRIALS}`) - console.log(`Required bits: ${REQUIRED_BITS}`) + console.log(`Work units: ${WORK}`) console.log(`Avg time: ${(avgTime / 1000).toFixed(3)}s`) console.log(`Min time: ${(minTime / 1000).toFixed(3)}s`) console.log(`Max time: ${(maxTime / 1000).toFixed(3)}s`) - console.log(`Avg iterations: ${Math.round(avgIter)}`) - console.log(`Expected iterations: ~${Math.pow(2, REQUIRED_BITS)}`) } test() diff --git a/frontend/src/utils/pow.js b/frontend/src/utils/pow.js index 5da4122..b1b33f3 100644 --- a/frontend/src/utils/pow.js +++ b/frontend/src/utils/pow.js @@ -1,23 +1,23 @@ /** - * Proof of Work utility using SHA-512 + * Proof of Work utility using PBKDF2-SHA512 * - * The PoW challenge requires finding a nonce such that - * SHA-512(challenge || nonce) has a specified number of leading zero bits. + * The PoW requires finding nonces where PBKDF2(challenge, nonce) produces + * output with a zero first byte. Each work unit requires finding one such nonce. + * All valid nonces are concatenated into a solution for server verification. */ /** * Solve a Proof of Work challenge * * @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided challenge - * @param {number} requiredZeroBits - Number of leading zero bits required (1-32) + * @param {number} work - Number of PBKDF2 work units required * @param {object} [options] - Optional parameters * @param {AbortSignal} [options.signal] - AbortSignal to cancel the operation - * @param {function} [options.onProgress] - Callback for progress updates (iterations count) - * @returns {Promise} The successful 8-byte nonce (little-endian) + * @returns {Promise} Solution: concatenated 8-byte nonces (8 * work bytes) * @throws {Error} If challenge is invalid or operation is aborted */ -export async function solvePoW(challenge, requiredZeroBits, options = {}) { - const { signal, onProgress } = options +export async function solvePoW(challenge, work, options = {}) { + const { signal } = options const startTime = performance.now() // Validate inputs @@ -29,114 +29,40 @@ export async function solvePoW(challenge, requiredZeroBits, options = {}) { throw new Error('Challenge must be exactly 8 bytes') } - if (requiredZeroBits < 1 || requiredZeroBits > 32) { - throw new Error('Required zero bits must be between 1 and 32') + // Import challenge as PBKDF2 key material + const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits']) + + // Build solution from found nonces + const solution = new Uint8Array(8 * work) + let totalIterations = 0 + const mask = 0x7FF // The client must work 2048x harder than the server + + // Sequential nonce starting at zero (little-endian, using Uint32Array for efficient increment) + const nonce = new Uint32Array(2) + + for (let i = 0; i < work; i++) { + if (signal?.aborted) { + throw new DOMException('PoW operation aborted', 'AbortError') + } + + // Find a nonce where PBKDF2 output passes the mask check + let result + do { + totalIterations++ + if (++nonce[0] === 0x100000000) ++nonce[1] // Increment 64-bit little-endian nonce + result = new Uint32Array(await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512'}, + baseKey, + 32 + )) + } while (result[0] & mask) + solution.set(new Uint8Array(nonce.buffer), i * 8) } - // Prepare the buffer: challenge (8 bytes) || nonce (8 bytes) - const data = new Uint8Array(16) - data.set(challengeBytes, 0) - // Nonce area data[8..15] starts at zero - - // Precalculate mask and byte count for the required zero bits - const fullZeroBytes = requiredZeroBits >>> 3 - const remainingBits = requiredZeroBits & 7 - const partialMask = remainingBits ? (0xFF << (8 - remainingBits)) & 0xFF : 0 - - while (true) { - // Increment 64-bit little-endian nonce - for (let i = 8; i < 16; i++) if (++data[i] !== 256) break - // Calculate SHA-512 hash and verify leading zero bits - const hash = new Uint8Array(await crypto.subtle.digest('SHA-512', data)) - if (hash[0]) continue // Quick check - most hashes fail here - // Yield to UI periodically and check abort signal - if (!(hash[1] & 0x0F)) { - await new Promise(r => setTimeout(r, 0)) - if (signal?.aborted) throw new DOMException('PoW operation aborted', 'AbortError') - } - // Check all full zero bytes - let valid = true - for (let i = 1; i < fullZeroBytes; i++) { - if (hash[i]) { valid = false; break } - } - if (!valid) continue - if (partialMask && (hash[fullZeroBytes] & partialMask)) continue - break - } - const iterations = Number(nonceToNumber(data.slice(8, 16))) const elapsed = (performance.now() - startTime) / 1000 - const iterPerSec = Math.round(iterations / elapsed) - console.log(`PoW solved in ${elapsed.toFixed(1)}s (${(iterPerSec / 1e3).toFixed(0)} k-it/s)`) - return data.slice(8, 16) -} - - -/** - * Verify a PoW solution - * - * @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided challenge - * @param {Uint8Array|ArrayBuffer} nonce - 8-byte client-provided nonce - * @param {number} requiredZeroBits - Number of leading zero bits required - * @returns {Promise} True if the solution is valid - */ -export async function verifyPoW(challenge, nonce, requiredZeroBits) { - const challengeBytes = challenge instanceof ArrayBuffer - ? new Uint8Array(challenge) - : challenge - - const nonceBytes = nonce instanceof ArrayBuffer - ? new Uint8Array(nonce) - : nonce - - if (challengeBytes.length !== 8 || nonceBytes.length !== 8) { - return false - } - - const data = new Uint8Array(16) - data.set(challengeBytes, 0) - data.set(nonceBytes, 8) - - const hashBuffer = await crypto.subtle.digest('SHA-512', data) - const hash = new Uint8Array(hashBuffer) - - // Check leading zero bits using same logic as solvePoW - const fullZeroBytes = requiredZeroBits >>> 3 - const remainingBits = requiredZeroBits & 7 - const partialMask = remainingBits ? (0xFF << (8 - remainingBits)) & 0xFF : 0 - - for (let i = 0; i < fullZeroBytes; i++) { - if (hash[i] !== 0) return false - } - if (partialMask && (hash[fullZeroBytes] & partialMask) !== 0) { - return false - } - return true -} - -/** - * Convert a nonce Uint8Array to a BigInt (little-endian) - * @param {Uint8Array} nonce - 8-byte little-endian nonce - * @returns {bigint} The nonce as a BigInt - */ -export function nonceToNumber(nonce) { - let value = 0n - for (let i = 7; i >= 0; i--) { - value = (value << 8n) | BigInt(nonce[i]) - } - return value -} - -/** - * Convert a BigInt to a nonce Uint8Array (little-endian) - * @param {bigint} value - The value to convert - * @returns {Uint8Array} 8-byte little-endian representation - */ -export function numberToNonce(value) { - const nonce = new Uint8Array(8) - let v = BigInt(value) - for (let i = 0; i < 8; i++) { - nonce[i] = Number(v & 0xFFn) - v >>= 8n - } - return nonce + const expectedIterations = work * (mask + 1) + const luckRatio = (totalIterations / expectedIterations).toFixed(1) + const bench = totalIterations / ((mask + 1) * elapsed) + console.log(`PoW work=${work} solved in ${elapsed.toFixed(2)}s (${luckRatio}x expected ${bench.toFixed(1)} work/s)`) + return solution } diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py new file mode 100644 index 0000000..4db4c63 --- /dev/null +++ b/paskia/fastapi/remote.py @@ -0,0 +1,335 @@ +""" +Remote authentication WebSocket endpoints. + +This module handles cross-device authentication where one device (requesting) +wants to log in and another device (authenticating) provides the passkey. + +Endpoints: +- /request: Called by the device wanting to be authenticated +- /pair: Called by the authenticating device to complete the request +""" + +import asyncio +from uuid import UUID + +import base64url + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect + +from paskia import remoteauth +from paskia.authsession import create_session +from paskia.fastapi.session import infodict +from paskia.fastapi.wsutil import require_pow, validate_origin, websocket_error_handler +from paskia.globals import db, passkey +from paskia.util import hostutil, pow + + +# Create a FastAPI subapp for remote auth WebSocket endpoints +app = FastAPI() + + +@app.websocket("/request") +@websocket_error_handler +async def websocket_remote_auth_request(ws: WebSocket): + """Request authentication from another device. + + This endpoint is called by the device that wants to be authenticated. + It creates a remote auth request and waits for another device to authenticate. + + Flow: + 1. Client connects + 2. Server sends HARD PoW challenge, client solves and responds + 3. Server creates a remote auth token and sends it with URL/expiry/pairing_code + 4. Server waits for another device to authenticate via /remote-auth/pair + 5. When auth completes, server sends session_token to this client + 6. Client can then use the session token to set a cookie + 7. Connection times out after 5 minutes with explicit timeout message + """ + origin = validate_origin(ws) + host = origin.split("://", 1)[1] + + if remoteauth.instance is None: + raise ValueError("Remote authentication is not available") + + # Require HARD PoW before creating the request (SECURITY) + await require_pow(ws, work=pow.HARD) + + metadata = infodict(ws, "remote-auth-request") + + # Create the remote auth request + token, pairing_code, expiry = await remoteauth.instance.create_request( + host=host, + ip=metadata.get("ip") or "", + user_agent=metadata.get("user_agent") or "", + ) + + # Build the URL for the authenticating device (same endpoint as reset tokens) + url = hostutil.auth_site_base_url() + token + + # Send the token, pairing code, and URL to the client + await ws.send_json( + { + "token": token, + "pairing_code": pairing_code, + "url": url, + "expires": expiry.isoformat().replace("+00:00", "Z"), + } + ) + + # Set up async notification + result_event = asyncio.Event() + result_data: dict = {} + + def on_complete( + session_token: str | None, + user_uuid: UUID | None, + credential_uuid: UUID | None, + ): + result_data["session_token"] = session_token + result_data["user_uuid"] = user_uuid + result_data["credential_uuid"] = credential_uuid + result_event.set() + + await remoteauth.instance.set_notify_callback(token, on_complete) + + # 5 minute timeout for the entire remote auth flow + timeout_seconds = 5 * 60 + + try: + # Wait for either: + # 1. Authentication to complete (result_event set) + # 2. Client to disconnect + # 3. Client to send a cancel message + # 4. Timeout after 5 minutes + + async with asyncio.timeout(timeout_seconds): + while True: + # Use asyncio.wait to handle both event and websocket + receive_task = asyncio.create_task(ws.receive_json()) + event_task = asyncio.create_task(result_event.wait()) + + done, pending = await asyncio.wait( + [receive_task, event_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + # Cancel pending tasks + for task in pending: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + if event_task in done: + # Authentication completed (or expired/cancelled) + if result_data.get("session_token"): + await ws.send_json( + { + "status": "authenticated", + "user_uuid": str(result_data["user_uuid"]), + "session_token": result_data["session_token"], + } + ) + else: + await ws.send_json( + { + "status": "expired", + "detail": "Remote authentication request expired or was cancelled", + } + ) + return + + if receive_task in done: + # Client sent a message + msg = receive_task.result() + if msg.get("action") == "cancel": + await remoteauth.instance.cancel_request(token) + await ws.send_json({"status": "cancelled"}) + return + # Ignore other messages + + except TimeoutError: + # 5 minute timeout reached + await remoteauth.instance.cancel_request(token) + await ws.send_json( + { + "status": "timeout", + "detail": "Remote authentication request timed out after 5 minutes", + } + ) + except WebSocketDisconnect: + # Client disconnected, cancel the request + await remoteauth.instance.cancel_request(token) + except Exception: + await remoteauth.instance.cancel_request(token) + raise + + +@app.websocket("/pair") +@websocket_error_handler +async def websocket_remote_auth_pair(ws: WebSocket): + """Complete a remote authentication request using a pairing code or link token. + + This endpoint is called from the user's profile on the authenticating device. + The user enters the pairing code displayed on the requesting device, or + opens the link which contains a 5-word token. + + Protocol: + 1. Server sends PoW challenge immediately on connect + 2. Client sends {code: "word.word.word", pow: ""} for 3-word pairing code + or {code: "word.word.word.word.word", pow: ""} for 5-word link token + 3. Server validates PoW and code: + - If invalid code/PoW: {status: 4xx, detail: "...", pow: {challenge, work}} + - If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, work}} + 4. Client can then send {authenticate: true, pow: ""} to start WebAuthn + 5. Server sends {optionsJSON: ...} + 6. Client sends WebAuthn response + 7. Server sends {status: "success", message: "..."} + + Note: 5-word tokens (from links) skip the PoW requirement since generating + the link already required HARD PoW. + """ + from paskia.util import useragent + + origin = validate_origin(ws) + + if remoteauth.instance is None: + raise ValueError("Remote authentication is not available") + + # Generate initial PoW challenge + challenge = pow.generate_challenge() + work = pow.NORMAL + + await ws.send_json({ + "pow": { + "challenge": base64url.enc(challenge), + "work": work, + } + }) + + request = None + webauthn_challenge = None + + while True: + msg = await ws.receive_json() + + # Check if this is a 5-word token (from link) - skip PoW validation + code = msg.get("code", "") + is_link_token = len(code.split(".")) == 5 + + if not is_link_token: + # Validate PoW for 3-word pairing codes + solution_b64 = msg.get("pow") + if not solution_b64: + raise ValueError("PoW solution required") + + try: + solution = base64url.dec(solution_b64) + except Exception: + raise ValueError("Invalid PoW solution encoding") + + try: + pow.verify_pow(challenge, solution, work) + except ValueError as e: + # Invalid PoW - send new challenge + challenge = pow.generate_challenge() + await ws.send_json({ + "status": 400, + "detail": str(e), + "pow": { + "challenge": base64url.enc(challenge), + "work": work, + } + }) + continue + + # Handle authenticate request (after successful lookup) + if msg.get("authenticate") and request is not None: + # Generate authentication options + options, webauthn_challenge = passkey.instance.auth_generate_options( + credential_ids=None + ) + await ws.send_json({"optionsJSON": options}) + + # Wait for WebAuthn response + credential = passkey.instance.auth_parse(await ws.receive_json()) + + # Fetch and verify credential + try: + stored_cred = await db.instance.get_credential_by_id(credential.raw_id) + except ValueError: + raise ValueError( + f"This passkey is no longer registered with {passkey.instance.rp_name}" + ) + + # Verify the credential + passkey.instance.auth_verify( + credential, webauthn_challenge, stored_cred, origin + ) + + # Update credential last_used + await db.instance.login(stored_cred.user_uuid, stored_cred) + + # Create a session for the REQUESTING device + assert stored_cred.uuid is not None + session_token = await create_session( + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_cred.uuid, + host=request.host, + ip=request.ip, + user_agent=request.user_agent, + ) + + # Complete the remote auth request (notifies the waiting device) + completed = await remoteauth.instance.complete_request( + token=request.key, + session_token=session_token, + user_uuid=stored_cred.user_uuid, + credential_uuid=stored_cred.uuid, + ) + + if not completed: + raise ValueError("Failed to complete remote authentication") + + await ws.send_json({ + "status": "success", + "message": "Authentication successful. The other device is now logged in.", + }) + break + + # Handle code lookup request + if not code: + raise ValueError("Pairing code required") + + # Look up the remote auth request by pairing code or token + if is_link_token: + request = await remoteauth.instance.get_request(code) + else: + request = await remoteauth.instance.get_request_by_pairing_code(code) + + # Generate new challenge for next request + challenge = pow.generate_challenge() + + if request is None: + await ws.send_json({ + "status": 404, + "detail": "Code not found", + "pow": { + "challenge": base64url.enc(challenge), + "work": work, + } + }) + request = None # Reset for next attempt + continue + + # Valid code found - send device info + await ws.send_json({ + "status": "found", + "host": request.host, + "user_agent_pretty": useragent.compact_user_agent(request.user_agent), + "pow": { + "challenge": base64url.enc(challenge), + "work": work, + } + }) diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 8a1d758..e66f202 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -1,93 +1,22 @@ -import asyncio -import base64 -import logging -from functools import wraps from uuid import UUID -from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from webauthn.helpers.exceptions import InvalidAuthenticationResponse +from fastapi import FastAPI, WebSocket -from paskia import remoteauth from paskia.authsession import create_session, get_reset, get_session from paskia.fastapi import authz from paskia.fastapi.session import AUTH_COOKIE, infodict +from paskia.fastapi.wsutil import require_pow, validate_origin, websocket_error_handler from paskia.globals import db, passkey -from paskia.util import hostutil, passphrase, pow +from paskia.util import passphrase from paskia.util.tokens import create_token, session_key -# WebSocket error handling decorator -def websocket_error_handler(func): - @wraps(func) - async def wrapper(ws: WebSocket, *args, **kwargs): - try: - await ws.accept() - return await func(ws, *args, **kwargs) - except WebSocketDisconnect: - pass - except authz.AuthException as e: - await ws.send_json( - { - "status": e.status_code, - **(await authz.auth_error_content(e)), - } - ) - except (ValueError, InvalidAuthenticationResponse) as e: - await ws.send_json({"status": 401, "detail": str(e)}) - except Exception: - logging.exception("Internal Server Error") - await ws.send_json({"status": 500, "detail": "Internal Server Error"}) - - return wrapper - - # Create a FastAPI subapp for WebSocket endpoints app = FastAPI() - -async def _require_pow(ws: WebSocket) -> None: - """Send a PoW challenge and verify the client's solution. - - Sends: {"pow": {"challenge": "", "bits": 14}} - Expects: {"pow": ""} - - Raises: - ValueError: If the PoW solution is invalid - """ - challenge = pow.generate_challenge() - bits = pow.DEFAULT_POW_BITS - - await ws.send_json({ - "pow": { - "challenge": base64.b64encode(challenge).decode(), - "bits": bits, - } - }) - - response = await ws.receive_json() - nonce_b64 = response.get("pow") - if not nonce_b64: - raise ValueError("PoW solution required") - - try: - nonce = base64.b64decode(nonce_b64) - except Exception: - raise ValueError("Invalid PoW nonce encoding") - - if not pow.verify_pow(challenge, nonce, bits): - raise ValueError("Invalid PoW solution") - - -def _validate_origin(ws: WebSocket) -> str: - """Extract and validate origin from WebSocket request headers. - - Raises: - ValueError: If origin header is missing or not in allowed list - """ - origin = ws.headers.get("origin") - if not origin: - raise ValueError("Origin header is required for WebSocket connections") - return passkey.instance.validate_origin(origin) +# Mount the remote auth subapp +from paskia.fastapi import remote +app.mount("/remote-auth", remote.app) async def register_chat( @@ -122,7 +51,7 @@ async def websocket_register_add( - Normal session via auth cookie (requires recent authentication) - Reset token supplied as ?reset=... (auth cookie ignored) """ - origin = _validate_origin(ws) + origin = validate_origin(ws) host = origin.split("://", 1)[1] if reset is not None: if not passphrase.is_well_formed(reset): @@ -178,7 +107,7 @@ async def websocket_register_add( @app.websocket("/authenticate") @websocket_error_handler async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): - origin = _validate_origin(ws) + origin = validate_origin(ws) host = origin.split("://", 1)[1] # If there's an existing session, restrict to that user's credentials (reauth) @@ -234,382 +163,3 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): "session_token": token, } ) - - -@app.websocket("/remote-auth/request") -@websocket_error_handler -async def websocket_remote_auth_request(ws: WebSocket): - """Request authentication from another device. - - This endpoint is called by the device that wants to be authenticated. - It creates a remote auth request and waits for another device to authenticate. - - Flow: - 1. Client connects - 2. Server sends PoW challenge, client solves and responds - 3. Server creates a remote auth token and sends it with URL/expiry/pairing_code - 4. Server waits for another device to authenticate via /remote-auth/complete - 5. When auth completes, server sends session_token to this client - 6. Client can then use the session token to set a cookie - """ - origin = _validate_origin(ws) - host = origin.split("://", 1)[1] - - if remoteauth.instance is None: - raise ValueError("Remote authentication is not available") - - # Require PoW before creating the request - await _require_pow(ws) - - metadata = infodict(ws, "remote-auth-request") - - # Create the remote auth request - token, pairing_code, expiry = await remoteauth.instance.create_request( - host=host, - ip=metadata.get("ip") or "", - user_agent=metadata.get("user_agent") or "", - ) - - # Build the URL for the authenticating device (same endpoint as reset tokens) - url = hostutil.auth_site_base_url() + token - - # Send the token, pairing code, and URL to the client - await ws.send_json( - { - "token": token, - "pairing_code": pairing_code, - "url": url, - "expires": expiry.isoformat().replace("+00:00", "Z"), - } - ) - - # Set up async notification - result_event = asyncio.Event() - result_data: dict = {} - - def on_complete( - session_token: str | None, - user_uuid: UUID | None, - credential_uuid: UUID | None, - ): - result_data["session_token"] = session_token - result_data["user_uuid"] = user_uuid - result_data["credential_uuid"] = credential_uuid - result_event.set() - - await remoteauth.instance.set_notify_callback(token, on_complete) - - try: - # Wait for either: - # 1. Authentication to complete (result_event set) - # 2. Client to disconnect - # 3. Client to send a cancel message - # 4. Timeout (handled by remoteauth cleanup) - - while True: - # Use asyncio.wait to handle both event and websocket - receive_task = asyncio.create_task(ws.receive_json()) - event_task = asyncio.create_task(result_event.wait()) - - done, pending = await asyncio.wait( - [receive_task, event_task], - return_when=asyncio.FIRST_COMPLETED, - ) - - # Cancel pending tasks - for task in pending: - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - if event_task in done: - # Authentication completed (or expired/cancelled) - if result_data.get("session_token"): - await ws.send_json( - { - "status": "authenticated", - "user_uuid": str(result_data["user_uuid"]), - "session_token": result_data["session_token"], - } - ) - else: - await ws.send_json( - { - "status": "expired", - "detail": "Remote authentication request expired or was cancelled", - } - ) - break - - if receive_task in done: - # Client sent a message - msg = receive_task.result() - if msg.get("action") == "cancel": - await remoteauth.instance.cancel_request(token) - await ws.send_json({"status": "cancelled"}) - break - # Ignore other messages - - except WebSocketDisconnect: - # Client disconnected, cancel the request - await remoteauth.instance.cancel_request(token) - except Exception: - await remoteauth.instance.cancel_request(token) - raise - - -@app.websocket("/remote-auth/complete/{token}") -@websocket_error_handler -async def websocket_remote_auth_complete(ws: WebSocket, token: str): - """Complete a remote authentication request. - - This endpoint is called by the authenticating device (the one with the passkey). - It performs WebAuthn authentication and notifies the requesting device. - - Flow: - 1. Client opens the remote auth link and connects here - 2. Server sends PoW challenge, client solves and responds - 3. Server verifies the token is valid - 4. Server sends WebAuthn options - 5. Client authenticates with passkey - 6. Server creates session for the REQUESTING device's host - 7. Server notifies the requesting device via the callback - 8. Server sends confirmation to this client - """ - origin = _validate_origin(ws) - - if remoteauth.instance is None: - raise ValueError("Remote authentication is not available") - - # Require PoW before revealing if token is valid - await _require_pow(ws) - - # Validate the remote auth token - request = await remoteauth.instance.get_request(token) - if request is None: - raise ValueError("This remote authentication link is invalid or has expired") - - if request.completed: - raise ValueError("This remote authentication has already been completed") - - # The session will be created for the requesting device's host, not this device's - target_host = request.host - - # Generate authentication options (no credential restriction for remote auth) - options, challenge = passkey.instance.auth_generate_options(credential_ids=None) - await ws.send_json({"optionsJSON": options}) - - # Wait for client authentication response - credential = passkey.instance.auth_parse(await ws.receive_json()) - - # Fetch and verify credential - try: - stored_cred = await db.instance.get_credential_by_id(credential.raw_id) - except ValueError: - raise ValueError( - f"This passkey is no longer registered with {passkey.instance.rp_name}" - ) - - # Verify the credential - passkey.instance.auth_verify(credential, challenge, stored_cred, origin) - - # Update credential last_used - await db.instance.login(stored_cred.user_uuid, stored_cred) - - # Create a session for the REQUESTING device - assert stored_cred.uuid is not None - session_token = await create_session( - user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - host=target_host, - ip=request.ip, - user_agent=request.user_agent, - ) - - # Complete the remote auth request (notifies the waiting device) - completed = await remoteauth.instance.complete_request( - token=token, - session_token=session_token, - user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - ) - - if not completed: - raise ValueError("Failed to complete remote authentication") - - # Send confirmation to the authenticating device - await ws.send_json( - { - "status": "success", - "message": "Authentication successful. The other device is now logged in.", - } - ) - - -@app.websocket("/remote-auth/pair") -@websocket_error_handler -async def websocket_remote_auth_pair(ws: WebSocket): - """Complete a remote authentication request using a pairing code. - - This endpoint is called from the user's profile on the authenticating device. - The user enters the pairing code displayed on the requesting device. - - Redesigned protocol (no keywords in URL): - 1. Server sends PoW challenge immediately on connect - 2. Client sends {code: "word.word.word", pow: ""} - 3. Server validates PoW and code: - - If invalid code/PoW: {status: "error", error: "...", pow: {challenge, bits}} - - If valid: {status: "found", host: "...", user_agent_pretty: "...", pow: {challenge, bits}} - 4. Client can then send {authenticate: true, pow: ""} to start WebAuthn - 5. Server sends {optionsJSON: ...} - 6. Client sends WebAuthn response - 7. Server sends {status: "success", message: "..."} - """ - from paskia.util import useragent - - origin = _validate_origin(ws) - - if remoteauth.instance is None: - raise ValueError("Remote authentication is not available") - - # Generate initial PoW challenge - challenge = pow.generate_challenge() - bits = pow.DEFAULT_POW_BITS - - await ws.send_json({ - "pow": { - "challenge": base64.b64encode(challenge).decode(), - "bits": bits, - } - }) - - request = None - webauthn_challenge = None - - while True: - msg = await ws.receive_json() - - # Validate PoW - nonce_b64 = msg.get("pow") - if not nonce_b64: - raise ValueError("PoW solution required") - - try: - nonce = base64.b64decode(nonce_b64) - except Exception: - raise ValueError("Invalid PoW nonce encoding") - - if not pow.verify_pow(challenge, nonce, bits): - # Invalid PoW - send new challenge - challenge = pow.generate_challenge() - await ws.send_json({ - "status": "error", - "error": "Invalid proof of work", - "pow": { - "challenge": base64.b64encode(challenge).decode(), - "bits": bits, - } - }) - continue - - # Handle authenticate request (after successful lookup) - if msg.get("authenticate") and request is not None: - # Generate authentication options - options, webauthn_challenge = passkey.instance.auth_generate_options( - credential_ids=None - ) - await ws.send_json({"optionsJSON": options}) - - # Wait for WebAuthn response - credential = passkey.instance.auth_parse(await ws.receive_json()) - - # Fetch and verify credential - try: - stored_cred = await db.instance.get_credential_by_id(credential.raw_id) - except ValueError: - raise ValueError( - f"This passkey is no longer registered with {passkey.instance.rp_name}" - ) - - # Verify the credential - passkey.instance.auth_verify( - credential, webauthn_challenge, stored_cred, origin - ) - - # Update credential last_used - await db.instance.login(stored_cred.user_uuid, stored_cred) - - # Create a session for the REQUESTING device - assert stored_cred.uuid is not None - session_token = await create_session( - user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - host=request.host, - ip=request.ip, - user_agent=request.user_agent, - ) - - # Complete the remote auth request (notifies the waiting device) - completed = await remoteauth.instance.complete_request( - token=request.key, - session_token=session_token, - user_uuid=stored_cred.user_uuid, - credential_uuid=stored_cred.uuid, - ) - - if not completed: - raise ValueError("Failed to complete remote authentication") - - await ws.send_json({ - "status": "success", - "message": "Authentication successful. The other device is now logged in.", - }) - break - - # Handle code lookup request - code = msg.get("code") - if not code: - raise ValueError("Pairing code required") - - # Look up the remote auth request by pairing code - request = await remoteauth.instance.get_request_by_pairing_code(code) - - # Generate new challenge for next request - challenge = pow.generate_challenge() - - if request is None: - await ws.send_json({ - "status": "error", - "error": "not_found", - "pow": { - "challenge": base64.b64encode(challenge).decode(), - "bits": bits, - } - }) - request = None # Reset for next attempt - continue - - if request.completed: - await ws.send_json({ - "status": "error", - "error": "already_completed", - "pow": { - "challenge": base64.b64encode(challenge).decode(), - "bits": bits, - } - }) - request = None - continue - - # Valid code found - send device info - await ws.send_json({ - "status": "found", - "host": request.host, - "user_agent_pretty": useragent.compact_user_agent(request.user_agent), - "pow": { - "challenge": base64.b64encode(challenge).decode(), - "bits": bits, - } - }) diff --git a/paskia/fastapi/wsutil.py b/paskia/fastapi/wsutil.py new file mode 100644 index 0000000..62ece16 --- /dev/null +++ b/paskia/fastapi/wsutil.py @@ -0,0 +1,89 @@ +""" +Shared WebSocket utilities for FastAPI endpoints. +""" + +import logging + +import base64url +from functools import wraps + +from fastapi import WebSocket, WebSocketDisconnect +from webauthn.helpers.exceptions import InvalidAuthenticationResponse + +from paskia.fastapi import authz +from paskia.globals import passkey +from paskia.util import pow + + +def websocket_error_handler(func): + """Decorator for WebSocket endpoints that handles common errors.""" + @wraps(func) + async def wrapper(ws: WebSocket, *args, **kwargs): + try: + await ws.accept() + return await func(ws, *args, **kwargs) + except WebSocketDisconnect: + pass + except authz.AuthException as e: + await ws.send_json( + { + "status": e.status_code, + **(await authz.auth_error_content(e)), + } + ) + except (ValueError, InvalidAuthenticationResponse) as e: + await ws.send_json({"status": 401, "detail": str(e)}) + except Exception: + logging.exception("Internal Server Error") + await ws.send_json({"status": 500, "detail": "Internal Server Error"}) + + return wrapper + + +async def require_pow(ws: WebSocket, work: int | None = None) -> None: + """Send a PoW challenge and verify the client's solution. + + Sends: {"pow": {"challenge": "", "work": 10}} + Expects: {"pow": ""} + + Args: + ws: WebSocket connection + work: PoW difficulty level (default: pow.DEFAULT_WORK) + + Raises: + ValueError: If the PoW solution is invalid + """ + challenge = pow.generate_challenge() + if work is None: + work = pow.DEFAULT_WORK + + await ws.send_json({ + "pow": { + "challenge": base64url.enc(challenge), + "work": work, + } + }) + + response = await ws.receive_json() + solution_b64 = response.get("pow") + if not solution_b64: + raise ValueError("PoW solution required") + + try: + solution = base64url.dec(solution_b64) + except Exception: + raise ValueError("Invalid PoW solution encoding") + + pow.verify_pow(challenge, solution, work) + + +def validate_origin(ws: WebSocket) -> str: + """Extract and validate origin from WebSocket request headers. + + Raises: + ValueError: If origin header is missing or not in allowed list + """ + origin = ws.headers.get("origin") + if not origin: + raise ValueError("Origin header is required for WebSocket connections") + return passkey.instance.validate_origin(origin) diff --git a/paskia/remoteauth.py b/paskia/remoteauth.py index 6de685e..b691a74 100644 --- a/paskia/remoteauth.py +++ b/paskia/remoteauth.py @@ -211,12 +211,6 @@ class RemoteAuthManager: if req is None: return False req.notify = callback - # If already completed, notify immediately - if req.completed: - try: - callback(req.session_token, req.user_uuid, req.credential_uuid) - except Exception: - pass return True async def complete_request( @@ -228,18 +222,15 @@ class RemoteAuthManager: ) -> bool: """Mark a request as completed with the authentication result. + The request is removed after notifying the waiting client. Returns True if the request existed and was completed. """ async with self._lock: - req = self._requests.get(token) + req = self._requests.pop(token, None) if req is None: return False - if req.completed: - return False # Already completed - req.completed = True - req.session_token = session_token - req.user_uuid = user_uuid - req.credential_uuid = credential_uuid + # Remove from pairing code index + self._by_pairing_code.pop(req.pairing_code, None) if req.notify: try: req.notify(session_token, user_uuid, credential_uuid) diff --git a/paskia/util/pow.py b/paskia/util/pow.py index a6eda52..6a2592c 100644 --- a/paskia/util/pow.py +++ b/paskia/util/pow.py @@ -1,62 +1,44 @@ """ -Proof of Work utility using SHA-512. +Proof of Work utility using PBKDF2-SHA512. -The PoW challenge requires finding a nonce such that -SHA-512(challenge || nonce) has a specified number of leading zero bits. - -Both challenge and nonce are 8 bytes (uint64), concatenated into 16 bytes for hashing. -The nonce is little-endian. +The PoW requires finding nonces where PBKDF2(challenge, nonce) produces +output with a zero first byte. Each work unit requires finding one such nonce. +All valid nonces are concatenated into a solution for server verification. """ import hashlib -import os - -# Default difficulty: 16 bits means ~65k iterations on average -DEFAULT_POW_BITS = 16 +import secrets +EASY = 2 # Around 0.25s +NORMAL = 8 # Around 1s +HARD = 32 # Around 4s def generate_challenge() -> bytes: """Generate a random 8-byte challenge.""" - return os.urandom(8) + return secrets.token_bytes(8) -def verify_pow(challenge: bytes, nonce: bytes, required_bits: int = DEFAULT_POW_BITS) -> bool: +def verify_pow(challenge: bytes, solution: bytes, work: int = NORMAL) -> None: """Verify a Proof of Work solution. Args: challenge: 8-byte server-provided challenge - nonce: 8-byte client-provided nonce (little-endian) - required_bits: Number of leading zero bits required (1-32) + solution: Concatenated 8-byte nonces (8 * work bytes) + work: Number of work units expected - Returns: - True if the solution is valid + Raises: + ValueError: If the solution is invalid """ - if len(challenge) != 8 or len(nonce) != 8: - return False + if len(challenge) != 8: + raise ValueError("Invalid challenge length") - if required_bits < 1 or required_bits > 32: - return False + if len(solution) != 8 * work: + raise ValueError("Invalid solution length") - # Concatenate challenge and nonce - data = challenge + nonce - - # Calculate SHA-512 hash - hash_bytes = hashlib.sha512(data).digest() - - # Check leading zero bits - full_zero_bytes = required_bits >> 3 # required_bits // 8 - remaining_bits = required_bits & 7 # required_bits % 8 - - # Check full zero bytes - for i in range(full_zero_bytes): - if hash_bytes[i] != 0: - return False - - # Check partial byte if needed - if remaining_bits: - # Mask for remaining bits: e.g., 3 bits -> 0b11100000 = 0xE0 - partial_mask = (0xFF << (8 - remaining_bits)) & 0xFF - if hash_bytes[full_zero_bytes] & partial_mask: - return False - - return True + # Verify each work unit - check that PBKDF2 output starts with 0x00 + for i in range(work): + nonce = solution[i * 8 : (i + 1) * 8] + # Require first byte of PBKDF2-SHA512 to be zero + result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2) + if result[0] or result[1] & 0x07: + raise ValueError("Invalid PoW solution") diff --git a/paskia/util/tokens.py b/paskia/util/tokens.py index 2126448..a702819 100644 --- a/paskia/util/tokens.py +++ b/paskia/util/tokens.py @@ -1,7 +1,8 @@ -import base64 import hashlib import secrets +import base64url + from paskia.util.passphrase import is_well_formed @@ -12,21 +13,20 @@ def create_token() -> str: def session_key(token: str) -> bytes: if len(token) != 16: raise ValueError("Session token must be exactly 16 characters long") - return b"sess" + base64.urlsafe_b64decode(token) + return b"sess" + base64url.dec(token) def encode_session_key(key: bytes) -> str: """Encode an opaque session key for external representation.""" - return base64.urlsafe_b64encode(key).decode().rstrip("=") + return base64url.enc(key) def decode_session_key(encoded: str) -> bytes: """Decode an opaque session key from its public representation.""" if not encoded: raise ValueError("Invalid session identifier") - padding = "=" * (-len(encoded) % 4) try: - raw = base64.urlsafe_b64decode(encoded + padding) + raw = base64url.dec(encoded) except Exception as exc: # pragma: no cover - defensive raise ValueError("Invalid session identifier") from exc if not raw.startswith(b"sess"):