DEV Community

Tosh
Tosh

Posted on

Why ownPublicKey() Is Unsafe for Access Control in Compact

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<64>): [] {
    // This check does NOT work as intended
    assert ownPublicKey() == ledger.ownerKey : "unauthorized";
    sendShielded(recipient, ledger.heldCoin, amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.


What 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
Enter fullscreen mode Exit fullscreen mode

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.


The Four-Step Attack

Here's exactly how an attacker exploits this:

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

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...)
Enter fullscreen mode Exit fullscreen mode

The owner thought their key was "private," but it was stored in plaintext the moment they called initialize().

Step 2: Build a CircuitContext with That Key

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
  }
});
Enter fullscreen mode Exit fullscreen mode

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.

Step 3: Generate a Valid ZK Proof

The attacker runs the proof generation with their crafted context:

const proof = await proveCircuit(withdrawCircuit, circuitContext, {
  recipient: attackerAddress,
  amount: totalBalance
});
Enter fullscreen mode Exit fullscreen mode

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.

Step 4: Submit the Transaction

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.


Why People Fall Into This Trap

The confusion comes from conflating two different things:

  1. Key ownership: proving that you hold the private key corresponding to a public key
  2. 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 Correct Alternatives

Option 1: Use ContractAddress for Identity

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<64>): [] {
    // 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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Option 2: Commit/Reveal Ownership Proof

For user ownership where you genuinely need to verify a spending key, use a commit/reveal scheme:

contract CommitRevealOwner {
  ledger ownerCommitment: Bytes<32>;
  ledger isInitialized: Boolean;

  circuit initialize(commitment: Bytes<32>): [] {
    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<32>,
    public operation: Uint<8>
  ): [] {
    // 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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

Then uses the secret when calling ownerAction:

// Off-chain, when performing owner operations
const circuitInputs = {
  witness: { ownerSecret: storedOwnerSecret },
  public: { operation: 1 }
};
Enter fullscreen mode Exit fullscreen mode

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

Option 3: Nullifier-Based One-Time Authorization

For single-use authorizations (like claiming a reward exactly once), derive a nullifier from the owner secret:

contract OneTimeClaim {
  ledger rewardCommitment: Bytes<32>;
  ledger claimed: Boolean;

  circuit claim(
    witness ownerSecret: Bytes<32>,
    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);
  }
}
Enter fullscreen mode Exit fullscreen mode

Summary: The Mental Model

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.

Top comments (0)