2026-09-23·by Sijie Wang#standmeet#architecture#design

owner-keypair-auth

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

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

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.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →