From 9cfb0ba0ccbcf3d81ed41f7344b2f751d40eccf1 Mon Sep 17 00:00:00 2001 From: PawiX25 Date: Tue, 7 Apr 2026 00:42:37 +0200 Subject: [PATCH 01/10] fix: harden recovery code login --- server/src/routes/api/login/recovery_codes.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/server/src/routes/api/login/recovery_codes.rs b/server/src/routes/api/login/recovery_codes.rs index 5c92da9..3c8a675 100644 --- a/server/src/routes/api/login/recovery_codes.rs +++ b/server/src/routes/api/login/recovery_codes.rs @@ -59,13 +59,18 @@ async fn login_with_recovery_code( let code_hash = verify_recovery_code(body.code, user.auth_factors.recovery_codes)?; - state + let update_result = state .database .collection::("users") - .find_one_and_update( + .update_one( doc! { "_id": *user_id, - "auth_factors.recovery_codes.code_hash": code_hash + "auth_factors.recovery_codes": { + "$elemMatch": { + "code_hash": &code_hash, + "used": false + } + } }, doc! { "$set": { @@ -75,7 +80,13 @@ async fn login_with_recovery_code( ) .await?; - set_recent_factor(&state.database, &user_id, SecondFactor::Totp.into()).await?; + if update_result.modified_count == 0 { + return Err(AxumError::unauthorized(eyre::eyre!( + "Recovery code already used" + ))); + } + + set_recent_factor(&state.database, &user_id, SecondFactor::RecoveryCode.into()).await?; session .insert("auth_state", AuthState::Authenticated) From d70a7ccfe3155ddf615c2fe9f934f10b0f550945 Mon Sep 17 00:00:00 2001 From: PawiX25 Date: Tue, 7 Apr 2026 00:42:49 +0200 Subject: [PATCH 02/10] feat: add pgp login screen --- apps/frontend/app/(loginform)/login/page.tsx | 4 + apps/frontend/app/(loginform)/login/pgp.tsx | 241 +++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 apps/frontend/app/(loginform)/login/pgp.tsx diff --git a/apps/frontend/app/(loginform)/login/page.tsx b/apps/frontend/app/(loginform)/login/page.tsx index 0dd7a05..668b1fb 100644 --- a/apps/frontend/app/(loginform)/login/page.tsx +++ b/apps/frontend/app/(loginform)/login/page.tsx @@ -15,12 +15,14 @@ import { AnimatePresence, motion } from 'motion/react'; import { RecoveryCode } from './recovery-code'; import { WebAuthn } from './webauthn'; import { WebAuthnPasswordless } from './webauthn-passwordless'; +import { Pgp } from './pgp'; export const formSchema = z.object({ username: z.string().min(1, 'Username is required'), password: z.string().optional(), totp: z.string().optional(), recovery_code: z.string().optional(), + pgp_signature: z.string().optional(), }); export type FormSchema = z.infer; @@ -48,6 +50,7 @@ export default function Page() { password: '', totp: '', recovery_code: '', + pgp_signature: '', }, }); @@ -69,6 +72,7 @@ export default function Page() { {screen === 'recoverycode' && } {screen === 'webauthn' && } {screen === 'webauthnpasswordless' && } + {screen === 'pgp' && }
diff --git a/apps/frontend/app/(loginform)/login/pgp.tsx b/apps/frontend/app/(loginform)/login/pgp.tsx new file mode 100644 index 0000000..7189852 --- /dev/null +++ b/apps/frontend/app/(loginform)/login/pgp.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { Alert, AlertDescription, AlertTitle } from '@components/ui/alert'; +import { Button } from '@components/ui/button'; +import { FormControl, FormField, FormItem, FormMessage } from '@components/ui/form'; +import { LinkComponent } from '@components/ui/link'; +import { LoginIcon } from '@components/ui/login-icon'; +import { $api } from '@lib/providers/api'; +import { + IconAlertCircle, + IconArrowRight, + IconCheck, + IconCopy, + IconKey, + IconRefresh, + IconTerminal2, + IconChevronDown, +} from '@tabler/icons-react'; +import { useSetAtom } from 'jotai'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; +import { useLoginSuccess } from '@lib/hooks'; +import { FormSchema, screenAtom } from './page'; +import { AnimatePresence, motion } from 'motion/react'; + +function CopyButton({ onCopy, copied }: { onCopy: () => void; copied: boolean }) { + return ( + + ); +} + +export function Pgp() { + const setScreen = useSetAtom(screenAtom); + const form = useFormContext(); + const username = form.watch('username'); + const { onSuccess } = useLoginSuccess(); + + const [challenge, setChallenge] = useState(''); + const [challengeError, setChallengeError] = useState(''); + const [copied, setCopied] = useState(false); + const [cmdCopied, setCmdCopied] = useState(false); + const [showCommand, setShowCommand] = useState(false); + + const challengeRequest = $api.useMutation('get', '/api/login/pgp/challenge', { + onSuccess: ({ challenge }) => { + setChallenge(challenge); + setChallengeError(''); + setCopied(false); + }, + onError: (e) => { + if (!challenge) { + setChallengeError('Failed to generate challenge. Try again in a moment.'); + } + }, + }); + + const pgpLogin = $api.useMutation('post', '/api/login/pgp/challenge', { + onSuccess, + onError: (e) => { + form.setError('pgp_signature', { + message: e?.error || 'Login failed.', + }); + }, + }); + + const challengeRequestRef = useRef(challengeRequest); + challengeRequestRef.current = challengeRequest; + + const [refreshSpin, setRefreshSpin] = useState(0); + + const refreshChallenge = useCallback(() => { + form.clearErrors('pgp_signature'); + challengeRequestRef.current.mutate({}); + setRefreshSpin((s) => s + 1); + }, [form]); + + useEffect(() => { + refreshChallenge(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const copyText = useCallback(async (text: string, setter: (v: boolean) => void) => { + try { + await navigator.clipboard.writeText(text); + setter(true); + setTimeout(() => setter(false), 1500); + } catch { + /* noop */ + } + }, []); + + const gpgCommand = challenge ? `echo "${challenge}" | gpg --clearsign` : ''; + + return ( +
+ pgpLogin.mutate({ + body: { + username: data.username, + signature: data.pgp_signature ?? '', + }, + }) + )} + > + + + +
+

Sign in with a PGP key

+

+ Sign the server challenge for {username}{' '} + setScreen('welcome')}>Not you? +

+
+
+ {challengeError && ( + + + Challenge Unavailable + {challengeError} + + )} + + {/* Step 1: Challenge */} +
+
+ +
+ copyText(challenge, setCopied)} copied={copied} /> + +
+
+
+ {challenge || Generating...} +
+
+ + {/* Quick sign helper (collapsible) */} + {challenge && ( +
+ + + {showCommand && ( + +
+
+                                            {gpgCommand}
+                                        
+ copyText(gpgCommand, setCmdCopied)} copied={cmdCopied} /> +
+
+ )} +
+
+ )} + + {/* Step 2: Paste signature */} +
+ + ( + + +