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 theSignerKeyInfoshape intake deserializes, with a synthetic nilpartner_idand the literalpartner_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 byjti. The unchanged client carries nokid;jtiis 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 (reusingcraig_security_contracts::RegisterSignerKeyRequest), and stores itpending. 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 toES256(ECDSA over the P-256 curve with SHA-256, the JOSE/JWA signature algorithm identifier), rejects a duplicatekid(HTTP 409), and is bounded by amax_keyscap (HTTP 429). -
PUT /keys/{kid}/approve(pending→approved) andPUT /keys/{kid}/revoke(→revoked, terminal) form the admin lifecycle, gated by a constant-time admin-token bearer check (subtle::ConstantTimeEqover 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 effectivestatus(an approved key pastexpires_atreadsexpired, mirroringlookup_active),created_at, andexpires_at. It deliberately omits thepublic_key_jwkand the partner-scoped holder identifier. It’s gated default-off byCRAIG_INTAKE_KEYRING__ENABLE_KEY_LIST: when off, the route is simply absent (HTTP 404, with no existence signal); when on, it’s unauthenticated likeregister, with the gate plus the internal-only deployment boundary as the protection. The browser reaches it only through the same-origin intake proxyGET /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-keyringcompose service plus anxtaskport mapping (container port 8010; the host port is ephemeral, allocated bycargo xtask devand recorded in.ports.env—cargo xtask dev statusshows the live mapping — while the rawdocker composefallback still publishes the default host port 8012). The admin token is the devstack-onlyADMIN_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.
Related
-
ADR-042 §D8/§D9 — the sidecar design + the no-partner signed-API auth.
-
Standalone intake architecture — where the keyring sits in the flow.
-
SHINES CPS intake — proposed API — the backend a signed submission forwards to.