Summary
The keeper-register stateless deployer proof authorizes the slab but not the
pool it registers. The signed message binds only (slabAddress, unix-minute),
so a single valid signature authorizes registering that slab against any pool
address — including one substituted by a replay of a captured signature.
Affected code
app/app/api/playground/keeper-register/route.ts
// :134-136 — the signed message
function statelessProofMessage(slabAddress: string, unixMinute: number): Uint8Array {
return new TextEncoder().encode(`${STATELESS_PROOF_PREFIX}:${slabAddress}:${unixMinute}`);
}
// :142-157 — verification takes only (slab, deployerPubkey, signature)
function verifyStatelessDeployerProof(slabAddress, deployerPubkeyBytes, signatureBytes) { ... }
The caller-supplied dexPoolAddress — which is what the keeper (and the display
feed via dex_pool_address) is pointed at, i.e. the pool the AuthMark settlement
price is derived from — is not part of the signed material. Neither is
mainnetCA or the rest of the payload.
Impact
Within the proof's validity window, a captured deployer signature can be
replayed with a substituted dexPoolAddress, repointing the market at an
attacker-chosen (but real, existence-checked) pool and thereby influencing the
settlement price the market resolves against.
Scope is bounded and worth stating plainly:
- The signature still proves control of
deployer, and the route independently
requires deployer to equal the slab's live on-chain marketauth. So this is
not "anyone can repoint any market."
- Once
percolator-stake's InitPool rotates marketauth to a PDA, no
signature passes again — the realistic exposure is the pre-InitPool window
for a freshly created market, or the admin-bypass path.
- Replay requires observing the deployer's signature, which travels over HTTPS.
It is therefore best characterised as a signature-hygiene weakness to harden for
v2: a proof should bind every security-relevant parameter it authorizes (the
pool), not just the slab. (For contrast, PR #2387 added payload binding on the
POST /api/markets path, which the launch flow no longer uses; the live
keeper-register proof was not covered.)
Proof of concept
Reproduces the message + verification verbatim and shows one signature verifies
regardless of the submitted pool, with a control asserting the slab is bound:
import nacl from "tweetnacl";
const PREFIX = "keeper-register";
// Buffer→Uint8Array (not TextEncoder) to avoid a jsdom realm mismatch with tweetnacl.
const msg = (slab: string, m: number) =>
new Uint8Array(Buffer.from(`${PREFIX}:${slab}:${m}`, "utf-8"));
const verify = (slab: string, pub: Uint8Array, sig: Uint8Array) => {
const now = Math.floor(Date.now() / 60_000);
for (let d = -5; d <= 1; d++)
try { if (nacl.sign.detached.verify(msg(slab, now + d), sig, pub)) return true; } catch {}
return false;
};
const deployer = nacl.sign.keyPair();
const slab = "Fg6ptQ111111111111111111111111111111111111";
const minute = Math.floor(Date.now() / 60_000);
const sig = nacl.sign.detached(msg(slab, minute), deployer.secretKey);
// signature verifies, and the signed string references no pool at all:
verify(slab, deployer.publicKey, sig); // true (any submitted pool is accepted)
`${PREFIX}:${slab}:${minute}`.includes("<any pool>"); // false — pool is unauthenticated
// control: the slab IS bound, confirming only the pool binding is missing
const s2 = nacl.sign.detached(msg("SLAB_ONE", minute), deployer.secretKey);
verify("SLAB_TWO", deployer.publicKey, s2); // false
verify("SLAB_ONE", deployer.publicKey, s2); // true
Suggested fix (rollout is a maintainer decision)
Bind the pool (and ideally a hash of the full registration payload) into the signed
message: keeper-register:<slab>:<minute>:<dexPoolAddress>, verified server-side.
This is a coordinated client + server change — the client signs the message in
app/hooks/useCreateMarket.ts (~L469) — so a naive cutover would make
already-deployed clients fail to register. Two rollout options, left for
maintainers to choose:
- Dual-accept, then deprecate — server accepts both the old
(slab:minute) and new (slab:minute:pool) messages for a transition window;
client emits the new format; drop the old format in a later release. No
registration breakage.
- Hard cutover — server requires the pool-bound message; client updated in
the same release. Strongest binding immediately, but clients on an older bundle
fail to register until they reload.
No fix PR is attached intentionally, so the rollout approach can be decided here.
Summary
The
keeper-registerstateless deployer proof authorizes the slab but not thepool it registers. The signed message binds only
(slabAddress, unix-minute),so a single valid signature authorizes registering that slab against any pool
address — including one substituted by a replay of a captured signature.
Affected code
app/app/api/playground/keeper-register/route.tsThe caller-supplied
dexPoolAddress— which is what the keeper (and the displayfeed via
dex_pool_address) is pointed at, i.e. the pool the AuthMark settlementprice is derived from — is not part of the signed material. Neither is
mainnetCAor the rest of thepayload.Impact
Within the proof's validity window, a captured deployer signature can be
replayed with a substituted
dexPoolAddress, repointing the market at anattacker-chosen (but real, existence-checked) pool and thereby influencing the
settlement price the market resolves against.
Scope is bounded and worth stating plainly:
deployer, and the route independentlyrequires
deployerto equal the slab's live on-chainmarketauth. So this isnot "anyone can repoint any market."
percolator-stake'sInitPoolrotatesmarketauthto a PDA, nosignature passes again — the realistic exposure is the pre-
InitPoolwindowfor a freshly created market, or the admin-bypass path.
It is therefore best characterised as a signature-hygiene weakness to harden for
v2: a proof should bind every security-relevant parameter it authorizes (the
pool), not just the slab. (For contrast, PR #2387 added payload binding on the
POST /api/marketspath, which the launch flow no longer uses; the livekeeper-registerproof was not covered.)Proof of concept
Reproduces the message + verification verbatim and shows one signature verifies
regardless of the submitted pool, with a control asserting the slab is bound:
Suggested fix (rollout is a maintainer decision)
Bind the pool (and ideally a hash of the full registration payload) into the signed
message:
keeper-register:<slab>:<minute>:<dexPoolAddress>, verified server-side.This is a coordinated client + server change — the client signs the message in
app/hooks/useCreateMarket.ts(~L469) — so a naive cutover would makealready-deployed clients fail to register. Two rollout options, left for
maintainers to choose:
(
slab:minute) and new (slab:minute:pool) messages for a transition window;client emits the new format; drop the old format in a later release. No
registration breakage.
the same release. Strongest binding immediately, but clients on an older bundle
fail to register until they reload.
No fix PR is attached intentionally, so the rollout approach can be decided here.