michal/tit

Browse tree · Show commit · Download archive

Diff

7bad1fd1b3ffa36b101907f5

.github/workflows/ci.yml

Mode 100644100644; object c218933a45ee38478417152b

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

README.md

Mode 100644100644; object 5122da99df1070b93ea7e446

@@ -31,8 +31,16 @@
 host key during subsequent starts. Keep this file with the instance data.
 
 The server owns the instance lock until it receives SIGINT or SIGTERM. Stop the
-server before you run an offline administrator command. A subsequent milestone
-routes necessary online administrator commands through the control socket.
+server before you run an offline administrator command. Create a signup code
+through the control socket while the server runs:
+
+```text
+tit --config /srv/tit/config.toml invite-code
+```
+
+The code is valid for one signup during the next 24 hours. Open `/signup` to
+create the account. Store the recovery credential offline when the Web UI shows
+it. Open `/recover` to replace all account keys with a new key.
 
 ## Quality gate
 
@@ -138,3 +146,16 @@
 and tests the public routes with SHA-1 and SHA-256 repositories. Read the
 [source search architectural decision record](docs/adr/0008-bounded-source-search.md)
 for the search limits and current measurement.
+
+## Milestone 3.1 gate
+
+Install stock Git and OpenSSH. Then, run the account lifecycle gate:
+
+```text
+./scripts/check-m3-1
+```
+
+This command tests invitation, signup, recovery, key revocation, account
+suspension, and the owner-only control socket. Read the
+[account lifecycle architectural decision record](docs/adr/0010-account-lifecycle.md)
+for the credential and failure behavior.

docs/adr/0009-operational-server.md

Mode 100644100644; object d7f230bcee5c52be60387537

@@ -18,7 +18,8 @@
 the listener that already started and return an error.
 
 At startup, read the active SSH public keys and the active public repositories
-from SQLite. Give the SSH transport a fixed map from each owner and repository
+from SQLite. Reload the key map after a successful account signup or recovery.
+Give the SSH transport a fixed map from each owner and repository
 slug to its immutable repository ID. Resolve the final path below the canonical
 repository directory. The instance lock prevents an offline command from
 changing this map during the server run.

docs/adr/0010-account-lifecycle.md

Mode 100644; object 792cf2221faa

@@ -1,0 +1,65 @@
+# Architectural decision record 0010: account lifecycle
+
+Status: Accepted
+
+Date: 2026-07-22
+
+## Context
+
+Account creation must use an invite code. An operator must create the code while
+the server owns the instance lock. Recovery must remove access from keys that a
+user can no longer control. The database must not contain an invite code or a
+recovery credential in plain text.
+
+## Decision
+
+Add the owner-only `control.sock` below the instance directory. The server
+refuses a symbolic link, a non-socket path, and an existing socket. The server
+creates the socket with mode 600. `tit invite-code` sends one bounded request to
+this socket. An invite code is valid for 24 hours and one signup. The database
+stores its SHA-256 hash.
+
+Signup requires an invite code, a valid username, and a supported SSH public
+key. One SQLite transaction consumes the invitation and creates the account,
+the first key, and the recovery credential hash. A failed transaction does not
+consume the invitation. The Web UI shows the recovery credential one time.
+
+Recovery requires the username, the current recovery credential, and a new SSH
+public key. One transaction revokes all old keys, activates the new key, and
+stores the hash of a new recovery credential. The Web UI shows the new
+credential one time. The old credential cannot be used again. The running SSH
+server reloads active keys after a successful signup or recovery.
+
+An administrator can add and revoke keys, suspend accounts, and restore
+accounts with offline `tit admin account` commands. The last active key cannot
+be revoked through the key-revocation command. An account row is never deleted.
+Thus, its unique username stays reserved after suspension.
+
+## Failure and threat cases
+
+The control protocol has a small fixed request and a five-second time limit.
+The client refuses a symbolic link, a non-socket path, and group or other
+permissions. Shutdown removes the socket only when its device and inode still
+identify the socket that this process created. Thus, shutdown does not remove a
+replacement path.
+
+Signup and recovery errors do not put a credential in a log or an error page.
+Malformed input has a fixed size limit. The account service parses SSH keys
+before a transaction starts. SQLite constraints preserve the invitation,
+username, key, and account-state invariants.
+
+## Evidence
+
+Storage tests cover one-time and expired invitations, transaction rollback,
+username reservation, key addition and revocation, recovery rotation, and
+account suspension. Control-socket tests cover permissions, cleanup, files, and
+symbolic links. The executable test uses `tit invite-code`, creates an account
+through the Web UI, uses its key through stock Git and OpenSSH, recovers the
+account, and confirms that the old key stops before the server restarts.
+
+## Consequences
+
+Open signup is not available. An operator must remove a stale `control.sock`
+after an unclean server stop, but the server does not automatically remove a
+filesystem entry that it did not create. This choice makes the safe owner
+action explicit.

scripts/check-m3-1

Mode 100755; object 38ebe9cb44f8

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

src/account.rs

Mode 100644; object 1f2cbb147a5d

@@ -1,0 +1,182 @@
+use std::path::{Path, PathBuf};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use rand::TryRng;
+use sha2::{Digest, Sha256};
+use thiserror::Error;
+
+use crate::auth::{AuthError, SshPublicKey, validate_username};
+use crate::store::{AccountRecovery, InvitedAccount, NewSshKey, Store, StoreError};
+
+const INVITATION_PREFIX: &str = "tit-invite-v1:";
+const RECOVERY_PREFIX: &str = "tit-recovery-v1:";
+const SECRET_BYTES: usize = 32;
+const INVITATION_LIFETIME_SECONDS: i64 = 24 * 60 * 60;
+
+#[derive(Clone)]
+pub(crate) struct AccountService {
+    database: PathBuf,
+}
+
+impl AccountService {
+    pub(crate) fn new(database: PathBuf) -> Self {
+        Self { database }
+    }
+
+    pub(crate) fn issue_invitation(&self) -> Result<String, AccountError> {
+        let now = now()?;
+        let expires_at = now
+            .checked_add(INVITATION_LIFETIME_SECONDS)
+            .ok_or(AccountError::Clock)?;
+        let code = random_secret(INVITATION_PREFIX)?;
+        Store::open(&self.database)?.create_signup_invitation(&hash(&code), now, expires_at)?;
+        Ok(code)
+    }
+
+    pub(crate) fn signup(
+        &self,
+        invitation: &str,
+        username: &str,
+        public_key: &str,
+    ) -> Result<String, AccountError> {
+        validate_username(username)?;
+        require_secret(invitation, INVITATION_PREFIX)?;
+        let key = SshPublicKey::parse(public_key)?;
+        let recovery = random_secret(RECOVERY_PREFIX)?;
+        let mut store = Store::open(&self.database)?;
+        store.create_account_with_invitation(&InvitedAccount {
+            invitation_hash: &hash(invitation),
+            username,
+            key: new_key(&key, "initial"),
+            recovery_hash: &hash(&recovery),
+            created_at: now()?,
+        })?;
+        Ok(recovery)
+    }
+
+    pub(crate) fn recover(
+        &self,
+        username: &str,
+        recovery: &str,
+        public_key: &str,
+    ) -> Result<String, AccountError> {
+        validate_username(username)?;
+        require_secret(recovery, RECOVERY_PREFIX)?;
+        let key = SshPublicKey::parse(public_key)?;
+        let replacement = random_secret(RECOVERY_PREFIX)?;
+        let mut store = Store::open(&self.database)?;
+        store.recover_account(&AccountRecovery {
+            username,
+            old_recovery_hash: &hash(recovery),
+            key: new_key(&key, "recovery"),
+            new_recovery_hash: &hash(&replacement),
+            created_at: now()?,
+        })?;
+        Ok(replacement)
+    }
+
+    pub(crate) fn add_key(
+        &self,
+        username: &str,
+        label: &str,
+        public_key: &str,
+    ) -> Result<String, AccountError> {
+        validate_username(username)?;
+        validate_label(label)?;
+        let key = SshPublicKey::parse(public_key)?;
+        Store::open(&self.database)?.add_account_key(username, &new_key(&key, label), now()?)?;
+        Ok(key.fingerprint().to_owned())
+    }
+
+    pub(crate) fn revoke_key(&self, username: &str, fingerprint: &str) -> Result<(), AccountError> {
+        validate_username(username)?;
+        Store::open(&self.database)?.revoke_account_key(username, fingerprint, now()?)?;
+        Ok(())
+    }
+
+    pub(crate) fn suspend(&self, username: &str, suspended: bool) -> Result<(), AccountError> {
+        validate_username(username)?;
+        Store::open(&self.database)?.suspend_account(username, suspended)?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "integration tests compile accounts without the server"
+    )]
+    pub(crate) fn database(&self) -> &Path {
+        &self.database
+    }
+}
+
+fn new_key<'a>(key: &'a SshPublicKey, label: &'a str) -> NewSshKey<'a> {
+    NewSshKey {
+        canonical_key: key.canonical(),
+        fingerprint: key.fingerprint(),
+        label,
+    }
+}
+
+fn validate_label(label: &str) -> Result<(), AccountError> {
+    if label.is_empty()
+        || label.len() > 80
+        || label.trim() != label
+        || label.chars().any(char::is_control)
+    {
+        return Err(AccountError::InvalidLabel);
+    }
+    Ok(())
+}
+
+fn require_secret(secret: &str, prefix: &'static str) -> Result<(), AccountError> {
+    let encoded = secret
+        .strip_prefix(prefix)
+        .ok_or(AccountError::InvalidSecret)?;
+    if encoded.len() != SECRET_BYTES * 2 || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) {
+        return Err(AccountError::InvalidSecret);
+    }
+    Ok(())
+}
+
+fn random_secret(prefix: &'static str) -> Result<String, AccountError> {
+    let mut bytes = [0_u8; SECRET_BYTES];
+    rand::rngs::SysRng
+        .try_fill_bytes(&mut bytes)
+        .map_err(|_| AccountError::Random)?;
+    let mut value = String::with_capacity(prefix.len() + SECRET_BYTES * 2);
+    value.push_str(prefix);
+    for byte in bytes {
+        use std::fmt::Write as _;
+        write!(value, "{byte:02x}").expect("writing to a string cannot fail");
+    }
+    Ok(value)
+}
+
+fn hash(secret: &str) -> [u8; 32] {
+    Sha256::digest(secret.as_bytes()).into()
+}
+
+fn now() -> Result<i64, AccountError> {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .map_err(|_| AccountError::Clock)?
+        .as_secs()
+        .try_into()
+        .map_err(|_| AccountError::Clock)
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum AccountError {
+    #[error(transparent)]
+    Auth(#[from] AuthError),
+    #[error(transparent)]
+    Store(#[from] StoreError),
+    #[error("key label is not valid")]
+    InvalidLabel,
+    #[error("credential format is not valid")]
+    InvalidSecret,
+    #[error("cannot create a random credential")]
+    Random,
+    #[error("system clock is before the Unix epoch")]
+    Clock,
+}

src/cli.rs

Mode 100644100644; object 4d442cbc6629dd4807fee74b

@@ -48,6 +48,8 @@
 pub(crate) enum Command {
     /// Start the HTTP and SSH servers
     Serve,
+    /// Create a single-use signup invitation
+    InviteCode,
     /// Check the instance database
     Doctor,
     /// Set up an uninitialized instance
@@ -69,6 +71,30 @@
         #[command(subcommand)]
         command: RepositoryCommand,
     },
+    /// Administer accounts
+    Account {
+        #[command(subcommand)]
+        command: AccountCommand,
+    },
+}
+
+#[derive(Clone, Debug, Subcommand)]
+pub(crate) enum AccountCommand {
+    /// Add an SSH public key
+    KeyAdd {
+        username: String,
+        label: String,
+        ssh_public_key: String,
+    },
+    /// Revoke an SSH public key
+    KeyRevoke {
+        username: String,
+        fingerprint: String,
+    },
+    /// Suspend an account
+    Suspend { username: String },
+    /// Restore a suspended account
+    Resume { username: String },
 }
 
 #[derive(Clone, Debug, Subcommand)]

src/control.rs

Mode 100644; object f46eb0874239

@@ -1,0 +1,279 @@
+use std::fs;
+use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use thiserror::Error;
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+use tokio::net::{UnixListener, UnixStream};
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+
+use crate::account::{AccountError, AccountService};
+
+pub(crate) const CONTROL_SOCKET_FILE: &str = "control.sock";
+const REQUEST: &[u8] = b"invite-code\n";
+const MAX_RESPONSE_BYTES: usize = 256;
+const IO_TIMEOUT: Duration = Duration::from_secs(5);
+
+pub(crate) struct RunningControlServer {
+    shutdown: oneshot::Sender<()>,
+    task: JoinHandle<Result<(), ControlError>>,
+}
+
+impl RunningControlServer {
+    pub(crate) fn start(
+        instance_dir: &Path,
+        accounts: AccountService,
+    ) -> Result<Self, ControlError> {
+        let path = instance_dir.join(CONTROL_SOCKET_FILE);
+        refuse_existing_path(&path)?;
+        let listener = UnixListener::bind(&path).map_err(|source| ControlError::Io {
+            path: path.clone(),
+            source,
+        })?;
+        let created = fs::symlink_metadata(&path).map_err(|source| ControlError::Io {
+            path: path.clone(),
+            source,
+        })?;
+        if !created.file_type().is_socket() {
+            return Err(ControlError::UnsafePath(path));
+        }
+        let cleanup = SocketCleanup {
+            path: path.clone(),
+            identity: SocketIdentity {
+                device: created.dev(),
+                inode: created.ino(),
+            },
+        };
+        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).map_err(|source| {
+            ControlError::Io {
+                path: path.clone(),
+                source,
+            }
+        })?;
+        let metadata = fs::symlink_metadata(&path).map_err(|source| ControlError::Io {
+            path: path.clone(),
+            source,
+        })?;
+        if !metadata.file_type().is_socket()
+            || metadata.dev() != cleanup.identity.device
+            || metadata.ino() != cleanup.identity.inode
+            || metadata.permissions().mode() & 0o777 != 0o600
+        {
+            return Err(ControlError::UnsafePath(path));
+        }
+        let (shutdown, mut receiver) = oneshot::channel();
+        let task = tokio::spawn(async move {
+            let _cleanup = cleanup;
+            loop {
+                tokio::select! {
+                    _ = &mut receiver => return Ok(()),
+                    accepted = listener.accept() => {
+                        let (stream, _) = accepted.map_err(ControlError::Accept)?;
+                        let service = accounts.clone();
+                        tokio::spawn(async move {
+                            let _ = handle(stream, service).await;
+                        });
+                    }
+                }
+            }
+        });
+        Ok(Self { shutdown, task })
+    }
+
+    pub(crate) async fn shutdown(self) -> Result<(), ControlError> {
+        let _ = self.shutdown.send(());
+        self.task.await.map_err(|_| ControlError::Join)??;
+        Ok(())
+    }
+}
+
+pub(crate) async fn request_invitation(instance_dir: &Path) -> Result<String, ControlError> {
+    let path = instance_dir.join(CONTROL_SOCKET_FILE);
+    let metadata = fs::symlink_metadata(&path).map_err(|source| ControlError::Io {
+        path: path.clone(),
+        source,
+    })?;
+    if metadata.file_type().is_symlink()
+        || !metadata.file_type().is_socket()
+        || metadata.permissions().mode() & 0o077 != 0
+    {
+        return Err(ControlError::UnsafePath(path));
+    }
+    let operation = async {
+        let mut stream = UnixStream::connect(&path).await?;
+        stream.write_all(REQUEST).await?;
+        stream.shutdown().await?;
+        let mut response = Vec::new();
+        stream
+            .take((MAX_RESPONSE_BYTES + 1) as u64)
+            .read_to_end(&mut response)
+            .await?;
+        if response.len() > MAX_RESPONSE_BYTES {
+            return Err(ControlError::InvalidResponse);
+        }
+        let response = String::from_utf8(response).map_err(|_| ControlError::InvalidResponse)?;
+        response
+            .strip_prefix("ok ")
+            .and_then(|value| value.strip_suffix('\n'))
+            .map(str::to_owned)
+            .ok_or(ControlError::InvalidResponse)
+    };
+    tokio::time::timeout(IO_TIMEOUT, operation)
+        .await
+        .map_err(|_| ControlError::Timeout)?
+}
+
+async fn handle(mut stream: UnixStream, accounts: AccountService) -> Result<(), ControlError> {
+    let mut request = Vec::new();
+    tokio::time::timeout(
+        IO_TIMEOUT,
+        (&mut stream)
+            .take((REQUEST.len() + 1) as u64)
+            .read_to_end(&mut request),
+    )
+    .await
+    .map_err(|_| ControlError::Timeout)??;
+    if request != REQUEST {
+        stream.write_all(b"error invalid-request\n").await?;
+        return Ok(());
+    }
+    let invitation = tokio::task::spawn_blocking(move || accounts.issue_invitation())
+        .await
+        .map_err(|_| ControlError::Join)??;
+    stream
+        .write_all(format!("ok {invitation}\n").as_bytes())
+        .await?;
+    stream.shutdown().await?;
+    Ok(())
+}
+
+fn refuse_existing_path(path: &Path) -> Result<(), ControlError> {
+    match fs::symlink_metadata(path) {
+        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() => {
+            Err(ControlError::UnsafePath(path.to_owned()))
+        }
+        Ok(_) => Err(ControlError::SocketExists(path.to_owned())),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+        Err(source) => Err(ControlError::Io {
+            path: path.to_owned(),
+            source,
+        }),
+    }
+}
+
+struct SocketIdentity {
+    device: u64,
+    inode: u64,
+}
+
+struct SocketCleanup {
+    path: PathBuf,
+    identity: SocketIdentity,
+}
+
+impl Drop for SocketCleanup {
+    fn drop(&mut self) {
+        let Ok(metadata) = fs::symlink_metadata(&self.path) else {
+            return;
+        };
+        if metadata.file_type().is_socket()
+            && metadata.dev() == self.identity.device
+            && metadata.ino() == self.identity.inode
+        {
+            let _ = fs::remove_file(&self.path);
+        }
+    }
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum ControlError {
+    #[error("control socket path is unsafe: {0}")]
+    UnsafePath(PathBuf),
+    #[error("control socket already exists: {0}")]
+    SocketExists(PathBuf),
+    #[error("control socket error for {path}: {source}")]
+    Io {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("control socket accept failed: {0}")]
+    Accept(std::io::Error),
+    #[error("control socket I/O failed: {0}")]
+    ProtocolIo(#[from] std::io::Error),
+    #[error("control request timed out")]
+    Timeout,
+    #[error("control response is invalid")]
+    InvalidResponse,
+    #[error("control task failed")]
+    Join,
+    #[error(transparent)]
+    Account(#[from] AccountError),
+}
+
+#[cfg(test)]
+mod tests {
+    use std::os::unix::fs::symlink;
+
+    use tempfile::TempDir;
+
+    use crate::store::Store;
+
+    use super::*;
+
+    #[tokio::test]
+    async fn creates_a_private_socket_and_removes_it_after_shutdown() {
+        let directory = TempDir::new().expect("create a control directory");
+        let database = directory.path().join("tit.sqlite3");
+        Store::open(&database).expect("create the database");
+        let server =
+            RunningControlServer::start(directory.path(), AccountService::new(database.clone()))
+                .expect("start the control server");
+        let path = directory.path().join(CONTROL_SOCKET_FILE);
+        assert_eq!(
+            fs::symlink_metadata(&path)
+                .expect("inspect the socket")
+                .permissions()
+                .mode()
+                & 0o777,
+            0o600
+        );
+        let invitation = request_invitation(directory.path())
+            .await
+            .expect("request an invitation");
+        assert!(invitation.starts_with("tit-invite-v1:"));
+        server.shutdown().await.expect("stop the control server");
+        assert!(!path.exists());
+
+        let _socket = std::os::unix::net::UnixListener::bind(&path)
+            .expect("create an existing control socket");
+        assert!(matches!(
+            RunningControlServer::start(directory.path(), AccountService::new(database)),
+            Err(ControlError::SocketExists(candidate)) if candidate == path
+        ));
+    }
+
+    #[test]
+    fn refuses_file_and_symlink_replacements() {
+        let directory = TempDir::new().expect("create a control directory");
+        let path = directory.path().join(CONTROL_SOCKET_FILE);
+        fs::write(&path, b"replacement").expect("write a replacement");
+        assert!(matches!(
+            RunningControlServer::start(
+                directory.path(),
+                AccountService::new(directory.path().join("tit.sqlite3"))
+            ),
+            Err(ControlError::UnsafePath(candidate)) if candidate == path
+        ));
+        fs::remove_file(&path).expect("remove the replacement");
+        symlink(directory.path().join("target"), &path).expect("create a replacement link");
+        assert!(matches!(
+            RunningControlServer::start(
+                directory.path(),
+                AccountService::new(directory.path().join("tit.sqlite3"))
+            ),
+            Err(ControlError::UnsafePath(candidate)) if candidate == path
+        ));
+    }
+}

src/http/mod.rs

Mode 100644100644; object f51553ff547586a77cb649a2

@@ -6,9 +6,9 @@
 
 use askama::Template;
 use axum::Router;
-use axum::body::{Body, HttpBody};
-use axum::extract::{Extension, RawQuery, Request};
-use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header};
+use axum::body::{Body, Bytes, HttpBody};
+use axum::extract::{DefaultBodyLimit, Extension, OriginalUri, RawQuery, Request, State};
+use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
 use axum::middleware::{self, Next};
 use axum::response::Response;
 use axum::routing::get;
@@ -17,8 +17,10 @@
 use tokio::sync::{Semaphore, oneshot};
 use tokio::task::JoinHandle;
 
+use crate::account::{AccountError, AccountService};
 use crate::auth::validate_username;
 use crate::domain::repository::validate_slug;
+use crate::store::StoreError;
 
 use self::public::PublicWeb;
 
@@ -30,7 +32,12 @@
 #[derive(Clone)]
 struct WebState {
     public: Option<PublicWeb>,
+    accounts: Option<AccountService>,
+    jobs: Arc<Semaphore>,
+    key_reloader: Option<AccountKeyReloader>,
 }
+
+type AccountKeyReloader = Arc<dyn Fn(&AccountService) -> Result<(), AccountError> + Send + Sync>;
 
 #[derive(Clone, Debug)]
 pub(crate) struct PublicWebConfig {
@@ -47,18 +54,48 @@
 
 impl RunningWebServer {
     pub(crate) async fn start(address: SocketAddr) -> Result<Self, WebError> {
-        Self::start_with_state(address, WebState { public: None }).await
+        Self::start_with_state(
+            address,
+            WebState {
+                public: None,
+                accounts: None,
+                jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
+                key_reloader: None,
+            },
+        )
+        .await
     }
 
     pub(crate) async fn start_public(
         address: SocketAddr,
         config: PublicWebConfig,
     ) -> Result<Self, WebError> {
-        let public = PublicWeb::open(config, Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)))?;
+        Self::start_public_inner(address, config, None).await
+    }
+
+    pub(crate) async fn start_public_with_key_reload(
+        address: SocketAddr,
+        config: PublicWebConfig,
+        key_reloader: AccountKeyReloader,
+    ) -> Result<Self, WebError> {
+        Self::start_public_inner(address, config, Some(key_reloader)).await
+    }
+
+    async fn start_public_inner(
+        address: SocketAddr,
+        config: PublicWebConfig,
+        key_reloader: Option<AccountKeyReloader>,
+    ) -> Result<Self, WebError> {
+        let jobs = Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS));
+        let accounts = AccountService::new(config.instance_dir.join(crate::store::DATABASE_FILE));
+        let public = PublicWeb::open(config, Arc::clone(&jobs))?;
         Self::start_with_state(
             address,
             WebState {
                 public: Some(public),
+                accounts: Some(accounts),
+                jobs,
+                key_reloader,
             },
         )
         .await
@@ -94,13 +131,30 @@
 }
 
 pub(crate) fn router() -> Router {
-    router_with_state(WebState { public: None })
+    router_with_state(WebState {
+        public: None,
+        accounts: None,
+        jobs: Arc::new(Semaphore::new(MAX_BLOCKING_WEB_JOBS)),
+        key_reloader: None,
+    })
 }
 
 fn router_with_state(state: WebState) -> Router {
     Router::new()
         .route("/", get(home))
         .route("/go", get(go_to_repository))
+        .route(
+            "/signup",
+            get(signup_form)
+                .post(signup)
+                .layer(DefaultBodyLimit::max(20 * 1024)),
+        )
+        .route(
+            "/recover",
+            get(recovery_form)
+                .post(recover)
+                .layer(DefaultBodyLimit::max(20 * 1024)),
+        )
         .route("/assets/style.css", get(style))
         .merge(public::routes())
         .fallback(not_found)
@@ -137,6 +191,210 @@
     }
 }
 
+async fn signup_form(Extension(request_id): Extension<RequestId>) -> Response {
+    render_account_form(&request_id.0, AccountFormKind::Signup, "", "")
+}
+
+async fn recovery_form(Extension(request_id): Extension<RequestId>) -> Response {
+    render_account_form(&request_id.0, AccountFormKind::Recovery, "", "")
+}
+
+async fn signup(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_account_form(&headers, &body, "invitation") {
+        Ok(fields) => fields,
+        Err(()) => {
+            return render_account_error(
+                &request_id.0,
+                AccountFormKind::Signup,
+                "",
+                "The signup request is not valid.",
+                StatusCode::BAD_REQUEST,
+            );
+        }
+    };
+    let username = fields.username.clone();
+    let result = account_job(state, move |accounts| {
+        accounts.signup(&fields.credential, &fields.username, &fields.public_key)
+    })
+    .await;
+    account_result(result, &request_id.0, AccountFormKind::Signup, &username)
+}
+
+async fn recover(
+    State(state): State<WebState>,
+    Extension(request_id): Extension<RequestId>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let fields = match parse_account_form(&headers, &body, "recovery") {
+        Ok(fields) => fields,
+        Err(()) => {
+            return render_account_error(
+                &request_id.0,
+                AccountFormKind::Recovery,
+                "",
+                "The recovery request is not valid.",
+                StatusCode::BAD_REQUEST,
+            );
+        }
+    };
+    let username = fields.username.clone();
+    let result = account_job(state, move |accounts| {
+        accounts.recover(&fields.username, &fields.credential, &fields.public_key)
+    })
+    .await;
+    account_result(result, &request_id.0, AccountFormKind::Recovery, &username)
+}
+
+async fn account_job<T: Send + 'static>(
+    state: WebState,
+    operation: impl FnOnce(AccountService) -> Result<T, AccountError> + Send + 'static,
+) -> Result<T, AccountError> {
+    let accounts = state.accounts.ok_or_else(|| {
+        AccountError::Store(StoreError::Integrity(
+            "account service is unavailable".to_owned(),
+        ))
+    })?;
+    let permit = state.jobs.acquire_owned().await.map_err(|_| {
+        AccountError::Store(StoreError::Integrity(
+            "account worker pool is unavailable".to_owned(),
+        ))
+    })?;
+    tokio::task::spawn_blocking(move || {
+        let _permit = permit;
+        let result = operation(accounts.clone());
+        if result.is_ok()
+            && let Some(reload) = state.key_reloader
+        {
+            reload(&accounts)?;
+        }
+        result
+    })
+    .await
+    .map_err(|_| AccountError::Store(StoreError::Integrity("account worker failed".to_owned())))?
+}
+
+fn account_result(
+    result: Result<String, AccountError>,
+    request_id: &str,
+    kind: AccountFormKind,
+    username: &str,
+) -> Response {
+    match result {
+        Ok(recovery) => render(
+            StatusCode::OK,
+            &RecoveryCodeTemplate {
+                request_id,
+                recovery: &recovery,
+            },
+        ),
+        Err(AccountError::Store(StoreError::UsernameUnavailable(_))) => render_account_error(
+            request_id,
+            kind,
+            username,
+            "That username is not available.",
+            StatusCode::CONFLICT,
+        ),
+        Err(
+            AccountError::Auth(_)
+            | AccountError::InvalidSecret
+            | AccountError::Store(StoreError::InvalidInvitation)
+            | AccountError::Store(StoreError::InvalidRecovery),
+        ) => render_account_error(
+            request_id,
+            kind,
+            username,
+            "The credential, username, or SSH public key is not valid.",
+            StatusCode::BAD_REQUEST,
+        ),
+        Err(_) => render_error(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            request_id,
+            "Internal server error",
+            "The account request could not be completed.",
+        ),
+    }
+}
+
+fn parse_account_form(
+    headers: &HeaderMap,
+    body: &[u8],
+    credential_name: &str,
+) -> Result<AccountForm, ()> {
+    if headers
+        .get(header::CONTENT_TYPE)
+        .and_then(|value| value.to_str().ok())
+        .and_then(|value| value.split(';').next())
+        != Some("application/x-www-form-urlencoded")
+        || !valid_percent_encoding(body)
+    {
+        return Err(());
+    }
+    let mut username = None;
+    let mut credential = None;
+    let mut public_key = None;
+    for (name, value) in url::form_urlencoded::parse(body) {
+        match name.as_ref() {
+            "username" if username.is_none() => username = Some(value.into_owned()),
+            name if name == credential_name && credential.is_none() => {
+                credential = Some(value.into_owned());
+            }
+            "public-key" if public_key.is_none() => public_key = Some(value.into_owned()),
+            _ => return Err(()),
+        }
+    }
+    Ok(AccountForm {
+        username: username.ok_or(())?,
+        credential: credential.ok_or(())?,
+        public_key: public_key.ok_or(())?,
+    })
+}
+
+fn render_account_form(
+    request_id: &str,
+    kind: AccountFormKind,
+    username: &str,
+    error: &str,
+) -> Response {
+    render_account_error(request_id, kind, username, error, StatusCode::OK)
+}
+
+fn render_account_error(
+    request_id: &str,
+    kind: AccountFormKind,
+    username: &str,
+    error: &str,
+    status: StatusCode,
+) -> Response {
+    render(
+        status,
+        &AccountFormTemplate {
+            request_id,
+            username,
+            error,
+            has_error: !error.is_empty(),
+            recovery: matches!(kind, AccountFormKind::Recovery),
+        },
+    )
+}
+
+struct AccountForm {
+    username: String,
+    credential: String,
+    public_key: String,
+}
+
+#[derive(Clone, Copy)]
+enum AccountFormKind {
+    Signup,
+    Recovery,
+}
+
 async fn style() -> Response {
     Response::builder()
         .status(StatusCode::OK)
@@ -155,16 +413,25 @@
     )
 }
 
-async fn method_not_allowed(Extension(request_id): Extension<RequestId>) -> Response {
+async fn method_not_allowed(
+    Extension(request_id): Extension<RequestId>,
+    OriginalUri(uri): OriginalUri,
+) -> Response {
     let mut response = render_error(
         StatusCode::METHOD_NOT_ALLOWED,
         &request_id.0,
         "Method not allowed",
         "This page does not accept the request method.",
     );
-    response
-        .headers_mut()
-        .insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
+    let allow = match uri.path() {
+        "/signup" | "/recover" => "GET, HEAD, POST",
+        path if path.ends_with("/git-upload-pack") => "POST",
+        _ => "GET, HEAD",
+    };
+    response.headers_mut().insert(
+        header::ALLOW,
+        HeaderValue::from_str(allow).expect("the method list is a header value"),
+    );
     response
 }
 
@@ -331,6 +598,23 @@
     request_id: &'a str,
     status: &'a str,
     message: &'a str,
+}
+
+#[derive(Template)]
+#[template(path = "account.html")]
+struct AccountFormTemplate<'a> {
+    request_id: &'a str,
+    username: &'a str,
+    error: &'a str,
+    has_error: bool,
+    recovery: bool,
+}
+
+#[derive(Template)]
+#[template(path = "recovery-code.html")]
+struct RecoveryCodeTemplate<'a> {
+    request_id: &'a str,
+    recovery: &'a str,
 }
 
 #[derive(Debug, Error)]

src/main.rs

Mode 100644100644; object 45180fd558730906c51d68c5

@@ -1,3 +1,4 @@
+mod account;
 mod admin;
 #[allow(
     dead_code,
@@ -7,6 +8,7 @@
 mod bootstrap;
 mod cli;
 mod config;
+mod control;
 mod domain;
 mod feed;
 #[allow(dead_code, reason = "the server uses only part of the shared Git API")]
@@ -25,7 +27,9 @@
 
 use clap::Parser;
 
-use crate::cli::{AdminCommand, Cli, Command, ObjectFormat, RepositoryCommand, SetupCommand};
+use crate::cli::{
+    AccountCommand, AdminCommand, Cli, Command, ObjectFormat, RepositoryCommand, SetupCommand,
+};
 
 #[tokio::main]
 async fn main() -> ExitCode {
@@ -48,6 +52,21 @@
                     ExitCode::FAILURE
                 }
             },
+            Some(Command::InviteCode) => {
+                match control::request_invitation(&config.instance_dir).await {
+                    Ok(code) => match writeln!(io::stdout().lock(), "Signup code: {code}") {
+                        Ok(()) => ExitCode::SUCCESS,
+                        Err(error) => {
+                            eprintln!("tit: cannot write the signup code: {error}");
+                            ExitCode::FAILURE
+                        }
+                    },
+                    Err(error) => {
+                        eprintln!("tit: {error}");
+                        ExitCode::FAILURE
+                    }
+                }
+            }
             Some(Command::Doctor) => match store::doctor(&config.instance_dir) {
                 Ok(()) => ExitCode::SUCCESS,
                 Err(error) => {
@@ -84,7 +103,57 @@
             Some(Command::Admin {
                 command: AdminCommand::Repository { command },
             }) => run_repository_command(&config.instance_dir, command),
+            Some(Command::Admin {
+                command: AdminCommand::Account { command },
+            }) => run_account_command(&config.instance_dir, command),
         },
+        Err(error) => {
+            eprintln!("tit: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
+
+fn run_account_command(instance_dir: &std::path::Path, command: AccountCommand) -> ExitCode {
+    let result = (|| -> Result<Option<String>, Box<dyn std::error::Error>> {
+        let _lock = instance::InstanceLock::acquire(instance_dir)?;
+        let database = instance::prepare_database(instance_dir)?;
+        let accounts = account::AccountService::new(database);
+        match command {
+            AccountCommand::KeyAdd {
+                username,
+                label,
+                ssh_public_key,
+            } => {
+                let fingerprint = accounts.add_key(&username, &label, &ssh_public_key)?;
+                Ok(Some(fingerprint))
+            }
+            AccountCommand::KeyRevoke {
+                username,
+                fingerprint,
+            } => {
+                accounts.revoke_key(&username, &fingerprint)?;
+                Ok(None)
+            }
+            AccountCommand::Suspend { username } => {
+                accounts.suspend(&username, true)?;
+                Ok(None)
+            }
+            AccountCommand::Resume { username } => {
+                accounts.suspend(&username, false)?;
+                Ok(None)
+            }
+        }
+    })();
+    match result {
+        Ok(Some(fingerprint)) => match writeln!(io::stdout().lock(), "fingerprint={fingerprint}") {
+            Ok(()) => ExitCode::SUCCESS,
+            Err(error) => {
+                eprintln!("tit: cannot write account information: {error}");
+                ExitCode::FAILURE
+            }
+        },
+        Ok(None) => ExitCode::SUCCESS,
         Err(error) => {
             eprintln!("tit: {error}");
             ExitCode::FAILURE

src/serve.rs

Mode 100644100644; object 34a8ef64f4b75103d1c4a61e

@@ -7,12 +7,14 @@
 use ssh_key::{Algorithm, LineEnding, PrivateKey};
 use thiserror::Error;
 
+use crate::account::AccountService;
 use crate::auth::{AuthError, SshPublicKey};
 use crate::config::{Config, ConfigError};
+use crate::control::{ControlError, RunningControlServer};
 use crate::git::transport::{GitRepositories, RepositoryPathError};
 use crate::http::{PublicWebConfig, RunningWebServer, WebError};
 use crate::instance::{InstanceError, InstanceLock, prepare_database, prepare_repository_root};
-use crate::ssh::{RunningSshServer, SshServerError};
+use crate::ssh::{AuthorizedSshKeys, RunningSshServer, SshServerError};
 use crate::store::{Store, StoreError};
 
 pub(crate) async fn run(config: &Config) -> Result<(), ServeError> {
@@ -32,20 +34,37 @@
     let git = GitRepositories::new_managed_public(&repository_root, repositories)?;
     drop(store);
 
+    let accounts = AccountService::new(database);
+    let control = RunningControlServer::start(&config.instance_dir, accounts.clone())?;
+    let authorized_keys = AuthorizedSshKeys::new(&keys);
+
     let (http_clone_base, ssh_clone_base) = clone_bases(config)?;
     let host_key = load_or_create_host_key(&config.instance_dir)?;
-    let web = RunningWebServer::start_public(
+    let reload_keys = {
+        let authorized_keys = authorized_keys.clone();
+        std::sync::Arc::new(move |accounts: &AccountService| {
+            let active = Store::open(accounts.database())?
+                .active_ssh_public_keys()?
+                .into_iter()
+                .map(|key| SshPublicKey::parse(&key))
+                .collect::<Result<Vec<_>, _>>()?;
+            authorized_keys.replace(&active);
+            Ok(())
+        })
+    };
+    let web = RunningWebServer::start_public_with_key_reload(
         config.http_listen,
         PublicWebConfig {
             instance_dir: config.instance_dir.clone(),
             http_clone_base,
             ssh_clone_base,
         },
+        reload_keys,
     )
     .await?;
-    let ssh = match RunningSshServer::start_with_git_and_host_key(
+    let ssh = match RunningSshServer::start_with_dynamic_keys(
         config.ssh_listen,
-        &keys,
+        authorized_keys,
         git,
         host_key,
     )
@@ -53,15 +72,18 @@
     {
         Ok(ssh) => ssh,
         Err(error) => {
+            control.shutdown().await?;
             web.shutdown().await?;
             return Err(error.into());
         }
     };
 
     let signal = shutdown_signal().await;
-    let (ssh_result, web_result) = tokio::join!(ssh.shutdown(), web.shutdown());
+    let (ssh_result, web_result, control_result) =
+        tokio::join!(ssh.shutdown(), web.shutdown(), control.shutdown());
     ssh_result?;
     web_result?;
+    control_result?;
     signal.map_err(ServeError::Signal)
 }
 
@@ -191,6 +213,8 @@
     Web(#[from] WebError),
     #[error(transparent)]
     Ssh(#[from] SshServerError),
+    #[error(transparent)]
+    Control(#[from] ControlError),
     #[error("cannot wait for a shutdown signal: {0}")]
     Signal(std::io::Error),
     #[error("cannot read or write SSH host key {path}: {source}")]

src/ssh.rs

Mode 100644100644; object 07a97aa26bc956285496ae13

@@ -1,8 +1,8 @@
 use std::borrow::Cow;
 use std::collections::{HashMap, HashSet};
 use std::net::SocketAddr;
-use std::sync::Arc;
 use std::sync::atomic::{AtomicUsize, Ordering};
+use std::sync::{Arc, RwLock};
 use std::time::Duration;
 
 use rand::rng;
@@ -32,6 +32,39 @@
     audit: Arc<RequestAudit>,
 }
 
+#[derive(Clone)]
+pub(crate) struct AuthorizedSshKeys {
+    keys: Arc<RwLock<HashMap<PublicKey, String>>>,
+}
+
+impl AuthorizedSshKeys {
+    pub(crate) fn new(keys: &[SshPublicKey]) -> Self {
+        Self {
+            keys: Arc::new(RwLock::new(key_map(keys))),
+        }
+    }
+
+    pub(crate) fn replace(&self, keys: &[SshPublicKey]) {
+        if let Ok(mut current) = self.keys.write() {
+            *current = key_map(keys);
+        }
+    }
+
+    fn actor(&self, key: &PublicKey) -> Option<String> {
+        self.keys.read().ok()?.get(key).cloned()
+    }
+
+    fn contains(&self, key: &PublicKey) -> bool {
+        self.keys.read().is_ok_and(|keys| keys.contains_key(key))
+    }
+}
+
+fn key_map(keys: &[SshPublicKey]) -> HashMap<PublicKey, String> {
+    keys.iter()
+        .map(|key| (key.public_key().clone(), key.fingerprint().to_owned()))
+        .collect()
+}
+
 impl RunningSshServer {
     pub(crate) async fn start(
         address: SocketAddr,
@@ -57,6 +90,16 @@
         host_key: PrivateKey,
     ) -> Result<Self, SshServerError> {
         Self::start_inner(address, authorized_keys, &[], Some(repositories), host_key).await
+    }
+
+    pub(crate) async fn start_with_dynamic_keys(
+        address: SocketAddr,
+        authorized_keys: AuthorizedSshKeys,
+        repositories: GitRepositories,
+        host_key: PrivateKey,
+    ) -> Result<Self, SshServerError> {
+        Self::start_inner_with_keys(address, authorized_keys, &[], Some(repositories), host_key)
+            .await
     }
 
     pub(crate) async fn start_with_git_writes(
@@ -93,6 +136,23 @@
         repositories: Option<GitRepositories>,
         host_key: PrivateKey,
     ) -> Result<Self, SshServerError> {
+        Self::start_inner_with_keys(
+            address,
+            AuthorizedSshKeys::new(authorized_keys),
+            writable_keys,
+            repositories,
+            host_key,
+        )
+        .await
+    }
+
+    async fn start_inner_with_keys(
+        address: SocketAddr,
+        authorized_keys: AuthorizedSshKeys,
+        writable_keys: &[SshPublicKey],
+        repositories: Option<GitRepositories>,
+        host_key: PrivateKey,
+    ) -> Result<Self, SshServerError> {
         let listener = TcpListener::bind(address).await?;
         let address = listener.local_addr()?;
         let mut methods = MethodSet::empty();
@@ -116,12 +176,6 @@
             nodelay: true,
             ..Default::default()
         });
-        let authorized_keys: Arc<HashMap<PublicKey, String>> = Arc::new(
-            authorized_keys
-                .iter()
-                .map(|key| (key.public_key().clone(), key.fingerprint().to_owned()))
-                .collect(),
-        );
         let writable_keys = Arc::new(
             writable_keys
                 .iter()
@@ -182,7 +236,7 @@
 
 #[derive(Clone)]
 struct SshServer {
-    authorized_keys: Arc<HashMap<PublicKey, String>>,
+    authorized_keys: AuthorizedSshKeys,
     writable_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
     repositories: Option<Arc<GitRepositories>>,
@@ -193,7 +247,7 @@
 
     fn new_client(&mut self, _peer_address: Option<SocketAddr>) -> Self::Handler {
         SshSession {
-            authorized_keys: Arc::clone(&self.authorized_keys),
+            authorized_keys: self.authorized_keys.clone(),
             writable_keys: Arc::clone(&self.writable_keys),
             audit: Arc::clone(&self.audit),
             repositories: self.repositories.clone(),
@@ -206,7 +260,7 @@
 }
 
 struct SshSession {
-    authorized_keys: Arc<HashMap<PublicKey, String>>,
+    authorized_keys: AuthorizedSshKeys,
     writable_keys: Arc<HashSet<PublicKey>>,
     audit: Arc<RequestAudit>,
     repositories: Option<Arc<GitRepositories>>,
@@ -250,8 +304,8 @@
         _user: &str,
         public_key: &PublicKey,
     ) -> Result<Auth, Self::Error> {
-        if let Some(actor) = self.authorized_keys.get(public_key) {
-            self.authenticated_actor = Some(actor.clone());
+        if let Some(actor) = self.authorized_keys.actor(public_key) {
+            self.authenticated_actor = Some(actor);
             self.authenticated_writer = self.writable_keys.contains(public_key);
             Ok(Auth::Accept)
         } else {
@@ -603,7 +657,7 @@
 
 impl SshSession {
     fn authorize(&self, public_key: &PublicKey) -> Auth {
-        if self.authorized_keys.contains_key(public_key) {
+        if self.authorized_keys.contains(public_key) {
             Auth::Accept
         } else {
             Auth::reject()

src/store/migrations/007_account_lifecycle.sql

Mode 100644; object 04f9f4b76b13

@@ -1,0 +1,22 @@
+CREATE TABLE signup_invitation (
+    id INTEGER PRIMARY KEY,
+    code_hash BLOB NOT NULL UNIQUE CHECK (length(code_hash) = 32),
+    created_at INTEGER NOT NULL CHECK (created_at >= 0),
+    expires_at INTEGER NOT NULL CHECK (expires_at > created_at),
+    consumed_at INTEGER CHECK (consumed_at IS NULL OR consumed_at >= created_at)
+) STRICT;
+
+CREATE INDEX signup_invitation_active
+ON signup_invitation (expires_at, consumed_at);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN label TEXT NOT NULL DEFAULT 'initial'
+    CHECK (length(label) BETWEEN 1 AND 80);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN last_used_at INTEGER
+    CHECK (last_used_at IS NULL OR last_used_at >= created_at);
+
+ALTER TABLE ssh_public_key
+ADD COLUMN revoked_at INTEGER
+    CHECK (revoked_at IS NULL OR revoked_at >= created_at);

src/store/mod.rs

Mode 100644100644; object 596b011e44a4adeecabcd438

@@ -9,7 +9,7 @@
 
 const BUSY_TIMEOUT: Duration = Duration::from_secs(5);
 const BUSY_TIMEOUT_MILLISECONDS: i64 = 5_000;
-const SCHEMA_VERSION: i64 = 6;
+const SCHEMA_VERSION: i64 = 7;
 #[allow(
     dead_code,
     reason = "the integration test imports this module without the CLI operation"
@@ -19,15 +19,20 @@
     dead_code,
     reason = "M1A proves migrations before the M2 server calls them"
 )]
-const MIGRATIONS: [&str; 6] = [
+const MIGRATIONS: [&str; 7] = [
     include_str!("migrations/001_initial.sql"),
     include_str!("migrations/002_state.sql"),
     include_str!("migrations/003_git_intents.sql"),
     include_str!("migrations/004_identity.sql"),
     include_str!("migrations/005_repository.sql"),
     include_str!("migrations/006_repository_events.sql"),
+    include_str!("migrations/007_account_lifecycle.sql"),
 ];
 
+#[allow(
+    dead_code,
+    reason = "integration tests compile storage without every account operation"
+)]
 #[derive(Debug, Error)]
 pub(crate) enum StoreError {
     #[error("SQLite error: {0}")]
@@ -58,6 +63,18 @@
     AlreadyInitialized,
     #[error("account does not exist or is not active: {0}")]
     AccountNotFound(String),
+    #[error("username is not available: {0}")]
+    UsernameUnavailable(String),
+    #[error("signup invitation is invalid, expired, or already used")]
+    InvalidInvitation,
+    #[error("recovery credential is invalid")]
+    InvalidRecovery,
+    #[error("SSH public key already exists")]
+    KeyExists,
+    #[error("active SSH public key does not exist")]
+    KeyNotFound,
+    #[error("an account must have at least one active SSH public key")]
+    LastKey,
     #[error("repository does not exist: {0}/{1}")]
     RepositoryNotFound(String, String),
     #[error("repository already exists: {0}/{1}")]
@@ -373,6 +390,199 @@
         Ok(())
     }
 
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn create_signup_invitation(
+        &self,
+        code_hash: &[u8; 32],
+        created_at: i64,
+        expires_at: i64,
+    ) -> Result<(), StoreError> {
+        self.connection.execute(
+            "INSERT INTO signup_invitation (code_hash, created_at, expires_at, consumed_at)
+             VALUES (?1, ?2, ?3, NULL)",
+            rusqlite::params![code_hash, created_at, expires_at],
+        )?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn create_account_with_invitation(
+        &mut self,
+        account: &InvitedAccount<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let consumed = transaction.execute(
+            "UPDATE signup_invitation SET consumed_at = ?2
+             WHERE code_hash = ?1 AND consumed_at IS NULL AND expires_at >= ?2",
+            rusqlite::params![account.invitation_hash, account.created_at],
+        )?;
+        if consumed != 1 {
+            return Err(StoreError::InvalidInvitation);
+        }
+        let inserted = transaction.execute(
+            "INSERT INTO account (username, is_administrator, state, created_at)
+             VALUES (?1, 0, 'active', ?2)",
+            rusqlite::params![account.username, account.created_at],
+        );
+        let account_id = match inserted {
+            Ok(1) => transaction.last_insert_rowid(),
+            Err(error) if is_unique_constraint(&error) => {
+                return Err(StoreError::UsernameUnavailable(account.username.to_owned()));
+            }
+            Err(error) => return Err(error.into()),
+            Ok(_) => unreachable!("an INSERT changes one row"),
+        };
+        insert_ssh_key(&transaction, account_id, &account.key, account.created_at)?;
+        transaction.execute(
+            "INSERT INTO recovery_credential (account_id, credential_hash, created_at)
+             VALUES (?1, ?2, ?3)",
+            rusqlite::params![account_id, account.recovery_hash, account.created_at],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn recover_account(
+        &mut self,
+        recovery: &AccountRecovery<'_>,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = transaction
+            .query_row(
+                "SELECT account.id FROM account
+                 JOIN recovery_credential ON recovery_credential.account_id = account.id
+                 WHERE account.username = ?1 AND account.state = 'active'
+                   AND recovery_credential.credential_hash = ?2",
+                rusqlite::params![recovery.username, recovery.old_recovery_hash],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?
+            .ok_or(StoreError::InvalidRecovery)?;
+        transaction.execute(
+            "UPDATE ssh_public_key SET revoked_at = ?2
+             WHERE account_id = ?1 AND revoked_at IS NULL",
+            rusqlite::params![account_id, recovery.created_at],
+        )?;
+        let existing = transaction
+            .query_row(
+                "SELECT account_id FROM ssh_public_key WHERE fingerprint = ?1",
+                [recovery.key.fingerprint],
+                |row| row.get::<_, i64>(0),
+            )
+            .optional()?;
+        match existing {
+            Some(owner) if owner == account_id => {
+                transaction.execute(
+                    "UPDATE ssh_public_key
+                     SET canonical_key = ?2, label = ?3, revoked_at = NULL
+                     WHERE account_id = ?1 AND fingerprint = ?4",
+                    rusqlite::params![
+                        account_id,
+                        recovery.key.canonical_key,
+                        recovery.key.label,
+                        recovery.key.fingerprint,
+                    ],
+                )?;
+            }
+            Some(_) => return Err(StoreError::KeyExists),
+            None => insert_ssh_key(&transaction, account_id, &recovery.key, recovery.created_at)?,
+        }
+        transaction.execute(
+            "UPDATE recovery_credential
+             SET credential_hash = ?2, created_at = ?3 WHERE account_id = ?1",
+            rusqlite::params![account_id, recovery.new_recovery_hash, recovery.created_at],
+        )?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn add_account_key(
+        &mut self,
+        username: &str,
+        key: &NewSshKey<'_>,
+        created_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = active_account_id(&transaction, username)?;
+        insert_ssh_key(&transaction, account_id, key, created_at)?;
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn revoke_account_key(
+        &mut self,
+        username: &str,
+        fingerprint: &str,
+        revoked_at: i64,
+    ) -> Result<(), StoreError> {
+        let transaction = self
+            .connection
+            .transaction_with_behavior(TransactionBehavior::Immediate)?;
+        let account_id = active_account_id(&transaction, username)?;
+        let active: i64 = transaction.query_row(
+            "SELECT count(*) FROM ssh_public_key WHERE account_id = ?1 AND revoked_at IS NULL",
+            [account_id],
+            |row| row.get(0),
+        )?;
+        if active <= 1 {
+            return Err(StoreError::LastKey);
+        }
+        let changed = transaction.execute(
+            "UPDATE ssh_public_key SET revoked_at = ?3
+             WHERE account_id = ?1 AND fingerprint = ?2 AND revoked_at IS NULL",
+            rusqlite::params![account_id, fingerprint, revoked_at],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::KeyNotFound);
+        }
+        transaction.commit()?;
+        Ok(())
+    }
+
+    #[allow(
+        dead_code,
+        reason = "some integration tests compile storage without accounts"
+    )]
+    pub(crate) fn suspend_account(
+        &self,
+        username: &str,
+        suspended: bool,
+    ) -> Result<(), StoreError> {
+        let state = if suspended { "suspended" } else { "active" };
+        let changed = self.connection.execute(
+            "UPDATE account SET state = ?2 WHERE username = ?1",
+            rusqlite::params![username, state],
+        )?;
+        if changed != 1 {
+            return Err(StoreError::AccountNotFound(username.to_owned()));
+        }
+        Ok(())
+    }
+
     pub(crate) fn create_repository(
         &mut self,
         repository: &NewRepository<'_>,
@@ -574,7 +784,7 @@
             "SELECT ssh_public_key.canonical_key
              FROM ssh_public_key
              JOIN account ON account.id = ssh_public_key.account_id
-             WHERE account.state = 'active'
+             WHERE account.state = 'active' AND ssh_public_key.revoked_at IS NULL
              ORDER BY ssh_public_key.id",
         )?;
         statement
@@ -645,6 +855,40 @@
     pub(crate) canonical_key: &'a str,
     pub(crate) fingerprint: &'a str,
     pub(crate) recovery_hash: &'a [u8; 32],
+    pub(crate) created_at: i64,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without accounts"
+)]
+pub(crate) struct NewSshKey<'a> {
+    pub(crate) canonical_key: &'a str,
+    pub(crate) fingerprint: &'a str,
+    pub(crate) label: &'a str,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without accounts"
+)]
+pub(crate) struct InvitedAccount<'a> {
+    pub(crate) invitation_hash: &'a [u8; 32],
+    pub(crate) username: &'a str,
+    pub(crate) key: NewSshKey<'a>,
+    pub(crate) recovery_hash: &'a [u8; 32],
+    pub(crate) created_at: i64,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without accounts"
+)]
+pub(crate) struct AccountRecovery<'a> {
+    pub(crate) username: &'a str,
+    pub(crate) old_recovery_hash: &'a [u8; 32],
+    pub(crate) key: NewSshKey<'a>,
+    pub(crate) new_recovery_hash: &'a [u8; 32],
     pub(crate) created_at: i64,
 }
 
@@ -727,6 +971,35 @@
     pub(crate) quarantine_path: String,
     pub(crate) state: String,
     pub(crate) pack_name: Option<String>,
+}
+
+#[allow(
+    dead_code,
+    reason = "some integration tests compile storage without accounts"
+)]
+fn insert_ssh_key(
+    transaction: &rusqlite::Transaction<'_>,
+    account_id: i64,
+    key: &NewSshKey<'_>,
+    created_at: i64,
+) -> Result<(), StoreError> {
+    match transaction.execute(
+        "INSERT INTO ssh_public_key
+         (account_id, canonical_key, fingerprint, created_at, label, last_used_at, revoked_at)
+         VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL)",
+        rusqlite::params![
+            account_id,
+            key.canonical_key,
+            key.fingerprint,
+            created_at,
+            key.label,
+        ],
+    ) {
+        Ok(1) => Ok(()),
+        Err(error) if is_unique_constraint(&error) => Err(StoreError::KeyExists),
+        Err(error) => Err(error.into()),
+        Ok(_) => unreachable!("an INSERT changes one row"),
+    }
 }
 
 fn insert_push_events(

templates/account.html

Mode 100644; object 6066c9882dd5

@@ -1,0 +1,33 @@
+{% extends "base.html" %}
+{% block title %}{% if recovery %}Recover account{% else %}Create account{% endif %} · tit{% endblock %}
+{% block content %}
+  <h1>{% if recovery %}Recover account{% else %}Create account{% endif %}</h1>
+{% if recovery %}
+  <p>Use your recovery code to replace all SSH keys. You will get a new recovery code.</p>
+{% else %}
+  <p>You must have a valid signup code and an SSH public key.</p>
+{% endif %}
+{% if has_error %}
+  <p class="error" role="alert">{{ error }}</p>
+{% endif %}
+  <form action="{% if recovery %}/recover{% else %}/signup{% endif %}" 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">
+{% if recovery %}
+      <label for="credential">Recovery code</label>
+      <input id="credential" name="recovery" type="password" required autocomplete="off">
+{% else %}
+      <label for="credential">Signup code</label>
+      <input id="credential" name="invitation" type="password" required autocomplete="off">
+{% endif %}
+    </div>
+    <div class="field">
+      <label for="public-key">New 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">{% if recovery %}Recover account{% else %}Create account{% endif %}</button>
+  </form>
+{% endblock %}

templates/base.html

Mode 100644100644; object d5831452ce03fa3cc9823fc0

@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">

templates/recovery-code.html

Mode 100644; object b9eb1278be38

@@ -1,0 +1,7 @@
+{% extends "base.html" %}
+{% block title %}Recovery code · tit{% endblock %}
+{% block content %}
+  <h1>Save your recovery code</h1>
+  <p>This code is shown one time. Store it offline. It replaces all SSH keys when you use it.</p>
+  <pre><code>{{ recovery }}</code></pre>
+{% endblock %}

tests/account_lifecycle.rs

Mode 100644; object de9934ee416d

@@ -1,0 +1,197 @@
+#[path = "../src/account.rs"]
+mod account;
+#[allow(
+    dead_code,
+    reason = "the account test uses only SSH public-key parsing"
+)]
+#[path = "../src/auth.rs"]
+mod auth;
+#[allow(
+    dead_code,
+    reason = "the account test does not use every store operation"
+)]
+#[path = "../src/store/mod.rs"]
+mod store;
+
+use rand::rng;
+use rusqlite::OptionalExtension;
+use sha2::{Digest, Sha256};
+use ssh_key::{Algorithm, PrivateKey};
+use tempfile::TempDir;
+
+use account::{AccountError, AccountService};
+use store::{Store, StoreError};
+
+#[test]
+fn invitation_signup_key_and_recovery_lifecycle_is_atomic() {
+    let directory = TempDir::new().expect("create an account directory");
+    let database = directory.path().join("tit.sqlite3");
+    Store::open(&database).expect("create the account database");
+    let accounts = AccountService::new(database.clone());
+    let first_key = public_key();
+    let second_key = public_key();
+    let third_key = public_key();
+
+    let invitation = accounts.issue_invitation().expect("issue an invitation");
+    assert!(invitation.starts_with("tit-invite-v1:"));
+    let stored_invitation: Vec<u8> = Store::open(&database)
+        .expect("open the account database")
+        .connection()
+        .query_row("SELECT code_hash FROM signup_invitation", [], |row| {
+            row.get(0)
+        })
+        .expect("read the invitation hash");
+    assert_eq!(stored_invitation.len(), 32);
+    assert_ne!(stored_invitation, invitation.as_bytes());
+
+    let recovery = accounts
+        .signup(&invitation, "alice", &first_key)
+        .expect("create the account");
+    assert!(recovery.starts_with("tit-recovery-v1:"));
+    assert!(matches!(
+        accounts.signup(&invitation, "bob", &second_key),
+        Err(AccountError::Store(StoreError::InvalidInvitation))
+    ));
+    let stored_recovery: Vec<u8> = Store::open(&database)
+        .expect("open the account database")
+        .connection()
+        .query_row(
+            "SELECT credential_hash FROM recovery_credential",
+            [],
+            |row| row.get(0),
+        )
+        .expect("read the recovery hash");
+    assert_eq!(stored_recovery.len(), 32);
+    assert_ne!(stored_recovery, recovery.as_bytes());
+
+    let second_fingerprint = accounts
+        .add_key("alice", "workstation", &second_key)
+        .expect("add a second key");
+    let first_fingerprint: String = Store::open(&database)
+        .expect("open the account database")
+        .connection()
+        .query_row(
+            "SELECT fingerprint FROM ssh_public_key WHERE label = 'initial'",
+            [],
+            |row| row.get(0),
+        )
+        .expect("read the first fingerprint");
+    accounts
+        .revoke_key("alice", &first_fingerprint)
+        .expect("revoke the first key");
+    assert!(matches!(
+        accounts.revoke_key("alice", &second_fingerprint),
+        Err(AccountError::Store(StoreError::LastKey))
+    ));
+
+    let replacement = accounts
+        .recover("alice", &recovery, &third_key)
+        .expect("recover the account");
+    assert!(matches!(
+        accounts.recover("alice", &recovery, &first_key),
+        Err(AccountError::Store(StoreError::InvalidRecovery))
+    ));
+    accounts
+        .recover("alice", &replacement, &first_key)
+        .expect("use the rotated recovery code");
+    assert_eq!(
+        Store::open(&database)
+            .expect("open the account database")
+            .active_ssh_public_keys()
+            .expect("list active SSH keys"),
+        vec![
+            auth::SshPublicKey::parse(&first_key)
+                .expect("parse the first key")
+                .canonical()
+                .to_owned()
+        ]
+    );
+}
+
+#[test]
+fn failed_signup_preserves_the_invitation_and_username_reservation() {
+    let directory = TempDir::new().expect("create an account directory");
+    let database = directory.path().join("tit.sqlite3");
+    Store::open(&database).expect("create the account database");
+    let accounts = AccountService::new(database.clone());
+    let first = accounts.issue_invitation().expect("issue an invitation");
+    accounts
+        .signup(&first, "alice", &public_key())
+        .expect("create the first account");
+
+    let second = accounts.issue_invitation().expect("issue an invitation");
+    assert!(matches!(
+        accounts.signup(&second, "alice", &public_key()),
+        Err(AccountError::Store(StoreError::UsernameUnavailable(_)))
+    ));
+    accounts
+        .signup(&second, "bob", &public_key())
+        .expect("reuse the invitation after a rolled-back signup");
+    accounts
+        .suspend("alice", true)
+        .expect("suspend the account");
+    assert_eq!(
+        Store::open(&database)
+            .expect("open the account database")
+            .connection()
+            .query_row(
+                "SELECT state FROM account WHERE username = 'alice'",
+                [],
+                |row| row.get::<_, String>(0),
+            )
+            .expect("read the account state"),
+        "suspended"
+    );
+    let third = accounts.issue_invitation().expect("issue an invitation");
+    assert!(matches!(
+        accounts.signup(&third, "alice", &public_key()),
+        Err(AccountError::Store(StoreError::UsernameUnavailable(_)))
+    ));
+    let consumed: Option<i64> = Store::open(&database)
+        .expect("open the account database")
+        .connection()
+        .query_row(
+            "SELECT consumed_at FROM signup_invitation ORDER BY id DESC LIMIT 1",
+            [],
+            |row| row.get(0),
+        )
+        .optional()
+        .expect("read the invitation")
+        .flatten();
+    assert_eq!(consumed, None);
+}
+
+#[test]
+fn expired_invitations_do_not_create_accounts() {
+    let directory = TempDir::new().expect("create an account directory");
+    let database = directory.path().join("tit.sqlite3");
+    let store = Store::open(&database).expect("create the account database");
+    let invitation =
+        "tit-invite-v1:0000000000000000000000000000000000000000000000000000000000000000";
+    let invitation_hash: [u8; 32] = Sha256::digest(invitation.as_bytes()).into();
+    store
+        .create_signup_invitation(&invitation_hash, 1, 2)
+        .expect("store an expired invitation");
+    let accounts = AccountService::new(database.clone());
+    assert!(matches!(
+        accounts.signup(invitation, "alice", &public_key()),
+        Err(AccountError::Store(StoreError::InvalidInvitation))
+    ));
+    assert_eq!(
+        Store::open(&database)
+            .expect("open the account database")
+            .connection()
+            .query_row("SELECT count(*) FROM account", [], |row| row
+                .get::<_, i64>(0))
+            .expect("count accounts"),
+        0
+    );
+}
+
+fn public_key() -> String {
+    PrivateKey::random(&mut rng(), Algorithm::Ed25519)
+        .expect("create an SSH key")
+        .public_key()
+        .to_openssh()
+        .expect("encode an SSH public key")
+}

tests/cli.rs

Mode 100644100644; object 91b9aaa1e01dee664552b531

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

tests/fixtures/sqlite/v6.sql

Mode 100644; object ca9e5b0e6f59

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

tests/public_routes.rs

Mode 100644100644; object ed4e71af7c17e202c68a76e2

@@ -1,5 +1,11 @@
 #[allow(
     dead_code,
+    reason = "the public-route test does not use account mutations"
+)]
+#[path = "../src/account.rs"]
+mod account;
+#[allow(
+    dead_code,
     reason = "the public-route test uses only username validation"
 )]
 #[path = "../src/auth.rs"]

tests/serve.rs

Mode 100644100644; object e59a1887c84cb86785ce74f5

@@ -64,6 +64,47 @@
     let mut server = spawn_server(&config);
     wait_for_listener(http, &mut server);
     wait_for_listener(ssh, &mut server);
+    let control_socket = instance.path().join("control.sock");
+    assert_eq!(
+        fs::symlink_metadata(&control_socket)
+            .expect("inspect the control socket")
+            .permissions()
+            .mode()
+            & 0o777,
+        0o600
+    );
+
+    let invitation_output = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .args([
+            "--config",
+            config.to_str().expect("a UTF-8 configuration path"),
+            "invite-code",
+        ])
+        .output()
+        .expect("request an invitation");
+    assert!(invitation_output.status.success());
+    let invitation = String::from_utf8(invitation_output.stdout)
+        .expect("read the invitation output")
+        .trim()
+        .strip_prefix("Signup code: ")
+        .expect("read the invitation code")
+        .to_owned();
+    let member_key = instance.path().join("member");
+    create_ssh_key_fixture(&member_key);
+    let member_public =
+        fs::read_to_string(member_key.with_extension("pub")).expect("read the member public key");
+    let signup = http_form(
+        http,
+        "/signup",
+        &[
+            ("invitation", invitation.as_str()),
+            ("username", "bob"),
+            ("public-key", member_public.trim()),
+        ],
+    );
+    assert!(signup.starts_with("HTTP/1.1 200"), "{signup}");
+    let recovery = between(&signup, "<pre><code>", "</code></pre>");
+    assert!(recovery.starts_with("tit-recovery-v1:"));
 
     let summary = http_get(http, "/alice/example");
     assert!(summary.starts_with("HTTP/1.1 200"));
@@ -83,6 +124,37 @@
         fs::read(http_clone.join("README.md")).expect("read the HTTP clone"),
         b"serve fixture\n"
     );
+
+    assert!(ssh_clone_succeeds(
+        ssh,
+        &member_key,
+        &instance.path().join("member-clone")
+    ));
+
+    let replacement_key = instance.path().join("replacement");
+    create_ssh_key_fixture(&replacement_key);
+    let replacement_public = fs::read_to_string(replacement_key.with_extension("pub"))
+        .expect("read the replacement public key");
+    let recovered = http_form(
+        http,
+        "/recover",
+        &[
+            ("recovery", recovery),
+            ("username", "bob"),
+            ("public-key", replacement_public.trim()),
+        ],
+    );
+    assert!(recovered.starts_with("HTTP/1.1 200"), "{recovered}");
+    assert!(!ssh_clone_succeeds(
+        ssh,
+        &member_key,
+        &instance.path().join("revoked-clone")
+    ));
+    assert!(ssh_clone_succeeds(
+        ssh,
+        &replacement_key,
+        &instance.path().join("replacement-clone")
+    ));
 
     let ssh_clone = instance.path().join("ssh-clone");
     let ssh_command = format!(
@@ -125,6 +197,7 @@
     assert!(String::from_utf8_lossy(&locked.stderr).contains("owns the instance lock"));
 
     server.terminate();
+    assert!(!control_socket.exists());
     let host_key = fs::read(instance.path().join("ssh_host_ed25519_key"))
         .expect("read the generated SSH host key");
     assert_eq!(
@@ -254,6 +327,54 @@
         .read_to_string(&mut response)
         .expect("read an HTTP response");
     response
+}
+
+fn http_form(address: SocketAddr, path: &str, fields: &[(&str, &str)]) -> String {
+    let body = url::form_urlencoded::Serializer::new(String::new())
+        .extend_pairs(fields.iter().copied())
+        .finish();
+    let mut stream = TcpStream::connect(address).expect("connect to the HTTP server");
+    write!(
+        stream,
+        "POST {path} HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+        body.len()
+    )
+    .expect("write an HTTP form");
+    let mut response = String::new();
+    stream
+        .read_to_string(&mut response)
+        .expect("read an HTTP response");
+    response
+}
+
+fn between<'a>(value: &'a str, start: &str, end: &str) -> &'a str {
+    value
+        .split_once(start)
+        .and_then(|(_, tail)| tail.split_once(end))
+        .map(|(value, _)| value)
+        .expect("find the response value")
+}
+
+fn ssh_clone_succeeds(address: SocketAddr, private_key: &Path, target: &Path) -> bool {
+    let ssh_command = format!(
+        "ssh -F /dev/null -i {} -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null",
+        private_key.display()
+    );
+    Command::new("git")
+        .args([
+            "clone",
+            "-q",
+            &format!(
+                "ssh://ignored@127.0.0.1:{}/alice/example.git",
+                address.port()
+            ),
+        ])
+        .arg(target)
+        .env("GIT_SSH_COMMAND", ssh_command)
+        .output()
+        .expect("clone through the tit SSH server")
+        .status
+        .success()
 }
 
 struct ChildGuard(Option<Child>);

tests/snapshots/web/bad-request.html

Mode 100644100644; object acb84f9ae18f159711d92b81

@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">

tests/snapshots/web/home.html

Mode 100644100644; object cf76f68a79f16500823ed072

@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">

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

Mode 100644100644; object 581566f04648c415a2c6edb7

@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">

tests/snapshots/web/not-found.html

Mode 100644100644; object f80436b71f8289966cf7e55f

@@ -12,6 +12,8 @@
     <a class="brand" href="/" aria-label="tit home">tit</a>
     <nav aria-label="Primary">
       <a href="/">Home</a>
+      <a href="/signup">Create account</a>
+      <a href="/recover">Recover account</a>
     </nav>
   </header>
   <main id="main">

tests/sqlite.rs

Mode 100644100644; object ec95cda5c1c7fd5cfa743118

@@ -20,6 +20,7 @@
 const V3_FIXTURE: &str = include_str!("fixtures/sqlite/v3.sql");
 const V4_FIXTURE: &str = include_str!("fixtures/sqlite/v4.sql");
 const V5_FIXTURE: &str = include_str!("fixtures/sqlite/v5.sql");
+const V6_FIXTURE: &str = include_str!("fixtures/sqlite/v6.sql");
 
 fn database(directory: &TempDir, name: &str) -> std::path::PathBuf {
     directory.path().join(name)
@@ -137,7 +138,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"), 6);
+    assert_eq!(store.schema_version().expect("read the schema version"), 7);
     assert_eq!(
         store
             .connection()
@@ -772,13 +773,14 @@
         (V3_FIXTURE, 3),
         (V4_FIXTURE, 4),
         (V5_FIXTURE, 5),
+        (V6_FIXTURE, 6),
     ] {
         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"), 6);
+        assert_eq!(store.schema_version().expect("read the schema version"), 7);
         store.integrity_check().expect("check migrated integrity");
         let state: String = store
             .connection()
@@ -844,7 +846,7 @@
 
 #[test]
 fn recovers_complete_schema_versions_after_a_process_kill_during_migration() {
-    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 6)] {
+    for (mode, expected_version) in [("migration-uncommitted", 1), ("migration-committed", 7)] {
         let directory = TempDir::new().expect("create a temporary directory");
         let path = database(&directory, "fixture.sqlite");
         create_fixture(&path, V1_FIXTURE);

tests/web_shell.rs

Mode 100644100644; object abf37bf22e159093f88fdfbf

@@ -1,3 +1,9 @@
+#[allow(
+    dead_code,
+    reason = "the Web shell test does not use account mutations"
+)]
+#[path = "../src/account.rs"]
+mod account;
 #[allow(dead_code, reason = "the Web shell test uses only username validation")]
 #[path = "../src/auth.rs"]
 mod auth;
@@ -79,6 +85,27 @@
         css_head.header("content-length"),
         css.body.len().to_string()
     );
+
+    let signup = request(server.address(), "GET", "/signup", &[]);
+    assert_eq!(signup.status, 200);
+    assert!(
+        signup
+            .body
+            .contains("<form action=\"/signup\" method=\"post\">")
+    );
+    assert!(signup.body.contains("name=\"invitation\""));
+    let recovery = request(server.address(), "GET", "/recover", &[]);
+    assert_eq!(recovery.status, 200);
+    assert!(
+        recovery
+            .body
+            .contains("<form action=\"/recover\" method=\"post\">")
+    );
+    assert!(recovery.body.contains("name=\"recovery\""));
+
+    let wrong_signup_method = request(server.address(), "PUT", "/signup", &[]);
+    assert_eq!(wrong_signup_method.status, 405);
+    assert_eq!(wrong_signup_method.header("allow"), "GET, HEAD, POST");
 
     server.shutdown().await.expect("stop the Web server");
 }