Proper PoW, unified verification WS etc

This commit is contained in:
Leo Vasanko
2025-12-07 04:12:11 +00:00
parent 9485cbd201
commit 4bb64863f9
8 changed files with 1014 additions and 179 deletions
+473 -100
View File
@@ -6,29 +6,35 @@
</div>
<form @submit.prevent="submitCode" class="pairing-form">
<div class="input-wrapper">
<input
ref="inputRef"
v-model="code"
type="text"
:placeholder="placeholder"
:disabled="loading || completed"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="pairing-input"
@input="handleInput"
@keydown="handleKeydown"
/>
<!-- Autocomplete hint overlay -->
<span v-if="autocompleteHint" class="autocomplete-hint">{{ autocompleteHintDisplay }}</span>
</div>
<!-- Looking up state -->
<div v-if="lookingUp" class="lookup-status">
<span class="lookup-spinner"></span>
<span>Looking up device</span>
<div class="input-row">
<div class="input-wrapper" :class="{ 'has-error': serverError, 'is-complete': deviceInfo && !serverError && !completed, 'is-verified': completed }">
<!-- Colored overlay to show invalid words in red -->
<span v-if="hasInvalidWord" class="input-overlay" aria-hidden="true"><span v-for="(segment, i) in coloredSegments" :key="i" :class="{ 'invalid-word': segment.invalid }">{{ segment.text }}</span></span>
<input
ref="inputRef"
v-model="code"
type="text"
:placeholder="placeholder"
:disabled="completed"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="pairing-input"
:class="{ 'text-hidden': hasInvalidWord }"
@input="handleInput"
@keydown="handleKeydown"
@click="updateAutocomplete"
@select="updateAutocomplete"
/>
<!-- Autocomplete hint overlay: shows text before cursor + hint suffix -->
<span v-if="autocompleteHint && !hasInvalidWord" class="autocomplete-hint" aria-hidden="true"><span class="hint-prefix">{{ autocompletePrefix }}</span><span class="hint-suffix">{{ autocompleteSuffix }}</span></span>
</div>
<!-- Processing status beside input -->
<div v-if="processingStatus" class="processing-status">
<span class="processing-icon">{{ processingStatus === 'pow' ? '🔐' : '📡' }}</span>
<span class="processing-spinner-small"></span>
</div>
</div>
<!-- Device info display (shown when 3 words match a request) -->
@@ -45,6 +51,7 @@
</div>
</div>
<button
ref="submitBtnRef"
type="submit"
:disabled="loading"
class="btn-primary"
@@ -65,12 +72,12 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
import { apiJson } from '@/utils/api'
import { getUniqueMatch, isValidWord } from '@/utils/wordlist'
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
import { solvePoW } from '@/utils/pow'
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
@@ -81,19 +88,56 @@ const props = defineProps({
const emit = defineEmits(['completed', 'error', 'cancelled'])
const inputRef = ref(null)
const submitBtnRef = ref(null)
const code = ref('')
const loading = ref(false)
const lookingUp = ref(false)
const isProcessing = ref(false) // Track if we're processing (solving PoW or waiting for server)
const processingStatus = ref('') // 'pow' = computing PoW, 'server' = waiting for server
const error = ref(null)
const completed = ref(false)
const completedMessage = ref('')
const deviceInfo = ref(null)
const autocompleteHint = ref('')
const invalidWords = ref([]) // Track which words are invalid
let ws = null
let lookupTimeout = null
const hasInvalidWord = ref(false) // Track if any word is invalid (not a valid prefix)
const serverError = ref(false) // Track if server rejected the code
const cursorPos = ref(0) // Track cursor position for autocomplete
// Get the current word being typed (the last word without a separator)
// Unified WebSocket state
let ws = null
let wsConnecting = false
let currentChallenge = null // Current PoW challenge from server
let currentBits = null
let powPromise = null // Promise for background PoW computation
let powNonce = null // Solved PoW nonce ready to use
let lookupTimeout = null
let lastLookedUpCode = null // Track last code we looked up to avoid duplicates
// Get the word at cursor position
function getWordAtCursor(input, cursor) {
if (!input || cursor < 0) return { word: '', start: 0, end: 0 }
// Find word boundaries around cursor
let start = cursor
let end = cursor
// Move start back to find word beginning
while (start > 0 && /[a-zA-Z]/.test(input[start - 1])) {
start--
}
// Move end forward to find word end
while (end < input.length && /[a-zA-Z]/.test(input[end])) {
end++
}
return {
word: input.slice(start, end),
start,
end
}
}
// Get the current word being typed (the last word without a separator) - kept for validation
function getCurrentWord(input) {
const match = input.match(/[a-zA-Z]+$/)
return match ? match[0] : ''
@@ -104,30 +148,94 @@ function getWords(input) {
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
}
// Count completed words (separated by space or dot)
function countWords(input) {
return getWords(input).length
}
// Validate all words and return list of invalid ones
function validateWords(input) {
// Count complete words (words followed by a separator)
function countCompleteWords(input) {
// Count words that are followed by a separator
const endsWithSeparator = /[.\s]$/.test(input)
const words = getWords(input)
return words.filter(w => !isValidWord(w))
return endsWithSeparator ? words.length : Math.max(0, words.length - 1)
}
// Check if all words are valid
// Check validity and return info about each word segment
function analyzeWords(input) {
if (!input) return { valid: true, segments: [] }
const segments = []
const endsWithSeparator = /[.\s]$/.test(input)
// Parse input preserving separators
let pos = 0
const regex = /([a-zA-Z]+)|([.\s]+)/g
let match
while ((match = regex.exec(input)) !== null) {
if (match[1]) {
// Word
segments.push({ text: match[1], isWord: true, start: match.index })
} else if (match[2]) {
// Separator
segments.push({ text: match[2], isWord: false, start: match.index })
}
}
// Determine validity of each word
const words = segments.filter(s => s.isWord)
let allValid = true
words.forEach((wordSeg, idx) => {
const isLastWord = idx === words.length - 1
const word = wordSeg.text.toLowerCase()
if (isLastWord && !endsWithSeparator) {
// Currently being typed - just needs to be valid prefix
wordSeg.invalid = !isValidPrefix(word)
} else {
// Complete word - must be a valid word
wordSeg.invalid = !isValidWord(word)
}
if (wordSeg.invalid) allValid = false
})
return { valid: allValid, segments }
}
// Computed property for colored segments
const coloredSegments = computed(() => {
const { segments } = analyzeWords(code.value)
return segments.map(s => ({
text: s.text,
invalid: s.invalid || false
}))
})
// Check if all completed words are valid and current word is a valid prefix
function checkWordsValidity(input) {
return analyzeWords(input).valid
}
// Check if all words are valid complete words
function allWordsValid(input) {
const words = getWords(input)
return words.length > 0 && words.every(w => isValidWord(w))
}
// Compute autocomplete hint for display
const autocompleteHintDisplay = computed(() => {
// Compute autocomplete prefix (text up to and including current word at cursor)
const autocompletePrefix = computed(() => {
if (!autocompleteHint.value || !code.value) return ''
const currentWord = getCurrentWord(code.value)
if (!currentWord) return ''
// Return the typed text + remaining hint characters
return code.value + autocompleteHint.value.slice(currentWord.length)
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return ''
// Return text up to end of current word (this part is transparent)
return code.value.slice(0, end)
})
// Compute autocomplete suffix (the completion part only)
const autocompleteSuffix = computed(() => {
if (!autocompleteHint.value || !code.value) return ''
const { word } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return ''
// Return only the remaining characters of the hint
return autocompleteHint.value.slice(word.length)
})
// Check if we have exactly 3 valid words
@@ -141,38 +249,122 @@ function normalizeCode(input) {
return input.trim().toLowerCase().split(/[.\s]+/).filter(w => w).join('.')
}
// Update autocomplete hint based on current input
function updateAutocomplete() {
const currentWord = getCurrentWord(code.value)
const wordCount = countWords(code.value)
// Start solving PoW in background
function startPowSolving() {
if (!currentChallenge || powPromise) return
// Don't autocomplete if we already have 3 words or no current word
if (wordCount >= 3 || !currentWord || currentWord.length < 1) {
const challenge = Uint8Array.from(atob(currentChallenge), c => c.charCodeAt(0))
powPromise = solvePoW(challenge, currentBits).then(nonce => {
powNonce = nonce
powPromise = null
})
}
// Get the solved PoW nonce, waiting if necessary
async function getPowNonce() {
if (powNonce) {
const nonce = powNonce
powNonce = null
return nonce
}
if (powPromise) {
await powPromise
const nonce = powNonce
powNonce = null
return nonce
}
// 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)
}
// Update challenge from server response and start solving
function updateChallenge(pow) {
if (pow?.challenge) {
currentChallenge = pow.challenge
currentBits = pow.bits
powNonce = null
powPromise = null
// Start solving in background immediately
startPowSolving()
}
}
// Connect to the unified WebSocket endpoint
async function ensureConnection() {
if (ws || wsConnecting) return
wsConnecting = true
try {
const settings = await getSettings()
const authHost = settings?.auth_host
// Single unified endpoint - no code in URL!
const wsPath = '/auth/ws/remote-auth/pair'
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
ws = await aWebSocket(wsUrl)
// First message is PoW challenge
const msg = await ws.receive_json()
updateChallenge(msg.pow)
} catch (err) {
console.error('WebSocket connection error:', err)
ws = null
} finally {
wsConnecting = false
}
}
// Update autocomplete hint based on current input and cursor position
function updateAutocomplete() {
// Update cursor position
cursorPos.value = inputRef.value?.selectionStart ?? code.value.length
const { word, end } = getWordAtCursor(code.value, cursorPos.value)
const completeWordCount = countCompleteWords(code.value)
// Don't autocomplete if:
// - we already have 3 complete words
// - no current word at cursor
// - cursor is not at the end of the word (user is editing middle of word)
if (completeWordCount >= 3 || !word || word.length < 1 || cursorPos.value !== end) {
autocompleteHint.value = ''
return
}
// Find unique match for current prefix
const match = getUniqueMatch(currentWord.toLowerCase())
if (match && match !== currentWord.toLowerCase()) {
const match = getUniqueMatch(word.toLowerCase())
if (match && match !== word.toLowerCase()) {
autocompleteHint.value = match
} else {
autocompleteHint.value = ''
}
}
// Apply autocomplete - complete the current word
// Apply autocomplete - complete the current word at cursor
function applyAutocomplete() {
if (!autocompleteHint.value) return false
const currentWord = getCurrentWord(code.value)
if (!currentWord) return false
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return false
// Replace the current word with the full word + space
const before = code.value.slice(0, start)
const after = code.value.slice(end)
code.value = before + autocompleteHint.value + ' ' + after
// Move cursor to after the inserted word + space
const newPos = start + autocompleteHint.value.length + 1
nextTick(() => {
inputRef.value?.setSelectionRange(newPos, newPos)
cursorPos.value = newPos
})
// Replace the current partial word with the full word + space
const beforeCurrent = code.value.slice(0, code.value.length - currentWord.length)
code.value = beforeCurrent + autocompleteHint.value + ' '
autocompleteHint.value = ''
return true
}
@@ -189,16 +381,22 @@ function handleInput() {
// Reset device info when code changes
deviceInfo.value = null
error.value = null
invalidWords.value = []
serverError.value = false
// Check if any word is invalid (not a valid prefix or complete word)
hasInvalidWord.value = !checkWordsValidity(code.value)
const words = getWords(code.value)
// Connect to WebSocket on first meaningful input (start solving PoW early)
if (words.length >= 1 && !ws && !wsConnecting) {
ensureConnection()
}
// Check for invalid words when we have 3 words
if (words.length === 3) {
const invalid = validateWords(code.value)
if (invalid.length > 0) {
invalidWords.value = invalid
error.value = `Unknown word${invalid.length > 1 ? 's' : ''}: ${invalid.join(', ')}`
if (!allWordsValid(code.value)) {
// Don't show error message - the red input is enough feedback
return
}
@@ -210,32 +408,118 @@ function handleInput() {
}
async function lookupDeviceInfo() {
if (!hasThreeValidWords.value || loading.value) return
// Don't start a new lookup if we're already processing or loading
if (isProcessing.value || loading.value) return
lookingUp.value = true
// Check if current input has 3 valid words
if (!hasThreeValidWords.value) return
const normalizedCode = normalizeCode(code.value)
// Skip if we already successfully looked up this exact code
if (normalizedCode === lastLookedUpCode && deviceInfo.value) {
return
}
isProcessing.value = true
processingStatus.value = 'pow'
error.value = null
serverError.value = false
try {
const normalizedCode = normalizeCode(code.value)
const info = await apiJson(`/auth/api/remote-auth-info?code=${encodeURIComponent(normalizedCode)}`)
deviceInfo.value = info
} catch (err) {
// 404 means no matching request found
if (err.status === 404) {
error.value = 'No device found with this code. Check the code and try again.'
} else {
console.error('Lookup error:', err)
error.value = err.message || 'Failed to look up device'
// Ensure we have a connection
await ensureConnection()
if (!ws) {
throw new Error('Failed to connect')
}
// Get PoW solution (may wait for background computation)
const nonce = await getPowNonce()
const powB64 = btoa(String.fromCharCode(...nonce))
// Take the field value NOW (after PoW is solved) - user may have changed it
const currentCode = normalizeCode(code.value)
// Check if we still have 3 valid words
if (!hasThreeValidWords.value) {
// User changed input while we were solving PoW - abort this lookup
return
}
// Now waiting for server
processingStatus.value = 'server'
// Send current code + PoW
ws.send_json({
code: currentCode,
pow: powB64
})
// Receive response
const res = await ws.receive_json()
// Update challenge for next request
updateChallenge(res.pow)
if (res.status === 'error') {
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) {
// Success! Set field to normalized value
code.value = currentCode.replace(/\./g, ' ')
deviceInfo.value = {
host: res.host,
user_agent_pretty: res.user_agent_pretty
}
lastLookedUpCode = currentCode
// Focus the submit button after device info loads
nextTick(() => {
submitBtnRef.value?.focus()
})
} else {
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
}
} catch (err) {
console.error('Lookup error:', err)
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
// Close broken connection
if (ws) {
ws.close()
ws = null
}
} finally {
lookingUp.value = false
isProcessing.value = false
processingStatus.value = ''
}
}
function handleKeydown(event) {
// Tab or Space triggers autocomplete if we have a hint
if ((event.key === 'Tab' || event.key === ' ') && autocompleteHint.value) {
// Tab triggers autocomplete if we have a hint
if (event.key === 'Tab') {
if (autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) {
event.preventDefault()
return
}
}
// Prevent Tab from moving focus if input has content (keep user in the input)
if (code.value.trim()) {
event.preventDefault()
}
return
}
// Space triggers autocomplete if we have a hint
if (event.key === ' ' && autocompleteHint.value) {
const applied = applyAutocomplete()
if (applied) {
event.preventDefault()
@@ -250,25 +534,38 @@ async function submitCode() {
error.value = null
try {
const settings = await getSettings()
const authHost = settings?.auth_host
// Ensure we still have the connection
if (!ws) {
await ensureConnection()
}
const normalizedCode = normalizeCode(code.value)
if (!ws) {
throw new Error('Failed to connect')
}
const wsPath = `/auth/ws/remote-auth/pair/${encodeURIComponent(normalizedCode)}`
const wsUrl = authHost && location.host !== authHost ? `//${authHost}${wsPath}` : wsPath
// Get PoW solution for authenticate request
const nonce = await getPowNonce()
const powB64 = btoa(String.fromCharCode(...nonce))
ws = await aWebSocket(wsUrl)
// Send authenticate request with PoW
ws.send_json({
authenticate: true,
pow: powB64
})
// Receive authentication options
const res = await ws.receive_json()
if (res.status) {
throw new Error(res.detail || `Connection failed: ${res.status}`)
if (res.status === 'error') {
throw new Error(res.error || 'Authentication failed')
}
if (!res.optionsJSON) {
throw new Error(res.detail || 'Failed to get authentication options')
}
// Perform WebAuthn authentication
const authResponse = await startAuthentication(res.optionsJSON || res)
const authResponse = await startAuthentication(res.optionsJSON)
ws.send_json(authResponse)
// Wait for confirmation
@@ -279,7 +576,7 @@ async function submitCode() {
completedMessage.value = result.message || 'The other device is now logged in.'
emit('completed')
} else {
throw new Error(result.detail || 'Authentication failed')
throw new Error(result.detail || result.error || 'Authentication failed')
}
} catch (err) {
console.error('Pairing error:', err)
@@ -290,6 +587,7 @@ async function submitCode() {
emit('error', message)
} finally {
loading.value = false
// Close the WebSocket after authentication attempt
if (ws) {
ws.close()
ws = null
@@ -300,12 +598,24 @@ async function submitCode() {
function reset() {
code.value = ''
error.value = null
serverError.value = false
completed.value = false
completedMessage.value = ''
deviceInfo.value = null
lookingUp.value = false
isProcessing.value = false
processingStatus.value = ''
autocompleteHint.value = ''
invalidWords.value = []
hasInvalidWord.value = false
lastLookedUpCode = null
// Close WebSocket on reset
if (ws) {
ws.close()
ws = null
}
currentChallenge = null
currentBits = null
powPromise = null
powNonce = null
}
onMounted(() => {
@@ -353,9 +663,56 @@ defineExpose({ reset })
gap: 0.5rem;
}
.input-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.input-wrapper {
position: relative;
display: flex;
max-width: 21ch;
}
.input-wrapper.has-error .pairing-input {
border-color: var(--color-error, #ef4444);
}
.input-wrapper.has-error .pairing-input:focus {
border-color: var(--color-error, #ef4444);
box-shadow: 0 0 0 2px var(--color-error-alpha, rgba(239, 68, 68, 0.2));
}
.input-wrapper.is-complete .pairing-input {
border-color: var(--color-success, #10b981);
}
.input-wrapper.is-complete .pairing-input:focus {
border-color: var(--color-success, #10b981);
box-shadow: 0 0 0 2px var(--color-success-alpha, rgba(16, 185, 129, 0.2));
}
.input-wrapper.is-verified .pairing-input {
border-color: var(--color-success, #10b981);
background: var(--color-success-bg, rgba(16, 185, 129, 0.1));
}
.input-overlay {
position: absolute;
left: 0;
top: 0;
padding: 0.625rem 0.75rem;
font-size: 1rem;
font-family: inherit;
color: var(--color-text);
pointer-events: none;
white-space: pre;
border: 1px solid transparent;
}
.input-overlay .invalid-word {
color: var(--color-error, #ef4444);
}
.pairing-input {
@@ -367,6 +724,11 @@ defineExpose({ reset })
border-radius: var(--radius-sm, 4px);
background: var(--color-surface);
color: var(--color-text);
caret-color: var(--color-text);
}
.pairing-input.text-hidden {
color: transparent;
}
.pairing-input:focus {
@@ -392,23 +754,34 @@ defineExpose({ reset })
padding: 0.625rem 0.75rem;
font-size: 1rem;
font-family: inherit;
color: var(--color-text-muted);
opacity: 0.5;
pointer-events: none;
white-space: pre;
}
.lookup-status {
.autocomplete-hint .hint-prefix {
color: transparent;
}
.autocomplete-hint .hint-suffix {
color: var(--color-text-muted);
opacity: 0.6;
}
.processing-status {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.lookup-spinner {
width: 14px;
height: 14px;
.processing-icon {
font-size: 0.875rem;
}
.processing-spinner-small {
width: 12px;
height: 12px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
@@ -61,6 +61,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings, uiBasePath } from '@/utils/settings'
import { solvePoW } from '@/utils/pow'
const props = defineProps({
token: { type: String, required: true }
@@ -118,6 +119,18 @@ async function authenticate() {
ws = await aWebSocket(wsUrl)
// 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 powStart = performance.now()
const nonce = await solvePoW(challenge, powChallenge.pow.bits)
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)) })
}
// Receive authentication options
const res = await ws.receive_json()
@@ -108,6 +108,7 @@ import QRCode from 'qrcode/lib/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
import { formatDate } from '@/utils/helpers'
import { solvePoW } from '@/utils/pow'
const props = defineProps({
active: { type: Boolean, default: false },
@@ -152,6 +153,18 @@ async function startRemoteAuth() {
ws = await aWebSocket(wsUrl)
// 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 powStart = performance.now()
const nonce = await solvePoW(challenge, powChallenge.pow.bits)
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)) })
}
// Receive the remote auth token, pairing code, and URL
const res = await ws.receive_json()
+46
View File
@@ -0,0 +1,46 @@
import { solvePoW, verifyPoW, nonceToNumber } from './pow.js'
const TRIALS = 20
const REQUIRED_BITS = 18
async function test() {
console.log(`Running ${TRIALS} trials with ${REQUIRED_BITS} required zero bits...\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 elapsed = performance.now() - start
const valid = await verifyPoW(challenge, nonce, REQUIRED_BITS)
const nonceVal = nonceToNumber(nonce)
times.push(elapsed)
iterations.push(Number(nonceVal))
console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, nonce=${nonceVal}, 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(`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()
+142
View File
@@ -0,0 +1,142 @@
/**
* Proof of Work utility using SHA-512
*
* The PoW challenge requires finding a nonce such that
* SHA-512(challenge || nonce) has a specified number of leading zero bits.
*/
/**
* 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 {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)
* @throws {Error} If challenge is invalid or operation is aborted
*/
export async function solvePoW(challenge, requiredZeroBits, options = {}) {
const { signal, onProgress } = options
const startTime = performance.now()
// Validate inputs
const challengeBytes = challenge instanceof ArrayBuffer
? new Uint8Array(challenge)
: challenge
if (!(challengeBytes instanceof Uint8Array) || challengeBytes.length !== 8) {
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')
}
// 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
}
File diff suppressed because one or more lines are too long
+204 -79
View File
@@ -1,4 +1,5 @@
import asyncio
import base64
import logging
from functools import wraps
from uuid import UUID
@@ -11,7 +12,7 @@ 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.globals import db, passkey
from paskia.util import hostutil, passphrase
from paskia.util import hostutil, passphrase, pow
from paskia.util.tokens import create_token, session_key
@@ -44,6 +45,39 @@ def websocket_error_handler(func):
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.
@@ -212,10 +246,11 @@ async def websocket_remote_auth_request(ws: WebSocket):
Flow:
1. Client connects
2. Server creates a remote auth token and sends it with URL/expiry/pairing_code
3. Server waits for another device to authenticate via /remote-auth/complete
4. When auth completes, server sends session_token to this client
5. Client can then use the session token to set a cookie
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]
@@ -223,6 +258,9 @@ async def websocket_remote_auth_request(ws: WebSocket):
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
@@ -332,18 +370,22 @@ async def websocket_remote_auth_complete(ws: WebSocket, token: str):
Flow:
1. Client opens the remote auth link and connects here
2. Server verifies the token is valid
3. Server sends WebAuthn options
4. Client authenticates with passkey
5. Server creates session for the REQUESTING device's host
6. Server notifies the requesting device via the callback
7. Server sends confirmation to this client
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:
@@ -406,85 +448,168 @@ async def websocket_remote_auth_complete(ws: WebSocket, token: str):
)
@app.websocket("/remote-auth/pair/{code}")
@app.websocket("/remote-auth/pair")
@websocket_error_handler
async def websocket_remote_auth_pair(ws: WebSocket, code: str):
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.
Flow:
1. User on Device B (with passkey) enters pairing code from Device A
2. Server looks up the remote auth request by pairing code
3. Server sends WebAuthn options
4. User authenticates with passkey
5. Server creates session for Device A's host with Device A's metadata
6. Server notifies Device A via the callback
7. Server sends confirmation to Device B
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")
# Look up the remote auth request by pairing code
request = await remoteauth.instance.get_request_by_pairing_code(code)
if request is None:
raise ValueError("Invalid or expired pairing code")
# Generate initial PoW challenge
challenge = pow.generate_challenge()
bits = pow.DEFAULT_POW_BITS
if request.completed:
raise ValueError("This remote authentication has already been completed")
# The session will be created for the requesting device's host
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 (with their IP/user-agent)
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=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")
# Send confirmation to the authenticating device
await ws.send_json(
{
"status": "success",
"message": "Authentication successful. The other device is now logged in.",
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,
}
})
+62
View File
@@ -0,0 +1,62 @@
"""
Proof of Work utility using SHA-512.
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.
"""
import hashlib
import os
# Default difficulty: 16 bits means ~65k iterations on average
DEFAULT_POW_BITS = 16
def generate_challenge() -> bytes:
"""Generate a random 8-byte challenge."""
return os.urandom(8)
def verify_pow(challenge: bytes, nonce: bytes, required_bits: int = DEFAULT_POW_BITS) -> bool:
"""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)
Returns:
True if the solution is valid
"""
if len(challenge) != 8 or len(nonce) != 8:
return False
if required_bits < 1 or required_bits > 32:
return False
# 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