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

This commit is contained in:
Leo Vasanko
2025-12-07 20:12:38 +00:00
parent 4bb64863f9
commit a54d872b46
12 changed files with 665 additions and 703 deletions
+107 -42
View File
@@ -75,9 +75,13 @@
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { dec as b64dec, enc as b64enc } from '@/utils/base64url'
import { getSettings } from '@/utils/settings'
import { getUniqueMatch, isValidWord, isValidPrefix } from '@/utils/wordlist'
import { solvePoW } from '@/utils/pow'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const props = defineProps({
title: { type: String, default: 'Help Another Device Sign In' },
@@ -106,9 +110,9 @@ const cursorPos = ref(0) // Track cursor position for autocomplete
let ws = null
let wsConnecting = false
let currentChallenge = null // Current PoW challenge from server
let currentBits = null
let currentWork = null
let powPromise = null // Promise for background PoW computation
let powNonce = null // Solved PoW nonce ready to use
let powSolution = null // Solved PoW solution ready to use
let lookupTimeout = null
let lastLookedUpCode = null // Track last code we looked up to avoid duplicates
@@ -143,7 +147,7 @@ function getCurrentWord(input) {
return match ? match[0] : ''
}
// Get all words from input
// Get all words from input (normalizes whitespace)
function getWords(input) {
return input.trim().split(/[.\s]+/).filter(w => w.length > 0)
}
@@ -253,40 +257,40 @@ function normalizeCode(input) {
function startPowSolving() {
if (!currentChallenge || powPromise) return
const challenge = Uint8Array.from(atob(currentChallenge), c => c.charCodeAt(0))
powPromise = solvePoW(challenge, currentBits).then(nonce => {
powNonce = nonce
const challenge = b64dec(currentChallenge)
powPromise = solvePoW(challenge, currentWork).then(solution => {
powSolution = solution
powPromise = null
})
}
// Get the solved PoW nonce, waiting if necessary
async function getPowNonce() {
if (powNonce) {
const nonce = powNonce
powNonce = null
return nonce
// Get the solved PoW solution, waiting if necessary
async function getPowSolution() {
if (powSolution) {
const solution = powSolution
powSolution = null
return solution
}
if (powPromise) {
await powPromise
const nonce = powNonce
powNonce = null
return nonce
const solution = powSolution
powSolution = null
return solution
}
// Need to solve now
if (!currentChallenge) {
throw new Error('No PoW challenge available')
}
const challenge = Uint8Array.from(atob(currentChallenge), c => c.charCodeAt(0))
return await solvePoW(challenge, currentBits)
const challenge = b64dec(currentChallenge)
return await solvePoW(challenge, currentWork)
}
// Update challenge from server response and start solving
function updateChallenge(pow) {
if (pow?.challenge) {
currentChallenge = pow.challenge
currentBits = pow.bits
powNonce = null
currentWork = pow.work
powSolution = null
powPromise = null
// Start solving in background immediately
startPowSolving()
@@ -310,10 +314,18 @@ async function ensureConnection() {
// First message is PoW challenge
const msg = await ws.receive_json()
if (msg.status && msg.detail) {
throw new Error(msg.detail)
}
if (!msg.pow?.challenge) {
throw new Error('Server did not send PoW challenge')
}
updateChallenge(msg.pow)
} catch (err) {
console.error('WebSocket connection error:', err)
authStore.showMessage('Pairing service: ' + (err.message || 'Connection failed'), 'error', 4000)
ws = null
throw err // Re-throw so caller knows connection failed
} finally {
wsConnecting = false
}
@@ -352,13 +364,20 @@ function applyAutocomplete() {
const { word, start, end } = getWordAtCursor(code.value, cursorPos.value)
if (!word) return false
// Replace the current word with the full word + space
// Count how many complete words we have before this one
const before = code.value.slice(0, start)
const after = code.value.slice(end)
code.value = before + autocompleteHint.value + ' ' + after
const wordsBefore = getWords(before).length
// Move cursor to after the inserted word + space
const newPos = start + autocompleteHint.value.length + 1
// Don't add space after the 3rd word (wordsBefore is 0-indexed count of words before current)
const isThirdWord = wordsBefore === 2
const suffix = isThirdWord ? '' : ' '
// Replace the current word with the full word (+ space if not 3rd word)
const after = code.value.slice(end)
code.value = before + autocompleteHint.value + suffix + after.trimStart()
// Move cursor to after the inserted word
const newPos = start + autocompleteHint.value.length + suffix.length
nextTick(() => {
inputRef.value?.setSelectionRange(newPos, newPos)
cursorPos.value = newPos
@@ -369,6 +388,24 @@ function applyAutocomplete() {
}
function handleInput() {
// Normalize: trim trailing whitespace if we have 3 complete words
const words = getWords(code.value)
if (words.length >= 3) {
// Normalize to exactly 3 words with single spaces, no trailing space
const normalized = words.slice(0, 3).join(' ')
if (code.value !== normalized) {
const cursorWasAtEnd = cursorPos.value >= code.value.length
code.value = normalized
// Keep cursor at end if it was there
if (cursorWasAtEnd) {
nextTick(() => {
inputRef.value?.setSelectionRange(normalized.length, normalized.length)
cursorPos.value = normalized.length
})
}
}
}
// Update autocomplete on input
updateAutocomplete()
@@ -386,15 +423,16 @@ function handleInput() {
// Check if any word is invalid (not a valid prefix or complete word)
hasInvalidWord.value = !checkWordsValidity(code.value)
const words = getWords(code.value)
// Re-get words after normalization
const currentWords = getWords(code.value)
// Connect to WebSocket on first meaningful input (start solving PoW early)
if (words.length >= 1 && !ws && !wsConnecting) {
if (currentWords.length >= 1 && !ws && !wsConnecting) {
ensureConnection()
}
// Check for invalid words when we have 3 words
if (words.length === 3) {
if (currentWords.length === 3) {
if (!allWordsValid(code.value)) {
// Don't show error message - the red input is enough feedback
return
@@ -435,8 +473,8 @@ async function lookupDeviceInfo() {
}
// Get PoW solution (may wait for background computation)
const nonce = await getPowNonce()
const powB64 = btoa(String.fromCharCode(...nonce))
const solution = await getPowSolution()
const powB64 = b64enc(solution)
// Take the field value NOW (after PoW is solved) - user may have changed it
const currentCode = normalizeCode(code.value)
@@ -459,16 +497,20 @@ async function lookupDeviceInfo() {
// Receive response
const res = await ws.receive_json()
// Update challenge for next request
// Update challenge for next request (included in all responses)
updateChallenge(res.pow)
if (res.status === 'error') {
// Check for error response (HTTP status code format)
if (typeof res.status === 'number' && res.status >= 400) {
authStore.showMessage(res.detail || 'Request failed', 'error', 4000)
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
// Don't select text - let user continue editing
// User needs to change the code before we retry
} else if (res.status === 'found' && res.host) {
// Keep connection open for retries (challenge was updated above)
return
}
if (res.status === 'found' && res.host) {
// Success! Set field to normalized value
code.value = currentCode.replace(/\./g, ' ')
deviceInfo.value = {
@@ -481,12 +523,20 @@ async function lookupDeviceInfo() {
submitBtnRef.value?.focus()
})
} else {
authStore.showMessage('Unexpected response from server', 'error', 3000)
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
}
} catch (err) {
console.error('Lookup error:', err)
// Show appropriate message for connection errors
const isDisconnect = err.message?.includes('closed')
authStore.showMessage(
isDisconnect ? 'Connection lost. Please try again.' : (err.message || 'Lookup failed'),
'error',
4000
)
serverError.value = true
deviceInfo.value = null
lastLookedUpCode = null
@@ -508,6 +558,8 @@ function handleKeydown(event) {
const applied = applyAutocomplete()
if (applied) {
event.preventDefault()
// Trigger input handling since programmatic changes don't fire input event
handleInput()
return
}
}
@@ -523,6 +575,8 @@ function handleKeydown(event) {
const applied = applyAutocomplete()
if (applied) {
event.preventDefault()
// Trigger input handling since programmatic changes don't fire input event
handleInput()
}
}
}
@@ -544,8 +598,8 @@ async function submitCode() {
}
// Get PoW solution for authenticate request
const nonce = await getPowNonce()
const powB64 = btoa(String.fromCharCode(...nonce))
const solution = await getPowSolution()
const powB64 = b64enc(solution)
// Send authenticate request with PoW
ws.send_json({
@@ -556,8 +610,9 @@ async function submitCode() {
// Receive authentication options
const res = await ws.receive_json()
if (res.status === 'error') {
throw new Error(res.error || 'Authentication failed')
// Check for server error response
if (typeof res.status === 'number' && res.status >= 400) {
throw new Error(res.detail || 'Authentication failed')
}
if (!res.optionsJSON) {
@@ -571,19 +626,29 @@ async function submitCode() {
// Wait for confirmation
const result = await ws.receive_json()
// Check for server error response
if (typeof result.status === 'number' && result.status >= 400) {
throw new Error(result.detail || 'Authentication failed')
}
if (result.status === 'success') {
completed.value = true
completedMessage.value = result.message || 'The other device is now logged in.'
authStore.showMessage('Device authenticated successfully!', 'success', 3000)
emit('completed')
} else {
throw new Error(result.detail || result.error || 'Authentication failed')
throw new Error(result.detail || 'Authentication failed')
}
} catch (err) {
console.error('Pairing error:', err)
const isDisconnect = err.message?.includes('closed')
const message = err.name === 'NotAllowedError'
? 'Passkey authentication was cancelled'
: (err.message || 'Failed to connect')
: isDisconnect
? 'Connection lost. Please try again.'
: (err.message || 'Failed to connect')
error.value = message
authStore.showMessage(message, 'error', 4000)
emit('error', message)
} finally {
loading.value = false
@@ -613,9 +678,9 @@ function reset() {
ws = null
}
currentChallenge = null
currentBits = null
currentWork = null
powPromise = null
powNonce = null
powSolution = null
}
onMounted(() => {