Owner keypair auth (Ed25519 Sigv1)
Parent: access-control
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).
Security model
WarningThe 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 signmethod + path + body-hash. (The file-header comment inkeypairs.go:4still says "no nonce table" — the code below it disagrees; trust the code.)
Class view
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.