aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 11:51:41 -0800
committerAdam Soltys <[email protected]>2026-02-16 11:51:41 -0800
commitee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40 (patch)
tree04d097d676c4c0a48287f631a539b4055522dcfe /lib
parent20cee249905ee6975e88089b76b2eccb4019e19a (diff)
downloadcoinos-server-ee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40.tar.gz
coinos-server-ee0513838b1d336fa60eb9a8f7ca1ce43e6d9e40.zip
passkey auth
Diffstat (limited to 'lib')
-rw-r--r--lib/passkey.ts134
-rw-r--r--lib/register.ts4
-rw-r--r--lib/whitelist.ts1
3 files changed, 138 insertions, 1 deletions
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",