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. 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");
}
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");
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
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,
};
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 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");
}
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
}
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]);
}
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"
);
}
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,
],
};
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();
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();
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)