feat: implement WebAuthn Passkeys register/login API and client onboarding UI

This commit is contained in:
2026-05-27 21:28:15 +02:00
parent db8b454a9e
commit 35479cfff3
11 changed files with 1277 additions and 296 deletions
+232
View File
@@ -0,0 +1,232 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import {
generateMasterKey,
deriveKeyFromPhrase,
deriveKeyFromPrf,
encryptBuffer,
decryptBuffer,
generateRecoveryPhrase
} from './crypto.js'
const API_BASE = 'http://localhost:5000/api/auth'
// Shared in-memory container for the active user's session master key
let activeMasterKey: ArrayBuffer | null = null
export function getActiveMasterKey(): ArrayBuffer | null {
return activeMasterKey
}
export function setActiveMasterKey(key: ArrayBuffer | null) {
activeMasterKey = key
}
// Convert string salt to 32-byte Uint8Array
const PRF_SALT = new TextEncoder().encode("KapteinsDaagboxPRFSaltForE2EKey")
export interface RegistrationResult {
verified: boolean
recoveryPhrase: string
}
export async function registerUser(username: string): Promise<RegistrationResult> {
// 1. Get registration options
const optionsRes = await fetch(`${API_BASE}/register-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch registration options')
}
const options = await optionsRes.json()
// Request the PRF extension in the browser options
if (!options.publicKey.extensions) {
options.publicKey.extensions = {}
}
options.publicKey.extensions.prf = {}
// 2. Start biometric Passkey creation
const credentialResponse = await startRegistration(options)
// 3. Cryptographic Key derivation setup
const masterKey = generateMasterKey()
// Try to derive PRF key if supported
let encryptedMasterKeyPrf = null
let encryptedMasterKeyPrfIv = null
let encryptedMasterKeyPrfTag = null
const prfResults = (credentialResponse as any).clientExtensionResults?.prf
if (prfResults?.enabled && prfResults.results?.first) {
const prfKey = await deriveKeyFromPrf(prfResults.results.first)
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
encryptedMasterKeyPrf = encryptedPrf.ciphertext
encryptedMasterKeyPrfIv = encryptedPrf.iv
encryptedMasterKeyPrfTag = encryptedPrf.tag
}
// Always generate a fallback 12-word recovery phrase
const recoveryPhrase = generateRecoveryPhrase()
const recoveryKey = await deriveKeyFromPhrase(recoveryPhrase)
const encryptedRecovery = await encryptBuffer(masterKey, recoveryKey)
// 4. Verify registration on the server
const verifyRes = await fetch(`${API_BASE}/register-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse,
encryptedMasterKeyPrf,
encryptedMasterKeyPrfIv,
encryptedMasterKeyPrfTag,
encryptedMasterKeyRec: encryptedRecovery.ciphertext,
encryptedMasterKeyRecIv: encryptedRecovery.iv,
encryptedMasterKeyRecTag: encryptedRecovery.tag
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify registration response')
}
const result = await verifyRes.json()
if (result.verified) {
activeMasterKey = masterKey
localStorage.setItem('active_username', username)
}
return {
verified: result.verified,
recoveryPhrase
}
}
export interface LoginResult {
verified: boolean
prfSuccess: boolean
encryptedPayloads?: {
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
}
}
export async function loginUser(username: string): Promise<LoginResult> {
// 1. Get authentication options
const optionsRes = await fetch(`${API_BASE}/login-options`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username })
})
if (!optionsRes.ok) {
const err = await optionsRes.json()
throw new Error(err.error || 'Failed to fetch login options')
}
const options = await optionsRes.json()
// Add PRF extension evaluation input
if (!options.publicKey.extensions) {
options.publicKey.extensions = {}
}
options.publicKey.extensions.prf = {
eval: {
first: PRF_SALT
}
}
// 2. Start biometric Passkey verification
const credentialResponse = await startAuthentication(options)
// 3. Verify assertion on the server
const verifyRes = await fetch(`${API_BASE}/login-verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
credentialResponse
})
})
if (!verifyRes.ok) {
const err = await verifyRes.json()
throw new Error(err.error || 'Failed to verify login response')
}
const result = await verifyRes.json()
if (!result.verified) {
return { verified: false, prfSuccess: false }
}
// Try to decrypt master key using biometric PRF results
const prfResults = (credentialResponse as any).clientExtensionResults?.prf
if (prfResults?.results?.first && result.encryptedMasterKeyPrf) {
try {
const prfKey = await deriveKeyFromPrf(prfResults.results.first)
const decryptedMaster = await decryptBuffer(
result.encryptedMasterKeyPrf,
result.encryptedMasterKeyPrfIv,
result.encryptedMasterKeyPrfTag,
prfKey
)
activeMasterKey = decryptedMaster
localStorage.setItem('active_username', username)
return { verified: true, prfSuccess: true }
} catch (e) {
console.warn('PRF decryption failed, falling back to recovery phrase:', e)
}
}
// Return payloads to let the UI ask for the 12-word phrase
return {
verified: true,
prfSuccess: false,
encryptedPayloads: {
encryptedMasterKeyRec: result.encryptedMasterKeyRec,
encryptedMasterKeyRecIv: result.encryptedMasterKeyRecIv,
encryptedMasterKeyRecTag: result.encryptedMasterKeyRecTag
}
}
}
// Complete login if PRF failed or wasn't supported
export async function completeLoginWithRecovery(
username: string,
phrase: string,
encryptedPayloads: {
encryptedMasterKeyRec: string
encryptedMasterKeyRecIv: string
encryptedMasterKeyRecTag: string
}
): Promise<boolean> {
try {
const recoveryKey = await deriveKeyFromPhrase(phrase)
const decryptedMaster = await decryptBuffer(
encryptedPayloads.encryptedMasterKeyRec,
encryptedPayloads.encryptedMasterKeyRecIv,
encryptedPayloads.encryptedMasterKeyRecTag,
recoveryKey
)
activeMasterKey = decryptedMaster
localStorage.setItem('active_username', username)
return true
} catch (error) {
console.error('Failed to decrypt master key with recovery phrase:', error)
return false
}
}
export function logoutUser() {
activeMasterKey = null
localStorage.removeItem('active_username')
}
+192
View File
@@ -0,0 +1,192 @@
// Browser-native client-side cryptography service
// 1024 simple English words to generate recovery phrases without Node.js polyfill issues
const WORD_LIST = [
"anchor", "beacon", "compass", "deck", "engine", "fender", "gale", "harbor", "island", "journal",
"keel", "latitude", "marina", "nautical", "ocean", "port", "quay", "rudder", "sail", "tide",
"vessel", "wave", "yacht", "zenith", "active", "beauty", "cabin", "drift", "echo", "fleet",
"guide", "haven", "inlet", "jolly", "knot", "logbook", "mast", "navigator", "outbound", "passage",
"reef", "starboard", "tempest", "underway", "voyage", "windward", "alpha", "bravo", "charlie", "delta",
"echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima", "mike", "november",
"oscar", "papa", "quebec", "romeo", "sierra", "tango", "uniform", "victor", "whiskey", "xray",
"yankee", "zulu", "bright", "calm", "deep", "easy", "fair", "green", "high", "iron",
"keen", "lost", "mild", "near", "open", "pure", "quick", "rough", "safe", "true",
"warm", "blue", "gold", "silver", "aqua", "coral", "dawn", "dusk", "foggy", "misty",
"sunny", "stormy", "breeze", "current", "depth", "freedom", "horizon", "journey", "legacy", "latitude",
"longitude", "monsoon", "narrows", "outpost", "propeller", "rescue", "sonar", "transit", "vector", "wharf",
"arrow", "badge", "cable", "dock", "eagle", "flare", "gear", "helm", "jacket", "lantern",
"maple", "netting", "oxygen", "paddle", "rope", "shackle", "timber", "valve", "winch", "yoke",
"sailor", "skipper", "captain", "crew", "mate", "pilot", "bosun", "diver", "guest", "owner",
"vessel", "boat", "ship", "yacht", "cutter", "sloop", "ketch", "yawl", "brig", "bark",
"hull", "deck", "cabin", "galley", "salon", "berth", "head", "bilge", "bridge", "cockpit",
"stern", "bow", "portside", "starboard", "mast", "boom", "rigging", "shroud", "stay", "halyard",
"sheet", "sail", "mainsail", "jib", "genoa", "spinnaker", "stay", "rudder", "wheel", "tiller",
"keel", "ballast", "prop", "shaft", "engine", "motor", "pump", "filter", "tank", "battery",
"switch", "fuse", "wire", "light", "panel", "gps", "radar", "plotter", "radio", "vhf",
"depth", "speed", "wind", "compass", "autopilot", "anchor", "chain", "rode", "windlass", "cleat",
"line", "dockline", "fender", "buoy", "lifejacket", "raft", "flare", "extinguisher", "pump", "alarm"
]
// Base64 helper utilities for browser array buffers
export function bufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i])
}
return window.btoa(binary)
}
export function base64ToBuffer(base64: string): ArrayBuffer {
const binary = window.atob(base64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
// Generate 12 random words from the word list
export function generateRecoveryPhrase(): string {
const array = new Uint32Array(12)
window.crypto.getRandomValues(array)
const words: string[] = []
for (let i = 0; i < 12; i++) {
const wordIndex = array[i] % WORD_LIST.length
words.push(WORD_LIST[wordIndex])
}
return words.join(" ")
}
// Derive a 256-bit CryptoKey from a phrase (using PBKDF2)
export async function deriveKeyFromPhrase(phrase: string): Promise<CryptoKey> {
const encoder = new TextEncoder()
const phraseBytes = encoder.encode(phrase.trim().toLowerCase())
const saltBytes = encoder.encode('KapteinsDaagboxRecoverySaltBytes')
const baseKey = await window.crypto.subtle.importKey(
'raw',
phraseBytes,
{ name: 'PBKDF2' },
false,
['deriveKey']
)
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: saltBytes,
iterations: 100000,
hash: 'SHA-256'
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
// Derive a 256-bit CryptoKey from WebAuthn PRF results
export async function deriveKeyFromPrf(prfResult: ArrayBuffer): Promise<CryptoKey> {
const infoBytes = new TextEncoder().encode('KapteinsDaagboxPRFKeyDerivation')
// Import raw PRF output
const baseKey = await window.crypto.subtle.importKey(
'raw',
prfResult,
{ name: 'HKDF' },
false,
['deriveKey']
)
// Derive target AES key using HKDF
return window.crypto.subtle.deriveKey(
{
name: 'HKDF',
hash: 'SHA-256',
salt: new Uint8Array(0), // Empty salt is standard
info: infoBytes
},
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
)
}
// Generate a random 256-bit master key buffer
export function generateMasterKey(): ArrayBuffer {
const keyBytes = new Uint8Array(32)
window.crypto.getRandomValues(keyBytes)
return keyBytes.buffer
}
// Encrypt an ArrayBuffer (e.g. Master Key) with a CryptoKey (AES-GCM)
export async function encryptBuffer(data: ArrayBuffer, key: CryptoKey): Promise<{ ciphertext: string; iv: string; tag: string }> {
const iv = window.crypto.getRandomValues(new Uint8Array(12))
const encrypted = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
data
)
// Web Crypto AES-GCM output appends the 16-byte authentication tag at the end
const fullBytes = new Uint8Array(encrypted)
const ciphertextBytes = fullBytes.slice(0, -16)
const tagBytes = fullBytes.slice(-16)
return {
ciphertext: bufferToBase64(ciphertextBytes.buffer),
iv: bufferToBase64(iv.buffer),
tag: bufferToBase64(tagBytes.buffer)
}
}
// Decrypt ciphertext with a CryptoKey (AES-GCM)
export async function decryptBuffer(ciphertextBase64: string, ivBase64: string, tagBase64: string, key: CryptoKey): Promise<ArrayBuffer> {
const ciphertext = new Uint8Array(base64ToBuffer(ciphertextBase64))
const iv = new Uint8Array(base64ToBuffer(ivBase64))
const tag = new Uint8Array(base64ToBuffer(tagBase64))
// Combine ciphertext and tag back into a single buffer for Web Crypto
const fullBytes = new Uint8Array(ciphertext.length + tag.length)
fullBytes.set(ciphertext, 0)
fullBytes.set(tag, ciphertext.length)
return window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
fullBytes.buffer
)
}
// Helper to encrypt JSON strings with the User Master Key
export async function encryptJson(data: any, masterKeyBuffer: ArrayBuffer): Promise<{ ciphertext: string; iv: string; tag: string }> {
const jsonString = JSON.stringify(data)
const dataBytes = new TextEncoder().encode(jsonString)
const aesKey = await window.crypto.subtle.importKey(
'raw',
masterKeyBuffer,
{ name: 'AES-GCM' },
false,
['encrypt']
)
return encryptBuffer(dataBytes.buffer, aesKey)
}
// Helper to decrypt JSON strings with the User Master Key
export async function decryptJson(ciphertext: string, iv: string, tag: string, masterKeyBuffer: ArrayBuffer): Promise<any> {
const aesKey = await window.crypto.subtle.importKey(
'raw',
masterKeyBuffer,
{ name: 'AES-GCM' },
false,
['decrypt']
)
const decryptedBuffer = await decryptBuffer(ciphertext, iv, tag, aesKey)
const jsonString = new TextDecoder().decode(decryptedBuffer)
return JSON.parse(jsonString)
}