Summary
The keeper request HMAC (app/lib/keeper-hmac.ts) signs only
HMAC-SHA256("<timestamp>.<rawBody>", secret). It does not bind the HTTP method
or path, carries no per-request nonce (a captured signature is replayable within
the 5-minute window), and the same KEEPER_REGISTER_SECRET is used to sign
outbound and verify inbound.
Affected code
// signed string — no method, no path, no nonce:
createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const MAX_SIGNATURE_AGE_MS = 5 * 60_000; // replay window; no nonce store
Hops that share this helper + secret:
| From (sign) |
To (verify) |
Scope |
markets/route.ts |
oracle-keeper/register |
internal (this repo) |
oracle-keeper/register |
external keeper service |
cross-service |
| keeper service |
markets/[slab] PATCH |
cross-service |
Impact
- Replay — a captured
(timestamp, body, signature) is accepted repeatedly for
up to 5 minutes (no nonce / one-time use).
- No method/path binding — a signature for one endpoint's body verifies at any
endpoint that accepts the same body. /api/oracle-keeper/register and the
markets/[slab] PATCH both consume a {slabAddress, mainnetCA}-shaped body, so
a signature observed on one hop is a valid credential on the other within the
window.
- One secret both directions — a signature observed inbound is reusable
outbound and vice-versa.
Exploitability is bounded: KEEPER_INTERNAL_URL is validated https/loopback, so
signatures travel encrypted and capture is required. This is a signature-hygiene
weakness to harden for v2, not a wide-open hole.
Proof of concept
Uses the real signKeeperRequest / verifyKeeperSignature:
import { signKeeperRequest, verifyKeeperSignature } from "@/lib/keeper-hmac";
const s = "secret";
const body = JSON.stringify({ slabAddress: "SLAB", mainnetCA: "CA" });
const { timestamp, signature } = signKeeperRequest(s, body);
verifyKeeperSignature(s, timestamp, body, signature); // true
verifyKeeperSignature(s, timestamp, body, signature); // true again — replay, no nonce
// same (timestamp, body, signature) is valid at ANY endpoint receiving this body
// controls (these hold):
const tampered = JSON.stringify({ slabAddress: "SLAB", mainnetCA: "ATTACKER" });
verifyKeeperSignature(s, timestamp, tampered, signature); // false — body IS bound
verifyKeeperSignature(s, String(Date.now() - 6*60_000), body, "…"); // false — window works
Run:
cd app && npx vitest run __tests__/api/keeper-hmac-binding.test.ts
Suggested fix (coordinated with the keeper service)
Bind method + path + a per-request nonce into the signed string, e.g.
HMAC("<method>\n<path>\n<timestamp>\n<nonce>\n<rawBody>", secret)
and reject already-seen nonces via a short-TTL store on the verify side.
Optionally use distinct secrets per direction (inbound-verify vs outbound-sign).
Because lib/keeper-hmac.ts is shared by hops that cross to the external,
maintainer-run keeper service (percolator-oracle-keeper), the format change must
land in lockstep across the app and that service. No fix PR is attached so the
rollout can be coordinated — e.g. accept both the old and new formats during a
transition window, then drop the old one (the same dual-accept approach used for
other auth-format migrations).
Summary
The keeper request HMAC (
app/lib/keeper-hmac.ts) signs onlyHMAC-SHA256("<timestamp>.<rawBody>", secret). It does not bind the HTTP methodor path, carries no per-request nonce (a captured signature is replayable within
the 5-minute window), and the same
KEEPER_REGISTER_SECRETis used to signoutbound and verify inbound.
Affected code
Hops that share this helper + secret:
markets/route.tsoracle-keeper/registeroracle-keeper/registermarkets/[slab]PATCHImpact
(timestamp, body, signature)is accepted repeatedly forup to 5 minutes (no nonce / one-time use).
endpoint that accepts the same body.
/api/oracle-keeper/registerand themarkets/[slab]PATCH both consume a{slabAddress, mainnetCA}-shaped body, soa signature observed on one hop is a valid credential on the other within the
window.
outbound and vice-versa.
Exploitability is bounded:
KEEPER_INTERNAL_URLis validatedhttps/loopback, sosignatures travel encrypted and capture is required. This is a signature-hygiene
weakness to harden for v2, not a wide-open hole.
Proof of concept
Uses the real
signKeeperRequest/verifyKeeperSignature:Run:
Suggested fix (coordinated with the keeper service)
Bind method + path + a per-request nonce into the signed string, e.g.
and reject already-seen nonces via a short-TTL store on the verify side.
Optionally use distinct secrets per direction (inbound-verify vs outbound-sign).
Because
lib/keeper-hmac.tsis shared by hops that cross to the external,maintainer-run keeper service (
percolator-oracle-keeper), the format change mustland in lockstep across the app and that service. No fix PR is attached so the
rollout can be coordinated — e.g. accept both the old and new formats during a
transition window, then drop the old one (the same dual-accept approach used for
other auth-format migrations).