michal/tit
Browse tree · Show commit · Download archive
Diff
bbd9c1843f6d → c24a185aa5d6
CONTRIBUTING.md
Mode 100644 → 100644; object ad611f62f9f4 → 207b77e39fd5
@@ -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 100644 → 100644; object 0aceba0522dc → fb83b5c66664
@@ -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 100644 → 100644; object 35e7ccbc5c50 → 7e4bd367db5f
@@ -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 100644 → 100644; object 3ab776942c7b → 9f1e642e7a1f
@@ -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 100644 → 100644; object 21f62d78c584 → 709e7ded2b21
@@ -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 100644 → 100644; object e19a35ef3ffa → 1d7df11a1741
@@ -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 100644 → 100644; object 797f5c74c10b → 77d812fb2775
@@ -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 100644 → 100644; object 2912cce06806 → f25578c1a4d4
@@ -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 100644 → 100644; object 7af25391e3da → f70be4937ee7
@@ -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 100644 → 100644; object 7e140161e642 → 6249182fd438
@@ -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 100644 → 100644; object fefa3ac234f6 → 7e917cbf9a63
@@ -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 100644 → 100644; object 3dd39f7efb26 → 078d1c75d146
@@ -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 100644 → 100644; object 70e0fc3ab2c4 → 82c2546cc31a
@@ -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 100644 → 100644; object 467932f76446 → 5ed95d1374bc
@@ -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 100644 → 100644; object 7dba0784dc1f → cc338bd31d0b
@@ -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 100644 → 100644; object de47307ba7bc → 89350e8232df
@@ -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 100644 → 100644; object e8eba083e445 → 14b481a32cd0
@@ -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 100644 → 100644; object dafdbb1e75ca → 647152561fd2
@@ -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 100644 → 100644; object 3327afb21dda → 97bb578af9a9
@@ -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 100644 → 100644; object 1a622a415f34 → 093f4529d165
@@ -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"])