From ee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 16 Feb 2026 11:51:41 -0800 Subject: passkey auth --- lib/passkey.ts | 134 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/register.ts | 4 +- lib/whitelist.ts | 1 + 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 lib/passkey.ts (limited to 'lib') diff --git a/lib/passkey.ts b/lib/passkey.ts new file mode 100644 index 00000000..b2cb96b4 --- /dev/null +++ b/lib/passkey.ts @@ -0,0 +1,134 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import type { + AuthenticatorTransportFuture, +} from "@simplewebauthn/types"; +import { db, g, s } from "$lib/db"; +import { fail, getUser } from "$lib/utils"; +import { v4 } from "uuid"; + +const rpName = "coinos"; + +function getRpID(origin: string) { + try { + return new URL(origin).hostname; + } catch { + return "localhost"; + } +} + +export async function generatePasskeyRegistration(user: any, origin: string) { + const rpID = getRpID(origin); + const passkeys = user.passkeys || []; + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: user.username, + userID: new TextEncoder().encode(user.id), + attestationType: "none", + excludeCredentials: passkeys.map((p: any) => ({ + id: p.credentialID, + transports: p.transports as AuthenticatorTransportFuture[], + })), + authenticatorSelection: { + residentKey: "required", + userVerification: "preferred", + }, + }); + + await db.set(`challenge:${user.id}`, options.challenge, { EX: 300 }); + + return options; +} + +export async function verifyPasskeyRegistration(user: any, response: any, origin: string) { + const rpID = getRpID(origin); + const expectedChallenge = await db.get(`challenge:${user.id}`); + if (!expectedChallenge) fail("Challenge expired"); + + const verification = await verifyRegistrationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + }); + + if (!verification.verified || !verification.registrationInfo) { + fail("Passkey verification failed"); + } + + await db.del(`challenge:${user.id}`); + + const { credential } = verification.registrationInfo; + + const cred = { + credentialID: credential.id, + credentialPublicKey: Buffer.from(credential.publicKey).toString("base64url"), + counter: credential.counter, + transports: response.response.transports || [], + createdAt: Date.now(), + }; + + await db.set(`passkey:${credential.id}`, user.id); + + return cred; +} + +export async function generatePasskeyLogin(origin: string) { + const rpID = getRpID(origin); + + const options = await generateAuthenticationOptions({ + rpID, + userVerification: "preferred", + }); + + const challengeId = v4(); + await db.set(`challenge:passkey:${challengeId}`, options.challenge, { EX: 300 }); + + return { ...options, challengeId }; +} + +export async function verifyPasskeyLogin(response: any, challengeId: string, origin: string) { + const rpID = getRpID(origin); + const userId = await db.get(`passkey:${response.id}`); + if (!userId) fail("Passkey not recognized"); + + const user = await g(`user:${userId}`); + if (!user) fail("User not found"); + + const passkeys = user.passkeys || []; + const passkey = passkeys.find( + (p: any) => p.credentialID === response.id, + ); + if (!passkey) fail("Passkey not found"); + + const expectedChallenge = await db.get(`challenge:passkey:${challengeId}`); + if (!expectedChallenge) fail("Challenge expired"); + + const verification = await verifyAuthenticationResponse({ + response, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: passkey.credentialID, + publicKey: Buffer.from(passkey.credentialPublicKey, "base64url"), + counter: passkey.counter, + transports: passkey.transports as AuthenticatorTransportFuture[], + }, + }); + + if (!verification.verified) fail("Passkey authentication failed"); + + await db.del(`challenge:passkey:${challengeId}`); + + passkey.counter = verification.authenticationInfo.newCounter; + await s(`user:${user.id}`, user); + + return user; +} diff --git a/lib/register.ts b/lib/register.ts index 816782c1..e99238b1 100644 --- a/lib/register.ts +++ b/lib/register.ts @@ -58,12 +58,14 @@ export default async (user, ip) => { user.migrated = true; user.locktime = 300; + user.passkeys = []; + let sk; if (!pubkey) { sk = randomBytes(32); pubkey = getPublicKey(sk); user.pubkey = pubkey; - user.nsec = nip49encrypt(sk, password); + if (password) user.nsec = nip49encrypt(sk, password); } user.npub = nip19.npubEncode(pubkey); diff --git a/lib/whitelist.ts b/lib/whitelist.ts index ab34be9f..c3094c6b 100644 --- a/lib/whitelist.ts +++ b/lib/whitelist.ts @@ -32,6 +32,7 @@ export default [ "npub", "nsec", "nwc", + "passkeys", "payments", "picture", "profile", -- cgit v1.2.3