michal/tit

Browse tree · Show commit · Download archive

Diff

b48e40edd69a46e73d7742dd

.github/workflows/ci.yml

Mode 100644100644; object 38478417152b3816cc016a90

@@ -30,7 +30,7 @@
           persist-credentials: false
       - run: cargo --version
       - run: cargo test --locked --all-targets --all-features
-      - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle
+      - run: cargo test --locked --release --test auth --test ssh --test account_lifecycle --test web_session
       - run: cargo test --locked --release --test git_repository --test git_http --test git_ssh --test public_routes --test serve
       - run: cargo test --locked --release --test git_reads measures_bounded_search_without_an_index -- --ignored --nocapture
       - run: cargo test --locked --release --test sqlite_workload -- --ignored --nocapture

Cargo.lock

Mode 100644100644; object 033fe5aa70e8f4e2775996e8

@@ -180,6 +180,7 @@
  "matchit",
  "memchr",
  "mime",
+ "multer",
  "percent-encoding",
  "pin-project-lite",
  "serde_core",
@@ -2428,6 +2429,23 @@
 ]
 
 [[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin",
+ "version_check",
+]
+
+[[package]]
 name = "new_debug_unreachable"
 version = "1.0.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3323,6 +3341,12 @@
  "libc",
  "windows-sys 0.61.2",
 ]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
 
 [[package]]
 name = "spki"

Cargo.toml

Mode 100644100644; object b63fcde59c22962fc54987a6

@@ -13,7 +13,7 @@
 [dependencies]
 ammonia = "4.1.4"
 askama = { version = "0.16", default-features = false, features = ["derive", "std"] }
-axum = { version = "0.8", default-features = false, features = ["http1", "original-uri", "query", "tokio"] }
+axum = { version = "0.8", default-features = false, features = ["http1", "multipart", "original-uri", "query", "tokio"] }
 clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
 gix = { version = "0.84", default-features = false, features = ["blame", "parallel", "sha1", "sha256"] }
 gix-pack = { version = "0.71", default-features = false, features = ["generate", "sha1", "sha256", "streaming-input"] }

README.md

Mode 100644100644; object 70b93ea7e44639797a0677f5

@@ -159,3 +159,17 @@
 suspension, and the owner-only control socket. Read the
 [account lifecycle architectural decision record](docs/adr/0010-account-lifecycle.md)
 for the credential and failure behavior.
+
+## Milestone 3.2 gate
+
+Install stock OpenSSH. Then, run the Web login gate:
+
+```text
+./scripts/check-m3-2
+```
+
+Open `/login`, create a challenge, and sign its exact content with the
+`tit-auth` SSHSIG namespace. The Web UI accepts the pasted SSHSIG envelope and
+creates an opaque session. Read the
+[Web login architectural decision record](docs/adr/0011-web-login-sessions.md)
+for the session and CSRF behavior.

docs/adr/0011-web-login-sessions.md

Mode 100644; object e66a5b838e68

@@ -1,0 +1,65 @@
+# Architectural decision record 0011: Web login sessions
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+The Web UI must use the same SSH key identity as the SSH server. A login
+challenge must not be reusable. A database copy must not contain a session
+token or a CSRF token that a user can use directly.
+
+## Decision
+
+The login form requires a username and an active SSH public key. The server
+creates a five-minute `tit-auth-v1` challenge and stores only the SHA-256 hash
+of its random nonce. The user signs the exact challenge with the `tit-auth`
+SSHSIG namespace and pastes the complete SSHSIG envelope into the Web UI.
+
+The server verifies the origin, username, key fingerprint, time, namespace,
+signature algorithm, and signature. The user can paste the envelope or upload
+the signature file. In one SQLite transaction, the server consumes the nonce
+and creates a seven-day session. The response stores an opaque session
+token in an `HttpOnly` cookie and a CSRF token in a second cookie. Both cookies
+use `SameSite=Strict`. HTTPS responses also use `Secure`. SQLite stores only
+SHA-256 hashes of both values.
+
+Challenge creation also sets a five-minute, `HttpOnly` login CSRF cookie. The
+verification form must submit the matching value, and the nonce row stores only
+its hash. Thus, a different site cannot complete a login in the user's browser.
+
+Each state-changing account form must compare its submitted CSRF token with the
+CSRF cookie and the stored hash. The initial account page supplies a logout
+form that uses this rule. Logout ends all sessions for the account. Recovery,
+key addition, key revocation, suspension, and restoration also end all account
+sessions in the same transaction as the privilege change.
+
+## Failure and threat cases
+
+Login and signature forms have fixed body limits. Axum and `multer` parse the
+uploaded form within the 64-KiB limit. Challenge and SSHSIG parsing have the
+authentication limits from architectural decision record 0002. A bad
+identity, signature, expired challenge, and consumed challenge use a common Web
+UI error. The response does not disclose which input failed.
+
+The server keeps a maximum of 1,024 active login nonces and removes consumed or
+expired nonces before it creates one. Cookie parsing rejects duplicate, empty,
+and oversized values. A session is not
+valid after its expiry, account suspension, logout, or a privilege change. A
+CSRF failure does not end the session because an external request must not be
+able to log out a user.
+
+## Evidence
+
+The session test signs with stock `ssh-keygen`, restarts the login service
+between issue and verification, rejects challenge replay and a bad CSRF token,
+checks the stored hashes, invalidates a session after key addition, and tests
+the function that ends all sessions. The executable test completes login and
+logout through HTTP. It also confirms CSRF rejection and session invalidation.
+
+## Consequences
+
+The user must supply the public key during login so the server can select the
+correct key without an account-enumeration page. The interface accepts a pasted
+SSHSIG envelope or an SSHSIG file.

scripts/check-m3-2

Mode 100755; object 61a9631093ed

@@ -1,0 +1,6 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test auth --test web_session
+cargo test --locked --release --test serve serves_an_imported_repository_through_http_and_ssh

src/account.rs

Mode 100644100644; object 1f2cbb147a5d10bf2111644e

@@ -96,7 +96,7 @@
 
     pub(crate) fn suspend(&self, username: &str, suspended: bool) -> Result<(), AccountError> {
         validate_username(username)?;
-        Store::open(&self.database)?.suspend_account(username, suspended)?;
+        Store::open(&self.database)?.suspend_account(username, suspended, now()?)?;
         Ok(())
     }
 

src/auth.rs

Mode 100644100644; object 569ee5f258d9797f5c74c10b

@@ -64,19 +64,8 @@
 
 impl LoginChallenges {
     pub(crate) fn new(public_url: &Url) -> Result<Self, AuthError> {
-        if !matches!(public_url.scheme(), "http" | "https")
-            || public_url.host().is_none()
-            || !public_url.username().is_empty()
-            || public_url.password().is_some()
-            || public_url.query().is_some()
-            || public_url.fragment().is_some()
-            || public_url.path() != "/"
-        {
-            return Err(AuthError::InvalidOrigin);
-        }
-
         Ok(Self {
-            origin: public_url.origin().ascii_serialization(),
+            origin: login_origin(public_url)?,
             nonces: Mutex::new(HashMap::new()),
         })
     }
@@ -97,11 +86,13 @@
             .ok_or(AuthError::InvalidLifetime)?;
 
         let nonce = self.new_nonce(issued_at, expires_at)?;
-        Ok(format!(
-            "{CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={}\nusername={username}\nfingerprint={}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
-            self.origin,
-            key.fingerprint(),
-            encode_hex(&nonce)
+        Ok(format_login_challenge(
+            &self.origin,
+            username,
+            key,
+            &nonce,
+            issued_at,
+            expires_at,
         ))
     }
 
@@ -113,52 +104,25 @@
         expected_key: &SshPublicKey,
         now: u64,
     ) -> Result<VerifiedLogin, AuthError> {
-        if challenge.len() > MAX_CHALLENGE_BYTES {
-            return Err(AuthError::InputTooLarge("login challenge"));
-        }
-        if signature.len() > MAX_SIGNATURE_BYTES {
-            return Err(AuthError::InputTooLarge("SSHSIG envelope"));
-        }
-        validate_username(expected_username)?;
-
-        let fields = ChallengeFields::parse(challenge)?;
-        if fields.origin != self.origin {
-            return Err(AuthError::WrongOrigin);
-        }
-        if fields.username != expected_username {
-            return Err(AuthError::WrongUsername);
-        }
-        if fields.fingerprint != expected_key.fingerprint() {
-            return Err(AuthError::WrongKey);
-        }
-        if fields.expires_at <= fields.issued_at
-            || fields.expires_at - fields.issued_at > MAX_CHALLENGE_LIFETIME_SECONDS
-        {
-            return Err(AuthError::InvalidLifetime);
-        }
-        if now < fields.issued_at || now > fields.expires_at {
-            return Err(AuthError::ExpiredChallenge);
-        }
-
-        let sshsig = SshSig::from_pem(signature).map_err(AuthError::SignatureEnvelope)?;
-        validate_signature_algorithm(expected_key.public_key(), &sshsig)?;
-        expected_key
-            .public_key()
-            .verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
-            .map_err(AuthError::SignatureVerification)?;
-
-        let nonce_hash = hash_nonce(&fields.nonce);
+        let verified = verify_login_challenge(
+            &self.origin,
+            challenge,
+            signature,
+            expected_username,
+            expected_key,
+            now,
+        )?;
         let mut nonces = self.nonces.lock().map_err(|_| AuthError::NonceStore)?;
-        match nonces.get(&nonce_hash) {
-            Some(stored_expiry) if *stored_expiry == fields.expires_at => {
-                nonces.remove(&nonce_hash);
+        match nonces.get(&verified.nonce_hash) {
+            Some(stored_expiry) if *stored_expiry == verified.expires_at => {
+                nonces.remove(&verified.nonce_hash);
             }
             _ => return Err(AuthError::ConsumedChallenge),
         }
 
         Ok(VerifiedLogin {
-            username: fields.username.to_owned(),
-            fingerprint: fields.fingerprint.to_owned(),
+            username: verified.username,
+            fingerprint: verified.fingerprint,
         })
     }
 
@@ -180,6 +144,89 @@
             }
         }
     }
+}
+
+pub(crate) fn login_origin(public_url: &Url) -> Result<String, AuthError> {
+    if !matches!(public_url.scheme(), "http" | "https")
+        || public_url.host().is_none()
+        || !public_url.username().is_empty()
+        || public_url.password().is_some()
+        || public_url.query().is_some()
+        || public_url.fragment().is_some()
+        || public_url.path() != "/"
+    {
+        return Err(AuthError::InvalidOrigin);
+    }
+    Ok(public_url.origin().ascii_serialization())
+}
+
+pub(crate) fn format_login_challenge(
+    origin: &str,
+    username: &str,
+    key: &SshPublicKey,
+    nonce: &[u8; NONCE_BYTES],
+    issued_at: u64,
+    expires_at: u64,
+) -> String {
+    format!(
+        "{CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={origin}\nusername={username}\nfingerprint={}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
+        key.fingerprint(),
+        encode_hex(nonce)
+    )
+}
+
+pub(crate) fn verify_login_challenge(
+    origin: &str,
+    challenge: &str,
+    signature: &str,
+    expected_username: &str,
+    expected_key: &SshPublicKey,
+    now: u64,
+) -> Result<PersistentVerifiedLogin, AuthError> {
+    if challenge.len() > MAX_CHALLENGE_BYTES {
+        return Err(AuthError::InputTooLarge("login challenge"));
+    }
+    if signature.len() > MAX_SIGNATURE_BYTES {
+        return Err(AuthError::InputTooLarge("SSHSIG envelope"));
+    }
+    validate_username(expected_username)?;
+    let fields = ChallengeFields::parse(challenge)?;
+    if fields.origin != origin {
+        return Err(AuthError::WrongOrigin);
+    }
+    if fields.username != expected_username {
+        return Err(AuthError::WrongUsername);
+    }
+    if fields.fingerprint != expected_key.fingerprint() {
+        return Err(AuthError::WrongKey);
+    }
+    if fields.expires_at <= fields.issued_at
+        || fields.expires_at - fields.issued_at > MAX_CHALLENGE_LIFETIME_SECONDS
+    {
+        return Err(AuthError::InvalidLifetime);
+    }
+    if now < fields.issued_at || now > fields.expires_at {
+        return Err(AuthError::ExpiredChallenge);
+    }
+    let sshsig = SshSig::from_pem(signature).map_err(AuthError::SignatureEnvelope)?;
+    validate_signature_algorithm(expected_key.public_key(), &sshsig)?;
+    expected_key
+        .public_key()
+        .verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
+        .map_err(AuthError::SignatureVerification)?;
+    Ok(PersistentVerifiedLogin {
+        username: fields.username.to_owned(),
+        fingerprint: fields.fingerprint.to_owned(),
+        nonce_hash: hash_nonce(&fields.nonce),
+        expires_at: fields.expires_at,
+    })
+}
+
+pub(crate) struct PersistentVerifiedLogin {
+    pub(crate) username: String,
+    pub(crate) fingerprint: String,
+    pub(crate) nonce_hash: [u8; 32],
+    pub(crate) expires_at: u64,
 }
 
 #[derive(Debug, Eq, PartialEq)]

src/http/mod.rs

Mode 100644100644; object 86a77cb649a27671cc6a00d1

@@ -7,7 +7,9 @@
 use askama::Template;
 use axum::Router;
 use axum::body::{Body, Bytes, HttpBody};
-use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, RawQuery, Request, State};
+use axum::extract::{
+    DefaultBodyLimit, Extension, Multipart, OriginalUri, RawQuery, Request, State,
+};
 use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
 use axum::middleware::{self, Next};
 use axum::response::Response;
@@ -20,6 +22,7 @@
 use crate::account::{AccountError, AccountService};
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
+use crate::session::{SessionError, WebLoginService};
 use crate::store::StoreError;
 
 use self::public::PublicWeb;
@@ -28,6 +31,9 @@
 const MAX_LOCATION_QUERY_BYTES: usize = 512;
 const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
 const MAX_BLOCKING_WEB_JOBS: usize = 8;
+const SESSION_COOKIE: &str = "tit-session";
+const CSRF_COOKIE: &str = "tit-csrf";
+const LOGIN_CSRF_COOKIE: &str = "tit-login-csrf";
 
 #[derive(Clone)]
 struct WebState {
@@ -35,6 +41,8 @@
     accounts: Option<AccountService>,
     jobs: Arc<Semaphore>,
     key_reloader: Option<AccountKeyReloader>,
+    login: Option<WebLoginService>,
+    secure_cookies: bool,
 }
 
 type AccountKeyReloader = Arc<dyn Fn(&AccountService) -> Result<(), AccountError> + Send + Sync>;
@@ -61,6 +69,8 @@
                 accounts: None,
                 jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
                 key_reloader: None,
+                login: None,
+                secure_cookies: false,
             },
         )
         .await
@@ -87,7 +97,12 @@
         key_reloader: Option<AccountKeyReloader>,
     ) -> Result<Self, WebError> {
         let jobs = Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS));
-        let accounts = AccountService::new(config.instance_dir.join(crate::store::DATABASE_FILE));
+        let database = config.instance_dir.join(crate::store::DATABASE_FILE);
+        let accounts = AccountService::new(database.clone());
+        let public_url = url::Url::parse(&format!("{}/", config.http_clone_base))
+            .map_err(WebError::CanonicalUrl)?;
+        let secure_cookies = public_url.scheme() == "https";
+        let login = WebLoginService::new(database, &public_url)?;
         let public = PublicWeb::open(config, Arc::clone(&jobs))?;
         Self::start_with_state(
             address,
@@ -96,6 +111,8 @@
                 accounts: Some(accounts),
                 jobs,
                 key_reloader,
+                login: Some(login),
+                secure_cookies,
             },
         )
         .await
@@ -136,6 +153,8 @@
         accounts: None,
         jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
         key_reloader: None,
+        login: None,
+        secure_cookies: false,
     })
 }
 
@@ -154,6 +173,25 @@
             get(recovery_form)
                 .post(recover)
                 .layer(DefaultBodyLimit::max(20 * 1024)),
+        )
+        .route(
+            "/login",
+            get(login_form)
+                .post(login_challenge)
+                .layer(DefaultBodyLimit::max(32 * 1024)),
+        )
+        .route(
+            "/login/verify",
+            axum::routing::post(login_verify).layer(DefaultBodyLimit::max(64 * 1024)),
+        )
+        .route(
+            "/login/verify-file",
+            axum::routing::post(login_verify_file).layer(DefaultBodyLimit::max(64 * 1024)),
+        )
+        .route("/account", get(account_page))
+        .route(
+            "/logout",
+            axum::routing::post(logout).layer(DefaultBodyLimit::max(1024)),
         )
         .route("/assets/style.css", get(style))
         .merge(public::routes())
@@ -249,6 +287,387 @@
     })
     .await;
     account_result(result, &request_id.0, AccountFormKind::Recovery, &username)
+}
+
+async fn login_form(Extension(request_id): Extension<RequestId>) -> Response {
+    render(
+        StatusCode::OK,
+        &LoginTemplate {
+            request_id: &request_id.0,
+            username: "",
+            error: "",
+            has_error: false,
+        },
+    )
+}
+
+async fn login_challenge(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["username", "public-key"]) {
+        Ok(fields) => fields,
+        Err(()) => return login_error(&request_id.0, "", "The login request is not valid."),
+    };
+    let username = fields[0].clone();
+    let display_username = username.clone();
+    let public_key = fields[1].clone();
+    let secure = state.secure_cookies;
+    let result = login_job(state, move |login| login.issue(&username, &public_key)).await;
+    match result {
+        Ok(challenge) => {
+            let mut response = render(
+                StatusCode::OK,
+                &LoginChallengeTemplate {
+                    request_id: &request_id.0,
+                    username: &display_username,
+                    public_key: &challenge.public_key,
+                    challenge: &challenge.challenge,
+                    login_csrf: &challenge.login_csrf,
+                },
+            );
+            append_cookie(
+                response.headers_mut(),
+                LOGIN_CSRF_COOKIE,
+                &challenge.login_csrf,
+                true,
+                secure,
+                5 * 60,
+            );
+            response
+        }
+        Err(_) => login_error(
+            &request_id.0,
+            &display_username,
+            "The username or SSH public key is not valid.",
+        ),
+    }
+}
+
+async fn login_verify(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(
+        &headers,
+        &body,
+        &[
+            "username",
+            "public-key",
+            "challenge",
+            "signature",
+            "login-csrf",
+        ],
+    ) {
+        Ok(fields) => fields,
+        Err(()) => return login_error(&request_id.0, "", "The login response is not valid."),
+    };
+    let username = fields[0].clone();
+    let public_key = fields[1].clone();
+    let challenge = fields[2].clone();
+    let signature = fields[3].clone();
+    let login_csrf = fields[4].clone();
+    if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
+        return login_error(&request_id.0, &username, "The login response is not valid.");
+    }
+    complete_login(
+        state,
+        &request_id.0,
+        username,
+        public_key,
+        challenge,
+        signature,
+        login_csrf,
+    )
+    .await
+}
+
+async fn login_verify_file(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    mut multipart: Multipart,
+) -> Response {
+    let mut username = None;
+    let mut public_key = None;
+    let mut challenge = None;
+    let mut signature = None;
+    let mut login_csrf = None;
+    loop {
+        let field = match multipart.next_field().await {
+            Ok(Some(field)) => field,
+            Ok(None) => break,
+            Err(_) => return login_error(&request_id.0, "", "The login response is not valid."),
+        };
+        match field.name() {
+            Some("username") if username.is_none() => username = field.text().await.ok(),
+            Some("public-key") if public_key.is_none() => public_key = field.text().await.ok(),
+            Some("challenge") if challenge.is_none() => challenge = field.text().await.ok(),
+            Some("signature-file") if signature.is_none() => signature = field.text().await.ok(),
+            Some("login-csrf") if login_csrf.is_none() => login_csrf = field.text().await.ok(),
+            _ => return login_error(&request_id.0, "", "The login response is not valid."),
+        }
+    }
+    let (Some(username), Some(public_key), Some(challenge), Some(signature), Some(login_csrf)) =
+        (username, public_key, challenge, signature, login_csrf)
+    else {
+        return login_error(&request_id.0, "", "The login response is not valid.");
+    };
+    if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
+        return login_error(&request_id.0, &username, "The login response is not valid.");
+    }
+    complete_login(
+        state,
+        &request_id.0,
+        username,
+        public_key,
+        challenge,
+        signature,
+        login_csrf,
+    )
+    .await
+}
+
+async fn complete_login(
+    state: WebState,
+    request_id: &str,
+    username: String,
+    public_key: String,
+    challenge: String,
+    signature: String,
+    login_csrf: String,
+) -> Response {
+    let display_username = username.clone();
+    let secure = state.secure_cookies;
+    let result = login_job(state, move |login| {
+        login.verify(&username, &public_key, &challenge, &signature, &login_csrf)
+    })
+    .await;
+    match result {
+        Ok(session) => {
+            let mut response = Response::builder()
+                .status(StatusCode::SEE_OTHER)
+                .header(header::LOCATION, "/account")
+                .header(header::CACHE_CONTROL, "no-store")
+                .body(Body::empty())
+                .expect("the login redirect is valid");
+            append_cookie(
+                response.headers_mut(),
+                SESSION_COOKIE,
+                &session.token,
+                true,
+                secure,
+                SESSION_COOKIE_MAX_AGE,
+            );
+            append_cookie(
+                response.headers_mut(),
+                LOGIN_CSRF_COOKIE,
+                "",
+                true,
+                secure,
+                0,
+            );
+            append_cookie(
+                response.headers_mut(),
+                CSRF_COOKIE,
+                &session.csrf,
+                false,
+                secure,
+                SESSION_COOKIE_MAX_AGE,
+            );
+            response
+        }
+        Err(_) => login_error(
+            request_id,
+            &display_username,
+            "The signature is not valid or the challenge has expired.",
+        ),
+    }
+}
+
+const SESSION_COOKIE_MAX_AGE: i64 = 7 * 24 * 60 * 60;
+
+async fn account_page(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+) -> Response {
+    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+        return login_redirect(false);
+    };
+    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+        return login_redirect(true);
+    };
+    let csrf_for_auth = csrf.clone();
+    match login_job(state, move |login| {
+        login.authenticate(&session_token, Some(&csrf_for_auth))
+    })
+    .await
+    {
+        Ok(session) => render(
+            StatusCode::OK,
+            &AccountTemplate {
+                request_id: &request_id.0,
+                username: &session.username,
+                administrator: session.is_administrator,
+                csrf: &csrf,
+            },
+        ),
+        Err(_) => login_redirect(true),
+    }
+}
+
+async fn logout(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["csrf"]) {
+        Ok(fields) => fields,
+        Err(()) => {
+            return render_error(
+                StatusCode::FORBIDDEN,
+                &request_id.0,
+                "Forbidden",
+                "The request is not authorized.",
+            );
+        }
+    };
+    let Some(session_token) = cookie(&headers, SESSION_COOKIE) else {
+        return login_redirect(true);
+    };
+    let Some(csrf) = cookie(&headers, CSRF_COOKIE) else {
+        return login_redirect(true);
+    };
+    if fields[0] != csrf {
+        return render_error(
+            StatusCode::FORBIDDEN,
+            &request_id.0,
+            "Forbidden",
+            "The request is not authorized.",
+        );
+    }
+    let result = login_job(state, move |login| {
+        let session = login.authenticate(&session_token, Some(&csrf))?;
+        login.end_all(&session.username)
+    })
+    .await;
+    if result.is_err() {
+        return login_redirect(true);
+    }
+    login_redirect(true)
+}
+
+async fn login_job<T: Send + 'static>(
+    state: WebState,
+    operation: impl FnOnce(WebLoginService) -> Result<T, SessionError> + Send + 'static,
+) -> Result<T, SessionError> {
+    let login = state.login.ok_or(SessionError::Unavailable)?;
+    let permit = state
+        .jobs
+        .acquire_owned()
+        .await
+        .map_err(|_| SessionError::Unavailable)?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        operation(login)
+    })
+    .await
+    .map_err(|_| SessionError::Unavailable)?
+}
+
+fn parse_named_form(
+    headers: &HeaderMap,
+    body: &[u8],
+    expected: &[&str],
+) -> Result<Vec<String>, ()> {
+    if headers
+        .get(header::CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+        .and_then(|value| value.split(';').next())
+        != Some("application/x-www-form-urlencoded")
+        || !valid_percent_encoding(body)
+    {
+        return Err(());
+    }
+    let mut values = vec![None; expected.len()];
+    for (name, value) in url::form_urlencoded::parse(body) {
+        let Some(index) = expected.iter().position(|candidate| *candidate == name) else {
+            return Err(());
+        };
+        if values[index].is_some() {
+            return Err(());
+        }
+        values[index] = Some(value.into_owned());
+    }
+    values.into_iter().collect::<Option<Vec<_>>>().ok_or(())
+}
+
+fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
+    let value = headers.get(header::COOKIE)?.to_str().ok()?;
+    let mut found = None;
+    for pair in value.split(';') {
+        let (candidate, value) = pair.trim().split_once('=')?;
+        if candidate == name {
+            if found.is_some() || value.is_empty() || value.len() > 128 {
+                return None;
+            }
+            found = Some(value.to_owned());
+        }
+    }
+    found
+}
+
+fn append_cookie(
+    headers: &mut HeaderMap,
+    name: &str,
+    value: &str,
+    http_only: bool,
+    secure: bool,
+    max_age: i64,
+) {
+    let mut cookie = format!("{name}={value}; Path=/; SameSite=Strict; Max-Age={max_age}");
+    if http_only {
+        cookie.push_str("; HttpOnly");
+    }
+    if secure {
+        cookie.push_str("; Secure");
+    }
+    headers.append(
+        header::SET_COOKIE,
+        HeaderValue::from_str(&cookie).expect("the session cookie is a header value"),
+    );
+}
+
+fn login_redirect(clear: bool) -> Response {
+    let mut response = Response::builder()
+        .status(StatusCode::SEE_OTHER)
+        .header(header::LOCATION, "/login")
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(Body::empty())
+        .expect("the login redirect is valid");
+    if clear {
+        append_cookie(response.headers_mut(), SESSION_COOKIE, "", true, false, 0);
+        append_cookie(response.headers_mut(), CSRF_COOKIE, "", false, false, 0);
+    }
+    response
+}
+
+fn login_error(request_id: &str, username: &str, error: &str) -> Response {
+    render(
+        StatusCode::BAD_REQUEST,
+        &LoginTemplate {
+            request_id,
+            username,
+            error,
+            has_error: true,
+        },
+    )
 }
 
 async fn account_job<T: Send + 'static>(
@@ -424,7 +843,8 @@
         "This page does not accept the request method.",
     );
     let allow = match uri.path() {
-        "/signup" | "/recover" => "GET, HEAD, POST",
+        "/signup" | "/recover" | "/login" => "GET, HEAD, POST",
+        "/login/verify" | "/login/verify-file" | "/logout" => "POST",
         path if path.ends_with("/git-upload-pack") => "POST",
         _ => "GET, HEAD",
     };
@@ -617,12 +1037,44 @@
     recovery: &'a str,
 }
 
+#[derive(Template)]
+#[template(path = "login.html")]
+struct LoginTemplate<'a> {
+    request_id: &'a str,
+    username: &'a str,
+    error: &'a str,
+    has_error: bool,
+}
+
+#[derive(Template)]
+#[template(path = "login-challenge.html")]
+struct LoginChallengeTemplate<'a> {
+    request_id: &'a str,
+    username: &'a str,
+    public_key: &'a str,
+    challenge: &'a str,
+    login_csrf: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "account-page.html")]
+struct AccountTemplate<'a> {
+    request_id: &'a str,
+    username: &'a str,
+    administrator: bool,
+    csrf: &'a str,
+}
+
 #[derive(Debug, Error)]
 pub(crate) enum WebError {
     #[error("HTTP listener error: {0}")]
     Io(#[from] std::io::Error),
     #[error("public Web configuration error: {0}")]
     Public(#[from] public::PublicWebError),
+    #[error("canonical URL error: {0}")]
+    CanonicalUrl(url::ParseError),
+    #[error(transparent)]
+    Session(#[from] SessionError),
     #[error("HTTP server task failed")]
     Join,
 }

src/main.rs

Mode 100644100644; object 0906c51d68c5f51455176375

@@ -18,6 +18,7 @@
 mod instance;
 mod markdown;
 mod serve;
+mod session;
 #[allow(dead_code, reason = "the server uses only part of the shared SSH API")]
 mod ssh;
 mod store;

src/session.rs

Mode 100644; object 6fd36f88f62b

@@ -1,0 +1,194 @@
+use std::path::PathBuf;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rand::TryRng;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+use url::Url;
+
+use crate::auth::{
+    AuthError, SshPublicKey, format_login_challenge, login_origin, verify_login_challenge,
+};
+use crate::store::{NewLoginNonce, NewWebSession, Store, StoreError, WebSessionRecord};
+
+const CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
+const SESSION_LIFETIME_SECONDS: i64 = 7 * 24 * 60 * 60;
+const SECRET_BYTES: usize = 32;
+
+#[derive(Clone)]
+pub(crate) struct WebLoginService {
+    database: PathBuf,
+    origin: String,
+}
+
+impl WebLoginService {
+    pub(crate) fn new(database: PathBuf, public_url: &Url) -> Result<Self, SessionError> {
+        Ok(Self {
+            database,
+            origin: login_origin(public_url)?,
+        })
+    }
+
+    pub(crate) fn issue(
+        &self,
+        username: &str,
+        public_key: &str,
+    ) -> Result<IssuedChallenge, SessionError> {
+        let key = SshPublicKey::parse(public_key)?;
+        let created_at = now()?;
+        let issued_at = u64::try_from(created_at).map_err(|_| SessionError::Clock)?;
+        let expires_at = issued_at
+            .checked_add(CHALLENGE_LIFETIME_SECONDS)
+            .ok_or(SessionError::Clock)?;
+        let nonce = random_bytes()?;
+        let login_csrf = encode_hex(&random_bytes()?);
+        Store::open(&self.database)?.create_login_nonce(&NewLoginNonce {
+            nonce_hash: &hash(&nonce),
+            csrf_hash: &hash(login_csrf.as_bytes()),
+            username,
+            fingerprint: key.fingerprint(),
+            created_at,
+            expires_at: i64::try_from(expires_at).map_err(|_| SessionError::Clock)?,
+        })?;
+        Ok(IssuedChallenge {
+            challenge: format_login_challenge(
+                &self.origin,
+                username,
+                &key,
+                &nonce,
+                issued_at,
+                expires_at,
+            ),
+            public_key: key.canonical().to_owned(),
+            login_csrf,
+        })
+    }
+
+    pub(crate) fn verify(
+        &self,
+        username: &str,
+        public_key: &str,
+        challenge: &str,
+        signature: &str,
+        login_csrf: &str,
+    ) -> Result<NewSession, SessionError> {
+        validate_token(login_csrf)?;
+        let key = SshPublicKey::parse(public_key)?;
+        let created_at = now()?;
+        let verified = verify_login_challenge(
+            &self.origin,
+            challenge,
+            signature,
+            username,
+            &key,
+            u64::try_from(created_at).map_err(|_| SessionError::Clock)?,
+        )?;
+        let session = encode_hex(&random_bytes()?);
+        let csrf = encode_hex(&random_bytes()?);
+        let expires_at = created_at
+            .checked_add(SESSION_LIFETIME_SECONDS)
+            .ok_or(SessionError::Clock)?;
+        Store::open(&self.database)?.consume_login_nonce(&NewWebSession {
+            nonce_hash: &verified.nonce_hash,
+            login_csrf_hash: &hash(login_csrf.as_bytes()),
+            username: &verified.username,
+            fingerprint: &verified.fingerprint,
+            session_hash: &hash(session.as_bytes()),
+            csrf_hash: &hash(csrf.as_bytes()),
+            created_at,
+            expires_at,
+        })?;
+        Ok(NewSession {
+            token: session,
+            csrf,
+        })
+    }
+
+    pub(crate) fn authenticate(
+        &self,
+        session: &str,
+        csrf: Option<&str>,
+    ) -> Result<WebSessionRecord, SessionError> {
+        validate_token(session)?;
+        if let Some(csrf) = csrf {
+            validate_token(csrf)?;
+        }
+        Store::open(&self.database)?
+            .web_session(
+                &hash(session.as_bytes()),
+                csrf.map(|value| hash(value.as_bytes())).as_ref(),
+                now()?,
+            )
+            .map_err(Into::into)
+    }
+
+    pub(crate) fn end_all(&self, username: &str) -> Result<(), SessionError> {
+        Store::open(&self.database)?.end_account_sessions(username, now()?)?;
+        Ok(())
+    }
+}
+
+pub(crate) struct IssuedChallenge {
+    pub(crate) challenge: String,
+    pub(crate) public_key: String,
+    pub(crate) login_csrf: String,
+}
+
+pub(crate) struct NewSession {
+    pub(crate) token: String,
+    pub(crate) csrf: String,
+}
+
+fn random_bytes() -> Result<[u8; SECRET_BYTES], SessionError> {
+    let mut bytes = [0_u8; SECRET_BYTES];
+    rand::rngs::SysRng
+        .try_fill_bytes(&mut bytes)
+        .map_err(|_| SessionError::Random)?;
+    Ok(bytes)
+}
+
+fn hash(value: &[u8]) -> [u8; 32] {
+    Sha256::digest(value).into()
+}
+
+fn encode_hex(value: &[u8]) -> String {
+    let mut result = String::with_capacity(value.len() * 2);
+    for byte in value {
+        use std::fmt::Write as _;
+        write!(result, "{byte:02x}").expect("writing to a string cannot fail");
+    }
+    result
+}
+
+fn validate_token(token: &str) -> Result<(), SessionError> {
+    if token.len() != SECRET_BYTES * 2 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+        return Err(SessionError::InvalidToken);
+    }
+    Ok(())
+}
+
+fn now() -> Result<i64, SessionError> {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_err(|_| SessionError::Clock)?
+        .as_secs()
+        .try_into()
+        .map_err(|_| SessionError::Clock)
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum SessionError {
+    #[error(transparent)]
+    Auth(#[from] AuthError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error("session token is not valid")]
+    InvalidToken,
+    #[error("cannot create random session data")]
+    Random,
+    #[error("system clock is before the Unix epoch")]
+    Clock,
+    #[allow(dead_code, reason = "integration tests use the service without HTTP")]
+    #[error("Web login service is not available")]
+    Unavailable,
+}

src/store/migrations/008_web_sessions.sql

Mode 100644; object f547b414f56b

@@ -1,0 +1,26 @@
+CREATE TABLE login_nonce (
+    id INTEGER PRIMARY KEY,
+    nonce_hash BLOB NOT NULL UNIQUE CHECK (length(nonce_hash) = 32),
+    csrf_hash BLOB NOT NULL CHECK (length(csrf_hash) = 32),
+    account_id INTEGER NOT NULL REFERENCES account (id) ON DELETE RESTRICT,
+    ssh_public_key_id INTEGER NOT NULL REFERENCES ssh_public_key (id) ON DELETE RESTRICT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    expires_at INTEGER NOT NULL CHECK (expires_at > created_at),
+    consumed_at INTEGER CHECK (consumed_at IS NULL OR consumed_at >= created_at)
+) STRICT;
+
+CREATE INDEX login_nonce_active
+ON login_nonce (expires_at, consumed_at);
+
+CREATE TABLE web_session (
+    id INTEGER PRIMARY KEY,
+    session_hash BLOB NOT NULL UNIQUE CHECK (length(session_hash) = 32),
+    csrf_hash BLOB NOT NULL CHECK (length(csrf_hash) = 32),
+    account_id INTEGER NOT NULL REFERENCES account (id) ON DELETE RESTRICT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    expires_at INTEGER NOT NULL CHECK (expires_at > created_at),
+    ended_at INTEGER CHECK (ended_at IS NULL OR ended_at >= created_at)
+) STRICT;
+
+CREATE INDEX web_session_account_active
+ON web_session (account_id, expires_at, ended_at);

src/store/mod.rs

Mode 100644100644; object adeecabcd438b6b32acbd397

@@ -9,7 +9,7 @@
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 7;
+const SCHEMA_VERSION: i64 = 8;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -19,7 +19,7 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 7] = [
+const MIGRATIONS: [&str; 8] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
@@ -27,6 +27,7 @@
     include_str!("migrations/005_repository.sql"),
     include_str!("migrations/006_repository_events.sql"),
     include_str!("migrations/007_account_lifecycle.sql"),
+    include_str!("migrations/008_web_sessions.sql"),
 ];
 
 #[allow(
@@ -75,6 +76,14 @@
     KeyNotFound,
     #[error("an account must have at least one active SSH public key")]
     LastKey,
+    #[error("login identity does not exist or is not active")]
+    LoginIdentity,
+    #[error("too many login challenges are active")]
+    LoginNonceLimit,
+    #[error("login challenge is invalid, expired, or already used")]
+    InvalidLoginChallenge,
+    #[error("Web session is invalid or expired")]
+    InvalidSession,
     #[error("repository does not exist: {0}/{1}")]
     RepositoryNotFound(String, String),
     #[error("repository already exists: {0}/{1}")]
@@ -506,6 +515,7 @@
              SET credential_hash = ?2, created_at = ?3 WHERE account_id = ?1",
             rusqlite::params![account_id, recovery.new_recovery_hash, recovery.created_at],
         )?;
+        end_sessions(&transaction, account_id, recovery.created_at)?;
         transaction.commit()?;
         Ok(())
     }
@@ -525,6 +535,7 @@
             .transaction_with_behavior(TransactionBehavior::Immediate)?;
         let account_id = active_account_id(&transaction, username)?;
         insert_ssh_key(&transaction, account_id, key, created_at)?;
+        end_sessions(&transaction, account_id, created_at)?;
         transaction.commit()?;
         Ok(())
     }
@@ -559,6 +570,7 @@
         if changed != 1 {
             return Err(StoreError::KeyNotFound);
         }
+        end_sessions(&transaction, account_id, revoked_at)?;
         transaction.commit()?;
         Ok(())
     }
@@ -568,18 +580,193 @@
         reason = "some integration tests compile storage without accounts"
     )]
     pub(crate) fn suspend_account(
-        &self,
+        &mut self,
         username: &str,
         suspended: bool,
+        changed_at: i64,
     ) -> Result<(), StoreError> {
         let state = if suspended { "suspended" } else { "active" };
-        let changed = self.connection.execute(
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let changed = transaction.execute(
             "UPDATE account SET state = ?2 WHERE username = ?1",
             rusqlite::params![username, state],
         )?;
         if changed != 1 {
             return Err(StoreError::AccountNotFound(username.to_owned()));
         }
+        let account_id: i64 = transaction.query_row(
+            "SELECT id FROM account WHERE username = ?1",
+            [username],
+            |row| row.get(0),
+        )?;
+        end_sessions(&transaction, account_id, changed_at)?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without Web login"
+    )]
+    pub(crate) fn create_login_nonce(
+        &mut self,
+        nonce: &NewLoginNonce<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        transaction.execute(
+            "DELETE FROM login_nonce WHERE expires_at < ?1 OR consumed_at IS NOT NULL",
+            [nonce.created_at],
+        )?;
+        let active: i64 =
+            transaction.query_row("SELECT count(*) FROM login_nonce", [], |row| row.get(0))?;
+        if active >= 1_024 {
+            return Err(StoreError::LoginNonceLimit);
+        }
+        let key = transaction
+            .query_row(
+                "SELECT account.id, ssh_public_key.id
+                 FROM account
+                 JOIN ssh_public_key ON ssh_public_key.account_id = account.id
+                 WHERE account.username = ?1 AND account.state = 'active'
+                   AND ssh_public_key.fingerprint = ?2
+                   AND ssh_public_key.revoked_at IS NULL",
+                rusqlite::params![nonce.username, nonce.fingerprint],
+                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
+            )
+            .optional()?
+            .ok_or(StoreError::LoginIdentity)?;
+        transaction.execute(
+            "INSERT INTO login_nonce
+             (nonce_hash, csrf_hash, account_id, ssh_public_key_id,
+              created_at, expires_at, consumed_at)
+             VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL)",
+            rusqlite::params![
+                nonce.nonce_hash,
+                nonce.csrf_hash,
+                key.0,
+                key.1,
+                nonce.created_at,
+                nonce.expires_at,
+            ],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without Web login"
+    )]
+    pub(crate) fn consume_login_nonce(
+        &mut self,
+        login: &NewWebSession<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = transaction
+            .query_row(
+                "SELECT login_nonce.account_id
+                 FROM login_nonce
+                 JOIN account ON account.id = login_nonce.account_id
+                 JOIN ssh_public_key ON ssh_public_key.id = login_nonce.ssh_public_key_id
+                 WHERE login_nonce.nonce_hash = ?1
+                   AND login_nonce.consumed_at IS NULL AND login_nonce.expires_at >= ?2
+                   AND account.username = ?3 AND account.state = 'active'
+                   AND ssh_public_key.fingerprint = ?4 AND ssh_public_key.revoked_at IS NULL
+                   AND login_nonce.csrf_hash = ?5",
+                rusqlite::params![
+                    login.nonce_hash,
+                    login.created_at,
+                    login.username,
+                    login.fingerprint,
+                    login.login_csrf_hash,
+                ],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?
+            .ok_or(StoreError::InvalidLoginChallenge)?;
+        let changed = transaction.execute(
+            "UPDATE login_nonce SET consumed_at = ?2
+             WHERE nonce_hash = ?1 AND consumed_at IS NULL",
+            rusqlite::params![login.nonce_hash, login.created_at],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::InvalidLoginChallenge);
+        }
+        transaction.execute(
+            "INSERT INTO web_session
+             (session_hash, csrf_hash, account_id, created_at, expires_at, ended_at)
+             VALUES (?1, ?2, ?3, ?4, ?5, NULL)",
+            rusqlite::params![
+                login.session_hash,
+                login.csrf_hash,
+                account_id,
+                login.created_at,
+                login.expires_at,
+            ],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without Web login"
+    )]
+    pub(crate) fn web_session(
+        &self,
+        session_hash: &[u8; 32],
+        csrf_hash: Option<&[u8; 32]>,
+        now: i64,
+    ) -> Result<WebSessionRecord, StoreError> {
+        self.connection
+            .query_row(
+                "SELECT account.username, account.is_administrator, web_session.expires_at
+                 FROM web_session
+                 JOIN account ON account.id = web_session.account_id
+                 WHERE web_session.session_hash = ?1 AND web_session.ended_at IS NULL
+                   AND web_session.expires_at >= ?3 AND account.state = 'active'
+                   AND (?2 IS NULL OR web_session.csrf_hash = ?2)",
+                rusqlite::params![session_hash, csrf_hash, now],
+                |row| {
+                    Ok(WebSessionRecord {
+                        username: row.get(0)?,
+                        is_administrator: row.get(1)?,
+                        expires_at: row.get(2)?,
+                    })
+                },
+            )
+            .optional()?
+            .ok_or(StoreError::InvalidSession)
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without Web login"
+    )]
+    pub(crate) fn end_account_sessions(
+        &mut self,
+        username: &str,
+        ended_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id: i64 = transaction
+            .query_row(
+                "SELECT id FROM account WHERE username = ?1",
+                [username],
+                |row| row.get(0),
+            )
+            .optional()?
+            .ok_or_else(|| StoreError::AccountNotFound(username.to_owned()))?;
+        end_sessions(&transaction, account_id, ended_at)?;
+        transaction.commit()?;
         Ok(())
     }
 
@@ -892,6 +1079,44 @@
     pub(crate) created_at: i64,
 }
 
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without Web login"
+)]
+pub(crate) struct NewLoginNonce<'a> {
+    pub(crate) nonce_hash: &'a [u8; 32],
+    pub(crate) csrf_hash: &'a [u8; 32],
+    pub(crate) username: &'a str,
+    pub(crate) fingerprint: &'a str,
+    pub(crate) created_at: i64,
+    pub(crate) expires_at: i64,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without Web login"
+)]
+pub(crate) struct NewWebSession<'a> {
+    pub(crate) nonce_hash: &'a [u8; 32],
+    pub(crate) login_csrf_hash: &'a [u8; 32],
+    pub(crate) username: &'a str,
+    pub(crate) fingerprint: &'a str,
+    pub(crate) session_hash: &'a [u8; 32],
+    pub(crate) csrf_hash: &'a [u8; 32],
+    pub(crate) created_at: i64,
+    pub(crate) expires_at: i64,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without Web login"
+)]
+pub(crate) struct WebSessionRecord {
+    pub(crate) username: String,
+    pub(crate) is_administrator: bool,
+    pub(crate) expires_at: i64,
+}
+
 pub(crate) struct NewRepository<'a> {
     pub(crate) id: &'a str,
     pub(crate) owner: &'a str,
@@ -1000,6 +1225,19 @@
         Err(error) => Err(error.into()),
         Ok(_) => unreachable!("an INSERT changes one row"),
     }
+}
+
+fn end_sessions(
+    transaction: &rusqlite::Transaction<'_>,
+    account_id: i64,
+    ended_at: i64,
+) -> Result<(), StoreError> {
+    transaction.execute(
+        "UPDATE web_session SET ended_at = ?2
+         WHERE account_id = ?1 AND ended_at IS NULL",
+        rusqlite::params![account_id, ended_at],
+    )?;
+    Ok(())
 }
 
 fn insert_push_events(

templates/account-page.html

Mode 100644; object 2e96854719fe

@@ -1,0 +1,15 @@
+{% extends "base.html" %}
+{% block title %}Account · tit{% endblock %}
+{% block content %}
+  <h1>Account</h1>
+  <dl>
+    <dt>Username</dt>
+    <dd>{{ username }}</dd>
+    <dt>Administrator</dt>
+    <dd>{% if administrator %}Yes{% else %}No{% endif %}</dd>
+  </dl>
+  <form action="/logout" method="post">
+    <input type="hidden" name="csrf" value="{{ csrf }}">
+    <button type="submit">Log out all sessions</button>
+  </form>
+{% endblock %}

templates/base.html

Mode 100644100644; object fa3cc9823fc03242bac8ffe1

@@ -14,6 +14,7 @@
       <a href="/">Home</a>
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
+      <a href="/login">Log in</a>
     </nav>
   </header>
   <main id="main">

templates/login-challenge.html

Mode 100644; object 70e0fc3ab2c4

@@ -1,0 +1,33 @@
+{% extends "base.html" %}
+{% block title %}Sign login challenge · tit{% endblock %}
+{% block content %}
+  <h1>Sign the login challenge</h1>
+  <p>Save the exact challenge text to a file. Sign it with <code>ssh-keygen -Y sign -n tit-auth -f KEY_FILE CHALLENGE_FILE</code>. Paste the complete SSH signature below.</p>
+  <div class="field">
+    <label for="challenge-display">Challenge</label>
+    <textarea id="challenge-display" readonly rows="10">{{ challenge }}</textarea>
+  </div>
+  <form action="/login/verify" method="post">
+    <input type="hidden" name="username" value="{{ username }}">
+    <input type="hidden" name="public-key" value="{{ public_key }}">
+    <input type="hidden" name="challenge" value="{{ challenge }}">
+    <input type="hidden" name="login-csrf" value="{{ login_csrf }}">
+    <div class="field">
+      <label for="signature">SSH signature</label>
+      <textarea id="signature" name="signature" required rows="10" autocomplete="off" autocapitalize="none" spellcheck="false"></textarea>
+    </div>
+    <button type="submit">Log in</button>
+  </form>
+  <h2>Upload a signature</h2>
+  <form action="/login/verify-file" method="post" enctype="multipart/form-data">
+    <input type="hidden" name="username" value="{{ username }}">
+    <input type="hidden" name="public-key" value="{{ public_key }}">
+    <input type="hidden" name="challenge" value="{{ challenge }}">
+    <input type="hidden" name="login-csrf" value="{{ login_csrf }}">
+    <div class="field">
+      <label for="signature-file">SSH signature file</label>
+      <input id="signature-file" name="signature-file" type="file" required>
+    </div>
+    <button type="submit">Upload and log in</button>
+  </form>
+{% endblock %}

templates/login.html

Mode 100644; object 467932f76446

@@ -1,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}Log in · tit{% endblock %}
+{% block content %}
+  <h1>Log in</h1>
+  <p>Enter your username and one active SSH public key.</p>
+{% if has_error %}
+  <p class="error" role="alert">{{ error }}</p>
+{% endif %}
+  <form action="/login" method="post">
+    <div class="field">
+      <label for="username">Username</label>
+      <input id="username" name="username" value="{{ username }}" required autocomplete="username" autocapitalize="none" spellcheck="false">
+    </div>
+    <div class="field">
+      <label for="public-key">SSH public key</label>
+      <textarea id="public-key" name="public-key" required rows="5" autocomplete="off" autocapitalize="none" spellcheck="false"></textarea>
+    </div>
+    <button type="submit">Create challenge</button>
+  </form>
+{% endblock %}

tests/cli.rs

Mode 100644100644; object ee664552b531b4f6b5d4fab8

@@ -12,11 +12,12 @@
 };
 use tempfile::TempDir;
 
-const V7_DATABASE: &str = concat!(
+const V8_DATABASE: &str = concat!(
     include_str!("fixtures/sqlite/v5.sql"),
     include_str!("../src/store/migrations/006_repository_events.sql"),
     include_str!("../src/store/migrations/007_account_lifecycle.sql"),
-    "PRAGMA user_version = 7;\n",
+    include_str!("../src/store/migrations/008_web_sessions.sql"),
+    "PRAGMA user_version = 8;\n",
 );
 
 #[test]
@@ -72,7 +73,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V7_DATABASE)
+        .execute_batch(V8_DATABASE)
         .expect("create the current database");
     drop(database);
 
@@ -124,7 +125,7 @@
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the instance database");
     database
-        .execute_batch(V7_DATABASE)
+        .execute_batch(V8_DATABASE)
         .expect("create the current database");
     database
         .pragma_update(None, "foreign_keys", false)

tests/fixtures/sqlite/v7.sql

Mode 100644; object 07c5d2f340ec

@@ -1,0 +1,191 @@
+CREATE TABLE m1a_parent (
+    id INTEGER PRIMARY KEY,
+    name TEXT NOT NULL UNIQUE CHECK (length(name) BETWEEN 1 AND 64),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX m1a_parent_created_at ON m1a_parent (created_at, id);
+
+CREATE TABLE m1a_child (
+    id INTEGER PRIMARY KEY,
+    parent_id INTEGER NOT NULL REFERENCES m1a_parent (id) ON DELETE RESTRICT,
+    sequence INTEGER NOT NULL CHECK (sequence >= 0),
+    body TEXT NOT NULL,
+    state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
+    UNIQUE (parent_id, sequence)
+) STRICT;
+
+CREATE INDEX m1a_child_state_parent
+ON m1a_child (state, parent_id, sequence);
+
+CREATE TABLE git_operation_intent (
+    id TEXT PRIMARY KEY CHECK (length(id) = 32),
+    repository_path TEXT NOT NULL CHECK (length(repository_path) BETWEEN 1 AND 4096),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    initial_refs BLOB NOT NULL,
+    proposed_refs BLOB NOT NULL,
+    event_payload BLOB NOT NULL,
+    quarantine_path TEXT NOT NULL CHECK (length(quarantine_path) BETWEEN 1 AND 4096),
+    state TEXT NOT NULL CHECK (state IN ('pending', 'promoted', 'completed', 'abandoned')),
+    pack_name TEXT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE INDEX git_operation_intent_incomplete
+ON git_operation_intent (state, created_at, id)
+WHERE state IN ('pending', 'promoted');
+
+CREATE TABLE git_operation_event (
+    id INTEGER PRIMARY KEY,
+    intent_id TEXT NOT NULL UNIQUE
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    payload BLOB NOT NULL
+) STRICT;
+
+CREATE TABLE account (
+    id INTEGER PRIMARY KEY,
+    username TEXT NOT NULL UNIQUE
+        CHECK (
+            length(username) BETWEEN 1 AND 40
+            AND username NOT GLOB '*[^a-z0-9-]*'
+            AND substr(username, 1, 1) != '-'
+            AND substr(username, -1, 1) != '-'
+        ),
+    is_administrator INTEGER NOT NULL
+        CHECK (is_administrator IN (0, 1)),
+    state TEXT NOT NULL
+        CHECK (state IN ('active', 'suspended')),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE TABLE ssh_public_key (
+    id INTEGER PRIMARY KEY,
+    account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    canonical_key TEXT NOT NULL
+        CHECK (length(canonical_key) BETWEEN 1 AND 16384),
+    fingerprint TEXT NOT NULL UNIQUE
+        CHECK (length(fingerprint) BETWEEN 1 AND 256),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (account_id, canonical_key)
+) STRICT;
+
+CREATE INDEX ssh_public_key_account
+ON ssh_public_key (account_id, id);
+
+CREATE TABLE recovery_credential (
+    account_id INTEGER PRIMARY KEY
+        REFERENCES account (id) ON DELETE RESTRICT,
+    credential_hash BLOB NOT NULL UNIQUE
+        CHECK (length(credential_hash) = 32),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0)
+) STRICT;
+
+CREATE TABLE repository (
+    id TEXT PRIMARY KEY
+        CHECK (length(id) = 32 AND id NOT GLOB '*[^0-9a-f]*'),
+    owner_account_id INTEGER NOT NULL
+        REFERENCES account (id) ON DELETE RESTRICT,
+    slug TEXT NOT NULL
+        CHECK (
+            length(slug) BETWEEN 1 AND 100
+            AND slug NOT GLOB '*[^a-z0-9._-]*'
+            AND substr(slug, 1, 1) GLOB '[a-z0-9]'
+            AND substr(slug, -1, 1) GLOB '[a-z0-9]'
+            AND substr(slug, -4) != '.git'
+            AND slug NOT IN ('admin', 'api', 'assets', 'feeds', 'issues', 'setup')
+        ),
+    visibility TEXT NOT NULL DEFAULT 'public'
+        CHECK (visibility IN ('public', 'private')),
+    state TEXT NOT NULL DEFAULT 'active'
+        CHECK (state IN ('active', 'archived')),
+    object_format TEXT NOT NULL
+        CHECK (object_format IN ('sha1', 'sha256')),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    archived_at INTEGER
+        CHECK (archived_at IS NULL OR archived_at >= created_at),
+    UNIQUE (owner_account_id, slug),
+    CHECK (
+        (state = 'active' AND archived_at IS NULL)
+        OR (state = 'archived' AND archived_at IS NOT NULL)
+    )
+) STRICT;
+
+INSERT INTO m1a_parent (id, name, created_at)
+VALUES (1, 'fixture', 1);
+
+INSERT INTO m1a_child (id, parent_id, sequence, body, state)
+VALUES (1, 1, 1, 'version five', 'closed');
+
+PRAGMA user_version = 5;
+
+CREATE TABLE repository_event (
+    id INTEGER PRIMARY KEY,
+    repository_id TEXT NOT NULL
+        REFERENCES repository (id) ON DELETE RESTRICT,
+    source_intent_id TEXT
+        REFERENCES git_operation_intent (id) ON DELETE RESTRICT,
+    source_ordinal INTEGER CHECK (source_ordinal IS NULL OR source_ordinal >= 0),
+    kind TEXT NOT NULL
+        CHECK (kind IN (
+            'repository-created', 'repository-imported', 'push',
+            'ref-created', 'ref-updated', 'ref-deleted',
+            'tag-created', 'tag-updated', 'tag-deleted'
+        )),
+    actor TEXT NOT NULL CHECK (length(actor) BETWEEN 1 AND 256),
+    ref_name BLOB,
+    old_target TEXT,
+    new_target TEXT,
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    UNIQUE (source_intent_id, source_ordinal),
+    CHECK (
+        (source_intent_id IS NULL AND source_ordinal IS NULL)
+        OR (source_intent_id IS NOT NULL AND source_ordinal IS NOT NULL)
+    ),
+    CHECK (
+        (kind IN ('repository-created', 'repository-imported', 'push')
+            AND ref_name IS NULL AND old_target IS NULL AND new_target IS NULL)
+        OR
+        (kind LIKE 'ref-%' OR kind LIKE 'tag-%')
+            AND ref_name IS NOT NULL
+            AND (old_target IS NOT NULL OR new_target IS NOT NULL)
+    )
+) STRICT;
+
+INSERT INTO repository_event (repository_id, kind, actor, created_at)
+SELECT repository.id, 'repository-created', account.username, repository.created_at
+FROM repository
+JOIN account ON account.id = repository.owner_account_id
+ORDER BY repository.created_at, repository.id;
+
+CREATE INDEX repository_event_feed
+ON repository_event (repository_id, id DESC);
+
+PRAGMA user_version = 6;
+
+
+CREATE TABLE signup_invitation (
+    id INTEGER PRIMARY KEY,
+    code_hash BLOB NOT NULL UNIQUE CHECK (length(code_hash) = 32),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    expires_at INTEGER NOT NULL CHECK (expires_at > created_at),
+    consumed_at INTEGER CHECK (consumed_at IS NULL OR consumed_at >= created_at)
+) STRICT;
+
+CREATE INDEX signup_invitation_active
+ON signup_invitation (expires_at, consumed_at);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN label TEXT NOT NULL DEFAULT 'initial'
+    CHECK (length(label) BETWEEN 1 AND 80);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN last_used_at INTEGER
+    CHECK (last_used_at IS NULL OR last_used_at >= created_at);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN revoked_at INTEGER
+    CHECK (revoked_at IS NULL OR revoked_at >= created_at);
+
+PRAGMA user_version = 7;
+

tests/public_routes.rs

Mode 100644100644; object e202c68a76e26c0ff2e83fe8

@@ -34,6 +34,9 @@
 mod instance;
 #[path = "../src/markdown.rs"]
 mod markdown;
+#[allow(dead_code, reason = "the public-route test does not complete a login")]
+#[path = "../src/session.rs"]
+mod session;
 #[allow(
     dead_code,
     reason = "the public-route test does not use each store API"

tests/serve.rs

Mode 100644100644; object b86785ce74f5c6070f477dbd

@@ -74,6 +74,91 @@
         0o600
     );
 
+    let login_challenge = http_form(
+        http,
+        "/login",
+        &[("username", "alice"), ("public-key", public_key.trim())],
+    );
+    assert!(login_challenge.starts_with("HTTP/1.1 200"));
+    let challenge = between(
+        &login_challenge,
+        "<textarea id=\"challenge-display\" readonly rows=\"10\">",
+        "</textarea>",
+    );
+    let signature = sign_challenge(instance.path(), &private_key, challenge);
+    let login_csrf_cookies = response_cookies(&login_challenge);
+    let login_csrf = cookie_value(&login_csrf_cookies, "tit-login-csrf");
+    let rejected_login = http_form_with_headers(
+        http,
+        "/login/verify",
+        &[
+            ("username", "alice"),
+            ("public-key", public_key.trim()),
+            ("challenge", challenge),
+            ("signature", &signature),
+            ("login-csrf", &"0".repeat(64)),
+        ],
+        &[("Cookie", &login_csrf_cookies)],
+    );
+    assert!(rejected_login.starts_with("HTTP/1.1 400"));
+    let login = http_form_with_headers(
+        http,
+        "/login/verify",
+        &[
+            ("username", "alice"),
+            ("public-key", public_key.trim()),
+            ("challenge", challenge),
+            ("signature", &signature),
+            ("login-csrf", login_csrf),
+        ],
+        &[("Cookie", &login_csrf_cookies)],
+    );
+    assert!(login.starts_with("HTTP/1.1 303"), "{login}");
+    let cookies = response_cookies(&login);
+    let account = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
+    assert!(account.starts_with("HTTP/1.1 200"));
+    assert!(account.contains("<dd>alice</dd>"));
+    let csrf = cookie_value(&cookies, "tit-csrf");
+    let rejected_logout = http_form_with_headers(
+        http,
+        "/logout",
+        &[("csrf", &"0".repeat(64))],
+        &[("Cookie", &cookies)],
+    );
+    assert!(rejected_logout.starts_with("HTTP/1.1 403"));
+    let logout =
+        http_form_with_headers(http, "/logout", &[("csrf", csrf)], &[("Cookie", &cookies)]);
+    assert!(logout.starts_with("HTTP/1.1 303"));
+    let ended = http_get_with_headers(http, "/account", &[("Cookie", &cookies)]);
+    assert!(ended.starts_with("HTTP/1.1 303"));
+
+    let upload_challenge_page = http_form(
+        http,
+        "/login",
+        &[("username", "alice"), ("public-key", public_key.trim())],
+    );
+    let upload_challenge = between(
+        &upload_challenge_page,
+        "<textarea id=\"challenge-display\" readonly rows=\"10\">",
+        "</textarea>",
+    );
+    let upload_signature = sign_challenge(instance.path(), &private_key, upload_challenge);
+    let upload_csrf_cookies = response_cookies(&upload_challenge_page);
+    let upload_csrf = cookie_value(&upload_csrf_cookies, "tit-login-csrf");
+    let uploaded = http_multipart(
+        http,
+        "/login/verify-file",
+        &[
+            ("username", "alice"),
+            ("public-key", public_key.trim()),
+            ("challenge", upload_challenge),
+            ("signature-file", &upload_signature),
+            ("login-csrf", upload_csrf),
+        ],
+        &[("Cookie", &upload_csrf_cookies)],
+    );
+    assert!(uploaded.starts_with("HTTP/1.1 303"), "{uploaded}");
+
     let invitation_output = Command::new(env!("CARGO_BIN_EXE_tit"))
         .args([
             "--config",
@@ -316,12 +401,19 @@
 }
 
 fn http_get(address: SocketAddr, path: &str) -> String {
+    http_get_with_headers(address, path, &[])
+}
+
+fn http_get_with_headers(address: SocketAddr, path: &str, headers: &[(&str, &str)]) -> String {
     let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
-    write!(
-        stream,
-        "GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n"
-    )
-    .expect("write an HTTP request");
+    let mut request = format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n");
+    for (name, value) in headers {
+        request.push_str(&format!("{name}: {value}\r\n"));
+    }
+    request.push_str("\r\n");
+    stream
+        .write_all(request.as_bytes())
+        .expect("write an HTTP request");
     let mut response = String::new();
     stream
         .read_to_string(&mut response)
@@ -330,21 +422,120 @@
 }
 
 fn http_form(address: SocketAddr, path: &str, fields: &[(&str, &str)]) -> String {
+    http_form_with_headers(address, path, fields, &[])
+}
+
+fn http_form_with_headers(
+    address: SocketAddr,
+    path: &str,
+    fields: &[(&str, &str)],
+    headers: &[(&str, &str)],
+) -> String {
     let body = url::form_urlencoded::Serializer::new(String::new())
         .extend_pairs(fields.iter().copied())
         .finish();
     let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
-    write!(
-        stream,
-        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+    let mut request = format!(
+        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n",
         body.len()
-    )
-    .expect("write an HTTP form");
+    );
+    for (name, value) in headers {
+        request.push_str(&format!("{name}: {value}\r\n"));
+    }
+    request.push_str("\r\n");
+    request.push_str(&body);
+    stream
+        .write_all(request.as_bytes())
+        .expect("write an HTTP form");
     let mut response = String::new();
     stream
         .read_to_string(&mut response)
         .expect("read an HTTP response");
     response
+}
+
+fn response_cookies(response: &str) -> String {
+    response
+        .lines()
+        .filter_map(|line| line.split_once(':'))
+        .filter(|(name, _)| name.eq_ignore_ascii_case("set-cookie"))
+        .map(|(_, value)| {
+            value
+                .trim()
+                .split_once(';')
+                .map_or(value.trim(), |(cookie, _)| cookie)
+        })
+        .collect::<Vec<_>>()
+        .join("; ")
+}
+
+fn http_multipart(
+    address: SocketAddr,
+    path: &str,
+    fields: &[(&str, &str)],
+    headers: &[(&str, &str)],
+) -> String {
+    let boundary = "tit-test-boundary";
+    let mut body = String::new();
+    for (name, value) in fields {
+        if *name == "signature-file" {
+            body.push_str(&format!(
+                "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"signature.sig\"\r\nContent-Type: application/octet-stream\r\n\r\n{value}\r\n"
+            ));
+        } else {
+            body.push_str(&format!(
+                "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n"
+            ));
+        }
+    }
+    body.push_str(&format!("--{boundary}--\r\n"));
+    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
+    let mut request = format!(
+        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: multipart/form-data; boundary={boundary}\r\nContent-Length: {}\r\nConnection: close\r\n",
+        body.len()
+    );
+    for (name, value) in headers {
+        request.push_str(&format!("{name}: {value}\r\n"));
+    }
+    request.push_str("\r\n");
+    request.push_str(&body);
+    stream
+        .write_all(request.as_bytes())
+        .expect("write a multipart HTTP form");
+    let mut response = String::new();
+    stream
+        .read_to_string(&mut response)
+        .expect("read an HTTP response");
+    response
+}
+
+fn cookie_value<'a>(cookies: &'a str, name: &str) -> &'a str {
+    cookies
+        .split("; ")
+        .find_map(|cookie| cookie.strip_prefix(&format!("{name}=")))
+        .expect("find the cookie")
+}
+
+fn sign_challenge(directory: &Path, private_key: &Path, challenge: &str) -> String {
+    let nonce = challenge
+        .lines()
+        .find_map(|line| line.strip_prefix("nonce="))
+        .expect("find the Web login nonce");
+    let path = directory.join(format!("web-login-{nonce}.challenge"));
+    fs::write(&path, challenge).expect("write the Web login challenge");
+    let output = Command::new("ssh-keygen")
+        .args(["-q", "-Y", "sign", "-f"])
+        .arg(private_key)
+        .args(["-n", "tit-auth"])
+        .arg(&path)
+        .output()
+        .expect("sign the Web login challenge");
+    assert!(
+        output.status.success(),
+        "cannot sign the Web login challenge: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    fs::read_to_string(path.with_extension("challenge.sig")).expect("read the Web login signature")
 }
 
 fn between<'a>(value: &'a str, start: &str, end: &str) -> &'a str {

tests/snapshots/web/bad-request.html

Mode 100644100644; object 159711d92b81cb36f34dd1de

@@ -14,6 +14,7 @@
       <a href="/">Home</a>
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
+      <a href="/login">Log in</a>
     </nav>
   </header>
   <main id="main">

tests/snapshots/web/home.html

Mode 100644100644; object 6500823ed07264f08cd9b85c

@@ -14,6 +14,7 @@
       <a href="/">Home</a>
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
+      <a href="/login">Log in</a>
     </nav>
   </header>
   <main id="main">

tests/snapshots/web/method-not-allowed.html

Mode 100644100644; object c415a2c6edb7cad969198904

@@ -14,6 +14,7 @@
       <a href="/">Home</a>
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
+      <a href="/login">Log in</a>
     </nav>
   </header>
   <main id="main">

tests/snapshots/web/not-found.html

Mode 100644100644; object 89966cf7e55f3d27c4f01d27

@@ -14,6 +14,7 @@
       <a href="/">Home</a>
       <a href="/signup">Create account</a>
       <a href="/recover">Recover account</a>
+      <a href="/login">Log in</a>
     </nav>
   </header>
   <main id="main">

tests/sqlite.rs

Mode 100644100644; object fd5cfa74311892695311f3d3

@@ -21,6 +21,7 @@
 const V4_FIXTURE: &str = include_str!("fixtures/sqlite/v4.sql");
 const V5_FIXTURE: &str = include_str!("fixtures/sqlite/v5.sql");
 const V6_FIXTURE: &str = include_str!("fixtures/sqlite/v6.sql");
+const V7_FIXTURE: &str = include_str!("fixtures/sqlite/v7.sql");
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
     directory.path().join(name)
@@ -138,7 +139,7 @@
     let directory = TempDir::new().expect("create a temporary directory");
     let store = Store::open(&database(&directory, "store.sqlite")).expect("open the store");
 
-    assert_eq!(store.schema_version().expect("read the schema version"), 7);
+    assert_eq!(store.schema_version().expect("read the schema version"), 8);
     assert_eq!(
         store
             .connection()
@@ -774,13 +775,14 @@
         (V4_FIXTURE, 4),
         (V5_FIXTURE, 5),
         (V6_FIXTURE, 6),
+        (V7_FIXTURE, 7),
     ] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "tit.sqlite3");
         create_fixture(&path, fixture);
 
         let store = Store::open(&path).expect("migrate the fixture");
-        assert_eq!(store.schema_version().expect("read the schema version"), 7);
+        assert_eq!(store.schema_version().expect("read the schema version"), 8);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -846,7 +848,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 7)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 8)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);

tests/web_session.rs

Mode 100644; object caa94aecfbfe

@@ -1,0 +1,178 @@
+#[allow(dead_code, reason = "the Web session test uses one shared test helper")]
+mod support;
+
+#[allow(
+    dead_code,
+    reason = "the Web session test uses part of account management"
+)]
+#[path = "../src/account.rs"]
+mod account;
+#[allow(dead_code, reason = "the Web session test uses part of authentication")]
+#[path = "../src/auth.rs"]
+mod auth;
+#[path = "../src/session.rs"]
+mod session;
+#[allow(
+    dead_code,
+    reason = "the Web session test does not use each store operation"
+)]
+#[path = "../src/store/mod.rs"]
+mod store;
+
+use std::fs;
+use std::path::Path;
+use std::process::Command;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use sha2::{Digest, Sha256};
+use support::create_ssh_key_fixture;
+use tempfile::TempDir;
+use url::Url;
+
+use account::AccountService;
+use auth::SshPublicKey;
+use session::{SessionError, WebLoginService};
+use store::{InitialAdministrator, Store, StoreError};
+
+#[test]
+fn persists_one_time_challenges_and_opaque_revocable_sessions() {
+    let directory = TempDir::new().expect("create a Web session directory");
+    let database = directory.path().join("tit.sqlite3");
+    let private_key = directory.path().join("identity");
+    create_ssh_key_fixture(&private_key);
+    let public_key =
+        fs::read_to_string(private_key.with_extension("pub")).expect("read the SSH public key");
+    let parsed = SshPublicKey::parse(&public_key).expect("parse the SSH public key");
+    let mut store = Store::open(&database).expect("create the database");
+    store
+        .create_initial_administrator(&InitialAdministrator {
+            username: "alice",
+            canonical_key: parsed.canonical(),
+            fingerprint: parsed.fingerprint(),
+            recovery_hash: &[1; 32],
+            created_at: now(),
+        })
+        .expect("create the account");
+
+    let origin = Url::parse("https://tit.example/").expect("parse the origin");
+    let login = WebLoginService::new(database.clone(), &origin).expect("create the login service");
+    let issued = login
+        .issue("alice", &public_key)
+        .expect("issue a login challenge");
+    let signature = sign(directory.path(), &private_key, &issued.challenge);
+
+    let restarted =
+        WebLoginService::new(database.clone(), &origin).expect("restart the login service");
+    let session = restarted
+        .verify(
+            "alice",
+            &issued.public_key,
+            &issued.challenge,
+            &signature,
+            &issued.login_csrf,
+        )
+        .expect("verify the login challenge after restart");
+    assert_eq!(
+        restarted
+            .authenticate(&session.token, Some(&session.csrf))
+            .expect("authenticate the Web session")
+            .username,
+        "alice"
+    );
+    assert!(matches!(
+        restarted.authenticate(&session.token, Some(&"0".repeat(64))),
+        Err(SessionError::Store(StoreError::InvalidSession))
+    ));
+    assert!(matches!(
+        restarted.verify(
+            "alice",
+            &issued.public_key,
+            &issued.challenge,
+            &signature,
+            &issued.login_csrf,
+        ),
+        Err(SessionError::Store(StoreError::InvalidLoginChallenge))
+    ));
+
+    let connection = Store::open(&database).expect("open the database");
+    let (session_hash, csrf_hash): (Vec<u8>, Vec<u8>) = connection
+        .connection()
+        .query_row(
+            "SELECT session_hash, csrf_hash FROM web_session",
+            [],
+            |row| Ok((row.get(0)?, row.get(1)?)),
+        )
+        .expect("read stored session hashes");
+    assert_eq!(
+        session_hash,
+        Sha256::digest(session.token.as_bytes()).as_slice()
+    );
+    assert_eq!(
+        csrf_hash,
+        Sha256::digest(session.csrf.as_bytes()).as_slice()
+    );
+    assert_ne!(session_hash, session.token.as_bytes());
+
+    let second_key = directory.path().join("second-identity");
+    create_ssh_key_fixture(&second_key);
+    let second_public = fs::read_to_string(second_key.with_extension("pub"))
+        .expect("read the second SSH public key");
+    AccountService::new(database.clone())
+        .add_key("alice", "second", &second_public)
+        .expect("change account privileges");
+    assert!(matches!(
+        restarted.authenticate(&session.token, None),
+        Err(SessionError::Store(StoreError::InvalidSession))
+    ));
+
+    let next = restarted
+        .issue("alice", &public_key)
+        .expect("issue another challenge");
+    let next_signature = sign(directory.path(), &private_key, &next.challenge);
+    let next_session = restarted
+        .verify(
+            "alice",
+            &next.public_key,
+            &next.challenge,
+            &next_signature,
+            &next.login_csrf,
+        )
+        .expect("create another session");
+    restarted.end_all("alice").expect("end all sessions");
+    assert!(matches!(
+        restarted.authenticate(&next_session.token, None),
+        Err(SessionError::Store(StoreError::InvalidSession))
+    ));
+}
+
+fn sign(directory: &Path, private_key: &Path, challenge: &str) -> String {
+    let nonce = challenge
+        .lines()
+        .find_map(|line| line.strip_prefix("nonce="))
+        .expect("find the challenge nonce");
+    let challenge_path = directory.join(format!("login-{nonce}.challenge"));
+    fs::write(&challenge_path, challenge).expect("write the challenge");
+    let output = Command::new("ssh-keygen")
+        .args(["-q", "-Y", "sign", "-f"])
+        .arg(private_key)
+        .args(["-n", "tit-auth"])
+        .arg(&challenge_path)
+        .output()
+        .expect("sign the challenge");
+    assert!(
+        output.status.success(),
+        "cannot sign the challenge: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    fs::read_to_string(challenge_path.with_extension("challenge.sig"))
+        .expect("read the SSH signature")
+}
+
+fn now() -> i64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .expect("read the clock")
+        .as_secs()
+        .try_into()
+        .expect("convert the clock")
+}

tests/web_shell.rs

Mode 100644100644; object 9093f88fdfbfa4e4e6a8fc55

@@ -32,6 +32,9 @@
 mod instance;
 #[path = "../src/markdown.rs"]
 mod markdown;
+#[allow(dead_code, reason = "the Web shell test does not complete a login")]
+#[path = "../src/session.rs"]
+mod session;
 #[allow(dead_code, reason = "the shell test does not use repository storage")]
 #[path = "../src/store/mod.rs"]
 mod store;