Implement code word based remote authentication (#1)
Add comprehensive remote authentication system allowing users to log in from one device by authenticating from another trusted device. Features include: - Proof of Work (PoW) protection using PBKDF2-SHA512 to prevent abuse - Simple pairing codes (3 words) protected by dynamic PoW difficulty - Autocomplete pairing code input with error checking - Real-time WebSocket communication between devices Unlike device addition links and reset links with QR codes that only allow adding an authentication method, and that work offline over the duration of several days, this mechanism is strictly online, with 5 minute time limit.
This commit is contained in:
@@ -18,12 +18,36 @@ class AwaitableWebSocket extends WebSocket {
|
||||
}
|
||||
this.onclose = e => {
|
||||
if (!this.#opened) {
|
||||
reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`))
|
||||
reject(new Error(`Failed to connect to server (code ${e.code})`))
|
||||
return
|
||||
}
|
||||
this.#err = e.wasClean
|
||||
? new Error(`Websocket ${this.url} closed ${e.code}`)
|
||||
: new Error(`WebSocket ${this.url} closed with error ${e.code}`)
|
||||
// Create user-friendly close messages
|
||||
let message
|
||||
if (e.wasClean) {
|
||||
// Standard close codes
|
||||
switch (e.code) {
|
||||
case 1000: message = 'Connection closed normally'; break
|
||||
case 1001: message = 'Server is going away'; break
|
||||
case 1002: message = 'Protocol error'; break
|
||||
case 1003: message = 'Unsupported data received'; break
|
||||
case 1006: message = 'Connection lost unexpectedly'; break
|
||||
case 1007: message = 'Invalid data received'; break
|
||||
case 1008: message = 'Policy violation'; break
|
||||
case 1009: message = 'Message too large'; break
|
||||
case 1010: message = 'Extension negotiation failed'; break
|
||||
case 1011: message = 'Server encountered an error'; break
|
||||
case 1012: message = 'Server is restarting'; break
|
||||
case 1013: message = 'Server is overloaded, try again later'; break
|
||||
case 1014: message = 'Bad gateway'; break
|
||||
case 1015: message = 'TLS handshake failed'; break
|
||||
default: message = `Connection closed (code ${e.code})`
|
||||
}
|
||||
} else {
|
||||
message = e.code === 1006
|
||||
? 'Connection lost unexpectedly'
|
||||
: `Connection closed with error (code ${e.code})`
|
||||
}
|
||||
this.#err = new Error(message)
|
||||
this.#waiting.splice(0).forEach(p => p.reject(this.#err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* URL-safe Base64 encoding/decoding utilities.
|
||||
*
|
||||
* These functions handle base64url format (RFC 4648) which uses:
|
||||
* - '-' instead of '+'
|
||||
* - '_' instead of '/'
|
||||
* - No padding '=' characters
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode a base64url string to Uint8Array.
|
||||
* Handles both standard base64 and URL-safe base64 (with or without padding).
|
||||
* @param {string} str - Base64url encoded string
|
||||
* @returns {Uint8Array} - Decoded bytes
|
||||
*/
|
||||
export function dec(str) {
|
||||
// Convert URL-safe characters to standard base64
|
||||
const base64 = str.replace(/-/g, '+').replace(/_/g, '/')
|
||||
// Add padding if needed
|
||||
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
|
||||
return Uint8Array.from(atob(padded), c => c.charCodeAt(0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a Uint8Array to base64url string.
|
||||
* @param {Uint8Array} bytes - Bytes to encode
|
||||
* @returns {string} - Base64url encoded string (no padding)
|
||||
*/
|
||||
export function enc(bytes) {
|
||||
const base64 = btoa(String.fromCharCode(...bytes))
|
||||
// Convert to URL-safe and remove padding
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { solvePoW, verifyPoW } from './pow.js'
|
||||
|
||||
const TRIALS = 5
|
||||
const WORK = 10
|
||||
|
||||
async function test() {
|
||||
console.log(`Running ${TRIALS} trials with ${WORK} work units...\n`)
|
||||
|
||||
const times = []
|
||||
|
||||
for (let trial = 1; trial <= TRIALS; trial++) {
|
||||
const challenge = crypto.getRandomValues(new Uint8Array(8))
|
||||
|
||||
const start = performance.now()
|
||||
const solution = await solvePoW(challenge, WORK)
|
||||
const elapsed = performance.now() - start
|
||||
|
||||
const valid = await verifyPoW(challenge, solution, WORK)
|
||||
|
||||
times.push(elapsed)
|
||||
|
||||
console.log(`Trial ${trial.toString().padStart(2)}: ${(elapsed / 1000).toFixed(3)}s, valid=${valid}`)
|
||||
}
|
||||
|
||||
const avgTime = times.reduce((a, b) => a + b, 0) / times.length
|
||||
const minTime = Math.min(...times)
|
||||
const maxTime = Math.max(...times)
|
||||
|
||||
console.log('\n--- Summary ---')
|
||||
console.log(`Trials: ${TRIALS}`)
|
||||
console.log(`Work units: ${WORK}`)
|
||||
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`)
|
||||
}
|
||||
|
||||
test()
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Proof of Work utility using PBKDF2-SHA512
|
||||
*
|
||||
* The PoW requires finding nonces where PBKDF2(challenge, nonce) produces
|
||||
* output with a zero first byte. Each work unit requires finding one such nonce.
|
||||
* All valid nonces are concatenated into a solution for server verification.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Solve a Proof of Work challenge
|
||||
*
|
||||
* @param {Uint8Array|ArrayBuffer} challenge - 8-byte server-provided challenge
|
||||
* @param {number} work - Number of PBKDF2 work units required
|
||||
* @param {object} [options] - Optional parameters
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the operation
|
||||
* @returns {Promise<Uint8Array>} Solution: concatenated 8-byte nonces (8 * work bytes)
|
||||
* @throws {Error} If challenge is invalid or operation is aborted
|
||||
*/
|
||||
export async function solvePoW(challenge, work, options = {}) {
|
||||
const { signal } = 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')
|
||||
}
|
||||
|
||||
// Import challenge as PBKDF2 key material
|
||||
const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits'])
|
||||
|
||||
// Build solution from found nonces
|
||||
const solution = new Uint8Array(8 * work)
|
||||
let totalIterations = 0
|
||||
const mask = 0x7FF // The client must work 2048x harder than the server
|
||||
|
||||
// Sequential nonce starting at zero (little-endian, using Uint32Array for efficient increment)
|
||||
const nonce = new Uint32Array(2)
|
||||
|
||||
for (let i = 0; i < work; i++) {
|
||||
if (signal?.aborted) {
|
||||
throw new DOMException('PoW operation aborted', 'AbortError')
|
||||
}
|
||||
|
||||
// Find a nonce where PBKDF2 output passes the mask check
|
||||
let result
|
||||
do {
|
||||
totalIterations++
|
||||
if (++nonce[0] === 0x100000000) ++nonce[1] // Increment 64-bit little-endian nonce
|
||||
result = new Uint32Array(await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512'},
|
||||
baseKey,
|
||||
32
|
||||
))
|
||||
} while (result[0] & mask)
|
||||
solution.set(new Uint8Array(nonce.buffer), i * 8)
|
||||
}
|
||||
|
||||
const elapsed = (performance.now() - startTime) / 1000
|
||||
const expectedIterations = work * (mask + 1)
|
||||
const luckRatio = (totalIterations / expectedIterations).toFixed(1)
|
||||
const bench = totalIterations / ((mask + 1) * elapsed)
|
||||
console.log(`PoW work=${work} solved in ${elapsed.toFixed(2)}s (${luckRatio}x expected ${bench.toFixed(1)} work/s)`)
|
||||
return solution
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user