From 014d4b57f0b86cd8bde190c3457fb1dca9b26712 Mon Sep 17 00:00:00 2001 From: lewis Date: Tue, 23 Dec 2025 20:27:17 +0200 Subject: [PATCH] Fixed up did:web account creation --- frontend/deno.lock | 10 + frontend/package.json | 2 + frontend/src/lib/api.ts | 63 ++- frontend/src/lib/auth.svelte.ts | 12 + frontend/src/lib/crypto.ts | 106 ++++ .../lib/registration/AppPasswordStep.svelte | 121 +++++ .../src/lib/registration/DidDocStep.svelte | 166 ++++++ .../src/lib/registration/KeyChoiceStep.svelte | 117 +++++ .../lib/registration/VerificationStep.svelte | 103 ++++ frontend/src/lib/registration/flow.svelte.ts | 340 ++++++++++++ frontend/src/lib/registration/index.ts | 6 + frontend/src/lib/registration/types.ts | 50 ++ frontend/src/routes/Register.svelte | 297 ++++++----- frontend/src/routes/RegisterPasskey.svelte | 485 ++++++------------ src/api/identity/account.rs | 32 +- src/api/server/passkey_account.rs | 145 ++++-- 16 files changed, 1537 insertions(+), 518 deletions(-) create mode 100644 frontend/src/lib/crypto.ts create mode 100644 frontend/src/lib/registration/AppPasswordStep.svelte create mode 100644 frontend/src/lib/registration/DidDocStep.svelte create mode 100644 frontend/src/lib/registration/KeyChoiceStep.svelte create mode 100644 frontend/src/lib/registration/VerificationStep.svelte create mode 100644 frontend/src/lib/registration/flow.svelte.ts create mode 100644 frontend/src/lib/registration/index.ts create mode 100644 frontend/src/lib/registration/types.ts diff --git a/frontend/deno.lock b/frontend/deno.lock index 4bf6511..a3c05bf 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -1,11 +1,13 @@ { "version": "5", "specifiers": { + "npm:@noble/secp256k1@^2.1.0": "2.3.0", "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3", "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", "npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1", "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1", "npm:jsdom@^25.0.1": "25.0.1", + "npm:multiformats@^13.3.1": "13.4.2", "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0", "npm:svelte@5": "5.45.10_acorn@8.15.0", "npm:vite@*": "6.4.1_picomatch@4.0.3", @@ -492,6 +494,9 @@ "@jridgewell/sourcemap-codec" ] }, + "@noble/secp256k1@2.3.0": { + "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" + }, "@rollup/rollup-android-arm-eabi@4.53.3": { "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", "os": ["android"], @@ -1281,6 +1286,9 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "multiformats@13.4.2": { + "integrity": "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==" + }, "nanoid@3.3.11": { "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "bin": true @@ -1636,11 +1644,13 @@ "workspace": { "packageJson": { "dependencies": [ + "npm:@noble/secp256k1@^2.1.0", "npm:@sveltejs/vite-plugin-svelte@5", "npm:@testing-library/jest-dom@^6.6.3", "npm:@testing-library/svelte@^5.2.6", "npm:@testing-library/user-event@^14.5.2", "npm:jsdom@^25.0.1", + "npm:multiformats@^13.3.1", "npm:svelte-i18n@^4.0.1", "npm:svelte@5", "npm:vite@6", diff --git a/frontend/package.json b/frontend/package.json index 4fe7c98..0882693 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@noble/secp256k1": "^2.1.0", + "multiformats": "^13.3.1", "svelte-i18n": "^4.0.1" }, "devDependencies": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a312695..7fafcee 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -95,6 +95,7 @@ export interface CreateAccountParams { inviteCode?: string didType?: DidType did?: string + signingKey?: string verificationChannel?: VerificationChannel discordId?: string telegramUsername?: string @@ -120,22 +121,34 @@ export interface ConfirmSignupResult { } export const api = { - async createAccount(params: CreateAccountParams): Promise { - return xrpc('com.atproto.server.createAccount', { + async createAccount(params: CreateAccountParams, byodToken?: string): Promise { + const url = `${API_BASE}/com.atproto.server.createAccount` + const headers: Record = { 'Content-Type': 'application/json' } + if (byodToken) { + headers['Authorization'] = `Bearer ${byodToken}` + } + const response = await fetch(url, { method: 'POST', - body: { + headers, + body: JSON.stringify({ handle: params.handle, email: params.email, password: params.password, inviteCode: params.inviteCode, didType: params.didType, did: params.did, + signingKey: params.signingKey, verificationChannel: params.verificationChannel, discordId: params.discordId, telegramUsername: params.telegramUsername, signalNumber: params.signalNumber, - }, + }), }) + const data = await response.json() + if (!response.ok) { + throw new ApiError(data.error, data.message, response.status) + } + return data }, async confirmSignup(did: string, verificationCode: string): Promise { @@ -750,6 +763,29 @@ export const api = { }) }, + async reserveSigningKey(did?: string): Promise<{ signingKey: string }> { + return xrpc('com.atproto.server.reserveSigningKey', { + method: 'POST', + body: { did }, + }) + }, + + async getRecommendedDidCredentials(token: string): Promise<{ + rotationKeys?: string[] + alsoKnownAs?: string[] + verificationMethods?: { atproto?: string } + services?: { atproto_pds?: { type: string; endpoint: string } } + }> { + return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token }) + }, + + async activateAccount(token: string): Promise { + await xrpc('com.atproto.server.activateAccount', { + method: 'POST', + token, + }) + }, + async createPasskeyAccount(params: { handle: string email?: string @@ -761,16 +797,29 @@ export const api = { discordId?: string telegramUsername?: string signalNumber?: string - }): Promise<{ + }, byodToken?: string): Promise<{ did: string handle: string setupToken: string setupExpiresAt: string }> { - return xrpc('com.tranquil.account.createPasskeyAccount', { + const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount` + const headers: Record = { + 'Content-Type': 'application/json' + } + if (byodToken) { + headers['Authorization'] = `Bearer ${byodToken}` + } + const res = await fetch(url, { method: 'POST', - body: params, + headers, + body: JSON.stringify(params), }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) + throw new ApiError(res.status, err.error, err.message) + } + return res.json() }, async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> { diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index e33a38d..f854608 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -265,6 +265,18 @@ export async function resendVerification(did: string): Promise { } } +export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void { + const newSession: Session = { + did: session.did, + handle: session.handle, + accessJwt: session.accessJwt, + refreshJwt: session.refreshJwt, + } + state.session = newSession + saveSession(newSession) + addOrUpdateSavedAccount(newSession) +} + export async function logout(): Promise { if (state.session) { try { diff --git a/frontend/src/lib/crypto.ts b/frontend/src/lib/crypto.ts new file mode 100644 index 0000000..d3ca6e3 --- /dev/null +++ b/frontend/src/lib/crypto.ts @@ -0,0 +1,106 @@ +import * as secp from '@noble/secp256k1' +import { base58btc } from 'multiformats/bases/base58' + +const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]) + +export interface Keypair { + privateKey: Uint8Array + publicKey: Uint8Array + publicKeyMultibase: string + publicKeyDidKey: string +} + +export async function generateKeypair(): Promise { + const privateKey = secp.utils.randomPrivateKey() + const publicKey = secp.getPublicKey(privateKey, true) + + const multicodecKey = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + publicKey.length) + multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0) + multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length) + + const publicKeyMultibase = base58btc.encode(multicodecKey) + const publicKeyDidKey = `did:key:${publicKeyMultibase}` + + return { + privateKey, + publicKey, + publicKeyMultibase, + publicKeyDidKey, + } +} + +function base64UrlEncode(data: Uint8Array | string): string { + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]) + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +export async function createServiceJwt( + privateKey: Uint8Array, + issuerDid: string, + audienceDid: string, + lxm: string +): Promise { + const header = { + alg: 'ES256K', + typ: 'JWT', + } + + const now = Math.floor(Date.now() / 1000) + const payload = { + iss: issuerDid, + sub: issuerDid, + aud: audienceDid, + exp: now + 180, + iat: now, + lxm: lxm, + } + + const headerEncoded = base64UrlEncode(JSON.stringify(header)) + const payloadEncoded = base64UrlEncode(JSON.stringify(payload)) + const message = `${headerEncoded}.${payloadEncoded}` + + const msgBytes = new TextEncoder().encode(message) + const hashBuffer = await crypto.subtle.digest('SHA-256', msgBytes) + const msgHash = new Uint8Array(hashBuffer) + const signature = await secp.signAsync(msgHash, privateKey) + const sigBytes = signature.toCompactRawBytes() + const signatureEncoded = base64UrlEncode(sigBytes) + + return `${message}.${signatureEncoded}` +} + +export function generateDidDocument( + did: string, + publicKeyMultibase: string, + handle: string, + pdsEndpoint: string +): object { + return { + '@context': [ + 'https://www.w3.org/ns/did/v1', + 'https://w3id.org/security/multikey/v1', + 'https://w3id.org/security/suites/secp256k1-2019/v1', + ], + id: did, + alsoKnownAs: [`at://${handle}`], + verificationMethod: [ + { + id: `${did}#atproto`, + type: 'Multikey', + controller: did, + publicKeyMultibase: publicKeyMultibase, + }, + ], + service: [ + { + id: '#atproto_pds', + type: 'AtprotoPersonalDataServer', + serviceEndpoint: pdsEndpoint, + }, + ], + } +} diff --git a/frontend/src/lib/registration/AppPasswordStep.svelte b/frontend/src/lib/registration/AppPasswordStep.svelte new file mode 100644 index 0000000..355fd52 --- /dev/null +++ b/frontend/src/lib/registration/AppPasswordStep.svelte @@ -0,0 +1,121 @@ + + +
+
+ Important: Save this app password! +

+ This app password is required to sign into apps that don't support passkeys yet (like bsky.app). + You will only see this password once. +

+
+ +
+
+ App Password for: {flow.account?.appPasswordName} +
+ {flow.account?.appPassword} + +
+ +
+ +
+ + +
+ + diff --git a/frontend/src/lib/registration/DidDocStep.svelte b/frontend/src/lib/registration/DidDocStep.svelte new file mode 100644 index 0000000..7741ea5 --- /dev/null +++ b/frontend/src/lib/registration/DidDocStep.svelte @@ -0,0 +1,166 @@ + + +
+
+ {title} +

{description}

+ https://{flow.extractDomain(flow.info.externalDid || '')}/.well-known/did.json +
+ +
+
{didDocument}
+ +
+ +
+ +
+ + + + {#if onBack} + + {/if} +
+ + diff --git a/frontend/src/lib/registration/KeyChoiceStep.svelte b/frontend/src/lib/registration/KeyChoiceStep.svelte new file mode 100644 index 0000000..5499b56 --- /dev/null +++ b/frontend/src/lib/registration/KeyChoiceStep.svelte @@ -0,0 +1,117 @@ + + +
+
+ External did:web Setup +

+ To use your own domain ({flow.extractDomain(flow.info.externalDid || '')}) as your identity, + you'll need to host a DID document. Choose how you'd like to set up the signing key: +

+
+ +
+ + + +
+ + {#if flow.state.submitting} +

Generating key...

+ {/if} + + +
+ + diff --git a/frontend/src/lib/registration/VerificationStep.svelte b/frontend/src/lib/registration/VerificationStep.svelte new file mode 100644 index 0000000..cde1afb --- /dev/null +++ b/frontend/src/lib/registration/VerificationStep.svelte @@ -0,0 +1,103 @@ + + +
+

+ We've sent a verification code to your {channelLabel(flow.info.verificationChannel)}. + Enter it below to continue. +

+ + {#if resendMessage} +
{resendMessage}
+ {/if} + +
+
+ + +
+ + + + +
+
+ + diff --git a/frontend/src/lib/registration/flow.svelte.ts b/frontend/src/lib/registration/flow.svelte.ts new file mode 100644 index 0000000..6c003e2 --- /dev/null +++ b/frontend/src/lib/registration/flow.svelte.ts @@ -0,0 +1,340 @@ +import { api, ApiError } from '../api' +import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto' +import type { + RegistrationMode, + RegistrationStep, + RegistrationInfo, + ExternalDidWebState, + AccountResult, + SessionState, +} from './types' + +export interface RegistrationFlowState { + mode: RegistrationMode + step: RegistrationStep + info: RegistrationInfo + externalDidWeb: ExternalDidWebState + account: AccountResult | null + session: SessionState | null + error: string | null + submitting: boolean + pdsHostname: string +} + +export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) { + let state = $state({ + mode, + step: 'info', + info: { + handle: '', + email: '', + password: '', + inviteCode: '', + didType: 'plc', + externalDid: '', + verificationChannel: 'email', + discordId: '', + telegramUsername: '', + signalNumber: '', + }, + externalDidWeb: { + keyMode: 'reserved', + }, + account: null, + session: null, + error: null, + submitting: false, + pdsHostname, + }) + + function getPdsEndpoint(): string { + return `https://${state.pdsHostname}` + } + + function getPdsDid(): string { + return `did:web:${state.pdsHostname}` + } + + function getFullHandle(): string { + return `${state.info.handle.trim()}.${state.pdsHostname}` + } + + function extractDomain(did: string): string { + return did.replace('did:web:', '').replace(/%3A/g, ':') + } + + function setError(err: unknown) { + if (err instanceof ApiError) { + state.error = err.message || 'An error occurred' + } else if (err instanceof Error) { + state.error = err.message || 'An error occurred' + } else { + state.error = 'An error occurred' + } + } + + async function proceedFromInfo() { + state.error = null + if (state.info.didType === 'web-external') { + state.step = 'key-choice' + } else { + state.step = 'creating' + } + } + + async function selectKeyMode(keyMode: 'reserved' | 'byod') { + state.submitting = true + state.error = null + state.externalDidWeb.keyMode = keyMode + + try { + let publicKeyMultibase: string + + if (keyMode === 'reserved') { + const result = await api.reserveSigningKey(state.info.externalDid!.trim()) + state.externalDidWeb.reservedSigningKey = result.signingKey + publicKeyMultibase = result.signingKey.replace('did:key:', '') + } else { + const keypair = await generateKeypair() + state.externalDidWeb.byodPrivateKey = keypair.privateKey + state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase + publicKeyMultibase = keypair.publicKeyMultibase + } + + const didDoc = generateDidDocument( + state.info.externalDid!.trim(), + publicKeyMultibase, + getFullHandle(), + getPdsEndpoint() + ) + state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t') + state.step = 'initial-did-doc' + } catch (err) { + setError(err) + } finally { + state.submitting = false + } + } + + async function confirmInitialDidDoc() { + state.step = 'creating' + } + + async function createPasswordAccount() { + state.submitting = true + state.error = null + + try { + let byodToken: string | undefined + + if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) { + byodToken = await createServiceJwt( + state.externalDidWeb.byodPrivateKey, + state.info.externalDid!.trim(), + getPdsDid(), + 'com.atproto.server.createAccount' + ) + } + + const result = await api.createAccount({ + handle: state.info.handle.trim(), + email: state.info.email.trim(), + password: state.info.password!, + inviteCode: state.info.inviteCode?.trim() || undefined, + didType: state.info.didType, + did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined, + signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved' + ? state.externalDidWeb.reservedSigningKey + : undefined, + verificationChannel: state.info.verificationChannel, + discordId: state.info.discordId?.trim() || undefined, + telegramUsername: state.info.telegramUsername?.trim() || undefined, + signalNumber: state.info.signalNumber?.trim() || undefined, + }, byodToken) + + state.account = { + did: result.did, + handle: result.handle, + } + state.step = 'verify' + } catch (err) { + setError(err) + } finally { + state.submitting = false + } + } + + async function createPasskeyAccount() { + state.submitting = true + state.error = null + + try { + let byodToken: string | undefined + + if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) { + byodToken = await createServiceJwt( + state.externalDidWeb.byodPrivateKey, + state.info.externalDid!.trim(), + getPdsDid(), + 'com.atproto.server.createAccount' + ) + } + + const result = await api.createPasskeyAccount({ + handle: state.info.handle.trim(), + email: state.info.email?.trim() || undefined, + inviteCode: state.info.inviteCode?.trim() || undefined, + didType: state.info.didType, + did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined, + signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved' + ? state.externalDidWeb.reservedSigningKey + : undefined, + verificationChannel: state.info.verificationChannel, + discordId: state.info.discordId?.trim() || undefined, + telegramUsername: state.info.telegramUsername?.trim() || undefined, + signalNumber: state.info.signalNumber?.trim() || undefined, + }, byodToken) + + state.account = { + did: result.did, + handle: result.handle, + setupToken: result.setupToken, + } + state.step = 'passkey' + } catch (err) { + setError(err) + } finally { + state.submitting = false + } + } + + function setPasskeyComplete(appPassword: string, appPasswordName: string) { + if (state.account) { + state.account.appPassword = appPassword + state.account.appPasswordName = appPasswordName + } + state.step = 'app-password' + } + + function proceedFromAppPassword() { + state.step = 'verify' + } + + async function verifyAccount(code: string) { + state.submitting = true + state.error = null + + try { + const confirmResult = await api.confirmSignup(state.account!.did, code.trim()) + + if (state.info.didType === 'web-external') { + const password = state.mode === 'passkey' ? state.account!.appPassword! : state.info.password! + const session = await api.createSession(state.account!.did, password) + state.session = { + accessJwt: session.accessJwt, + refreshJwt: session.refreshJwt, + } + + if (state.externalDidWeb.keyMode === 'byod') { + const credentials = await api.getRecommendedDidCredentials(session.accessJwt) + const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || '' + + const didDoc = generateDidDocument( + state.info.externalDid!.trim(), + newPublicKeyMultibase, + state.account!.handle, + getPdsEndpoint() + ) + state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t') + state.step = 'updated-did-doc' + } else { + await api.activateAccount(session.accessJwt) + await finalizeSession() + state.step = 'redirect-to-dashboard' + } + } else { + state.session = { + accessJwt: confirmResult.accessJwt, + refreshJwt: confirmResult.refreshJwt, + } + await finalizeSession() + state.step = 'redirect-to-dashboard' + } + } catch (err) { + setError(err) + } finally { + state.submitting = false + } + } + + async function activateAccount() { + state.submitting = true + state.error = null + + try { + await api.activateAccount(state.session!.accessJwt) + await finalizeSession() + state.step = 'redirect-to-dashboard' + } catch (err) { + setError(err) + } finally { + state.submitting = false + } + } + + function goBack() { + switch (state.step) { + case 'key-choice': + state.step = 'info' + break + case 'initial-did-doc': + state.step = 'key-choice' + break + case 'passkey': + state.step = state.info.didType === 'web-external' ? 'initial-did-doc' : 'info' + break + } + } + + async function finalizeSession() { + if (!state.session || !state.account) return + const { setSession } = await import('../auth.svelte') + setSession({ + did: state.account.did, + handle: state.account.handle, + accessJwt: state.session.accessJwt, + refreshJwt: state.session.refreshJwt, + }) + } + + return { + get state() { return state }, + get info() { return state.info }, + get externalDidWeb() { return state.externalDidWeb }, + get account() { return state.account }, + get session() { return state.session }, + + getPdsEndpoint, + getPdsDid, + getFullHandle, + extractDomain, + + proceedFromInfo, + selectKeyMode, + confirmInitialDidDoc, + createPasswordAccount, + createPasskeyAccount, + setPasskeyComplete, + proceedFromAppPassword, + verifyAccount, + activateAccount, + finalizeSession, + goBack, + + setError(msg: string) { state.error = msg }, + clearError() { state.error = null }, + setSubmitting(val: boolean) { state.submitting = val }, + } +} + +export type RegistrationFlow = ReturnType diff --git a/frontend/src/lib/registration/index.ts b/frontend/src/lib/registration/index.ts new file mode 100644 index 0000000..7358637 --- /dev/null +++ b/frontend/src/lib/registration/index.ts @@ -0,0 +1,6 @@ +export * from './types' +export * from './flow.svelte' +export { default as VerificationStep } from './VerificationStep.svelte' +export { default as KeyChoiceStep } from './KeyChoiceStep.svelte' +export { default as DidDocStep } from './DidDocStep.svelte' +export { default as AppPasswordStep } from './AppPasswordStep.svelte' diff --git a/frontend/src/lib/registration/types.ts b/frontend/src/lib/registration/types.ts new file mode 100644 index 0000000..1733b25 --- /dev/null +++ b/frontend/src/lib/registration/types.ts @@ -0,0 +1,50 @@ +import type { VerificationChannel, DidType } from '../api' + +export type RegistrationMode = 'password' | 'passkey' + +export type RegistrationStep = + | 'info' + | 'key-choice' + | 'initial-did-doc' + | 'creating' + | 'passkey' + | 'app-password' + | 'verify' + | 'updated-did-doc' + | 'activating' + | 'redirect-to-dashboard' + +export interface RegistrationInfo { + handle: string + email: string + password?: string + inviteCode?: string + didType: DidType + externalDid?: string + verificationChannel: VerificationChannel + discordId?: string + telegramUsername?: string + signalNumber?: string +} + +export interface ExternalDidWebState { + keyMode: 'reserved' | 'byod' + reservedSigningKey?: string + byodPrivateKey?: Uint8Array + byodPublicKeyMultibase?: string + initialDidDocument?: string + updatedDidDocument?: string +} + +export interface AccountResult { + did: string + handle: string + setupToken?: string + appPassword?: string + appPasswordName?: string +} + +export interface SessionState { + accessJwt: string + refreshJwt: string +} diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index 11dcc12..d0a8cd5 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -1,24 +1,14 @@
-
-
-
- {$_('register.migrateTitle')} -

{$_('register.migrateDescription')}

- - {$_('register.migrateLink')} → - + {#if flow?.state.step === 'info'} +
+
+
+ {$_('register.migrateTitle')} +

{$_('register.migrateDescription')}

+ + {$_('register.migrateLink')} → + +
-
- - {#if error} -
{error}
{/if}

{$_('register.title')}

-

{$_('register.subtitle')}

+

{getSubtitle()}

- {#if loadingServerInfo} + {#if flow?.state.error} +
{flow.state.error}
+ {/if} + + {#if loadingServerInfo || !flow}

{$_('common.loading')}

- {:else} -
{ e.preventDefault(); handleSubmit(e); }}> + + {:else if flow.state.step === 'info'} +
- {#if handleHasDot} + {#if flow.info.handle.includes('.')}

{$_('register.handleDotWarning')}

{:else if fullHandle()}

{$_('register.handleHint', { values: { handle: fullHandle() } })}

@@ -182,9 +194,9 @@ @@ -197,7 +209,7 @@ type="password" bind:value={confirmPassword} placeholder={$_('register.confirmPasswordPlaceholder')} - disabled={submitting} + disabled={flow.state.submitting} required />
@@ -208,7 +220,7 @@
- {#if didType === 'web'} + {#if flow.info.didType === 'web'}
{$_('register.didWebWarningTitle')}
    @@ -244,15 +256,15 @@
{/if} - {#if didType === 'web-external'} + {#if flow.info.didType === 'web-external'}

{$_('register.externalDidHint')}

@@ -266,7 +278,7 @@
-
- {#if verificationChannel === 'email'} + {#if flow.info.verificationChannel === 'email'}
- {:else if verificationChannel === 'discord'} + {:else if flow.info.verificationChannel === 'discord'}

{$_('register.discordIdHint')}

- {:else if verificationChannel === 'telegram'} + {:else if flow.info.verificationChannel === 'telegram'}
- {:else if verificationChannel === 'signal'} + {:else if flow.info.verificationChannel === 'signal'}

{$_('register.signalNumberHint')}

@@ -339,16 +351,16 @@
{/if} - @@ -358,6 +370,35 @@ + + {:else if flow.state.step === 'key-choice'} + + + {:else if flow.state.step === 'initial-did-doc'} + flow?.goBack()} + /> + + {:else if flow.state.step === 'creating'} + {#await flow.createPasswordAccount()} +

{$_('register.creating')}

+ {/await} + + {:else if flow.state.step === 'verify'} + + + {:else if flow.state.step === 'updated-did-doc'} + flow?.activateAccount()} + /> + + {:else if flow.state.step === 'redirect-to-dashboard'} +

Redirecting to dashboard...

{/if}
diff --git a/frontend/src/routes/RegisterPasskey.svelte b/frontend/src/routes/RegisterPasskey.svelte index 9a86071..b3137c4 100644 --- a/frontend/src/routes/RegisterPasskey.svelte +++ b/frontend/src/routes/RegisterPasskey.svelte @@ -1,35 +1,25 @@
- {#if step === 'info'} + {#if flow?.state.step === 'info'}
@@ -322,39 +260,28 @@ {/if}

Create Passkey Account

-

- {#if step === 'info'} - Create an ultra-secure account using a passkey instead of a password. - {:else if step === 'passkey'} - Register your passkey to secure your account. - {:else if step === 'app-password'} - Save your app password for third-party apps. - {:else if step === 'verify'} - Verify your {channelLabel(verificationChannel)} to complete registration. - {:else} - Your account has been created successfully! - {/if} -

+

{getSubtitle()}

- {#if error} -
{error}
+ {#if flow?.state.error} +
{flow.state.error}
{/if} - {#if loadingServerInfo} + {#if loadingServerInfo || !flow}

Loading...

- {:else if step === 'info'} + + {:else if flow.state.step === 'info'}
- {#if handle.includes('.')} + {#if flow.info.handle.includes('.')}

Custom domain handles can be set up after account creation.

{:else if fullHandle()}

Your full handle will be: @{fullHandle()}

@@ -366,7 +293,7 @@

Choose how you'd like to verify your account and receive notifications.

-
- {#if verificationChannel === 'email'} + {#if flow.info.verificationChannel === 'email'}
- +
- {:else if verificationChannel === 'discord'} + {:else if flow.info.verificationChannel === 'discord'}
- +

Your numeric Discord user ID (enable Developer Mode to find it)

- {:else if verificationChannel === 'telegram'} + {:else if flow.info.verificationChannel === 'telegram'}
- +
- {:else if verificationChannel === 'signal'} + {:else if flow.info.verificationChannel === 'signal'}
- +

Include country code (e.g., +1 for US)

{/if} @@ -409,28 +336,28 @@

Choose how your decentralized identity will be managed.

- {#if didType === 'web'} + {#if flow.info.didType === 'web'}
Important: Understand the trade-offs
    @@ -441,11 +368,11 @@
{/if} - {#if didType === 'web-external'} + {#if flow.info.didType === 'web-external'}
- -

Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS

+ +

You'll need to serve a DID document at https://{flow.info.externalDid ? extractDomain(flow.info.externalDid) : 'yourdomain.com'}/.well-known/did.json

{/if} @@ -453,7 +380,7 @@ {#if serverInfo?.inviteCodeRequired}
- +
{/if} @@ -467,19 +394,36 @@
-
- {:else if step === 'passkey'} + + {:else if flow.state.step === 'key-choice'} + + + {:else if flow.state.step === 'initial-did-doc'} + flow?.goBack()} + /> + + {:else if flow.state.step === 'creating'} + {#await flow.createPasskeyAccount()} +

Creating your account...

+ {/await} + + {:else if flow.state.step === 'passkey'}
- +

A friendly name to identify this passkey

@@ -492,69 +436,30 @@
- -
- {:else if step === 'app-password'} -
-
- Important: Save this app password! -

This app password is required to sign into apps that don't support passkeys yet (like bsky.app). You will only see this password once.

-
-
-
App Password for: {appPasswordResult?.appPasswordName}
- {appPasswordResult?.appPassword} - -
+ {:else if flow.state.step === 'app-password'} + -
- -
+ {:else if flow.state.step === 'verify'} + - -
- {:else if step === 'verify'} -
-

We've sent a verification code to your {channelLabel(verificationChannel)}. Enter it below to complete your account setup.

+ {:else if flow.state.step === 'updated-did-doc'} + flow?.activateAccount()} + /> - {#if resendMessage} -
{resendMessage}
- {/if} - -
{ e.preventDefault(); handleVerification(); }}> -
- - -
- - - - -
-
- {:else if step === 'success'} -
-
-

Account Created!

-

Your passkey-only account has been created successfully.

-

@{setupData?.handle}

- -
+ {:else if flow.state.step === 'redirect-to-dashboard'} +

Redirecting to dashboard...

{/if}
@@ -609,7 +514,7 @@ text-decoration: underline; } - h1, h2 { + h1 { margin: 0 0 var(--space-3) 0; } @@ -697,11 +602,6 @@ color: var(--warning-text); } - .warning-box p { - margin: 0; - color: var(--warning-text); - } - .warning-box ul { margin: var(--space-4) 0 0 0; padding-left: var(--space-5); @@ -749,77 +649,6 @@ font-size: var(--text-lg); } - .app-password-display { - background: var(--bg-card); - border: 2px solid var(--accent); - border-radius: var(--radius-xl); - padding: var(--space-6); - text-align: center; - } - - .app-password-label { - font-size: var(--text-sm); - color: var(--text-secondary); - margin-bottom: var(--space-4); - } - - .app-password-code { - display: block; - font-size: var(--text-xl); - font-family: ui-monospace, monospace; - letter-spacing: 0.1em; - padding: var(--space-5); - background: var(--bg-input); - border-radius: var(--radius-md); - margin-bottom: var(--space-4); - user-select: all; - } - - .copy-btn { - margin-top: 0; - padding: var(--space-3) var(--space-5); - font-size: var(--text-sm); - } - - .checkbox-label { - display: flex; - align-items: center; - gap: var(--space-3); - cursor: pointer; - font-weight: var(--font-normal); - } - - .checkbox-label input[type="checkbox"] { - width: auto; - padding: 0; - } - - .success-content { - text-align: center; - } - - .success-icon { - font-size: var(--text-4xl); - color: var(--success-text); - margin-bottom: var(--space-4); - } - - .success-content p { - color: var(--text-secondary); - } - - .handle-display { - font-size: var(--text-xl); - font-weight: var(--font-semibold); - color: var(--text-primary); - margin: var(--space-4) 0; - } - - .info-text { - color: var(--text-secondary); - margin: 0; - } - .link-text { text-align: center; margin-top: var(--space-6); diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index b29c71c..3e967ea 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -118,13 +118,6 @@ pub async fn create_account( None }; - let is_migration = migration_auth.is_some() - && input - .did - .as_ref() - .map(|d| d.starts_with("did:plc:") || d.starts_with("did:web:")) - .unwrap_or(false); - let is_did_web_byod = migration_auth.is_some() && input .did @@ -132,23 +125,30 @@ pub async fn create_account( .map(|d| d.starts_with("did:web:")) .unwrap_or(false); - if is_migration { - if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) + let is_migration = migration_auth.is_some() + && input + .did + .as_ref() + .map(|d| d.starts_with("did:plc:")) + .unwrap_or(false); + + if is_migration || is_did_web_byod { + if let (Some(provided_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) { - if migration_did != auth_did { + if provided_did != auth_did { return ( StatusCode::FORBIDDEN, Json(json!({ "error": "AuthorizationError", - "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) + "message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did) })), ) .into_response(); } if is_did_web_byod { - info!(did = %migration_did, "Processing did:web BYOD account creation"); + info!(did = %provided_did, "Processing did:web BYOD account creation"); } else { - info!(did = %migration_did, "Processing account migration"); + info!(did = %provided_did, "Processing account migration"); } } } @@ -717,7 +717,7 @@ pub async fn create_account( .await .map(|c| c.unwrap_or(0) == 0) .unwrap_or(false); - let deactivated_at: Option> = if is_migration { + let deactivated_at: Option> = if is_migration || is_did_web_byod { Some(chrono::Utc::now()) } else { None @@ -946,7 +946,7 @@ pub async fn create_account( ) .into_response(); } - if !is_migration { + if !is_migration && !is_did_web_byod { if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await { @@ -972,6 +972,8 @@ pub async fn create_account( { warn!("Failed to create default profile for {}: {}", did, e); } + } + if !is_migration { if let Some(ref recipient) = verification_recipient && let Err(e) = crate::comms::enqueue_signup_verification( &state.db, diff --git a/src/api/server/passkey_account.rs b/src/api/server/passkey_account.rs index 8195541..79c6fd9 100644 --- a/src/api/server/passkey_account.rs +++ b/src/api/server/passkey_account.rs @@ -12,10 +12,11 @@ use rand::Rng; use serde::{Deserialize, Serialize}; use serde_json::json; use std::sync::Arc; -use tracing::{error, info, warn}; +use tracing::{debug, error, info, warn}; use uuid::Uuid; use crate::api::repo::record::utils::create_signed_commit; +use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; use crate::state::{AppState, RateLimitKind}; use crate::validation::validate_password; @@ -106,6 +107,45 @@ pub async fn create_passkey_account( .into_response(); } + let byod_auth = if let Some(token) = + extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok())) + { + if is_service_token(&token) { + let verifier = ServiceTokenVerifier::new(); + match verifier + .verify_service_token(&token, Some("com.atproto.server.createAccount")) + .await + { + Ok(claims) => { + debug!("Service token verified for BYOD did:web: iss={}", claims.iss); + Some(claims.iss) + } + Err(e) => { + error!("Service token verification failed: {:?}", e); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "AuthenticationFailed", + "message": format!("Service token verification failed: {}", e) + })), + ) + .into_response(); + } + } + } else { + None + } + } else { + None + }; + + let is_byod_did_web = byod_auth.is_some() + && input + .did + .as_ref() + .map(|d| d.starts_with("did:web:")) + .unwrap_or(false); + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let pds_suffix = format!(".{}", hostname); @@ -301,21 +341,37 @@ pub async fn create_passkey_account( ) .into_response(); } - if let Err(e) = crate::api::identity::did::verify_did_web( - d, - &hostname, - &input.handle, - input.signing_key.as_deref(), - ) - .await - { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidDid", "message": e})), + if is_byod_did_web { + if let Some(ref auth_did) = byod_auth { + if d != auth_did { + return ( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "AuthorizationError", + "message": format!("Service token issuer {} does not match DID {}", auth_did, d) + })), + ) + .into_response(); + } + } + info!(did = %d, "Creating external did:web passkey account (BYOD key)"); + } else { + if let Err(e) = crate::api::identity::did::verify_did_web( + d, + &hostname, + &input.handle, + input.signing_key.as_deref(), ) - .into_response(); + .await + { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidDid", "message": e})), + ) + .into_response(); + } + info!(did = %d, "Creating external did:web passkey account (reserved key)"); } - info!(did = %d, "Creating external did:web passkey account"); d.to_string() } _ => { @@ -398,14 +454,20 @@ pub async fn create_passkey_account( .map(|c| c.unwrap_or(0) == 0) .unwrap_or(false); + let deactivated_at: Option> = if is_byod_did_web { + Some(Utc::now()) + } else { + None + }; + let user_insert: Result<(Uuid,), _> = sqlx::query_as( r#"INSERT INTO users ( handle, email, did, password_hash, password_required, preferred_comms_channel, discord_id, telegram_username, signal_number, recovery_token, recovery_token_expires_at, - is_admin - ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10) RETURNING id"#, + is_admin, deactivated_at + ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#, ) .bind(&handle) .bind(&email) @@ -435,6 +497,7 @@ pub async fn create_passkey_account( .bind(&setup_token_hash) .bind(setup_expires_at) .bind(is_first_user) + .bind(deactivated_at) .fetch_one(&mut *tx) .await; @@ -612,30 +675,32 @@ pub async fn create_passkey_account( .into_response(); } - if let Err(e) = - crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await - { - warn!("Failed to sequence identity event for {}: {}", did, e); - } - if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, &did, true, None).await - { - warn!("Failed to sequence account event for {}: {}", did, e); - } - let profile_record = serde_json::json!({ - "$type": "app.bsky.actor.profile", - "displayName": handle - }); - if let Err(e) = crate::api::repo::record::create_record_internal( - &state, - &did, - "app.bsky.actor.profile", - "self", - &profile_record, - ) - .await - { - warn!("Failed to create default profile for {}: {}", did, e); + if !is_byod_did_web { + if let Err(e) = + crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await + { + warn!("Failed to sequence identity event for {}: {}", did, e); + } + if let Err(e) = + crate::api::repo::record::sequence_account_event(&state, &did, true, None).await + { + warn!("Failed to sequence account event for {}: {}", did, e); + } + let profile_record = serde_json::json!({ + "$type": "app.bsky.actor.profile", + "displayName": handle + }); + if let Err(e) = crate::api::repo::record::create_record_internal( + &state, + &did, + "app.bsky.actor.profile", + "self", + &profile_record, + ) + .await + { + warn!("Failed to create default profile for {}: {}", did, e); + } } if let Err(e) = crate::comms::enqueue_signup_verification(