feat: enhance WebAuthn PRF handling with follow-up authentication and base64url encoding for user IDs
This commit is contained in:
@@ -89,6 +89,52 @@ function base64urlToBuffer(base64url: string): ArrayBuffer {
|
|||||||
return bytes.buffer
|
return bytes.buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function randomChallengeBase64url(): string {
|
||||||
|
const bytes = new Uint8Array(32)
|
||||||
|
window.crypto.getRandomValues(bytes)
|
||||||
|
let binary = ''
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i])
|
||||||
|
}
|
||||||
|
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPrfFirst(clientExtensionResults: any): ArrayBuffer | null {
|
||||||
|
const first = clientExtensionResults?.prf?.results?.first
|
||||||
|
if (!first) return null
|
||||||
|
return typeof first === 'string' ? base64urlToBuffer(first) : first
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some authenticators (notably on Chrome/Android and other platforms) only
|
||||||
|
// expose the PRF output during an assertion (`navigator.credentials.get`),
|
||||||
|
// not during credential creation. When that happens we perform a follow-up
|
||||||
|
// authentication against the freshly created credential purely to obtain the
|
||||||
|
// PRF output. The assertion itself is not sent to the server.
|
||||||
|
async function evaluatePrfViaAuthentication(
|
||||||
|
credentialId: string,
|
||||||
|
transports?: string[]
|
||||||
|
): Promise<ArrayBuffer | null> {
|
||||||
|
try {
|
||||||
|
const authOptions: any = {
|
||||||
|
challenge: randomChallengeBase64url(),
|
||||||
|
allowCredentials: [
|
||||||
|
{
|
||||||
|
id: credentialId,
|
||||||
|
type: 'public-key',
|
||||||
|
...(transports && transports.length ? { transports } : {})
|
||||||
|
}
|
||||||
|
],
|
||||||
|
userVerification: 'preferred',
|
||||||
|
extensions: { prf: { eval: { first: PRF_SALT.buffer } } }
|
||||||
|
}
|
||||||
|
const authResponse = await startAuthentication({ optionsJSON: authOptions })
|
||||||
|
return extractPrfFirst(authResponse.clientExtensionResults)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('PRF follow-up authentication during registration failed:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegistrationResult {
|
export interface RegistrationResult {
|
||||||
verified: boolean
|
verified: boolean
|
||||||
recoveryPhrase: string
|
recoveryPhrase: string
|
||||||
@@ -109,11 +155,14 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
|||||||
|
|
||||||
const options = await optionsRes.json()
|
const options = await optionsRes.json()
|
||||||
|
|
||||||
// Request the PRF extension in the browser options
|
// Request the PRF extension WITH an evaluation salt. This must match the
|
||||||
|
// salt used during login (PRF_SALT), otherwise the PRF-derived key produced
|
||||||
|
// at login would never match what was stored here and every login would fall
|
||||||
|
// back to the recovery phrase.
|
||||||
if (!options.extensions) {
|
if (!options.extensions) {
|
||||||
options.extensions = {}
|
options.extensions = {}
|
||||||
}
|
}
|
||||||
options.extensions.prf = {}
|
options.extensions.prf = { eval: { first: PRF_SALT.buffer } }
|
||||||
|
|
||||||
// 2. Start biometric Passkey creation
|
// 2. Start biometric Passkey creation
|
||||||
let credentialResponse
|
let credentialResponse
|
||||||
@@ -144,17 +193,24 @@ export async function registerUser(username: string): Promise<RegistrationResult
|
|||||||
let encryptedMasterKeyPrfIv = null
|
let encryptedMasterKeyPrfIv = null
|
||||||
let encryptedMasterKeyPrfTag = null
|
let encryptedMasterKeyPrfTag = null
|
||||||
|
|
||||||
console.log('Registration credential response:', credentialResponse)
|
|
||||||
const clientExtensionResults = credentialResponse.clientExtensionResults || {}
|
const clientExtensionResults = credentialResponse.clientExtensionResults || {}
|
||||||
console.log('Registration client extension results:', clientExtensionResults)
|
|
||||||
const prfResults = (clientExtensionResults as any).prf
|
const prfResults = (clientExtensionResults as any).prf
|
||||||
console.log('Registration PRF extension result:', prfResults)
|
console.log('Registration PRF extension result:', prfResults)
|
||||||
|
|
||||||
if (prfResults?.enabled && prfResults.results?.first) {
|
// Obtain the PRF output. Prefer the value returned by create(); if the
|
||||||
const firstBuffer = typeof prfResults.results.first === 'string'
|
// authenticator advertised PRF support but did not return a result, fall
|
||||||
? base64urlToBuffer(prfResults.results.first)
|
// back to a follow-up assertion to retrieve it.
|
||||||
: prfResults.results.first
|
let prfFirstBuffer: ArrayBuffer | null = extractPrfFirst(clientExtensionResults)
|
||||||
const prfKey = await deriveKeyFromPrf(firstBuffer)
|
if (!prfFirstBuffer && prfResults?.enabled) {
|
||||||
|
console.log('PRF enabled but no result from create(); performing follow-up assertion')
|
||||||
|
prfFirstBuffer = await evaluatePrfViaAuthentication(
|
||||||
|
credentialResponse.id,
|
||||||
|
credentialResponse.response.transports
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prfFirstBuffer) {
|
||||||
|
const prfKey = await deriveKeyFromPrf(prfFirstBuffer)
|
||||||
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
|
const encryptedPrf = await encryptBuffer(masterKey, prfKey)
|
||||||
encryptedMasterKeyPrf = encryptedPrf.ciphertext
|
encryptedMasterKeyPrf = encryptedPrf.ciphertext
|
||||||
encryptedMasterKeyPrfIv = encryptedPrf.iv
|
encryptedMasterKeyPrfIv = encryptedPrf.iv
|
||||||
|
|||||||
+49
-11
@@ -1,16 +1,40 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Change directory to the repository root relative to the script location
|
# Remote deployment configuration
|
||||||
cd "$(dirname "$0")/.."
|
# Override any of these via environment variables if needed, e.g.:
|
||||||
|
# REMOTE_HOST=192.168.1.10 ./scripts/update-prod.sh
|
||||||
|
REMOTE_USER="${REMOTE_USER:-root}"
|
||||||
|
REMOTE_HOST="${REMOTE_HOST:-10.0.0.25}"
|
||||||
|
REMOTE_DIR="${REMOTE_DIR:-/opt/kapteins-daagbok}"
|
||||||
|
REMOTE_TARGET="${REMOTE_USER}@${REMOTE_HOST}"
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
COMPOSE_FILE="docker-compose.yml"
|
COMPOSE_FILE="docker-compose.yml"
|
||||||
BACKEND_CONTAINER="daagbox-prod-backend"
|
BACKEND_CONTAINER="daagbox-prod-backend"
|
||||||
|
MAX_WAIT=35
|
||||||
|
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
# Translates to: Kapteins Daagbox Production Environment Update
|
# Translates to: Kapteins Daagbox Production Environment Update
|
||||||
echo " Kapteins Daagbox Prod Environment Update "
|
echo " Kapteins Daagbox Prod Environment Update "
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
echo "Target: ${REMOTE_TARGET}:${REMOTE_DIR}"
|
||||||
|
echo "=================================================="
|
||||||
|
|
||||||
|
# Run the whole update procedure remotely over SSH.
|
||||||
|
# The remote arguments are forwarded positionally so the heredoc can stay
|
||||||
|
# single-quoted (no local variable expansion / escaping surprises).
|
||||||
|
ssh -o ConnectTimeout=10 "$REMOTE_TARGET" 'bash -s' -- \
|
||||||
|
"$REMOTE_DIR" "$COMPOSE_FILE" "$BACKEND_CONTAINER" "$MAX_WAIT" "$REMOTE_HOST" <<'REMOTE_SCRIPT'
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REMOTE_DIR="$1"
|
||||||
|
COMPOSE_FILE="$2"
|
||||||
|
BACKEND_CONTAINER="$3"
|
||||||
|
MAX_WAIT="$4"
|
||||||
|
REMOTE_HOST="$5"
|
||||||
|
|
||||||
|
# Change to the deployment directory on the remote host
|
||||||
|
cd "$REMOTE_DIR" || { echo "Error: Remote directory '$REMOTE_DIR' not found."; exit 1; }
|
||||||
|
|
||||||
# 1. Pull latest code changes
|
# 1. Pull latest code changes
|
||||||
echo "Pulling latest changes from Git..."
|
echo "Pulling latest changes from Git..."
|
||||||
@@ -22,7 +46,7 @@ fi
|
|||||||
|
|
||||||
# 2. Build docker images without cache
|
# 2. Build docker images without cache
|
||||||
echo "Rebuilding Docker images without cache..."
|
echo "Rebuilding Docker images without cache..."
|
||||||
docker compose -f $COMPOSE_FILE build --no-cache
|
docker compose -f "$COMPOSE_FILE" build --no-cache
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo "Error: Docker compose build failed."
|
echo "Error: Docker compose build failed."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -30,7 +54,7 @@ fi
|
|||||||
|
|
||||||
# 3. Spin up the containers
|
# 3. Spin up the containers
|
||||||
echo "Starting updated container stack..."
|
echo "Starting updated container stack..."
|
||||||
docker compose -f $COMPOSE_FILE up -d
|
docker compose -f "$COMPOSE_FILE" up -d
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo "Error: Failed to spin up docker-compose stack."
|
echo "Error: Failed to spin up docker-compose stack."
|
||||||
exit 1
|
exit 1
|
||||||
@@ -45,18 +69,17 @@ fi
|
|||||||
|
|
||||||
# 5. Wait for services to become healthy (Prisma migrations & DB check)
|
# 5. Wait for services to become healthy (Prisma migrations & DB check)
|
||||||
echo "Waiting for services to become healthy..."
|
echo "Waiting for services to become healthy..."
|
||||||
MAX_WAIT=35
|
|
||||||
COUNTER=0
|
COUNTER=0
|
||||||
IS_READY=false
|
IS_READY=false
|
||||||
|
|
||||||
while [ $COUNTER -lt $MAX_WAIT ]; do
|
while [ $COUNTER -lt $MAX_WAIT ]; do
|
||||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' $BACKEND_CONTAINER 2>/dev/null)
|
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null)
|
||||||
|
|
||||||
if [ "$STATUS" = "healthy" ]; then
|
if [ "$STATUS" = "healthy" ]; then
|
||||||
IS_READY=true
|
IS_READY=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sleep 1
|
sleep 1
|
||||||
COUNTER=$((COUNTER + 1))
|
COUNTER=$((COUNTER + 1))
|
||||||
# Show simple progress dots
|
# Show simple progress dots
|
||||||
@@ -66,17 +89,32 @@ echo ""
|
|||||||
|
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
echo "Container Statuses:"
|
echo "Container Statuses:"
|
||||||
docker compose -f $COMPOSE_FILE ps
|
docker compose -f "$COMPOSE_FILE" ps
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
|
||||||
if [ "$IS_READY" = true ]; then
|
if [ "$IS_READY" = true ]; then
|
||||||
echo "SUCCESS: Production environment updated and healthy!"
|
echo "SUCCESS: Production environment updated and healthy!"
|
||||||
echo " -> App Frontend (Nginx): http://localhost"
|
echo " -> App Frontend (Nginx): http://${REMOTE_HOST}"
|
||||||
echo " -> Backend API Health: http://localhost/api/health"
|
echo " -> Backend API Health: http://${REMOTE_HOST}/api/health"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
else
|
else
|
||||||
echo "WARNING: Backend did not transition to healthy in time."
|
echo "WARNING: Backend did not transition to healthy in time."
|
||||||
echo "Check backend container logs for details:"
|
echo "Check backend container logs for details:"
|
||||||
echo " -> docker compose logs backend"
|
echo " -> docker compose logs backend"
|
||||||
echo "=================================================="
|
echo "=================================================="
|
||||||
|
exit 3
|
||||||
fi
|
fi
|
||||||
|
REMOTE_SCRIPT
|
||||||
|
|
||||||
|
# Capture and report the remote exit status locally
|
||||||
|
REMOTE_EXIT=$?
|
||||||
|
echo "=================================================="
|
||||||
|
if [ $REMOTE_EXIT -eq 0 ]; then
|
||||||
|
echo "Remote update completed successfully on ${REMOTE_TARGET}."
|
||||||
|
elif [ $REMOTE_EXIT -eq 3 ]; then
|
||||||
|
echo "Remote update finished, but the backend was not healthy in time on ${REMOTE_TARGET}."
|
||||||
|
else
|
||||||
|
echo "Remote update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})."
|
||||||
|
fi
|
||||||
|
echo "=================================================="
|
||||||
|
exit $REMOTE_EXIT
|
||||||
|
|||||||
@@ -34,10 +34,19 @@ router.post('/register-options', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'User already exists' })
|
return res.status(400).json({ error: 'User already exists' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: @simplewebauthn/server v9 places `userID` verbatim into the
|
||||||
|
// emitted `user.id` JSON field. The browser client (v13) however decodes
|
||||||
|
// `user.id` as a base64url string. Passing a raw username therefore either
|
||||||
|
// corrupts the user handle or, for usernames containing characters outside
|
||||||
|
// the base64url alphabet (".", " ", "@", umlauts, ...), makes the browser
|
||||||
|
// throw "Invalid character" before the passkey prompt even appears.
|
||||||
|
// Encoding the username as base64url keeps the value spec-compliant.
|
||||||
|
const userID = Buffer.from(username, 'utf8').toString('base64url')
|
||||||
|
|
||||||
const options = await generateRegistrationOptions({
|
const options = await generateRegistrationOptions({
|
||||||
rpName,
|
rpName,
|
||||||
rpID,
|
rpID,
|
||||||
userID: username,
|
userID,
|
||||||
userName: username,
|
userName: username,
|
||||||
userDisplayName: username,
|
userDisplayName: username,
|
||||||
attestationType: 'none',
|
attestationType: 'none',
|
||||||
|
|||||||
Reference in New Issue
Block a user