Server keys in etc/, bind in docker compose
CI / test (push) Successful in 5s

- 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
This commit is contained in:
2026-03-01 13:02:40 +00:00
parent a5a97a0ad9
commit 18328706bd
14 changed files with 129 additions and 16 deletions
+6
View File
@@ -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=
+3 -1
View File
@@ -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)
```
+7 -2
View File
@@ -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`
+13
View File
@@ -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);
"
+22
View File
@@ -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"
+4
View File
@@ -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
+19
View File
@@ -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))
}
+8
View File
@@ -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
+3 -1
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
server-service.pub
server-service.key
server-service.env
+26
View File
@@ -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`.
+11 -10
View File
@@ -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)
+3 -1
View File
@@ -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})
+1 -1
View File
@@ -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)