Owner keypair auth (Ed25519 Sigv1)

Updated · View the entry on sijie.xyz ↗

The owner MCP handle (/mcp/*) gates every HTTP request with a valid Sigv1 signature — legacy Bearer PAT was removed when keypair auth replaced PATs (routes/mcphandle/server.go).

Scope precision: Sigv1 guards only the outward service handle (StandMeet as MCP server, as-mcp-facade). The inward capability plane (our agent consuming mcp-servers/ as host) is process-local — stdio children + capsocket — and never passes through this auth (confusables).

Key lifecycle

The keypair is generated server-side (ed25519.GenerateKey, backend/internal/owner/usecase/keypairs.go); the private-key PEM is returned exactly once in the POST /api/admin/keypairs response and never stored — only the public key persists in the owner_keypairs table. Domain comment: "the private key never enters the domain (owner keeps the PEM)" (owner/entity/keypair.go:2). Admin CRUD rides the normal owner session cookie; list returns metadata only (now including where the key was last used — last_used_ip + last_used_user_agent, shipped 2026-09-05, 169a51d79, migration 2026-09-06-keypair-last-used-meta.sql); DELETE /{key_id} is a hard delete = revocation (no status column).

The wire format

Authorization: Sigv1 keyId=<X>,ts=<unix-seconds>,nonce=<uuid>,sig=<base64>

The signed message is a FIXED challenge, not the request:

"standmeet-sigv1" + "\n" + keyId + "\n" + ts + "\n" + nonce

Verification flow

Prefix + four fields present → ts within ±5 min skew → public key lookup by keyId (miss → 401, existence not leaked) → ed25519.Verify → nonce first-seen check (Redis SetNX on sigv1nonce:<keyId>:<nonce>, TTL = 2×skew; fail-open if Redis is down) → best-effort touch of last_used_at + last_used_ip + last_used_user_agent → ownerID injected into ctx and propagated to MCP tool handlers (keypairs.go:176-302).

sequenceDiagram
  participant O as owner (admin UI)
  participant B as backend
  participant C as MCP client (holds private PEM)
  O->>B: POST /api/admin/keypairs (session cookie)
  B-->>O: private_key_pem — returned ONCE, never stored
  C->>B: /mcp/* with Sigv1 keyId,ts,nonce,sig
  B->>B: ts within ±5min? pubkey lookup? ed25519.Verify(challenge)? nonce unseen?
  B-->>C: ownerID ctx → tool handlers

Security model

Warning

The signature does not cover the request. The challenge binds identity + timestamp + a one-time nonce — not method, path, or body. Since 2026-07-04 (0fa5177e1) a captured header is replay-dead (the nonce burns in Redis on first accept), but it still authenticates the caller, not each message: a live attacker in the window could still attach a fresh-signed header to a different /mcp/* request only if it holds the private key. The nonce check fails open on a Redis outage — acceptable for the owner surface, and exactly why the embed analog fails closed. Acceptable under the current threat model (single owner, TLS assumed); the remaining upgrade path is to sign method + path + body-hash. (The file-header comment in keypairs.go:4 still says "no nonce table" — the code below it disagrees; trust the code.)

Class view

classDiagram
  class owner_keypairs {
    <<postgres - public half only>>
    id uuid PK
    owner_id uuid FK
    key_id text UNIQUE
    public_key_pem text
    label text
    last_used_at timestamptz NULL
    last_used_ip text NULL
    last_used_user_agent text NULL
    created_at timestamptz
  }
  class CreateKeypair {
    <<func>>
    takes ctx, KeypairDeps, *CreateKeypairInput
    returns (CreatedKeypair, error)
    ed25519.GenerateKey server-side
    private PEM in the return value ONCE, never stored
  }
  class VerifySigv1 {
    <<func>>
    takes ctx, KeypairDeps, authHeader, usedIP, usedUA
    returns (ownerID string, error)
    ts within 5min skew
    pubkey lookup by key_id - miss = plain 401
    ed25519.Verify on the fixed challenge
    nonce first-seen in Redis - fail-open
  }
  CreateKeypair --> owner_keypairs : persists public half
  VerifySigv1 ..> owner_keypairs : lookup + touch last_used_at/ip/user_agent

Setup token (distinct subsystem)

The setup token is separate — first-run instance claim (printed at boot + /srv/first-run.txt, sha256 into instance_settings, consumed once by POST /api/admin/claim which creates the owner). It bootstraps the owner account; keypairs come later via the authenticated admin API (confusables).

Cross-links: trusted-identity-via-meta, acl-and-quota-granularity.

Related notes