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