diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93cc892..2a8fb43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,8 +14,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.2 - run: bun install --frozen-lockfile - run: bun run format:check @@ -24,8 +22,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.2 - run: bun install --frozen-lockfile - run: bun run lint:check @@ -34,8 +30,6 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.2 - run: bun install --frozen-lockfile - run: bun run test:run @@ -44,7 +38,5 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - with: - bun-version: 1.4.2 - run: bun install --frozen-lockfile - run: bun run typecheck diff --git a/bun.lock b/bun.lock index a0b993e..86f430a 100644 --- a/bun.lock +++ b/bun.lock @@ -113,10 +113,10 @@ "name": "@repo/example-tanstack-otp", "dependencies": { "@repo/shared-react": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.39", "@tanstack/react-start": "^1.168.58", - "authax": "workspace:*", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", @@ -181,11 +181,10 @@ "name": "@repo/example-tanstack-passkey", "dependencies": { "@repo/shared-react": "workspace:*", - "@repo/shared-webauthn": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.39", "@tanstack/react-start": "^1.168.58", - "authax": "workspace:*", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", @@ -242,6 +241,9 @@ "next", ], }, + "packages/spike": { + "name": "@repo/spike", + }, }, "trustedDependencies": [ "sharp", @@ -509,6 +511,8 @@ "@repo/shared-webauthn": ["@repo/shared-webauthn@workspace:examples/shared/webauthn"], + "@repo/spike": ["@repo/spike@workspace:packages/spike"], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.11", "", { "os": "android", "cpu": "arm" }, "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.11", "", { "os": "android", "cpu": "arm64" }, "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA=="], diff --git a/examples/bun-react/otp-memory-cookie/src/index.css b/examples/bun-react/otp-memory-cookie/src/index.css index 017b778..3306d99 100644 --- a/examples/bun-react/otp-memory-cookie/src/index.css +++ b/examples/bun-react/otp-memory-cookie/src/index.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/bun-react/otp-memory-header/src/index.css b/examples/bun-react/otp-memory-header/src/index.css index 017b778..3306d99 100644 --- a/examples/bun-react/otp-memory-header/src/index.css +++ b/examples/bun-react/otp-memory-header/src/index.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/convex-react/otp/src/index.css b/examples/convex-react/otp/src/index.css index 017b778..3306d99 100644 --- a/examples/convex-react/otp/src/index.css +++ b/examples/convex-react/otp/src/index.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/nextjs/otp-memory/app/globals.css b/examples/nextjs/otp-memory/app/globals.css index 017b778..3306d99 100644 --- a/examples/nextjs/otp-memory/app/globals.css +++ b/examples/nextjs/otp-memory/app/globals.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/shared/react/src/styles.css b/examples/shared/react/src/styles.css index 32b0d79..a084e3a 100644 --- a/examples/shared/react/src/styles.css +++ b/examples/shared/react/src/styles.css @@ -1 +1,8 @@ @source "."; + +@layer base { + button:not(:disabled), + [role="button"]:not(:disabled) { + cursor: pointer; + } +} diff --git a/examples/tanstack-start-react/otp-memory/package.json b/examples/tanstack-start-react/otp-memory/package.json index 395c7f6..139d087 100644 --- a/examples/tanstack-start-react/otp-memory/package.json +++ b/examples/tanstack-start-react/otp-memory/package.json @@ -10,8 +10,8 @@ "typecheck": "tsc" }, "dependencies": { - "authax": "workspace:*", "@repo/shared-react": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.39", "@tanstack/react-start": "^1.168.58", diff --git a/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts b/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts index 826a1e2..260db6e 100644 --- a/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts +++ b/examples/tanstack-start-react/otp-memory/src/auth-rpc.ts @@ -1,9 +1,16 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { db } from "./db"; -import { auth, emailOtp } from "./auth"; +import { emailOtp, sessionManager } from "./auth"; import { sessionCookie } from "./session-cookie"; +/** The session behind the request's cookie, null when there is none */ +async function getIdentity() { + const token = sessionCookie.get(); + + return token === null ? null : sessionManager.get(token); +} + /** * Request OTP schema */ @@ -15,34 +22,47 @@ export const requestOtpSchema = z.object({ * Verify OTP schema */ export const verifyOtpSchema = z.object({ - identifier: z.email(), + ticket: z.string(), otp: z.string().length(6), }); /** * Send OTP to identifier server function + * + * Returns the ticket the client sends back with the OTP. */ export const requestOtp = createServerFn({ method: "POST" }) .validator(requestOtpSchema) - .handler(({ data }) => auth.strategies.email.request(data)); + .handler(async ({ data }) => ({ + success: true, + ticket: await emailOtp.send(data.identifier), + })); /** * Verify OTP server function * - * Authenticates with the OTP, which upserts the user and establishes a + * Authenticates with the OTP, finds or creates the user, and establishes a * session. Returns isNew to distinguish sign-up from sign-in (for analytics, * onboarding, etc.). */ export const verifyOtp = createServerFn({ method: "POST" }) .validator(verifyOtpSchema) .handler(async ({ data }) => { - const result = await auth.strategies.email.authenticate(data); + const result = await emailOtp.verify(data); if (!result.success) return { success: false }; - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + const { identifier } = result.data.proven; + const { row: user, isNew } = await db.users.findOrInsert( + { email: identifier }, + { userId: crypto.randomUUID(), email: identifier }, + ); + + sessionCookie.set( + await sessionManager.make(result.data, { userId: user.userId }), + ); - return { success: true, isNew: result.data.user.isNew }; + return { success: true, isNew }; }); /** @@ -54,13 +74,16 @@ export const verifyOtp = createServerFn({ method: "POST" }) export const changeEmail = createServerFn({ method: "POST" }) .validator(verifyOtpSchema) .handler(async ({ data }) => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { success: false }; - const verified = await emailOtp.verify(data.identifier, data.otp); - if (!verified) return { success: false }; + const verified = await emailOtp.verify(data); + if (!verified.success) return { success: false }; - const user = db.users.updateEmail(identity.userId, data.identifier); + const user = await db.users.updateEmail( + identity.userId, + verified.data.proven.identifier, + ); if (!user) return { success: false }; return { success: true, viewer: user }; @@ -72,7 +95,9 @@ export const changeEmail = createServerFn({ method: "POST" }) * Ends the current session and clears the session cookie. */ export const signOut = createServerFn({ method: "POST" }).handler(async () => { - await auth.session.end(sessionCookie.get()); + const token = sessionCookie.get(); + + if (token !== null) await sessionManager.end(token); sessionCookie.clear(); }); @@ -83,8 +108,8 @@ export const signOut = createServerFn({ method: "POST" }).handler(async () => { */ export const signOutAll = createServerFn({ method: "POST" }).handler( async () => { - const identity = await auth.session.get(sessionCookie.get()); - if (identity) db.sessions.deleteAllForUser(identity.userId); + const identity = await getIdentity(); + if (identity) await db.sessions.deleteAllForUser(identity.userId); sessionCookie.clear(); }, ); @@ -95,7 +120,7 @@ export const signOutAll = createServerFn({ method: "POST" }).handler( * Returns the current user if authenticated, or null otherwise. */ export const getViewer = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); - return identity ? (db.users.get(identity.userId) ?? null) : null; + return identity ? db.users.get(identity.userId) : null; }); diff --git a/examples/tanstack-start-react/otp-memory/src/auth.ts b/examples/tanstack-start-react/otp-memory/src/auth.ts index 05f9ba1..3851bcb 100644 --- a/examples/tanstack-start-react/otp-memory/src/auth.ts +++ b/examples/tanstack-start-react/otp-memory/src/auth.ts @@ -1,34 +1,55 @@ -import { makeAuth, makeOpaqueSession, makeOtp, makeOtpStrategy } from "authax"; +import { makeOpaqueSessionManager, makeOTP } from "@repo/spike"; import { db } from "./db"; -const session = makeOpaqueSession({ - storage: db.sessions, - ttl: 30 * 24 * 60 * 60 * 1000, +/** How long someone stays signed in, in ms. The cookie lives as long. */ +export const sessionTtl = 30 * 24 * 60 * 60 * 1000; + +export const sessionManager = makeOpaqueSessionManager<{ userId: string }>({ + store: async (token, row) => { + await db.sessions.insert({ + id: token, + userId: row.userId, + expiresAt: new Date(row.expiresAt), + }); + }, + get: async (token) => { + const row = await db.sessions.get(token); + + return row + ? { userId: row.userId, expiresAt: row.expiresAt.getTime() } + : null; + }, + delete: async (token) => { + await db.sessions.delete(token); + }, + ttl: sessionTtl, }); -export const emailOtp = makeOtp({ - storage: db.otps, - delivery: { - send: async (identifier, otp) => { - console.log(`[OTP] ${identifier}: ${otp}`); - }, +export const emailOtp = makeOTP({ + store: async (ticket, row) => { + await db.otps.insert({ + id: ticket, + email: row.identifier, + otp: row.otp, + expiresAt: new Date(row.expiresAt), + attemptsLeft: row.attemptsLeft, + }); + }, + take: async (ticket) => { + const row = await db.otps.delete(ticket); + + return row + ? { + identifier: row.email, + otp: row.otp, + expiresAt: row.expiresAt.getTime(), + attemptsLeft: row.attemptsLeft, + } + : null; + }, + send: async (identifier, otp) => { + console.log(`[OTP] ${identifier}: ${otp}`); }, ttl: 10 * 60 * 1000, attempts: 3, }); - -export const auth = makeAuth(session, (kernel) => ({ - email: makeOtpStrategy(kernel, { - request: async ({ identifier }) => { - await emailOtp.request(identifier); - return { success: true }; - }, - authenticate: async ({ identifier, otp }) => { - if (!(await emailOtp.verify(identifier, otp))) { - return { success: false, error: "invalid_otp" }; - } - - return { success: true, data: db.users.upsert(identifier) }; - }, - }), -})); diff --git a/examples/tanstack-start-react/otp-memory/src/db.ts b/examples/tanstack-start-react/otp-memory/src/db.ts index 5411432..66315d8 100644 --- a/examples/tanstack-start-react/otp-memory/src/db.ts +++ b/examples/tanstack-start-react/otp-memory/src/db.ts @@ -4,68 +4,41 @@ * Simple in-memory stores for demonstration purposes. In a real app these * would be replaced with database queries. */ -import type { OtpRecord, SessionRecord } from "authax"; +import { makeMemoryTable } from "@repo/spike/demo"; -const users = new Map(); -let userIdCounter = 0; +const users = makeMemoryTable<{ userId: string; email: string }>("userId"); -const sessions = new Map(); -const otps = new Map(); +const sessions = makeMemoryTable<{ + id: string; + userId: string; + expiresAt: Date; +}>("id"); + +const otps = makeMemoryTable<{ + id: string; + email: string; + otp: string; + expiresAt: Date; + attemptsLeft: number; +}>("id"); export const db = { users: { - upsert: (email: string) => { - const exists = Array.from(users.values()).find((u) => u.email === email); - - if (exists) { - return { userId: exists.userId, isNew: false }; - } - - const userId = `user_${++userIdCounter}`; - users.set(userId, { userId, email }); + ...users, - return { userId, isNew: true }; - }, - - get: (userId: string) => users.get(userId), - - updateEmail: (userId: string, email: string) => { - const user = users.get(userId); - if (!user) return undefined; - user.email = email; - return user; - }, + updateEmail: (userId: string, email: string) => + users.update(userId, { email }), }, sessions: { - store: async (record: SessionRecord) => { - sessions.set(record.sessionId, record); - }, - - get: async (sessionId: string) => sessions.get(sessionId) ?? null, - - delete: async (sessionId: string) => { - sessions.delete(sessionId); - }, + ...sessions, - deleteAllForUser: (userId: string) => { - for (const [sessionId, record] of sessions) { - if (record.userId === userId) { - sessions.delete(sessionId); - } + deleteAllForUser: async (userId: string) => { + for (const session of await sessions.where({ userId })) { + await sessions.delete(session.id); } }, }, - otps: { - store: async (record: OtpRecord) => { - otps.set(record.identifier, record); - }, - - take: async (identifier: string) => { - const record = otps.get(identifier) ?? null; - otps.delete(identifier); - return record; - }, - }, + otps, }; diff --git a/examples/tanstack-start-react/otp-memory/src/routes/index.tsx b/examples/tanstack-start-react/otp-memory/src/routes/index.tsx index e5f5f54..7b4c298 100644 --- a/examples/tanstack-start-react/otp-memory/src/routes/index.tsx +++ b/examples/tanstack-start-react/otp-memory/src/routes/index.tsx @@ -30,6 +30,7 @@ type Viewer = { userId: string; email: string }; function AuthFlow(props: { onSignedIn: () => void }) { const [step, setStep] = useState<"email" | "otp">("email"); const [email, setEmail] = useState(""); + const [ticket, setTicket] = useState(""); const [otp, setOtp] = useState(""); const [error, setError] = useState(null); @@ -41,6 +42,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { e.preventDefault(); const result = await requestOtp({ data: { identifier: email } }); if (result.success) { + setTicket(result.ticket); setStep("otp"); setError(null); } else { @@ -66,7 +68,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { onSubmit={async (e) => { e.preventDefault(); const result = await verifyOtp({ - data: { identifier: email, otp }, + data: { ticket, otp }, }); if (result.success) { props.onSignedIn(); @@ -82,9 +84,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { @@ -92,7 +92,8 @@ function AuthFlow(props: { onSignedIn: () => void }) { variant="secondary" type="button" onClick={async () => { - await requestOtp({ data: { identifier: email } }); + const result = await requestOtp({ data: { identifier: email } }); + setTicket(result.ticket); setOtp(""); setError(null); }} @@ -106,6 +107,7 @@ function AuthFlow(props: { onSignedIn: () => void }) { function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { const [step, setStep] = useState<"email" | "otp">("email"); const [email, setEmail] = useState(""); + const [ticket, setTicket] = useState(""); const [otp, setOtp] = useState(""); const [error, setError] = useState(null); @@ -117,6 +119,7 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { e.preventDefault(); const result = await requestOtp({ data: { identifier: email } }); if (result.success) { + setTicket(result.ticket); setStep("otp"); setError(null); } else { @@ -148,7 +151,7 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { onSubmit={async (e) => { e.preventDefault(); const result = await changeEmail({ - data: { identifier: email, otp }, + data: { ticket, otp }, }); if (result.success) { props.onDone(); @@ -164,9 +167,7 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { @@ -174,7 +175,8 @@ function ChangeEmailFlow(props: { onDone: () => void; onCancel: () => void }) { variant="secondary" type="button" onClick={async () => { - await requestOtp({ data: { identifier: email } }); + const result = await requestOtp({ data: { identifier: email } }); + setTicket(result.ticket); setOtp(""); setError(null); }} diff --git a/examples/tanstack-start-react/otp-memory/src/session-cookie.ts b/examples/tanstack-start-react/otp-memory/src/session-cookie.ts index 60eb30c..b09ec3f 100644 --- a/examples/tanstack-start-react/otp-memory/src/session-cookie.ts +++ b/examples/tanstack-start-react/otp-memory/src/session-cookie.ts @@ -1,4 +1,5 @@ import { getCookie, setCookie } from "@tanstack/react-start/server"; +import { sessionTtl } from "./auth"; const name = "session"; @@ -12,7 +13,7 @@ const options = { /** Moves the session token between the request and the auth API */ export const sessionCookie = { get: () => getCookie(name) ?? null, - set: (token: string, expiresAt: Date) => - setCookie(name, token, { ...options, expires: expiresAt }), + set: (token: string) => + setCookie(name, token, { ...options, maxAge: sessionTtl / 1000 }), clear: () => setCookie(name, "", { ...options, maxAge: 0 }), }; diff --git a/examples/tanstack-start-react/otp-memory/src/styles.css b/examples/tanstack-start-react/otp-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/otp-memory/src/styles.css +++ b/examples/tanstack-start-react/otp-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/otp-passkey-memory/src/styles.css b/examples/tanstack-start-react/otp-passkey-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/otp-passkey-memory/src/styles.css +++ b/examples/tanstack-start-react/otp-passkey-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css b/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css +++ b/examples/tanstack-start-react/otp-passkey-strict-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/passkey-memory/package.json b/examples/tanstack-start-react/passkey-memory/package.json index 9ffa4db..7407dc4 100644 --- a/examples/tanstack-start-react/passkey-memory/package.json +++ b/examples/tanstack-start-react/passkey-memory/package.json @@ -10,16 +10,15 @@ "typecheck": "tsc" }, "dependencies": { - "authax": "workspace:*", "@repo/shared-react": "workspace:*", + "@repo/spike": "workspace:*", "@tailwindcss/vite": "^4.3.3", "@tanstack/react-router": "^1.170.39", "@tanstack/react-start": "^1.168.58", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", - "zod": "^4.6.5", - "@repo/shared-webauthn": "workspace:*" + "zod": "^4.6.5" }, "devDependencies": { "@types/node": "^26.6.3", diff --git a/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts b/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts index b87b3bc..a7ba63a 100644 --- a/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts +++ b/examples/tanstack-start-react/passkey-memory/src/auth-rpc.ts @@ -1,13 +1,34 @@ import { createServerFn } from "@tanstack/react-start"; -import { - passkeyAuthenticationCredentialSchema, - passkeyRegistrationCredentialSchema, -} from "@repo/shared-webauthn"; import { z } from "zod"; import { db } from "./db"; -import { auth } from "./auth"; +import { passkey, sessionManager } from "./auth"; import { sessionCookie } from "./session-cookie"; +/** The session behind the request's cookie, null when there is none */ +async function getIdentity() { + const token = sessionCookie.get(); + + return token === null ? null : sessionManager.get(token); +} + +/** What the browser sends after it created a passkey */ +const registrationCredentialSchema = z.object({ + response: z.object({ + clientDataJSON: z.string(), + attestationObject: z.string(), + }), +}); + +/** What the browser sends after it signed in with a passkey */ +const authenticationCredentialSchema = z.object({ + id: z.string(), + response: z.object({ + clientDataJSON: z.string(), + authenticatorData: z.string(), + signature: z.string(), + }), +}); + /** * Server function: Start passkey registration * @@ -15,13 +36,13 @@ import { sessionCookie } from "./session-cookie"; * application user is provisioned only after the ceremony verifies. */ export const startRegistration = createServerFn({ method: "POST" }).handler( - async () => { - const result = await auth.strategies.passkeys.createRegistrationOptions(); - - if (!result.success) return { success: false as const }; - - return { success: true as const, options: result.data }; - }, + async () => ({ + success: true as const, + options: await passkey.beginRegistration({ + handle: crypto.randomUUID(), + name: "New user", + }), + }), ); /** @@ -31,15 +52,23 @@ export const startRegistration = createServerFn({ method: "POST" }).handler( * establishes a session. */ export const verifyRegistration = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyRegistrationCredentialSchema })) + .validator(z.object({ credential: registrationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyRegistration({ - credential: data.credential, - }); + const result = await passkey.finishRegistration(data.credential); + + if (!result.success) { + console.log("[passkey] refused:", result.error); + + return { success: false as const }; + } - if (!result.success) return { success: false as const }; + const userId = result.data.proven.userHandle; + const { isNew } = await db.users.findOrInsert({ userId }, { userId }); - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + // A ceremony that was started to add a passkey signs nobody up + if (!isNew) return { success: false as const }; + + sessionCookie.set(await sessionManager.make(result.data, { userId })); return { success: true as const }; }); @@ -52,14 +81,16 @@ export const verifyRegistration = createServerFn({ method: "POST" }) */ export const startAddPasskey = createServerFn({ method: "POST" }).handler( async () => { - const result = - await auth.strategies.passkeys.createAdditionalRegistrationOptions( - sessionCookie.get(), - ); - - if (!result.success) return { success: false as const }; + const identity = await getIdentity(); + if (!identity) return { success: false as const }; - return { success: true as const, options: result.data }; + return { + success: true as const, + options: await passkey.beginRegistration({ + handle: identity.userId, + name: identity.userId, + }), + }; }, ); @@ -70,14 +101,26 @@ export const startAddPasskey = createServerFn({ method: "POST" }).handler( * for the current user. No session is established. */ export const verifyAddPasskey = createServerFn({ method: "POST" }) - .validator(z.object({ credential: passkeyRegistrationCredentialSchema })) + .validator(z.object({ credential: registrationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyAdditionalRegistration( - sessionCookie.get(), - { credential: data.credential }, - ); + const identity = await getIdentity(); + if (!identity) return { success: false as const }; + + const result = await passkey.finishRegistration(data.credential); + + if (!result.success) { + console.log("[passkey] refused:", result.error); - if (!result.success) return { success: false as const }; + return { success: false as const }; + } + + // The ceremony has to be one this user started. The passkey is already + // stored when the answer comes back, so it is removed again. + if (result.data.proven.userHandle !== identity.userId) { + await db.credentials.delete(result.data.proven.credentialId); + + return { success: false as const }; + } return { success: true as const }; }); @@ -89,17 +132,10 @@ export const verifyAddPasskey = createServerFn({ method: "POST" }) */ export const startAuthentication = createServerFn({ method: "POST", -}).handler(async () => { - const result = await auth.strategies.passkeys.createAuthenticationOptions(); - return { success: true as const, options: result.data }; -}); - -/** - * Verify passkey authentication schema - */ -const verifyAuthenticationSchema = z.object({ - credential: passkeyAuthenticationCredentialSchema, -}); +}).handler(async () => ({ + success: true as const, + options: await passkey.beginAuthentication(), +})); /** * Verify passkey authentication @@ -108,15 +144,21 @@ const verifyAuthenticationSchema = z.object({ * establishes a session. */ export const verifyAuthentication = createServerFn({ method: "POST" }) - .validator(verifyAuthenticationSchema) + .validator(z.object({ credential: authenticationCredentialSchema })) .handler(async ({ data }) => { - const result = await auth.strategies.passkeys.verifyAuthentication({ - credential: data.credential, - }); + const result = await passkey.finishAuthentication(data.credential); - if (!result.success) return { success: false as const }; + if (!result.success) { + console.log("[passkey] refused:", result.error); - sessionCookie.set(result.data.session.token, result.data.session.expiresAt); + return { success: false as const }; + } + + sessionCookie.set( + await sessionManager.make(result.data, { + userId: result.data.proven.userHandle, + }), + ); return { success: true as const }; }); @@ -127,12 +169,12 @@ export const verifyAuthentication = createServerFn({ method: "POST" }) * Returns stored credential metadata for the authenticated user. */ export const listPasskeys = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { passkeys: [] }; - const passkeys = await db.credentials.list(identity.userId); + const passkeys = await db.credentials.where({ userId: identity.userId }); return { - passkeys: passkeys.map((p) => ({ id: p.credentialId })), + passkeys: passkeys.map((p) => ({ id: p.id })), }; }); @@ -144,16 +186,16 @@ export const listPasskeys = createServerFn().handler(async () => { export const removePasskey = createServerFn({ method: "POST" }) .validator(z.object({ credentialId: z.string() })) .handler(async ({ data }) => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); if (!identity) return { success: false as const }; - const passkeys = await db.credentials.list(identity.userId); + const passkeys = await db.credentials.where({ userId: identity.userId }); if (passkeys.length <= 1) return { success: false as const }; - const owns = passkeys.some((p) => p.credentialId === data.credentialId); + const owns = passkeys.some((p) => p.id === data.credentialId); if (!owns) return { success: false as const }; - db.credentials.delete(data.credentialId); + await db.credentials.delete(data.credentialId); return { success: true as const }; }); @@ -163,7 +205,9 @@ export const removePasskey = createServerFn({ method: "POST" }) * Ends the current session and clears the session cookie. */ export const signOut = createServerFn({ method: "POST" }).handler(async () => { - await auth.session.end(sessionCookie.get()); + const token = sessionCookie.get(); + + if (token !== null) await sessionManager.end(token); sessionCookie.clear(); }); @@ -174,8 +218,8 @@ export const signOut = createServerFn({ method: "POST" }).handler(async () => { */ export const signOutAll = createServerFn({ method: "POST" }).handler( async () => { - const identity = await auth.session.get(sessionCookie.get()); - if (identity) db.sessions.deleteAllForUser(identity.userId); + const identity = await getIdentity(); + if (identity) await db.sessions.deleteAllForUser(identity.userId); sessionCookie.clear(); }, ); @@ -186,7 +230,7 @@ export const signOutAll = createServerFn({ method: "POST" }).handler( * Returns the current user if authenticated, or null otherwise. */ export const getViewer = createServerFn().handler(async () => { - const identity = await auth.session.get(sessionCookie.get()); + const identity = await getIdentity(); - return identity ? (db.users.get(identity.userId) ?? null) : null; + return identity ? db.users.get(identity.userId) : null; }); diff --git a/examples/tanstack-start-react/passkey-memory/src/auth.ts b/examples/tanstack-start-react/passkey-memory/src/auth.ts index 78bd7d2..e40c42b 100644 --- a/examples/tanstack-start-react/passkey-memory/src/auth.ts +++ b/examples/tanstack-start-react/passkey-memory/src/auth.ts @@ -1,30 +1,65 @@ -import { - makeAuth, - makeOpaqueSession, - makePasskeyEngine, - makePasskeyStrategy, -} from "authax"; +import { makeOpaqueSessionManager, makePasskey } from "@repo/spike"; import { db } from "./db"; -const session = makeOpaqueSession({ - storage: db.sessions, - ttl: 30 * 24 * 60 * 60 * 1000, -}); +/** How long someone stays signed in, in ms. The cookie lives as long. */ +export const sessionTtl = 30 * 24 * 60 * 60 * 1000; + +export const sessionManager = makeOpaqueSessionManager<{ userId: string }>({ + store: async (token, row) => { + await db.sessions.insert({ + id: token, + userId: row.userId, + expiresAt: new Date(row.expiresAt), + }); + }, + get: async (token) => { + const row = await db.sessions.get(token); -const passkey = makePasskeyEngine({ - storage: db.credentials, - challenge: { storage: db.challenges, ttl: 5 * 60 * 1000 }, - webAuthn: { - rpId: "localhost", - rpName: "Auth Passkey Demo", - allowedOrigins: ["http://localhost:3107"], - }, - displayName: async (context) => - context.intent === "add" ? context.userId : "New user", - signUp: async () => db.users.create().userId, - debug: true, + return row + ? { userId: row.userId, expiresAt: row.expiresAt.getTime() } + : null; + }, + delete: async (token) => { + await db.sessions.delete(token); + }, + ttl: sessionTtl, }); -export const auth = makeAuth(session, (kernel) => ({ - passkeys: makePasskeyStrategy(kernel, passkey), -})); +export const passkey = makePasskey({ + rpId: "localhost", + rpName: "Auth Passkey Demo", + origins: ["http://localhost:3107"], + ttl: 5 * 60 * 1000, + storeChallenge: async (challenge, row) => { + await db.challenges.insert({ + id: challenge, + userId: row.handle, + expiresAt: new Date(row.expiresAt), + }); + }, + takeChallenge: async (challenge) => { + const row = await db.challenges.delete(challenge); + + return row + ? { handle: row.userId, expiresAt: row.expiresAt.getTime() } + : null; + }, + storeCredential: async (credentialId, row) => { + await db.credentials.insert({ + id: credentialId, + userId: row.handle, + publicKey: row.publicKey, + counter: row.counter, + }); + }, + getCredential: async (credentialId) => { + const row = await db.credentials.get(credentialId); + + return row + ? { handle: row.userId, publicKey: row.publicKey, counter: row.counter } + : null; + }, + setCounter: async (credentialId, counter) => { + await db.credentials.update(credentialId, { counter }); + }, +}); diff --git a/examples/tanstack-start-react/passkey-memory/src/db.ts b/examples/tanstack-start-react/passkey-memory/src/db.ts index 5043359..6aff738 100644 --- a/examples/tanstack-start-react/passkey-memory/src/db.ts +++ b/examples/tanstack-start-react/passkey-memory/src/db.ts @@ -4,74 +4,44 @@ * Simple in-memory stores for demonstration purposes. In a real app these * would be replaced with database queries. */ -import type { ChallengeRecord, CredentialRecord, SessionRecord } from "authax"; - -const users = new Map(); -let userIdCounter = 0; - -const sessions = new Map(); -const credentials = new Map(); -const challenges = new Map(); +import { makeMemoryTable } from "@repo/spike/demo"; + +const users = makeMemoryTable<{ userId: string }>("userId"); + +const sessions = makeMemoryTable<{ + id: string; + userId: string; + expiresAt: Date; +}>("id"); + +const credentials = makeMemoryTable<{ + id: string; + userId: string; + publicKey: string; + counter: number; +}>("id"); + +const challenges = makeMemoryTable<{ + id: string; + /** Who a registration is for. Null when the challenge is for signing in. */ + userId: string | null; + expiresAt: Date; +}>("id"); export const db = { - users: { - create: () => { - const userId = `user_${++userIdCounter}`; - users.set(userId, { userId }); - return { userId }; - }, - get: (userId: string) => users.get(userId), - }, + users, sessions: { - store: async (record: SessionRecord) => { - sessions.set(record.sessionId, record); - }, + ...sessions, - get: async (sessionId: string) => sessions.get(sessionId) ?? null, - - delete: async (sessionId: string) => { - sessions.delete(sessionId); - }, - - deleteAllForUser: (userId: string) => { - for (const [sessionId, record] of sessions) { - if (record.userId === userId) { - sessions.delete(sessionId); - } + deleteAllForUser: async (userId: string) => { + for (const session of await sessions.where({ userId })) { + await sessions.delete(session.id); } }, }, - credentials: { - store: async (record: CredentialRecord) => { - credentials.set(record.credentialId, record); - }, - - get: async (credentialId: string) => credentials.get(credentialId) ?? null, - - list: async (userId: string) => - Array.from(credentials.values()).filter((c) => c.userId === userId), - - setCounter: async (credentialId: string, counter: number) => { - const record = credentials.get(credentialId); - if (record) credentials.set(credentialId, { ...record, counter }); - }, - - delete: (credentialId: string) => { - credentials.delete(credentialId); - }, - }, - - challenges: { - store: async (record: ChallengeRecord) => { - challenges.set(record.challenge, record); - }, + credentials, - take: async (challenge: string) => { - const record = challenges.get(challenge) ?? null; - challenges.delete(challenge); - return record; - }, - }, + challenges, }; diff --git a/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts b/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts index 60eb30c..b09ec3f 100644 --- a/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts +++ b/examples/tanstack-start-react/passkey-memory/src/session-cookie.ts @@ -1,4 +1,5 @@ import { getCookie, setCookie } from "@tanstack/react-start/server"; +import { sessionTtl } from "./auth"; const name = "session"; @@ -12,7 +13,7 @@ const options = { /** Moves the session token between the request and the auth API */ export const sessionCookie = { get: () => getCookie(name) ?? null, - set: (token: string, expiresAt: Date) => - setCookie(name, token, { ...options, expires: expiresAt }), + set: (token: string) => + setCookie(name, token, { ...options, maxAge: sessionTtl / 1000 }), clear: () => setCookie(name, "", { ...options, maxAge: 0 }), }; diff --git a/examples/tanstack-start-react/passkey-memory/src/styles.css b/examples/tanstack-start-react/passkey-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/passkey-memory/src/styles.css +++ b/examples/tanstack-start-react/passkey-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/examples/tanstack-start-react/passkey-otp-memory/src/styles.css b/examples/tanstack-start-react/passkey-otp-memory/src/styles.css index 017b778..3306d99 100644 --- a/examples/tanstack-start-react/passkey-otp-memory/src/styles.css +++ b/examples/tanstack-start-react/passkey-otp-memory/src/styles.css @@ -1,10 +1,2 @@ @import "tailwindcss"; @import "@repo/shared-react/styles.css"; - -/* Button cursor */ -@layer base { - button:not(:disabled), - [role="button"]:not(:disabled) { - cursor: pointer; - } -} diff --git a/package.json b/package.json index 09abcb3..94c7b40 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@repo/monorepo", "private": true, "type": "module", + "packageManager": "bun@1.4.2", "workspaces": [ "packages/*", "examples/*/*" @@ -14,7 +15,7 @@ "test:run": "vitest run", "test:watch": "vitest", "typecheck": "bun run --filter '*' typecheck", - "check": "bun run format:check && bun run lint:check && bun run test:run && bun run typecheck", + "check": "bun install --frozen-lockfile && bun run format:check && bun run lint:check && bun run test:run && bun run typecheck", "outdated": "bun outdated --recursive", "update": "bun update --recursive", "update:latest": "bun update --latest --recursive", diff --git a/packages/spike/README.md b/packages/spike/README.md new file mode 100644 index 0000000..bcfda14 --- /dev/null +++ b/packages/spike/README.md @@ -0,0 +1,44 @@ +# Spike + +This library consists of three main entities. + +- Session managers +- Authentication strategies +- Proofs + +This is how they relate. + +- A strategy authenticates and issues a proof of what was proven. +- A session manager consumes the proof and issues a session. +- The app hands the proof from one to the other and decides what the session holds. + +Strategies and session managers are completely independent. They communicate using proofs. + +## Decisions + +- An OTP is verified against the request that asked for it, not the identifier. The requesting client holds a random ticket from `send`, and the OTP alone is useless without it. Signing in on another device is a magic link's job. +- The library generates every string it hands a client. OTP tickets, passkey challenges, and session tokens are random columns the library fills, never the app's ids. The app keys its tables however it likes. +- A different trade-off is a different factory. An OTP verified against the identifier, with nothing for the client to hold, would sit beside the ticket one rather than change it. +- The library is not extensible. It ships the strategies and session managers, and an app wires them and writes none of its own. `issueProof` and `consumeProof` are not exported, so an agent cannot shortcut sign-in through the library and have it look like proper use. Auth is what everyone gets wrong, agents most of all, so the scrutiny belongs in one place. A new strategy or session manager is a pull request. + +## OTP lookup options + +The identifier is the email address or phone number the OTP is sent to. + +| | Ticket | Identifier, one pending | Identifier, many pending | +| --- | --- | --- | --- | +| Verify looks up by | the ticket | the identifier | the identifier and the OTP together | +| Client holds between send and verify | the ticket | nothing | nothing | +| Typing the OTP on another device | does not work | works | works | +| A second request for the same identifier | stands beside the first | replaces the first | stands beside the first | +| A guess is tested against | one OTP | one OTP | every pending OTP for that identifier | +| Wrong attempts are counted on | the request row | the identifier row | a separate counter per identifier | +| Someone sees the OTP mid sign-in | useless without the ticket | usable with the identifier | usable with the identifier | +| A stranger requests an OTP for your identifier | yours is untouched | yours stops working | one more OTP that would work | + +Ticket is the only option where an OTP is bound to the client that asked and where a stranger's request changes nothing for you. Identifier with one pending is the simplest for the app and the weakest against interference. Identifier with many pending has the costs of both and should not be built. + +## Revisit + +- Opening the library to bespoke strategies and session managers, by exporting `issueProof` and `consumeProof` or a `makeStrategy` factory around them. Either hands the shortcut back, so only on real demand. +- Type contracts. For now the types are inferred from the functions, which shows what is needed and what is not. Once the shapes settle, a new strategy or session manager starts from its contract type, as in `const makeThing: Contract = (args) => {}`. diff --git a/packages/spike/package.json b/packages/spike/package.json new file mode 100644 index 0000000..7e517b0 --- /dev/null +++ b/packages/spike/package.json @@ -0,0 +1,12 @@ +{ + "name": "@repo/spike", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./demo": "./src/demo/index.ts" + }, + "scripts": { + "typecheck": "tsc" + } +} diff --git a/packages/spike/spike-authenticator.ts b/packages/spike/spike-authenticator.ts new file mode 100644 index 0000000..0def0af --- /dev/null +++ b/packages/spike/spike-authenticator.ts @@ -0,0 +1,161 @@ +import { base64urlEncode, sha256 } from "./src/webauthn/crypto"; + +/** + * A software authenticator for the playground. It stands in for the browser + * and the device, and answers ceremony options the way they would. The first + * passkey it made is the one it signs in with. + */ +export function makeAuthenticator(origin: string) { + const passkeys: { + credentialId: Uint8Array; + privateKey: CryptoKey; + uses: number; + }[] = []; + + return { + /** What navigator.credentials.create returns, as JSON */ + create: async (options: PublicKeyCredentialCreationOptionsJSON) => { + const keys = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const jwk = await crypto.subtle.exportKey("jwk", keys.publicKey); + const credentialId = crypto.getRandomValues(new Uint8Array(16)); + + passkeys.push({ credentialId, privateKey: keys.privateKey, uses: 0 }); + + const authenticatorData = [ + ...(await authenticatorDataHead(options.rp.id ?? "", 0x45, 0)), + ...new Uint8Array(16), + credentialId.length >> 8, + credentialId.length & 0xff, + ...credentialId, + ...map([ + [integer(1), integer(2)], + [integer(3), integer(-7)], + [integer(-1), integer(1)], + [integer(-2), bytes(decode(jwk.x ?? ""))], + [integer(-3), bytes(decode(jwk.y ?? ""))], + ]), + ]; + + return { + response: { + clientDataJSON: clientData("webauthn.create", options.challenge), + attestationObject: base64urlEncode( + new Uint8Array( + map([ + [text("fmt"), text("none")], + [text("attStmt"), map([])], + [text("authData"), bytes(new Uint8Array(authenticatorData))], + ]), + ), + ), + }, + }; + }, + + /** What navigator.credentials.get returns, as JSON */ + get: async (options: PublicKeyCredentialRequestOptionsJSON) => { + const [passkey] = passkeys; + + if (passkey === undefined) throw new Error("no passkey on this device"); + + passkey.uses += 1; + + const clientDataJSON = clientData("webauthn.get", options.challenge); + const authenticatorData = new Uint8Array( + await authenticatorDataHead(options.rpId ?? "", 0x05, passkey.uses), + ); + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + passkey.privateKey, + new Uint8Array([ + ...authenticatorData, + ...(await sha256(decode(clientDataJSON))), + ]), + ); + + return { + id: base64urlEncode(passkey.credentialId), + response: { + clientDataJSON, + authenticatorData: base64urlEncode(authenticatorData), + signature: base64urlEncode(der(new Uint8Array(signature))), + }, + }; + }, + }; + + function clientData(type: string, challenge: string) { + return base64urlEncode( + JSON.stringify({ type, challenge, origin, crossOrigin: false }), + ); + } +} + +/** The hash of the relying party id, the flags, and the counter */ +async function authenticatorDataHead( + rpId: string, + flags: number, + counter: number, +) { + return [ + ...(await sha256(new TextEncoder().encode(rpId))), + flags, + counter >>> 24, + (counter >> 16) & 0xff, + (counter >> 8) & 0xff, + counter & 0xff, + ]; +} + +function decode(base64url: string) { + const binary = atob(base64url.replaceAll("-", "+").replaceAll("_", "/")); + + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); +} + +/** Web Crypto signs as r and s side by side, WebAuthn sends them as DER */ +function der(signature: Uint8Array) { + const part = (value: Uint8Array) => { + const trimmed = [...value]; + + while (trimmed.length > 1 && trimmed[0] === 0) trimmed.shift(); + + const positive = (trimmed[0] ?? 0) & 0x80 ? [0, ...trimmed] : trimmed; + + return [0x02, positive.length, ...positive]; + }; + + const r = part(signature.subarray(0, 32)); + const s = part(signature.subarray(32)); + + return new Uint8Array([0x30, r.length + s.length, ...r, ...s]); +} + +// The few CBOR encoders the two responses need + +function head(major: number, length: number) { + if (length < 24) return [(major << 5) | length]; + if (length < 256) return [(major << 5) | 24, length]; + + return [(major << 5) | 25, length >> 8, length & 0xff]; +} + +function integer(value: number) { + return value >= 0 ? head(0, value) : head(1, -1 - value); +} + +function bytes(value: Uint8Array) { + return [...head(2, value.length), ...value]; +} + +function text(value: string) { + return [...head(3, value.length), ...new TextEncoder().encode(value)]; +} + +function map(entries: [number[], number[]][]) { + return [...head(5, entries.length), ...entries.flat(2)]; +} diff --git a/packages/spike/spike-no-kernel.ts b/packages/spike/spike-no-kernel.ts new file mode 100644 index 0000000..8c8eaa6 --- /dev/null +++ b/packages/spike/spike-no-kernel.ts @@ -0,0 +1,242 @@ +import { + makeOpaqueSessionManager, + makeOTP, + makePasskey, + makeSignedSessionManager, +} from "./src/index"; +import { makeMemoryTable } from "./src/demo/index"; +import { makeAuthenticator } from "./spike-authenticator"; + +/** + * App + */ + +const sessionsTable = makeMemoryTable<{ + token: string; + userId: string; + expiresAt: number; +}>("token"); +const otpsTable = makeMemoryTable<{ + ticket: string; + identifier: string; + otp: string; + expiresAt: number; + attemptsLeft: number; +}>("ticket"); +const challengesTable = makeMemoryTable<{ + challenge: string; + handle: string | null; + expiresAt: number; +}>("challenge"); +const credentialsTable = makeMemoryTable<{ + credentialId: string; + handle: string; + publicKey: string; + counter: number; +}>("credentialId"); + +// Stands in for the browser and the device +const authenticator = makeAuthenticator("http://localhost:3000"); + +// Captures what would have been delivered, for the demo +const delivered = new Map(); + +const opaque = makeOpaqueSessionManager<{ userId: string }>({ + store: async (token, row) => { + await sessionsTable.insert({ token, ...row }); + }, + get: (token) => sessionsTable.get(token), + delete: async (token) => { + await sessionsTable.delete(token); + }, + ttl: 30 * 24 * 60 * 60 * 1000, +}); + +const emailOtp = makeOTP({ + store: async (ticket, row) => { + await otpsTable.insert({ ticket, ...row }); + }, + take: (ticket) => otpsTable.delete(ticket), + send: async (identifier, otp) => { + console.log("email to:", identifier, "otp:", otp); + delivered.set(identifier, otp); + }, + ttl: 10 * 60 * 1000, + attempts: 3, +}); + +const smsOtp = makeOTP({ + store: async (ticket, row) => { + await otpsTable.insert({ ticket, ...row }); + }, + take: (ticket) => otpsTable.delete(ticket), + send: async (identifier, otp) => { + console.log("sms to:", identifier, "otp:", otp); + delivered.set(identifier, otp); + }, + ttl: 10 * 60 * 1000, + attempts: 3, +}); + +const passkey = makePasskey({ + rpId: "localhost", + rpName: "Spike", + origins: ["http://localhost:3000"], + ttl: 5 * 60 * 1000, + storeChallenge: async (challenge, row) => { + await challengesTable.insert({ challenge, ...row }); + }, + takeChallenge: (challenge) => challengesTable.delete(challenge), + storeCredential: async (credentialId, row) => { + await credentialsTable.insert({ credentialId, ...row }); + }, + getCredential: (credentialId) => credentialsTable.get(credentialId), + setCounter: async (credentialId, counter) => { + await credentialsTable.update(credentialId, { counter }); + }, +}); + +// +// Playground +// + +console.log("-".repeat(80)); + +// Email. 1. request, 2. verify, 3. session +const emailTicket = await emailOtp.send("ripley@example.com"); + +const emailProof = await emailOtp.verify({ + ticket: emailTicket, + otp: delivered.get("ripley@example.com") ?? "", +}); +if (!emailProof.success) throw new Error(emailProof.error); + +const emailSessionId = await opaque.make(emailProof.data, { + userId: `user-for-${emailProof.data.proven.identifier}`, +}); +console.log("SESSION", await opaque.get(emailSessionId)); + +// SMS. Same three steps, other instance +const smsTicket = await smsOtp.send("+15555550100"); + +const smsProof = await smsOtp.verify({ + ticket: smsTicket, + otp: delivered.get("+15555550100") ?? "", +}); +if (!smsProof.success) throw new Error(smsProof.error); + +const smsSessionId = await opaque.make(smsProof.data, { + userId: `user-for-${smsProof.data.proven.identifier}`, +}); +console.log("SESSION", await opaque.get(smsSessionId)); + +// Passkey sign-up. 1. app creates the user, 2. begin with its id as the +// handle, 3. browser creates a credential, 4. finish, 5. session +const ripley = "user-ripley"; +const signUp = await passkey.beginRegistration({ + handle: ripley, + name: "ripley@example.com", +}); +console.log("OPTIONS", signUp); +const registered = await passkey.finishRegistration( + await authenticator.create(signUp), +); +if (!registered.success) throw new Error(registered.error); + +const signUpSessionId = await opaque.make(registered.data, { + userId: registered.data.proven.userHandle, +}); +console.log("SESSION", await opaque.get(signUpSessionId)); + +// Passkey sign-in. 1. begin, 2. browser signs, 3. finish, 4. session +const signIn = await passkey.beginAuthentication(); +const authenticated = await passkey.finishAuthentication( + await authenticator.get(signIn), +); +if (!authenticated.success) throw new Error(authenticated.error); + +const signInSessionId = await opaque.make(authenticated.data, { + userId: authenticated.data.proven.userHandle, +}); +console.log("SESSION", await opaque.get(signInSessionId)); + +// Add a passkey while signed in. The session says who, its user id is the +// handle, no new session +const current = await opaque.get(signInSessionId); +if (current === null) throw new Error("not signed in"); + +const add = await passkey.beginRegistration({ + handle: current.userId, + name: "ripley@example.com", +}); +const added = await passkey.finishRegistration(await authenticator.create(add)); +if (!added.success) throw new Error(added.error); +console.log("ADDED", added.data.proven); + +// Signed session manager. Same three steps, no table. The token carries the +// session +const signed = makeSignedSessionManager<{ userId: string }>({ + secret: "spike-secret", + ttl: 60 * 60 * 1000, +}); + +const signedTicket = await emailOtp.send("ripley@example.com"); +const signedProof = await emailOtp.verify({ + ticket: signedTicket, + otp: delivered.get("ripley@example.com") ?? "", +}); +if (!signedProof.success) throw new Error(signedProof.error); + +const token = await signed.make(signedProof.data, { + userId: `user-for-${signedProof.data.proven.identifier}`, +}); +console.log("TOKEN", token); +console.log("SIGNED SESSION", await signed.get(token)); +console.log("TAMPERED", await signed.get(`${token.slice(0, -2)}xx`)); + +// Failures come back with a reason +const wrongTicket = await emailOtp.send("ripley@example.com"); +console.log( + "WRONG", + await emailOtp.verify({ ticket: wrongTicket, otp: "nope" }), +); +console.log( + "USED", + await emailOtp.verify({ ticket: emailTicket, otp: "nope" }), +); + +// A device with a passkey the app never stored +const stranger = makeAuthenticator("http://localhost:3000"); +await stranger.create( + await passkey.beginRegistration({ handle: "nobody", name: "nobody" }), +); +console.log( + "STRANGER", + await passkey.finishAuthentication( + await stranger.get(await passkey.beginAuthentication()), + ), +); + +// The right passkey, presented from another site +const elsewhere = makeAuthenticator("http://evil.example"); +console.log( + "ELSEWHERE", + await passkey.finishRegistration( + await elsewhere.create( + await passkey.beginRegistration({ handle: ripley, name: "ripley" }), + ), + ), +); + +// Reuse the proof. Rejected at runtime +try { + await opaque.make(emailProof.data, { userId: "someone-else" }); +} catch (error) { + console.log("REUSE", error instanceof Error ? error.message : error); +} + +// The guard. Never called, it exists to show what does not compile. +export function withoutProof(userId: string) { + // @ts-expect-error a session cannot be made without a proof + return opaque.make({ userId }, { userId }); +} diff --git a/packages/spike/spike.ts b/packages/spike/spike.ts new file mode 100644 index 0000000..1ed63cd --- /dev/null +++ b/packages/spike/spike.ts @@ -0,0 +1,115 @@ +import { makeMemoryTable } from "./src/demo/index"; + +const _email = "ripley@example.com"; +let _interceptedOneTimePasscode = ""; + +// The app's own table. Keyed by a random id, email is a column. +const usersTable = makeMemoryTable<{ id: string; email: string }>("id"); + +// Opaque session. Remembers the user id under a random id. +const sessionsTable = makeMemoryTable<{ id: string; userId: string }>("id"); + +// OTP. One row per sent otp, keyed by a random id. Checked once. +const otpsTable = makeMemoryTable<{ id: string; email: string; otp: string }>( + "id", +); + +/** + * Session + */ + +const opaque = { + make: async ({ userId }: { userId: string }) => { + console.log("making session", userId); + + const id = crypto.randomUUID(); + await sessionsTable.insert({ id, userId }); + + console.log("session made", id, userId); + return id; + }, + + get: async (id: string) => { + console.log("getting session", id); + return sessionsTable.get(id); + }, +}; + +/** + * OTP strategy + */ + +const otp = { + send: async (email: string) => { + console.log("sending", email); + const otp = crypto.randomUUID(); + const id = crypto.randomUUID(); + await otpsTable.insert({ id, email, otp }); + + // Capture the otp for demo + _interceptedOneTimePasscode = otp; + + // send the otp to the email address + console.log("sent", email, otp); + return id; + }, + verify: async ({ id, otp }: { id: string; otp: string }) => { + console.log("verifying", id, otp); + + const row = await otpsTable.delete(id); + + const ok = row !== null && row.otp === otp; + + console.log("verified", row?.email, ok); + return ok && row !== null ? { email: row.email } : null; + }, +}; + +/** + * Core + */ + +/** Takes an input and returns what was proven, or null. */ +type Authenticate = (input: Input) => Promise; + +/** Takes what was proven and returns a session id. */ +type MakeSession = (proven: Proven) => Promise; + +/** The rule. A session is made only when authenticate returned something. */ +function core( + authenticate: Authenticate, + makeSession: MakeSession, +) { + return async (input: Input): Promise => { + const proven = await authenticate(input); + if (proven === null) return null; + + return makeSession(proven); + }; +} + +// +// Playground +// + +console.log("-".repeat(80)); + +const otpId = await otp.send(_email); + +export const signIn = core(otp.verify, async ({ email }) => { + const [existing] = await usersTable.where({ email }); + const user = existing ?? { id: crypto.randomUUID(), email }; + if (existing === undefined) await usersTable.insert(user); + + return opaque.make({ userId: user.id }); +}); + +const sessionId = await signIn({ + id: otpId, + otp: _interceptedOneTimePasscode, +}); +console.log("sessionId", sessionId); + +const session = sessionId ? await opaque.get(sessionId) : null; +console.log("SESSION", session); +console.log("USER", session ? await usersTable.get(session.userId) : null); diff --git a/packages/spike/src/demo/index.ts b/packages/spike/src/demo/index.ts new file mode 100644 index 0000000..b9d0f03 --- /dev/null +++ b/packages/spike/src/demo/index.ts @@ -0,0 +1 @@ +export { makeMemoryTable } from "./memory-table"; diff --git a/packages/spike/src/demo/memory-table.ts b/packages/spike/src/demo/memory-table.ts new file mode 100644 index 0000000..69eb4f5 --- /dev/null +++ b/packages/spike/src/demo/memory-table.ts @@ -0,0 +1,84 @@ +/** The fields of a row that hold a string, the only ones that can be its key */ +type StringField = { + [Field in keyof Row]: Row[Field] extends string ? Field : never; +}[keyof Row]; + +/** + * For demos only. Rows live in memory and are lost on restart. Every function + * is async, so the code that calls it reads as it would against a database. + * key names the field that holds the key of a row. + */ +export function makeMemoryTable( + key: StringField & keyof Row, +) { + const rows = new Map(); + + const insert = (row: Row) => { + const id = String(row[key]); + + if (rows.has(id)) throw new Error("a row with this key already exists"); + + rows.set(id, { ...row }); + }; + + const where = (match: Partial) => + [...rows.values()] + .filter((row) => + Object.entries(match).every( + ([field, value]) => row[field as keyof Row] === value, + ), + ) + .map((row) => ({ ...row })); + + return { + /** Stores a new row under its key. Throws when the key is taken. */ + insert: async (row: Row) => insert(row), + + /** The row for a key, null when there is none */ + get: async (id: string) => { + const row = rows.get(id); + + return row === undefined ? null : { ...row }; + }, + + /** + * Changes fields of the row for a key and returns the row, null when + * there is none. The key itself cannot change. + */ + update: async (id: string, fields: Partial) => { + const row = rows.get(id); + + if (row === undefined) return null; + if (key in fields) throw new Error("the key of a row cannot change"); + + rows.set(id, { ...row, ...fields }); + + return { ...row, ...fields }; + }, + + /** Removes the row for a key and returns it, null when there is none */ + delete: async (id: string) => { + const row = rows.get(id) ?? null; + rows.delete(id); + + return row; + }, + + /** Every row whose fields equal the ones given */ + where: async (match: Partial) => where(match), + + /** + * The first row whose fields equal the ones in match. When there is none, + * row is inserted and returned. isNew tells which of the two happened. + */ + findOrInsert: async (match: Partial, row: Row) => { + const [found] = where(match); + + if (found !== undefined) return { row: found, isNew: false }; + + insert(row); + + return { row: { ...row }, isNew: true }; + }, + }; +} diff --git a/packages/spike/src/index.ts b/packages/spike/src/index.ts new file mode 100644 index 0000000..8efb2b1 --- /dev/null +++ b/packages/spike/src/index.ts @@ -0,0 +1,4 @@ +export { makeOpaqueSessionManager } from "./sessions/opaque"; +export { makeSignedSessionManager } from "./sessions/signed"; +export { makeOTP } from "./strategies/otp"; +export { makePasskey } from "./strategies/passkey"; diff --git a/packages/spike/src/index.typecheck.ts b/packages/spike/src/index.typecheck.ts new file mode 100644 index 0000000..39fc0aa --- /dev/null +++ b/packages/spike/src/index.typecheck.ts @@ -0,0 +1,19 @@ +/** + * The export guards written during the spike, kept so they are not lost. + * Typechecked, never run. Each expected error proves a name is absent from + * the public entry. + */ + +// @ts-expect-error only the library issues proofs +import { issueProof } from "./index"; +// @ts-expect-error only the library consumes proofs +import { consumeProof } from "./index"; +// @ts-expect-error failures are made by strategies, the app only reads them +import { fail } from "./index"; +// @ts-expect-error an app never has to name a proof +import type { Proof } from "./index"; +// @ts-expect-error an app never has to name a failure +import type { Failure } from "./index"; + +export const absent = [issueProof, consumeProof, fail]; +export type Absent = [Proof, Failure]; diff --git a/packages/spike/src/proof.ts b/packages/spike/src/proof.ts new file mode 100644 index 0000000..38083ed --- /dev/null +++ b/packages/spike/src/proof.ts @@ -0,0 +1,36 @@ +/** Marks the type so a proof cannot be written by hand. Never exported. */ +const brand: unique symbol = Symbol("proof"); + +/** + * A receipt for what a strategy proved. A strategy issues one and a session + * manager consumes it. The library remembers every proof it issues and + * consumes only those, so an object that only looks like a proof is refused. + * A proof is an object in the server's memory and never leaves the process + * that issued it, so it is issued and consumed in the same call. + */ +export type Proof = { readonly proven: T; readonly [brand]: true }; + +/** Every proof issued and not yet consumed */ +const issued = new WeakSet(); + +/** A strategy ends by calling this. Nothing else makes a proof. */ +export function issueProof(proven: T): Proof { + const proof = Object.freeze({ + proven: Object.freeze({ ...proven }), + [brand]: true as const, + }); + + issued.add(proof); + + return proof; +} + +/** + * A session manager starts by calling this. Throws for a proof that was + * never issued or was already consumed. + */ +export function consumeProof(proof: Proof): void { + if (!issued.delete(proof)) { + throw new Error("not a proof, or already consumed"); + } +} diff --git a/packages/spike/src/proof.typecheck.ts b/packages/spike/src/proof.typecheck.ts new file mode 100644 index 0000000..3812b1f --- /dev/null +++ b/packages/spike/src/proof.typecheck.ts @@ -0,0 +1,22 @@ +/** + * The compile guards written during the spike, kept so they are not lost. + * Typechecked, never run. + */ +import type { Proof } from "./proof"; +import type { makeOpaqueSessionManager } from "./sessions/opaque"; + +declare const sessionManager: ReturnType< + typeof makeOpaqueSessionManager<{ userId: string }> +>; + +// @ts-expect-error a proof cannot be written by hand +const written: Proof<{ identifier: string }> = { + proven: { identifier: "victim@example.com" }, +}; +void written; + +void sessionManager.make( + // @ts-expect-error a session cannot be made without a proof + { proven: { identifier: "victim@example.com" } }, + { userId: "victim" }, +); diff --git a/packages/spike/src/result.ts b/packages/spike/src/result.ts new file mode 100644 index 0000000..c12959b --- /dev/null +++ b/packages/spike/src/result.ts @@ -0,0 +1,28 @@ +/** + * Every reason a strategy can fail with. A reason is added here before + * anything can fail with it. + */ +type Reason = + | "unknown_ticket" + | "expired_otp" + | "wrong_otp" + | "unknown_challenge" + | "expired_challenge" + | "unknown_credential" + | "invalid_credential"; + +/** What a strategy returns. Narrow on success, then read data or error. */ +export type Result = Success | Failure; + +type Success = { success: true; data: T }; + +/** An expected failure the app branches on. The error names what did not hold. */ +type Failure = { success: false; error: E }; + +export function succeed(data: T): Success { + return { success: true, data }; +} + +export function fail(reason: E): Failure { + return { success: false, error: reason }; +} diff --git a/packages/spike/src/sessions/opaque.ts b/packages/spike/src/sessions/opaque.ts new file mode 100644 index 0000000..4ec7bb7 --- /dev/null +++ b/packages/spike/src/sessions/opaque.ts @@ -0,0 +1,47 @@ +import { consumeProof, type Proof } from "../proof"; + +/** + * The token is a random string the row is stored under. The row is the + * session with expiresAt added, so a session has no expiresAt of its own. + */ +export function makeOpaqueSessionManager(args: { + /** Stores a session row under the token. expiresAt is in ms since the epoch. */ + store: (token: string, row: Session & { expiresAt: number }) => Promise; + /** Reads the session row for a token, null when there is none */ + get: (token: string) => Promise<(Session & { expiresAt: number }) | null>; + /** Removes the session row for a token. Does nothing when there is none. */ + delete: (token: string) => Promise; + /** Lifetime of a session in ms */ + ttl: number; +}) { + return { + /** Spends the proof and returns the token the session is stored under */ + make: async (proof: Proof, session: Session) => { + consumeProof(proof); + + const token = crypto.randomUUID(); + + await args.store(token, { + ...session, + expiresAt: Date.now() + args.ttl, + }); + + return token; + }, + + /** Null when there is no session for the token or it has expired */ + get: async (token: string): Promise => { + const row = await args.get(token); + + if (row === null) return null; + if (row.expiresAt <= Date.now()) return null; + + return row; + }, + + /** Ends the session. The token stops working at once. */ + end: async (token: string) => { + await args.delete(token); + }, + }; +} diff --git a/packages/spike/src/sessions/signed.ts b/packages/spike/src/sessions/signed.ts new file mode 100644 index 0000000..ee19efd --- /dev/null +++ b/packages/spike/src/sessions/signed.ts @@ -0,0 +1,85 @@ +import { consumeProof, type Proof } from "../proof"; + +/** + * Stateless. The session travels inside the token, signed so it cannot be + * altered. Nothing is stored, so there is nothing to end. A token is valid + * until it expires, which is why ttl is not optional here. + */ +export function makeSignedSessionManager(args: { + /** HMAC secret. Anyone holding it can mint a session. */ + secret: string; + /** Lifetime of a token in ms */ + ttl: number; +}) { + const key = crypto.subtle.importKey( + "raw", + new TextEncoder().encode(args.secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); + + return { + make: async (proof: Proof, session: Session) => { + consumeProof(proof); + + const payload = encode( + JSON.stringify({ session, exp: Date.now() + args.ttl }), + ); + const signature = await crypto.subtle.sign( + "HMAC", + await key, + bytes(payload), + ); + + return `${payload}.${encode(signature)}`; + }, + + get: async (token: string) => { + const [payload, signature, ...rest] = token.split("."); + if (payload === undefined || signature === undefined || rest.length > 0) { + return null; + } + + const valid = await crypto.subtle.verify( + "HMAC", + await key, + decode(signature), + bytes(payload), + ); + if (!valid) return null; + + // Safe to trust the shape, the signature proves this process wrote it + const { session, exp } = JSON.parse(text(decode(payload))) as { + session: Session; + exp: number; + }; + if (exp < Date.now()) return null; + + return session; + }, + }; +} + +function bytes(value: string) { + return new TextEncoder().encode(value); +} + +function text(value: Uint8Array) { + return new TextDecoder().decode(value); +} + +function encode(value: string | ArrayBuffer) { + const raw = typeof value === "string" ? bytes(value) : new Uint8Array(value); + + return btoa(String.fromCharCode(...raw)) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/, ""); +} + +function decode(value: string) { + const padded = value.replaceAll("-", "+").replaceAll("_", "/"); + + return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0)); +} diff --git a/packages/spike/src/strategies/otp.ts b/packages/spike/src/strategies/otp.ts new file mode 100644 index 0000000..5713ab4 --- /dev/null +++ b/packages/spike/src/strategies/otp.ts @@ -0,0 +1,94 @@ +import { fail, succeed, type Result } from "../result"; +import { issueProof, type Proof } from "../proof"; + +/** What the app stores for one otp request */ +type OtpRow = { + identifier: string; + otp: string; + /** When the otp stops working, in ms since the epoch */ + expiresAt: number; + /** How many guesses the ticket still allows */ + attemptsLeft: number; +}; + +export function makeOTP(args: { + /** + * Stores an otp row under the ticket. Called on send, and again after a + * wrong guess to put back the row that take removed. + */ + store: (ticket: string, row: OtpRow) => Promise; + /** Removes the otp row for a ticket and returns it, atomically. Null when there is none. */ + take: (ticket: string) => Promise; + /** Delivers the otp to the identifier, an email address or a phone number */ + send: (identifier: string, otp: string) => Promise; + /** Lifetime of an otp in ms */ + ttl: number; + /** How many guesses one ticket allows */ + attempts: number; +}) { + return { + /** Returns the ticket the requesting client holds until it verifies */ + send: async (identifier: string) => { + const ticket = crypto.randomUUID(); + const otp = sixDigits(); + + await args.store(ticket, { + identifier, + otp, + expiresAt: Date.now() + args.ttl, + attemptsLeft: args.attempts, + }); + await args.send(identifier, otp); + + return ticket; + }, + + verify: async ({ + ticket, + otp, + }: { + ticket: string; + otp: string; + }): Promise< + Result< + Proof<{ identifier: string }>, + "unknown_ticket" | "expired_otp" | "wrong_otp" + > + > => { + // The row is out of the table while it is checked, so guesses made at + // the same time cannot get past the limit + const row = await args.take(ticket); + + if (row === null) return fail("unknown_ticket"); + if (row.expiresAt <= Date.now()) return fail("expired_otp"); + + if (row.otp !== otp) { + if (row.attemptsLeft > 1) { + await args.store(ticket, { + ...row, + attemptsLeft: row.attemptsLeft - 1, + }); + } + + return fail("wrong_otp"); + } + + return succeed(issueProof({ identifier: row.identifier })); + }, + }; +} + +/** Six random digits, every value as likely as any other */ +function sixDigits() { + const bytes = new Uint8Array(4); + const view = new DataView(bytes.buffer); + + // 4 294 000 000 is the largest multiple of a million that fits in 32 bits. + // A draw at or above it is thrown away, or the low values would come up + // more often. + do { + crypto.getRandomValues(bytes); + } while (view.getUint32(0) >= 4_294_000_000); + + return String(view.getUint32(0) % 1_000_000).padStart(6, "0"); +} diff --git a/packages/spike/src/strategies/passkey.ts b/packages/spike/src/strategies/passkey.ts new file mode 100644 index 0000000..3bf0eac --- /dev/null +++ b/packages/spike/src/strategies/passkey.ts @@ -0,0 +1,200 @@ +import { fail, succeed, type Result } from "../result"; +import { issueProof, type Proof } from "../proof"; +import type { + PasskeyAuthenticationCredential, + PasskeyRegistrationCredential, +} from "../webauthn/contracts"; +import { + base64urlDecode, + base64urlEncode, + randomBase64url, +} from "../webauthn/crypto"; +import { invariant } from "../webauthn/lib"; +import { + parseClientData, + verifyAuthenticationCredential, + verifyRegistrationCredential, +} from "../webauthn/webauthn"; + +/** What the app stores for one challenge */ +type ChallengeRow = { + /** Who a registration is for. Null when the challenge is for authentication. */ + handle: string | null; + /** When the challenge stops working, in ms since the epoch */ + expiresAt: number; +}; + +/** What the app stores for one passkey */ +type CredentialRow = { + /** Who the passkey belongs to */ + handle: string; + publicKey: string; + /** How many times the authenticator says the passkey was used */ + counter: number; +}; + +/** + * The library stores the credential with the handle the app gave it and hands + * the handle back on authentication. What the handle means is the app's + * business. + */ +export function makePasskey(args: { + /** The relying party id, the domain passkeys are bound to */ + rpId: string; + /** The relying party name, shown by the authenticator */ + rpName: string; + /** Every origin a ceremony may run from, as scheme, host, and port */ + origins: string[]; + /** Lifetime of a challenge in ms */ + ttl: number; + /** Stores a challenge row under the challenge */ + storeChallenge: (challenge: string, row: ChallengeRow) => Promise; + /** Removes the row for a challenge and returns it, atomically. Null when there is none. */ + takeChallenge: (challenge: string) => Promise; + /** Stores a credential row under the id the authenticator chose */ + storeCredential: (credentialId: string, row: CredentialRow) => Promise; + /** Reads the credential row for an id, null when there is none */ + getCredential: (credentialId: string) => Promise; + /** Writes the counter of a credential after the passkey was used */ + setCounter: (credentialId: string, counter: number) => Promise; +}) { + const policy = { rpId: args.rpId, allowedOrigins: args.origins }; + + // Spike only. Says in the server log why the verification refused a + // passkey, until the reasons are designed. + const refused = (error: unknown) => { + console.error("[passkey] refused:", error); + + return null; + }; + + const makeChallenge = async (handle: string | null) => { + const challenge = randomBase64url(32); + + await args.storeChallenge(challenge, { + handle, + expiresAt: Date.now() + args.ttl, + }); + + return challenge; + }; + + return { + /** + * Returns the options the browser creates a passkey from. handle is the + * app's stable id for the person, the authenticator keeps it with the + * credential. name is what the authenticator shows, an email or a + * username. + */ + beginRegistration: async ({ + handle, + name, + }: { + handle: string; + name: string; + }): Promise => ({ + challenge: await makeChallenge(handle), + rp: { id: args.rpId, name: args.rpName }, + user: { id: base64urlEncode(handle), name, displayName: name }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + attestation: "none", + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }), + + /** Takes what the browser made from the options, and stores the passkey */ + finishRegistration: async ( + credential: PasskeyRegistrationCredential, + ): Promise< + Result< + Proof<{ credentialId: string; userHandle: string }>, + "unknown_challenge" | "expired_challenge" | "invalid_credential" + > + > => { + const clientData = parseClientData(credential.response.clientDataJSON); + + if (clientData === null) return fail("invalid_credential"); + + const row = await args.takeChallenge(clientData.challenge); + + if (row === null || row.handle === null) return fail("unknown_challenge"); + if (row.expiresAt <= Date.now()) return fail("expired_challenge"); + + const verified = await verifyRegistrationCredential( + credential, + clientData.challenge, + policy, + ).catch(refused); + + if (verified === null) return fail("invalid_credential"); + + await args.storeCredential(verified.credentialId, { + handle: row.handle, + publicKey: base64urlEncode(verified.publicKey), + counter: verified.counter, + }); + + return succeed( + issueProof({ + credentialId: verified.credentialId, + userHandle: row.handle, + }), + ); + }, + + /** Returns the options the browser signs in with */ + beginAuthentication: + async (): Promise => ({ + challenge: await makeChallenge(null), + rpId: args.rpId, + userVerification: "preferred", + }), + + /** Takes what the browser made from the options */ + finishAuthentication: async ( + credential: PasskeyAuthenticationCredential, + ): Promise< + Result< + Proof<{ credentialId: string; userHandle: string }>, + | "unknown_challenge" + | "expired_challenge" + | "unknown_credential" + | "invalid_credential" + > + > => { + const clientData = parseClientData(credential.response.clientDataJSON); + + if (clientData === null) return fail("invalid_credential"); + + const row = await args.takeChallenge(clientData.challenge); + + if (row === null || row.handle !== null) return fail("unknown_challenge"); + if (row.expiresAt <= Date.now()) return fail("expired_challenge"); + + const stored = await args.getCredential(credential.id); + + if (stored === null) return fail("unknown_credential"); + + const publicKey = base64urlDecode(stored.publicKey); + + invariant(publicKey, "the library stored the public key as base64url"); + + const verified = await verifyAuthenticationCredential( + credential, + { publicKey, counter: stored.counter }, + clientData.challenge, + policy, + ).catch(refused); + + if (verified === null) return fail("invalid_credential"); + + await args.setCounter(credential.id, verified.counter); + + return succeed( + issueProof({ credentialId: credential.id, userHandle: stored.handle }), + ); + }, + }; +} diff --git a/packages/spike/src/webauthn/cbor.ts b/packages/spike/src/webauthn/cbor.ts new file mode 100644 index 0000000..04e9fe5 --- /dev/null +++ b/packages/spike/src/webauthn/cbor.ts @@ -0,0 +1,102 @@ +/** + * Minimal CBOR decoder for WebAuthn + * + * Only decodes the subset used by WebAuthn: + * - Major type 0: unsigned integer + * - Major type 1: negative integer + * - Major type 2: byte string + * - Major type 3: text string + * - Major type 4: array + * - Major type 5: map + * + * All reads are bounded by the input. + */ + +export type CborValue = + number | Uint8Array | string | CborValue[] | Map; + +export function decodeCbor(data: Uint8Array): CborValue { + let offset = 0; + + function read(n: number): Uint8Array { + if (offset + n > data.length) { + throw new Error("CBOR: input exhausted"); + } + const slice = data.subarray(offset, offset + n); + offset += n; + return slice; + } + + function readUint8(): number { + const byte = data[offset]; + if (byte === undefined) { + throw new Error("CBOR: input exhausted"); + } + offset += 1; + return byte; + } + + function readLength(additionalInfo: number): number { + if (additionalInfo < 24) return additionalInfo; + if (additionalInfo === 24) return readUint8(); + if (additionalInfo === 25) { + const bytes = read(2); + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint16(0); + } + if (additionalInfo === 26) { + const bytes = read(4); + return new DataView( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ).getUint32(0); + } + throw new Error("CBOR: unsupported length encoding"); + } + + function decode(): CborValue { + const initial = readUint8(); + const majorType = initial >> 5; + const additionalInfo = initial & 0x1f; + + switch (majorType) { + case 0: // unsigned integer + return readLength(additionalInfo); + case 1: // negative integer + return -1 - readLength(additionalInfo); + case 2: // byte string + return new Uint8Array(read(readLength(additionalInfo))); + case 3: { + // text string + const bytes = read(readLength(additionalInfo)); + return new TextDecoder().decode(bytes); + } + case 4: { + // array + const length = readLength(additionalInfo); + const arr: CborValue[] = []; + for (let i = 0; i < length; i++) arr.push(decode()); + return arr; + } + case 5: { + // map + const length = readLength(additionalInfo); + const map = new Map(); + for (let i = 0; i < length; i++) { + const key = decode(); + const value = decode(); + map.set(key, value); + } + return map; + } + default: + throw new Error(`CBOR: unsupported major type ${majorType.toString()}`); + } + } + + return decode(); +} diff --git a/packages/spike/src/webauthn/contracts.ts b/packages/spike/src/webauthn/contracts.ts new file mode 100644 index 0000000..e8837dc --- /dev/null +++ b/packages/spike/src/webauthn/contracts.ts @@ -0,0 +1,23 @@ +/** + * Passkey ceremony inputs, picked from the standard WebAuthn JSON types: + * exactly the fields verification consumes. Everything outside the signed bytes + * is unauthenticated and not accepted. The output of + * PublicKeyCredential.toJSON() satisfies these shapes. + */ +export type PasskeyRegistrationCredential = { + response: Pick< + AuthenticatorAttestationResponseJSON, + "clientDataJSON" | "attestationObject" + >; +}; + +/** See PasskeyRegistrationCredential. id locates the stored credential; the signature check binds it. */ +export type PasskeyAuthenticationCredential = Pick< + AuthenticationResponseJSON, + "id" +> & { + response: Pick< + AuthenticatorAssertionResponseJSON, + "clientDataJSON" | "authenticatorData" | "signature" + >; +}; diff --git a/packages/spike/src/webauthn/crypto.ts b/packages/spike/src/webauthn/crypto.ts new file mode 100644 index 0000000..5296ce4 --- /dev/null +++ b/packages/spike/src/webauthn/crypto.ts @@ -0,0 +1,134 @@ +import { invariant } from "./lib"; + +/** + * Crypto primitives using Web Crypto API + * + * All functions work in browsers, Node.js 18+, Bun, and Deno. + */ + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** Encode bytes or a UTF-8 string as unpadded base64url */ +export function base64urlEncode(data: Uint8Array | string): string { + const bytes = typeof data === "string" ? encoder.encode(data) : data; + const binary = String.fromCharCode(...bytes); + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +/** Decode base64url string to bytes */ +export function base64urlDecode(str: string): Uint8Array | null { + try { + const padded = str.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(padded); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); + } catch { + return null; + } +} + +/** Convert base64url string to ArrayBuffer */ +export function base64urlToBuffer(base64url: string): ArrayBuffer { + const bytes = base64urlDecode(base64url); + + invariant( + bytes, + "WebAuthn options from the server are well-formed base64url", + ); + + // Create a fresh ArrayBuffer (not SharedArrayBuffer) for WebAuthn API compatibility + return new Uint8Array(bytes).buffer; +} + +/** Convert ArrayBuffer to base64url string */ +export function bufferToBase64url(buffer: ArrayBuffer): string { + return base64urlEncode(new Uint8Array(buffer)); +} + +/** Compute SHA-256 hash of data */ +export async function sha256(data: Uint8Array): Promise { + // Create a fresh ArrayBuffer to satisfy TypeScript's BufferSource type + const buffer = new Uint8Array(data).buffer; + const hash = await crypto.subtle.digest("SHA-256", buffer); + return new Uint8Array(hash); +} + +/** Import a secret string as an HMAC-SHA256 key */ +async function importHmacKey( + secret: string, + usages: KeyUsage[], +): Promise { + try { + return await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + usages, + ); + } catch { + return null; + } +} + +/** Sign a payload string with HMAC-SHA256, return base64url signature */ +export async function hmacSign( + payload: string, + secret: string, +): Promise { + const key = await importHmacKey(secret, ["sign"]); + if (!key) return null; + + const signature = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(payload), + ); + + return base64urlEncode(new Uint8Array(signature)); +} + +/** Verify an HMAC-SHA256 signature (constant-time comparison) */ +export async function hmacVerify( + payload: string, + signature: string, + secret: string, +): Promise { + const sigBytes = base64urlDecode(signature); + if (!sigBytes) return false; + + const key = await importHmacKey(secret, ["verify"]); + if (!key) return false; + + // Create a new ArrayBuffer to satisfy TypeScript's BufferSource type + const sigBuffer = new Uint8Array(sigBytes).buffer; + return crypto.subtle.verify("HMAC", key, sigBuffer, encoder.encode(payload)); +} + +/** Encode a JSON payload to base64url */ +// TODO: Rename to jsonToBase64Url +export function encodePayload(payload: object): string { + return base64urlEncode(JSON.stringify(payload)); +} + +/** Decode a base64url string to JSON payload */ +// TODO: Rename to base64UrlToJson +export function decodePayload(encoded: string): unknown { + const bytes = base64urlDecode(encoded); + if (!bytes) return null; + + try { + const parsed: unknown = JSON.parse(decoder.decode(bytes)); + return parsed; + } catch { + return null; + } +} + +/** Unguessable base64url token from byteCount random bytes */ +export function randomBase64url(byteCount: number): string { + return base64urlEncode(crypto.getRandomValues(new Uint8Array(byteCount))); +} diff --git a/packages/spike/src/webauthn/lib.ts b/packages/spike/src/webauthn/lib.ts new file mode 100644 index 0000000..922a767 --- /dev/null +++ b/packages/spike/src/webauthn/lib.ts @@ -0,0 +1,24 @@ +/** + * Invariant assertion + * + * A _type assertion_ tells the compiler to believe a condition with no check. + * An _invariant assertion_ checks the condition at runtime and throws if it + * fails. Both narrow the type. + * + * This project bans type assertions and uses invariant assertions instead. + * + * A failed invariant is a bug, not bad input. + */ +export function invariant( + /** The condition that must hold. */ + condition: unknown, + /** Why the condition cannot fail. */ + message: string, +): asserts condition { + if (!condition) throw new Error(`Invariant violation: ${message}`); +} + +/** True for any non-null object. Narrows it so keys can be read. */ +export function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} diff --git a/packages/spike/src/webauthn/webauthn.ts b/packages/spike/src/webauthn/webauthn.ts new file mode 100644 index 0000000..4d7d7fe --- /dev/null +++ b/packages/spike/src/webauthn/webauthn.ts @@ -0,0 +1,451 @@ +/** + * WebAuthn verification utilities + * + * Implements credential verification using Web Crypto API. + * No external dependencies. + */ + +import { base64urlDecode, base64urlEncode, sha256 } from "./crypto"; +import { decodeCbor, type CborValue } from "./cbor"; +import type { + PasskeyRegistrationCredential, + PasskeyAuthenticationCredential, +} from "./contracts"; + +const encoder = new TextEncoder(); + +type ClientData = { + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type */ + type: string; // "webauthn.create" or "webauthn.get" + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-challenge */ + challenge: string; + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-origin */ + origin: string; + /** https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-crossorigin */ + crossOrigin: boolean; +}; + +/** Decodes and validates clientDataJSON. Malformed input returns null. */ +export function parseClientData(clientDataJSON: string): ClientData | null { + const bytes = base64urlDecode(clientDataJSON); + if (bytes === null) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } + + if (typeof parsed !== "object" || parsed === null) return null; + const type = "type" in parsed ? parsed.type : null; + const challenge = "challenge" in parsed ? parsed.challenge : null; + const origin = "origin" in parsed ? parsed.origin : null; + const crossOrigin = "crossOrigin" in parsed ? parsed.crossOrigin : false; + + if ( + typeof type !== "string" || + typeof challenge !== "string" || + typeof origin !== "string" + ) { + return null; + } + + return { type, challenge, origin, crossOrigin: crossOrigin === true }; +} + +type ParsedAuthData = { + rpIdHash: Uint8Array; + flags: number; + signCount: number; + userPresent: boolean; + userVerified: boolean; + credentialId?: Uint8Array | undefined; + coseKey?: Map | undefined; +}; + +/** Compare two Uint8Arrays for equality */ +function arrayEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +/** Concatenate two Uint8Arrays */ +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a, 0); + result.set(b, a.length); + return result; +} + +/** + * The origin must match one allowed origin exactly (scheme + host + port), + * and cross-origin ceremonies are rejected. + */ +function verifyOrigin(clientData: ClientData, allowedOrigins: string[]): void { + if (clientData.crossOrigin) { + throw new Error("Cross-origin ceremony rejected"); + } + if (!allowedOrigins.includes(clientData.origin)) { + throw new Error(`Origin not allowed: ${clientData.origin}`); + } +} + +/** + * Convert DER-encoded ECDSA signature to raw format + * + * WebAuthn returns signatures in DER format: + * 0x30 0x02 0x02 + * + * Web Crypto expects raw format: r || s (32 bytes each for P-256) + */ +function derToRaw(der: Uint8Array): Uint8Array { + function byteAt(i: number): number { + const byte = der[i]; + if (byte === undefined) { + throw new Error("DER signature truncated"); + } + return byte; + } + + // Parse DER sequence + if (byteAt(0) !== 0x30) { + throw new Error("Invalid DER signature: expected sequence"); + } + + let offset = 2; // skip 0x30 and length byte + + // Parse r integer + if (byteAt(offset) !== 0x02) { + throw new Error("Invalid DER signature: expected integer tag for r"); + } + const rLen = byteAt(offset + 1); + if (offset + 2 + rLen > der.length) { + throw new Error("DER signature truncated"); + } + let r = der.subarray(offset + 2, offset + 2 + rLen); + offset += 2 + rLen; + + // Parse s integer + if (byteAt(offset) !== 0x02) { + throw new Error("Invalid DER signature: expected integer tag for s"); + } + const sLen = byteAt(offset + 1); + if (offset + 2 + sLen > der.length) { + throw new Error("DER signature truncated"); + } + let s = der.subarray(offset + 2, offset + 2 + sLen); + + // DER integers may have leading zero for positive numbers + // Strip leading zeros but keep 32 bytes + if (r.length > 32) r = r.subarray(r.length - 32); + if (s.length > 32) s = s.subarray(s.length - 32); + + // Pad to 32 bytes each + const raw = new Uint8Array(64); + raw.set(r, 32 - r.length); + raw.set(s, 64 - s.length); + + return raw; +} + +/** + * Parse authenticator data + * + * Format: + * rpIdHash (32 bytes) + * flags (1 byte) + * signCount (4 bytes, big-endian) + * [attestedCredentialData] (if AT flag set) + * [extensions] (if ED flag set) + * + * Attested credential data format: + * aaguid (16 bytes) + * credentialIdLength (2 bytes, big-endian) + * credentialId (credentialIdLength bytes) + * credentialPublicKey (COSE, remaining bytes) + */ +function parseAuthData(authData: Uint8Array): ParsedAuthData { + if (authData.length < 37) { + throw new Error("Authenticator data too short"); + } + + const rpIdHash = authData.subarray(0, 32); + const view = new DataView( + authData.buffer, + authData.byteOffset, + authData.byteLength, + ); + const flags = view.getUint8(32); + const signCount = view.getUint32(33, false); + + const userPresent = !!(flags & 0x01); + const userVerified = !!(flags & 0x04); + const attestedCredentialData = !!(flags & 0x40); + + let credentialId: Uint8Array | undefined; + let coseKey: Map | undefined; + + if (attestedCredentialData) { + if (authData.length < 55) { + throw new Error("Attested credential data too short"); + } + // Skip aaguid (16 bytes), read credentialIdLength + const credIdLen = new DataView( + authData.buffer, + authData.byteOffset + 53, + 2, + ).getUint16(0, false); + if (authData.length < 55 + credIdLen) { + throw new Error("Credential id out of bounds"); + } + + credentialId = authData.subarray(55, 55 + credIdLen); + const publicKeyBytes = authData.subarray(55 + credIdLen); + const decoded = decodeCbor(publicKeyBytes); + if (!(decoded instanceof Map)) { + throw new Error("COSE key is not a map"); + } + coseKey = decoded; + } + + return { + rpIdHash, + flags, + signCount, + userPresent, + userVerified, + credentialId, + coseKey, + }; +} + +/** + * Serialize COSE key to Uint8Array for storage + * + * We store just the raw x,y coordinates (64 bytes) with a type prefix + * Format: 0x04 || x (32 bytes) || y (32 bytes) + */ +function serializeCoseKey(coseKey: Map): Uint8Array { + const kty = coseKey.get(1); + const alg = coseKey.get(3); + + if (kty !== 2 || alg !== -7) { + throw new Error("Only ES256 (P-256) keys supported"); + } + + const x = coseKey.get(-2); + const y = coseKey.get(-3); + if ( + !(x instanceof Uint8Array) || + !(y instanceof Uint8Array) || + x.length !== 32 || + y.length !== 32 + ) { + throw new Error("Invalid P-256 coordinates"); + } + + // Uncompressed point format: 0x04 || x || y + const result = new Uint8Array(65); + result[0] = 0x04; + result.set(x, 1); + result.set(y, 33); + return result; +} + +/** + * Import stored public key as CryptoKey + * + * Expects format: 0x04 || x (32 bytes) || y (32 bytes) + */ +async function importStoredKey(publicKey: Uint8Array): Promise { + if (publicKey.length !== 65 || publicKey[0] !== 0x04) { + throw new Error("Invalid stored public key format"); + } + + const x = publicKey.subarray(1, 33); + const y = publicKey.subarray(33, 65); + + return crypto.subtle.importKey( + "jwk", + { + kty: "EC", + crv: "P-256", + x: base64urlEncode(x), + y: base64urlEncode(y), + }, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); +} + +type WebAuthnPolicy = { + rpId: string; + allowedOrigins: string[]; +}; + +export type VerifyRegistrationResult = { + credentialId: string; + publicKey: Uint8Array; + counter: number; +}; + +/** + * Verify a WebAuthn registration credential + */ +export async function verifyRegistrationCredential( + credential: PasskeyRegistrationCredential, + expectedChallenge: string, + policy: WebAuthnPolicy, +): Promise { + // 1. Decode and verify clientDataJSON + const clientData = parseClientData(credential.response.clientDataJSON); + if (!clientData) { + throw new Error("Invalid clientDataJSON"); + } + + // https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type + if (clientData.type !== "webauthn.create") { + throw new Error("Invalid clientData type: expected webauthn.create"); + } + + if (clientData.challenge !== expectedChallenge) { + throw new Error("Challenge mismatch"); + } + + verifyOrigin(clientData, policy.allowedOrigins); + + // 2. Decode attestationObject (CBOR) + const attestationBytes = base64urlDecode( + credential.response.attestationObject, + ); + if (!attestationBytes) { + throw new Error("Invalid attestationObject encoding"); + } + const attestationObject = decodeCbor(attestationBytes); + if (!(attestationObject instanceof Map)) { + throw new Error("attestationObject is not a map"); + } + + const authData = attestationObject.get("authData"); + if (!(authData instanceof Uint8Array)) { + throw new Error("Missing authData in attestationObject"); + } + + // 3. Parse authData + const parsed = parseAuthData(authData); + + // 4. Verify rpIdHash + const expectedRpIdHash = await sha256(encoder.encode(policy.rpId)); + if (!arrayEqual(parsed.rpIdHash, expectedRpIdHash)) { + throw new Error("RP ID hash mismatch"); + } + + // 5. Verify flags + if (!parsed.userPresent) { + throw new Error("User presence required"); + } + + // 6. Extract credential data + if (!parsed.credentialId || !parsed.coseKey) { + throw new Error("No credential data in authData"); + } + + // For "none" attestation (which we use), we skip attestation verification + // and trust the credential. This is acceptable for most use cases. + + return { + credentialId: base64urlEncode(parsed.credentialId), + publicKey: serializeCoseKey(parsed.coseKey), + counter: parsed.signCount, + }; +} + +export type VerifyAuthenticationResult = { + counter: number; +}; + +/** + * Verify a WebAuthn authentication credential + */ +export async function verifyAuthenticationCredential( + credential: PasskeyAuthenticationCredential, + storedCredential: { publicKey: Uint8Array; counter: number }, + expectedChallenge: string, + policy: WebAuthnPolicy, +): Promise { + // 1. Decode and verify clientDataJSON + const clientDataBytes = base64urlDecode(credential.response.clientDataJSON); + const clientData = parseClientData(credential.response.clientDataJSON); + if (!clientDataBytes || !clientData) { + throw new Error("Invalid clientDataJSON"); + } + + // https://www.w3.org/TR/webauthn-3/#dom-collectedclientdata-type + if (clientData.type !== "webauthn.get") { + throw new Error("Invalid clientData type: expected webauthn.get"); + } + + if (clientData.challenge !== expectedChallenge) { + throw new Error("Challenge mismatch"); + } + + verifyOrigin(clientData, policy.allowedOrigins); + + // 2. Decode authenticatorData + const authData = base64urlDecode(credential.response.authenticatorData); + if (!authData) { + throw new Error("Invalid authenticatorData encoding"); + } + const parsed = parseAuthData(authData); + + // 3. Verify rpIdHash + const expectedRpIdHash = await sha256(encoder.encode(policy.rpId)); + if (!arrayEqual(parsed.rpIdHash, expectedRpIdHash)) { + throw new Error("RP ID hash mismatch"); + } + + // 4. Verify user presence + if (!parsed.userPresent) { + throw new Error("User presence required"); + } + + // 5. Verify counter (replay protection) + // Counter of 0 means the authenticator doesn't support counters + if ( + storedCredential.counter !== 0 && + parsed.signCount !== 0 && + parsed.signCount <= storedCredential.counter + ) { + throw new Error("Signature counter replay detected"); + } + + // 6. Verify signature + const clientDataHash = await sha256(clientDataBytes); + const signedData = concat(authData, clientDataHash); + const signature = base64urlDecode(credential.response.signature); + if (!signature) { + throw new Error("Invalid signature encoding"); + } + + const publicKey = await importStoredKey(storedCredential.publicKey); + const rawSignature = derToRaw(signature); + + // Create fresh ArrayBuffers to satisfy TypeScript's BufferSource type + const valid = await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + new Uint8Array(rawSignature).buffer, + new Uint8Array(signedData).buffer, + ); + + if (!valid) { + throw new Error("Invalid signature"); + } + + return { counter: parsed.signCount }; +} diff --git a/packages/spike/tsconfig.json b/packages/spike/tsconfig.json new file mode 100644 index 0000000..89ca6a2 --- /dev/null +++ b/packages/spike/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + /* Environment */ + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ESNext", "DOM"], + + /* Base */ + "moduleDetection": "force", + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true + } +}