Skip to content

Keeper HMAC not bound to method/path and has no nonce (replay within the 5-minute window) #2476

Description

@0x-SquidSol

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions