- 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:
@@ -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=
|
||||||
@@ -13,6 +13,7 @@ This file gives future coding agents a fast path map for this repository.
|
|||||||
- TypeScript API client: `libs/geo-api-client/`
|
- TypeScript API client: `libs/geo-api-client/`
|
||||||
- CI workflow: `.gitea/workflows/ci.yml`
|
- CI workflow: `.gitea/workflows/ci.yml`
|
||||||
- Architecture/planning docs: `docs/`
|
- Architecture/planning docs: `docs/`
|
||||||
|
- Server keys: `etc/` (generated by `./bin/gen-server-keys.sh`)
|
||||||
|
|
||||||
## Most common commands
|
## Most common commands
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ From repo root:
|
|||||||
```bash
|
```bash
|
||||||
go test ./...
|
go test ./...
|
||||||
go run ./cmd/api
|
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 down
|
||||||
docker compose --profile test run --rm test # run tests as root (avoids var/ permission issues)
|
docker compose --profile test run --rm test # run tests as root (avoids var/ permission issues)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -33,17 +33,22 @@ Local default (for development): `http://localhost:8122`.
|
|||||||
Optional environment variables:
|
Optional environment variables:
|
||||||
|
|
||||||
- `ADDR` (default `:8122`)
|
- `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`)
|
- `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
|
## Docker Compose
|
||||||
|
|
||||||
Build and run the backend service:
|
Generate server keys (creates `etc/server-service.*`), then build and run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
./bin/gen-server-keys.sh
|
||||||
COMPOSE_BAKE=true docker compose up --build -d
|
COMPOSE_BAKE=true docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or use `./bin/up.sh` which runs the key generation if needed.
|
||||||
|
|
||||||
This starts:
|
This starts:
|
||||||
|
|
||||||
- `db` (`postgis/postgis`) on `5432`
|
- `db` (`postgis/postgis`) on `5432`
|
||||||
|
|||||||
Executable
+13
@@ -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);
|
||||||
|
"
|
||||||
@@ -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"
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")/.."
|
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
|
docker compose up --build -d
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"momswap/backend/internal/app"
|
"momswap/backend/internal/app"
|
||||||
@@ -16,6 +17,16 @@ func main() {
|
|||||||
adminPublicKey := os.Getenv("ADMIN_PUBLIC_KEY")
|
adminPublicKey := os.Getenv("ADMIN_PUBLIC_KEY")
|
||||||
servicePublicKey := getEnv("SERVICE_PUBLIC_KEY", adminPublicKey)
|
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()
|
memory := store.NewMemoryStore()
|
||||||
service := app.NewService(memory, app.Config{
|
service := app.NewService(memory, app.Config{
|
||||||
ChallengeTTL: 5 * time.Minute,
|
ChallengeTTL: 5 * time.Minute,
|
||||||
@@ -37,3 +48,11 @@ func getEnv(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readKeyFile(path string) string {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ services:
|
|||||||
target: runtime
|
target: runtime
|
||||||
image: momswap-backend:latest
|
image: momswap-backend:latest
|
||||||
container_name: momswap-backend-api
|
container_name: momswap-backend-api
|
||||||
|
env_file:
|
||||||
|
- etc/server-service.env
|
||||||
environment:
|
environment:
|
||||||
ADDR: ":8122"
|
ADDR: ":8122"
|
||||||
ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}"
|
ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}"
|
||||||
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
|
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
|
||||||
|
volumes:
|
||||||
|
- ./etc:/app/etc:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -42,10 +46,14 @@ services:
|
|||||||
target: dev
|
target: dev
|
||||||
image: momswap-backend:dev
|
image: momswap-backend:dev
|
||||||
container_name: momswap-backend-api-dev
|
container_name: momswap-backend-api-dev
|
||||||
|
env_file:
|
||||||
|
- etc/server-service.env
|
||||||
environment:
|
environment:
|
||||||
ADDR: ":8122"
|
ADDR: ":8122"
|
||||||
ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}"
|
ADMIN_PUBLIC_KEY: "${ADMIN_PUBLIC_KEY:-}"
|
||||||
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
|
DATABASE_URL: "postgres://${POSTGRES_USER:-momswap}:${POSTGRES_PASSWORD:-momswap}@db:5432/${POSTGRES_DB:-momswap}?sslmode=disable"
|
||||||
|
volumes:
|
||||||
|
- ./etc:/src/etc:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -68,10 +68,12 @@ Key methods:
|
|||||||
|
|
||||||
When `SERVICE_PUBLIC_KEY` (or `ADMIN_PUBLIC_KEY`) is set, users can register without an invitation:
|
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.
|
2. Sign that key with your private key.
|
||||||
3. `POST /v1/auth/register-by-signature` with `{ publicKey, signature }`.
|
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)
|
## Example (TypeScript app)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
server-service.pub
|
||||||
|
server-service.key
|
||||||
|
server-service.env
|
||||||
@@ -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
@@ -12,15 +12,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUnauthorized = errors.New("unauthorized")
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
ErrForbidden = errors.New("forbidden")
|
ErrForbidden = errors.New("forbidden")
|
||||||
ErrBadRequest = errors.New("bad request")
|
ErrBadRequest = errors.New("bad request")
|
||||||
ErrInviteInvalid = errors.New("invite invalid")
|
ErrRegistrationNotConfigured = errors.New("registration by signature not configured; set ADMIN_PUBLIC_KEY")
|
||||||
ErrInviteExpired = errors.New("invite expired")
|
ErrInviteInvalid = errors.New("invite invalid")
|
||||||
ErrInviteExhaust = errors.New("invite exhausted")
|
ErrInviteExpired = errors.New("invite expired")
|
||||||
ErrAlreadyUser = errors.New("user already registered")
|
ErrInviteExhaust = errors.New("invite exhausted")
|
||||||
ErrCollectionMiss = errors.New("collection missing")
|
ErrAlreadyUser = errors.New("user already registered")
|
||||||
ErrFeatureMiss = errors.New("feature missing")
|
ErrCollectionMiss = errors.New("collection missing")
|
||||||
|
ErrFeatureMiss = errors.New("feature missing")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -62,7 +63,7 @@ func (s *Service) ServicePublicKey() string {
|
|||||||
|
|
||||||
func (s *Service) RegisterBySignature(publicKey, signature string) error {
|
func (s *Service) RegisterBySignature(publicKey, signature string) error {
|
||||||
if s.servicePublicKey == "" {
|
if s.servicePublicKey == "" {
|
||||||
return fmt.Errorf("%w: registration by signature not configured", ErrBadRequest)
|
return ErrRegistrationNotConfigured
|
||||||
}
|
}
|
||||||
if publicKey == "" {
|
if publicKey == "" {
|
||||||
return fmt.Errorf("%w: missing public key", ErrBadRequest)
|
return fmt.Errorf("%w: missing public key", ErrBadRequest)
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ func statusFromErr(err error) int {
|
|||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
case errors.Is(err, app.ErrAlreadyUser):
|
case errors.Is(err, app.ErrAlreadyUser):
|
||||||
return http.StatusConflict
|
return http.StatusConflict
|
||||||
|
case errors.Is(err, app.ErrRegistrationNotConfigured):
|
||||||
|
return http.StatusServiceUnavailable
|
||||||
case errors.Is(err, app.ErrInviteInvalid),
|
case errors.Is(err, app.ErrInviteInvalid),
|
||||||
errors.Is(err, app.ErrInviteExpired),
|
errors.Is(err, app.ErrInviteExpired),
|
||||||
errors.Is(err, app.ErrInviteExhaust):
|
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) {
|
func (a *API) getServiceKey(w http.ResponseWriter, _ *http.Request) {
|
||||||
pk := a.service.ServicePublicKey()
|
pk := a.service.ServicePublicKey()
|
||||||
if pk == "" {
|
if pk == "" {
|
||||||
writeErr(w, app.ErrBadRequest)
|
writeErr(w, app.ErrRegistrationNotConfigured)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk})
|
writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk})
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ createApp({
|
|||||||
try {
|
try {
|
||||||
await client.registerBySigningServiceKey(state.publicKey, state.privateKey);
|
await client.registerBySigningServiceKey(state.publicKey, state.privateKey);
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
// Proceed to login: already registered or registration disabled (invitation flow)
|
// Proceed to login: already registered or registration disabled (invitation flow)
|
||||||
|
|||||||
Reference in New Issue
Block a user