From 18328706bd8c6320f48213a96c5be8ccc252d4d5 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 1 Mar 2026 13:02:40 +0000 Subject: [PATCH] Server keys in etc/, bind in docker compose - bin/gen-server-keys.sh: generate Ed25519 keypair to etc/server-service.{pub,key,env} - main.go: read keys from file (ADMIN_PUBLIC_KEY_FILE) when env empty - docker-compose: env_file etc/server-service.env, mount etc/ - bin/up.sh: auto-run gen-server-keys if etc/server-service.env missing - ErrRegistrationNotConfigured for clearer 503 when keys not set - etc/README.md, etc/.gitignore - bin/gen-admin-key.sh for one-off key gen - .env.example Made-with: Cursor --- .env.example | 6 ++++++ AGENTS.md | 4 +++- README.md | 9 +++++++-- bin/gen-admin-key.sh | 13 +++++++++++++ bin/gen-server-keys.sh | 22 +++++++++++++++++++++ bin/up.sh | 4 ++++ cmd/api/main.go | 19 ++++++++++++++++++ docker-compose.yml | 8 ++++++++ docs/typescript-frontend-integration.md | 4 +++- etc/.gitignore | 3 +++ etc/README.md | 26 +++++++++++++++++++++++++ internal/app/service.go | 21 ++++++++++---------- internal/http/handlers.go | 4 +++- web/app.js | 2 +- 14 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 .env.example create mode 100755 bin/gen-admin-key.sh create mode 100644 bin/gen-server-keys.sh create mode 100644 etc/.gitignore create mode 100644 etc/README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..32185e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Required for registration-by-signature and bootstrap admin. +# Generate with: ./bin/gen-admin-key.sh +ADMIN_PUBLIC_KEY= + +# Optional: override service key for registration (defaults to ADMIN_PUBLIC_KEY) +# SERVICE_PUBLIC_KEY= diff --git a/AGENTS.md b/AGENTS.md index 2e152b7..eca79a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ This file gives future coding agents a fast path map for this repository. - TypeScript API client: `libs/geo-api-client/` - CI workflow: `.gitea/workflows/ci.yml` - Architecture/planning docs: `docs/` +- Server keys: `etc/` (generated by `./bin/gen-server-keys.sh`) ## Most common commands @@ -21,7 +22,8 @@ From repo root: ```bash go test ./... go run ./cmd/api -docker compose up --build -d +./bin/gen-server-keys.sh # before first docker up (creates etc/server-service.*) +./bin/up.sh # or: docker compose up --build -d docker compose down docker compose --profile test run --rm test # run tests as root (avoids var/ permission issues) ``` diff --git a/README.md b/README.md index c711b01..f2a0834 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,22 @@ Local default (for development): `http://localhost:8122`. Optional environment variables: - `ADDR` (default `:8122`) -- `ADMIN_PUBLIC_KEY` (bootstrap initial inviter/admin user) +- `ADMIN_PUBLIC_KEY` — **required for registration**: bootstrap admin + service key for `register-by-signature`. Generate with `./bin/gen-admin-key.sh` - `SERVICE_PUBLIC_KEY` (public key users sign to register; defaults to `ADMIN_PUBLIC_KEY`) +**Deployment:** Set `ADMIN_PUBLIC_KEY` before starting. Without it, `/v1/service-key` returns 503 and registration is disabled. + ## Docker Compose -Build and run the backend service: +Generate server keys (creates `etc/server-service.*`), then build and run: ```bash +./bin/gen-server-keys.sh COMPOSE_BAKE=true docker compose up --build -d ``` +Or use `./bin/up.sh` which runs the key generation if needed. + This starts: - `db` (`postgis/postgis`) on `5432` diff --git a/bin/gen-admin-key.sh b/bin/gen-admin-key.sh new file mode 100755 index 0000000..96f40f6 --- /dev/null +++ b/bin/gen-admin-key.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Generate Ed25519 keypair for ADMIN_PUBLIC_KEY (one-off, prints to stdout). +# For etc/ + docker compose, use ./bin/gen-server-keys.sh instead. +set -e +cd "$(dirname "$0")/.." +(cd libs/geo-api-client && bun run build 2>/dev/null) || true +cd libs/geo-api-client +bun -e " +import { generateKeyPair } from './dist/index.js'; +const k = await generateKeyPair(); +console.log('ADMIN_PUBLIC_KEY=' + k.publicKey); +console.log('# Private key (keep secret, bootstrap only): ' + k.privateKey); +" diff --git a/bin/gen-server-keys.sh b/bin/gen-server-keys.sh new file mode 100644 index 0000000..be9ff4a --- /dev/null +++ b/bin/gen-server-keys.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Generate server-service Ed25519 keypair. Output in etc/ for docker compose. +# Clients download the public key via GET /v1/service-key. +set -e +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +mkdir -p "$ROOT/etc" +(cd "$ROOT/libs/geo-api-client" && bun run build 2>/dev/null) || true +cd "$ROOT/libs/geo-api-client" +OUT=$(bun -e " +import { generateKeyPair } from './dist/index.js'; +const k = await generateKeyPair(); +console.log(k.publicKey); +console.log(k.privateKey); +") +PUB=$(echo "$OUT" | head -1) +PRIV=$(echo "$OUT" | tail -1) +echo "$PUB" > "$ROOT/etc/server-service.pub" +echo "$PRIV" > "$ROOT/etc/server-service.key" +echo "ADMIN_PUBLIC_KEY=$PUB" > "$ROOT/etc/server-service.env" +echo "SERVICE_PUBLIC_KEY=$PUB" >> "$ROOT/etc/server-service.env" +chmod 600 "$ROOT/etc/server-service.key" "$ROOT/etc/server-service.env" 2>/dev/null || true +echo "Wrote etc/server-service.pub, etc/server-service.key, etc/server-service.env" diff --git a/bin/up.sh b/bin/up.sh index ecd2ee2..55434fd 100755 --- a/bin/up.sh +++ b/bin/up.sh @@ -1,4 +1,8 @@ #!/bin/bash set -e cd "$(dirname "$0")/.." +if [ ! -f etc/server-service.env ]; then + echo "Generating server keys (etc/server-service.env)..." + ./bin/gen-server-keys.sh +fi docker compose up --build -d diff --git a/cmd/api/main.go b/cmd/api/main.go index da496c6..dac8b8b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -4,6 +4,7 @@ import ( "log" "net/http" "os" + "strings" "time" "momswap/backend/internal/app" @@ -16,6 +17,16 @@ func main() { adminPublicKey := os.Getenv("ADMIN_PUBLIC_KEY") servicePublicKey := getEnv("SERVICE_PUBLIC_KEY", adminPublicKey) + if adminPublicKey == "" { + adminPublicKey = readKeyFile(getEnv("ADMIN_PUBLIC_KEY_FILE", "etc/server-service.pub")) + } + if servicePublicKey == "" { + servicePublicKey = readKeyFile(getEnv("SERVICE_PUBLIC_KEY_FILE", "etc/server-service.pub")) + } + if servicePublicKey == "" { + servicePublicKey = adminPublicKey + } + memory := store.NewMemoryStore() service := app.NewService(memory, app.Config{ ChallengeTTL: 5 * time.Minute, @@ -37,3 +48,11 @@ func getEnv(key, fallback string) string { } return v } + +func readKeyFile(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} diff --git a/docker-compose.yml b/docker-compose.yml index 98b1b4d..bdd1d7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,10 +23,14 @@ services: target: runtime image: momswap-backend:latest container_name: momswap-backend-api + env_file: + - etc/server-service.env environment: ADDR: ":8122" ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}" DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" + volumes: + - ./etc:/app/etc:ro depends_on: db: condition: service_healthy @@ -42,10 +46,14 @@ services: target: dev image: momswap-backend:dev container_name: momswap-backend-api-dev + env_file: + - etc/server-service.env environment: ADDR: ":8122" ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}" DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable" + volumes: + - ./etc:/src/etc:ro depends_on: db: condition: service_healthy diff --git a/docs/typescript-frontend-integration.md b/docs/typescript-frontend-integration.md index 992700c..4a51ec1 100644 --- a/docs/typescript-frontend-integration.md +++ b/docs/typescript-frontend-integration.md @@ -68,10 +68,12 @@ Key methods: When `SERVICE_PUBLIC_KEY` (or `ADMIN_PUBLIC_KEY`) is set, users can register without an invitation: -1. `GET /v1/service-key` — fetch the API public key to sign. +1. `GET /v1/service-key` — fetch the server public key (clients use this for registration and further signed communication). 2. Sign that key with your private key. 3. `POST /v1/auth/register-by-signature` with `{ publicKey, signature }`. +Server keys are generated with `./bin/gen-server-keys.sh` and stored in `etc/`. + ## Example (TypeScript app) ```ts diff --git a/etc/.gitignore b/etc/.gitignore new file mode 100644 index 0000000..215b7ca --- /dev/null +++ b/etc/.gitignore @@ -0,0 +1,3 @@ +server-service.pub +server-service.key +server-service.env diff --git a/etc/README.md b/etc/README.md new file mode 100644 index 0000000..6886b40 --- /dev/null +++ b/etc/README.md @@ -0,0 +1,26 @@ +# Server Service Keys + +Server Ed25519 keypair for client authentication and registration. + +## Generate + +```bash +./bin/gen-server-keys.sh +``` + +Creates: + +- `server-service.pub` — public key; clients download via `GET /v1/service-key` +- `server-service.key` — private key (keep secret) +- `server-service.env` — env vars for docker compose (`ADMIN_PUBLIC_KEY`, `SERVICE_PUBLIC_KEY`) + +## Client Usage + +Clients fetch the server public key and use it to: + +1. **Register** — sign the server pubkey, post to `POST /v1/auth/register-by-signature` +2. **Verify server identity** — for future signed responses or request validation + +## Docker Compose + +The api service uses `env_file: etc/server-service.env` and mounts `./etc` so keys are available. Run `./bin/gen-server-keys.sh` before first `docker compose up`. diff --git a/internal/app/service.go b/internal/app/service.go index c29ea79..48e3e66 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -12,15 +12,16 @@ import ( ) var ( - ErrUnauthorized = errors.New("unauthorized") - ErrForbidden = errors.New("forbidden") - ErrBadRequest = errors.New("bad request") - ErrInviteInvalid = errors.New("invite invalid") - ErrInviteExpired = errors.New("invite expired") - ErrInviteExhaust = errors.New("invite exhausted") - ErrAlreadyUser = errors.New("user already registered") - ErrCollectionMiss = errors.New("collection missing") - ErrFeatureMiss = errors.New("feature missing") + ErrUnauthorized = errors.New("unauthorized") + ErrForbidden = errors.New("forbidden") + ErrBadRequest = errors.New("bad request") + ErrRegistrationNotConfigured = errors.New("registration by signature not configured; set ADMIN_PUBLIC_KEY") + ErrInviteInvalid = errors.New("invite invalid") + ErrInviteExpired = errors.New("invite expired") + ErrInviteExhaust = errors.New("invite exhausted") + ErrAlreadyUser = errors.New("user already registered") + ErrCollectionMiss = errors.New("collection missing") + ErrFeatureMiss = errors.New("feature missing") ) type Config struct { @@ -62,7 +63,7 @@ func (s *Service) ServicePublicKey() string { func (s *Service) RegisterBySignature(publicKey, signature string) error { if s.servicePublicKey == "" { - return fmt.Errorf("%w: registration by signature not configured", ErrBadRequest) + return ErrRegistrationNotConfigured } if publicKey == "" { return fmt.Errorf("%w: missing public key", ErrBadRequest) diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 43e6d3a..b472abb 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -88,6 +88,8 @@ func statusFromErr(err error) int { return http.StatusBadRequest case errors.Is(err, app.ErrAlreadyUser): return http.StatusConflict + case errors.Is(err, app.ErrRegistrationNotConfigured): + return http.StatusServiceUnavailable case errors.Is(err, app.ErrInviteInvalid), errors.Is(err, app.ErrInviteExpired), errors.Is(err, app.ErrInviteExhaust): @@ -165,7 +167,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { func (a *API) getServiceKey(w http.ResponseWriter, _ *http.Request) { pk := a.service.ServicePublicKey() if pk == "" { - writeErr(w, app.ErrBadRequest) + writeErr(w, app.ErrRegistrationNotConfigured) return } writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk}) diff --git a/web/app.js b/web/app.js index 201d1a5..268880a 100644 --- a/web/app.js +++ b/web/app.js @@ -47,7 +47,7 @@ createApp({ try { await client.registerBySigningServiceKey(state.publicKey, state.privateKey); } catch (err) { - if (!err.message.includes("already registered") && !err.message.includes("not configured")) { + if (!err.message.includes("already registered") && !err.message.includes("not configured") && !err.message.includes("ADMIN_PUBLIC_KEY")) { throw err; } // Proceed to login: already registered or registration disabled (invitation flow)