DEV Community

Cover image for The Zero-Knowledge Trap: Why ownPublicKey() Cannot Prove Identity in Compact
Harrie
Harrie

Posted on

The Zero-Knowledge Trap: Why ownPublicKey() Cannot Prove Identity in Compact

Midnight Network Challenge: Enhance the Ecosystem

For everyone who has ever written Solidity before, you should know this pattern:

require(msg.sender == owner, "Not the owner");
Enter fullscreen mode Exit fullscreen mode

It works because the EVM cryptographically verifies the transaction signature. The protocol proves the sender knows the private key, so identity verification is free.

When developers arrive at Midnight and discover ownPublicKey(), the instinct is similar, like with solidity: this is my msg.sender. It looks the same. It reads cleanly, and compiles without errors.

But the problem is that ZK circuits are not the EVM. ownPublicKey() does not verify what you think it verifies. In a Compact circuit, it compiles to an unconstrained private_input; a value the prover sets freely, with zero cryptographic obligation to prove they own the corresponding secret key.

This article shows exactly what happens when ownPublicKey() is used for access control, walks through a four-step attack against a vulnerable contract, explains why OpenZeppelin's Ownable.compact carries this vulnerability today, and demonstrates the correct fix: witness-based secret key commitment with persistentHash.

Every Compact code block in this article has been compiled and verified against compiler v0.30.0.


The Vulnerable Pattern

Consider a simple private vault. A user registers as an owner and expects that only they can call privileged functions.

A developer coming from Solidity would naturally write this:

pragma language_version >= 0.20;
import CompactStandardLibrary;

export ledger vault_owner: ZswapCoinPublicKey;

export circuit deposit(): [] {
  vault_owner = disclose(ownPublicKey());
}

export circuit withdraw(): [] {
  assert(ownPublicKey() == vault_owner, "Not the vault owner");
}
Enter fullscreen mode Exit fullscreen mode

This looks reasonable. The owner is recorded on deposit. The withdraw circuit checks the caller's public key against the stored owner. The assertion should block anyone else.

It does not.


What ownPublicKey() Actually Compiles To

When you call a circuit on Midnight, you are not executing code on a blockchain node. You are generating a zero-knowledge proof — a cryptographic argument that says:

"I know some private inputs such that, when I run this circuit with these inputs, the computation is consistent with the current ledger state."

The ZK proof system has two categories of values:

  • Public inputs — visible to everyone: ledger state, values passed through disclose()
  • Private inputs (witnesses) — known only to the prover: values supplied off-chain that feed into the circuit

ownPublicKey() compiles to a private input. The prover supplies it. The circuit does not constrain it to any cryptographic relationship with a secret key. It is simply a field in the proof that the prover fills in with whatever value they choose.

This means the assertion:

assert(ownPublicKey() == vault_owner, "Not the vault owner");
Enter fullscreen mode Exit fullscreen mode

does not prove the caller owns the key. It proves something far weaker:

"I know a value that equals vault_owner."

And vault_owner is a public ledger state — visible to everyone on the chain.


The Four-Step Attack

Here is how an attacker bypasses this access control without knowing any secret key.

Step 1: Read the Owner's Public Key from the Ledger

Ledger state in Compact is public. Any value stored with disclose() is readable by anyone querying the chain.

// Off-chain attacker script
const ledger = await deployedVault.ledger();
const storedOwner = ledger.vault_owner;
// storedOwner is now in the attacker's hands — it is just public data
Enter fullscreen mode Exit fullscreen mode

Step 2: Build a CircuitContext with the Spoofed Identity

In Midnight's TypeScript SDK, the CircuitContext provides the private inputs for proof generation. The attacker constructs one where ownPublicKey() returns the value just read from the ledger:

// ownPublicKey() is unconstrained — the attacker sets it freely
const attackContext = {
  ...defaultContext,
  ownPublicKey: () => storedOwner,
};
Enter fullscreen mode Exit fullscreen mode

No secret key required. ownPublicKey() is a free variable with no cryptographic binding.

Step 3: Generate a Valid ZK Proof

const proof = await deployedVault.prove.withdraw(attackContext);
Enter fullscreen mode Exit fullscreen mode

Inside the circuit, the proof system evaluates:

assert(ownPublicKey() == vault_owner)
→ assert(storedOwner == storedOwner)
→ assert(true)  ✓
Enter fullscreen mode Exit fullscreen mode

The assertion passes. The proof is valid. The attacker never needed a secret key.

Step 4: Submit the Transaction

await deployedVault.submit(proof);
// Transaction accepted. Privileged action executed.
Enter fullscreen mode Exit fullscreen mode

The blockchain verifies the proof, finds it valid, and executes the circuit. This is not a bug in Midnight's proving system — the proof is valid. It correctly proves the prover knows a value equal to vault_owner. The flaw is in treating ownPublicKey() as proof of key ownership when it only proves value knowledge.


OpenZeppelin's Ownable.compact: The Ecosystem-Wide Impact

This is not a theoretical edge case. It is present in production code that developers are actively building on.

OpenZeppelin's compact-contracts library, the canonical smart contract library for Midnight, directly analogous to OpenZeppelin's Solidity contracts, implements access control through Ownable.compact. Here is the exact assertOnlyOwner circuit from their repository:

export circuit assertOnlyOwner(): [] {
  Initializable_assertInitialized();
  const caller = ownPublicKey();
  assert(caller == _owner.left, "Ownable: caller is not the owner");
}
Enter fullscreen mode Exit fullscreen mode

Every contract that imports Ownable.compact and calls assertOnlyOwner() carries this vulnerability. The typical usage looks like:

import "./compact-contracts/.../Ownable" prefix Ownable_;

export circuit adminWithdraw(): [] {
  Ownable_assertOnlyOwner();  // Does NOT prove ownership
  // ... privileged operation executes for any attacker
}
Enter fullscreen mode Exit fullscreen mode

The attack is identical to the vault example:

  1. Read _owner from the ledger — it is stored as public state via disclose(newOwner)
  2. Construct a context where ownPublicKey() returns _owner.left
  3. Generate a valid ZK proof — the circuit evaluates _owner.left == _owner.left
  4. Submit the transaction — any circuit guarded by assertOnlyOwner() is bypassed

The OpenZeppelin repository notes that the code is "highly experimental" and is to be used "at your own risk." But the specific mechanism of this vulnerability, that ownPublicKey() is unconstrained in ZK circuits, is not surfaced prominently. Developers who see assertOnlyOwner() have every reason to trust it works.


The Correct Pattern: Witness + persistentHash Commitment

The fix requires a shift in its concept.

Instead of asking "what is the caller's public key?", you ask "Can the caller prove they know a secret whose hash matches the value stored on-chain?"

This is a commitment scheme. The owner registration stores a cryptographic hash of a secret key. Ownership verification requires the caller to re-derive that same hash, which is only possible if they know the original secret.

Midnight's own example-bboard contract uses this pattern correctly. Here is the relevant portion of the actual bboard.compact source:

witness localSecretKey(): Bytes<32>;

export circuit post(newMessage: Opaque<"string">): [] {
  assert(state == State.VACANT, "Attempted to post to an occupied board");
  owner = disclose(publicKey(localSecretKey(), sequence as Field as Bytes<32>));
  message = disclose(some<Opaque<"string">>(newMessage));
  state = State.OCCUPIED;
}

export circuit takeDown(): Opaque<"string"> {
  assert(state == State.OCCUPIED, "Attempted to take down post from an empty board");
  assert(
    owner == publicKey(localSecretKey(), sequence as Field as Bytes<32>),
    "Attempted to take down post, but not the current owner"
  );
  const formerMsg = message.value;
  state = State.VACANT;
  sequence.increment(1);
  message = none<Opaque<"string">>();
  return formerMsg;
}

export circuit publicKey(sk: Bytes<32>, sequence: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<3, Bytes<32>>>([pad(32, "bboard:pk:"), sequence, sk]);
}
Enter fullscreen mode Exit fullscreen mode

Three things make this secure:

1. witness localSecretKey(): Bytes<32>
The secret key is a private input, but the circuit constrains it through the hash computation. It is not a free variable — its value must produce a hash that matches the stored commitment.

2. persistentHash
The owner is stored as a hash of [domain_prefix, sequence, secret_key]. This is a one-way commitment. Knowing the output tells you nothing about the input.

3. The ownership check
takeDown() re-derives the same hash and asserts equality. The ZK proof now proves: "I know an sk such that hash(prefix, sequence, sk) == owner." An attacker who does not know sk cannot produce a valid proof, because persistentHash is one-way.


The Secure Vault: Fixed Implementation

Applying the commitment pattern to the vulnerable vault. This compiles cleanly against v0.30.0:

pragma language_version >= 0.20;
import CompactStandardLibrary;

export ledger vault_owner: Bytes<32>;

witness localSecretKey(): Bytes<32>;

circuit ownerCommitment(sk: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<2, Bytes<32>>>([pad(32, "vault:owner:"), sk]);
}

export circuit deposit(): [] {
  vault_owner = disclose(ownerCommitment(localSecretKey()));
}

export circuit withdraw(): [] {
  assert(
    ownerCommitment(localSecretKey()) == vault_owner,
    "Not the vault owner"
  );
}
Enter fullscreen mode Exit fullscreen mode

The off-chain witnesses file provides the secret key from private state:

// witnesses.ts
import { WitnessContext } from '@midnight-ntwrk/compact-runtime';

export type VaultPrivateState = {
  readonly secretKey: Uint8Array;
};

export const createVaultPrivateState = (secretKey: Uint8Array): VaultPrivateState => ({
  secretKey,
});

export const witnesses = {
  localSecretKey: (
    { privateState }: WitnessContext<VaultPrivateState>
  ): [VaultPrivateState, Uint8Array] => [
    privateState,
    privateState.secretKey,
  ],
};
Enter fullscreen mode Exit fullscreen mode

Now replay the attack. The attacker reads vault_owner from the ledger — they get a 32-byte hash value. They cannot reverse persistentHash to find sk. When they attempt to generate a proof for withdraw(), the circuit requires them to supply an sk such that hash("vault:owner:", sk) == vault_owner. Without the original secret key, no valid proof can be constructed. The attack fails.


Why the Domain Prefix Matters

Notice pad(32, "vault:owner:") in the persistentHash call. This is domain separation.

If you use the same hash function and inputs across multiple commitments in a contract, an attacker might satisfy one circuit's constraint by reusing a proof from a different circuit — a cross-context replay attack.

The domain prefix ensures that a commitment built for vault ownership cannot satisfy constraints elsewhere in the contract. The example-bboard uses "bboard:pk:" for exactly this reason.

Establish a naming convention for your own contracts: "contractname:purpose:" — and never reuse the same prefix across different commitment types.


Testing Your Implementation

Compiling without errors is not security. A test suite for this pattern must verify three scenarios.

Test 1: The Legitimate Owner Can Call Withdraw

const ownerSecretKey = crypto.getRandomValues(new Uint8Array(32));
const ownerState = createVaultPrivateState(ownerSecretKey);

await vault.callCircuit('deposit', [], ownerState);

// Should succeed
const result = await vault.callCircuit('withdraw', [], ownerState);
expect(result).toBeDefined();
Enter fullscreen mode Exit fullscreen mode

Test 2: A Different Key Holder Cannot Call Withdraw

const attackerSecretKey = crypto.getRandomValues(new Uint8Array(32));
const attackerState = createVaultPrivateState(attackerSecretKey);

// Should throw — attacker's commitment does not match stored owner
await expect(
  vault.callCircuit('withdraw', [], attackerState)
).rejects.toThrow();
Enter fullscreen mode Exit fullscreen mode

Test 3: An Attacker Using the Stored Ledger Value Directly Cannot Withdraw

This test directly reproduces the ownPublicKey() attack pattern against the fixed contract:

// Attacker reads the public ledger value
const ledger = await vault.ledger();
const storedOwner = ledger.vault_owner;

// Attacker attempts to use the stored hash as if it were the secret key
const fakeState = createVaultPrivateState(storedOwner);

// Should fail — persistentHash("vault:owner:", stored_hash) != persistentHash("vault:owner:", original_sk)
await expect(
  vault.callCircuit('withdraw', [], fakeState)
).rejects.toThrow();
Enter fullscreen mode Exit fullscreen mode

If all three pass, you have verified correctness and resistance to the ownPublicKey() class of attack.


The Mental Model

When reviewing any Compact access control circuit, apply one question:

Does this proof require the caller to demonstrate knowledge of a secret, or only knowledge of a public value?

Pattern What it proves Secure
assert(ownPublicKey() == ledger.owner) Caller knows ledger.owner (public) ❌ No
assert(persistentHash([prefix, witness()]) == ledger.owner) Caller knows the preimage of ledger.owner ✅ Yes

The rule: public values cannot gate access. If the value being compared against is visible on-chain, any prover can satisfy the assertion without owning any secret. Access control must be gated on something the prover must know but cannot observe — a preimage, a secret key.

ownPublicKey() feels like it should be that secret. In the EVM, the transaction signature is the proof. In a ZK circuit, ownPublicKey() is just a number the prover fills in. The circuit has no mechanism to bind it to an externally held secret.


Conclusion

The ownPublicKey() vulnerability is not a bug in Midnight. It is a consequence of the fundamental difference between EVM identity and ZK identity.

In the EVM, the protocol enforces that msg.sender matches a transaction signature. In a ZK circuit, the circuit itself must enforce identity — by requiring the prover to demonstrate knowledge of a secret whose commitment is stored on-chain.

ownPublicKey() skips that enforcement. It gives developers a familiar-looking API that silently removes the security guarantee they are relying on.

The fix: declare a witness localSecretKey(): Bytes<32>, store a persistentHash commitment, and verify by re-deriving the commitment. Every access control pattern in Compact should follow this structure — the same structure the Midnight Foundation used in example-bboard.

OpenZeppelin's Ownable.compact uses ownPublicKey() in assertOnlyOwner(). Every contract using that library today is vulnerable until it is patched.

Before shipping any contract with ownership or role-based access control, apply the mental model test: Does proving ownership require the caller to know something that is not already visible on-chain? If the answer is no, the access control does not work.


All Compact code examples compiled and verified against Compact compiler v0.30.0 using the Midnight MCP toolchain. Reference implementation: midnightntwrk/example-bboard. Questions or corrections? Drop a comment below.

Top comments (0)