michal/tit

Browse tree · Show commit · Download archive

Diff

bbd9c1843f6dc24a185aa5d6

CONTRIBUTING.md

Mode 100644100644; object ad611f62f9f4207b77e39fd5

@@ -92,7 +92,8 @@
 - Cargo, Git, OpenSSH, Rust, SQLite, and the exact names of crates, commands,
   types, files, paths, and configuration fields.
 - acceptance gate, audit event, collaborator role, domain event, feed token,
-  invite code, login nonce, recovery credential, and security boundary.
+  invite code, login approval, login nonce, recovery credential, and security
+  boundary.
 - architectural decision record, built-in SSH server, canonical URL,
   client-side router, progressive enhancement, recoverable runtime failure,
   server-rendered HTML, server-side validation, symbolic logo, and tit bird.

PLAN.md

Mode 100644100644; object 0aceba0522dcfb83b5c66664

@@ -142,14 +142,16 @@
 The command is valid only for an uninitialized instance, creates exactly one
 administrator, and prints its recovery code once.
 
-Web login uses the standard SSH signature envelope rather than a custom
-cryptographic format. The login page presents a challenge and an exact command
-with `ssh-keygen -Y sign` and the dedicated `tit-auth` namespace. The
-challenge contains these minimum items:
+Web login uses the built-in SSH service as the primary approval path. The
+browser creates a one-time approval, and an authenticated `ssh` command binds
+the approval to the account and active key. The fallback uses the standard SSH
+signature envelope rather than a custom cryptographic format. It presents a
+challenge and an exact command with `ssh-keygen -Y sign` and the dedicated
+`tit-auth` namespace. The challenge contains these minimum items:
 
 - protocol version and purpose.
 - canonical CDE origin.
-- normalized username and selected key fingerprint.
+- normalized username.
 - cryptographically random nonce.
 - issue and expiry times.
 
@@ -777,6 +779,46 @@
 configure, make a backup, restore, upgrade, and remove `tit`. The operator can
 also cause damage to a disposable copy and restore it. All security and recovery
 tests use the release artifact, not a debug build.
+
+##### M6.7 — Streamlined Web login
+
+Status: Complete.
+
+- Make browser login through the built-in SSH server the primary login
+  workflow. The browser creates a short-lived, one-time secret and supplies an
+  exact `ssh` command. The SSH connection identifies the account from its key
+  and approves only that browser login request.
+- Bind each login approval to the browser login CSRF cookie. Store only secret
+  hashes. Show the canonical origin and authenticated username in SSH output.
+  Atomically consume the approval when the server creates the Web session.
+- Keep the SSHSIG workflow as a fallback. Ask for a username, but do not ask the
+  user to paste a public key. Read the signing key from the SSHSIG envelope and
+  require an active key for the account.
+- Supply the fallback challenge as a downloadable file and show an exact
+  `ssh-keygen -Y sign` command. Make signature-file upload the primary fallback
+  response. Keep signature text input as a secondary response.
+- After an invalid fallback signature, show the same challenge and error while
+  the login nonce remains valid. Do not make the user create a new challenge
+  unless it expires or is consumed.
+- Keep both workflows complete without JavaScript. Apply the existing login
+  rate limit, audit behavior, redaction rules, session rules, and generic
+  authentication errors.
+
+Gate: stock OpenSSH approves a browser login through the built-in SSH server,
+and stock `ssh-keygen` completes the fallback workflow. Black-box tests cover
+approval replay, expiry, a different browser cookie, a changed secret, a
+revoked key, a suspended account, restart, and concurrent approval. Fallback
+tests cover an unknown key, an invalid signature, retry with the same challenge,
+challenge download bytes, upload, paste, replay, and expiry. Both workflows
+create the same session type and operate without JavaScript. Do not start M7.1
+before this gate passes.
+
+Gate evidence: `./scripts/check-m3-2` passes with stock OpenSSH and
+`ssh-keygen` on 2026-07-24. The production-process test covers SSH approval,
+fallback download, upload, paste, retry, session creation, and secret
+redaction. Session tests cover approval restart, expiry, replay, changed
+secrets, browser binding, revoked keys, suspended accounts, unknown signing
+keys, and concurrent completion.
 
 #### Milestone 7 — adversarial review remediation
 

README.md

Mode 100644100644; object 35e7ccbc5c507e4bd367db5f

@@ -348,9 +348,11 @@
 ./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
+Open `/login` and select **Log in with SSH**. Run the exact command, check the
+origin and account in its output, and continue in the browser. If the SSH
+service is not available, create a fallback challenge and sign its exact
+content with the `tit-auth` SSHSIG namespace. The Web UI accepts the signature
+file 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.
 

assets/style.css

Mode 100644100644; object 3ab776942c7b9f1e642e7a1f

@@ -46,6 +46,15 @@
   max-width: 32rem;
 }
 
+details {
+  margin-block: 1.5rem;
+}
+
+summary {
+  cursor: pointer;
+  font-weight: 700;
+}
+
 .repository-header nav,
 .repository-header h1 {
   margin-block: 0.5rem;

docs/adr/0002-ssh-identity.md

Mode 100644100644; object 21f62d78c584709e7ded2b21

@@ -31,7 +31,7 @@
 until this boundary can enforce RSA-SHA2. A stock OpenSSH client that is forced
 to use `ssh-rsa` cannot authenticate to the M1B server.
 
-Use this canonical login challenge:
+The first feasibility gate uses this canonical login challenge:
 
 ```text
 tit-auth-v1
@@ -53,8 +53,9 @@
 of 1,024 active challenges and remove expired entries when a new challenge is
 issued. Consume the hash under one lock only after all context and signature
 checks pass. A process restart invalidates all challenges in this feasibility
-implementation. Milestone 3 must put nonce hashes in SQLite before Web login is
-available.
+implementation. Milestone 3 puts nonce hashes in SQLite. Milestone 6.7 replaces
+the Web fallback challenge with `tit-auth-v2`. That format removes the
+fingerprint field because the SSHSIG envelope contains the signing key.
 
 The SSH server accepts public-key authentication only. It ignores the SSH
 username. It accepts session channels and the exact `tit --version` command.

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

Mode 100644100644; object e19a35ef3ffa1d7df11a1741

@@ -4,6 +4,8 @@
 
 Date: 2026-07-22
 
+Amended: 2026-07-24
+
 ## Context
 
 The Web UI must use the same SSH key identity as the SSH server. A login
@@ -12,20 +14,31 @@
 
 ## 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 primary login form creates a five-minute SSH approval. The Web UI shows an
+exact `ssh` command with a random one-time secret. The browser stores a
+different login CSRF value in an `HttpOnly` cookie. SQLite stores only the
+SHA-256 hashes of these two values.
 
-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. An HTML form changes challenge line endings to CRLF. The
-HTTP interface changes these line endings back to LF before signature
-verification. 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.
+The user runs the command against the built-in SSH server. Public-key
+authentication selects the account and active key. The `login` command binds
+the pending approval to that account and key in one transaction. Its output
+shows the Web origin and account. The browser supplies the one-time secret and
+the matching login CSRF value when it completes the login. In one SQLite
+transaction, the server consumes the approval and creates a seven-day session.
+
+The fallback form requires only a username. The server creates a five-minute
+`tit-auth-v2` challenge and stores only the SHA-256 hash of its random nonce.
+The user downloads and signs the exact challenge with the `tit-auth` SSHSIG
+namespace. The SSHSIG envelope contains the signing public key. The server
+verifies that this key is active on the named account. The user can upload the
+signature file or paste the complete envelope. An HTML form changes challenge
+line endings to CRLF. The HTTP interface changes these line endings back to LF
+before signature verification. A failed signature returns the same challenge
+page so that the user can try again before it expires.
+
+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
@@ -46,25 +59,29 @@
 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.
+The server keeps a maximum of 1,024 active login nonces and 1,024 active SSH
+approvals. It removes consumed or expired records 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 with browser CRLF line endings. It rejects an incorrect
-upload content type and malformed multipart content. It also confirms CSRF
+the function that ends all sessions. It also verifies approval binding and
+concurrent one-time consumption.
+
+The executable test creates an approval through HTTP, approves it with stock
+`ssh`, and completes the browser session. It also completes the fallback with
+stock `ssh-keygen` and browser CRLF line endings. It rejects an incorrect
+upload content type and malformed multipart content. It 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.
+The primary flow needs access to the built-in SSH service. The fallback works
+when the user cannot connect to that service. Neither flow asks the user to
+copy a public key.

src/auth.rs

Mode 100644100644; object 797f5c74c10b77d812fb2775

@@ -8,6 +8,7 @@
 use url::Url;
 
 const CHALLENGE_HEADER: &str = "tit-auth-v1";
+const KEYLESS_CHALLENGE_HEADER: &str = "tit-auth-v2";
 const CHALLENGE_PURPOSE: &str = "web-login";
 const SIGNATURE_NAMESPACE: &str = "tit-auth";
 const MAX_KEY_BYTES: usize = 16 * 1024;
@@ -31,7 +32,11 @@
             return Err(AuthError::InputTooLarge("SSH public key"));
         }
 
-        let mut key = PublicKey::from_openssh(input).map_err(AuthError::PublicKey)?;
+        let key = PublicKey::from_openssh(input).map_err(AuthError::PublicKey)?;
+        Self::from_public_key(key)
+    }
+
+    fn from_public_key(mut key: PublicKey) -> Result<Self, AuthError> {
         validate_key_algorithm(&key)?;
         key.set_comment("");
         let canonical = key.to_openssh().map_err(AuthError::PublicKey)?;
@@ -54,6 +59,62 @@
     pub(crate) fn public_key(&self) -> &PublicKey {
         &self.key
     }
+}
+
+pub(crate) fn format_keyless_login_challenge(
+    origin: &str,
+    username: &str,
+    nonce: &[u8; NONCE_BYTES],
+    issued_at: u64,
+    expires_at: u64,
+) -> String {
+    format!(
+        "{KEYLESS_CHALLENGE_HEADER}\npurpose={CHALLENGE_PURPOSE}\norigin={origin}\nusername={username}\nnonce={}\nissued-at={issued_at}\nexpires-at={expires_at}\n",
+        encode_hex(nonce)
+    )
+}
+
+pub(crate) fn verify_keyless_login_challenge(
+    origin: &str,
+    challenge: &str,
+    signature: &str,
+    expected_username: &str,
+    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 = KeylessChallengeFields::parse(challenge)?;
+    if fields.origin != origin {
+        return Err(AuthError::WrongOrigin);
+    }
+    if fields.username != expected_username {
+        return Err(AuthError::WrongUsername);
+    }
+    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)?;
+    let key = SshPublicKey::from_public_key(PublicKey::new(sshsig.public_key().clone(), ""))?;
+    validate_signature_algorithm(key.public_key(), &sshsig)?;
+    key.public_key()
+        .verify(SIGNATURE_NAMESPACE, challenge.as_bytes(), &sshsig)
+        .map_err(AuthError::SignatureVerification)?;
+    Ok(PersistentVerifiedLogin {
+        username: fields.username.to_owned(),
+        fingerprint: key.fingerprint().to_owned(),
+        nonce_hash: hash_nonce(&fields.nonce),
+        expires_at: fields.expires_at,
+    })
 }
 
 #[derive(Debug)]
@@ -284,6 +345,44 @@
     nonce: [u8; NONCE_BYTES],
     issued_at: u64,
     expires_at: u64,
+}
+
+struct KeylessChallengeFields<'a> {
+    origin: &'a str,
+    username: &'a str,
+    nonce: [u8; NONCE_BYTES],
+    issued_at: u64,
+    expires_at: u64,
+}
+
+impl<'a> KeylessChallengeFields<'a> {
+    fn parse(challenge: &'a str) -> Result<Self, AuthError> {
+        let body = challenge
+            .strip_suffix('\n')
+            .ok_or(AuthError::MalformedChallenge)?;
+        let mut lines = body.split('\n');
+        if lines.next() != Some(KEYLESS_CHALLENGE_HEADER)
+            || lines.next() != Some("purpose=web-login")
+        {
+            return Err(AuthError::MalformedChallenge);
+        }
+        let origin = field(lines.next(), "origin=")?;
+        let username = field(lines.next(), "username=")?;
+        validate_username(username)?;
+        let nonce = decode_hex(field(lines.next(), "nonce=")?)?;
+        let issued_at = parse_time(field(lines.next(), "issued-at=")?)?;
+        let expires_at = parse_time(field(lines.next(), "expires-at=")?)?;
+        if lines.next().is_some() || origin.is_empty() || origin.contains(['\r', '\n']) {
+            return Err(AuthError::MalformedChallenge);
+        }
+        Ok(Self {
+            origin,
+            username,
+            nonce,
+            issued_at,
+            expires_at,
+        })
+    }
 }
 
 impl<'a> ChallengeFields<'a> {

src/http/mod.rs

Mode 100644100644; object 2912cce06806f25578c1a4d4

@@ -109,6 +109,7 @@
     telemetry: Telemetry,
     key_reloader: Option<AccountKeyReloader>,
     login: Option<WebLoginService>,
+    ssh_login_target: Option<SshLoginTarget>,
     repositories: Option<RepositoryService>,
     issues: Option<IssueService>,
     pull_requests: Option<PullRequestService>,
@@ -117,6 +118,23 @@
     watches: Option<WatchService>,
     readiness: Option<ListenerReadiness>,
     secure_cookies: bool,
+}
+
+#[derive(Clone)]
+struct SshLoginTarget {
+    host: String,
+    port: u16,
+}
+
+impl SshLoginTarget {
+    fn command(&self, secret: &str) -> String {
+        format!(
+            "ssh -p {} {} login {}",
+            self.port,
+            shell_word(&self.host),
+            secret
+        )
+    }
 }
 
 #[derive(Clone)]
@@ -175,6 +193,7 @@
                 telemetry: Telemetry::default(),
                 key_reloader: None,
                 login: None,
+                ssh_login_target: None,
                 repositories: None,
                 issues: None,
                 pull_requests: None,
@@ -225,6 +244,7 @@
         let jobs = Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS));
         let requests = Arc::new(Semaphore::new(config.max_connections));
         let max_request_bytes = config.max_request_bytes;
+        let ssh_login_target = parse_ssh_login_target(&config.ssh_clone_base)?;
         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))
@@ -260,6 +280,7 @@
                 telemetry,
                 key_reloader,
                 login: Some(login),
+                ssh_login_target: Some(ssh_login_target),
                 repositories: Some(repositories),
                 issues: Some(issues),
                 pull_requests: Some(pull_requests),
@@ -331,6 +352,7 @@
         telemetry: Telemetry::default(),
         key_reloader: None,
         login: None,
+        ssh_login_target: None,
         repositories: None,
         issues: None,
         pull_requests: None,
@@ -373,12 +395,24 @@
                 .layer(DefaultBodyLimit::max(32 * 1024)),
         )
         .route(
+            "/login/ssh",
+            axum::routing::post(login_ssh).layer(DefaultBodyLimit::max(1024)),
+        )
+        .route(
+            "/login/ssh/complete",
+            axum::routing::post(login_ssh_complete).layer(DefaultBodyLimit::max(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(
+            "/login/challenge.txt",
+            axum::routing::post(login_challenge_download).layer(DefaultBodyLimit::max(16 * 1024)),
         )
         .route("/account", get(account_page))
         .route(
@@ -421,6 +455,31 @@
         Duration::from_secs(60),
         MAX_LOGIN_CLIENTS,
     )
+}
+
+fn parse_ssh_login_target(value: &str) -> Result<SshLoginTarget, WebError> {
+    let url = url::Url::parse(value).map_err(WebError::CanonicalUrl)?;
+    let host = url.host_str().ok_or_else(|| {
+        WebError::PublicConfig("the SSH clone URL does not have a host".to_owned())
+    })?;
+    Ok(SshLoginTarget {
+        host: if host.contains(':') {
+            format!("[{host}]")
+        } else {
+            host.to_owned()
+        },
+        port: url.port().unwrap_or(22),
+    })
+}
+
+fn shell_word(value: &str) -> String {
+    if value.bytes().all(|byte| {
+        byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b'[' | b']' | b':')
+    }) {
+        value.to_owned()
+    } else {
+        format!("'{}'", value.replace('\'', "'\\''"))
+    }
 }
 
 async fn request_guard(
@@ -694,15 +753,14 @@
             "Login attempt limit exceeded.\n",
         );
     }
-    let fields = match parse_named_form(&headers, &body, &["username", "public-key"]) {
+    let fields = match parse_named_form(&headers, &body, &["username"]) {
         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;
+    let result = login_job(state, move |login| login.issue(&username)).await;
     match result {
         Ok(challenge) => {
             let mut response = render(
@@ -710,9 +768,10 @@
                 &LoginChallengeTemplate {
                     request_id: &request_id.0,
                     username: &display_username,
-                    public_key: &challenge.public_key,
                     challenge: &challenge.challenge,
                     login_csrf: &challenge.login_csrf,
+                    error: "",
+                    has_error: false,
                     signed_in: false,
                 },
             );
@@ -729,7 +788,124 @@
         Err(_) => login_error(
             &request_id.0,
             &display_username,
-            "The username or SSH public key is not valid.",
+            "The username is not valid or the account is not active.",
+        ),
+    }
+}
+
+async fn login_ssh(
+    State(state): State<WebState>,
+    Extension(peer): Extension<ClientAddress>,
+    Extension(request_id): Extension<RequestId>,
+) -> Response {
+    if !allow_login_attempt(&state, peer) {
+        return limit_response(
+            StatusCode::TOO_MANY_REQUESTS,
+            "Login attempt limit exceeded.\n",
+        );
+    }
+    let Some(target) = state.ssh_login_target.clone() else {
+        return login_error(
+            &request_id.0,
+            "",
+            "SSH login is not available on this instance.",
+        );
+    };
+    let secure = state.secure_cookies;
+    match login_job(state, |login| login.issue_approval()).await {
+        Ok(approval) => {
+            let command = target.command(&approval.secret);
+            let mut response = render(
+                StatusCode::OK,
+                &LoginSshTemplate {
+                    request_id: &request_id.0,
+                    command: &command,
+                    secret: &approval.secret,
+                    login_csrf: &approval.login_csrf,
+                    error: "",
+                    has_error: false,
+                    signed_in: false,
+                },
+            );
+            append_cookie(
+                response.headers_mut(),
+                LOGIN_CSRF_COOKIE,
+                &approval.login_csrf,
+                true,
+                secure,
+                5 * 60,
+            );
+            response
+        }
+        Err(_) => login_error(
+            &request_id.0,
+            "",
+            "The SSH login request could not be created.",
+        ),
+    }
+}
+
+async fn login_ssh_complete(
+    State(state): State<WebState>,
+    Extension(peer): Extension<ClientAddress>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    if !allow_login_attempt(&state, peer) {
+        return limit_response(
+            StatusCode::TOO_MANY_REQUESTS,
+            "Login attempt limit exceeded.\n",
+        );
+    }
+    let fields = match parse_named_form(&headers, &body, &["secret", "login-csrf"]) {
+        Ok(fields) => fields,
+        Err(()) => {
+            return rejected_login(state, &request_id.0, "", "The login response is not valid.")
+                .await;
+        }
+    };
+    let secret = fields[0].clone();
+    let login_csrf = fields[1].clone();
+    if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
+        return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
+    }
+    let secure = state.secure_cookies;
+    let correlation_id = request_id.0.clone();
+    let result = login_job(state.clone(), {
+        let secret = secret.clone();
+        let login_csrf = login_csrf.clone();
+        move |login| login.complete_approval(&secret, &login_csrf, &correlation_id)
+    })
+    .await;
+    match result {
+        Ok(session) => login_success(session, secure),
+        Err(SessionError::Store(StoreError::LoginApprovalPending)) => {
+            let Some(target) = state.ssh_login_target else {
+                return login_error(
+                    &request_id.0,
+                    "",
+                    "SSH login is not available on this instance.",
+                );
+            };
+            let command = target.command(&secret);
+            render(
+                StatusCode::CONFLICT,
+                &LoginSshTemplate {
+                    request_id: &request_id.0,
+                    command: &command,
+                    secret: &secret,
+                    login_csrf: &login_csrf,
+                    error: "Authenticate with SSH before you continue.",
+                    has_error: true,
+                    signed_in: false,
+                },
+            )
+        }
+        Err(_) => login_error(
+            &request_id.0,
+            "",
+            "The SSH login request is invalid or has expired.",
         ),
     }
 }
@@ -750,13 +926,7 @@
     let fields = match parse_named_form(
         &headers,
         &body,
-        &[
-            "username",
-            "public-key",
-            "challenge",
-            "signature",
-            "login-csrf",
-        ],
+        &["username", "challenge", "signature", "login-csrf"],
     ) {
         Ok(fields) => fields,
         Err(()) => {
@@ -765,10 +935,9 @@
         }
     };
     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();
+    let challenge = fields[1].clone();
+    let signature = fields[2].clone();
+    let login_csrf = fields[3].clone();
     if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(login_csrf.as_str()) {
         return rejected_login(
             state,
@@ -782,12 +951,31 @@
         state,
         &request_id.0,
         username,
-        public_key,
         challenge,
         signature,
         login_csrf,
     )
     .await
+}
+
+async fn login_challenge_download(headers: HeaderMap, body: Bytes) -> Response {
+    let fields = match parse_named_form(&headers, &body, &["username", "challenge", "login-csrf"]) {
+        Ok(fields) => fields,
+        Err(()) => return limit_response(StatusCode::BAD_REQUEST, "The request is not valid.\n"),
+    };
+    if cookie(&headers, LOGIN_CSRF_COOKIE).as_deref() != Some(fields[2].as_str()) {
+        return limit_response(StatusCode::FORBIDDEN, "The request is not authorized.\n");
+    }
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
+        .header(
+            header::CONTENT_DISPOSITION,
+            "attachment; filename=\"tit-login-challenge.txt\"",
+        )
+        .header(header::CACHE_CONTROL, "no-store")
+        .body(Body::from(normalize_browser_newlines(fields[1].clone())))
+        .expect("the login challenge download is valid")
 }
 
 async fn login_verify_file(
@@ -814,7 +1002,6 @@
     };
     let mut multipart = multra::Multipart::new(body.into_data_stream(), boundary);
     let mut username = None;
-    let mut public_key = None;
     let mut challenge = None;
     let mut signature = None;
     let mut login_csrf = None;
@@ -834,7 +1021,6 @@
         };
         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(),
@@ -849,8 +1035,8 @@
             }
         }
     }
-    let (Some(username), Some(public_key), Some(challenge), Some(signature), Some(login_csrf)) =
-        (username, public_key, challenge, signature, login_csrf)
+    let (Some(username), Some(challenge), Some(signature), Some(login_csrf)) =
+        (username, challenge, signature, login_csrf)
     else {
         return rejected_login(state, &request_id.0, "", "The login response is not valid.").await;
     };
@@ -867,7 +1053,6 @@
         state,
         &request_id.0,
         username,
-        public_key,
         challenge,
         signature,
         login_csrf,
@@ -879,19 +1064,19 @@
     state: WebState,
     request_id: &str,
     username: String,
-    public_key: String,
     challenge: String,
     signature: String,
     login_csrf: String,
 ) -> Response {
     let display_username = username.clone();
     let challenge = normalize_browser_newlines(challenge);
+    let display_challenge = challenge.clone();
+    let display_login_csrf = login_csrf.clone();
     let secure = state.secure_cookies;
     let correlation_id = request_id.to_owned();
     let result = login_job(state, move |login| {
         login.verify(
             &username,
-            &public_key,
             &challenge,
             &signature,
             &login_csrf,
@@ -900,45 +1085,54 @@
     })
     .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.",
+        Ok(session) => login_success(session, secure),
+        Err(_) => render(
+            StatusCode::BAD_REQUEST,
+            &LoginChallengeTemplate {
+                request_id,
+                username: &display_username,
+                challenge: &display_challenge,
+                login_csrf: &display_login_csrf,
+                error: "The signature is not valid or the challenge has expired.",
+                has_error: true,
+                signed_in: false,
+            },
         ),
     }
+}
+
+fn login_success(session: crate::session::NewSession, secure: bool) -> Response {
+    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
 }
 
 fn normalize_browser_newlines(value: String) -> String {
@@ -1786,9 +1980,22 @@
 struct LoginChallengeTemplate<'a> {
     request_id: &'a str,
     username: &'a str,
-    public_key: &'a str,
     challenge: &'a str,
     login_csrf: &'a str,
+    error: &'a str,
+    has_error: bool,
+    signed_in: bool,
+}
+
+#[derive(Template)]
+#[template(path = "login-ssh.html")]
+struct LoginSshTemplate<'a> {
+    request_id: &'a str,
+    command: &'a str,
+    secret: &'a str,
+    login_csrf: &'a str,
+    error: &'a str,
+    has_error: bool,
     signed_in: bool,
 }
 
@@ -1818,6 +2025,8 @@
     Public(#[from] public::PublicWebError),
     #[error("canonical URL error: {0}")]
     CanonicalUrl(url::ParseError),
+    #[error("public Web configuration error: {0}")]
+    PublicConfig(String),
     #[error(transparent)]
     Session(#[from] SessionError),
     #[error("HTTP server task failed")]

src/serve.rs

Mode 100644100644; object 7af25391e3daf70be4937ee7

@@ -19,7 +19,8 @@
 use crate::maintenance::MaintenanceGate;
 use crate::policy::PolicyError;
 use crate::pull_request::{PullRequestError, PullRequestService};
-use crate::ssh::{AuthorizedSshKeys, RunningSshServer, SshServerError};
+use crate::session::{SessionError, WebLoginService};
+use crate::ssh::{AuthorizedSshKeys, LoginApprover, RunningSshServer, SshServerError};
 use crate::store::{Store, StoreError};
 use crate::telemetry::Telemetry;
 
@@ -43,7 +44,7 @@
     )?;
     drop(store);
 
-    let accounts = AccountService::new(database);
+    let accounts = AccountService::new(database.clone());
     let backup = OnlineBackupService::new(
         config.instance_dir.clone(),
         config.config_path.clone(),
@@ -55,6 +56,14 @@
     let readiness = ListenerReadiness::default();
 
     let (http_clone_base, ssh_clone_base) = clone_bases(config)?;
+    let login = WebLoginService::new(database, &config.public_url)?;
+    let login_approver: LoginApprover =
+        std::sync::Arc::new(move |secret, username, fingerprint| {
+            login
+                .approve(secret, username, fingerprint)
+                .map(|approved| (approved.origin, approved.username))
+                .map_err(|_| ())
+        });
     let host_key = load_or_create_host_key(&config.instance_dir)?;
     let reload_keys = {
         let authorized_keys = authorized_keys.clone();
@@ -89,6 +98,7 @@
         host_key,
         usize::try_from(config.max_connections).map_err(|_| ServeError::ConnectionLimit)?,
         telemetry.clone(),
+        login_approver,
     )
     .await
     {
@@ -285,6 +295,8 @@
     Web(#[from] WebError),
     #[error(transparent)]
     Ssh(#[from] SshServerError),
+    #[error(transparent)]
+    Session(#[from] SessionError),
     #[error(transparent)]
     Control(#[from] ControlError),
     #[error("cannot wait for a shutdown signal: {0}")]

src/session.rs

Mode 100644100644; object 7e140161e6426249182fd438

@@ -7,10 +7,11 @@
 use url::Url;
 
 use crate::auth::{
-    AuthError, SshPublicKey, format_login_challenge, login_origin, verify_login_challenge,
+    AuthError, format_keyless_login_challenge, login_origin, verify_keyless_login_challenge,
 };
 use crate::store::{
-    NewAuditEvent, NewLoginNonce, NewWebSession, Store, StoreError, WebSessionRecord,
+    ApproveLogin, NewApprovedWebSession, NewAuditEvent, NewLoginApproval, NewLoginNonce,
+    NewWebSession, Store, StoreError, WebSessionRecord,
 };
 
 const CHALLENGE_LIFETIME_SECONDS: u64 = 5 * 60;
@@ -31,12 +32,8 @@
         })
     }
 
-    pub(crate) fn issue(
-        &self,
-        username: &str,
-        public_key: &str,
-    ) -> Result<IssuedChallenge, SessionError> {
-        let key = SshPublicKey::parse(public_key)?;
+    pub(crate) fn issue(&self, username: &str) -> Result<IssuedChallenge, SessionError> {
+        crate::auth::validate_username(username)?;
         let created_at = now()?;
         let issued_at = u64::try_from(created_at).map_err(|_| SessionError::Clock)?;
         let expires_at = issued_at
@@ -48,28 +45,90 @@
             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(
+            challenge: format_keyless_login_challenge(
                 &self.origin,
                 username,
-                &key,
                 &nonce,
                 issued_at,
                 expires_at,
             ),
-            public_key: key.canonical().to_owned(),
             login_csrf,
+        })
+    }
+
+    pub(crate) fn issue_approval(&self) -> Result<IssuedLoginApproval, SessionError> {
+        let created_at = now()?;
+        let expires_at = created_at
+            .checked_add(
+                i64::try_from(CHALLENGE_LIFETIME_SECONDS).map_err(|_| SessionError::Clock)?,
+            )
+            .ok_or(SessionError::Clock)?;
+        let secret = encode_hex(&random_bytes()?);
+        let login_csrf = encode_hex(&random_bytes()?);
+        Store::open(&self.database)?.create_login_approval(&NewLoginApproval {
+            secret_hash: &hash(secret.as_bytes()),
+            csrf_hash: &hash(login_csrf.as_bytes()),
+            created_at,
+            expires_at,
+        })?;
+        Ok(IssuedLoginApproval { secret, login_csrf })
+    }
+
+    pub(crate) fn approve(
+        &self,
+        secret: &str,
+        username: &str,
+        fingerprint: &str,
+    ) -> Result<ApprovedLogin, SessionError> {
+        validate_token(secret)?;
+        Store::open(&self.database)?.approve_login(&ApproveLogin {
+            secret_hash: &hash(secret.as_bytes()),
+            username,
+            fingerprint,
+            approved_at: now()?,
+        })?;
+        Ok(ApprovedLogin {
+            origin: self.origin.clone(),
+            username: username.to_owned(),
+        })
+    }
+
+    pub(crate) fn complete_approval(
+        &self,
+        secret: &str,
+        login_csrf: &str,
+        correlation_id: &str,
+    ) -> Result<NewSession, SessionError> {
+        validate_token(secret)?;
+        validate_token(login_csrf)?;
+        let created_at = now()?;
+        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_approval(&NewApprovedWebSession {
+            secret_hash: &hash(secret.as_bytes()),
+            login_csrf_hash: &hash(login_csrf.as_bytes()),
+            session_hash: &hash(session.as_bytes()),
+            csrf_hash: &hash(csrf.as_bytes()),
+            created_at,
+            expires_at,
+            correlation_id,
+        })?;
+        Ok(NewSession {
+            token: session,
+            csrf,
         })
     }
 
     pub(crate) fn verify(
         &self,
         username: &str,
-        public_key: &str,
         challenge: &str,
         signature: &str,
         login_csrf: &str,
@@ -78,13 +137,11 @@
         let created_at = now()?;
         let result = (|| {
             validate_token(login_csrf)?;
-            let key = SshPublicKey::parse(public_key)?;
-            let verified = verify_login_challenge(
+            let verified = verify_keyless_login_challenge(
                 &self.origin,
                 challenge,
                 signature,
                 username,
-                &key,
                 u64::try_from(created_at).map_err(|_| SessionError::Clock)?,
             )?;
             let session = encode_hex(&random_bytes()?);
@@ -177,8 +234,17 @@
 
 pub(crate) struct IssuedChallenge {
     pub(crate) challenge: String,
-    pub(crate) public_key: String,
     pub(crate) login_csrf: String,
+}
+
+pub(crate) struct IssuedLoginApproval {
+    pub(crate) secret: String,
+    pub(crate) login_csrf: String,
+}
+
+pub(crate) struct ApprovedLogin {
+    pub(crate) origin: String,
+    pub(crate) username: String,
 }
 
 pub(crate) struct NewSession {

src/ssh.rs

Mode 100644100644; object fefa3ac234f67e917cbf9a63

@@ -29,6 +29,7 @@
 
 const VERSION_COMMAND: &[u8] = b"tit --version";
 const HELP_COMMAND: &[u8] = b"help";
+const LOGIN_COMMAND_PREFIX: &[u8] = b"login ";
 const GIT_PROTOCOL_VARIABLE: &str = "GIT_PROTOCOL";
 const MAX_RECEIVE_PACK_BYTES: u64 = 128 * 1024 * 1024;
 const MAX_REPOSITORY_COMMAND_BYTES: usize = 512;
@@ -46,6 +47,7 @@
 Available tit SSH commands:
   help
   tit --version
+  login ONE-TIME-SECRET
   repo create NAME [--output human|json]
   issue list OWNER/REPOSITORY [--output human|json]
   issue create OWNER/REPOSITORY [--output human|json]
@@ -54,6 +56,27 @@
 Git clients can also use git-upload-pack and git-receive-pack.
 ";
 const HELP_GUIDANCE: &str = "Use 'help' to list the available commands.";
+
+pub(crate) type LoginApprover =
+    Arc<dyn Fn(&str, &str, &str) -> Result<(String, String), ()> + Send + Sync>;
+
+struct SshRuntime {
+    max_connections: usize,
+    telemetry: Telemetry,
+    login: Option<LoginApprover>,
+}
+
+fn parse_login_command(command: &[u8]) -> Option<String> {
+    let secret = command.strip_prefix(LOGIN_COMMAND_PREFIX)?;
+    if secret.len() != 64
+        || !secret
+            .iter()
+            .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
+    {
+        return None;
+    }
+    String::from_utf8(secret.to_vec()).ok()
+}
 
 pub(crate) struct RunningSshServer {
     address: SocketAddr,
@@ -164,6 +187,7 @@
         host_key: PrivateKey,
         max_connections: usize,
         telemetry: Telemetry,
+        login: LoginApprover,
     ) -> Result<Self, SshServerError> {
         recover_pushes(&repositories).await?;
         Self::start_inner_with_keys(
@@ -172,8 +196,11 @@
             &[],
             Some(repositories),
             host_key,
-            max_connections,
-            telemetry,
+            SshRuntime {
+                max_connections,
+                telemetry,
+                login: Some(login),
+            },
         )
         .await
     }
@@ -209,8 +236,11 @@
             writable_keys,
             repositories,
             host_key,
-            1024,
-            Telemetry::default(),
+            SshRuntime {
+                max_connections: 1024,
+                telemetry: Telemetry::default(),
+                login: None,
+            },
         )
         .await
     }
@@ -221,8 +251,7 @@
         writable_keys: &[SshPublicKey],
         repositories: Option<GitRepositories>,
         host_key: PrivateKey,
-        max_connections: usize,
-        telemetry: Telemetry,
+        runtime: SshRuntime,
     ) -> Result<Self, SshServerError> {
         let listener = TcpListener::bind(address).await?;
         let address = listener.local_addr()?;
@@ -259,13 +288,14 @@
             writable_keys,
             audit: Arc::clone(&audit),
             repositories: repositories.map(Arc::new),
-            connections: Arc::new(Semaphore::new(max_connections)),
+            login: runtime.login,
+            connections: Arc::new(Semaphore::new(runtime.max_connections)),
             attempts: AttemptLimiter::new(
                 SSH_ATTEMPTS_PER_MINUTE,
                 Duration::from_secs(60),
                 MAX_SSH_CLIENTS,
             ),
-            telemetry,
+            telemetry: runtime.telemetry,
         };
         let (handle_sender, handle_receiver) = oneshot::channel();
         let task = tokio::spawn(async move {
@@ -349,6 +379,7 @@
     writable_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
     repositories: Option<Arc<GitRepositories>>,
+    login: Option<LoginApprover>,
     connections: Arc<Semaphore>,
     attempts: AttemptLimiter<IpAddr>,
     telemetry: Telemetry,
@@ -365,6 +396,7 @@
             writable_keys: Arc::clone(&self.writable_keys),
             audit: Arc::clone(&self.audit),
             repositories: self.repositories.clone(),
+            login: self.login.clone(),
             protocol: ProtocolVersion::V0,
             exec_channels: HashMap::new(),
             authenticated_identity: None,
@@ -386,6 +418,7 @@
     writable_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
     repositories: Option<Arc<GitRepositories>>,
+    login: Option<LoginApprover>,
     protocol: ProtocolVersion,
     exec_channels: HashMap<ChannelId, ExecChannel>,
     authenticated_identity: Option<SshIdentity>,
@@ -597,6 +630,48 @@
             session.channel_success(channel)?;
             session.data(channel, HELP_TEXT.as_bytes())?;
             finish_git_channel(channel, 0, session)?;
+        } else if command.starts_with(LOGIN_COMMAND_PREFIX) {
+            self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
+            session.channel_success(channel)?;
+            let result = match (
+                parse_login_command(command),
+                self.active_identity(),
+                self.login.clone(),
+            ) {
+                (Some(secret), Some(identity), Some(login)) => {
+                    let username = identity.username;
+                    let fingerprint = identity.fingerprint;
+                    tokio::task::spawn_blocking(move || login(&secret, &username, &fingerprint))
+                        .await
+                        .ok()
+                        .and_then(Result::ok)
+                }
+                _ => None,
+            };
+            match result {
+                Some((origin, username)) => {
+                    session.data(
+                        channel,
+                        format!(
+                            "Browser login approved.\nOrigin: {}\nAccount: {}\nReturn to your browser and select Continue.\n",
+                            origin, username
+                        )
+                        .into_bytes(),
+                    )?;
+                    finish_git_channel(channel, 0, session)?;
+                }
+                None => {
+                    session.extended_data(
+                        channel,
+                        1,
+                        format!(
+                            "tit: The login approval is invalid or expired.\n{HELP_GUIDANCE}\n"
+                        )
+                        .into_bytes(),
+                    )?;
+                    finish_git_channel(channel, 1, session)?;
+                }
+            }
         } else if is_repository_command(command) {
             self.audit.accepted_exec.fetch_add(1, Ordering::Relaxed);
             session.channel_success(channel)?;

src/store/migrations/018_streamlined_login.sql

Mode 100644; object 9a62162cb0b1

@@ -1,0 +1,44 @@
+ALTER TABLE login_nonce RENAME TO login_nonce_v17;
+
+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 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;
+
+INSERT INTO login_nonce
+    (id, nonce_hash, csrf_hash, account_id, ssh_public_key_id,
+     created_at, expires_at, consumed_at)
+SELECT id, nonce_hash, csrf_hash, account_id, ssh_public_key_id,
+       created_at, expires_at, consumed_at
+FROM login_nonce_v17;
+
+DROP TABLE login_nonce_v17;
+
+CREATE INDEX login_nonce_active
+ON login_nonce (expires_at, consumed_at);
+
+CREATE TABLE ssh_login_approval (
+    id INTEGER PRIMARY KEY,
+    secret_hash BLOB NOT NULL UNIQUE CHECK (length(secret_hash) = 32),
+    csrf_hash BLOB NOT NULL CHECK (length(csrf_hash) = 32),
+    account_id INTEGER REFERENCES account (id) ON DELETE RESTRICT,
+    ssh_public_key_id INTEGER 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),
+    approved_at INTEGER CHECK (approved_at IS NULL OR approved_at >= created_at),
+    consumed_at INTEGER CHECK (consumed_at IS NULL OR consumed_at >= created_at),
+    CHECK (
+        (account_id IS NULL AND ssh_public_key_id IS NULL AND approved_at IS NULL)
+        OR
+        (account_id IS NOT NULL AND ssh_public_key_id IS NOT NULL AND approved_at IS NOT NULL)
+    )
+) STRICT;
+
+CREATE INDEX ssh_login_approval_active
+ON ssh_login_approval (expires_at, consumed_at);

src/store/mod.rs

Mode 100644100644; object 3dd39f7efb26078d1c75d146

@@ -14,7 +14,7 @@
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
 const MAX_ACTIVE_FEED_TOKENS: i64 = 32;
-const SCHEMA_VERSION: i64 = 17;
+const SCHEMA_VERSION: i64 = 18;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -24,7 +24,7 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 17] = [
+const MIGRATIONS: [&str; 18] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
@@ -42,6 +42,7 @@
     include_str!("migrations/015_pull_requests.sql"),
     include_str!("migrations/016_pull_request_reviews.sql"),
     include_str!("migrations/017_pull_request_merges.sql"),
+    include_str!("migrations/018_streamlined_login.sql"),
 ];
 
 #[allow(
@@ -96,6 +97,10 @@
     LoginNonceLimit,
     #[error("login challenge is invalid, expired, or already used")]
     InvalidLoginChallenge,
+    #[error("SSH login approval is invalid, expired, or already used")]
+    InvalidLoginApproval,
+    #[error("SSH login approval is waiting for SSH authentication")]
+    LoginApprovalPending,
     #[error("Web session is invalid or expired")]
     InvalidSession,
     #[error("repository does not exist: {0}/{1}")]
@@ -951,16 +956,12 @@
         if active >= 1_024 {
             return Err(StoreError::LoginNonceLimit);
         }
-        let key = transaction
+        let account_id = 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)?)),
+                "SELECT id FROM account
+                 WHERE username = ?1 AND state = 'active'",
+                [nonce.username],
+                |row| row.get::<_, i64>(0),
             )
             .optional()?
             .ok_or(StoreError::LoginIdentity)?;
@@ -968,12 +969,11 @@
             "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)",
+             VALUES (?1, ?2, ?3, NULL, ?4, ?5, NULL)",
             rusqlite::params![
                 nonce.nonce_hash,
                 nonce.csrf_hash,
-                key.0,
-                key.1,
+                account_id,
                 nonce.created_at,
                 nonce.expires_at,
             ],
@@ -998,11 +998,13 @@
                 "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
+                 JOIN ssh_public_key ON ssh_public_key.account_id = login_nonce.account_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.ssh_public_key_id IS NULL
+                        OR login_nonce.ssh_public_key_id = ssh_public_key.id)
                    AND login_nonce.csrf_hash = ?5",
                 rusqlite::params![
                     login.nonce_hash,
@@ -1048,6 +1050,146 @@
         )?;
         transaction.commit()?;
         Ok(())
+    }
+
+    pub(crate) fn create_login_approval(
+        &mut self,
+        approval: &NewLoginApproval<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        transaction.execute(
+            "DELETE FROM ssh_login_approval
+             WHERE expires_at < ?1 OR consumed_at IS NOT NULL",
+            [approval.created_at],
+        )?;
+        let active: i64 =
+            transaction.query_row("SELECT count(*) FROM ssh_login_approval", [], |row| {
+                row.get(0)
+            })?;
+        if active >= 1_024 {
+            return Err(StoreError::LoginNonceLimit);
+        }
+        transaction.execute(
+            "INSERT INTO ssh_login_approval
+             (secret_hash, csrf_hash, account_id, ssh_public_key_id,
+              created_at, expires_at, approved_at, consumed_at)
+             VALUES (?1, ?2, NULL, NULL, ?3, ?4, NULL, NULL)",
+            rusqlite::params![
+                approval.secret_hash,
+                approval.csrf_hash,
+                approval.created_at,
+                approval.expires_at,
+            ],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn approve_login(&mut self, approval: &ApproveLogin<'_>) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let (account_id, key_id) = 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![approval.username, approval.fingerprint],
+                |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
+            )
+            .optional()?
+            .ok_or(StoreError::LoginIdentity)?;
+        let changed = transaction.execute(
+            "UPDATE ssh_login_approval
+             SET account_id = ?2, ssh_public_key_id = ?3, approved_at = ?4
+             WHERE secret_hash = ?1 AND account_id IS NULL
+               AND approved_at IS NULL AND consumed_at IS NULL AND expires_at >= ?4",
+            rusqlite::params![
+                approval.secret_hash,
+                account_id,
+                key_id,
+                approval.approved_at,
+            ],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::InvalidLoginApproval);
+        }
+        transaction.commit()?;
+        Ok(())
+    }
+
+    pub(crate) fn consume_login_approval(
+        &mut self,
+        login: &NewApprovedWebSession<'_>,
+    ) -> Result<String, StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let approval = transaction
+            .query_row(
+                "SELECT account.id, account.username, ssh_login_approval.approved_at
+                 FROM ssh_login_approval
+                 LEFT JOIN account ON account.id = ssh_login_approval.account_id
+                 LEFT JOIN ssh_public_key
+                   ON ssh_public_key.id = ssh_login_approval.ssh_public_key_id
+                 WHERE ssh_login_approval.secret_hash = ?1
+                   AND ssh_login_approval.csrf_hash = ?2
+                   AND ssh_login_approval.consumed_at IS NULL
+                   AND ssh_login_approval.expires_at >= ?3
+                   AND (ssh_login_approval.account_id IS NULL
+                        OR (account.state = 'active' AND ssh_public_key.revoked_at IS NULL))",
+                rusqlite::params![login.secret_hash, login.login_csrf_hash, login.created_at,],
+                |row| {
+                    Ok((
+                        row.get::<_, Option<i64>>(0)?,
+                        row.get::<_, Option<String>>(1)?,
+                        row.get::<_, Option<i64>>(2)?,
+                    ))
+                },
+            )
+            .optional()?
+            .ok_or(StoreError::InvalidLoginApproval)?;
+        let (Some(account_id), Some(username), Some(_)) = approval else {
+            return Err(StoreError::LoginApprovalPending);
+        };
+        let changed = transaction.execute(
+            "UPDATE ssh_login_approval SET consumed_at = ?2
+             WHERE secret_hash = ?1 AND consumed_at IS NULL",
+            rusqlite::params![login.secret_hash, login.created_at],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::InvalidLoginApproval);
+        }
+        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,
+            ],
+        )?;
+        insert_audit_event(
+            &transaction,
+            &NewAuditEvent {
+                action: "login",
+                actor: &username,
+                target: &username,
+                outcome: "success",
+                correlation_id: login.correlation_id,
+                created_at: login.created_at,
+            },
+        )?;
+        transaction.commit()?;
+        Ok(username)
     }
 
     #[allow(
@@ -3356,9 +3498,32 @@
     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,
+}
+
+pub(crate) struct NewLoginApproval<'a> {
+    pub(crate) secret_hash: &'a [u8; 32],
+    pub(crate) csrf_hash: &'a [u8; 32],
+    pub(crate) created_at: i64,
+    pub(crate) expires_at: i64,
+}
+
+pub(crate) struct ApproveLogin<'a> {
+    pub(crate) secret_hash: &'a [u8; 32],
+    pub(crate) username: &'a str,
+    pub(crate) fingerprint: &'a str,
+    pub(crate) approved_at: i64,
+}
+
+pub(crate) struct NewApprovedWebSession<'a> {
+    pub(crate) secret_hash: &'a [u8; 32],
+    pub(crate) login_csrf_hash: &'a [u8; 32],
+    pub(crate) session_hash: &'a [u8; 32],
+    pub(crate) csrf_hash: &'a [u8; 32],
+    pub(crate) created_at: i64,
+    pub(crate) expires_at: i64,
+    pub(crate) correlation_id: &'a str,
 }
 
 #[allow(

templates/login-challenge.html

Mode 100644100644; object 70e0fc3ab2c482c2546cc31a

@@ -2,26 +2,22 @@
 {% 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>
+  <p>Download the challenge. Sign it with <code>ssh-keygen -Y sign -n tit-auth -f KEY_FILE tit-login-challenge.txt</code>.</p>
+{% if has_error %}
+  <p class="error" role="alert">{{ error }}</p>
+{% endif %}
+  <form action="/login/challenge.txt" method="post">
+    <input type="hidden" name="username" value="{{ username }}">
+    <input type="hidden" name="challenge" value="{{ challenge }}">
+    <input type="hidden" name="login-csrf" value="{{ login_csrf }}">
+    <button type="submit">Download challenge</button>
+  </form>
   <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">
@@ -30,4 +26,17 @@
     </div>
     <button type="submit">Upload and log in</button>
   </form>
+  <details>
+    <summary>Paste the signature instead</summary>
+    <form action="/login/verify" method="post">
+      <input type="hidden" name="username" value="{{ username }}">
+      <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>
+  </details>
 {% endblock %}

templates/login-ssh.html

Mode 100644; object ccb4c60d58a1

@@ -1,0 +1,17 @@
+{% extends "base.html" %}
+{% block title %}Approve login · tit{% endblock %}
+{% block content %}
+  <h1>Approve login with SSH</h1>
+  <p>Run this command in a terminal. Use an SSH key that is active on your account.</p>
+  <pre><code>{{ command }}</code></pre>
+  <p>The SSH response shows the site origin and account. Check both values before you continue.</p>
+{% if has_error %}
+  <p class="error" role="alert">{{ error }}</p>
+{% endif %}
+  <form action="/login/ssh/complete" method="post">
+    <input type="hidden" name="secret" value="{{ secret }}">
+    <input type="hidden" name="login-csrf" value="{{ login_csrf }}">
+    <button type="submit">Continue</button>
+  </form>
+  <p><a href="/login">Cancel</a></p>
+{% endblock %}

templates/login.html

Mode 100644100644; object 467932f764465ed95d1374bc

@@ -2,19 +2,22 @@
 {% block title %}Log in · tit{% endblock %}
 {% block content %}
   <h1>Log in</h1>
-  <p>Enter your username and one active SSH public key.</p>
+  <p>Use an SSH key that is active on your account.</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 action="/login/ssh" method="post">
+    <button type="submit">Log in with SSH</button>
   </form>
+  <details>
+    <summary>Sign a challenge instead</summary>
+    <p>Use this method when you cannot connect to the tit SSH service.</p>
+    <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>
+      <button type="submit">Create challenge</button>
+    </form>
+  </details>
 {% endblock %}

tests/auth.rs

Mode 100644100644; object 7dba0784dc1fcc338bd31d0b

@@ -7,7 +7,10 @@
 use std::sync::{Arc, Barrier};
 use std::thread;
 
-use auth::{AuthError, LoginChallenges, SshPublicKey};
+use auth::{
+    AuthError, LoginChallenges, SshPublicKey, format_keyless_login_challenge,
+    verify_keyless_login_challenge,
+};
 use tempfile::TempDir;
 use url::Url;
 
@@ -97,6 +100,33 @@
         assert_eq!(verified.username, "alice");
         assert_eq!(verified.fingerprint, key.fingerprint());
     }
+}
+
+#[test]
+fn derives_the_key_from_a_keyless_stock_sshsig_envelope() {
+    let directory = TempDir::new().expect("create a key directory");
+    let private_key = directory.path().join("keyless");
+    generate_key(&private_key, KeyFixture::Ed25519);
+    let key = parse_public_key(&private_key).expect("parse the public key");
+    let nonce = [7_u8; 32];
+    let challenge = format_keyless_login_challenge(
+        "https://tit.example",
+        "alice",
+        &nonce,
+        ISSUED_AT,
+        ISSUED_AT + LIFETIME,
+    );
+    let signature = sign(&directory, "keyless", &private_key, "tit-auth", &challenge);
+
+    let verified = verify_keyless_login_challenge(
+        "https://tit.example",
+        &challenge,
+        &signature,
+        "alice",
+        ISSUED_AT + 1,
+    )
+    .expect("verify a keyless challenge");
+    assert_eq!(verified.fingerprint, key.fingerprint());
 }
 
 #[test]

tests/cli.rs

Mode 100644100644; object de47307ba7bc89350e8232df

@@ -26,7 +26,8 @@
     include_str!("../src/store/migrations/015_pull_requests.sql"),
     include_str!("../src/store/migrations/016_pull_request_reviews.sql"),
     include_str!("../src/store/migrations/017_pull_request_merges.sql"),
-    "PRAGMA user_version = 17;\n",
+    include_str!("../src/store/migrations/018_streamlined_login.sql"),
+    "PRAGMA user_version = 18;\n",
 );
 
 #[test]

tests/serve.rs

Mode 100644100644; object e8eba083e44514b481a32cd0

@@ -177,46 +177,29 @@
     assert!(anonymous_home.contains("<a href=\"/login\">Log in</a>"));
     assert!(!anonymous_home.contains("<a href=\"/account\">Account</a>"));
 
-    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 browser_challenge = challenge.replace('\n', "\r\n");
-    let browser_signature = signature.replace('\n', "\r\n");
-    let login_csrf_cookies = response_cookies(&login_challenge);
+    let login_approval = http_form(http, "/login/ssh", &[]);
+    assert!(login_approval.starts_with("HTTP/1.1 200"));
+    let secret = between(&login_approval, "name=\"secret\" value=\"", "\">");
+    let login_csrf_cookies = response_cookies(&login_approval);
     let login_csrf = cookie_value(&login_csrf_cookies, "tit-login-csrf");
+    let ssh_approval = ssh_exec(ssh, &private_key, &["login", secret]);
+    assert!(ssh_approval.status.success());
+    let ssh_approval_output =
+        String::from_utf8(ssh_approval.stdout).expect("read SSH approval output");
+    assert!(ssh_approval_output.contains(&format!("Origin: http://{http}")));
+    assert!(ssh_approval_output.contains("Account: alice"));
     let rejected_login = http_form_with_headers(
         http,
-        "/login/verify",
-        &[
-            ("username", "alice"),
-            ("public-key", public_key.trim()),
-            ("challenge", &browser_challenge),
-            ("signature", &browser_signature),
-            ("login-csrf", &"0".repeat(64)),
-        ],
+        "/login/ssh/complete",
+        &[("secret", secret), ("login-csrf", &"0".repeat(64))],
         &[("Cookie", &login_csrf_cookies)],
     );
     assert!(rejected_login.starts_with("HTTP/1.1 400"));
     let rejected_login_id = response_header(&rejected_login, "x-request-id").to_owned();
     let login = http_form_with_headers(
         http,
-        "/login/verify",
-        &[
-            ("username", "alice"),
-            ("public-key", public_key.trim()),
-            ("challenge", &browser_challenge),
-            ("signature", &browser_signature),
-            ("login-csrf", login_csrf),
-        ],
+        "/login/ssh/complete",
+        &[("secret", secret), ("login-csrf", login_csrf)],
         &[("Cookie", &login_csrf_cookies)],
     );
     assert!(login.starts_with("HTTP/1.1 303"), "{login}");
@@ -306,11 +289,7 @@
     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_page = http_form(http, "/login", &[("username", "alice")]);
     let upload_challenge = between(
         &upload_challenge_page,
         "<textarea id=\"challenge-display\" readonly rows=\"10\">",
@@ -320,6 +299,24 @@
     let browser_upload_challenge = upload_challenge.replace('\n', "\r\n");
     let upload_csrf_cookies = response_cookies(&upload_challenge_page);
     let upload_csrf = cookie_value(&upload_csrf_cookies, "tit-login-csrf");
+    let downloaded_challenge = http_form_with_headers(
+        http,
+        "/login/challenge.txt",
+        &[
+            ("username", "alice"),
+            ("challenge", &browser_upload_challenge),
+            ("login-csrf", upload_csrf),
+        ],
+        &[("Cookie", &upload_csrf_cookies)],
+    );
+    assert!(downloaded_challenge.starts_with("HTTP/1.1 200"));
+    assert_eq!(
+        downloaded_challenge
+            .split_once("\r\n\r\n")
+            .expect("split the challenge response")
+            .1,
+        upload_challenge
+    );
     let wrong_upload_type = http_form_with_headers(
         http,
         "/login/verify-file",
@@ -335,12 +332,24 @@
         &[("Cookie", &upload_csrf_cookies)],
     );
     assert!(malformed_upload.starts_with("HTTP/1.1 400"));
+    let invalid_signature = http_form_with_headers(
+        http,
+        "/login/verify",
+        &[
+            ("username", "alice"),
+            ("challenge", &browser_upload_challenge),
+            ("signature", "not an SSHSIG envelope"),
+            ("login-csrf", upload_csrf),
+        ],
+        &[("Cookie", &upload_csrf_cookies)],
+    );
+    assert!(invalid_signature.starts_with("HTTP/1.1 400"));
+    assert!(invalid_signature.contains("The signature is not valid"));
     let uploaded = http_multipart(
         http,
         "/login/verify-file",
         &[
             ("username", "alice"),
-            ("public-key", public_key.trim()),
             ("challenge", &browser_upload_challenge),
             ("signature-file", &upload_signature),
             ("login-csrf", upload_csrf),
@@ -349,6 +358,27 @@
     );
     assert!(uploaded.starts_with("HTTP/1.1 303"), "{uploaded}");
     let private_cookies = response_cookies(&uploaded);
+    let paste_challenge_page = http_form(http, "/login", &[("username", "alice")]);
+    let paste_challenge = between(
+        &paste_challenge_page,
+        "<textarea id=\"challenge-display\" readonly rows=\"10\">",
+        "</textarea>",
+    );
+    let paste_signature = sign_challenge(instance.path(), &private_key, paste_challenge);
+    let paste_cookies = response_cookies(&paste_challenge_page);
+    let paste_csrf = cookie_value(&paste_cookies, "tit-login-csrf");
+    let pasted = http_form_with_headers(
+        http,
+        "/login/verify",
+        &[
+            ("username", "alice"),
+            ("challenge", &paste_challenge.replace('\n', "\r\n")),
+            ("signature", &paste_signature.replace('\n', "\r\n")),
+            ("login-csrf", paste_csrf),
+        ],
+        &[("Cookie", &paste_cookies)],
+    );
+    assert!(pasted.starts_with("HTTP/1.1 303"), "{pasted}");
     let database = rusqlite::Connection::open(instance.path().join("tit.sqlite3"))
         .expect("open the repository database");
     database
@@ -504,8 +534,11 @@
             event.0, event.1, event.2, event.3, event.4
         );
         assert!(!visible.contains(recovery));
-        assert!(!visible.contains(challenge));
-        assert!(!visible.contains(&signature));
+        assert!(!visible.contains(secret));
+        assert!(!visible.contains(upload_challenge));
+        assert!(!visible.contains(&upload_signature));
+        assert!(!visible.contains(paste_challenge));
+        assert!(!visible.contains(&paste_signature));
     }
     drop(statement);
     drop(database);
@@ -577,11 +610,13 @@
     assert!(!logs.contains(feed_secret));
     for secret in [
         recovery,
-        challenge,
-        signature.as_str(),
+        secret,
+        upload_challenge,
+        paste_challenge,
         invitation.trim(),
         private_cookies.as_str(),
         upload_signature.as_str(),
+        paste_signature.as_str(),
     ] {
         assert!(!logs.contains(secret));
     }

tests/sqlite.rs

Mode 100644100644; object dafdbb1e75ca647152561fd2

@@ -104,6 +104,20 @@
     include_str!("../src/store/migrations/016_pull_request_reviews.sql"),
     "PRAGMA user_version = 16;\n",
 );
+const V17_FIXTURE: &str = concat!(
+    include_str!("fixtures/sqlite/v7.sql"),
+    include_str!("../src/store/migrations/008_web_sessions.sql"),
+    include_str!("../src/store/migrations/009_repository_authorization.sql"),
+    include_str!("../src/store/migrations/010_audit_history.sql"),
+    include_str!("../src/store/migrations/011_domain_events.sql"),
+    include_str!("../src/store/migrations/012_issues.sql"),
+    include_str!("../src/store/migrations/013_watches.sql"),
+    include_str!("../src/store/migrations/014_feed_tokens.sql"),
+    include_str!("../src/store/migrations/015_pull_requests.sql"),
+    include_str!("../src/store/migrations/016_pull_request_reviews.sql"),
+    include_str!("../src/store/migrations/017_pull_request_merges.sql"),
+    "PRAGMA user_version = 17;\n",
+);
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
     directory.path().join(name)
@@ -221,7 +235,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"), 17);
+    assert_eq!(store.schema_version().expect("read the schema version"), 18);
     assert_eq!(
         store
             .connection()
@@ -1586,13 +1600,14 @@
         (V14_FIXTURE, 14),
         (V15_FIXTURE, 15),
         (V16_FIXTURE, 16),
+        (V17_FIXTURE, 17),
     ] {
         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"), 17);
+        assert_eq!(store.schema_version().expect("read the schema version"), 18);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -1665,7 +1680,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 17)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 18)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);

tests/ssh.rs

Mode 100644100644; object 3327afb21dda97bb578af9a9

@@ -102,6 +102,7 @@
     );
     let help_text = String::from_utf8(help.stdout).expect("read the help output");
     assert!(help_text.contains("Available tit SSH commands:"));
+    assert!(help_text.contains("login ONE-TIME-SECRET"));
     assert!(help_text.contains("repo create NAME"));
     assert!(!help_text.contains("object-format"));
     assert!(help_text.contains("issue list OWNER/REPOSITORY"));

tests/web_session.rs

Mode 100644100644; object 1a622a415f34093f4529d165

@@ -22,6 +22,8 @@
 use std::fs;
 use std::path::Path;
 use std::process::Command;
+use std::sync::{Arc, Barrier};
+use std::thread;
 use std::time::{SystemTime, UNIX_EPOCH};
 
 use sha2::{Digest, Sha256};
@@ -56,17 +58,27 @@
 
     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 issued = login.issue("alice").expect("issue a login challenge");
     let signature = sign(directory.path(), &private_key, &issued.challenge);
+    let unknown_key = directory.path().join("unknown-identity");
+    create_ssh_key_fixture(&unknown_key);
+    let unknown_signature = sign(directory.path(), &unknown_key, &issued.challenge);
 
     let restarted =
         WebLoginService::new(database.clone(), &origin).expect("restart the login service");
+    assert!(matches!(
+        restarted.verify(
+            "alice",
+            &issued.challenge,
+            &unknown_signature,
+            &issued.login_csrf,
+            "unknown-key",
+        ),
+        Err(SessionError::Store(StoreError::InvalidLoginChallenge))
+    ));
     let session = restarted
         .verify(
             "alice",
-            &issued.public_key,
             &issued.challenge,
             &signature,
             &issued.login_csrf,
@@ -87,7 +99,6 @@
     assert!(matches!(
         restarted.verify(
             "alice",
-            &issued.public_key,
             &issued.challenge,
             &signature,
             &issued.login_csrf,
@@ -127,14 +138,11 @@
         Err(SessionError::Store(StoreError::InvalidSession))
     ));
 
-    let next = restarted
-        .issue("alice", &public_key)
-        .expect("issue another challenge");
+    let next = restarted.issue("alice").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,
@@ -148,12 +156,153 @@
     ));
 }
 
+#[test]
+fn binds_ssh_approval_to_one_browser_and_consumes_it_once() {
+    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");
+    Store::open(&database)
+        .expect("create the database")
+        .create_initial_administrator(&InitialAdministrator {
+            username: "alice",
+            canonical_key: parsed.canonical(),
+            fingerprint: parsed.fingerprint(),
+            recovery_hash: &[2; 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 approval = login.issue_approval().expect("issue an SSH approval");
+
+    assert!(matches!(
+        login.complete_approval(&approval.secret, &approval.login_csrf, "pending"),
+        Err(SessionError::Store(StoreError::LoginApprovalPending))
+    ));
+    assert!(matches!(
+        login.complete_approval(&approval.secret, &"0".repeat(64), "wrong-browser"),
+        Err(SessionError::Store(StoreError::InvalidLoginApproval))
+    ));
+    let mut changed_secret = approval.secret.clone();
+    changed_secret.replace_range(
+        ..1,
+        if changed_secret.starts_with('0') {
+            "1"
+        } else {
+            "0"
+        },
+    );
+    assert!(matches!(
+        login.complete_approval(&changed_secret, &approval.login_csrf, "changed-secret"),
+        Err(SessionError::Store(StoreError::InvalidLoginApproval))
+    ));
+    let restarted =
+        WebLoginService::new(database.clone(), &origin).expect("restart the login service");
+    let approved = restarted
+        .approve(&approval.secret, "alice", parsed.fingerprint())
+        .expect("approve the browser login");
+    assert_eq!(approved.origin, "https://tit.example");
+    assert_eq!(approved.username, "alice");
+    assert!(matches!(
+        restarted.approve(&approval.secret, "alice", parsed.fingerprint()),
+        Err(SessionError::Store(StoreError::InvalidLoginApproval))
+    ));
+
+    let login = Arc::new(restarted);
+    let barrier = Arc::new(Barrier::new(8));
+    let workers = (0..8)
+        .map(|index| {
+            let login = Arc::clone(&login);
+            let barrier = Arc::clone(&barrier);
+            let secret = approval.secret.clone();
+            let csrf = approval.login_csrf.clone();
+            thread::spawn(move || {
+                barrier.wait();
+                login
+                    .complete_approval(&secret, &csrf, &format!("consume-{index}"))
+                    .is_ok()
+            })
+        })
+        .collect::<Vec<_>>();
+    let successes = workers
+        .into_iter()
+        .map(|worker| worker.join().expect("join an approval consumer"))
+        .filter(|success| *success)
+        .count();
+    assert_eq!(successes, 1);
+
+    let revoked = login
+        .issue_approval()
+        .expect("issue a revoked-key approval");
+    Store::open(&database)
+        .expect("open the database")
+        .connection()
+        .execute(
+            "UPDATE ssh_public_key SET revoked_at = ?1 WHERE fingerprint = ?2",
+            rusqlite::params![now(), parsed.fingerprint()],
+        )
+        .expect("revoke the login key");
+    assert!(matches!(
+        login.approve(&revoked.secret, "alice", parsed.fingerprint()),
+        Err(SessionError::Store(StoreError::LoginIdentity))
+    ));
+    Store::open(&database)
+        .expect("open the database")
+        .connection()
+        .execute(
+            "UPDATE ssh_public_key SET revoked_at = NULL WHERE fingerprint = ?1",
+            [parsed.fingerprint()],
+        )
+        .expect("restore the login key");
+
+    let suspended = login
+        .issue_approval()
+        .expect("issue a suspended-account approval");
+    Store::open(&database)
+        .expect("open the database")
+        .connection()
+        .execute("UPDATE account SET state = 'suspended'", [])
+        .expect("suspend the account");
+    assert!(matches!(
+        login.approve(&suspended.secret, "alice", parsed.fingerprint()),
+        Err(SessionError::Store(StoreError::LoginIdentity))
+    ));
+    Store::open(&database)
+        .expect("open the database")
+        .connection()
+        .execute("UPDATE account SET state = 'active'", [])
+        .expect("restore the account");
+
+    let expired = login.issue_approval().expect("issue an expiring approval");
+    Store::open(&database)
+        .expect("open the database")
+        .connection()
+        .execute(
+            "UPDATE ssh_login_approval SET created_at = 0, expires_at = 1
+             WHERE approved_at IS NULL",
+            [],
+        )
+        .expect("expire pending approvals");
+    assert!(matches!(
+        login.approve(&expired.secret, "alice", parsed.fingerprint()),
+        Err(SessionError::Store(StoreError::InvalidLoginApproval))
+    ));
+}
+
 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"));
+    let key_name = private_key
+        .file_name()
+        .and_then(|name| name.to_str())
+        .expect("read the private-key name");
+    let challenge_path = directory.join(format!("login-{nonce}-{key_name}.challenge"));
     fs::write(&challenge_path, challenge).expect("write the challenge");
     let output = Command::new("ssh-keygen")
         .args(["-q", "-Y", "sign", "-f"])