Add register-by-signature, web fixes, bin scripts, docs
CI / test (push) Successful in 5s

- 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:
2026-03-01 12:58:44 +00:00
parent 978e0403eb
commit a5a97a0ad9
19 changed files with 405 additions and 41 deletions
+173 -31
View File
@@ -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 (ChallengeResponse)
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":"<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
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\":\"<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.
```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":"<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
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 |