craig-intake-keyring — Signer-Key Registry Sidecar

On this page

craig-intake-keyring is a minimal workspace service (services/craig-intake-keyring/, container port 8010) introduced by ADR-042 §D8 (MR8 / #688). It exists only for the standalone-SHINES deployment.

Why it exists

craig-intake is a stateless edge (ADR-017): it validates and forwards, with no service-side DB of its own. But the standalone signature-only API needs two pieces of state: the registered public keys and the JWS replay-dedup records. Rather than smear that state into the edge, which would break ADR-017, the keyring isolates it in one tiny flat-file service. Crucially, it re-exposes the same HTTP contracts craig-security and craig-cases already serve, so intake’s existing SignerAuthClient and JwsReplayClient just point at the sidecar instead. No new client is needed, and JWS is verified in standalone mode exactly as it is in integrated mode.

The store holds only public keys (keygen is client-side; private keys never leave the browser) and replay records — all publishable, so encryption would be obscurity, not security (Kerckhoffs). The store is therefore an unencrypted flat JSON file.

Browser key generation uses the Web Crypto API (crypto.subtle), which the platform exposes only in a secure context: HTTPS, or localhost. The keygen page must therefore be served over HTTPS in production. This is also why the standalone-SHINES e2e (#706) drives the register → approve → sign → submit chain over HTTP, the real integrator’s API path, rather than through the browser keygen: the Playwright container reaches the instance via host.docker.internal, which is not a secure context.

Re-exposed contracts (consumed by intake unchanged)

  • GET /v1/security/signer-keys/by-kid/{kid}: returns the SignerKeyInfo shape intake deserializes, with a synthetic nil partner_id and the literal partner_status: "active", the exact value the verifier accepts, since there is no partner tenancy. It returns HTTP 404 for an unknown, pending, revoked, or expired key. Key lifecycle is enforced by withholding the key, mirroring craig-security.

  • POST /v1/cases/_internal/jws-replay-check: takes { partner_id, jti, iat } and dedups by jti. The unchanged client carries no kid; jti is a per-submission UUID, so cross-holder collision is negligible. It returns HTTP 200 for first-seen, HTTP 401 for a replay, and records past a configurable retention window are pruned.

Key lifecycle

  • POST /keys/register: accepts a browser-generated public JWK and a holder label (reusing craig_security_contracts::RegisterSignerKeyRequest), and stores it pending. It is unauthenticated by design: anyone may propose a key, but an admin must approve it before it can sign an accepted report. It allowlist-validates the algorithm to ES256 (ECDSA over the P-256 curve with SHA-256, the JOSE/JWA signature algorithm identifier), rejects a duplicate kid (HTTP 409), and is bounded by a max_keys cap (HTTP 429).

  • PUT /keys/{kid}/approve (pendingapproved) and PUT /keys/{kid}/revoke (→ revoked, terminal) form the admin lifecycle, gated by a constant-time admin-token bearer check (subtle::ConstantTimeEq over SHA-256 digests; an unset token fails closed). These are operator-only; there is no browser approve/revoke UI.

  • GET /keys (#714) is a metadata-only list of registered keys for the view-keys testing aid: kid, display_name, an effective status (an approved key past expires_at reads expired, mirroring lookup_active), created_at, and expires_at. It deliberately omits the public_key_jwk and the partner-scoped holder identifier. It’s gated default-off by CRAIG_INTAKE_KEYRING__ENABLE_KEY_LIST: when off, the route is simply absent (HTTP 404, with no existence signal); when on, it’s unauthenticated like register, with the gate plus the internal-only deployment boundary as the protection. The browser reaches it only through the same-origin intake proxy GET /signed/v1/keys.

SignerAuthClient caches by-kid hits, including None, for 60s, so an approve or revoke is not observed by intake for up to 60s. Tests register and approve a fresh kid so a pre-approval None is never cached.

Store + operations

  • Store: unencrypted flat JSON, holding public keys, replay records, and holder labels, written atomically via a temp-in-dir → fsync → rename → fsync-parent-dir sequence under a parking_lot::RwLock. The service runs as a single instance: its state lives only in that process’s memory and its local file, with no shared store or cross-process coordination, so a second replica could not see the first’s keys or replay records.

  • Image: Alpine, musl, rustls; runs non-root, with a HEALTHCHECK.

  • Devstack: a craig-intake-keyring compose service plus an xtask port mapping (container port 8010; the host port is ephemeral, allocated by cargo xtask dev and recorded in .ports.envcargo xtask dev status shows the live mapping — while the raw docker compose fallback still publishes the default host port 8012). The admin token is the devstack-only ADMIN_TOKEN: devstack-keyring-admin-token.

Deployment boundary

The keyring is internal-network-only in production: no CORS, and it’s never host-published. The keygen page registers a public key by POSTing same-origin to an intake proxy (POST /signed/v1/keys/register), which forwards server-side to the sidecar, so the browser only ever talks to intake’s origin (ADR-042 §D9). The devstack host-port mapping exists purely as a test convenience, letting the signed-API e2e and the Rust integration tests drive the admin approve route directly.

Edit this page · latest