Proper PoW, unified verification WS etc
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
Reference in New Issue
Block a user