# 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_MODE` and `RPC_RESPONSE_BINDING` are 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:

1. **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.
2. **Identity failures surface as 401/403 to end users.** `rpc_identity_*` and `rpc_caller_forbidden` were not in the caller's "unavailable" code table, so a provider's 401/403 became a `ContractCallError` with the same status — a deployment misconfiguration answered the end user "unauthorized".
3. `RPC_RESPONSE_BINDING=strict` accepts `alg=token` bindings without a pinned `service_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)) == x` and 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.** `P` and `pk` are in the key *and* in the MAC input; the provider requires `aud == RPC_SERVICE_ID` and `pk` ∈ its own kids. A request for P never verifies at Q (Q derives `K_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_digest` on 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; `iat` is 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 fails `rpc_auth_expired` (→ 503 at the caller; alert on `sandloom_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 + 2` s, at most `2·skew + 2`). A repeat inside that window is `401 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 most `RPC_REPLAY_CACHE_PER_CALLER_MAX_ENTRIES` (default 100 000 = half the table). Sizing: a nonce lives ≈ `skew + 2` s (worst case `2·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 at `skew = 60`; alert on `sandloom_rpc_auth_rejected_total{reason="caller_quota"}`: an allowed peer flooding it (e.g. `iat = now + skew` at full rate) is refused itself (`503`, reason `caller_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) or `all`: an atomic `SET NX` with that TTL on the **security cache** (`SECURITY_CACHE_INSTANCE`, shared by every replica and worker) for command calls (`commands`) or every call (`all`); `local` disables it. The shared key is a SHA-256 of `LP(provider, caller, nonce)` and lives a constant `3·skew` s — not derived from one replica's clock: a request is accepted by replicas over at most `2·skew + d` of real time, `d` being the clock difference between two replicas, and the deployment rule (the same one callers meet) is `d ≤ skew` — a deployment requirement (`deploy/README.md`), watched by `sandloom_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=local` is refused in production.
- **Multi-replica limit.** With `local` (or for reads under `commands`) 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_mac` accepts only `X-SANDLOOM-RPC-Auth`. A request with only an Ed25519 signature gets `401 rpc_auth_required` (a caller still on `service_identity`: an operator error worth naming); a request with only `X-Internal-Token`, or with no credential at all, keeps the hidden `404` of `service_identity`. Ed25519 signatures are never verified in this mode.
- In `service_mac_dual` a present `X-SANDLOOM-RPC-Auth` is the **only** credential checked: a bad MAC is `401` even 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 a `service_identity` caller's request (no flag) uses the Ed25519 path — its context carries `aud` when the binding is pinned (see below). A `service_identity` provider (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) fails `503 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 are `401 rpc_auth_invalid`.
- `RPC_ALLOWED_CALLS` and deny-by-default are unchanged; the caller id the allowlist is consulted with is the verified `caller` of 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 under `multi_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`, the `service_identity` proof). 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 and `n` to 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 in `service_mac` signs every call that may write whenever it holds `RPC_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_KEYS` but no longer published is reported at startup (warning; an error under `multi_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 with `RPC_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 in `RPC_DH_EXTRA_PRIVATE_KEYS` (`{"<kid>": "<PEM>"}`, SECRETS\_DIR file `rpc_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's `RPC_DH_EXTRA_PRIVATE_KEYS`; 2) publish kid2 in `RPC_SERVICE_DH_KEYS[P]` everywhere, then set `RPC_SERVICE_DH_CURRENT_KIDS[P]=kid2` (callers start addressing kid2; P accepts both); 3) make kid2 P's `RPC_DH_PRIVATE_KEY`/`RPC_DH_KEY_ID` and move kid1 to the extras; 4) remove kid1 from `RPC_SERVICE_DH_KEYS` everywhere; 5) after `RPC_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_KEYS` after it left `RPC_SERVICE_DH_KEYS` keeps 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** (`Settings` validation, 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 own `internal_token` is 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_dual` additionally needs the Ed25519 identity of `service_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_invalid` for 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_replayed` are 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 in `sandloom_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 to `503   RemoteServiceUnavailable` with 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:

1. **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.
2. **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.
3. **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.
4. **Key-compromise impersonation of reads** (section above): accepted in the default; commands are protected by the caller's signature, and `RPC_COMMANDS_REQUIRE_SIGNATURE=all` removes it at the Ed25519 price.

What improves:

1. **Audience binding by construction** (review finding 1 closed in `service_mac`; in the Ed25519 modes for pinned bindings).
2. **Replay protection** beyond a time window (nonce guard).
3. **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".
4. **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 in `RPC_SERVICE_KEYS`) — it now signs only their commands. A pure provider needs only its X25519 key. Migration from `service_identity`: deploy this version everywhere (still `service_identity`; pinned callers now send `aud`) → every service holds its X25519 key, every binding pinned → `service_mac_dual` everywhere → `service_mac` everywhere. The first step is a hard precondition: a MAC-mode caller's keyed responses cover `X-SANDLOOM-RPC-Auth`, which a provider of an older version does not know (its keyed responses would fail `mac_mismatch`). The shipped microservices profile and the split conformance matrix run `service_mac`.
- `RPC_RESPONSE_BINDING` is 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_token` model): any holder can impersonate every service — the property `service_identity` was 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=strict` still accepts `alg=token` response bindings for token-authenticated calls (`shared_token`, `dual`, a binding with its own `internal_token`) — inherent to the token model; drop it once `dual` is retired. Done 2026-10-25 (amendment): the token modes and `alg=token` bindings 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`) and `RPC_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 guard `RPC_REPLAY_GUARD`, window `RPC_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; `all` signs reads too) and the deny-by-default allowlist `RPC_ALLOWED_CALLS`. Ed25519-only and token requests are refused; `INTERNAL_API_TOKEN` guards only the ops endpoints and never authenticates `/_sandloom/rpc` (nor does `INTERNAL_ENDPOINTS_OPEN_IN_DEV` open it).
- Every `SERVICE_BINDINGS` entry must pin `"service_id"`; a process with bindings must hold its own `RPC_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=token` replies are never accepted. An unkeyed error answer — the provider could not authenticate the request — surfaces at the caller as `503 RemoteServiceUnavailable` carrying the provider's error code.

## Related reading

- [Service RPC guide](<https://sandloom.dev/docs/operate/service-rpc/index.md>)
- [Security model](<https://sandloom.dev/docs/reference/security-model/index.md>)
