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 = [
2321 "pin-project-lite",2321 "pin-project-lite",
2322 "smallvec",2322 "smallvec",
2323 "tokio",2323 "tokio",
2324 "want",
2325]
2326
2327[[package]]
2328name = "hyper-tls"
2329version = "0.6.0"
2330source = "registry+https://github.com/rust-lang/crates.io-index"
2331checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
2332dependencies = [
2333 "bytes",
2334 "http-body-util",
2335 "hyper",
2336 "hyper-util",
2337 "native-tls",
2338 "tokio",
2339 "tokio-native-tls",
2340 "tower-service",
2324]2341]
23252342
2326[[package]]2343[[package]]
@@ -2329,13 +2346,21 @@ version = "0.1.20"
2329source = "registry+https://github.com/rust-lang/crates.io-index"2346source = "registry+https://github.com/rust-lang/crates.io-index"
2330checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"2347checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
2331dependencies = [2348dependencies = [
2349 "base64",
2332 "bytes",2350 "bytes",
2351 "futures-channel",
2352 "futures-util",
2333 "http",2353 "http",
2334 "http-body",2354 "http-body",
2335 "hyper",2355 "hyper",
2356 "ipnet",
2357 "libc",
2358 "percent-encoding",
2336 "pin-project-lite",2359 "pin-project-lite",
2360 "socket2",
2337 "tokio",2361 "tokio",
2338 "tower-service",2362 "tower-service",
2363 "tracing",
2339]2364]
23402365
2341[[package]]2366[[package]]
@@ -2556,6 +2581,12 @@ dependencies = [
2556]2581]
25572582
2558[[package]]2583[[package]]
2584name = "ipnet"
2585version = "2.12.0"
2586source = "registry+https://github.com/rust-lang/crates.io-index"
2587checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
2588
2589[[package]]
2559name = "is_terminal_polyfill"2590name = "is_terminal_polyfill"
2560version = "1.70.2"2591version = "1.70.2"
2561source = "registry+https://github.com/rust-lang/crates.io-index"2592source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3850,6 +3881,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3850checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884"3881checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884"
38513882
3852[[package]]3883[[package]]
3884name = "reqwest"
3885version = "0.12.28"
3886source = "registry+https://github.com/rust-lang/crates.io-index"
3887checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
3888dependencies = [
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]]
3853name = "rfc6979"3920name = "rfc6979"
3854version = "0.4.0"3921version = "0.4.0"
3855source = "registry+https://github.com/rust-lang/crates.io-index"3922source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4099,6 +4166,15 @@ dependencies = [
4099]4166]
41004167
4101[[package]]4168[[package]]
4169name = "rustls-pki-types"
4170version = "1.15.1"
4171source = "registry+https://github.com/rust-lang/crates.io-index"
4172checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
4173dependencies = [
4174 "zeroize",
4175]
4176
4177[[package]]
4102name = "rustversion"4178name = "rustversion"
4103version = "1.0.23"4179version = "1.0.23"
4104source = "registry+https://github.com/rust-lang/crates.io-index"4180source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4931,6 +5007,9 @@ name = "sync_wrapper"
4931version = "1.0.2"5007version = "1.0.2"
4932source = "registry+https://github.com/rust-lang/crates.io-index"5008source = "registry+https://github.com/rust-lang/crates.io-index"
4933checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"5009checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
5010dependencies = [
5011 "futures-core",
5012]
49345013
4935[[package]]5014[[package]]
4936name = "synstructure"5015name = "synstructure"
@@ -5219,14 +5298,17 @@ dependencies = [
5219 "bitflags",5298 "bitflags",
5220 "bytes",5299 "bytes",
5221 "futures-core",5300 "futures-core",
5301 "futures-util",
5222 "http",5302 "http",
5223 "http-body",5303 "http-body",
5224 "pin-project-lite",5304 "pin-project-lite",
5225 "tokio",5305 "tokio",
5226 "tokio-util",5306 "tokio-util",
5307 "tower",
5227 "tower-layer",5308 "tower-layer",
5228 "tower-service",5309 "tower-service",
5229 "tracing",5310 "tracing",
5311 "url",
5230 "uuid",5312 "uuid",
5231]5313]
52325314
@@ -5348,6 +5430,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5348checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"5430checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
53495431
5350[[package]]5432[[package]]
5433name = "try-lock"
5434version = "0.2.5"
5435source = "registry+https://github.com/rust-lang/crates.io-index"
5436checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
5437
5438[[package]]
5351name = "twofish"5439name = "twofish"
5352version = "0.7.1"5440version = "0.7.1"
5353source = "registry+https://github.com/rust-lang/crates.io-index"5441source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5528,6 +5616,15 @@ dependencies = [
5528]5616]
55295617
5530[[package]]5618[[package]]
5619name = "want"
5620version = "0.3.1"
5621source = "registry+https://github.com/rust-lang/crates.io-index"
5622checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
5623dependencies = [
5624 "try-lock",
5625]
5626
5627[[package]]
5531name = "wasi"5628name = "wasi"
5532version = "0.11.1+wasi-snapshot-preview1"5629version = "0.11.1+wasi-snapshot-preview1"
5533source = "registry+https://github.com/rust-lang/crates.io-index"5630source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5618,6 +5715,7 @@ dependencies = [
5618 "maud",5715 "maud",
5619 "mime_guess",5716 "mime_guess",
5620 "model",5717 "model",
5718 "reqwest",
5621 "rust-embed",5719 "rust-embed",
5622 "serde",5720 "serde",
5623 "serde_json",5721 "serde_json",
@@ -5635,6 +5733,16 @@ dependencies = [
5635]5733]
56365734
5637[[package]]5735[[package]]
5736name = "web-sys"
5737version = "0.3.103"
5738source = "registry+https://github.com/rust-lang/crates.io-index"
5739checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
5740dependencies = [
5741 "js-sys",
5742 "wasm-bindgen",
5743]
5744
5745[[package]]
5638name = "web-time"5746name = "web-time"
5639version = "1.1.0"5747version = "1.1.0"
5640source = "registry+https://github.com/rust-lang/crates.io-index"5748source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +7 −0
@@ -160,6 +160,13 @@ tokio-util = { version = "0.7", features = ["io"] }
160flate2 = "1"160flate2 = "1"
161# Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate.161# Content search (ripgrep's engine). Dual Unlicense/MIT — MIT satisfies the gate.
162grep = "0.3"162grep = "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.
166reqwest = { version = "0.12", default-features = false, features = [
167 "native-tls",
168 "json",
169] }
163# SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key).170# SSH server (default features: aws-lc-rs backend, no ring; re-exports ssh-key).
164russh = "0.62"171russh = "0.62"
165tracing = "0.1"172tracing = "0.1"
assets/base.css +17 −0
@@ -592,6 +592,23 @@ select:focus {
592.token-list {592.token-list {
593 margin-bottom: 1rem;593 margin-bottom: 1rem;
594}594}
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}
595.token-scopes {612.token-scopes {
596 margin: 0.75rem 0 0;613 margin: 0.75rem 0 0;
597 padding: 0.5rem 0.85rem;614 padding: 0.5rem 0.85rem;
crates/config/src/lib.rs +2 −2
@@ -32,8 +32,8 @@ use figment::providers::{Env, Format, Serialized, Toml};
3232
33pub use crate::secret::{REDACTED, Secret};33pub use crate::secret::{REDACTED, Secret};
34pub use crate::types::{34pub use crate::types::{
35 Auth, Config, Database, DefaultVisibility, Git, Instance, Log, LogFormat, LogLevel, Mail,35 Auth, Captcha, CaptchaProvider, Config, Database, DefaultVisibility, Git, Instance, Log,
36 MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui,36 LogFormat, LogLevel, Mail, MailBackend, MailEncryption, Search, Server, Ssh, Storage, Ui,
37};37};
3838
39/// System-wide configuration path, the lowest-priority file source.39/// System-wide configuration path, the lowest-priority file source.
crates/config/src/types.rs +85 −0
@@ -32,6 +32,9 @@ pub struct Config {
32 pub auth: Auth,32 pub auth: Auth,
33 /// Outbound mail.33 /// Outbound mail.
34 pub mail: Mail,34 pub mail: Mail,
35 /// Optional bot mitigation on self-registration.
36 #[serde(default)]
37 pub captcha: Captcha,
35 /// Web UI presentation.38 /// Web UI presentation.
36 pub ui: Ui,39 pub ui: Ui,
37 /// Git binary and pack-transport limits.40 /// Git binary and pack-transport limits.
@@ -126,6 +129,88 @@ impl Default for Instance {
126 }129 }
127}130}
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")]
135pub 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
145impl 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)]
192pub 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
204impl 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
129/// `[server]` — HTTP binding and limits.214/// `[server]` — HTTP binding and limits.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]216#[serde(deny_unknown_fields)]
crates/web/Cargo.toml +1 −0
@@ -30,6 +30,7 @@ ammonia = { workspace = true }
30tokio-util = { workspace = true }30tokio-util = { workspace = true }
31flate2 = { workspace = true }31flate2 = { workspace = true }
32grep = { workspace = true }32grep = { workspace = true }
33reqwest = { workspace = true }
33base64 = { workspace = true }34base64 = { workspace = true }
34time = { workspace = true }35time = { workspace = true }
35serde = { workspace = true }36serde = { workspace = true }
crates/web/src/lib.rs +2 −0
@@ -157,6 +157,8 @@ pub fn build_router(state: AppState) -> Router {
157 .route("/settings/password", post(pages::account_password))157 .route("/settings/password", post(pages::account_password))
158 .route("/settings/tokens", post(pages::token_create))158 .route("/settings/tokens", post(pages::token_create))
159 .route("/settings/tokens/revoke", post(pages::token_revoke))159 .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))
160 .route("/settings/avatar", post(avatar::upload))162 .route("/settings/avatar", post(avatar::upload))
161 .route("/settings/theme", get(pages::set_theme))163 .route("/settings/theme", get(pages::set_theme))
162 .route("/avatar/{username}", get(avatar::show))164 .route("/avatar/{username}", get(avatar::show))
crates/web/src/pages.rs +257 −7
@@ -12,7 +12,7 @@ use axum::extract::{Path, State};
12use axum::http::{HeaderMap, Uri, header};12use axum::http::{HeaderMap, Uri, header};
13use axum::response::{IntoResponse, Redirect, Response};13use axum::response::{IntoResponse, Redirect, Response};
14use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};14use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
15use maud::{Markup, html};15use maud::{Markup, PreEscaped, html};
16use model::User;16use model::User;
17use serde::Deserialize;17use serde::Deserialize;
1818
@@ -559,11 +559,25 @@ pub struct RegisterForm {
559 /// Confirmation.559 /// Confirmation.
560 #[serde(default)]560 #[serde(default)]
561 confirm: String,561 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,
562 /// CSRF token.568 /// CSRF token.
563 #[serde(rename = "_csrf")]569 #[serde(rename = "_csrf")]
564 csrf: String,570 csrf: String,
565}571}
566572
573/// The captcha provider and public site key when captcha is enabled, for
574/// rendering the widget on the sign-up form.
575fn 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
567/// `GET /register` — the sign-up form, or `404` when registration is disabled.581/// `GET /register` — the sign-up form, or `404` when registration is disabled.
568pub async fn register_form(582pub async fn register_form(
569 State(state): State<AppState>,583 State(state): State<AppState>,
@@ -578,12 +592,25 @@ pub async fn register_form(
578 return Redirect::to("/").into_response();592 return Redirect::to("/").into_response();
579 }593 }
580 let (jar, chrome) = build_chrome(&state, jar, None, uri.path().to_string());594 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 );
582 (jar, page(&chrome, "Sign up", body)).into_response()602 (jar, page(&chrome, "Sign up", body)).into_response()
583}603}
584604
585/// Render the sign-up form with an optional error and prefilled fields.605/// Render the sign-up form with an optional error, prefilled fields, and the
586fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) -> Markup {606/// captcha widget when one is configured.
607fn register_body(
608 csrf: &str,
609 error: Option<&str>,
610 username: &str,
611 email: &str,
612 captcha: Option<&(config::CaptchaProvider, String)>,
613) -> Markup {
587 html! {614 html! {
588 div class="auth-wrap" {615 div class="auth-wrap" {
589 div class="auth-card" {616 div class="auth-card" {
@@ -605,6 +632,12 @@ fn register_body(csrf: &str, error: Option<&str>, username: &str, email: &str) -
605 label for="confirm" { "Confirm password" }632 label for="confirm" { "Confirm password" }
606 input type="password" id="confirm" name="confirm"633 input type="password" id="confirm" name="confirm"
607 autocomplete="new-password" required;634 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 }
608 button class="btn btn-primary" type="submit" { "Create account" }641 button class="btn btn-primary" type="submit" { "Create account" }
609 }642 }
610 p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } }643 p class="muted auth-alt" { "Already have an account? " a href="/login" { "Sign in" } }
@@ -629,9 +662,10 @@ pub async fn register_submit(
629 let username = form.username.trim().to_string();662 let username = form.username.trim().to_string();
630 let email = form.email.trim().to_string();663 let email = form.email.trim().to_string();
631664
665 let captcha = captcha_view(&state.config);
632 let reject = |msg: &str, username: &str, email: &str| {666 let reject = |msg: &str, username: &str, email: &str| {
633 let (jar, chrome) = build_chrome(&state, jar.clone(), None, "/register".to_string());667 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());
635 (669 (
636 axum::http::StatusCode::BAD_REQUEST,670 axum::http::StatusCode::BAD_REQUEST,
637 jar,671 jar,
@@ -650,6 +684,22 @@ pub async fn register_submit(
650 return reject("Password must be at least 8 characters.", &username, &email);684 return reject("Password must be at least 8 characters.", &username, &email);
651 }685 }
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
653 let params = auth::HashParams {703 let params = auth::HashParams {
654 m_cost: state.config.auth.argon2_m_cost,704 m_cost: state.config.auth.argon2_m_cost,
655 t_cost: state.config.auth.argon2_t_cost,705 t_cost: state.config.auth.argon2_t_cost,
@@ -696,6 +746,42 @@ pub async fn register_submit(
696 }746 }
697}747}
698748
749/// The relevant fields of a provider `siteverify` response.
750#[derive(Debug, Deserialize)]
751struct 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.
760async 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
699/// Logout form field.785/// Logout form field.
700#[derive(Debug, Deserialize)]786#[derive(Debug, Deserialize)]
701pub struct CsrfForm {787pub struct CsrfForm {
@@ -841,8 +927,9 @@ pub async fn settings(
841 uri: Uri,927 uri: Uri,
842) -> AppResult<Response> {928) -> AppResult<Response> {
843 let tokens = state.store.tokens_by_user(&user.id).await?;929 let tokens = state.store.tokens_by_user(&user.id).await?;
930 let keys = state.store.keys_by_user(&user.id).await?;
844 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());931 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);
846 Ok((jar, page(&chrome, "Settings", body)).into_response())933 Ok((jar, page(&chrome, "Settings", body)).into_response())
847}934}
848935
@@ -1129,9 +1216,10 @@ pub async fn token_create(
1129 .tokens_by_user(&user.id)1216 .tokens_by_user(&user.id)
1130 .await1217 .await
1131 .unwrap_or_default();1218 .unwrap_or_default();
1219 let keys = state.store.keys_by_user(&user.id).await.unwrap_or_default();
1132 let (jar, chrome) =1220 let (jar, chrome) =
1133 build_chrome(&state, jar, Some(user.clone()), "/settings".to_string());1221 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));
1135 (jar, page(&chrome, "Settings", body)).into_response()1223 (jar, page(&chrome, "Settings", body)).into_response()
1136 }1224 }
1137 Err(err) => err.into_response(),1225 Err(err) => err.into_response(),
@@ -1174,11 +1262,118 @@ pub async fn token_revoke(
1174 Redirect::to("/settings").into_response()1262 Redirect::to("/settings").into_response()
1175}1263}
11761264
1265/// The add-key form.
1266#[derive(Debug, Deserialize)]
1267pub 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.
1283pub 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)]
1339pub 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.
1349pub 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
1177/// Render the settings page: avatar, profile, account credentials, and tokens.1371/// Render the settings page: avatar, profile, account credentials, and tokens.
1178fn settings_body(1372fn settings_body(
1179 user: &User,1373 user: &User,
1180 csrf: &str,1374 csrf: &str,
1181 tokens: &[model::ApiToken],1375 tokens: &[model::ApiToken],
1376 keys: &[model::Key],
1182 new_token: Option<&str>,1377 new_token: Option<&str>,
1183) -> Markup {1378) -> Markup {
1184 let val = |v: &Option<String>| v.clone().unwrap_or_default();1379 let val = |v: &Option<String>| v.clone().unwrap_or_default();
@@ -1265,10 +1460,65 @@ fn settings_body(
1265 }1460 }
1266 }1461 }
1267 }1462 }
1463 (keys_section(csrf, keys))
1268 (tokens_section(csrf, tokens, new_token))1464 (tokens_section(csrf, tokens, new_token))
1269 }1465 }
1270}1466}
12711467
1468/// The SSH and GPG keys settings section.
1469fn 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
1272/// The API tokens settings section.1522/// The API tokens settings section.
1273fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup {1523fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup {
1274 html! {1524 html! {
crates/web/src/tests.rs +173 −0
@@ -835,3 +835,176 @@ async fn settings_requires_authentication() {
835 assert_eq!(res.status(), StatusCode::SEE_OTHER);835 assert_eq!(res.status(), StatusCode::SEE_OTHER);
836 assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login");836 assert_eq!(res.headers().get(header::LOCATION).unwrap(), "/login");
837}837}
838
839/// A valid SSH public key for exercising the key-management UI.
840const TEST_SSH_KEY: &str = "ssh-ed25519 \
841AAAAC3NzaC1lZDI1NTE5AAAAIJqq6nD0E4SlM+xw1UVOompehxQE8sUYCVoyV+NBjfKU web-test@fabrica";
842
843#[tokio::test]
844async 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]
907async 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.
930async 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]
945async 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]
959async 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.
995fn 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
639uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path639uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path
640for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large,640for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large,
641multi-phase build delivered incrementally with the gate green at each step.641multi-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 |
647hcaptcha | recaptcha`, `site_key`, `secret_key`). When — and only when — an admin enables it,
648the provider's widget script is loaded on the sign-up page and the solved token is verified
649server-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
651reject 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
653out 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
656page") — it cannot work without third-party JS and an out-of-band verify. The spec nonetheless
657lists it as an optional feature, so the resolution is to make it a **deliberate, opt-in**
658deviation: the no-network invariant holds for every page by default, and enabling captcha is an
659explicit administrator choice to accept a third-party dependency on one page. No other page
660loads 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
665and GPG keys" section listing the viewer's keys with delete buttons and an add form (type +
666optional label + pasted material), backed by `POST /settings/keys` and `/settings/keys/delete`.
667The add handler reuses `git::parse_public_key` for validation and fingerprinting and rejects a
668duplicate fingerprint; delete is ownership-checked against `keys_by_user`. This closes the last
669account-settings gap (§19) so key management no longer requires shell access.