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
224 lines
7.7 KiB
Markdown
224 lines
7.7 KiB
Markdown
# Ed25519 Security Use Cases
|
||
|
||
This document describes how the Momswap Geo backend uses Ed25519 signatures for authentication and registration.
|
||
|
||
## Overview
|
||
|
||
- **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
|
||
userClient[UserClient] -->|"holds private key locally"| privateKey[PrivateKey]
|
||
userClient -->|"shares public key"| backendApi[BackendAPI]
|
||
backendApi -->|"registers identity by public key"| usersTable[(UsersByPublicKey)]
|
||
```
|
||
|
||
## Use Cases
|
||
|
||
### 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
|
||
participant C as Client
|
||
participant A as API
|
||
participant U as UsersStore
|
||
|
||
C->>A: POST /v1/auth/challenge {publicKey}
|
||
A-->>C: {nonce, messageToSign}
|
||
C->>C: sign(messageToSign, privateKey)
|
||
C->>A: POST /v1/auth/login {publicKey, nonce, signature}
|
||
A->>U: load user by publicKey
|
||
A->>A: verify signature with publicKey
|
||
A-->>C: {accessToken}
|
||
```
|
||
|
||
**Flow:**
|
||
|
||
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":"..."}`
|
||
|
||
**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>\"}"
|
||
```
|
||
|
||
**Example (TypeScript):**
|
||
|
||
```ts
|
||
const challenge = await client.createChallenge(publicKey);
|
||
const signature = await signMessage(privateKey, challenge.messageToSign);
|
||
const token = await client.loginWithSignature(publicKey, privateKey);
|
||
```
|
||
|
||
---
|
||
|
||
### 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
|
||
inviter[InviterUser] -->|"sign invite payload"| invitePayload[InvitePayloadAndSignature]
|
||
newUser[NewUser] -->|"submit public key + invite + proof signature"| registerEndpoint[RegisterEndpoint]
|
||
registerEndpoint --> verifyInvite[VerifyInviteSignatureAndRules]
|
||
registerEndpoint --> verifyProof[VerifyNewUserProofSignature]
|
||
verifyInvite --> usersStore[(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
|
||
|
||
- **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 |
|