From a5a97a0ad9db0b4ec6fa4a8a4a3ccf4e2b31a9a9 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Sun, 1 Mar 2026 12:58:44 +0000 Subject: [PATCH] Add register-by-signature, web fixes, bin scripts, docs - Register by signing service key: GET /v1/service-key, POST /v1/auth/register-by-signature - Login auto-attempts register first for new users - Web: default API URL momswap.produktor.duckdns.org, /libs/ static handler - Docker: webbuild stage for geo-api-client, copy web+libs to runtime - Bin scripts: test.sh, run.sh, up.sh, down.sh - docs/ed25519-security-use-cases.md: use cases, message formats, examples - SERVICE_PUBLIC_KEY env (defaults to ADMIN_PUBLIC_KEY) Made-with: Cursor --- .dockerignore | 1 + AGENTS.md | 1 + Dockerfile | 17 ++ README.md | 7 + bin/down.sh | 4 + bin/run.sh | 4 + bin/test.sh | 4 + bin/up.sh | 4 + cmd/api/main.go | 3 +- docker-compose.yml | 13 ++ docs/ed25519-security-use-cases.md | 204 ++++++++++++++++++++---- docs/typescript-frontend-integration.md | 17 +- internal/app/service.go | 33 +++- internal/http/api_test.go | 58 ++++++- internal/http/handlers.go | 31 ++++ libs/geo-api-client/dist/index.js | 11 ++ libs/geo-api-client/src/GeoApiClient.ts | 13 ++ web/app.js | 20 ++- web/index.html | 1 + 19 files changed, 405 insertions(+), 41 deletions(-) create mode 100644 bin/down.sh create mode 100755 bin/run.sh create mode 100755 bin/test.sh create mode 100755 bin/up.sh diff --git a/.dockerignore b/.dockerignore index d3aaeae..82b6e5e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,7 @@ .git .gitea docs +var node_modules **/node_modules **/*.log diff --git a/AGENTS.md b/AGENTS.md index 5e1b410..2e152b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,7 @@ go test ./... go run ./cmd/api docker compose up --build -d docker compose down +docker compose --profile test run --rm test # run tests as root (avoids var/ permission issues) ``` TypeScript client: diff --git a/Dockerfile b/Dockerfile index f87212c..bd2f176 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,15 +22,32 @@ RUN --mount=type=cache,target=/go/pkg/mod \ CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \ go build -p "$(nproc)" -trimpath -ldflags="-s -w" -o /out/api ./cmd/api +FROM base AS webbuild +RUN apt-get update && apt-get install -y --no-install-recommends unzip curl && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://bun.sh/install | bash \ + && mv /root/.bun/bin/bun /usr/local/bin/ && rm -rf /root/.bun +WORKDIR /src/libs/geo-api-client +RUN bun install && bun run build + FROM base AS dev ENV ADDR=:8122 EXPOSE 8122 CMD ["go", "run", "./cmd/api"] +FROM base AS test +USER root +RUN apt-get update && apt-get install -y --no-install-recommends unzip curl && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL https://bun.sh/install | bash \ + && mv /root/.bun/bin/bun /usr/local/bin/ && rm -rf /root/.bun +WORKDIR /src +CMD ["sh", "-c", "go test ./... && cd libs/geo-api-client && bun install && bun test"] + FROM gcr.io/distroless/static-debian12:nonroot AS runtime WORKDIR /app COPY --from=builder /out/api /app/api +COPY --from=builder /src/web /app/web +COPY --from=webbuild /src/libs /app/libs ENV ADDR=:8122 EXPOSE 8122 diff --git a/README.md b/README.md index e41564e..c711b01 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ go test ./... go run ./cmd/api ``` +Run tests via Docker (avoids local permission issues, e.g. `var/`): + +```bash +docker compose --profile test run --rm test +``` + Primary deployed base URL: `https://momswap.produktor.duckdns.org/`. Local default (for development): `http://localhost:8122`. @@ -28,6 +34,7 @@ Optional environment variables: - `ADDR` (default `:8122`) - `ADMIN_PUBLIC_KEY` (bootstrap initial inviter/admin user) +- `SERVICE_PUBLIC_KEY` (public key users sign to register; defaults to `ADMIN_PUBLIC_KEY`) ## Docker Compose diff --git a/bin/down.sh b/bin/down.sh new file mode 100644 index 0000000..7b63506 --- /dev/null +++ b/bin/down.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +docker compose down diff --git a/bin/run.sh b/bin/run.sh new file mode 100755 index 0000000..fd24856 --- /dev/null +++ b/bin/run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +go run ./cmd/api diff --git a/bin/test.sh b/bin/test.sh new file mode 100755 index 0000000..f015e59 --- /dev/null +++ b/bin/test.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +docker compose --profile test run --rm test diff --git a/bin/up.sh b/bin/up.sh new file mode 100755 index 0000000..ecd2ee2 --- /dev/null +++ b/bin/up.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")/.." +docker compose up --build -d diff --git a/cmd/api/main.go b/cmd/api/main.go index 88af1b2..da496c6 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -14,12 +14,13 @@ import ( func main() { addr := getEnv("ADDR", ":8122") adminPublicKey := os.Getenv("ADMIN_PUBLIC_KEY") + servicePublicKey := getEnv("SERVICE_PUBLIC_KEY", adminPublicKey) memory := store.NewMemoryStore() service := app.NewService(memory, app.Config{ ChallengeTTL: 5 * time.Minute, SessionTTL: 24 * time.Hour, - }) + }, servicePublicKey) service.BootstrapAdmin(adminPublicKey) api := httpapi.NewAPI(service) diff --git a/docker-compose.yml b/docker-compose.yml index 1f7a582..98b1b4d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -68,3 +68,16 @@ services: - action: rebuild path: ./Dockerfile + test: + build: + context: . + dockerfile: Dockerfile + target: test + image: momswap-backend:test + volumes: + - .:/src + working_dir: /src + user: root + profiles: + - test + diff --git a/docs/ed25519-security-use-cases.md b/docs/ed25519-security-use-cases.md index 5b50a73..606cb37 100644 --- a/docs/ed25519-security-use-cases.md +++ b/docs/ed25519-security-use-cases.md @@ -1,10 +1,13 @@ # Ed25519 Security Use Cases -This document describes how Ed25519 key pairs are used for identity, authentication, and secure access to feature collection data. +This document describes how the Momswap Geo backend uses Ed25519 signatures for authentication and registration. -## 1) Identification by Public Key +## Overview -Each user identity is represented by an Ed25519 public key. The backend never needs the private key. +- **Identity** = Ed25519 public key (base64url, 43 chars, no padding) +- **All keys** use RFC 8032 Ed25519; Go `crypto/ed25519` on server, `@noble/ed25519` in TypeScript client +- **Signatures** are base64url-encoded (64 bytes → 86 chars unpadded) +- **Private keys never leave the client**; server only verifies signatures ```mermaid flowchart LR @@ -13,9 +16,13 @@ flowchart LR backendApi -->|"registers identity by public key"| usersTable[(UsersByPublicKey)] ``` -## 2) Authentication via Challenge Signature +## Use Cases -Authentication is challenge-response: client signs server nonce with private key; server verifies with stored public key. +### 1. Login (Challenge–Response) + +Proves the client controls the private key for a registered public key. + +**Message to sign:** `login:` + nonce (exact UTF-8 string) ```mermaid sequenceDiagram @@ -32,36 +39,117 @@ sequenceDiagram A-->>C: {accessToken} ``` -## 3) Secure Data Storing (Write Feature Collection Data) +**Flow:** -Only an authenticated token linked to a verified public key can create collections and features for that owner key. +1. `POST /v1/auth/challenge` with `{"publicKey":""}` +2. Server returns `{"nonce":"...", "messageToSign":"login:"}` +3. Client signs `login:` with private key +4. `POST /v1/auth/login` with `{"publicKey","nonce","signature"}` +5. Server verifies and returns `{"accessToken":"..."}` -```mermaid -flowchart TD - requestCreate[CreateCollectionOrFeatureRequest] --> bearerCheck[BearerTokenValidation] - bearerCheck --> sessionStore[(Sessions)] - sessionStore --> ownerKey[ResolvedOwnerPublicKey] - ownerKey --> ownershipRule[OwnershipValidation] - ownershipRule --> writeData[(CollectionsAndFeatures)] - ownershipRule --> reject403[RejectForbidden] +**Example (curl):** + +```bash +# Step 1: Get challenge +CHALLENGE=$(curl -s -X POST https://momswap.produktor.duckdns.org/v1/auth/challenge \ + -H "Content-Type: application/json" \ + -d '{"publicKey":"txdkGKNdcZIEoQMJ0dqum3msjT6-2mO4yLVhtidRFJI"}') +NONCE=$(echo "$CHALLENGE" | jq -r '.nonce') + +# Step 2: Sign "login:$NONCE" with your private key, then: +curl -s -X POST https://momswap.produktor.duckdns.org/v1/auth/login \ + -H "Content-Type: application/json" \ + -d "{\"publicKey\":\"txdkGKNdcZIEoQMJ0dqum3msjT6-2mO4yLVhtidRFJI\",\"nonce\":\"$NONCE\",\"signature\":\"\"}" ``` -## 4) Secure Data Receiving (Read Feature Collection Data) +**Example (TypeScript):** -Reads are filtered by ownership. A token from one key cannot read another key's collections/features. - -```mermaid -flowchart TD - requestRead[ReadCollectionsOrFeaturesRequest] --> validateToken[ValidateBearerToken] - validateToken --> ownerKey[OwnerPublicKeyFromSession] - ownerKey --> queryOwned[QueryOnlyOwnerRows] - queryOwned --> responseData[ReturnOwnedCollectionsFeatures] - ownerKey --> denyOther[RejectOtherOwnerAccess] +```ts +const challenge = await client.createChallenge(publicKey); +const signature = await signMessage(privateKey, challenge.messageToSign); +const token = await client.loginWithSignature(publicKey, privateKey); ``` -## 5) Invitation-Based Identity Expansion +--- -Existing users can issue signed invitations. New users prove ownership of their own private key at registration. +### 2. Register by Signing Service Key + +Registers a new user without an invitation. User proves key ownership by signing the API's advertised public key. + +**Message to sign:** The service public key string (exact value from `GET /v1/service-key`) + +**Flow:** + +1. `GET /v1/service-key` → `{"publicKey":""}` +2. Client signs that exact string with private key +3. `POST /v1/auth/register-by-signature` with `{"publicKey","signature"}` +4. Server verifies and registers the user + +**Requires:** `SERVICE_PUBLIC_KEY` or `ADMIN_PUBLIC_KEY` must be set. + +**Example (curl):** + +```bash +# Step 1: Fetch service key +SERVICE_KEY=$(curl -s https://momswap.produktor.duckdns.org/v1/service-key | jq -r '.publicKey') + +# Step 2: Sign $SERVICE_KEY with your private key, then: +curl -s -X POST https://momswap.produktor.duckdns.org/v1/auth/register-by-signature \ + -H "Content-Type: application/json" \ + -d "{\"publicKey\":\"\",\"signature\":\"\"}" +``` + +**Example (TypeScript):** + +```ts +await client.registerBySigningServiceKey(publicKey, privateKey); +``` + +--- + +### 3. Create Invitation + +An authenticated user creates a signed invitation for new users. + +**Message to sign:** `invite:` + base64url(payload) + +Payload JSON: + +```json +{ + "jti": "unique-token-id", + "inviterPublicKey": "", + "inviteePublicKey": "", + "expiresAtUnix": 1735689600, + "maxUses": 1 +} +``` + +**Flow:** + +1. Build payload, base64url-encode it → `payloadB64` +2. Sign `invite:` + payloadB64 with inviter's private key +3. `POST /v1/invitations` (with Bearer token) with `{"invitePayloadB64","inviteSignature"}` + +**Example (TypeScript):** + +```ts +const payload = { jti, inviterPublicKey, expiresAtUnix, maxUses }; +const payloadB64 = bytesToBase64Url(textToBytes(JSON.stringify(payload))); +const inviteSig = await signMessage(inviterPrivateKey, `invite:${payloadB64}`); +await client.createInvitation(payload, inviterPrivateKey); +``` + +--- + +### 4. Register with Invitation + +New user registers using a valid invitation and proves key ownership. + +**Messages to sign:** + +1. Invite: inviter signs `invite:` + payloadB64 (validated on create) +2. Proof: invitee signs `register:` + `` + `:` + `` ```mermaid flowchart LR @@ -73,9 +161,63 @@ flowchart LR verifyProof --> usersStore ``` +**Flow:** + +1. Obtain invitation (payloadB64, inviteSignature, jti) from inviter +2. Sign `register:` + yourPublicKey + `:` + jti with your private key +3. `POST /v1/auth/register` with `{"publicKey","invitePayloadB64","inviteSignature","proofSignature"}` + +**Example (TypeScript):** + +```ts +const proofSig = await signMessage(privateKey, `register:${publicKey}:${jti}`); +await client.registerWithInvitation({ + publicKey, privateKey, invitePayloadB64, inviteSignature, jti +}); +``` + +--- + +### 5. Secure Data Access (Collections & Features) + +Only an authenticated token linked to a verified public key can create and read collections/features for that owner. + +```mermaid +flowchart TD + request[CreateOrReadRequest] --> bearerCheck[BearerTokenValidation] + bearerCheck --> sessionStore[(Sessions)] + sessionStore --> ownerKey[ResolvedOwnerPublicKey] + ownerKey --> ownershipRule[OwnershipValidation] + ownershipRule --> data[(CollectionsAndFeatures)] + ownershipRule --> reject403[RejectForbidden] +``` + +--- + +## Message Format Summary + +| Use Case | Message to Sign | +|-----------------------|------------------------------------------------------| +| Login | `login:` + nonce | +| Register (service) | Service public key (exact string from API) | +| Create invitation | `invite:` + base64url(payload) | +| Register (invitation) | `register:` + publicKey + `:` + jti | + ## Security Notes -- Private keys stay client-side. -- Public keys are stable user identifiers. -- Session tokens are granted only after valid Ed25519 signature verification. -- Collection/feature access is enforced by owner key derived from session identity. +- **Replay protection:** Challenges are single-use and expire (5 min TTL). Invitations have expiry and max-use limits. +- **Key binding:** Login challenge is bound to the public key that requested it. Invitation proof binds the new user's key to the JTI. +- **Canonical encoding:** Use UTF-8 for all messages; base64url without padding for keys and signatures. +- **Storage:** Client stores keys in `localStorage`; consider an encrypted wrapper for higher-security deployments. +- **Private keys stay client-side.** Session tokens are granted only after valid Ed25519 signature verification. + +## API Endpoints Reference + +| Method | Endpoint | Auth | Purpose | +|--------|---------------------------------|------|----------------------------| +| GET | /v1/service-key | No | Fetch service pubkey | +| POST | /v1/auth/challenge | No | Get login nonce | +| POST | /v1/auth/login | No | Exchange signature for JWT | +| POST | /v1/auth/register-by-signature | No | Register (sign service key)| +| POST | /v1/auth/register | No | Register with invitation | +| POST | /v1/invitations | Yes | Create invitation | diff --git a/docs/typescript-frontend-integration.md b/docs/typescript-frontend-integration.md index aacfe6b..992700c 100644 --- a/docs/typescript-frontend-integration.md +++ b/docs/typescript-frontend-integration.md @@ -44,8 +44,10 @@ Key methods: - `importKeys(keys)` - `exportKeys()` - `setAccessToken(token)` +- `getServicePublicKey()` - `createChallenge(publicKey)` - `loginWithSignature(publicKey, privateKey)` +- `registerBySigningServiceKey(publicKey, privateKey)` — register by signing the API service public key (no invitation required) - `createInvitation(payload, inviterPrivateKey)` - `registerWithInvitation(...)` - `listCollections()` @@ -57,9 +59,18 @@ Key methods: 1. Create one `GeoApiClient` instance per backend base URL. 2. Call `ensureKeysInStorage()` when app initializes. -3. Use `loginWithSignature()` to obtain and set a bearer token. -4. Call collection/feature methods after authentication. -5. Use `importKeys`/`exportKeys` in profile settings UX. +3. If not yet registered: call `registerBySigningServiceKey(publicKey, privateKey)` (signs the API service key and publishes your public key). +4. Use `loginWithSignature()` to obtain and set a bearer token. +5. Call collection/feature methods after authentication. +6. Use `importKeys`/`exportKeys` in profile settings UX. + +## Registration by signing service key + +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. +2. Sign that key with your private key. +3. `POST /v1/auth/register-by-signature` with `{ publicKey, signature }`. ## Example (TypeScript app) diff --git a/internal/app/service.go b/internal/app/service.go index 9e3bdfc..c29ea79 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -29,12 +29,13 @@ type Config struct { } type Service struct { - store *store.MemoryStore - config Config + store *store.MemoryStore + config Config + servicePublicKey string } -func NewService(memoryStore *store.MemoryStore, cfg Config) *Service { - return &Service{store: memoryStore, config: cfg} +func NewService(memoryStore *store.MemoryStore, cfg Config, servicePublicKey string) *Service { + return &Service{store: memoryStore, config: cfg, servicePublicKey: servicePublicKey} } type InvitationPayload struct { @@ -55,6 +56,30 @@ func (s *Service) BootstrapAdmin(publicKey string) { }) } +func (s *Service) ServicePublicKey() string { + return s.servicePublicKey +} + +func (s *Service) RegisterBySignature(publicKey, signature string) error { + if s.servicePublicKey == "" { + return fmt.Errorf("%w: registration by signature not configured", ErrBadRequest) + } + if publicKey == "" { + return fmt.Errorf("%w: missing public key", ErrBadRequest) + } + if _, err := s.store.GetUser(publicKey); err == nil { + return ErrAlreadyUser + } + if err := auth.VerifySignature(publicKey, s.servicePublicKey, signature); err != nil { + return fmt.Errorf("%w: signature verification failed", ErrBadRequest) + } + s.store.UpsertUser(store.User{ + PublicKey: publicKey, + CreatedAt: time.Now().UTC(), + }) + return nil +} + func (s *Service) CreateChallenge(publicKey string) (string, error) { if publicKey == "" { return "", fmt.Errorf("%w: missing public key", ErrBadRequest) diff --git a/internal/http/api_test.go b/internal/http/api_test.go index 41716a4..c8c4b7e 100644 --- a/internal/http/api_test.go +++ b/internal/http/api_test.go @@ -21,7 +21,7 @@ func newTestServer(adminPublicKey string) *httptest.Server { svc := app.NewService(memory, app.Config{ ChallengeTTL: 5 * time.Minute, SessionTTL: 24 * time.Hour, - }) + }, adminPublicKey) svc.BootstrapAdmin(adminPublicKey) api := httpapi.NewAPI(svc) return httptest.NewServer(api.Routes()) @@ -36,6 +36,22 @@ func mustJSON(t *testing.T, value interface{}) []byte { return b } +func getJSON(t *testing.T, client *http.Client, url string, token string) (*http.Response, map[string]interface{}) { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, url, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + defer resp.Body.Close() + out := map[string]interface{}{} + _ = json.NewDecoder(resp.Body).Decode(&out) + return resp, out +} + func postJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) { t.Helper() req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(mustJSON(t, body))) @@ -107,6 +123,46 @@ func registerUserViaAdmin(t *testing.T, client *http.Client, baseURL, adminPub s } } +func TestRegisterBySignature(t *testing.T) { + adminPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate admin key: %v", err) + } + adminPubB64 := base64.RawURLEncoding.EncodeToString(adminPub) + + server := newTestServer(adminPubB64) + defer server.Close() + client := server.Client() + + svcKeyResp, svcKeyData := getJSON(t, client, server.URL+"/v1/service-key", "") + if svcKeyResp.StatusCode != http.StatusOK { + t.Fatalf("get service key status=%d body=%v", svcKeyResp.StatusCode, svcKeyData) + } + if svcKeyData["publicKey"] != adminPubB64 { + t.Fatalf("service key mismatch: got %v", svcKeyData["publicKey"]) + } + + userPub, userPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate user key: %v", err) + } + userPubB64 := base64.RawURLEncoding.EncodeToString(userPub) + sig := base64.RawURLEncoding.EncodeToString(ed25519.Sign(userPriv, []byte(adminPubB64))) + + regResp, regData := postJSON(t, client, server.URL+"/v1/auth/register-by-signature", map[string]string{ + "publicKey": userPubB64, + "signature": sig, + }, "") + if regResp.StatusCode != http.StatusCreated { + t.Fatalf("register-by-signature status=%d body=%v", regResp.StatusCode, regData) + } + + userToken := loginUser(t, client, server.URL, userPubB64, userPriv) + if userToken == "" { + t.Fatal("login after register-by-signature failed") + } +} + func TestRegisterLoginAndProfile(t *testing.T) { adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader) if err != nil { diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 62c6a3f..43e6d3a 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -22,11 +22,14 @@ func NewAPI(svc *app.Service) *API { func (a *API) Routes() http.Handler { mux := http.NewServeMux() staticFiles := http.FileServer(http.Dir("web")) + libsFiles := http.FileServer(http.Dir("libs")) mux.HandleFunc("GET /healthz", a.health) + mux.HandleFunc("GET /v1/service-key", a.getServiceKey) mux.HandleFunc("POST /v1/auth/challenge", a.createChallenge) mux.HandleFunc("POST /v1/auth/login", a.login) mux.HandleFunc("POST /v1/auth/register", a.register) + mux.HandleFunc("POST /v1/auth/register-by-signature", a.registerBySignature) mux.HandleFunc("POST /v1/invitations", a.createInvitation) mux.HandleFunc("GET /v1/me/keys", a.me) @@ -37,6 +40,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature) mux.Handle("/web/", http.StripPrefix("/web/", staticFiles)) + mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles)) mux.Handle("/", http.RedirectHandler("/web/", http.StatusTemporaryRedirect)) return withCORS(mux) @@ -82,6 +86,8 @@ func statusFromErr(err error) int { return http.StatusForbidden case errors.Is(err, app.ErrBadRequest): return http.StatusBadRequest + case errors.Is(err, app.ErrAlreadyUser): + return http.StatusConflict case errors.Is(err, app.ErrInviteInvalid), errors.Is(err, app.ErrInviteExpired), errors.Is(err, app.ErrInviteExhaust): @@ -156,6 +162,15 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"accessToken": token}) } +func (a *API) getServiceKey(w http.ResponseWriter, _ *http.Request) { + pk := a.service.ServicePublicKey() + if pk == "" { + writeErr(w, app.ErrBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]string{"publicKey": pk}) +} + func (a *API) register(w http.ResponseWriter, r *http.Request) { var req struct { PublicKey string `json:"publicKey"` @@ -174,6 +189,22 @@ func (a *API) register(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"}) } +func (a *API) registerBySignature(w http.ResponseWriter, r *http.Request) { + var req struct { + PublicKey string `json:"publicKey"` + Signature string `json:"signature"` + } + if err := readJSON(r, &req); err != nil { + writeErr(w, app.ErrBadRequest) + return + } + if err := a.service.RegisterBySignature(req.PublicKey, req.Signature); err != nil { + writeErr(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]string{"status": "registered"}) +} + func (a *API) createInvitation(w http.ResponseWriter, r *http.Request) { user, err := a.authUser(r) if err != nil { diff --git a/libs/geo-api-client/dist/index.js b/libs/geo-api-client/dist/index.js index 6f8a82b..16baa49 100644 --- a/libs/geo-api-client/dist/index.js +++ b/libs/geo-api-client/dist/index.js @@ -546,9 +546,20 @@ class GeoApiClient { } return await res.json(); } + async getServicePublicKey() { + return this.request("/v1/service-key", { method: "GET" }); + } async createChallenge(publicKey) { return this.request("/v1/auth/challenge", { method: "POST", body: { publicKey } }); } + async registerBySigningServiceKey(publicKey, privateKey) { + const { publicKey: servicePublicKey } = await this.getServicePublicKey(); + const signature = await signMessage(privateKey, servicePublicKey); + await this.request("/v1/auth/register-by-signature", { + method: "POST", + body: { publicKey, signature } + }); + } async loginWithSignature(publicKey, privateKey) { const challenge = await this.createChallenge(publicKey); const signature = await signMessage(privateKey, challenge.messageToSign); diff --git a/libs/geo-api-client/src/GeoApiClient.ts b/libs/geo-api-client/src/GeoApiClient.ts index 14f25fe..7f34670 100644 --- a/libs/geo-api-client/src/GeoApiClient.ts +++ b/libs/geo-api-client/src/GeoApiClient.ts @@ -62,10 +62,23 @@ export class GeoApiClient { return (await res.json()) as T; } + async getServicePublicKey(): Promise<{ publicKey: string }> { + return this.request("/v1/service-key", { method: "GET" }); + } + async createChallenge(publicKey: string): Promise<{ nonce: string; messageToSign: string }> { return this.request("/v1/auth/challenge", { method: "POST", body: { publicKey } }); } + async registerBySigningServiceKey(publicKey: string, privateKey: string): Promise { + const { publicKey: servicePublicKey } = await this.getServicePublicKey(); + const signature = await signMessage(privateKey, servicePublicKey); + await this.request("/v1/auth/register-by-signature", { + method: "POST", + body: { publicKey, signature }, + }); + } + async loginWithSignature(publicKey: string, privateKey: string): Promise { const challenge = await this.createChallenge(publicKey); const signature = await signMessage(privateKey, challenge.messageToSign); diff --git a/web/app.js b/web/app.js index 03062a5..201d1a5 100644 --- a/web/app.js +++ b/web/app.js @@ -4,7 +4,7 @@ const { createApp, ref, reactive, onMounted } = Vue; createApp({ setup() { - const apiBase = ref(localStorage.getItem("geo_api_base") || "http://localhost:8122"); + const apiBase = ref(localStorage.getItem("geo_api_base") || "https://momswap.produktor.duckdns.org"); const state = reactive({ publicKey: "", privateKey: "", @@ -33,8 +33,25 @@ createApp({ } }; + const register = async () => { + try { + await client.registerBySigningServiceKey(state.publicKey, state.privateKey); + state.status = "Registered. Use Login to authenticate."; + } catch (err) { + state.status = err.message; + } + }; + const login = async () => { try { + try { + await client.registerBySigningServiceKey(state.publicKey, state.privateKey); + } catch (err) { + if (!err.message.includes("already registered") && !err.message.includes("not configured")) { + throw err; + } + // Proceed to login: already registered or registration disabled (invitation flow) + } state.accessToken = await client.loginWithSignature(state.publicKey, state.privateKey); client.setAccessToken(state.accessToken); state.status = "Authenticated."; @@ -73,6 +90,7 @@ createApp({ state, rebuildClient, ensureKeys, + register, login, listCollections, createCollection, diff --git a/web/index.html b/web/index.html index b1be896..616bf17 100644 --- a/web/index.html +++ b/web/index.html @@ -39,6 +39,7 @@ Ensure Keys in localStorage + Register (sign service key) Login