import { fail, redirect } from "@sveltejs/kit"; import { get2FARedirect } from "$lib/server/2fa"; import { bigEndian } from "@oslojs/binary"; import { parseAttestationObject, AttestationStatementFormat, parseClientDataJSON, coseAlgorithmES256, coseEllipticCurveP256, ClientDataType, coseAlgorithmRS256 } from "@oslojs/webauthn"; import { ECDSAPublicKey, p256 } from "@oslojs/crypto/ecdsa"; import { decodeBase64 } from "@oslojs/encoding"; import { verifyWebAuthnChallenge, createPasskeyCredential, getUserPasskeyCredentials } from "$lib/server/webauthn"; import { setSessionAs2FAVerified } from "$lib/server/session"; import { RSAPublicKey } from "@oslojs/crypto/rsa"; import { SqliteError } from "better-sqlite3"; import type { WebAuthnUserCredential } from "$lib/server/webauthn"; import type { AttestationStatement, AuthenticatorData, ClientData, COSEEC2PublicKey, COSERSAPublicKey } from "@oslojs/webauthn"; import type { Actions, RequestEvent } from "./$types"; export async function load(event: RequestEvent) { if (event.locals.user === null || event.locals.session === null) { return redirect(302, "/login"); } if (!event.locals.user.emailVerified) { return redirect(302, "/verify-email"); } if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { return redirect(302, get2FARedirect(event.locals.user)); } const credentials = getUserPasskeyCredentials(event.locals.user.id); const credentialUserId = new Uint8Array(8); bigEndian.putUint64(credentialUserId, BigInt(event.locals.user.id), 0); return { credentials, credentialUserId, user: event.locals.user }; } export const actions: Actions = { default: action }; async function action(event: RequestEvent) { if (event.locals.session === null || event.locals.user === null) { return new Response("Not authenticated", { status: 401 }); } if (!event.locals.user.emailVerified) { return new Response("Forbidden", { status: 403 }); } if (event.locals.user.registered2FA && !event.locals.session.twoFactorVerified) { return new Response("Forbidden", { status: 403 }); } const formData = await event.request.formData(); let name = formData.get("name"); let encodedAttestationObject = formData.get("attestation_object"); let encodedClientDataJSON = formData.get("client_data_json"); if ( typeof name !== "string" || typeof encodedAttestationObject !== "string" || typeof encodedClientDataJSON !== "string" ) { return fail(400, { message: "Invalid or missing fields" }); } let attestationObjectBytes: Uint8Array, clientDataJSON: Uint8Array; try { attestationObjectBytes = decodeBase64(encodedAttestationObject); clientDataJSON = decodeBase64(encodedClientDataJSON); } catch { return fail(400, { message: "Invalid or missing fields" }); } let attestationStatement: AttestationStatement; let authenticatorData: AuthenticatorData; try { let attestationObject = parseAttestationObject(attestationObjectBytes); attestationStatement = attestationObject.attestationStatement; authenticatorData = attestationObject.authenticatorData; } catch { return fail(400, { message: "Invalid data" }); } if (attestationStatement.format !== AttestationStatementFormat.None) { return fail(400, { message: "Invalid data" }); } // TODO: Update host if (!authenticatorData.verifyRelyingPartyIdHash("localhost")) { return fail(400, { message: "Invalid data" }); } if (!authenticatorData.userPresent || !authenticatorData.userVerified) { return fail(400, { message: "Invalid data" }); } if (authenticatorData.credential === null) { return fail(400, { message: "Invalid data" }); } let clientData: ClientData; try { clientData = parseClientDataJSON(clientDataJSON); } catch { return fail(400, { message: "Invalid data" }); } if (clientData.type !== ClientDataType.Create) { return fail(400, { message: "Invalid data" }); } if (!verifyWebAuthnChallenge(clientData.challenge)) { return fail(400, { message: "Invalid data" }); } // TODO: Update origin if (clientData.origin !== "http://localhost:5173") { return fail(400, { message: "Invalid data" }); } if (clientData.crossOrigin !== null && clientData.crossOrigin) { return fail(400, { message: "Invalid data" }); } let credential: WebAuthnUserCredential; if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmES256) { let cosePublicKey: COSEEC2PublicKey; try { cosePublicKey = authenticatorData.credential.publicKey.ec2(); } catch { return fail(400, { message: "Invalid data" }); } if (cosePublicKey.curve !== coseEllipticCurveP256) { return fail(400, { message: "Unsupported algorithm" }); } const encodedPublicKey = new ECDSAPublicKey(p256, cosePublicKey.x, cosePublicKey.y).encodeSEC1Uncompressed(); credential = { id: authenticatorData.credential.id, userId: event.locals.user.id, algorithmId: coseAlgorithmES256, name, publicKey: encodedPublicKey }; } else if (authenticatorData.credential.publicKey.algorithm() === coseAlgorithmRS256) { let cosePublicKey: COSERSAPublicKey; try { cosePublicKey = authenticatorData.credential.publicKey.rsa(); } catch { return fail(400, { message: "Invalid data" }); } const encodedPublicKey = new RSAPublicKey(cosePublicKey.n, cosePublicKey.e).encodePKCS1(); credential = { id: authenticatorData.credential.id, userId: event.locals.user.id, algorithmId: coseAlgorithmRS256, name, publicKey: encodedPublicKey }; } else { return fail(400, { message: "Unsupported algorithm" }); } // We don't have to worry about race conditions since queries are synchronous const credentials = getUserPasskeyCredentials(event.locals.user.id); if (credentials.length >= 5) { return fail(400, { message: "Too many credentials" }); } try { createPasskeyCredential(credential); } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { return fail(400, { message: "Invalid data" }); } return fail(500, { message: "Internal error" }); } if (!event.locals.session.twoFactorVerified) { setSessionAs2FAVerified(event.locals.session.id); } if (!event.locals.user.registered2FA) { return redirect(302, "/recovery-code"); } return redirect(302, "/"); }