Files
backend/docs/ed25519-security-use-cases.md
Andriy Oblivantsev a5a97a0ad9
CI / test (push) Successful in 5s
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
2026-03-01 12:59:02 +00:00

7.7 KiB
Raw Permalink Blame History

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
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 (ChallengeResponse)

Proves the client controls the private key for a registered public key.

Message to sign: login: + nonce (exact UTF-8 string)

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):

# 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):

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):

# 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):

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:

{
  "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):

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>
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):

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.

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