- 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
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
.git
|
.git
|
||||||
.gitea
|
.gitea
|
||||||
docs
|
docs
|
||||||
|
var
|
||||||
node_modules
|
node_modules
|
||||||
**/node_modules
|
**/node_modules
|
||||||
**/*.log
|
**/*.log
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ go test ./...
|
|||||||
go run ./cmd/api
|
go run ./cmd/api
|
||||||
docker compose up --build -d
|
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)
|
||||||
```
|
```
|
||||||
|
|
||||||
TypeScript client:
|
TypeScript client:
|
||||||
|
|||||||
+17
@@ -22,15 +22,32 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
|
CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} \
|
||||||
go build -p "$(nproc)" -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
|
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
|
FROM base AS dev
|
||||||
ENV ADDR=:8122
|
ENV ADDR=:8122
|
||||||
EXPOSE 8122
|
EXPOSE 8122
|
||||||
CMD ["go", "run", "./cmd/api"]
|
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
|
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /out/api /app/api
|
COPY --from=builder /out/api /app/api
|
||||||
|
COPY --from=builder /src/web /app/web
|
||||||
|
COPY --from=webbuild /src/libs /app/libs
|
||||||
|
|
||||||
ENV ADDR=:8122
|
ENV ADDR=:8122
|
||||||
EXPOSE 8122
|
EXPOSE 8122
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ go test ./...
|
|||||||
go run ./cmd/api
|
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/`.
|
Primary deployed base URL: `https://momswap.produktor.duckdns.org/`.
|
||||||
|
|
||||||
Local default (for development): `http://localhost:8122`.
|
Local default (for development): `http://localhost:8122`.
|
||||||
@@ -28,6 +34,7 @@ Optional environment variables:
|
|||||||
|
|
||||||
- `ADDR` (default `:8122`)
|
- `ADDR` (default `:8122`)
|
||||||
- `ADMIN_PUBLIC_KEY` (bootstrap initial inviter/admin user)
|
- `ADMIN_PUBLIC_KEY` (bootstrap initial inviter/admin user)
|
||||||
|
- `SERVICE_PUBLIC_KEY` (public key users sign to register; defaults to `ADMIN_PUBLIC_KEY`)
|
||||||
|
|
||||||
## Docker Compose
|
## Docker Compose
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
docker compose down
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
go run ./cmd/api
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
docker compose --profile test run --rm test
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
docker compose up --build -d
|
||||||
+2
-1
@@ -14,12 +14,13 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
addr := getEnv("ADDR", ":8122")
|
addr := getEnv("ADDR", ":8122")
|
||||||
adminPublicKey := os.Getenv("ADMIN_PUBLIC_KEY")
|
adminPublicKey := os.Getenv("ADMIN_PUBLIC_KEY")
|
||||||
|
servicePublicKey := getEnv("SERVICE_PUBLIC_KEY", 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,
|
||||||
SessionTTL: 24 * time.Hour,
|
SessionTTL: 24 * time.Hour,
|
||||||
})
|
}, servicePublicKey)
|
||||||
service.BootstrapAdmin(adminPublicKey)
|
service.BootstrapAdmin(adminPublicKey)
|
||||||
|
|
||||||
api := httpapi.NewAPI(service)
|
api := httpapi.NewAPI(service)
|
||||||
|
|||||||
@@ -68,3 +68,16 @@ services:
|
|||||||
- action: rebuild
|
- action: rebuild
|
||||||
path: ./Dockerfile
|
path: ./Dockerfile
|
||||||
|
|
||||||
|
test:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
target: test
|
||||||
|
image: momswap-backend:test
|
||||||
|
volumes:
|
||||||
|
- .:/src
|
||||||
|
working_dir: /src
|
||||||
|
user: root
|
||||||
|
profiles:
|
||||||
|
- test
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
# Ed25519 Security Use Cases
|
# 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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -13,9 +16,13 @@ flowchart LR
|
|||||||
backendApi -->|"registers identity by public key"| usersTable[(UsersByPublicKey)]
|
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
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -32,36 +39,117 @@ sequenceDiagram
|
|||||||
A-->>C: {accessToken}
|
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":"<base64url>"}`
|
||||||
|
2. Server returns `{"nonce":"...", "messageToSign":"login:<nonce>"}`
|
||||||
|
3. Client signs `login:<nonce>` with private key
|
||||||
|
4. `POST /v1/auth/login` with `{"publicKey","nonce","signature"}`
|
||||||
|
5. Server verifies and returns `{"accessToken":"..."}`
|
||||||
|
|
||||||
```mermaid
|
**Example (curl):**
|
||||||
flowchart TD
|
|
||||||
requestCreate[CreateCollectionOrFeatureRequest] --> bearerCheck[BearerTokenValidation]
|
```bash
|
||||||
bearerCheck --> sessionStore[(Sessions)]
|
# Step 1: Get challenge
|
||||||
sessionStore --> ownerKey[ResolvedOwnerPublicKey]
|
CHALLENGE=$(curl -s -X POST https://momswap.produktor.duckdns.org/v1/auth/challenge \
|
||||||
ownerKey --> ownershipRule[OwnershipValidation]
|
-H "Content-Type: application/json" \
|
||||||
ownershipRule --> writeData[(CollectionsAndFeatures)]
|
-d '{"publicKey":"txdkGKNdcZIEoQMJ0dqum3msjT6-2mO4yLVhtidRFJI"}')
|
||||||
ownershipRule --> reject403[RejectForbidden]
|
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\":\"<your_signature_base64url>\"}"
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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.
|
```ts
|
||||||
|
const challenge = await client.createChallenge(publicKey);
|
||||||
```mermaid
|
const signature = await signMessage(privateKey, challenge.messageToSign);
|
||||||
flowchart TD
|
const token = await client.loginWithSignature(publicKey, privateKey);
|
||||||
requestRead[ReadCollectionsOrFeaturesRequest] --> validateToken[ValidateBearerToken]
|
|
||||||
validateToken --> ownerKey[OwnerPublicKeyFromSession]
|
|
||||||
ownerKey --> queryOwned[QueryOnlyOwnerRows]
|
|
||||||
queryOwned --> responseData[ReturnOwnedCollectionsFeatures]
|
|
||||||
ownerKey --> denyOther[RejectOtherOwnerAccess]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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":"<base64url>"}`
|
||||||
|
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\":\"<your_pubkey_base64url>\",\"signature\":\"<signature_base64url>\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**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": "<inviter_base64url>",
|
||||||
|
"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:` + `<publicKey>` + `:` + `<jti>`
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -73,9 +161,63 @@ flowchart LR
|
|||||||
verifyProof --> usersStore
|
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
|
## Security Notes
|
||||||
|
|
||||||
- Private keys stay client-side.
|
- **Replay protection:** Challenges are single-use and expire (5 min TTL). Invitations have expiry and max-use limits.
|
||||||
- Public keys are stable user identifiers.
|
- **Key binding:** Login challenge is bound to the public key that requested it. Invitation proof binds the new user's key to the JTI.
|
||||||
- Session tokens are granted only after valid Ed25519 signature verification.
|
- **Canonical encoding:** Use UTF-8 for all messages; base64url without padding for keys and signatures.
|
||||||
- Collection/feature access is enforced by owner key derived from session identity.
|
- **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 |
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ Key methods:
|
|||||||
- `importKeys(keys)`
|
- `importKeys(keys)`
|
||||||
- `exportKeys()`
|
- `exportKeys()`
|
||||||
- `setAccessToken(token)`
|
- `setAccessToken(token)`
|
||||||
|
- `getServicePublicKey()`
|
||||||
- `createChallenge(publicKey)`
|
- `createChallenge(publicKey)`
|
||||||
- `loginWithSignature(publicKey, privateKey)`
|
- `loginWithSignature(publicKey, privateKey)`
|
||||||
|
- `registerBySigningServiceKey(publicKey, privateKey)` — register by signing the API service public key (no invitation required)
|
||||||
- `createInvitation(payload, inviterPrivateKey)`
|
- `createInvitation(payload, inviterPrivateKey)`
|
||||||
- `registerWithInvitation(...)`
|
- `registerWithInvitation(...)`
|
||||||
- `listCollections()`
|
- `listCollections()`
|
||||||
@@ -57,9 +59,18 @@ Key methods:
|
|||||||
|
|
||||||
1. Create one `GeoApiClient` instance per backend base URL.
|
1. Create one `GeoApiClient` instance per backend base URL.
|
||||||
2. Call `ensureKeysInStorage()` when app initializes.
|
2. Call `ensureKeysInStorage()` when app initializes.
|
||||||
3. Use `loginWithSignature()` to obtain and set a bearer token.
|
3. If not yet registered: call `registerBySigningServiceKey(publicKey, privateKey)` (signs the API service key and publishes your public key).
|
||||||
4. Call collection/feature methods after authentication.
|
4. Use `loginWithSignature()` to obtain and set a bearer token.
|
||||||
5. Use `importKeys`/`exportKeys` in profile settings UX.
|
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)
|
## Example (TypeScript app)
|
||||||
|
|
||||||
|
|||||||
+27
-2
@@ -31,10 +31,11 @@ type Config struct {
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
store *store.MemoryStore
|
store *store.MemoryStore
|
||||||
config Config
|
config Config
|
||||||
|
servicePublicKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(memoryStore *store.MemoryStore, cfg Config) *Service {
|
func NewService(memoryStore *store.MemoryStore, cfg Config, servicePublicKey string) *Service {
|
||||||
return &Service{store: memoryStore, config: cfg}
|
return &Service{store: memoryStore, config: cfg, servicePublicKey: servicePublicKey}
|
||||||
}
|
}
|
||||||
|
|
||||||
type InvitationPayload struct {
|
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) {
|
func (s *Service) CreateChallenge(publicKey string) (string, error) {
|
||||||
if publicKey == "" {
|
if publicKey == "" {
|
||||||
return "", fmt.Errorf("%w: missing public key", ErrBadRequest)
|
return "", fmt.Errorf("%w: missing public key", ErrBadRequest)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func newTestServer(adminPublicKey string) *httptest.Server {
|
|||||||
svc := app.NewService(memory, app.Config{
|
svc := app.NewService(memory, app.Config{
|
||||||
ChallengeTTL: 5 * time.Minute,
|
ChallengeTTL: 5 * time.Minute,
|
||||||
SessionTTL: 24 * time.Hour,
|
SessionTTL: 24 * time.Hour,
|
||||||
})
|
}, adminPublicKey)
|
||||||
svc.BootstrapAdmin(adminPublicKey)
|
svc.BootstrapAdmin(adminPublicKey)
|
||||||
api := httpapi.NewAPI(svc)
|
api := httpapi.NewAPI(svc)
|
||||||
return httptest.NewServer(api.Routes())
|
return httptest.NewServer(api.Routes())
|
||||||
@@ -36,6 +36,22 @@ func mustJSON(t *testing.T, value interface{}) []byte {
|
|||||||
return b
|
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{}) {
|
func postJSON(t *testing.T, client *http.Client, url string, body interface{}, token string) (*http.Response, map[string]interface{}) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(mustJSON(t, body)))
|
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) {
|
func TestRegisterLoginAndProfile(t *testing.T) {
|
||||||
adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader)
|
adminPub, adminPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,11 +22,14 @@ func NewAPI(svc *app.Service) *API {
|
|||||||
func (a *API) Routes() http.Handler {
|
func (a *API) Routes() http.Handler {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
staticFiles := http.FileServer(http.Dir("web"))
|
staticFiles := http.FileServer(http.Dir("web"))
|
||||||
|
libsFiles := http.FileServer(http.Dir("libs"))
|
||||||
|
|
||||||
mux.HandleFunc("GET /healthz", a.health)
|
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/challenge", a.createChallenge)
|
||||||
mux.HandleFunc("POST /v1/auth/login", a.login)
|
mux.HandleFunc("POST /v1/auth/login", a.login)
|
||||||
mux.HandleFunc("POST /v1/auth/register", a.register)
|
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("POST /v1/invitations", a.createInvitation)
|
||||||
mux.HandleFunc("GET /v1/me/keys", a.me)
|
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.HandleFunc("DELETE /v1/features/{id}", a.deleteFeature)
|
||||||
|
|
||||||
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
|
mux.Handle("/web/", http.StripPrefix("/web/", staticFiles))
|
||||||
|
mux.Handle("/libs/", http.StripPrefix("/libs/", libsFiles))
|
||||||
mux.Handle("/", http.RedirectHandler("/web/", http.StatusTemporaryRedirect))
|
mux.Handle("/", http.RedirectHandler("/web/", http.StatusTemporaryRedirect))
|
||||||
|
|
||||||
return withCORS(mux)
|
return withCORS(mux)
|
||||||
@@ -82,6 +86,8 @@ func statusFromErr(err error) int {
|
|||||||
return http.StatusForbidden
|
return http.StatusForbidden
|
||||||
case errors.Is(err, app.ErrBadRequest):
|
case errors.Is(err, app.ErrBadRequest):
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
|
case errors.Is(err, app.ErrAlreadyUser):
|
||||||
|
return http.StatusConflict
|
||||||
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):
|
||||||
@@ -156,6 +162,15 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"accessToken": token})
|
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) {
|
func (a *API) register(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
PublicKey string `json:"publicKey"`
|
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"})
|
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) {
|
func (a *API) createInvitation(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := a.authUser(r)
|
user, err := a.authUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Vendored
+11
@@ -546,9 +546,20 @@ class GeoApiClient {
|
|||||||
}
|
}
|
||||||
return await res.json();
|
return await res.json();
|
||||||
}
|
}
|
||||||
|
async getServicePublicKey() {
|
||||||
|
return this.request("/v1/service-key", { method: "GET" });
|
||||||
|
}
|
||||||
async createChallenge(publicKey) {
|
async createChallenge(publicKey) {
|
||||||
return this.request("/v1/auth/challenge", { method: "POST", body: { 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) {
|
async loginWithSignature(publicKey, privateKey) {
|
||||||
const challenge = await this.createChallenge(publicKey);
|
const challenge = await this.createChallenge(publicKey);
|
||||||
const signature = await signMessage(privateKey, challenge.messageToSign);
|
const signature = await signMessage(privateKey, challenge.messageToSign);
|
||||||
|
|||||||
@@ -62,10 +62,23 @@ export class GeoApiClient {
|
|||||||
return (await res.json()) as T;
|
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 }> {
|
async createChallenge(publicKey: string): Promise<{ nonce: string; messageToSign: string }> {
|
||||||
return this.request("/v1/auth/challenge", { method: "POST", body: { publicKey } });
|
return this.request("/v1/auth/challenge", { method: "POST", body: { publicKey } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async registerBySigningServiceKey(publicKey: string, privateKey: string): Promise<void> {
|
||||||
|
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<string> {
|
async loginWithSignature(publicKey: string, privateKey: string): Promise<string> {
|
||||||
const challenge = await this.createChallenge(publicKey);
|
const challenge = await this.createChallenge(publicKey);
|
||||||
const signature = await signMessage(privateKey, challenge.messageToSign);
|
const signature = await signMessage(privateKey, challenge.messageToSign);
|
||||||
|
|||||||
+19
-1
@@ -4,7 +4,7 @@ const { createApp, ref, reactive, onMounted } = Vue;
|
|||||||
|
|
||||||
createApp({
|
createApp({
|
||||||
setup() {
|
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({
|
const state = reactive({
|
||||||
publicKey: "",
|
publicKey: "",
|
||||||
privateKey: "",
|
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 () => {
|
const login = async () => {
|
||||||
try {
|
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);
|
state.accessToken = await client.loginWithSignature(state.publicKey, state.privateKey);
|
||||||
client.setAccessToken(state.accessToken);
|
client.setAccessToken(state.accessToken);
|
||||||
state.status = "Authenticated.";
|
state.status = "Authenticated.";
|
||||||
@@ -73,6 +90,7 @@ createApp({
|
|||||||
state,
|
state,
|
||||||
rebuildClient,
|
rebuildClient,
|
||||||
ensureKeys,
|
ensureKeys,
|
||||||
|
register,
|
||||||
login,
|
login,
|
||||||
listCollections,
|
listCollections,
|
||||||
createCollection,
|
createCollection,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<v-btn color="secondary" @click="ensureKeys">Ensure Keys in localStorage</v-btn>
|
<v-btn color="secondary" @click="ensureKeys">Ensure Keys in localStorage</v-btn>
|
||||||
<v-textarea v-model="state.publicKey" class="mt-3" label="Public Key" rows="2"></v-textarea>
|
<v-textarea v-model="state.publicKey" class="mt-3" label="Public Key" rows="2"></v-textarea>
|
||||||
<v-textarea v-model="state.privateKey" label="Private Key (local only)" rows="2"></v-textarea>
|
<v-textarea v-model="state.privateKey" label="Private Key (local only)" rows="2"></v-textarea>
|
||||||
|
<v-btn color="secondary" class="mt-2" @click="register">Register (sign service key)</v-btn>
|
||||||
<v-btn color="success" class="mt-2" @click="login">Login</v-btn>
|
<v-btn color="success" class="mt-2" @click="login">Login</v-btn>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|||||||
Reference in New Issue
Block a user