diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 22e9cbed9..4d5e2a2ff 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -66,24 +66,59 @@ 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 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. diff --git a/.gitignore b/.gitignore index 239b62946..0370e5798 100644 --- a/.gitignore +++ b/.gitignore @@ -259,3 +259,10 @@ docs/superpowers/ graphify-out/ .gitattributes + +.docker/ +# Docker Compose +.env + +# Local dev secrets (`make -C rust db`) +rust/dev-secrets/ 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. diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 000000000..d715505e1 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,6 @@ +# Public hostname. Point its DNS A/AAAA record at this server before starting. +# For a local trial: DOMAIN=localhost +DOMAIN=keygo.example.com + +# Allow anyone to register? If false, registration requires an invite. +KEYGO_ALLOW_SIGNUPS=false diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 000000000..acb06ec98 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,44 @@ +{$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 { + # 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 + } + + reverse_proxy api:8000 +} diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 000000000..1c3d40cf0 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,84 @@ +name: KeyGo Backend Server + +x-app: &app + build: + context: ../rust + dockerfile: crates/keygo-server/Dockerfile + environment: + KEYGO_PUBLIC_URL: https://${DOMAIN:?Set DOMAIN in .env (copy .env.example)} + KEYGO_ALLOW_SIGNUPS: ${KEYGO_ALLOW_SIGNUPS:-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 + + api: + <<: *app + command: [ "keygo-server", "serve" ] + restart: unless-stopped + 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 + web: { } 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 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 06737697a..26dbc4964 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]] @@ -37,18 +37,89 @@ dependencies = [ "polyval", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +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" +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" 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" @@ -56,9 +127,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]] @@ -138,12 +209,97 @@ 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" +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", + "axum-macros", + "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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base16ct" version = "1.0.0" @@ -156,6 +312,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" @@ -191,6 +353,18 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[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" @@ -198,7 +372,16 @@ 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]] @@ -208,6 +391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -216,6 +400,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" @@ -268,8 +458,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]] @@ -305,8 +495,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", ] @@ -326,6 +516,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 +546,18 @@ 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.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -383,6 +586,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" @@ -392,12 +604,72 @@ 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-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" +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" 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" @@ -406,23 +678,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]] @@ -465,6 +747,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" @@ -472,11 +770,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", @@ -499,27 +797,66 @@ 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 = "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" +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", + "zeroize", ] [[package]] @@ -533,15 +870,21 @@ 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" 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", @@ -564,11 +907,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", @@ -579,6 +922,27 @@ name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[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" @@ -586,17 +950,17 @@ 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", ] @@ -626,22 +990,58 @@ 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" 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" @@ -649,16 +1049,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "flume" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ - "percent-encoding", + "futures-core", + "futures-sink", + "spin", ] [[package]] -name = "fs-err" +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", +] + +[[package]] +name = "fs-err" version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" @@ -666,18 +1083,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" @@ -691,11 +1146,36 @@ 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", ] +[[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" @@ -705,7 +1185,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -725,14 +1205,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", ] @@ -747,25 +1238,69 @@ 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" 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.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]] @@ -774,9 +1309,60 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] +[[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 = "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" @@ -789,6 +1375,40 @@ dependencies = [ "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]] name = "icu_collections" version = "2.3.0" @@ -900,7 +1520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -914,6 +1534,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" @@ -947,7 +1573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -956,21 +1582,21 @@ version = "0.1.0" dependencies = [ "aead", "aes-gcm-siv", - "argon2", + "argon2 0.6.0", "async-trait", - "base64", + "base64 0.23.1", "bcs", "ciborium", "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", @@ -980,6 +1606,57 @@ dependencies = [ "zeroize", ] +[[package]] +name = "keygo-opaque" +version = "0.1.0" +dependencies = [ + "base64 0.23.1", + "opaque-ke", + "rand_core 0.6.4", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror", + "voprf", + "zeroize", +] + +[[package]] +name = "keygo-server" +version = "0.1.0" +dependencies = [ + "anstream", + "anstyle", + "anyhow", + "axum", + "base64 0.23.1", + "clap", + "getrandom 0.4.3", + "hex", + "hmac 0.13.0", + "http-body-util", + "humantime", + "keygo-opaque", + "moka", + "secrecy", + "serde", + "serde_json", + "sha2 0.11.1", + "sqlx", + "tempfile", + "thiserror", + "time", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "unicode-normalization", + "url", + "uuid", + "zeroize", +] + [[package]] name = "keygo-uniffi" version = "0.1.0" @@ -991,12 +1668,28 @@ dependencies = [ "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" 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" @@ -1009,32 +1702,83 @@ 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" 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 = "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" 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", + "const-oid 0.10.2", + "crypto-common 0.2.2", "ctutils", "hybrid-array", "module-lattice", @@ -1054,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" @@ -1064,6 +1825,21 @@ dependencies = [ "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]] +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" @@ -1079,6 +1855,33 @@ 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 = "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" @@ -1086,10 +1889,39 @@ 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]] +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]] @@ -1145,13 +1977,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", ] @@ -1180,13 +2012,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", ] @@ -1213,7 +2056,7 @@ checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" dependencies = [ "base64ct", "ctutils", - "getrandom", + "getrandom 0.4.3", ] [[package]] @@ -1228,10 +2071,16 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", + "der 0.8.2", "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" @@ -1245,10 +2094,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", "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" @@ -1258,16 +2113,22 @@ 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" 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", ] @@ -1278,7 +2139,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", @@ -1315,6 +2176,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" @@ -1322,8 +2192,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]] @@ -1332,14 +2211,40 @@ 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" +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 0.7.5", + "hmac 0.13.0", ] [[package]] @@ -1382,6 +2287,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" @@ -1402,20 +2313,42 @@ 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", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -1458,10 +2391,11 @@ dependencies = [ [[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 = [ + "foldhash", "indexmap", "itoa", "memchr", @@ -1470,6 +2404,17 @@ dependencies = [ "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]] name = "serde_spanned" version = "1.1.2" @@ -1479,13 +2424,25 @@ dependencies = [ "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]] name = "serdect" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" dependencies = [ - "base16ct", + "base16ct 1.0.0", "serde", ] @@ -1496,81 +2453,312 @@ 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 = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "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]] +name = "signature" +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", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "smawk" +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 = "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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "base64ct", + "der 0.8.2", ] [[package]] -name = "shake" +name = "sponge-cursor" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" -dependencies = [ - "digest", - "keccak", - "sponge-cursor", -] +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" [[package]] -name = "signature" -version = "3.0.0" +name = "sqlx" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ - "digest", - "rand_core", + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", ] [[package]] -name = "siphasher" -version = "1.0.4" +name = "sqlx-core" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +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", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", +] [[package]] -name = "slab" -version = "0.4.12" +name = "sqlx-macros" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] [[package]] -name = "smallvec" -version = "1.16.3" +name = "sqlx-macros-core" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] [[package]] -name = "smawk" -version = "0.3.3" +name = "sqlx-mysql" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" +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", + "time", + "tracing", + "uuid", +] [[package]] -name = "spki" -version = "0.8.1" +name = "sqlx-postgres" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ - "base64ct", - "der", + "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", + "time", + "tracing", + "uuid", + "whoami", ] [[package]] -name = "sponge-cursor" -version = "0.1.0" +name = "sqlx-sqlite" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +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", + "time", + "tracing", + "url", + "uuid", +] [[package]] name = "stable_deref_trait" @@ -1584,6 +2772,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" @@ -1639,6 +2838,12 @@ dependencies = [ "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" @@ -1650,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" @@ -1657,7 +2868,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", @@ -1693,6 +2904,45 @@ dependencies = [ "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]] +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" @@ -1703,14 +2953,26 @@ 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", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys", ] [[package]] @@ -1724,6 +2986,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" @@ -1771,28 +3044,156 @@ 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", ] +[[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]] name = "typenum" 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" @@ -1934,7 +3335,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", ] @@ -1957,18 +3358,66 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ - "getrandom", + "getrandom 0.4.3", "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 = "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" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -2023,6 +3472,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" @@ -2053,8 +3508,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/Makefile b/rust/Makefile index d5c2ee732..33b93fecc 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,59 @@ 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_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; } + @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-opaque/Cargo.toml b/rust/crates/keygo-opaque/Cargo.toml new file mode 100644 index 000000000..b4bbf8cab --- /dev/null +++ b/rust/crates/keygo-opaque/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "keygo-opaque" +version.workspace = true +edition.workspace = true + +[dependencies] +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/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..becfe37cf --- /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, SUITE_ID}; 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..3a11b8410 --- /dev/null +++ b/rust/crates/keygo-opaque/src/messages.rs @@ -0,0 +1,64 @@ +use crate::error::{OpaqueError, OpaqueResult}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +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>; +} + +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-opaque/src/server.rs b/rust/crates/keygo-opaque/src/server.rs new file mode 100644 index 000000000..c4f981e66 --- /dev/null +++ b/rust/crates/keygo-opaque/src/server.rs @@ -0,0 +1,68 @@ +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. + #[derive(Clone)] + ServerSetup(ServerSetup) as Zeroizing>; + + /// Server state held between KE2 and KE3. + #[derive(Clone)] + 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..9032248d7 --- /dev/null +++ b/rust/crates/keygo-opaque/src/suite.rs @@ -0,0 +1,83 @@ +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"; + +/// 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 { + 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) + ); +} 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-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-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-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-5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241.json b/rust/crates/keygo-server/.sqlx/query-5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241.json new file mode 100644 index 000000000..03363fa07 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241.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": "5ed1adb514659b2b28cc2340df225798363922530c2c4855836da1f9333a3241" +} 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-85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481.json b/rust/crates/keygo-server/.sqlx/query-85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481.json new file mode 100644 index 000000000..142d5eb8a --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481.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": "85f64fd50bded406bfb384b272fdf51449421754d38a3798b5874f754698f481" +} diff --git a/rust/crates/keygo-server/.sqlx/query-999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196.json b/rust/crates/keygo-server/.sqlx/query-999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196.json new file mode 100644 index 000000000..16eeacb01 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196.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": "999c8acaa631c7ad524ff789e28cc81fe691a8ea66b24a19fdd918aacc525196" +} diff --git a/rust/crates/keygo-server/.sqlx/query-abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2.json b/rust/crates/keygo-server/.sqlx/query-abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2.json new file mode 100644 index 000000000..ed36d8fa3 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2.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": "abdc0a044f8c72f9a1a4d25c0c33d968330dc97e271c825020f724ca4783f4c2" +} 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/.sqlx/query-b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc.json b/rust/crates/keygo-server/.sqlx/query-b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc.json new file mode 100644 index 000000000..7d47a12ee --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from pending_registrations where expires_at <= now()", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "b6289fd03ff58c825dba5925199fad106259f972a5a6d75a84838bda4c9c78cc" +} diff --git a/rust/crates/keygo-server/.sqlx/query-b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3.json b/rust/crates/keygo-server/.sqlx/query-b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3.json new file mode 100644 index 000000000..03710f3a9 --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from pending_registrations\n where id = $1 and expires_at > now()\n returning user_id, username, invite_hash", + "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" + } + } + }, + { + "ordinal": 2, + "name": "invite_hash", + "type_info": "Bytea", + "origin": { + "Table": { + "table": "pending_registrations", + "name": "invite_hash" + } + } + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "b85438372cf0c06e5a9f2f1657f0975dd8eb102447be3c187cb82b1a677fb5d3" +} diff --git a/rust/crates/keygo-server/.sqlx/query-c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327.json b/rust/crates/keygo-server/.sqlx/query-c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327.json new file mode 100644 index 000000000..985a6cecf --- /dev/null +++ b/rust/crates/keygo-server/.sqlx/query-c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327.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": "c9482dda19cb67fab64db269b2a993a7b12da41c392fbf8f66c29a1c08531327" +} 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/.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/.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-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 new file mode 100644 index 000000000..358321d9c --- /dev/null +++ b/rust/crates/keygo-server/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "keygo-server" +description = "KeyGo self-hostable sync server." +version.workspace = true +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" +clap = { version = "4.6.7", features = ["derive", "env"] } +base64 = "0.23.1" +getrandom = "0.4.3" +hex = "0.4.3" +secrecy = "0.10.3" +tempfile = "3.27.0" +url = "2.5.8" +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"] } +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", "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"] } +moka = { version = "0.12.16", features = ["sync"] } + +[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/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/migrations/20261014141017_init.sql b/rust/crates/keygo-server/migrations/20261014141017_init.sql new file mode 100644 index 000000000..edebc33c8 --- /dev/null +++ b/rust/crates/keygo-server/migrations/20261014141017_init.sql @@ -0,0 +1,31 @@ +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 table invites +( + code_hash bytea primary key, + created_at timestamptz not null default now(), + expires_at timestamptz not null +); + +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 + 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 new file mode 100644 index 000000000..9b7166929 --- /dev/null +++ b/rust/crates/keygo-server/src/api/error.rs @@ -0,0 +1,228 @@ +use crate::domain::UsernameError; +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; + +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, + InviteRequired, + InviteInvalid, + RegistrationExpired, + LoginExpired, +} + +pub trait HttpError: std::error::Error + Send + Sync + 'static { + fn status_and_code(&self) -> (StatusCode, ErrorCode); +} + +pub struct ServerError { + status: StatusCode, + code: ErrorCode, + message: String, + source: Option, +} + +pub type ServerResult = Result; + +impl ServerError { + pub fn new(status: StatusCode, code: ErrorCode, message: impl Into) -> Self { + Self { + status, + code, + message: message.into(), + source: None, + } + } + + pub fn not_found() -> Self { + Self::new( + StatusCode::NOT_FOUND, + ErrorCode::NotFound, + "resource not found", + ) + } + + fn internal(source: anyhow::Error) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: ErrorCode::Internal, + message: "internal server error".to_owned(), + source: Some(source), + } + } +} + +impl From for ServerError { + fn from(err: E) -> Self { + let (status, code) = err.status_and_code(); + if status.is_server_error() { + return Self { + status, + code, + ..Self::internal(err.into()) + }; + } + + Self::new(status, 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 sqlx::Error { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + INTERNAL + } +} + +impl HttpError for JsonRejection { + 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, + _ => self.status(), + }; + (status, ErrorCode::InvalidRequest) + } +} + +impl HttpError for UsernameError { + fn status_and_code(&self) -> (StatusCode, ErrorCode) { + (StatusCode::BAD_REQUEST, ErrorCode::InvalidUsername) + } +} + +impl HttpError for OpaqueError { + 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 => (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, + } + } +} + +/// 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; + + 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 serde_json::json; + + #[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_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; + + 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 database_errors_are_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 new file mode 100644 index 000000000..901095bc0 --- /dev/null +++ b/rust/crates/keygo-server/src/api/mod.rs @@ -0,0 +1,165 @@ +mod error; +mod extract; +mod routes; +mod state; + +pub use error::{ErrorCode, HttpError, ServerError, ServerResult}; +pub use extract::Json; +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()) + .fallback(not_found) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +fn v1() -> Router { + Router::new() + .nest("/meta", routes::meta::router()) + .nest("/auth", routes::auth::router()) +} + +async fn not_found() -> ServerError { + ServerError::not_found() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cache::LoginStates; + use crate::domain::InvitePepper; + use crate::service::registration::Signup; + 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; + + 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()) + } + + 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 { + 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() + .connect_lazy_with(sqlx::postgres::PgConnectOptions::new()), + login_states: LoginStates::new(), + } + } + + #[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 { + signup: Signup::Open, + ..state() + }; + + let (status, body) = get(state, "/api/v1/meta").await; + + assert_eq!(status, StatusCode::OK); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + body, + json!({ "version": env!("CARGO_PKG_VERSION"), "allow_signups": true }), + ); + } + + #[tokio::test] + async fn unknown_route_is_not_found() { + 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::BAD_REQUEST); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["code"], "invalid_request"); + 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() + .route("/start", post(start)) + .route("/finish", post(finish)) +} + +#[derive(Deserialize)] +struct StartRequest { + /// Parsed in the handler so a bad name answers `invalid_username`, not `invalid_request`. + username: String, + credential_request: CredentialRequest, +} + +#[derive(Serialize)] +struct StartResponse { + login_id: LoginId, + credential_response: CredentialResponse, + expires_in: u64, +} + +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(request): Json, +) -> ServerResult> { + let input = StartLogin { + username: Username::parse(&request.username)?, + credential_request: request.credential_request, + }; + + let started = login::start(&state.db, &state.login_states, &state.opaque_state, input).await?; + + Ok(Json(StartResponse { + login_id: started.login_id, + 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(request): Json, +) -> ServerResult> { + let input = FinishLogin { + login_id: request.login_id, + credential_finalization: request.credential_finalization, + }; + + let finished = login::finish(&state.db, &state.login_states, input).await?; + + Ok(Json(FinishResponse { + user_id: finished.user_id, + username: finished.username, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::api::error::render; + use crate::testing::{PASSWORD, app_state, post_to, 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(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_to(state.clone(), 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() { + 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"); + } + + #[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(&state, "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_to(state.clone(), FINISH, body).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(finished, json!({ "user_id": user_id, "username": "ada" })); + 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(&state, "ada").await; + let (_, unknown) = start_over_http(&state, "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 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_to(state.clone(), 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 state = app_state(&db, &ServerSetup::generate()); + let body = json!({ "username": "ada", "credential_request": "AAAA" }); + + let (status, body) = post_to(state.clone(), START, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["code"], "invalid_request"); + 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(&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_to(state.clone(), 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(); + let state = app_state(&db, &setup); + register(&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_to(state.clone(), 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(); + let state = app_state(&db, &setup); + register(&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_to(state.clone(), 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 state = app_state(&db, &ServerSetup::generate()); + let body = json!({ "login_id": "short", "credential_finalization": "AAAA" }); + + 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/mod.rs b/rust/crates/keygo-server/src/api/routes/auth/mod.rs new file mode 100644 index 000000000..a9a5e5abc --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/auth/mod.rs @@ -0,0 +1,18 @@ +use crate::api::AppState; +use axum::Router; +use axum::extract::DefaultBodyLimit; + +mod login; +mod register; + +/// The largest legitimate body is a key bundle of a few hundred bytes. Every auth endpoint is +/// reachable without a token, so anything larger is refused before it is buffered and parsed. +/// Scoped to `/auth` so routes with larger payloads set their own limit. +pub const MAX_BODY_BYTES: usize = 16 * 1024; + +pub fn router() -> 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 new file mode 100644 index 000000000..7aef518da --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/auth/register.rs @@ -0,0 +1,355 @@ +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}; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/start", post(start)) + .route("/finish", post(finish)) +} + +#[derive(Deserialize)] +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(Serialize)] +struct StartResponse { + registration_id: RegistrationId, + registration_response: RegistrationResponse, + expires_in: u64, +} + +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), + 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(request): Json, +) -> ServerResult> { + 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, &state.signup, input).await?; + + Ok(Json(StartResponse { + registration_id: started.registration_id, + 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::InviteInvalid => (StatusCode::FORBIDDEN, ErrorCode::InviteInvalid), + Self::Db(err) => err.status_and_code(), + } + } +} + +async fn finish( + State(state): State, + Json(request): Json, +) -> ServerResult<(StatusCode, Json)> { + let input = FinishRegistration { + registration_id: request.registration_id, + registration_upload: request.registration_upload, + }; + + let finished = registration::finish(&state.db, input).await?; + + // 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)] +mod tests { + use super::*; + use crate::api::error::render; + 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}; + 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() { + 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"); + } + + #[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::CREATED); + assert_eq!(finished, json!({ "username": "ada" })); + assert_eq!(count(&db, "users").await, 1); + } + + fn invite_only_state(db: &PgPool, setup: &ServerSetup) -> AppState { + AppState { + signup: invite_only(), + ..app_state(db, setup) + } + } + + #[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::CREATED); + 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()); + + 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_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()); + + 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 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(); + 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/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..2455f067e --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/meta.rs @@ -0,0 +1,24 @@ +use crate::api::AppState; +use crate::service::registration::Signup; +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)] +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: matches!(state.signup, Signup::Open), + }) +} 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..5c7ed0e16 --- /dev/null +++ b/rust/crates/keygo-server/src/api/routes/mod.rs @@ -0,0 +1,3 @@ +pub mod auth; +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..1c1c86c4f --- /dev/null +++ b/rust/crates/keygo-server/src/api/state.rs @@ -0,0 +1,29 @@ +use crate::cache::LoginStates; +use crate::config::DeploymentConfig; +use crate::service::registration::Signup; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub signup: Signup, + pub opaque_state: keygo_opaque::server::ServerSetup, + pub db: PgPool, + pub login_states: LoginStates, +} + +impl AppState { + pub fn new(config: &DeploymentConfig, db: PgPool) -> anyhow::Result { + let secrets = &config.secrets; + Ok(Self { + signup: if config.server.allow_signups { + Signup::Open + } else { + Signup::InviteOnly(Arc::new(secrets.invite_pepper()?)) + }, + 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..8429e5d77 --- /dev/null +++ b/rust/crates/keygo-server/src/cache/login_states.rs @@ -0,0 +1,118 @@ +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; + +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 { + // 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)] + 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 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)); + 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/cli/commands/check.rs b/rust/crates/keygo-server/src/cli/commands/check.rs new file mode 100644 index 000000000..5828b336d --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/check.rs @@ -0,0 +1,618 @@ +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; + +#[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()), + ]; + 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: &DeploymentConfig) -> Section { + let server = &config.server; + Section { + title: "Config".into(), + checks: vec![ + check_environment(server.environment), + check_public_url(&server.public_url, server.environment), + check_signups(server.allow_signups, server.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 { + if let Some(url) = &db.url { + return check_database_url(url.expose_secret()); + } + + 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_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| 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()), + 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 weakness = match store.inspect(secret) { + Ok(weakness) => weakness, + Err(e) => return Check::new(Status::Fail, label, format!("{e:#}")), + }; + + 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); + 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 secrecy::ExposeSecret; + use std::fs::Permissions; + + const VALID: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + + fn database() -> DatabaseConfig { + DatabaseConfig { + url: None, + 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); + } + + 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_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(); + + 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")); + } + + #[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/cli/commands/init.rs b/rust/crates/keygo-server/src/cli/commands/init.rs new file mode 100644 index 000000000..f1f08fff6 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/init.rs @@ -0,0 +1,30 @@ +use crate::config::SecretsConfig; +use crate::secrets::{CreateOutcome, Secret}; +use anyhow::Context; +use clap::Args; + +#[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 { + let status = match store.create(secret, &secret.generate()?)? { + CreateOutcome::Created => "created", + CreateOutcome::AlreadyExists => { + store + .inspect(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/invite/create.rs b/rust/crates/keygo-server/src/cli/commands/invite/create.rs new file mode 100644 index 000000000..f71fafdf7 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/invite/create.rs @@ -0,0 +1,94 @@ +use super::{Connection, format_utc, with_db}; +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 { + /// How long the invite stays redeemable, from 1 to 30 days, e.g. "72h" or "2weeks". + #[arg(short, long, default_value = "1d", value_parser = parse_ttl)] + ttl: Duration, + #[command(flatten)] + connection: Connection, +} + +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!( + ttl <= MAX_TTL, + "ttl must be at most {}", + humantime::format_duration(MAX_TTL) + ); + Ok(ttl) +} + +pub fn run(args: &CreateInviteArgs) -> anyhow::Result<()> { + let pepper = args.connection.secrets.invite_pepper()?; + + let created = with_db(&args.connection, 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/list.rs b/rust/crates/keygo-server/src/cli/commands/invite/list.rs new file mode 100644 index 000000000..c4c4567c3 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/invite/list.rs @@ -0,0 +1,104 @@ +use super::{Connection, format_utc, id_width, with_db}; +use crate::service::invite::{self, Invite}; +use clap::Args; +use std::iter; + +#[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(()) +} + +fn table(invites: &[Invite]) -> String { + if invites.is_empty() { + return "No open invites".into(); + } + + let width = id_width(invites).max("ID".len()); + let header = 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 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 new file mode 100644 index 000000000..20eb30a83 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/invite/mod.rs @@ -0,0 +1,73 @@ +pub mod create; +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::{DatabaseConfig, SecretsConfig}; +use crate::db; +use crate::service::invite::Invite; +use clap::{Args, 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 { + /// Create an invite code that one registration can redeem. + Create(CreateInviteArgs), + /// List the open invites. + #[command(visible_alias = "ls")] + List(ListInviteArgs), + /// Revoke an open invite so its code no longer works. + Revoke(RevokeInviteArgs), +} + +/// 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(&args), + InviteCommands::List(args) => list::run(&args), + InviteCommands::Revoke(args) => revoke::run(&args), + } +} + +fn format_utc(at: OffsetDateTime) -> String { + at.to_offset(UtcOffset::UTC) + .format(UTC_MINUTES) + .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( + 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(&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 new file mode 100644 index 000000000..06a9472e5 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/invite/revoke.rs @@ -0,0 +1,73 @@ +use super::{Connection, format_utc, id_width, with_db}; +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, + #[command(flatten)] + connection: Connection, +} + +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)) + } + 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/cli/commands/mod.rs b/rust/crates/keygo-server/src/cli/commands/mod.rs new file mode 100644 index 000000000..137292492 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/mod.rs @@ -0,0 +1,4 @@ +pub mod check; +pub mod init; +pub mod invite; +pub 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..85a18f2f4 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/commands/serve.rs @@ -0,0 +1,25 @@ +use crate::config::DeploymentConfig; +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, + #[command(flatten)] + config: DeploymentConfig, +} + +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(); + + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .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 new file mode 100644 index 000000000..3b094d9f7 --- /dev/null +++ b/rust/crates/keygo-server/src/cli/mod.rs @@ -0,0 +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 clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "keygo-server", version, about)] +pub struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// 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<()> { + 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 new file mode 100644 index 000000000..7df9e11cd --- /dev/null +++ b/rust/crates/keygo-server/src/config.rs @@ -0,0 +1,109 @@ +use crate::domain::InvitePepper; +use crate::secrets::{DbPassword, OpaqueSetup, SecretStore}; +use clap::{Args, ValueEnum}; +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 DeploymentConfig { + #[command(flatten)] + pub server: ServerConfig, + #[command(flatten)] + pub secrets: SecretsConfig, + #[command(flatten)] + pub database: DatabaseConfig, +} + +#[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, + /// Let anyone register without an invite. + #[arg(long, env = "KEYGO_ALLOW_SIGNUPS")] + pub allow_signups: bool, +} + +#[derive(Args)] +#[command(next_help_heading = "Secrets")] +pub struct SecretsConfig { + /// Directory with one file per secret. + #[arg( + long = "secrets-dir", + value_name = "DIR", + env = "KEYGO_SECRETS_DIR", + default_value = "/run/secrets" + )] + pub dir: PathBuf, +} + +#[derive(Args)] +#[command(next_help_heading = "Database")] +pub struct DatabaseConfig { + /// 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" + )] + pub host: String, + /// Postgres port. + #[arg(long = "db-port", env = "KEYGO_DATABASE_PORT", default_value_t = 5432)] + pub port: u16, + /// 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" + )] + pub username: String, +} + +#[derive(ValueEnum, Clone, Copy, Default, Debug, PartialEq, Eq)] +pub enum Environment { + #[default] + #[value(alias = "prod")] + Production, + #[value(alias = "dev")] + Development, +} + +impl Environment { + pub fn name(&self) -> &'static str { + match self { + Environment::Production => "production", + Environment::Development => "development", + } + } +} + +impl SecretsConfig { + pub(crate) fn store(&self) -> SecretStore { + SecretStore::new(&self.dir) + } + + pub(crate) fn opaque_setup(&self) -> anyhow::Result { + self.store().read::() + } + + pub(crate) fn db_password(&self) -> anyhow::Result { + self.store().read::() + } + + pub(crate) fn invite_pepper(&self) -> anyhow::Result { + self.store().read::() + } +} 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..5a29876b6 --- /dev/null +++ b/rust/crates/keygo-server/src/db/invites.rs @@ -0,0 +1,272 @@ +use super::corrupt; +use crate::domain::{InviteHash, InviteId}; +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, +} + +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, + 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 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 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", + 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_as!( + InviteRow, + "select code_hash, created_at, expires_at from invites + where expires_at > now() + order by created_at, code_hash", + ) + .fetch_all(conn) + .await?; + + 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)] +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 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(); + 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 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(); + 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/mod.rs b/rust/crates/keygo-server/src/db/mod.rs new file mode 100644 index 000000000..5e5d25879 --- /dev/null +++ b/rust/crates/keygo-server/src/db/mod.rs @@ -0,0 +1,50 @@ +pub mod invites; +pub mod opaque_records; +pub mod registrations; +pub mod users; + +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(database: &DatabaseConfig, secrets: &SecretsConfig) -> anyhow::Result { + Ok(PgPoolOptions::new() + .max_connections(10) + .acquire_timeout(Duration::from_secs(5)) + .connect_with(connect_options(database, secrets)?) + .await?) +} + +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(&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 + .ssl_mode(PgSslMode::Disable)) +} + +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 new file mode 100644 index 000000000..f09625550 --- /dev/null +++ b/rust/crates/keygo-server/src/db/opaque_records.rs @@ -0,0 +1,208 @@ +use super::corrupt; +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, + 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(()) +} + +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(corrupt(format!( + "opaque record uses unknown suite {:?}", + row.suite + ))); + } + + let record = Zeroizing::new(row.record); + let file = PasswordFile::from_bytes(&record).map_err(corrupt)?; + + Ok(Some(Record { + user_id: row.user_id, + 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 new file mode 100644 index 000000000..4727864f5 --- /dev/null +++ b/rust/crates/keygo-server/src/db/registrations.rs @@ -0,0 +1,286 @@ +use super::corrupt; +use crate::domain::{InviteHash, 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, + invite_hash: Option<&InviteHash>, + 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, 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, + 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) + .await?; + + Ok(()) +} + +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 +/// `None` for an unknown or expired registration. +pub async fn take( + conn: &mut PgConnection, + 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, invite_hash", + registration_id.as_bytes().as_slice(), + ) + .fetch_optional(conn) + .await? + else { + return Ok(None); + }; + + 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, + })) +} + +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::*; + 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), None, 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_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(); + 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 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(); + 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 new file mode 100644 index 000000000..64ac74a32 --- /dev/null +++ b/rust/crates/keygo-server/src/db/users.rs @@ -0,0 +1,116 @@ +use super::corrupt; +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 +} + +pub async fn insert( + conn: &mut PgConnection, + user_id: Uuid, + username: &Username, +) -> sqlx::Result<()> { + sqlx::query!( + "insert into users (id, username) values ($1, $2)", + user_id, + username.as_str(), + ) + .execute(conn) + .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); + }; + + 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/domain/invite_code.rs b/rust/crates/keygo-server/src/domain/invite_code.rs new file mode 100644 index 000000000..b1805a53c --- /dev/null +++ b/rust/crates/keygo-server/src/domain/invite_code.rs @@ -0,0 +1,126 @@ +use super::InviteHash; +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) -> InviteHash { + let mut mac = + Hmac::::new_from_slice(self.0.as_slice()).expect("HMAC accepts any key length"); + mac.update(&code.0); + <[u8; 32]>::from(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).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..05fafa213 --- /dev/null +++ b/rust/crates/keygo-server/src/domain/invite_id.rs @@ -0,0 +1,140 @@ +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)] +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) + } + + 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 { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::invite_hash as hash; + + 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); + } + + #[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/domain/mod.rs b/rust/crates/keygo-server/src/domain/mod.rs new file mode 100644 index 000000000..833299199 --- /dev/null +++ b/rust/crates/keygo-server/src/domain/mod.rs @@ -0,0 +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/domain/random_id.rs b/rust/crates/keygo-server/src/domain/random_id.rs new file mode 100644 index 000000000..6f1d07fef --- /dev/null +++ b/rust/crates/keygo-server/src/domain/random_id.rs @@ -0,0 +1,105 @@ +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 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"; +} + +impl RegistrationId { + pub const fn as_bytes(&self) -> &[u8; LEN] { + &self.0 + } +} + +#[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/username.rs b/rust/crates/keygo-server/src/domain/username.rs new file mode 100644 index 000000000..f88f976ac --- /dev/null +++ b/rust/crates/keygo-server/src/domain/username.rs @@ -0,0 +1,258 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use thiserror::Error; +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)] + 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, Serialize, Deserialize)] +#[serde(try_from = "String")] +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 + } + + pub fn is_reserved(&self) -> bool { + RESERVED.contains(&self.as_str()) + } +} + +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 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| { + 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 new file mode 100644 index 000000000..fc5831e21 --- /dev/null +++ b/rust/crates/keygo-server/src/main.rs @@ -0,0 +1,24 @@ +use crate::cli::Cli; +use std::process::ExitCode; + +mod api; +mod cache; +mod cli; +mod config; +mod db; +mod domain; +mod secrets; +mod server; +mod service; +#[cfg(test)] +mod testing; + +fn main() -> ExitCode { + match Cli::start() { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err:?}"); + ExitCode::FAILURE + } + } +} diff --git a/rust/crates/keygo-server/src/secrets.rs b/rust/crates/keygo-server/src/secrets.rs new file mode 100644 index 000000000..8cdad8e78 --- /dev/null +++ b/rust/crates/keygo-server/src/secrets.rs @@ -0,0 +1,468 @@ +use crate::domain::InvitePepper; +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::{Path, PathBuf}; +use zeroize::Zeroizing; + +macro_rules! secrets { + ($($ty:ident),+ $(,)?) => { + #[derive(Clone, Copy, Debug)] + pub enum Secret { + $($ty,)+ + } + + impl Secret { + pub const ALL: &'static [Secret] = &[$(Secret::$ty),+]; + + pub const fn name(self) -> &'static str { + match self { + $(Secret::$ty => <$ty as TypedSecret>::NAME,)+ + } + } + + 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> { + match self { + $(Secret::$ty => assess::<$ty>(stored),)+ + } + } + } + }; +} + +fn assess(stored: &str) -> anyhow::Result> { + T::decode(stored).map(|value| T::weakness(&value)) +} + +pub trait TypedSecret { + /// File name inside the secrets directory. + const NAME: &'static str; + 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 NAME: &'static str = "db_password"; + 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()) + } + + fn decode(stored: &str) -> anyhow::Result { + ensure!(!stored.is_empty(), "empty"); + Ok(stored.into()) + } + + fn weakness(value: &SecretString) -> Option { + const MIN_CHARS: usize = 16; + (value.expose_secret().chars().count() < MIN_CHARS) + .then(|| format!("shorter than {MIN_CHARS} characters")) + } +} + +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") + } +} + +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)] +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()) + } + + fn path_of(&self) -> PathBuf { + self.dir.join(T::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 read(&self) -> anyhow::Result { + 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 path = self.path(secret); + let stored = read_text(&path)?; + secret + .assess(stored.expose_secret()) + .with_context(|| format!("{} is invalid", path.display())) + } + + pub fn create(&self, secret: Secret, value: &SecretString) -> anyhow::Result { + secret + .assess(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) + } +} + +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::*; + 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!(DbPassword::decode(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 externally_issued_password_is_accepted() { + assert!(DbPassword::decode("Tr0ub4dor&3-Cloud!").is_ok()); + } + + #[test] + fn empty_password_is_rejected() { + assert!(DbPassword::decode("").is_err()); + } + + #[test] + fn short_password_is_usable_but_weak() { + assert_eq!( + Secret::DbPassword.assess("hunter2").unwrap().as_deref(), + Some("shorter than 16 characters"), + ); + 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 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(); + + let outcome = store.create(Secret::DbPassword, &VALID.into()).unwrap(); + + assert_eq!(outcome, CreateOutcome::Created); + assert_eq!(store.read::().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, &"".into()).is_err()); + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); + } + + #[test] + fn read_strips_trailing_line_breaks() { + let (_dir, store) = store(); + write_raw( + &store, + Secret::DbPassword, + format!("{VALID}\r\n").as_bytes(), + ); + + let value = store.read::().unwrap(); + + 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(); + store.create(Secret::DbPassword, &VALID.into()).unwrap(); + + let password = store.read::().unwrap(); + + 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(); + + let err = store.read::().unwrap_err(); + + assert_eq!( + err.downcast_ref::().map(|e| e.kind()), + Some(ErrorKind::NotFound), + ); + } + + #[test] + fn read_rejects_empty_value() { + let (_dir, store) = store(); + write_raw(&store, Secret::DbPassword, b"\r\n"); + + assert!(store.read::().is_err()); + } + + #[test] + fn read_rejects_non_utf8() { + let (_dir, store) = store(); + write_raw(&store, Secret::DbPassword, &[0xff; 64]); + + 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_corrupt_opaque_setup() { + let (_dir, store) = store(); + write_raw(&store, Secret::OpaqueSetup, VALID.as_bytes()); + + assert!(store.inspect(Secret::OpaqueSetup).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); + } +} 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..00bf040bb --- /dev/null +++ b/rust/crates/keygo-server/src/server/mod.rs @@ -0,0 +1,44 @@ +use crate::api::{self, AppState}; +use crate::config::DeploymentConfig; +use crate::db; +use anyhow::Context; +use std::net::SocketAddr; +use tokio::net::TcpListener; +use tokio::signal::unix::{SignalKind, signal}; + +mod sweeper; + +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())); + let app = api::router(AppState::new(config, db)?); + 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?; + sweeper.abort(); + 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 => {} + } +} 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..e28863d9b --- /dev/null +++ b/rust/crates/keygo-server/src/server/sweeper.rs @@ -0,0 +1,90 @@ +use crate::db::{invites, 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, 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) { + 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 invites = invites::delete_expired(&mut conn).await?; + tracing::debug!(registrations, invites, "swept expired rows"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::InviteHash; + use crate::service::registration::{self, Signup, StartRegistration}; + use crate::testing::{count, expire}; + 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(), + invite: None, + registration_request, + }; + registration::start(db, setup, &Signup::Open, 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; + create_invite(&db, 1).await; + expire(&db, "pending_registrations").await; + expire(&db, "invites").await; + start_registration(&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, "invites").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, "invites").await, 0); + } +} 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..44a948167 --- /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/list.rs b/rust/crates/keygo-server/src/service/invite/list.rs new file mode 100644 index 000000000..0a89c2a0d --- /dev/null +++ b/rust/crates/keygo-server/src/service/invite/list.rs @@ -0,0 +1,45 @@ +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?; + Ok(Invite::all(&open)) +} + +#[cfg(test)] +mod tests { + use super::*; + 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() + } + + #[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 new file mode 100644 index 000000000..ebfcabb96 --- /dev/null +++ b/rust/crates/keygo-server/src/service/invite/mod.rs @@ -0,0 +1,36 @@ +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}; +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, + } + } + + 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/service/login/finish.rs b/rust/crates/keygo-server/src/service/login/finish.rs new file mode 100644 index 000000000..864678393 --- /dev/null +++ b/rust/crates/keygo-server/src/service/login/finish.rs @@ -0,0 +1,236 @@ +use crate::cache::LoginStates; +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), +} + +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, + login_states: &LoginStates, + input: FinishLogin, +) -> Result { + let FinishLogin { + login_id, + credential_finalization, + } = input; + + 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)?; + + Ok(Finished { user_id, username }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{PASSWORD, finish_login, register, start_login, username}; + use keygo_opaque::server::ServerSetup; + use std::time::Duration; + + #[sqlx::test] + async fn right_password_logs_in(db: PgPool) { + let login_states = LoginStates::new(); + let setup = ServerSetup::generate(); + let user_id = register(&db, &setup, "ada").await; + let (client, started) = start_login(&db, &login_states, &setup, "ADA").await; + + let finished = finish(&db, &login_states, finish_login(client, started)) + .await + .unwrap(); + + assert_eq!(finished.user_id, user_id); + assert_eq!(finished.username, username("ada")); + assert_eq!(login_states.count(), 0); + } + + #[sqlx::test] + async fn login_cannot_be_finished_twice(db: PgPool) { + let login_states = LoginStates::new(); + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (client, started) = start_login(&db, &login_states, &setup, "ada").await; + let input = finish_login(client, started); + let replay = FinishLogin { + login_id: input.login_id, + credential_finalization: CredentialFinalization::from_bytes( + &input.credential_finalization.to_bytes(), + ) + .unwrap(), + }; + finish(&db, &login_states, input).await.unwrap(); + + let err = finish(&db, &login_states, replay).await.err().unwrap(); + + assert!(matches!(err, FinishError::Expired), "{err:?}"); + } + + #[sqlx::test] + async fn foreign_finalization_is_invalid_and_consumes_the_login(db: PgPool) { + let login_states = LoginStates::new(); + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (_, target) = start_login(&db, &login_states, &setup, "ada").await; + let (client, other) = start_login(&db, &login_states, &setup, "ada").await; + let forged = FinishLogin { + login_id: target.login_id, + ..finish_login(client, other) + }; + let retry = FinishLogin { + login_id: forged.login_id, + credential_finalization: CredentialFinalization::from_bytes( + &forged.credential_finalization.to_bytes(), + ) + .unwrap(), + }; + + let err = finish(&db, &login_states, forged).await.err().unwrap(); + let retried = finish(&db, &login_states, retry).await.err().unwrap(); + + assert!( + matches!(err, FinishError::Opaque(OpaqueError::InvalidLogin)), + "{err:?}" + ); + assert!(matches!(retried, FinishError::Expired), "{retried:?}"); + } + + #[sqlx::test] + async fn wrong_password_fails_at_the_client(db: PgPool) { + let login_states = LoginStates::new(); + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (_, started) = start_login(&db, &login_states, &setup, "ada").await; + let (client, _) = keygo_opaque::client::ClientLogin::start(b"wrong password").unwrap(); + + let err = client + .finish(b"wrong password", started.credential_response) + .err() + .unwrap(); + + assert_eq!(err, OpaqueError::InvalidLogin); + } + + #[sqlx::test] + async fn unknown_user_fails_at_the_client_like_a_wrong_password(db: PgPool) { + let login_states = LoginStates::new(); + let (client, started) = + start_login(&db, &login_states, &ServerSetup::generate(), "ada").await; + + let err = client + .finish(PASSWORD, started.credential_response) + .err() + .unwrap(); + + assert_eq!(err, OpaqueError::InvalidLogin); + } + + #[sqlx::test] + async fn unknown_user_rejects_any_finalization(db: PgPool) { + let login_states = LoginStates::new(); + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (_, fake) = start_login(&db, &login_states, &setup, "bob").await; + let (client, real) = start_login(&db, &login_states, &setup, "ada").await; + let input = FinishLogin { + login_id: fake.login_id, + ..finish_login(client, real) + }; + + let err = finish(&db, &login_states, input).await.err().unwrap(); + + assert!( + matches!(err, FinishError::Opaque(OpaqueError::InvalidLogin)), + "{err:?}" + ); + } + + #[sqlx::test] + async fn deleted_user_cannot_finish(db: PgPool) { + let login_states = LoginStates::new(); + let setup = ServerSetup::generate(); + let user_id = register(&db, &setup, "ada").await; + let (client, started) = start_login(&db, &login_states, &setup, "ada").await; + sqlx::query("delete from users where id = $1") + .bind(user_id) + .execute(&db) + .await + .unwrap(); + + let err = finish(&db, &login_states, finish_login(client, started)) + .await + .err() + .unwrap(); + + assert!( + matches!(err, FinishError::Opaque(OpaqueError::InvalidLogin)), + "{err:?}" + ); + } + + #[sqlx::test] + async fn unknown_login_is_expired(db: PgPool) { + let login_states = LoginStates::new(); + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (client, started) = start_login(&db, &login_states, &setup, "ada").await; + let input = FinishLogin { + login_id: LoginId::random(), + ..finish_login(client, started) + }; + + let err = finish(&db, &login_states, input).await.err().unwrap(); + + assert!(matches!(err, FinishError::Expired), "{err:?}"); + assert_eq!(login_states.count(), 1); + } + + #[sqlx::test] + async fn expired_login_is_expired(db: PgPool) { + let login_states = LoginStates::with_ttl(Duration::from_millis(10)); + let setup = ServerSetup::generate(); + register(&db, &setup, "ada").await; + let (client, started) = start_login(&db, &login_states, &setup, "ada").await; + tokio::time::sleep(Duration::from_millis(50)).await; + + let err = finish(&db, &login_states, finish_login(client, started)) + .await + .err() + .unwrap(); + + assert!(matches!(err, FinishError::Expired), "{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..2ff91e503 --- /dev/null +++ b/rust/crates/keygo-server/src/service/login/mod.rs @@ -0,0 +1,9 @@ +mod finish; +mod start; + +pub use finish::*; +pub use start::*; + +use std::time::Duration; + +pub const TTL: Duration = Duration::from_mins(1); 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..5e5c20a17 --- /dev/null +++ b/rust/crates/keygo-server/src/service/login/start.rs @@ -0,0 +1,134 @@ +use crate::cache::{LoginStates, Pending}; +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 credential_request: CredentialRequest, +} + +pub struct Started { + pub login_id: LoginId, + pub credential_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, + login_states: &LoginStates, + setup: &ServerSetup, + input: StartLogin, +) -> Result { + let StartLogin { + username, + credential_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, credential_response) = + ServerLogin::start(setup, password_file, credential_request, &identifier)?; + + let login_id = LoginId::random(); + login_states.insert(login_id, Pending { user_id, state }); + + Ok(Started { + login_id, + credential_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::*; + use crate::testing::{register, start_login}; + + 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"); + } + + #[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, &login_states, &setup, "ada").await; + + assert_eq!(started.expires_in, TTL); + 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 login_states = LoginStates::new(); + + let (_, started) = start_login(&db, &login_states, &ServerSetup::generate(), "ada").await; + + assert_eq!(started.expires_in, TTL); + 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, &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!(login_states.count(), 2); + } +} 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..4c204a554 --- /dev/null +++ b/rust/crates/keygo-server/src/service/mod.rs @@ -0,0 +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 new file mode 100644 index 000000000..f0953c7e5 --- /dev/null +++ b/rust/crates/keygo-server/src/service/registration/finish.rs @@ -0,0 +1,234 @@ +use crate::db::{invites, opaque_records, registrations, 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("invite is not valid or has expired")] + InviteInvalid, + #[error("database error: {0}")] + Db(#[from] sqlx::Error), +} + +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::take(&mut tx, ®istration_id) + .await? + .ok_or(FinishError::Expired)?; + if let Some(invite_hash) = &pending.invite_hash + && !invites::spend(&mut tx, invite_hash).await? + { + return Err(FinishError::InviteInvalid); + } + + 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, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::registration::{self, Signup, StartRegistration, Started}; + use crate::testing::{PASSWORD, count, create_invite, expire, invite_only, username}; + use keygo_opaque::client::ClientRegistration; + use keygo_opaque::server::ServerSetup; + use uuid::Uuid; + + async fn started(db: &PgPool, user_id: Uuid, name: &str) -> 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(), signup, 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)); + } + + #[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 new file mode 100644 index 000000000..dbabbf3e2 --- /dev/null +++ b/rust/crates/keygo-server/src/service/registration/mod.rs @@ -0,0 +1,17 @@ +mod finish; +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 new file mode 100644 index 000000000..83c071648 --- /dev/null +++ b/rust/crates/keygo-server/src/service/registration/start.rs @@ -0,0 +1,362 @@ +use crate::db::registrations::{self, ReserveError}; +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; +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 reserved")] + ReservedUsername, + #[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")] + 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, + pub invite: Option, + pub registration_request: RegistrationRequest, +} + +pub struct Started { + pub registration_id: RegistrationId, + pub registration_response: RegistrationResponse, + pub expires_in: Duration, +} + +pub async fn start( + db: &PgPool, + setup: &ServerSetup, + signup: &Signup, + input: StartRegistration, +) -> Result { + let StartRegistration { + username, + user_id, + invite, + registration_request, + } = input; + + if !is_random_uuid(user_id) { + return Err(StartError::InvalidUserId); + } + + // 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())?; + + let mut tx = db.begin().await?; + let registration_id = RegistrationId::random(); + 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, + // and these reads, each with a fresh snapshot, then see the user it created. + // + // 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 { + registration_id, + registration_response, + expires_in: TTL, + }) +} + +/// 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 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testing::{count, create_invite, invite_only, register, username}; + use keygo_opaque::client::ClientRegistration; + + #[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() + )); + } + + 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(), 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] + 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 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(); + 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..3f789aaf4 --- /dev/null +++ b/rust/crates/keygo-server/src/testing.rs @@ -0,0 +1,145 @@ +use crate::api::{AppState, router}; +use crate::cache::LoginStates; +use crate::db::invites; +use crate::domain::{InviteHash, InvitePepper, Username}; +use crate::service::invite; +use crate::service::login::{self, FinishLogin, StartLogin}; +use crate::service::registration::{self, FinishRegistration, Signup, 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 std::sync::Arc; +use std::time::Duration; +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, + invite: None, + registration_request, + }; + let started = registration::start(db, setup, &Signup::Open, 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, + login_states: &LoginStates, + 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, login_states, 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 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(); +} + +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) + .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() +} + +/// 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(), + 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) + .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()) +}