The minimum a module must meet when it issues its own long-lived caller
credentials instead of trusting an issuer's JWKS. Option C in
guides/03-machine-identity.md is the loosest way to identify a caller and
the owner rules it stays for some modules, so this contract exists to make
"loose" mean "no shared infrastructure", never "no standard". A module on
this contract is conforming; a module with one shared password is not.
Audience: anyone implementing the keys verifier, or issuing a key.
The key#
| Property | Requirement |
|---|---|
| format | smk_<env>_<random>: literal prefix, the environment (local, dev, staging, prod), then at least 32 bytes of cryptographic randomness, base16 or base62 |
| shown | once, at issue; never retrievable afterwards |
| stored | hashed (argon2id or scrypt; SHA-256 with a per-module pepper is the floor) with a short lookup prefix stored in clear so the row can be found without scanning |
| bound to | exactly one caller (caller_id) and one environment; a key from dev is rejected by a prod module even if the hash matches |
| carries | tenants (list or "*"), scopes, optional budget, expires_at, created_at, created_by, label |
| expiry | mandatory; default 90 days, maximum 1 year; a key with no expiry is a contract violation |
| rotation | issue the replacement, run both for the overlap window, revoke the old; the module accepts any non-expired, non-revoked key for the caller |
| revocation | delete or mark the row; takes effect on the next request; no cache longer than 60 s |
| audit | issue, rotate, revoke, and first-use events written to the module's log with caller_id, never the key |
Transport#
Authorization: Bearer smk_…only. Not a query string, not a cookie, not a custom header. (The extractor'srequest.query_params.get("key")is the named lapse,automation/router.py:35.)- The tenant travels on the request as
X-Tenant-Idor in the body, and is checked against the key'stenants(canon/02). - Compared in constant time against the hash; the module never logs, echoes, or stores the presented value.
- The callback signing secret is a separate per-caller value handed out with the key, never the key itself.
The verifier#
The keys implementation of the verifier port (canon/02, "The verifier is
a port"): bearer string in, caller context out, or 401 for every credential
refusal (unknown, expired, revoked, and a key from another env, so a probe
learns nothing about a key's validity elsewhere), 403 only for a valid
credential that lacks the tenant or scope. Same context shape as the
jwks verifier, with issued_by: keys:<module> set so downstream records
say which path the call took. A module may run keys beside jwks in the
verifier chain (guides/03, "Supporting more than one at once").
Two rules for any keys-shaped verifier, module-issued or remote (learned
from services-api's apikeys/client.py:38 and app/auth.py:46): a
credential that is not key-shaped is rejected before any lookup and never
reaches a network; and when the verifier depends on another service, that
service being unreachable is a 503 to the caller and is never cached as a
rejection, so an issuer outage cannot lock a valid key out for the TTL.
Verdict caches are bounded by the 60 s revocation ceiling below and are not
env-settable past it.
Issuing#
- A key is issued by a module operator through an admin scope, never by the caller self-serving; the operator names the caller, env, tenants, scopes, budget, expiry, and a label.
- The module keeps the count of active keys per caller small (default cap 2, for rotation) and per env; more is a config decision with a reason.
- Every module on this contract exposes
GET /health/deepwith the number of active keys and the soonest expiry, so an expiring key is visible before it fails.
Conformance rows#
Added to contracts/conformance.md for any module whose manifest lists a
keys verifier:
- a key from another env is rejected with 401 (never 403, which would confirm the key is valid somewhere)
- an expired key is rejected with 401 within one request of expiry
- a revoked key is rejected within 60 s
- the presented key never appears in logs (grep the log after a request)
- a key in a query string is rejected with 401 and logged as a misuse
/health/deepreports active-key count and soonest expiry
Banned#
- A key without an expiry.
- One key shared by more than one caller.
- A key valid in more than one environment.
- A key stored in clear, or logged.
- A key accepted anywhere except the
Authorizationheader. - Reusing the key as the callback signing secret.
- Sharing the key store itself with another service. Copy the pattern, keep
the tables separate: a schema change in the other service's key table
silently changes this module's auth surface, and the two stop being able to
deploy independently. (
evidence/20260820-internal-dashboard-reuse.md)