From d63de7ef29d6c1dd0c0ea5d2d8a4e47dfd8c5f50 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 10:57:58 +0200 Subject: [PATCH 01/43] feat(server-cli): add config --- rust/Cargo.lock | 84 +++++++++++++++++++++++ rust/crates/keygo-server/Cargo.toml | 9 +++ rust/crates/keygo-server/src/config.rs | 94 ++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 rust/crates/keygo-server/Cargo.toml create mode 100644 rust/crates/keygo-server/src/config.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 06737697a..2077949a0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -37,12 +37,56 @@ dependencies = [ "polyval", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -326,6 +370,7 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ + "anstream", "anstyle", "clap_lex", "strsim", @@ -355,6 +400,12 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-oid" version = "0.10.2" @@ -914,6 +965,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.14.0" @@ -980,6 +1037,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keygo-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "secret-string", +] + [[package]] name = "keygo-uniffi" version = "0.1.0" @@ -1079,6 +1145,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "p256" version = "0.14.0" @@ -1416,6 +1488,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-string" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068d960589a13890bdb2a9ea5e7ae0974b89249101117f2e9a5e166bf3af44b9" + [[package]] name = "semver" version = "1.0.28" @@ -1957,6 +2035,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.28.0" diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml new file mode 100644 index 000000000..7fe1e4d25 --- /dev/null +++ b/rust/crates/keygo-server/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "keygo-server" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow = "1.0.104" +clap = { version = "4.6.7", features = ["derive", "env"] } +secret-string = "0.0.2" diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs new file mode 100644 index 000000000..3ccfdbcea --- /dev/null +++ b/rust/crates/keygo-server/src/config.rs @@ -0,0 +1,94 @@ +use anyhow::Context; +use clap::{Args, ValueEnum}; +use secret_string::SecretString; +use std::path::PathBuf; + +#[derive(Args)] +pub struct Config { + #[arg( + long, + env = "KEYGO_ENVIRONMENT", + value_enum, + default_value_t, + global = true + )] + pub environment: Environment, + + #[arg( + long, + env = "KEYGO_PUBLIC_URL", + default_value = "http://localhost", + global = true + )] + pub public_url: String, + + #[arg(long, env = "KEYGO_ALLOW_SIGNUPS", global = true)] + pub allow_signups: bool, + + #[command(flatten, next_help_heading = "Database")] + pub database: DatabaseConfig, +} + +#[derive(Args)] +pub struct DatabaseConfig { + #[arg( + long = "db-host", + env = "KEYGO_DATABASE_HOST", + default_value = "postgres", + global = true + )] + pub host: String, + + #[arg( + long = "db-port", + env = "KEYGO_DATABASE_PORT", + default_value_t = 5432, + global = true + )] + pub port: u16, + + #[arg( + long = "db-name", + env = "KEYGO_DATABASE_NAME", + default_value = "keygo", + global = true + )] + pub name: String, + + #[arg( + long = "db-user", + env = "KEYGO_DATABASE_USERNAME", + default_value = "keygo", + global = true + )] + pub username: String, + + #[arg( + long = "db-password-file", + env = "KEYGO_DATABASE_PASSWORD_FILE", + global = true + )] + pub password_file: Option, +} + +impl DatabaseConfig { + pub fn password(&self) -> anyhow::Result>> { + self.password_file + .as_deref() + .map(|path| { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("cannot read {}", path.display()))?; + Ok(SecretString::from(raw.trim().to_owned())) + }) + .transpose() + } +} + +#[derive(ValueEnum, Clone, Copy, Default, Debug, PartialEq, Eq)] +pub enum Environment { + #[value(alias = "prod")] + Production, + #[default] + #[value(alias = "dev")] + Development, +} From f1b3890809a3709ad0699c770c168780f258bb5d Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 12:55:12 +0200 Subject: [PATCH 02/43] feat(server-cli): add init command --- rust/Cargo.lock | 21 +- rust/crates/keygo-server/Cargo.toml | 6 +- .../keygo-server/src/cli/commands/init.rs | 22 ++ .../keygo-server/src/cli/commands/mod.rs | 1 + rust/crates/keygo-server/src/cli/mod.rs | 29 ++ rust/crates/keygo-server/src/config.rs | 31 +-- rust/crates/keygo-server/src/secrets.rs | 262 ++++++++++++++++++ 7 files changed, 345 insertions(+), 27 deletions(-) create mode 100644 rust/crates/keygo-server/src/cli/commands/init.rs create mode 100644 rust/crates/keygo-server/src/cli/commands/mod.rs create mode 100644 rust/crates/keygo-server/src/cli/mod.rs create mode 100644 rust/crates/keygo-server/src/secrets.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2077949a0..9c2c597b1 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -810,6 +810,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.13.0" @@ -1043,7 +1049,11 @@ version = "0.1.0" dependencies = [ "anyhow", "clap", - "secret-string", + "getrandom", + "hex", + "secrecy", + "tempfile", + "zeroize", ] [[package]] @@ -1489,10 +1499,13 @@ dependencies = [ ] [[package]] -name = "secret-string" -version = "0.0.2" +name = "secrecy" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "068d960589a13890bdb2a9ea5e7ae0974b89249101117f2e9a5e166bf3af44b9" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] [[package]] name = "semver" diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 7fe1e4d25..11c9fb695 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -4,6 +4,10 @@ version.workspace = true edition.workspace = true [dependencies] +zeroize.workspace = true anyhow = "1.0.104" clap = { version = "4.6.7", features = ["derive", "env"] } -secret-string = "0.0.2" +getrandom = "0.4.3" +hex = "0.4.3" +secrecy = "0.10.3" +tempfile = "3.27.0" diff --git a/rust/crates/keygo-server/src/cli/commands/init.rs b/rust/crates/keygo-server/src/cli/commands/init.rs new file mode 100644 index 000000000..a3ec8fdd5 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/init.rs @@ -0,0 +1,22 @@ +use crate::config::Config; +use crate::secrets::{CreateOutcome, Secret, SecretStore}; +use anyhow::Context; + +pub fn run(config: &Config) -> anyhow::Result<()> { + let store = SecretStore::new(&config.secrets_dir); + store.create_dir()?; + + for &secret in Secret::ALL { + let status = match store.create(secret, &secret.generate()?)? { + CreateOutcome::Created => "created", + CreateOutcome::AlreadyExists => { + store + .load(secret) + .context("refusing to overwrite an existing secret")?; + "exists" + } + }; + println!("{:<20} {status}", secret.name()); + } + Ok(()) +} diff --git a/rust/crates/keygo-server/src/cli/commands/mod.rs b/rust/crates/keygo-server/src/cli/commands/mod.rs new file mode 100644 index 000000000..7fa65c7ca --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/mod.rs @@ -0,0 +1 @@ +pub(crate) mod init; diff --git a/rust/crates/keygo-server/src/cli/mod.rs b/rust/crates/keygo-server/src/cli/mod.rs new file mode 100644 index 000000000..dfbf39ff5 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/mod.rs @@ -0,0 +1,29 @@ +mod commands; + +use crate::config::Config; +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "keygo-server", version, about)] +pub struct Cli { + #[command(flatten)] + config: Config, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Init, +} + +impl Cli { + pub fn start() -> anyhow::Result<()> { + let cli = Cli::parse(); + + match cli.command { + Commands::Init => commands::init::run(&cli.config), + } + } +} diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs index 3ccfdbcea..3af5d0804 100644 --- a/rust/crates/keygo-server/src/config.rs +++ b/rust/crates/keygo-server/src/config.rs @@ -1,6 +1,4 @@ -use anyhow::Context; use clap::{Args, ValueEnum}; -use secret_string::SecretString; use std::path::PathBuf; #[derive(Args)] @@ -25,6 +23,15 @@ pub struct Config { #[arg(long, env = "KEYGO_ALLOW_SIGNUPS", global = true)] pub allow_signups: bool, + /// Directory with one file per secret. Compose mounts its secrets at /run/secrets. + #[arg( + long, + env = "KEYGO_SECRETS_DIR", + default_value = "/run/secrets", + global = true + )] + pub secrets_dir: PathBuf, + #[command(flatten, next_help_heading = "Database")] pub database: DatabaseConfig, } @@ -62,26 +69,6 @@ pub struct DatabaseConfig { global = true )] pub username: String, - - #[arg( - long = "db-password-file", - env = "KEYGO_DATABASE_PASSWORD_FILE", - global = true - )] - pub password_file: Option, -} - -impl DatabaseConfig { - pub fn password(&self) -> anyhow::Result>> { - self.password_file - .as_deref() - .map(|path| { - let raw = std::fs::read_to_string(path) - .with_context(|| format!("cannot read {}", path.display()))?; - Ok(SecretString::from(raw.trim().to_owned())) - }) - .transpose() - } } #[derive(ValueEnum, Clone, Copy, Default, Debug, PartialEq, Eq)] diff --git a/rust/crates/keygo-server/src/secrets.rs b/rust/crates/keygo-server/src/secrets.rs new file mode 100644 index 000000000..bbcd61010 --- /dev/null +++ b/rust/crates/keygo-server/src/secrets.rs @@ -0,0 +1,262 @@ +use anyhow::{Context, anyhow, ensure}; +use secrecy::{ExposeSecret, SecretString}; +use std::fs::{self, DirBuilder, File, Permissions}; +use std::io::{ErrorKind, Write}; +use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; +use std::path::PathBuf; +use zeroize::Zeroizing; + +#[derive(Clone, Copy, Debug)] +pub enum Secret { + DbPassword, +} + +impl Secret { + pub const ALL: &'static [Secret] = &[Secret::DbPassword]; + + pub fn name(self) -> &'static str { + match self { + Secret::DbPassword => "db_password", + } + } + + pub fn generate(self) -> anyhow::Result { + match self { + Secret::DbPassword => { + let mut raw = Zeroizing::new([0u8; 32]); + getrandom::fill(raw.as_mut_slice()).map_err(|e| anyhow!("getrandom: {}", e))?; + Ok(hex::encode(raw.as_slice()).into()) + } + } + } + + pub fn validate(self, value: &str) -> anyhow::Result<()> { + match self { + Secret::DbPassword => ensure!(value.len() >= 64, "shorter than 64 characters"), + } + Ok(()) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum CreateOutcome { + Created, + AlreadyExists, +} + +pub struct SecretStore { + dir: PathBuf, +} + +impl SecretStore { + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + pub fn path(&self, secret: Secret) -> PathBuf { + self.dir.join(secret.name()) + } + + pub fn create_dir(&self) -> anyhow::Result<()> { + DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(&self.dir) + .with_context(|| format!("cannot create {}", self.dir.display())) + } + + pub fn load(&self, secret: Secret) -> anyhow::Result { + let path = self.path(secret); + let bytes = Zeroizing::new( + fs::read(&path).with_context(|| format!("cannot read {}", path.display()))?, + ); + let value = str::from_utf8(&bytes) + .with_context(|| format!("{} is not UTF-8", path.display()))? + .trim(); + secret + .validate(value) + .with_context(|| format!("{} is invalid", path.display()))?; + Ok(value.into()) + } + + pub fn create(&self, secret: Secret, value: &SecretString) -> anyhow::Result { + secret + .validate(value.expose_secret()) + .with_context(|| format!("refusing to store invalid {}", secret.name()))?; + + let path = self.path(secret); + let mut tmp = tempfile::Builder::new() + .prefix(&format!(".{}.", secret.name())) + .permissions(Permissions::from_mode(0o400)) + .tempfile_in(&self.dir) + .with_context(|| format!("cannot create a temp file in {}", self.dir.display()))?; + tmp.write_all(value.expose_secret().as_bytes())?; + tmp.as_file().sync_all()?; + + match tmp.persist_noclobber(&path) { + Ok(_) => {} + Err(e) if e.error.kind() == ErrorKind::AlreadyExists => { + return Ok(CreateOutcome::AlreadyExists); + } + Err(e) => { + return Err(e.error).with_context(|| format!("cannot create {}", path.display())); + } + } + + File::open(&self.dir) + .and_then(|dir| dir.sync_all()) + .with_context(|| format!("cannot sync {}", self.dir.display()))?; + Ok(CreateOutcome::Created) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::MetadataExt; + + const VALID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn store() -> (tempfile::TempDir, SecretStore) { + let dir = tempfile::tempdir().unwrap(); + let store = SecretStore::new(dir.path()); + (dir, store) + } + + fn write_raw(store: &SecretStore, secret: Secret, contents: &[u8]) { + fs::write(store.path(secret), contents).unwrap(); + } + + #[test] + fn generated_db_password_is_valid() { + let value = Secret::DbPassword.generate().unwrap(); + + assert!(Secret::DbPassword.validate(value.expose_secret()).is_ok()); + } + + #[test] + fn generated_values_differ() { + let a = Secret::DbPassword.generate().unwrap(); + let b = Secret::DbPassword.generate().unwrap(); + + assert_ne!(a.expose_secret(), b.expose_secret()); + } + + #[test] + fn db_password_length_boundary() { + assert!(Secret::DbPassword.validate(&VALID[..63]).is_err()); + assert!(Secret::DbPassword.validate(VALID).is_ok()); + } + + #[test] + fn create_then_load_round_trips() { + let (_dir, store) = store(); + + let outcome = store.create(Secret::DbPassword, &VALID.into()).unwrap(); + + assert_eq!(outcome, CreateOutcome::Created); + assert_eq!( + store.load(Secret::DbPassword).unwrap().expose_secret(), + VALID + ); + } + + #[test] + fn create_writes_owner_read_only_file() { + let (_dir, store) = store(); + + store.create(Secret::DbPassword, &VALID.into()).unwrap(); + + let mode = fs::metadata(store.path(Secret::DbPassword)).unwrap().mode(); + assert_eq!(mode & 0o777, 0o400); + } + + #[test] + fn create_leaves_no_temp_files_behind() { + let (dir, store) = store(); + + store.create(Secret::DbPassword, &VALID.into()).unwrap(); + + let names: Vec<_> = fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(names, ["db_password"]); + } + + #[test] + fn create_does_not_overwrite_existing_secret() { + let (_dir, store) = store(); + let existing = "f".repeat(64); + write_raw(&store, Secret::DbPassword, existing.as_bytes()); + + let outcome = store.create(Secret::DbPassword, &VALID.into()).unwrap(); + + assert_eq!(outcome, CreateOutcome::AlreadyExists); + assert_eq!( + fs::read_to_string(store.path(Secret::DbPassword)).unwrap(), + existing + ); + } + + #[test] + fn create_rejects_invalid_value_without_writing() { + let (dir, store) = store(); + + assert!(store.create(Secret::DbPassword, &"short".into()).is_err()); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[test] + fn load_strips_trailing_line_breaks() { + let (_dir, store) = store(); + write_raw( + &store, + Secret::DbPassword, + format!("{VALID}\r\n").as_bytes(), + ); + + let value = store.load(Secret::DbPassword).unwrap(); + + assert_eq!(value.expose_secret(), VALID); + } + + #[test] + fn load_fails_when_missing() { + let (_dir, store) = store(); + + let err = store.load(Secret::DbPassword).unwrap_err(); + + assert_eq!( + err.downcast_ref::().map(|e| e.kind()), + Some(ErrorKind::NotFound), + ); + } + + #[test] + fn load_rejects_too_short_value() { + let (_dir, store) = store(); + write_raw(&store, Secret::DbPassword, b"short\n"); + + assert!(store.load(Secret::DbPassword).is_err()); + } + + #[test] + fn load_rejects_non_utf8() { + let (_dir, store) = store(); + write_raw(&store, Secret::DbPassword, &[0xff; 64]); + + assert!(store.load(Secret::DbPassword).is_err()); + } + + #[test] + fn create_dir_makes_owner_only_directory() { + let (dir, _) = store(); + let nested = dir.path().join("nested/secrets"); + + SecretStore::new(&nested).create_dir().unwrap(); + + let mode = fs::metadata(&nested).unwrap().mode(); + assert_eq!(mode & 0o777, 0o700); + } +} From 6a0a3fda840a5c1a001081c71823ce241651bf33 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 14:11:35 +0200 Subject: [PATCH 03/43] feat(server-cli): add check command --- rust/Cargo.lock | 3 + rust/crates/keygo-server/Cargo.toml | 3 + .../keygo-server/src/cli/commands/check.rs | 469 ++++++++++++++++++ .../keygo-server/src/cli/commands/mod.rs | 1 + rust/crates/keygo-server/src/cli/mod.rs | 2 + rust/crates/keygo-server/src/config.rs | 9 + rust/crates/keygo-server/src/main.rs | 9 + rust/crates/keygo-server/src/secrets.rs | 64 ++- 8 files changed, 547 insertions(+), 13 deletions(-) create mode 100644 rust/crates/keygo-server/src/cli/commands/check.rs create mode 100644 rust/crates/keygo-server/src/main.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9c2c597b1..ec5fa7e5b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1047,12 +1047,15 @@ dependencies = [ name = "keygo-server" version = "0.1.0" dependencies = [ + "anstream", + "anstyle", "anyhow", "clap", "getrandom", "hex", "secrecy", "tempfile", + "url", "zeroize", ] diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 11c9fb695..21af2d1e6 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -5,9 +5,12 @@ edition.workspace = true [dependencies] zeroize.workspace = true +anstream = "1.0.0" +anstyle = "1.0.14" anyhow = "1.0.104" clap = { version = "4.6.7", features = ["derive", "env"] } getrandom = "0.4.3" hex = "0.4.3" secrecy = "0.10.3" tempfile = "3.27.0" +url = "2.5.8" diff --git a/rust/crates/keygo-server/src/cli/commands/check.rs b/rust/crates/keygo-server/src/cli/commands/check.rs new file mode 100644 index 000000000..1fdeaf864 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/check.rs @@ -0,0 +1,469 @@ +use crate::config::{Config, DatabaseConfig, Environment}; +use crate::secrets::{Secret, SecretStore}; +use anstream::println; +use anstyle::{AnsiColor, Style}; +use secrecy::ExposeSecret; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use url::Url; + +pub fn run(config: &Config) -> anyhow::Result<()> { + let sections = [check_config(config), check_secrets(&config.secrets_dir)]; + print_report(§ions); + + if sections.iter().any(|s| s.count(Status::Fail) > 0) { + std::process::exit(1); + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Status { + Pass, + Warn, + Fail, +} + +impl Status { + fn symbol(self) -> &'static str { + match self { + Status::Pass => "✓", + Status::Warn => "!", + Status::Fail => "✗", + } + } + + fn style(self) -> Style { + let color = match self { + Status::Pass => AnsiColor::Green, + Status::Warn => AnsiColor::Yellow, + Status::Fail => AnsiColor::Red, + }; + Style::new().fg_color(Some(color.into())).bold() + } +} + +#[derive(Debug)] +struct Check { + status: Status, + label: String, + detail: String, +} + +impl Check { + fn new(status: Status, label: impl Into, detail: impl Into) -> Self { + Self { + status, + label: label.into(), + detail: detail.into(), + } + } +} + +struct Section { + title: String, + checks: Vec, +} + +impl Section { + fn count(&self, status: Status) -> usize { + self.checks.iter().filter(|c| c.status == status).count() + } +} + +fn check_config(config: &Config) -> Section { + Section { + title: "Config".into(), + checks: vec![ + check_environment(config.environment), + check_public_url(&config.public_url, config.environment), + check_signups(config.allow_signups, config.environment), + check_database(&config.database), + ], + } +} + +fn check_environment(environment: Environment) -> Check { + let status = match environment { + Environment::Production => Status::Pass, + Environment::Development => Status::Warn, + }; + + Check::new(status, "environment", environment.name()) +} + +fn check_public_url(raw: &str, environment: Environment) -> Check { + let label = "public url"; + let url = match Url::parse(raw) { + Ok(url) => url, + Err(e) => return Check::new(Status::Fail, label, format!("{raw:?} is not a URL: {e}")), + }; + + if !matches!(url.scheme(), "http" | "https") { + return Check::new( + Status::Fail, + label, + format!("{raw} must use http or https, not {}", url.scheme()), + ); + } + + if url.host().is_none() { + return Check::new(Status::Fail, label, format!("{raw} has no host")); + } + + if environment == Environment::Production && url.scheme() != "https" { + return Check::new( + Status::Fail, + label, + format!("{raw} must use https in production"), + ); + } + + Check::new(Status::Pass, label, raw) +} + +fn check_signups(allow_signups: bool, environment: Environment) -> Check { + let label = "signups"; + match (allow_signups, environment) { + (true, Environment::Production) => { + Check::new(Status::Warn, label, "enabled, anyone can create an account") + } + (true, _) => Check::new(Status::Pass, label, "enabled"), + (false, _) => Check::new(Status::Pass, label, "disabled"), + } +} + +fn check_database(db: &DatabaseConfig) -> Check { + let label = "database"; + let missing: Vec<_> = [ + ("host", &db.host), + ("name", &db.name), + ("username", &db.username), + ] + .into_iter() + .filter(|(_, value)| value.trim().is_empty()) + .map(|(field, _)| field) + .collect(); + + if !missing.is_empty() { + Check::new(Status::Fail, label, format!("empty {}", missing.join(", "))) + } else if db.port == 0 { + Check::new(Status::Fail, label, "port must not be 0") + } else { + Check::new( + Status::Pass, + label, + format!("{}@{}:{}/{}", db.username, db.host, db.port, db.name), + ) + } +} + +fn check_secrets(dir: &Path) -> Section { + let store = SecretStore::new(dir); + let mut checks = vec![check_secrets_dir(dir)]; + checks.extend( + Secret::ALL + .iter() + .map(|&secret| check_secret(&store, secret)), + ); + + Section { + title: format!("Secrets ({})", dir.display()), + checks, + } +} + +fn check_secrets_dir(dir: &Path) -> Check { + let label = "directory"; + let metadata = match fs::metadata(dir) { + Ok(metadata) => metadata, + Err(e) => return Check::new(Status::Fail, label, format!("cannot open: {e}")), + }; + if !metadata.is_dir() { + return Check::new(Status::Fail, label, "not a directory"); + } + + let mode = metadata.permissions().mode() & 0o777; + if mode & 0o077 != 0 { + Check::new( + Status::Warn, + label, + format!("mode {mode:o}, accessible to other users"), + ) + } else { + Check::new(Status::Pass, label, format!("mode {mode:o}")) + } +} + +fn check_secret(store: &SecretStore, secret: Secret) -> Check { + let label = secret.name(); + let value = match store.load(secret) { + Ok(value) => value, + Err(e) => return Check::new(Status::Fail, label, format!("{e:#}")), + }; + + let mut issues: Vec = secret.weakness(value.expose_secret()).into_iter().collect(); + // load() already read the file, so its metadata is only missing on a race we can ignore. + let mode = fs::metadata(store.path(secret)) + .map(|m| m.permissions().mode() & 0o777) + .unwrap_or(0); + if mode & 0o077 != 0 { + issues.push(format!("mode {mode:o} is readable by other users")); + } + + if issues.is_empty() { + Check::new(Status::Pass, label, "valid") + } else { + Check::new( + Status::Warn, + label, + format!("valid, but {}", issues.join("; ")), + ) + } +} + +fn print_report(sections: &[Section]) { + let heading = Style::new().bold().underline(); + let dim = Style::new().dimmed(); + let width = sections + .iter() + .flat_map(|s| &s.checks) + .map(|c| c.label.chars().count()) + .max() + .unwrap_or(0); + + for section in sections { + println!("{heading}{}{heading:#}", section.title); + for check in §ion.checks { + let style = check.status.style(); + println!( + " {style}{}{style:#} {: Status::Pass, + (0, _) => Status::Warn, + _ => Status::Fail, + }; + let summary = match (errors, warnings) { + (0, 0) => "All checks passed".to_string(), + (0, _) => format!("{warnings} {}", plural(warnings, "warning")), + (_, 0) => format!("{errors} {}", plural(errors, "error")), + _ => format!( + "{errors} {}, {warnings} {}", + plural(errors, "error"), + plural(warnings, "warning"), + ), + }; + let style = status.style(); + println!("{style}{} {summary}{style:#}", status.symbol()); +} + +fn plural(n: usize, word: &str) -> String { + if n == 1 { + word.to_string() + } else { + format!("{word}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::Permissions; + + const VALID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn database() -> DatabaseConfig { + DatabaseConfig { + host: "postgres".into(), + port: 5432, + name: "keygo".into(), + username: "keygo".into(), + } + } + + fn secrets_dir(mode: u32) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::set_permissions(dir.path(), Permissions::from_mode(mode)).unwrap(); + dir + } + + fn write_secret(dir: &Path, secret: Secret, contents: &str, mode: u32) { + let path = dir.join(secret.name()); + fs::write(&path, contents).unwrap(); + fs::set_permissions(&path, Permissions::from_mode(mode)).unwrap(); + } + + #[test] + fn development_environment_warns() { + let check = check_environment(Environment::Development); + + assert_eq!(check.status, Status::Warn); + } + + #[test] + fn https_url_passes_in_production() { + let check = check_public_url("https://keygo.example", Environment::Production); + + assert_eq!(check.status, Status::Pass); + } + + #[test] + fn http_url_passes_in_development() { + let check = check_public_url("http://localhost", Environment::Development); + + assert_eq!(check.status, Status::Pass); + } + + #[test] + fn http_url_fails_in_production() { + let check = check_public_url("http://keygo.example", Environment::Production); + + assert_eq!(check.status, Status::Fail); + } + + #[test] + fn unparseable_url_fails() { + let check = check_public_url("keygo.example", Environment::Development); + + assert_eq!(check.status, Status::Fail); + } + + #[test] + fn non_http_scheme_fails() { + let check = check_public_url("ftp://keygo.example", Environment::Development); + + assert_eq!(check.status, Status::Fail); + } + + #[test] + fn open_signups_warn_only_in_production() { + assert_eq!( + check_signups(true, Environment::Production).status, + Status::Warn + ); + assert_eq!( + check_signups(true, Environment::Development).status, + Status::Pass + ); + assert_eq!( + check_signups(false, Environment::Production).status, + Status::Pass + ); + } + + #[test] + fn complete_database_config_passes() { + let check = check_database(&database()); + + assert_eq!(check.status, Status::Pass); + assert_eq!(check.detail, "keygo@postgres:5432/keygo"); + } + + #[test] + fn database_config_reports_every_empty_field() { + let db = DatabaseConfig { + host: " ".into(), + username: String::new(), + ..database() + }; + + let check = check_database(&db); + + assert_eq!(check.status, Status::Fail); + assert_eq!(check.detail, "empty host, username"); + } + + #[test] + fn database_port_zero_fails() { + let db = DatabaseConfig { + port: 0, + ..database() + }; + + assert_eq!(check_database(&db).status, Status::Fail); + } + + #[test] + fn missing_secrets_dir_fails() { + let dir = tempfile::tempdir().unwrap(); + + let check = check_secrets_dir(&dir.path().join("absent")); + + assert_eq!(check.status, Status::Fail); + } + + #[test] + fn owner_only_secrets_dir_passes() { + let dir = secrets_dir(0o700); + + assert_eq!(check_secrets_dir(dir.path()).status, Status::Pass); + } + + #[test] + fn shared_secrets_dir_warns() { + let dir = secrets_dir(0o755); + + assert_eq!(check_secrets_dir(dir.path()).status, Status::Warn); + } + + #[test] + fn valid_owner_only_secret_passes() { + let dir = secrets_dir(0o700); + write_secret(dir.path(), Secret::DbPassword, VALID, 0o400); + + let check = check_secret(&SecretStore::new(dir.path()), Secret::DbPassword); + + assert_eq!(check.status, Status::Pass); + } + + #[test] + fn valid_but_shared_secret_warns() { + let dir = secrets_dir(0o700); + write_secret(dir.path(), Secret::DbPassword, VALID, 0o644); + + let check = check_secret(&SecretStore::new(dir.path()), Secret::DbPassword); + + assert_eq!(check.status, Status::Warn); + } + + #[test] + fn missing_secret_fails() { + let dir = secrets_dir(0o700); + + let check = check_secret(&SecretStore::new(dir.path()), Secret::DbPassword); + + assert_eq!(check.status, Status::Fail); + } + + #[test] + fn empty_secret_fails() { + let dir = secrets_dir(0o700); + write_secret(dir.path(), Secret::DbPassword, "\n", 0o400); + + let check = check_secret(&SecretStore::new(dir.path()), Secret::DbPassword); + + assert_eq!(check.status, Status::Fail); + } + + #[test] + fn weak_secret_warns_without_revealing_its_value() { + let dir = secrets_dir(0o700); + write_secret(dir.path(), Secret::DbPassword, "hunter2", 0o400); + + let check = check_secret(&SecretStore::new(dir.path()), Secret::DbPassword); + + assert_eq!(check.status, Status::Warn); + assert!(!check.detail.contains("hunter2")); + } +} diff --git a/rust/crates/keygo-server/src/cli/commands/mod.rs b/rust/crates/keygo-server/src/cli/commands/mod.rs index 7fa65c7ca..8f2418977 100644 --- a/rust/crates/keygo-server/src/cli/commands/mod.rs +++ b/rust/crates/keygo-server/src/cli/commands/mod.rs @@ -1 +1,2 @@ +pub(crate) mod check; pub(crate) mod init; diff --git a/rust/crates/keygo-server/src/cli/mod.rs b/rust/crates/keygo-server/src/cli/mod.rs index dfbf39ff5..3431e3d1c 100644 --- a/rust/crates/keygo-server/src/cli/mod.rs +++ b/rust/crates/keygo-server/src/cli/mod.rs @@ -16,6 +16,7 @@ pub struct Cli { #[derive(Subcommand)] enum Commands { Init, + Check, } impl Cli { @@ -24,6 +25,7 @@ impl Cli { match cli.command { Commands::Init => commands::init::run(&cli.config), + Commands::Check => commands::check::run(&cli.config), } } } diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs index 3af5d0804..92e8c68d3 100644 --- a/rust/crates/keygo-server/src/config.rs +++ b/rust/crates/keygo-server/src/config.rs @@ -79,3 +79,12 @@ pub enum Environment { #[value(alias = "dev")] Development, } + +impl Environment { + pub fn name(&self) -> &'static str { + match self { + Environment::Production => "production", + Environment::Development => "development", + } + } +} diff --git a/rust/crates/keygo-server/src/main.rs b/rust/crates/keygo-server/src/main.rs new file mode 100644 index 000000000..b9151519a --- /dev/null +++ b/rust/crates/keygo-server/src/main.rs @@ -0,0 +1,9 @@ +use crate::cli::Cli; + +mod cli; +mod config; +mod secrets; + +fn main() -> anyhow::Result<()> { + Cli::start() +} diff --git a/rust/crates/keygo-server/src/secrets.rs b/rust/crates/keygo-server/src/secrets.rs index bbcd61010..51851b951 100644 --- a/rust/crates/keygo-server/src/secrets.rs +++ b/rust/crates/keygo-server/src/secrets.rs @@ -20,22 +20,35 @@ impl Secret { } } - pub fn generate(self) -> anyhow::Result { + /// Strength of the values `init` generates. + pub fn generated_bits(self) -> usize { match self { - Secret::DbPassword => { - let mut raw = Zeroizing::new([0u8; 32]); - getrandom::fill(raw.as_mut_slice()).map_err(|e| anyhow!("getrandom: {}", e))?; - Ok(hex::encode(raw.as_slice()).into()) - } + Secret::DbPassword => 256, } } + pub fn generate(self) -> anyhow::Result { + let mut raw = Zeroizing::new(vec![0u8; self.generated_bits() / 8]); + getrandom::fill(raw.as_mut_slice()).map_err(|e| anyhow!("getrandom: {}", e))?; + Ok(hex::encode(raw.as_slice()).into()) + } + + /// Rejects values the server cannot use at all. pub fn validate(self, value: &str) -> anyhow::Result<()> { match self { - Secret::DbPassword => ensure!(value.len() >= 64, "shorter than 64 characters"), + Secret::DbPassword => ensure!(!value.is_empty(), "empty"), } Ok(()) } + + /// Flags a usable value that looks weak. + pub fn weakness(self, value: &str) -> Option { + const MIN_CHARS: usize = 16; + match self { + Secret::DbPassword => (value.chars().count() < MIN_CHARS) + .then(|| format!("shorter than {MIN_CHARS} characters")), + } + } } #[derive(Debug, PartialEq, Eq)] @@ -143,9 +156,34 @@ mod tests { } #[test] - fn db_password_length_boundary() { - assert!(Secret::DbPassword.validate(&VALID[..63]).is_err()); - assert!(Secret::DbPassword.validate(VALID).is_ok()); + fn generated_value_has_exactly_the_generated_bits() { + let value = Secret::DbPassword.generate().unwrap(); + + assert_eq!( + value.expose_secret().len() * 4, + Secret::DbPassword.generated_bits() + ); + assert_eq!(Secret::DbPassword.weakness(value.expose_secret()), None); + } + + #[test] + fn externally_issued_password_is_accepted() { + assert!(Secret::DbPassword.validate("Tr0ub4dor&3-Cloud!").is_ok()); + } + + #[test] + fn empty_password_is_rejected() { + assert!(Secret::DbPassword.validate("").is_err()); + } + + #[test] + fn short_password_is_usable_but_weak() { + assert!(Secret::DbPassword.validate("hunter2").is_ok()); + assert_eq!( + Secret::DbPassword.weakness("hunter2").as_deref(), + Some("shorter than 16 characters"), + ); + assert_eq!(Secret::DbPassword.weakness(&"x".repeat(16)), None); } #[test] @@ -203,7 +241,7 @@ mod tests { fn create_rejects_invalid_value_without_writing() { let (dir, store) = store(); - assert!(store.create(Secret::DbPassword, &"short".into()).is_err()); + assert!(store.create(Secret::DbPassword, &"".into()).is_err()); assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); } @@ -234,9 +272,9 @@ mod tests { } #[test] - fn load_rejects_too_short_value() { + fn load_rejects_empty_value() { let (_dir, store) = store(); - write_raw(&store, Secret::DbPassword, b"short\n"); + write_raw(&store, Secret::DbPassword, b"\r\n"); assert!(store.load(Secret::DbPassword).is_err()); } From 252013d937ff6008a608ff475745507bf3680559 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 16:36:19 +0200 Subject: [PATCH 04/43] feat(server): add basic server --- .gitignore | 4 + deploy/.env.example | 6 + deploy/caddy/Caddyfile | 43 + deploy/compose.yaml | 91 + rust/.dockerignore | 6 + rust/Cargo.lock | 1597 +++++++++++------ rust/crates/keygo-server/Cargo.toml | 11 + rust/crates/keygo-server/Dockerfile | 22 + rust/crates/keygo-server/src/api/mod.rs | 73 + .../keygo-server/src/api/routes/health.rs | 12 + .../keygo-server/src/api/routes/meta.rs | 24 + .../crates/keygo-server/src/api/routes/mod.rs | 2 + rust/crates/keygo-server/src/api/state.rs | 14 + .../keygo-server/src/cli/commands/mod.rs | 1 + .../keygo-server/src/cli/commands/serve.rs | 23 + rust/crates/keygo-server/src/cli/mod.rs | 3 + rust/crates/keygo-server/src/main.rs | 2 + rust/crates/keygo-server/src/server/mod.rs | 36 + 18 files changed, 1380 insertions(+), 590 deletions(-) create mode 100644 deploy/.env.example create mode 100644 deploy/caddy/Caddyfile create mode 100644 deploy/compose.yaml create mode 100644 rust/.dockerignore create mode 100644 rust/crates/keygo-server/Dockerfile create mode 100644 rust/crates/keygo-server/src/api/mod.rs create mode 100644 rust/crates/keygo-server/src/api/routes/health.rs create mode 100644 rust/crates/keygo-server/src/api/routes/meta.rs create mode 100644 rust/crates/keygo-server/src/api/routes/mod.rs create mode 100644 rust/crates/keygo-server/src/api/state.rs create mode 100644 rust/crates/keygo-server/src/cli/commands/serve.rs create mode 100644 rust/crates/keygo-server/src/server/mod.rs diff --git a/.gitignore b/.gitignore index 239b62946..2464c46d3 100644 --- a/.gitignore +++ b/.gitignore @@ -259,3 +259,7 @@ docs/superpowers/ graphify-out/ .gitattributes + +.docker/ +# Docker Compose +.env diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 000000000..0e1427c27 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,6 @@ +# Public hostname. Point its DNS A/AAAA record at this server before starting. +# For local trail use: DOMAIN=localhost +DOMAIN=keygo.example.com + +# Allow anyone to register? If false, registration require an invite link. +KEYGO_ALLOW_SIGNUP=false diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 000000000..f067c5159 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,43 @@ +{$DOMAIN} { + encode zstd gzip + + request_body { + max_size 1MB + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + Referrer-Policy "no-referrer" + -Server + } + + handle_errors { + @updating { + expression {err.status_code} in [502, 503, 504] + file { + root /control + try_files /updating + } + } + handle @updating { + header Retry-After 15 + respond `{"status":"updating"}` 503 + } + + @failed { + expression {err.status_code} in [502, 503, 504] + file { + root /control + try_files /update-failed + } + } + handle @failed { + respond `{"status":"update_failed"}` 503 + } + + respond `{"status":"unavailable"}` 502 + } + + reverse_proxy api:8000 +} diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 000000000..d6a325f37 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,91 @@ +name: KeyGo Backend Server + +x-app: &app + build: + context: ../rust + dockerfile: crates/keygo-server/Dockerfile + environment: &app-env + KEYGO_PUBLIC_URL: https://${DOMAIN:?Set DOMAIN in .env (copy .env.example)} + KEYGO_ALLOW_SIGNUPS: ${KEYGO_ALLOW_SIGNUP:-false} + KEYGO_DATABASE_USERNAME: ${KEYGO_DATABASE_USER:-keygo} + KEYGO_DATABASE_NAME: ${KEYGO_DATABASE_NAME:-keygo} + +services: + init: + <<: *app + read_only: true + profiles: + - setup + command: [ "keygo-server", "init" ] + network_mode: none + volumes: + - secrets:/run/secrets + + postgres: + image: postgres:18-trixie + restart: unless-stopped + environment: + POSTGRES_USER: ${KEYGO_DATABASE_USER:-keygo} + POSTGRES_DB: ${KEYGO_DATABASE_NAME:-keygo} + # Read by the entrypoint while still root, so the keygo-owned 0400 file is fine. + POSTGRES_PASSWORD_FILE: /run/secrets/db_password + networks: + - backend + volumes: + - secrets:/run/secrets:ro + - pgdata:/var/lib/postgresql + healthcheck: + test: [ "CMD-SHELL", "pg_isready -h 127.0.0.1 -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\"" ] + interval: 5s + timeout: 3s + retries: 10 + + # Needs `keygo-server serve`, which does not exist yet: start with `--profile server`. + api: + <<: *app + command: [ "keygo-server", "serve" ] + restart: unless-stopped + environment: + <<: *app-env + # The proxy subnet: only Caddy reaches api there, so trust its X-Forwarded-*. + FORWARDED_ALLOW_IPS: 172.31.0.0/24 + networks: + - backend + - proxy + volumes: + - secrets:/run/secrets:ro + depends_on: + postgres: { condition: service_healthy } + read_only: true + + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + environment: + DOMAIN: ${DOMAIN} + volumes: + - caddy_data:/data + - caddy_config:/config + - ./caddy:/etc/caddy:ro + networks: + - web + - proxy + +volumes: + secrets: + pgdata: + caddy_data: + caddy_config: + +networks: + backend: + internal: true + proxy: + internal: true + ipam: + config: [ { subnet: 172.31.0.0/24 } ] + web: { } diff --git a/rust/.dockerignore b/rust/.dockerignore new file mode 100644 index 000000000..2447e10dc --- /dev/null +++ b/rust/.dockerignore @@ -0,0 +1,6 @@ +* +!Cargo.toml +!Cargo.lock +!crates/ +!tools/ +crates/**/target \ No newline at end of file diff --git a/rust/Cargo.lock b/rust/Cargo.lock index ec5fa7e5b..1173edac3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,8 +8,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "inout", + "crypto-common", + "inout", ] [[package]] @@ -18,9 +18,9 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher", - "cpubits", - "cpufeatures", + "cipher", + "cpubits", + "cpufeatures", ] [[package]] @@ -29,12 +29,21 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c32325128cb24de2a2d14d92da7569dae7073f9e47334521d494747fa94d902" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ctutils", - "polyval", + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "polyval", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", ] [[package]] @@ -43,13 +52,13 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", ] [[package]] @@ -64,7 +73,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ - "utf8parse", + "utf8parse", ] [[package]] @@ -73,7 +82,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys", ] [[package]] @@ -82,9 +91,9 @@ version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", + "anstyle", + "once_cell_polyfill", + "windows-sys", ] [[package]] @@ -99,10 +108,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", + "base64ct", + "blake2", + "cpufeatures", + "password-hash", ] [[package]] @@ -111,11 +120,11 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" dependencies = [ - "askama_macros", - "itoa", - "percent-encoding", - "serde", - "serde_json", + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", ] [[package]] @@ -124,16 +133,16 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" dependencies = [ - "askama_parser", - "basic-toml", - "glob", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 3.0.7", + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 3.0.7", ] [[package]] @@ -142,7 +151,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" dependencies = [ - "askama_derive", + "askama_derive", ] [[package]] @@ -151,11 +160,11 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" dependencies = [ - "rustc-hash", - "serde", - "serde_derive", - "unicode-ident", - "winnow", + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", ] [[package]] @@ -164,11 +173,11 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c97d7ff3c25d6c10d64170c12acaf5d4245e76dece3779c1d92b153a64f11df" dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", ] [[package]] @@ -177,17 +186,75 @@ version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base16ct" version = "1.0.0" @@ -218,7 +285,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" dependencies = [ - "serde", + "serde", ] [[package]] @@ -227,7 +294,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "350f2b5fa7b76b498158ec1079dc0ea842c5b622b6b3f675005fddd889f2c9a7" dependencies = [ - "serde_core", + "serde_core", ] [[package]] @@ -242,7 +309,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest", ] [[package]] @@ -251,7 +318,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "hybrid-array", + "hybrid-array", ] [[package]] @@ -272,7 +339,7 @@ version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ - "serde_core", + "serde_core", ] [[package]] @@ -281,8 +348,8 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" dependencies = [ - "serde", - "serde_core", + "serde", + "serde_core", ] [[package]] @@ -291,12 +358,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror", + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", ] [[package]] @@ -311,9 +378,9 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core", + "cfg-if", + "cpufeatures", + "rand_core", ] [[package]] @@ -322,9 +389,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", + "ciborium-io", + "ciborium-ll", + "serde", ] [[package]] @@ -339,8 +406,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ - "ciborium-io", - "half", + "ciborium-io", + "half", ] [[package]] @@ -349,9 +416,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer", - "crypto-common", - "inout", + "block-buffer", + "crypto-common", + "inout", ] [[package]] @@ -360,8 +427,8 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ - "clap_builder", - "clap_derive", + "clap_builder", + "clap_derive", ] [[package]] @@ -370,10 +437,10 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] @@ -382,10 +449,10 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.7", + "heck", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -424,8 +491,8 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1eb98d5e9155e2cf7cd942c8b3033097d4563b6fb0a00b9caecb74669555c058" dependencies = [ - "ciborium", - "ciborium-io", + "ciborium", + "ciborium-io", ] [[package]] @@ -440,7 +507,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ - "libc", + "libc", ] [[package]] @@ -455,14 +522,14 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ - "cpubits", - "ctutils", - "getrandom", - "hybrid-array", - "num-traits", - "rand_core", - "subtle", - "zeroize", + "cpubits", + "ctutils", + "getrandom", + "hybrid-array", + "num-traits", + "rand_core", + "subtle", + "zeroize", ] [[package]] @@ -471,9 +538,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom", - "hybrid-array", - "rand_core", + "getrandom", + "hybrid-array", + "rand_core", ] [[package]] @@ -482,10 +549,10 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", + "csv-core", + "itoa", + "ryu", + "serde_core", ] [[package]] @@ -494,7 +561,7 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" dependencies = [ - "memchr", + "memchr", ] [[package]] @@ -503,7 +570,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher", ] [[package]] @@ -512,8 +579,8 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ - "cmov", - "subtle", + "cmov", + "subtle", ] [[package]] @@ -522,15 +589,15 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ - "cfg-if", - "cpufeatures", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rand_core", - "rustc_version", - "subtle", - "zeroize", + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core", + "rustc_version", + "subtle", + "zeroize", ] [[package]] @@ -539,9 +606,9 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -556,9 +623,9 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] @@ -567,10 +634,10 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "ctutils", + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", ] [[package]] @@ -579,9 +646,9 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -590,13 +657,13 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", - "zeroize", + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", ] [[package]] @@ -605,8 +672,8 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", - "signature", + "pkcs8", + "signature", ] [[package]] @@ -615,14 +682,14 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core", - "serde", - "sha2", - "signature", - "subtle", - "zeroize", + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", ] [[package]] @@ -637,19 +704,19 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common", - "digest", - "ff", - "group", - "hybrid-array", - "pem-rfc7468", - "pkcs8", - "rand_core", - "sec1", - "subtle", - "zeroize", + "base16ct", + "crypto-bigint", + "crypto-common", + "digest", + "ff", + "group", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", ] [[package]] @@ -658,7 +725,7 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" dependencies = [ - "serde", + "serde", ] [[package]] @@ -673,8 +740,8 @@ version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "libc", - "windows-sys", + "libc", + "windows-sys", ] [[package]] @@ -689,8 +756,8 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core", - "subtle", + "rand_core", + "subtle", ] [[package]] @@ -699,13 +766,19 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "percent-encoding", + "percent-encoding", ] [[package]] @@ -714,7 +787,7 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" dependencies = [ - "autocfg", + "autocfg", ] [[package]] @@ -741,10 +814,10 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "futures-core", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] @@ -753,10 +826,10 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "rand_core", + "cfg-if", + "libc", + "r-efi", + "rand_core", ] [[package]] @@ -771,9 +844,9 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" dependencies = [ - "log", - "plain", - "scroll", + "log", + "plain", + "scroll", ] [[package]] @@ -782,9 +855,9 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "ff", - "rand_core", - "subtle", + "ff", + "rand_core", + "subtle", ] [[package]] @@ -793,9 +866,9 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", + "cfg-if", + "crunchy", + "zerocopy", ] [[package]] @@ -822,7 +895,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac", ] [[package]] @@ -831,19 +904,98 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ - "ctutils", - "subtle", - "typenum", - "zeroize", + "ctutils", + "subtle", + "typenum", + "zeroize", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] @@ -852,12 +1004,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] @@ -866,11 +1018,11 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] @@ -879,12 +1031,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] @@ -899,13 +1051,13 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] @@ -920,13 +1072,13 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] @@ -935,9 +1087,9 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] @@ -946,8 +1098,8 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "icu_normalizer", - "icu_properties", + "icu_normalizer", + "icu_properties", ] [[package]] @@ -956,10 +1108,10 @@ version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ - "equivalent", - "hashbrown", - "serde", - "serde_core", + "equivalent", + "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -968,7 +1120,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "hybrid-array", + "hybrid-array", ] [[package]] @@ -983,7 +1135,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ - "either", + "either", ] [[package]] @@ -998,9 +1150,9 @@ version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] @@ -1009,67 +1161,82 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ - "cfg-if", - "cpufeatures", + "cfg-if", + "cpufeatures", ] [[package]] name = "keygo-core" version = "0.1.0" dependencies = [ - "aead", - "aes-gcm-siv", - "argon2", - "async-trait", - "base64", - "bcs", - "ciborium", - "coset", - "csv", - "email_address", - "hkdf", - "passkey", - "passkey-authenticator", - "passkey-types", - "serde", - "serde_json", - "sha2", - "subtle", - "thiserror", - "tokio", - "totp-rs", - "url", - "uuid", - "zeroize", + "aead", + "aes-gcm-siv", + "argon2", + "async-trait", + "base64", + "bcs", + "ciborium", + "coset", + "csv", + "email_address", + "hkdf", + "passkey", + "passkey-authenticator", + "passkey-types", + "serde", + "serde_json", + "sha2", + "subtle", + "thiserror", + "tokio", + "totp-rs", + "url", + "uuid", + "zeroize", ] [[package]] name = "keygo-server" version = "0.1.0" dependencies = [ - "anstream", - "anstyle", - "anyhow", - "clap", - "getrandom", - "hex", - "secrecy", - "tempfile", - "url", - "zeroize", + "anstream", + "anstyle", + "anyhow", + "axum", + "clap", + "getrandom", + "hex", + "http-body-util", + "secrecy", + "serde", + "serde_json", + "tempfile", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "zeroize", ] [[package]] name = "keygo-uniffi" version = "0.1.0" dependencies = [ - "keygo-core", - "thiserror", - "tokio", - "uniffi", - "uuid", + "keygo-core", + "thiserror", + "tokio", + "uniffi", + "uuid", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.190" @@ -1094,32 +1261,64 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "ml-dsa" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "const-oid", - "crypto-common", - "ctutils", - "hybrid-array", - "module-lattice", - "pkcs8", - "shake", - "signature", + "const-oid", + "crypto-common", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8", + "shake", + "signature", ] [[package]] @@ -1128,9 +1327,9 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" dependencies = [ - "ctutils", - "hybrid-array", - "num-traits", + "ctutils", + "hybrid-array", + "num-traits", ] [[package]] @@ -1139,8 +1338,17 @@ version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "memchr", - "minimal-lexical", + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", ] [[package]] @@ -1149,7 +1357,7 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "autocfg", + "autocfg", ] [[package]] @@ -1170,11 +1378,11 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", ] [[package]] @@ -1183,11 +1391,11 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91be8b7aaa8f305eab1fae1b17d05f986c07497210edd303e61aa95121374f65" dependencies = [ - "passkey-authenticator", - "passkey-client", - "passkey-crypto", - "passkey-transports", - "passkey-types", + "passkey-authenticator", + "passkey-client", + "passkey-crypto", + "passkey-transports", + "passkey-types", ] [[package]] @@ -1196,12 +1404,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fb7e1cb79a932ac3a5ceb0fa73b25a6028ef1bd69a007441e6cce9ecf2e6e0f" dependencies = [ - "async-trait", - "ciborium", - "log", - "passkey-crypto", - "passkey-types", - "tokio", + "async-trait", + "ciborium", + "log", + "passkey-crypto", + "passkey-types", + "tokio", ] [[package]] @@ -1210,16 +1418,16 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a005ba82014459c51985f7e6f4b606a85ea5e8d1c9bbd6bc053e2b2b0c7f4f" dependencies = [ - "ciborium", - "idna", - "itertools", - "passkey-authenticator", - "passkey-crypto", - "passkey-types", - "public-suffix", - "serde", - "serde_json", - "url", + "ciborium", + "idna", + "itertools", + "passkey-authenticator", + "passkey-crypto", + "passkey-types", + "public-suffix", + "serde", + "serde_json", + "url", ] [[package]] @@ -1228,17 +1436,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5108c58974bacdae414ee66edf0c009818509ccf6be4a911032147336e3da27" dependencies = [ - "coset", - "ed25519-dalek", - "getrandom", - "hmac", - "log", - "ml-dsa", - "p256", - "rand", - "sha2", - "signature", - "zeroize", + "coset", + "ed25519-dalek", + "getrandom", + "hmac", + "log", + "ml-dsa", + "p256", + "rand", + "sha2", + "signature", + "zeroize", ] [[package]] @@ -1253,16 +1461,16 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4ed76f185c29d0d0c226ecf653531bb962e63eff3bf6d5cd5062288116138d2" dependencies = [ - "bitflags", - "ciborium", - "data-encoding", - "indexmap", - "passkey-crypto", - "serde", - "serde_json", - "strum", - "url", - "zeroize", + "bitflags", + "ciborium", + "data-encoding", + "indexmap", + "passkey-crypto", + "serde", + "serde_json", + "strum", + "url", + "zeroize", ] [[package]] @@ -1271,8 +1479,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "getrandom", - "phc", + "getrandom", + "phc", ] [[package]] @@ -1281,7 +1489,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ - "base64ct", + "base64ct", ] [[package]] @@ -1296,9 +1504,9 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" dependencies = [ - "base64ct", - "ctutils", - "getrandom", + "base64ct", + "ctutils", + "getrandom", ] [[package]] @@ -1313,8 +1521,8 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der", + "spki", ] [[package]] @@ -1329,9 +1537,9 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ - "cpubits", - "cpufeatures", - "universal-hash", + "cpubits", + "cpufeatures", + "universal-hash", ] [[package]] @@ -1340,7 +1548,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ - "zerovec", + "zerovec", ] [[package]] @@ -1349,12 +1557,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint", - "crypto-common", - "ff", - "rand_core", - "subtle", - "zeroize", + "crypto-bigint", + "crypto-common", + "ff", + "rand_core", + "subtle", + "zeroize", ] [[package]] @@ -1363,11 +1571,11 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ - "elliptic-curve", - "once_cell", - "primefield", - "serdect", - "wnaf", + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -1376,7 +1584,7 @@ version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "unicode-ident", + "unicode-ident", ] [[package]] @@ -1391,7 +1599,7 @@ version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ - "proc-macro2", + "proc-macro2", ] [[package]] @@ -1406,9 +1614,9 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", - "getrandom", - "rand_core", + "chacha20", + "getrandom", + "rand_core", ] [[package]] @@ -1417,14 +1625,31 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "crypto-bigint", - "hmac", + "crypto-bigint", + "hmac", ] [[package]] @@ -1439,7 +1664,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver", ] [[package]] @@ -1448,11 +1673,11 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -1473,7 +1698,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" dependencies = [ - "scroll_derive", + "scroll_derive", ] [[package]] @@ -1482,9 +1707,9 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1493,12 +1718,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", ] [[package]] @@ -1507,7 +1732,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" dependencies = [ - "zeroize", + "zeroize", ] [[package]] @@ -1516,8 +1741,8 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ - "serde", - "serde_core", + "serde", + "serde_core", ] [[package]] @@ -1526,8 +1751,8 @@ version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "serde_core", - "serde_derive", + "serde_core", + "serde_derive", ] [[package]] @@ -1536,7 +1761,7 @@ version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "serde_derive", + "serde_derive", ] [[package]] @@ -1545,23 +1770,35 @@ version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ - "indexmap", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", ] [[package]] @@ -1570,7 +1807,19 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ - "serde_core", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] @@ -1579,8 +1828,8 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", - "serde", + "base16ct", + "serde", ] [[package]] @@ -1589,9 +1838,9 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -1600,9 +1849,9 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] @@ -1611,9 +1860,28 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ - "digest", - "keccak", - "sponge-cursor", + "digest", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", ] [[package]] @@ -1622,8 +1890,8 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest", - "rand_core", + "digest", + "rand_core", ] [[package]] @@ -1650,14 +1918,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "spki" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ - "base64ct", - "der", + "base64ct", + "der", ] [[package]] @@ -1690,7 +1968,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros", ] [[package]] @@ -1699,10 +1977,10 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1717,9 +1995,9 @@ version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -1728,20 +2006,26 @@ version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "proc-macro2", + "quote", + "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -1750,11 +2034,11 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", ] [[package]] @@ -1763,8 +2047,8 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" dependencies = [ - "smawk", - "unicode-width", + "smawk", + "unicode-width", ] [[package]] @@ -1773,7 +2057,7 @@ version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl", ] [[package]] @@ -1782,9 +2066,18 @@ version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", ] [[package]] @@ -1793,8 +2086,8 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "displaydoc", - "zerovec", + "displaydoc", + "zerovec", ] [[package]] @@ -1803,8 +2096,13 @@ version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ - "pin-project-lite", - "tokio-macros", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", ] [[package]] @@ -1813,9 +2111,9 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -1824,13 +2122,13 @@ version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] @@ -1839,7 +2137,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ - "serde_core", + "serde_core", ] [[package]] @@ -1848,7 +2146,7 @@ version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "winnow", ] [[package]] @@ -1863,16 +2161,123 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49d0f9b7aa5def86d3a8d8ba926fe8c1990924dd715d2b5fc54a4aa3788c11c1" dependencies = [ - "base32", - "constant_time_eq", - "hmac", - "percent-encoding", - "rand", - "serde", - "sha1", - "sha2", - "url", - "zeroize", + "base32", + "constant_time_eq", + "hmac", + "percent-encoding", + "rand", + "serde", + "sha1", + "sha2", + "url", + "zeroize", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -1899,21 +2304,21 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76407f5f396a2c949a069eff4a9fca9ce462410eb872bffef4c2b7b89804a77a" dependencies = [ - "anyhow", - "camino", - "cargo_metadata", - "clap", - "uniffi_bindgen", - "uniffi_core", - "uniffi_macros", - "uniffi_pipeline", + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", ] [[package]] name = "uniffi-bindgen" version = "0.1.0" dependencies = [ - "uniffi", + "uniffi", ] [[package]] @@ -1922,24 +2327,24 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df3ced8f0eda3de99f6d50820e8628f443da8e4f447f477d1403ceba29dacbd" dependencies = [ - "anyhow", - "askama", - "camino", - "cargo_metadata", - "fs-err", - "glob", - "goblin", - "heck", - "indexmap", - "once_cell", - "serde", - "tempfile", - "textwrap", - "toml", - "uniffi_internal_macros", - "uniffi_meta", - "uniffi_pipeline", - "uniffi_udl", + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", ] [[package]] @@ -1948,11 +2353,11 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7530ae8efeaaa488622865966763fe0294832779fff80508c36d69c6a874a6a" dependencies = [ - "anyhow", - "async-compat", - "bytes", - "once_cell", - "static_assertions", + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", ] [[package]] @@ -1961,11 +2366,11 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f4bad017164375d99450d70495014810d15a84ba9da6ed14b9628b1424223ba" dependencies = [ - "anyhow", - "indexmap", - "proc-macro2", - "quote", - "syn 2.0.119", + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1974,15 +2379,15 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b855a58da153739ce6e863f6e296edc3716a093768c4d3d957f17f5e4317c60" dependencies = [ - "camino", - "fs-err", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.119", - "toml", - "uniffi_meta", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", ] [[package]] @@ -1991,10 +2396,10 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af0c88664a9cb9559856f5a250283c9708923b303170e2e5c7db8f4ad65e9459" dependencies = [ - "anyhow", - "siphasher", - "uniffi_internal_macros", - "uniffi_pipeline", + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", ] [[package]] @@ -2003,11 +2408,11 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d00f5c044b4a3dca0b1226c92ddaeea626f337470e22180dd182160c87a06b0b" dependencies = [ - "anyhow", - "heck", - "indexmap", - "tempfile", - "uniffi_internal_macros", + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", ] [[package]] @@ -2016,10 +2421,10 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55dfd9c778d6b39cb5acd541d9ff63431974035d2bad83c897b766a6ee3152ac" dependencies = [ - "anyhow", - "textwrap", - "uniffi_meta", - "weedle2", + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", ] [[package]] @@ -2028,8 +2433,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", - "ctutils", + "crypto-common", + "ctutils", ] [[package]] @@ -2038,11 +2443,11 @@ version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", ] [[package]] @@ -2063,23 +2468,35 @@ version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ - "getrandom", - "js-sys", - "serde_core", - "wasm-bindgen", + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] @@ -2088,8 +2505,8 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "quote", + "wasm-bindgen-macro-support", ] [[package]] @@ -2098,11 +2515,11 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.7", - "wasm-bindgen-shared", + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", ] [[package]] @@ -2111,7 +2528,7 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ - "unicode-ident", + "unicode-ident", ] [[package]] @@ -2120,7 +2537,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" dependencies = [ - "nom", + "nom", ] [[package]] @@ -2135,7 +2552,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link", ] [[package]] @@ -2144,7 +2561,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ - "memchr", + "memchr", ] [[package]] @@ -2153,10 +2570,10 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" dependencies = [ - "ff", - "group", - "hybrid-array", - "primefield", + "ff", + "group", + "hybrid-array", + "primefield", ] [[package]] @@ -2171,9 +2588,9 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] @@ -2182,10 +2599,10 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", - "synstructure", + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", ] [[package]] @@ -2194,7 +2611,7 @@ version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ - "zerocopy-derive", + "zerocopy-derive", ] [[package]] @@ -2203,9 +2620,9 @@ version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2214,7 +2631,7 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ - "zerofrom-derive", + "zerofrom-derive", ] [[package]] @@ -2223,10 +2640,10 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", - "synstructure", + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", ] [[package]] @@ -2235,7 +2652,7 @@ version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ - "zeroize_derive", + "zeroize_derive", ] [[package]] @@ -2244,9 +2661,9 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2255,9 +2672,9 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "displaydoc", - "yoke", - "zerofrom", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] @@ -2266,9 +2683,9 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", + "yoke", + "zerofrom", + "zerovec-derive", ] [[package]] @@ -2277,9 +2694,9 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 21af2d1e6..4877969cf 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -14,3 +14,14 @@ hex = "0.4.3" secrecy = "0.10.3" tempfile = "3.27.0" url = "2.5.8" +axum = "0.8.9" +serde = { version = "1.0.229", features = ["derive"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "signal"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tower-http = { version = "0.7.1", features = ["trace"] } + +[dev-dependencies] +http-body-util = "0.1.5" +serde_json = "1.0.154" +tower = { version = "0.5.3", features = ["util"] } diff --git a/rust/crates/keygo-server/Dockerfile b/rust/crates/keygo-server/Dockerfile new file mode 100644 index 000000000..e933d1ac3 --- /dev/null +++ b/rust/crates/keygo-server/Dockerfile @@ -0,0 +1,22 @@ +FROM rust:1.98.0-alpine AS build +WORKDIR /app +# Bypass rust-toolchain.toml so rustup doesn't fetch the Android targets +ENV RUSTUP_TOOLCHAIN=1.98.0 +COPY . . +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/app/target \ + cargo build --release --locked -p keygo-server \ + && cp target/release/keygo-server /keygo-server + +FROM alpine:3.24 +RUN addgroup -S -g 10001 keygo && \ + adduser -S -D -H -u 10001 -G keygo keygo && \ + install -d -m 700 -o keygo -g keygo /run/secrets + +USER 10001 +COPY --from=build /keygo-server /usr/local/bin/keygo-server + +EXPOSE 8000 +HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \ + CMD ["wget", "-q", "--spider", "http://127.0.0.1:8000/health/live"] +CMD ["keygo-server", "serve"] diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs new file mode 100644 index 000000000..f985c122d --- /dev/null +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -0,0 +1,73 @@ +mod routes; +mod state; + +pub use state::AppState; + +use axum::Router; +use tower_http::trace::TraceLayer; + +pub fn router(state: AppState) -> Router { + Router::new() + .nest("/health", routes::health::router()) + .nest("/api/v1", v1()) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +fn v1() -> Router { + Router::new().nest("/meta", routes::meta::router()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use serde_json::{Value, json}; + use tower::ServiceExt; + + async fn get(state: AppState, uri: &str) -> (StatusCode, Vec) { + let request = Request::get(uri).body(Body::empty()).unwrap(); + let response = router(state).oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, body.to_vec()) + } + + fn state() -> AppState { + AppState { + allow_signups: false, + } + } + + #[tokio::test] + async fn liveness_probe_is_ok() { + let (status, _) = get(state(), "/health/live").await; + + assert_eq!(status, StatusCode::OK); + } + + #[tokio::test] + async fn server_info_reports_version_and_signups() { + let state = AppState { + allow_signups: true, + }; + + let (status, body) = get(state, "/api/v1/server").await; + + assert_eq!(status, StatusCode::OK); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + body, + json!({ "version": env!("CARGO_PKG_VERSION"), "allowSignups": true }), + ); + } + + #[tokio::test] + async fn unknown_route_is_not_found() { + let (status, _) = get(state(), "/api/v1/nope").await; + + assert_eq!(status, StatusCode::NOT_FOUND); + } +} diff --git a/rust/crates/keygo-server/src/api/routes/health.rs b/rust/crates/keygo-server/src/api/routes/health.rs new file mode 100644 index 000000000..5cdbdfa22 --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/health.rs @@ -0,0 +1,12 @@ +use crate::api::AppState; +use axum::Router; +use axum::http::StatusCode; +use axum::routing::get; + +pub fn router() -> Router { + Router::new().route("/live", get(live)) +} + +async fn live() -> StatusCode { + StatusCode::OK +} diff --git a/rust/crates/keygo-server/src/api/routes/meta.rs b/rust/crates/keygo-server/src/api/routes/meta.rs new file mode 100644 index 000000000..433150441 --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/meta.rs @@ -0,0 +1,24 @@ +use crate::api::AppState; +use axum::extract::State; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Serialize; + +pub fn router() -> Router { + Router::new().route("/", get(info)) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ServerInfo { + version: &'static str, + allow_signups: bool, +} + +/// What a client needs to know before talking to this server. +async fn info(State(state): State) -> Json { + Json(ServerInfo { + version: env!("CARGO_PKG_VERSION"), + allow_signups: state.allow_signups, + }) +} diff --git a/rust/crates/keygo-server/src/api/routes/mod.rs b/rust/crates/keygo-server/src/api/routes/mod.rs new file mode 100644 index 000000000..81e7bf7ac --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/mod.rs @@ -0,0 +1,2 @@ +pub mod health; +pub mod meta; diff --git a/rust/crates/keygo-server/src/api/state.rs b/rust/crates/keygo-server/src/api/state.rs new file mode 100644 index 000000000..2f0cc32df --- /dev/null +++ b/rust/crates/keygo-server/src/api/state.rs @@ -0,0 +1,14 @@ +use crate::config::Config; + +#[derive(Clone)] +pub struct AppState { + pub allow_signups: bool, +} + +impl AppState { + pub fn new(config: &Config) -> Self { + Self { + allow_signups: config.allow_signups, + } + } +} diff --git a/rust/crates/keygo-server/src/cli/commands/mod.rs b/rust/crates/keygo-server/src/cli/commands/mod.rs index 8f2418977..c89831e25 100644 --- a/rust/crates/keygo-server/src/cli/commands/mod.rs +++ b/rust/crates/keygo-server/src/cli/commands/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod check; pub(crate) mod init; +pub(crate) mod serve; diff --git a/rust/crates/keygo-server/src/cli/commands/serve.rs b/rust/crates/keygo-server/src/cli/commands/serve.rs new file mode 100644 index 000000000..fed5d9539 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/serve.rs @@ -0,0 +1,23 @@ +use crate::config::Config; +use crate::server; +use clap::Args; +use std::net::SocketAddr; +use tracing_subscriber::EnvFilter; + +#[derive(Args)] +pub struct ServeArgs { + /// Address the API listens on. + #[arg(long, env = "KEYGO_LISTEN", default_value = "0.0.0.0:8000")] + listen: SocketAddr, +} + +pub fn run(config: &Config, args: &ServeArgs) -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_env("KEYGO_LOG").unwrap_or_else(|_| "info".into())) + .init(); + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(server::start(config, args.listen)) +} diff --git a/rust/crates/keygo-server/src/cli/mod.rs b/rust/crates/keygo-server/src/cli/mod.rs index 3431e3d1c..71b92aad6 100644 --- a/rust/crates/keygo-server/src/cli/mod.rs +++ b/rust/crates/keygo-server/src/cli/mod.rs @@ -1,5 +1,6 @@ mod commands; +use crate::cli::commands::serve::ServeArgs; use crate::config::Config; use clap::{Parser, Subcommand}; @@ -17,6 +18,7 @@ pub struct Cli { enum Commands { Init, Check, + Serve(ServeArgs), } impl Cli { @@ -26,6 +28,7 @@ impl Cli { match cli.command { Commands::Init => commands::init::run(&cli.config), Commands::Check => commands::check::run(&cli.config), + Commands::Serve(args) => commands::serve::run(&cli.config, &args), } } } diff --git a/rust/crates/keygo-server/src/main.rs b/rust/crates/keygo-server/src/main.rs index b9151519a..f42227970 100644 --- a/rust/crates/keygo-server/src/main.rs +++ b/rust/crates/keygo-server/src/main.rs @@ -1,8 +1,10 @@ use crate::cli::Cli; +mod api; mod cli; mod config; mod secrets; +mod server; fn main() -> anyhow::Result<()> { Cli::start() diff --git a/rust/crates/keygo-server/src/server/mod.rs b/rust/crates/keygo-server/src/server/mod.rs new file mode 100644 index 000000000..ee93740a6 --- /dev/null +++ b/rust/crates/keygo-server/src/server/mod.rs @@ -0,0 +1,36 @@ +use crate::api::{self, AppState}; +use crate::config::Config; +use anyhow::Context; +use std::net::SocketAddr; +use tokio::net::TcpListener; +use tokio::signal::unix::{SignalKind, signal}; + +pub async fn start(config: &Config, listen: SocketAddr) -> anyhow::Result<()> { + let app = api::router(AppState::new(config)); + let listener = TcpListener::bind(listen) + .await + .with_context(|| format!("cannot listen on {listen}"))?; + tracing::info!(address = %listener.local_addr()?, "listening"); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + tracing::info!("stopped"); + Ok(()) +} + +async fn shutdown_signal() { + let terminate = async { + match signal(SignalKind::terminate()) { + Ok(mut terminate) => { + terminate.recv().await; + } + Err(_) => std::future::pending().await, + } + }; + + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate => {} + } +} From 7205fa08cf976c86903016cd70d57c39882082d1 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 18:26:28 +0200 Subject: [PATCH 05/43] feat(opaque): add opaque protocol --- rust/Cargo.lock | 494 +++++++++++++++++---- rust/crates/keygo-opaque/Cargo.toml | 15 + rust/crates/keygo-opaque/src/client.rs | 92 ++++ rust/crates/keygo-opaque/src/error.rs | 33 ++ rust/crates/keygo-opaque/src/keys.rs | 33 ++ rust/crates/keygo-opaque/src/lib.rs | 20 + rust/crates/keygo-opaque/src/macros.rs | 19 + rust/crates/keygo-opaque/src/messages.rs | 24 + rust/crates/keygo-opaque/src/server.rs | 66 +++ rust/crates/keygo-opaque/src/suite.rs | 79 ++++ rust/crates/keygo-opaque/tests/protocol.rs | 118 +++++ 11 files changed, 904 insertions(+), 89 deletions(-) create mode 100644 rust/crates/keygo-opaque/Cargo.toml create mode 100644 rust/crates/keygo-opaque/src/client.rs create mode 100644 rust/crates/keygo-opaque/src/error.rs create mode 100644 rust/crates/keygo-opaque/src/keys.rs create mode 100644 rust/crates/keygo-opaque/src/lib.rs create mode 100644 rust/crates/keygo-opaque/src/macros.rs create mode 100644 rust/crates/keygo-opaque/src/messages.rs create mode 100644 rust/crates/keygo-opaque/src/server.rs create mode 100644 rust/crates/keygo-opaque/src/suite.rs create mode 100644 rust/crates/keygo-opaque/tests/protocol.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 1173edac3..68154d41e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,7 +8,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "inout", ] @@ -20,7 +20,7 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -102,6 +102,18 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2 0.10.6", + "cpufeatures 0.2.17", + "password-hash 0.5.0", +] + [[package]] name = "argon2" version = "0.6.0" @@ -109,9 +121,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", - "blake2", - "cpufeatures", - "password-hash", + "blake2 0.11.0", + "cpufeatures 0.3.1", + "password-hash 0.6.1", ] [[package]] @@ -255,6 +267,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -303,13 +321,31 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake2" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -379,8 +415,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures", - "rand_core", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -416,8 +452,8 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "inout", ] @@ -473,6 +509,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -501,6 +543,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -516,6 +567,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-bigint" version = "0.7.5" @@ -524,23 +587,33 @@ checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ "cpubits", "ctutils", - "getrandom", + "getrandom 0.4.3", "hybrid-array", "num-traits", - "rand_core", + "rand_core 0.10.1", "subtle", "zeroize", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom", + "getrandom 0.4.3", "hybrid-array", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -583,6 +656,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rand_core 0.6.4", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -590,11 +679,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rand_core", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", "subtle", "zeroize", @@ -617,26 +706,58 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "der" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ - "const-oid", + "const-oid 0.10.2", "pem-rfc7468", "zeroize", ] +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", "ctutils", ] @@ -657,9 +778,9 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", - "digest", - "elliptic-curve", + "der 0.8.2", + "digest 0.11.3", + "elliptic-curve 0.14.1", "rfc6979", "signature", "spki", @@ -682,11 +803,11 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 5.0.0", "ed25519", - "rand_core", + "rand_core 0.10.1", "serde", - "sha2", + "sha2 0.11.1", "signature", "subtle", "zeroize", @@ -698,23 +819,41 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", +] + [[package]] name = "elliptic-curve" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common", - "digest", - "ff", - "group", + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", "hybrid-array", "pem-rfc7468", "pkcs8", - "rand_core", - "sec1", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -750,16 +889,32 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "ff" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core", + "rand_core 0.10.1", "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -820,6 +975,28 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -829,7 +1006,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -849,14 +1026,25 @@ dependencies = [ "scroll", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "group" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "ff", - "rand_core", + "ff 0.14.0", + "rand_core 0.10.1", "subtle", ] @@ -889,13 +1077,31 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -904,7 +1110,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -1162,7 +1368,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -1171,7 +1377,7 @@ version = "0.1.0" dependencies = [ "aead", "aes-gcm-siv", - "argon2", + "argon2 0.6.0", "async-trait", "base64", "bcs", @@ -1179,13 +1385,13 @@ dependencies = [ "coset", "csv", "email_address", - "hkdf", + "hkdf 0.13.0", "passkey", "passkey-authenticator", "passkey-types", "serde", "serde_json", - "sha2", + "sha2 0.11.1", "subtle", "thiserror", "tokio", @@ -1195,6 +1401,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keygo-opaque" +version = "0.1.0" +dependencies = [ + "opaque-ke", + "rand_core 0.6.4", + "sha2 0.10.9", + "thiserror", + "voprf", + "zeroize", +] + [[package]] name = "keygo-server" version = "0.1.0" @@ -1204,7 +1422,7 @@ dependencies = [ "anyhow", "axum", "clap", - "getrandom", + "getrandom 0.4.3", "hex", "http-body-util", "secrecy", @@ -1311,8 +1529,8 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "const-oid", - "crypto-common", + "const-oid 0.10.2", + "crypto-common 0.2.2", "ctutils", "hybrid-array", "module-lattice", @@ -1372,6 +1590,27 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-ke" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded22991b43cd15561b62b2e1cf9ace1344a8534eebec96202d5c96a77a6616a" +dependencies = [ + "argon2 0.5.3", + "curve25519-dalek 4.1.3", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve 0.13.8", + "generic-array", + "hkdf 0.12.4", + "hmac 0.12.1", + "rand 0.8.8", + "subtle", + "voprf", + "zeroize", +] + [[package]] name = "p256" version = "0.14.0" @@ -1379,10 +1618,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ "ecdsa", - "elliptic-curve", + "elliptic-curve 0.14.1", "primefield", "primeorder", - "sha2", + "sha2 0.11.1", ] [[package]] @@ -1438,13 +1677,13 @@ checksum = "e5108c58974bacdae414ee66edf0c009818509ccf6be4a911032147336e3da27" dependencies = [ "coset", "ed25519-dalek", - "getrandom", - "hmac", + "getrandom 0.4.3", + "hmac 0.13.0", "log", "ml-dsa", "p256", - "rand", - "sha2", + "rand 0.10.3", + "sha2 0.11.1", "signature", "zeroize", ] @@ -1473,13 +1712,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "password-hash" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "getrandom", + "getrandom 0.4.3", "phc", ] @@ -1506,7 +1756,7 @@ checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" dependencies = [ "base64ct", "ctutils", - "getrandom", + "getrandom 0.4.3", ] [[package]] @@ -1521,7 +1771,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", + "der 0.8.2", "spki", ] @@ -1538,7 +1788,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", "universal-hash", ] @@ -1557,10 +1807,10 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint", - "crypto-common", - "ff", - "rand_core", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", "subtle", "zeroize", ] @@ -1571,7 +1821,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.14.1", "once_cell", "primefield", "serdect", @@ -1608,6 +1858,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.3" @@ -1615,8 +1874,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom", - "rand_core", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1648,8 +1916,8 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "crypto-bigint", - "hmac", + "crypto-bigint 0.7.5", + "hmac 0.13.0", ] [[package]] @@ -1712,15 +1980,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct", + "base16ct 1.0.0", "ctutils", - "der", + "der 0.8.2", "hybrid-array", "subtle", "zeroize", @@ -1828,7 +2109,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -1839,19 +2120,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] name = "sha2" -version = "0.11.0" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1860,7 +2152,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ - "digest", + "digest 0.11.3", "keccak", "sponge-cursor", ] @@ -1890,8 +2182,8 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest", - "rand_core", + "digest 0.11.3", + "rand_core 0.10.1", ] [[package]] @@ -1935,7 +2227,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ "base64ct", - "der", + "der 0.8.2", ] [[package]] @@ -2035,7 +2327,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -2163,12 +2455,12 @@ checksum = "49d0f9b7aa5def86d3a8d8ba926fe8c1990924dd715d2b5fc54a4aa3788c11c1" dependencies = [ "base32", "constant_time_eq", - "hmac", + "hmac 0.13.0", "percent-encoding", - "rand", + "rand 0.10.3", "serde", "sha1", - "sha2", + "sha2 0.11.1", "url", "zeroize", ] @@ -2433,7 +2725,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "ctutils", ] @@ -2468,7 +2760,7 @@ version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ - "getrandom", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -2480,6 +2772,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "voprf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" +dependencies = [ + "curve25519-dalek 4.1.3", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve 0.13.8", + "generic-array", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2570,8 +2886,8 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" dependencies = [ - "ff", - "group", + "ff 0.14.0", + "group 0.14.0", "hybrid-array", "primefield", ] diff --git a/rust/crates/keygo-opaque/Cargo.toml b/rust/crates/keygo-opaque/Cargo.toml new file mode 100644 index 000000000..9e002f161 --- /dev/null +++ b/rust/crates/keygo-opaque/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "keygo-opaque" +version.workspace = true +edition.workspace = true + +[dependencies] +thiserror.workspace = true +zeroize.workspace = true + +opaque-ke = { version = "4.0.1", default-features = false, features = ["argon2", "ristretto255"] } +# opaque-ke 4 is built on digest 0.10 / rand_core 0.6; newer majors do not satisfy its trait bounds. +rand_core = { version = "0.6.4", features = ["getrandom"] } +sha2 = "0.10.9" +# Only to classify the OPRF errors opaque-ke passes through. +voprf = { version = "0.5.0", default-features = false } diff --git a/rust/crates/keygo-opaque/src/client.rs b/rust/crates/keygo-opaque/src/client.rs new file mode 100644 index 000000000..bf196b489 --- /dev/null +++ b/rust/crates/keygo-opaque/src/client.rs @@ -0,0 +1,92 @@ +use crate::error::{OpaqueError, OpaqueResult}; +use crate::keys::{ExportKey, SessionKey}; +use crate::messages::{ + CredentialFinalization, CredentialRequest, CredentialResponse, RegistrationRequest, + RegistrationResponse, RegistrationUpload, +}; +use crate::suite::{KeyGoSuite, client_login_parameters}; +use opaque_ke::ClientRegistrationFinishParameters; +use opaque_ke::keypair::PublicKey; +use rand_core::OsRng; + +/// Client state held between registration start and finish. +pub struct ClientRegistration(opaque_ke::ClientRegistration); + +pub struct RegistrationFinish { + pub upload: RegistrationUpload, + pub export_key: ExportKey, + pub server_public_key: Vec, +} + +impl ClientRegistration { + pub fn start(password: &[u8]) -> OpaqueResult<(Self, RegistrationRequest)> { + require_password(password)?; + let result = opaque_ke::ClientRegistration::start(&mut OsRng, password)?; + Ok((Self(result.state), RegistrationRequest(result.message))) + } + + pub fn finish( + self, + password: &[u8], + response: RegistrationResponse, + ) -> OpaqueResult { + require_password(password)?; + let result = self.0.finish( + &mut OsRng, + password, + response.0, + ClientRegistrationFinishParameters::default(), + )?; + Ok(RegistrationFinish { + upload: RegistrationUpload(result.message), + export_key: ExportKey::take(result.export_key), + server_public_key: public_key_bytes(&result.server_s_pk), + }) + } +} + +/// Client state held between KE1 and KE3. +pub struct ClientLogin(opaque_ke::ClientLogin); + +pub struct LoginFinish { + pub finalization: CredentialFinalization, + pub session_key: SessionKey, + pub export_key: ExportKey, + pub server_public_key: Vec, +} + +impl ClientLogin { + pub fn start(password: &[u8]) -> OpaqueResult<(Self, CredentialRequest)> { + require_password(password)?; + let result = opaque_ke::ClientLogin::start(&mut OsRng, password)?; + Ok((Self(result.state), CredentialRequest(result.message))) + } + + pub fn finish( + self, + password: &[u8], + response: CredentialResponse, + ) -> OpaqueResult { + require_password(password)?; + let result = self + .0 + .finish(&mut OsRng, password, response.0, client_login_parameters())?; + Ok(LoginFinish { + finalization: CredentialFinalization(result.message), + session_key: SessionKey::take(result.session_key), + export_key: ExportKey::take(result.export_key), + server_public_key: public_key_bytes(&result.server_s_pk), + }) + } +} + +fn require_password(password: &[u8]) -> OpaqueResult<()> { + if password.is_empty() { + return Err(OpaqueError::EmptyPassword); + } + Ok(()) +} + +fn public_key_bytes(key: &PublicKey) -> Vec { + key.serialize().to_vec() +} diff --git a/rust/crates/keygo-opaque/src/error.rs b/rust/crates/keygo-opaque/src/error.rs new file mode 100644 index 000000000..304969715 --- /dev/null +++ b/rust/crates/keygo-opaque/src/error.rs @@ -0,0 +1,33 @@ +use opaque_ke::errors::{InternalError, ProtocolError}; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum OpaqueError { + #[error("Login failed: wrong password or unknown account")] + InvalidLogin, + #[error("Server reflected the client's blinded element")] + ReflectedValue, + #[error("Malformed OPAQUE message")] + Malformed, + #[error("Password must not be empty")] + EmptyPassword, + #[error("OPAQUE protocol failure: {0}")] + Protocol(String), +} + +pub type OpaqueResult = Result; + +impl From for OpaqueError { + fn from(err: ProtocolError) -> Self { + match err { + ProtocolError::InvalidLoginError => Self::InvalidLogin, + ProtocolError::ReflectedValueError => Self::ReflectedValue, + ProtocolError::SerializationError + | ProtocolError::SizeError { .. } + | ProtocolError::LibraryError(InternalError::OprfError( + voprf::Error::Deserialization, + )) => Self::Malformed, + other => Self::Protocol(format!("{other:?}")), + } + } +} diff --git a/rust/crates/keygo-opaque/src/keys.rs b/rust/crates/keygo-opaque/src/keys.rs new file mode 100644 index 000000000..9ffc62f94 --- /dev/null +++ b/rust/crates/keygo-opaque/src/keys.rs @@ -0,0 +1,33 @@ +use opaque_ke::generic_array::GenericArray; +use opaque_ke::generic_array::typenum::U64; +use zeroize::{Zeroize, Zeroizing}; + +pub const KEY_LEN: usize = 64; + +macro_rules! secret_key { + ($name:ident) => { + pub struct $name(Zeroizing<[u8; KEY_LEN]>); + + impl $name { + pub fn as_bytes(&self) -> &[u8; KEY_LEN] { + &self.0 + } + + pub(crate) fn take(mut out: GenericArray) -> Self { + let mut key = Zeroizing::new([0u8; KEY_LEN]); + key.copy_from_slice(&out); + out.as_mut_slice().zeroize(); + Self(key) + } + } + + impl core::fmt::Debug for $name { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(concat!(stringify!($name), "()")) + } + } + }; +} + +secret_key!(SessionKey); +secret_key!(ExportKey); diff --git a/rust/crates/keygo-opaque/src/lib.rs b/rust/crates/keygo-opaque/src/lib.rs new file mode 100644 index 000000000..dd36b0712 --- /dev/null +++ b/rust/crates/keygo-opaque/src/lib.rs @@ -0,0 +1,20 @@ +#![forbid(unsafe_code)] + +#[macro_use] +mod macros; + +pub mod client; +pub mod server; + +mod error; +mod keys; +mod messages; +mod suite; + +pub use error::{OpaqueError, OpaqueResult}; +pub use keys::{ExportKey, KEY_LEN, SessionKey}; +pub use messages::{ + CredentialFinalization, CredentialRequest, CredentialResponse, PasswordFile, + RegistrationRequest, RegistrationResponse, RegistrationUpload, +}; +pub use suite::{KSF_ITERS, KSF_LANES, KSF_MEM_KIB, LOGIN_CONTEXT}; diff --git a/rust/crates/keygo-opaque/src/macros.rs b/rust/crates/keygo-opaque/src/macros.rs new file mode 100644 index 000000000..a05f5268f --- /dev/null +++ b/rust/crates/keygo-opaque/src/macros.rs @@ -0,0 +1,19 @@ +/// Declares newtypes over `opaque_ke` types instantiated with `KeyGoSuite`, each with a byte codec. +/// +/// `as $bytes` picks the `to_bytes` output; use `Zeroizing>` for anything secret. +macro_rules! opaque_newtype { + ($($(#[$attr:meta])* $name:ident($inner:ident) as $bytes:ty;)+) => {$( + $(#[$attr])* + pub struct $name(pub(crate) opaque_ke::$inner<$crate::suite::KeyGoSuite>); + + impl $name { + pub fn to_bytes(&self) -> $bytes { + self.0.serialize().to_vec().into() + } + + pub fn from_bytes(bytes: &[u8]) -> $crate::OpaqueResult { + Ok(Self(opaque_ke::$inner::deserialize(bytes)?)) + } + } + )+}; +} diff --git a/rust/crates/keygo-opaque/src/messages.rs b/rust/crates/keygo-opaque/src/messages.rs new file mode 100644 index 000000000..eafd56f27 --- /dev/null +++ b/rust/crates/keygo-opaque/src/messages.rs @@ -0,0 +1,24 @@ +use zeroize::Zeroizing; + +opaque_newtype! { + /// Registration, client -> server + RegistrationRequest(RegistrationRequest) as Vec; + + /// Registration, server -> client + RegistrationResponse(RegistrationResponse) as Vec; + + /// Registration, client -> server + RegistrationUpload(RegistrationUpload) as Vec; + + /// Login (KE1), client -> server + CredentialRequest(CredentialRequest) as Vec; + + /// Login (KE2), server -> client + CredentialResponse(CredentialResponse) as Vec; + + /// Login (KE3), client -> server + CredentialFinalization(CredentialFinalization) as Vec; + + /// Per-account server record + PasswordFile(ServerRegistration) as Zeroizing>; +} diff --git a/rust/crates/keygo-opaque/src/server.rs b/rust/crates/keygo-opaque/src/server.rs new file mode 100644 index 000000000..6574e9576 --- /dev/null +++ b/rust/crates/keygo-opaque/src/server.rs @@ -0,0 +1,66 @@ +use crate::error::OpaqueResult; +use crate::keys::SessionKey; +use crate::messages::{ + CredentialFinalization, CredentialRequest, CredentialResponse, PasswordFile, + RegistrationRequest, RegistrationResponse, RegistrationUpload, +}; +use crate::suite::server_login_parameters; +use rand_core::OsRng; +use zeroize::Zeroizing; + +opaque_newtype! { + /// Long-term server keypair and OPRF seed, shared by all accounts. + ServerSetup(ServerSetup) as Zeroizing>; + + /// Server state held between KE2 and KE3. + ServerLogin(ServerLogin) as Zeroizing>; +} + +impl ServerSetup { + pub fn generate() -> Self { + Self(opaque_ke::ServerSetup::new(&mut OsRng)) + } + + pub fn public_key(&self) -> Vec { + self.0.keypair().public().serialize().to_vec() + } +} + +pub fn registration_start( + setup: &ServerSetup, + request: RegistrationRequest, + credential_identifier: &[u8], +) -> OpaqueResult { + let result = opaque_ke::ServerRegistration::start(&setup.0, request.0, credential_identifier)?; + Ok(RegistrationResponse(result.message)) +} + +pub fn registration_finish(upload: RegistrationUpload) -> PasswordFile { + PasswordFile(opaque_ke::ServerRegistration::finish(upload.0)) +} + +impl ServerLogin { + /// A `None` password file still yields a well-formed response, so unknown accounts fail at the + /// client exactly like a wrong password. + pub fn start( + setup: &ServerSetup, + password_file: Option, + request: CredentialRequest, + credential_identifier: &[u8], + ) -> OpaqueResult<(Self, CredentialResponse)> { + let result = opaque_ke::ServerLogin::start( + &mut OsRng, + &setup.0, + password_file.map(|file| file.0), + request.0, + credential_identifier, + server_login_parameters(), + )?; + Ok((Self(result.state), CredentialResponse(result.message))) + } + + pub fn finish(self, finalization: CredentialFinalization) -> OpaqueResult { + let result = self.0.finish(finalization.0, server_login_parameters())?; + Ok(SessionKey::take(result.session_key)) + } +} diff --git a/rust/crates/keygo-opaque/src/suite.rs b/rust/crates/keygo-opaque/src/suite.rs new file mode 100644 index 000000000..039a4329d --- /dev/null +++ b/rust/crates/keygo-opaque/src/suite.rs @@ -0,0 +1,79 @@ +use opaque_ke::argon2::{Algorithm, Argon2, Params, Version}; +use opaque_ke::errors::InternalError; +use opaque_ke::generic_array::{ArrayLength, GenericArray}; +use opaque_ke::ksf::Ksf; +use opaque_ke::{CipherSuite, ClientLoginFinishParameters, ServerLoginParameters}; + +pub const KSF_MEM_KIB: u32 = 64 * 1024; +pub const KSF_ITERS: u32 = 3; +pub const KSF_LANES: u32 = 4; + +/// Binds logins to KeyGo so they can't be replayed against other OPAQUE deployments. +pub const LOGIN_CONTEXT: &[u8] = b"KeyGo-OPAQUE-v1"; + +pub(crate) struct KeyGoSuite; + +impl CipherSuite for KeyGoSuite { + type OprfCs = opaque_ke::Ristretto255; + type KeyExchange = opaque_ke::TripleDh; + type Ksf = KeyGoKsf; +} + +pub(crate) struct KeyGoKsf(Argon2<'static>); + +impl Default for KeyGoKsf { + fn default() -> Self { + let params = Params::new(KSF_MEM_KIB, KSF_ITERS, KSF_LANES, None) + .expect("pinned Argon2 params are valid"); + Self(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) + } +} + +impl Ksf for KeyGoKsf { + fn hash>( + &self, + input: GenericArray, + ) -> Result, InternalError> { + self.0.hash(input) + } +} + +pub(crate) fn client_login_parameters() +-> ClientLoginFinishParameters<'static, 'static, 'static, KeyGoSuite> { + ClientLoginFinishParameters { + context: Some(LOGIN_CONTEXT), + ..Default::default() + } +} + +pub(crate) fn server_login_parameters() -> ServerLoginParameters<'static, 'static> { + ServerLoginParameters { + context: Some(LOGIN_CONTEXT), + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ksf_cost_is_pinned() { + assert_eq!((KSF_MEM_KIB, KSF_ITERS, KSF_LANES), (65_536, 3, 4)); + } + + #[test] + fn login_context_is_pinned() { + assert_eq!(LOGIN_CONTEXT, b"KeyGo-OPAQUE-v1"); + } + + #[test] + fn ksf_uses_pinned_params() { + let ksf = KeyGoKsf::default(); + let params = ksf.0.params(); + assert_eq!( + (params.m_cost(), params.t_cost(), params.p_cost()), + (KSF_MEM_KIB, KSF_ITERS, KSF_LANES), + ); + } +} diff --git a/rust/crates/keygo-opaque/tests/protocol.rs b/rust/crates/keygo-opaque/tests/protocol.rs new file mode 100644 index 000000000..3eeb97ebe --- /dev/null +++ b/rust/crates/keygo-opaque/tests/protocol.rs @@ -0,0 +1,118 @@ +use keygo_opaque::client::{ClientLogin, ClientRegistration, LoginFinish}; +use keygo_opaque::server::{self, ServerLogin, ServerSetup}; +use keygo_opaque::{ + CredentialFinalization, CredentialRequest, CredentialResponse, OpaqueError, PasswordFile, + RegistrationRequest, +}; + +const ID: &[u8] = b"account-1"; +const PASSWORD: &[u8] = b"correct horse battery staple"; + +fn register(setup: &ServerSetup, password: &[u8]) -> (PasswordFile, Vec) { + let (client, request) = ClientRegistration::start(password).unwrap(); + let request = RegistrationRequest::from_bytes(&request.to_bytes()).unwrap(); + let response = server::registration_start(setup, request, ID).unwrap(); + let finish = client.finish(password, response).unwrap(); + let file = server::registration_finish(finish.upload); + let file = PasswordFile::from_bytes(&file.to_bytes()).unwrap(); + (file, finish.export_key.as_bytes().to_vec()) +} + +fn login( + setup: &ServerSetup, + file: Option, + password: &[u8], +) -> Result<(LoginFinish, ServerLogin), OpaqueError> { + let (client, request) = ClientLogin::start(password)?; + let request = CredentialRequest::from_bytes(&request.to_bytes())?; + let (server, response) = ServerLogin::start(setup, file, request, ID)?; + let server = ServerLogin::from_bytes(&server.to_bytes())?; + let response = CredentialResponse::from_bytes(&response.to_bytes())?; + Ok((client.finish(password, response)?, server)) +} + +#[test] +fn register_then_login_agrees_on_keys() { + let setup = ServerSetup::generate(); + let (file, registration_export_key) = register(&setup, PASSWORD); + + let (client, server) = login(&setup, Some(file), PASSWORD).unwrap(); + let finalization = CredentialFinalization::from_bytes(&client.finalization.to_bytes()).unwrap(); + let server_session_key = server.finish(finalization).unwrap(); + + assert_eq!(client.session_key.as_bytes(), server_session_key.as_bytes()); + assert_eq!( + client.export_key.as_bytes().as_slice(), + registration_export_key + ); + assert_eq!(client.server_public_key, setup.public_key()); +} + +#[test] +fn wrong_password_is_rejected_by_the_client() { + let setup = ServerSetup::generate(); + let (file, _) = register(&setup, PASSWORD); + + assert_eq!( + login(&setup, Some(file), b"wrong").err(), + Some(OpaqueError::InvalidLogin), + ); +} + +#[test] +fn unknown_account_fails_like_a_wrong_password() { + let setup = ServerSetup::generate(); + + assert_eq!( + login(&setup, None, PASSWORD).err(), + Some(OpaqueError::InvalidLogin), + ); +} + +#[test] +fn login_against_a_different_server_setup_fails() { + let (file, _) = register(&ServerSetup::generate(), PASSWORD); + + assert_eq!( + login(&ServerSetup::generate(), Some(file), PASSWORD).err(), + Some(OpaqueError::InvalidLogin), + ); +} + +#[test] +fn server_setup_survives_serialization() { + let setup = ServerSetup::generate(); + let restored = ServerSetup::from_bytes(&setup.to_bytes()).unwrap(); + let (file, _) = register(&setup, PASSWORD); + + assert_eq!(restored.public_key(), setup.public_key()); + assert!(login(&restored, Some(file), PASSWORD).is_ok()); +} + +#[test] +fn empty_password_is_rejected() { + assert_eq!( + ClientRegistration::start(b"").err(), + Some(OpaqueError::EmptyPassword) + ); + assert_eq!( + ClientLogin::start(b"").err(), + Some(OpaqueError::EmptyPassword) + ); +} + +#[test] +fn malformed_bytes_are_reported_as_malformed() { + assert_eq!( + RegistrationRequest::from_bytes(&[]).err(), + Some(OpaqueError::Malformed) + ); + assert_eq!( + CredentialRequest::from_bytes(&[0xff; 7]).err(), + Some(OpaqueError::Malformed) + ); + assert_eq!( + PasswordFile::from_bytes(&[0; 3]).err(), + Some(OpaqueError::Malformed) + ); +} From 3b53090a4ecfad11a9d0c6bd7e59bd652f24f751 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 19:15:35 +0200 Subject: [PATCH 06/43] test(server): request meta endpoint at /api/v1/meta Co-Authored-By: Claude Opus 5.5 --- rust/crates/keygo-server/src/api/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index f985c122d..26ca8a389 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -54,7 +54,7 @@ mod tests { allow_signups: true, }; - let (status, body) = get(state, "/api/v1/server").await; + let (status, body) = get(state, "/api/v1/meta").await; assert_eq!(status, StatusCode::OK); let body: Value = serde_json::from_slice(&body).unwrap(); From cc162535ce101911097570628c41e76bf70613b6 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 20:02:50 +0200 Subject: [PATCH 07/43] refactor(server): read secrets through typed decoders --- .../keygo-server/src/cli/commands/check.rs | 9 +- .../keygo-server/src/cli/commands/init.rs | 2 +- rust/crates/keygo-server/src/secrets.rs | 154 ++++++++++++------ 3 files changed, 107 insertions(+), 58 deletions(-) diff --git a/rust/crates/keygo-server/src/cli/commands/check.rs b/rust/crates/keygo-server/src/cli/commands/check.rs index 1fdeaf864..2213822ad 100644 --- a/rust/crates/keygo-server/src/cli/commands/check.rs +++ b/rust/crates/keygo-server/src/cli/commands/check.rs @@ -2,7 +2,6 @@ use crate::config::{Config, DatabaseConfig, Environment}; use crate::secrets::{Secret, SecretStore}; use anstream::println; use anstyle::{AnsiColor, Style}; -use secrecy::ExposeSecret; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; @@ -198,13 +197,13 @@ fn check_secrets_dir(dir: &Path) -> Check { fn check_secret(store: &SecretStore, secret: Secret) -> Check { let label = secret.name(); - let value = match store.load(secret) { - Ok(value) => value, + let weakness = match store.inspect(secret) { + Ok(weakness) => weakness, Err(e) => return Check::new(Status::Fail, label, format!("{e:#}")), }; - let mut issues: Vec = secret.weakness(value.expose_secret()).into_iter().collect(); - // load() already read the file, so its metadata is only missing on a race we can ignore. + let mut issues: Vec = weakness.into_iter().collect(); + // inspect() already read the file, so its metadata is only missing on a race we can ignore. let mode = fs::metadata(store.path(secret)) .map(|m| m.permissions().mode() & 0o777) .unwrap_or(0); diff --git a/rust/crates/keygo-server/src/cli/commands/init.rs b/rust/crates/keygo-server/src/cli/commands/init.rs index a3ec8fdd5..e087e4ec6 100644 --- a/rust/crates/keygo-server/src/cli/commands/init.rs +++ b/rust/crates/keygo-server/src/cli/commands/init.rs @@ -11,7 +11,7 @@ pub fn run(config: &Config) -> anyhow::Result<()> { CreateOutcome::Created => "created", CreateOutcome::AlreadyExists => { store - .load(secret) + .inspect(secret) .context("refusing to overwrite an existing secret")?; "exists" } diff --git a/rust/crates/keygo-server/src/secrets.rs b/rust/crates/keygo-server/src/secrets.rs index 51851b951..77ee2d29b 100644 --- a/rust/crates/keygo-server/src/secrets.rs +++ b/rust/crates/keygo-server/src/secrets.rs @@ -20,34 +20,61 @@ impl Secret { } } - /// Strength of the values `init` generates. - pub fn generated_bits(self) -> usize { + pub fn generate(self) -> anyhow::Result { match self { - Secret::DbPassword => 256, + Secret::DbPassword => DbPassword::generate(), } } - pub fn generate(self) -> anyhow::Result { - let mut raw = Zeroizing::new(vec![0u8; self.generated_bits() / 8]); + /// Fails if the server cannot use `stored`, otherwise says why it looks weak, if it does. + fn assess(self, stored: &str) -> anyhow::Result> { + fn assess(stored: &str) -> anyhow::Result> { + T::decode(stored).map(|value| T::weakness(&value)) + } + + match self { + Secret::DbPassword => assess::(stored), + } + } +} + +pub trait TypedSecret { + const SECRET: Secret; + type Value; + + fn generate() -> anyhow::Result; + + fn decode(stored: &str) -> anyhow::Result; + fn weakness(_value: &Self::Value) -> Option { + None + } +} + +pub struct DbPassword; + +impl DbPassword { + const GENERATED_BITS: usize = 256; +} + +impl TypedSecret for DbPassword { + const SECRET: Secret = Secret::DbPassword; + type Value = SecretString; + + fn generate() -> anyhow::Result { + let mut raw = Zeroizing::new(vec![0u8; Self::GENERATED_BITS / 8]); getrandom::fill(raw.as_mut_slice()).map_err(|e| anyhow!("getrandom: {}", e))?; Ok(hex::encode(raw.as_slice()).into()) } - /// Rejects values the server cannot use at all. - pub fn validate(self, value: &str) -> anyhow::Result<()> { - match self { - Secret::DbPassword => ensure!(!value.is_empty(), "empty"), - } - Ok(()) + fn decode(stored: &str) -> anyhow::Result { + ensure!(!stored.is_empty(), "empty"); + Ok(stored.into()) } - /// Flags a usable value that looks weak. - pub fn weakness(self, value: &str) -> Option { + fn weakness(value: &SecretString) -> Option { const MIN_CHARS: usize = 16; - match self { - Secret::DbPassword => (value.chars().count() < MIN_CHARS) - .then(|| format!("shorter than {MIN_CHARS} characters")), - } + (value.expose_secret().chars().count() < MIN_CHARS) + .then(|| format!("shorter than {MIN_CHARS} characters")) } } @@ -78,7 +105,20 @@ impl SecretStore { .with_context(|| format!("cannot create {}", self.dir.display())) } - pub fn load(&self, secret: Secret) -> anyhow::Result { + pub fn read(&self) -> anyhow::Result { + let stored = self.read_text(T::SECRET)?; + T::decode(stored.expose_secret()) + .with_context(|| format!("{} is invalid", self.path(T::SECRET).display())) + } + + pub fn inspect(&self, secret: Secret) -> anyhow::Result> { + let stored = self.read_text(secret)?; + secret + .assess(stored.expose_secret()) + .with_context(|| format!("{} is invalid", self.path(secret).display())) + } + + fn read_text(&self, secret: Secret) -> anyhow::Result { let path = self.path(secret); let bytes = Zeroizing::new( fs::read(&path).with_context(|| format!("cannot read {}", path.display()))?, @@ -86,15 +126,12 @@ impl SecretStore { let value = str::from_utf8(&bytes) .with_context(|| format!("{} is not UTF-8", path.display()))? .trim(); - secret - .validate(value) - .with_context(|| format!("{} is invalid", path.display()))?; Ok(value.into()) } pub fn create(&self, secret: Secret, value: &SecretString) -> anyhow::Result { secret - .validate(value.expose_secret()) + .assess(value.expose_secret()) .with_context(|| format!("refusing to store invalid {}", secret.name()))?; let path = self.path(secret); @@ -144,7 +181,7 @@ mod tests { fn generated_db_password_is_valid() { let value = Secret::DbPassword.generate().unwrap(); - assert!(Secret::DbPassword.validate(value.expose_secret()).is_ok()); + assert!(DbPassword::decode(value.expose_secret()).is_ok()); } #[test] @@ -155,48 +192,33 @@ mod tests { assert_ne!(a.expose_secret(), b.expose_secret()); } - #[test] - fn generated_value_has_exactly_the_generated_bits() { - let value = Secret::DbPassword.generate().unwrap(); - - assert_eq!( - value.expose_secret().len() * 4, - Secret::DbPassword.generated_bits() - ); - assert_eq!(Secret::DbPassword.weakness(value.expose_secret()), None); - } - #[test] fn externally_issued_password_is_accepted() { - assert!(Secret::DbPassword.validate("Tr0ub4dor&3-Cloud!").is_ok()); + assert!(DbPassword::decode("Tr0ub4dor&3-Cloud!").is_ok()); } #[test] fn empty_password_is_rejected() { - assert!(Secret::DbPassword.validate("").is_err()); + assert!(DbPassword::decode("").is_err()); } #[test] fn short_password_is_usable_but_weak() { - assert!(Secret::DbPassword.validate("hunter2").is_ok()); assert_eq!( - Secret::DbPassword.weakness("hunter2").as_deref(), + Secret::DbPassword.assess("hunter2").unwrap().as_deref(), Some("shorter than 16 characters"), ); - assert_eq!(Secret::DbPassword.weakness(&"x".repeat(16)), None); + assert_eq!(Secret::DbPassword.assess(&"x".repeat(16)).unwrap(), None); } #[test] - fn create_then_load_round_trips() { + fn create_then_read_round_trips() { let (_dir, store) = store(); let outcome = store.create(Secret::DbPassword, &VALID.into()).unwrap(); assert_eq!(outcome, CreateOutcome::Created); - assert_eq!( - store.load(Secret::DbPassword).unwrap().expose_secret(), - VALID - ); + assert_eq!(store.read::().unwrap().expose_secret(), VALID); } #[test] @@ -246,7 +268,7 @@ mod tests { } #[test] - fn load_strips_trailing_line_breaks() { + fn read_strips_trailing_line_breaks() { let (_dir, store) = store(); write_raw( &store, @@ -254,16 +276,26 @@ mod tests { format!("{VALID}\r\n").as_bytes(), ); - let value = store.load(Secret::DbPassword).unwrap(); + let value = store.read::().unwrap(); assert_eq!(value.expose_secret(), VALID); } #[test] - fn load_fails_when_missing() { + fn read_returns_db_password_as_stored() { + let (_dir, store) = store(); + store.create(Secret::DbPassword, &VALID.into()).unwrap(); + + let password = store.read::().unwrap(); + + assert_eq!(password.expose_secret(), VALID); + } + + #[test] + fn read_fails_when_missing() { let (_dir, store) = store(); - let err = store.load(Secret::DbPassword).unwrap_err(); + let err = store.read::().unwrap_err(); assert_eq!( err.downcast_ref::().map(|e| e.kind()), @@ -272,19 +304,37 @@ mod tests { } #[test] - fn load_rejects_empty_value() { + fn read_rejects_empty_value() { let (_dir, store) = store(); write_raw(&store, Secret::DbPassword, b"\r\n"); - assert!(store.load(Secret::DbPassword).is_err()); + assert!(store.read::().is_err()); } #[test] - fn load_rejects_non_utf8() { + fn read_rejects_non_utf8() { let (_dir, store) = store(); write_raw(&store, Secret::DbPassword, &[0xff; 64]); - assert!(store.load(Secret::DbPassword).is_err()); + assert!(store.read::().is_err()); + } + + #[test] + fn inspect_reports_weak_stored_secret() { + let (_dir, store) = store(); + write_raw(&store, Secret::DbPassword, b"hunter2"); + + let weakness = store.inspect(Secret::DbPassword).unwrap(); + + assert_eq!(weakness.as_deref(), Some("shorter than 16 characters")); + } + + #[test] + fn inspect_rejects_empty_value() { + let (_dir, store) = store(); + write_raw(&store, Secret::DbPassword, b"\r\n"); + + assert!(store.inspect(Secret::DbPassword).is_err()); } #[test] From 3dd4bb312f4e5f73ce0e5a2b3ae22bcd99435702 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 20:37:42 +0200 Subject: [PATCH 08/43] feat(opaque): add opaque secrets --- .../keygo-server/src/cli/commands/check.rs | 27 +++ rust/crates/keygo-server/src/secrets.rs | 171 +++++++++++++----- 2 files changed, 152 insertions(+), 46 deletions(-) diff --git a/rust/crates/keygo-server/src/cli/commands/check.rs b/rust/crates/keygo-server/src/cli/commands/check.rs index 2213822ad..f8eae668a 100644 --- a/rust/crates/keygo-server/src/cli/commands/check.rs +++ b/rust/crates/keygo-server/src/cli/commands/check.rs @@ -278,6 +278,7 @@ fn plural(n: usize, word: &str) -> String { #[cfg(test)] mod tests { use super::*; + use secrecy::ExposeSecret; use std::fs::Permissions; const VALID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -465,4 +466,30 @@ mod tests { assert_eq!(check.status, Status::Warn); assert!(!check.detail.contains("hunter2")); } + + #[test] + fn generated_opaque_setup_passes() { + let dir = secrets_dir(0o700); + let value = Secret::OpaqueSetup.generate().unwrap(); + write_secret( + dir.path(), + Secret::OpaqueSetup, + value.expose_secret(), + 0o400, + ); + + let check = check_secret(&SecretStore::new(dir.path()), Secret::OpaqueSetup); + + assert_eq!(check.status, Status::Pass); + } + + #[test] + fn corrupt_opaque_setup_fails() { + let dir = secrets_dir(0o700); + write_secret(dir.path(), Secret::OpaqueSetup, VALID, 0o400); + + let check = check_secret(&SecretStore::new(dir.path()), Secret::OpaqueSetup); + + assert_eq!(check.status, Status::Fail); + } } diff --git a/rust/crates/keygo-server/src/secrets.rs b/rust/crates/keygo-server/src/secrets.rs index 77ee2d29b..7fbf15b44 100644 --- a/rust/crates/keygo-server/src/secrets.rs +++ b/rust/crates/keygo-server/src/secrets.rs @@ -1,45 +1,51 @@ use anyhow::{Context, anyhow, ensure}; +use keygo_opaque::server::ServerSetup; use secrecy::{ExposeSecret, SecretString}; use std::fs::{self, DirBuilder, File, Permissions}; use std::io::{ErrorKind, Write}; use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use zeroize::Zeroizing; -#[derive(Clone, Copy, Debug)] -pub enum Secret { - DbPassword, -} +macro_rules! secrets { + ($($ty:ident),+ $(,)?) => { + #[derive(Clone, Copy, Debug)] + pub enum Secret { + $($ty,)+ + } -impl Secret { - pub const ALL: &'static [Secret] = &[Secret::DbPassword]; + impl Secret { + pub const ALL: &'static [Secret] = &[$(Secret::$ty),+]; - pub fn name(self) -> &'static str { - match self { - Secret::DbPassword => "db_password", - } - } + pub const fn name(self) -> &'static str { + match self { + $(Secret::$ty => <$ty as TypedSecret>::NAME,)+ + } + } - pub fn generate(self) -> anyhow::Result { - match self { - Secret::DbPassword => DbPassword::generate(), - } - } + pub fn generate(self) -> anyhow::Result { + match self { + $(Secret::$ty => <$ty as TypedSecret>::generate(),)+ + } + } - /// Fails if the server cannot use `stored`, otherwise says why it looks weak, if it does. - fn assess(self, stored: &str) -> anyhow::Result> { - fn assess(stored: &str) -> anyhow::Result> { - T::decode(stored).map(|value| T::weakness(&value)) + /// Fails if the server cannot use `stored`, otherwise says why it looks weak, if it does. + fn assess(self, stored: &str) -> anyhow::Result> { + match self { + $(Secret::$ty => assess::<$ty>(stored),)+ + } + } } + }; +} - match self { - Secret::DbPassword => assess::(stored), - } - } +fn assess(stored: &str) -> anyhow::Result> { + T::decode(stored).map(|value| T::weakness(&value)) } pub trait TypedSecret { - const SECRET: Secret; + /// File name inside the secrets directory. + const NAME: &'static str; type Value; fn generate() -> anyhow::Result; @@ -57,7 +63,7 @@ impl DbPassword { } impl TypedSecret for DbPassword { - const SECRET: Secret = Secret::DbPassword; + const NAME: &'static str = "db_password"; type Value = SecretString; fn generate() -> anyhow::Result { @@ -78,6 +84,27 @@ impl TypedSecret for DbPassword { } } +pub struct OpaqueSetup; + +impl TypedSecret for OpaqueSetup { + const NAME: &'static str = "opaque_setup"; + type Value = ServerSetup; + + fn generate() -> anyhow::Result { + Ok(hex::encode(ServerSetup::generate().to_bytes().as_slice()).into()) + } + + fn decode(stored: &str) -> anyhow::Result { + let raw = Zeroizing::new(hex::decode(stored).context("not hex")?); + ServerSetup::from_bytes(&raw).context("not an OPAQUE server setup") + } +} + +secrets! { + DbPassword, + OpaqueSetup, +} + #[derive(Debug, PartialEq, Eq)] pub enum CreateOutcome { Created, @@ -97,6 +124,10 @@ impl SecretStore { self.dir.join(secret.name()) } + fn path_of(&self) -> PathBuf { + self.dir.join(T::NAME) + } + pub fn create_dir(&self) -> anyhow::Result<()> { DirBuilder::new() .recursive(true) @@ -106,27 +137,17 @@ impl SecretStore { } pub fn read(&self) -> anyhow::Result { - let stored = self.read_text(T::SECRET)?; - T::decode(stored.expose_secret()) - .with_context(|| format!("{} is invalid", self.path(T::SECRET).display())) + let path = self.path_of::(); + let stored = read_text(&path)?; + T::decode(stored.expose_secret()).with_context(|| format!("{} is invalid", path.display())) } pub fn inspect(&self, secret: Secret) -> anyhow::Result> { - let stored = self.read_text(secret)?; + let path = self.path(secret); + let stored = read_text(&path)?; secret .assess(stored.expose_secret()) - .with_context(|| format!("{} is invalid", self.path(secret).display())) - } - - fn read_text(&self, secret: Secret) -> anyhow::Result { - let path = self.path(secret); - let bytes = Zeroizing::new( - fs::read(&path).with_context(|| format!("cannot read {}", path.display()))?, - ); - let value = str::from_utf8(&bytes) - .with_context(|| format!("{} is not UTF-8", path.display()))? - .trim(); - Ok(value.into()) + .with_context(|| format!("{} is invalid", path.display())) } pub fn create(&self, secret: Secret, value: &SecretString) -> anyhow::Result { @@ -160,6 +181,15 @@ impl SecretStore { } } +fn read_text(path: &Path) -> anyhow::Result { + let bytes = + Zeroizing::new(fs::read(path).with_context(|| format!("cannot read {}", path.display()))?); + let value = str::from_utf8(&bytes) + .with_context(|| format!("{} is not UTF-8", path.display()))? + .trim(); + Ok(value.into()) +} + #[cfg(test)] mod tests { use super::*; @@ -211,6 +241,35 @@ mod tests { assert_eq!(Secret::DbPassword.assess(&"x".repeat(16)).unwrap(), None); } + #[test] + fn generated_opaque_setup_is_valid_and_decodes() { + let value = Secret::OpaqueSetup.generate().unwrap(); + + assert_eq!( + Secret::OpaqueSetup.assess(value.expose_secret()).unwrap(), + None + ); + } + + #[test] + fn generated_opaque_setups_differ() { + let a = Secret::OpaqueSetup.generate().unwrap(); + let b = Secret::OpaqueSetup.generate().unwrap(); + + assert_ne!(a.expose_secret(), b.expose_secret()); + } + + #[test] + fn random_hex_is_not_an_opaque_setup() { + assert!(OpaqueSetup::decode(VALID).is_err()); + } + + #[test] + fn non_hex_opaque_setup_is_rejected() { + assert!(OpaqueSetup::decode("not hex at all").is_err()); + assert!(OpaqueSetup::decode("").is_err()); + } + #[test] fn create_then_read_round_trips() { let (_dir, store) = store(); @@ -281,6 +340,18 @@ mod tests { assert_eq!(value.expose_secret(), VALID); } + #[test] + fn read_decodes_stored_opaque_setup() { + let (_dir, store) = store(); + let stored = OpaqueSetup::generate().unwrap(); + let setup = OpaqueSetup::decode(stored.expose_secret()).unwrap(); + store.create(Secret::OpaqueSetup, &stored).unwrap(); + + let read = store.read::().unwrap(); + + assert_eq!(read.public_key(), setup.public_key()); + } + #[test] fn read_returns_db_password_as_stored() { let (_dir, store) = store(); @@ -291,6 +362,14 @@ mod tests { assert_eq!(password.expose_secret(), VALID); } + #[test] + fn read_rejects_corrupt_opaque_setup() { + let (_dir, store) = store(); + write_raw(&store, Secret::OpaqueSetup, VALID.as_bytes()); + + assert!(store.read::().is_err()); + } + #[test] fn read_fails_when_missing() { let (_dir, store) = store(); @@ -330,11 +409,11 @@ mod tests { } #[test] - fn inspect_rejects_empty_value() { + fn inspect_rejects_corrupt_opaque_setup() { let (_dir, store) = store(); - write_raw(&store, Secret::DbPassword, b"\r\n"); + write_raw(&store, Secret::OpaqueSetup, VALID.as_bytes()); - assert!(store.inspect(Secret::DbPassword).is_err()); + assert!(store.inspect(Secret::OpaqueSetup).is_err()); } #[test] From 47a485de3231031a929e073091bc116b29945832 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 20:39:57 +0200 Subject: [PATCH 09/43] feat(opaque): add opaque to api app state --- rust/Cargo.lock | 1643 ++++++++++---------- rust/crates/keygo-opaque/src/server.rs | 1 + rust/crates/keygo-server/Cargo.toml | 1 + rust/crates/keygo-server/src/api/mod.rs | 2 + rust/crates/keygo-server/src/api/state.rs | 8 +- rust/crates/keygo-server/src/config.rs | 8 + rust/crates/keygo-server/src/server/mod.rs | 2 +- 7 files changed, 840 insertions(+), 825 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 68154d41e..9f4c389a3 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,8 +8,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common 0.2.2", - "inout", + "crypto-common 0.2.2", + "inout", ] [[package]] @@ -18,9 +18,9 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher", - "cpubits", - "cpufeatures 0.3.1", + "cipher", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] @@ -29,12 +29,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c32325128cb24de2a2d14d92da7569dae7073f9e47334521d494747fa94d902" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ctutils", - "polyval", + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "polyval", ] [[package]] @@ -43,7 +43,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ - "memchr", + "memchr", ] [[package]] @@ -52,13 +52,13 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", ] [[package]] @@ -73,7 +73,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ - "utf8parse", + "utf8parse", ] [[package]] @@ -82,7 +82,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys", ] [[package]] @@ -91,9 +91,9 @@ version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys", + "anstyle", + "once_cell_polyfill", + "windows-sys", ] [[package]] @@ -108,10 +108,10 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ - "base64ct", - "blake2 0.10.6", - "cpufeatures 0.2.17", - "password-hash 0.5.0", + "base64ct", + "blake2 0.10.6", + "cpufeatures 0.2.17", + "password-hash 0.5.0", ] [[package]] @@ -120,10 +120,10 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ - "base64ct", - "blake2 0.11.0", - "cpufeatures 0.3.1", - "password-hash 0.6.1", + "base64ct", + "blake2 0.11.0", + "cpufeatures 0.3.1", + "password-hash 0.6.1", ] [[package]] @@ -132,11 +132,11 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6024d73179f43f15ccd2b881bfea6fee7f3a46ec53f33b52210dea749ebebaa4" dependencies = [ - "askama_macros", - "itoa", - "percent-encoding", - "serde", - "serde_json", + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", ] [[package]] @@ -145,16 +145,16 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071ee5ebf2138e3ad180e0aacf6940c2cab5e6d8333741d9925c7bee2b153f39" dependencies = [ - "askama_parser", - "basic-toml", - "glob", - "memchr", - "proc-macro2", - "quote", - "rustc-hash", - "serde", - "serde_derive", - "syn 3.0.7", + "askama_parser", + "basic-toml", + "glob", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 3.0.7", ] [[package]] @@ -163,7 +163,7 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "643e1c7cbb6aec1d920332fe51a7c0d8219e273dcb8602db03f5263e4d16487b" dependencies = [ - "askama_derive", + "askama_derive", ] [[package]] @@ -172,11 +172,11 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c5ae75772275d268b03ab8bdccdd12117b6169ee23256942b34e46c9f476583" dependencies = [ - "rustc-hash", - "serde", - "serde_derive", - "unicode-ident", - "winnow", + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", ] [[package]] @@ -185,11 +185,11 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c97d7ff3c25d6c10d64170c12acaf5d4245e76dece3779c1d92b153a64f11df" dependencies = [ - "futures-core", - "futures-io", - "once_cell", - "pin-project-lite", - "tokio", + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", ] [[package]] @@ -198,9 +198,9 @@ version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -221,31 +221,31 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -254,17 +254,17 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -303,7 +303,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" dependencies = [ - "serde", + "serde", ] [[package]] @@ -312,7 +312,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "350f2b5fa7b76b498158ec1079dc0ea842c5b622b6b3f675005fddd889f2c9a7" dependencies = [ - "serde_core", + "serde_core", ] [[package]] @@ -327,7 +327,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest 0.10.7", + "digest 0.10.7", ] [[package]] @@ -336,7 +336,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest 0.11.3", + "digest 0.11.3", ] [[package]] @@ -345,7 +345,7 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "generic-array", + "generic-array", ] [[package]] @@ -354,7 +354,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "hybrid-array", + "hybrid-array", ] [[package]] @@ -375,7 +375,7 @@ version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ - "serde_core", + "serde_core", ] [[package]] @@ -384,8 +384,8 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" dependencies = [ - "serde", - "serde_core", + "serde", + "serde_core", ] [[package]] @@ -394,12 +394,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror", + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", ] [[package]] @@ -414,9 +414,9 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", ] [[package]] @@ -425,9 +425,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", + "ciborium-io", + "ciborium-ll", + "serde", ] [[package]] @@ -442,8 +442,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ - "ciborium-io", - "half", + "ciborium-io", + "half", ] [[package]] @@ -452,9 +452,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", - "inout", + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout", ] [[package]] @@ -463,8 +463,8 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ - "clap_builder", - "clap_derive", + "clap_builder", + "clap_derive", ] [[package]] @@ -473,10 +473,10 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", + "anstream", + "anstyle", + "clap_lex", + "strsim", ] [[package]] @@ -485,10 +485,10 @@ version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.7", + "heck", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -533,8 +533,8 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1eb98d5e9155e2cf7cd942c8b3033097d4563b6fb0a00b9caecb74669555c058" dependencies = [ - "ciborium", - "ciborium-io", + "ciborium", + "ciborium-io", ] [[package]] @@ -549,7 +549,7 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "libc", + "libc", ] [[package]] @@ -558,7 +558,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ - "libc", + "libc", ] [[package]] @@ -573,10 +573,10 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", ] [[package]] @@ -585,14 +585,14 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", ] [[package]] @@ -601,8 +601,8 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "generic-array", - "typenum", + "generic-array", + "typenum", ] [[package]] @@ -611,9 +611,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", - "hybrid-array", - "rand_core 0.10.1", + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -622,10 +622,10 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", + "csv-core", + "itoa", + "ryu", + "serde_core", ] [[package]] @@ -634,7 +634,7 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" dependencies = [ - "memchr", + "memchr", ] [[package]] @@ -643,7 +643,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher", ] [[package]] @@ -652,8 +652,8 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ - "cmov", - "subtle", + "cmov", + "subtle", ] [[package]] @@ -662,14 +662,14 @@ version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "fiat-crypto 0.2.9", - "rand_core 0.6.4", - "rustc_version", - "subtle", - "zeroize", + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rand_core 0.6.4", + "rustc_version", + "subtle", + "zeroize", ] [[package]] @@ -678,15 +678,15 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", - "rand_core 0.10.1", - "rustc_version", - "subtle", - "zeroize", + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", + "rustc_version", + "subtle", + "zeroize", ] [[package]] @@ -695,9 +695,9 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -712,8 +712,8 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", - "zeroize", + "const-oid 0.9.6", + "zeroize", ] [[package]] @@ -722,9 +722,9 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468", - "zeroize", + "const-oid 0.10.2", + "pem-rfc7468", + "zeroize", ] [[package]] @@ -733,9 +733,9 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -744,9 +744,9 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", - "subtle", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -755,10 +755,10 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -767,9 +767,9 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -778,13 +778,13 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der 0.8.2", - "digest 0.11.3", - "elliptic-curve 0.14.1", - "rfc6979", - "signature", - "spki", - "zeroize", + "der 0.8.2", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979", + "signature", + "spki", + "zeroize", ] [[package]] @@ -793,8 +793,8 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", - "signature", + "pkcs8", + "signature", ] [[package]] @@ -803,14 +803,14 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519", - "rand_core 0.10.1", - "serde", - "sha2 0.11.1", - "signature", - "subtle", - "zeroize", + "curve25519-dalek 5.0.0", + "ed25519", + "rand_core 0.10.1", + "serde", + "sha2 0.11.1", + "signature", + "subtle", + "zeroize", ] [[package]] @@ -825,16 +825,16 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct 0.2.0", - "crypto-bigint 0.5.5", - "digest 0.10.7", - "ff 0.13.1", - "generic-array", - "group 0.13.0", - "rand_core 0.6.4", - "sec1 0.7.3", - "subtle", - "zeroize", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", + "digest 0.10.7", + "ff 0.13.1", + "generic-array", + "group 0.13.0", + "rand_core 0.6.4", + "sec1 0.7.3", + "subtle", + "zeroize", ] [[package]] @@ -843,19 +843,19 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" dependencies = [ - "base16ct 1.0.0", - "crypto-bigint 0.7.5", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff 0.14.0", - "group 0.14.0", - "hybrid-array", - "pem-rfc7468", - "pkcs8", - "rand_core 0.10.1", - "sec1 0.8.1", - "subtle", - "zeroize", + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pem-rfc7468", + "pkcs8", + "rand_core 0.10.1", + "sec1 0.8.1", + "subtle", + "zeroize", ] [[package]] @@ -864,7 +864,7 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" dependencies = [ - "serde", + "serde", ] [[package]] @@ -879,8 +879,8 @@ version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "libc", - "windows-sys", + "libc", + "windows-sys", ] [[package]] @@ -895,8 +895,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core 0.6.4", - "subtle", + "rand_core 0.6.4", + "subtle", ] [[package]] @@ -905,8 +905,8 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "rand_core 0.10.1", - "subtle", + "rand_core 0.10.1", + "subtle", ] [[package]] @@ -933,7 +933,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "percent-encoding", + "percent-encoding", ] [[package]] @@ -942,7 +942,7 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" dependencies = [ - "autocfg", + "autocfg", ] [[package]] @@ -969,10 +969,10 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", + "futures-core", + "futures-task", + "pin-project-lite", + "slab", ] [[package]] @@ -981,9 +981,9 @@ version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "typenum", - "version_check", - "zeroize", + "typenum", + "version_check", + "zeroize", ] [[package]] @@ -992,9 +992,9 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", - "libc", - "wasi", + "cfg-if", + "libc", + "wasi", ] [[package]] @@ -1003,10 +1003,10 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "cfg-if", - "libc", - "r-efi", - "rand_core 0.10.1", + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", ] [[package]] @@ -1021,9 +1021,9 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" dependencies = [ - "log", - "plain", - "scroll", + "log", + "plain", + "scroll", ] [[package]] @@ -1032,9 +1032,9 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff 0.13.1", - "rand_core 0.6.4", - "subtle", + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", ] [[package]] @@ -1043,9 +1043,9 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "ff 0.14.0", - "rand_core 0.10.1", - "subtle", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", ] [[package]] @@ -1054,9 +1054,9 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", + "cfg-if", + "crunchy", + "zerocopy", ] [[package]] @@ -1083,7 +1083,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac 0.12.1", ] [[package]] @@ -1092,7 +1092,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.13.0", + "hmac 0.13.0", ] [[package]] @@ -1101,7 +1101,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest 0.10.7", ] [[package]] @@ -1110,7 +1110,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest 0.11.3", ] [[package]] @@ -1119,8 +1119,8 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ - "bytes", - "itoa", + "bytes", + "itoa", ] [[package]] @@ -1129,8 +1129,8 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ - "bytes", - "http", + "bytes", + "http", ] [[package]] @@ -1139,11 +1139,11 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] @@ -1164,10 +1164,10 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ - "ctutils", - "subtle", - "typenum", - "zeroize", + "ctutils", + "subtle", + "typenum", + "zeroize", ] [[package]] @@ -1176,17 +1176,17 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ - "atomic-waker", - "bytes", - "futures-core", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", ] [[package]] @@ -1195,13 +1195,13 @@ version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "bytes", - "http", - "http-body", - "hyper", - "pin-project-lite", - "tokio", - "tower-service", + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] @@ -1210,12 +1210,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] @@ -1224,11 +1224,11 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] @@ -1237,12 +1237,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] @@ -1257,13 +1257,13 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] @@ -1278,13 +1278,13 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] @@ -1293,9 +1293,9 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] @@ -1304,8 +1304,8 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "icu_normalizer", - "icu_properties", + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1314,10 +1314,10 @@ version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ - "equivalent", - "hashbrown", - "serde", - "serde_core", + "equivalent", + "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -1326,7 +1326,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "hybrid-array", + "hybrid-array", ] [[package]] @@ -1341,7 +1341,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ - "either", + "either", ] [[package]] @@ -1356,9 +1356,9 @@ version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] @@ -1367,86 +1367,87 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", + "cfg-if", + "cpufeatures 0.3.1", ] [[package]] name = "keygo-core" version = "0.1.0" dependencies = [ - "aead", - "aes-gcm-siv", - "argon2 0.6.0", - "async-trait", - "base64", - "bcs", - "ciborium", - "coset", - "csv", - "email_address", - "hkdf 0.13.0", - "passkey", - "passkey-authenticator", - "passkey-types", - "serde", - "serde_json", - "sha2 0.11.1", - "subtle", - "thiserror", - "tokio", - "totp-rs", - "url", - "uuid", - "zeroize", + "aead", + "aes-gcm-siv", + "argon2 0.6.0", + "async-trait", + "base64", + "bcs", + "ciborium", + "coset", + "csv", + "email_address", + "hkdf 0.13.0", + "passkey", + "passkey-authenticator", + "passkey-types", + "serde", + "serde_json", + "sha2 0.11.1", + "subtle", + "thiserror", + "tokio", + "totp-rs", + "url", + "uuid", + "zeroize", ] [[package]] name = "keygo-opaque" version = "0.1.0" dependencies = [ - "opaque-ke", - "rand_core 0.6.4", - "sha2 0.10.9", - "thiserror", - "voprf", - "zeroize", + "opaque-ke", + "rand_core 0.6.4", + "sha2 0.10.9", + "thiserror", + "voprf", + "zeroize", ] [[package]] name = "keygo-server" version = "0.1.0" dependencies = [ - "anstream", - "anstyle", - "anyhow", - "axum", - "clap", - "getrandom 0.4.3", - "hex", - "http-body-util", - "secrecy", - "serde", - "serde_json", - "tempfile", - "tokio", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", - "url", - "zeroize", + "anstream", + "anstyle", + "anyhow", + "axum", + "clap", + "getrandom 0.4.3", + "hex", + "http-body-util", + "keygo-opaque", + "secrecy", + "serde", + "serde_json", + "tempfile", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "zeroize", ] [[package]] name = "keygo-uniffi" version = "0.1.0" dependencies = [ - "keygo-core", - "thiserror", - "tokio", - "uniffi", - "uuid", + "keygo-core", + "thiserror", + "tokio", + "uniffi", + "uuid", ] [[package]] @@ -1485,7 +1486,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata", + "regex-automata", ] [[package]] @@ -1518,9 +1519,9 @@ version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ - "libc", - "wasi", - "windows-sys", + "libc", + "wasi", + "windows-sys", ] [[package]] @@ -1529,14 +1530,14 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" dependencies = [ - "const-oid 0.10.2", - "crypto-common 0.2.2", - "ctutils", - "hybrid-array", - "module-lattice", - "pkcs8", - "shake", - "signature", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8", + "shake", + "signature", ] [[package]] @@ -1545,9 +1546,9 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" dependencies = [ - "ctutils", - "hybrid-array", - "num-traits", + "ctutils", + "hybrid-array", + "num-traits", ] [[package]] @@ -1556,8 +1557,8 @@ version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ - "memchr", - "minimal-lexical", + "memchr", + "minimal-lexical", ] [[package]] @@ -1566,7 +1567,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys", ] [[package]] @@ -1575,7 +1576,7 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "autocfg", + "autocfg", ] [[package]] @@ -1596,19 +1597,19 @@ version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ded22991b43cd15561b62b2e1cf9ace1344a8534eebec96202d5c96a77a6616a" dependencies = [ - "argon2 0.5.3", - "curve25519-dalek 4.1.3", - "derive-where", - "digest 0.10.7", - "displaydoc", - "elliptic-curve 0.13.8", - "generic-array", - "hkdf 0.12.4", - "hmac 0.12.1", - "rand 0.8.8", - "subtle", - "voprf", - "zeroize", + "argon2 0.5.3", + "curve25519-dalek 4.1.3", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve 0.13.8", + "generic-array", + "hkdf 0.12.4", + "hmac 0.12.1", + "rand 0.8.8", + "subtle", + "voprf", + "zeroize", ] [[package]] @@ -1617,11 +1618,11 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "ecdsa", - "elliptic-curve 0.14.1", - "primefield", - "primeorder", - "sha2 0.11.1", + "ecdsa", + "elliptic-curve 0.14.1", + "primefield", + "primeorder", + "sha2 0.11.1", ] [[package]] @@ -1630,11 +1631,11 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91be8b7aaa8f305eab1fae1b17d05f986c07497210edd303e61aa95121374f65" dependencies = [ - "passkey-authenticator", - "passkey-client", - "passkey-crypto", - "passkey-transports", - "passkey-types", + "passkey-authenticator", + "passkey-client", + "passkey-crypto", + "passkey-transports", + "passkey-types", ] [[package]] @@ -1643,12 +1644,12 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fb7e1cb79a932ac3a5ceb0fa73b25a6028ef1bd69a007441e6cce9ecf2e6e0f" dependencies = [ - "async-trait", - "ciborium", - "log", - "passkey-crypto", - "passkey-types", - "tokio", + "async-trait", + "ciborium", + "log", + "passkey-crypto", + "passkey-types", + "tokio", ] [[package]] @@ -1657,16 +1658,16 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a005ba82014459c51985f7e6f4b606a85ea5e8d1c9bbd6bc053e2b2b0c7f4f" dependencies = [ - "ciborium", - "idna", - "itertools", - "passkey-authenticator", - "passkey-crypto", - "passkey-types", - "public-suffix", - "serde", - "serde_json", - "url", + "ciborium", + "idna", + "itertools", + "passkey-authenticator", + "passkey-crypto", + "passkey-types", + "public-suffix", + "serde", + "serde_json", + "url", ] [[package]] @@ -1675,17 +1676,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5108c58974bacdae414ee66edf0c009818509ccf6be4a911032147336e3da27" dependencies = [ - "coset", - "ed25519-dalek", - "getrandom 0.4.3", - "hmac 0.13.0", - "log", - "ml-dsa", - "p256", - "rand 0.10.3", - "sha2 0.11.1", - "signature", - "zeroize", + "coset", + "ed25519-dalek", + "getrandom 0.4.3", + "hmac 0.13.0", + "log", + "ml-dsa", + "p256", + "rand 0.10.3", + "sha2 0.11.1", + "signature", + "zeroize", ] [[package]] @@ -1700,16 +1701,16 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4ed76f185c29d0d0c226ecf653531bb962e63eff3bf6d5cd5062288116138d2" dependencies = [ - "bitflags", - "ciborium", - "data-encoding", - "indexmap", - "passkey-crypto", - "serde", - "serde_json", - "strum", - "url", - "zeroize", + "bitflags", + "ciborium", + "data-encoding", + "indexmap", + "passkey-crypto", + "serde", + "serde_json", + "strum", + "url", + "zeroize", ] [[package]] @@ -1718,9 +1719,9 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "base64ct", + "rand_core 0.6.4", + "subtle", ] [[package]] @@ -1729,8 +1730,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "getrandom 0.4.3", - "phc", + "getrandom 0.4.3", + "phc", ] [[package]] @@ -1739,7 +1740,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" dependencies = [ - "base64ct", + "base64ct", ] [[package]] @@ -1754,9 +1755,9 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" dependencies = [ - "base64ct", - "ctutils", - "getrandom 0.4.3", + "base64ct", + "ctutils", + "getrandom 0.4.3", ] [[package]] @@ -1771,8 +1772,8 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der 0.8.2", - "spki", + "der 0.8.2", + "spki", ] [[package]] @@ -1787,9 +1788,9 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ - "cpubits", - "cpufeatures 0.3.1", - "universal-hash", + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", ] [[package]] @@ -1798,7 +1799,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ - "zerovec", + "zerovec", ] [[package]] @@ -1807,12 +1808,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" dependencies = [ - "crypto-bigint 0.7.5", - "crypto-common 0.2.2", - "ff 0.14.0", - "rand_core 0.10.1", - "subtle", - "zeroize", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", ] [[package]] @@ -1821,11 +1822,11 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" dependencies = [ - "elliptic-curve 0.14.1", - "once_cell", - "primefield", - "serdect", - "wnaf", + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -1834,7 +1835,7 @@ version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "unicode-ident", + "unicode-ident", ] [[package]] @@ -1849,7 +1850,7 @@ version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ - "proc-macro2", + "proc-macro2", ] [[package]] @@ -1864,7 +1865,7 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ - "rand_core 0.6.4", + "rand_core 0.6.4", ] [[package]] @@ -1873,9 +1874,9 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -1884,7 +1885,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.17", + "getrandom 0.2.17", ] [[package]] @@ -1899,9 +1900,9 @@ version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] @@ -1916,8 +1917,8 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" dependencies = [ - "crypto-bigint 0.7.5", - "hmac 0.13.0", + "crypto-bigint 0.7.5", + "hmac 0.13.0", ] [[package]] @@ -1932,7 +1933,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver", + "semver", ] [[package]] @@ -1941,11 +1942,11 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -1966,7 +1967,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" dependencies = [ - "scroll_derive", + "scroll_derive", ] [[package]] @@ -1975,9 +1976,9 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -1986,11 +1987,11 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", - "generic-array", - "subtle", - "zeroize", + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "subtle", + "zeroize", ] [[package]] @@ -1999,12 +2000,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ - "base16ct 1.0.0", - "ctutils", - "der 0.8.2", - "hybrid-array", - "subtle", - "zeroize", + "base16ct 1.0.0", + "ctutils", + "der 0.8.2", + "hybrid-array", + "subtle", + "zeroize", ] [[package]] @@ -2013,7 +2014,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" dependencies = [ - "zeroize", + "zeroize", ] [[package]] @@ -2022,8 +2023,8 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ - "serde", - "serde_core", + "serde", + "serde_core", ] [[package]] @@ -2032,8 +2033,8 @@ version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "serde_core", - "serde_derive", + "serde_core", + "serde_derive", ] [[package]] @@ -2042,7 +2043,7 @@ version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "serde_derive", + "serde_derive", ] [[package]] @@ -2051,9 +2052,9 @@ version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -2062,13 +2063,13 @@ version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ - "foldhash", - "indexmap", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] @@ -2077,9 +2078,9 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ - "itoa", - "serde", - "serde_core", + "itoa", + "serde", + "serde_core", ] [[package]] @@ -2088,7 +2089,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ - "serde_core", + "serde_core", ] [[package]] @@ -2097,10 +2098,10 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] @@ -2109,8 +2110,8 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct 1.0.0", - "serde", + "base16ct 1.0.0", + "serde", ] [[package]] @@ -2119,9 +2120,9 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -2130,9 +2131,9 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -2141,9 +2142,9 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -2152,9 +2153,9 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ - "digest 0.11.3", - "keccak", - "sponge-cursor", + "digest 0.11.3", + "keccak", + "sponge-cursor", ] [[package]] @@ -2163,7 +2164,7 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "lazy_static", + "lazy_static", ] [[package]] @@ -2172,8 +2173,8 @@ version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ - "errno", - "libc", + "errno", + "libc", ] [[package]] @@ -2182,8 +2183,8 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", + "digest 0.11.3", + "rand_core 0.10.1", ] [[package]] @@ -2216,8 +2217,8 @@ version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "libc", - "windows-sys", + "libc", + "windows-sys", ] [[package]] @@ -2226,8 +2227,8 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ - "base64ct", - "der 0.8.2", + "base64ct", + "der 0.8.2", ] [[package]] @@ -2260,7 +2261,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros", ] [[package]] @@ -2269,10 +2270,10 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2287,9 +2288,9 @@ version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -2298,9 +2299,9 @@ version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] @@ -2315,9 +2316,9 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -2326,11 +2327,11 @@ version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", ] [[package]] @@ -2339,8 +2340,8 @@ version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" dependencies = [ - "smawk", - "unicode-width", + "smawk", + "unicode-width", ] [[package]] @@ -2349,7 +2350,7 @@ version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl", ] [[package]] @@ -2358,9 +2359,9 @@ version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -2369,7 +2370,7 @@ version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ - "cfg-if", + "cfg-if", ] [[package]] @@ -2378,8 +2379,8 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "displaydoc", - "zerovec", + "displaydoc", + "zerovec", ] [[package]] @@ -2388,13 +2389,13 @@ version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", ] [[package]] @@ -2403,9 +2404,9 @@ version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] @@ -2414,13 +2415,13 @@ version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] @@ -2429,7 +2430,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ - "serde_core", + "serde_core", ] [[package]] @@ -2438,7 +2439,7 @@ version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow", + "winnow", ] [[package]] @@ -2453,16 +2454,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49d0f9b7aa5def86d3a8d8ba926fe8c1990924dd715d2b5fc54a4aa3788c11c1" dependencies = [ - "base32", - "constant_time_eq", - "hmac 0.13.0", - "percent-encoding", - "rand 0.10.3", - "serde", - "sha1", - "sha2 0.11.1", - "url", - "zeroize", + "base32", + "constant_time_eq", + "hmac 0.13.0", + "percent-encoding", + "rand 0.10.3", + "serde", + "sha1", + "sha2 0.11.1", + "url", + "zeroize", ] [[package]] @@ -2471,14 +2472,14 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -2487,15 +2488,15 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ - "bitflags", - "bytes", - "http", - "http-body", - "percent-encoding", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", + "bitflags", + "bytes", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", ] [[package]] @@ -2516,10 +2517,10 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", ] [[package]] @@ -2528,9 +2529,9 @@ version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2539,8 +2540,8 @@ version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ - "once_cell", - "valuable", + "once_cell", + "valuable", ] [[package]] @@ -2549,9 +2550,9 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ - "log", - "once_cell", - "tracing-core", + "log", + "once_cell", + "tracing-core", ] [[package]] @@ -2560,16 +2561,16 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -2596,21 +2597,21 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76407f5f396a2c949a069eff4a9fca9ce462410eb872bffef4c2b7b89804a77a" dependencies = [ - "anyhow", - "camino", - "cargo_metadata", - "clap", - "uniffi_bindgen", - "uniffi_core", - "uniffi_macros", - "uniffi_pipeline", + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", ] [[package]] name = "uniffi-bindgen" version = "0.1.0" dependencies = [ - "uniffi", + "uniffi", ] [[package]] @@ -2619,24 +2620,24 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3df3ced8f0eda3de99f6d50820e8628f443da8e4f447f477d1403ceba29dacbd" dependencies = [ - "anyhow", - "askama", - "camino", - "cargo_metadata", - "fs-err", - "glob", - "goblin", - "heck", - "indexmap", - "once_cell", - "serde", - "tempfile", - "textwrap", - "toml", - "uniffi_internal_macros", - "uniffi_meta", - "uniffi_pipeline", - "uniffi_udl", + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", ] [[package]] @@ -2645,11 +2646,11 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7530ae8efeaaa488622865966763fe0294832779fff80508c36d69c6a874a6a" dependencies = [ - "anyhow", - "async-compat", - "bytes", - "once_cell", - "static_assertions", + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", ] [[package]] @@ -2658,11 +2659,11 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f4bad017164375d99450d70495014810d15a84ba9da6ed14b9628b1424223ba" dependencies = [ - "anyhow", - "indexmap", - "proc-macro2", - "quote", - "syn 2.0.119", + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2671,15 +2672,15 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b855a58da153739ce6e863f6e296edc3716a093768c4d3d957f17f5e4317c60" dependencies = [ - "camino", - "fs-err", - "once_cell", - "proc-macro2", - "quote", - "serde", - "syn 2.0.119", - "toml", - "uniffi_meta", + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "toml", + "uniffi_meta", ] [[package]] @@ -2688,10 +2689,10 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af0c88664a9cb9559856f5a250283c9708923b303170e2e5c7db8f4ad65e9459" dependencies = [ - "anyhow", - "siphasher", - "uniffi_internal_macros", - "uniffi_pipeline", + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", ] [[package]] @@ -2700,11 +2701,11 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d00f5c044b4a3dca0b1226c92ddaeea626f337470e22180dd182160c87a06b0b" dependencies = [ - "anyhow", - "heck", - "indexmap", - "tempfile", - "uniffi_internal_macros", + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", ] [[package]] @@ -2713,10 +2714,10 @@ version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55dfd9c778d6b39cb5acd541d9ff63431974035d2bad83c897b766a6ee3152ac" dependencies = [ - "anyhow", - "textwrap", - "uniffi_meta", - "weedle2", + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", ] [[package]] @@ -2725,8 +2726,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common 0.2.2", - "ctutils", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2735,11 +2736,11 @@ version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", ] [[package]] @@ -2760,10 +2761,10 @@ version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "wasm-bindgen", + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", ] [[package]] @@ -2784,16 +2785,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" dependencies = [ - "curve25519-dalek 4.1.3", - "derive-where", - "digest 0.10.7", - "displaydoc", - "elliptic-curve 0.13.8", - "generic-array", - "rand_core 0.6.4", - "sha2 0.10.9", - "subtle", - "zeroize", + "curve25519-dalek 4.1.3", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve 0.13.8", + "generic-array", + "rand_core 0.6.4", + "sha2 0.10.9", + "subtle", + "zeroize", ] [[package]] @@ -2808,11 +2809,11 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] @@ -2821,8 +2822,8 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ - "quote", - "wasm-bindgen-macro-support", + "quote", + "wasm-bindgen-macro-support", ] [[package]] @@ -2831,11 +2832,11 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.7", - "wasm-bindgen-shared", + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.7", + "wasm-bindgen-shared", ] [[package]] @@ -2844,7 +2845,7 @@ version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ - "unicode-ident", + "unicode-ident", ] [[package]] @@ -2853,7 +2854,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" dependencies = [ - "nom", + "nom", ] [[package]] @@ -2868,7 +2869,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link", ] [[package]] @@ -2877,7 +2878,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ - "memchr", + "memchr", ] [[package]] @@ -2886,10 +2887,10 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" dependencies = [ - "ff 0.14.0", - "group 0.14.0", - "hybrid-array", - "primefield", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "primefield", ] [[package]] @@ -2904,9 +2905,9 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", + "stable_deref_trait", + "yoke-derive", + "zerofrom", ] [[package]] @@ -2915,10 +2916,10 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", - "synstructure", + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", ] [[package]] @@ -2927,7 +2928,7 @@ version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ - "zerocopy-derive", + "zerocopy-derive", ] [[package]] @@ -2936,9 +2937,9 @@ version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2947,7 +2948,7 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ - "zerofrom-derive", + "zerofrom-derive", ] [[package]] @@ -2956,10 +2957,10 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", - "synstructure", + "proc-macro2", + "quote", + "syn 3.0.7", + "synstructure", ] [[package]] @@ -2968,7 +2969,7 @@ version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ - "zeroize_derive", + "zeroize_derive", ] [[package]] @@ -2977,9 +2978,9 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2988,9 +2989,9 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ - "displaydoc", - "yoke", - "zerofrom", + "displaydoc", + "yoke", + "zerofrom", ] [[package]] @@ -2999,9 +3000,9 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", + "yoke", + "zerofrom", + "zerovec-derive", ] [[package]] @@ -3010,9 +3011,9 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.7", + "proc-macro2", + "quote", + "syn 3.0.7", ] [[package]] diff --git a/rust/crates/keygo-opaque/src/server.rs b/rust/crates/keygo-opaque/src/server.rs index 6574e9576..97434d1ca 100644 --- a/rust/crates/keygo-opaque/src/server.rs +++ b/rust/crates/keygo-opaque/src/server.rs @@ -10,6 +10,7 @@ use zeroize::Zeroizing; opaque_newtype! { /// Long-term server keypair and OPRF seed, shared by all accounts. + #[derive(Clone)] ServerSetup(ServerSetup) as Zeroizing>; /// Server state held between KE2 and KE3. diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 4877969cf..6c7bed15f 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +keygo-opaque = { path = "../keygo-opaque" } zeroize.workspace = true anstream = "1.0.0" anstyle = "1.0.14" diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index 26ca8a389..f7a84c8f3 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -38,6 +38,7 @@ mod tests { fn state() -> AppState { AppState { allow_signups: false, + opaque_state: keygo_opaque::server::ServerSetup::generate(), } } @@ -52,6 +53,7 @@ mod tests { async fn server_info_reports_version_and_signups() { let state = AppState { allow_signups: true, + ..state() }; let (status, body) = get(state, "/api/v1/meta").await; diff --git a/rust/crates/keygo-server/src/api/state.rs b/rust/crates/keygo-server/src/api/state.rs index 2f0cc32df..abee799b3 100644 --- a/rust/crates/keygo-server/src/api/state.rs +++ b/rust/crates/keygo-server/src/api/state.rs @@ -3,12 +3,14 @@ use crate::config::Config; #[derive(Clone)] pub struct AppState { pub allow_signups: bool, + pub opaque_state: keygo_opaque::server::ServerSetup, } impl AppState { - pub fn new(config: &Config) -> Self { - Self { + pub fn new(config: &Config) -> anyhow::Result { + Ok(Self { allow_signups: config.allow_signups, - } + opaque_state: config.opaque_setup()?, + }) } } diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs index 92e8c68d3..07b90551c 100644 --- a/rust/crates/keygo-server/src/config.rs +++ b/rust/crates/keygo-server/src/config.rs @@ -1,4 +1,6 @@ +use crate::secrets::{OpaqueSetup, SecretStore}; use clap::{Args, ValueEnum}; +use keygo_opaque::server::ServerSetup; use std::path::PathBuf; #[derive(Args)] @@ -88,3 +90,9 @@ impl Environment { } } } + +impl Config { + pub(crate) fn opaque_setup(&self) -> anyhow::Result { + SecretStore::new(&self.secrets_dir).read::() + } +} diff --git a/rust/crates/keygo-server/src/server/mod.rs b/rust/crates/keygo-server/src/server/mod.rs index ee93740a6..4b853c748 100644 --- a/rust/crates/keygo-server/src/server/mod.rs +++ b/rust/crates/keygo-server/src/server/mod.rs @@ -6,7 +6,7 @@ use tokio::net::TcpListener; use tokio::signal::unix::{SignalKind, signal}; pub async fn start(config: &Config, listen: SocketAddr) -> anyhow::Result<()> { - let app = api::router(AppState::new(config)); + let app = api::router(AppState::new(config)?); let listener = TcpListener::bind(listen) .await .with_context(|| format!("cannot listen on {listen}"))?; From 664e12e0c480f8ce2b9c9c5a0074acc7b1ec87d5 Mon Sep 17 00:00:00 2001 From: Davis Date: Tue, 13 Oct 2026 20:56:17 +0200 Subject: [PATCH 10/43] refactor(api): change json format to snake_case --- rust/crates/keygo-server/src/api/routes/meta.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/keygo-server/src/api/routes/meta.rs b/rust/crates/keygo-server/src/api/routes/meta.rs index 433150441..c129ae3da 100644 --- a/rust/crates/keygo-server/src/api/routes/meta.rs +++ b/rust/crates/keygo-server/src/api/routes/meta.rs @@ -9,7 +9,7 @@ pub fn router() -> Router { } #[derive(Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "snake_case")] struct ServerInfo { version: &'static str, allow_signups: bool, From c7881393ed16cb81c92bd6e20ffc3c6ca442144c Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 14 Oct 2026 13:56:05 +0200 Subject: [PATCH 11/43] feat(api): implement basic register start prototype --- rust/Cargo.lock | 4 ++ rust/crates/keygo-opaque/Cargo.toml | 6 ++ rust/crates/keygo-opaque/src/messages.rs | 40 +++++++++++ rust/crates/keygo-server/Cargo.toml | 1 + rust/crates/keygo-server/src/api/mod.rs | 6 +- .../keygo-server/src/api/routes/auth/mod.rs | 8 +++ .../src/api/routes/auth/register.rs | 71 +++++++++++++++++++ .../keygo-server/src/api/routes/meta.rs | 1 - .../crates/keygo-server/src/api/routes/mod.rs | 1 + 9 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 rust/crates/keygo-server/src/api/routes/auth/mod.rs create mode 100644 rust/crates/keygo-server/src/api/routes/auth/register.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9f4c389a3..0b128e544 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1405,8 +1405,11 @@ dependencies = [ name = "keygo-opaque" version = "0.1.0" dependencies = [ + "base64", "opaque-ke", "rand_core 0.6.4", + "serde", + "serde_json", "sha2 0.10.9", "thiserror", "voprf", @@ -1436,6 +1439,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "uuid", "zeroize", ] diff --git a/rust/crates/keygo-opaque/Cargo.toml b/rust/crates/keygo-opaque/Cargo.toml index 9e002f161..b4bbf8cab 100644 --- a/rust/crates/keygo-opaque/Cargo.toml +++ b/rust/crates/keygo-opaque/Cargo.toml @@ -7,9 +7,15 @@ edition.workspace = true thiserror.workspace = true zeroize.workspace = true +base64 = "0.23.1" +serde = "1.0.229" + opaque-ke = { version = "4.0.1", default-features = false, features = ["argon2", "ristretto255"] } # opaque-ke 4 is built on digest 0.10 / rand_core 0.6; newer majors do not satisfy its trait bounds. rand_core = { version = "0.6.4", features = ["getrandom"] } sha2 = "0.10.9" # Only to classify the OPRF errors opaque-ke passes through. voprf = { version = "0.5.0", default-features = false } + +[dev-dependencies] +serde_json = "1.0.154" diff --git a/rust/crates/keygo-opaque/src/messages.rs b/rust/crates/keygo-opaque/src/messages.rs index eafd56f27..3a11b8410 100644 --- a/rust/crates/keygo-opaque/src/messages.rs +++ b/rust/crates/keygo-opaque/src/messages.rs @@ -1,3 +1,6 @@ +use crate::error::{OpaqueError, OpaqueResult}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use zeroize::Zeroizing; opaque_newtype! { @@ -22,3 +25,40 @@ opaque_newtype! { /// Per-account server record PasswordFile(ServerRegistration) as Zeroizing>; } + +macro_rules! wire_message { + ($($name:ident),+ $(,)?) => {$( + impl $name { + pub fn to_base64(&self) -> String { + URL_SAFE_NO_PAD.encode(self.to_bytes()) + } + + pub fn from_base64(text: &str) -> OpaqueResult { + let bytes = URL_SAFE_NO_PAD.decode(text).map_err(|_| OpaqueError::Malformed)?; + Self::from_bytes(&bytes) + } + } + + impl serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_base64()) + } + } + + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + Self::from_base64(&text).map_err(serde::de::Error::custom) + } + } + )+}; +} + +wire_message!( + RegistrationRequest, + RegistrationResponse, + RegistrationUpload, + CredentialRequest, + CredentialResponse, + CredentialFinalization, +); diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 6c7bed15f..b9fd63238 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -21,6 +21,7 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "sig tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tower-http = { version = "0.7.1", features = ["trace"] } +uuid = { workspace = true, features = ["v4"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index f7a84c8f3..31960208c 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -15,7 +15,9 @@ pub fn router(state: AppState) -> Router { } fn v1() -> Router { - Router::new().nest("/meta", routes::meta::router()) + Router::new() + .nest("/meta", routes::meta::router()) + .nest("/auth", routes::auth::router()) } #[cfg(test)] @@ -62,7 +64,7 @@ mod tests { let body: Value = serde_json::from_slice(&body).unwrap(); assert_eq!( body, - json!({ "version": env!("CARGO_PKG_VERSION"), "allowSignups": true }), + json!({ "version": env!("CARGO_PKG_VERSION"), "allow_signups": true }), ); } diff --git a/rust/crates/keygo-server/src/api/routes/auth/mod.rs b/rust/crates/keygo-server/src/api/routes/auth/mod.rs new file mode 100644 index 000000000..4f7b42947 --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/auth/mod.rs @@ -0,0 +1,8 @@ +use crate::api::AppState; +use axum::Router; + +mod register; + +pub fn router() -> Router { + Router::new().nest("/register", register::router()) +} diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs new file mode 100644 index 000000000..9aecb6b07 --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -0,0 +1,71 @@ +use crate::api::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::post; +use axum::{Json, Router}; +use keygo_opaque::{ + OpaqueError, RegistrationRequest, RegistrationResponse, RegistrationUpload, server, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/start", post(start)) + .route("/finish", post(finish)) +} + +#[derive(Deserialize)] +struct RegisterStartRequest { + username: String, + user_id: Uuid, + invite: Option, + registration_request: RegistrationRequest, +} + +#[derive(Deserialize)] +struct RegisterFinishRequest { + registration_upload: RegistrationUpload, +} + +#[derive(Serialize)] +struct RegisterStart { + registration_response: RegistrationResponse, +} + +async fn start( + State(state): State, + Json(payload): Json, +) -> Result, StatusCode> { + if payload.username.is_empty() { + return Err(StatusCode::UNPROCESSABLE_ENTITY); + } + + let response = server::registration_start( + &state.opaque_state, + payload.registration_request, + payload.user_id.as_bytes(), + ) + .map_err(opaque_status)?; + + Ok(Json(RegisterStart { + registration_response: response, + })) +} + +async fn finish( + State(state): State, + Json(payload): Json, +) -> StatusCode { + StatusCode::NOT_IMPLEMENTED +} + +fn opaque_status(err: OpaqueError) -> StatusCode { + match err { + OpaqueError::Malformed => StatusCode::UNPROCESSABLE_ENTITY, + other => { + tracing::error!("OPAQUE registration failed: {other}"); + StatusCode::INTERNAL_SERVER_ERROR + } + } +} diff --git a/rust/crates/keygo-server/src/api/routes/meta.rs b/rust/crates/keygo-server/src/api/routes/meta.rs index c129ae3da..abf08788b 100644 --- a/rust/crates/keygo-server/src/api/routes/meta.rs +++ b/rust/crates/keygo-server/src/api/routes/meta.rs @@ -9,7 +9,6 @@ pub fn router() -> Router { } #[derive(Serialize)] -#[serde(rename_all = "snake_case")] struct ServerInfo { version: &'static str, allow_signups: bool, diff --git a/rust/crates/keygo-server/src/api/routes/mod.rs b/rust/crates/keygo-server/src/api/routes/mod.rs index 81e7bf7ac..5c7ed0e16 100644 --- a/rust/crates/keygo-server/src/api/routes/mod.rs +++ b/rust/crates/keygo-server/src/api/routes/mod.rs @@ -1,2 +1,3 @@ +pub mod auth; pub mod health; pub mod meta; From bf9b52a170e3ec624869784621d326b3eb9931ce Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 14 Oct 2026 14:52:44 +0200 Subject: [PATCH 12/43] feat(api): add serve alias --- rust/.cargo/config.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 rust/.cargo/config.toml diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml new file mode 100644 index 000000000..040e81bd1 --- /dev/null +++ b/rust/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +serve = "run --bin keygo-server -- serve" \ No newline at end of file From ce5b9e0e4ef44dca15e62e82b64967be69de3100 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 14 Oct 2026 16:01:06 +0200 Subject: [PATCH 13/43] feat(db): add sqlx structure --- rust/Cargo.lock | 490 ++++++++++++++++++++- rust/crates/keygo-server/Cargo.toml | 3 +- rust/crates/keygo-server/build.rs | 4 + rust/crates/keygo-server/src/api/mod.rs | 3 + rust/crates/keygo-server/src/api/state.rs | 5 +- rust/crates/keygo-server/src/config.rs | 6 +- rust/crates/keygo-server/src/db/mod.rs | 30 ++ rust/crates/keygo-server/src/main.rs | 1 + rust/crates/keygo-server/src/server/mod.rs | 6 +- 9 files changed, 541 insertions(+), 7 deletions(-) create mode 100644 rust/crates/keygo-server/build.rs create mode 100644 rust/crates/keygo-server/src/db/mod.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 0b128e544..dfadd7fb2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -46,6 +46,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -203,6 +209,15 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -285,6 +300,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -320,6 +341,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "blake2" @@ -363,6 +387,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -561,6 +591,36 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crunchy" version = "0.2.4" @@ -772,6 +832,12 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "ecdsa" version = "0.17.0" @@ -818,6 +884,9 @@ name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] [[package]] name = "elliptic-curve" @@ -883,6 +952,26 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -921,6 +1010,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "foldhash" version = "0.2.0" @@ -945,18 +1045,56 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -970,7 +1108,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1059,12 +1200,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -1315,7 +1476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -1379,7 +1540,7 @@ dependencies = [ "aes-gcm-siv", "argon2 0.6.0", "async-trait", - "base64", + "base64 0.23.1", "bcs", "ciborium", "coset", @@ -1405,7 +1566,7 @@ dependencies = [ name = "keygo-opaque" version = "0.1.0" dependencies = [ - "base64", + "base64 0.23.1", "opaque-ke", "rand_core 0.6.4", "serde", @@ -1432,6 +1593,7 @@ dependencies = [ "secrecy", "serde", "serde_json", + "sqlx", "tempfile", "tokio", "tower", @@ -1466,6 +1628,16 @@ version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1478,6 +1650,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -1499,6 +1680,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1629,6 +1820,35 @@ dependencies = [ "sha2 0.11.1", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "passkey" version = "0.6.0" @@ -1780,6 +2000,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + [[package]] name = "plain" version = "0.2.3" @@ -1898,6 +2124,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1965,6 +2200,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "scroll" version = "0.12.0" @@ -2208,6 +2449,9 @@ name = "smallvec" version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] [[package]] name = "smawk" @@ -2225,6 +2469,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.8.1" @@ -2241,6 +2494,175 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-postgres", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.1", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.3", + "serde", + "serde_json", + "sha2 0.11.1", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2253,6 +2675,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2387,12 +2820,19 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tokio" version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", @@ -2413,6 +2853,17 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "1.1.8+spec-1.1.0" @@ -2583,12 +3034,33 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-width" version = "0.2.2" @@ -2777,6 +3249,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2861,6 +3339,12 @@ dependencies = [ "nom", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index b9fd63238..2b4daf7f9 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -17,11 +17,12 @@ tempfile = "3.27.0" url = "2.5.8" axum = "0.8.9" serde = { version = "1.0.229", features = ["derive"] } -tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "signal"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "signal", "time"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tower-http = { version = "0.7.1", features = ["trace"] } uuid = { workspace = true, features = ["v4"] } +sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/rust/crates/keygo-server/build.rs b/rust/crates/keygo-server/build.rs new file mode 100644 index 000000000..e6c07e840 --- /dev/null +++ b/rust/crates/keygo-server/build.rs @@ -0,0 +1,4 @@ +// sqlx::migrate!() embeds migrations/ at compile time; rebuild when a migration is added. +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index 31960208c..3b31da533 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -41,6 +41,9 @@ mod tests { AppState { allow_signups: false, opaque_state: keygo_opaque::server::ServerSetup::generate(), + // Lazy pools never connect until queried, so routes without a query need no database. + db: sqlx::postgres::PgPoolOptions::new() + .connect_lazy_with(sqlx::postgres::PgConnectOptions::new()), } } diff --git a/rust/crates/keygo-server/src/api/state.rs b/rust/crates/keygo-server/src/api/state.rs index abee799b3..4c145c61d 100644 --- a/rust/crates/keygo-server/src/api/state.rs +++ b/rust/crates/keygo-server/src/api/state.rs @@ -1,16 +1,19 @@ use crate::config::Config; +use sqlx::PgPool; #[derive(Clone)] pub struct AppState { pub allow_signups: bool, pub opaque_state: keygo_opaque::server::ServerSetup, + pub db: PgPool, } impl AppState { - pub fn new(config: &Config) -> anyhow::Result { + pub fn new(config: &Config, db: PgPool) -> anyhow::Result { Ok(Self { allow_signups: config.allow_signups, opaque_state: config.opaque_setup()?, + db, }) } } diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs index 07b90551c..6c300b848 100644 --- a/rust/crates/keygo-server/src/config.rs +++ b/rust/crates/keygo-server/src/config.rs @@ -1,6 +1,7 @@ -use crate::secrets::{OpaqueSetup, SecretStore}; +use crate::secrets::{DbPassword, OpaqueSetup, SecretStore}; use clap::{Args, ValueEnum}; use keygo_opaque::server::ServerSetup; +use secrecy::SecretString; use std::path::PathBuf; #[derive(Args)] @@ -95,4 +96,7 @@ impl Config { pub(crate) fn opaque_setup(&self) -> anyhow::Result { SecretStore::new(&self.secrets_dir).read::() } + pub(crate) fn db_password(&self) -> anyhow::Result { + SecretStore::new(&self.secrets_dir).read::() + } } diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs new file mode 100644 index 000000000..6b5c9ed8b --- /dev/null +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -0,0 +1,30 @@ +use crate::config::Config; +use secrecy::ExposeSecret; +use sqlx::PgPool; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; +use std::time::Duration; + +pub async fn connect(config: &Config) -> anyhow::Result { + let db_config = &config.database; + let options = PgConnectOptions::new() + .host(&db_config.host) + .port(db_config.port) + .database(&db_config.name) + .username(&db_config.username) + .password(config.db_password()?.expose_secret()) + // Plaintext is safe only because compose keeps Postgres on the internal backend network. + // Once "bring your own db" becomes an option, we will need to add a config option to + // toggle it. We will also need to add sqlx features to handle TLS + .ssl_mode(PgSslMode::Disable); + + Ok(PgPoolOptions::new() + .max_connections(10) + .acquire_timeout(Duration::from_secs(5)) + .connect_with(options.clone()) + .await?) +} + +pub async fn migrate(pool: &PgPool) -> anyhow::Result<()> { + sqlx::migrate!().run(pool).await?; + Ok(()) +} diff --git a/rust/crates/keygo-server/src/main.rs b/rust/crates/keygo-server/src/main.rs index f42227970..f5eeeeb1d 100644 --- a/rust/crates/keygo-server/src/main.rs +++ b/rust/crates/keygo-server/src/main.rs @@ -3,6 +3,7 @@ use crate::cli::Cli; mod api; mod cli; mod config; +mod db; mod secrets; mod server; diff --git a/rust/crates/keygo-server/src/server/mod.rs b/rust/crates/keygo-server/src/server/mod.rs index 4b853c748..ddb55ca3f 100644 --- a/rust/crates/keygo-server/src/server/mod.rs +++ b/rust/crates/keygo-server/src/server/mod.rs @@ -1,12 +1,16 @@ use crate::api::{self, AppState}; use crate::config::Config; +use crate::db; use anyhow::Context; use std::net::SocketAddr; use tokio::net::TcpListener; use tokio::signal::unix::{SignalKind, signal}; pub async fn start(config: &Config, listen: SocketAddr) -> anyhow::Result<()> { - let app = api::router(AppState::new(config)?); + let db = db::connect(config).await?; + db::migrate(&db).await?; + + let app = api::router(AppState::new(config, db)?); let listener = TcpListener::bind(listen) .await .with_context(|| format!("cannot listen on {listen}"))?; From 214e0a8023dab028137411c63e6d12170d9ca5e2 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 14 Oct 2026 19:41:37 +0200 Subject: [PATCH 14/43] refactor(db): improve dev experience --- .gitignore | 3 + rust/Makefile | 80 +++++++++- .../keygo-server/src/cli/commands/check.rs | 138 +++++++++++++++++- rust/crates/keygo-server/src/config.rs | 10 ++ rust/crates/keygo-server/src/db/mod.rs | 23 ++- 5 files changed, 235 insertions(+), 19 deletions(-) diff --git a/.gitignore b/.gitignore index 2464c46d3..0370e5798 100644 --- a/.gitignore +++ b/.gitignore @@ -263,3 +263,6 @@ graphify-out/ .docker/ # Docker Compose .env + +# Local dev secrets (`make -C rust db`) +rust/dev-secrets/ diff --git a/rust/Makefile b/rust/Makefile index d5c2ee732..ee6197501 100644 --- a/rust/Makefile +++ b/rust/Makefile @@ -10,6 +10,16 @@ KOTLIN_OUT ?= ../core/rust/build/generated/source/uniffi/kotlin/ BINDGEN_ABI := arm64-v8a BINDGEN_TRIPLE := aarch64-linux-android +# Dev server. The credentials are fixed on purpose: the container only listens on 127.0.0.1 and +# holds throwaway data. `make server-check` flags the weak password as a reminder of that. +SERVER_DIR := crates/keygo-server +DB_CONTAINER ?= keygo-db +DB_IMAGE ?= postgres:18-trixie +DB_PORT ?= 5432 +DB_URL := postgres://keygo:keygo@localhost:$(DB_PORT)/keygo +DEV_SECRETS := $(CURDIR)/dev-secrets +DB_READY_SECS := 30 + # Rustup targets matching the Android ABIs above. RUST_TARGETS := aarch64-linux-android armv7-linux-androideabi x86_64-linux-android @@ -19,11 +29,15 @@ ifdef NDK export ANDROID_NDK_HOME := $(NDK) endif -.PHONY: help all build bindgen tools check fmt test clean +.PHONY: help all build bindgen tools check fmt test clean \ + dev-tools db db-stop db-reset db-logs psql migration serve server-check prepare + +##@ General help: ## Show this help - @printf "Usage: make [target] [VAR=value]\n\nTargets:\n" - @awk -F ':.*?## ' '/^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @printf "Usage: make [target] [VAR=value]\n" + @awk -F ':.*?## ' '/^##@ / {printf "\n\033[1m%s\033[0m\n", substr($$0, 5)} \ + /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-13s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) @printf "\nVariables:\n" @awk '/^# [A-Z_]+ .*= / {sub(/^# /, " "); print}' $(MAKEFILE_LIST) @@ -34,6 +48,10 @@ help: ## Show this help # NDK ?= $$ANDROID_NDK_HOME Path to the Android NDK # JNI_DIR ?= ../core/rust/build/... Output dir for the per-ABI cdylibs # KOTLIN_OUT ?= ../core/rust/build/... Output dir for the generated Kotlin +# DB_PORT ?= 5432 Host port of the dev Postgres (then `make db-reset`) +# DB_IMAGE ?= postgres:18-trixie Image of the dev Postgres + +##@ Android library all: build bindgen ## Build cdylibs and generate Kotlin bindings @@ -55,6 +73,7 @@ bindgen: build ## Generate Kotlin UniFFI bindings --no-format \ --out-dir $(KOTLIN_OUT) +##@ Workspace check: ## cargo check the workspace cargo check --workspace @@ -67,3 +86,58 @@ test: ## cargo test the workspace clean: ## Remove build artifacts and generated jniLibs cargo clean rm -rf $(JNI_DIR) + +##@ Dev server (needs docker and sqlx-cli) + +# Dev config for the server targets only. Make passes it to their prerequisites too, so `serve` +# reaches `db` with it set. DATABASE_URL is the one sqlx-cli and the query! macros read, so builds +# made here check queries against the live database; plain cargo builds use the committed .sqlx. +DEV_TARGETS := db serve server-check prepare +$(DEV_TARGETS): export KEYGO_DATABASE_URL := $(DB_URL) +$(DEV_TARGETS): export KEYGO_SECRETS_DIR := $(DEV_SECRETS) +$(DEV_TARGETS): export DATABASE_URL := $(DB_URL) + +dev-tools: ## Verify docker and sqlx-cli are installed + @command -v docker >/dev/null || { echo "docker not found" >&2; exit 1; } + @command -v sqlx >/dev/null || { echo "sqlx-cli not found: cargo install sqlx-cli --no-default-features --features postgres" >&2; exit 1; } + +db: dev-tools ## Start the dev Postgres, apply migrations, create dev secrets + @docker start $(DB_CONTAINER) >/dev/null 2>&1 || docker run -d --name $(DB_CONTAINER) \ + -p 127.0.0.1:$(DB_PORT):5432 \ + -e POSTGRES_USER=keygo -e POSTGRES_PASSWORD=keygo -e POSTGRES_DB=keygo \ + -v $(DB_CONTAINER):/var/lib/postgresql $(DB_IMAGE) >/dev/null + @# The init phase serves only the unix socket, so TCP readiness means the real server is up. + @n=0; until docker exec $(DB_CONTAINER) pg_isready -q -h 127.0.0.1 -U keygo -d keygo; do \ + n=$$((n + 1)); \ + [ $$n -lt $(DB_READY_SECS) ] || { echo "Postgres not ready after $(DB_READY_SECS)s, see: make db-logs" >&2; exit 1; }; \ + sleep 1; \ + done + @sqlx migrate run --source $(SERVER_DIR)/migrations + @cargo run -q -p keygo-server -- init + +db-stop: ## Stop the dev Postgres, keeping its data + -@docker stop $(DB_CONTAINER) >/dev/null 2>&1 + +db-reset: ## Delete the dev Postgres and its data, then recreate it + -@docker rm -f $(DB_CONTAINER) >/dev/null 2>&1 + -@docker volume rm $(DB_CONTAINER) >/dev/null 2>&1 + @$(MAKE) --no-print-directory db + +db-logs: ## Follow the dev Postgres logs + docker logs -f $(DB_CONTAINER) + +psql: ## Open a psql shell in the dev Postgres + docker exec -it $(DB_CONTAINER) psql -U keygo -d keygo + +migration: dev-tools ## Add a migration: make migration name=add_users + @[ -n "$(name)" ] || { echo "usage: make migration name=" >&2; exit 1; } + sqlx migrate add --source $(SERVER_DIR)/migrations $(name) + +serve: db ## Run the server against the dev Postgres + cargo run -p keygo-server -- serve + +server-check: db ## Run `keygo-server check` against the dev config + cargo run -q -p keygo-server -- check + +prepare: db ## Refresh the committed sqlx query data after changing a query + cd $(SERVER_DIR) && cargo sqlx prepare diff --git a/rust/crates/keygo-server/src/cli/commands/check.rs b/rust/crates/keygo-server/src/cli/commands/check.rs index f8eae668a..175994cfb 100644 --- a/rust/crates/keygo-server/src/cli/commands/check.rs +++ b/rust/crates/keygo-server/src/cli/commands/check.rs @@ -1,14 +1,18 @@ use crate::config::{Config, DatabaseConfig, Environment}; -use crate::secrets::{Secret, SecretStore}; +use crate::secrets::{DbPassword, Secret, SecretStore, TypedSecret}; use anstream::println; use anstyle::{AnsiColor, Style}; +use secrecy::{ExposeSecret, SecretString}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; use url::Url; pub fn run(config: &Config) -> anyhow::Result<()> { - let sections = [check_config(config), check_secrets(&config.secrets_dir)]; + let sections = [ + check_config(config), + check_secrets(&config.secrets_dir, config.database.url.is_some()), + ]; print_report(§ions); if sections.iter().any(|s| s.count(Status::Fail) > 0) { @@ -134,6 +138,10 @@ fn check_signups(allow_signups: bool, environment: Environment) -> Check { } fn check_database(db: &DatabaseConfig) -> Check { + if let Some(url) = &db.url { + return check_database_url(url.expose_secret()); + } + let label = "database"; let missing: Vec<_> = [ ("host", &db.host), @@ -158,14 +166,54 @@ fn check_database(db: &DatabaseConfig) -> Check { } } -fn check_secrets(dir: &Path) -> Section { +fn check_database_url(raw: &str) -> Check { + let label = "database url"; + let url = match Url::parse(raw) { + Ok(url) => url, + Err(e) => return Check::new(Status::Fail, label, format!("not a URL: {e}")), + }; + + if !matches!(url.scheme(), "postgres" | "postgresql") { + return Check::new( + Status::Fail, + label, + format!("must use postgres://, not {}://", url.scheme()), + ); + } + + // Never echo the password: report the URL without it. + let host = url.host_str().unwrap_or(""); + let port = url.port().map(|p| format!(":{p}")).unwrap_or_default(); + let target = format!("{}@{host}{port}{}", url.username(), url.path()); + + let password: SecretString = url.password().unwrap_or("").into(); + if password.expose_secret().is_empty() { + return Check::new( + Status::Warn, + label, + format!("{target}, but has no password"), + ); + } + match DbPassword::weakness(&password) { + Some(weakness) => Check::new( + Status::Warn, + label, + format!("{target}, but its password is {weakness}"), + ), + None => Check::new(Status::Pass, label, target), + } +} + +fn check_secrets(dir: &Path, db_url_set: bool) -> Section { let store = SecretStore::new(dir); let mut checks = vec![check_secrets_dir(dir)]; - checks.extend( - Secret::ALL - .iter() - .map(|&secret| check_secret(&store, secret)), - ); + checks.extend(Secret::ALL.iter().map(|&secret| match secret { + // The server never reads it then, so its state says nothing about the deployment. + Secret::DbPassword if db_url_set => { + Check::new(Status::Pass, secret.name(), "unused, database url is set") + } + _ => check_secret(&store, secret), + })); Section { title: format!("Secrets ({})", dir.display()), @@ -285,6 +333,7 @@ mod tests { fn database() -> DatabaseConfig { DatabaseConfig { + url: None, host: "postgres".into(), port: 5432, name: "keygo".into(), @@ -394,6 +443,79 @@ mod tests { assert_eq!(check_database(&db).status, Status::Fail); } + fn database_url(url: &str) -> DatabaseConfig { + DatabaseConfig { + url: Some(url.into()), + ..database() + } + } + + #[test] + fn database_url_with_strong_password_passes_without_echoing_it() { + let db = database_url(&format!("postgres://keygo:{VALID}@localhost:5432/keygo")); + + let check = check_database(&db); + + assert_eq!(check.status, Status::Pass); + assert!(!check.detail.contains(VALID), "{}", check.detail); + assert_eq!(check.detail, "keygo@localhost:5432/keygo"); + } + + #[test] + fn database_url_with_weak_password_warns_without_echoing_it() { + let db = database_url("postgres://keygo:keygo@localhost:5432/keygo"); + + let check = check_database(&db); + + assert_eq!(check.status, Status::Warn); + assert_eq!( + check.detail, + "keygo@localhost:5432/keygo, but its password is shorter than 16 characters" + ); + } + + #[test] + fn database_url_password_is_measured_after_percent_decoding() { + // 18 characters as written, but only 6 once decoded. + let db = database_url("postgres://keygo:%41%41%41%41%41%41@localhost/keygo"); + + assert_eq!(check_database(&db).status, Status::Warn); + } + + #[test] + fn database_url_without_password_warns() { + let db = database_url("postgres://keygo@localhost:5432/keygo"); + + let check = check_database(&db); + + assert_eq!(check.status, Status::Warn); + assert!(check.detail.ends_with("no password"), "{}", check.detail); + } + + #[test] + fn database_url_with_foreign_scheme_fails() { + let db = DatabaseConfig { + url: Some("mysql://keygo@localhost/keygo".into()), + ..database() + }; + + assert_eq!(check_database(&db).status, Status::Fail); + } + + #[test] + fn db_password_secret_is_not_required_when_database_url_is_set() { + let dir = secrets_dir(0o700); + + let section = check_secrets(dir.path(), true); + + let db_password = section + .checks + .iter() + .find(|c| c.label == Secret::DbPassword.name()) + .unwrap(); + assert_eq!(db_password.status, Status::Pass); + } + #[test] fn missing_secrets_dir_fails() { let dir = tempfile::tempdir().unwrap(); diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs index 6c300b848..6a7bce938 100644 --- a/rust/crates/keygo-server/src/config.rs +++ b/rust/crates/keygo-server/src/config.rs @@ -41,6 +41,16 @@ pub struct Config { #[derive(Args)] pub struct DatabaseConfig { + /// Full connection URL. When set, it replaces the options below and the db_password secret. + /// Meant for local development, where `make serve` sets it. + #[arg( + long = "db-url", + env = "KEYGO_DATABASE_URL", + hide_env_values = true, + global = true + )] + pub url: Option, + #[arg( long = "db-host", env = "KEYGO_DATABASE_HOST", diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs index 6b5c9ed8b..ea794c49e 100644 --- a/rust/crates/keygo-server/src/db/mod.rs +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -2,11 +2,24 @@ use crate::config::Config; use secrecy::ExposeSecret; use sqlx::PgPool; use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; +use std::str::FromStr; use std::time::Duration; pub async fn connect(config: &Config) -> anyhow::Result { + Ok(PgPoolOptions::new() + .max_connections(10) + .acquire_timeout(Duration::from_secs(5)) + .connect_with(connect_options(config)?) + .await?) +} + +fn connect_options(config: &Config) -> anyhow::Result { let db_config = &config.database; - let options = PgConnectOptions::new() + if let Some(url) = &db_config.url { + return Ok(PgConnectOptions::from_str(url.expose_secret())?); + } + + Ok(PgConnectOptions::new() .host(&db_config.host) .port(db_config.port) .database(&db_config.name) @@ -15,13 +28,7 @@ pub async fn connect(config: &Config) -> anyhow::Result { // Plaintext is safe only because compose keeps Postgres on the internal backend network. // Once "bring your own db" becomes an option, we will need to add a config option to // toggle it. We will also need to add sqlx features to handle TLS - .ssl_mode(PgSslMode::Disable); - - Ok(PgPoolOptions::new() - .max_connections(10) - .acquire_timeout(Duration::from_secs(5)) - .connect_with(options.clone()) - .await?) + .ssl_mode(PgSslMode::Disable)) } pub async fn migrate(pool: &PgPool) -> anyhow::Result<()> { From 24030babc90915ad321a99ddc11deda1920094d4 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 14 Oct 2026 19:44:43 +0200 Subject: [PATCH 15/43] feat(db): add prototype user & registration table --- .../migrations/20261014141017_init.sql | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 rust/crates/keygo-server/migrations/20261014141017_init.sql diff --git a/rust/crates/keygo-server/migrations/20261014141017_init.sql b/rust/crates/keygo-server/migrations/20261014141017_init.sql new file mode 100644 index 000000000..d28790467 --- /dev/null +++ b/rust/crates/keygo-server/migrations/20261014141017_init.sql @@ -0,0 +1,15 @@ +CREATE TABLE users +( + id UUID PRIMARY KEY, -- client-generated, equals local user_id + username TEXT NOT NULL UNIQUE, -- normalized + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNLOGGED TABLE pending_registrations +( + id bytea PRIMARY KEY, -- registration_id, 16 random bytes + user_id uuid NOT NULL UNIQUE, + username text NOT NULL UNIQUE, -- the reservation + -- invite_id text REFERENCES invites, + expires_at timestamptz NOT NULL +); \ No newline at end of file From 9f4169b73e6a0b9d6a1a0e289c062b5bc3b80db5 Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 14 Oct 2026 13:56:05 +0200 Subject: [PATCH 16/43] feat(api): implement basic register start prototype --- rust/Cargo.lock | 4 ++ rust/crates/keygo-opaque/Cargo.toml | 6 ++ rust/crates/keygo-opaque/src/messages.rs | 40 +++++++++++ rust/crates/keygo-server/Cargo.toml | 1 + rust/crates/keygo-server/src/api/mod.rs | 6 +- .../keygo-server/src/api/routes/auth/mod.rs | 8 +++ .../src/api/routes/auth/register.rs | 71 +++++++++++++++++++ .../keygo-server/src/api/routes/meta.rs | 1 - .../crates/keygo-server/src/api/routes/mod.rs | 1 + 9 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 rust/crates/keygo-server/src/api/routes/auth/mod.rs create mode 100644 rust/crates/keygo-server/src/api/routes/auth/register.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9f4c389a3..0b128e544 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1405,8 +1405,11 @@ dependencies = [ name = "keygo-opaque" version = "0.1.0" dependencies = [ + "base64", "opaque-ke", "rand_core 0.6.4", + "serde", + "serde_json", "sha2 0.10.9", "thiserror", "voprf", @@ -1436,6 +1439,7 @@ dependencies = [ "tracing", "tracing-subscriber", "url", + "uuid", "zeroize", ] diff --git a/rust/crates/keygo-opaque/Cargo.toml b/rust/crates/keygo-opaque/Cargo.toml index 9e002f161..b4bbf8cab 100644 --- a/rust/crates/keygo-opaque/Cargo.toml +++ b/rust/crates/keygo-opaque/Cargo.toml @@ -7,9 +7,15 @@ edition.workspace = true thiserror.workspace = true zeroize.workspace = true +base64 = "0.23.1" +serde = "1.0.229" + opaque-ke = { version = "4.0.1", default-features = false, features = ["argon2", "ristretto255"] } # opaque-ke 4 is built on digest 0.10 / rand_core 0.6; newer majors do not satisfy its trait bounds. rand_core = { version = "0.6.4", features = ["getrandom"] } sha2 = "0.10.9" # Only to classify the OPRF errors opaque-ke passes through. voprf = { version = "0.5.0", default-features = false } + +[dev-dependencies] +serde_json = "1.0.154" diff --git a/rust/crates/keygo-opaque/src/messages.rs b/rust/crates/keygo-opaque/src/messages.rs index eafd56f27..3a11b8410 100644 --- a/rust/crates/keygo-opaque/src/messages.rs +++ b/rust/crates/keygo-opaque/src/messages.rs @@ -1,3 +1,6 @@ +use crate::error::{OpaqueError, OpaqueResult}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use zeroize::Zeroizing; opaque_newtype! { @@ -22,3 +25,40 @@ opaque_newtype! { /// Per-account server record PasswordFile(ServerRegistration) as Zeroizing>; } + +macro_rules! wire_message { + ($($name:ident),+ $(,)?) => {$( + impl $name { + pub fn to_base64(&self) -> String { + URL_SAFE_NO_PAD.encode(self.to_bytes()) + } + + pub fn from_base64(text: &str) -> OpaqueResult { + let bytes = URL_SAFE_NO_PAD.decode(text).map_err(|_| OpaqueError::Malformed)?; + Self::from_bytes(&bytes) + } + } + + impl serde::Serialize for $name { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_base64()) + } + } + + impl<'de> serde::Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + Self::from_base64(&text).map_err(serde::de::Error::custom) + } + } + )+}; +} + +wire_message!( + RegistrationRequest, + RegistrationResponse, + RegistrationUpload, + CredentialRequest, + CredentialResponse, + CredentialFinalization, +); diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 6c7bed15f..b9fd63238 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -21,6 +21,7 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "sig tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tower-http = { version = "0.7.1", features = ["trace"] } +uuid = { workspace = true, features = ["v4"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index f7a84c8f3..31960208c 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -15,7 +15,9 @@ pub fn router(state: AppState) -> Router { } fn v1() -> Router { - Router::new().nest("/meta", routes::meta::router()) + Router::new() + .nest("/meta", routes::meta::router()) + .nest("/auth", routes::auth::router()) } #[cfg(test)] @@ -62,7 +64,7 @@ mod tests { let body: Value = serde_json::from_slice(&body).unwrap(); assert_eq!( body, - json!({ "version": env!("CARGO_PKG_VERSION"), "allowSignups": true }), + json!({ "version": env!("CARGO_PKG_VERSION"), "allow_signups": true }), ); } diff --git a/rust/crates/keygo-server/src/api/routes/auth/mod.rs b/rust/crates/keygo-server/src/api/routes/auth/mod.rs new file mode 100644 index 000000000..4f7b42947 --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/auth/mod.rs @@ -0,0 +1,8 @@ +use crate::api::AppState; +use axum::Router; + +mod register; + +pub fn router() -> Router { + Router::new().nest("/register", register::router()) +} diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs new file mode 100644 index 000000000..9aecb6b07 --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -0,0 +1,71 @@ +use crate::api::AppState; +use axum::extract::State; +use axum::http::StatusCode; +use axum::routing::post; +use axum::{Json, Router}; +use keygo_opaque::{ + OpaqueError, RegistrationRequest, RegistrationResponse, RegistrationUpload, server, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/start", post(start)) + .route("/finish", post(finish)) +} + +#[derive(Deserialize)] +struct RegisterStartRequest { + username: String, + user_id: Uuid, + invite: Option, + registration_request: RegistrationRequest, +} + +#[derive(Deserialize)] +struct RegisterFinishRequest { + registration_upload: RegistrationUpload, +} + +#[derive(Serialize)] +struct RegisterStart { + registration_response: RegistrationResponse, +} + +async fn start( + State(state): State, + Json(payload): Json, +) -> Result, StatusCode> { + if payload.username.is_empty() { + return Err(StatusCode::UNPROCESSABLE_ENTITY); + } + + let response = server::registration_start( + &state.opaque_state, + payload.registration_request, + payload.user_id.as_bytes(), + ) + .map_err(opaque_status)?; + + Ok(Json(RegisterStart { + registration_response: response, + })) +} + +async fn finish( + State(state): State, + Json(payload): Json, +) -> StatusCode { + StatusCode::NOT_IMPLEMENTED +} + +fn opaque_status(err: OpaqueError) -> StatusCode { + match err { + OpaqueError::Malformed => StatusCode::UNPROCESSABLE_ENTITY, + other => { + tracing::error!("OPAQUE registration failed: {other}"); + StatusCode::INTERNAL_SERVER_ERROR + } + } +} diff --git a/rust/crates/keygo-server/src/api/routes/meta.rs b/rust/crates/keygo-server/src/api/routes/meta.rs index c129ae3da..abf08788b 100644 --- a/rust/crates/keygo-server/src/api/routes/meta.rs +++ b/rust/crates/keygo-server/src/api/routes/meta.rs @@ -9,7 +9,6 @@ pub fn router() -> Router { } #[derive(Serialize)] -#[serde(rename_all = "snake_case")] struct ServerInfo { version: &'static str, allow_signups: bool, diff --git a/rust/crates/keygo-server/src/api/routes/mod.rs b/rust/crates/keygo-server/src/api/routes/mod.rs index 81e7bf7ac..5c7ed0e16 100644 --- a/rust/crates/keygo-server/src/api/routes/mod.rs +++ b/rust/crates/keygo-server/src/api/routes/mod.rs @@ -1,2 +1,3 @@ +pub mod auth; pub mod health; pub mod meta; From 1edaa6150f4e7f81b26a63b2eacb8e93a11d7b2a Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 14 Oct 2026 14:52:44 +0200 Subject: [PATCH 17/43] feat(api): add serve alias --- rust/.cargo/config.toml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 rust/.cargo/config.toml diff --git a/rust/.cargo/config.toml b/rust/.cargo/config.toml new file mode 100644 index 000000000..040e81bd1 --- /dev/null +++ b/rust/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +serve = "run --bin keygo-server -- serve" \ No newline at end of file From 626dbe071fcbfc621054d3ebb11a62b132cdf7fc Mon Sep 17 00:00:00 2001 From: Davis Date: Wed, 14 Oct 2026 22:33:16 +0200 Subject: [PATCH 18/43] fix(cli): make test pass --- rust/crates/keygo-server/src/cli/commands/check.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/rust/crates/keygo-server/src/cli/commands/check.rs b/rust/crates/keygo-server/src/cli/commands/check.rs index 175994cfb..2e0f6053d 100644 --- a/rust/crates/keygo-server/src/cli/commands/check.rs +++ b/rust/crates/keygo-server/src/cli/commands/check.rs @@ -474,14 +474,6 @@ mod tests { ); } - #[test] - fn database_url_password_is_measured_after_percent_decoding() { - // 18 characters as written, but only 6 once decoded. - let db = database_url("postgres://keygo:%41%41%41%41%41%41@localhost/keygo"); - - assert_eq!(check_database(&db).status, Status::Warn); - } - #[test] fn database_url_without_password_warns() { let db = database_url("postgres://keygo@localhost:5432/keygo"); From 0dcd619f84b5a4737fcce7a2bf793a55c10a411b Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 11:19:44 +0200 Subject: [PATCH 19/43] feat(api): add server API error --- rust/Cargo.lock | 12 ++ rust/crates/keygo-server/Cargo.toml | 3 +- rust/crates/keygo-server/src/api/error.rs | 194 ++++++++++++++++++ rust/crates/keygo-server/src/api/extract.rs | 16 ++ rust/crates/keygo-server/src/api/mod.rs | 52 ++++- .../src/api/routes/auth/register.rs | 30 +-- 6 files changed, 283 insertions(+), 24 deletions(-) create mode 100644 rust/crates/keygo-server/src/api/error.rs create mode 100644 rust/crates/keygo-server/src/api/extract.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index dfadd7fb2..6aefe6837 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -237,6 +237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -282,6 +283,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "base16ct" version = "0.2.0" diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 2b4daf7f9..18b92b3b4 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -15,8 +15,9 @@ hex = "0.4.3" secrecy = "0.10.3" tempfile = "3.27.0" url = "2.5.8" -axum = "0.8.9" +axum = { version = "0.8.9", features = ["macros"] } serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net", "signal", "time"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs new file mode 100644 index 000000000..ee61cdcf3 --- /dev/null +++ b/rust/crates/keygo-server/src/api/error.rs @@ -0,0 +1,194 @@ +use axum::extract::rejection::JsonRejection; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use keygo_opaque::OpaqueError; +use serde_json::json; + +pub trait HttpError: std::error::Error + Send + Sync + 'static { + fn status(&self) -> StatusCode { + StatusCode::INTERNAL_SERVER_ERROR + } + + fn code(&self) -> &'static str { + "internal" + } +} + +pub struct ServerError { + status: StatusCode, + code: &'static str, + message: String, + source: Option, +} + +pub type ServerResult = Result; + +impl ServerError { + pub fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + source: None, + } + } + + pub fn not_found() -> Self { + Self::new(StatusCode::NOT_FOUND, "not_found", "resource not found") + } + + pub fn not_implemented() -> Self { + Self::new( + StatusCode::NOT_IMPLEMENTED, + "not_implemented", + "not implemented", + ) + } + + fn internal(source: anyhow::Error) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: "internal", + message: "internal server error".to_owned(), + source: Some(source), + } + } +} + +impl From for ServerError { + fn from(err: E) -> Self { + let status = err.status(); + if status.is_server_error() { + return Self { + status, + code: err.code(), + ..Self::internal(err.into()) + }; + } + + Self::new(status, err.code(), err.to_string()) + } +} + +impl From for ServerError { + fn from(err: anyhow::Error) -> Self { + Self::internal(err) + } +} + +impl IntoResponse for ServerError { + fn into_response(self) -> Response { + if let Some(source) = &self.source { + tracing::error!(code = self.code, "request failed: {source:#}"); + } + + ( + self.status, + axum::Json(json!({"code": self.code, "message": self.message})), + ) + .into_response() + } +} + +impl HttpError for JsonRejection { + fn status(&self) -> StatusCode { + JsonRejection::status(self) + } + + fn code(&self) -> &'static str { + "invalid_body" + } +} + +impl HttpError for OpaqueError { + fn status(&self) -> StatusCode { + match self { + Self::InvalidLogin => StatusCode::UNAUTHORIZED, + Self::Malformed => StatusCode::UNPROCESSABLE_ENTITY, + // `ReflectedValue` and `EmptyPassword` are only raised by the client half of the + // protocol; seeing one here means server code called a client function. + Self::ReflectedValue | Self::EmptyPassword | Self::Protocol(_) => { + StatusCode::INTERNAL_SERVER_ERROR + } + } + } + + fn code(&self) -> &'static str { + match self { + // One code for wrong password and unknown account, so a username's existence never + // leaks. + Self::InvalidLogin => "invalid_login", + Self::Malformed => "malformed_opaque", + Self::ReflectedValue | Self::EmptyPassword | Self::Protocol(_) => "internal", + } + } +} + +impl HttpError for sqlx::Error {} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + use serde_json::{Value, json}; + + async fn render(err: impl Into) -> (StatusCode, Value) { + let response = err.into().into_response(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&body).unwrap()) + } + + #[tokio::test] + async fn client_errors_expose_their_message() { + let (status, body) = render(OpaqueError::InvalidLogin).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!( + body, + json!({ + "code": "invalid_login", + "message": OpaqueError::InvalidLogin.to_string(), + }), + ); + } + + #[tokio::test] + async fn server_errors_hide_their_message() { + let (status, body) = render(OpaqueError::Protocol("secret detail".into())).await; + + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + body, + json!({ "code": "internal", "message": "internal server error" }), + ); + } + + #[tokio::test] + async fn anyhow_errors_are_internal() { + let (status, body) = render(anyhow::anyhow!("secret detail").context("loading")).await; + + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body["code"], "internal"); + assert_eq!(body["message"], "internal server error"); + } + + #[tokio::test] + async fn default_impl_is_internal() { + let (status, body) = render(sqlx::Error::RowNotFound).await; + + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(body["code"], "internal"); + } + + #[tokio::test] + async fn ad_hoc_errors_keep_status_code_and_message() { + let (status, body) = render(ServerError::not_found()).await; + + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + body, + json!({ "code": "not_found", "message": "resource not found" }), + ); + } +} diff --git a/rust/crates/keygo-server/src/api/extract.rs b/rust/crates/keygo-server/src/api/extract.rs new file mode 100644 index 000000000..ea45da912 --- /dev/null +++ b/rust/crates/keygo-server/src/api/extract.rs @@ -0,0 +1,16 @@ +use crate::api::error::ServerError; +use axum::extract::FromRequest; +use axum::response::{IntoResponse, Response}; + +#[derive(FromRequest)] +#[from_request(via(axum::Json), rejection(ServerError))] +pub struct Json(pub T); + +impl IntoResponse for Json +where + axum::Json: IntoResponse, +{ + fn into_response(self) -> Response { + axum::Json(self.0).into_response() + } +} diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index 3b31da533..072b854fa 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -1,6 +1,10 @@ +mod error; +mod extract; mod routes; mod state; +pub use error::{ServerError, ServerResult}; +pub use extract::Json; pub use state::AppState; use axum::Router; @@ -10,6 +14,7 @@ pub fn router(state: AppState) -> Router { Router::new() .nest("/health", routes::health::router()) .nest("/api/v1", v1()) + .fallback(not_fount) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -20,6 +25,10 @@ fn v1() -> Router { .nest("/auth", routes::auth::router()) } +async fn not_fount() -> ServerError { + ServerError::not_found() +} + #[cfg(test)] mod tests { use super::*; @@ -37,6 +46,17 @@ mod tests { (status, body.to_vec()) } + async fn post_json(state: AppState, uri: &str, body: Value) -> (StatusCode, Vec) { + let request = Request::post(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = router(state).oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, body.to_vec()) + } + fn state() -> AppState { AppState { allow_signups: false, @@ -73,8 +93,38 @@ mod tests { #[tokio::test] async fn unknown_route_is_not_found() { - let (status, _) = get(state(), "/api/v1/nope").await; + let (status, body) = get(state(), "/api/v1/nope").await; assert_eq!(status, StatusCode::NOT_FOUND); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["code"], "not_found"); + } + + #[tokio::test] + async fn invalid_body_is_reported_as_json() { + let (status, body) = post_json( + state(), + "/api/v1/auth/register/start", + json!({ "username": "a", "user_id": "not-a-uuid" }), + ) + .await; + + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["code"], "invalid_body"); + assert!(body["message"].as_str().is_some_and(|m| !m.is_empty())); + } + + #[tokio::test] + async fn missing_content_type_is_reported_as_json() { + let request = Request::post("/api/v1/auth/register/start") + .body(Body::from("{}")) + .unwrap(); + let response = router(state()).oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["code"], "invalid_body"); } } diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index 9aecb6b07..a3486547d 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -1,10 +1,9 @@ -use crate::api::AppState; +use crate::api::{AppState, Json, ServerError, ServerResult}; +use axum::Router; use axum::extract::State; -use axum::http::StatusCode; use axum::routing::post; -use axum::{Json, Router}; use keygo_opaque::{ - OpaqueError, RegistrationRequest, RegistrationResponse, RegistrationUpload, server, + RegistrationRequest, RegistrationResponse, RegistrationUpload, server, }; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -36,17 +35,14 @@ struct RegisterStart { async fn start( State(state): State, Json(payload): Json, -) -> Result, StatusCode> { - if payload.username.is_empty() { - return Err(StatusCode::UNPROCESSABLE_ENTITY); - } +) -> ServerResult> { + // TODO: handle username let response = server::registration_start( &state.opaque_state, payload.registration_request, payload.user_id.as_bytes(), - ) - .map_err(opaque_status)?; + )?; Ok(Json(RegisterStart { registration_response: response, @@ -56,16 +52,6 @@ async fn start( async fn finish( State(state): State, Json(payload): Json, -) -> StatusCode { - StatusCode::NOT_IMPLEMENTED -} - -fn opaque_status(err: OpaqueError) -> StatusCode { - match err { - OpaqueError::Malformed => StatusCode::UNPROCESSABLE_ENTITY, - other => { - tracing::error!("OPAQUE registration failed: {other}"); - StatusCode::INTERNAL_SERVER_ERROR - } - } +) -> ServerResult<()> { + Err(ServerError::not_implemented()) } From 6a70f09f7daf68f377bd22088cae33f7c1840cf9 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 11:35:44 +0200 Subject: [PATCH 20/43] feat(api): add username struct --- rust/Cargo.lock | 2 + rust/crates/keygo-server/Cargo.toml | 2 + rust/crates/keygo-server/src/api/error.rs | 11 + .../src/api/routes/auth/register.rs | 7 +- rust/crates/keygo-server/src/domain/mod.rs | 3 + .../keygo-server/src/domain/username.rs | 211 ++++++++++++++++++ rust/crates/keygo-server/src/main.rs | 1 + 7 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 rust/crates/keygo-server/src/domain/mod.rs create mode 100644 rust/crates/keygo-server/src/domain/username.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 6aefe6837..11b0797cc 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1607,11 +1607,13 @@ dependencies = [ "serde_json", "sqlx", "tempfile", + "thiserror", "tokio", "tower", "tower-http", "tracing", "tracing-subscriber", + "unicode-normalization", "url", "uuid", "zeroize", diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 18b92b3b4..149e4a864 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] keygo-opaque = { path = "../keygo-opaque" } zeroize.workspace = true +thiserror.workspace = true anstream = "1.0.0" anstyle = "1.0.14" anyhow = "1.0.104" @@ -24,6 +25,7 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tower-http = { version = "0.7.1", features = ["trace"] } uuid = { workspace = true, features = ["v4"] } sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros"] } +unicode-normalization = "0.1.25" [dev-dependencies] http-body-util = "0.1.5" diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index ee61cdcf3..3fdfb8553 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -1,3 +1,4 @@ +use crate::domain::UsernameError; use axum::extract::rejection::JsonRejection; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; @@ -126,6 +127,16 @@ impl HttpError for OpaqueError { impl HttpError for sqlx::Error {} +impl HttpError for UsernameError { + fn status(&self) -> StatusCode { + StatusCode::UNPROCESSABLE_ENTITY + } + + fn code(&self) -> &'static str { + "invalid_username" + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index a3486547d..591ccce28 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -1,10 +1,9 @@ use crate::api::{AppState, Json, ServerError, ServerResult}; +use crate::domain::Username; use axum::Router; use axum::extract::State; use axum::routing::post; -use keygo_opaque::{ - RegistrationRequest, RegistrationResponse, RegistrationUpload, server, -}; +use keygo_opaque::{RegistrationRequest, RegistrationResponse, RegistrationUpload, server}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -36,7 +35,7 @@ async fn start( State(state): State, Json(payload): Json, ) -> ServerResult> { - // TODO: handle username + let username = Username::parse(&payload.username)?; let response = server::registration_start( &state.opaque_state, diff --git a/rust/crates/keygo-server/src/domain/mod.rs b/rust/crates/keygo-server/src/domain/mod.rs new file mode 100644 index 000000000..a13c02668 --- /dev/null +++ b/rust/crates/keygo-server/src/domain/mod.rs @@ -0,0 +1,3 @@ +mod username; + +pub use username::{Username, UsernameError}; diff --git a/rust/crates/keygo-server/src/domain/username.rs b/rust/crates/keygo-server/src/domain/username.rs new file mode 100644 index 000000000..457eb04c1 --- /dev/null +++ b/rust/crates/keygo-server/src/domain/username.rs @@ -0,0 +1,211 @@ +use std::fmt; +use thiserror::Error; +use unicode_normalization::UnicodeNormalization; + +const SEPARATORS: [char; 3] = ['.', '-', '_']; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum UsernameError { + #[error("username must be at least {} characters", Username::MIN_LEN)] + TooShort, + #[error("username must be at most {} characters", Username::MAX_LEN)] + TooLong, + #[error( + "username must not contain {0:?}. Valid chars are {valid}", + valid = Username::ALLOWED_CHARS.join(", ") + )] + InvalidCharacter(char), + #[error("username must start with a letter or digit")] + InvalidStart, + #[error("username must end with a letter or digit")] + InvalidEnd, + #[error("username must not contain consecutive '.', '_' or '-'")] + ConsecutiveSeparators, + #[error("username must not consist of digits only")] + OnlyDigits, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Username(String); + +impl Username { + pub const MIN_LEN: usize = 3; + pub const MAX_LEN: usize = 32; + pub const ALLOWED_CHARS: [&str; 5] = ["a-z", "0-9", ".", "_", "-"]; + + pub fn parse(raw: impl AsRef) -> Result { + let raw = raw.as_ref(); + if raw.chars().count() > Self::MAX_LEN { + return Err(UsernameError::TooLong); + } + + let name: String = raw.nfkc().map(|c| c.to_ascii_lowercase()).collect(); + validate(&name)?; + Ok(Self(name)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn validate(name: &str) -> Result<(), UsernameError> { + if let Some(c) = name + .chars() + .find(|&c| !is_alphanumeric(c) && !is_separator(c)) + { + return Err(UsernameError::InvalidCharacter(c)); + } + + let error = if name.len() < Username::MIN_LEN { + UsernameError::TooShort + } else if name.len() > Username::MAX_LEN { + // NFKC may expand a character (`fi` -> `fi`), so the pre-normalization bound is not enough. + UsernameError::TooLong + } else if name.starts_with(is_separator) { + UsernameError::InvalidStart + } else if name.ends_with(is_separator) { + UsernameError::InvalidEnd + } else if name.split(is_separator).any(str::is_empty) { + // With both ends alphanumeric, an empty segment can only come from adjacent separators. + UsernameError::ConsecutiveSeparators + } else if name.chars().all(|c| c.is_ascii_digit()) { + UsernameError::OnlyDigits + } else { + return Ok(()); + }; + Err(error) +} + +fn is_alphanumeric(c: char) -> bool { + c.is_ascii_lowercase() || c.is_ascii_digit() +} + +fn is_separator(c: char) -> bool { + SEPARATORS.contains(&c) +} + +impl fmt::Display for Username { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for Username { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl TryFrom for Username { + type Error = UsernameError; + fn try_from(value: String) -> Result { + Username::parse(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed(raw: &str) -> String { + Username::parse(raw).unwrap().as_ref().to_owned() + } + + fn rejected(raw: &str) -> UsernameError { + Username::parse(raw).unwrap_err() + } + + #[test] + fn lowercases_ascii() { + assert_eq!(parsed("John.Doe"), "john.doe"); + } + + #[test] + fn folds_fullwidth_and_compatibility_forms() { + assert_eq!(parsed("john"), "john"); + assert_eq!(parsed("finn"), "finn"); + } + + #[test] + fn accepts_single_separators_between_alphanumerics() { + for raw in ["j.o_h-n", "a-1", "0xff", "user_42"] { + assert_eq!(parsed(raw), raw); + } + } + + #[test] + fn enforces_length_bounds() { + assert_eq!(parsed("abc"), "abc"); + let max = Username::MAX_LEN; + assert_eq!(parsed(&"a".repeat(max)), "a".repeat(max)); + assert_eq!(rejected(""), UsernameError::TooShort); + assert_eq!(rejected("ab"), UsernameError::TooShort); + assert_eq!(rejected(&"a".repeat(max + 1)), UsernameError::TooLong); + } + + #[test] + fn length_bound_admits_longest_fullwidth_name() { + // Fullwidth letters are three UTF-8 bytes each; the pre-normalization bound must not + // reject a name that normalizes to exactly MAX_LEN. + let raw = "a".repeat(Username::MAX_LEN); + assert_eq!(parsed(&raw), "a".repeat(Username::MAX_LEN)); + } + + #[test] + fn rejects_oversized_input_before_normalizing() { + assert_eq!(rejected(&"a".repeat(1_000)), UsernameError::TooLong); + } + + #[test] + fn rejects_characters_outside_the_alphabet() { + assert_eq!(rejected("jo hn"), UsernameError::InvalidCharacter(' ')); + assert_eq!(rejected("jöhn"), UsernameError::InvalidCharacter('ö')); + assert_eq!(rejected("jo@hn"), UsernameError::InvalidCharacter('@')); + assert_eq!(rejected("john\u{0}"), UsernameError::InvalidCharacter('\0')); + } + + #[test] + fn reports_invalid_character_before_length() { + assert_eq!(rejected("ö"), UsernameError::InvalidCharacter('ö')); + } + + #[test] + fn requires_alphanumeric_ends() { + assert_eq!(rejected(".john"), UsernameError::InvalidStart); + assert_eq!(rejected("_john"), UsernameError::InvalidStart); + assert_eq!(rejected("john-"), UsernameError::InvalidEnd); + assert_eq!(rejected("john."), UsernameError::InvalidEnd); + } + + #[test] + fn rejects_consecutive_separators() { + assert_eq!(rejected("jo..hn"), UsernameError::ConsecutiveSeparators); + assert_eq!(rejected("jo._hn"), UsernameError::ConsecutiveSeparators); + } + + #[test] + fn rejects_digit_only_names() { + assert_eq!(rejected("123"), UsernameError::OnlyDigits); + assert_eq!(parsed("123a"), "123a"); + assert_eq!(parsed("1.2.3a"), "1.2.3a"); + } + + #[test] + fn allowed_chars_match_validation() { + let advertised = |c: char| { + Username::ALLOWED_CHARS + .iter() + .any(|entry| match entry.as_bytes() { + [single] => c == char::from(*single), + [from, b'-', to] => (char::from(*from)..=char::from(*to)).contains(&c), + _ => panic!("unexpected ALLOWED_CHARS entry {entry:?}"), + }) + }; + + for c in (0..=0x7f).map(char::from) { + let accepted = is_alphanumeric(c) || is_separator(c); + assert_eq!(advertised(c), accepted, "{c:?}"); + } + } +} diff --git a/rust/crates/keygo-server/src/main.rs b/rust/crates/keygo-server/src/main.rs index f5eeeeb1d..26ddc3bb7 100644 --- a/rust/crates/keygo-server/src/main.rs +++ b/rust/crates/keygo-server/src/main.rs @@ -4,6 +4,7 @@ mod api; mod cli; mod config; mod db; +mod domain; mod secrets; mod server; From 64a638f1a91a7749aba471f31b616e19625be924 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 14:00:24 +0200 Subject: [PATCH 21/43] feat(api): implement opaque registration start --- rust/Cargo.lock | 7 + ...fc9b0a09e47c7356d00bc09185ea84c0ad479.json | 23 ++++ ...894bd58d2bf0e4f61101192fece85284d40ff.json | 23 ++++ ...de07972a81fddbf2853cf3a4ab1f4fa20fc9c.json | 17 +++ ...b32986fa08c450e22784ac82c70e33539e8a0.json | 15 +++ rust/crates/keygo-server/Cargo.toml | 3 +- rust/crates/keygo-server/src/api/error.rs | 22 ++++ .../src/api/routes/auth/register.rs | 23 ++-- rust/crates/keygo-server/src/db/mod.rs | 3 + .../keygo-server/src/db/registrations.rs | 64 ++++++++++ rust/crates/keygo-server/src/db/users.rs | 21 +++ rust/crates/keygo-server/src/domain/mod.rs | 2 + .../src/domain/registration_id.rs | 84 ++++++++++++ rust/crates/keygo-server/src/main.rs | 1 + rust/crates/keygo-server/src/service/mod.rs | 1 + .../src/service/registration/mod.rs | 7 + .../src/service/registration/start.rs | 120 ++++++++++++++++++ 17 files changed, 426 insertions(+), 10 deletions(-) create mode 100644 rust/crates/keygo-server/.sqlx/query-1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479.json create mode 100644 rust/crates/keygo-server/.sqlx/query-76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff.json create mode 100644 rust/crates/keygo-server/.sqlx/query-b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c.json create mode 100644 rust/crates/keygo-server/.sqlx/query-d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0.json create mode 100644 rust/crates/keygo-server/src/db/registrations.rs create mode 100644 rust/crates/keygo-server/src/db/users.rs create mode 100644 rust/crates/keygo-server/src/domain/registration_id.rs create mode 100644 rust/crates/keygo-server/src/service/mod.rs create mode 100644 rust/crates/keygo-server/src/service/registration/mod.rs create mode 100644 rust/crates/keygo-server/src/service/registration/start.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 11b0797cc..f130e8d46 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1597,6 +1597,7 @@ dependencies = [ "anstyle", "anyhow", "axum", + "base64 0.23.1", "clap", "getrandom 0.4.3", "hex", @@ -2553,6 +2554,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", ] [[package]] @@ -2585,7 +2587,9 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx-core", + "sqlx-mysql", "sqlx-postgres", + "sqlx-sqlite", "syn 2.0.119", "thiserror", "tokio", @@ -2616,6 +2620,7 @@ dependencies = [ "sqlx-core", "thiserror", "tracing", + "uuid", ] [[package]] @@ -2650,6 +2655,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -2675,6 +2681,7 @@ dependencies = [ "thiserror", "tracing", "url", + "uuid", ] [[package]] diff --git a/rust/crates/keygo-server/.sqlx/query-1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479.json b/rust/crates/keygo-server/.sqlx/query-1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479.json new file mode 100644 index 000000000..a8e4d2adb --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479" +} diff --git a/rust/crates/keygo-server/.sqlx/query-76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff.json b/rust/crates/keygo-server/.sqlx/query-76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff.json new file mode 100644 index 000000000..e8e1871c0 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1) AS \"exists!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff" +} diff --git a/rust/crates/keygo-server/.sqlx/query-b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c.json b/rust/crates/keygo-server/.sqlx/query-b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c.json new file mode 100644 index 000000000..8833ce23c --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO pending_registrations (id, user_id, username, expires_at)\n VALUES ($1, $2, $3, now() + make_interval(secs => $4))\n ON CONFLICT (user_id) DO UPDATE\n SET id = excluded.id, username = excluded.username, expires_at = excluded.expires_at", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "Uuid", + "Text", + "Float8" + ] + }, + "nullable": [] + }, + "hash": "b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c" +} diff --git a/rust/crates/keygo-server/.sqlx/query-d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0.json b/rust/crates/keygo-server/.sqlx/query-d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0.json new file mode 100644 index 000000000..9abbd6497 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM pending_registrations\n WHERE expires_at <= now() AND (username = $1 OR user_id = $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0" +} diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 149e4a864..ac9102be9 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -11,6 +11,7 @@ anstream = "1.0.0" anstyle = "1.0.14" anyhow = "1.0.104" clap = { version = "4.6.7", features = ["derive", "env"] } +base64 = "0.23.1" getrandom = "0.4.3" hex = "0.4.3" secrecy = "0.10.3" @@ -24,7 +25,7 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tower-http = { version = "0.7.1", features = ["trace"] } uuid = { workspace = true, features = ["v4"] } -sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros"] } +sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros", "uuid"] } unicode-normalization = "0.1.25" [dev-dependencies] diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index 3fdfb8553..0a01fe5ef 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -1,4 +1,5 @@ use crate::domain::UsernameError; +use crate::service::registration; use axum::extract::rejection::JsonRejection; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; @@ -127,6 +128,27 @@ impl HttpError for OpaqueError { impl HttpError for sqlx::Error {} +impl HttpError for registration::StartError { + fn status(&self) -> StatusCode { + match self { + Self::InvalidUserId => StatusCode::BAD_REQUEST, + Self::UsernameTaken | Self::UserIdTaken => StatusCode::CONFLICT, + Self::Opaque(err) => err.status(), + Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(&self) -> &'static str { + match self { + Self::InvalidUserId => "invalid_request", + Self::UsernameTaken => "username_taken", + Self::UserIdTaken => "user_id_taken", + Self::Opaque(err) => err.code(), + Self::Db(_) => "internal", + } + } +} + impl HttpError for UsernameError { fn status(&self) -> StatusCode { StatusCode::UNPROCESSABLE_ENTITY diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index 591ccce28..12270ca18 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -1,9 +1,10 @@ use crate::api::{AppState, Json, ServerError, ServerResult}; -use crate::domain::Username; +use crate::domain::{RegistrationId, Username}; +use crate::service::registration::{self, StartRegistration}; use axum::Router; use axum::extract::State; use axum::routing::post; -use keygo_opaque::{RegistrationRequest, RegistrationResponse, RegistrationUpload, server}; +use keygo_opaque::{RegistrationRequest, RegistrationResponse, RegistrationUpload}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -28,23 +29,27 @@ struct RegisterFinishRequest { #[derive(Serialize)] struct RegisterStart { + registration_id: RegistrationId, registration_response: RegistrationResponse, + expires_in: u64, } async fn start( State(state): State, Json(payload): Json, ) -> ServerResult> { - let username = Username::parse(&payload.username)?; + let input = StartRegistration { + username: Username::parse(&payload.username)?, + user_id: payload.user_id, + request: payload.registration_request, + }; - let response = server::registration_start( - &state.opaque_state, - payload.registration_request, - payload.user_id.as_bytes(), - )?; + let started = registration::start(&state.db, &state.opaque_state, input).await?; Ok(Json(RegisterStart { - registration_response: response, + registration_id: started.registration_id, + registration_response: started.response, + expires_in: started.expires_in.as_secs(), })) } diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs index ea794c49e..66a27313d 100644 --- a/rust/crates/keygo-server/src/db/mod.rs +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -1,3 +1,6 @@ +pub mod registrations; +pub mod users; + use crate::config::Config; use secrecy::ExposeSecret; use sqlx::PgPool; diff --git a/rust/crates/keygo-server/src/db/registrations.rs b/rust/crates/keygo-server/src/db/registrations.rs new file mode 100644 index 000000000..e97b1a0e1 --- /dev/null +++ b/rust/crates/keygo-server/src/db/registrations.rs @@ -0,0 +1,64 @@ +use crate::domain::{RegistrationId, Username}; +use sqlx::PgConnection; +use std::time::Duration; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum ReserveError { + #[error("user_id is already in use")] + UserIdTaken, + #[error("username is already in use")] + UsernameTaken, + #[error("database error: {0}")] + Db(#[source] sqlx::Error), +} + +impl From for ReserveError { + fn from(err: sqlx::Error) -> Self { + let constraint = err + .as_database_error() + .filter(|db| db.is_unique_violation()) + .and_then(|db| db.constraint()); + + match constraint { + Some("pending_registrations_username_key") => Self::UsernameTaken, + Some("pending_registrations_user_id_key") => Self::UserIdTaken, + _ => Self::Db(err), + } + } +} + +pub async fn reserve( + conn: &mut PgConnection, + registration_id: &RegistrationId, + user_id: Uuid, + username: &Username, + ttl: Duration, +) -> Result<(), ReserveError> { + sqlx::query!( + "DELETE FROM pending_registrations + WHERE expires_at <= now() AND (username = $1 OR user_id = $2)", + username.as_str(), + user_id, + ) + .execute(&mut *conn) + .await?; + + // A concurrent reserve that wins between the sweep and here surfaces as a unique violation on + // username, or as a takeover on user_id, so the two statements need no surrounding transaction. + sqlx::query!( + "INSERT INTO pending_registrations (id, user_id, username, expires_at) + VALUES ($1, $2, $3, now() + make_interval(secs => $4)) + ON CONFLICT (user_id) DO UPDATE + SET id = excluded.id, username = excluded.username, expires_at = excluded.expires_at", + registration_id.as_bytes().as_slice(), + user_id, + username.as_str(), + ttl.as_secs_f64(), + ) + .execute(conn) + .await?; + + Ok(()) +} diff --git a/rust/crates/keygo-server/src/db/users.rs b/rust/crates/keygo-server/src/db/users.rs new file mode 100644 index 000000000..837f8a96a --- /dev/null +++ b/rust/crates/keygo-server/src/db/users.rs @@ -0,0 +1,21 @@ +use crate::domain::Username; +use sqlx::PgConnection; +use uuid::Uuid; + +pub async fn username_exists(conn: &mut PgConnection, username: &Username) -> sqlx::Result { + sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1) AS "exists!""#, + username.as_str(), + ) + .fetch_one(conn) + .await +} + +pub async fn id_exists(conn: &mut PgConnection, user_id: Uuid) -> sqlx::Result { + sqlx::query_scalar!( + r#"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1) AS "exists!""#, + user_id, + ) + .fetch_one(conn) + .await +} diff --git a/rust/crates/keygo-server/src/domain/mod.rs b/rust/crates/keygo-server/src/domain/mod.rs index a13c02668..3b8e4a564 100644 --- a/rust/crates/keygo-server/src/domain/mod.rs +++ b/rust/crates/keygo-server/src/domain/mod.rs @@ -1,3 +1,5 @@ +mod registration_id; mod username; +pub use registration_id::RegistrationId; pub use username::{Username, UsernameError}; diff --git a/rust/crates/keygo-server/src/domain/registration_id.rs b/rust/crates/keygo-server/src/domain/registration_id.rs new file mode 100644 index 000000000..0f61669e9 --- /dev/null +++ b/rust/crates/keygo-server/src/domain/registration_id.rs @@ -0,0 +1,84 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use thiserror::Error; + +const LEN: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("registration_id must be {LEN} bytes of base64url without padding")] +pub struct InvalidRegistrationId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct RegistrationId([u8; LEN]); + +impl RegistrationId { + pub fn random() -> Self { + let mut bytes = [0u8; LEN]; + getrandom::fill(&mut bytes).expect("OS random number generator unavailable"); + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; LEN] { + &self.0 + } + + pub fn to_base64(&self) -> String { + URL_SAFE_NO_PAD.encode(self.0) + } + + pub fn from_base64(text: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(text) + .map_err(|_| InvalidRegistrationId)?; + bytes + .try_into() + .map(Self) + .map_err(|_| InvalidRegistrationId) + } +} + +impl Serialize for RegistrationId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_base64()) + } +} + +impl<'de> Deserialize<'de> for RegistrationId { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + Self::from_base64(&text).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_ids_differ() { + assert_ne!(RegistrationId::random(), RegistrationId::random()); + } + + #[test] + fn base64_round_trips() { + let id = RegistrationId::random(); + + assert_eq!(RegistrationId::from_base64(&id.to_base64()), Ok(id)); + } + + #[test] + fn serializes_as_unpadded_base64url() { + let id = RegistrationId([0xff; LEN]); + + assert_eq!(serde_json::to_value(id).unwrap(), "_____________________w"); + } + + #[test] + fn rejects_wrong_length_and_alphabet() { + assert!(RegistrationId::from_base64(&URL_SAFE_NO_PAD.encode([0; 15])).is_err()); + assert!(RegistrationId::from_base64(&URL_SAFE_NO_PAD.encode([0; 17])).is_err()); + assert!(RegistrationId::from_base64("+++++++++++++++++++++w").is_err()); + assert!(RegistrationId::from_base64("_____________________w==").is_err()); + } +} diff --git a/rust/crates/keygo-server/src/main.rs b/rust/crates/keygo-server/src/main.rs index 26ddc3bb7..bc05c02c6 100644 --- a/rust/crates/keygo-server/src/main.rs +++ b/rust/crates/keygo-server/src/main.rs @@ -7,6 +7,7 @@ mod db; mod domain; mod secrets; mod server; +mod service; fn main() -> anyhow::Result<()> { Cli::start() diff --git a/rust/crates/keygo-server/src/service/mod.rs b/rust/crates/keygo-server/src/service/mod.rs new file mode 100644 index 000000000..038a1c755 --- /dev/null +++ b/rust/crates/keygo-server/src/service/mod.rs @@ -0,0 +1 @@ +pub mod registration; diff --git a/rust/crates/keygo-server/src/service/registration/mod.rs b/rust/crates/keygo-server/src/service/registration/mod.rs new file mode 100644 index 000000000..a5101206d --- /dev/null +++ b/rust/crates/keygo-server/src/service/registration/mod.rs @@ -0,0 +1,7 @@ +mod start; + +pub use start::*; + +use std::time::Duration; + +pub const TTL: Duration = Duration::from_mins(5); diff --git a/rust/crates/keygo-server/src/service/registration/start.rs b/rust/crates/keygo-server/src/service/registration/start.rs new file mode 100644 index 000000000..1d551c3fa --- /dev/null +++ b/rust/crates/keygo-server/src/service/registration/start.rs @@ -0,0 +1,120 @@ +use crate::db::registrations::ReserveError; +use crate::db::{registrations, users}; +use crate::domain::{RegistrationId, Username}; +use crate::service::registration::TTL; +use keygo_opaque::server::ServerSetup; +use keygo_opaque::{OpaqueError, RegistrationRequest, RegistrationResponse, server}; +use sqlx::PgPool; +use std::time::Duration; +use thiserror::Error; +use uuid::{Uuid, Variant}; + +#[derive(Debug, Error)] +pub enum StartError { + #[error("user_id must be a random (version 4) UUID")] + InvalidUserId, + #[error("username is already taken")] + UsernameTaken, + #[error("user_id is already registered")] + UserIdTaken, + #[error(transparent)] + Opaque(#[from] OpaqueError), + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +impl From for StartError { + fn from(err: ReserveError) -> Self { + match err { + ReserveError::UsernameTaken => Self::UsernameTaken, + ReserveError::UserIdTaken => Self::UserIdTaken, + ReserveError::Db(err) => Self::Db(err), + } + } +} + +pub struct StartRegistration { + pub username: Username, + pub user_id: Uuid, + // TODO: add `invite: Option` once invites exist. + pub request: RegistrationRequest, +} + +pub struct Started { + pub registration_id: RegistrationId, + pub response: RegistrationResponse, + pub expires_in: Duration, +} + +pub async fn start( + db: &PgPool, + setup: &ServerSetup, + input: StartRegistration, +) -> Result { + let StartRegistration { + username, + user_id, + request, + } = input; + + if !is_random_uuid(user_id) { + return Err(StartError::InvalidUserId); + } + + // TODO: check the invite here once invites exist, so only invite holders can probe usernames. + + let mut tx = db.begin().await?; + let registration_id = RegistrationId::random(); + registrations::reserve(&mut tx, ®istration_id, user_id, &username, TTL).await?; + + // Checked after reserving on purpose. A finish that consumed the previous reservation for + // this name or id still holds its row (or index entry), so reserve waits for it to commit, + // and these reads, each with a fresh snapshot, then see the user it created. + if users::username_exists(&mut tx, &username).await? { + return Err(StartError::UsernameTaken); + } + + if users::id_exists(&mut tx, user_id).await? { + return Err(StartError::UserIdTaken); + } + + let response = server::registration_start(setup, request, user_id.as_bytes())?; + tx.commit().await?; + + Ok(Started { + registration_id, + response, + expires_in: TTL, + }) +} + +fn is_random_uuid(id: Uuid) -> bool { + // The nil UUID has version 0, so the version check already rejects it. + id.get_version_num() == 4 && id.get_variant() == Variant::RFC4122 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_random_uuids() { + assert!(is_random_uuid(Uuid::new_v4())); + assert!(is_random_uuid( + "7f0c1e52-3a9b-4c11-8d0e-2b5a6c9d4e10".parse().unwrap() + )); + } + + #[test] + fn rejects_structured_uuids() { + assert!(!is_random_uuid(Uuid::nil())); + assert!(!is_random_uuid(Uuid::max())); + // Version 1 (time-based) and version 4 bits with the Microsoft variant. + assert!(!is_random_uuid( + "7f0c1e52-3a9b-1c11-8d0e-2b5a6c9d4e10".parse().unwrap() + )); + assert!(!is_random_uuid( + "7f0c1e52-3a9b-4c11-cd0e-2b5a6c9d4e10".parse().unwrap() + )); + } +} From ff9e33ea9e241683457f6874db6ef161ff519d2b Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 16:21:23 +0200 Subject: [PATCH 22/43] feat(api): implement registration finish --- rust/crates/keygo-opaque/src/lib.rs | 2 +- rust/crates/keygo-opaque/src/suite.rs | 4 ++ .../migrations/20261014141017_init.sql | 8 +++ rust/crates/keygo-server/src/api/error.rs | 16 ++++++ .../src/api/routes/auth/register.rs | 23 ++++++-- rust/crates/keygo-server/src/db/mod.rs | 1 + .../keygo-server/src/db/opaque_records.rs | 22 ++++++++ .../keygo-server/src/db/registrations.rs | 37 +++++++++++++ rust/crates/keygo-server/src/db/users.rs | 15 ++++++ .../keygo-server/src/domain/username.rs | 4 +- .../src/service/registration/finish.rs | 54 +++++++++++++++++++ .../src/service/registration/mod.rs | 2 + 12 files changed, 182 insertions(+), 6 deletions(-) create mode 100644 rust/crates/keygo-server/src/db/opaque_records.rs create mode 100644 rust/crates/keygo-server/src/service/registration/finish.rs diff --git a/rust/crates/keygo-opaque/src/lib.rs b/rust/crates/keygo-opaque/src/lib.rs index dd36b0712..becfe37cf 100644 --- a/rust/crates/keygo-opaque/src/lib.rs +++ b/rust/crates/keygo-opaque/src/lib.rs @@ -17,4 +17,4 @@ pub use messages::{ CredentialFinalization, CredentialRequest, CredentialResponse, PasswordFile, RegistrationRequest, RegistrationResponse, RegistrationUpload, }; -pub use suite::{KSF_ITERS, KSF_LANES, KSF_MEM_KIB, LOGIN_CONTEXT}; +pub use suite::{KSF_ITERS, KSF_LANES, KSF_MEM_KIB, LOGIN_CONTEXT, SUITE_ID}; diff --git a/rust/crates/keygo-opaque/src/suite.rs b/rust/crates/keygo-opaque/src/suite.rs index 039a4329d..9032248d7 100644 --- a/rust/crates/keygo-opaque/src/suite.rs +++ b/rust/crates/keygo-opaque/src/suite.rs @@ -11,6 +11,10 @@ pub const KSF_LANES: u32 = 4; /// Binds logins to KeyGo so they can't be replayed against other OPAQUE deployments. pub const LOGIN_CONTEXT: &[u8] = b"KeyGo-OPAQUE-v1"; +/// Names `KeyGoSuite` and the KSF parameters above, stored next to every password file. Bump it +/// whenever either changes, so records made under the old suite can be told apart and upgraded. +pub const SUITE_ID: &str = "keygo-opaque-v1"; + pub(crate) struct KeyGoSuite; impl CipherSuite for KeyGoSuite { diff --git a/rust/crates/keygo-server/migrations/20261014141017_init.sql b/rust/crates/keygo-server/migrations/20261014141017_init.sql index d28790467..2a7b6d3af 100644 --- a/rust/crates/keygo-server/migrations/20261014141017_init.sql +++ b/rust/crates/keygo-server/migrations/20261014141017_init.sql @@ -5,6 +5,14 @@ CREATE TABLE users created_at timestamptz NOT NULL DEFAULT now() ); +create table opaque_records +( + user_id uuid primary key references users on delete cascade, + suite text not null, + record bytea not null, + updated_at timestamptz not null +); + CREATE UNLOGGED TABLE pending_registrations ( id bytea PRIMARY KEY, -- registration_id, 16 random bytes diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index 0a01fe5ef..51fb36d88 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -149,6 +149,22 @@ impl HttpError for registration::StartError { } } +impl HttpError for registration::FinishError { + fn status(&self) -> StatusCode { + match self { + Self::Expired => StatusCode::BAD_REQUEST, + Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(&self) -> &'static str { + match self { + Self::Expired => "registration_expired", + Self::Db(_) => "internal", + } + } +} + impl HttpError for UsernameError { fn status(&self) -> StatusCode { StatusCode::UNPROCESSABLE_ENTITY diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index 12270ca18..a4e48310d 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -1,6 +1,6 @@ -use crate::api::{AppState, Json, ServerError, ServerResult}; +use crate::api::{AppState, Json, ServerResult}; use crate::domain::{RegistrationId, Username}; -use crate::service::registration::{self, StartRegistration}; +use crate::service::registration::{self, FinishRegistration, StartRegistration}; use axum::Router; use axum::extract::State; use axum::routing::post; @@ -24,6 +24,7 @@ struct RegisterStartRequest { #[derive(Deserialize)] struct RegisterFinishRequest { + registration_id: RegistrationId, registration_upload: RegistrationUpload, } @@ -34,6 +35,11 @@ struct RegisterStart { expires_in: u64, } +#[derive(Serialize)] +struct RegisterFinish { + pub username: Username, +} + async fn start( State(state): State, Json(payload): Json, @@ -56,6 +62,15 @@ async fn start( async fn finish( State(state): State, Json(payload): Json, -) -> ServerResult<()> { - Err(ServerError::not_implemented()) +) -> ServerResult> { + let input = FinishRegistration { + registration_id: payload.registration_id, + registration_upload: payload.registration_upload, + }; + + let finished = registration::finish(&state.db, input).await?; + + Ok(Json(RegisterFinish { + username: finished.username, + })) } diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs index 66a27313d..ee1222d84 100644 --- a/rust/crates/keygo-server/src/db/mod.rs +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -1,3 +1,4 @@ +pub mod opaque_records; pub mod registrations; pub mod users; diff --git a/rust/crates/keygo-server/src/db/opaque_records.rs b/rust/crates/keygo-server/src/db/opaque_records.rs new file mode 100644 index 000000000..dc6a36342 --- /dev/null +++ b/rust/crates/keygo-server/src/db/opaque_records.rs @@ -0,0 +1,22 @@ +use keygo_opaque::{PasswordFile, SUITE_ID}; +use sqlx::PgConnection; +use uuid::Uuid; + +pub async fn insert( + conn: &mut PgConnection, + user_id: Uuid, + file: &PasswordFile, +) -> sqlx::Result<()> { + let record = file.to_bytes(); + sqlx::query!( + "INSERT INTO opaque_records (user_id, suite, record, updated_at) + VALUES ($1, $2, $3, now())", + user_id, + SUITE_ID, + record.as_slice(), + ) + .execute(conn) + .await?; + + Ok(()) +} diff --git a/rust/crates/keygo-server/src/db/registrations.rs b/rust/crates/keygo-server/src/db/registrations.rs index e97b1a0e1..409f3101d 100644 --- a/rust/crates/keygo-server/src/db/registrations.rs +++ b/rust/crates/keygo-server/src/db/registrations.rs @@ -62,3 +62,40 @@ pub async fn reserve( Ok(()) } + +#[derive(Debug, Error)] +pub enum ConsumeError { + #[error("registration request expired")] + Expired, + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +pub struct Pending { + pub user_id: Uuid, + pub username: Username, +} + +pub async fn consume( + conn: &mut PgConnection, + registration_id: &RegistrationId, +) -> Result { + let row = sqlx::query!( + "DELETE FROM pending_registrations + WHERE id = $1 AND expires_at > now() + RETURNING user_id, username", + registration_id.as_bytes().as_slice(), + ) + .fetch_optional(conn) + .await? + .ok_or(ConsumeError::Expired)?; + + // `reserve` only stores parsed names, so this fails only on a corrupted row. + let username = + Username::parse(&row.username).map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + + Ok(Pending { + user_id: row.user_id, + username, + }) +} diff --git a/rust/crates/keygo-server/src/db/users.rs b/rust/crates/keygo-server/src/db/users.rs index 837f8a96a..31f68ab00 100644 --- a/rust/crates/keygo-server/src/db/users.rs +++ b/rust/crates/keygo-server/src/db/users.rs @@ -19,3 +19,18 @@ pub async fn id_exists(conn: &mut PgConnection, user_id: Uuid) -> sqlx::Result sqlx::Result<()> { + sqlx::query!( + "INSERT INTO users (id, username) VALUES ($1, $2)", + user_id, + username.as_str() + ) + .execute(conn) + .await?; + Ok(()) +} diff --git a/rust/crates/keygo-server/src/domain/username.rs b/rust/crates/keygo-server/src/domain/username.rs index 457eb04c1..266e4571b 100644 --- a/rust/crates/keygo-server/src/domain/username.rs +++ b/rust/crates/keygo-server/src/domain/username.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use std::fmt; use thiserror::Error; use unicode_normalization::UnicodeNormalization; @@ -25,7 +26,8 @@ pub enum UsernameError { OnlyDigits, } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String")] pub struct Username(String); impl Username { diff --git a/rust/crates/keygo-server/src/service/registration/finish.rs b/rust/crates/keygo-server/src/service/registration/finish.rs new file mode 100644 index 000000000..4cb3fdd11 --- /dev/null +++ b/rust/crates/keygo-server/src/service/registration/finish.rs @@ -0,0 +1,54 @@ +use crate::db::registrations::{self, ConsumeError}; +use crate::db::{opaque_records, users}; +use crate::domain::{RegistrationId, Username}; +use keygo_opaque::{RegistrationUpload, server}; +use sqlx::PgPool; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum FinishError { + #[error("registration expired or unknown")] + Expired, + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +impl From for FinishError { + fn from(err: ConsumeError) -> Self { + match err { + ConsumeError::Expired => Self::Expired, + ConsumeError::Db(err) => Self::Db(err), + } + } +} + +pub struct FinishRegistration { + pub registration_id: RegistrationId, + pub registration_upload: RegistrationUpload, + // TODO: key bundle + device info +} + +pub struct Finished { + pub username: Username, + // TODO: session +} + +pub async fn finish(db: &PgPool, input: FinishRegistration) -> Result { + let FinishRegistration { + registration_id, + registration_upload, + } = input; + + // Pure computation, so it runs before the transaction to keep the reservation's lock short. + let password_file = server::registration_finish(registration_upload); + + let mut tx = db.begin().await?; + let pending = registrations::consume(&mut tx, ®istration_id).await?; + users::insert(&mut tx, pending.user_id, &pending.username).await?; + opaque_records::insert(&mut tx, pending.user_id, &password_file).await?; + tx.commit().await?; + + Ok(Finished { + username: pending.username, + }) +} diff --git a/rust/crates/keygo-server/src/service/registration/mod.rs b/rust/crates/keygo-server/src/service/registration/mod.rs index a5101206d..2a287ff8a 100644 --- a/rust/crates/keygo-server/src/service/registration/mod.rs +++ b/rust/crates/keygo-server/src/service/registration/mod.rs @@ -1,5 +1,7 @@ +mod finish; mod start; +pub use finish::*; pub use start::*; use std::time::Duration; From 06bfc03e5a884291c034d6f424f15af7c48316df Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 18:05:16 +0200 Subject: [PATCH 23/43] feat(api): implement opaque login start --- ...8a1b90c12382a950eba28fae0b46dc7af76a4.json | 52 +++++++++ ...260945e6a2eec1334071970e95bdeb4cf60d2.json | 15 +++ ...fda559f58415c5ebf3b46b5a7e0dab7f6f720.json | 18 +++ ...b3490ec0792f4f5352b8fb06f0d78074e0c1f.json | 40 +++++++ ...a4348d29a8e9aacb43ceb19f2a9dd45fa6636.json | 16 +++ .../migrations/20261014141017_init.sql | 11 +- rust/crates/keygo-server/src/api/error.rs | 18 ++- .../keygo-server/src/api/routes/auth/login.rs | 49 +++++++++ .../keygo-server/src/api/routes/auth/mod.rs | 5 +- .../keygo-server/src/db/login_states.rs | 42 +++++++ rust/crates/keygo-server/src/db/mod.rs | 1 + .../keygo-server/src/db/opaque_records.rs | 39 +++++++ rust/crates/keygo-server/src/domain/mod.rs | 4 +- .../keygo-server/src/domain/random_id.rs | 103 ++++++++++++++++++ .../src/domain/registration_id.rs | 84 -------------- .../keygo-server/src/service/login/mod.rs | 7 ++ .../keygo-server/src/service/login/start.rs | 91 ++++++++++++++++ rust/crates/keygo-server/src/service/mod.rs | 1 + 18 files changed, 507 insertions(+), 89 deletions(-) create mode 100644 rust/crates/keygo-server/.sqlx/query-549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4.json create mode 100644 rust/crates/keygo-server/.sqlx/query-8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2.json create mode 100644 rust/crates/keygo-server/.sqlx/query-af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720.json create mode 100644 rust/crates/keygo-server/.sqlx/query-d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f.json create mode 100644 rust/crates/keygo-server/.sqlx/query-ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636.json create mode 100644 rust/crates/keygo-server/src/api/routes/auth/login.rs create mode 100644 rust/crates/keygo-server/src/db/login_states.rs create mode 100644 rust/crates/keygo-server/src/domain/random_id.rs delete mode 100644 rust/crates/keygo-server/src/domain/registration_id.rs create mode 100644 rust/crates/keygo-server/src/service/login/mod.rs create mode 100644 rust/crates/keygo-server/src/service/login/start.rs diff --git a/rust/crates/keygo-server/.sqlx/query-549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4.json b/rust/crates/keygo-server/.sqlx/query-549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4.json new file mode 100644 index 000000000..12d0c730b --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT r.user_id, r.suite, r.record\n FROM opaque_records r JOIN users u ON u.id = r.user_id\n WHERE u.username = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "opaque_records", + "name": "user_id" + } + } + }, + { + "ordinal": 1, + "name": "suite", + "type_info": "Text", + "origin": { + "Table": { + "table": "opaque_records", + "name": "suite" + } + } + }, + { + "ordinal": 2, + "name": "record", + "type_info": "Bytea", + "origin": { + "Table": { + "table": "opaque_records", + "name": "record" + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4" +} diff --git a/rust/crates/keygo-server/.sqlx/query-8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2.json b/rust/crates/keygo-server/.sqlx/query-8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2.json new file mode 100644 index 000000000..0c6271143 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (id, username) VALUES ($1, $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2" +} diff --git a/rust/crates/keygo-server/.sqlx/query-af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720.json b/rust/crates/keygo-server/.sqlx/query-af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720.json new file mode 100644 index 000000000..4e1fd9e20 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO login_states (id, user_id, purpose, state, expires_at)\n VALUES ($1, $2, $3, $4, now() + make_interval(secs => $5))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "Uuid", + "Text", + "Bytea", + "Float8" + ] + }, + "nullable": [] + }, + "hash": "af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720" +} diff --git a/rust/crates/keygo-server/.sqlx/query-d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f.json b/rust/crates/keygo-server/.sqlx/query-d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f.json new file mode 100644 index 000000000..f0c31c8e1 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM pending_registrations\n WHERE id = $1 AND expires_at > now()\n RETURNING user_id, username", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "pending_registrations", + "name": "user_id" + } + } + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text", + "origin": { + "Table": { + "table": "pending_registrations", + "name": "username" + } + } + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f" +} diff --git a/rust/crates/keygo-server/.sqlx/query-ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636.json b/rust/crates/keygo-server/.sqlx/query-ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636.json new file mode 100644 index 000000000..6d782d77b --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO opaque_records (user_id, suite, record, updated_at)\n VALUES ($1, $2, $3, now())", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636" +} diff --git a/rust/crates/keygo-server/migrations/20261014141017_init.sql b/rust/crates/keygo-server/migrations/20261014141017_init.sql index 2a7b6d3af..8d2e1a550 100644 --- a/rust/crates/keygo-server/migrations/20261014141017_init.sql +++ b/rust/crates/keygo-server/migrations/20261014141017_init.sql @@ -20,4 +20,13 @@ CREATE UNLOGGED TABLE pending_registrations username text NOT NULL UNIQUE, -- the reservation -- invite_id text REFERENCES invites, expires_at timestamptz NOT NULL -); \ No newline at end of file +); + +CREATE UNLOGGED TABLE login_states +( + id bytea PRIMARY KEY, -- login_id, 16 random bytes + user_id uuid, -- null on the fake path + purpose text NOT NULL CHECK (purpose IN ('login', 'step_up')), + state bytea NOT NULL, -- serialized ServerLogin + expires_at timestamptz NOT NULL +); diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index 51fb36d88..e293519af 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -1,5 +1,5 @@ use crate::domain::UsernameError; -use crate::service::registration; +use crate::service::{login, registration}; use axum::extract::rejection::JsonRejection; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; @@ -165,6 +165,22 @@ impl HttpError for registration::FinishError { } } +impl HttpError for login::StartError { + fn status(&self) -> StatusCode { + match self { + Self::Opaque(err) => err.status(), + Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(&self) -> &'static str { + match self { + Self::Opaque(err) => err.code(), + Self::Db(_) => "internal", + } + } +} + impl HttpError for UsernameError { fn status(&self) -> StatusCode { StatusCode::UNPROCESSABLE_ENTITY diff --git a/rust/crates/keygo-server/src/api/routes/auth/login.rs b/rust/crates/keygo-server/src/api/routes/auth/login.rs new file mode 100644 index 000000000..3f2cf4ea4 --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/auth/login.rs @@ -0,0 +1,49 @@ +use crate::api::{AppState, Json, ServerError, ServerResult}; +use crate::domain::{LoginId, Username}; +use crate::service::login::{self, StartLogin}; +use axum::Router; +use axum::extract::State; +use axum::routing::post; +use keygo_opaque::{CredentialRequest, CredentialResponse}; +use serde::{Deserialize, Serialize}; + +pub fn router() -> Router { + Router::new() + .route("/start", post(start)) + .route("/finish", post(finish)) +} + +#[derive(Deserialize)] +struct LoginStartRequest { + username: String, + credential_request: CredentialRequest, +} + +#[derive(Serialize)] +struct LoginStart { + login_id: LoginId, + credential_response: CredentialResponse, + expires_in: u64, +} + +async fn start( + State(state): State, + Json(payload): Json, +) -> ServerResult> { + let input = StartLogin { + username: Username::parse(&payload.username)?, + request: payload.credential_request, + }; + + let started = login::start(&state.db, &state.opaque_state, input).await?; + + Ok(Json(LoginStart { + login_id: started.login_id, + credential_response: started.response, + expires_in: started.expires_in.as_secs(), + })) +} + +async fn finish() -> ServerResult<()> { + Err(ServerError::not_implemented()) +} diff --git a/rust/crates/keygo-server/src/api/routes/auth/mod.rs b/rust/crates/keygo-server/src/api/routes/auth/mod.rs index 4f7b42947..d258c11df 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/mod.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/mod.rs @@ -1,8 +1,11 @@ use crate::api::AppState; use axum::Router; +mod login; mod register; pub fn router() -> Router { - Router::new().nest("/register", register::router()) + Router::new() + .nest("/register", register::router()) + .nest("/login", login::router()) } diff --git a/rust/crates/keygo-server/src/db/login_states.rs b/rust/crates/keygo-server/src/db/login_states.rs new file mode 100644 index 000000000..320f40afc --- /dev/null +++ b/rust/crates/keygo-server/src/db/login_states.rs @@ -0,0 +1,42 @@ +use crate::domain::LoginId; +use keygo_opaque::server::ServerLogin; +use sqlx::PgConnection; +use std::time::Duration; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Purpose { + Login, +} + +impl Purpose { + const fn as_str(self) -> &'static str { + match self { + Self::Login => "login", + } + } +} + +pub async fn insert( + conn: &mut PgConnection, + login_id: &LoginId, + user_id: Option, + purpose: Purpose, + state: &ServerLogin, + ttl: Duration, +) -> sqlx::Result<()> { + let state = state.to_bytes(); + sqlx::query!( + "INSERT INTO login_states (id, user_id, purpose, state, expires_at) + VALUES ($1, $2, $3, $4, now() + make_interval(secs => $5))", + login_id.as_bytes().as_slice(), + user_id, + purpose.as_str(), + state.as_slice(), + ttl.as_secs_f64(), + ) + .execute(conn) + .await?; + + Ok(()) +} diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs index ee1222d84..4388ab99b 100644 --- a/rust/crates/keygo-server/src/db/mod.rs +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -1,3 +1,4 @@ +pub mod login_states; pub mod opaque_records; pub mod registrations; pub mod users; diff --git a/rust/crates/keygo-server/src/db/opaque_records.rs b/rust/crates/keygo-server/src/db/opaque_records.rs index dc6a36342..24bea7bf6 100644 --- a/rust/crates/keygo-server/src/db/opaque_records.rs +++ b/rust/crates/keygo-server/src/db/opaque_records.rs @@ -1,6 +1,8 @@ +use crate::domain::Username; use keygo_opaque::{PasswordFile, SUITE_ID}; use sqlx::PgConnection; use uuid::Uuid; +use zeroize::Zeroizing; pub async fn insert( conn: &mut PgConnection, @@ -20,3 +22,40 @@ pub async fn insert( Ok(()) } + +pub struct Record { + pub user_id: Uuid, + pub file: PasswordFile, +} + +pub async fn find_by_username( + conn: &mut PgConnection, + username: &Username, +) -> sqlx::Result> { + let Some(row) = sqlx::query!( + "SELECT r.user_id, r.suite, r.record + FROM opaque_records r JOIN users u ON u.id = r.user_id + WHERE u.username = $1", + username.as_str(), + ) + .fetch_optional(conn) + .await? + else { + return Ok(None); + }; + + if row.suite != SUITE_ID { + return Err(sqlx::Error::Decode( + format!("opaque record uses unknown suite {:?}", row.suite).into(), + )); + } + + let record = Zeroizing::new(row.record); + let file = + PasswordFile::from_bytes(&record).map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + + Ok(Some(Record { + user_id: row.user_id, + file, + })) +} diff --git a/rust/crates/keygo-server/src/domain/mod.rs b/rust/crates/keygo-server/src/domain/mod.rs index 3b8e4a564..8ef2a1ac8 100644 --- a/rust/crates/keygo-server/src/domain/mod.rs +++ b/rust/crates/keygo-server/src/domain/mod.rs @@ -1,5 +1,5 @@ -mod registration_id; +mod random_id; mod username; -pub use registration_id::RegistrationId; +pub use random_id::{LoginId, RegistrationId}; pub use username::{Username, UsernameError}; diff --git a/rust/crates/keygo-server/src/domain/random_id.rs b/rust/crates/keygo-server/src/domain/random_id.rs new file mode 100644 index 000000000..55cd63fe5 --- /dev/null +++ b/rust/crates/keygo-server/src/domain/random_id.rs @@ -0,0 +1,103 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use thiserror::Error; + +const LEN: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +#[error("{field} must be {LEN} bytes of base64url without padding")] +pub struct InvalidId { + field: &'static str, +} + +/// Declares an opaque handle of 16 random bytes that travels as base64url without padding. +macro_rules! random_id { + ($($(#[$attr:meta])* $name:ident as $field:literal;)+) => {$( + $(#[$attr])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct $name([u8; LEN]); + + impl $name { + pub fn random() -> Self { + let mut bytes = [0u8; LEN]; + getrandom::fill(&mut bytes).expect("OS random number generator unavailable"); + Self(bytes) + } + + pub fn as_bytes(&self) -> &[u8; LEN] { + &self.0 + } + + pub fn to_base64(self) -> String { + URL_SAFE_NO_PAD.encode(self.0) + } + + pub fn from_base64(text: &str) -> Result { + const INVALID: InvalidId = InvalidId { field: $field }; + let bytes = URL_SAFE_NO_PAD.decode(text).map_err(|_| INVALID)?; + bytes.try_into().map(Self).map_err(|_| INVALID) + } + } + + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_base64()) + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + let text = String::deserialize(deserializer)?; + Self::from_base64(&text).map_err(serde::de::Error::custom) + } + } + )+}; +} + +random_id! { + /// Handle for a pending registration, between `register/start` and `register/finish`. + RegistrationId as "registration_id"; + + /// Handle for a login handshake, between `login/start` and `login/finish`. + LoginId as "login_id"; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_ids_differ() { + assert_ne!(RegistrationId::random(), RegistrationId::random()); + } + + #[test] + fn base64_round_trips() { + let id = RegistrationId::random(); + + assert_eq!(RegistrationId::from_base64(&id.to_base64()), Ok(id)); + } + + #[test] + fn serializes_as_unpadded_base64url() { + let id = RegistrationId([0xff; LEN]); + + assert_eq!(serde_json::to_value(id).unwrap(), "_____________________w"); + } + + #[test] + fn rejects_wrong_length_and_alphabet() { + assert!(RegistrationId::from_base64(&URL_SAFE_NO_PAD.encode([0; 15])).is_err()); + assert!(RegistrationId::from_base64(&URL_SAFE_NO_PAD.encode([0; 17])).is_err()); + assert!(RegistrationId::from_base64("+++++++++++++++++++++w").is_err()); + assert!(RegistrationId::from_base64("_____________________w==").is_err()); + } + + #[test] + fn error_names_the_field() { + let err = LoginId::from_base64("").unwrap_err(); + + assert!(err.to_string().starts_with("login_id ")); + } +} diff --git a/rust/crates/keygo-server/src/domain/registration_id.rs b/rust/crates/keygo-server/src/domain/registration_id.rs deleted file mode 100644 index 0f61669e9..000000000 --- a/rust/crates/keygo-server/src/domain/registration_id.rs +++ /dev/null @@ -1,84 +0,0 @@ -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use thiserror::Error; - -const LEN: usize = 16; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] -#[error("registration_id must be {LEN} bytes of base64url without padding")] -pub struct InvalidRegistrationId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct RegistrationId([u8; LEN]); - -impl RegistrationId { - pub fn random() -> Self { - let mut bytes = [0u8; LEN]; - getrandom::fill(&mut bytes).expect("OS random number generator unavailable"); - Self(bytes) - } - - pub fn as_bytes(&self) -> &[u8; LEN] { - &self.0 - } - - pub fn to_base64(&self) -> String { - URL_SAFE_NO_PAD.encode(self.0) - } - - pub fn from_base64(text: &str) -> Result { - let bytes = URL_SAFE_NO_PAD - .decode(text) - .map_err(|_| InvalidRegistrationId)?; - bytes - .try_into() - .map(Self) - .map_err(|_| InvalidRegistrationId) - } -} - -impl Serialize for RegistrationId { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.to_base64()) - } -} - -impl<'de> Deserialize<'de> for RegistrationId { - fn deserialize>(deserializer: D) -> Result { - let text = String::deserialize(deserializer)?; - Self::from_base64(&text).map_err(serde::de::Error::custom) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn random_ids_differ() { - assert_ne!(RegistrationId::random(), RegistrationId::random()); - } - - #[test] - fn base64_round_trips() { - let id = RegistrationId::random(); - - assert_eq!(RegistrationId::from_base64(&id.to_base64()), Ok(id)); - } - - #[test] - fn serializes_as_unpadded_base64url() { - let id = RegistrationId([0xff; LEN]); - - assert_eq!(serde_json::to_value(id).unwrap(), "_____________________w"); - } - - #[test] - fn rejects_wrong_length_and_alphabet() { - assert!(RegistrationId::from_base64(&URL_SAFE_NO_PAD.encode([0; 15])).is_err()); - assert!(RegistrationId::from_base64(&URL_SAFE_NO_PAD.encode([0; 17])).is_err()); - assert!(RegistrationId::from_base64("+++++++++++++++++++++w").is_err()); - assert!(RegistrationId::from_base64("_____________________w==").is_err()); - } -} diff --git a/rust/crates/keygo-server/src/service/login/mod.rs b/rust/crates/keygo-server/src/service/login/mod.rs new file mode 100644 index 000000000..ebf90e1f7 --- /dev/null +++ b/rust/crates/keygo-server/src/service/login/mod.rs @@ -0,0 +1,7 @@ +mod start; + +pub use start::*; + +use std::time::Duration; + +pub const TTL: Duration = Duration::from_secs(60); diff --git a/rust/crates/keygo-server/src/service/login/start.rs b/rust/crates/keygo-server/src/service/login/start.rs new file mode 100644 index 000000000..bd0c6b98a --- /dev/null +++ b/rust/crates/keygo-server/src/service/login/start.rs @@ -0,0 +1,91 @@ +use crate::db::login_states::{self, Purpose}; +use crate::db::opaque_records; +use crate::domain::{LoginId, Username}; +use crate::service::login::TTL; +use keygo_opaque::server::{ServerLogin, ServerSetup}; +use keygo_opaque::{CredentialRequest, CredentialResponse, OpaqueError}; +use sqlx::PgPool; +use std::time::Duration; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum StartError { + #[error(transparent)] + Opaque(#[from] OpaqueError), + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +pub struct StartLogin { + pub username: Username, + pub request: CredentialRequest, +} + +pub struct Started { + pub login_id: LoginId, + pub response: CredentialResponse, + pub expires_in: Duration, +} + +/// Answers known and unknown usernames alike: an unknown one runs OPAQUE against a fake record +/// whose identifier is stable per name, so the client fails at `finish` exactly as for a wrong +/// password and the response never says whether the account exists. The identifier needs no +/// secret of its own: it only feeds the OPRF key derivation, which is keyed by the secret seed in +/// `ServerSetup`. +pub async fn start( + db: &PgPool, + setup: &ServerSetup, + input: StartLogin, +) -> Result { + let StartLogin { username, request } = input; + // TODO: rate-limit starts per account + + let mut conn = db.acquire().await?; + let record = opaque_records::find_by_username(&mut conn, &username).await?; + + let (user_id, password_file, identifier) = match record { + Some(record) => ( + Some(record.user_id), + Some(record.file), + record.user_id.as_bytes().to_vec(), + ), + None => (None, None, fake_identifier(&username)), + }; + + let (state, response) = ServerLogin::start(setup, password_file, request, &identifier)?; + + let login_id = LoginId::random(); + login_states::insert(&mut conn, &login_id, user_id, Purpose::Login, &state, TTL).await?; + + Ok(Started { + login_id, + response, + expires_in: TTL, + }) +} + +/// The domain tag keeps a fake identifier from ever encoding like a real one, a 16-byte user id. +/// Usernames never contain NUL, so the separator keeps tag and name unambiguous. +fn fake_identifier(username: &Username) -> Vec { + [b"v1:fake/login\0".as_slice(), username.as_str().as_bytes()].concat() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn name(raw: &str) -> Username { + Username::parse(raw).unwrap() + } + + #[test] + fn fake_identifier_is_stable_per_username() { + assert_eq!(fake_identifier(&name("ada")), fake_identifier(&name("ADA"))); + assert_ne!(fake_identifier(&name("ada")), fake_identifier(&name("bob"))); + } + + #[test] + fn fake_identifier_is_pinned() { + assert_eq!(fake_identifier(&name("ada")), b"v1:fake/login\0ada"); + } +} diff --git a/rust/crates/keygo-server/src/service/mod.rs b/rust/crates/keygo-server/src/service/mod.rs index 038a1c755..9f50879b0 100644 --- a/rust/crates/keygo-server/src/service/mod.rs +++ b/rust/crates/keygo-server/src/service/mod.rs @@ -1 +1,2 @@ +pub mod login; pub mod registration; From e15077aa1fff0e4e12a760930215c0915d4d2f10 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 18:05:16 +0200 Subject: [PATCH 24/43] fix(api): align error codes with spec --- rust/crates/keygo-server/src/api/error.rs | 35 ++++++++++++++++++----- rust/crates/keygo-server/src/api/mod.rs | 6 ++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index e293519af..9d6f0c186 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -94,11 +94,16 @@ impl IntoResponse for ServerError { impl HttpError for JsonRejection { fn status(&self) -> StatusCode { - JsonRejection::status(self) + match self { + // Malformed or mistyped JSON is a bad request like any other (§15); a missing content + // type or an oversized body keeps its own HTTP status. + Self::JsonDataError(_) | Self::JsonSyntaxError(_) => StatusCode::BAD_REQUEST, + _ => JsonRejection::status(self), + } } fn code(&self) -> &'static str { - "invalid_body" + "invalid_request" } } @@ -106,7 +111,7 @@ impl HttpError for OpaqueError { fn status(&self) -> StatusCode { match self { Self::InvalidLogin => StatusCode::UNAUTHORIZED, - Self::Malformed => StatusCode::UNPROCESSABLE_ENTITY, + Self::Malformed => StatusCode::BAD_REQUEST, // `ReflectedValue` and `EmptyPassword` are only raised by the client half of the // protocol; seeing one here means server code called a client function. Self::ReflectedValue | Self::EmptyPassword | Self::Protocol(_) => { @@ -119,8 +124,8 @@ impl HttpError for OpaqueError { match self { // One code for wrong password and unknown account, so a username's existence never // leaks. - Self::InvalidLogin => "invalid_login", - Self::Malformed => "malformed_opaque", + Self::InvalidLogin => "invalid_credentials", + Self::Malformed => "invalid_request", Self::ReflectedValue | Self::EmptyPassword | Self::Protocol(_) => "internal", } } @@ -183,7 +188,7 @@ impl HttpError for login::StartError { impl HttpError for UsernameError { fn status(&self) -> StatusCode { - StatusCode::UNPROCESSABLE_ENTITY + StatusCode::BAD_REQUEST } fn code(&self) -> &'static str { @@ -212,12 +217,28 @@ mod tests { assert_eq!( body, json!({ - "code": "invalid_login", + "code": "invalid_credentials", "message": OpaqueError::InvalidLogin.to_string(), }), ); } + #[tokio::test] + async fn malformed_opaque_messages_are_invalid_requests() { + let (status, body) = render(OpaqueError::Malformed).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_request"); + } + + #[tokio::test] + async fn invalid_usernames_are_bad_requests() { + let (status, body) = render(UsernameError::TooShort).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_username"); + } + #[tokio::test] async fn server_errors_hide_their_message() { let (status, body) = render(OpaqueError::Protocol("secret detail".into())).await; diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index 072b854fa..b0cc15a6c 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -109,9 +109,9 @@ mod tests { ) .await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(status, StatusCode::BAD_REQUEST); let body: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(body["code"], "invalid_body"); + assert_eq!(body["code"], "invalid_request"); assert!(body["message"].as_str().is_some_and(|m| !m.is_empty())); } @@ -125,6 +125,6 @@ mod tests { assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); let body = response.into_body().collect().await.unwrap().to_bytes(); let body: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(body["code"], "invalid_body"); + assert_eq!(body["code"], "invalid_request"); } } From 49e5f71592d5ba52348273684719f6005b510efb Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 19:57:39 +0200 Subject: [PATCH 25/43] feat(api): implement login finish --- ...d3b17c37c8ef5d69e68670e7cccf69aafafce.json | 41 +++++++++++++ ...a5b8470628ddbaa454af4f49f6fe6953f9014.json | 28 +++++++++ rust/crates/keygo-server/src/api/error.rs | 34 +++++++++++ .../keygo-server/src/api/routes/auth/login.rs | 36 +++++++++-- .../keygo-server/src/db/login_states.rs | 44 +++++++++++++ rust/crates/keygo-server/src/db/users.rs | 17 ++++++ .../keygo-server/src/service/login/finish.rs | 61 +++++++++++++++++++ .../keygo-server/src/service/login/mod.rs | 2 + 8 files changed, 258 insertions(+), 5 deletions(-) create mode 100644 rust/crates/keygo-server/.sqlx/query-20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce.json create mode 100644 rust/crates/keygo-server/.sqlx/query-de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014.json create mode 100644 rust/crates/keygo-server/src/service/login/finish.rs diff --git a/rust/crates/keygo-server/.sqlx/query-20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce.json b/rust/crates/keygo-server/.sqlx/query-20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce.json new file mode 100644 index 000000000..841fe6412 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM login_states\n WHERE id = $1 AND purpose = $2 AND expires_at > now()\n RETURNING user_id, state", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "login_states", + "name": "user_id" + } + } + }, + { + "ordinal": 1, + "name": "state", + "type_info": "Bytea", + "origin": { + "Table": { + "table": "login_states", + "name": "state" + } + } + } + ], + "parameters": { + "Left": [ + "Bytea", + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce" +} diff --git a/rust/crates/keygo-server/.sqlx/query-de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014.json b/rust/crates/keygo-server/.sqlx/query-de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014.json new file mode 100644 index 000000000..1f8fbecac --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT username FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Text", + "origin": { + "Table": { + "table": "users", + "name": "username" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014" +} diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index 9d6f0c186..1af8e465e 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -186,6 +186,24 @@ impl HttpError for login::StartError { } } +impl HttpError for login::FinishError { + fn status(&self) -> StatusCode { + match self { + Self::Expired => StatusCode::BAD_REQUEST, + Self::Opaque(err) => err.status(), + Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn code(&self) -> &'static str { + match self { + Self::Expired => "login_expired", + Self::Opaque(err) => err.code(), + Self::Db(_) => "internal", + } + } +} + impl HttpError for UsernameError { fn status(&self) -> StatusCode { StatusCode::BAD_REQUEST @@ -231,6 +249,22 @@ mod tests { assert_eq!(body["code"], "invalid_request"); } + #[tokio::test] + async fn expired_logins_are_bad_requests() { + let (status, body) = render(login::FinishError::Expired).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "login_expired"); + } + + #[tokio::test] + async fn failed_login_finish_matches_failed_start() { + let (status, body) = render(login::FinishError::Opaque(OpaqueError::InvalidLogin)).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "invalid_credentials"); + } + #[tokio::test] async fn invalid_usernames_are_bad_requests() { let (status, body) = render(UsernameError::TooShort).await; diff --git a/rust/crates/keygo-server/src/api/routes/auth/login.rs b/rust/crates/keygo-server/src/api/routes/auth/login.rs index 3f2cf4ea4..ea041f4c2 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/login.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/login.rs @@ -1,11 +1,12 @@ -use crate::api::{AppState, Json, ServerError, ServerResult}; +use crate::api::{AppState, Json, ServerResult}; use crate::domain::{LoginId, Username}; -use crate::service::login::{self, StartLogin}; +use crate::service::login::{self, FinishLogin, StartLogin}; use axum::Router; use axum::extract::State; use axum::routing::post; -use keygo_opaque::{CredentialRequest, CredentialResponse}; +use keygo_opaque::{CredentialFinalization, CredentialRequest, CredentialResponse}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; pub fn router() -> Router { Router::new() @@ -19,6 +20,12 @@ struct LoginStartRequest { credential_request: CredentialRequest, } +#[derive(Deserialize)] +struct LoginFinishRequest { + login_id: LoginId, + credential_finalization: CredentialFinalization, +} + #[derive(Serialize)] struct LoginStart { login_id: LoginId, @@ -26,6 +33,12 @@ struct LoginStart { expires_in: u64, } +#[derive(Serialize)] +struct LoginFinish { + user_id: Uuid, + username: Username, +} + async fn start( State(state): State, Json(payload): Json, @@ -44,6 +57,19 @@ async fn start( })) } -async fn finish() -> ServerResult<()> { - Err(ServerError::not_implemented()) +async fn finish( + State(state): State, + Json(payload): Json, +) -> ServerResult> { + let input = FinishLogin { + login_id: payload.login_id, + credential_finalization: payload.credential_finalization, + }; + + let finished = login::finish(&state.db, input).await?; + + Ok(Json(LoginFinish { + user_id: finished.user_id, + username: finished.username, + })) } diff --git a/rust/crates/keygo-server/src/db/login_states.rs b/rust/crates/keygo-server/src/db/login_states.rs index 320f40afc..f542b98aa 100644 --- a/rust/crates/keygo-server/src/db/login_states.rs +++ b/rust/crates/keygo-server/src/db/login_states.rs @@ -2,7 +2,9 @@ use crate::domain::LoginId; use keygo_opaque::server::ServerLogin; use sqlx::PgConnection; use std::time::Duration; +use thiserror::Error; use uuid::Uuid; +use zeroize::Zeroizing; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Purpose { @@ -40,3 +42,45 @@ pub async fn insert( Ok(()) } + +#[derive(Debug, Error)] +pub enum TakeError { + #[error("login expired or unknown")] + Expired, + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +pub struct Taken { + /// `None` on the fake path for an unknown username. + pub user_id: Option, + pub state: ServerLogin, +} + +/// Deletes the state as it reads it, so a login can be finished at most once. +pub async fn take( + conn: &mut PgConnection, + login_id: &LoginId, + purpose: Purpose, +) -> Result { + let row = sqlx::query!( + "DELETE FROM login_states + WHERE id = $1 AND purpose = $2 AND expires_at > now() + RETURNING user_id, state", + login_id.as_bytes().as_slice(), + purpose.as_str(), + ) + .fetch_optional(conn) + .await? + .ok_or(TakeError::Expired)?; + + let state = Zeroizing::new(row.state); + // `insert` only stores serialized states, so this fails only on a corrupted row. + let state = + ServerLogin::from_bytes(&state).map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + + Ok(Taken { + user_id: row.user_id, + state, + }) +} diff --git a/rust/crates/keygo-server/src/db/users.rs b/rust/crates/keygo-server/src/db/users.rs index 31f68ab00..6c7d3d553 100644 --- a/rust/crates/keygo-server/src/db/users.rs +++ b/rust/crates/keygo-server/src/db/users.rs @@ -34,3 +34,20 @@ pub async fn insert( .await?; Ok(()) } + +pub async fn find_username( + conn: &mut PgConnection, + user_id: Uuid, +) -> sqlx::Result> { + let Some(username) = sqlx::query_scalar!("SELECT username FROM users WHERE id = $1", user_id) + .fetch_optional(conn) + .await? + else { + return Ok(None); + }; + + // `insert` only stores parsed names, so this fails only on a corrupted row. + Username::parse(&username) + .map(Some) + .map_err(|err| sqlx::Error::Decode(Box::new(err))) +} diff --git a/rust/crates/keygo-server/src/service/login/finish.rs b/rust/crates/keygo-server/src/service/login/finish.rs new file mode 100644 index 000000000..e944e4a2a --- /dev/null +++ b/rust/crates/keygo-server/src/service/login/finish.rs @@ -0,0 +1,61 @@ +use crate::db::login_states::{self, Purpose, TakeError}; +use crate::db::users; +use crate::domain::{LoginId, Username}; +use keygo_opaque::{CredentialFinalization, OpaqueError}; +use sqlx::PgPool; +use thiserror::Error; +use uuid::Uuid; + +#[derive(Debug, Error)] +pub enum FinishError { + #[error("login expired or unknown")] + Expired, + #[error(transparent)] + Opaque(#[from] OpaqueError), + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +impl From for FinishError { + fn from(err: TakeError) -> Self { + match err { + TakeError::Expired => Self::Expired, + TakeError::Db(err) => Self::Db(err), + } + } +} + +pub struct FinishLogin { + pub login_id: LoginId, + pub credential_finalization: CredentialFinalization, +} + +pub struct Finished { + pub user_id: Uuid, + pub username: Username, + // TODO: session +} + +/// The state is consumed before verifying, so a failed finish cannot be retried with the same +/// `login_id`: every password guess costs a fresh `start`. +pub async fn finish(db: &PgPool, input: FinishLogin) -> Result { + let FinishLogin { + login_id, + credential_finalization, + } = input; + + let mut conn = db.acquire().await?; + let taken = login_states::take(&mut conn, &login_id, Purpose::Login).await?; + + // TODO: derive the session from the session key once sessions exist. + let _session_key = taken.state.finish(credential_finalization)?; + + // The fake path cannot produce a valid finalization, and an account deleted since `start` has + // no user left to log into; both answer exactly like a wrong password. + let user_id = taken.user_id.ok_or(OpaqueError::InvalidLogin)?; + let username = users::find_username(&mut conn, user_id) + .await? + .ok_or(OpaqueError::InvalidLogin)?; + + Ok(Finished { user_id, username }) +} diff --git a/rust/crates/keygo-server/src/service/login/mod.rs b/rust/crates/keygo-server/src/service/login/mod.rs index ebf90e1f7..387280360 100644 --- a/rust/crates/keygo-server/src/service/login/mod.rs +++ b/rust/crates/keygo-server/src/service/login/mod.rs @@ -1,5 +1,7 @@ +mod finish; mod start; +pub use finish::*; pub use start::*; use std::time::Duration; From 61f28746653dad6082fc28860c9be8a3b107de02 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 20:47:48 +0200 Subject: [PATCH 26/43] fix(api): typo --- rust/crates/keygo-server/src/api/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index b0cc15a6c..ec379c962 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -3,7 +3,7 @@ mod extract; mod routes; mod state; -pub use error::{ServerError, ServerResult}; +pub use error::{HttpError, ServerError, ServerResult}; pub use extract::Json; pub use state::AppState; @@ -14,7 +14,7 @@ pub fn router(state: AppState) -> Router { Router::new() .nest("/health", routes::health::router()) .nest("/api/v1", v1()) - .fallback(not_fount) + .fallback(not_found) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -25,7 +25,7 @@ fn v1() -> Router { .nest("/auth", routes::auth::router()) } -async fn not_fount() -> ServerError { +async fn not_found() -> ServerError { ServerError::not_found() } From 48cab5290d35e4fa159a2060e20061914ca5d31b Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 20:48:11 +0200 Subject: [PATCH 27/43] refactor(api): change from sec to min --- rust/crates/keygo-server/src/service/login/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/keygo-server/src/service/login/mod.rs b/rust/crates/keygo-server/src/service/login/mod.rs index 387280360..2ff91e503 100644 --- a/rust/crates/keygo-server/src/service/login/mod.rs +++ b/rust/crates/keygo-server/src/service/login/mod.rs @@ -6,4 +6,4 @@ pub use start::*; use std::time::Duration; -pub const TTL: Duration = Duration::from_secs(60); +pub const TTL: Duration = Duration::from_mins(1); From 951555200b74a9e03fa7bce9c0f17adc4520b78b Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 20:51:48 +0200 Subject: [PATCH 28/43] refactor(api): clean up API structure --- rust/crates/keygo-server/src/api/error.rs | 212 +++++------------- rust/crates/keygo-server/src/api/mod.rs | 2 +- .../keygo-server/src/api/routes/auth/login.rs | 92 ++++++-- .../src/api/routes/auth/register.rs | 93 ++++++-- .../keygo-server/src/db/login_states.rs | 31 +-- rust/crates/keygo-server/src/db/mod.rs | 6 + .../keygo-server/src/db/opaque_records.rs | 11 +- .../keygo-server/src/db/registrations.rs | 29 +-- rust/crates/keygo-server/src/db/users.rs | 9 +- .../keygo-server/src/service/login/finish.rs | 19 +- .../keygo-server/src/service/login/start.rs | 15 +- .../src/service/registration/finish.rs | 16 +- .../src/service/registration/start.rs | 17 +- 13 files changed, 270 insertions(+), 282 deletions(-) diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index 1af8e465e..c16d727e0 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -1,24 +1,37 @@ use crate::domain::UsernameError; -use crate::service::{login, registration}; use axum::extract::rejection::JsonRejection; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use keygo_opaque::OpaqueError; +use serde::Serialize; use serde_json::json; -pub trait HttpError: std::error::Error + Send + Sync + 'static { - fn status(&self) -> StatusCode { - StatusCode::INTERNAL_SERVER_ERROR - } +const INTERNAL: (StatusCode, ErrorCode) = (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Internal); + +/// Every `code` the API answers with; clients match on the serialized name. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorCode { + // general + Internal, + InvalidRequest, + NotFound, + // auth + InvalidUsername, + InvalidCredentials, + UsernameTaken, + UserIdTaken, + RegistrationExpired, + LoginExpired, +} - fn code(&self) -> &'static str { - "internal" - } +pub trait HttpError: std::error::Error + Send + Sync + 'static { + fn status_and_code(&self) -> (StatusCode, ErrorCode); } pub struct ServerError { status: StatusCode, - code: &'static str, + code: ErrorCode, message: String, source: Option, } @@ -26,7 +39,7 @@ pub struct ServerError { pub type ServerResult = Result; impl ServerError { - pub fn new(status: StatusCode, code: &'static str, message: impl Into) -> Self { + pub fn new(status: StatusCode, code: ErrorCode, message: impl Into) -> Self { Self { status, code, @@ -36,21 +49,17 @@ impl ServerError { } pub fn not_found() -> Self { - Self::new(StatusCode::NOT_FOUND, "not_found", "resource not found") - } - - pub fn not_implemented() -> Self { Self::new( - StatusCode::NOT_IMPLEMENTED, - "not_implemented", - "not implemented", + StatusCode::NOT_FOUND, + ErrorCode::NotFound, + "resource not found", ) } fn internal(source: anyhow::Error) -> Self { Self { status: StatusCode::INTERNAL_SERVER_ERROR, - code: "internal", + code: ErrorCode::Internal, message: "internal server error".to_owned(), source: Some(source), } @@ -59,16 +68,16 @@ impl ServerError { impl From for ServerError { fn from(err: E) -> Self { - let status = err.status(); + let (status, code) = err.status_and_code(); if status.is_server_error() { return Self { status, - code: err.code(), + code, ..Self::internal(err.into()) }; } - Self::new(status, err.code(), err.to_string()) + Self::new(status, code, err.to_string()) } } @@ -81,7 +90,7 @@ impl From for ServerError { impl IntoResponse for ServerError { fn into_response(self) -> Response { if let Some(source) = &self.source { - tracing::error!(code = self.code, "request failed: {source:#}"); + tracing::error!(code = ?self.code, "request failed: {source:#}"); } ( @@ -92,140 +101,59 @@ impl IntoResponse for ServerError { } } +impl HttpError for sqlx::Error { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + INTERNAL + } +} + impl HttpError for JsonRejection { - fn status(&self) -> StatusCode { - match self { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + let status = match self { // Malformed or mistyped JSON is a bad request like any other (§15); a missing content // type or an oversized body keeps its own HTTP status. Self::JsonDataError(_) | Self::JsonSyntaxError(_) => StatusCode::BAD_REQUEST, - _ => JsonRejection::status(self), - } + _ => self.status(), + }; + (status, ErrorCode::InvalidRequest) } +} - fn code(&self) -> &'static str { - "invalid_request" +impl HttpError for UsernameError { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + (StatusCode::BAD_REQUEST, ErrorCode::InvalidUsername) } } impl HttpError for OpaqueError { - fn status(&self) -> StatusCode { - match self { - Self::InvalidLogin => StatusCode::UNAUTHORIZED, - Self::Malformed => StatusCode::BAD_REQUEST, - // `ReflectedValue` and `EmptyPassword` are only raised by the client half of the - // protocol; seeing one here means server code called a client function. - Self::ReflectedValue | Self::EmptyPassword | Self::Protocol(_) => { - StatusCode::INTERNAL_SERVER_ERROR - } - } - } - - fn code(&self) -> &'static str { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { match self { // One code for wrong password and unknown account, so a username's existence never // leaks. - Self::InvalidLogin => "invalid_credentials", - Self::Malformed => "invalid_request", - Self::ReflectedValue | Self::EmptyPassword | Self::Protocol(_) => "internal", - } - } -} - -impl HttpError for sqlx::Error {} - -impl HttpError for registration::StartError { - fn status(&self) -> StatusCode { - match self { - Self::InvalidUserId => StatusCode::BAD_REQUEST, - Self::UsernameTaken | Self::UserIdTaken => StatusCode::CONFLICT, - Self::Opaque(err) => err.status(), - Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - fn code(&self) -> &'static str { - match self { - Self::InvalidUserId => "invalid_request", - Self::UsernameTaken => "username_taken", - Self::UserIdTaken => "user_id_taken", - Self::Opaque(err) => err.code(), - Self::Db(_) => "internal", - } - } -} - -impl HttpError for registration::FinishError { - fn status(&self) -> StatusCode { - match self { - Self::Expired => StatusCode::BAD_REQUEST, - Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - fn code(&self) -> &'static str { - match self { - Self::Expired => "registration_expired", - Self::Db(_) => "internal", - } - } -} - -impl HttpError for login::StartError { - fn status(&self) -> StatusCode { - match self { - Self::Opaque(err) => err.status(), - Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - fn code(&self) -> &'static str { - match self { - Self::Opaque(err) => err.code(), - Self::Db(_) => "internal", - } - } -} - -impl HttpError for login::FinishError { - fn status(&self) -> StatusCode { - match self { - Self::Expired => StatusCode::BAD_REQUEST, - Self::Opaque(err) => err.status(), - Self::Db(_) => StatusCode::INTERNAL_SERVER_ERROR, - } - } - - fn code(&self) -> &'static str { - match self { - Self::Expired => "login_expired", - Self::Opaque(err) => err.code(), - Self::Db(_) => "internal", + Self::InvalidLogin => (StatusCode::UNAUTHORIZED, ErrorCode::InvalidCredentials), + Self::Malformed => (StatusCode::BAD_REQUEST, ErrorCode::InvalidRequest), + // `ReflectedValue` and `EmptyPassword` are only raised by the client half of the + // protocol; seeing one here means server code called a client function. + Self::ReflectedValue | Self::EmptyPassword | Self::Protocol(_) => INTERNAL, } } } -impl HttpError for UsernameError { - fn status(&self) -> StatusCode { - StatusCode::BAD_REQUEST - } +/// Renders an error the way a handler would answer with it. +#[cfg(test)] +pub async fn render(err: impl Into) -> (StatusCode, serde_json::Value) { + use http_body_util::BodyExt; - fn code(&self) -> &'static str { - "invalid_username" - } + let response = err.into().into_response(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&body).unwrap()) } #[cfg(test)] mod tests { use super::*; - use http_body_util::BodyExt; - use serde_json::{Value, json}; - - async fn render(err: impl Into) -> (StatusCode, Value) { - let response = err.into().into_response(); - let status = response.status(); - let body = response.into_body().collect().await.unwrap().to_bytes(); - (status, serde_json::from_slice(&body).unwrap()) - } + use serde_json::json; #[tokio::test] async fn client_errors_expose_their_message() { @@ -249,22 +177,6 @@ mod tests { assert_eq!(body["code"], "invalid_request"); } - #[tokio::test] - async fn expired_logins_are_bad_requests() { - let (status, body) = render(login::FinishError::Expired).await; - - assert_eq!(status, StatusCode::BAD_REQUEST); - assert_eq!(body["code"], "login_expired"); - } - - #[tokio::test] - async fn failed_login_finish_matches_failed_start() { - let (status, body) = render(login::FinishError::Opaque(OpaqueError::InvalidLogin)).await; - - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body["code"], "invalid_credentials"); - } - #[tokio::test] async fn invalid_usernames_are_bad_requests() { let (status, body) = render(UsernameError::TooShort).await; @@ -294,7 +206,7 @@ mod tests { } #[tokio::test] - async fn default_impl_is_internal() { + async fn database_errors_are_internal() { let (status, body) = render(sqlx::Error::RowNotFound).await; assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index ec379c962..fa290a8fa 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -3,7 +3,7 @@ mod extract; mod routes; mod state; -pub use error::{HttpError, ServerError, ServerResult}; +pub use error::{ErrorCode, HttpError, ServerError, ServerResult}; pub use extract::Json; pub use state::AppState; diff --git a/rust/crates/keygo-server/src/api/routes/auth/login.rs b/rust/crates/keygo-server/src/api/routes/auth/login.rs index ea041f4c2..24bbb2775 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/login.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/login.rs @@ -1,8 +1,9 @@ -use crate::api::{AppState, Json, ServerResult}; +use crate::api::{AppState, ErrorCode, HttpError, Json, ServerResult}; use crate::domain::{LoginId, Username}; use crate::service::login::{self, FinishLogin, StartLogin}; use axum::Router; use axum::extract::State; +use axum::http::StatusCode; use axum::routing::post; use keygo_opaque::{CredentialFinalization, CredentialRequest, CredentialResponse}; use serde::{Deserialize, Serialize}; @@ -15,61 +16,104 @@ pub fn router() -> Router { } #[derive(Deserialize)] -struct LoginStartRequest { +struct StartRequest { + /// Parsed in the handler so a bad name answers `invalid_username`, not `invalid_request`. username: String, credential_request: CredentialRequest, } -#[derive(Deserialize)] -struct LoginFinishRequest { - login_id: LoginId, - credential_finalization: CredentialFinalization, -} - #[derive(Serialize)] -struct LoginStart { +struct StartResponse { login_id: LoginId, credential_response: CredentialResponse, expires_in: u64, } -#[derive(Serialize)] -struct LoginFinish { - user_id: Uuid, - username: Username, +impl HttpError for login::StartError { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + match self { + Self::Opaque(err) => err.status_and_code(), + Self::Db(err) => err.status_and_code(), + } + } } async fn start( State(state): State, - Json(payload): Json, -) -> ServerResult> { + Json(request): Json, +) -> ServerResult> { let input = StartLogin { - username: Username::parse(&payload.username)?, - request: payload.credential_request, + username: Username::parse(&request.username)?, + credential_request: request.credential_request, }; let started = login::start(&state.db, &state.opaque_state, input).await?; - Ok(Json(LoginStart { + Ok(Json(StartResponse { login_id: started.login_id, - credential_response: started.response, + credential_response: started.credential_response, expires_in: started.expires_in.as_secs(), })) } +#[derive(Deserialize)] +struct FinishRequest { + login_id: LoginId, + credential_finalization: CredentialFinalization, +} + +#[derive(Serialize)] +struct FinishResponse { + user_id: Uuid, + username: Username, +} + +impl HttpError for login::FinishError { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + match self { + Self::Expired => (StatusCode::BAD_REQUEST, ErrorCode::LoginExpired), + Self::Opaque(err) => err.status_and_code(), + Self::Db(err) => err.status_and_code(), + } + } +} + async fn finish( State(state): State, - Json(payload): Json, -) -> ServerResult> { + Json(request): Json, +) -> ServerResult> { let input = FinishLogin { - login_id: payload.login_id, - credential_finalization: payload.credential_finalization, + login_id: request.login_id, + credential_finalization: request.credential_finalization, }; let finished = login::finish(&state.db, input).await?; - Ok(Json(LoginFinish { + Ok(Json(FinishResponse { user_id: finished.user_id, username: finished.username, })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::error::render; + use keygo_opaque::OpaqueError; + + #[tokio::test] + async fn expired_logins_are_bad_requests() { + let (status, body) = render(login::FinishError::Expired).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "login_expired"); + } + + #[tokio::test] + async fn failed_login_finish_matches_failed_start() { + let (status, body) = render(login::FinishError::Opaque(OpaqueError::InvalidLogin)).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "invalid_credentials"); + } +} diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index a4e48310d..ff445520a 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -1,8 +1,9 @@ -use crate::api::{AppState, Json, ServerResult}; +use crate::api::{AppState, ErrorCode, HttpError, Json, ServerResult}; use crate::domain::{RegistrationId, Username}; use crate::service::registration::{self, FinishRegistration, StartRegistration}; use axum::Router; use axum::extract::State; +use axum::http::StatusCode; use axum::routing::post; use keygo_opaque::{RegistrationRequest, RegistrationResponse, RegistrationUpload}; use serde::{Deserialize, Serialize}; @@ -15,62 +16,106 @@ pub fn router() -> Router { } #[derive(Deserialize)] -struct RegisterStartRequest { +struct StartRequest { + /// Parsed in the handler so a bad name answers `invalid_username`, not `invalid_request`. username: String, user_id: Uuid, invite: Option, registration_request: RegistrationRequest, } -#[derive(Deserialize)] -struct RegisterFinishRequest { - registration_id: RegistrationId, - registration_upload: RegistrationUpload, -} - #[derive(Serialize)] -struct RegisterStart { +struct StartResponse { registration_id: RegistrationId, registration_response: RegistrationResponse, expires_in: u64, } -#[derive(Serialize)] -struct RegisterFinish { - pub username: Username, +impl HttpError for registration::StartError { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + match self { + Self::InvalidUserId => (StatusCode::BAD_REQUEST, ErrorCode::InvalidRequest), + Self::UsernameTaken => (StatusCode::CONFLICT, ErrorCode::UsernameTaken), + Self::UserIdTaken => (StatusCode::CONFLICT, ErrorCode::UserIdTaken), + Self::Opaque(err) => err.status_and_code(), + Self::Db(err) => err.status_and_code(), + } + } } async fn start( State(state): State, - Json(payload): Json, -) -> ServerResult> { + Json(request): Json, +) -> ServerResult> { let input = StartRegistration { - username: Username::parse(&payload.username)?, - user_id: payload.user_id, - request: payload.registration_request, + username: Username::parse(&request.username)?, + user_id: request.user_id, + registration_request: request.registration_request, }; let started = registration::start(&state.db, &state.opaque_state, input).await?; - Ok(Json(RegisterStart { + Ok(Json(StartResponse { registration_id: started.registration_id, - registration_response: started.response, + registration_response: started.registration_response, expires_in: started.expires_in.as_secs(), })) } +#[derive(Deserialize)] +struct FinishRequest { + registration_id: RegistrationId, + registration_upload: RegistrationUpload, +} + +#[derive(Serialize)] +struct FinishResponse { + username: Username, +} + +impl HttpError for registration::FinishError { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + match self { + Self::Expired => (StatusCode::BAD_REQUEST, ErrorCode::RegistrationExpired), + Self::Db(err) => err.status_and_code(), + } + } +} + async fn finish( State(state): State, - Json(payload): Json, -) -> ServerResult> { + Json(request): Json, +) -> ServerResult> { let input = FinishRegistration { - registration_id: payload.registration_id, - registration_upload: payload.registration_upload, + registration_id: request.registration_id, + registration_upload: request.registration_upload, }; let finished = registration::finish(&state.db, input).await?; - Ok(Json(RegisterFinish { + Ok(Json(FinishResponse { username: finished.username, })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::error::render; + + #[tokio::test] + async fn expired_registrations_are_bad_requests() { + let (status, body) = render(registration::FinishError::Expired).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "registration_expired"); + } + + #[tokio::test] + async fn taken_usernames_conflict() { + let (status, body) = render(registration::StartError::UsernameTaken).await; + + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], "username_taken"); + } +} diff --git a/rust/crates/keygo-server/src/db/login_states.rs b/rust/crates/keygo-server/src/db/login_states.rs index f542b98aa..5dd671be8 100644 --- a/rust/crates/keygo-server/src/db/login_states.rs +++ b/rust/crates/keygo-server/src/db/login_states.rs @@ -1,8 +1,8 @@ +use super::corrupt; use crate::domain::LoginId; use keygo_opaque::server::ServerLogin; use sqlx::PgConnection; use std::time::Duration; -use thiserror::Error; use uuid::Uuid; use zeroize::Zeroizing; @@ -43,27 +43,20 @@ pub async fn insert( Ok(()) } -#[derive(Debug, Error)] -pub enum TakeError { - #[error("login expired or unknown")] - Expired, - #[error("database error: {0}")] - Db(#[from] sqlx::Error), -} - -pub struct Taken { +pub struct Pending { /// `None` on the fake path for an unknown username. pub user_id: Option, pub state: ServerLogin, } -/// Deletes the state as it reads it, so a login can be finished at most once. +/// Deletes the state as it reads it, so a login can be finished at most once. Returns `None` for +/// an unknown or expired login. pub async fn take( conn: &mut PgConnection, login_id: &LoginId, purpose: Purpose, -) -> Result { - let row = sqlx::query!( +) -> sqlx::Result> { + let Some(row) = sqlx::query!( "DELETE FROM login_states WHERE id = $1 AND purpose = $2 AND expires_at > now() RETURNING user_id, state", @@ -72,15 +65,15 @@ pub async fn take( ) .fetch_optional(conn) .await? - .ok_or(TakeError::Expired)?; + else { + return Ok(None); + }; let state = Zeroizing::new(row.state); - // `insert` only stores serialized states, so this fails only on a corrupted row. - let state = - ServerLogin::from_bytes(&state).map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + let state = ServerLogin::from_bytes(&state).map_err(corrupt)?; - Ok(Taken { + Ok(Some(Pending { user_id: row.user_id, state, - }) + })) } diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs index 4388ab99b..aeab498f1 100644 --- a/rust/crates/keygo-server/src/db/mod.rs +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -40,3 +40,9 @@ pub async fn migrate(pool: &PgPool) -> anyhow::Result<()> { sqlx::migrate!().run(pool).await?; Ok(()) } + +/// Reports a stored value that no longer decodes. Every writer stores only valid values, so this +/// means a corrupted row. +fn corrupt(err: impl Into) -> sqlx::Error { + sqlx::Error::Decode(err.into()) +} diff --git a/rust/crates/keygo-server/src/db/opaque_records.rs b/rust/crates/keygo-server/src/db/opaque_records.rs index 24bea7bf6..a920d63d3 100644 --- a/rust/crates/keygo-server/src/db/opaque_records.rs +++ b/rust/crates/keygo-server/src/db/opaque_records.rs @@ -1,3 +1,4 @@ +use super::corrupt; use crate::domain::Username; use keygo_opaque::{PasswordFile, SUITE_ID}; use sqlx::PgConnection; @@ -45,14 +46,14 @@ pub async fn find_by_username( }; if row.suite != SUITE_ID { - return Err(sqlx::Error::Decode( - format!("opaque record uses unknown suite {:?}", row.suite).into(), - )); + return Err(corrupt(format!( + "opaque record uses unknown suite {:?}", + row.suite + ))); } let record = Zeroizing::new(row.record); - let file = - PasswordFile::from_bytes(&record).map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + let file = PasswordFile::from_bytes(&record).map_err(corrupt)?; Ok(Some(Record { user_id: row.user_id, diff --git a/rust/crates/keygo-server/src/db/registrations.rs b/rust/crates/keygo-server/src/db/registrations.rs index 409f3101d..480e842b3 100644 --- a/rust/crates/keygo-server/src/db/registrations.rs +++ b/rust/crates/keygo-server/src/db/registrations.rs @@ -1,3 +1,4 @@ +use super::corrupt; use crate::domain::{RegistrationId, Username}; use sqlx::PgConnection; use std::time::Duration; @@ -63,24 +64,18 @@ pub async fn reserve( Ok(()) } -#[derive(Debug, Error)] -pub enum ConsumeError { - #[error("registration request expired")] - Expired, - #[error("database error: {0}")] - Db(#[from] sqlx::Error), -} - pub struct Pending { pub user_id: Uuid, pub username: Username, } -pub async fn consume( +/// Deletes the reservation as it reads it, so a registration can be finished at most once. Returns +/// `None` for an unknown or expired registration. +pub async fn take( conn: &mut PgConnection, registration_id: &RegistrationId, -) -> Result { - let row = sqlx::query!( +) -> sqlx::Result> { + let Some(row) = sqlx::query!( "DELETE FROM pending_registrations WHERE id = $1 AND expires_at > now() RETURNING user_id, username", @@ -88,14 +83,14 @@ pub async fn consume( ) .fetch_optional(conn) .await? - .ok_or(ConsumeError::Expired)?; + else { + return Ok(None); + }; - // `reserve` only stores parsed names, so this fails only on a corrupted row. - let username = - Username::parse(&row.username).map_err(|err| sqlx::Error::Decode(Box::new(err)))?; + let username = Username::parse(&row.username).map_err(corrupt)?; - Ok(Pending { + Ok(Some(Pending { user_id: row.user_id, username, - }) + })) } diff --git a/rust/crates/keygo-server/src/db/users.rs b/rust/crates/keygo-server/src/db/users.rs index 6c7d3d553..823f94a2e 100644 --- a/rust/crates/keygo-server/src/db/users.rs +++ b/rust/crates/keygo-server/src/db/users.rs @@ -1,3 +1,4 @@ +use super::corrupt; use crate::domain::Username; use sqlx::PgConnection; use uuid::Uuid; @@ -28,10 +29,11 @@ pub async fn insert( sqlx::query!( "INSERT INTO users (id, username) VALUES ($1, $2)", user_id, - username.as_str() + username.as_str(), ) .execute(conn) .await?; + Ok(()) } @@ -46,8 +48,5 @@ pub async fn find_username( return Ok(None); }; - // `insert` only stores parsed names, so this fails only on a corrupted row. - Username::parse(&username) - .map(Some) - .map_err(|err| sqlx::Error::Decode(Box::new(err))) + Username::parse(&username).map(Some).map_err(corrupt) } diff --git a/rust/crates/keygo-server/src/service/login/finish.rs b/rust/crates/keygo-server/src/service/login/finish.rs index e944e4a2a..c237eacce 100644 --- a/rust/crates/keygo-server/src/service/login/finish.rs +++ b/rust/crates/keygo-server/src/service/login/finish.rs @@ -1,4 +1,4 @@ -use crate::db::login_states::{self, Purpose, TakeError}; +use crate::db::login_states::{self, Purpose}; use crate::db::users; use crate::domain::{LoginId, Username}; use keygo_opaque::{CredentialFinalization, OpaqueError}; @@ -16,15 +16,6 @@ pub enum FinishError { Db(#[from] sqlx::Error), } -impl From for FinishError { - fn from(err: TakeError) -> Self { - match err { - TakeError::Expired => Self::Expired, - TakeError::Db(err) => Self::Db(err), - } - } -} - pub struct FinishLogin { pub login_id: LoginId, pub credential_finalization: CredentialFinalization, @@ -45,14 +36,16 @@ pub async fn finish(db: &PgPool, input: FinishLogin) -> Result Result { - let StartLogin { username, request } = input; + let StartLogin { + username, + credential_request, + } = input; + // TODO: rate-limit starts per account let mut conn = db.acquire().await?; @@ -52,14 +56,15 @@ pub async fn start( None => (None, None, fake_identifier(&username)), }; - let (state, response) = ServerLogin::start(setup, password_file, request, &identifier)?; + let (state, credential_response) = + ServerLogin::start(setup, password_file, credential_request, &identifier)?; let login_id = LoginId::random(); login_states::insert(&mut conn, &login_id, user_id, Purpose::Login, &state, TTL).await?; Ok(Started { login_id, - response, + credential_response, expires_in: TTL, }) } diff --git a/rust/crates/keygo-server/src/service/registration/finish.rs b/rust/crates/keygo-server/src/service/registration/finish.rs index 4cb3fdd11..6cf34fc41 100644 --- a/rust/crates/keygo-server/src/service/registration/finish.rs +++ b/rust/crates/keygo-server/src/service/registration/finish.rs @@ -1,5 +1,4 @@ -use crate::db::registrations::{self, ConsumeError}; -use crate::db::{opaque_records, users}; +use crate::db::{opaque_records, registrations, users}; use crate::domain::{RegistrationId, Username}; use keygo_opaque::{RegistrationUpload, server}; use sqlx::PgPool; @@ -13,15 +12,6 @@ pub enum FinishError { Db(#[from] sqlx::Error), } -impl From for FinishError { - fn from(err: ConsumeError) -> Self { - match err { - ConsumeError::Expired => Self::Expired, - ConsumeError::Db(err) => Self::Db(err), - } - } -} - pub struct FinishRegistration { pub registration_id: RegistrationId, pub registration_upload: RegistrationUpload, @@ -43,7 +33,9 @@ pub async fn finish(db: &PgPool, input: FinishRegistration) -> Result` once invites exist. - pub request: RegistrationRequest, + pub registration_request: RegistrationRequest, } pub struct Started { pub registration_id: RegistrationId, - pub response: RegistrationResponse, + pub registration_response: RegistrationResponse, pub expires_in: Duration, } @@ -54,7 +54,7 @@ pub async fn start( let StartRegistration { username, user_id, - request, + registration_request, } = input; if !is_random_uuid(user_id) { @@ -63,6 +63,10 @@ pub async fn start( // TODO: check the invite here once invites exist, so only invite holders can probe usernames. + // Pure computation, so it runs before the transaction to keep the reservation's lock short. + let registration_response = + server::registration_start(setup, registration_request, user_id.as_bytes())?; + let mut tx = db.begin().await?; let registration_id = RegistrationId::random(); registrations::reserve(&mut tx, ®istration_id, user_id, &username, TTL).await?; @@ -78,12 +82,11 @@ pub async fn start( return Err(StartError::UserIdTaken); } - let response = server::registration_start(setup, request, user_id.as_bytes())?; tx.commit().await?; Ok(Started { registration_id, - response, + registration_response, expires_in: TTL, }) } From 422f2769720fe41275f1b36e260ef03cc377aaad Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 21:11:59 +0200 Subject: [PATCH 29/43] feat(test): add tests --- .github/workflows/ci.yaml | 21 +++ .../keygo-server/src/api/routes/auth/login.rs | 150 ++++++++++++++++ .../src/api/routes/auth/register.rs | 129 ++++++++++++++ .../keygo-server/src/db/login_states.rs | 168 ++++++++++++++++++ .../keygo-server/src/db/opaque_records.rs | 146 +++++++++++++++ .../keygo-server/src/db/registrations.rs | 138 ++++++++++++++ rust/crates/keygo-server/src/db/users.rs | 64 +++++++ rust/crates/keygo-server/src/main.rs | 2 + .../keygo-server/src/service/login/finish.rs | 167 +++++++++++++++++ .../keygo-server/src/service/login/start.rs | 38 ++++ .../src/service/registration/finish.rs | 115 ++++++++++++ .../src/service/registration/start.rs | 61 +++++++ rust/crates/keygo-server/src/testing.rs | 101 +++++++++++ 13 files changed, 1300 insertions(+) create mode 100644 rust/crates/keygo-server/src/testing.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 22e9cbed9..7ffd55a91 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,6 +66,27 @@ jobs: if: needs.detect-changes.outputs.rust == 'true' runs-on: ubuntu-latest + # keygo-server's #[sqlx::test]s create a throwaway database per test through DATABASE_URL. + # SQLX_OFFLINE keeps the query! macros on the committed .sqlx data instead of this empty server. + services: + postgres: + image: postgres:18-trixie + env: + POSTGRES_USER: keygo + POSTGRES_PASSWORD: keygo + POSTGRES_DB: keygo + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U keygo -d keygo" + --health-interval 2s + --health-timeout 5s + --health-retries 15 + + env: + DATABASE_URL: postgres://keygo:keygo@localhost:5432/keygo + SQLX_OFFLINE: "true" + defaults: run: working-directory: ./rust diff --git a/rust/crates/keygo-server/src/api/routes/auth/login.rs b/rust/crates/keygo-server/src/api/routes/auth/login.rs index 24bbb2775..7b2636f9f 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/login.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/login.rs @@ -99,7 +99,38 @@ async fn finish( mod tests { use super::*; use crate::api::error::render; + use crate::testing::{PASSWORD, count, post, register}; use keygo_opaque::OpaqueError; + use keygo_opaque::client::ClientLogin; + use keygo_opaque::server::ServerSetup; + use serde_json::{Value, json}; + use sqlx::PgPool; + + const START: &str = "/api/v1/auth/login/start"; + const FINISH: &str = "/api/v1/auth/login/finish"; + + async fn start_over_http( + db: &PgPool, + setup: &ServerSetup, + username: &str, + ) -> (ClientLogin, Value) { + let (client, request) = ClientLogin::start(PASSWORD).unwrap(); + let body = json!({ "username": username, "credential_request": request.to_base64() }); + let (status, started) = post(db, setup, START, body).await; + assert_eq!(status, StatusCode::OK); + (client, started) + } + + fn finalization(client: ClientLogin, started: &Value) -> String { + let response = + CredentialResponse::from_base64(started["credential_response"].as_str().unwrap()) + .unwrap(); + client + .finish(PASSWORD, response) + .unwrap() + .finalization + .to_base64() + } #[tokio::test] async fn expired_logins_are_bad_requests() { @@ -116,4 +147,123 @@ mod tests { assert_eq!(status, StatusCode::UNAUTHORIZED); assert_eq!(body["code"], "invalid_credentials"); } + + #[sqlx::test] + async fn logs_in_over_http(db: PgPool) { + let setup = ServerSetup::generate(); + let user_id = register(&db, &setup, "ada").await; + let (client, started) = start_over_http(&db, &setup, "Ada").await; + + assert_eq!(started["expires_in"], login::TTL.as_secs()); + let body = json!({ + "login_id": started["login_id"], + "credential_finalization": finalization(client, &started), + }); + + let (status, finished) = post(&db, &setup, FINISH, body).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(finished, json!({ "user_id": user_id, "username": "ada" })); + assert_eq!(count(&db, "login_states").await, 0); + } + + #[sqlx::test] + async fn unknown_user_starts_like_a_known_one(db: PgPool) { + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + + let (_, known) = start_over_http(&db, &setup, "ada").await; + let (_, unknown) = start_over_http(&db, &setup, "bob").await; + + let shape = |body: &Value| { + body.as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), value.as_str().map(str::len))) + .collect::>() + }; + assert_eq!(shape(&known), shape(&unknown)); + } + + #[sqlx::test] + async fn start_rejects_an_invalid_username(db: PgPool) { + let (_, request) = ClientLogin::start(PASSWORD).unwrap(); + let body = json!({ "username": "a", "credential_request": request.to_base64() }); + + let (status, body) = post(&db, &ServerSetup::generate(), START, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_username"); + } + + #[sqlx::test] + async fn start_rejects_a_malformed_credential_request(db: PgPool) { + let body = json!({ "username": "ada", "credential_request": "AAAA" }); + + let (status, body) = post(&db, &ServerSetup::generate(), START, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_request"); + assert_eq!(count(&db, "login_states").await, 0); + } + + #[sqlx::test] + async fn foreign_finalization_is_unauthorized(db: PgPool) { + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (_, target) = start_over_http(&db, &setup, "ada").await; + let (client, other) = start_over_http(&db, &setup, "ada").await; + let body = json!({ + "login_id": target["login_id"], + "credential_finalization": finalization(client, &other), + }); + + let (status, body) = post(&db, &setup, FINISH, body).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "invalid_credentials"); + } + + #[sqlx::test] + async fn unknown_user_finish_is_unauthorized(db: PgPool) { + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (_, fake) = start_over_http(&db, &setup, "bob").await; + let (client, real) = start_over_http(&db, &setup, "ada").await; + let body = json!({ + "login_id": fake["login_id"], + "credential_finalization": finalization(client, &real), + }); + + let (status, body) = post(&db, &setup, FINISH, body).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["code"], "invalid_credentials"); + } + + #[sqlx::test] + async fn finish_rejects_an_unknown_login(db: PgPool) { + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (client, started) = start_over_http(&db, &setup, "ada").await; + let body = json!({ + "login_id": LoginId::random(), + "credential_finalization": finalization(client, &started), + }); + + let (status, body) = post(&db, &setup, FINISH, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "login_expired"); + } + + #[sqlx::test] + async fn finish_rejects_a_malformed_login_id(db: PgPool) { + let body = json!({ "login_id": "short", "credential_finalization": "AAAA" }); + + let (status, body) = post(&db, &ServerSetup::generate(), FINISH, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_request"); + } } diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index ff445520a..0ab8a1612 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -102,6 +102,24 @@ async fn finish( mod tests { use super::*; use crate::api::error::render; + use crate::testing::{count, post, register}; + use keygo_opaque::client::ClientRegistration; + use keygo_opaque::server::ServerSetup; + use serde_json::{Value, json}; + use sqlx::PgPool; + + const START: &str = "/api/v1/auth/register/start"; + const FINISH: &str = "/api/v1/auth/register/finish"; + + fn start_body(username: &str, user_id: Uuid) -> (ClientRegistration, Value) { + let (client, request) = ClientRegistration::start(crate::testing::PASSWORD).unwrap(); + let body = json!({ + "username": username, + "user_id": user_id, + "registration_request": request.to_base64(), + }); + (client, body) + } #[tokio::test] async fn expired_registrations_are_bad_requests() { @@ -118,4 +136,115 @@ mod tests { assert_eq!(status, StatusCode::CONFLICT); assert_eq!(body["code"], "username_taken"); } + + #[sqlx::test] + async fn registers_over_http(db: PgPool) { + let setup = ServerSetup::generate(); + let user_id = Uuid::new_v4(); + let (client, body) = start_body("Ada", user_id); + + let (status, started) = post(&db, &setup, START, body).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(started["expires_in"], registration::TTL.as_secs()); + let response = + RegistrationResponse::from_base64(started["registration_response"].as_str().unwrap()) + .unwrap(); + let upload = client + .finish(crate::testing::PASSWORD, response) + .unwrap() + .upload; + let body = json!({ + "registration_id": started["registration_id"], + "registration_upload": upload.to_base64(), + }); + + let (status, finished) = post(&db, &setup, FINISH, body).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(finished, json!({ "username": "ada" })); + assert_eq!(count(&db, "users").await, 1); + } + + #[sqlx::test] + async fn start_rejects_an_invalid_username(db: PgPool) { + let (_, body) = start_body("a", Uuid::new_v4()); + + let (status, body) = post(&db, &ServerSetup::generate(), START, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_username"); + } + + #[sqlx::test] + async fn start_rejects_a_structured_user_id(db: PgPool) { + let (_, body) = start_body("ada", Uuid::nil()); + + let (status, body) = post(&db, &ServerSetup::generate(), START, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_request"); + } + + #[sqlx::test] + async fn start_rejects_a_malformed_registration_request(db: PgPool) { + let body = json!({ + "username": "ada", + "user_id": Uuid::new_v4(), + "registration_request": "AAAA", + }); + + let (status, body) = post(&db, &ServerSetup::generate(), START, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_request"); + } + + #[sqlx::test] + async fn start_conflicts_on_a_taken_username(db: PgPool) { + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (_, body) = start_body("ADA", Uuid::new_v4()); + + let (status, body) = post(&db, &setup, START, body).await; + + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], "username_taken"); + } + + #[sqlx::test] + async fn start_conflicts_on_a_taken_user_id(db: PgPool) { + let setup = ServerSetup::generate(); + let user_id = register(&db, &setup, "ada").await; + let (_, body) = start_body("bob", user_id); + + let (status, body) = post(&db, &setup, START, body).await; + + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], "user_id_taken"); + } + + #[sqlx::test] + async fn finish_rejects_an_unknown_registration(db: PgPool) { + let setup = ServerSetup::generate(); + let (client, body) = start_body("ada", Uuid::new_v4()); + let (_, started) = post(&db, &setup, START, body).await; + let response = + RegistrationResponse::from_base64(started["registration_response"].as_str().unwrap()) + .unwrap(); + let upload = client + .finish(crate::testing::PASSWORD, response) + .unwrap() + .upload; + let body = json!({ + "registration_id": RegistrationId::random(), + "registration_upload": upload.to_base64(), + }); + + let (status, body) = post(&db, &setup, FINISH, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "registration_expired"); + assert_eq!(count(&db, "users").await, 0); + } } diff --git a/rust/crates/keygo-server/src/db/login_states.rs b/rust/crates/keygo-server/src/db/login_states.rs index 5dd671be8..28cbe9f03 100644 --- a/rust/crates/keygo-server/src/db/login_states.rs +++ b/rust/crates/keygo-server/src/db/login_states.rs @@ -77,3 +77,171 @@ pub async fn take( state, })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{count, expire, expires_in_secs}; + use keygo_opaque::client::ClientLogin; + use keygo_opaque::server::ServerSetup; + use sqlx::PgPool; + + const TTL: Duration = Duration::from_mins(1); + + fn server_login() -> ServerLogin { + let (_, request) = ClientLogin::start(b"password").unwrap(); + let (state, _) = + ServerLogin::start(&ServerSetup::generate(), None, request, b"identifier").unwrap(); + state + } + + async fn insert_new(conn: &mut PgConnection, user_id: Option) -> LoginId { + let login_id = LoginId::random(); + insert( + conn, + &login_id, + user_id, + Purpose::Login, + &server_login(), + TTL, + ) + .await + .unwrap(); + login_id + } + + #[sqlx::test] + async fn state_is_taken_once(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let user_id = Uuid::new_v4(); + let login_id = insert_new(&mut conn, Some(user_id)).await; + + let pending = take(&mut conn, &login_id, Purpose::Login) + .await + .unwrap() + .unwrap(); + + assert_eq!(pending.user_id, Some(user_id)); + assert!( + take(&mut conn, &login_id, Purpose::Login) + .await + .unwrap() + .is_none() + ); + assert_eq!(count(&db, "login_states").await, 0); + } + + #[sqlx::test] + async fn state_round_trips_its_bytes(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let state = server_login(); + let login_id = LoginId::random(); + insert(&mut conn, &login_id, None, Purpose::Login, &state, TTL) + .await + .unwrap(); + + let pending = take(&mut conn, &login_id, Purpose::Login) + .await + .unwrap() + .unwrap(); + + assert_eq!(*pending.state.to_bytes(), *state.to_bytes()); + } + + #[sqlx::test] + async fn fake_path_stores_no_user(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let login_id = insert_new(&mut conn, None).await; + + let pending = take(&mut conn, &login_id, Purpose::Login) + .await + .unwrap() + .unwrap(); + + assert_eq!(pending.user_id, None); + } + + #[sqlx::test] + async fn unknown_login_is_none(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert_new(&mut conn, None).await; + + let pending = take(&mut conn, &LoginId::random(), Purpose::Login) + .await + .unwrap(); + + assert!(pending.is_none()); + assert_eq!(count(&db, "login_states").await, 1); + } + + #[sqlx::test] + async fn expired_login_is_none(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let login_id = insert_new(&mut conn, None).await; + expire(&db, "login_states").await; + + assert!( + take(&mut conn, &login_id, Purpose::Login) + .await + .unwrap() + .is_none() + ); + } + + #[sqlx::test] + async fn state_expires_after_ttl(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert_new(&mut conn, None).await; + + let remaining = expires_in_secs(&db, "login_states").await; + + assert!( + (TTL.as_secs_f64() - 5.0..=TTL.as_secs_f64()).contains(&remaining), + "{remaining}" + ); + } + + #[sqlx::test] + async fn state_of_another_purpose_is_not_taken(db: PgPool) { + let login_id = LoginId::random(); + sqlx::query( + "INSERT INTO login_states (id, purpose, state, expires_at) + VALUES ($1, 'step_up', $2, now() + interval '1 minute')", + ) + .bind(login_id.as_bytes().as_slice()) + .bind(server_login().to_bytes().as_slice()) + .execute(&db) + .await + .unwrap(); + let mut conn = db.acquire().await.unwrap(); + + assert!( + take(&mut conn, &login_id, Purpose::Login) + .await + .unwrap() + .is_none() + ); + assert_eq!(count(&db, "login_states").await, 1); + } + + #[sqlx::test] + async fn undecodable_state_is_corrupt(db: PgPool) { + let login_id = LoginId::random(); + sqlx::query( + "INSERT INTO login_states (id, purpose, state, expires_at) + VALUES ($1, 'login', '\\x00', now() + interval '1 minute')", + ) + .bind(login_id.as_bytes().as_slice()) + .execute(&db) + .await + .unwrap(); + let mut conn = db.acquire().await.unwrap(); + + let err = take(&mut conn, &login_id, Purpose::Login) + .await + .err() + .unwrap(); + + assert!(matches!(err, sqlx::Error::Decode(_))); + } +} diff --git a/rust/crates/keygo-server/src/db/opaque_records.rs b/rust/crates/keygo-server/src/db/opaque_records.rs index a920d63d3..778a73b6f 100644 --- a/rust/crates/keygo-server/src/db/opaque_records.rs +++ b/rust/crates/keygo-server/src/db/opaque_records.rs @@ -60,3 +60,149 @@ pub async fn find_by_username( file, })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::users; + use crate::testing::{PASSWORD, username}; + use keygo_opaque::client::ClientRegistration; + use keygo_opaque::server::{self, ServerSetup}; + use sqlx::PgPool; + + fn password_file(user_id: Uuid) -> PasswordFile { + let setup = ServerSetup::generate(); + let (client, request) = ClientRegistration::start(PASSWORD).unwrap(); + let response = server::registration_start(&setup, request, user_id.as_bytes()).unwrap(); + let finish = client.finish(PASSWORD, response).unwrap(); + server::registration_finish(finish.upload) + } + + async fn insert_user(db: &PgPool, name: &str) -> Uuid { + let user_id = Uuid::new_v4(); + let mut conn = db.acquire().await.unwrap(); + users::insert(&mut conn, user_id, &username(name)) + .await + .unwrap(); + user_id + } + + async fn insert_raw(db: &PgPool, user_id: Uuid, suite: &str, record: &[u8]) { + sqlx::query( + "INSERT INTO opaque_records (user_id, suite, record, updated_at) + VALUES ($1, $2, $3, now())", + ) + .bind(user_id) + .bind(suite) + .bind(record) + .execute(db) + .await + .unwrap(); + } + + #[sqlx::test] + async fn record_is_found_by_username(db: PgPool) { + let user_id = insert_user(&db, "ada").await; + let file = password_file(user_id); + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, user_id, &file).await.unwrap(); + + let record = find_by_username(&mut conn, &username("ada")) + .await + .unwrap() + .unwrap(); + + assert_eq!(record.user_id, user_id); + assert_eq!(*record.file.to_bytes(), *file.to_bytes()); + } + + #[sqlx::test] + async fn record_stores_the_suite(db: PgPool) { + let user_id = insert_user(&db, "ada").await; + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, user_id, &password_file(user_id)) + .await + .unwrap(); + + let suite: String = sqlx::query_scalar("SELECT suite FROM opaque_records") + .fetch_one(&db) + .await + .unwrap(); + + assert_eq!(suite, SUITE_ID); + } + + #[sqlx::test] + async fn unknown_username_has_no_record(db: PgPool) { + insert_user(&db, "ada").await; + let mut conn = db.acquire().await.unwrap(); + + for name in ["bob", "ada"] { + let record = find_by_username(&mut conn, &username(name)).await.unwrap(); + assert!(record.is_none(), "{name}"); + } + } + + #[sqlx::test] + async fn record_requires_a_user(db: PgPool) { + let user_id = Uuid::new_v4(); + let mut conn = db.acquire().await.unwrap(); + + let err = insert(&mut conn, user_id, &password_file(user_id)) + .await + .unwrap_err(); + + assert!(err.as_database_error().unwrap().is_foreign_key_violation()); + } + + #[sqlx::test] + async fn record_is_deleted_with_its_user(db: PgPool) { + let user_id = insert_user(&db, "ada").await; + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, user_id, &password_file(user_id)) + .await + .unwrap(); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&db) + .await + .unwrap(); + + assert_eq!(crate::testing::count(&db, "opaque_records").await, 0); + } + + #[sqlx::test] + async fn unknown_suite_is_corrupt(db: PgPool) { + let user_id = insert_user(&db, "ada").await; + insert_raw( + &db, + user_id, + "keygo-opaque-v0", + &password_file(user_id).to_bytes(), + ) + .await; + let mut conn = db.acquire().await.unwrap(); + + let err = find_by_username(&mut conn, &username("ada")) + .await + .err() + .unwrap(); + + assert!(matches!(err, sqlx::Error::Decode(_))); + } + + #[sqlx::test] + async fn undecodable_record_is_corrupt(db: PgPool) { + let user_id = insert_user(&db, "ada").await; + insert_raw(&db, user_id, SUITE_ID, &[0]).await; + let mut conn = db.acquire().await.unwrap(); + + let err = find_by_username(&mut conn, &username("ada")) + .await + .err() + .unwrap(); + + assert!(matches!(err, sqlx::Error::Decode(_))); + } +} diff --git a/rust/crates/keygo-server/src/db/registrations.rs b/rust/crates/keygo-server/src/db/registrations.rs index 480e842b3..b38d3aae1 100644 --- a/rust/crates/keygo-server/src/db/registrations.rs +++ b/rust/crates/keygo-server/src/db/registrations.rs @@ -94,3 +94,141 @@ pub async fn take( username, })) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{count, expire, username}; + use sqlx::PgPool; + + const TTL: Duration = Duration::from_mins(5); + + async fn reserve_new( + conn: &mut PgConnection, + user_id: Uuid, + name: &str, + ) -> Result { + let registration_id = RegistrationId::random(); + reserve(conn, ®istration_id, user_id, &username(name), TTL).await?; + Ok(registration_id) + } + + #[sqlx::test] + async fn reservation_is_taken_once(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let user_id = Uuid::new_v4(); + let registration_id = reserve_new(&mut conn, user_id, "ada").await.unwrap(); + + let pending = take(&mut conn, ®istration_id).await.unwrap().unwrap(); + + assert_eq!(pending.user_id, user_id); + assert_eq!(pending.username, username("ada")); + assert!(take(&mut conn, ®istration_id).await.unwrap().is_none()); + assert_eq!(count(&db, "pending_registrations").await, 0); + } + + #[sqlx::test] + async fn unknown_registration_is_none(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + reserve_new(&mut conn, Uuid::new_v4(), "ada").await.unwrap(); + + let pending = take(&mut conn, &RegistrationId::random()).await.unwrap(); + + assert!(pending.is_none()); + assert_eq!(count(&db, "pending_registrations").await, 1); + } + + #[sqlx::test] + async fn expired_registration_is_none(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let registration_id = reserve_new(&mut conn, Uuid::new_v4(), "ada").await.unwrap(); + expire(&db, "pending_registrations").await; + + assert!(take(&mut conn, ®istration_id).await.unwrap().is_none()); + } + + #[sqlx::test] + async fn live_reservation_holds_its_username(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + reserve_new(&mut conn, Uuid::new_v4(), "ada").await.unwrap(); + + let err = reserve_new(&mut conn, Uuid::new_v4(), "ada") + .await + .unwrap_err(); + + assert!(matches!(err, ReserveError::UsernameTaken), "{err:?}"); + } + + #[sqlx::test] + async fn expired_reservation_frees_its_username(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let stale = reserve_new(&mut conn, Uuid::new_v4(), "ada").await.unwrap(); + expire(&db, "pending_registrations").await; + let user_id = Uuid::new_v4(); + + let fresh = reserve_new(&mut conn, user_id, "ada").await.unwrap(); + + assert!(take(&mut conn, &stale).await.unwrap().is_none()); + let pending = take(&mut conn, &fresh).await.unwrap().unwrap(); + assert_eq!(pending.user_id, user_id); + } + + #[sqlx::test] + async fn same_user_id_takes_over_its_reservation(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let user_id = Uuid::new_v4(); + let first = reserve_new(&mut conn, user_id, "ada").await.unwrap(); + + let second = reserve_new(&mut conn, user_id, "bob").await.unwrap(); + + assert!(take(&mut conn, &first).await.unwrap().is_none()); + let pending = take(&mut conn, &second).await.unwrap().unwrap(); + assert_eq!(pending.username, username("bob")); + reserve_new(&mut conn, Uuid::new_v4(), "ada").await.unwrap(); + } + + #[sqlx::test] + async fn takeover_cannot_claim_another_users_name(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let user_id = Uuid::new_v4(); + let own = reserve_new(&mut conn, user_id, "ada").await.unwrap(); + reserve_new(&mut conn, Uuid::new_v4(), "bob").await.unwrap(); + + let err = reserve_new(&mut conn, user_id, "bob").await.unwrap_err(); + + assert!(matches!(err, ReserveError::UsernameTaken), "{err:?}"); + assert!(take(&mut conn, &own).await.unwrap().is_some()); + } + + #[sqlx::test] + async fn reservation_expires_after_ttl(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + reserve_new(&mut conn, Uuid::new_v4(), "ada").await.unwrap(); + + let remaining = crate::testing::expires_in_secs(&db, "pending_registrations").await; + + assert!( + (TTL.as_secs_f64() - 5.0..=TTL.as_secs_f64()).contains(&remaining), + "{remaining}" + ); + } + + #[sqlx::test] + async fn stored_invalid_username_is_corrupt(db: PgPool) { + let registration_id = RegistrationId::random(); + sqlx::query( + "INSERT INTO pending_registrations (id, user_id, username, expires_at) + VALUES ($1, $2, 'NOT VALID', now() + interval '1 minute')", + ) + .bind(registration_id.as_bytes().as_slice()) + .bind(Uuid::new_v4()) + .execute(&db) + .await + .unwrap(); + let mut conn = db.acquire().await.unwrap(); + + let err = take(&mut conn, ®istration_id).await.err().unwrap(); + + assert!(matches!(err, sqlx::Error::Decode(_))); + } +} diff --git a/rust/crates/keygo-server/src/db/users.rs b/rust/crates/keygo-server/src/db/users.rs index 823f94a2e..a04184148 100644 --- a/rust/crates/keygo-server/src/db/users.rs +++ b/rust/crates/keygo-server/src/db/users.rs @@ -50,3 +50,67 @@ pub async fn find_username( Username::parse(&username).map(Some).map_err(corrupt) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::username; + use sqlx::PgPool; + + #[sqlx::test] + async fn inserted_user_is_found_by_name_and_id(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let user_id = Uuid::new_v4(); + + insert(&mut conn, user_id, &username("ada")).await.unwrap(); + + assert!(username_exists(&mut conn, &username("ada")).await.unwrap()); + assert!(id_exists(&mut conn, user_id).await.unwrap()); + assert_eq!( + find_username(&mut conn, user_id).await.unwrap(), + Some(username("ada")), + ); + } + + #[sqlx::test] + async fn unknown_user_is_absent(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + + assert!(!username_exists(&mut conn, &username("ada")).await.unwrap()); + assert!(!id_exists(&mut conn, Uuid::new_v4()).await.unwrap()); + assert_eq!( + find_username(&mut conn, Uuid::new_v4()).await.unwrap(), + None + ); + } + + #[sqlx::test] + async fn usernames_and_ids_are_unique(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let user_id = Uuid::new_v4(); + insert(&mut conn, user_id, &username("ada")).await.unwrap(); + + let same_name = insert(&mut conn, Uuid::new_v4(), &username("ada")).await; + let same_id = insert(&mut conn, user_id, &username("bob")).await; + + for result in [same_name, same_id] { + let err = result.unwrap_err(); + assert!(err.as_database_error().unwrap().is_unique_violation()); + } + } + + #[sqlx::test] + async fn stored_invalid_username_is_corrupt(db: PgPool) { + let user_id = Uuid::new_v4(); + sqlx::query("INSERT INTO users (id, username) VALUES ($1, 'NOT VALID')") + .bind(user_id) + .execute(&db) + .await + .unwrap(); + let mut conn = db.acquire().await.unwrap(); + + let err = find_username(&mut conn, user_id).await.unwrap_err(); + + assert!(matches!(err, sqlx::Error::Decode(_))); + } +} diff --git a/rust/crates/keygo-server/src/main.rs b/rust/crates/keygo-server/src/main.rs index bc05c02c6..29e15a1b8 100644 --- a/rust/crates/keygo-server/src/main.rs +++ b/rust/crates/keygo-server/src/main.rs @@ -8,6 +8,8 @@ mod domain; mod secrets; mod server; mod service; +#[cfg(test)] +mod testing; fn main() -> anyhow::Result<()> { Cli::start() diff --git a/rust/crates/keygo-server/src/service/login/finish.rs b/rust/crates/keygo-server/src/service/login/finish.rs index c237eacce..606499f97 100644 --- a/rust/crates/keygo-server/src/service/login/finish.rs +++ b/rust/crates/keygo-server/src/service/login/finish.rs @@ -52,3 +52,170 @@ pub async fn finish(db: &PgPool, input: FinishLogin) -> Result Vec { #[cfg(test)] mod tests { use super::*; + use crate::testing::{count, register, start_login}; fn name(raw: &str) -> Username { Username::parse(raw).unwrap() @@ -93,4 +94,41 @@ mod tests { fn fake_identifier_is_pinned() { assert_eq!(fake_identifier(&name("ada")), b"v1:fake/login\0ada"); } + + async fn stored_user_ids(db: &PgPool) -> Vec> { + sqlx::query_scalar("SELECT user_id FROM login_states") + .fetch_all(db) + .await + .unwrap() + } + + #[sqlx::test] + async fn start_stores_state_for_a_known_user(db: PgPool) { + let setup = ServerSetup::generate(); + let user_id = register(&db, &setup, "ada").await; + + let (_, started) = start_login(&db, &setup, "ada").await; + + assert_eq!(started.expires_in, TTL); + assert_eq!(stored_user_ids(&db).await, [Some(user_id)]); + } + + #[sqlx::test] + async fn start_answers_an_unknown_user_without_one(db: PgPool) { + let (_, started) = start_login(&db, &ServerSetup::generate(), "ada").await; + + assert_eq!(started.expires_in, TTL); + assert_eq!(stored_user_ids(&db).await, [None]); + } + + #[sqlx::test] + async fn every_start_is_a_separate_login(db: PgPool) { + let setup = ServerSetup::generate(); + + let (_, first) = start_login(&db, &setup, "ada").await; + let (_, second) = start_login(&db, &setup, "ada").await; + + assert_ne!(first.login_id, second.login_id); + assert_eq!(count(&db, "login_states").await, 2); + } } diff --git a/rust/crates/keygo-server/src/service/registration/finish.rs b/rust/crates/keygo-server/src/service/registration/finish.rs index 6cf34fc41..9ac902e2f 100644 --- a/rust/crates/keygo-server/src/service/registration/finish.rs +++ b/rust/crates/keygo-server/src/service/registration/finish.rs @@ -44,3 +44,118 @@ pub async fn finish(db: &PgPool, input: FinishRegistration) -> Result FinishRegistration { + let (client, registration_request) = ClientRegistration::start(PASSWORD).unwrap(); + let input = StartRegistration { + username: username(name), + user_id, + registration_request, + }; + let Started { + registration_id, + registration_response, + .. + } = registration::start(db, &ServerSetup::generate(), input) + .await + .unwrap(); + let finish = client.finish(PASSWORD, registration_response).unwrap(); + + FinishRegistration { + registration_id, + registration_upload: finish.upload, + } + } + + async fn user_count(db: &PgPool) -> (i64, i64, i64) { + ( + count(db, "users").await, + count(db, "opaque_records").await, + count(db, "pending_registrations").await, + ) + } + + #[sqlx::test] + async fn finish_creates_the_user_and_record(db: PgPool) { + let user_id = Uuid::new_v4(); + let input = started(&db, user_id, "Ada").await; + + let finished = finish(&db, input).await.unwrap(); + + assert_eq!(finished.username, username("ada")); + assert_eq!(user_count(&db).await, (1, 1, 0)); + let stored: (Uuid, String) = sqlx::query_as("SELECT id, username FROM users") + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(stored, (user_id, "ada".to_owned())); + } + + #[sqlx::test] + async fn finish_consumes_the_registration(db: PgPool) { + let input = started(&db, Uuid::new_v4(), "ada").await; + let replay = FinishRegistration { + registration_id: input.registration_id, + registration_upload: RegistrationUpload::from_bytes( + &input.registration_upload.to_bytes(), + ) + .unwrap(), + }; + finish(&db, input).await.unwrap(); + + let err = finish(&db, replay).await.err().unwrap(); + + assert!(matches!(err, FinishError::Expired), "{err:?}"); + assert_eq!(user_count(&db).await, (1, 1, 0)); + } + + #[sqlx::test] + async fn finish_rejects_an_unknown_registration(db: PgPool) { + let input = started(&db, Uuid::new_v4(), "ada").await; + let unknown = FinishRegistration { + registration_id: RegistrationId::random(), + ..input + }; + + let err = finish(&db, unknown).await.err().unwrap(); + + assert!(matches!(err, FinishError::Expired), "{err:?}"); + assert_eq!(user_count(&db).await, (0, 0, 1)); + } + + #[sqlx::test] + async fn finish_rejects_an_expired_registration(db: PgPool) { + let input = started(&db, Uuid::new_v4(), "ada").await; + expire(&db, "pending_registrations").await; + + let err = finish(&db, input).await.err().unwrap(); + + assert!(matches!(err, FinishError::Expired), "{err:?}"); + assert_eq!(user_count(&db).await, (0, 0, 1)); + } + + #[sqlx::test] + async fn failed_finish_keeps_the_reservation(db: PgPool) { + let user_id = Uuid::new_v4(); + let input = started(&db, user_id, "ada").await; + sqlx::query("INSERT INTO users (id, username) VALUES ($1, 'squatter')") + .bind(user_id) + .execute(&db) + .await + .unwrap(); + + let err = finish(&db, input).await.err().unwrap(); + + assert!(matches!(err, FinishError::Db(_)), "{err:?}"); + assert_eq!(user_count(&db).await, (1, 0, 1)); + } +} diff --git a/rust/crates/keygo-server/src/service/registration/start.rs b/rust/crates/keygo-server/src/service/registration/start.rs index 1cb6af88c..52e156bec 100644 --- a/rust/crates/keygo-server/src/service/registration/start.rs +++ b/rust/crates/keygo-server/src/service/registration/start.rs @@ -99,6 +99,8 @@ fn is_random_uuid(id: Uuid) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::testing::{count, register, username}; + use keygo_opaque::client::ClientRegistration; #[test] fn accepts_random_uuids() { @@ -120,4 +122,63 @@ mod tests { "7f0c1e52-3a9b-4c11-cd0e-2b5a6c9d4e10".parse().unwrap() )); } + + async fn start_as(db: &PgPool, user_id: Uuid, name: &str) -> Result { + let (_, registration_request) = ClientRegistration::start(b"password").unwrap(); + let input = StartRegistration { + username: username(name), + user_id, + registration_request, + }; + start(db, &ServerSetup::generate(), input).await + } + + #[sqlx::test] + async fn start_reserves_the_username(db: PgPool) { + let started = start_as(&db, Uuid::new_v4(), "ada").await.unwrap(); + + assert_eq!(started.expires_in, TTL); + assert_eq!(count(&db, "pending_registrations").await, 1); + let err = start_as(&db, Uuid::new_v4(), "ada").await.err().unwrap(); + assert!(matches!(err, StartError::UsernameTaken), "{err:?}"); + } + + #[sqlx::test] + async fn start_rejects_structured_user_ids(db: PgPool) { + let err = start_as(&db, Uuid::nil(), "ada").await.err().unwrap(); + + assert!(matches!(err, StartError::InvalidUserId), "{err:?}"); + assert_eq!(count(&db, "pending_registrations").await, 0); + } + + #[sqlx::test] + async fn start_rejects_a_registered_username(db: PgPool) { + register(&db, &ServerSetup::generate(), "ada").await; + + let err = start_as(&db, Uuid::new_v4(), "ada").await.err().unwrap(); + + assert!(matches!(err, StartError::UsernameTaken), "{err:?}"); + assert_eq!(count(&db, "pending_registrations").await, 0); + } + + #[sqlx::test] + async fn start_rejects_a_registered_user_id(db: PgPool) { + let user_id = register(&db, &ServerSetup::generate(), "ada").await; + + let err = start_as(&db, user_id, "bob").await.err().unwrap(); + + assert!(matches!(err, StartError::UserIdTaken), "{err:?}"); + assert_eq!(count(&db, "pending_registrations").await, 0); + } + + #[sqlx::test] + async fn restart_with_the_same_user_id_replaces_the_reservation(db: PgPool) { + let user_id = Uuid::new_v4(); + start_as(&db, user_id, "ada").await.unwrap(); + + start_as(&db, user_id, "bob").await.unwrap(); + + assert_eq!(count(&db, "pending_registrations").await, 1); + start_as(&db, Uuid::new_v4(), "ada").await.unwrap(); + } } diff --git a/rust/crates/keygo-server/src/testing.rs b/rust/crates/keygo-server/src/testing.rs new file mode 100644 index 000000000..7e5a4c539 --- /dev/null +++ b/rust/crates/keygo-server/src/testing.rs @@ -0,0 +1,101 @@ +use crate::api::{AppState, router}; +use crate::domain::Username; +use crate::service::login::{self, FinishLogin, StartLogin}; +use crate::service::registration::{self, FinishRegistration, StartRegistration}; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use keygo_opaque::client::{ClientLogin, ClientRegistration}; +use keygo_opaque::server::ServerSetup; +use serde_json::Value; +use sqlx::{AssertSqlSafe, PgPool}; +use tower::ServiceExt; +use uuid::Uuid; + +pub const PASSWORD: &[u8] = b"correct horse battery staple"; + +pub fn username(raw: &str) -> Username { + Username::parse(raw).unwrap() +} +pub async fn register(db: &PgPool, setup: &ServerSetup, name: &str) -> Uuid { + let user_id = Uuid::new_v4(); + let (client, registration_request) = ClientRegistration::start(PASSWORD).unwrap(); + + let input = StartRegistration { + username: username(name), + user_id, + registration_request, + }; + let started = registration::start(db, setup, input).await.unwrap(); + + let finish = client + .finish(PASSWORD, started.registration_response) + .unwrap(); + let input = FinishRegistration { + registration_id: started.registration_id, + registration_upload: finish.upload, + }; + registration::finish(db, input).await.unwrap(); + + user_id +} +pub async fn start_login( + db: &PgPool, + setup: &ServerSetup, + name: &str, +) -> (ClientLogin, login::Started) { + let (client, credential_request) = ClientLogin::start(PASSWORD).unwrap(); + let input = StartLogin { + username: username(name), + credential_request, + }; + (client, login::start(db, setup, input).await.unwrap()) +} +pub fn finish_login(client: ClientLogin, started: login::Started) -> FinishLogin { + let finish = client + .finish(PASSWORD, started.credential_response) + .unwrap(); + FinishLogin { + login_id: started.login_id, + credential_finalization: finish.finalization, + } +} + +pub async fn count(db: &PgPool, table: &str) -> i64 { + sqlx::query_scalar(AssertSqlSafe(format!("SELECT count(*) FROM {table}"))) + .fetch_one(db) + .await + .unwrap() +} +pub async fn expire(db: &PgPool, table: &str) { + sqlx::query(AssertSqlSafe(format!( + "UPDATE {table} SET expires_at = now() - interval '1 second'" + ))) + .execute(db) + .await + .unwrap(); +} +pub async fn expires_in_secs(db: &PgPool, table: &str) -> f64 { + sqlx::query_scalar(AssertSqlSafe(format!( + "SELECT extract(epoch FROM max(expires_at) - now())::float8 FROM {table}" + ))) + .fetch_one(db) + .await + .unwrap() +} + +pub async fn post(db: &PgPool, setup: &ServerSetup, uri: &str, body: Value) -> (StatusCode, Value) { + let state = AppState { + allow_signups: true, + opaque_state: setup.clone(), + db: db.clone(), + }; + let request = Request::post(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = router(state).oneshot(request).await.unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&body).unwrap()) +} From 483ec0161a28bc65d3eaa94ac9c54e8de9f99383 Mon Sep 17 00:00:00 2001 From: Davis Date: Thu, 15 Oct 2026 21:18:49 +0200 Subject: [PATCH 30/43] feat(sweeper): add db sweeper --- ...3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json | 12 +++ ...dcbed072889d1cc47d341a414202c26242362.json | 12 +++ .../migrations/20261014141017_init.sql | 4 + .../keygo-server/src/db/login_states.rs | 26 +++++++ .../keygo-server/src/db/registrations.rs | 20 +++++ rust/crates/keygo-server/src/server/mod.rs | 4 + .../crates/keygo-server/src/server/sweeper.rs | 75 +++++++++++++++++++ 7 files changed, 153 insertions(+) create mode 100644 rust/crates/keygo-server/.sqlx/query-74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json create mode 100644 rust/crates/keygo-server/.sqlx/query-c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362.json create mode 100644 rust/crates/keygo-server/src/server/sweeper.rs diff --git a/rust/crates/keygo-server/.sqlx/query-74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json b/rust/crates/keygo-server/.sqlx/query-74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json new file mode 100644 index 000000000..5e7d073d5 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM pending_registrations WHERE expires_at <= now()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc" +} diff --git a/rust/crates/keygo-server/.sqlx/query-c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362.json b/rust/crates/keygo-server/.sqlx/query-c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362.json new file mode 100644 index 000000000..b44a524ca --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM login_states WHERE expires_at <= now()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362" +} diff --git a/rust/crates/keygo-server/migrations/20261014141017_init.sql b/rust/crates/keygo-server/migrations/20261014141017_init.sql index 8d2e1a550..5887372f6 100644 --- a/rust/crates/keygo-server/migrations/20261014141017_init.sql +++ b/rust/crates/keygo-server/migrations/20261014141017_init.sql @@ -22,6 +22,8 @@ CREATE UNLOGGED TABLE pending_registrations expires_at timestamptz NOT NULL ); +CREATE INDEX pending_registrations_expires_at_idx ON pending_registrations (expires_at); + CREATE UNLOGGED TABLE login_states ( id bytea PRIMARY KEY, -- login_id, 16 random bytes @@ -30,3 +32,5 @@ CREATE UNLOGGED TABLE login_states state bytea NOT NULL, -- serialized ServerLogin expires_at timestamptz NOT NULL ); + +CREATE INDEX login_states_expires_at_idx ON login_states (expires_at); diff --git a/rust/crates/keygo-server/src/db/login_states.rs b/rust/crates/keygo-server/src/db/login_states.rs index 28cbe9f03..dc0a839e0 100644 --- a/rust/crates/keygo-server/src/db/login_states.rs +++ b/rust/crates/keygo-server/src/db/login_states.rs @@ -78,6 +78,14 @@ pub async fn take( })) } +pub async fn delete_expired(conn: &mut PgConnection) -> sqlx::Result { + let result = sqlx::query!("DELETE FROM login_states WHERE expires_at <= now()") + .execute(conn) + .await?; + + Ok(result.rows_affected()) +} + #[cfg(test)] mod tests { use super::*; @@ -188,6 +196,24 @@ mod tests { ); } + #[sqlx::test] + async fn delete_expired_keeps_live_states(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert_new(&mut conn, None).await; + insert_new(&mut conn, Some(Uuid::new_v4())).await; + expire(&db, "login_states").await; + let live = insert_new(&mut conn, None).await; + + assert_eq!(delete_expired(&mut conn).await.unwrap(), 2); + assert_eq!(count(&db, "login_states").await, 1); + assert!( + take(&mut conn, &live, Purpose::Login) + .await + .unwrap() + .is_some() + ); + } + #[sqlx::test] async fn state_expires_after_ttl(db: PgPool) { let mut conn = db.acquire().await.unwrap(); diff --git a/rust/crates/keygo-server/src/db/registrations.rs b/rust/crates/keygo-server/src/db/registrations.rs index b38d3aae1..f82804262 100644 --- a/rust/crates/keygo-server/src/db/registrations.rs +++ b/rust/crates/keygo-server/src/db/registrations.rs @@ -95,6 +95,14 @@ pub async fn take( })) } +pub async fn delete_expired(conn: &mut PgConnection) -> sqlx::Result { + let result = sqlx::query!("DELETE FROM pending_registrations WHERE expires_at <= now()") + .execute(conn) + .await?; + + Ok(result.rows_affected()) +} + #[cfg(test)] mod tests { use super::*; @@ -213,6 +221,18 @@ mod tests { ); } + #[sqlx::test] + async fn delete_expired_keeps_live_reservations(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + reserve_new(&mut conn, Uuid::new_v4(), "ada").await.unwrap(); + expire(&db, "pending_registrations").await; + let live = reserve_new(&mut conn, Uuid::new_v4(), "bob").await.unwrap(); + + assert_eq!(delete_expired(&mut conn).await.unwrap(), 1); + assert_eq!(count(&db, "pending_registrations").await, 1); + assert!(take(&mut conn, &live).await.unwrap().is_some()); + } + #[sqlx::test] async fn stored_invalid_username_is_corrupt(db: PgPool) { let registration_id = RegistrationId::random(); diff --git a/rust/crates/keygo-server/src/server/mod.rs b/rust/crates/keygo-server/src/server/mod.rs index ddb55ca3f..0579f5758 100644 --- a/rust/crates/keygo-server/src/server/mod.rs +++ b/rust/crates/keygo-server/src/server/mod.rs @@ -6,10 +6,13 @@ use std::net::SocketAddr; use tokio::net::TcpListener; use tokio::signal::unix::{SignalKind, signal}; +mod sweeper; + pub async fn start(config: &Config, listen: SocketAddr) -> anyhow::Result<()> { let db = db::connect(config).await?; db::migrate(&db).await?; + let sweeper = tokio::spawn(sweeper::run(db.clone())); let app = api::router(AppState::new(config, db)?); let listener = TcpListener::bind(listen) .await @@ -19,6 +22,7 @@ pub async fn start(config: &Config, listen: SocketAddr) -> anyhow::Result<()> { axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal()) .await?; + sweeper.abort(); tracing::info!("stopped"); Ok(()) } diff --git a/rust/crates/keygo-server/src/server/sweeper.rs b/rust/crates/keygo-server/src/server/sweeper.rs new file mode 100644 index 000000000..4b8c25c4d --- /dev/null +++ b/rust/crates/keygo-server/src/server/sweeper.rs @@ -0,0 +1,75 @@ +use crate::db::{login_states, registrations}; +use sqlx::PgPool; +use std::time::Duration; +use tokio::time::MissedTickBehavior; + +/// How often expired rows are deleted. Expired rows are never read, since every `take` filters on +/// `expires_at`, so this bounds only how long they occupy the table, not how long they are valid. +const INTERVAL: Duration = Duration::from_mins(1); + +/// Deletes expired registrations and login states until the task is aborted. A start that is +/// never finished (a wrong password fails at the client, which then never calls `finish`) leaves +/// its row behind, so without this the tables would only grow. +pub async fn run(db: PgPool) { + let mut interval = tokio::time::interval(INTERVAL); + interval.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + interval.tick().await; + if let Err(err) = sweep(&db).await { + tracing::warn!("sweeping expired rows failed: {err:#}"); + } + } +} + +async fn sweep(db: &PgPool) -> sqlx::Result<()> { + let mut conn = db.acquire().await?; + let registrations = registrations::delete_expired(&mut conn).await?; + let logins = login_states::delete_expired(&mut conn).await?; + tracing::debug!(registrations, logins, "swept expired rows"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::registration::{self, StartRegistration}; + use crate::testing::{count, expire, start_login}; + use keygo_opaque::client::ClientRegistration; + use keygo_opaque::server::ServerSetup; + use uuid::Uuid; + + async fn start_registration(db: &PgPool, setup: &ServerSetup, name: &str) { + let (_, registration_request) = ClientRegistration::start(b"password").unwrap(); + let input = StartRegistration { + username: crate::testing::username(name), + user_id: Uuid::new_v4(), + registration_request, + }; + registration::start(db, setup, input).await.unwrap(); + } + + #[sqlx::test] + async fn sweep_deletes_only_expired_rows(db: PgPool) { + let setup = ServerSetup::generate(); + start_registration(&db, &setup, "ada").await; + start_login(&db, &setup, "ada").await; + expire(&db, "pending_registrations").await; + expire(&db, "login_states").await; + start_registration(&db, &setup, "bob").await; + start_login(&db, &setup, "bob").await; + + sweep(&db).await.unwrap(); + + assert_eq!(count(&db, "pending_registrations").await, 1); + assert_eq!(count(&db, "login_states").await, 1); + } + + #[sqlx::test] + async fn sweep_of_empty_tables_is_a_no_op(db: PgPool) { + sweep(&db).await.unwrap(); + + assert_eq!(count(&db, "pending_registrations").await, 0); + assert_eq!(count(&db, "login_states").await, 0); + } +} From b1a36575b2679f4d534169f16fa9a8159077a665 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 17 Oct 2026 12:34:44 +0200 Subject: [PATCH 31/43] feat(invite): add invite code generator --- rust/Cargo.lock | 11 ++ rust/crates/keygo-server/Cargo.toml | 3 + rust/crates/keygo-server/src/api/error.rs | 1 + .../src/cli/commands/invite/create.rs | 33 +++++ .../src/cli/commands/invite/mod.rs | 16 +++ .../keygo-server/src/cli/commands/mod.rs | 7 +- rust/crates/keygo-server/src/cli/mod.rs | 4 + rust/crates/keygo-server/src/config.rs | 5 + .../keygo-server/src/domain/invite_code.rs | 125 ++++++++++++++++++ rust/crates/keygo-server/src/domain/mod.rs | 2 + rust/crates/keygo-server/src/secrets.rs | 39 ++++++ 11 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 rust/crates/keygo-server/src/cli/commands/invite/create.rs create mode 100644 rust/crates/keygo-server/src/cli/commands/invite/mod.rs create mode 100644 rust/crates/keygo-server/src/domain/invite_code.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index f130e8d46..d03fc2309 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -391,6 +391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -831,6 +832,7 @@ dependencies = [ "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", + "zeroize", ] [[package]] @@ -1331,6 +1333,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -1601,11 +1609,14 @@ dependencies = [ "clap", "getrandom 0.4.3", "hex", + "hmac 0.13.0", "http-body-util", + "humantime", "keygo-opaque", "secrecy", "serde", "serde_json", + "sha2 0.11.1", "sqlx", "tempfile", "thiserror", diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index ac9102be9..73c1f6eae 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -27,6 +27,9 @@ tower-http = { version = "0.7.1", features = ["trace"] } uuid = { workspace = true, features = ["v4"] } sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros", "uuid"] } unicode-normalization = "0.1.25" +humantime = "2.4.0" +hmac = "0.13.0" +sha2 = { version = "0.11.0", features = ["zeroize"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index c16d727e0..1af0c65ea 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -21,6 +21,7 @@ pub enum ErrorCode { InvalidCredentials, UsernameTaken, UserIdTaken, + InviteInvalid, RegistrationExpired, LoginExpired, } diff --git a/rust/crates/keygo-server/src/cli/commands/invite/create.rs b/rust/crates/keygo-server/src/cli/commands/invite/create.rs new file mode 100644 index 000000000..bed55ba2d --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/invite/create.rs @@ -0,0 +1,33 @@ +use crate::config::Config; +use crate::domain::InviteCode; +use anyhow::ensure; +use clap::Args; +use std::time::Duration; + +#[derive(Args)] +pub struct CreateInviteArgs { + #[arg(long, default_value = "1d", value_parser=ttl_parser)] + ttl: Duration, +} + +fn ttl_parser(s: &str) -> anyhow::Result { + let d = humantime::parse_duration(s)?; + ensure!(d >= Duration::from_hours(24), "ttl must be at least 1d"); + ensure!( + d <= Duration::from_hours(30 * 24), + "ttl must be at most 30d" + ); + Ok(d) +} + +pub fn run(config: &Config, args: &CreateInviteArgs) -> anyhow::Result<()> { + let code = InviteCode::generate(); + + { + let invite_pepper = config.invite_pepper()?; + let hash = invite_pepper.hash(&code); + } + + println!("{}", code); + Ok(()) +} diff --git a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs new file mode 100644 index 000000000..d4ebc33de --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs @@ -0,0 +1,16 @@ +pub mod create; + +use crate::cli::commands::invite::create::CreateInviteArgs; +use crate::config::Config; +use clap::Subcommand; + +#[derive(Subcommand)] +pub enum InviteCommands { + Create(CreateInviteArgs), +} + +pub fn run(config: &Config, commands: InviteCommands) -> anyhow::Result<()> { + match commands { + InviteCommands::Create(args) => create::run(config, &args), + } +} diff --git a/rust/crates/keygo-server/src/cli/commands/mod.rs b/rust/crates/keygo-server/src/cli/commands/mod.rs index c89831e25..137292492 100644 --- a/rust/crates/keygo-server/src/cli/commands/mod.rs +++ b/rust/crates/keygo-server/src/cli/commands/mod.rs @@ -1,3 +1,4 @@ -pub(crate) mod check; -pub(crate) mod init; -pub(crate) mod serve; +pub mod check; +pub mod init; +pub mod invite; +pub mod serve; diff --git a/rust/crates/keygo-server/src/cli/mod.rs b/rust/crates/keygo-server/src/cli/mod.rs index 71b92aad6..7f8b3e1e0 100644 --- a/rust/crates/keygo-server/src/cli/mod.rs +++ b/rust/crates/keygo-server/src/cli/mod.rs @@ -1,5 +1,6 @@ mod commands; +use crate::cli::commands::invite::InviteCommands; use crate::cli::commands::serve::ServeArgs; use crate::config::Config; use clap::{Parser, Subcommand}; @@ -19,6 +20,8 @@ enum Commands { Init, Check, Serve(ServeArgs), + #[command(subcommand)] + Invite(InviteCommands), } impl Cli { @@ -29,6 +32,7 @@ impl Cli { Commands::Init => commands::init::run(&cli.config), Commands::Check => commands::check::run(&cli.config), Commands::Serve(args) => commands::serve::run(&cli.config, &args), + Commands::Invite(commands) => commands::invite::run(&cli.config, commands), } } } diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs index 6a7bce938..fbc26479e 100644 --- a/rust/crates/keygo-server/src/config.rs +++ b/rust/crates/keygo-server/src/config.rs @@ -1,3 +1,4 @@ +use crate::domain::InvitePepper; use crate::secrets::{DbPassword, OpaqueSetup, SecretStore}; use clap::{Args, ValueEnum}; use keygo_opaque::server::ServerSetup; @@ -102,6 +103,7 @@ impl Environment { } } +// TODO: maybe find better API structure impl Config { pub(crate) fn opaque_setup(&self) -> anyhow::Result { SecretStore::new(&self.secrets_dir).read::() @@ -109,4 +111,7 @@ impl Config { pub(crate) fn db_password(&self) -> anyhow::Result { SecretStore::new(&self.secrets_dir).read::() } + pub(crate) fn invite_pepper(&self) -> anyhow::Result { + SecretStore::new(&self.secrets_dir).read::() + } } diff --git a/rust/crates/keygo-server/src/domain/invite_code.rs b/rust/crates/keygo-server/src/domain/invite_code.rs new file mode 100644 index 000000000..5fdbcf73b --- /dev/null +++ b/rust/crates/keygo-server/src/domain/invite_code.rs @@ -0,0 +1,125 @@ +use hmac::{Hmac, KeyInit, Mac}; +use sha2::Sha256; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; +use thiserror::Error; +use zeroize::Zeroizing; + +const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // no I, L, O, U + +pub struct InvitePepper(Zeroizing<[u8; 32]>); + +impl InvitePepper { + pub fn new(bytes: [u8; 32]) -> Self { + Self(Zeroizing::new(bytes)) + } + + pub fn hash(&self, code: &InviteCode) -> [u8; 32] { + let mut mac = + Hmac::::new_from_slice(self.0.as_slice()).expect("HMAC accepts any key length"); + mac.update(&code.0); + mac.finalize().into_bytes().into() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct InviteCode([u8; 12]); + +impl InviteCode { + pub fn generate() -> Self { + let mut code = [0; 12]; + getrandom::fill(&mut code).expect("getrandom failed"); + for b in &mut code { + *b = ALPHABET[(*b & 0x1F) as usize]; + } + InviteCode(code) + } + + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.0).expect("invite code is ASCII") + } +} + +#[derive(Debug, Error)] +pub enum ParseInviteCodeError { + #[error("too short: {0} of 12 characters")] + TooShort(usize), + #[error("too long: more than 12 characters")] + TooLong, + #[error("invalid char: {0:?}")] + InvalidCharacter(char), +} + +impl FromStr for InviteCode { + type Err = ParseInviteCodeError; + + fn from_str(s: &str) -> Result { + let mut code = [0; 12]; + let mut len = 0; + + for c in s.chars() { + let c = match c.to_ascii_uppercase() { + c if c == '-' || c.is_whitespace() => continue, + 'O' => '0', + 'I' | 'L' => '1', + c @ ('0'..='9' | 'A'..='Z') if c != 'U' => c, + _ => return Err(ParseInviteCodeError::InvalidCharacter(c)), + }; + + if len == code.len() { + return Err(ParseInviteCodeError::TooLong); + } + code[len] = c as u8; + len += 1; + } + + if len != code.len() { + return Err(ParseInviteCodeError::TooShort(len)); + } + + Ok(InviteCode(code)) + } +} + +impl Display for InviteCode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let s = self.as_str(); + write!(f, "{}-{}-{}", &s[0..4], &s[4..8], &s[8..12]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pepper(byte: u8) -> InvitePepper { + InvitePepper::new([byte; 32]) + } + + #[test] + fn spellings_of_one_code_hash_alike() { + let a: InviteCode = "7KQ2M9XD4HPA".parse().unwrap(); + let b: InviteCode = "7kq2-m9xd 4hpa".parse().unwrap(); + + assert_eq!(pepper(1).hash(&a), pepper(1).hash(&b)); + } + + #[test] + fn hash_depends_on_the_pepper() { + let code = InviteCode::generate(); + + assert_ne!(pepper(1).hash(&code), pepper(2).hash(&code)); + } + + #[test] + fn hash_is_hmac_sha256_of_the_canonical_code() { + let code: InviteCode = "7KQ2-M9XD-4HPA".parse().unwrap(); + let mut mac = Hmac::::new_from_slice(&[1; 32]).unwrap(); + mac.update(b"7KQ2M9XD4HPA"); + + assert_eq!( + pepper(1).hash(&code), + <[u8; 32]>::from(mac.finalize().into_bytes()) + ); + } +} diff --git a/rust/crates/keygo-server/src/domain/mod.rs b/rust/crates/keygo-server/src/domain/mod.rs index 8ef2a1ac8..22172e212 100644 --- a/rust/crates/keygo-server/src/domain/mod.rs +++ b/rust/crates/keygo-server/src/domain/mod.rs @@ -1,5 +1,7 @@ +mod invite_code; mod random_id; mod username; +pub use invite_code::{InviteCode, InvitePepper}; pub use random_id::{LoginId, RegistrationId}; pub use username::{Username, UsernameError}; diff --git a/rust/crates/keygo-server/src/secrets.rs b/rust/crates/keygo-server/src/secrets.rs index 7fbf15b44..8cdad8e78 100644 --- a/rust/crates/keygo-server/src/secrets.rs +++ b/rust/crates/keygo-server/src/secrets.rs @@ -1,3 +1,4 @@ +use crate::domain::InvitePepper; use anyhow::{Context, anyhow, ensure}; use keygo_opaque::server::ServerSetup; use secrecy::{ExposeSecret, SecretString}; @@ -100,9 +101,27 @@ impl TypedSecret for OpaqueSetup { } } +impl TypedSecret for InvitePepper { + const NAME: &'static str = "invite_pepper"; + type Value = Self; + + fn generate() -> anyhow::Result { + let mut raw = Zeroizing::new([0u8; 32]); + getrandom::fill(raw.as_mut_slice()).map_err(|e| anyhow!("getrandom: {}", e))?; + Ok(hex::encode(raw.as_slice()).into()) + } + + fn decode(stored: &str) -> anyhow::Result { + let raw = Zeroizing::new(hex::decode(stored).context("not hex")?); + let bytes: [u8; 32] = raw.as_slice().try_into().context("not 32 bytes")?; + Ok(Self::new(bytes)) + } +} + secrets! { DbPassword, OpaqueSetup, + InvitePepper, } #[derive(Debug, PartialEq, Eq)] @@ -270,6 +289,26 @@ mod tests { assert!(OpaqueSetup::decode("").is_err()); } + #[test] + fn generated_invite_pepper_is_valid() { + let value = Secret::InvitePepper.generate().unwrap(); + + assert!(InvitePepper::decode(value.expose_secret()).is_ok()); + } + + #[test] + fn invite_pepper_must_be_32_bytes() { + assert!(InvitePepper::decode(VALID).is_ok()); + assert!(InvitePepper::decode(&VALID[2..]).is_err()); + assert!(InvitePepper::decode(&format!("{VALID}00")).is_err()); + } + + #[test] + fn non_hex_invite_pepper_is_rejected() { + assert!(InvitePepper::decode("not hex at all").is_err()); + assert!(InvitePepper::decode("").is_err()); + } + #[test] fn create_then_read_round_trips() { let (_dir, store) = store(); From e5aa49d5d07f50a78bad2db95458488df2289043 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 17 Oct 2026 15:26:30 +0200 Subject: [PATCH 32/43] feat(invite): wire up invite create cli --- rust/Cargo.lock | 53 +++++++ ...c4071b61357d59fff68db6f184a15e7c89cb8.json | 41 ++++++ ...9d115cb4bcbba00e99be77e126008e44044cf.json | 12 ++ ...9878d527804121cbb798f25f4c04cc538b7b5.json | 16 ++ ...a77cf808bf60dd854a040e168ad0843138d2.json} | 4 +- ...1c68fc7ec51caaefa2eae62db88f6e851acf.json} | 4 +- ...5798363922530c2c4855836da1f9333a3241.json} | 4 +- ...f51449421754d38a3798b5874f754698f481.json} | 4 +- ...c81fe691a8ea66b24a19fdd918aacc525196.json} | 4 +- ...890749a3748d3d86950150c7ad6b8960973d3.json | 17 +++ ...d968330dc97e271c825020f724ca4783f4c2.json} | 4 +- ...dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json} | 4 +- ...ad106259f972a5a6d75a84838bda4c9c78cc.json} | 4 +- ...de07972a81fddbf2853cf3a4ab1f4fa20fc9c.json | 17 --- ...93a7b12da41c392fbf8f66c29a1c08531327.json} | 4 +- ...dcbed072889d1cc47d341a414202c26242362.json | 12 -- ...b32986fa08c450e22784ac82c70e33539e8a0.json | 15 -- ...f3c52fe9feaed0d48e40040b7c8c86bb396e3.json | 50 +++++++ ...a4348d29a8e9aacb43ceb19f2a9dd45fa6636.json | 16 -- ...9581b911f3cee8e447952d3f7b405edaf1863.json | 15 ++ rust/crates/keygo-server/Cargo.toml | 3 +- .../migrations/20261014141017_init.sql | 43 +++--- .../src/cli/commands/invite/create.rs | 90 ++++++++++-- .../src/cli/commands/invite/mod.rs | 8 + rust/crates/keygo-server/src/db/invites.rs | 137 ++++++++++++++++++ .../keygo-server/src/db/login_states.rs | 20 +-- rust/crates/keygo-server/src/db/mod.rs | 1 + .../keygo-server/src/db/opaque_records.rs | 18 +-- .../keygo-server/src/db/registrations.rs | 24 +-- rust/crates/keygo-server/src/db/users.rs | 10 +- .../keygo-server/src/domain/invite_code.rs | 9 +- .../keygo-server/src/domain/invite_hash.rs | 76 ++++++++++ .../keygo-server/src/domain/invite_id.rs | 86 +++++++++++ rust/crates/keygo-server/src/domain/mod.rs | 4 + .../keygo-server/src/service/invite/create.rs | 76 ++++++++++ .../keygo-server/src/service/invite/mod.rs | 24 +++ .../keygo-server/src/service/login/finish.rs | 2 +- .../keygo-server/src/service/login/start.rs | 2 +- rust/crates/keygo-server/src/service/mod.rs | 1 + .../src/service/registration/finish.rs | 4 +- rust/crates/keygo-server/src/testing.rs | 6 +- 41 files changed, 786 insertions(+), 158 deletions(-) create mode 100644 rust/crates/keygo-server/.sqlx/query-166dc6d92cd5b214dc8ba33d977c4071b61357d59fff68db6f184a15e7c89cb8.json create mode 100644 rust/crates/keygo-server/.sqlx/query-27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf.json create mode 100644 rust/crates/keygo-server/.sqlx/query-2fc338c23e43fd96308fd9748c19878d527804121cbb798f25f4c04cc538b7b5.json rename rust/crates/keygo-server/.sqlx/{query-20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce.json => query-43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2.json} (76%) rename rust/crates/keygo-server/.sqlx/{query-d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f.json => query-5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf.json} (76%) rename rust/crates/keygo-server/.sqlx/{query-76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff.json => query-5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241.json} (69%) rename rust/crates/keygo-server/.sqlx/{query-8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2.json => query-85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481.json} (55%) rename rust/crates/keygo-server/.sqlx/{query-549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4.json => query-999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196.json} (81%) create mode 100644 rust/crates/keygo-server/.sqlx/query-a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3.json rename rust/crates/keygo-server/.sqlx/{query-1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479.json => query-abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2.json} (65%) rename rust/crates/keygo-server/.sqlx/{query-af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720.json => query-b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json} (54%) rename rust/crates/keygo-server/.sqlx/{query-74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json => query-b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc.json} (50%) delete mode 100644 rust/crates/keygo-server/.sqlx/query-b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c.json rename rust/crates/keygo-server/.sqlx/{query-de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014.json => query-c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327.json} (75%) delete mode 100644 rust/crates/keygo-server/.sqlx/query-c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362.json delete mode 100644 rust/crates/keygo-server/.sqlx/query-d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0.json create mode 100644 rust/crates/keygo-server/.sqlx/query-e580eb63cd901168e03e9ff4497f3c52fe9feaed0d48e40040b7c8c86bb396e3.json delete mode 100644 rust/crates/keygo-server/.sqlx/query-ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636.json create mode 100644 rust/crates/keygo-server/.sqlx/query-f88e78950b1f8835063743f37529581b911f3cee8e447952d3f7b405edaf1863.json create mode 100644 rust/crates/keygo-server/src/db/invites.rs create mode 100644 rust/crates/keygo-server/src/domain/invite_hash.rs create mode 100644 rust/crates/keygo-server/src/domain/invite_id.rs create mode 100644 rust/crates/keygo-server/src/service/invite/create.rs create mode 100644 rust/crates/keygo-server/src/service/invite/mod.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d03fc2309..3a6757d10 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -800,6 +800,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive-where" version = "1.7.0" @@ -1620,6 +1626,7 @@ dependencies = [ "sqlx", "tempfile", "thiserror", + "time", "tokio", "tower", "tower-http", @@ -1791,6 +1798,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -2058,6 +2071,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "primefield" version = "0.14.0" @@ -2561,6 +2580,7 @@ dependencies = [ "sha2 0.10.9", "smallvec", "thiserror", + "time", "tokio", "tokio-stream", "tracing", @@ -2630,6 +2650,7 @@ dependencies = [ "sha2 0.11.1", "sqlx-core", "thiserror", + "time", "tracing", "uuid", ] @@ -2665,6 +2686,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "uuid", "whoami", @@ -2690,6 +2712,7 @@ dependencies = [ "serde", "sqlx-core", "thiserror", + "time", "tracing", "url", "uuid", @@ -2842,6 +2865,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" diff --git a/rust/crates/keygo-server/.sqlx/query-166dc6d92cd5b214dc8ba33d977c4071b61357d59fff68db6f184a15e7c89cb8.json b/rust/crates/keygo-server/.sqlx/query-166dc6d92cd5b214dc8ba33d977c4071b61357d59fff68db6f184a15e7c89cb8.json new file mode 100644 index 000000000..65a733a97 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-166dc6d92cd5b214dc8ba33d977c4071b61357d59fff68db6f184a15e7c89cb8.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into invites (code_hash, expires_at)\n values ($1, now() + make_interval(secs => $2))\n returning created_at, expires_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "invites", + "name": "created_at" + } + } + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "invites", + "name": "expires_at" + } + } + } + ], + "parameters": { + "Left": [ + "Bytea", + "Float8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "166dc6d92cd5b214dc8ba33d977c4071b61357d59fff68db6f184a15e7c89cb8" +} diff --git a/rust/crates/keygo-server/.sqlx/query-27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf.json b/rust/crates/keygo-server/.sqlx/query-27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf.json new file mode 100644 index 000000000..1b4a64e93 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from login_states where expires_at <= now()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf" +} diff --git a/rust/crates/keygo-server/.sqlx/query-2fc338c23e43fd96308fd9748c19878d527804121cbb798f25f4c04cc538b7b5.json b/rust/crates/keygo-server/.sqlx/query-2fc338c23e43fd96308fd9748c19878d527804121cbb798f25f4c04cc538b7b5.json new file mode 100644 index 000000000..a36301661 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-2fc338c23e43fd96308fd9748c19878d527804121cbb798f25f4c04cc538b7b5.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into opaque_records (user_id, suite, record, updated_at)\n values ($1, $2, $3, now())", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "2fc338c23e43fd96308fd9748c19878d527804121cbb798f25f4c04cc538b7b5" +} diff --git a/rust/crates/keygo-server/.sqlx/query-20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce.json b/rust/crates/keygo-server/.sqlx/query-43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2.json similarity index 76% rename from rust/crates/keygo-server/.sqlx/query-20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce.json rename to rust/crates/keygo-server/.sqlx/query-43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2.json index 841fe6412..349405893 100644 --- a/rust/crates/keygo-server/.sqlx/query-20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce.json +++ b/rust/crates/keygo-server/.sqlx/query-43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM login_states\n WHERE id = $1 AND purpose = $2 AND expires_at > now()\n RETURNING user_id, state", + "query": "delete from login_states\n where id = $1 and purpose = $2 and expires_at > now()\n returning user_id, state", "describe": { "columns": [ { @@ -37,5 +37,5 @@ false ] }, - "hash": "20a3c17f09385f8518a5e59b21ed3b17c37c8ef5d69e68670e7cccf69aafafce" + "hash": "43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2" } diff --git a/rust/crates/keygo-server/.sqlx/query-d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f.json b/rust/crates/keygo-server/.sqlx/query-5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf.json similarity index 76% rename from rust/crates/keygo-server/.sqlx/query-d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f.json rename to rust/crates/keygo-server/.sqlx/query-5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf.json index f0c31c8e1..c10469a9b 100644 --- a/rust/crates/keygo-server/.sqlx/query-d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f.json +++ b/rust/crates/keygo-server/.sqlx/query-5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM pending_registrations\n WHERE id = $1 AND expires_at > now()\n RETURNING user_id, username", + "query": "delete from pending_registrations\n where id = $1 and expires_at > now()\n returning user_id, username", "describe": { "columns": [ { @@ -36,5 +36,5 @@ false ] }, - "hash": "d94f57324fb5b110dd162ebf921b3490ec0792f4f5352b8fb06f0d78074e0c1f" + "hash": "5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf" } diff --git a/rust/crates/keygo-server/.sqlx/query-76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff.json b/rust/crates/keygo-server/.sqlx/query-5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241.json similarity index 69% rename from rust/crates/keygo-server/.sqlx/query-76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff.json rename to rust/crates/keygo-server/.sqlx/query-5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241.json index e8e1871c0..03363fa07 100644 --- a/rust/crates/keygo-server/.sqlx/query-76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff.json +++ b/rust/crates/keygo-server/.sqlx/query-5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1) AS \"exists!\"", + "query": "select exists(select 1 from users where id = $1) as \"exists!\"", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "76a7e92c144ac7ff3992987838d894bd58d2bf0e4f61101192fece85284d40ff" + "hash": "5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241" } diff --git a/rust/crates/keygo-server/.sqlx/query-8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2.json b/rust/crates/keygo-server/.sqlx/query-85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481.json similarity index 55% rename from rust/crates/keygo-server/.sqlx/query-8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2.json rename to rust/crates/keygo-server/.sqlx/query-85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481.json index 0c6271143..142d5eb8a 100644 --- a/rust/crates/keygo-server/.sqlx/query-8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2.json +++ b/rust/crates/keygo-server/.sqlx/query-85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO users (id, username) VALUES ($1, $2)", + "query": "insert into users (id, username) values ($1, $2)", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "8ae771c633837883a05e2716910260945e6a2eec1334071970e95bdeb4cf60d2" + "hash": "85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481" } diff --git a/rust/crates/keygo-server/.sqlx/query-549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4.json b/rust/crates/keygo-server/.sqlx/query-999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196.json similarity index 81% rename from rust/crates/keygo-server/.sqlx/query-549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4.json rename to rust/crates/keygo-server/.sqlx/query-999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196.json index 12d0c730b..16eeacb01 100644 --- a/rust/crates/keygo-server/.sqlx/query-549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4.json +++ b/rust/crates/keygo-server/.sqlx/query-999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT r.user_id, r.suite, r.record\n FROM opaque_records r JOIN users u ON u.id = r.user_id\n WHERE u.username = $1", + "query": "select r.user_id, r.suite, r.record\n from opaque_records r join users u on u.id = r.user_id\n where u.username = $1", "describe": { "columns": [ { @@ -48,5 +48,5 @@ false ] }, - "hash": "549204cb13cb17bac289cd3aaca8a1b90c12382a950eba28fae0b46dc7af76a4" + "hash": "999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196" } diff --git a/rust/crates/keygo-server/.sqlx/query-a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3.json b/rust/crates/keygo-server/.sqlx/query-a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3.json new file mode 100644 index 000000000..2ec334aca --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into pending_registrations (id, user_id, username, expires_at)\n values ($1, $2, $3, now() + make_interval(secs => $4))\n on conflict (user_id) do update\n set id = excluded.id, username = excluded.username, expires_at = excluded.expires_at", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "Uuid", + "Text", + "Float8" + ] + }, + "nullable": [] + }, + "hash": "a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3" +} diff --git a/rust/crates/keygo-server/.sqlx/query-1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479.json b/rust/crates/keygo-server/.sqlx/query-abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2.json similarity index 65% rename from rust/crates/keygo-server/.sqlx/query-1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479.json rename to rust/crates/keygo-server/.sqlx/query-abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2.json index a8e4d2adb..ed36d8fa3 100644 --- a/rust/crates/keygo-server/.sqlx/query-1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479.json +++ b/rust/crates/keygo-server/.sqlx/query-abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM users WHERE username = $1) AS \"exists!\"", + "query": "select exists(select 1 from users where username = $1) as \"exists!\"", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "1dcaf0dd04ffb42c41136b3852efc9b0a09e47c7356d00bc09185ea84c0ad479" + "hash": "abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2" } diff --git a/rust/crates/keygo-server/.sqlx/query-af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720.json b/rust/crates/keygo-server/.sqlx/query-b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json similarity index 54% rename from rust/crates/keygo-server/.sqlx/query-af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720.json rename to rust/crates/keygo-server/.sqlx/query-b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json index 4e1fd9e20..d07c09a55 100644 --- a/rust/crates/keygo-server/.sqlx/query-af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720.json +++ b/rust/crates/keygo-server/.sqlx/query-b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO login_states (id, user_id, purpose, state, expires_at)\n VALUES ($1, $2, $3, $4, now() + make_interval(secs => $5))", + "query": "insert into login_states (id, user_id, purpose, state, expires_at)\n values ($1, $2, $3, $4, now() + make_interval(secs => $5))", "describe": { "columns": [], "parameters": { @@ -14,5 +14,5 @@ }, "nullable": [] }, - "hash": "af7885fe63b1c3fe8221e3ac8d4fda559f58415c5ebf3b46b5a7e0dab7f6f720" + "hash": "b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc" } diff --git a/rust/crates/keygo-server/.sqlx/query-74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json b/rust/crates/keygo-server/.sqlx/query-b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc.json similarity index 50% rename from rust/crates/keygo-server/.sqlx/query-74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json rename to rust/crates/keygo-server/.sqlx/query-b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc.json index 5e7d073d5..7d47a12ee 100644 --- a/rust/crates/keygo-server/.sqlx/query-74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc.json +++ b/rust/crates/keygo-server/.sqlx/query-b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM pending_registrations WHERE expires_at <= now()", + "query": "delete from pending_registrations where expires_at <= now()", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "74d15fea7f4edfa1ddae7900c1a3e1c65e9d30df31b98e24c3ef7bf9edb159bc" + "hash": "b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc" } diff --git a/rust/crates/keygo-server/.sqlx/query-b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c.json b/rust/crates/keygo-server/.sqlx/query-b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c.json deleted file mode 100644 index 8833ce23c..000000000 --- a/rust/crates/keygo-server/.sqlx/query-b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO pending_registrations (id, user_id, username, expires_at)\n VALUES ($1, $2, $3, now() + make_interval(secs => $4))\n ON CONFLICT (user_id) DO UPDATE\n SET id = excluded.id, username = excluded.username, expires_at = excluded.expires_at", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Bytea", - "Uuid", - "Text", - "Float8" - ] - }, - "nullable": [] - }, - "hash": "b9c0bc0f4d667dfcca9d39738fade07972a81fddbf2853cf3a4ab1f4fa20fc9c" -} diff --git a/rust/crates/keygo-server/.sqlx/query-de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014.json b/rust/crates/keygo-server/.sqlx/query-c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327.json similarity index 75% rename from rust/crates/keygo-server/.sqlx/query-de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014.json rename to rust/crates/keygo-server/.sqlx/query-c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327.json index 1f8fbecac..985a6cecf 100644 --- a/rust/crates/keygo-server/.sqlx/query-de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014.json +++ b/rust/crates/keygo-server/.sqlx/query-c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT username FROM users WHERE id = $1", + "query": "select username from users where id = $1", "describe": { "columns": [ { @@ -24,5 +24,5 @@ false ] }, - "hash": "de3230de507ca1e11d2ca40bef8a5b8470628ddbaa454af4f49f6fe6953f9014" + "hash": "c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327" } diff --git a/rust/crates/keygo-server/.sqlx/query-c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362.json b/rust/crates/keygo-server/.sqlx/query-c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362.json deleted file mode 100644 index b44a524ca..000000000 --- a/rust/crates/keygo-server/.sqlx/query-c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM login_states WHERE expires_at <= now()", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "c993b03d1efe96ce760e39c44aadcbed072889d1cc47d341a414202c26242362" -} diff --git a/rust/crates/keygo-server/.sqlx/query-d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0.json b/rust/crates/keygo-server/.sqlx/query-d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0.json deleted file mode 100644 index 9abbd6497..000000000 --- a/rust/crates/keygo-server/.sqlx/query-d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM pending_registrations\n WHERE expires_at <= now() AND (username = $1 OR user_id = $2)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Uuid" - ] - }, - "nullable": [] - }, - "hash": "d466775c2062b7f3f136ba466c9b32986fa08c450e22784ac82c70e33539e8a0" -} diff --git a/rust/crates/keygo-server/.sqlx/query-e580eb63cd901168e03e9ff4497f3c52fe9feaed0d48e40040b7c8c86bb396e3.json b/rust/crates/keygo-server/.sqlx/query-e580eb63cd901168e03e9ff4497f3c52fe9feaed0d48e40040b7c8c86bb396e3.json new file mode 100644 index 000000000..1be76a09e --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-e580eb63cd901168e03e9ff4497f3c52fe9feaed0d48e40040b7c8c86bb396e3.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "select code_hash, created_at, expires_at from invites\n where expires_at > now()\n order by created_at, code_hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code_hash", + "type_info": "Bytea", + "origin": { + "Table": { + "table": "invites", + "name": "code_hash" + } + } + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "invites", + "name": "created_at" + } + } + }, + { + "ordinal": 2, + "name": "expires_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "invites", + "name": "expires_at" + } + } + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "e580eb63cd901168e03e9ff4497f3c52fe9feaed0d48e40040b7c8c86bb396e3" +} diff --git a/rust/crates/keygo-server/.sqlx/query-ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636.json b/rust/crates/keygo-server/.sqlx/query-ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636.json deleted file mode 100644 index 6d782d77b..000000000 --- a/rust/crates/keygo-server/.sqlx/query-ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO opaque_records (user_id, suite, record, updated_at)\n VALUES ($1, $2, $3, now())", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Bytea" - ] - }, - "nullable": [] - }, - "hash": "ed4ccda15c0f989ab852f1b0a2ea4348d29a8e9aacb43ceb19f2a9dd45fa6636" -} diff --git a/rust/crates/keygo-server/.sqlx/query-f88e78950b1f8835063743f37529581b911f3cee8e447952d3f7b405edaf1863.json b/rust/crates/keygo-server/.sqlx/query-f88e78950b1f8835063743f37529581b911f3cee8e447952d3f7b405edaf1863.json new file mode 100644 index 000000000..abf402d33 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-f88e78950b1f8835063743f37529581b911f3cee8e447952d3f7b405edaf1863.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from pending_registrations\n where expires_at <= now() and (username = $1 or user_id = $2)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f88e78950b1f8835063743f37529581b911f3cee8e447952d3f7b405edaf1863" +} diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 73c1f6eae..9e37059b6 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -25,11 +25,12 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tower-http = { version = "0.7.1", features = ["trace"] } uuid = { workspace = true, features = ["v4"] } -sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros", "uuid"] } +sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "postgres", "migrate", "macros", "uuid", "time"] } unicode-normalization = "0.1.25" humantime = "2.4.0" hmac = "0.13.0" sha2 = { version = "0.11.0", features = ["zeroize"] } +time = { version = "0.3.55", features = ["formatting", "macros"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/rust/crates/keygo-server/migrations/20261014141017_init.sql b/rust/crates/keygo-server/migrations/20261014141017_init.sql index 5887372f6..28de7200e 100644 --- a/rust/crates/keygo-server/migrations/20261014141017_init.sql +++ b/rust/crates/keygo-server/migrations/20261014141017_init.sql @@ -1,8 +1,15 @@ -CREATE TABLE users +create table users ( - id UUID PRIMARY KEY, -- client-generated, equals local user_id - username TEXT NOT NULL UNIQUE, -- normalized - created_at timestamptz NOT NULL DEFAULT now() + id uuid primary key, -- client-generated, equals local user_id + username text not null unique, -- normalized + created_at timestamptz not null default now() +); + +create table invites +( + code_hash bytea primary key, + created_at timestamptz not null default now(), + expires_at timestamptz not null ); create table opaque_records @@ -13,24 +20,22 @@ create table opaque_records updated_at timestamptz not null ); -CREATE UNLOGGED TABLE pending_registrations +create unlogged table pending_registrations ( - id bytea PRIMARY KEY, -- registration_id, 16 random bytes - user_id uuid NOT NULL UNIQUE, - username text NOT NULL UNIQUE, -- the reservation - -- invite_id text REFERENCES invites, - expires_at timestamptz NOT NULL + id bytea primary key, -- registration_id, 16 random bytes + user_id uuid not null unique, + username text not null unique, -- the reservation + -- invite_id text references invites, + expires_at timestamptz not null ); +create index pending_registrations_expires_at_idx on pending_registrations (expires_at); -CREATE INDEX pending_registrations_expires_at_idx ON pending_registrations (expires_at); - -CREATE UNLOGGED TABLE login_states +create unlogged table login_states ( - id bytea PRIMARY KEY, -- login_id, 16 random bytes + id bytea primary key, -- login_id, 16 random bytes user_id uuid, -- null on the fake path - purpose text NOT NULL CHECK (purpose IN ('login', 'step_up')), - state bytea NOT NULL, -- serialized ServerLogin - expires_at timestamptz NOT NULL + purpose text not null check (purpose in ('login', 'step_up')), + state bytea not null, -- serialized ServerLogin + expires_at timestamptz not null ); - -CREATE INDEX login_states_expires_at_idx ON login_states (expires_at); +create index login_states_expires_at_idx on login_states (expires_at); diff --git a/rust/crates/keygo-server/src/cli/commands/invite/create.rs b/rust/crates/keygo-server/src/cli/commands/invite/create.rs index bed55ba2d..459223912 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/create.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/create.rs @@ -1,33 +1,97 @@ +use super::{format_utc, with_db}; use crate::config::Config; -use crate::domain::InviteCode; +use crate::service::invite::{self, Created}; use anyhow::ensure; use clap::Args; use std::time::Duration; +const MIN_TTL: Duration = Duration::from_hours(24); +const MAX_TTL: Duration = Duration::from_hours(30 * 24); + #[derive(Args)] pub struct CreateInviteArgs { - #[arg(long, default_value = "1d", value_parser=ttl_parser)] + /// How long the invite stays redeemable, from 1 to 30 days, e.g. "72h" or "2weeks". + #[arg(long, default_value = "1d", value_parser = parse_ttl)] ttl: Duration, } -fn ttl_parser(s: &str) -> anyhow::Result { - let d = humantime::parse_duration(s)?; - ensure!(d >= Duration::from_hours(24), "ttl must be at least 1d"); +fn parse_ttl(s: &str) -> anyhow::Result { + let ttl = humantime::parse_duration(s)?; + ensure!( + ttl >= MIN_TTL, + "ttl must be at least {}", + humantime::format_duration(MIN_TTL) + ); ensure!( - d <= Duration::from_hours(30 * 24), - "ttl must be at most 30d" + ttl <= MAX_TTL, + "ttl must be at most {}", + humantime::format_duration(MAX_TTL) ); - Ok(d) + Ok(ttl) } pub fn run(config: &Config, args: &CreateInviteArgs) -> anyhow::Result<()> { - let code = InviteCode::generate(); + let pepper = config.invite_pepper()?; - { - let invite_pepper = config.invite_pepper()?; - let hash = invite_pepper.hash(&code); + if config.allow_signups { + eprintln!("warning: sign-up is open, so registration will not ask for this invite"); } - println!("{}", code); + let created = with_db(config, async |db| { + Ok(invite::create(db, &pepper, args.ttl).await?) + })?; + + println!("{}", summary(&created)); Ok(()) } + +fn summary(created: &Created) -> String { + let Created { code, invite } = created; + format!( + "Invite {code} expires {} id {}", + format_utc(invite.expires_at), + invite.id, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::InviteId; + use crate::service::invite::Invite; + use time::macros::datetime; + + #[test] + fn ttl_accepts_its_bounds() { + assert_eq!(parse_ttl("1d").unwrap(), MIN_TTL); + assert_eq!(parse_ttl("30d").unwrap(), MAX_TTL); + } + + #[test] + fn ttl_outside_its_bounds_is_rejected() { + assert!(parse_ttl("23h").is_err()); + assert!(parse_ttl("31d").is_err()); + } + + #[test] + fn unparseable_ttl_is_rejected() { + assert!(parse_ttl("soon").is_err()); + } + + #[test] + fn summary_shows_code_expiry_and_id() { + let created = Created { + code: "7KQ2M9XD4HPA".parse().unwrap(), + invite: Invite { + id: InviteId::new(&[0xa3; 32].into(), &[]), + created_at: datetime!(2026-10-17 08:30 UTC), + expires_at: datetime!(2026-10-18 08:30 UTC), + }, + }; + + assert_eq!( + summary(&created), + "Invite 7KQ2-M9XD-4HPA expires 2026-10-18 08:30 UTC id a3a3a" + ); + } +} diff --git a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs index d4ebc33de..83fd67d88 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs @@ -2,7 +2,15 @@ pub mod create; use crate::cli::commands::invite::create::CreateInviteArgs; use crate::config::Config; +use crate::db; use clap::Subcommand; +use sqlx::PgPool; +use time::format_description::BorrowedFormatItem; +use time::macros::format_description; +use time::{OffsetDateTime, UtcOffset}; + +const UTC_MINUTES: &[BorrowedFormatItem] = + format_description!("[year]-[month]-[day] [hour]:[minute] UTC"); #[derive(Subcommand)] pub enum InviteCommands { diff --git a/rust/crates/keygo-server/src/db/invites.rs b/rust/crates/keygo-server/src/db/invites.rs new file mode 100644 index 000000000..19c0588a3 --- /dev/null +++ b/rust/crates/keygo-server/src/db/invites.rs @@ -0,0 +1,137 @@ +use super::corrupt; +use crate::domain::InviteHash; +use sqlx::PgConnection; +use std::time::Duration; +use time::OffsetDateTime; + +#[derive(Debug)] +pub struct OpenInvite { + pub code_hash: InviteHash, + pub created_at: OffsetDateTime, + pub expires_at: OffsetDateTime, +} + +pub async fn insert( + conn: &mut PgConnection, + code_hash: &InviteHash, + ttl: Duration, +) -> sqlx::Result { + let row = sqlx::query!( + "insert into invites (code_hash, expires_at) + values ($1, now() + make_interval(secs => $2)) + returning created_at, expires_at", + code_hash.as_bytes().as_slice(), + ttl.as_secs_f64(), + ) + .fetch_one(conn) + .await?; + + Ok(OpenInvite { + code_hash: *code_hash, + created_at: row.created_at, + expires_at: row.expires_at, + }) +} + +pub async fn list(conn: &mut PgConnection) -> sqlx::Result> { + let rows = sqlx::query!( + "select code_hash, created_at, expires_at from invites + where expires_at > now() + order by created_at, code_hash", + ) + .fetch_all(conn) + .await?; + + rows.into_iter() + .map(|row| { + Ok(OpenInvite { + code_hash: row.code_hash.as_slice().try_into().map_err(corrupt)?, + created_at: row.created_at, + expires_at: row.expires_at, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{count, expire, expires_in_secs}; + use sqlx::PgPool; + + const TTL: Duration = Duration::from_hours(24); + + fn hash(byte: u8) -> InviteHash { + [byte; 32].into() + } + + #[sqlx::test] + async fn invite_stores_its_hash(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + + insert(&mut conn, &hash(7), TTL).await.unwrap(); + + let stored: Vec = sqlx::query_scalar("select code_hash from invites") + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(stored, [7; 32]); + } + + #[sqlx::test] + async fn invite_expires_after_ttl(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + + let invite = insert(&mut conn, &hash(7), TTL).await.unwrap(); + + assert_eq!(invite.expires_at - invite.created_at, TTL); + let remaining = expires_in_secs(&db, "invites").await; + assert!( + (TTL.as_secs_f64() - 5.0..=TTL.as_secs_f64()).contains(&remaining), + "{remaining}" + ); + } + + #[sqlx::test] + async fn duplicate_hash_is_rejected(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, &hash(7), TTL).await.unwrap(); + + let err = insert(&mut conn, &hash(7), TTL).await.unwrap_err(); + + assert!(err.as_database_error().unwrap().is_unique_violation()); + assert_eq!(count(&db, "invites").await, 1); + } + + #[sqlx::test] + async fn list_returns_open_invites_oldest_first(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, &hash(1), TTL).await.unwrap(); + insert(&mut conn, &hash(2), TTL).await.unwrap(); + sqlx::query( + "update invites set created_at = created_at - interval '1 hour' where code_hash = $1", + ) + .bind([2u8; 32].as_slice()) + .execute(&db) + .await + .unwrap(); + + let hashes: Vec<_> = list(&mut conn) + .await + .unwrap() + .into_iter() + .map(|invite| invite.code_hash) + .collect(); + + assert_eq!(hashes, [hash(2), hash(1)]); + } + + #[sqlx::test] + async fn list_skips_expired_invites(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, &hash(7), TTL).await.unwrap(); + expire(&db, "invites").await; + + assert!(list(&mut conn).await.unwrap().is_empty()); + } +} diff --git a/rust/crates/keygo-server/src/db/login_states.rs b/rust/crates/keygo-server/src/db/login_states.rs index dc0a839e0..ff2e542f0 100644 --- a/rust/crates/keygo-server/src/db/login_states.rs +++ b/rust/crates/keygo-server/src/db/login_states.rs @@ -29,8 +29,8 @@ pub async fn insert( ) -> sqlx::Result<()> { let state = state.to_bytes(); sqlx::query!( - "INSERT INTO login_states (id, user_id, purpose, state, expires_at) - VALUES ($1, $2, $3, $4, now() + make_interval(secs => $5))", + "insert into login_states (id, user_id, purpose, state, expires_at) + values ($1, $2, $3, $4, now() + make_interval(secs => $5))", login_id.as_bytes().as_slice(), user_id, purpose.as_str(), @@ -57,9 +57,9 @@ pub async fn take( purpose: Purpose, ) -> sqlx::Result> { let Some(row) = sqlx::query!( - "DELETE FROM login_states - WHERE id = $1 AND purpose = $2 AND expires_at > now() - RETURNING user_id, state", + "delete from login_states + where id = $1 and purpose = $2 and expires_at > now() + returning user_id, state", login_id.as_bytes().as_slice(), purpose.as_str(), ) @@ -79,7 +79,7 @@ pub async fn take( } pub async fn delete_expired(conn: &mut PgConnection) -> sqlx::Result { - let result = sqlx::query!("DELETE FROM login_states WHERE expires_at <= now()") + let result = sqlx::query!("delete from login_states where expires_at <= now()") .execute(conn) .await?; @@ -231,8 +231,8 @@ mod tests { async fn state_of_another_purpose_is_not_taken(db: PgPool) { let login_id = LoginId::random(); sqlx::query( - "INSERT INTO login_states (id, purpose, state, expires_at) - VALUES ($1, 'step_up', $2, now() + interval '1 minute')", + "insert into login_states (id, purpose, state, expires_at) + values ($1, 'step_up', $2, now() + interval '1 minute')", ) .bind(login_id.as_bytes().as_slice()) .bind(server_login().to_bytes().as_slice()) @@ -254,8 +254,8 @@ mod tests { async fn undecodable_state_is_corrupt(db: PgPool) { let login_id = LoginId::random(); sqlx::query( - "INSERT INTO login_states (id, purpose, state, expires_at) - VALUES ($1, 'login', '\\x00', now() + interval '1 minute')", + "insert into login_states (id, purpose, state, expires_at) + values ($1, 'login', '\\x00', now() + interval '1 minute')", ) .bind(login_id.as_bytes().as_slice()) .execute(&db) diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs index aeab498f1..d73b38fa4 100644 --- a/rust/crates/keygo-server/src/db/mod.rs +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -1,3 +1,4 @@ +pub mod invites; pub mod login_states; pub mod opaque_records; pub mod registrations; diff --git a/rust/crates/keygo-server/src/db/opaque_records.rs b/rust/crates/keygo-server/src/db/opaque_records.rs index 778a73b6f..f09625550 100644 --- a/rust/crates/keygo-server/src/db/opaque_records.rs +++ b/rust/crates/keygo-server/src/db/opaque_records.rs @@ -12,8 +12,8 @@ pub async fn insert( ) -> sqlx::Result<()> { let record = file.to_bytes(); sqlx::query!( - "INSERT INTO opaque_records (user_id, suite, record, updated_at) - VALUES ($1, $2, $3, now())", + "insert into opaque_records (user_id, suite, record, updated_at) + values ($1, $2, $3, now())", user_id, SUITE_ID, record.as_slice(), @@ -34,9 +34,9 @@ pub async fn find_by_username( username: &Username, ) -> sqlx::Result> { let Some(row) = sqlx::query!( - "SELECT r.user_id, r.suite, r.record - FROM opaque_records r JOIN users u ON u.id = r.user_id - WHERE u.username = $1", + "select r.user_id, r.suite, r.record + from opaque_records r join users u on u.id = r.user_id + where u.username = $1", username.as_str(), ) .fetch_optional(conn) @@ -89,8 +89,8 @@ mod tests { async fn insert_raw(db: &PgPool, user_id: Uuid, suite: &str, record: &[u8]) { sqlx::query( - "INSERT INTO opaque_records (user_id, suite, record, updated_at) - VALUES ($1, $2, $3, now())", + "insert into opaque_records (user_id, suite, record, updated_at) + values ($1, $2, $3, now())", ) .bind(user_id) .bind(suite) @@ -124,7 +124,7 @@ mod tests { .await .unwrap(); - let suite: String = sqlx::query_scalar("SELECT suite FROM opaque_records") + let suite: String = sqlx::query_scalar("select suite from opaque_records") .fetch_one(&db) .await .unwrap(); @@ -163,7 +163,7 @@ mod tests { .await .unwrap(); - sqlx::query("DELETE FROM users WHERE id = $1") + sqlx::query("delete from users where id = $1") .bind(user_id) .execute(&db) .await diff --git a/rust/crates/keygo-server/src/db/registrations.rs b/rust/crates/keygo-server/src/db/registrations.rs index f82804262..39bbf08c9 100644 --- a/rust/crates/keygo-server/src/db/registrations.rs +++ b/rust/crates/keygo-server/src/db/registrations.rs @@ -38,8 +38,8 @@ pub async fn reserve( ttl: Duration, ) -> Result<(), ReserveError> { sqlx::query!( - "DELETE FROM pending_registrations - WHERE expires_at <= now() AND (username = $1 OR user_id = $2)", + "delete from pending_registrations + where expires_at <= now() and (username = $1 or user_id = $2)", username.as_str(), user_id, ) @@ -49,10 +49,10 @@ pub async fn reserve( // A concurrent reserve that wins between the sweep and here surfaces as a unique violation on // username, or as a takeover on user_id, so the two statements need no surrounding transaction. sqlx::query!( - "INSERT INTO pending_registrations (id, user_id, username, expires_at) - VALUES ($1, $2, $3, now() + make_interval(secs => $4)) - ON CONFLICT (user_id) DO UPDATE - SET id = excluded.id, username = excluded.username, expires_at = excluded.expires_at", + "insert into pending_registrations (id, user_id, username, expires_at) + values ($1, $2, $3, now() + make_interval(secs => $4)) + on conflict (user_id) do update + set id = excluded.id, username = excluded.username, expires_at = excluded.expires_at", registration_id.as_bytes().as_slice(), user_id, username.as_str(), @@ -76,9 +76,9 @@ pub async fn take( registration_id: &RegistrationId, ) -> sqlx::Result> { let Some(row) = sqlx::query!( - "DELETE FROM pending_registrations - WHERE id = $1 AND expires_at > now() - RETURNING user_id, username", + "delete from pending_registrations + where id = $1 and expires_at > now() + returning user_id, username", registration_id.as_bytes().as_slice(), ) .fetch_optional(conn) @@ -96,7 +96,7 @@ pub async fn take( } pub async fn delete_expired(conn: &mut PgConnection) -> sqlx::Result { - let result = sqlx::query!("DELETE FROM pending_registrations WHERE expires_at <= now()") + let result = sqlx::query!("delete from pending_registrations where expires_at <= now()") .execute(conn) .await?; @@ -237,8 +237,8 @@ mod tests { async fn stored_invalid_username_is_corrupt(db: PgPool) { let registration_id = RegistrationId::random(); sqlx::query( - "INSERT INTO pending_registrations (id, user_id, username, expires_at) - VALUES ($1, $2, 'NOT VALID', now() + interval '1 minute')", + "insert into pending_registrations (id, user_id, username, expires_at) + values ($1, $2, 'NOT VALID', now() + interval '1 minute')", ) .bind(registration_id.as_bytes().as_slice()) .bind(Uuid::new_v4()) diff --git a/rust/crates/keygo-server/src/db/users.rs b/rust/crates/keygo-server/src/db/users.rs index a04184148..64ac74a32 100644 --- a/rust/crates/keygo-server/src/db/users.rs +++ b/rust/crates/keygo-server/src/db/users.rs @@ -5,7 +5,7 @@ use uuid::Uuid; pub async fn username_exists(conn: &mut PgConnection, username: &Username) -> sqlx::Result { sqlx::query_scalar!( - r#"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1) AS "exists!""#, + r#"select exists(select 1 from users where username = $1) as "exists!""#, username.as_str(), ) .fetch_one(conn) @@ -14,7 +14,7 @@ pub async fn username_exists(conn: &mut PgConnection, username: &Username) -> sq pub async fn id_exists(conn: &mut PgConnection, user_id: Uuid) -> sqlx::Result { sqlx::query_scalar!( - r#"SELECT EXISTS(SELECT 1 FROM users WHERE id = $1) AS "exists!""#, + r#"select exists(select 1 from users where id = $1) as "exists!""#, user_id, ) .fetch_one(conn) @@ -27,7 +27,7 @@ pub async fn insert( username: &Username, ) -> sqlx::Result<()> { sqlx::query!( - "INSERT INTO users (id, username) VALUES ($1, $2)", + "insert into users (id, username) values ($1, $2)", user_id, username.as_str(), ) @@ -41,7 +41,7 @@ pub async fn find_username( conn: &mut PgConnection, user_id: Uuid, ) -> sqlx::Result> { - let Some(username) = sqlx::query_scalar!("SELECT username FROM users WHERE id = $1", user_id) + let Some(username) = sqlx::query_scalar!("select username from users where id = $1", user_id) .fetch_optional(conn) .await? else { @@ -102,7 +102,7 @@ mod tests { #[sqlx::test] async fn stored_invalid_username_is_corrupt(db: PgPool) { let user_id = Uuid::new_v4(); - sqlx::query("INSERT INTO users (id, username) VALUES ($1, 'NOT VALID')") + sqlx::query("insert into users (id, username) values ($1, 'NOT VALID')") .bind(user_id) .execute(&db) .await diff --git a/rust/crates/keygo-server/src/domain/invite_code.rs b/rust/crates/keygo-server/src/domain/invite_code.rs index 5fdbcf73b..b1805a53c 100644 --- a/rust/crates/keygo-server/src/domain/invite_code.rs +++ b/rust/crates/keygo-server/src/domain/invite_code.rs @@ -1,3 +1,4 @@ +use super::InviteHash; use hmac::{Hmac, KeyInit, Mac}; use sha2::Sha256; use std::fmt::{Display, Formatter}; @@ -14,11 +15,11 @@ impl InvitePepper { Self(Zeroizing::new(bytes)) } - pub fn hash(&self, code: &InviteCode) -> [u8; 32] { + pub fn hash(&self, code: &InviteCode) -> InviteHash { let mut mac = Hmac::::new_from_slice(self.0.as_slice()).expect("HMAC accepts any key length"); mac.update(&code.0); - mac.finalize().into_bytes().into() + <[u8; 32]>::from(mac.finalize().into_bytes()).into() } } @@ -118,8 +119,8 @@ mod tests { mac.update(b"7KQ2M9XD4HPA"); assert_eq!( - pepper(1).hash(&code), - <[u8; 32]>::from(mac.finalize().into_bytes()) + pepper(1).hash(&code).as_bytes(), + &<[u8; 32]>::from(mac.finalize().into_bytes()) ); } } diff --git a/rust/crates/keygo-server/src/domain/invite_hash.rs b/rust/crates/keygo-server/src/domain/invite_hash.rs new file mode 100644 index 000000000..ea2e22051 --- /dev/null +++ b/rust/crates/keygo-server/src/domain/invite_hash.rs @@ -0,0 +1,76 @@ +use std::array::TryFromSliceError; +use std::fmt::{self, Display, Formatter}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct InviteHash([u8; 32]); + +impl InviteHash { + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn shared_hex_digits(&self, other: &Self) -> usize { + self.hex_digits() + .zip(other.hex_digits()) + .take_while(|(a, b)| a == b) + .count() + } + + fn hex_digits(&self) -> impl Iterator { + self.0.iter().flat_map(|byte| [byte >> 4, byte & 0x0F]) + } +} + +impl From<[u8; 32]> for InviteHash { + fn from(bytes: [u8; 32]) -> Self { + Self(bytes) + } +} + +impl TryFrom<&[u8]> for InviteHash { + type Error = TryFromSliceError; + + fn try_from(bytes: &[u8]) -> Result { + bytes.try_into().map(Self) + } +} + +impl Display for InviteHash { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(self.0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn displays_as_lowercase_hex() { + let hash = InviteHash::from([0xab; 32]); + + assert_eq!(hash.to_string(), "ab".repeat(32)); + } + + #[test] + fn shared_digits_count_half_bytes() { + let a = InviteHash::from([0xaa; 32]); + let mut b = [0xaa; 32]; + b[1] = 0xab; + + assert_eq!(a.shared_hex_digits(&b.into()), 3); + } + + #[test] + fn equal_hashes_share_every_digit() { + let a = InviteHash::from([0xaa; 32]); + + assert_eq!(a.shared_hex_digits(&a), 64); + } + + #[test] + fn only_32_bytes_convert() { + assert!(InviteHash::try_from([0; 31].as_slice()).is_err()); + assert!(InviteHash::try_from([0; 32].as_slice()).is_ok()); + } +} diff --git a/rust/crates/keygo-server/src/domain/invite_id.rs b/rust/crates/keygo-server/src/domain/invite_id.rs new file mode 100644 index 000000000..40367cf44 --- /dev/null +++ b/rust/crates/keygo-server/src/domain/invite_id.rs @@ -0,0 +1,86 @@ +use super::InviteHash; +use std::fmt::{self, Display, Formatter}; + +const MIN_LEN: usize = 5; + +/// The invite's hash in hex, cut where no other open invite shares the prefix. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InviteId(String); + +impl InviteId { + pub fn new(hash: &InviteHash, open: &[InviteHash]) -> Self { + let shared = open + .iter() + .filter(|other| *other != hash) + .map(|other| hash.shared_hex_digits(other)) + .max() + .unwrap_or(0); + + let mut id = hash.to_string(); + id.truncate((shared + 1).clamp(MIN_LEN, id.len())); + Self(id) + } +} + +impl Display for InviteId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hash(prefix: &[u8]) -> InviteHash { + let mut hash = [0; 32]; + hash[..prefix.len()].copy_from_slice(prefix); + hash.into() + } + + fn id(hash: InviteHash, open: &[InviteHash]) -> String { + InviteId::new(&hash, open).to_string() + } + + #[test] + fn lone_invite_gets_the_minimum_length() { + let a = hash(&[0x5b, 0xe2, 0x0f]); + + assert_eq!(id(a, &[a]), "5be20"); + } + + #[test] + fn distinct_prefixes_stay_at_the_minimum_length() { + let a = hash(&[0x5b, 0xe2, 0x0f]); + let b = hash(&[0xe8, 0x1d, 0x4f]); + + assert_eq!(id(a, &[a, b]), "5be20"); + } + + #[test] + fn shared_prefix_grows_to_the_first_difference() { + let a = hash(&[0xa3, 0xf9, 0xc1, 0x70]); + let b = hash(&[0xa3, 0xf9, 0xc1, 0xe0]); + + assert_eq!(id(a, &[a, b]), "a3f9c17"); + assert_eq!(id(b, &[a, b]), "a3f9c1e"); + } + + #[test] + fn longest_shared_prefix_decides() { + let a = hash(&[0xa3, 0xf9, 0xc1, 0x70]); + let near = hash(&[0xa3, 0xf9, 0xc1, 0x7f]); + let far = hash(&[0xa3, 0xf9, 0x00]); + + assert_eq!(id(a, &[far, a, near]), "a3f9c170"); + } + + #[test] + fn hashes_differing_only_in_the_last_char_need_all_64() { + let a = [0xaa; 32]; + let mut b = a; + b[31] = 0xab; + + assert_eq!(id(a.into(), &[a.into(), b.into()]).len(), 64); + } +} diff --git a/rust/crates/keygo-server/src/domain/mod.rs b/rust/crates/keygo-server/src/domain/mod.rs index 22172e212..833299199 100644 --- a/rust/crates/keygo-server/src/domain/mod.rs +++ b/rust/crates/keygo-server/src/domain/mod.rs @@ -1,7 +1,11 @@ mod invite_code; +mod invite_hash; +mod invite_id; mod random_id; mod username; pub use invite_code::{InviteCode, InvitePepper}; +pub use invite_hash::InviteHash; +pub use invite_id::InviteId; pub use random_id::{LoginId, RegistrationId}; pub use username::{Username, UsernameError}; diff --git a/rust/crates/keygo-server/src/service/invite/create.rs b/rust/crates/keygo-server/src/service/invite/create.rs new file mode 100644 index 000000000..958c61e1b --- /dev/null +++ b/rust/crates/keygo-server/src/service/invite/create.rs @@ -0,0 +1,76 @@ +use super::Invite; +use crate::db::invites; +use crate::domain::{InviteCode, InvitePepper}; +use sqlx::PgPool; +use std::time::Duration; + +pub struct Created { + pub code: InviteCode, + pub invite: Invite, +} + +pub async fn create(db: &PgPool, pepper: &InvitePepper, ttl: Duration) -> sqlx::Result { + let code = InviteCode::generate(); + let mut conn = db.acquire().await?; + let stored = invites::insert(&mut conn, &pepper.hash(&code), ttl).await?; + + let open: Vec<_> = invites::list(&mut conn) + .await? + .into_iter() + .map(|invite| invite.code_hash) + .collect(); + + Ok(Created { + code, + invite: Invite::new(stored, &open), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::count; + + const TTL: Duration = Duration::from_hours(24); + + fn pepper() -> InvitePepper { + InvitePepper::new([1; 32]) + } + + #[sqlx::test] + async fn stores_the_peppered_hash_of_the_code(db: PgPool) { + let created = create(&db, &pepper(), TTL).await.unwrap(); + + let stored: Vec = sqlx::query_scalar("select code_hash from invites") + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(stored, pepper().hash(&created.code).as_bytes()); + } + + #[sqlx::test] + async fn every_invite_gets_its_own_code(db: PgPool) { + let a = create(&db, &pepper(), TTL).await.unwrap(); + let b = create(&db, &pepper(), TTL).await.unwrap(); + + assert!(a.code != b.code); + assert_eq!(count(&db, "invites").await, 2); + } + + #[sqlx::test] + async fn id_is_a_prefix_of_the_stored_hash(db: PgPool) { + let created = create(&db, &pepper(), TTL).await.unwrap(); + + let hash = pepper().hash(&created.code).to_string(); + let id = created.invite.id.to_string(); + assert!(hash.starts_with(&id)); + assert_eq!(id.len(), 5); + } + + #[sqlx::test] + async fn expiry_is_ttl_after_creation(db: PgPool) { + let created = create(&db, &pepper(), TTL).await.unwrap(); + + assert_eq!(created.invite.expires_at - created.invite.created_at, TTL); + } +} diff --git a/rust/crates/keygo-server/src/service/invite/mod.rs b/rust/crates/keygo-server/src/service/invite/mod.rs new file mode 100644 index 000000000..5d88a9227 --- /dev/null +++ b/rust/crates/keygo-server/src/service/invite/mod.rs @@ -0,0 +1,24 @@ +mod create; + +pub use create::*; + +use crate::db::invites::OpenInvite; +use crate::domain::{InviteHash, InviteId}; +use time::OffsetDateTime; + +#[derive(Debug)] +pub struct Invite { + pub id: InviteId, + pub created_at: OffsetDateTime, + pub expires_at: OffsetDateTime, +} + +impl Invite { + fn new(invite: OpenInvite, open: &[InviteHash]) -> Self { + Self { + id: InviteId::new(&invite.code_hash, open), + created_at: invite.created_at, + expires_at: invite.expires_at, + } + } +} diff --git a/rust/crates/keygo-server/src/service/login/finish.rs b/rust/crates/keygo-server/src/service/login/finish.rs index 606499f97..4bbb67742 100644 --- a/rust/crates/keygo-server/src/service/login/finish.rs +++ b/rust/crates/keygo-server/src/service/login/finish.rs @@ -171,7 +171,7 @@ mod tests { let setup = ServerSetup::generate(); let user_id = register(&db, &setup, "ada").await; let (client, started) = start_login(&db, &setup, "ada").await; - sqlx::query("DELETE FROM users WHERE id = $1") + sqlx::query("delete from users where id = $1") .bind(user_id) .execute(&db) .await diff --git a/rust/crates/keygo-server/src/service/login/start.rs b/rust/crates/keygo-server/src/service/login/start.rs index 1085c626a..1575df89a 100644 --- a/rust/crates/keygo-server/src/service/login/start.rs +++ b/rust/crates/keygo-server/src/service/login/start.rs @@ -96,7 +96,7 @@ mod tests { } async fn stored_user_ids(db: &PgPool) -> Vec> { - sqlx::query_scalar("SELECT user_id FROM login_states") + sqlx::query_scalar("select user_id from login_states") .fetch_all(db) .await .unwrap() diff --git a/rust/crates/keygo-server/src/service/mod.rs b/rust/crates/keygo-server/src/service/mod.rs index 9f50879b0..4c204a554 100644 --- a/rust/crates/keygo-server/src/service/mod.rs +++ b/rust/crates/keygo-server/src/service/mod.rs @@ -1,2 +1,3 @@ +pub mod invite; pub mod login; pub mod registration; diff --git a/rust/crates/keygo-server/src/service/registration/finish.rs b/rust/crates/keygo-server/src/service/registration/finish.rs index 9ac902e2f..28d800fb4 100644 --- a/rust/crates/keygo-server/src/service/registration/finish.rs +++ b/rust/crates/keygo-server/src/service/registration/finish.rs @@ -93,7 +93,7 @@ mod tests { assert_eq!(finished.username, username("ada")); assert_eq!(user_count(&db).await, (1, 1, 0)); - let stored: (Uuid, String) = sqlx::query_as("SELECT id, username FROM users") + let stored: (Uuid, String) = sqlx::query_as("select id, username from users") .fetch_one(&db) .await .unwrap(); @@ -147,7 +147,7 @@ mod tests { async fn failed_finish_keeps_the_reservation(db: PgPool) { let user_id = Uuid::new_v4(); let input = started(&db, user_id, "ada").await; - sqlx::query("INSERT INTO users (id, username) VALUES ($1, 'squatter')") + sqlx::query("insert into users (id, username) values ($1, 'squatter')") .bind(user_id) .execute(&db) .await diff --git a/rust/crates/keygo-server/src/testing.rs b/rust/crates/keygo-server/src/testing.rs index 7e5a4c539..e436b6cf3 100644 --- a/rust/crates/keygo-server/src/testing.rs +++ b/rust/crates/keygo-server/src/testing.rs @@ -62,14 +62,14 @@ pub fn finish_login(client: ClientLogin, started: login::Started) -> FinishLogin } pub async fn count(db: &PgPool, table: &str) -> i64 { - sqlx::query_scalar(AssertSqlSafe(format!("SELECT count(*) FROM {table}"))) + sqlx::query_scalar(AssertSqlSafe(format!("select count(*) from {table}"))) .fetch_one(db) .await .unwrap() } pub async fn expire(db: &PgPool, table: &str) { sqlx::query(AssertSqlSafe(format!( - "UPDATE {table} SET expires_at = now() - interval '1 second'" + "update {table} set expires_at = now() - interval '1 second'" ))) .execute(db) .await @@ -77,7 +77,7 @@ pub async fn expire(db: &PgPool, table: &str) { } pub async fn expires_in_secs(db: &PgPool, table: &str) -> f64 { sqlx::query_scalar(AssertSqlSafe(format!( - "SELECT extract(epoch FROM max(expires_at) - now())::float8 FROM {table}" + "select extract(epoch from max(expires_at) - now())::float8 from {table}" ))) .fetch_one(db) .await From d904337faabcc8622d556c5622cca0ba570aa385 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sat, 17 Oct 2026 18:05:15 +0200 Subject: [PATCH 33/43] feat(invite): implement invite list command --- ...fd9734c20a49da9825a2ea65e255432a42ddb.json | 12 ++ .../src/cli/commands/invite/list.rs | 104 ++++++++++++++++++ .../src/cli/commands/invite/mod.rs | 22 ++++ rust/crates/keygo-server/src/db/invites.rs | 37 +++++++ .../crates/keygo-server/src/server/sweeper.rs | 24 +++- .../keygo-server/src/service/invite/list.rs | 64 +++++++++++ .../keygo-server/src/service/invite/mod.rs | 2 + 7 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 rust/crates/keygo-server/.sqlx/query-b03b8820877b00693ea9e489f6dfd9734c20a49da9825a2ea65e255432a42ddb.json create mode 100644 rust/crates/keygo-server/src/cli/commands/invite/list.rs create mode 100644 rust/crates/keygo-server/src/service/invite/list.rs diff --git a/rust/crates/keygo-server/.sqlx/query-b03b8820877b00693ea9e489f6dfd9734c20a49da9825a2ea65e255432a42ddb.json b/rust/crates/keygo-server/.sqlx/query-b03b8820877b00693ea9e489f6dfd9734c20a49da9825a2ea65e255432a42ddb.json new file mode 100644 index 000000000..5f64f41f4 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-b03b8820877b00693ea9e489f6dfd9734c20a49da9825a2ea65e255432a42ddb.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from invites where expires_at <= now()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "b03b8820877b00693ea9e489f6dfd9734c20a49da9825a2ea65e255432a42ddb" +} diff --git a/rust/crates/keygo-server/src/cli/commands/invite/list.rs b/rust/crates/keygo-server/src/cli/commands/invite/list.rs new file mode 100644 index 000000000..987b5a9ef --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/invite/list.rs @@ -0,0 +1,104 @@ +use super::{format_utc, with_db}; +use crate::config::Config; +use crate::service::invite::{self, Invite}; + +pub fn run(config: &Config) -> anyhow::Result<()> { + let invites = with_db(config, async |db| Ok(invite::list(db).await?))?; + println!("{}", table(&invites)); + Ok(()) +} + +fn table(invites: &[Invite]) -> String { + if invites.is_empty() { + return "No open invites".into(); + } + + let ids: Vec<_> = invites.iter().map(|invite| invite.id.to_string()).collect(); + let width = ids + .iter() + .map(String::len) + .chain(["ID".len()]) + .max() + .unwrap_or(0); + + let mut table = format!("{: InviteHash { + let mut hash = [0; 32]; + hash[..prefix.len()].copy_from_slice(prefix); + hash.into() + } + + fn invite( + hash: InviteHash, + open: &[InviteHash], + created_at: OffsetDateTime, + expires_at: OffsetDateTime, + ) -> Invite { + Invite { + id: InviteId::new(&hash, open), + created_at, + expires_at, + } + } + + #[test] + fn table_pads_ids_to_the_longest() { + let a = hash(&[0xa3, 0xf9, 0xc1, 0x70]); + let b = hash(&[0xa3, 0xf9, 0xc1, 0xe0]); + let c = hash(&[0x5b]); + let open = [a, b, c]; + let invites = [ + invite( + a, + &open, + datetime!(2026-10-17 08:30 UTC), + datetime!(2026-10-18 08:30 UTC), + ), + invite( + b, + &open, + datetime!(2026-10-17 09:12 UTC), + datetime!(2026-10-24 09:12 UTC), + ), + invite( + c, + &open, + datetime!(2026-10-17 10:00 +02:00), + datetime!(2026-11-16 10:00 +02:00), + ), + ]; + + assert_eq!( + table(&invites), + [ + "ID CREATED EXPIRES", + "a3f9c17 2026-10-17 08:30 UTC 2026-10-18 08:30 UTC", + "a3f9c1e 2026-10-17 09:12 UTC 2026-10-24 09:12 UTC", + "5b000 2026-10-17 08:00 UTC 2026-11-16 08:00 UTC", + ] + .join("\n"), + ); + } + + #[test] + fn empty_table_says_so() { + assert_eq!(table(&[]), "No open invites"); + } +} diff --git a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs index 83fd67d88..7fd2fd94f 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs @@ -1,4 +1,5 @@ pub mod create; +pub mod list; use crate::cli::commands::invite::create::CreateInviteArgs; use crate::config::Config; @@ -15,10 +16,31 @@ const UTC_MINUTES: &[BorrowedFormatItem] = #[derive(Subcommand)] pub enum InviteCommands { Create(CreateInviteArgs), + List, } pub fn run(config: &Config, commands: InviteCommands) -> anyhow::Result<()> { match commands { InviteCommands::Create(args) => create::run(config, &args), + InviteCommands::List => list::run(config), } } + +fn format_utc(at: OffsetDateTime) -> String { + at.to_offset(UtcOffset::UTC) + .format(UTC_MINUTES) + .expect("an OffsetDateTime has every component the format needs") +} + +fn with_db( + config: &Config, + task: impl AsyncFnOnce(&PgPool) -> anyhow::Result, +) -> anyhow::Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let db = db::connect(config).await?; + task(&db).await + }) +} diff --git a/rust/crates/keygo-server/src/db/invites.rs b/rust/crates/keygo-server/src/db/invites.rs index 19c0588a3..8f756f5d8 100644 --- a/rust/crates/keygo-server/src/db/invites.rs +++ b/rust/crates/keygo-server/src/db/invites.rs @@ -33,6 +33,14 @@ pub async fn insert( }) } +pub async fn delete_expired(conn: &mut PgConnection) -> sqlx::Result { + let result = sqlx::query!("delete from invites where expires_at <= now()") + .execute(conn) + .await?; + + Ok(result.rows_affected()) +} + pub async fn list(conn: &mut PgConnection) -> sqlx::Result> { let rows = sqlx::query!( "select code_hash, created_at, expires_at from invites @@ -103,6 +111,35 @@ mod tests { assert_eq!(count(&db, "invites").await, 1); } + #[sqlx::test] + async fn delete_expired_keeps_open_invites(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, &hash(1), TTL).await.unwrap(); + expire(&db, "invites").await; + insert(&mut conn, &hash(2), TTL).await.unwrap(); + + assert_eq!(delete_expired(&mut conn).await.unwrap(), 1); + assert_eq!(count(&db, "invites").await, 1); + let open: Vec<_> = list(&mut conn) + .await + .unwrap() + .into_iter() + .map(|invite| invite.code_hash) + .collect(); + assert_eq!(open, [hash(2)]); + } + + #[sqlx::test] + async fn list_keeps_expired_rows_in_the_table(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, &hash(7), TTL).await.unwrap(); + expire(&db, "invites").await; + + list(&mut conn).await.unwrap(); + + assert_eq!(count(&db, "invites").await, 1); + } + #[sqlx::test] async fn list_returns_open_invites_oldest_first(db: PgPool) { let mut conn = db.acquire().await.unwrap(); diff --git a/rust/crates/keygo-server/src/server/sweeper.rs b/rust/crates/keygo-server/src/server/sweeper.rs index 4b8c25c4d..cfe68d658 100644 --- a/rust/crates/keygo-server/src/server/sweeper.rs +++ b/rust/crates/keygo-server/src/server/sweeper.rs @@ -1,4 +1,4 @@ -use crate::db::{login_states, registrations}; +use crate::db::{invites, login_states, registrations}; use sqlx::PgPool; use std::time::Duration; use tokio::time::MissedTickBehavior; @@ -7,7 +7,7 @@ use tokio::time::MissedTickBehavior; /// `expires_at`, so this bounds only how long they occupy the table, not how long they are valid. const INTERVAL: Duration = Duration::from_mins(1); -/// Deletes expired registrations and login states until the task is aborted. A start that is +/// Deletes expired registrations, login states and invites until the task is aborted. A start that is /// never finished (a wrong password fails at the client, which then never calls `finish`) leaves /// its row behind, so without this the tables would only grow. pub async fn run(db: PgPool) { @@ -26,13 +26,15 @@ async fn sweep(db: &PgPool) -> sqlx::Result<()> { let mut conn = db.acquire().await?; let registrations = registrations::delete_expired(&mut conn).await?; let logins = login_states::delete_expired(&mut conn).await?; - tracing::debug!(registrations, logins, "swept expired rows"); + let invites = invites::delete_expired(&mut conn).await?; + tracing::debug!(registrations, logins, invites, "swept expired rows"); Ok(()) } #[cfg(test)] mod tests { use super::*; + use crate::domain::InviteHash; use crate::service::registration::{self, StartRegistration}; use crate::testing::{count, expire, start_login}; use keygo_opaque::client::ClientRegistration; @@ -49,20 +51,35 @@ mod tests { registration::start(db, setup, input).await.unwrap(); } + async fn create_invite(db: &PgPool, byte: u8) { + let mut conn = db.acquire().await.unwrap(); + invites::insert( + &mut conn, + &InviteHash::from([byte; 32]), + Duration::from_hours(24), + ) + .await + .unwrap(); + } + #[sqlx::test] async fn sweep_deletes_only_expired_rows(db: PgPool) { let setup = ServerSetup::generate(); start_registration(&db, &setup, "ada").await; start_login(&db, &setup, "ada").await; + create_invite(&db, 1).await; expire(&db, "pending_registrations").await; expire(&db, "login_states").await; + expire(&db, "invites").await; start_registration(&db, &setup, "bob").await; start_login(&db, &setup, "bob").await; + create_invite(&db, 2).await; sweep(&db).await.unwrap(); assert_eq!(count(&db, "pending_registrations").await, 1); assert_eq!(count(&db, "login_states").await, 1); + assert_eq!(count(&db, "invites").await, 1); } #[sqlx::test] @@ -71,5 +88,6 @@ mod tests { assert_eq!(count(&db, "pending_registrations").await, 0); assert_eq!(count(&db, "login_states").await, 0); + assert_eq!(count(&db, "invites").await, 0); } } diff --git a/rust/crates/keygo-server/src/service/invite/list.rs b/rust/crates/keygo-server/src/service/invite/list.rs new file mode 100644 index 000000000..69b787e7f --- /dev/null +++ b/rust/crates/keygo-server/src/service/invite/list.rs @@ -0,0 +1,64 @@ +use super::Invite; +use crate::db::invites; +use sqlx::PgPool; + +pub async fn list(db: &PgPool) -> sqlx::Result> { + let mut conn = db.acquire().await?; + let open = invites::list(&mut conn).await?; + let hashes: Vec<_> = open.iter().map(|invite| invite.code_hash).collect(); + + Ok(open + .into_iter() + .map(|invite| Invite::new(invite, &hashes)) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::InviteHash; + use std::time::Duration; + + const TTL: Duration = Duration::from_hours(24); + + fn hash(prefix: &[u8]) -> InviteHash { + let mut hash = [0; 32]; + hash[..prefix.len()].copy_from_slice(prefix); + hash.into() + } + + async fn insert(db: &PgPool, hash: InviteHash) { + let mut conn = db.acquire().await.unwrap(); + invites::insert(&mut conn, &hash, TTL).await.unwrap(); + } + + fn ids(invites: &[Invite]) -> Vec { + invites.iter().map(|invite| invite.id.to_string()).collect() + } + + #[sqlx::test] + async fn ids_grow_until_they_are_unique(db: PgPool) { + insert(&db, hash(&[0xa3, 0xf9, 0xc1, 0x70])).await; + insert(&db, hash(&[0xa3, 0xf9, 0xc1, 0xe0])).await; + + let mut ids = ids(&list(&db).await.unwrap()); + ids.sort(); + + assert_eq!(ids, ["a3f9c17", "a3f9c1e"]); + } + + #[sqlx::test] + async fn keeps_the_oldest_first_order(db: PgPool) { + insert(&db, hash(&[0x11])).await; + insert(&db, hash(&[0x22])).await; + sqlx::query( + "update invites set created_at = created_at - interval '1 hour' where code_hash = $1", + ) + .bind(hash(&[0x22]).as_bytes().as_slice()) + .execute(&db) + .await + .unwrap(); + + assert_eq!(ids(&list(&db).await.unwrap()), ["22000", "11000"]); + } +} diff --git a/rust/crates/keygo-server/src/service/invite/mod.rs b/rust/crates/keygo-server/src/service/invite/mod.rs index 5d88a9227..354e240c0 100644 --- a/rust/crates/keygo-server/src/service/invite/mod.rs +++ b/rust/crates/keygo-server/src/service/invite/mod.rs @@ -1,6 +1,8 @@ mod create; +mod list; pub use create::*; +pub use list::*; use crate::db::invites::OpenInvite; use crate::domain::{InviteHash, InviteId}; From 01abb229e4bb8f4b9337d9cae328a0e7984d9467 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 18 Oct 2026 00:21:34 +0200 Subject: [PATCH 34/43] feat(invite): implement invite revoke command --- ...56496517c056cae271415399ea589624db619.json | 52 ++++++++ ...bfbb00e5af72c5de0617118c578820fa4180d.json | 14 ++ .../src/cli/commands/invite/list.rs | 38 +++--- .../src/cli/commands/invite/mod.rs | 13 ++ .../src/cli/commands/invite/revoke.rs | 72 ++++++++++ rust/crates/keygo-server/src/db/invites.rs | 64 +++++++-- .../keygo-server/src/domain/invite_id.rs | 66 +++++++++- rust/crates/keygo-server/src/main.rs | 11 +- .../keygo-server/src/service/invite/create.rs | 2 +- .../keygo-server/src/service/invite/list.rs | 23 +--- .../keygo-server/src/service/invite/mod.rs | 12 +- .../keygo-server/src/service/invite/revoke.rs | 123 ++++++++++++++++++ rust/crates/keygo-server/src/testing.rs | 16 ++- 13 files changed, 441 insertions(+), 65 deletions(-) create mode 100644 rust/crates/keygo-server/.sqlx/query-0ecd651215f1d57e78d41540fcb56496517c056cae271415399ea589624db619.json create mode 100644 rust/crates/keygo-server/.sqlx/query-dfaa54ca5ac1b1ec09f8fd90535bfbb00e5af72c5de0617118c578820fa4180d.json create mode 100644 rust/crates/keygo-server/src/cli/commands/invite/revoke.rs create mode 100644 rust/crates/keygo-server/src/service/invite/revoke.rs diff --git a/rust/crates/keygo-server/.sqlx/query-0ecd651215f1d57e78d41540fcb56496517c056cae271415399ea589624db619.json b/rust/crates/keygo-server/.sqlx/query-0ecd651215f1d57e78d41540fcb56496517c056cae271415399ea589624db619.json new file mode 100644 index 000000000..594922a33 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-0ecd651215f1d57e78d41540fcb56496517c056cae271415399ea589624db619.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "select code_hash, created_at, expires_at\n from invites\n where expires_at > now() and starts_with(encode(code_hash, 'hex'), $1)\n order by created_at, code_hash\n for update", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code_hash", + "type_info": "Bytea", + "origin": { + "Table": { + "table": "invites", + "name": "code_hash" + } + } + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "invites", + "name": "created_at" + } + } + }, + { + "ordinal": 2, + "name": "expires_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "invites", + "name": "expires_at" + } + } + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "0ecd651215f1d57e78d41540fcb56496517c056cae271415399ea589624db619" +} diff --git a/rust/crates/keygo-server/.sqlx/query-dfaa54ca5ac1b1ec09f8fd90535bfbb00e5af72c5de0617118c578820fa4180d.json b/rust/crates/keygo-server/.sqlx/query-dfaa54ca5ac1b1ec09f8fd90535bfbb00e5af72c5de0617118c578820fa4180d.json new file mode 100644 index 000000000..1960000f2 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-dfaa54ca5ac1b1ec09f8fd90535bfbb00e5af72c5de0617118c578820fa4180d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from invites where code_hash = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "dfaa54ca5ac1b1ec09f8fd90535bfbb00e5af72c5de0617118c578820fa4180d" +} diff --git a/rust/crates/keygo-server/src/cli/commands/invite/list.rs b/rust/crates/keygo-server/src/cli/commands/invite/list.rs index 987b5a9ef..9e5332605 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/list.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/list.rs @@ -1,6 +1,7 @@ -use super::{format_utc, with_db}; +use super::{format_utc, id_width, with_db}; use crate::config::Config; use crate::service::invite::{self, Invite}; +use std::iter; pub fn run(config: &Config) -> anyhow::Result<()> { let invites = with_db(config, async |db| Ok(invite::list(db).await?))?; @@ -13,38 +14,31 @@ fn table(invites: &[Invite]) -> String { return "No open invites".into(); } - let ids: Vec<_> = invites.iter().map(|invite| invite.id.to_string()).collect(); - let width = ids - .iter() - .map(String::len) - .chain(["ID".len()]) - .max() - .unwrap_or(0); - - let mut table = format!("{:>() + .join("\n") } #[cfg(test)] mod tests { use super::*; use crate::domain::{InviteHash, InviteId}; + use crate::testing::invite_hash as hash; use time::OffsetDateTime; use time::macros::datetime; - fn hash(prefix: &[u8]) -> InviteHash { - let mut hash = [0; 32]; - hash[..prefix.len()].copy_from_slice(prefix); - hash.into() - } - fn invite( hash: InviteHash, open: &[InviteHash], diff --git a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs index 7fd2fd94f..eaf398b12 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs @@ -1,9 +1,12 @@ pub mod create; pub mod list; +pub mod revoke; use crate::cli::commands::invite::create::CreateInviteArgs; +use crate::cli::commands::invite::revoke::RevokeInviteArgs; use crate::config::Config; use crate::db; +use crate::service::invite::Invite; use clap::Subcommand; use sqlx::PgPool; use time::format_description::BorrowedFormatItem; @@ -17,12 +20,14 @@ const UTC_MINUTES: &[BorrowedFormatItem] = pub enum InviteCommands { Create(CreateInviteArgs), List, + Revoke(RevokeInviteArgs), } pub fn run(config: &Config, commands: InviteCommands) -> anyhow::Result<()> { match commands { InviteCommands::Create(args) => create::run(config, &args), InviteCommands::List => list::run(config), + InviteCommands::Revoke(args) => revoke::run(config, &args), } } @@ -32,6 +37,14 @@ fn format_utc(at: OffsetDateTime) -> String { .expect("an OffsetDateTime has every component the format needs") } +fn id_width(invites: &[Invite]) -> usize { + invites + .iter() + .map(|invite| invite.id.as_str().len()) + .max() + .unwrap_or(0) +} + fn with_db( config: &Config, task: impl AsyncFnOnce(&PgPool) -> anyhow::Result, diff --git a/rust/crates/keygo-server/src/cli/commands/invite/revoke.rs b/rust/crates/keygo-server/src/cli/commands/invite/revoke.rs new file mode 100644 index 000000000..2096df7ac --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/invite/revoke.rs @@ -0,0 +1,72 @@ +use super::{format_utc, id_width, with_db}; +use crate::config::Config; +use crate::domain::InviteId; +use crate::service::invite::{self, Invite, RevokeError}; +use anyhow::anyhow; +use clap::Args; + +#[derive(Args)] +pub struct RevokeInviteArgs { + /// Any prefix of the invite's hash, 5 to 64 hex characters, such as the id `invite list` prints. + id: InviteId, +} + +pub fn run(config: &Config, args: &RevokeInviteArgs) -> anyhow::Result<()> { + with_db(config, async |db| { + invite::revoke(db, &args.id).await.map_err(|err| match err { + RevokeError::Ambiguous { ref matches, .. } => { + anyhow!("{err}:\n{}", candidates(matches)) + } + err => err.into(), + }) + })?; + + println!("Revoked {}", args.id); + Ok(()) +} + +fn candidates(invites: &[Invite]) -> String { + let width = id_width(invites); + invites + .iter() + .map(|invite| { + format!( + " {:>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::invite_hash as hash; + use time::macros::datetime; + + #[test] + fn candidates_line_up_their_dates() { + let a = hash(&[0xa3, 0xf9, 0xc1, 0x70]); + let b = hash(&[0xa3, 0xf9, 0xc1, 0x7e]); + let open = [a, b]; + let invite = |hash, created_at| Invite { + id: InviteId::new(&hash, &open), + created_at, + expires_at: datetime!(2026-10-30 00:00 UTC), + }; + + assert_eq!( + candidates(&[ + invite(a, datetime!(2026-10-16 14:03 UTC)), + invite(b, datetime!(2026-10-17 08:30 UTC)), + ]), + [ + " a3f9c170 created 2026-10-16 14:03 UTC", + " a3f9c17e created 2026-10-17 08:30 UTC", + ] + .join("\n"), + ); + } +} diff --git a/rust/crates/keygo-server/src/db/invites.rs b/rust/crates/keygo-server/src/db/invites.rs index 8f756f5d8..93e64646c 100644 --- a/rust/crates/keygo-server/src/db/invites.rs +++ b/rust/crates/keygo-server/src/db/invites.rs @@ -1,5 +1,5 @@ use super::corrupt; -use crate::domain::InviteHash; +use crate::domain::{InviteHash, InviteId}; use sqlx::PgConnection; use std::time::Duration; use time::OffsetDateTime; @@ -11,6 +11,28 @@ pub struct OpenInvite { pub expires_at: OffsetDateTime, } +struct InviteRow { + code_hash: Vec, + created_at: OffsetDateTime, + expires_at: OffsetDateTime, +} + +impl TryFrom for OpenInvite { + type Error = sqlx::Error; + + fn try_from(row: InviteRow) -> sqlx::Result { + Ok(Self { + code_hash: row.code_hash.as_slice().try_into().map_err(corrupt)?, + created_at: row.created_at, + expires_at: row.expires_at, + }) + } +} + +fn open_invites(rows: Vec) -> sqlx::Result> { + rows.into_iter().map(OpenInvite::try_from).collect() +} + pub async fn insert( conn: &mut PgConnection, code_hash: &InviteHash, @@ -41,8 +63,20 @@ pub async fn delete_expired(conn: &mut PgConnection) -> sqlx::Result { Ok(result.rows_affected()) } +pub async fn delete(conn: &mut PgConnection, hash: &InviteHash) -> sqlx::Result { + let result = sqlx::query!( + "delete from invites where code_hash = $1", + hash.as_bytes().as_slice(), + ) + .execute(conn) + .await?; + + Ok(result.rows_affected()) +} + pub async fn list(conn: &mut PgConnection) -> sqlx::Result> { - let rows = sqlx::query!( + let rows = sqlx::query_as!( + InviteRow, "select code_hash, created_at, expires_at from invites where expires_at > now() order by created_at, code_hash", @@ -50,15 +84,23 @@ pub async fn list(conn: &mut PgConnection) -> sqlx::Result> { .fetch_all(conn) .await?; - rows.into_iter() - .map(|row| { - Ok(OpenInvite { - code_hash: row.code_hash.as_slice().try_into().map_err(corrupt)?, - created_at: row.created_at, - expires_at: row.expires_at, - }) - }) - .collect() + open_invites(rows) +} + +pub async fn find_by_id(conn: &mut PgConnection, id: &InviteId) -> sqlx::Result> { + let rows = sqlx::query_as!( + InviteRow, + "select code_hash, created_at, expires_at + from invites + where expires_at > now() and starts_with(encode(code_hash, 'hex'), $1) + order by created_at, code_hash + for update", + id.as_str(), + ) + .fetch_all(conn) + .await?; + + open_invites(rows) } #[cfg(test)] diff --git a/rust/crates/keygo-server/src/domain/invite_id.rs b/rust/crates/keygo-server/src/domain/invite_id.rs index 40367cf44..05fafa213 100644 --- a/rust/crates/keygo-server/src/domain/invite_id.rs +++ b/rust/crates/keygo-server/src/domain/invite_id.rs @@ -1,7 +1,10 @@ use super::InviteHash; use std::fmt::{self, Display, Formatter}; +use std::str::FromStr; +use thiserror::Error; const MIN_LEN: usize = 5; +const MAX_LEN: usize = 64; /// The invite's hash in hex, cut where no other open invite shares the prefix. #[derive(Clone, Debug, PartialEq, Eq)] @@ -20,6 +23,39 @@ impl InviteId { id.truncate((shared + 1).clamp(MIN_LEN, id.len())); Self(id) } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Error)] +pub enum ParseInviteIdError { + #[error("must be at least {MIN_LEN} hex characters, got {0}")] + TooShort(usize), + #[error("must be at most {MAX_LEN} hex characters, got {0}")] + TooLong(usize), + #[error("{0:?} is not a hex digit")] + InvalidCharacter(char), +} + +impl FromStr for InviteId { + type Err = ParseInviteIdError; + + fn from_str(s: &str) -> Result { + let id = s.trim().to_ascii_lowercase(); + if let Some(c) = id.chars().find(|c| !c.is_ascii_hexdigit()) { + return Err(ParseInviteIdError::InvalidCharacter(c)); + } + if id.len() < MIN_LEN { + return Err(ParseInviteIdError::TooShort(id.len())); + } + if id.len() > MAX_LEN { + return Err(ParseInviteIdError::TooLong(id.len())); + } + + Ok(Self(id)) + } } impl Display for InviteId { @@ -31,12 +67,7 @@ impl Display for InviteId { #[cfg(test)] mod tests { use super::*; - - fn hash(prefix: &[u8]) -> InviteHash { - let mut hash = [0; 32]; - hash[..prefix.len()].copy_from_slice(prefix); - hash.into() - } + use crate::testing::invite_hash as hash; fn id(hash: InviteHash, open: &[InviteHash]) -> String { InviteId::new(&hash, open).to_string() @@ -83,4 +114,27 @@ mod tests { assert_eq!(id(a.into(), &[a.into(), b.into()]).len(), 64); } + + #[test] + fn parses_odd_length_ids_case_insensitively() { + let id: InviteId = " A3F9C17 ".parse().unwrap(); + + assert_eq!(id.to_string(), "a3f9c17"); + } + + #[test] + fn parse_rejects_ids_list_never_prints() { + assert!(matches!( + "a3f9".parse::(), + Err(ParseInviteIdError::TooShort(4)) + )); + assert!(matches!( + "a".repeat(65).parse::(), + Err(ParseInviteIdError::TooLong(65)) + )); + assert!(matches!( + "a3f9%".parse::(), + Err(ParseInviteIdError::InvalidCharacter('%')) + )); + } } diff --git a/rust/crates/keygo-server/src/main.rs b/rust/crates/keygo-server/src/main.rs index 29e15a1b8..917c3f918 100644 --- a/rust/crates/keygo-server/src/main.rs +++ b/rust/crates/keygo-server/src/main.rs @@ -1,4 +1,5 @@ use crate::cli::Cli; +use std::process::ExitCode; mod api; mod cli; @@ -11,6 +12,12 @@ mod service; #[cfg(test)] mod testing; -fn main() -> anyhow::Result<()> { - Cli::start() +fn main() -> ExitCode { + match Cli::start() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err:?}"); + ExitCode::FAILURE + } + } } diff --git a/rust/crates/keygo-server/src/service/invite/create.rs b/rust/crates/keygo-server/src/service/invite/create.rs index 958c61e1b..44a948167 100644 --- a/rust/crates/keygo-server/src/service/invite/create.rs +++ b/rust/crates/keygo-server/src/service/invite/create.rs @@ -22,7 +22,7 @@ pub async fn create(db: &PgPool, pepper: &InvitePepper, ttl: Duration) -> sqlx:: Ok(Created { code, - invite: Invite::new(stored, &open), + invite: Invite::new(&stored, &open), }) } diff --git a/rust/crates/keygo-server/src/service/invite/list.rs b/rust/crates/keygo-server/src/service/invite/list.rs index 69b787e7f..0a89c2a0d 100644 --- a/rust/crates/keygo-server/src/service/invite/list.rs +++ b/rust/crates/keygo-server/src/service/invite/list.rs @@ -5,32 +5,13 @@ use sqlx::PgPool; pub async fn list(db: &PgPool) -> sqlx::Result> { let mut conn = db.acquire().await?; let open = invites::list(&mut conn).await?; - let hashes: Vec<_> = open.iter().map(|invite| invite.code_hash).collect(); - - Ok(open - .into_iter() - .map(|invite| Invite::new(invite, &hashes)) - .collect()) + Ok(Invite::all(&open)) } #[cfg(test)] mod tests { use super::*; - use crate::domain::InviteHash; - use std::time::Duration; - - const TTL: Duration = Duration::from_hours(24); - - fn hash(prefix: &[u8]) -> InviteHash { - let mut hash = [0; 32]; - hash[..prefix.len()].copy_from_slice(prefix); - hash.into() - } - - async fn insert(db: &PgPool, hash: InviteHash) { - let mut conn = db.acquire().await.unwrap(); - invites::insert(&mut conn, &hash, TTL).await.unwrap(); - } + use crate::testing::{insert_invite as insert, invite_hash as hash}; fn ids(invites: &[Invite]) -> Vec { invites.iter().map(|invite| invite.id.to_string()).collect() diff --git a/rust/crates/keygo-server/src/service/invite/mod.rs b/rust/crates/keygo-server/src/service/invite/mod.rs index 354e240c0..ebfcabb96 100644 --- a/rust/crates/keygo-server/src/service/invite/mod.rs +++ b/rust/crates/keygo-server/src/service/invite/mod.rs @@ -1,8 +1,10 @@ mod create; mod list; +mod revoke; pub use create::*; pub use list::*; +pub use revoke::*; use crate::db::invites::OpenInvite; use crate::domain::{InviteHash, InviteId}; @@ -16,11 +18,19 @@ pub struct Invite { } impl Invite { - fn new(invite: OpenInvite, open: &[InviteHash]) -> Self { + fn new(invite: &OpenInvite, open: &[InviteHash]) -> Self { Self { id: InviteId::new(&invite.code_hash, open), created_at: invite.created_at, expires_at: invite.expires_at, } } + + fn all(open: &[OpenInvite]) -> Vec { + let hashes: Vec<_> = open.iter().map(|invite| invite.code_hash).collect(); + + open.iter() + .map(|invite| Self::new(invite, &hashes)) + .collect() + } } diff --git a/rust/crates/keygo-server/src/service/invite/revoke.rs b/rust/crates/keygo-server/src/service/invite/revoke.rs new file mode 100644 index 000000000..975615328 --- /dev/null +++ b/rust/crates/keygo-server/src/service/invite/revoke.rs @@ -0,0 +1,123 @@ +use super::Invite; +use crate::db::invites; +use crate::domain::InviteId; +use sqlx::PgPool; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RevokeError { + #[error("no open invite matches {0}")] + NotFound(InviteId), + #[error("{id} matches {} open invites, nothing revoked", matches.len())] + Ambiguous { id: InviteId, matches: Vec }, + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +pub async fn revoke(db: &PgPool, invite_id: &InviteId) -> Result<(), RevokeError> { + let not_found = || RevokeError::NotFound(invite_id.clone()); + + let mut tx = db.begin().await?; + let matches = invites::find_by_id(&mut tx, invite_id).await?; + let hash = match matches.as_slice() { + [] => return Err(not_found()), + [invite] => invite.code_hash, + _ => { + return Err(RevokeError::Ambiguous { + id: invite_id.clone(), + matches: Invite::all(&matches), + }); + } + }; + + if invites::delete(&mut tx, &hash).await? == 0 { + return Err(not_found()); + } + tx.commit().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::InviteHash; + use crate::testing::{count, expire, insert_invite as insert, invite_hash as hash}; + + fn id(s: &str) -> InviteId { + s.parse().unwrap() + } + + async fn open_hashes(db: &PgPool) -> Vec { + let mut conn = db.acquire().await.unwrap(); + invites::list(&mut conn) + .await + .unwrap() + .into_iter() + .map(|invite| invite.code_hash) + .collect() + } + + #[sqlx::test] + async fn deletes_the_matching_invite_only(db: PgPool) { + insert(&db, hash(&[0xa3, 0xf9, 0xc1, 0x70])).await; + insert(&db, hash(&[0xa3, 0xf9, 0xc1, 0xe0])).await; + + revoke(&db, &id("a3f9c17")).await.unwrap(); + + assert_eq!(open_hashes(&db).await, [hash(&[0xa3, 0xf9, 0xc1, 0xe0])]); + } + + #[sqlx::test] + async fn full_hash_revokes(db: PgPool) { + let full = hash(&[0xa3, 0xf9, 0xc1, 0x70]); + insert(&db, full).await; + + revoke(&db, &id(&full.to_string())).await.unwrap(); + + assert_eq!(count(&db, "invites").await, 0); + } + + #[sqlx::test] + async fn shared_prefix_is_ambiguous(db: PgPool) { + insert(&db, hash(&[0xa3, 0xf9, 0xc1, 0x70])).await; + insert(&db, hash(&[0xa3, 0xf9, 0xc1, 0xe0])).await; + + let err = revoke(&db, &id("a3f9c")).await.unwrap_err(); + + assert!(matches!(err, RevokeError::Ambiguous { .. })); + assert_eq!(count(&db, "invites").await, 2); + } + + #[sqlx::test] + async fn expired_invite_does_not_make_an_id_ambiguous(db: PgPool) { + insert(&db, hash(&[0xa3, 0xf9, 0xc1, 0xe0])).await; + expire(&db, "invites").await; + insert(&db, hash(&[0xa3, 0xf9, 0xc1, 0x70])).await; + + revoke(&db, &id("a3f9c")).await.unwrap(); + + assert!(open_hashes(&db).await.is_empty()); + assert_eq!(count(&db, "invites").await, 1); + } + + #[sqlx::test] + async fn unknown_id_is_not_found(db: PgPool) { + insert(&db, hash(&[0x11])).await; + + let err = revoke(&db, &id("22000")).await.unwrap_err(); + + assert!(matches!(err, RevokeError::NotFound(_))); + assert_eq!(count(&db, "invites").await, 1); + } + + #[sqlx::test] + async fn expired_invite_is_not_found(db: PgPool) { + insert(&db, hash(&[0x11])).await; + expire(&db, "invites").await; + + let err = revoke(&db, &id("11000")).await.unwrap_err(); + + assert!(matches!(err, RevokeError::NotFound(_))); + assert_eq!(count(&db, "invites").await, 1); + } +} diff --git a/rust/crates/keygo-server/src/testing.rs b/rust/crates/keygo-server/src/testing.rs index e436b6cf3..5ac4312d6 100644 --- a/rust/crates/keygo-server/src/testing.rs +++ b/rust/crates/keygo-server/src/testing.rs @@ -1,5 +1,6 @@ use crate::api::{AppState, router}; -use crate::domain::Username; +use crate::db::invites; +use crate::domain::{InviteHash, Username}; use crate::service::login::{self, FinishLogin, StartLogin}; use crate::service::registration::{self, FinishRegistration, StartRegistration}; use axum::body::Body; @@ -9,6 +10,7 @@ use keygo_opaque::client::{ClientLogin, ClientRegistration}; use keygo_opaque::server::ServerSetup; use serde_json::Value; use sqlx::{AssertSqlSafe, PgPool}; +use std::time::Duration; use tower::ServiceExt; use uuid::Uuid; @@ -61,6 +63,18 @@ pub fn finish_login(client: ClientLogin, started: login::Started) -> FinishLogin } } +pub fn invite_hash(prefix: &[u8]) -> InviteHash { + let mut hash = [0; 32]; + hash[..prefix.len()].copy_from_slice(prefix); + hash.into() +} +pub async fn insert_invite(db: &PgPool, hash: InviteHash) { + let mut conn = db.acquire().await.unwrap(); + invites::insert(&mut conn, &hash, Duration::from_hours(24)) + .await + .unwrap(); +} + pub async fn count(db: &PgPool, table: &str) -> i64 { sqlx::query_scalar(AssertSqlSafe(format!("select count(*) from {table}"))) .fetch_one(db) From 6da1fb7ea6a419d3e693f266487b7fe5873f62dd Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 18 Oct 2026 09:59:26 +0200 Subject: [PATCH 35/43] feat(api): implement invite gate --- ...1d2b05a54866dd934a2b6ea115b8de5c73cf3.json | 14 ++ ...e415a6affec6d1b7f7183f5952fe175b50f92.json | 23 +++ ...890749a3748d3d86950150c7ad6b8960973d3.json | 17 -- ...975dd8eb102447be3c187cb82b1a677fb5d3.json} | 18 +- ...255922e7ff4e42f6a4e72ea4e0a8668c6a1f5.json | 18 ++ .../migrations/20261014141017_init.sql | 10 +- rust/crates/keygo-server/src/api/error.rs | 1 + rust/crates/keygo-server/src/api/mod.rs | 7 +- .../src/api/routes/auth/register.rs | 83 +++++++++- .../keygo-server/src/api/routes/meta.rs | 3 +- rust/crates/keygo-server/src/api/state.rs | 10 +- rust/crates/keygo-server/src/db/invites.rs | 56 +++++++ .../keygo-server/src/db/registrations.rs | 44 ++++- .../crates/keygo-server/src/server/sweeper.rs | 7 +- .../src/service/registration/finish.rs | 81 ++++++++- .../src/service/registration/mod.rs | 8 + .../src/service/registration/start.rs | 155 +++++++++++++++++- rust/crates/keygo-server/src/testing.rs | 29 +++- 18 files changed, 528 insertions(+), 56 deletions(-) create mode 100644 rust/crates/keygo-server/.sqlx/query-0b8b93a96d890fa227741e75f7c1d2b05a54866dd934a2b6ea115b8de5c73cf3.json create mode 100644 rust/crates/keygo-server/.sqlx/query-7698696a6ec834db1afd41f4751e415a6affec6d1b7f7183f5952fe175b50f92.json delete mode 100644 rust/crates/keygo-server/.sqlx/query-a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3.json rename rust/crates/keygo-server/.sqlx/{query-5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf.json => query-b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3.json} (68%) create mode 100644 rust/crates/keygo-server/.sqlx/query-d46fe1824723331bc66d1751437255922e7ff4e42f6a4e72ea4e0a8668c6a1f5.json diff --git a/rust/crates/keygo-server/.sqlx/query-0b8b93a96d890fa227741e75f7c1d2b05a54866dd934a2b6ea115b8de5c73cf3.json b/rust/crates/keygo-server/.sqlx/query-0b8b93a96d890fa227741e75f7c1d2b05a54866dd934a2b6ea115b8de5c73cf3.json new file mode 100644 index 000000000..33575eabd --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-0b8b93a96d890fa227741e75f7c1d2b05a54866dd934a2b6ea115b8de5c73cf3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from invites where code_hash = $1 and expires_at > now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "0b8b93a96d890fa227741e75f7c1d2b05a54866dd934a2b6ea115b8de5c73cf3" +} diff --git a/rust/crates/keygo-server/.sqlx/query-7698696a6ec834db1afd41f4751e415a6affec6d1b7f7183f5952fe175b50f92.json b/rust/crates/keygo-server/.sqlx/query-7698696a6ec834db1afd41f4751e415a6affec6d1b7f7183f5952fe175b50f92.json new file mode 100644 index 000000000..01e40fef7 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-7698696a6ec834db1afd41f4751e415a6affec6d1b7f7183f5952fe175b50f92.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "select exists(\n select 1 from invites where code_hash = $1 and expires_at > now()\n ) as \"open!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "open!", + "type_info": "Bool", + "origin": "Expression" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7698696a6ec834db1afd41f4751e415a6affec6d1b7f7183f5952fe175b50f92" +} diff --git a/rust/crates/keygo-server/.sqlx/query-a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3.json b/rust/crates/keygo-server/.sqlx/query-a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3.json deleted file mode 100644 index 2ec334aca..000000000 --- a/rust/crates/keygo-server/.sqlx/query-a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "insert into pending_registrations (id, user_id, username, expires_at)\n values ($1, $2, $3, now() + make_interval(secs => $4))\n on conflict (user_id) do update\n set id = excluded.id, username = excluded.username, expires_at = excluded.expires_at", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Bytea", - "Uuid", - "Text", - "Float8" - ] - }, - "nullable": [] - }, - "hash": "a49ac053481e65cf91e788aa6b0890749a3748d3d86950150c7ad6b8960973d3" -} diff --git a/rust/crates/keygo-server/.sqlx/query-5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf.json b/rust/crates/keygo-server/.sqlx/query-b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3.json similarity index 68% rename from rust/crates/keygo-server/.sqlx/query-5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf.json rename to rust/crates/keygo-server/.sqlx/query-b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3.json index c10469a9b..03710f3a9 100644 --- a/rust/crates/keygo-server/.sqlx/query-5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf.json +++ b/rust/crates/keygo-server/.sqlx/query-b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "delete from pending_registrations\n where id = $1 and expires_at > now()\n returning user_id, username", + "query": "delete from pending_registrations\n where id = $1 and expires_at > now()\n returning user_id, username, invite_hash", "describe": { "columns": [ { @@ -24,6 +24,17 @@ "name": "username" } } + }, + { + "ordinal": 2, + "name": "invite_hash", + "type_info": "Bytea", + "origin": { + "Table": { + "table": "pending_registrations", + "name": "invite_hash" + } + } } ], "parameters": { @@ -33,8 +44,9 @@ }, "nullable": [ false, - false + false, + true ] }, - "hash": "5ac9a52348e7466479ac7371eff01c68fc7ec51caaefa2eae62db88f6e851acf" + "hash": "b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3" } diff --git a/rust/crates/keygo-server/.sqlx/query-d46fe1824723331bc66d1751437255922e7ff4e42f6a4e72ea4e0a8668c6a1f5.json b/rust/crates/keygo-server/.sqlx/query-d46fe1824723331bc66d1751437255922e7ff4e42f6a4e72ea4e0a8668c6a1f5.json new file mode 100644 index 000000000..c785fbabe --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-d46fe1824723331bc66d1751437255922e7ff4e42f6a4e72ea4e0a8668c6a1f5.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "insert into pending_registrations (id, user_id, username, invite_hash, expires_at)\n values ($1, $2, $3, $4, now() + make_interval(secs => $5))\n on conflict (user_id) do update\n set id = excluded.id, username = excluded.username,\n invite_hash = excluded.invite_hash, expires_at = excluded.expires_at", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "Uuid", + "Text", + "Bytea", + "Float8" + ] + }, + "nullable": [] + }, + "hash": "d46fe1824723331bc66d1751437255922e7ff4e42f6a4e72ea4e0a8668c6a1f5" +} diff --git a/rust/crates/keygo-server/migrations/20261014141017_init.sql b/rust/crates/keygo-server/migrations/20261014141017_init.sql index 28de7200e..d17e67697 100644 --- a/rust/crates/keygo-server/migrations/20261014141017_init.sql +++ b/rust/crates/keygo-server/migrations/20261014141017_init.sql @@ -22,11 +22,11 @@ create table opaque_records create unlogged table pending_registrations ( - id bytea primary key, -- registration_id, 16 random bytes - user_id uuid not null unique, - username text not null unique, -- the reservation - -- invite_id text references invites, - expires_at timestamptz not null + id bytea primary key, -- registration_id, 16 random bytes + user_id uuid not null unique, + username text not null unique, -- the reservation + invite_hash bytea, + expires_at timestamptz not null ); create index pending_registrations_expires_at_idx on pending_registrations (expires_at); diff --git a/rust/crates/keygo-server/src/api/error.rs b/rust/crates/keygo-server/src/api/error.rs index 1af0c65ea..9b7166929 100644 --- a/rust/crates/keygo-server/src/api/error.rs +++ b/rust/crates/keygo-server/src/api/error.rs @@ -21,6 +21,7 @@ pub enum ErrorCode { InvalidCredentials, UsernameTaken, UserIdTaken, + InviteRequired, InviteInvalid, RegistrationExpired, LoginExpired, diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index fa290a8fa..e2c8b1a40 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -32,10 +32,13 @@ async fn not_found() -> ServerError { #[cfg(test)] mod tests { use super::*; + use crate::domain::InvitePepper; + use crate::service::registration::Signup; use axum::body::Body; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use serde_json::{Value, json}; + use std::sync::Arc; use tower::ServiceExt; async fn get(state: AppState, uri: &str) -> (StatusCode, Vec) { @@ -59,7 +62,7 @@ mod tests { fn state() -> AppState { AppState { - allow_signups: false, + signup: Signup::InviteOnly(Arc::new(InvitePepper::new([0; 32]))), opaque_state: keygo_opaque::server::ServerSetup::generate(), // Lazy pools never connect until queried, so routes without a query need no database. db: sqlx::postgres::PgPoolOptions::new() @@ -77,7 +80,7 @@ mod tests { #[tokio::test] async fn server_info_reports_version_and_signups() { let state = AppState { - allow_signups: true, + signup: Signup::Open, ..state() }; diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index 0ab8a1612..2f6aa64ca 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -35,6 +35,8 @@ impl HttpError for registration::StartError { fn status_and_code(&self) -> (StatusCode, ErrorCode) { match self { Self::InvalidUserId => (StatusCode::BAD_REQUEST, ErrorCode::InvalidRequest), + Self::InviteRequired => (StatusCode::FORBIDDEN, ErrorCode::InviteRequired), + Self::InviteInvalid => (StatusCode::FORBIDDEN, ErrorCode::InviteInvalid), Self::UsernameTaken => (StatusCode::CONFLICT, ErrorCode::UsernameTaken), Self::UserIdTaken => (StatusCode::CONFLICT, ErrorCode::UserIdTaken), Self::Opaque(err) => err.status_and_code(), @@ -50,10 +52,11 @@ async fn start( let input = StartRegistration { username: Username::parse(&request.username)?, user_id: request.user_id, + invite: request.invite, registration_request: request.registration_request, }; - let started = registration::start(&state.db, &state.opaque_state, input).await?; + let started = registration::start(&state.db, &state.opaque_state, &state.signup, input).await?; Ok(Json(StartResponse { registration_id: started.registration_id, @@ -77,6 +80,7 @@ impl HttpError for registration::FinishError { fn status_and_code(&self) -> (StatusCode, ErrorCode) { match self { Self::Expired => (StatusCode::BAD_REQUEST, ErrorCode::RegistrationExpired), + Self::InviteInvalid => (StatusCode::FORBIDDEN, ErrorCode::InviteInvalid), Self::Db(err) => err.status_and_code(), } } @@ -102,7 +106,7 @@ async fn finish( mod tests { use super::*; use crate::api::error::render; - use crate::testing::{count, post, register}; + use crate::testing::{count, create_invite, invite_only, post, post_to, register}; use keygo_opaque::client::ClientRegistration; use keygo_opaque::server::ServerSetup; use serde_json::{Value, json}; @@ -166,6 +170,81 @@ mod tests { assert_eq!(count(&db, "users").await, 1); } + fn invite_only_state(db: &PgPool, setup: &ServerSetup) -> AppState { + AppState { + signup: invite_only(), + opaque_state: setup.clone(), + db: db.clone(), + } + } + + #[sqlx::test] + async fn registers_with_an_invite_over_http(db: PgPool) { + let setup = ServerSetup::generate(); + let (client, mut body) = start_body("ada", Uuid::new_v4()); + body["invite"] = create_invite(&db).await.into(); + + let (status, started) = post_to(invite_only_state(&db, &setup), START, body).await; + + assert_eq!(status, StatusCode::OK); + let response = + RegistrationResponse::from_base64(started["registration_response"].as_str().unwrap()) + .unwrap(); + let upload = client + .finish(crate::testing::PASSWORD, response) + .unwrap() + .upload; + let body = json!({ + "registration_id": started["registration_id"], + "registration_upload": upload.to_base64(), + }); + + let (status, _) = post_to(invite_only_state(&db, &setup), FINISH, body).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(count(&db, "users").await, 1); + assert_eq!(count(&db, "invites").await, 0); + } + + #[sqlx::test] + async fn invite_only_start_without_an_invite_is_forbidden(db: PgPool) { + let (_, body) = start_body("ada", Uuid::new_v4()); + + let (status, body) = post_to( + invite_only_state(&db, &ServerSetup::generate()), + START, + body, + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invite_required"); + } + + #[sqlx::test] + async fn invite_only_start_with_an_unknown_invite_is_forbidden(db: PgPool) { + let (_, mut body) = start_body("ada", Uuid::new_v4()); + body["invite"] = "7KQ2M9XD4HPA".into(); + + let (status, body) = post_to( + invite_only_state(&db, &ServerSetup::generate()), + START, + body, + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invite_invalid"); + } + + #[tokio::test] + async fn invite_spent_meanwhile_is_forbidden_at_finish() { + let (status, body) = render(registration::FinishError::InviteInvalid).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "invite_invalid"); + } + #[sqlx::test] async fn start_rejects_an_invalid_username(db: PgPool) { let (_, body) = start_body("a", Uuid::new_v4()); diff --git a/rust/crates/keygo-server/src/api/routes/meta.rs b/rust/crates/keygo-server/src/api/routes/meta.rs index abf08788b..2455f067e 100644 --- a/rust/crates/keygo-server/src/api/routes/meta.rs +++ b/rust/crates/keygo-server/src/api/routes/meta.rs @@ -1,4 +1,5 @@ use crate::api::AppState; +use crate::service::registration::Signup; use axum::extract::State; use axum::routing::get; use axum::{Json, Router}; @@ -18,6 +19,6 @@ struct ServerInfo { async fn info(State(state): State) -> Json { Json(ServerInfo { version: env!("CARGO_PKG_VERSION"), - allow_signups: state.allow_signups, + allow_signups: matches!(state.signup, Signup::Open), }) } diff --git a/rust/crates/keygo-server/src/api/state.rs b/rust/crates/keygo-server/src/api/state.rs index 4c145c61d..af77c8c3e 100644 --- a/rust/crates/keygo-server/src/api/state.rs +++ b/rust/crates/keygo-server/src/api/state.rs @@ -1,9 +1,11 @@ use crate::config::Config; +use crate::service::registration::Signup; use sqlx::PgPool; +use std::sync::Arc; #[derive(Clone)] pub struct AppState { - pub allow_signups: bool, + pub signup: Signup, pub opaque_state: keygo_opaque::server::ServerSetup, pub db: PgPool, } @@ -11,7 +13,11 @@ pub struct AppState { impl AppState { pub fn new(config: &Config, db: PgPool) -> anyhow::Result { Ok(Self { - allow_signups: config.allow_signups, + signup: if config.allow_signups { + Signup::Open + } else { + Signup::InviteOnly(Arc::new(config.invite_pepper()?)) + }, opaque_state: config.opaque_setup()?, db, }) diff --git a/rust/crates/keygo-server/src/db/invites.rs b/rust/crates/keygo-server/src/db/invites.rs index 93e64646c..5a29876b6 100644 --- a/rust/crates/keygo-server/src/db/invites.rs +++ b/rust/crates/keygo-server/src/db/invites.rs @@ -63,6 +63,31 @@ pub async fn delete_expired(conn: &mut PgConnection) -> sqlx::Result { Ok(result.rows_affected()) } +pub async fn is_open(conn: &mut PgConnection, hash: &InviteHash) -> sqlx::Result { + let open = sqlx::query_scalar!( + r#"select exists( + select 1 from invites where code_hash = $1 and expires_at > now() + ) as "open!""#, + hash.as_bytes().as_slice(), + ) + .fetch_one(conn) + .await?; + + Ok(open) +} + +/// Deletes the invite if it is still open, so it can create at most one account. +pub async fn spend(conn: &mut PgConnection, hash: &InviteHash) -> sqlx::Result { + let result = sqlx::query!( + "delete from invites where code_hash = $1 and expires_at > now()", + hash.as_bytes().as_slice(), + ) + .execute(conn) + .await?; + + Ok(result.rows_affected() == 1) +} + pub async fn delete(conn: &mut PgConnection, hash: &InviteHash) -> sqlx::Result { let result = sqlx::query!( "delete from invites where code_hash = $1", @@ -205,6 +230,37 @@ mod tests { assert_eq!(hashes, [hash(2), hash(1)]); } + #[sqlx::test] + async fn open_invite_is_open_until_it_expires(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, &hash(7), TTL).await.unwrap(); + + assert!(is_open(&mut conn, &hash(7)).await.unwrap()); + assert!(!is_open(&mut conn, &hash(8)).await.unwrap()); + expire(&db, "invites").await; + assert!(!is_open(&mut conn, &hash(7)).await.unwrap()); + } + + #[sqlx::test] + async fn invite_is_spent_once(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, &hash(7), TTL).await.unwrap(); + + assert!(spend(&mut conn, &hash(7)).await.unwrap()); + assert!(!spend(&mut conn, &hash(7)).await.unwrap()); + assert_eq!(count(&db, "invites").await, 0); + } + + #[sqlx::test] + async fn expired_invite_cannot_be_spent(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + insert(&mut conn, &hash(7), TTL).await.unwrap(); + expire(&db, "invites").await; + + assert!(!spend(&mut conn, &hash(7)).await.unwrap()); + assert_eq!(count(&db, "invites").await, 1); + } + #[sqlx::test] async fn list_skips_expired_invites(db: PgPool) { let mut conn = db.acquire().await.unwrap(); diff --git a/rust/crates/keygo-server/src/db/registrations.rs b/rust/crates/keygo-server/src/db/registrations.rs index 39bbf08c9..4727864f5 100644 --- a/rust/crates/keygo-server/src/db/registrations.rs +++ b/rust/crates/keygo-server/src/db/registrations.rs @@ -1,5 +1,5 @@ use super::corrupt; -use crate::domain::{RegistrationId, Username}; +use crate::domain::{InviteHash, RegistrationId, Username}; use sqlx::PgConnection; use std::time::Duration; use thiserror::Error; @@ -35,6 +35,7 @@ pub async fn reserve( registration_id: &RegistrationId, user_id: Uuid, username: &Username, + invite_hash: Option<&InviteHash>, ttl: Duration, ) -> Result<(), ReserveError> { sqlx::query!( @@ -49,13 +50,15 @@ pub async fn reserve( // A concurrent reserve that wins between the sweep and here surfaces as a unique violation on // username, or as a takeover on user_id, so the two statements need no surrounding transaction. sqlx::query!( - "insert into pending_registrations (id, user_id, username, expires_at) - values ($1, $2, $3, now() + make_interval(secs => $4)) + "insert into pending_registrations (id, user_id, username, invite_hash, expires_at) + values ($1, $2, $3, $4, now() + make_interval(secs => $5)) on conflict (user_id) do update - set id = excluded.id, username = excluded.username, expires_at = excluded.expires_at", + set id = excluded.id, username = excluded.username, + invite_hash = excluded.invite_hash, expires_at = excluded.expires_at", registration_id.as_bytes().as_slice(), user_id, username.as_str(), + invite_hash.map(|hash| hash.as_bytes().as_slice()), ttl.as_secs_f64(), ) .execute(conn) @@ -67,6 +70,7 @@ pub async fn reserve( pub struct Pending { pub user_id: Uuid, pub username: Username, + pub invite_hash: Option, } /// Deletes the reservation as it reads it, so a registration can be finished at most once. Returns @@ -78,7 +82,7 @@ pub async fn take( let Some(row) = sqlx::query!( "delete from pending_registrations where id = $1 and expires_at > now() - returning user_id, username", + returning user_id, username, invite_hash", registration_id.as_bytes().as_slice(), ) .fetch_optional(conn) @@ -88,10 +92,16 @@ pub async fn take( }; let username = Username::parse(&row.username).map_err(corrupt)?; + let invite_hash = row + .invite_hash + .map(|hash| hash.as_slice().try_into()) + .transpose() + .map_err(corrupt)?; Ok(Some(Pending { user_id: row.user_id, username, + invite_hash, })) } @@ -117,7 +127,7 @@ mod tests { name: &str, ) -> Result { let registration_id = RegistrationId::random(); - reserve(conn, ®istration_id, user_id, &username(name), TTL).await?; + reserve(conn, ®istration_id, user_id, &username(name), None, TTL).await?; Ok(registration_id) } @@ -131,10 +141,32 @@ mod tests { assert_eq!(pending.user_id, user_id); assert_eq!(pending.username, username("ada")); + assert_eq!(pending.invite_hash, None); assert!(take(&mut conn, ®istration_id).await.unwrap().is_none()); assert_eq!(count(&db, "pending_registrations").await, 0); } + #[sqlx::test] + async fn reservation_carries_its_invite(db: PgPool) { + let mut conn = db.acquire().await.unwrap(); + let registration_id = RegistrationId::random(); + let invite = InviteHash::from([7; 32]); + reserve( + &mut conn, + ®istration_id, + Uuid::new_v4(), + &username("ada"), + Some(&invite), + TTL, + ) + .await + .unwrap(); + + let pending = take(&mut conn, ®istration_id).await.unwrap().unwrap(); + + assert_eq!(pending.invite_hash, Some(invite)); + } + #[sqlx::test] async fn unknown_registration_is_none(db: PgPool) { let mut conn = db.acquire().await.unwrap(); diff --git a/rust/crates/keygo-server/src/server/sweeper.rs b/rust/crates/keygo-server/src/server/sweeper.rs index cfe68d658..b2ceead4b 100644 --- a/rust/crates/keygo-server/src/server/sweeper.rs +++ b/rust/crates/keygo-server/src/server/sweeper.rs @@ -35,7 +35,7 @@ async fn sweep(db: &PgPool) -> sqlx::Result<()> { mod tests { use super::*; use crate::domain::InviteHash; - use crate::service::registration::{self, StartRegistration}; + use crate::service::registration::{self, Signup, StartRegistration}; use crate::testing::{count, expire, start_login}; use keygo_opaque::client::ClientRegistration; use keygo_opaque::server::ServerSetup; @@ -46,9 +46,12 @@ mod tests { let input = StartRegistration { username: crate::testing::username(name), user_id: Uuid::new_v4(), + invite: None, registration_request, }; - registration::start(db, setup, input).await.unwrap(); + registration::start(db, setup, &Signup::Open, input) + .await + .unwrap(); } async fn create_invite(db: &PgPool, byte: u8) { diff --git a/rust/crates/keygo-server/src/service/registration/finish.rs b/rust/crates/keygo-server/src/service/registration/finish.rs index 28d800fb4..f0953c7e5 100644 --- a/rust/crates/keygo-server/src/service/registration/finish.rs +++ b/rust/crates/keygo-server/src/service/registration/finish.rs @@ -1,4 +1,4 @@ -use crate::db::{opaque_records, registrations, users}; +use crate::db::{invites, opaque_records, registrations, users}; use crate::domain::{RegistrationId, Username}; use keygo_opaque::{RegistrationUpload, server}; use sqlx::PgPool; @@ -8,6 +8,8 @@ use thiserror::Error; pub enum FinishError { #[error("registration expired or unknown")] Expired, + #[error("invite is not valid or has expired")] + InviteInvalid, #[error("database error: {0}")] Db(#[from] sqlx::Error), } @@ -36,6 +38,12 @@ pub async fn finish(db: &PgPool, input: FinishRegistration) -> Result Result FinishRegistration { + started_with(db, &Signup::Open, None, user_id, name).await + } + + async fn started_with( + db: &PgPool, + signup: &Signup, + invite: Option<&str>, + user_id: Uuid, + name: &str, + ) -> FinishRegistration { let (client, registration_request) = ClientRegistration::start(PASSWORD).unwrap(); let input = StartRegistration { username: username(name), user_id, + invite: invite.map(str::to_owned), registration_request, }; let Started { registration_id, registration_response, .. - } = registration::start(db, &ServerSetup::generate(), input) + } = registration::start(db, &ServerSetup::generate(), signup, input) .await .unwrap(); let finish = client.finish(PASSWORD, registration_response).unwrap(); @@ -158,4 +177,58 @@ mod tests { assert!(matches!(err, FinishError::Db(_)), "{err:?}"); assert_eq!(user_count(&db).await, (1, 0, 1)); } + + #[sqlx::test] + async fn finish_spends_the_invite(db: PgPool) { + let code = create_invite(&db).await; + let input = started_with(&db, &invite_only(), Some(&code), Uuid::new_v4(), "ada").await; + + finish(&db, input).await.unwrap(); + + assert_eq!(user_count(&db).await, (1, 1, 0)); + assert_eq!(count(&db, "invites").await, 0); + } + + #[sqlx::test] + async fn one_invite_creates_one_account(db: PgPool) { + let code = create_invite(&db).await; + let ada = started_with(&db, &invite_only(), Some(&code), Uuid::new_v4(), "ada").await; + let bob = started_with(&db, &invite_only(), Some(&code), Uuid::new_v4(), "bob").await; + finish(&db, ada).await.unwrap(); + + let err = finish(&db, bob).await.err().unwrap(); + + assert!(matches!(err, FinishError::InviteInvalid), "{err:?}"); + assert_eq!(user_count(&db).await, (1, 1, 1)); + } + + #[sqlx::test] + async fn invite_expired_since_start_creates_nothing(db: PgPool) { + let code = create_invite(&db).await; + let input = started_with(&db, &invite_only(), Some(&code), Uuid::new_v4(), "ada").await; + expire(&db, "invites").await; + + let err = finish(&db, input).await.err().unwrap(); + + assert!(matches!(err, FinishError::InviteInvalid), "{err:?}"); + assert_eq!(user_count(&db).await, (0, 0, 1)); + assert_eq!(count(&db, "invites").await, 1); + } + + #[sqlx::test] + async fn failed_finish_keeps_the_invite(db: PgPool) { + let code = create_invite(&db).await; + let user_id = Uuid::new_v4(); + let input = started_with(&db, &invite_only(), Some(&code), user_id, "ada").await; + sqlx::query("insert into users (id, username) values ($1, 'squatter')") + .bind(user_id) + .execute(&db) + .await + .unwrap(); + + let err = finish(&db, input).await.err().unwrap(); + + assert!(matches!(err, FinishError::Db(_)), "{err:?}"); + assert_eq!(count(&db, "invites").await, 1); + } } diff --git a/rust/crates/keygo-server/src/service/registration/mod.rs b/rust/crates/keygo-server/src/service/registration/mod.rs index 2a287ff8a..dbabbf3e2 100644 --- a/rust/crates/keygo-server/src/service/registration/mod.rs +++ b/rust/crates/keygo-server/src/service/registration/mod.rs @@ -4,6 +4,14 @@ mod start; pub use finish::*; pub use start::*; +use crate::domain::InvitePepper; +use std::sync::Arc; use std::time::Duration; pub const TTL: Duration = Duration::from_mins(5); + +#[derive(Clone)] +pub enum Signup { + Open, + InviteOnly(Arc), +} diff --git a/rust/crates/keygo-server/src/service/registration/start.rs b/rust/crates/keygo-server/src/service/registration/start.rs index 52e156bec..5174661d3 100644 --- a/rust/crates/keygo-server/src/service/registration/start.rs +++ b/rust/crates/keygo-server/src/service/registration/start.rs @@ -1,7 +1,7 @@ use crate::db::registrations::{self, ReserveError}; -use crate::db::users; -use crate::domain::{RegistrationId, Username}; -use crate::service::registration::TTL; +use crate::db::{invites, users}; +use crate::domain::{InviteCode, InviteHash, RegistrationId, Username}; +use crate::service::registration::{Signup, TTL}; use keygo_opaque::server::ServerSetup; use keygo_opaque::{OpaqueError, RegistrationRequest, RegistrationResponse, server}; use sqlx::PgPool; @@ -13,6 +13,10 @@ use uuid::{Uuid, Variant}; pub enum StartError { #[error("user_id must be a random (version 4) UUID")] InvalidUserId, + #[error("this server needs an invite to register")] + InviteRequired, + #[error("invite is not valid or has expired")] + InviteInvalid, #[error("username is already taken")] UsernameTaken, #[error("user_id is already registered")] @@ -36,7 +40,7 @@ impl From for StartError { pub struct StartRegistration { pub username: Username, pub user_id: Uuid, - // TODO: add `invite: Option` once invites exist. + pub invite: Option, pub registration_request: RegistrationRequest, } @@ -49,11 +53,13 @@ pub struct Started { pub async fn start( db: &PgPool, setup: &ServerSetup, + signup: &Signup, input: StartRegistration, ) -> Result { let StartRegistration { username, user_id, + invite, registration_request, } = input; @@ -61,7 +67,8 @@ pub async fn start( return Err(StartError::InvalidUserId); } - // TODO: check the invite here once invites exist, so only invite holders can probe usernames. + // Before any username check, so only invite holders can probe usernames. + let invite_hash = check_invite(db, signup, invite.as_deref()).await?; // Pure computation, so it runs before the transaction to keep the reservation's lock short. let registration_response = @@ -69,7 +76,15 @@ pub async fn start( let mut tx = db.begin().await?; let registration_id = RegistrationId::random(); - registrations::reserve(&mut tx, ®istration_id, user_id, &username, TTL).await?; + registrations::reserve( + &mut tx, + ®istration_id, + user_id, + &username, + invite_hash.as_ref(), + TTL, + ) + .await?; // Checked after reserving on purpose. A finish that consumed the previous reservation for // this name or id still holds its row (or index entry), so reserve waits for it to commit, @@ -91,6 +106,30 @@ pub async fn start( }) } +/// Checks without reserving: two holders of one code can both start, and finish decides who +/// spends it. +async fn check_invite( + db: &PgPool, + signup: &Signup, + invite: Option<&str>, +) -> Result, StartError> { + let Signup::InviteOnly(pepper) = signup else { + return Ok(None); + }; + + let code: InviteCode = invite + .ok_or(StartError::InviteRequired)? + .parse() + .map_err(|_| StartError::InviteInvalid)?; + let hash = pepper.hash(&code); + + let mut conn = db.acquire().await?; + if !invites::is_open(&mut conn, &hash).await? { + return Err(StartError::InviteInvalid); + } + Ok(Some(hash)) +} + fn is_random_uuid(id: Uuid) -> bool { // The nil UUID has version 0, so the version check already rejects it. id.get_version_num() == 4 && id.get_variant() == Variant::RFC4122 @@ -99,7 +138,7 @@ fn is_random_uuid(id: Uuid) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::testing::{count, register, username}; + use crate::testing::{count, create_invite, invite_only, register, username}; use keygo_opaque::client::ClientRegistration; #[test] @@ -124,13 +163,113 @@ mod tests { } async fn start_as(db: &PgPool, user_id: Uuid, name: &str) -> Result { + start_with(db, &Signup::Open, None, user_id, name).await + } + + async fn start_with( + db: &PgPool, + signup: &Signup, + invite: Option<&str>, + user_id: Uuid, + name: &str, + ) -> Result { let (_, registration_request) = ClientRegistration::start(b"password").unwrap(); let input = StartRegistration { username: username(name), user_id, + invite: invite.map(str::to_owned), registration_request, }; - start(db, &ServerSetup::generate(), input).await + start(db, &ServerSetup::generate(), signup, input).await + } + + async fn pending_invite(db: &PgPool) -> Option> { + sqlx::query_scalar("select invite_hash from pending_registrations") + .fetch_one(db) + .await + .unwrap() + } + + #[sqlx::test] + async fn invite_only_start_carries_the_invite_without_spending_it(db: PgPool) { + let code = create_invite(&db).await; + + start_with(&db, &invite_only(), Some(&code), Uuid::new_v4(), "ada") + .await + .unwrap(); + + assert!(pending_invite(&db).await.is_some()); + assert_eq!(count(&db, "invites").await, 1); + } + + #[sqlx::test] + async fn one_invite_can_start_two_registrations(db: PgPool) { + let code = create_invite(&db).await; + + start_with(&db, &invite_only(), Some(&code), Uuid::new_v4(), "ada") + .await + .unwrap(); + start_with(&db, &invite_only(), Some(&code), Uuid::new_v4(), "bob") + .await + .unwrap(); + + assert_eq!(count(&db, "pending_registrations").await, 2); + } + + #[sqlx::test] + async fn invite_only_start_requires_an_invite(db: PgPool) { + let err = start_with(&db, &invite_only(), None, Uuid::new_v4(), "ada") + .await + .err() + .unwrap(); + + assert!(matches!(err, StartError::InviteRequired), "{err:?}"); + assert_eq!(count(&db, "pending_registrations").await, 0); + } + + #[sqlx::test] + async fn unknown_and_malformed_invites_are_invalid(db: PgPool) { + create_invite(&db).await; + + for invite in ["7KQ2M9XD4HPA", "not a code"] { + let err = start_with(&db, &invite_only(), Some(invite), Uuid::new_v4(), "ada") + .await + .err() + .unwrap(); + + assert!( + matches!(err, StartError::InviteInvalid), + "{invite}: {err:?}" + ); + } + assert_eq!(count(&db, "pending_registrations").await, 0); + } + + #[sqlx::test] + async fn invite_is_checked_before_the_username(db: PgPool) { + register(&db, &ServerSetup::generate(), "ada").await; + + let err = start_with(&db, &invite_only(), None, Uuid::new_v4(), "ada") + .await + .err() + .unwrap(); + + assert!(matches!(err, StartError::InviteRequired), "{err:?}"); + } + + #[sqlx::test] + async fn open_signup_ignores_the_invite(db: PgPool) { + start_with( + &db, + &Signup::Open, + Some("not a code"), + Uuid::new_v4(), + "ada", + ) + .await + .unwrap(); + + assert_eq!(pending_invite(&db).await, None); } #[sqlx::test] diff --git a/rust/crates/keygo-server/src/testing.rs b/rust/crates/keygo-server/src/testing.rs index 5ac4312d6..3c8a194c0 100644 --- a/rust/crates/keygo-server/src/testing.rs +++ b/rust/crates/keygo-server/src/testing.rs @@ -1,8 +1,9 @@ use crate::api::{AppState, router}; use crate::db::invites; -use crate::domain::{InviteHash, Username}; +use crate::domain::{InviteHash, InvitePepper, Username}; +use crate::service::invite; use crate::service::login::{self, FinishLogin, StartLogin}; -use crate::service::registration::{self, FinishRegistration, StartRegistration}; +use crate::service::registration::{self, FinishRegistration, Signup, StartRegistration}; use axum::body::Body; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; @@ -10,6 +11,7 @@ use keygo_opaque::client::{ClientLogin, ClientRegistration}; use keygo_opaque::server::ServerSetup; use serde_json::Value; use sqlx::{AssertSqlSafe, PgPool}; +use std::sync::Arc; use std::time::Duration; use tower::ServiceExt; use uuid::Uuid; @@ -26,9 +28,12 @@ pub async fn register(db: &PgPool, setup: &ServerSetup, name: &str) -> Uuid { let input = StartRegistration { username: username(name), user_id, + invite: None, registration_request, }; - let started = registration::start(db, setup, input).await.unwrap(); + let started = registration::start(db, setup, &Signup::Open, input) + .await + .unwrap(); let finish = client .finish(PASSWORD, started.registration_response) @@ -75,6 +80,19 @@ pub async fn insert_invite(db: &PgPool, hash: InviteHash) { .unwrap(); } +const INVITE_PEPPER: [u8; 32] = [1; 32]; + +pub fn invite_only() -> Signup { + Signup::InviteOnly(Arc::new(InvitePepper::new(INVITE_PEPPER))) +} +pub async fn create_invite(db: &PgPool) -> String { + let pepper = InvitePepper::new(INVITE_PEPPER); + let created = invite::create(db, &pepper, Duration::from_hours(24)) + .await + .unwrap(); + created.code.to_string() +} + pub async fn count(db: &PgPool, table: &str) -> i64 { sqlx::query_scalar(AssertSqlSafe(format!("select count(*) from {table}"))) .fetch_one(db) @@ -100,10 +118,13 @@ pub async fn expires_in_secs(db: &PgPool, table: &str) -> f64 { pub async fn post(db: &PgPool, setup: &ServerSetup, uri: &str, body: Value) -> (StatusCode, Value) { let state = AppState { - allow_signups: true, + signup: Signup::Open, opaque_state: setup.clone(), db: db.clone(), }; + post_to(state, uri, body).await +} +pub async fn post_to(state: AppState, uri: &str, body: Value) -> (StatusCode, Value) { let request = Request::post(uri) .header("content-type", "application/json") .body(Body::from(body.to_string())) From 68f4eef64dd555b543e278dd015b273ee166862f Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 18 Oct 2026 10:37:26 +0200 Subject: [PATCH 36/43] refactor(api): cleanup API help sections --- rust/crates/keygo-server/Cargo.toml | 1 + rust/crates/keygo-server/src/api/state.rs | 11 +- .../keygo-server/src/cli/commands/check.rs | 23 ++-- .../keygo-server/src/cli/commands/init.rs | 16 ++- .../src/cli/commands/invite/create.rs | 17 ++- .../src/cli/commands/invite/list.rs | 14 ++- .../src/cli/commands/invite/mod.rs | 32 ++++-- .../src/cli/commands/invite/revoke.rs | 9 +- .../keygo-server/src/cli/commands/serve.rs | 8 +- rust/crates/keygo-server/src/cli/mod.rs | 26 ++--- rust/crates/keygo-server/src/config.rs | 108 ++++++++---------- rust/crates/keygo-server/src/db/mod.rs | 24 ++-- rust/crates/keygo-server/src/server/mod.rs | 6 +- 13 files changed, 164 insertions(+), 131 deletions(-) diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index 9e37059b6..ce48573f2 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "keygo-server" +description = "KeyGo self-hostable sync server." version.workspace = true edition.workspace = true diff --git a/rust/crates/keygo-server/src/api/state.rs b/rust/crates/keygo-server/src/api/state.rs index af77c8c3e..4c4f0d12d 100644 --- a/rust/crates/keygo-server/src/api/state.rs +++ b/rust/crates/keygo-server/src/api/state.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::config::DeploymentConfig; use crate::service::registration::Signup; use sqlx::PgPool; use std::sync::Arc; @@ -11,14 +11,15 @@ pub struct AppState { } impl AppState { - pub fn new(config: &Config, db: PgPool) -> anyhow::Result { + pub fn new(config: &DeploymentConfig, db: PgPool) -> anyhow::Result { + let secrets = &config.secrets; Ok(Self { - signup: if config.allow_signups { + signup: if config.server.allow_signups { Signup::Open } else { - Signup::InviteOnly(Arc::new(config.invite_pepper()?)) + Signup::InviteOnly(Arc::new(secrets.invite_pepper()?)) }, - opaque_state: config.opaque_setup()?, + opaque_state: secrets.opaque_setup()?, db, }) } diff --git a/rust/crates/keygo-server/src/cli/commands/check.rs b/rust/crates/keygo-server/src/cli/commands/check.rs index 2e0f6053d..5828b336d 100644 --- a/rust/crates/keygo-server/src/cli/commands/check.rs +++ b/rust/crates/keygo-server/src/cli/commands/check.rs @@ -1,17 +1,25 @@ -use crate::config::{Config, DatabaseConfig, Environment}; +use crate::config::{DatabaseConfig, DeploymentConfig, Environment}; use crate::secrets::{DbPassword, Secret, SecretStore, TypedSecret}; use anstream::println; use anstyle::{AnsiColor, Style}; +use clap::Args; use secrecy::{ExposeSecret, SecretString}; use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::Path; use url::Url; -pub fn run(config: &Config) -> anyhow::Result<()> { +#[derive(Args)] +pub struct CheckArgs { + #[command(flatten)] + config: DeploymentConfig, +} + +pub fn run(args: &CheckArgs) -> anyhow::Result<()> { + let config = &args.config; let sections = [ check_config(config), - check_secrets(&config.secrets_dir, config.database.url.is_some()), + check_secrets(&config.secrets.dir, config.database.url.is_some()), ]; print_report(§ions); @@ -75,13 +83,14 @@ impl Section { } } -fn check_config(config: &Config) -> Section { +fn check_config(config: &DeploymentConfig) -> Section { + let server = &config.server; Section { title: "Config".into(), checks: vec![ - check_environment(config.environment), - check_public_url(&config.public_url, config.environment), - check_signups(config.allow_signups, config.environment), + check_environment(server.environment), + check_public_url(&server.public_url, server.environment), + check_signups(server.allow_signups, server.environment), check_database(&config.database), ], } diff --git a/rust/crates/keygo-server/src/cli/commands/init.rs b/rust/crates/keygo-server/src/cli/commands/init.rs index e087e4ec6..f1f08fff6 100644 --- a/rust/crates/keygo-server/src/cli/commands/init.rs +++ b/rust/crates/keygo-server/src/cli/commands/init.rs @@ -1,9 +1,16 @@ -use crate::config::Config; -use crate::secrets::{CreateOutcome, Secret, SecretStore}; +use crate::config::SecretsConfig; +use crate::secrets::{CreateOutcome, Secret}; use anyhow::Context; +use clap::Args; -pub fn run(config: &Config) -> anyhow::Result<()> { - let store = SecretStore::new(&config.secrets_dir); +#[derive(Args)] +pub struct InitArgs { + #[command(flatten)] + secrets: SecretsConfig, +} + +pub fn run(args: &InitArgs) -> anyhow::Result<()> { + let store = args.secrets.store(); store.create_dir()?; for &secret in Secret::ALL { @@ -18,5 +25,6 @@ pub fn run(config: &Config) -> anyhow::Result<()> { }; println!("{:<20} {status}", secret.name()); } + Ok(()) } diff --git a/rust/crates/keygo-server/src/cli/commands/invite/create.rs b/rust/crates/keygo-server/src/cli/commands/invite/create.rs index 459223912..f71fafdf7 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/create.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/create.rs @@ -1,5 +1,4 @@ -use super::{format_utc, with_db}; -use crate::config::Config; +use super::{Connection, format_utc, with_db}; use crate::service::invite::{self, Created}; use anyhow::ensure; use clap::Args; @@ -11,8 +10,10 @@ const MAX_TTL: Duration = Duration::from_hours(30 * 24); #[derive(Args)] pub struct CreateInviteArgs { /// How long the invite stays redeemable, from 1 to 30 days, e.g. "72h" or "2weeks". - #[arg(long, default_value = "1d", value_parser = parse_ttl)] + #[arg(short, long, default_value = "1d", value_parser = parse_ttl)] ttl: Duration, + #[command(flatten)] + connection: Connection, } fn parse_ttl(s: &str) -> anyhow::Result { @@ -30,14 +31,10 @@ fn parse_ttl(s: &str) -> anyhow::Result { Ok(ttl) } -pub fn run(config: &Config, args: &CreateInviteArgs) -> anyhow::Result<()> { - let pepper = config.invite_pepper()?; +pub fn run(args: &CreateInviteArgs) -> anyhow::Result<()> { + let pepper = args.connection.secrets.invite_pepper()?; - if config.allow_signups { - eprintln!("warning: sign-up is open, so registration will not ask for this invite"); - } - - let created = with_db(config, async |db| { + let created = with_db(&args.connection, async |db| { Ok(invite::create(db, &pepper, args.ttl).await?) })?; diff --git a/rust/crates/keygo-server/src/cli/commands/invite/list.rs b/rust/crates/keygo-server/src/cli/commands/invite/list.rs index 9e5332605..c4c4567c3 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/list.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/list.rs @@ -1,10 +1,16 @@ -use super::{format_utc, id_width, with_db}; -use crate::config::Config; +use super::{Connection, format_utc, id_width, with_db}; use crate::service::invite::{self, Invite}; +use clap::Args; use std::iter; -pub fn run(config: &Config) -> anyhow::Result<()> { - let invites = with_db(config, async |db| Ok(invite::list(db).await?))?; +#[derive(Args)] +pub struct ListInviteArgs { + #[command(flatten)] + connection: Connection, +} + +pub fn run(args: &ListInviteArgs) -> anyhow::Result<()> { + let invites = with_db(&args.connection, async |db| Ok(invite::list(db).await?))?; println!("{}", table(&invites)); Ok(()) } diff --git a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs index eaf398b12..20eb30a83 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/mod.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs @@ -3,11 +3,12 @@ pub mod list; pub mod revoke; use crate::cli::commands::invite::create::CreateInviteArgs; +use crate::cli::commands::invite::list::ListInviteArgs; use crate::cli::commands::invite::revoke::RevokeInviteArgs; -use crate::config::Config; +use crate::config::{DatabaseConfig, SecretsConfig}; use crate::db; use crate::service::invite::Invite; -use clap::Subcommand; +use clap::{Args, Subcommand}; use sqlx::PgPool; use time::format_description::BorrowedFormatItem; use time::macros::format_description; @@ -18,16 +19,29 @@ const UTC_MINUTES: &[BorrowedFormatItem] = #[derive(Subcommand)] pub enum InviteCommands { + /// Create an invite code that one registration can redeem. Create(CreateInviteArgs), - List, + /// List the open invites. + #[command(visible_alias = "ls")] + List(ListInviteArgs), + /// Revoke an open invite so its code no longer works. Revoke(RevokeInviteArgs), } -pub fn run(config: &Config, commands: InviteCommands) -> anyhow::Result<()> { +/// What every invite command needs to reach the database. +#[derive(Args)] +pub struct Connection { + #[command(flatten)] + secrets: SecretsConfig, + #[command(flatten)] + database: DatabaseConfig, +} + +pub fn run(commands: InviteCommands) -> anyhow::Result<()> { match commands { - InviteCommands::Create(args) => create::run(config, &args), - InviteCommands::List => list::run(config), - InviteCommands::Revoke(args) => revoke::run(config, &args), + InviteCommands::Create(args) => create::run(&args), + InviteCommands::List(args) => list::run(&args), + InviteCommands::Revoke(args) => revoke::run(&args), } } @@ -46,14 +60,14 @@ fn id_width(invites: &[Invite]) -> usize { } fn with_db( - config: &Config, + connection: &Connection, task: impl AsyncFnOnce(&PgPool) -> anyhow::Result, ) -> anyhow::Result { tokio::runtime::Builder::new_current_thread() .enable_all() .build()? .block_on(async { - let db = db::connect(config).await?; + let db = db::connect(&connection.database, &connection.secrets).await?; task(&db).await }) } diff --git a/rust/crates/keygo-server/src/cli/commands/invite/revoke.rs b/rust/crates/keygo-server/src/cli/commands/invite/revoke.rs index 2096df7ac..06a9472e5 100644 --- a/rust/crates/keygo-server/src/cli/commands/invite/revoke.rs +++ b/rust/crates/keygo-server/src/cli/commands/invite/revoke.rs @@ -1,5 +1,4 @@ -use super::{format_utc, id_width, with_db}; -use crate::config::Config; +use super::{Connection, format_utc, id_width, with_db}; use crate::domain::InviteId; use crate::service::invite::{self, Invite, RevokeError}; use anyhow::anyhow; @@ -9,10 +8,12 @@ use clap::Args; pub struct RevokeInviteArgs { /// Any prefix of the invite's hash, 5 to 64 hex characters, such as the id `invite list` prints. id: InviteId, + #[command(flatten)] + connection: Connection, } -pub fn run(config: &Config, args: &RevokeInviteArgs) -> anyhow::Result<()> { - with_db(config, async |db| { +pub fn run(args: &RevokeInviteArgs) -> anyhow::Result<()> { + with_db(&args.connection, async |db| { invite::revoke(db, &args.id).await.map_err(|err| match err { RevokeError::Ambiguous { ref matches, .. } => { anyhow!("{err}:\n{}", candidates(matches)) diff --git a/rust/crates/keygo-server/src/cli/commands/serve.rs b/rust/crates/keygo-server/src/cli/commands/serve.rs index fed5d9539..85a18f2f4 100644 --- a/rust/crates/keygo-server/src/cli/commands/serve.rs +++ b/rust/crates/keygo-server/src/cli/commands/serve.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::config::DeploymentConfig; use crate::server; use clap::Args; use std::net::SocketAddr; @@ -9,9 +9,11 @@ pub struct ServeArgs { /// Address the API listens on. #[arg(long, env = "KEYGO_LISTEN", default_value = "0.0.0.0:8000")] listen: SocketAddr, + #[command(flatten)] + config: DeploymentConfig, } -pub fn run(config: &Config, args: &ServeArgs) -> anyhow::Result<()> { +pub fn run(args: &ServeArgs) -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::try_from_env("KEYGO_LOG").unwrap_or_else(|_| "info".into())) .init(); @@ -19,5 +21,5 @@ pub fn run(config: &Config, args: &ServeArgs) -> anyhow::Result<()> { tokio::runtime::Builder::new_multi_thread() .enable_all() .build()? - .block_on(server::start(config, args.listen)) + .block_on(server::start(&args.config, args.listen)) } diff --git a/rust/crates/keygo-server/src/cli/mod.rs b/rust/crates/keygo-server/src/cli/mod.rs index 7f8b3e1e0..3b094d9f7 100644 --- a/rust/crates/keygo-server/src/cli/mod.rs +++ b/rust/crates/keygo-server/src/cli/mod.rs @@ -1,38 +1,38 @@ mod commands; +use crate::cli::commands::check::CheckArgs; +use crate::cli::commands::init::InitArgs; use crate::cli::commands::invite::InviteCommands; use crate::cli::commands::serve::ServeArgs; -use crate::config::Config; use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "keygo-server", version, about)] pub struct Cli { - #[command(flatten)] - config: Config, - #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { - Init, - Check, + /// Generate the secrets the server needs. Existing secrets are kept. + Init(InitArgs), + /// Validate the configuration and secrets without starting the server. + Check(CheckArgs), + /// Run the API server. Serve(ServeArgs), + /// Manage the invite codes that registration asks for. #[command(subcommand)] Invite(InviteCommands), } impl Cli { pub fn start() -> anyhow::Result<()> { - let cli = Cli::parse(); - - match cli.command { - Commands::Init => commands::init::run(&cli.config), - Commands::Check => commands::check::run(&cli.config), - Commands::Serve(args) => commands::serve::run(&cli.config, &args), - Commands::Invite(commands) => commands::invite::run(&cli.config, commands), + match Cli::parse().command { + Commands::Init(args) => commands::init::run(&args), + Commands::Check(args) => commands::check::run(&args), + Commands::Serve(args) => commands::serve::run(&args), + Commands::Invite(commands) => commands::invite::run(commands), } } } diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs index fbc26479e..d15669407 100644 --- a/rust/crates/keygo-server/src/config.rs +++ b/rust/crates/keygo-server/src/config.rs @@ -5,82 +5,69 @@ use keygo_opaque::server::ServerSetup; use secrecy::SecretString; use std::path::PathBuf; +/// Everything `serve` reads, so `check` validates exactly what the server will run with. #[derive(Args)] -pub struct Config { - #[arg( - long, - env = "KEYGO_ENVIRONMENT", - value_enum, - default_value_t, - global = true - )] - pub environment: Environment, +pub struct DeploymentConfig { + #[command(flatten)] + pub server: ServerConfig, + #[command(flatten)] + pub secrets: SecretsConfig, + #[command(flatten)] + pub database: DatabaseConfig, +} - #[arg( - long, - env = "KEYGO_PUBLIC_URL", - default_value = "http://localhost", - global = true - )] +#[derive(Args)] +#[command(next_help_heading = "Server")] +pub struct ServerConfig { + /// Deployment environment. + #[arg(long, env = "KEYGO_ENVIRONMENT", value_enum, default_value_t)] + pub environment: Environment, + /// URL that clients reach the server at. + #[arg(long, env = "KEYGO_PUBLIC_URL", default_value = "http://localhost")] pub public_url: String, - - #[arg(long, env = "KEYGO_ALLOW_SIGNUPS", global = true)] + /// Let anyone register without an invite. + #[arg(long, env = "KEYGO_ALLOW_SIGNUPS")] pub allow_signups: bool, +} - /// Directory with one file per secret. Compose mounts its secrets at /run/secrets. +#[derive(Args)] +#[command(next_help_heading = "Secrets")] +pub struct SecretsConfig { + /// Directory with one file per secret. #[arg( - long, + long = "secrets-dir", + value_name = "DIR", env = "KEYGO_SECRETS_DIR", - default_value = "/run/secrets", - global = true + default_value = "/run/secrets" )] - pub secrets_dir: PathBuf, - - #[command(flatten, next_help_heading = "Database")] - pub database: DatabaseConfig, + pub dir: PathBuf, } #[derive(Args)] +#[command(next_help_heading = "Database")] pub struct DatabaseConfig { - /// Full connection URL. When set, it replaces the options below and the db_password secret. - /// Meant for local development, where `make serve` sets it. - #[arg( - long = "db-url", - env = "KEYGO_DATABASE_URL", - hide_env_values = true, - global = true - )] + /// Connection URL. Replaces the options below and the db_password secret. + #[arg(long = "db-url", env = "KEYGO_DATABASE_URL", hide_env_values = true)] pub url: Option, - + /// Postgres host. #[arg( long = "db-host", env = "KEYGO_DATABASE_HOST", - default_value = "postgres", - global = true + default_value = "postgres" )] pub host: String, - - #[arg( - long = "db-port", - env = "KEYGO_DATABASE_PORT", - default_value_t = 5432, - global = true - )] + /// Postgres port. + #[arg(long = "db-port", env = "KEYGO_DATABASE_PORT", default_value_t = 5432)] pub port: u16, - - #[arg( - long = "db-name", - env = "KEYGO_DATABASE_NAME", - default_value = "keygo", - global = true - )] + /// Database name. + #[arg(long = "db-name", env = "KEYGO_DATABASE_NAME", default_value = "keygo")] pub name: String, - + /// Database user. Its password is the db_password secret. #[arg( long = "db-user", + value_name = "USER", env = "KEYGO_DATABASE_USERNAME", - default_value = "keygo", - global = true + default_value = "keygo" )] pub username: String, } @@ -103,15 +90,20 @@ impl Environment { } } -// TODO: maybe find better API structure -impl Config { +impl SecretsConfig { + pub(crate) fn store(&self) -> SecretStore { + SecretStore::new(&self.dir) + } + pub(crate) fn opaque_setup(&self) -> anyhow::Result { - SecretStore::new(&self.secrets_dir).read::() + self.store().read::() } + pub(crate) fn db_password(&self) -> anyhow::Result { - SecretStore::new(&self.secrets_dir).read::() + self.store().read::() } + pub(crate) fn invite_pepper(&self) -> anyhow::Result { - SecretStore::new(&self.secrets_dir).read::() + self.store().read::() } } diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs index d73b38fa4..751212e7f 100644 --- a/rust/crates/keygo-server/src/db/mod.rs +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -4,33 +4,35 @@ pub mod opaque_records; pub mod registrations; pub mod users; -use crate::config::Config; +use crate::config::{DatabaseConfig, SecretsConfig}; use secrecy::ExposeSecret; use sqlx::PgPool; use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgSslMode}; use std::str::FromStr; use std::time::Duration; -pub async fn connect(config: &Config) -> anyhow::Result { +pub async fn connect(database: &DatabaseConfig, secrets: &SecretsConfig) -> anyhow::Result { Ok(PgPoolOptions::new() .max_connections(10) .acquire_timeout(Duration::from_secs(5)) - .connect_with(connect_options(config)?) + .connect_with(connect_options(database, secrets)?) .await?) } -fn connect_options(config: &Config) -> anyhow::Result { - let db_config = &config.database; - if let Some(url) = &db_config.url { +fn connect_options( + database: &DatabaseConfig, + secrets: &SecretsConfig, +) -> anyhow::Result { + if let Some(url) = &database.url { return Ok(PgConnectOptions::from_str(url.expose_secret())?); } Ok(PgConnectOptions::new() - .host(&db_config.host) - .port(db_config.port) - .database(&db_config.name) - .username(&db_config.username) - .password(config.db_password()?.expose_secret()) + .host(&database.host) + .port(database.port) + .database(&database.name) + .username(&database.username) + .password(secrets.db_password()?.expose_secret()) // Plaintext is safe only because compose keeps Postgres on the internal backend network. // Once "bring your own db" becomes an option, we will need to add a config option to // toggle it. We will also need to add sqlx features to handle TLS diff --git a/rust/crates/keygo-server/src/server/mod.rs b/rust/crates/keygo-server/src/server/mod.rs index 0579f5758..00bf040bb 100644 --- a/rust/crates/keygo-server/src/server/mod.rs +++ b/rust/crates/keygo-server/src/server/mod.rs @@ -1,5 +1,5 @@ use crate::api::{self, AppState}; -use crate::config::Config; +use crate::config::DeploymentConfig; use crate::db; use anyhow::Context; use std::net::SocketAddr; @@ -8,8 +8,8 @@ use tokio::signal::unix::{SignalKind, signal}; mod sweeper; -pub async fn start(config: &Config, listen: SocketAddr) -> anyhow::Result<()> { - let db = db::connect(config).await?; +pub async fn start(config: &DeploymentConfig, listen: SocketAddr) -> anyhow::Result<()> { + let db = db::connect(&config.database, &config.secrets).await?; db::migrate(&db).await?; let sweeper = tokio::spawn(sweeper::run(db.clone())); From 3103fb04350c03cbaf80235c76a63df2553dc881 Mon Sep 17 00:00:00 2001 From: Davis Wolfermann Date: Sun, 18 Oct 2026 15:52:52 +0200 Subject: [PATCH 37/43] refactor(cache): move db to cache --- rust/Cargo.lock | 48 +++ rust/crates/keygo-opaque/src/server.rs | 1 + ...9d115cb4bcbba00e99be77e126008e44044cf.json | 12 - ...5a77cf808bf60dd854a040e168ad0843138d2.json | 41 --- ...7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json | 18 -- rust/crates/keygo-server/Cargo.toml | 1 + .../migrations/20261014141017_init.sql | 10 - rust/crates/keygo-server/src/api/mod.rs | 2 + .../keygo-server/src/api/routes/auth/login.rs | 56 ++-- .../src/api/routes/auth/register.rs | 5 +- rust/crates/keygo-server/src/api/state.rs | 3 + .../keygo-server/src/cache/login_states.rs | 84 ++++++ rust/crates/keygo-server/src/cache/mod.rs | 3 + .../keygo-server/src/db/login_states.rs | 273 ------------------ rust/crates/keygo-server/src/db/mod.rs | 1 - .../keygo-server/src/domain/random_id.rs | 10 +- rust/crates/keygo-server/src/main.rs | 1 + .../crates/keygo-server/src/server/sweeper.rs | 12 +- .../keygo-server/src/service/login/finish.rs | 77 +++-- .../keygo-server/src/service/login/start.rs | 34 +-- rust/crates/keygo-server/src/testing.rs | 19 +- 21 files changed, 261 insertions(+), 450 deletions(-) delete mode 100644 rust/crates/keygo-server/.sqlx/query-27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf.json delete mode 100644 rust/crates/keygo-server/.sqlx/query-43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2.json delete mode 100644 rust/crates/keygo-server/.sqlx/query-b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json create mode 100644 rust/crates/keygo-server/src/cache/login_states.rs create mode 100644 rust/crates/keygo-server/src/cache/mod.rs delete mode 100644 rust/crates/keygo-server/src/db/login_states.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3a6757d10..26dbc4964 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -619,6 +619,24 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -1619,6 +1637,7 @@ dependencies = [ "http-body-util", "humantime", "keygo-opaque", + "moka", "secrecy", "serde", "serde_json", @@ -1779,6 +1798,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "nom" version = "7.1.3" @@ -2062,6 +2098,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.6" @@ -2813,6 +2855,12 @@ dependencies = [ "syn 3.0.7", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" diff --git a/rust/crates/keygo-opaque/src/server.rs b/rust/crates/keygo-opaque/src/server.rs index 97434d1ca..c4f981e66 100644 --- a/rust/crates/keygo-opaque/src/server.rs +++ b/rust/crates/keygo-opaque/src/server.rs @@ -14,6 +14,7 @@ opaque_newtype! { ServerSetup(ServerSetup) as Zeroizing>; /// Server state held between KE2 and KE3. + #[derive(Clone)] ServerLogin(ServerLogin) as Zeroizing>; } diff --git a/rust/crates/keygo-server/.sqlx/query-27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf.json b/rust/crates/keygo-server/.sqlx/query-27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf.json deleted file mode 100644 index 1b4a64e93..000000000 --- a/rust/crates/keygo-server/.sqlx/query-27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "delete from login_states where expires_at <= now()", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "27dc5dbfd342cbf053689ab50799d115cb4bcbba00e99be77e126008e44044cf" -} diff --git a/rust/crates/keygo-server/.sqlx/query-43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2.json b/rust/crates/keygo-server/.sqlx/query-43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2.json deleted file mode 100644 index 349405893..000000000 --- a/rust/crates/keygo-server/.sqlx/query-43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "delete from login_states\n where id = $1 and purpose = $2 and expires_at > now()\n returning user_id, state", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "user_id", - "type_info": "Uuid", - "origin": { - "Table": { - "table": "login_states", - "name": "user_id" - } - } - }, - { - "ordinal": 1, - "name": "state", - "type_info": "Bytea", - "origin": { - "Table": { - "table": "login_states", - "name": "state" - } - } - } - ], - "parameters": { - "Left": [ - "Bytea", - "Text" - ] - }, - "nullable": [ - true, - false - ] - }, - "hash": "43d5b9c11e531ea93532112575d5a77cf808bf60dd854a040e168ad0843138d2" -} diff --git a/rust/crates/keygo-server/.sqlx/query-b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json b/rust/crates/keygo-server/.sqlx/query-b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json deleted file mode 100644 index d07c09a55..000000000 --- a/rust/crates/keygo-server/.sqlx/query-b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "insert into login_states (id, user_id, purpose, state, expires_at)\n values ($1, $2, $3, $4, now() + make_interval(secs => $5))", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Bytea", - "Uuid", - "Text", - "Bytea", - "Float8" - ] - }, - "nullable": [] - }, - "hash": "b50180e72b11f47700587beafcd7dd47bcfd4d9426e2e0ac8f158e5e0102acfc" -} diff --git a/rust/crates/keygo-server/Cargo.toml b/rust/crates/keygo-server/Cargo.toml index ce48573f2..358321d9c 100644 --- a/rust/crates/keygo-server/Cargo.toml +++ b/rust/crates/keygo-server/Cargo.toml @@ -32,6 +32,7 @@ humantime = "2.4.0" hmac = "0.13.0" sha2 = { version = "0.11.0", features = ["zeroize"] } time = { version = "0.3.55", features = ["formatting", "macros"] } +moka = { version = "0.12.16", features = ["sync"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/rust/crates/keygo-server/migrations/20261014141017_init.sql b/rust/crates/keygo-server/migrations/20261014141017_init.sql index d17e67697..edebc33c8 100644 --- a/rust/crates/keygo-server/migrations/20261014141017_init.sql +++ b/rust/crates/keygo-server/migrations/20261014141017_init.sql @@ -29,13 +29,3 @@ create unlogged table pending_registrations expires_at timestamptz not null ); create index pending_registrations_expires_at_idx on pending_registrations (expires_at); - -create unlogged table login_states -( - id bytea primary key, -- login_id, 16 random bytes - user_id uuid, -- null on the fake path - purpose text not null check (purpose in ('login', 'step_up')), - state bytea not null, -- serialized ServerLogin - expires_at timestamptz not null -); -create index login_states_expires_at_idx on login_states (expires_at); diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index e2c8b1a40..3510677c7 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -32,6 +32,7 @@ async fn not_found() -> ServerError { #[cfg(test)] mod tests { use super::*; + use crate::cache::LoginStates; use crate::domain::InvitePepper; use crate::service::registration::Signup; use axum::body::Body; @@ -67,6 +68,7 @@ mod tests { // Lazy pools never connect until queried, so routes without a query need no database. db: sqlx::postgres::PgPoolOptions::new() .connect_lazy_with(sqlx::postgres::PgConnectOptions::new()), + login_states: LoginStates::new(), } } diff --git a/rust/crates/keygo-server/src/api/routes/auth/login.rs b/rust/crates/keygo-server/src/api/routes/auth/login.rs index 7b2636f9f..8537a5a97 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/login.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/login.rs @@ -47,7 +47,7 @@ async fn start( credential_request: request.credential_request, }; - let started = login::start(&state.db, &state.opaque_state, input).await?; + let started = login::start(&state.db, &state.login_states, &state.opaque_state, input).await?; Ok(Json(StartResponse { login_id: started.login_id, @@ -87,7 +87,7 @@ async fn finish( credential_finalization: request.credential_finalization, }; - let finished = login::finish(&state.db, input).await?; + let finished = login::finish(&state.db, &state.login_states, input).await?; Ok(Json(FinishResponse { user_id: finished.user_id, @@ -99,7 +99,7 @@ async fn finish( mod tests { use super::*; use crate::api::error::render; - use crate::testing::{PASSWORD, count, post, register}; + use crate::testing::{PASSWORD, app_state, post_to, register}; use keygo_opaque::OpaqueError; use keygo_opaque::client::ClientLogin; use keygo_opaque::server::ServerSetup; @@ -109,14 +109,10 @@ mod tests { const START: &str = "/api/v1/auth/login/start"; const FINISH: &str = "/api/v1/auth/login/finish"; - async fn start_over_http( - db: &PgPool, - setup: &ServerSetup, - username: &str, - ) -> (ClientLogin, Value) { + async fn start_over_http(state: &AppState, username: &str) -> (ClientLogin, Value) { let (client, request) = ClientLogin::start(PASSWORD).unwrap(); let body = json!({ "username": username, "credential_request": request.to_base64() }); - let (status, started) = post(db, setup, START, body).await; + let (status, started) = post_to(state.clone(), START, body).await; assert_eq!(status, StatusCode::OK); (client, started) } @@ -151,8 +147,9 @@ mod tests { #[sqlx::test] async fn logs_in_over_http(db: PgPool) { let setup = ServerSetup::generate(); + let state = app_state(&db, &setup); let user_id = register(&db, &setup, "ada").await; - let (client, started) = start_over_http(&db, &setup, "Ada").await; + let (client, started) = start_over_http(&state, "Ada").await; assert_eq!(started["expires_in"], login::TTL.as_secs()); let body = json!({ @@ -160,20 +157,21 @@ mod tests { "credential_finalization": finalization(client, &started), }); - let (status, finished) = post(&db, &setup, FINISH, body).await; + let (status, finished) = post_to(state.clone(), FINISH, body).await; assert_eq!(status, StatusCode::OK); assert_eq!(finished, json!({ "user_id": user_id, "username": "ada" })); - assert_eq!(count(&db, "login_states").await, 0); + assert_eq!(state.login_states.count(), 0); } #[sqlx::test] async fn unknown_user_starts_like_a_known_one(db: PgPool) { let setup = ServerSetup::generate(); + let state = app_state(&db, &setup); register(&db, &setup, "ada").await; - let (_, known) = start_over_http(&db, &setup, "ada").await; - let (_, unknown) = start_over_http(&db, &setup, "bob").await; + let (_, known) = start_over_http(&state, "ada").await; + let (_, unknown) = start_over_http(&state, "bob").await; let shape = |body: &Value| { body.as_object() @@ -187,10 +185,11 @@ mod tests { #[sqlx::test] async fn start_rejects_an_invalid_username(db: PgPool) { + let state = app_state(&db, &ServerSetup::generate()); let (_, request) = ClientLogin::start(PASSWORD).unwrap(); let body = json!({ "username": "a", "credential_request": request.to_base64() }); - let (status, body) = post(&db, &ServerSetup::generate(), START, body).await; + let (status, body) = post_to(state.clone(), START, body).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["code"], "invalid_username"); @@ -198,27 +197,29 @@ mod tests { #[sqlx::test] async fn start_rejects_a_malformed_credential_request(db: PgPool) { + let state = app_state(&db, &ServerSetup::generate()); let body = json!({ "username": "ada", "credential_request": "AAAA" }); - let (status, body) = post(&db, &ServerSetup::generate(), START, body).await; + let (status, body) = post_to(state.clone(), START, body).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["code"], "invalid_request"); - assert_eq!(count(&db, "login_states").await, 0); + assert_eq!(state.login_states.count(), 0); } #[sqlx::test] async fn foreign_finalization_is_unauthorized(db: PgPool) { let setup = ServerSetup::generate(); + let state = app_state(&db, &setup); register(&db, &setup, "ada").await; - let (_, target) = start_over_http(&db, &setup, "ada").await; - let (client, other) = start_over_http(&db, &setup, "ada").await; + let (_, target) = start_over_http(&state, "ada").await; + let (client, other) = start_over_http(&state, "ada").await; let body = json!({ "login_id": target["login_id"], "credential_finalization": finalization(client, &other), }); - let (status, body) = post(&db, &setup, FINISH, body).await; + let (status, body) = post_to(state.clone(), FINISH, body).await; assert_eq!(status, StatusCode::UNAUTHORIZED); assert_eq!(body["code"], "invalid_credentials"); @@ -227,15 +228,16 @@ mod tests { #[sqlx::test] async fn unknown_user_finish_is_unauthorized(db: PgPool) { let setup = ServerSetup::generate(); + let state = app_state(&db, &setup); register(&db, &setup, "ada").await; - let (_, fake) = start_over_http(&db, &setup, "bob").await; - let (client, real) = start_over_http(&db, &setup, "ada").await; + let (_, fake) = start_over_http(&state, "bob").await; + let (client, real) = start_over_http(&state, "ada").await; let body = json!({ "login_id": fake["login_id"], "credential_finalization": finalization(client, &real), }); - let (status, body) = post(&db, &setup, FINISH, body).await; + let (status, body) = post_to(state.clone(), FINISH, body).await; assert_eq!(status, StatusCode::UNAUTHORIZED); assert_eq!(body["code"], "invalid_credentials"); @@ -244,14 +246,15 @@ mod tests { #[sqlx::test] async fn finish_rejects_an_unknown_login(db: PgPool) { let setup = ServerSetup::generate(); + let state = app_state(&db, &setup); register(&db, &setup, "ada").await; - let (client, started) = start_over_http(&db, &setup, "ada").await; + let (client, started) = start_over_http(&state, "ada").await; let body = json!({ "login_id": LoginId::random(), "credential_finalization": finalization(client, &started), }); - let (status, body) = post(&db, &setup, FINISH, body).await; + let (status, body) = post_to(state.clone(), FINISH, body).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["code"], "login_expired"); @@ -259,9 +262,10 @@ mod tests { #[sqlx::test] async fn finish_rejects_a_malformed_login_id(db: PgPool) { + let state = app_state(&db, &ServerSetup::generate()); let body = json!({ "login_id": "short", "credential_finalization": "AAAA" }); - let (status, body) = post(&db, &ServerSetup::generate(), FINISH, body).await; + let (status, body) = post_to(state, FINISH, body).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body["code"], "invalid_request"); diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index 2f6aa64ca..5191f4c2e 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -106,7 +106,7 @@ async fn finish( mod tests { use super::*; use crate::api::error::render; - use crate::testing::{count, create_invite, invite_only, post, post_to, register}; + use crate::testing::{app_state, count, create_invite, invite_only, post, post_to, register}; use keygo_opaque::client::ClientRegistration; use keygo_opaque::server::ServerSetup; use serde_json::{Value, json}; @@ -173,8 +173,7 @@ mod tests { fn invite_only_state(db: &PgPool, setup: &ServerSetup) -> AppState { AppState { signup: invite_only(), - opaque_state: setup.clone(), - db: db.clone(), + ..app_state(db, setup) } } diff --git a/rust/crates/keygo-server/src/api/state.rs b/rust/crates/keygo-server/src/api/state.rs index 4c4f0d12d..1c1c86c4f 100644 --- a/rust/crates/keygo-server/src/api/state.rs +++ b/rust/crates/keygo-server/src/api/state.rs @@ -1,3 +1,4 @@ +use crate::cache::LoginStates; use crate::config::DeploymentConfig; use crate::service::registration::Signup; use sqlx::PgPool; @@ -8,6 +9,7 @@ pub struct AppState { pub signup: Signup, pub opaque_state: keygo_opaque::server::ServerSetup, pub db: PgPool, + pub login_states: LoginStates, } impl AppState { @@ -21,6 +23,7 @@ impl AppState { }, opaque_state: secrets.opaque_setup()?, db, + login_states: LoginStates::new(), }) } } diff --git a/rust/crates/keygo-server/src/cache/login_states.rs b/rust/crates/keygo-server/src/cache/login_states.rs new file mode 100644 index 000000000..4d59d8a63 --- /dev/null +++ b/rust/crates/keygo-server/src/cache/login_states.rs @@ -0,0 +1,84 @@ +use crate::domain::LoginId; +use keygo_opaque::server::ServerLogin; +use moka::sync::Cache; +use std::time::Duration; +use uuid::Uuid; + +const TTL: Duration = Duration::from_mins(1); +const MAX_ENTRIES: u64 = 10_000; + +#[derive(Clone)] +pub struct Pending { + /// `None` on the fake path for an unknown username. + pub user_id: Option, + pub state: ServerLogin, +} + +#[derive(Clone)] +pub struct LoginStates(Cache); + +impl LoginStates { + pub fn new() -> Self { + Self::with_ttl(TTL) + } + + pub fn with_ttl(ttl: Duration) -> Self { + Self( + Cache::builder() + .max_capacity(MAX_ENTRIES) + .time_to_live(ttl) + .build(), + ) + } + + pub fn insert(&self, key: LoginId, value: Pending) { + self.0.insert(key, value); + } + + pub fn take(&self, key: &LoginId) -> Option { + self.0.remove(key) + } + + #[cfg(test)] + pub fn count(&self) -> u64 { + self.0.run_pending_tasks(); + self.0.entry_count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use keygo_opaque::client::ClientLogin; + use keygo_opaque::server::ServerSetup; + + fn pending() -> Pending { + let (_, request) = ClientLogin::start(b"password").unwrap(); + let (state, _) = + ServerLogin::start(&ServerSetup::generate(), None, request, b"identifier").unwrap(); + Pending { + user_id: None, + state, + } + } + + #[test] + fn a_state_is_taken_once() { + let states = LoginStates::new(); + let id = LoginId::random(); + states.insert(id, pending()); + + assert!(states.take(&id).is_some()); + assert!(states.take(&id).is_none()); + } + + #[test] + fn an_expired_state_cannot_be_taken() { + let states = LoginStates::with_ttl(Duration::from_millis(10)); + let id = LoginId::random(); + states.insert(id, pending()); + std::thread::sleep(Duration::from_millis(50)); + + assert!(states.take(&id).is_none()); + } +} diff --git a/rust/crates/keygo-server/src/cache/mod.rs b/rust/crates/keygo-server/src/cache/mod.rs new file mode 100644 index 000000000..bb05804d0 --- /dev/null +++ b/rust/crates/keygo-server/src/cache/mod.rs @@ -0,0 +1,3 @@ +pub mod login_states; + +pub use login_states::*; diff --git a/rust/crates/keygo-server/src/db/login_states.rs b/rust/crates/keygo-server/src/db/login_states.rs deleted file mode 100644 index ff2e542f0..000000000 --- a/rust/crates/keygo-server/src/db/login_states.rs +++ /dev/null @@ -1,273 +0,0 @@ -use super::corrupt; -use crate::domain::LoginId; -use keygo_opaque::server::ServerLogin; -use sqlx::PgConnection; -use std::time::Duration; -use uuid::Uuid; -use zeroize::Zeroizing; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Purpose { - Login, -} - -impl Purpose { - const fn as_str(self) -> &'static str { - match self { - Self::Login => "login", - } - } -} - -pub async fn insert( - conn: &mut PgConnection, - login_id: &LoginId, - user_id: Option, - purpose: Purpose, - state: &ServerLogin, - ttl: Duration, -) -> sqlx::Result<()> { - let state = state.to_bytes(); - sqlx::query!( - "insert into login_states (id, user_id, purpose, state, expires_at) - values ($1, $2, $3, $4, now() + make_interval(secs => $5))", - login_id.as_bytes().as_slice(), - user_id, - purpose.as_str(), - state.as_slice(), - ttl.as_secs_f64(), - ) - .execute(conn) - .await?; - - Ok(()) -} - -pub struct Pending { - /// `None` on the fake path for an unknown username. - pub user_id: Option, - pub state: ServerLogin, -} - -/// Deletes the state as it reads it, so a login can be finished at most once. Returns `None` for -/// an unknown or expired login. -pub async fn take( - conn: &mut PgConnection, - login_id: &LoginId, - purpose: Purpose, -) -> sqlx::Result> { - let Some(row) = sqlx::query!( - "delete from login_states - where id = $1 and purpose = $2 and expires_at > now() - returning user_id, state", - login_id.as_bytes().as_slice(), - purpose.as_str(), - ) - .fetch_optional(conn) - .await? - else { - return Ok(None); - }; - - let state = Zeroizing::new(row.state); - let state = ServerLogin::from_bytes(&state).map_err(corrupt)?; - - Ok(Some(Pending { - user_id: row.user_id, - state, - })) -} - -pub async fn delete_expired(conn: &mut PgConnection) -> sqlx::Result { - let result = sqlx::query!("delete from login_states where expires_at <= now()") - .execute(conn) - .await?; - - Ok(result.rows_affected()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::testing::{count, expire, expires_in_secs}; - use keygo_opaque::client::ClientLogin; - use keygo_opaque::server::ServerSetup; - use sqlx::PgPool; - - const TTL: Duration = Duration::from_mins(1); - - fn server_login() -> ServerLogin { - let (_, request) = ClientLogin::start(b"password").unwrap(); - let (state, _) = - ServerLogin::start(&ServerSetup::generate(), None, request, b"identifier").unwrap(); - state - } - - async fn insert_new(conn: &mut PgConnection, user_id: Option) -> LoginId { - let login_id = LoginId::random(); - insert( - conn, - &login_id, - user_id, - Purpose::Login, - &server_login(), - TTL, - ) - .await - .unwrap(); - login_id - } - - #[sqlx::test] - async fn state_is_taken_once(db: PgPool) { - let mut conn = db.acquire().await.unwrap(); - let user_id = Uuid::new_v4(); - let login_id = insert_new(&mut conn, Some(user_id)).await; - - let pending = take(&mut conn, &login_id, Purpose::Login) - .await - .unwrap() - .unwrap(); - - assert_eq!(pending.user_id, Some(user_id)); - assert!( - take(&mut conn, &login_id, Purpose::Login) - .await - .unwrap() - .is_none() - ); - assert_eq!(count(&db, "login_states").await, 0); - } - - #[sqlx::test] - async fn state_round_trips_its_bytes(db: PgPool) { - let mut conn = db.acquire().await.unwrap(); - let state = server_login(); - let login_id = LoginId::random(); - insert(&mut conn, &login_id, None, Purpose::Login, &state, TTL) - .await - .unwrap(); - - let pending = take(&mut conn, &login_id, Purpose::Login) - .await - .unwrap() - .unwrap(); - - assert_eq!(*pending.state.to_bytes(), *state.to_bytes()); - } - - #[sqlx::test] - async fn fake_path_stores_no_user(db: PgPool) { - let mut conn = db.acquire().await.unwrap(); - let login_id = insert_new(&mut conn, None).await; - - let pending = take(&mut conn, &login_id, Purpose::Login) - .await - .unwrap() - .unwrap(); - - assert_eq!(pending.user_id, None); - } - - #[sqlx::test] - async fn unknown_login_is_none(db: PgPool) { - let mut conn = db.acquire().await.unwrap(); - insert_new(&mut conn, None).await; - - let pending = take(&mut conn, &LoginId::random(), Purpose::Login) - .await - .unwrap(); - - assert!(pending.is_none()); - assert_eq!(count(&db, "login_states").await, 1); - } - - #[sqlx::test] - async fn expired_login_is_none(db: PgPool) { - let mut conn = db.acquire().await.unwrap(); - let login_id = insert_new(&mut conn, None).await; - expire(&db, "login_states").await; - - assert!( - take(&mut conn, &login_id, Purpose::Login) - .await - .unwrap() - .is_none() - ); - } - - #[sqlx::test] - async fn delete_expired_keeps_live_states(db: PgPool) { - let mut conn = db.acquire().await.unwrap(); - insert_new(&mut conn, None).await; - insert_new(&mut conn, Some(Uuid::new_v4())).await; - expire(&db, "login_states").await; - let live = insert_new(&mut conn, None).await; - - assert_eq!(delete_expired(&mut conn).await.unwrap(), 2); - assert_eq!(count(&db, "login_states").await, 1); - assert!( - take(&mut conn, &live, Purpose::Login) - .await - .unwrap() - .is_some() - ); - } - - #[sqlx::test] - async fn state_expires_after_ttl(db: PgPool) { - let mut conn = db.acquire().await.unwrap(); - insert_new(&mut conn, None).await; - - let remaining = expires_in_secs(&db, "login_states").await; - - assert!( - (TTL.as_secs_f64() - 5.0..=TTL.as_secs_f64()).contains(&remaining), - "{remaining}" - ); - } - - #[sqlx::test] - async fn state_of_another_purpose_is_not_taken(db: PgPool) { - let login_id = LoginId::random(); - sqlx::query( - "insert into login_states (id, purpose, state, expires_at) - values ($1, 'step_up', $2, now() + interval '1 minute')", - ) - .bind(login_id.as_bytes().as_slice()) - .bind(server_login().to_bytes().as_slice()) - .execute(&db) - .await - .unwrap(); - let mut conn = db.acquire().await.unwrap(); - - assert!( - take(&mut conn, &login_id, Purpose::Login) - .await - .unwrap() - .is_none() - ); - assert_eq!(count(&db, "login_states").await, 1); - } - - #[sqlx::test] - async fn undecodable_state_is_corrupt(db: PgPool) { - let login_id = LoginId::random(); - sqlx::query( - "insert into login_states (id, purpose, state, expires_at) - values ($1, 'login', '\\x00', now() + interval '1 minute')", - ) - .bind(login_id.as_bytes().as_slice()) - .execute(&db) - .await - .unwrap(); - let mut conn = db.acquire().await.unwrap(); - - let err = take(&mut conn, &login_id, Purpose::Login) - .await - .err() - .unwrap(); - - assert!(matches!(err, sqlx::Error::Decode(_))); - } -} diff --git a/rust/crates/keygo-server/src/db/mod.rs b/rust/crates/keygo-server/src/db/mod.rs index 751212e7f..5e5d25879 100644 --- a/rust/crates/keygo-server/src/db/mod.rs +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -1,5 +1,4 @@ pub mod invites; -pub mod login_states; pub mod opaque_records; pub mod registrations; pub mod users; diff --git a/rust/crates/keygo-server/src/domain/random_id.rs b/rust/crates/keygo-server/src/domain/random_id.rs index 55cd63fe5..6f1d07fef 100644 --- a/rust/crates/keygo-server/src/domain/random_id.rs +++ b/rust/crates/keygo-server/src/domain/random_id.rs @@ -25,10 +25,6 @@ macro_rules! random_id { Self(bytes) } - pub fn as_bytes(&self) -> &[u8; LEN] { - &self.0 - } - pub fn to_base64(self) -> String { URL_SAFE_NO_PAD.encode(self.0) } @@ -63,6 +59,12 @@ random_id! { LoginId as "login_id"; } +impl RegistrationId { + pub const fn as_bytes(&self) -> &[u8; LEN] { + &self.0 + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/keygo-server/src/main.rs b/rust/crates/keygo-server/src/main.rs index 917c3f918..fc5831e21 100644 --- a/rust/crates/keygo-server/src/main.rs +++ b/rust/crates/keygo-server/src/main.rs @@ -2,6 +2,7 @@ use crate::cli::Cli; use std::process::ExitCode; mod api; +mod cache; mod cli; mod config; mod db; diff --git a/rust/crates/keygo-server/src/server/sweeper.rs b/rust/crates/keygo-server/src/server/sweeper.rs index b2ceead4b..e28863d9b 100644 --- a/rust/crates/keygo-server/src/server/sweeper.rs +++ b/rust/crates/keygo-server/src/server/sweeper.rs @@ -1,4 +1,4 @@ -use crate::db::{invites, login_states, registrations}; +use crate::db::{invites, registrations}; use sqlx::PgPool; use std::time::Duration; use tokio::time::MissedTickBehavior; @@ -25,9 +25,8 @@ pub async fn run(db: PgPool) { async fn sweep(db: &PgPool) -> sqlx::Result<()> { let mut conn = db.acquire().await?; let registrations = registrations::delete_expired(&mut conn).await?; - let logins = login_states::delete_expired(&mut conn).await?; let invites = invites::delete_expired(&mut conn).await?; - tracing::debug!(registrations, logins, invites, "swept expired rows"); + tracing::debug!(registrations, invites, "swept expired rows"); Ok(()) } @@ -36,7 +35,7 @@ mod tests { use super::*; use crate::domain::InviteHash; use crate::service::registration::{self, Signup, StartRegistration}; - use crate::testing::{count, expire, start_login}; + use crate::testing::{count, expire}; use keygo_opaque::client::ClientRegistration; use keygo_opaque::server::ServerSetup; use uuid::Uuid; @@ -69,19 +68,15 @@ mod tests { async fn sweep_deletes_only_expired_rows(db: PgPool) { let setup = ServerSetup::generate(); start_registration(&db, &setup, "ada").await; - start_login(&db, &setup, "ada").await; create_invite(&db, 1).await; expire(&db, "pending_registrations").await; - expire(&db, "login_states").await; expire(&db, "invites").await; start_registration(&db, &setup, "bob").await; - start_login(&db, &setup, "bob").await; create_invite(&db, 2).await; sweep(&db).await.unwrap(); assert_eq!(count(&db, "pending_registrations").await, 1); - assert_eq!(count(&db, "login_states").await, 1); assert_eq!(count(&db, "invites").await, 1); } @@ -90,7 +85,6 @@ mod tests { sweep(&db).await.unwrap(); assert_eq!(count(&db, "pending_registrations").await, 0); - assert_eq!(count(&db, "login_states").await, 0); assert_eq!(count(&db, "invites").await, 0); } } diff --git a/rust/crates/keygo-server/src/service/login/finish.rs b/rust/crates/keygo-server/src/service/login/finish.rs index 4bbb67742..864678393 100644 --- a/rust/crates/keygo-server/src/service/login/finish.rs +++ b/rust/crates/keygo-server/src/service/login/finish.rs @@ -1,4 +1,4 @@ -use crate::db::login_states::{self, Purpose}; +use crate::cache::LoginStates; use crate::db::users; use crate::domain::{LoginId, Username}; use keygo_opaque::{CredentialFinalization, OpaqueError}; @@ -29,23 +29,25 @@ pub struct Finished { /// The state is consumed before verifying, so a failed finish cannot be retried with the same /// `login_id`: every password guess costs a fresh `start`. -pub async fn finish(db: &PgPool, input: FinishLogin) -> Result { +pub async fn finish( + db: &PgPool, + login_states: &LoginStates, + input: FinishLogin, +) -> Result { let FinishLogin { login_id, credential_finalization, } = input; - let mut conn = db.acquire().await?; - let pending = login_states::take(&mut conn, &login_id, Purpose::Login) - .await? - .ok_or(FinishError::Expired)?; - + let pending = login_states.take(&login_id).ok_or(FinishError::Expired)?; // TODO: derive the session from the session key once sessions exist. let _session_key = pending.state.finish(credential_finalization)?; // The fake path cannot produce a valid finalization, and an account deleted since `start` has // no user left to log into; both answer exactly like a wrong password. let user_id = pending.user_id.ok_or(OpaqueError::InvalidLogin)?; + + let mut conn = db.acquire().await?; let username = users::find_username(&mut conn, user_id) .await? .ok_or(OpaqueError::InvalidLogin)?; @@ -56,27 +58,32 @@ pub async fn finish(db: &PgPool, input: FinishLogin) -> Result Result { @@ -60,7 +61,7 @@ pub async fn start( ServerLogin::start(setup, password_file, credential_request, &identifier)?; let login_id = LoginId::random(); - login_states::insert(&mut conn, &login_id, user_id, Purpose::Login, &state, TTL).await?; + login_states.insert(login_id, Pending { user_id, state }); Ok(Started { login_id, @@ -78,7 +79,7 @@ fn fake_identifier(username: &Username) -> Vec { #[cfg(test)] mod tests { use super::*; - use crate::testing::{count, register, start_login}; + use crate::testing::{register, start_login}; fn name(raw: &str) -> Username { Username::parse(raw).unwrap() @@ -95,40 +96,39 @@ mod tests { assert_eq!(fake_identifier(&name("ada")), b"v1:fake/login\0ada"); } - async fn stored_user_ids(db: &PgPool) -> Vec> { - sqlx::query_scalar("select user_id from login_states") - .fetch_all(db) - .await - .unwrap() - } - #[sqlx::test] async fn start_stores_state_for_a_known_user(db: PgPool) { + let login_states = LoginStates::new(); let setup = ServerSetup::generate(); let user_id = register(&db, &setup, "ada").await; - let (_, started) = start_login(&db, &setup, "ada").await; + let (_, started) = start_login(&db, &login_states, &setup, "ada").await; assert_eq!(started.expires_in, TTL); - assert_eq!(stored_user_ids(&db).await, [Some(user_id)]); + let pending = login_states.take(&started.login_id).unwrap(); + assert_eq!(pending.user_id, Some(user_id)); } #[sqlx::test] async fn start_answers_an_unknown_user_without_one(db: PgPool) { - let (_, started) = start_login(&db, &ServerSetup::generate(), "ada").await; + let login_states = LoginStates::new(); + + let (_, started) = start_login(&db, &login_states, &ServerSetup::generate(), "ada").await; assert_eq!(started.expires_in, TTL); - assert_eq!(stored_user_ids(&db).await, [None]); + let pending = login_states.take(&started.login_id).unwrap(); + assert_eq!(pending.user_id, None); } #[sqlx::test] async fn every_start_is_a_separate_login(db: PgPool) { + let login_states = LoginStates::new(); let setup = ServerSetup::generate(); - let (_, first) = start_login(&db, &setup, "ada").await; - let (_, second) = start_login(&db, &setup, "ada").await; + let (_, first) = start_login(&db, &login_states, &setup, "ada").await; + let (_, second) = start_login(&db, &login_states, &setup, "ada").await; assert_ne!(first.login_id, second.login_id); - assert_eq!(count(&db, "login_states").await, 2); + assert_eq!(login_states.count(), 2); } } diff --git a/rust/crates/keygo-server/src/testing.rs b/rust/crates/keygo-server/src/testing.rs index 3c8a194c0..3f789aaf4 100644 --- a/rust/crates/keygo-server/src/testing.rs +++ b/rust/crates/keygo-server/src/testing.rs @@ -1,4 +1,5 @@ use crate::api::{AppState, router}; +use crate::cache::LoginStates; use crate::db::invites; use crate::domain::{InviteHash, InvitePepper, Username}; use crate::service::invite; @@ -48,6 +49,7 @@ pub async fn register(db: &PgPool, setup: &ServerSetup, name: &str) -> Uuid { } pub async fn start_login( db: &PgPool, + login_states: &LoginStates, setup: &ServerSetup, name: &str, ) -> (ClientLogin, login::Started) { @@ -56,7 +58,10 @@ pub async fn start_login( username: username(name), credential_request, }; - (client, login::start(db, setup, input).await.unwrap()) + ( + client, + login::start(db, login_states, setup, input).await.unwrap(), + ) } pub fn finish_login(client: ClientLogin, started: login::Started) -> FinishLogin { let finish = client @@ -116,13 +121,17 @@ pub async fn expires_in_secs(db: &PgPool, table: &str) -> f64 { .unwrap() } -pub async fn post(db: &PgPool, setup: &ServerSetup, uri: &str, body: Value) -> (StatusCode, Value) { - let state = AppState { +/// Requests that must see each other's login states share one `AppState` through `post_to`. +pub fn app_state(db: &PgPool, setup: &ServerSetup) -> AppState { + AppState { signup: Signup::Open, opaque_state: setup.clone(), db: db.clone(), - }; - post_to(state, uri, body).await + login_states: LoginStates::new(), + } +} +pub async fn post(db: &PgPool, setup: &ServerSetup, uri: &str, body: Value) -> (StatusCode, Value) { + post_to(app_state(db, setup), uri, body).await } pub async fn post_to(state: AppState, uri: &str, body: Value) -> (StatusCode, Value) { let request = Request::post(uri) From 2eb967b798c270e4b7b47890182d4ea71b863cd9 Mon Sep 17 00:00:00 2001 From: Davis Date: Mon, 19 Oct 2026 20:06:50 +0200 Subject: [PATCH 38/43] fix(api): align register and login with spec --- rust/crates/keygo-server/src/api/mod.rs | 30 ++++++++++++ .../keygo-server/src/api/routes/auth/mod.rs | 7 +++ .../src/api/routes/auth/register.rs | 39 ++++++++++++--- .../keygo-server/src/cache/login_states.rs | 36 +++++++++++++- .../keygo-server/src/domain/username.rs | 45 ++++++++++++++++++ .../src/service/registration/start.rs | 47 +++++++++++++++++-- 6 files changed, 193 insertions(+), 11 deletions(-) diff --git a/rust/crates/keygo-server/src/api/mod.rs b/rust/crates/keygo-server/src/api/mod.rs index 3510677c7..901095bc0 100644 --- a/rust/crates/keygo-server/src/api/mod.rs +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -38,6 +38,7 @@ mod tests { use axum::body::Body; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; + use routes::auth::MAX_BODY_BYTES; use serde_json::{Value, json}; use std::sync::Arc; use tower::ServiceExt; @@ -120,6 +121,35 @@ mod tests { assert!(body["message"].as_str().is_some_and(|m| !m.is_empty())); } + #[tokio::test] + async fn oversized_body_is_rejected_as_json() { + let padding = "a".repeat(MAX_BODY_BYTES); + let (status, body) = post_json( + state(), + "/api/v1/auth/register/start", + json!({ "username": padding }), + ) + .await; + + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["code"], "invalid_request"); + } + + #[tokio::test] + async fn body_at_the_limit_is_parsed() { + let body = json!({ "username": "" }).to_string(); + let padded = format!("{body: Router { Router::new() .nest("/register", register::router()) .nest("/login", login::router()) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) } diff --git a/rust/crates/keygo-server/src/api/routes/auth/register.rs b/rust/crates/keygo-server/src/api/routes/auth/register.rs index 5191f4c2e..7aef518da 100644 --- a/rust/crates/keygo-server/src/api/routes/auth/register.rs +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -35,6 +35,7 @@ impl HttpError for registration::StartError { fn status_and_code(&self) -> (StatusCode, ErrorCode) { match self { Self::InvalidUserId => (StatusCode::BAD_REQUEST, ErrorCode::InvalidRequest), + Self::ReservedUsername => (StatusCode::BAD_REQUEST, ErrorCode::InvalidUsername), Self::InviteRequired => (StatusCode::FORBIDDEN, ErrorCode::InviteRequired), Self::InviteInvalid => (StatusCode::FORBIDDEN, ErrorCode::InviteInvalid), Self::UsernameTaken => (StatusCode::CONFLICT, ErrorCode::UsernameTaken), @@ -89,7 +90,7 @@ impl HttpError for registration::FinishError { async fn finish( State(state): State, Json(request): Json, -) -> ServerResult> { +) -> ServerResult<(StatusCode, Json)> { let input = FinishRegistration { registration_id: request.registration_id, registration_upload: request.registration_upload, @@ -97,9 +98,13 @@ async fn finish( let finished = registration::finish(&state.db, input).await?; - Ok(Json(FinishResponse { - username: finished.username, - })) + // 201 is the client's commit point (§8): only then does it write anything locally. + Ok(( + StatusCode::CREATED, + Json(FinishResponse { + username: finished.username, + }), + )) } #[cfg(test)] @@ -165,7 +170,7 @@ mod tests { let (status, finished) = post(&db, &setup, FINISH, body).await; - assert_eq!(status, StatusCode::OK); + assert_eq!(status, StatusCode::CREATED); assert_eq!(finished, json!({ "username": "ada" })); assert_eq!(count(&db, "users").await, 1); } @@ -200,7 +205,7 @@ mod tests { let (status, _) = post_to(invite_only_state(&db, &setup), FINISH, body).await; - assert_eq!(status, StatusCode::OK); + assert_eq!(status, StatusCode::CREATED); assert_eq!(count(&db, "users").await, 1); assert_eq!(count(&db, "invites").await, 0); } @@ -254,6 +259,16 @@ mod tests { assert_eq!(body["code"], "invalid_username"); } + #[sqlx::test] + async fn start_rejects_a_reserved_username(db: PgPool) { + let (_, body) = start_body("admin", Uuid::new_v4()); + + let (status, body) = post(&db, &ServerSetup::generate(), START, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_username"); + } + #[sqlx::test] async fn start_rejects_a_structured_user_id(db: PgPool) { let (_, body) = start_body("ada", Uuid::nil()); @@ -290,6 +305,18 @@ mod tests { assert_eq!(body["code"], "username_taken"); } + #[sqlx::test] + async fn retry_after_a_lost_201_conflicts_on_the_user_id(db: PgPool) { + let setup = ServerSetup::generate(); + let user_id = register(&db, &setup, "ada").await; + let (_, body) = start_body("ada", user_id); + + let (status, body) = post(&db, &setup, START, body).await; + + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(body["code"], "user_id_taken"); + } + #[sqlx::test] async fn start_conflicts_on_a_taken_user_id(db: PgPool) { let setup = ServerSetup::generate(); diff --git a/rust/crates/keygo-server/src/cache/login_states.rs b/rust/crates/keygo-server/src/cache/login_states.rs index 4d59d8a63..8429e5d77 100644 --- a/rust/crates/keygo-server/src/cache/login_states.rs +++ b/rust/crates/keygo-server/src/cache/login_states.rs @@ -1,5 +1,6 @@ use crate::domain::LoginId; use keygo_opaque::server::ServerLogin; +use moka::ops::compute::{CompResult, Op}; use moka::sync::Cache; use std::time::Duration; use uuid::Uuid; @@ -36,7 +37,20 @@ impl LoginStates { } pub fn take(&self, key: &LoginId) -> Option { - self.0.remove(key) + // Not `Cache::remove`: that returns an entry even after it expired, until maintenance drops + // it. `and_compute_with` reads the entry the way `get` does, so it never sees an expired one, + // and it runs calls on the same key one at a time, so a state can be taken only once. + let result = self + .0 + .entry_by_ref(key) + .and_compute_with(|entry| match entry { + Some(_) => Op::Remove, + None => Op::Nop, + }); + match result { + CompResult::Removed(entry) => Some(entry.into_value()), + _ => None, + } } #[cfg(test)] @@ -72,6 +86,26 @@ mod tests { assert!(states.take(&id).is_none()); } + #[test] + fn concurrent_takes_get_the_state_once() { + let states = LoginStates::new(); + let id = LoginId::random(); + states.insert(id, pending()); + + let taken = std::thread::scope(|scope| { + let handles: Vec<_> = (0..8) + .map(|_| scope.spawn(|| states.take(&id).is_some())) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|&taken| taken) + .count() + }); + + assert_eq!(taken, 1); + } + #[test] fn an_expired_state_cannot_be_taken() { let states = LoginStates::with_ttl(Duration::from_millis(10)); diff --git a/rust/crates/keygo-server/src/domain/username.rs b/rust/crates/keygo-server/src/domain/username.rs index 266e4571b..f88f976ac 100644 --- a/rust/crates/keygo-server/src/domain/username.rs +++ b/rust/crates/keygo-server/src/domain/username.rs @@ -5,6 +5,33 @@ use unicode_normalization::UnicodeNormalization; const SEPARATORS: [char; 3] = ['.', '-', '_']; +/// Names that read as speaking for the server or the project. Kept out of `parse` on purpose: a +/// name added here later must not turn an existing account's stored username into a decode error. +const RESERVED: &[&str] = &[ + "abuse", + "admin", + "administrator", + "anonymous", + "api", + "help", + "hostmaster", + "info", + "keygo", + "moderator", + "no-reply", + "noreply", + "official", + "operator", + "owner", + "postmaster", + "root", + "security", + "staff", + "support", + "system", + "webmaster", +]; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] pub enum UsernameError { #[error("username must be at least {} characters", Username::MIN_LEN)] @@ -49,6 +76,10 @@ impl Username { pub fn as_str(&self) -> &str { &self.0 } + + pub fn is_reserved(&self) -> bool { + RESERVED.contains(&self.as_str()) + } } fn validate(name: &str) -> Result<(), UsernameError> { @@ -193,6 +224,20 @@ mod tests { assert_eq!(parsed("1.2.3a"), "1.2.3a"); } + #[test] + fn reserved_names_parse_but_are_reserved() { + assert!(Username::parse("Admin").unwrap().is_reserved()); + assert!(Username::parse("root").unwrap().is_reserved()); + assert!(!Username::parse("admin2").unwrap().is_reserved()); + } + + #[test] + fn reserved_names_are_valid_usernames() { + for name in RESERVED { + assert_eq!(parsed(name), *name); + } + } + #[test] fn allowed_chars_match_validation() { let advertised = |c: char| { diff --git a/rust/crates/keygo-server/src/service/registration/start.rs b/rust/crates/keygo-server/src/service/registration/start.rs index 5174661d3..83c071648 100644 --- a/rust/crates/keygo-server/src/service/registration/start.rs +++ b/rust/crates/keygo-server/src/service/registration/start.rs @@ -13,6 +13,8 @@ use uuid::{Uuid, Variant}; pub enum StartError { #[error("user_id must be a random (version 4) UUID")] InvalidUserId, + #[error("username is reserved")] + ReservedUsername, #[error("this server needs an invite to register")] InviteRequired, #[error("invite is not valid or has expired")] @@ -70,6 +72,10 @@ pub async fn start( // Before any username check, so only invite holders can probe usernames. let invite_hash = check_invite(db, signup, invite.as_deref()).await?; + if username.is_reserved() { + return Err(StartError::ReservedUsername); + } + // Pure computation, so it runs before the transaction to keep the reservation's lock short. let registration_response = server::registration_start(setup, registration_request, user_id.as_bytes())?; @@ -89,14 +95,19 @@ pub async fn start( // Checked after reserving on purpose. A finish that consumed the previous reservation for // this name or id still holds its row (or index entry), so reserve waits for it to commit, // and these reads, each with a fresh snapshot, then see the user it created. - if users::username_exists(&mut tx, &username).await? { - return Err(StartError::UsernameTaken); - } - + // + // The id goes first: a client whose finish committed but whose 201 was lost retries with the + // same id and name, and must learn that its id is taken so it signs in. Answering + // `username_taken` would send it back to the username field instead. The id is 122 random + // bits, so checking it first gives nobody a way to probe for accounts. if users::id_exists(&mut tx, user_id).await? { return Err(StartError::UserIdTaken); } + if users::username_exists(&mut tx, &username).await? { + return Err(StartError::UsernameTaken); + } + tx.commit().await?; Ok(Started { @@ -310,6 +321,34 @@ mod tests { assert_eq!(count(&db, "pending_registrations").await, 0); } + #[sqlx::test] + async fn retry_after_a_committed_finish_is_user_id_taken(db: PgPool) { + let user_id = register(&db, &ServerSetup::generate(), "ada").await; + + let err = start_as(&db, user_id, "ada").await.err().unwrap(); + + assert!(matches!(err, StartError::UserIdTaken), "{err:?}"); + assert_eq!(count(&db, "pending_registrations").await, 0); + } + + #[sqlx::test] + async fn start_rejects_a_reserved_username(db: PgPool) { + let err = start_as(&db, Uuid::new_v4(), "Admin").await.err().unwrap(); + + assert!(matches!(err, StartError::ReservedUsername), "{err:?}"); + assert_eq!(count(&db, "pending_registrations").await, 0); + } + + #[sqlx::test] + async fn invite_is_checked_before_the_reserved_list(db: PgPool) { + let err = start_with(&db, &invite_only(), None, Uuid::new_v4(), "admin") + .await + .err() + .unwrap(); + + assert!(matches!(err, StartError::InviteRequired), "{err:?}"); + } + #[sqlx::test] async fn restart_with_the_same_user_id_replaces_the_reservation(db: PgPool) { let user_id = Uuid::new_v4(); From 9d93b3f7036f9b0b0bb84c0e6440093feb800703 Mon Sep 17 00:00:00 2001 From: Davis Date: Mon, 19 Oct 2026 21:01:01 +0200 Subject: [PATCH 39/43] docs(claude): document sync server and rust workspace --- CLAUDE.md | 51 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 68b883c07..98acd1a7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,8 +15,12 @@ repository. ## Build & Test ```bash -make -C rust test # Rust unit tests +make -C rust test # Rust workspace tests (server tests need Postgres, see below) make -C rust bindgen # Generate uniffi bindings +make -C rust db # Dev Postgres (docker) + migrations + dev secrets +make -C rust serve # Run keygo-server against the dev Postgres +make -C rust prepare # Refresh committed .sqlx data after changing a query +make -C rust migration name= # Add a sqlx migration ./gradlew build # Full build ./gradlew test # All unit tests ./gradlew :app:test # Module-specific tests (preferred for small changes) @@ -56,6 +60,16 @@ Android password manager using Clean Architecture per module: | `:legacy-migration` | The whole v1 to v2 migration: main password, item import (high risk) | | `:core:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings; Rust sources in `rust/crates/` | +Rust workspace (`rust/`, toolchain pinned in `rust-toolchain.toml`, edition 2024): + +| Crate | Purpose | +|-----------------|-------------------------------------------------------------------------------| +| `keygo-core` | Crypto, ARK session, passkeys, TOTP, backup, card formatting used by the app | +| `keygo-uniffi` | UniFFI bindings exposing `keygo-core` to Kotlin (`libkeygo_bindings.so`) | +| `keygo-opaque` | OPAQUE (opaque-ke, ristretto255 + Argon2) client/server wrapper, `forbid(unsafe_code)` | +| `keygo-server` | Self-hostable sync server (axum + sqlx/Postgres + clap CLI), see Sync Server | +| `tools/uniffi-bindgen` | Bindgen runner used by `make bindgen` | + ## Key Patterns **Result type** — Sealed `Result` with `Success`/`Failure`. Use helpers: `onSuccess()`, @@ -133,6 +147,34 @@ carries its own rules: - Do not widen the escrow's lifetime, its auth policy, or the set of callers that can read it without explicit instruction. +## Sync Server + +`rust/crates/keygo-server` (work in progress on `feat/backend`). Deployment lives in `deploy/` +(Docker Compose: `init` profile generates secrets, `postgres` on an internal network, `api`, +Caddy as TLS proxy; `.env.example` sets `DOMAIN` and signups). + +- **Layers** — `cli/` (clap: `init`, `check`, `serve`, `invite create|list|revoke`) → `api/` + (axum routes, `ServerError` JSON errors with snake_case `ErrorCode`) → `service/` (registration, + login, invite use cases) → `db/` (sqlx `query!` per table) / `cache/` (in-memory moka state) → + `domain/` (validated newtypes: `Username`, `InviteCode`, `InviteHash`, ids). + `server/` boots the pool, runs migrations, and spawns the `sweeper` that deletes expired rows. +- **Config** — `config::DeploymentConfig` is the single source for `serve` and `check`; every + option has a `KEYGO_*` env var. Secrets are one file each in `KEYGO_SECRETS_DIR` + (`/run/secrets`): `db_password`, `opaque_setup`, `invite_pepper`, read through typed decoders + in `secrets.rs`. `init` creates missing ones and never overwrites. +- **API** — `/health/live`, `/api/v1/meta`, `/api/v1/auth/{register,login}/{start,finish}`. + JSON is snake_case. Auth bodies are capped at `MAX_BODY_BYTES` (16 KiB). +- **Registration** — two-step OPAQUE; `start` reserves username/user_id in the unlogged + `pending_registrations` table (5 min TTL). `user_id` is client-generated (UUID v4) and equals the + local user id. Unless `KEYGO_ALLOW_SIGNUPS`, an invite is required; invite codes are stored only + as an HMAC-SHA256 under `invite_pepper`. +- **Login** — two-step OPAQUE; per-login `ServerLogin` state lives in `cache::LoginStates` + (1 min TTL, taken at most once). Unknown usernames run against a fake record so responses never + reveal whether an account exists — keep that property. +- **Schema** — `migrations/` is applied by `serve` on startup (`sqlx::migrate!`). Never edit a + shipped migration; add a new one. After changing any query run `make -C rust prepare` and commit + the `.sqlx/` data (plain builds and CI use it offline via `SQLX_OFFLINE`). + ## Sensitive Areas - **Migration** (`legacy-migration`) — preserve backward compat, smallest safe change. The v1 main @@ -144,6 +186,10 @@ carries its own rules: - **Autofill** (`feature/autofill/`) — constrained by Android framework, keep conservative - **UniFFI** — preserve memory and type safety across the FFI boundary. - **Room schema** — check migration implications before changing entities +- **Server auth** (`keygo-opaque`, `keygo-server` secrets, OPAQUE flows, invite hashing) — treat as + security-sensitive. `opaque_setup` and `invite_pepper` cannot be rotated without invalidating + every account/invite; the OPAQUE suite constants (`SUITE_ID`, KSF params, `LOGIN_CONTEXT`) are + wire/storage identity. ## Code Style @@ -177,6 +223,9 @@ carries its own rules: (`SessionImpl` wraps the UniFFI `ArkSessionInterface`). Its fakes live in `:core:security` testFixtures (`de.davis.keygo.core.security`): `FakeSession`, `FakeArkCredential`, and `FakeSessionFactory` for code that opens a throwaway session through `SessionFactory`. +- **Server tests** — `keygo-server` DB tests use `#[sqlx::test]` (a throwaway database per test via + `DATABASE_URL`); run `make -C rust db` first locally. CI provides a Postgres service. Shared + helpers live in `src/testing.rs`; API tests drive `api::router` with `tower::ServiceExt::oneshot`. - **testFixtures + Compose plugin** — Any module with `kotlin.compose` that enables testFixtures must add `testFixturesImplementation(libs.androidx.compose.runtime)` to avoid "Compose Runtime not on classpath" compile errors. See `:core:item` for the canonical pattern. From ac0013e32bcafcb041d892a9175c40741bb46390 Mon Sep 17 00:00:00 2001 From: Davis Date: Mon, 19 Oct 2026 21:01:01 +0200 Subject: [PATCH 40/43] fix(deploy): drop unused proxy config and align signup variable --- deploy/.env.example | 6 +++--- deploy/compose.yaml | 11 ++--------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/deploy/.env.example b/deploy/.env.example index 0e1427c27..d715505e1 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,6 +1,6 @@ # Public hostname. Point its DNS A/AAAA record at this server before starting. -# For local trail use: DOMAIN=localhost +# For a local trial: DOMAIN=localhost DOMAIN=keygo.example.com -# Allow anyone to register? If false, registration require an invite link. -KEYGO_ALLOW_SIGNUP=false +# Allow anyone to register? If false, registration requires an invite. +KEYGO_ALLOW_SIGNUPS=false diff --git a/deploy/compose.yaml b/deploy/compose.yaml index d6a325f37..1c3d40cf0 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -4,9 +4,9 @@ x-app: &app build: context: ../rust dockerfile: crates/keygo-server/Dockerfile - environment: &app-env + environment: KEYGO_PUBLIC_URL: https://${DOMAIN:?Set DOMAIN in .env (copy .env.example)} - KEYGO_ALLOW_SIGNUPS: ${KEYGO_ALLOW_SIGNUP:-false} + KEYGO_ALLOW_SIGNUPS: ${KEYGO_ALLOW_SIGNUPS:-false} KEYGO_DATABASE_USERNAME: ${KEYGO_DATABASE_USER:-keygo} KEYGO_DATABASE_NAME: ${KEYGO_DATABASE_NAME:-keygo} @@ -40,15 +40,10 @@ services: timeout: 3s retries: 10 - # Needs `keygo-server serve`, which does not exist yet: start with `--profile server`. api: <<: *app command: [ "keygo-server", "serve" ] restart: unless-stopped - environment: - <<: *app-env - # The proxy subnet: only Caddy reaches api there, so trust its X-Forwarded-*. - FORWARDED_ALLOW_IPS: 172.31.0.0/24 networks: - backend - proxy @@ -86,6 +81,4 @@ networks: internal: true proxy: internal: true - ipam: - config: [ { subnet: 172.31.0.0/24 } ] web: { } From b6184101abdf1c206838a4095a6064909abf2a0c Mon Sep 17 00:00:00 2001 From: Davis Date: Mon, 19 Oct 2026 21:01:01 +0200 Subject: [PATCH 41/43] refactor(caddy): disable update marker handling --- deploy/caddy/Caddyfile | 45 +++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index f067c5159..acb06ec98 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -13,28 +13,29 @@ } handle_errors { - @updating { - expression {err.status_code} in [502, 503, 504] - file { - root /control - try_files /updating - } - } - handle @updating { - header Retry-After 15 - respond `{"status":"updating"}` 503 - } - - @failed { - expression {err.status_code} in [502, 503, 504] - file { - root /control - try_files /update-failed - } - } - handle @failed { - respond `{"status":"update_failed"}` 503 - } + # Disabled until something writes the marker files and /control is mounted. + # @updating { + # expression {err.status_code} in [502, 503, 504] + # file { + # root /control + # try_files /updating + # } + # } + # handle @updating { + # header Retry-After 15 + # respond `{"status":"updating"}` 503 + # } + # + # @failed { + # expression {err.status_code} in [502, 503, 504] + # file { + # root /control + # try_files /update-failed + # } + # } + # handle @failed { + # respond `{"status":"update_failed"}` 503 + # } respond `{"status":"unavailable"}` 502 } From 0adfc0749aef8ce218a7125080e5a07abcf147e9 Mon Sep 17 00:00:00 2001 From: Davis Date: Mon, 19 Oct 2026 21:01:01 +0200 Subject: [PATCH 42/43] feat(server): default to production environment --- rust/Makefile | 1 + rust/crates/keygo-server/src/config.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/Makefile b/rust/Makefile index ee6197501..33b93fecc 100644 --- a/rust/Makefile +++ b/rust/Makefile @@ -96,6 +96,7 @@ DEV_TARGETS := db serve server-check prepare $(DEV_TARGETS): export KEYGO_DATABASE_URL := $(DB_URL) $(DEV_TARGETS): export KEYGO_SECRETS_DIR := $(DEV_SECRETS) $(DEV_TARGETS): export DATABASE_URL := $(DB_URL) +$(DEV_TARGETS): export KEYGO_ENVIRONMENT := development dev-tools: ## Verify docker and sqlx-cli are installed @command -v docker >/dev/null || { echo "docker not found" >&2; exit 1; } diff --git a/rust/crates/keygo-server/src/config.rs b/rust/crates/keygo-server/src/config.rs index d15669407..7df9e11cd 100644 --- a/rust/crates/keygo-server/src/config.rs +++ b/rust/crates/keygo-server/src/config.rs @@ -74,9 +74,9 @@ pub struct DatabaseConfig { #[derive(ValueEnum, Clone, Copy, Default, Debug, PartialEq, Eq)] pub enum Environment { + #[default] #[value(alias = "prod")] Production, - #[default] #[value(alias = "dev")] Development, } From c1d4b22d9196452d02761e6019e76ac2f4d8f4bf Mon Sep 17 00:00:00 2001 From: Davis Date: Mon, 19 Oct 2026 21:01:01 +0200 Subject: [PATCH 43/43] ci(rust): check committed sqlx query data --- .github/workflows/ci.yaml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 7ffd55a91..4d5e2a2ff 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -93,18 +93,32 @@ jobs: steps: - uses: actions/checkout@v6 + # Own key: sharing rust-lint's would never save this job's build or the installed sqlx-cli. - uses: actions/cache@v5 with: path: | ~/.cargo/registry ~/.cargo/git + ~/.cargo/bin/sqlx + ~/.cargo/bin/cargo-sqlx + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json rust/target - key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }} - restore-keys: ${{ runner.os }}-cargo-host- + key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-test- - name: Build run: cargo build --workspace --all-targets --verbose - name: Run tests run: cargo test --workspace --verbose + # Offline builds never see the schema, so a migration that changes a queried column would + # still compile against stale .sqlx data. Recompute it against the migrated database instead. + - name: Install sqlx-cli + run: cargo install sqlx-cli@0.9.0 --locked --no-default-features --features postgres + - name: Check committed sqlx query data + working-directory: ./rust/crates/keygo-server + run: | + sqlx migrate run + cargo sqlx prepare --check # Android unit tests. Waits for rust-lint when rust changed, so NDK cross-compilation isn't # triggered on code that already fails fmt/clippy.