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 { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { startAuthentication } from '@simplewebauthn/browser'
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
import aWebSocket from '@/utils/awaitable-websocket'
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
|
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
|
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
|
||||||
import { solvePoW } from '@/utils/pow'
|
import { solvePoW } from '@/utils/pow'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: { type: String, default: 'Help Another Device Sign In' },
|
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 ws = null
|
||||||
let wsConnecting = false
|
let wsConnecting = false
|
||||||
let currentChallenge = null // Current PoW challenge from server
|
let currentChallenge = null // Current PoW challenge from server
|
||||||
let currentBits = null
|
let currentWork = null
|
||||||
let powPromise = null // Promise for background PoW computation
|
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 lookupTimeout = null
|
||||||
let lastLookedUpCode = null // Track last code we looked up to avoid duplicates
|
let lastLookedUpCode = null // Track last code we looked up to avoid duplicates
|
||||||
|
|
||||||
@@ -143,7 +147,7 @@ function getCurrentWord(input) {
|
|||||||
return match ? match[0] : ''
|
return match ? match[0] : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all words from input
|
// Get all words from input (normalizes whitespace)
|
||||||
function getWords(input) {
|
function getWords(input) {
|
||||||
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
|
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
|
||||||
}
|
}
|
||||||
@@ -253,40 +257,40 @@ function normalizeCode(input) {
|
|||||||
function startPowSolving() {
|
function startPowSolving() {
|
||||||
if (!currentChallenge || powPromise) return
|
if (!currentChallenge || powPromise) return
|
||||||
|
|
||||||
const challenge = Uint8Array.from(atob(currentChallenge), c => c.charCodeAt(0))
|
const challenge = b64dec(currentChallenge)
|
||||||
powPromise = solvePoW(challenge, currentBits).then(nonce => {
|
powPromise = solvePoW(challenge, currentWork).then(solution => {
|
||||||
powNonce = nonce
|
powSolution = solution
|
||||||
powPromise = null
|
powPromise = null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the solved PoW nonce, waiting if necessary
|
// Get the solved PoW solution, waiting if necessary
|
||||||
async function getPowNonce() {
|
async function getPowSolution() {
|
||||||
if (powNonce) {
|
if (powSolution) {
|
||||||
const nonce = powNonce
|
const solution = powSolution
|
||||||
powNonce = null
|
powSolution = null
|
||||||
return nonce
|
return solution
|
||||||
}
|
}
|
||||||
if (powPromise) {
|
if (powPromise) {
|
||||||
await powPromise
|
await powPromise
|
||||||
const nonce = powNonce
|
const solution = powSolution
|
||||||
powNonce = null
|
powSolution = null
|
||||||
return nonce
|
return solution
|
||||||
}
|
}
|
||||||
// Need to solve now
|
// Need to solve now
|
||||||
if (!currentChallenge) {
|
if (!currentChallenge) {
|
||||||
throw new Error('No PoW challenge available')
|
throw new Error('No PoW challenge available')
|
||||||
}
|
}
|
||||||
const challenge = Uint8Array.from(atob(currentChallenge), c => c.charCodeAt(0))
|
const challenge = b64dec(currentChallenge)
|
||||||
return await solvePoW(challenge, currentBits)
|
return await solvePoW(challenge, currentWork)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update challenge from server response and start solving
|
// Update challenge from server response and start solving
|
||||||
function updateChallenge(pow) {
|
function updateChallenge(pow) {
|
||||||
if (pow?.challenge) {
|
if (pow?.challenge) {
|
||||||
currentChallenge = pow.challenge
|
currentChallenge = pow.challenge
|
||||||
currentBits = pow.bits
|
currentWork = pow.work
|
||||||
powNonce = null
|
powSolution = null
|
||||||
powPromise = null
|
powPromise = null
|
||||||
// Start solving in background immediately
|
// Start solving in background immediately
|
||||||
startPowSolving()
|
startPowSolving()
|
||||||
@@ -310,10 +314,18 @@ async function ensureConnection() {
|
|||||||
|
|
||||||
// First message is PoW challenge
|
// First message is PoW challenge
|
||||||
const msg = await ws.receive_json()
|
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)
|
updateChallenge(msg.pow)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('WebSocket connection error:', err)
|
console.error('WebSocket connection error:', err)
|
||||||
|
authStore.showMessage('Pairing service: ' + (err.message || 'Connection failed'), 'error', 4000)
|
||||||
ws = null
|
ws = null
|
||||||
|
throw err // Re-throw so caller knows connection failed
|
||||||
} finally {
|
} finally {
|
||||||
wsConnecting = false
|
wsConnecting = false
|
||||||
}
|
}
|
||||||
@@ -352,13 +364,20 @@ function applyAutocomplete() {
|
|||||||
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
|
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
|
||||||
if (!word) return false
|
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 before = code.value.slice(0, start)
|
||||||
const after = code.value.slice(end)
|
const wordsBefore = getWords(before).length
|
||||||
code.value = before + autocompleteHint.value + ' ' + after
|
|
||||||
|
|
||||||
// Move cursor to after the inserted word + space
|
// Don't add space after the 3rd word (wordsBefore is 0-indexed count of words before current)
|
||||||
const newPos = start + autocompleteHint.value.length + 1
|
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(() => {
|
nextTick(() => {
|
||||||
inputRef.value?.setSelectionRange(newPos, newPos)
|
inputRef.value?.setSelectionRange(newPos, newPos)
|
||||||
cursorPos.value = newPos
|
cursorPos.value = newPos
|
||||||
@@ -369,6 +388,24 @@ function applyAutocomplete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleInput() {
|
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
|
// Update autocomplete on input
|
||||||
updateAutocomplete()
|
updateAutocomplete()
|
||||||
|
|
||||||
@@ -386,15 +423,16 @@ function handleInput() {
|
|||||||
// Check if any word is invalid (not a valid prefix or complete word)
|
// Check if any word is invalid (not a valid prefix or complete word)
|
||||||
hasInvalidWord.value = !checkWordsValidity(code.value)
|
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)
|
// Connect to WebSocket on first meaningful input (start solving PoW early)
|
||||||
if (words.length >= 1 && !ws && !wsConnecting) {
|
if (currentWords.length >= 1 && !ws && !wsConnecting) {
|
||||||
ensureConnection()
|
ensureConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for invalid words when we have 3 words
|
// Check for invalid words when we have 3 words
|
||||||
if (words.length === 3) {
|
if (currentWords.length === 3) {
|
||||||
if (!allWordsValid(code.value)) {
|
if (!allWordsValid(code.value)) {
|
||||||
// Don't show error message - the red input is enough feedback
|
// Don't show error message - the red input is enough feedback
|
||||||
return
|
return
|
||||||
@@ -435,8 +473,8 @@ async function lookupDeviceInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get PoW solution (may wait for background computation)
|
// Get PoW solution (may wait for background computation)
|
||||||
const nonce = await getPowNonce()
|
const solution = await getPowSolution()
|
||||||
const powB64 = btoa(String.fromCharCode(...nonce))
|
const powB64 = b64enc(solution)
|
||||||
|
|
||||||
// Take the field value NOW (after PoW is solved) - user may have changed it
|
// Take the field value NOW (after PoW is solved) - user may have changed it
|
||||||
const currentCode = normalizeCode(code.value)
|
const currentCode = normalizeCode(code.value)
|
||||||
@@ -459,16 +497,20 @@ async function lookupDeviceInfo() {
|
|||||||
// Receive response
|
// Receive response
|
||||||
const res = await ws.receive_json()
|
const res = await ws.receive_json()
|
||||||
|
|
||||||
// Update challenge for next request
|
// Update challenge for next request (included in all responses)
|
||||||
updateChallenge(res.pow)
|
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
|
serverError.value = true
|
||||||
deviceInfo.value = null
|
deviceInfo.value = null
|
||||||
lastLookedUpCode = null
|
lastLookedUpCode = null
|
||||||
// Don't select text - let user continue editing
|
// Keep connection open for retries (challenge was updated above)
|
||||||
// User needs to change the code before we retry
|
return
|
||||||
} else if (res.status === 'found' && res.host) {
|
}
|
||||||
|
|
||||||
|
if (res.status === 'found' && res.host) {
|
||||||
// Success! Set field to normalized value
|
// Success! Set field to normalized value
|
||||||
code.value = currentCode.replace(/\./g, ' ')
|
code.value = currentCode.replace(/\./g, ' ')
|
||||||
deviceInfo.value = {
|
deviceInfo.value = {
|
||||||
@@ -481,12 +523,20 @@ async function lookupDeviceInfo() {
|
|||||||
submitBtnRef.value?.focus()
|
submitBtnRef.value?.focus()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
authStore.showMessage('Unexpected response from server', 'error', 3000)
|
||||||
serverError.value = true
|
serverError.value = true
|
||||||
deviceInfo.value = null
|
deviceInfo.value = null
|
||||||
lastLookedUpCode = null
|
lastLookedUpCode = null
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Lookup error:', 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
|
serverError.value = true
|
||||||
deviceInfo.value = null
|
deviceInfo.value = null
|
||||||
lastLookedUpCode = null
|
lastLookedUpCode = null
|
||||||
@@ -508,6 +558,8 @@ function handleKeydown(event) {
|
|||||||
const applied = applyAutocomplete()
|
const applied = applyAutocomplete()
|
||||||
if (applied) {
|
if (applied) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
// Trigger input handling since programmatic changes don't fire input event
|
||||||
|
handleInput()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,6 +575,8 @@ function handleKeydown(event) {
|
|||||||
const applied = applyAutocomplete()
|
const applied = applyAutocomplete()
|
||||||
if (applied) {
|
if (applied) {
|
||||||
event.preventDefault()
|
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
|
// Get PoW solution for authenticate request
|
||||||
const nonce = await getPowNonce()
|
const solution = await getPowSolution()
|
||||||
const powB64 = btoa(String.fromCharCode(...nonce))
|
const powB64 = b64enc(solution)
|
||||||
|
|
||||||
// Send authenticate request with PoW
|
// Send authenticate request with PoW
|
||||||
ws.send_json({
|
ws.send_json({
|
||||||
@@ -556,8 +610,9 @@ async function submitCode() {
|
|||||||
// Receive authentication options
|
// Receive authentication options
|
||||||
const res = await ws.receive_json()
|
const res = await ws.receive_json()
|
||||||
|
|
||||||
if (res.status === 'error') {
|
// Check for server error response
|
||||||
throw new Error(res.error || 'Authentication failed')
|
if (typeof res.status === 'number' && res.status >= 400) {
|
||||||
|
throw new Error(res.detail || 'Authentication failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.optionsJSON) {
|
if (!res.optionsJSON) {
|
||||||
@@ -571,19 +626,29 @@ async function submitCode() {
|
|||||||
// Wait for confirmation
|
// Wait for confirmation
|
||||||
const result = await ws.receive_json()
|
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') {
|
if (result.status === 'success') {
|
||||||
completed.value = true
|
completed.value = true
|
||||||
completedMessage.value = result.message || 'The other device is now logged in.'
|
completedMessage.value = result.message || 'The other device is now logged in.'
|
||||||
|
authStore.showMessage('Device authenticated successfully!', 'success', 3000)
|
||||||
emit('completed')
|
emit('completed')
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.detail || result.error || 'Authentication failed')
|
throw new Error(result.detail || 'Authentication failed')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Pairing error:', err)
|
console.error('Pairing error:', err)
|
||||||
|
const isDisconnect = err.message?.includes('closed')
|
||||||
const message = err.name === 'NotAllowedError'
|
const message = err.name === 'NotAllowedError'
|
||||||
? 'Passkey authentication was cancelled'
|
? 'Passkey authentication was cancelled'
|
||||||
: (err.message || 'Failed to connect')
|
: isDisconnect
|
||||||
|
? 'Connection lost. Please try again.'
|
||||||
|
: (err.message || 'Failed to connect')
|
||||||
error.value = message
|
error.value = message
|
||||||
|
authStore.showMessage(message, 'error', 4000)
|
||||||
emit('error', message)
|
emit('error', message)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -613,9 +678,9 @@ function reset() {
|
|||||||
ws = null
|
ws = null
|
||||||
}
|
}
|
||||||
currentChallenge = null
|
currentChallenge = null
|
||||||
currentBits = null
|
currentWork = null
|
||||||
powPromise = null
|
powPromise = null
|
||||||
powNonce = null
|
powSolution = null
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
import { startAuthentication } from '@simplewebauthn/browser'
|
import { startAuthentication } from '@simplewebauthn/browser'
|
||||||
import aWebSocket from '@/utils/awaitable-websocket'
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
|
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||||
import { getSettings, uiBasePath } from '@/utils/settings'
|
import { getSettings, uiBasePath } from '@/utils/settings'
|
||||||
import { solvePoW } from '@/utils/pow'
|
import { solvePoW } from '@/utils/pow'
|
||||||
|
|
||||||
@@ -122,13 +123,12 @@ async function authenticate() {
|
|||||||
// First message is PoW challenge
|
// First message is PoW challenge
|
||||||
const powChallenge = await ws.receive_json()
|
const powChallenge = await ws.receive_json()
|
||||||
if (powChallenge.pow) {
|
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 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 powTime = performance.now() - powStart
|
||||||
const nonceVal = nonce.reduce((acc, b, i) => acc + BigInt(b) * (1n << BigInt(i * 8)), 0n)
|
console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`)
|
||||||
console.log(`PoW solved: ${Number(nonceVal)} iterations in ${(powTime / 1000).toFixed(3)}s (${(powTime / Number(nonceVal)).toFixed(3)}ms/iter)`)
|
ws.send_json({ pow: b64enc(nonces) })
|
||||||
ws.send_json({ pow: btoa(String.fromCharCode(...nonce)) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive authentication options
|
// Receive authentication options
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
||||||
import QRCode from 'qrcode/lib/browser'
|
import QRCode from 'qrcode/lib/browser'
|
||||||
import aWebSocket from '@/utils/awaitable-websocket'
|
import aWebSocket from '@/utils/awaitable-websocket'
|
||||||
|
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
|
||||||
import { getSettings } from '@/utils/settings'
|
import { getSettings } from '@/utils/settings'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
import { solvePoW } from '@/utils/pow'
|
import { solvePoW } from '@/utils/pow'
|
||||||
@@ -156,13 +157,12 @@ async function startRemoteAuth() {
|
|||||||
// First message is PoW challenge
|
// First message is PoW challenge
|
||||||
const powChallenge = await ws.receive_json()
|
const powChallenge = await ws.receive_json()
|
||||||
if (powChallenge.pow) {
|
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 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 powTime = performance.now() - powStart
|
||||||
const nonceVal = nonce.reduce((acc, b, i) => acc + BigInt(b) * (1n << BigInt(i * 8)), 0n)
|
console.log(`PoW solved: ${powChallenge.pow.work} work units in ${(powTime / 1000).toFixed(3)}s`)
|
||||||
console.log(`PoW solved: ${Number(nonceVal)} iterations in ${(powTime / 1000).toFixed(3)}s (${(powTime / Number(nonceVal)).toFixed(3)}ms/iter)`)
|
ws.send_json({ pow: b64enc(nonces) })
|
||||||
ws.send_json({ pow: btoa(String.fromCharCode(...nonce)) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive the remote auth token, pairing code, and URL
|
// 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 TRIALS = 5
|
||||||
const REQUIRED_BITS = 18
|
const WORK = 10
|
||||||
|
|
||||||
async function test() {
|
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 times = []
|
||||||
const iterations = []
|
|
||||||
|
|
||||||
for (let trial = 1; trial <= TRIALS; trial++) {
|
for (let trial = 1; trial <= TRIALS; trial++) {
|
||||||
const challenge = crypto.getRandomValues(new Uint8Array(8))
|
const challenge = crypto.getRandomValues(new Uint8Array(8))
|
||||||
|
|
||||||
const start = performance.now()
|
const start = performance.now()
|
||||||
let lastIterations = 0
|
const solution = await solvePoW(challenge, WORK)
|
||||||
const nonce = await solvePoW(challenge, REQUIRED_BITS, {
|
|
||||||
onProgress: (n) => { lastIterations = n }
|
|
||||||
})
|
|
||||||
const elapsed = performance.now() - start
|
const elapsed = performance.now() - start
|
||||||
|
|
||||||
const valid = await verifyPoW(challenge, nonce, REQUIRED_BITS)
|
const valid = await verifyPoW(challenge, solution, WORK)
|
||||||
const nonceVal = nonceToNumber(nonce)
|
|
||||||
|
|
||||||
times.push(elapsed)
|
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 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 minTime = Math.min(...times)
|
||||||
const maxTime = Math.max(...times)
|
const maxTime = Math.max(...times)
|
||||||
|
|
||||||
console.log('\n--- Summary ---')
|
console.log('\n--- Summary ---')
|
||||||
console.log(`Trials: ${TRIALS}`)
|
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(`Avg time: ${(avgTime / 1000).toFixed(3)}s`)
|
||||||
console.log(`Min time: ${(minTime / 1000).toFixed(3)}s`)
|
console.log(`Min time: ${(minTime / 1000).toFixed(3)}s`)
|
||||||
console.log(`Max time: ${(maxTime / 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()
|
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
|
* The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
||||||
* SHA-512(challenge || nonce) has a specified number of leading zero bits.
|
* 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
|
* Solve a Proof of Work challenge
|
||||||
*
|
*
|
||||||
* @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided 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 {object} [options] - Optional parameters
|
||||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the operation
|
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the operation
|
||||||
* @param {function} [options.onProgress] - Callback for progress updates (iterations count)
|
* @returns {Promise<Uint8Array>} Solution: concatenated 8-byte nonces (8 * work bytes)
|
||||||
* @returns {Promise<Uint8Array>} The successful 8-byte nonce (little-endian)
|
|
||||||
* @throws {Error} If challenge is invalid or operation is aborted
|
* @throws {Error} If challenge is invalid or operation is aborted
|
||||||
*/
|
*/
|
||||||
export async function solvePoW(challenge, requiredZeroBits, options = {}) {
|
export async function solvePoW(challenge, work, options = {}) {
|
||||||
const { signal, onProgress } = options
|
const { signal } = options
|
||||||
const startTime = performance.now()
|
const startTime = performance.now()
|
||||||
|
|
||||||
// Validate inputs
|
// Validate inputs
|
||||||
@@ -29,114 +29,40 @@ export async function solvePoW(challenge, requiredZeroBits, options = {}) {
|
|||||||
throw new Error('Challenge must be exactly 8 bytes')
|
throw new Error('Challenge must be exactly 8 bytes')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiredZeroBits < 1 || requiredZeroBits > 32) {
|
// Import challenge as PBKDF2 key material
|
||||||
throw new Error('Required zero bits must be between 1 and 32')
|
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 elapsed = (performance.now() - startTime) / 1000
|
||||||
const iterPerSec = Math.round(iterations / elapsed)
|
const expectedIterations = work * (mask + 1)
|
||||||
console.log(`PoW solved in ${elapsed.toFixed(1)}s (${(iterPerSec / 1e3).toFixed(0)} k-it/s)`)
|
const luckRatio = (totalIterations / expectedIterations).toFixed(1)
|
||||||
return data.slice(8, 16)
|
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
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 uuid import UUID
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket
|
||||||
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
|
||||||
|
|
||||||
from paskia import remoteauth
|
|
||||||
from paskia.authsession import create_session, get_reset, get_session
|
from paskia.authsession import create_session, get_reset, get_session
|
||||||
from paskia.fastapi import authz
|
from paskia.fastapi import authz
|
||||||
from paskia.fastapi.session import AUTH_COOKIE, infodict
|
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.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
|
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
|
# Create a FastAPI subapp for WebSocket endpoints
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
# Mount the remote auth subapp
|
||||||
async def _require_pow(ws: WebSocket) -> None:
|
from paskia.fastapi import remote
|
||||||
"""Send a PoW challenge and verify the client's solution.
|
app.mount("/remote-auth", remote.app)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
async def register_chat(
|
async def register_chat(
|
||||||
@@ -122,7 +51,7 @@ async def websocket_register_add(
|
|||||||
- Normal session via auth cookie (requires recent authentication)
|
- Normal session via auth cookie (requires recent authentication)
|
||||||
- Reset token supplied as ?reset=... (auth cookie ignored)
|
- Reset token supplied as ?reset=... (auth cookie ignored)
|
||||||
"""
|
"""
|
||||||
origin = _validate_origin(ws)
|
origin = validate_origin(ws)
|
||||||
host = origin.split("://", 1)[1]
|
host = origin.split("://", 1)[1]
|
||||||
if reset is not None:
|
if reset is not None:
|
||||||
if not passphrase.is_well_formed(reset):
|
if not passphrase.is_well_formed(reset):
|
||||||
@@ -178,7 +107,7 @@ async def websocket_register_add(
|
|||||||
@app.websocket("/authenticate")
|
@app.websocket("/authenticate")
|
||||||
@websocket_error_handler
|
@websocket_error_handler
|
||||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||||
origin = _validate_origin(ws)
|
origin = validate_origin(ws)
|
||||||
host = origin.split("://", 1)[1]
|
host = origin.split("://", 1)[1]
|
||||||
|
|
||||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
# 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,
|
"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:
|
if req is None:
|
||||||
return False
|
return False
|
||||||
req.notify = callback
|
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
|
return True
|
||||||
|
|
||||||
async def complete_request(
|
async def complete_request(
|
||||||
@@ -228,18 +222,15 @@ class RemoteAuthManager:
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Mark a request as completed with the authentication result.
|
"""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.
|
Returns True if the request existed and was completed.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
req = self._requests.get(token)
|
req = self._requests.pop(token, None)
|
||||||
if req is None:
|
if req is None:
|
||||||
return False
|
return False
|
||||||
if req.completed:
|
# Remove from pairing code index
|
||||||
return False # Already completed
|
self._by_pairing_code.pop(req.pairing_code, None)
|
||||||
req.completed = True
|
|
||||||
req.session_token = session_token
|
|
||||||
req.user_uuid = user_uuid
|
|
||||||
req.credential_uuid = credential_uuid
|
|
||||||
if req.notify:
|
if req.notify:
|
||||||
try:
|
try:
|
||||||
req.notify(session_token, user_uuid, credential_uuid)
|
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
|
The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
||||||
SHA-512(challenge || nonce) has a specified number of leading zero bits.
|
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.
|
||||||
Both challenge and nonce are 8 bytes (uint64), concatenated into 16 bytes for hashing.
|
|
||||||
The nonce is little-endian.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import secrets
|
||||||
|
|
||||||
# Default difficulty: 16 bits means ~65k iterations on average
|
|
||||||
DEFAULT_POW_BITS = 16
|
|
||||||
|
|
||||||
|
EASY = 2 # Around 0.25s
|
||||||
|
NORMAL = 8 # Around 1s
|
||||||
|
HARD = 32 # Around 4s
|
||||||
|
|
||||||
def generate_challenge() -> bytes:
|
def generate_challenge() -> bytes:
|
||||||
"""Generate a random 8-byte challenge."""
|
"""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.
|
"""Verify a Proof of Work solution.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
challenge: 8-byte server-provided challenge
|
challenge: 8-byte server-provided challenge
|
||||||
nonce: 8-byte client-provided nonce (little-endian)
|
solution: Concatenated 8-byte nonces (8 * work bytes)
|
||||||
required_bits: Number of leading zero bits required (1-32)
|
work: Number of work units expected
|
||||||
|
|
||||||
Returns:
|
Raises:
|
||||||
True if the solution is valid
|
ValueError: If the solution is invalid
|
||||||
"""
|
"""
|
||||||
if len(challenge) != 8 or len(nonce) != 8:
|
if len(challenge) != 8:
|
||||||
return False
|
raise ValueError("Invalid challenge length")
|
||||||
|
|
||||||
if required_bits < 1 or required_bits > 32:
|
if len(solution) != 8 * work:
|
||||||
return False
|
raise ValueError("Invalid solution length")
|
||||||
|
|
||||||
# Concatenate challenge and nonce
|
# Verify each work unit - check that PBKDF2 output starts with 0x00
|
||||||
data = challenge + nonce
|
for i in range(work):
|
||||||
|
nonce = solution[i * 8 : (i + 1) * 8]
|
||||||
# Calculate SHA-512 hash
|
# Require first byte of PBKDF2-SHA512 to be zero
|
||||||
hash_bytes = hashlib.sha512(data).digest()
|
result = hashlib.pbkdf2_hmac("sha512", challenge, nonce, 128, 2)
|
||||||
|
if result[0] or result[1] & 0x07:
|
||||||
# Check leading zero bits
|
raise ValueError("Invalid PoW solution")
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
|
import base64url
|
||||||
|
|
||||||
from paskia.util.passphrase import is_well_formed
|
from paskia.util.passphrase import is_well_formed
|
||||||
|
|
||||||
|
|
||||||
@@ -12,21 +13,20 @@ def create_token() -> str:
|
|||||||
def session_key(token: str) -> bytes:
|
def session_key(token: str) -> bytes:
|
||||||
if len(token) != 16:
|
if len(token) != 16:
|
||||||
raise ValueError("Session token must be exactly 16 characters long")
|
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:
|
def encode_session_key(key: bytes) -> str:
|
||||||
"""Encode an opaque session key for external representation."""
|
"""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:
|
def decode_session_key(encoded: str) -> bytes:
|
||||||
"""Decode an opaque session key from its public representation."""
|
"""Decode an opaque session key from its public representation."""
|
||||||
if not encoded:
|
if not encoded:
|
||||||
raise ValueError("Invalid session identifier")
|
raise ValueError("Invalid session identifier")
|
||||||
padding = "=" * (-len(encoded) % 4)
|
|
||||||
try:
|
try:
|
||||||
raw = base64.urlsafe_b64decode(encoded + padding)
|
raw = base64url.dec(encoded)
|
||||||
except Exception as exc: # pragma: no cover - defensive
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
raise ValueError("Invalid session identifier") from exc
|
raise ValueError("Invalid session identifier") from exc
|
||||||
if not raw.startswith(b"sess"):
|
if not raw.startswith(b"sess"):
|
||||||
|
|||||||
Reference in New Issue
Block a user