Various improvements to remote link login. PoW algorithm tuned. Added base64url and helper modules.

This commit is contained in:
Leo Vasanko
2025-12-07 20:12:38 +00:00
parent 4bb64863f9
commit a54d872b46
12 changed files with 665 additions and 703 deletions
+106 -41
View File
@@ -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'
: 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(() => {
@@ -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
@@ -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
+33
View File
@@ -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(/=+$/, '')
}
+8 -17
View File
@@ -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()
+40 -114
View File
@@ -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<Uint8Array>} The successful 8-byte nonce (little-endian)
* @returns {Promise<Uint8Array>} 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')
}
// 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
// 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)
}
// 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<boolean>} 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
}
+335
View File
@@ -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: "<base64>"} for 3-word pairing code
or {code: "word.word.word.word.word", pow: "<base64>"} 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: "<base64>"} 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,
}
})
+8 -458
View File
@@ -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": "<base64>", "bits": 14}}
Expects: {"pow": "<base64-nonce>"}
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: "<base64-nonce>"}
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: "<nonce>"} 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,
}
})
+89
View File
@@ -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": "<base64>", "work": 10}}
Expects: {"pow": "<base64-solution>"}
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)
+4 -13
View File
@@ -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)
+25 -43
View File
@@ -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")
+5 -5
View File
@@ -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"):