AI News Hub Logo

AI News Hub

Why ownPublicKey() Is Unsafe for Access Control in Compact

DEV Community
Tosh

Why ownPublicKey() Is Unsafe for Access Control in Compact There's a pattern I see in early Midnight contracts that looks completely reasonable at first glance and is completely broken in practice. It goes like this: store the owner's public key in the ledger at initialization, then check ownPublicKey() in privileged circuits to verify the caller is the owner. // DON'T DO THIS contract BrokenVault { ledger ownerKey: ZswapCoinPublicKey; circuit initialize(ownerKey: ZswapCoinPublicKey): [] { ledger.ownerKey = ownerKey; } circuit withdraw(recipient: ZswapCoinPublicKey, amount: Uint): [] { // This check does NOT work as intended assert ownPublicKey() == ledger.ownerKey : "unauthorized"; sendShielded(recipient, ledger.heldCoin, amount); } } This contract will compile without warnings. The check seems logical. And an attacker can completely bypass it in four steps. Let me walk through why. ownPublicKey() Actually Does To understand the vulnerability, you need to know what ownPublicKey() compiles to. In Compact's ZK circuit model, ownPublicKey() is not a verified identity claim. It's a private_input — an unconstrained witness value that the proof generator supplies. When you call ownPublicKey() in a circuit, the compiled circuit says: "take this value from the private input, don't verify where it came from." The ZK proof guarantees that the circuit was executed correctly with some consistent set of inputs. But for private_input values, there's no constraint tying those inputs to any real-world identity. The prover can supply any value for ownPublicKey() when generating the proof, as long as it makes the circuit constraints satisfiable. So assert ownPublicKey() == ledger.ownerKey compiles to something roughly equivalent to: // Pseudocode for what the ZK circuit actually checks: private_input_key = prover_supplied_value // ← no constraint on this! assert private_input_key == ledger.ownerKey The proof will be valid as long as the prover sets private_input_key equal to ledger.ownerKey. Which any prover can do, because ledger.ownerKey is public on-chain state. Here's exactly how an attacker exploits this: Every piece of ledger state in a Compact contract is publicly visible on Midnight's transparent ledger layer. The attacker queries the contract's ledger state: midnight-cli query-ledger --contract 0xabc123 --field ownerKey # Returns: ZswapCoinPublicKey(0x04f3a2...) The owner thought their key was "private," but it was stored in plaintext the moment they called initialize(). When generating a ZK proof for a Compact transaction, the user constructs a CircuitContext that includes the private inputs. The attacker builds this context with the owner's public key as the ownPublicKey value: // Attacker's JavaScript transaction builder const circuitContext = new CircuitContext({ privateInputs: { ownPublicKey: stolenOwnerKey, // key read from ledger in step 1 // ... other witness values } }); There's no signature check here. The CircuitContext constructor doesn't verify that you own the key you're claiming — it's just a value you provide. The attacker runs the proof generation with their crafted context: const proof = await proveCircuit(withdrawCircuit, circuitContext, { recipient: attackerAddress, amount: totalBalance }); Because the circuit's only constraint is private_input_key == ledger.ownerKey, and the attacker set private_input_key to exactly ledger.ownerKey, the circuit is satisfiable. The proof is valid. The attacker submits the proof to the network. The network verifies the ZK proof (which is valid), checks the ledger constraints (which are satisfied), and executes the transaction. The vault is drained. The owner's wallet never signed anything. The attacker never knew the owner's spending key or any private information beyond what was already public on-chain. The confusion comes from conflating two different things: Key ownership: proving that you hold the private key corresponding to a public key Value equality: proving that a circuit variable equals some stored value ZK proofs can do (2) extremely well. They can prove arbitrary circuit computations are correct. But (1) requires a separate mechanism — a digital signature, a key-based commitment reveal, or a kernel-level identity check. ownPublicKey() is documented as "the public key associated with the current transaction context," but nothing in the ZK circuit enforces that this key belongs to the transaction submitter. The enforcement would require a signature verification inside the circuit, which Compact doesn't do automatically for private_input values. If you've written Solidity, think of it this way: ownPublicKey() is like msg.sender if msg.sender could be set to any address by the transaction sender. It's obviously broken when you put it that way, but the abstraction leak isn't obvious in Compact's syntax. The cleanest alternative for contract-level access control is ContractAddress. A contract's address is deterministically derived from its code and deployment parameters — it's an on-chain fact, not a prover-supplied claim. For contracts that need to restrict operations to a specific deployer, store the deployer's contract address rather than their public key: contract SecureVault { ledger adminContract: ContractAddress; circuit initialize(admin: ContractAddress): [] { assert ledger.adminContract == ContractAddress.zero() : "already initialized"; ledger.adminContract = admin; } circuit adminWithdraw(recipient: ZswapCoinPublicKey, amount: Uint): [] { // ContractAddress.caller() returns the address of the calling contract // This IS constrained by the kernel — the prover can't fake it assert ContractAddress.caller() == ledger.adminContract : "unauthorized"; sendShielded(recipient, ledger.heldCoin, amount); } } ContractAddress.caller() is provided by the transaction kernel, not the prover. It's verified at the protocol level, not just at the circuit level. An attacker cannot supply a different value for this — it's determined by which contract invoked yours. This pattern works well for contract-to-contract access control. For user-to-contract access control, you need the commitment approach. For user ownership where you genuinely need to verify a spending key, use a commit/reveal scheme: contract CommitRevealOwner { ledger ownerCommitment: Bytes; ledger isInitialized: Boolean; circuit initialize(commitment: Bytes): [] { assert !ledger.isInitialized : "already initialized"; // Store a commitment to the owner's secret, not the secret itself ledger.ownerCommitment = commitment; ledger.isInitialized = true; } circuit ownerAction( witness ownerSecret: Bytes, public operation: Uint ): [] { // The circuit CONSTRAINS ownerSecret to match the stored commitment // The prover must know the actual secret to generate this proof assert verifyCommitment(ownerSecret, ledger.ownerCommitment) : "invalid owner proof"; // Now do privileged operation if operation == 1 { // withdraw logic } } } The key difference: the commitment is stored on-chain (public), but the secret is a witness (private input) that the circuit constrains via verifyCommitment. To produce a valid proof, the prover must know ownerSecret such that hash(ownerSecret) == ledger.ownerCommitment. An attacker who only knows the commitment cannot reverse it to find the secret. The owner generates the commitment during setup: // Off-chain, during initialization const ownerSecret = crypto.randomBytes(32); const commitment = hash(ownerSecret); // store this on-chain // Keep ownerSecret private! Then uses the secret when calling ownerAction: // Off-chain, when performing owner operations const circuitInputs = { witness: { ownerSecret: storedOwnerSecret }, public: { operation: 1 } }; This works because: verifyCommitment creates a ZK constraint on the witness The constraint is part of the circuit definition, not a prover choice Without the preimage, the constraint cannot be satisfied The proof fails to generate, not just the assertion For single-use authorizations (like claiming a reward exactly once), derive a nullifier from the owner secret: contract OneTimeClaim { ledger rewardCommitment: Bytes; ledger claimed: Boolean; circuit claim( witness ownerSecret: Bytes, recipient: ZswapCoinPublicKey ): [] { assert !ledger.claimed : "already claimed"; assert verifyCommitment(ownerSecret, ledger.rewardCommitment) : "invalid proof"; ledger.claimed = true; sendShielded(recipient, ledger.heldCoin, ledger.heldCoin.info.value); } } When writing access control in Compact, ask: "Is this value constrained by the ZK circuit, or is it an unconstrained witness?" Mechanism Constrained? Safe for Access Control? ownPublicKey() ❌ Unconstrained private_input ❌ No ContractAddress.caller() ✅ Kernel-provided ✅ Yes (contract-to-contract) verifyCommitment(secret, stored) ✅ Circuit constraint ✅ Yes (user-to-contract) Ledger value equality check ✅ But must be on trusted value ⚠️ Depends on the value The rule of thumb: any value that comes from private_input (witnesses, ownPublicKey()) can be anything the prover wants it to be, unless there's an explicit ZK constraint binding it to something publicly verifiable. Don't use those values as identity claims without a corresponding constraint that makes faking them impossible. Fix ownPublicKey()-based access control before deploying to mainnet. The vulnerability is silent — contracts that use it will compile, deploy, and appear to work in testing. The attack only happens when there's something worth stealing.