Why ownPublicKey() Can’t Be Trusted for Access Control

TL;DR

If 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:

  1. Read the owner’s public key from public ledger state.
  2. Build a CircuitContext that injects that key as the value of ownPublicKey().
  3. Generate a valid proof.
  4. Submit the transaction and pass your “owner-only” check.

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:

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.

Context: what the Compact trust model actually guarantees

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 witness function is the code that you wrote in your own implementation. Any DApp may provide any implementation that it wants for your witness functions.”
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.

Why ownPublicKey() is unconstrained in the ZK circuit

The 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:

  1. it is part of verified public input,
  2. it is public ledger state read by the circuit,
  3. it is the output of a cryptographic relation enforced by the circuit,
  4. or it is derived from the above through constrained computation.

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:

It does not establish:

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.

A minimal vulnerable Compact pattern

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 stored owner field. 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.

The 4-step attack, precisely

Here is the attack path the bounty asks you to demonstrate.

Step 1: Read the owner’s public key from the ledger

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.

Step 2: Build a CircuitContext with that key

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

Step 3: Generate a valid ZK proof

Because the circuit constraint is only equality, the attacker’s chosen input satisfies it:

The proof is valid because the circuit was satisfied.

Step 4: Submit the transaction

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.

Working proof-of-concept code: vulnerability and fix

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:

  1. a working, executable TypeScript model of the security property, and
  2. the Compact contract snippets that implement the same pattern in-contract.

The TypeScript below runs on plain Node.js and demonstrates both the attack and the fix.

Executable TypeScript model

// 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:

Vulnerable Compact version

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;
}

Fixed Compact version

Assumptions:

  1. persistentHash(...) is available in your Compact toolchain under that name, as referenced in the bounty.
  2. The commitment and secret are represented with compatible byte types in your SDK version.
  3. The witness return type and persistentHash input 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:

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.

Proof-of-concept on example-bboard

The 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:

  1. store an owner/admin public key in ledger state,
  2. call ownPublicKey() in an admin circuit,
  3. compare the two,
  4. authorize a privileged action on equality.

How to adapt the vulnerable pattern to the board example

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.

How the exploit looks against the board

Once you identify that check, the exploit is unchanged:

  1. query the board contract state and read the public owner key,
  2. construct the proving context so ownPublicKey() resolves to that key,
  3. generate the proof for the privileged board circuit,
  4. submit the transaction.

The proof passes because the circuit proved equality, not key ownership.

Why this matters for a message board specifically

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.

Refactoring example-bboard to the safe pattern

The repair is to replace “owner public key” as the authorization root with a commitment to an admin secret.

A safe migration path is:

  1. generate a random 32-byte admin secret off-chain,
  2. compute persistentHash(secret),
  3. store only the hash in ledger state during initialization,
  4. declare a witness that returns the secret for admin circuits,
  5. check the witness value by hashing it inside the circuit.

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.

The correct alternative: witness-based secret key plus persistentHash commitment

This pattern works because it uses public state and private knowledge in the right roles.

What goes on-chain

A public commitment:

This is safe to publish because commitments are designed to be public.

What stays off-chain

The secret preimage:

This is the actual authorization capability.

What the witness does

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.

What the circuit proves

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.

Why this is stronger than a public key equality check

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.

Note on Ownable.compact from OpenZeppelin Compact Contracts

The 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:

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:

Pitfalls and common errors

1. “But witnesses are untrusted too”

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.

2. “The owner public key is public, but the caller still has to prove something”

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.

3. “Can I hash the public key instead?”

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.

4. “Can I use a witness secret without a commitment?”

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.

5. “Can I rotate the admin secret?”

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.

6. “What if I want multiple admins?”

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.

7. “Does this only affect board apps?”

No. It affects any Compact contract whose access control relies on ownPublicKey() as if it were an authenticated caller identity:

References

Bounty spec coverage


Where to go next

Thanks for reading this far. If “Compact access-control patterns” connected with where you are, three concrete next steps:

Learn more in Midnight

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.

Find paid work in Midnight

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.

Audit your own ZK pipeline

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.

If this saved you time

Three ways to support this work, pick whichever matches your situation:

$15 · One-time
All 17 tutorials as one Markdown + companion code repos. Offline-readable.
Get the Bundle →
$19 / month
Real-time bounty alerts pushed to your Telegram, filtered by ecosystem.
Try Radar →
$99 · One-time
Pre-flight audit of your ZK repo. See sample.
Order Audit →

Free alternative: Sponsor on GitHub · Star the repo · Share with one ZK developer who'd benefit

Related projects