For everyone who has ever written Solidity before, you should know this pattern:
require(msg.sender == owner, "Not the owner");
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");
}
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");
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
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,
};
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);
Inside the circuit, the proof system evaluates:
assert(ownPublicKey() == vault_owner)
→ assert(storedOwner == storedOwner)
→ assert(true) ✓
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.
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");
}
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
}
The attack is identical to the vault example:
- Read
_ownerfrom the ledger — it is stored as public state viadisclose(newOwner) - Construct a context where
ownPublicKey()returns_owner.left - Generate a valid ZK proof — the circuit evaluates
_owner.left == _owner.left✓ - 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]);
}
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"
);
}
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,
],
};
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();
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();
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();
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)