feat: implement WebAuthn Passkeys register/login API and client onboarding UI
This commit is contained in:
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user