Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d63de7e
feat(server-cli): add config
OffRange Oct 13, 2026
f1b3890
feat(server-cli): add init command
OffRange Oct 13, 2026
6a0a3fd
feat(server-cli): add check command
OffRange Oct 13, 2026
252013d
feat(server): add basic server
OffRange Oct 13, 2026
7205fa0
feat(opaque): add opaque protocol
OffRange Oct 13, 2026
3b53090
test(server): request meta endpoint at /api/v1/meta
OffRange Oct 13, 2026
cc16253
refactor(server): read secrets through typed decoders
OffRange Oct 13, 2026
3dd4bb3
feat(opaque): add opaque secrets
OffRange Oct 13, 2026
47a485d
feat(opaque): add opaque to api app state
OffRange Oct 13, 2026
664e12e
refactor(api): change json format to snake_case
OffRange Oct 13, 2026
c788139
feat(api): implement basic register start prototype
OffRange Oct 14, 2026
bf9b52a
feat(api): add serve alias
OffRange Oct 14, 2026
ce5b9e0
feat(db): add sqlx structure
OffRange Oct 14, 2026
214e0a8
refactor(db): improve dev experience
OffRange Oct 14, 2026
24030ba
feat(db): add prototype user & registration table
OffRange Oct 14, 2026
9f4169b
feat(api): implement basic register start prototype
OffRange Oct 14, 2026
1edaa61
feat(api): add serve alias
OffRange Oct 14, 2026
c940163
Merge branch 'refs/heads/feat/backend-db' into feat/backend
OffRange Oct 14, 2026
626dbe0
fix(cli): make test pass
OffRange Oct 14, 2026
0dcd619
feat(api): add server API error
OffRange Oct 15, 2026
6a70f09
feat(api): add username struct
OffRange Oct 15, 2026
64a638f
feat(api): implement opaque registration start
OffRange Oct 15, 2026
ff9e33e
feat(api): implement registration finish
OffRange Oct 15, 2026
06bfc03
feat(api): implement opaque login start
OffRange Oct 15, 2026
e15077a
fix(api): align error codes with spec
OffRange Oct 15, 2026
49e5f71
feat(api): implement login finish
OffRange Oct 15, 2026
61f2874
fix(api): typo
OffRange Oct 15, 2026
48cab52
refactor(api): change from sec to min
OffRange Oct 15, 2026
9515552
refactor(api): clean up API structure
OffRange Oct 15, 2026
422f276
feat(test): add tests
OffRange Oct 15, 2026
483ec01
feat(sweeper): add db sweeper
OffRange Oct 15, 2026
b1a3657
feat(invite): add invite code generator
OffRange Oct 17, 2026
e5aa49d
feat(invite): wire up invite create cli
OffRange Oct 17, 2026
d904337
feat(invite): implement invite list command
OffRange Oct 17, 2026
01abb22
feat(invite): implement invite revoke command
OffRange Oct 17, 2026
6da1fb7
feat(api): implement invite gate
OffRange Oct 18, 2026
68f4eef
refactor(api): cleanup API help sections
OffRange Oct 18, 2026
3103fb0
refactor(cache): move db to cache
OffRange Oct 18, 2026
2eb967b
fix(api): align register and login with spec
OffRange Oct 19, 2026
9d93b3f
docs(claude): document sync server and rust workspace
OffRange Oct 19, 2026
ac0013e
fix(deploy): drop unused proxy config and align signup variable
OffRange Oct 19, 2026
b618410
refactor(caddy): disable update marker handling
OffRange Oct 19, 2026
0adfc07
feat(server): default to production environment
OffRange Oct 19, 2026
c1d4b22
ci(rust): check committed sqlx query data
OffRange Oct 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,10 @@ docs/superpowers/

graphify-out/
.gitattributes

.docker/
# Docker Compose
.env

# Local dev secrets (`make -C rust db`)
rust/dev-secrets/
51 changes: 50 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<desc> # Add a sqlx migration
./gradlew build # Full build
./gradlew test # All unit tests
./gradlew :app:test # Module-specific tests (preferred for small changes)
Expand Down Expand Up @@ -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<S, E>` with `Success`/`Failure`. Use helpers: `onSuccess()`,
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions deploy/.env.example
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions deploy/caddy/Caddyfile
Original file line number Diff line number Diff line change
@@ -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
}
84 changes: 84 additions & 0 deletions deploy/compose.yaml
Original file line number Diff line number Diff line change
@@ -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: { }
2 changes: 2 additions & 0 deletions rust/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[alias]
serve = "run --bin keygo-server -- serve"
6 changes: 6 additions & 0 deletions rust/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
*
!Cargo.toml
!Cargo.lock
!crates/
!tools/
crates/**/target
Loading
Loading