DEV Community

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

Posted on • Edited 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. Identity verification comes for free.

When developers arrive at Midnight and discover ownPublicKey(), the assumption is immediate: this is my msg.sender. Same shape, same purpose, compiles without errors.

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 no cryptographic obligation to prove they own the corresponding secret key. That means every access control check built on it is bypassable by anyone who can read the chain.

All code in this article compiles against compactc 0.31.0 (language version 0.23.0).


The vulnerable pattern

Take a simple private vault. A user registers as owner on deposit and expects only they can trigger a withdrawal.

The natural instinct coming from Solidity:

pragma language_version >= 0.23;
import CompactStandardLibrary;

export ledger vault_owner: ZswapCoinPublicKey;

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

// Note: disclose(ownPublicKey()) compiles because ownPublicKey() is a witness
// output — a private value the circuit reveals to the ledger via disclose().
// This pattern looks safe but is the root of the vulnerability: the value
// disclosed is only as trustworthy as the unconstrained input that produced it.
export circuit withdraw(): [] {
  assert(ownPublicKey() == vault_owner, "Not the vault owner");
}
Enter fullscreen mode Exit fullscreen mode

This looks correct. The owner is recorded on deposit. The withdraw circuit checks the caller's key against the stored value. 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 just a field in the proof that the prover fills in with whatever value they choose.

So 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 much weaker:

"I know a value that equals vault_owner."

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


The four-step attack

The snippets below illustrate the attack flow conceptually. The actual SDK methods have different names; the full runnable implementation with correct Midnight SDK shapes is in the PoC repository.

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 proof context with the spoofed identity

The Midnight SDK's proof generation takes private inputs from the prover. The attacker constructs a context where ownPublicKey() returns the value they 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 in production code that developers are actively building on.

OpenZeppelin's compact-contracts library — their Midnight equivalent of the Solidity contracts — implements access control through Ownable.compact. Here is the exact assertOnlyOwner circuit:

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 gates privileged operations behind assertOnlyOwner() is currently bypassable using this attack. The typical usage:

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: read _owner from the ledger, set ownPublicKey() to _owner.left in your proof context, generate a valid proof — the circuit evaluates _owner.left == _owner.left — then submit.

The OpenZeppelin repository notes the code is "highly experimental" and "at your own risk." But the specific mechanism here — that ownPublicKey() is unconstrained in ZK circuits — is not called out anywhere obvious. Developers who see assertOnlyOwner() have every reason to trust it does what the name says.

I verified the Ownable.compact implementation above against the OpenZeppelin compact-contracts repository in May 2025 and have notified OpenZeppelin of this issue. If you are using Ownable.compact for privileged access control right now, stop relying on assertOnlyOwner() as a security boundary until a patch lands. You can wait for the patch, fork and rewrite assertOnlyOwner() yourself using the commitment pattern below, or migrate your contracts to skip the library entirely.


The correct pattern: witness + persistentHash commitment

The fix is a question shift. Instead of asking "what is the caller's public key?", ask "can the caller prove they know a secret whose hash matches the value stored on-chain?"

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 correctly. The relevant part of bboard.compact:

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

Notice what makes this secure. witness localSecretKey(): Bytes<32> is a private input, but unlike ownPublicKey() it is constrained — its value must produce a hash that matches the stored commitment, so the prover cannot fill it in freely. The owner is not stored as a key but as a persistentHash of [domain_prefix, sequence, secret_key], a one-way commitment where knowing the output tells you nothing about the input. And takeDown() re-derives that hash and asserts equality, so the ZK proof proves "I know an sk such that hash(prefix, sequence, sk) == owner" — something an attacker who only has the stored value cannot do.


The secure vault: fixed implementation

Applying the same pattern to the vulnerable vault. This compiles cleanly against compactc 0.31.0 (language version 0.23.0):

pragma language_version >= 0.23;
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 example-bboard contract includes a sequence parameter — publicKey(sk, sequence) — so that each round produces a different commitment. Without it, repeated identity disclosures let observers link transactions back to the same owner key. This vault omits the sequence parameter because ownership is fixed and never rotates. For contracts with rotating ownership or multiple identity disclosures, follow bboard's pattern to prevent linkability.

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

Run the attack against this version. The attacker reads vault_owner from the ledger and gets a 32-byte hash. They cannot reverse persistentHash to find sk. When they try to generate a proof for withdraw(), the circuit requires an sk such that hash("vault:owner:", sk) == vault_owner. Without the original secret key, no valid proof exists. 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. The prefix makes sure a commitment built for vault ownership cannot satisfy constraints anywhere else. example-bboard uses "bboard:pk:" for exactly this reason.

A good 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. Three scenarios need explicit test coverage.

Test 1: The legitimate owner can call withdraw

// Always use crypto.getRandomValues for secret key generation.
// Math.random() is not cryptographically secure — a predictable key
// undermines the entire security guarantee of the commitment scheme.
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

All three passing means the contract is both correct and resistant to the ownPublicKey() class of attack.


Valid uses of ownPublicKey()

The vulnerability here is specific to access control — using ownPublicKey() to prove the caller is allowed to perform an action. It does have legitimate uses, and they share one characteristic: ownPublicKey() identifies who an operation is for, not who is allowed to perform it.

Minting tokens to the caller with mint(amount, ownPublicKey(), value) is fine — it just directs coins to the caller's address, no authorization check involved. The same goes for sendShielded(coin, left(ownPublicKey()), value) for self-transfers, or recipient = disclose(ownPublicKey()) to publish your key so others can pay you.

The line is this: use ownPublicKey() to designate a destination and you are fine. The moment you write assert(... == ownPublicKey()) to block someone from doing something, switch to the commitment pattern.


The mental model

When reviewing any Compact access control circuit, one question covers it:

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

If the value being compared against is visible on-chain, any prover can satisfy the assertion without owning any secret. Access control has to be gated on something the prover has to know but cannot observe — a preimage, a secret key.

ownPublicKey() feels like it should be that secret. In the EVM it is — the transaction signature is the proof. In a ZK circuit, ownPublicKey() is just a number the prover fills in. The circuit has no way 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 has to enforce identity — by requiring the prover to prove 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.

Declare a witness localSecretKey(): Bytes<32>, store a persistentHash commitment, and verify by re-deriving the commitment. That is the same pattern the Midnight Foundation used in example-bboard, and it is the right answer for any Compact contract that needs access control.

OpenZeppelin's Ownable.compact uses ownPublicKey() in assertOnlyOwner(). Every contract that gates privileged operations behind assertOnlyOwner() is currently bypassable until the library is patched.

Before shipping any contract with ownership or role-based access control, ask: 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 compactc 0.31.0 (language version 0.23.0). Reference implementation: midnightntwrk/example-bboard. Full proof-of-concept repo (contracts + deploy + exploit scripts + tests): IamHarrie-Labs/midnight-ownpublickey-poc. Questions or corrections? Drop a comment below.

Top comments (0)