Various improvements to remote link login. PoW algorithm tuned. Added base64url and helper modules.
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(/=+$/, '')
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
+41
-115
@@ -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')
|
||||
}
|
||||
|
||||
// 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<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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user