ADR-0004: Internal RPC request authentication with per-pair HMAC keys¶
- Status: Accepted (draft — awaiting the maintainers' sign-off).
Amended 2026-10-25: the pair MAC is the only
internal-RPC authentication;
RPC_AUTH_MODEandRPC_RESPONSE_BINDINGare removed — setting either fails startup unless it is set to its inert value (see "Amendment (2026-10-25)"). The sections below that compare modes describe the migration that the amendment completed - Date: 2026-10-21
- Deciders: Sandloom maintainers
- Supersedes: — (extends the service-identity model of #57 and the keyed
response binding of
docs/SERVICE_RPC.md→ "Response binding")
Context¶
In RPC_AUTH_MODE=service_identity every internal RPC call (and every
describe / readiness probe) is signed by the caller with its own Ed25519 key
(sandloom.core.rpc_identity); the provider verifies the signature with the
caller's public key from RPC_SERVICE_KEYS and authorizes the caller against
RPC_ALLOWED_CALLS. The native-acceleration research
(docs/design/native-acceleration-research-2026-10-21.md, R2, branch
claude/confident-sammet-a122dc) measured the cost:
| primitive (cryptography 50 / OpenSSL, arm64) | Ir / op (cachegrind) | wall µs |
|---|---|---|
| Ed25519 verify (provider, every call) | 695 k = 28 % of the provider side of a call | 86 |
| Ed25519 sign (caller, every call) | 230 k | 29 |
| HMAC-SHA256, prepared key context | 14.5 k | 1.1 |
A Rust verifier (ed25519-dalek) would only buy 2.1× at the price of a native
crypto dependency. Every service already holds a dedicated static X25519
key for the keyed response binding (RPC_DH_PRIVATE_KEY, public halves in
RPC_SERVICE_DH_KEYS), and both ends of every pair can already derive a
shared per-pair secret. The 2026-10-21 RPC review also found:
- No audience. The Ed25519 signature covers method, target, body digest and context, but not the provider. A request captured on the wire can be replayed to another service that serves the same contract (two replicas sets, a staging/prod pair sharing keys, a contract hosted by two services) within the 300 s context window.
- Identity failures surface as 401/403 to end users.
rpc_identity_*andrpc_caller_forbiddenwere not in the caller's "unavailable" code table, so a provider's 401/403 became aContractCallErrorwith the same status — a deployment misconfiguration answered the end user "unauthorized". RPC_RESPONSE_BINDING=strictacceptsalg=tokenbindings without a pinnedservice_id(token deployments only; see "Follow-ups").
Decision¶
Add a request authenticator that is a MAC under a per-pair, directional,
HKDF-derived key from the services' existing X25519 keys, behind two new
RPC_AUTH_MODE values, and make it the target mode of split deployments:
RPC_AUTH_MODE |
caller sends | provider accepts |
|---|---|---|
shared_token |
token + context HMAC | token |
dual |
token + Ed25519 | token or Ed25519 |
service_identity |
Ed25519 (+ aud when the binding is pinned) |
Ed25519 |
service_mac_dual (migration) |
pair MAC + Ed25519 | pair MAC if present (never falls back once present), else Ed25519 |
service_mac (target) |
pair MAC; commands also Ed25519-signed | pair MAC, plus the caller's Ed25519 signature on commands (RPC_COMMANDS_REQUIRE_SIGNATURE, on in prod) — Ed25519-only / token requests are refused |
The command signature is the mitigation of key-compromise impersonation (see "Key-compromise impersonation" below): the pair key is symmetric, so whoever holds a provider's X25519 private key can compute it for every caller. Effects therefore also need a proof only the caller can produce; reads — the hot path — stay MAC-only.
Implementation: sandloom.core.rpc_pair_mac (key schedule, canonical encoding,
MAC, header, replay guard, configuration checks); wiring in sandloom.core.rpc.
All primitives come from cryptography (X25519, HKDF) and the stdlib
(hmac, hashlib); there is no custom crypto code.
Key schedule¶
For a caller C (X25519 key c, kid ck) and a provider P (key p, kid
pk), both sides compute
Z = X25519(c, P_pub) = X25519(p, C_pub) # cryptography's exchange
reject: exchange error (low-order / non-canonical point) or Z == 0^32
K_req = HKDF-SHA256(IKM = Z,
salt = "sandloom-rpc-pair-mac/v1",
info = LP("sandloom-rpc-request-key", "1", "caller->provider",
C, ck, P, pk),
L = 32)
LP(...) is the canonical length-prefixed encoding below. The identities are
in role order (caller first, provider second) with an explicit direction
label, which is canonical: both sides know which role each id plays, and
K_req(C→P) ≠ K_req(P→C) (the reverse direction is a different key — a
provider can never turn a request it received into one it sends). Keys per
pair and direction:
| key | salt / info | used for |
|---|---|---|
K_req(C→P) (new) |
sandloom-rpc-pair-mac/v1, LP("sandloom-rpc-request-key","1","caller->provider",C,ck,P,pk) |
caller → provider request MAC |
K_resp(P→C) (existing v2 response binding) |
sandloom-rpc-response-binding/v2, "response"\0C\0ck\0P\0pk |
provider → caller response MAC |
Distinct salts and infos make the keys independent HKDF outputs of the same
Z: neither key ever MACs the other direction's messages, and no X25519 key
is used for anything but key agreement (the Ed25519 signing key stays
separate; startup refuses a DH key whose 32 bytes equal the signing seed).
No third "response" key is introduced: the provider→caller direction is
already served, domain-separated, by K_resp. Derived keys are cached as
prepared HMAC contexts keyed by (own private PEM, peer public PEM, info); the
info names both kids, so a rotated key is a new cache entry and the cache is
bounded (cleared when full) and cleared explicitly by clear_key_cache().
Request MAC¶
Headers (one call attempt):
X-SANDLOOM-RPC-Nonce: 32 lowercase hex = 128 random bits (os.urandom), fresh per attempt
X-SANDLOOM-RPC-Context: base64url(JSON {tenant_id, idk, iat[, aud, svc, kid, dhk]})
X-SANDLOOM-RPC-Auth: v=1;caller=<C>;ck=<ck>;aud=<P>;pk=<pk>;mac=<base64url(32-byte tag)>
tag = HMAC-SHA256(K_req(C→P),
LP("sandloom-rpc-request-mac", "1", C, ck, P, pk,
HTTP method, target "<contract>/<method>" (GET: "<contract>"),
nonce, raw context bytes, SHA-256(raw body)))
LP(x1..xn) = for each xi: uint32_be(len(xi)) || xi (fixed arity n)
- Unambiguous. A fixed number of fields, each with a 4-byte length
prefix: the encoding is injective (it has a decoder; the property tests
check
decode(encode(x)) == xand that no two distinct inputs collide). - Covers everything the provider acts on. The provider authorizes the caller id from the header (the one the key was derived from), serves the target from the URL, binds tenant / idempotency key / issue time from the context bytes, and executes the body — all inside the tag. The context is covered as the exact bytes the provider parses (it is parsed only after the tag verified), so no JSON canonicalisation is needed; duplicate JSON keys are refused anyway. Future context fields (actor, deadline) are covered automatically.
- Audience by construction.
Pandpkare in the key and in the MAC input; the provider requiresaud == RPC_SERVICE_IDandpk∈ its own kids. A request for P never verifies at Q (Q derivesK_req(C→Q) ≠ K_req(C→P)). - The response covers the request MAC. The keyed response binding's MAC
input includes the request's
X-SANDLOOM-RPC-Auth(with the signature headers), so a pair-keyed response verifies only for the exact request it answers — never for another request re-labelled with its nonce. - Order (S5 kept). The provider reads the raw body under the body cap,
verifies the tag over the raw bytes, and only then decodes the context and
the MessagePack body. Comparison is
hmac.compare_digeston the full 32-byte tag; a tag that does not decode to exactly 32 bytes (truncated, padded, non-base64url) is rejected before any comparison.
Replay¶
- Freshness.
|now − iat| ≤ RPC_AUTH_MAX_SKEW_SECONDS(default 60 s, 5…300) in the MAC modes;iatis integer seconds from the caller's clock. The rule is symmetric: a caller up to the skew ahead or behind is accepted; beyond it the call failsrpc_auth_expired(→ 503 at the caller; alert onsandloom_rpc_auth_rejected_total{reason="expired"}— a drifting node). The Ed25519 path keeps its 300 s window. - Nonce replay guard. After the tag verified (only authenticated callers
can insert entries), the provider records
(caller, nonce)until the request could no longer pass the freshness check on its own clock (iat + skew − now + 2s, at most2·skew + 2). A repeat inside that window is401 rpc_auth_replayed. The nonce guard runs only AFTER the caller is authorized (RPC_ALLOWED_CALLS): a key holder without an allowlist entry cannot fill it. - Layer 1, always: a per-process bounded table
(
RPC_REPLAY_CACHE_MAX_ENTRIES, default 200 000 ≈ 2·60 s at 1 600 calls/s per process, ≈ 40 MB worst case), of which one caller may hold at mostRPC_REPLAY_CACHE_PER_CALLER_MAX_ENTRIES(default 100 000 = half the table). Sizing: a nonce lives ≈skew + 2s (worst case2·skew + 2), so a caller needs at least its peak calls/s per provider process ×(2·skew + 2)— the default carries ≈ 830 calls/s per caller per process atskew = 60; alert onsandloom_rpc_auth_rejected_total{reason="caller_quota"}: an allowed peer flooding it (e.g.iat = now + skewat full rate) is refused itself (503, reasoncaller_quota), the other callers are not. When the table is full of live entries the provider fails closed (503 rpc_replay_unavailable) instead of evicting a live nonce; it is never rebuilt on reconfiguration. - Layer 2,
RPC_REPLAY_GUARD=commands(default) orall: an atomicSET NXwith that TTL on the security cache (SECURITY_CACHE_INSTANCE, shared by every replica and worker) for command calls (commands) or every call (all);localdisables it. The shared key is a SHA-256 ofLP(provider, caller, nonce)and lives a constant3·skews — not derived from one replica's clock: a request is accepted by replicas over at most2·skew + dof real time,dbeing the clock difference between two replicas, and the deployment rule (the same one callers meet) isd ≤ skew— a deployment requirement (deploy/README.md), watched bysandloom_rpc_auth_clock_skew_total{caller}(an accepted request off by more than half the window; one log line a minute per caller). A cache outage fails closed in production (503 rpc_replay_unavailable, the caller retries) and open outside it (logged, counted; a startup warning says so).RPC_REPLAY_GUARD=localis refused in production. - Multi-replica limit. With
local(or for reads undercommands) a replay that lands on another worker process or replica within the window is not detected. That is accepted for reads (no effect; an on-path party can read the response anyway without TLS) and is why commands default to the shared layer — on top of the durable command dedupe (contract_command_log), which already turns a replayed command with the same idempotency key into a replay of its stored result. - Transport retry. The proxy's single retry uses a new nonce and a new tag, so it is never mistaken for a replay.
Downgrade¶
- A provider in
service_macaccepts onlyX-SANDLOOM-RPC-Auth. A request with only an Ed25519 signature gets401 rpc_auth_required(a caller still onservice_identity: an operator error worth naming); a request with onlyX-Internal-Token, or with no credential at all, keeps the hidden404ofservice_identity. Ed25519 signatures are never verified in this mode. - In
service_mac_duala presentX-SANDLOOM-RPC-Authis the only credential checked: a bad MAC is401even when a valid Ed25519 signature rides along (no fallback an attacker could steer). A dual caller marks the context it MACs ("mac": 1, inside the signed context); a MAC-mode provider refuses such a context on the Ed25519 path (401 rpc_auth_required), so stripping the MAC cannot skip the nonce guard. Only aservice_identitycaller's request (no flag) uses the Ed25519 path — its context carriesaudwhen the binding is pinned (see below). Aservice_identityprovider (mid-rollout) ignores the flag. - A request carrying any of
X-SANDLOOM-RPC-Auth,X-SANDLOOM-RPC-Nonce,X-SANDLOOM-RPC-Context, the signature headers more than once is refused (401) and its response is never bound: the verifier and the response binder can never read two different nonces. - The caller never falls back: in a MAC mode a call whose pair key cannot be
derived (binding without
service_id, provider without published X25519 key, low-order key) fails503 rpc_auth_unavailable; startup refuses that configuration (below), so this only happens on a live misconfiguration. - Unknown header versions (
v≠1), unknown or duplicated header parameters, and unknown caller / provider kids are401 rpc_auth_invalid. RPC_ALLOWED_CALLSand deny-by-default are unchanged; the caller id the allowlist is consulted with is the verifiedcallerof the MAC header.
Key-compromise impersonation (KCI) and the command signature¶
Static-static ECDH is symmetric: X25519(c, P) = X25519(p, C). Whoever holds
a provider P's X25519 private key — a leaked secret, a stolen config, a
retired key kept in RPC_DH_EXTRA_PRIVATE_KEYS; code execution is not needed
— derives K_req(C→P) for every caller C and can present requests to P
as the most privileged caller (RPC_ALLOWED_CALLS on P is then moot). With
Ed25519, the same leak gave only P's outbound rights. A provider's X25519
key is therefore an "all inbound rights" secret, on top of being its
callers'-response key.
Mitigation (decided):
RPC_COMMANDS_REQUIRE_SIGNATURE(auto= on in prod;on;all;off, refused undermulti_tenant_strict): a command call (any method that is not a declared read, and every not-yet-described method) additionally carries the caller's Ed25519 signature over the same context, target and body (X-SANDLOOM-RPC-Service-Signature, theservice_identityproof). The signed context carries the attempt's nonce (n; the caller re-signs per transport attempt), so the signature binds the nonce the MAC and the replay guard use: a captured signed command cannot be re-MACed under a fresh nonce by a holder of the provider key. The provider verifies it with the caller's PUBLIC key (RPC_SERVICE_KEYS) after the MAC and the allowlist and requires the signer to be the MAC's caller andnto be the request's nonce (401 rpc_auth_signature_required/rpc_auth_invalid). The provider never holds a caller's Ed25519 private key, so a leaked provider X25519 key can forge reads at most, never effects. A caller inservice_macsigns every call that may write whenever it holdsRPC_PRIVATE_KEY; startup refuses a calling service without one while the requirement is on.all: every call is signed and checked — reads become KCI-resistant too, at the Ed25519 price on every call (the ≈ 126 µs this ADR removes).- Residual risk (documented, accepted in the default): with P's X25519
key an attacker can issue reads to P as any caller, naming any tenant
— i.e. read whatever P's read contracts expose. Treat a provider's X25519
key like its database credentials; rotate it at once on suspicion;
deployments that need KCI-resistant reads set
all. - Retired keys widen the window: a kid kept in
RPC_DH_EXTRA_PRIVATE_KEYSbut no longer published is reported at startup (warning; an error undermulti_tenant_strict). The rotation overlap is bounded (below).
Alternatives considered for KCI: an interactive authenticated key exchange
with ephemeral keys on both sides (Noise KK/IK style) resists KCI for every
call but needs per-connection handshakes and session state across a pooled
HTTP client — a protocol of its own, not a MAC; per-caller provider keys
only shrink the blast radius per leaked key, they do not remove it (all of a
provider's keys live in the same secret store); signing every call is the
all option. Signatures on effects only keep the read path cheap and make
the worst case of a provider-key leak "reads", which the decision accepts.
Ed25519 audience (closes review finding 1 for the migration window)¶
In service_identity, dual and service_mac_dual a caller whose binding
pins the provider (service_id) adds aud to its signed context; a provider
that finds aud ≠ its own id answers 401. Old providers ignore the extra
field, old callers send none (accepted) — so it is rolling-upgrade safe.
Key rotation¶
RPC_SERVICE_DH_KEYS[svc]may list several kids. Select the addressed kid explicitly withRPC_SERVICE_DH_CURRENT_KIDS={"<svc>": "<kid>"}; JSON object insertion order is not a rotation protocol. Existing configurations without an explicit selection use the lexicographically greatest published kid, which is deterministic under JSON reordering but does not infer which arbitrary kid is newest. Set the explicit map before rotating.- A provider accepts requests addressed to its current kid
(
RPC_DH_KEY_ID) and to any kid inRPC_DH_EXTRA_PRIVATE_KEYS({"<kid>": "<PEM>"}, SECRETS_DIR filerpc_dh_extra_private_keys). - Rotating provider P (no failed call at any step): 1) pin the existing kid1
in
RPC_SERVICE_DH_CURRENT_KIDS[P], generate kid2, add its private key to P'sRPC_DH_EXTRA_PRIVATE_KEYS; 2) publish kid2 inRPC_SERVICE_DH_KEYS[P]everywhere, then setRPC_SERVICE_DH_CURRENT_KIDS[P]=kid2(callers start addressing kid2; P accepts both); 3) make kid2 P'sRPC_DH_PRIVATE_KEY/RPC_DH_KEY_IDand move kid1 to the extras; 4) remove kid1 fromRPC_SERVICE_DH_KEYSeverywhere; 5) afterRPC_AUTH_MAX_SKEW_SECONDS, drop kid1 from P. A caller's own rotation needs only steps 2–5 (its kid travels in the header). - Bounded overlap. Steps 1–5 take one rolling deploy each; the whole
rotation should complete within 24 h, and step 5 must follow step 4 within
one deploy: a kid still in
RPC_DH_EXTRA_PRIVATE_KEYSafter it leftRPC_SERVICE_DH_KEYSkeeps deriving every caller's key towards P (KCI, above) and is reported at every start (multi_tenant_strict: refused).sandloom keys generate --purpose rpc-dh --service <id>emits one X25519 pair. - Startup checks (
Settingsvalidation, every MAC mode): own X25519 key present and its published half matches; every extra key parses, has a distinct kid, and (if published) matches; every binding without its owninternal_tokenis pinned (service_id) to a service with published X25519 keys and each pair key derives (rejects low-order / all-zero points at startup); every explicit current kid is published and a selected local kid has its private key installed; no two services publish the same X25519 public key; the X25519 private key is not the Ed25519 signing seed; every published key is an X25519 key (the existing parse).service_mac_dualadditionally needs the Ed25519 identity ofservice_identity.
Errors¶
- Rejection log lines are rate-limited (one per reason and caller per minute); the counter keeps the exact rate.
- Provider:
401 rpc_auth_invalidfor every cryptographic / parse failure (malformed header, unknown kid, wrong audience, wrong key, bad tag, unknown version) — an unauthenticated party learns nothing about which check failed;401 rpc_auth_expired/rpc_auth_replayedare only reachable after the tag verified;401 rpc_auth_required(downgrade);403 rpc_caller_forbidden;503 rpc_replay_unavailable. The detailed reason is a fixed vocabulary insandloom_rpc_auth_rejected_total{reason}and a log line with ids and kids only — never a key, tag, or derived secret. - Caller: every auth code (
rpc_auth_*,rpc_identity_*,rpc_caller_forbidden,rpc_replay_unavailable) maps to503 RemoteServiceUnavailablewith its code (review finding 2); a bare 401/403 without an envelope already did.
Threat model¶
Assets: the right to invoke contract methods of a provider as a given caller service (and to name a tenant in its context); integrity of arguments and results. Out of scope: confidentiality (TLS/mTLS), compromise of the provider process itself (it runs the contract), denial of service by an on-path party.
| Attacker | service_identity (Ed25519) |
service_mac (pair MAC) |
|---|---|---|
| Network attacker inside the cluster (reads, injects, replays; holds no key) | cannot forge; can replay a captured request to another service serving the same contract within 300 s, and to the same provider within 300 s (no nonce cache) | cannot forge (no K_req); replay to another service fails (audience in key + MAC); replay to the same provider fails (nonce guard: per process, plus the shared layer for commands; reads across replicas — see "Replay") within a 60 s window |
| Compromised peer service Q (holds Q's keys) | can call what RPC_ALLOWED_CALLS[Q] allows; cannot sign as C |
same; can derive only pair keys that involve Q: it can forge requests Q→P (allowed anyway) and, as a provider, "requests from C" only to itself (K_req(C→Q)), i.e. nothing it could not do by running its own code; cannot derive K_req(C→P) for any other P (needs c or p) |
| Compromise of a static X25519 private key of a caller C (leaked config / secret store) | — (the DH key only keyed responses) | new exposure: the holder can derive K_req(C→P) for every P and forge C's reads to all its peers (until C rotates); C's commands still need C's Ed25519 key |
| Compromise of a static X25519 private key of a provider P (KCI) | P's outbound rights only | new exposure: the holder derives K_req(C→P) for every caller C and can issue reads to P as any caller and tenant; commands are refused without the caller's Ed25519 signature (RPC_COMMANDS_REQUIRE_SIGNATURE, on in prod; all also protects reads). P's X25519 key = "all inbound read rights" |
Leaked public config (RPC_SERVICE_DH_KEYS, allowlist) |
nothing secret | nothing secret: public keys and ids only; kids are not secrets |
Leaked INTERNAL_API_TOKEN |
opens ops endpoints, not contracts | same; in service_mac the token never authenticates /_sandloom/rpc |
| Replay | 300 s window, no nonce cache | 60 s window (configurable), nonce guard, fresh nonce + tag per attempt |
| Downgrade (strip MAC / present Ed25519 or token) | n/a | service_mac: refused (rpc_auth_required); service_mac_dual: MAC present → MAC only; MAC stripped → Ed25519 (migration window only; audience-bound when pinned); callers never fall back |
| Key confusion (sign key as DH key, response key as request key, direction swap, caller/provider id swap) | n/a | separate settings and parsers per key type; seed reuse refused at startup; distinct HKDF salt + info per purpose and direction; ids and kids in role order inside info and MAC input; tests: swapped ids, reversed direction, response key as request key all fail |
| Low-order / malicious public key in config | n/a | cryptography refuses the exchange; all-zero Z refused explicitly; every configured pair derived at startup |
What changes for the worse, stated plainly:
- No third-party non-repudiation. An Ed25519 signature proves to anyone holding C's public key that C produced the request; a MAC proves it only to P (both C and P can compute it). Nothing in Sandloom uses requests as evidence for a third party (audit records are written by the provider about an authenticated caller), so this property is not relied upon.
- A provider can forge requests from a caller — to itself only. P can
compute
K_req(C→P), so P could fabricate "C called me". P already executes whatever it likes; it cannot use the key towards any other service. - The X25519 key becomes a signing-grade secret (rows "Compromise of a static X25519 private key" above): before, its leak allowed forging responses of that service; now it also allows forging that service's read requests to every peer and — for a provider — any caller's read requests to it (KCI). Rotation is per service and needs no other service to change keys.
- Key-compromise impersonation of reads (section above): accepted in
the default; commands are protected by the caller's signature, and
RPC_COMMANDS_REQUIRE_SIGNATURE=allremoves it at the Ed25519 price.
What improves:
- Audience binding by construction (review finding 1 closed in
service_mac; in the Ed25519 modes for pinned bindings). - Replay protection beyond a time window (nonce guard).
- Cost. Per read call ≈ 126 µs (sign + verify) → ≈ 11 µs on both sides together, replay guard and strict context decode included; a command keeps the Ed25519 cost (+11 % for the MAC) — see "Measurements".
- Error mapping (review finding 2 closed in every mode).
Measurements¶
benchmarks/native/bench_rpc_auth.py — one call's authentication work per
side, through the real Sandloom functions (context built outside the op in both
modes, ~100-byte msgpack body). Wall clock, CPython 3.12.10, arm64, cases
interleaved round-robin, n = 5000 × 9 reps, median (min);
benchmarks/native/results/rpc_auth_wall_2026-10-21.txt:
| side | Ed25519 (service_identity) |
pair MAC (service_mac) |
|
|---|---|---|---|
| caller: authenticate one request | 30.9 µs (rpc_identity.sign) |
3.1 µs (key lookup + encoding + HMAC + header) | 10× |
| provider: verify one request | 95.1 µs (rpc_identity.verify + context decode) |
7.7 µs (parse + key lookup + HMAC + compare + strict context decode + window + replay guard) | 12× |
| provider: tag verification alone | — | 3.8 µs | |
| per call, both sides | ≈ 126 µs | ≈ 10.7 µs | −115 µs |
Where the remaining µs go (cProfile): the strict context decode (duplicate-
key check, 2.6 µs), header regex, key-cache lookups; the HMAC itself is ≈ 1 µs
(the pair's fixed MAC-input prefix is absorbed into the prepared HMAC
context once per key). The shared nonce layer adds one security-cache SET NX
round trip for commands only (RPC_REPLAY_GUARD=commands), which already
pay a database transaction.
Cachegrind (instruction counts — the stand's evidence, insensitive to the
shared machine's load): CG_K=1000 benchmarks/native/cg_micro.sh
bench_rpc_auth.py, CPython 3.13.16 in the ladder's valgrind image,
Ir/op = (Ir(K) − Ir(0)) / K; benchmarks/native/results/cg_rpc_auth.json:
| case | Ed25519 (service_identity) |
pair MAC, read | pair MAC, command (MAC + Ed25519, KCI mitigation) |
|---|---|---|---|
| caller | 250 149 Ir | 45 157 Ir | 295 837 Ir |
| provider (full check) | 824 505 Ir | 121 612 Ir | 896 281 Ir |
| provider, tag only | — | 60 001 Ir | — |
| per call, both sides | 1 074 654 Ir | 166 769 Ir (6.4×, −908 k) | 1 192 118 Ir (+11 % vs Ed25519) |
Reads — the hot path (membership, claims, facts) — get the full saving.
A command pays the Ed25519 price plus the MAC (+118 k Ir, ≈ +15 µs wall:
mac.cmd.caller 47 µs / mac.cmd.provider 127 µs on a loaded machine,
results/rpc_auth_wall_2026-10-21.txt), on top of the database transaction
every command already runs — the price of "a leaked provider key never
forges an effect". RPC_COMMANDS_REQUIRE_SIGNATURE=all makes every call
cost the command column.
The provider's Ed25519 verify was ≈ 28 % of the provider side of an RPC
call (R3t.srv 2.51 M Ir in the research stand); a MAC-only read leaves
≈ 7 %.
Consequences¶
- Split deployments move to
RPC_AUTH_MODE=service_mac. Calling services keep their Ed25519 key (RPC_PRIVATE_KEY, public halves inRPC_SERVICE_KEYS) — it now signs only their commands. A pure provider needs only its X25519 key. Migration fromservice_identity: deploy this version everywhere (stillservice_identity; pinned callers now sendaud) → every service holds its X25519 key, every binding pinned →service_mac_dualeverywhere →service_maceverywhere. The first step is a hard precondition: a MAC-mode caller's keyed responses coverX-SANDLOOM-RPC-Auth, which a provider of an older version does not know (its keyed responses would failmac_mismatch). The shipped microservices profile and the split conformance matrix runservice_mac. RPC_RESPONSE_BINDINGis unchanged and still covers responses (removed 2026-10-25: responses are always keyed — see the amendment).- The monolith is unaffected (contract calls are direct Python calls).
Alternatives considered¶
- Rust Ed25519 verifier (
ed25519-dalek): 2.1× wall, keeps non-repudiation, adds a native crypto dependency and keeps ~40 µs/call — rejected (research R2). - mTLS only: authenticates the peer, but not the request at the application layer (a sidecar or gateway terminating TLS erases it) and gives no per-contract authorization input — complementary, not a replacement.
- One shared HMAC key (the
shared_tokenmodel): any holder can impersonate every service — the propertyservice_identitywas introduced to remove. - Ephemeral DH per connection / Noise: forward secrecy for a protocol that does not encrypt; much more code and state for no gain here.
Security review¶
An adversarial review (a separate agent whose only brief was to break the
design and the code, 2026-10-21) produced eight findings; five were
confirmed with proof-of-concept tests. All are fixed, each with a regression
test (test_rpc_pair_mac.py::test_r1…r8), and the original PoCs now fail.
| # | Severity | Finding | Fix |
|---|---|---|---|
| 1 | high (confirmed) | Response substitution in service_mac + strict: an on-path party drops request R1, then the victim's R2, and replays R1 with a SECOND X-SANDLOOM-RPC-Nonce (R2's). The fast-path binder read the last nonce, the verifier the first; the v2 response MAC covered only the Ed25519/context signatures (absent in service_mac), so the caller accepted R1's result as R2's |
the response MAC input covers X-SANDLOOM-RPC-Auth; duplicated auth/nonce/context/signature headers are refused and never bound |
| 2 | medium (confirmed) | service_mac_dual: stripping the MAC fell back to Ed25519 — no nonce guard, 300 s window — for every request of the migration window |
signed "mac": 1 context flag; MAC-mode providers refuse a flagged context without its MAC |
| 3 | medium (confirmed) | A peer with a published X25519 key but no allowlist entry could fill the replay table (and the shared cache) and make the provider fail closed for everyone | authorize right after the tag, before freshness and the nonce guard |
| 4 | low | The shared-layer TTL was computed from one replica's clock | constant (now 3·skew, second review) |
| 5 | low (confirmed) | iat = 10**400 (authenticated caller) raised OverflowError → 500 |
range-check iat before arithmetic → rpc_auth_expired |
| 6 | low | Unauthenticated junk produced one WARNING per request (log flooding) | rate-limited rejection logs |
| 7 | low (confirmed) | Shared replay keys collided for ids containing : |
key = SHA-256 of the length-prefixed fields |
| 8 | info | Guard rebuilt (state lost) when two configs differed in size; ~100 MB default; ADR TTL wording | guard never rebuilt; default 200 000; wording fixed |
Claims the review could not break: forgery without K_req (swapped ids,
reversed direction, response key as request key, self-pairs, a compromised
third service); injectivity of the encoding and the single canonical header
form; nothing decoded before the tag verifies; audience binding (also the
Ed25519 aud); downgrade refusal in service_mac and no fallback once a MAC
is present; low-order / all-zero points refused at startup and verification;
cache keys follow key rotation; replay-guard window arithmetic; 503 mapping
at the caller.
Remaining, accepted: the endpoint answers 401 rpc_auth_invalid (not the
hidden 404) to a malformed X-SANDLOOM-RPC-Auth, revealing the MAC mode to a
party that can already reach the internal network; reads replayed to a
different replica / worker within the window are served under
RPC_REPLAY_GUARD=commands (no effect, and the response is bound to the
original request, so it cannot be substituted for another call).
Second review (independent, 2026-10-21)¶
A second adversarial review by a reviewer who had not seen the design found
no exploitable cryptographic or verification flaw, and five items, all fixed
(tests in test_rpc_pair_mac.py, section "second review"):
| # | Severity | Finding | Fix |
|---|---|---|---|
| 1 | medium (confirmed with a script) | Key-compromise impersonation: the holder of a provider's X25519 private key derives every caller's pair key and can forge requests to it as the most privileged caller; the threat model only covered caller-key leaks | "Key-compromise impersonation" section; commands also need the caller's Ed25519 signature (RPC_COMMANDS_REQUIRE_SIGNATURE, on in prod, off refused under multi_tenant_strict, all for reads); stale extra kids warned / refused; bounded rotation overlap; tests pin that a forged read passes and a forged command is refused with no effect |
| 2 | low | Shared TTL 2·skew + 2 only holds for replica clocks within 2 s |
3·skew (replica drift ≤ skew), docstring + test |
| 3 | low | RPC_REPLAY_GUARD=local allowed in prod; the fail-open shared layer was silent outside prod |
refused in prod; startup warning outside prod |
| 4 | low | One allowed peer could fill the 200 k process table (→ 503 for everyone) | per-caller quota (RPC_REPLAY_CACHE_PER_CALLER_MAX_ENTRIES) |
| 5 | tests | Missing: KCI, X-Tenant-Id on the MAC path, an old provider kid from the extras after unpublishing, replica clock drift, HEAD on the status route |
added; HEAD on /_sandloom/rpc/<contract> is now 405 (it was served under a GET MAC) |
Third review (re-review of the KCI fix, 2026-10-21)¶
Verdict "safe to merge": the KCI mitigation holds (no bypass through a missing / duplicated signature, a foreign signer, method-kind confusion or a mode switch). Three low findings, fixed:
| # | Finding | Fix |
|---|---|---|
| 1 | The command signature did not cover the nonce: with a leaked provider key, a captured genuine signed command could be re-MACed under fresh nonces and pass authentication repeatedly in the window (only the durable command dedupe prevented a second effect) | the signed context carries the attempt's nonce (n), re-signed per attempt (the transport retry stays safe); the provider requires n = the MAC nonce. Tests: the exact re-MAC scenario (refused, no effect), a command retried after a lost answer (accepted, executed once). A per-call guard key was rejected: it would turn the safe retry of a command into a false replay |
| 2 | The per-caller quota (50 000) counted reads too: ≈ 800 calls/s per caller per process → 503 under normal load |
default = half the table (100 000), sizing formula, alert hint |
| 3 | The shared TTL (3·skew) relies on replica drift ≤ skew, unchecked |
explicit requirement in deploy/README.md; sandloom_rpc_auth_clock_skew_total{caller} + a rate-limited log for accepted requests off by more than half the window |
Follow-ups¶
RPC_RESPONSE_BINDING=strictstill acceptsalg=tokenresponse bindings for token-authenticated calls (shared_token,dual, a binding with its owninternal_token) — inherent to the token model; drop it oncedualis retired. Done 2026-10-25 (amendment): the token modes andalg=tokenbindings are gone.
Amendment (2026-10-25)¶
Sandloom carries no backward compatibility before its first release, so the migration path above is removed and the target state is the only state:
RPC_AUTH_MODE(shared_token,dual,service_identity,service_mac_dual,service_mac) andRPC_RESPONSE_BINDING(permissive,strict) are removed settings (sandloom.core.config.tiers.REMOVED): startup refuses them unless set to their inert value (service_mac/strict).- The pair MAC is the only internal-RPC request authentication (key from
RPC_DH_PRIVATE_KEY/RPC_SERVICE_DH_KEYS, audience-bound, nonce replay guardRPC_REPLAY_GUARD, windowRPC_AUTH_MAX_SKEW_SECONDS), plus the caller's Ed25519 signature on command calls (RPC_PRIVATE_KEY/RPC_KEY_ID/RPC_SERVICE_KEYS;RPC_COMMANDS_REQUIRE_SIGNATURE:auto= on in prod, off in dev/test;allsigns reads too) and the deny-by-default allowlistRPC_ALLOWED_CALLS. Ed25519-only and token requests are refused;INTERNAL_API_TOKENguards only the ops endpoints and never authenticates/_sandloom/rpc(nor doesINTERNAL_ENDPOINTS_OPEN_IN_DEVopen it). - Every
SERVICE_BINDINGSentry must pin"service_id"; a process with bindings must hold its ownRPC_DH_PRIVATE_KEY(startup refuses otherwise); a provider without one refuses every RPC call; a monolith (no bindings) needs no RPC keys. - Responses are always keyed (v2 binding, X25519 pair key, pinned provider):
unbound, unkeyed (v1) and
alg=tokenreplies are never accepted. An unkeyed error answer — the provider could not authenticate the request — surfaces at the caller as503 RemoteServiceUnavailablecarrying the provider's error code.