Files
backend/docs/ed25519-security-use-cases.md
Andriy Oblivantsev efe5907adc
CI / test (push) Successful in 3s
Update docs and defaults for tenerife.baby domain.
This replaces old momswap.produktor.duckdns.org references with tenerife.baby and refreshes the TypeScript integration guide to reflect the current asset upload, sharing, and relative-link flow.

Made-with: Cursor
2026-03-02 21:31:21 +00:00

7.7 KiB
Raw 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://tenerife.baby/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://tenerife.baby/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://tenerife.baby/v1/service-key | jq -r '.publicKey')

# Step 2: Sign $SERVICE_KEY with your private key, then:
curl -s -X POST https://tenerife.baby/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