fabrica

hanna/fabrica

feat(web): captcha on sign-up and SSH/GPG key management in settings

cf3b331 · hanna committed on 2026-07-25

Add an optional [captcha] config table (none | hcaptcha | recaptcha, site_key,
secret_key); when enabled the provider widget renders on /register and the
solved token is verified server-side via reqwest (native-tls) before any store
write, failing closed. This is a documented opt-in exception to §9.5. Also
surface the existing key store in account settings: list/add/delete SSH and GPG
keys (reusing git::parse_public_key for validation, dup-checked, ownership-
checked on delete) via /settings/keys[/delete]. Closes the last §19 gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
10 files changed · +680 −9UnifiedSplit
Cargo.lock +108 −0
@@ -2321,6 +2321,23 @@ dependencies = [
23212321 "pin-project-lite",
23222322 "smallvec",
23232323 "tokio",
2324+ "want",
2325+]
2326+
2327+[[package]]
2328+name = "hyper-tls"
2329+version = "0.6.0"
2330+source = "registry+https://github.com/rust-lang/crates.io-index"
2331+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
2332+dependencies = [
2333+ "bytes",
2334+ "http-body-util",
2335+ "hyper",
2336+ "hyper-util",
2337+ "native-tls",
2338+ "tokio",
2339+ "tokio-native-tls",
2340+ "tower-service",
23242341 ]
23252342
23262343 [[package]]
@@ -2329,13 +2346,21 @@ version = "0.1.20"
23292346 source = "registry+https://github.com/rust-lang/crates.io-index"
23302347 checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
23312348 dependencies = [
2349+ "base64",
23322350 "bytes",
2351+ "futures-channel",
2352+ "futures-util",
23332353 "http",
23342354 "http-body",
23352355 "hyper",
2356+ "ipnet",
2357+ "libc",
2358+ "percent-encoding",
23362359 "pin-project-lite",
2360+ "socket2",
23372361 "tokio",
23382362 "tower-service",
2363+ "tracing",
23392364 ]
23402365
23412366 [[package]]
@@ -2556,6 +2581,12 @@ dependencies = [
25562581 ]
25572582
25582583 [[package]]
2584+name = "ipnet"
2585+version = "2.12.0"
2586+source = "registry+https://github.com/rust-lang/crates.io-index"
2587+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
2588+
2589+[[package]]
25592590 name = "is_terminal_polyfill"
25602591 version = "1.70.2"
25612592 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3850,6 +3881,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
38503881 checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884"
38513882
38523883 [[package]]
3884+name = "reqwest"
3885+version = "0.12.28"
3886+source = "registry+https://github.com/rust-lang/crates.io-index"
3887+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
3888+dependencies = [
3889+ "base64",
3890+ "bytes",
3891+ "futures-core",
3892+ "http",
3893+ "http-body",
3894+ "http-body-util",
3895+ "hyper",
3896+ "hyper-tls",
3897+ "hyper-util",
3898+ "js-sys",
3899+ "log",
3900+ "native-tls",
3901+ "percent-encoding",
3902+ "pin-project-lite",
3903+ "rustls-pki-types",
3904+ "serde",
3905+ "serde_json",
3906+ "serde_urlencoded",
3907+ "sync_wrapper",
3908+ "tokio",
3909+ "tokio-native-tls",
3910+ "tower",
3911+ "tower-http",
3912+ "tower-service",
3913+ "url",
3914+ "wasm-bindgen",
3915+ "wasm-bindgen-futures",
3916+ "web-sys",
3917+]
3918+
3919+[[package]]
38533920 name = "rfc6979"
38543921 version = "0.4.0"
38553922 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4099,6 +4166,15 @@ dependencies = [
40994166 ]
41004167
41014168 [[package]]
4169+name = "rustls-pki-types"
4170+version = "1.15.1"
4171+source = "registry+https://github.com/rust-lang/crates.io-index"
4172+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
4173+dependencies = [
4174+ "zeroize",
4175+]
4176+
4177+[[package]]
41024178 name = "rustversion"
41034179 version = "1.0.23"
41044180 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4931,6 +5007,9 @@ name = "sync_wrapper"
49315007 version = "1.0.2"
49325008 source = "registry+https://github.com/rust-lang/crates.io-index"
49335009 checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
5010+dependencies = [
5011+ "futures-core",
5012+]
49345013
49355014 [[package]]
49365015 name = "synstructure"
@@ -5219,14 +5298,17 @@ dependencies = [
52195298 "bitflags",
52205299 "bytes",
52215300 "futures-core",
5301+ "futures-util",
52225302 "http",
52235303 "http-body",
52245304 "pin-project-lite",
52255305 "tokio",
52265306 "tokio-util",
5307+ "tower",
52275308 "tower-layer",
52285309 "tower-service",
52295310 "tracing",
5311+ "url",
52305312 "uuid",
52315313 ]
52325314
@@ -5348,6 +5430,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
53485430 checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
53495431
53505432 [[package]]
5433+name = "try-lock"
5434+version = "0.2.5"
5435+source = "registry+https://github.com/rust-lang/crates.io-index"
5436+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
5437+
5438+[[package]]
53515439 name = "twofish"
53525440 version = "0.7.1"
53535441 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5528,6 +5616,15 @@ dependencies = [
55285616 ]
55295617
55305618 [[package]]
5619+name = "want"
5620+version = "0.3.1"
5621+source = "registry+https://github.com/rust-lang/crates.io-index"
5622+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
5623+dependencies = [
5624+ "try-lock",
5625+]
5626+
5627+[[package]]
55315628 name = "wasi"
55325629 version = "0.11.1+wasi-snapshot-preview1"
55335630 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5618,6 +5715,7 @@ dependencies = [
56185715 "maud",
56195716 "mime_guess",
56205717 "model",
5718+ "reqwest",
56215719 "rust-embed",
56225720 "serde",
56235721 "serde_json",
@@ -5635,6 +5733,16 @@ dependencies = [
56355733 ]
56365734
56375735 [[package]]
5736+name = "web-sys"
5737+version = "0.3.103"
5738+source = "registry+https://github.com/rust-lang/crates.io-index"
5739+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
5740+dependencies = [
5741+ "js-sys",
5742+ "wasm-bindgen",
5743+]
5744+
5745+[[package]]
56385746 name = "web-time"
56395747 version = "1.1.0"
56405748 source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +7 −0
@@ -160,6 +160,13 @@ tokio-util = { version = "0.7", features = ["io"] }
160160 flate2 = "1"
161161 # Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate.
162162 grep = "0.3"
163+# Outbound HTTP for optional captcha verification only. native-tls (system
164+# OpenSSL, already a build input) not rustls, so `ring` stays out of the graph;
165+# http2/charset off to keep the tree small. See docs/decisions.md.
166+reqwest = { version = "0.12", default-features = false, features = [
167+ "native-tls",
168+ "json",
169+] }
163170 # SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key).
164171 russh = "0.62"
165172 tracing = "0.1"
assets/base.css +17 −0
@@ -592,6 +592,23 @@ select:focus {
592592 .token-list {
593593 margin-bottom: 1rem;
594594 }
595+.key-list {
596+ margin-bottom: 1rem;
597+}
598+.key-add textarea {
599+ font-family: var(--fb-font-mono);
600+}
601+.key-add-row {
602+ display: flex;
603+ align-items: center;
604+ gap: 0.75rem;
605+ flex-wrap: wrap;
606+ margin-bottom: 0.5rem;
607+}
608+.key-add-row select,
609+.key-add-row input {
610+ width: auto;
611+}
595612 .token-scopes {
596613 margin: 0.75rem 0 0;
597614 padding: 0.5rem 0.85rem;
crates/config/src/lib.rs +2 −2
@@ -32,8 +32,8 @@ use figment::providers::{Env, Format, Serialized, Toml};
3232
3333 pub use crate::secret::{REDACTED, Secret};
3434 pub use crate::types::{
35- Auth, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail,
36- MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui,
35+ Auth, Captcha, CaptchaProvider, Config, Database, DefaultVisibility, Git, Instance, Log,
36+ LogFormat, LogLevel, Mail, MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui,
3737 };
3838
3939 /// System-wide configuration path, the lowest-priority file source.
crates/config/src/types.rs +85 −0
@@ -32,6 +32,9 @@ pub struct Config {
3232 pub auth: Auth,
3333 /// Outbound mail.
3434 pub mail: Mail,
35+ /// Optional bot mitigation on self-registration.
36+ #[serde(default)]
37+ pub captcha: Captcha,
3538 /// Web UI presentation.
3639 pub ui: Ui,
3740 /// Git binary and pack-transport limits.
@@ -126,6 +129,88 @@ impl Default for Instance {
126129 }
127130 }
128131
132+/// A supported captcha provider for the sign-up form.
133+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
134+#[serde(rename_all = "lowercase")]
135+pub enum CaptchaProvider {
136+ /// No captcha (the default).
137+ #[default]
138+ None,
139+ /// [hCaptcha](https://www.hcaptcha.com).
140+ HCaptcha,
141+ /// Google [reCAPTCHA](https://developers.google.com/recaptcha) v2.
142+ ReCaptcha,
143+}
144+
145+impl CaptchaProvider {
146+ /// The browser widget script URL, or `None` for [`CaptchaProvider::None`].
147+ #[must_use]
148+ pub fn script_url(self) -> Option<&'static str> {
149+ match self {
150+ Self::None => None,
151+ Self::HCaptcha => Some("https://js.hcaptcha.com/1/api.js"),
152+ Self::ReCaptcha => Some("https://www.google.com/recaptcha/api.js"),
153+ }
154+ }
155+
156+ /// The CSS class the widget script looks for to render the challenge.
157+ #[must_use]
158+ pub fn widget_class(self) -> &'static str {
159+ match self {
160+ Self::None | Self::HCaptcha => "h-captcha",
161+ Self::ReCaptcha => "g-recaptcha",
162+ }
163+ }
164+
165+ /// The form field the widget populates with the solved token.
166+ #[must_use]
167+ pub fn response_field(self) -> &'static str {
168+ match self {
169+ Self::None | Self::HCaptcha => "h-captcha-response",
170+ Self::ReCaptcha => "g-recaptcha-response",
171+ }
172+ }
173+
174+ /// The server-side verification endpoint.
175+ #[must_use]
176+ pub fn verify_url(self) -> &'static str {
177+ match self {
178+ Self::None | Self::HCaptcha => "https://api.hcaptcha.com/siteverify",
179+ Self::ReCaptcha => "https://www.google.com/recaptcha/api/siteverify",
180+ }
181+ }
182+}
183+
184+/// `[captcha]` — optional bot mitigation on the sign-up form.
185+///
186+/// Off by default. Enabling it is a deliberate, opt-in exception to the "no
187+/// third-party network requests from any page" rule (§9.5): the chosen provider's
188+/// widget script loads on `/register` and the server verifies the solved token
189+/// out-of-band. See `docs/decisions.md`.
190+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
191+#[serde(deny_unknown_fields)]
192+pub struct Captcha {
193+ /// Which provider to use (or `none`, the default).
194+ #[serde(default)]
195+ pub provider: CaptchaProvider,
196+ /// The public site key embedded in the page.
197+ #[serde(default)]
198+ pub site_key: String,
199+ /// The provider secret used for server-side verification.
200+ #[serde(default)]
201+ pub secret_key: Option<Secret>,
202+}
203+
204+impl Captcha {
205+ /// Whether captcha is fully configured and should be enforced on sign-up.
206+ #[must_use]
207+ pub fn enabled(&self) -> bool {
208+ self.provider != CaptchaProvider::None
209+ && !self.site_key.is_empty()
210+ && self.secret_key.is_some()
211+ }
212+}
213+
129214 /// `[server]` — HTTP binding and limits.
130215 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131216 #[serde(deny_unknown_fields)]
crates/web/Cargo.toml +1 −0
@@ -30,6 +30,7 @@ ammonia = { workspace = true }
3030 tokio-util = { workspace = true }
3131 flate2 = { workspace = true }
3232 grep = { workspace = true }
33+reqwest = { workspace = true }
3334 base64 = { workspace = true }
3435 time = { workspace = true }
3536 serde = { workspace = true }
crates/web/src/lib.rs +2 −0
@@ -157,6 +157,8 @@ pub fn build_router(state: AppState) -> Router {
157157 .route("/settings/password", post(pages::account_password))
158158 .route("/settings/tokens", post(pages::token_create))
159159 .route("/settings/tokens/revoke", post(pages::token_revoke))
160+ .route("/settings/keys", post(pages::key_add))
161+ .route("/settings/keys/delete", post(pages::key_delete))
160162 .route("/settings/avatar", post(avatar::upload))
161163 .route("/settings/theme", get(pages::set_theme))
162164 .route("/avatar/{username}", get(avatar::show))
crates/web/src/pages.rs +257 −7
@@ -12,7 +12,7 @@ use axum::extract::{Path, State};
1212 use axum::http::{HeaderMap, Uri, header};
1313 use axum::response::{IntoResponse, Redirect, Response};
1414 use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
15-use maud::{Markup, html};
15+use maud::{Markup, PreEscaped, html};
1616 use model::User;
1717 use serde::Deserialize;
1818
@@ -559,11 +559,25 @@ pub struct RegisterForm {
559559 /// Confirmation.
560560 #[serde(default)]
561561 confirm: String,
562+ /// hCaptcha solved token (populated by the hCaptcha widget).
563+ #[serde(default, rename = "h-captcha-response")]
564+ hcaptcha_response: String,
565+ /// reCAPTCHA solved token (populated by the reCAPTCHA widget).
566+ #[serde(default, rename = "g-recaptcha-response")]
567+ grecaptcha_response: String,
562568 /// CSRF token.
563569 #[serde(rename = "_csrf")]
564570 csrf: String,
565571 }
566572
573+/// The captcha provider and public site key when captcha is enabled, for
574+/// rendering the widget on the sign-up form.
575+fn captcha_view(cfg: &config::Config) -> Option<(config::CaptchaProvider, String)> {
576+ cfg.captcha
577+ .enabled()
578+ .then(|| (cfg.captcha.provider, cfg.captcha.site_key.clone()))
579+}
580+
567581 /// `GET /register` — the sign-up form, or `404` when registration is disabled.
568582 pub async fn register_form(
569583 State(state): State<AppState>,
@@ -578,12 +592,25 @@ pub async fn register_form(
578592 return Redirect::to("/").into_response();
579593 }
580594 let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());
581- let body = register_body(&chrome.csrf, None, "", "");
595+ let body = register_body(
596+ &chrome.csrf,
597+ None,
598+ "",
599+ "",
600+ captcha_view(&state.config).as_ref(),
601+ );
582602 (jar, page(&chrome, "Sign up", body)).into_response()
583603 }
584604
585-/// Render the sign-up form with an optional error and prefilled fields.
586-fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) -> Markup {
605+/// Render the sign-up form with an optional error, prefilled fields, and the
606+/// captcha widget when one is configured.
607+fn register_body(
608+ csrf: &str,
609+ error: Option<&str>,
610+ username: &str,
611+ email: &str,
612+ captcha: Option<&(config::CaptchaProvider, String)>,
613+) -> Markup {
587614 html! {
588615 div class="auth-wrap" {
589616 div class="auth-card" {
@@ -605,6 +632,12 @@ fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) -
605632 label for="confirm" { "Confirm password" }
606633 input type="password" id="confirm" name="confirm"
607634 autocomplete="new-password" required;
635+ @if let Some((provider, site_key)) = captcha {
636+ @if let Some(script) = provider.script_url() {
637+ (PreEscaped(format!("<script src=\"{script}\" async defer></script>")))
638+ div class=(provider.widget_class()) data-sitekey=(site_key) {}
639+ }
640+ }
608641 button class="btn btn-primary" type="submit" { "Create account" }
609642 }
610643 p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } }
@@ -629,9 +662,10 @@ pub async fn register_submit(
629662 let username = form.username.trim().to_string();
630663 let email = form.email.trim().to_string();
631664
665+ let captcha = captcha_view(&state.config);
632666 let reject = |msg: &str, username: &str, email: &str| {
633667 let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string());
634- let body = register_body(&chrome.csrf, Some(msg), username, email);
668+ let body = register_body(&chrome.csrf, Some(msg), username, email, captcha.as_ref());
635669 (
636670 axum::http::StatusCode::BAD_REQUEST,
637671 jar,
@@ -650,6 +684,22 @@ pub async fn register_submit(
650684 return reject("Password must be at least 8 characters.", &username, &email);
651685 }
652686
687+ // When captcha is enabled, the solved token must verify with the provider
688+ // before we touch the store.
689+ if state.config.captcha.enabled() {
690+ let token = match state.config.captcha.provider {
691+ config::CaptchaProvider::ReCaptcha => form.grecaptcha_response.trim(),
692+ _ => form.hcaptcha_response.trim(),
693+ };
694+ if !verify_captcha(&state.config.captcha, token).await {
695+ return reject(
696+ "Captcha verification failed. Please try again.",
697+ &username,
698+ &email,
699+ );
700+ }
701+ }
702+
653703 let params = auth::HashParams {
654704 m_cost: state.config.auth.argon2_m_cost,
655705 t_cost: state.config.auth.argon2_t_cost,
@@ -696,6 +746,42 @@ pub async fn register_submit(
696746 }
697747 }
698748
749+/// The relevant fields of a provider `siteverify` response.
750+#[derive(Debug, Deserialize)]
751+struct SiteVerify {
752+ /// Whether the token was valid.
753+ #[serde(default)]
754+ success: bool,
755+}
756+
757+/// Verify a solved captcha `token` with the configured provider. Returns `false`
758+/// on an empty token, a missing secret, a network error, or a rejected token — a
759+/// closed failure mode, so a broken verifier never lets a bot through.
760+async fn verify_captcha(cfg: &config::Captcha, token: &str) -> bool {
761+ if token.is_empty() {
762+ return false;
763+ }
764+ let Some(secret) = cfg.secret_key.as_ref() else {
765+ return false;
766+ };
767+ let Ok(client) = reqwest::Client::builder()
768+ .timeout(std::time::Duration::from_secs(10))
769+ .build()
770+ else {
771+ return false;
772+ };
773+ let form = [("secret", secret.expose()), ("response", token)];
774+ match client
775+ .post(cfg.provider.verify_url())
776+ .form(&form)
777+ .send()
778+ .await
779+ {
780+ Ok(resp) => resp.json::<SiteVerify>().await.is_ok_and(|v| v.success),
781+ Err(_) => false,
782+ }
783+}
784+
699785 /// Logout form field.
700786 #[derive(Debug, Deserialize)]
701787 pub struct CsrfForm {
@@ -841,8 +927,9 @@ pub async fn settings(
841927 uri: Uri,
842928 ) -> AppResult<Response> {
843929 let tokens = state.store.tokens_by_user(&user.id).await?;
930+ let keys = state.store.keys_by_user(&user.id).await?;
844931 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
845- let body = settings_body(&user, &chrome.csrf, &tokens, None);
932+ let body = settings_body(&user, &chrome.csrf, &tokens, &keys, None);
846933 Ok((jar, page(&chrome, "Settings", body)).into_response())
847934 }
848935
@@ -1129,9 +1216,10 @@ pub async fn token_create(
11291216 .tokens_by_user(&user.id)
11301217 .await
11311218 .unwrap_or_default();
1219+ let keys = state.store.keys_by_user(&user.id).await.unwrap_or_default();
11321220 let (jar, chrome) =
11331221 build_chrome(&state, jar, Some(user.clone()), "/settings".to_string());
1134- let body = settings_body(&user, &chrome.csrf, &tokens, Some(&jwt));
1222+ let body = settings_body(&user, &chrome.csrf, &tokens, &keys, Some(&jwt));
11351223 (jar, page(&chrome, "Settings", body)).into_response()
11361224 }
11371225 Err(err) => err.into_response(),
@@ -1174,11 +1262,118 @@ pub async fn token_revoke(
11741262 Redirect::to("/settings").into_response()
11751263 }
11761264
1265+/// The add-key form.
1266+#[derive(Debug, Deserialize)]
1267+pub struct KeyAddForm {
1268+ /// `ssh` or `gpg`.
1269+ #[serde(default)]
1270+ kind: String,
1271+ /// Optional label.
1272+ #[serde(default)]
1273+ name: String,
1274+ /// The public key material (`authorized_keys` line or armored block).
1275+ #[serde(default)]
1276+ key: String,
1277+ /// CSRF token.
1278+ #[serde(rename = "_csrf")]
1279+ csrf: String,
1280+}
1281+
1282+/// `POST /settings/keys` — register an SSH or GPG public key for the viewer.
1283+pub async fn key_add(
1284+ State(state): State<AppState>,
1285+ RequireUser(user): RequireUser,
1286+ jar: CookieJar,
1287+ headers: HeaderMap,
1288+ Form(form): Form<KeyAddForm>,
1289+) -> Response {
1290+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1291+ return AppError::Forbidden.into_response();
1292+ }
1293+ let Some(kind) = model::KeyKind::from_token(&form.kind) else {
1294+ return AppError::BadRequest("Choose a key type.".to_string()).into_response();
1295+ };
1296+ let material = form.key.trim();
1297+ let Ok(parsed) = git::parse_public_key(kind, material) else {
1298+ return AppError::BadRequest(format!("That does not look like a valid {kind} key."))
1299+ .into_response();
1300+ };
1301+ // Reject a duplicate fingerprint (registered to anyone).
1302+ match state
1303+ .store
1304+ .key_by_fingerprint(kind, &parsed.fingerprint)
1305+ .await
1306+ {
1307+ Ok(Some(_)) => {
1308+ return AppError::BadRequest("That key is already registered.".to_string())
1309+ .into_response();
1310+ }
1311+ Ok(None) => {}
1312+ Err(err) => return AppError::from(err).into_response(),
1313+ }
1314+ let name = form.name.trim();
1315+ let label = (!name.is_empty())
1316+ .then(|| name.to_string())
1317+ .or(parsed.label);
1318+ match state
1319+ .store
1320+ .create_key(store::NewKey {
1321+ user_id: user.id.clone(),
1322+ kind,
1323+ name: label,
1324+ fingerprint: parsed.fingerprint,
1325+ public_key: parsed.public_key,
1326+ })
1327+ .await
1328+ {
1329+ Ok(_) => Redirect::to("/settings").into_response(),
1330+ Err(store::StoreError::Conflict { .. }) => {
1331+ AppError::BadRequest("That key is already registered.".to_string()).into_response()
1332+ }
1333+ Err(err) => AppError::from(err).into_response(),
1334+ }
1335+}
1336+
1337+/// The delete-key form.
1338+#[derive(Debug, Deserialize)]
1339+pub struct KeyDeleteForm {
1340+ /// The key id.
1341+ #[serde(default)]
1342+ id: String,
1343+ /// CSRF token.
1344+ #[serde(rename = "_csrf")]
1345+ csrf: String,
1346+}
1347+
1348+/// `POST /settings/keys/delete` — delete one of the viewer's keys.
1349+pub async fn key_delete(
1350+ State(state): State<AppState>,
1351+ RequireUser(user): RequireUser,
1352+ jar: CookieJar,
1353+ headers: HeaderMap,
1354+ Form(form): Form<KeyDeleteForm>,
1355+) -> Response {
1356+ if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
1357+ return AppError::Forbidden.into_response();
1358+ }
1359+ // Only delete a key that belongs to the viewer.
1360+ let owned = state
1361+ .store
1362+ .keys_by_user(&user.id)
1363+ .await
1364+ .is_ok_and(|keys| keys.iter().any(|k| k.id == form.id));
1365+ if owned {
1366+ let _ = state.store.delete_key(&form.id).await;
1367+ }
1368+ Redirect::to("/settings").into_response()
1369+}
1370+
11771371 /// Render the settings page: avatar, profile, account credentials, and tokens.
11781372 fn settings_body(
11791373 user: &User,
11801374 csrf: &str,
11811375 tokens: &[model::ApiToken],
1376+ keys: &[model::Key],
11821377 new_token: Option<&str>,
11831378 ) -> Markup {
11841379 let val = |v: &Option<String>| v.clone().unwrap_or_default();
@@ -1265,10 +1460,65 @@ fn settings_body(
12651460 }
12661461 }
12671462 }
1463+ (keys_section(csrf, keys))
12681464 (tokens_section(csrf, tokens, new_token))
12691465 }
12701466 }
12711467
1468+/// The SSH and GPG keys settings section.
1469+fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup {
1470+ html! {
1471+ section class="listing" {
1472+ h2 { "SSH and GPG keys" }
1473+ div class="card" {
1474+ p class="muted field-hint" {
1475+ "SSH keys authorize push over SSH; both SSH and GPG keys verify commit signatures."
1476+ }
1477+ @if keys.is_empty() {
1478+ p class="muted" { "No keys yet." }
1479+ } @else {
1480+ ul class="entry-list key-list" {
1481+ @for k in keys {
1482+ li class="entry-row" {
1483+ div class="entry-row-body" {
1484+ span class="entry-row-title" {
1485+ span class="badge" { (k.kind.as_str()) }
1486+ " " (k.name.as_deref().unwrap_or("(unnamed)"))
1487+ }
1488+ div class="entry-row-meta muted mono" { (k.fingerprint) }
1489+ }
1490+ div class="entry-row-actions" {
1491+ form method="post" action="/settings/keys/delete" {
1492+ input type="hidden" name="_csrf" value=(csrf);
1493+ input type="hidden" name="id" value=(k.id);
1494+ button class="btn btn-danger" type="submit" { "Delete" }
1495+ }
1496+ }
1497+ }
1498+ }
1499+ }
1500+ }
1501+ form class="key-add" method="post" action="/settings/keys" {
1502+ input type="hidden" name="_csrf" value=(csrf);
1503+ div class="key-add-row" {
1504+ label for="key_kind" { "Type" }
1505+ select id="key_kind" name="kind" {
1506+ option value="ssh" { "SSH" }
1507+ option value="gpg" { "GPG" }
1508+ }
1509+ label for="key_name" { "Title" }
1510+ input type="text" id="key_name" name="name" placeholder="Optional label";
1511+ }
1512+ label for="key_material" { "Key" }
1513+ textarea id="key_material" name="key" rows="4" required
1514+ placeholder="Paste an SSH public key or an ASCII-armored GPG public key" {}
1515+ button class="btn btn-primary" type="submit" { "Add key" }
1516+ }
1517+ }
1518+ }
1519+ }
1520+}
1521+
12721522 /// The API tokens settings section.
12731523 fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup {
12741524 html! {
crates/web/src/tests.rs +173 −0
@@ -835,3 +835,176 @@ async fn settings_requires_authentication() {
835835 assert_eq!(res.status(), StatusCode::SEE_OTHER);
836836 assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login");
837837 }
838+
839+/// A valid SSH public key for exercising the key-management UI.
840+const TEST_SSH_KEY: &str = "ssh-ed25519 \
841+AAAAC3NzaC1lZDI1NTE5AAAAIJqq6nD0E4SlM+xw1UVOompehxQE8sUYCVoyV+NBjfKU web-test@fabrica";
842+
843+#[tokio::test]
844+async fn settings_keys_add_list_and_delete() {
845+ let (state, app) = app().await;
846+ let cookie = login(&app).await;
847+ let token = cookie
848+ .split("fabrica_csrf=")
849+ .nth(1)
850+ .and_then(|s| s.split(';').next())
851+ .unwrap()
852+ .to_string();
853+
854+ // The settings page shows the keys section.
855+ let req = Request::builder()
856+ .uri("/settings")
857+ .header(header::COOKIE, cookie.clone())
858+ .body(Body::empty())
859+ .unwrap();
860+ let res = app.clone().oneshot(req).await.unwrap();
861+ assert_eq!(res.status(), StatusCode::OK);
862+ assert!(body_string(res).await.contains("SSH and GPG keys"));
863+
864+ // Add an SSH key.
865+ let body = format!(
866+ "kind=ssh&name=laptop&key={}&_csrf={token}",
867+ urlencoding(TEST_SSH_KEY)
868+ );
869+ let req = Request::builder()
870+ .method("POST")
871+ .uri("/settings/keys")
872+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
873+ .header(header::COOKIE, cookie.clone())
874+ .body(Body::from(body))
875+ .unwrap();
876+ let res = app.clone().oneshot(req).await.unwrap();
877+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
878+
879+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
880+ let keys = state.store.keys_by_user(&ada.id).await.unwrap();
881+ assert_eq!(keys.len(), 1, "key was stored");
882+ let key_id = keys[0].id.clone();
883+
884+ // It renders on the settings page.
885+ let req = Request::builder()
886+ .uri("/settings")
887+ .header(header::COOKIE, cookie.clone())
888+ .body(Body::empty())
889+ .unwrap();
890+ let res = app.clone().oneshot(req).await.unwrap();
891+ assert!(body_string(res).await.contains("laptop"), "key label shown");
892+
893+ // Delete it.
894+ let req = Request::builder()
895+ .method("POST")
896+ .uri("/settings/keys/delete")
897+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
898+ .header(header::COOKIE, cookie)
899+ .body(Body::from(format!("id={key_id}&_csrf={token}")))
900+ .unwrap();
901+ let res = app.oneshot(req).await.unwrap();
902+ assert_eq!(res.status(), StatusCode::SEE_OTHER);
903+ assert!(state.store.keys_by_user(&ada.id).await.unwrap().is_empty());
904+}
905+
906+#[tokio::test]
907+async fn settings_key_add_rejects_garbage() {
908+ let (_state, app) = app().await;
909+ let cookie = login(&app).await;
910+ let token = cookie
911+ .split("fabrica_csrf=")
912+ .nth(1)
913+ .and_then(|s| s.split(';').next())
914+ .unwrap()
915+ .to_string();
916+ let req = Request::builder()
917+ .method("POST")
918+ .uri("/settings/keys")
919+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
920+ .header(header::COOKIE, cookie)
921+ .body(Body::from(format!(
922+ "kind=ssh&name=x&key=not-a-key&_csrf={token}"
923+ )))
924+ .unwrap();
925+ let res = app.oneshot(req).await.unwrap();
926+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
927+}
928+
929+/// An app with hCaptcha configured, so the sign-up form must show and enforce it.
930+async fn app_with_captcha() -> Router {
931+ let store = Store::connect("sqlite::memory:", 1).await.unwrap();
932+ store.migrate().await.unwrap();
933+ let mut config = Config::default();
934+ config.instance.name = "Forge".to_string();
935+ config.instance.allow_registration = true;
936+ config.auth.cookie_secure = false;
937+ config.captcha.provider = config::CaptchaProvider::HCaptcha;
938+ config.captcha.site_key = "test-site-key".to_string();
939+ config.captcha.secret_key = Some(config::Secret::new("test-secret".to_string()));
940+ let state = AppState::build(Arc::new(config), store, "test-secret".to_string()).unwrap();
941+ crate::build_router(state)
942+}
943+
944+#[tokio::test]
945+async fn register_page_shows_the_captcha_widget() {
946+ let app = app_with_captcha().await;
947+ let res = app.oneshot(get("/register")).await.unwrap();
948+ assert_eq!(res.status(), StatusCode::OK);
949+ let html = body_string(res).await;
950+ assert!(html.contains("js.hcaptcha.com"), "widget script present");
951+ assert!(
952+ html.contains("data-sitekey=\"test-site-key\""),
953+ "site key present"
954+ );
955+ assert!(html.contains("h-captcha"), "widget container present");
956+}
957+
958+#[tokio::test]
959+async fn register_is_rejected_when_captcha_is_missing() {
960+ let app = app_with_captcha().await;
961+ let form = app.clone().oneshot(get("/register")).await.unwrap();
962+ let cookie = form
963+ .headers()
964+ .get(header::SET_COOKIE)
965+ .unwrap()
966+ .to_str()
967+ .unwrap()
968+ .to_string();
969+ let token = cookie
970+ .split("fabrica_csrf=")
971+ .nth(1)
972+ .and_then(|s| s.split(';').next())
973+ .unwrap()
974+ .to_string();
975+ // No captcha token in the body → rejected before any network call.
976+ let req = Request::builder()
977+ .method("POST")
978+ .uri("/register")
979+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
980+ .header(header::COOKIE, format!("fabrica_csrf={token}"))
981+ .body(Body::from(format!(
982+ "username=bot&email=bot@example.com&password=secret12&confirm=secret12&_csrf={token}"
983+ )))
984+ .unwrap();
985+ let res = app.oneshot(req).await.unwrap();
986+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
987+ assert!(
988+ body_string(res)
989+ .await
990+ .contains("Captcha verification failed")
991+ );
992+}
993+
994+/// Minimal application/x-www-form-urlencoded encoding for test bodies.
995+fn urlencoding(s: &str) -> String {
996+ use std::fmt::Write as _;
997+ let mut out = String::with_capacity(s.len());
998+ for b in s.bytes() {
999+ match b {
1000+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1001+ out.push(b as char);
1002+ }
1003+ b' ' => out.push('+'),
1004+ _ => {
1005+ let _ = write!(out, "%{b:02X}");
1006+ }
1007+ }
1008+ }
1009+ out
1010+}
docs/decisions.md +28 −0
@@ -639,3 +639,31 @@ just a private browser. Sharing the issue/PR numbering and tables keeps labels a
639639 uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path
640640 for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large,
641641 multi-phase build delivered incrementally with the gate green at each step.
642+
643+## 2026-07-25 — Captcha on sign-up: an opt-in exception to "no network from pages"
644+
645+**Decision:** Implement the spec §19 optional captcha (hCaptcha / reCAPTCHA v2) on the
646+`/register` form, off by default via a new `[captcha]` config table (`provider = none |
647+hcaptcha | recaptcha`, `site_key`, `secret_key`). When — and only when — an admin enables it,
648+the provider's widget script is loaded on the sign-up page and the solved token is verified
649+server-side with an outbound HTTPS POST to the provider's `siteverify` endpoint. Verification
650+**fails closed**: an empty token, a missing secret, a network error, or a rejected token all
651+reject the sign-up, so a broken verifier never admits a bot. The HTTP call uses `reqwest`
652+(`default-features = false`, `native-tls` + `json`) — native-tls, not rustls, so `ring` stays
653+out of the graph, consistent with the mail/SSH TLS decisions.
654+
655+**Rationale:** captcha is fundamentally incompatible with §9.5 ("no network requests from any
656+page") — it cannot work without third-party JS and an out-of-band verify. The spec nonetheless
657+lists it as an optional feature, so the resolution is to make it a **deliberate, opt-in**
658+deviation: the no-network invariant holds for every page by default, and enabling captcha is an
659+explicit administrator choice to accept a third-party dependency on one page. No other page
660+loads external resources.
661+
662+## 2026-07-25 — SSH/GPG key management in the web account settings
663+
664+**Decision:** Surface the existing key store (CLI phase 8) in the web settings page: an "SSH
665+and GPG keys" section listing the viewer's keys with delete buttons and an add form (type +
666+optional label + pasted material), backed by `POST /settings/keys` and `/settings/keys/delete`.
667+The add handler reuses `git::parse_public_key` for validation and fingerprinting and rejects a
668+duplicate fingerprint; delete is ownership-checked against `keys_by_user`. This closes the last
669+account-settings gap (§19) so key management no longer requires shell access.