git-ents.gitmain
⌘K
foforge
commit 75b9b77
web: require member sign-in on the hosted surface

The pre-redo key-proof sign-in, automated (roots.web-signin): /login mints a one-time code bound to the browser session; the CLI signs the host+code+nonce payload under the git-ents-login SSHSIG namespace and posts it back; verification is pure-Rust ssh_key against the enrolled member list, re-checked on every mutation so revocation bites mid-session. AccessPolicy::{Trusted, SignInRequired(Realm)} is injected by the composition root: under Trusted the login routes are unrouted and the local surface is byte-identical, down to the policy-driven Secure cookie attribute. Mutations are attributed to the signed-in member via the two-arg receive_identity! (receive.attributed-author).

Assisted-by: Claude:claude-fable-5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/Cargo.toml @@ -30,6 +30,7 @@ maud = { workspace = true } pulldown-cmark = { workspace = true } serde = { version = "1", features = ["derive"] } +ssh-key = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true }
crates/cli/ents-web/src/identity.rs @@ -99,8 +99,20 @@ #[macro_export] macro_rules! receive_identity { ($identity:expr) => { + $crate::receive_identity!($identity, None) + }; + // The attributed form (`receive.attributed-author`, + // `roots.web-signing`): `$author` is an + // `Option<gix::actor::Signature>` naming the signed-in member -- + // `pages::member_author(&session)` at every mutation call site -- so + // hosted history reads "member via the web" while the committer and + // signature stay the injected identity. Under a `Trusted` policy the + // session never holds a member, the option is `None`, and the commit + // is byte-identical to the single-argument form's. + ($identity:expr, $author:expr) => { ents_receive::Identity { actor: $identity.actor(), + author: $author, sign: &|payload| $identity.sign(payload), } };
crates/cli/ents-web/src/lib.rs @@ -108,6 +108,7 @@ pub(crate) mod asciidoc; pub(crate) mod assets; +pub mod auth; pub(crate) mod editor; pub mod error; pub mod identity;
crates/cli/ents-web/src/router.rs @@ -26,7 +26,7 @@ use crate::assets; use crate::pages; use crate::session; -use crate::state::AppState; +use crate::state::{AccessPolicy, AppState}; /// Build the full route table, wrapped in the session middleware /// (`roots.web-session`). @@ -38,7 +38,22 @@ where O: Find + Write + Send + 'static, { + // The sign-in surface exists only where the injected policy demands + // it (`roots.web-signin`): under `Trusted` — the local root — these + // routes are not merely inert, they are unrouted, so `/login` is a + // plain 404 and the local surface is byte-identical to before. + let sign_in = match state.access { + AccessPolicy::SignInRequired(_) => Router::new() + .route("/login", get(pages::login::show::<O>)) + .route( + "/login/challenge/{code}", + get(pages::login::challenge::<O>).post(pages::login::complete::<O>), + ) + .route("/logout", post(pages::login::logout::<O>)), + AccessPolicy::Trusted => Router::new(), + }; Router::new() + .merge(sign_in) .route("/", get(pages::dashboard::show::<O>)) .route("/members", get(pages::members::list::<O>)) .route("/members/{username}", get(pages::members::show::<O>)) @@ -92,6 +107,13 @@ .route("/inbox", get(pages::inbox::list::<O>)) .route("/style.css", get(style)) .route("/ents.js", get(script)) + // Layer order: axum runs the last-added layer first, so the + // session middleware (added below) resolves the session before + // the auth middleware consults it. + .layer(middleware::from_fn_with_state( + Arc::clone(&state), + auth_middleware::<O>, + )) .layer(middleware::from_fn_with_state( Arc::clone(&state), session_middleware::<O>, @@ -99,6 +121,78 @@ .with_state(state) } +/// The access-policy middleware (`roots.web-signin`): under +/// [`AccessPolicy::Trusted`] every request passes untouched — the local +/// root's behavior is byte-identical to before this middleware existed. +/// Under [`AccessPolicy::SignInRequired`], a state-changing request (every +/// mutation in this crate is a `POST`) requires a session signed in as a +/// member who is *still* enrolled and active — re-checked here on every +/// mutation, so a revocation takes effect mid-session, not at the next +/// sign-in. `/login` and `/logout` are exempt: the sign-in surface itself +/// authenticates by signature, and logout only clears session state. +// @relation(roots.web-signin, scope=function) +async fn auth_middleware<O>( + State(state): State<Arc<AppState<O>>>, + request: Request, + next: Next, +) -> Response +where + O: Find + Write + Send + 'static, +{ + let AccessPolicy::SignInRequired(_) = &state.access else { + return next.run(request).await; + }; + let path = request.uri().path(); + if request.method() != axum::http::Method::POST + || path.starts_with("/login") + || path == "/logout" + { + return next.run(request).await; + } + + let member = request + .extensions() + .get::<session::Session>() + .and_then(|session| session.member.clone()); + let enrolled = match &member { + Some(member) => crate::auth::active_member_by_key(&state, &member.key) + .ok() + .flatten() + .is_some_and(|username| username == member.username), + None => false, + }; + if enrolled { + return next.run(request).await; + } + + // A signed-in member who no longer verifies against the live member + // list is signed out, not just refused (`roots.web-signin`). + if member.is_some() + && let Some(id) = request + .headers() + .get(header::COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(session::session_id_from_cookie_header) + { + state.sessions.clear_member(id); + } + + let wants_html = request + .headers() + .get(header::ACCEPT) + .and_then(|value| value.to_str().ok()) + .is_some_and(|accept| accept.contains("text/html")); + if wants_html { + axum::response::Redirect::to("/login").into_response() + } else { + ( + axum::http::StatusCode::UNAUTHORIZED, + "sign in first: this deployment requires an authenticated member for mutations\n", + ) + .into_response() + } +} + /// The session middleware (`roots.web-session`): recognize an existing /// session cookie, or mint a fresh one and set it on the response. Every /// handler reads the resolved [`session::Session`] via `Extension`. @@ -134,9 +228,15 @@ } }; request.extensions_mut().insert(session); + request + .extensions_mut() + .insert(session::SessionId(id.clone())); let mut response = next.run(request).await; - if is_new && let Ok(value) = HeaderValue::from_str(&session::set_cookie_header(&id)) { + // `Secure` is policy-driven: hosted (sign-in-required) serving is + // HTTPS-only, local plain-HTTP loopback must not lose its cookie. + let secure = matches!(state.access, AccessPolicy::SignInRequired(_)); + if is_new && let Ok(value) = HeaderValue::from_str(&session::set_cookie_header(&id, secure)) { response.headers_mut().append(header::SET_COOKIE, value); } response
crates/cli/ents-web/src/session.rs @@ -21,17 +21,41 @@ /// request carries its CSRF token in. pub const CSRF_FIELD: &str = "csrf"; -/// One held session: nothing but the CSRF token it was issued. -/// `roots.web-session` requires no more than this -- there is no login -/// step in this phase (see `ents-web`'s crate doc for the scoping this -/// leaves for a future account/login system), so a session's only job is -/// letting this server recognize "the same browser that fetched the form -/// is the one submitting it," which a bare CSRF token already proves. -// @relation(roots.web-session, scope=file) +/// The member a session proved control of a key for +/// (`roots.web-signin`): nothing but the username and the public key — +/// no secret is ever transmitted or stored. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionMember { + /// The enrolled member's username, as resolved at sign-in. + pub username: String, + /// The member's OpenSSH public key line, re-checked against the live + /// member list at every mutation (`roots.web-signin`). + pub key: String, +} + +/// The current request's session id, inserted alongside [`Session`] by +/// the session middleware — a login page needs it to bind a challenge to +/// exactly this browser (`roots.web-signin`), and nothing else does: the +/// id is the cookie's secret, so handlers receive it in this deliberate +/// newtype rather than a bare `String` any format call could leak. +#[derive(Debug, Clone)] +pub struct SessionId(pub String); + +/// One held session: the CSRF token it was issued, plus — once the +/// browser completes a sign-in (`roots.web-signin`) — the member it +/// authenticated as. Under a `Trusted` access policy +/// ([`crate::state::AccessPolicy`], the local root) `member` stays +/// `None` for every session and grants nothing either way: the injected +/// identity is the whole story there, so a session's only job is letting +/// this server recognize "the same browser that fetched the form is the +/// one submitting it," which the CSRF token proves. +// @relation(roots.web-session, roots.web-signin, scope=file) #[derive(Debug, Clone)] pub struct Session { /// The token a state-changing request must echo back. pub csrf: String, + /// The member this session signed in as, if any. + pub member: Option<SessionMember>, } /// Server-memory-only session storage (`roots.web-session`). @@ -66,6 +90,7 @@ let id = random_token(); let session = Session { csrf: random_token(), + member: None, }; #[expect( clippy::unwrap_used, @@ -85,6 +110,33 @@ #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")] self.sessions.lock().unwrap().get(id).cloned() } + + /// Mark `id`'s session as signed in as `member` + /// (`roots.web-signin`), returning whether the session existed — a + /// consumed challenge naming a session this store has never held (a + /// restart between page load and sign-in) authenticates nothing. + pub fn authenticate(&self, id: &str, member: SessionMember) -> bool { + #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")] + let mut sessions = self.sessions.lock().unwrap(); + match sessions.get_mut(id) { + Some(session) => { + session.member = Some(member); + true + } + None => false, + } + } + + /// Drop `id`'s signed-in member, keeping the session itself — the + /// logout action, and the auth middleware's response to a member + /// revoked mid-session (`roots.web-signin`). + pub fn clear_member(&self, id: &str) { + #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")] + let mut sessions = self.sessions.lock().unwrap(); + if let Some(session) = sessions.get_mut(id) { + session.member = None; + } + } } /// A random, URL-safe token: 32 hex characters from 16 random bytes. @@ -115,9 +167,16 @@ /// replacement for it: `SameSite=Strict` alone would already block a /// cross-site POST, but a network intermediary or a future relaxation of /// that attribute must not silently remove the protection). +/// +/// `secure` adds the `Secure` attribute — set by the hosted deployment, +/// which only ever serves behind HTTPS, and never by local plain-HTTP +/// loopback serving, where the attribute would make the browser drop the +/// cookie entirely. Policy-driven, not deployment-sniffed: the caller +/// passes what its [`crate::state::AccessPolicy`] implies. #[must_use] -pub fn set_cookie_header(id: &str) -> String { - format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict") +pub fn set_cookie_header(id: &str, secure: bool) -> String { + let secure = if secure { "; Secure" } else { "" }; + format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict{secure}") } #[cfg(test)] @@ -144,7 +203,7 @@ #[rstest] // @relation(roots.web-session, scope=function, role=Verifies) fn cookie_header_round_trips_the_session_id() { - let header = set_cookie_header("abc123"); + let header = set_cookie_header("abc123", false); assert!(header.contains("HttpOnly")); let raw_cookie = header.split(';').next().expect("at least one segment"); assert_eq!(session_id_from_cookie_header(raw_cookie), Some("abc123"));
crates/cli/ents-web/src/state.rs @@ -30,9 +30,39 @@ use ents_receive::{EventSink, Mode}; use gix_ref_store::RefStore; +use crate::auth::ChallengeStore; use crate::identity::SigningIdentity; use crate::session::SessionStore; +/// Who may mutate through this deployment's web UI — injected by the +/// composition root, exactly as the signing identity is +/// (`roots.web-agnostic`): this crate branches on the injected policy's +/// value, never on where it is running (`arch.no-hosted-branch`'s +/// spirit). +// @relation(roots.web-signin, scope=type) +pub enum AccessPolicy { + /// Every session mutates as the injected identity — the local root + /// (`roots.local`), where the operator's own key *is* the identity + /// and no sign-in surface exists (`roots.web-signin`). + Trusted, + /// Anonymous sessions browse read-only; a mutation requires a + /// session signed in as an enrolled, active member + /// (`roots.web-signin`) — the hosted root. + SignInRequired(Realm), +} + +/// What a sign-in-required deployment knows about itself: the canonical +/// external host bound into every challenge payload +/// ([`crate::auth::challenge_payload`]), and the outstanding challenges. +pub struct Realm { + /// The host a member addresses this deployment as, e.g. + /// `git.ents.cloud` — a signature is bound to it, so one minted for + /// this realm verifies nowhere else (`roots.web-signin`). + pub host: String, + /// Outstanding sign-in challenges, memory-only like the sessions. + pub challenges: ChallengeStore, +} + /// Everything a page handler needs: the four composition-root seams, the /// gate policy in force, the repository's working-tree path (comment /// anchoring resolves paths against it), and the in-memory session store @@ -67,6 +97,10 @@ pub path: PathBuf, /// In-memory web sessions (`roots.web-session`). pub sessions: SessionStore, + /// Who may mutate here (`roots.web-signin`): [`AccessPolicy::Trusted`] + /// unless the composition root said otherwise via + /// [`AppState::with_access`]. + pub access: AccessPolicy, } impl<O> AppState<O> { @@ -89,9 +123,22 @@ identity, path, sessions: SessionStore::default(), + access: AccessPolicy::Trusted, } } + /// Replace the default [`AccessPolicy::Trusted`] — the hosted + /// composition root's one extra wiring step + /// (`roots.single-node-hosted`, `roots.web-signin`). A consuming + /// builder rather than a constructor parameter so every existing + /// `new` caller (every `Trusted` deployment and test fixture) stays + /// untouched. + #[must_use] + pub fn with_access(mut self, access: AccessPolicy) -> Self { + self.access = access; + self + } + /// Lock the object store for the duration of one request. /// /// Poisoning recovers rather than propagating (mirrors
crates/cli/ents-web/tests/router.rs @@ -2830,3 +2830,429 @@ assert_eq!(response.status(), StatusCode::OK, "GET {path}"); } } + +// --------------------------------------------------------------------- +// roots.web-signin: the hosted sign-in surface, driven exactly as the +// CLI and a browser would drive it, still with no socket anywhere. +// --------------------------------------------------------------------- + +/// Sign `payload` under the *login* namespace with seed `seed`'s key -- +/// the same deterministic key `Keypair::from_seed(seed)` wraps, rebuilt +/// here because `Keypair::sign` deliberately signs only git's own commit +/// namespace. +fn login_sign(seed: u8, payload: &[u8]) -> String { + use ssh_key::private::{Ed25519Keypair, KeypairData}; + use ssh_key::{HashAlg, LineEnding, PrivateKey}; + let pair = Ed25519Keypair::from_seed(&[seed; 32]); + let key = PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed"); + key.sign(ents_web::auth::LOGIN_NAMESPACE, HashAlg::Sha512, payload) + .expect("signing is infallible") + .to_pem(LineEnding::LF) + .expect("renders") +} + +/// Percent-encode a form value (the armored signature carries newlines, +/// `+`, `/`, and `=`, every one of which is significant to a form body). +fn urlencode(value: &str) -> String { + value + .bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + char::from(b).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} + +/// A sign-in-required state over `refs`/`objects`, host `ents.test` -- +/// the hosted composition root's shape (`roots.single-node-hosted`), +/// minus the real repo. +fn build_signin_state( + identity: FixtureIdentity, + refs: MemRefStore, + objects: ObjectStore, +) -> Arc<AppState<ObjectStore>> { + Arc::new( + AppState::new( + Box::new(refs), + objects, + Box::new(NullEventSink), + Mode::Advisory, + Box::new(identity), + std::env::temp_dir(), + ) + .with_access(ents_web::state::AccessPolicy::SignInRequired( + ents_web::state::Realm { + host: "ents.test".to_owned(), + challenges: ents_web::auth::ChallengeStore::default(), + }, + )), + ) +} + +/// GET /login with `cookie`, returning the fresh code the page displays. +async fn fetch_login_code(router: &axum::Router, cookie: &str) -> String { + let response = router + .clone() + .oneshot( + Request::get("/login") + .header(header::COOKIE, cookie) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8( + response + .into_body() + .collect() + .await + .expect("body") + .to_bytes() + .to_vec(), + ) + .expect("utf8"); + let after = body + .split("ents.test ") + .nth(1) + .expect("the page displays the login command"); + after.chars().take(9).collect() +} + +/// Complete a challenge for `code` as seed `seed`'s key, returning the +/// response. +async fn complete_challenge( + router: &axum::Router, + code: &str, + seed: u8, + host: &str, +) -> axum::response::Response { + let challenge = router + .clone() + .oneshot( + Request::get(format!("/login/challenge/{code}")) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(challenge.status(), StatusCode::OK, "challenge fetch"); + let text = String::from_utf8( + challenge + .into_body() + .collect() + .await + .expect("body") + .to_bytes() + .to_vec(), + ) + .expect("utf8"); + let nonce = text + .lines() + .find_map(|line| line.strip_prefix("nonce=")) + .expect("a nonce line"); + + let normalized = ents_web::auth::normalize_code(code); + let payload = ents_web::auth::challenge_payload(host, &normalized, nonce); + let signature = login_sign(seed, payload.as_bytes()); + let public_key = Keypair::from_seed(seed).public_openssh(); + let form = format!( + "public_key={}&signature={}", + urlencode(&public_key), + urlencode(&signature) + ); + router + .clone() + .oneshot( + Request::post(format!("/login/challenge/{code}")) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(form)) + .expect("request"), + ) + .await + .expect("in-process call") +} + +/// Rewrite `username`'s member record as revoked, directly against +/// `state`'s own stores -- the commit is staged via a scratch ref store +/// (`write_meta_entity` wants a concrete `MemRefStore`) and applied to +/// the live one through the trait's own CAS transaction. +fn revoke_member(state: &AppState<ObjectStore>, username: &str, key: &Keypair, seconds: i64) { + let scratch = MemRefStore::default(); + let name = ents_model::namespace::member_ref(&MemberId::new(username)).expect("valid"); + let mut member = ents_model::Member::new( + MemberId::new(username), + key.public_openssh(), + Provenance::AdminRegistered, + ); + member.state = ents_model::MemberState::Revoked; + let oid = write_meta_entity( + &scratch, + &*state.objects(), + name.clone(), + &member, + Some(&Keypair::from_seed(9)), + seconds, + ); + state + .refs + .transaction(&[gix_ref_store::RefEdit { + name, + expected: gix_ref_store::Expected::Any, + new: Some(oid), + }]) + .expect("applies"); +} + +#[tokio::test] +// @relation(roots.web-signin, scope=function, role=Verifies) +async fn the_login_surface_is_unrouted_under_trusted() { + let state = build_state(FixtureIdentity { + name: "local-user", + key: Keypair::from_seed(1), + }); + let router = ents_web::router(Arc::clone(&state)); + for path in ["/login", "/login/challenge/ABCD2345"] { + let response = router + .clone() + .oneshot(Request::get(path).body(Body::empty()).expect("request")) + .await + .expect("in-process call"); + assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}"); + } +} + +#[tokio::test] +// @relation(roots.web-signin, scope=function, role=Verifies) +async fn the_cli_challenge_flow_signs_the_browser_session_in() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let joey = Keypair::from_seed(7); + enroll_member( + &refs, + &objects, + "joey", + &joey, + Provenance::AdminRegistered, + 100, + ); + let state = build_signin_state( + FixtureIdentity { + name: "server", + key: Keypair::from_seed(9), + }, + refs, + objects, + ); + let router = ents_web::router(Arc::clone(&state)); + + let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/").await; + let code = fetch_login_code(&router, &cookie).await; + + let response = complete_challenge(&router, &code, 7, "ents.test").await; + assert_eq!(response.status(), StatusCode::OK, "sign-in completes"); + + // The browser's next look at /login reads as signed in. + let session_id = cookie + .split(';') + .next() + .expect("segment") + .split_once('=') + .expect("name=value") + .1 + .to_owned(); + let member = state + .sessions + .get(&session_id) + .expect("held") + .member + .expect("signed in"); + assert_eq!(member.username, "joey"); + + // And a second post of the same code finds it consumed -- posted + // directly, since the challenge fetch itself now correctly 404s. + let replay = router + .clone() + .oneshot( + Request::post(format!("/login/challenge/{code}")) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from("public_key=x&signature=y")) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(replay.status(), StatusCode::NOT_FOUND, "single-use"); +} + +#[tokio::test] +// @relation(roots.web-signin, scope=function, role=Verifies) +async fn a_wrong_host_signature_or_foreign_key_is_refused() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let joey = Keypair::from_seed(7); + enroll_member( + &refs, + &objects, + "joey", + &joey, + Provenance::AdminRegistered, + 100, + ); + let state = build_signin_state( + FixtureIdentity { + name: "server", + key: Keypair::from_seed(9), + }, + refs, + objects, + ); + let router = ents_web::router(Arc::clone(&state)); + let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/").await; + + // A signature over another deployment's host does not verify here. + let code = fetch_login_code(&router, &cookie).await; + let response = complete_challenge(&router, &code, 7, "evil.example").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // An unenrolled key's valid signature is refused as not a member. + let code = fetch_login_code(&router, &cookie).await; + let response = complete_challenge(&router, &code, 3, "ents.test").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // A revoked member's key is refused the same way. + revoke_member(&state, "joey", &joey, 200); + let code = fetch_login_code(&router, &cookie).await; + let response = complete_challenge(&router, &code, 7, "ents.test").await; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +// @relation(roots.web-signin, roots.web-session, scope=function, role=Verifies) +async fn mutations_require_a_live_signed_in_member_and_csrf_still_gates() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + let joey = Keypair::from_seed(7); + enroll_member( + &refs, + &objects, + "joey", + &joey, + Provenance::AdminRegistered, + 100, + ); + let state = build_signin_state( + FixtureIdentity { + name: "server", + key: Keypair::from_seed(9), + }, + refs, + objects, + ); + let router = ents_web::router(Arc::clone(&state)); + let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/").await; + + let post_account = |form: String, with_cookie: bool, accept_html: bool| { + let mut request = Request::post("/account") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); + if with_cookie { + request = request.header(header::COOKIE, cookie.clone()); + } + if accept_html { + request = request.header(header::ACCEPT, "text/html"); + } + router + .clone() + .oneshot(request.body(Body::from(form)).expect("request")) + }; + + // Anonymous: a browser-shaped POST redirects to /login, a bare one + // gets 401. + let form = format!("member=joey&login=j@ents.test&csrf={csrf}"); + let response = post_account(form.clone(), true, true).await.expect("call"); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION).expect("location"), + "/login" + ); + let response = post_account(form.clone(), true, false).await.expect("call"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + // Sign in, then the same POST passes the middleware -- and CSRF is + // still enforced on top of it. + let code = fetch_login_code(&router, &cookie).await; + let signed_in = complete_challenge(&router, &code, 7, "ents.test").await; + assert_eq!(signed_in.status(), StatusCode::OK); + let response = post_account(form.clone(), true, true).await.expect("call"); + assert_eq!(response.status(), StatusCode::SEE_OTHER, "mutation lands"); + assert_eq!( + response.headers().get(header::LOCATION).expect("location"), + "/account" + ); + + // The landed commit is authored by the member and committed by the + // server identity -- "joey via the web" + // (receive.attributed-author, roots.web-signing). + { + use gix_object::Find as _; + let account_ref: gix::refs::FullName = ents_model::namespace::ACCOUNT_REF + .try_into() + .expect("valid"); + let tip = state + .refs + .get(account_ref.as_ref()) + .expect("readable") + .expect("written"); + let mut buf = Vec::new(); + let objects = state.objects(); + let data = objects + .try_find(&tip, &mut buf) + .expect("readable") + .expect("present"); + let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("parses"); + assert_eq!( + commit.author().expect("author").name, + "joey", + "authored by the signed-in member" + ); + assert_eq!( + commit.committer().expect("committer").name, + "server", + "committed by the server identity" + ); + } + let bad_csrf = "member=joey&login=j@ents.test&csrf=not-the-token".to_owned(); + let response = post_account(bad_csrf, true, true).await.expect("call"); + assert_ne!( + response.status(), + StatusCode::SEE_OTHER, + "a signed-in session still fails a wrong csrf token" + ); + + // Revoke joey mid-session: the next mutation is refused and the + // session is signed out, not just bounced. + revoke_member(&state, "joey", &joey, 300); + let response = post_account(form, true, true).await.expect("call"); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION).expect("location"), + "/login" + ); + let session_id = cookie + .split(';') + .next() + .expect("segment") + .split_once('=') + .expect("name=value") + .1; + assert!( + state + .sessions + .get(session_id) + .expect("held") + .member + .is_none(), + "a revoked member is signed out, not left holding a dead session" + ); +}
crates/cli/ents-web/src/pages/account.rs @@ -174,7 +174,7 @@ state.events.as_ref(), name, &account, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), "Create account (web)", state.mode, )?;
crates/cli/ents-web/src/pages/comments.rs @@ -361,7 +361,7 @@ state.events.as_ref(), &id, form.body, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, )?; crate::error::outcome_to_result(outcome)?; @@ -392,7 +392,7 @@ &*state.objects(), state.events.as_ref(), &id, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, Some(&identity.public_openssh()), )?; @@ -424,7 +424,7 @@ &*state.objects(), state.events.as_ref(), &id, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, Some(&identity.public_openssh()), )?; @@ -534,7 +534,7 @@ state.events.as_ref(), &state.path, new, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, )?; crate::error::outcome_to_result(outcome)?;
crates/cli/ents-web/src/pages/commits.rs @@ -571,7 +571,7 @@ state.events.as_ref(), &state.path, new, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, )?; crate::error::outcome_to_result(outcome)?; @@ -656,7 +656,7 @@ &state.path, new, &member, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, )?; crate::error::outcome_to_result(outcome)?;
crates/cli/ents-web/src/pages/effects.rs @@ -146,7 +146,7 @@ state.events.as_ref(), ref_name, &effect, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), &format!("Define effect {name}"), state.mode, )?;
crates/cli/ents-web/src/pages/files.rs @@ -994,6 +994,7 @@ fn session() -> Session { Session { csrf: "test-csrf-token".to_owned(), + member: None, } }
crates/cli/ents-web/src/pages/issues.rs @@ -233,7 +233,7 @@ &*state.objects(), state.events.as_ref(), new, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, )?; crate::error::outcome_to_result(outcome)?; @@ -291,7 +291,7 @@ state.events.as_ref(), &id, edit, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, )?; crate::error::outcome_to_result(outcome)?; @@ -343,7 +343,7 @@ state.events.as_ref(), &state.path, new, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, )?; crate::error::outcome_to_result(outcome)?;
crates/cli/ents-web/src/pages/members.rs @@ -184,7 +184,7 @@ /// omitting it (`roots.web-agnostic`: a reader surfaces a marker, never an /// error or a silent gap, for one entity written by a schema this build no /// longer speaks). -fn read_all<O: Find>( +pub(crate) fn read_all<O: Find>( state: &AppState<O>, ) -> Result<Vec<(String, std::result::Result<Member, String>)>> { let mut out = Vec::new();
crates/cli/ents-web/src/pages/mod.rs @@ -30,6 +30,7 @@ pub mod files; pub mod inbox; pub mod issues; +pub mod login; pub mod members; pub mod meta; pub mod redactions; @@ -442,6 +443,29 @@ /// /// [`Error::BadCsrf`] if `submitted` does not match. // @relation(roots.web-session, scope=function) +/// The author signature an attributed mutation carries +/// (`receive.attributed-author`): the session's signed-in member, stamped +/// with the current time, or `None` when the session holds no member -- +/// every `Trusted` deployment, and a hosted request that somehow reached a +/// mutation anonymously (the auth middleware refuses those first). The +/// synthetic email domain is reserved (RFC 2606): a member record carries +/// no email of its own. +// @relation(receive.attributed-author, scope=function) +pub(crate) fn member_author(session: &Session) -> Option<gix::actor::Signature> { + let member = session.member.as_ref()?; + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .try_into() + .unwrap_or_default(); + Some(gix::actor::Signature { + name: member.username.clone().into(), + email: format!("{}@members.invalid", member.username).into(), + time: gix::date::Time { seconds, offset: 0 }, + }) +} + pub(crate) fn require_csrf(session: &Session, submitted: &str) -> Result<()> { if submitted == session.csrf { Ok(())
crates/cli/ents-web/src/pages/toolchains.rs @@ -146,7 +146,7 @@ state.events.as_ref(), name, &recipe, - &crate::receive_identity!(identity), + &crate::receive_identity!(identity, crate::pages::member_author(&session)), state.mode, ) .map_err(|source| match source {
crates/cli/ents-web/src/auth.rs @@ -1,0 +1,340 @@ +//! Hosted web sign-in (`roots.web-signin`): prove control of an enrolled, +//! active member key by signing a server-issued one-time challenge. +//! +//! The protocol is the pre-redo forge's key-proof sign-in with the paste +//! step replaced by `git ents login`: the `/login` page mints a short code +//! bound to the browser's own session, the CLI fetches the full challenge, +//! signs it under [`LOGIN_NAMESPACE`] — deliberately distinct from git's +//! `git` commit namespace, so a sign-in signature can never double as a +//! push signature or vice versa — and posts the signature back. The +//! server verifies it in pure Rust against the member's stored OpenSSH +//! public key, the same `ssh_key` technique `ents_gate::signature` uses +//! for commit signatures. +//! +//! Replay is bound off at every joint: the signed payload names the +//! serving host, the code, and the nonce ([`challenge_payload`]), so a +//! signature minted for one deployment or one browser session verifies +//! nowhere else; a challenge is single-use ([`ChallengeStore::take`]) and +//! expires after [`CHALLENGE_TTL`], measured on the monotonic clock. +// @relation(roots.web-signin, scope=file) + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use ents_model::MemberState; +use gix_object::Find; +use ssh_key::{PublicKey, SshSig}; + +use crate::state::AppState; + +/// The SSHSIG namespace a sign-in signature is made under — never `git`, +/// so a login signature and a push signature are unexchangeable +/// (`roots.web-signin`). +pub const LOGIN_NAMESPACE: &str = "git-ents-login"; + +/// How long an issued sign-in challenge stays valid. Measured with +/// [`Instant`], so a wall-clock step never extends or shortens a +/// challenge's life (a suspended machine's paused monotonic clock can +/// honor one slightly longer than this in wall time — accepted). +pub const CHALLENGE_TTL: Duration = Duration::from_secs(600); + +/// The exact bytes both sides sign and verify: version-tagged, binding +/// the serving host, the browser code, and the one-time nonce. The CLI +/// MUST rebuild this locally from the host the member addressed rather +/// than signing server-supplied bytes (`roots.web-signin`). +#[must_use] +pub fn challenge_payload(host: &str, code: &str, nonce: &str) -> String { + format!("git-ents-login-v1\nhost={host}\ncode={code}\nnonce={nonce}\n") +} + +/// Normalize a user-facing code: the `/login` page displays `XXXX-XXXX` +/// and a human may retype it in either case, with or without the dash. +#[must_use] +pub fn normalize_code(code: &str) -> String { + code.chars() + .filter(|c| *c != '-') + .map(|c| c.to_ascii_uppercase()) + .collect() +} + +/// One outstanding sign-in challenge: which browser session it will +/// authenticate, the nonce the signature must cover, and when it was +/// issued. +#[derive(Debug, Clone)] +pub struct Challenge { + /// The session id the `/login` page bound this challenge to — the + /// only session [`ChallengeStore::take`]'s caller may authenticate. + pub session_id: String, + /// The one-time nonce bound into [`challenge_payload`]. + pub nonce: String, + /// When this challenge was issued, for [`CHALLENGE_TTL`]. + issued: Instant, +} + +/// Outstanding sign-in challenges, keyed by their user-facing code — +/// memory-only, like [`crate::session::SessionStore`], and pruned of +/// expired entries on every issue. +#[derive(Default)] +pub struct ChallengeStore { + table: Mutex<HashMap<String, Challenge>>, +} + +impl ChallengeStore { + /// Mint a fresh challenge bound to `session_id`, returning its + /// `(code, nonce)`. Re-issuing for the same session replaces that + /// session's outstanding challenge, so one browser holds at most one + /// live code. + #[must_use] + pub fn issue(&self, session_id: &str) -> (String, String) { + let code = random_code(); + let nonce = random_nonce(); + let challenge = Challenge { + session_id: session_id.to_owned(), + nonce: nonce.clone(), + issued: Instant::now(), + }; + let mut table = self.lock(); + let now = Instant::now(); + table.retain(|_code, held| { + now.duration_since(held.issued) < CHALLENGE_TTL && held.session_id != session_id + }); + table.insert(code.clone(), challenge); + (code, nonce) + } + + /// Read `code`'s live challenge without consuming it — the CLI's + /// initial fetch. Expired entries read as absent. + #[must_use] + pub fn peek(&self, code: &str) -> Option<Challenge> { + let code = normalize_code(code); + let table = self.lock(); + let held = table.get(&code)?; + (Instant::now().duration_since(held.issued) < CHALLENGE_TTL).then(|| held.clone()) + } + + /// Consume `code`, returning its challenge iff it was live and + /// unexpired — single-use by construction: a second take of the same + /// code is `None` whatever the first returned. + #[must_use] + pub fn take(&self, code: &str) -> Option<Challenge> { + let code = normalize_code(code); + let held = self.lock().remove(&code)?; + (Instant::now().duration_since(held.issued) < CHALLENGE_TTL).then_some(held) + } + + /// Age `code`'s challenge by `by`, as if it had been issued that much + /// earlier — tests cannot construct an [`Instant`] in the past any + /// other way. + #[cfg(test)] + fn backdate(&self, code: &str, by: Duration) { + if let Some(held) = self.lock().get_mut(&normalize_code(code)) + && let Some(issued) = held.issued.checked_sub(by) + { + held.issued = issued; + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Challenge>> { + self.table + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +/// Whether `signature` (an armored SSHSIG) over `payload` verifies +/// against `public_key` (an OpenSSH single-line key, as stored on an +/// [`ents_model::Member`]) under [`LOGIN_NAMESPACE`]. Any malformed key, +/// malformed signature, wrong namespace, or failed cryptographic check is +/// `false` — mirrors `ents_gate::signature`'s identical posture for +/// commit signatures. +#[must_use] +pub fn verify_login(public_key: &str, payload: &[u8], signature: &str) -> bool { + let Ok(key) = PublicKey::from_openssh(public_key) else { + return false; + }; + let Ok(sig) = SshSig::from_pem(signature) else { + return false; + }; + key.verify(LOGIN_NAMESPACE, payload, &sig).is_ok() +} + +/// Resolve `pubkey` to the enrolled, *active* member that stores it, if +/// any — the sign-in completion's membership check, and the auth +/// middleware's per-mutation re-check (`roots.web-signin`: a session +/// whose member is no longer enrolled and active is refused at the time +/// of the mutation, not only at sign-in). +/// +/// # Errors +/// +/// Propagates a ref-store read failure; an individual member ref this +/// build cannot decode is skipped, exactly as the members page skips it. +pub(crate) fn active_member_by_key<O: Find>( + state: &AppState<O>, + pubkey: &str, +) -> crate::Result<Option<String>> { + for (username, member) in crate::pages::members::read_all(state)? { + if let Ok(member) = member + && member.key == pubkey + && member.state == MemberState::Active + { + return Ok(Some(username)); + } + } + Ok(None) +} + +/// A user-facing code: eight characters of Crockford-style base32 (no +/// `I`, `L`, `O`, `U`, no lowercase), ~40 bits — plenty for a single-use +/// secret that lives ten minutes, and short enough to retype. +fn random_code() -> String { + const ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + let mut bytes = [0u8; 8]; + fill_random(&mut bytes); + bytes + .iter() + .map(|b| { + // The low five bits index exactly the 32-entry alphabet, so + // the draw is uniform and the index cannot overrun. + let index = usize::from(*b & 0x1f); + #[expect( + clippy::indexing_slicing, + reason = "a five-bit index cannot overrun the 32-entry alphabet" + )] + char::from(ALPHABET[index]) + }) + .collect() +} + +/// A nonce: 32 hex characters from 16 random bytes, the same shape as a +/// session id. +fn random_nonce() -> String { + let mut bytes = [0u8; 16]; + fill_random(&mut bytes); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn fill_random(bytes: &mut [u8]) { + #[expect( + clippy::expect_used, + reason = "getrandom only fails when the platform has no randomness source at all, which \ + every target this crate ships to provides" + )] + getrandom::fill(bytes).expect("platform randomness source is available"); +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used, reason = "unit test")] + + use rstest::rstest; + use ssh_key::private::{Ed25519Keypair, KeypairData}; + use ssh_key::{HashAlg, LineEnding, PrivateKey}; + + use super::*; + + fn keypair(seed: u8) -> PrivateKey { + let pair = Ed25519Keypair::from_seed(&[seed; 32]); + PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed") + } + + fn sign_in_namespace(key: &PrivateKey, namespace: &str, payload: &[u8]) -> String { + key.sign(namespace, HashAlg::Sha512, payload) + .expect("signing is infallible for a loaded key") + .to_pem(LineEnding::LF) + .expect("an SSHSIG always renders as PEM") + } + + fn public_line(key: &PrivateKey) -> String { + key.public_key().to_openssh().expect("renders") + } + + #[rstest] + // @relation(roots.web-signin, scope=function, role=Verifies) + fn payload_is_the_exact_versioned_bytes_both_sides_build() { + assert_eq!( + challenge_payload("git.ents.cloud", "ABCD2345", "0f" /* nonce */), + "git-ents-login-v1\nhost=git.ents.cloud\ncode=ABCD2345\nnonce=0f\n" + ); + } + + #[rstest] + // @relation(roots.web-signin, scope=function, role=Verifies) + fn a_challenge_is_single_use() { + let store = ChallengeStore::default(); + let (code, nonce) = store.issue("session-1"); + let taken = store.take(&code).expect("first take"); + assert_eq!(taken.session_id, "session-1"); + assert_eq!(taken.nonce, nonce); + assert!(store.take(&code).is_none(), "second take must fail"); + } + + #[rstest] + // @relation(roots.web-signin, scope=function, role=Verifies) + fn peek_reads_without_consuming_and_codes_normalize() { + let store = ChallengeStore::default(); + let (code, nonce) = store.issue("session-1"); + let (head, tail) = code.split_at(4); + let dashed = format!("{head}-{}", tail.to_lowercase()); + assert_eq!(store.peek(&dashed).expect("live").nonce, nonce); + assert!(store.take(&dashed).is_some(), "peek must not consume"); + } + + #[rstest] + // @relation(roots.web-signin, scope=function, role=Verifies) + fn an_expired_challenge_reads_as_absent() { + let store = ChallengeStore::default(); + let (code, _nonce) = store.issue("session-1"); + store.backdate(&code, CHALLENGE_TTL.saturating_add(Duration::from_secs(1))); + assert!(store.peek(&code).is_none()); + assert!(store.take(&code).is_none()); + } + + #[rstest] + // @relation(roots.web-signin, scope=function, role=Verifies) + fn reissuing_replaces_the_sessions_outstanding_challenge() { + let store = ChallengeStore::default(); + let (first, _) = store.issue("session-1"); + let (second, _) = store.issue("session-1"); + assert!(store.take(&first).is_none(), "replaced by the re-issue"); + assert!(store.take(&second).is_some()); + } + + #[rstest] + // @relation(roots.web-signin, scope=function, role=Verifies) + fn verify_accepts_only_the_login_namespace_over_the_exact_payload() { + let key = keypair(1); + let payload = challenge_payload("git.ents.cloud", "ABCD2345", "0011"); + let good = sign_in_namespace(&key, LOGIN_NAMESPACE, payload.as_bytes()); + assert!(verify_login(&public_line(&key), payload.as_bytes(), &good)); + + // A real signature under git's own commit namespace over the very + // same bytes must not double as a login (`roots.web-signin`). + let push_shaped = sign_in_namespace(&key, "git", payload.as_bytes()); + assert!(!verify_login( + &public_line(&key), + payload.as_bytes(), + &push_shaped + )); + + // Any drift in the signed payload — another host, another code, + // another nonce — fails verification. + let other = challenge_payload("evil.example", "ABCD2345", "0011"); + assert!(!verify_login(&public_line(&key), other.as_bytes(), &good)); + + // Another member's key does not verify this member's signature. + assert!(!verify_login( + &public_line(&keypair(2)), + payload.as_bytes(), + &good + )); + + // Garbage inputs are false, never a panic. + assert!(!verify_login("not a key", payload.as_bytes(), &good)); + assert!(!verify_login( + &public_line(&key), + payload.as_bytes(), + "not a signature" + )); + } +}
crates/cli/ents-web/src/pages/login.rs @@ -1,0 +1,257 @@ +//! `GET /login`, `GET`/`POST /login/challenge/{code}`, `POST /logout`: +//! the hosted sign-in surface (`roots.web-signin`), mounted only when the +//! composition root injected [`AccessPolicy::SignInRequired`] — a local +//! root has no sign-in surface at all, so under +//! [`AccessPolicy::Trusted`] these routes do not exist and `/login` is a +//! plain 404. +//! +//! The flow inverts a device-code login (`gh auth login`'s shape) because +//! here the *CLI* holds the credential: the browser's `/login` page mints +//! a short one-time code bound to its own session and displays the +//! `git ents login` command to run; the CLI fetches the full challenge +//! (`GET`, non-consuming), rebuilds the payload locally from the host the +//! member addressed, signs it under [`crate::auth::LOGIN_NAMESPACE`], and +//! posts the signature back (`POST`, consuming). The page refreshes +//! itself until the session reads as signed in. +//! +//! The CLI endpoints speak `key=value` text lines, not HTML forms' +//! escaping rules and not JSON — the same trivially-parseable shape the +//! challenge payload itself uses, so neither side grows a parser. + +use std::sync::Arc; + +use axum::Form; +use axum::extract::{Path, State}; +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Redirect, Response}; +use gix_object::{Find, Write}; +use maud::html; +use serde::Deserialize; + +use crate::auth; +use crate::session::{Session, SessionId, SessionMember}; +use crate::state::{AccessPolicy, AppState, Realm}; + +/// The realm, or a 404 — these handlers are only ever routed under +/// [`AccessPolicy::SignInRequired`], so a miss here is a wiring error, +/// answered exactly as an unmounted route would be. +fn realm<O>(state: &AppState<O>) -> Result<&Realm, Box<Response>> { + match &state.access { + AccessPolicy::SignInRequired(realm) => Ok(realm), + AccessPolicy::Trusted => Err(Box::new(StatusCode::NOT_FOUND.into_response())), + } +} + +/// `GET /login`: the sign-in page. Signed out, it mints a challenge +/// bound to this browser's session and shows the one command to run; +/// the page refreshes itself every few seconds until the session reads +/// as signed in — no script needed, and a challenge consumed by the CLI +/// re-issues on the next refresh only if sign-in did not complete. +// @relation(roots.web-signin, scope=function) +pub async fn show<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + axum::Extension(SessionId(session_id)): axum::Extension<SessionId>, +) -> Response +where + O: Find + Write + Send + 'static, +{ + let realm = match realm(&state) { + Ok(realm) => realm, + Err(response) => return *response, + }; + + let body = match &session.member { + Some(member) => html! { + div.readable { + p { "Signed in as " strong { (member.username) } "." } + p.muted { + "Edits you make here are authored as this member and " + "signed by the server's own key -- history reads " + em { (member.username) " via the web" } "." + } + form method="post" action="/logout" { + input type="hidden" name="csrf" value=(session.csrf); + button.btn type="submit" { "Sign out" } + } + } + }, + None => { + let (code, _nonce) = realm.challenges.issue(&session_id); + // The code is eight ASCII base32 characters by construction; + // split_at is byte-indexed and cannot land inside a char. + let (head, tail) = code.split_at(4); + let display = format!("{head}-{tail}"); + html! { + div.readable { + p { + "Prove control of an enrolled member key. Run this " + "on your own machine -- the key never leaves it:" + } + pre.signin-cmd { + "git ents login https://" (realm.host) " " (display) + } + p.muted { + "This page refreshes on its own; the code is " + "single-use and expires in ten minutes." + } + } + } + } + }; + + let markup = super::layout( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + super::Tab::Account, + "Sign in", + body, + ); + if session.member.is_some() { + markup.into_response() + } else { + // A refresh header, not a script: the page re-renders as signed + // in on the first refresh after the CLI completes the challenge. + ([(header::HeaderName::from_static("refresh"), "3")], markup).into_response() + } +} + +/// `GET /login/challenge/{code}`: the CLI's fetch — the challenge's +/// bound facts as `key=value` lines, without consuming it. The CLI MUST +/// rebuild the payload from the host it addressed rather than trusting +/// these lines (`roots.web-signin`); they exist so it can carry the +/// nonce and confirm the host matches. +// @relation(roots.web-signin, scope=function) +pub async fn challenge<O>( + State(state): State<Arc<AppState<O>>>, + Path(code): Path<String>, +) -> Response +where + O: Find + Write + Send + 'static, +{ + let realm = match realm(&state) { + Ok(realm) => realm, + Err(response) => return *response, + }; + match realm.challenges.peek(&code) { + Some(challenge) => ( + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + format!( + "host={}\ncode={}\nnonce={}\n", + realm.host, + auth::normalize_code(&code), + challenge.nonce + ), + ) + .into_response(), + None => ( + StatusCode::NOT_FOUND, + "unknown or expired code; reload the sign-in page for a fresh one\n", + ) + .into_response(), + } +} + +/// What the CLI posts back to complete a sign-in: the member's public +/// key line and the armored SSHSIG over the locally-rebuilt payload. +#[derive(Deserialize)] +pub struct Completion { + /// The member's OpenSSH public key line. + pub public_key: String, + /// The armored SSHSIG PEM over [`crate::auth::challenge_payload`]. + pub signature: String, +} + +/// `POST /login/challenge/{code}`: consume the challenge, verify the +/// signature over the server's own reconstruction of the payload, check +/// the key names an enrolled *active* member, and mark the bound browser +/// session signed in. Deliberately outside the session/CSRF discipline: +/// this request carries no cookie and authenticates by signature alone +/// (`roots.web-signin`). +// @relation(roots.web-signin, scope=function) +pub async fn complete<O>( + State(state): State<Arc<AppState<O>>>, + Path(code): Path<String>, + Form(completion): Form<Completion>, +) -> Response +where + O: Find + Write + Send + 'static, +{ + let realm = match realm(&state) { + Ok(realm) => realm, + Err(response) => return *response, + }; + let Some(challenge) = realm.challenges.take(&code) else { + return ( + StatusCode::NOT_FOUND, + "unknown or expired code; reload the sign-in page for a fresh one\n", + ) + .into_response(); + }; + + let code = auth::normalize_code(&code); + let payload = auth::challenge_payload(&realm.host, &code, &challenge.nonce); + let public_key = completion.public_key.trim(); + if !auth::verify_login(public_key, payload.as_bytes(), &completion.signature) { + return ( + StatusCode::UNAUTHORIZED, + "the signature did not verify against that key for this host and code\n", + ) + .into_response(); + } + let username = match auth::active_member_by_key(&state, public_key) { + Ok(Some(username)) => username, + Ok(None) => { + return ( + StatusCode::UNAUTHORIZED, + "that key is not an enrolled, active member of this repository\n", + ) + .into_response(); + } + Err(error) => return error.into_response(), + }; + + let member = SessionMember { + username: username.clone(), + key: public_key.to_owned(), + }; + if !state.sessions.authenticate(&challenge.session_id, member) { + return ( + StatusCode::GONE, + "the browser session that requested this code no longer exists; reload the sign-in \ + page\n", + ) + .into_response(); + } + ( + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + format!("member={username}\n"), + ) + .into_response() +} + +/// What the logout form posts: the session's CSRF token. +#[derive(Deserialize)] +pub struct LogoutForm { + /// The per-session CSRF token (`roots.web-session`). + pub csrf: String, +} + +/// `POST /logout`: drop the session's signed-in member, keeping the +/// session (and its CSRF token) itself. CSRF-checked like every other +/// browser mutation — a cross-site form must not be able to sign the +/// user out. +// @relation(roots.web-signin, roots.web-session, scope=function) +pub async fn logout<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + axum::Extension(SessionId(session_id)): axum::Extension<SessionId>, + Form(form): Form<LogoutForm>, +) -> crate::Result<impl IntoResponse> +where + O: Find + Write + Send + 'static, +{ + super::require_csrf(&session, &form.csrf)?; + state.sessions.clear_member(&session_id); + Ok(Redirect::to("/")) +}