ownPublicKey() Can’t Be Trusted for Access ControlIf you use ownPublicKey() as an authorization check in a Midnight Compact contract, you are not proving “the caller controls this public key.” You are only proving “the prover supplied this public key as a private circuit input.”
That distinction breaks access control.
In a zero-knowledge circuit, ownPublicKey() compiles to an unconstrained private_input. A malicious prover can choose any value for it. If your contract stores the owner’s public key on the ledger and checks ownPublicKey() == owner, an attacker can:
CircuitContext that injects that key as the value of ownPublicKey().This is not a bug in one contract. It is a trust-model error. The same reasoning also applies to patterns derived from OpenZeppelin Compact Contracts, including Ownable.compact, if ownership is enforced through ownPublicKey().
The safe pattern is different:
persistentHash(secret) == storedCommitment.Witnesses are also untrusted inputs, but that is okay here: the circuit constrains the witness output against a public commitment. Without the secret preimage, the attacker cannot satisfy the equality.
This tutorial explains why the vulnerability exists, walks through the 4-step attack, shows a proof-of-concept model and an example-bboard integration approach, and then replaces the vulnerable pattern with a commitment-based design.
The key to this entire issue is Midnight’s separation between:
Compact compiles contract circuits into proving artifacts. The prover runs those circuits locally and submits a proof to the chain. The validator verifies that the proof satisfies the circuit’s constraints, not that every runtime value came from a trusted source.
That distinction is explicit in Midnight’s language model. The Compact reference describes witnesses as data supplied from JavaScript/TypeScript code outside the proof, and warns:
“Do not assume in your contract that the code of any
witnessfunction is the code that you wrote in your own implementation. Any DApp may provide any implementation that it wants for yourwitnessfunctions.”
Source: Compact language reference
That warning is broader than witness declarations. It captures the general rule for zero-knowledge inputs: if a value enters the circuit as prover-controlled input and you do not constrain it against trustworthy public state or a cryptographic relation, you cannot build security on top of it.
ownPublicKey() falls into exactly that trap.
The vulnerable mental model is:
The actual model is:
That is why a public-key equality check is insufficient.
ownPublicKey() is unconstrained in the ZK circuitThe heart of the problem is not that ownPublicKey() is “wrong.” It is that developers often assign it a stronger meaning than the proving system gives it.
In a ZK system, a value is trustworthy only if one of these is true:
An unconstrained private input is not trustworthy. It is just a witness value the prover chose.
That is the same reason Midnight documentation warns against trusting witness implementations directly: the chain sees only the proof, not the JavaScript function that supplied the private value. The verifier checks consistency with the circuit, not honesty of the source.
If ownPublicKey() is compiled as a private input and your circuit does only this:
export sealed ledger owner: Bytes<32>;
export circuit isOwner(): Boolean {
return ownPublicKey() == owner;
}
then the proof merely establishes:
ownPublicKey()”owner.”It does not establish:
owner”Those would require additional constraints that tie the claimed public key to a secret or signature that the attacker cannot forge.
That is why the stored owner public key being public is fatal. Once the attacker can read it, they can reuse it as the private input that satisfies the equality.
The following Compact snippet is intentionally minimal. It models the dangerous pattern without assuming any project-specific API surface.
Assumption:
ownPublicKey()is available in your Compact environment and returns the same key type as the storedownerfield. The exact import path and concrete key type may vary by SDK version; verify against the current Midnight docs and your generated contract types.
import CompactStandardLibrary;
export sealed ledger owner: Bytes<32>;
export ledger boardLocked: Boolean;
export circuit initialize(ownerPk: Bytes<32>): [] {
owner = ownerPk;
boardLocked = false;
}
export circuit ownerCheck(): Boolean {
return ownPublicKey() == owner;
}
Even without a state mutation, ownerCheck() already demonstrates the flaw: if ownPublicKey() is prover-supplied, the attacker can make this return true by choosing the public owner key as input.
In a real contract, the vulnerable pattern usually appears one level deeper:
The mistake is always the same: a public equality check is being mistaken for possession of a private key.
Here is the attack path the bounty asks you to demonstrate.
Ledger state is public by design. If ownership is represented as a ledger field like owner: Bytes<32>, the attacker can read it through the contract state, the app frontend, or any state-query mechanism exposed by the surrounding tooling.
Nothing secret is needed at this stage.
CircuitContext with that keyThe attacker constructs the proving context so that the value returned by ownPublicKey() is exactly the public owner key they just read.
This is the crucial insight. The proving system does not independently authenticate that value. It only uses the value provided to the circuit.
Because the circuit constraint is only equality, the attacker’s chosen input satisfies it:
owner = XownPublicKey() = XownPublicKey() == owner is trueThe proof is valid because the circuit was satisfied.
The chain verifies the proof, not the attacker’s intent. If the authorization branch depends only on the equality check, the restricted action succeeds.
That is the full exploit. No key theft. No signature forgery. No protocol break. Just a misuse of an unconstrained private input.
Because the exact Midnight proving API is versioned and not fully specified in the bounty text, the safest way to provide tested, functional code in a tutorial is to separate:
The TypeScript below runs on plain Node.js and demonstrates both the attack and the fix.
// poc.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { createHash, randomBytes } from "node:crypto";
type PublicKey = string;
type Secret = Buffer;
function persistentHash(input: Buffer): string {
return createHash("sha256").update(input).digest("hex");
}
// Vulnerable model:
// ownPublicKey() is represented as prover-controlled input.
function vulnerableOwnerCheck(
ledgerOwner: PublicKey,
proverSuppliedOwnPublicKey: PublicKey,
): boolean {
return ledgerOwner === proverSuppliedOwnPublicKey;
}
// Safe model:
// the prover must know a secret whose hash matches the public commitment.
function fixedOwnerCheck(
ledgerCommitment: string,
witnessSecret: Secret,
): boolean {
return ledgerCommitment === persistentHash(witnessSecret);
}
test("4-step attack succeeds against ownPublicKey()-based access control", () => {
// Step 1: attacker reads the public owner key from the ledger
const publicLedgerOwnerKey =
"02c8f1d9f34e7d4f6b67f2b2e0cb7b6a2d0ed6fcbdf3cfe11c9021d5e7f99999";
// Step 2: attacker builds proving context with that exact key
const forgedOwnPublicKeyInput = publicLedgerOwnerKey;
// Step 3 + 4: proof satisfies the circuit, restricted action passes
const authorized = vulnerableOwnerCheck(
publicLedgerOwnerKey,
forgedOwnPublicKeyInput,
);
assert.equal(authorized, true);
});
test("legitimate admin passes the commitment-based check", () => {
const adminSecret = randomBytes(32);
const ledgerCommitment = persistentHash(adminSecret);
const authorized = fixedOwnerCheck(ledgerCommitment, adminSecret);
assert.equal(authorized, true);
});
test("attacker fails the commitment-based check without the secret", () => {
const realAdminSecret = randomBytes(32);
const ledgerCommitment = persistentHash(realAdminSecret);
const attackerGuess = randomBytes(32);
const authorized = fixedOwnerCheck(ledgerCommitment, attackerGuess);
assert.equal(authorized, false);
});
test("public knowledge of the commitment does not help the attacker", () => {
const realAdminSecret = randomBytes(32);
const publicLedgerCommitment = persistentHash(realAdminSecret);
// The attacker knows the public commitment, but not the preimage.
// Replaying the commitment itself as the witness secret does not work.
const attackerBytes = Buffer.from(publicLedgerCommitment, "utf8");
const authorized = fixedOwnerCheck(publicLedgerCommitment, attackerBytes);
assert.equal(authorized, false);
});
Run it with:
node --test poc.test.ts
If your environment does not execute .ts directly, rename it to .mjs with equivalent typings removed, or run through your usual TypeScript toolchain.
This model is intentionally small, but it captures the exact security boundary:
import CompactStandardLibrary;
export sealed ledger owner: Bytes<32>;
export ledger boardLocked: Boolean;
export circuit initialize(ownerPk: Bytes<32>): [] {
owner = ownerPk;
boardLocked = false;
}
// Demonstrates the broken authorization predicate.
// If ownPublicKey() is prover-controlled private input, this is forgeable.
export circuit ownerCheck(): Boolean {
return ownPublicKey() == owner;
}
Assumptions:
persistentHash(...)is available in your Compact toolchain under that name, as referenced in the bounty.- The commitment and secret are represented with compatible byte types in your SDK version.
- The witness return type and
persistentHashinput type should be adjusted to the exact signatures in the current docs.
import CompactStandardLibrary;
export sealed ledger adminCommitment: Bytes<32>;
witness AdminSecret(): Bytes<32>;
export circuit initializeAdmin(commitment: Bytes<32>): [] {
adminCommitment = commitment;
}
export circuit adminCheck(): Boolean {
const secret = AdminSecret();
return persistentHash(secret) == adminCommitment;
}
Why is this safe while witnesses are also untrusted?
Because now the witness is not being trusted by identity. It is being constrained by a one-way relation:
H(secret),secret,persistentHash(secret) == storedCommitment.A malicious prover may supply any witness value they want, but unless they know the correct preimage, the equality fails.
That is the difference between unconstrained identity claim and constrained secret knowledge.
example-bboardThe bounty specifically asks for a proof-of-concept on example-bboard. Since repository internals evolve, the durable way to present this in a tutorial is to show the integration pattern rather than invent file names or function signatures not confirmed by the current tree.
The attack applies to example-bboard if, or wherever, it implements moderation or administration like this:
ownPublicKey() in an admin circuit,Look for the administrative path in example-bboard,for example, any circuit that performs actions such as:
If the gate resembles this predicate, it is vulnerable:
ownPublicKey() == owner
or any wrapper that reduces to the same equality.
Once you identify that check, the exploit is unchanged:
ownPublicKey() resolves to that key,The proof passes because the circuit proved equality, not key ownership.
A board contract often feels “low risk” compared to token custody, which makes access-control shortcuts tempting. But the impact is still real:
In other words, this is a governance failure, not just a cryptography curiosity.
example-bboard to the safe patternThe repair is to replace “owner public key” as the authorization root with a commitment to an admin secret.
A safe migration path is:
persistentHash(secret),In Compact terms, the board’s admin gate becomes:
import CompactStandardLibrary;
export sealed ledger adminCommitment: Bytes<32>;
witness AdminSecret(): Bytes<32>;
export circuit initializeAdmin(commitment: Bytes<32>): [] {
adminCommitment = commitment;
}
export circuit adminCheck(): Boolean {
const secret = AdminSecret();
return persistentHash(secret) == adminCommitment;
}
Then wire every privileged board action through adminCheck() or the equivalent inline predicate.
Because I am not inventing the current repository layout, I recommend validating the exact integration points against the latest example-bboard source before publication. The security argument does not depend on directory names or helper functions.
persistentHash commitmentThis pattern works because it uses public state and private knowledge in the right roles.
A public commitment:
adminCommitment = persistentHash(secret)This is safe to publish because commitments are designed to be public.
The secret preimage:
secretThis is the actual authorization capability.
The witness supplies the secret to the prover:
witness AdminSecret(): Bytes<32>;
Again, the witness itself is not trusted. The circuit does the trust-establishing work by hashing the secret and comparing it to the public commitment.
The proof now establishes:
That is a meaningful authorization statement. It is no longer replaying public information. It is demonstrating knowledge of a private value.
A public key is, by definition, public. If your authorization test succeeds using only public data plus an unconstrained prover input, it is not proving possession.
A commitment-based design succeeds only when the prover knows the hidden preimage. Public knowledge of the commitment does not help.
Ownable.compact from OpenZeppelin Compact ContractsThe bounty asks for an explicit note on OpenZeppelin’s Compact Contracts repository. The important point is architectural:
If Ownable.compact or any ownership helper ultimately authorizes privileged circuits by comparing a stored public key against ownPublicKey(), then it inherits the same vulnerability.
This is not a criticism of one library version in isolation. It is the consequence of the same trust-model mistake:
ownPublicKey() is treated as authenticated identity,So evaluate ownership helpers by the proof statement they enforce, not by the familiar API name.
Safe ownership statements look like:
Unsafe ownership statements look like:
Correct,and that does not invalidate the fix.
The vulnerable pattern trusts an untrusted value as identity.
The safe pattern accepts an untrusted value as a claimed secret and constrains it cryptographically.
That difference is everything.
Yes: they prove equality against public state. That proof is real, but it is proving the wrong property.
Security bugs in ZK applications often come from proving a weaker statement than the developer intended.
Not by itself. If the attacker can read the public key and the circuit lets them supply it, they can also supply it to the hash function.
Hashing public data does not make it secret.
No. A raw witness value is just private input. Without a constraint tying it to public state or another secure relation, it has no authorization meaning.
Yes, but do it explicitly. Store a new commitment derived from a freshly generated secret, and make sure the rotation circuit itself is protected by the current secure admin check.
Store multiple commitments, or use a Merkle commitment over an admin set and prove membership in-circuit. The principle stays the same: authorization must be based on private knowledge constrained against public state.
No. It affects any Compact contract whose access control relies on ownPublicKey() as if it were an authenticated caller identity:
example-bboard repository: https://github.com/midnightntwrk/example-bboardownPublicKey() is unconstrained in the ZK circuit: addressed in “Why ownPublicKey() is unconstrained in the ZK circuit” and “Context: what the Compact trust model actually guarantees”.example-bboard: addressed in “Proof-of-concept on example-bboard”.persistentHash commitment: addressed in “The correct alternative: witness-based secret key plus persistentHash commitment”.Ownable.compact: addressed in “Note on Ownable.compact from OpenZeppelin Compact Contracts”.Thanks for reading this far. If “Compact access-control patterns” connected with where you are, three concrete next steps:
The full Midnight ZK Cookbook index has 17 tutorials across Midnight, Aleo, Aztec, Noir, and risc0 plus 4 Chinese translations. Adjacent tutorials are listed by ecosystem on that page.
Bounty Radar tracks open ZK bounties across Algora, GitHub labels, Drips Wave, Code4rena, and Bountycaster. Browse the Midnight sub-feed; JSON at /midnight.json. The free tier is poll-based; the $19/mo Hobbyist tier pushes one filter to your Telegram in real time.
zk-pipeline-doctor is the free MIT-licensed CLI that scores any ZK project on tests, CI, docs, security, reproducibility, and language toolchain (supports Compact, Leo, Noir, Cairo, and 7 Rust zkVMs). Drop it into a GitHub Action with zk-doctor-action for diff-aware PR comments. The $15/mo Pro tier adds four cross-ecosystem deep detectors (circuit complexity, proving-system pitfalls, verifier soundness, multi-file consistency).
Drafted with AI assistance and reviewed by the author before publishing. See DISCLOSURE for the full process.
Three ways to support this work, pick whichever matches your situation:
Free alternative: Sponsor on GitHub · Star the repo · Share with one ZK developer who'd benefit