Access Proof Circuit
The Access Proof circuit enables persistent, unlinkable authentication against a committed key stored in the OpenCommitmentTree. Unlike the GhostRedemption circuit, which spends a commitment (via nullifier registration), the Access Proof circuit proves knowledge of a commitment without consuming it. Each access session uses a different sessionNonce, producing a unique accessTag that cannot be linked to the committed key or to other sessions.
This circuit powers the PersistentKeyVault -- Specter's mechanism for reusable encryption keys, recurring authenticated access, and long-lived private identities.
Design Rationale
The GhostRedemption circuit is designed for one-time use: each reveal registers a nullifier, permanently marking the commitment as spent. This is correct for token transfers (preventing double-spending), but many use cases require repeated access to the same secret:
- Reusable encryption keys: A user commits a public key and proves knowledge of it across multiple sessions to decrypt messages.
- Authenticated API access: A user proves they hold a valid credential without revealing the credential or creating a permanent on-chain link.
- Private channel membership: A user proves membership in a group commitment set repeatedly, for each new session.
The Access Proof circuit solves this by omitting nullifier registration and replacing it with a session-scoped accessTag. The commitment is never "spent" and can be used indefinitely.
Signal Layout
Public Inputs (4)
| Index | Signal | Type | Description |
|---|---|---|---|
| 0 | root | Field element | Merkle tree root from the OpenCommitmentTree. Must exist in the 100-root ring buffer. |
| 1 | dataHash | Field element | Hash of the data associated with this commitment. Typically keccak256(data) % BN254_FIELD. |
| 2 | sessionNonce | Field element | A fresh random nonce provided by the verifier (or agreed upon) for this session. Different for each access. |
| 3 | accessTag | Field element | Poseidon2(nullifierSecret, sessionNonce). Proves knowledge of nullifierSecret without revealing it. |
Private Inputs (5)
| Signal | Type | Description |
|---|---|---|
secret | Field element | 31-byte random secret from the original commitment. |
nullifierSecret | Field element | 31-byte random secret. Used to derive accessTag (not a nullifier). |
blinding | Field element | Random blinding factor from the original commitment. |
pathElements[20] | Array of 20 field elements | Sibling hashes along the Merkle path from leaf to root. |
pathIndices[20] | Array of 20 bits | Direction bits for the Merkle path. |
What This Circuit Does NOT Do
Understanding the Access Proof circuit requires understanding what is deliberately absent compared to GhostRedemption:
| Feature | GhostRedemption | Access Proof |
|---|---|---|
| Nullifier computation | Yes -- Poseidon2(Poseidon2(nullifierSecret, commitment), leafIndex) | No -- no nullifier is computed or output |
| Nullifier registration | Yes -- nullifier is marked as spent on-chain | No -- nothing is spent |
| Recipient binding | Yes -- proof bound to specific recipient address | No -- no recipient concept |
| Amount handling | Yes -- withdrawAmount, range check, change commitment | No -- no amounts involved |
| Change commitment | Yes -- re-commits leftover balance | No -- commitment is reused as-is |
| Policy binding | Yes -- policyId and policyParamsHash in constraints | No -- no policy enforcement |
| Token ID | Yes -- tokenId in commitment preimage | No -- uses dataHash instead |
The Access Proof circuit is structurally simpler: it proves "I know a valid commitment in this tree with this dataHash" and produces a session-scoped tag, nothing more.
Circuit Template
template AccessProof(depth) {
// Public inputs
signal input root;
signal input dataHash;
signal input sessionNonce;
signal input accessTag;
// Private inputs
signal input secret;
signal input nullifierSecret;
signal input blinding;
signal input pathElements[depth];
signal input pathIndices[depth];
// Step 1: Compute commitment from preimage
component commitmentHasher = PoseidonT5(4);
commitmentHasher.inputs[0] <== secret;
commitmentHasher.inputs[1] <== nullifierSecret;
commitmentHasher.inputs[2] <== dataHash;
commitmentHasher.inputs[3] <== blinding;
signal commitment <== commitmentHasher.out;
// Step 2: Verify Merkle membership
component merkleProof = MerkleTreeChecker(depth);
merkleProof.leaf <== commitment;
merkleProof.root <== root;
for (var i = 0; i < depth; i++) {
merkleProof.pathElements[i] <== pathElements[i];
merkleProof.pathIndices[i] <== pathIndices[i];
}
// Step 3: Verify access tag
component tagHasher = PoseidonT3(2);
tagHasher.inputs[0] <== nullifierSecret;
tagHasher.inputs[1] <== sessionNonce;
accessTag === tagHasher.out;
}
component main {public [root, dataHash, sessionNonce, accessTag]} = AccessProof(20);
Constraint Breakdown
Step 1: Commitment Preimage (Poseidon4)
The prover demonstrates knowledge of the four values that hash to a valid commitment:
commitment = Poseidon4(secret, nullifierSecret, dataHash, blinding)
This uses the same Poseidon4 (PoseidonT5) hash as OpenGhostVault commitments. The dataHash is a public input, which means the verifier knows what data the prover is claiming access to -- but not which specific commitment in the tree corresponds to that data.
component commitmentHasher = PoseidonT5(4);
commitmentHasher.inputs[0] <== secret;
commitmentHasher.inputs[1] <== nullifierSecret;
commitmentHasher.inputs[2] <== dataHash;
commitmentHasher.inputs[3] <== blinding;
signal commitment <== commitmentHasher.out;
Constraint count: ~700 R1CS constraints.
Step 2: Merkle Membership
Identical to GhostRedemption's Merkle membership check. The circuit verifies a 20-level path from the commitment (leaf) to the public root:
For each level i from 0 to 19:
if pathIndices[i] == 0:
currentHash = Poseidon2(currentHash, pathElements[i])
else:
currentHash = Poseidon2(pathElements[i], currentHash)
assert currentHash == root
component merkleProof = MerkleTreeChecker(20);
merkleProof.leaf <== commitment;
merkleProof.root <== root;
for (var i = 0; i < 20; i++) {
merkleProof.pathElements[i] <== pathElements[i];
merkleProof.pathIndices[i] <== pathIndices[i];
}
The prover must supply a valid Merkle path. Since the tree is append-only and the root is checked against the on-chain ring buffer, this guarantees the commitment was inserted into the tree at some point.
Constraint count: ~5,000 R1CS constraints.
Step 3: Access Tag Verification
The access tag is the key innovation of this circuit. Instead of computing a nullifier (which would be registered on-chain and "spend" the commitment), the circuit computes a session-scoped tag:
accessTag = Poseidon2(nullifierSecret, sessionNonce)
component tagHasher = PoseidonT3(2);
tagHasher.inputs[0] <== nullifierSecret;
tagHasher.inputs[1] <== sessionNonce;
accessTag === tagHasher.out;
The sessionNonce is provided by the verifier (or agreed upon via a protocol). For each new session, a different nonce is used, producing a different access tag.
Constraint count: ~250 R1CS constraints.
Total Constraint Summary
| Constraint Group | Constraints (approx.) |
|---|---|
| Commitment preimage (Poseidon4) | 700 |
| Merkle membership (20 levels) | 5,000 |
| Access tag (Poseidon2) | 250 |
| Total | ~5,950 |
The Access Proof circuit is roughly 35% smaller than GhostRedemption (~9,250 constraints), resulting in faster proof generation and a smaller proving key.
Unlinkability Analysis
The core privacy property of the Access Proof circuit is session unlinkability: given two access tags from different sessions, an observer cannot determine whether they came from the same user.
Why Access Tags Are Unlinkable
Consider a user with nullifierSecret = ns accessing the system in two sessions:
Session 1: sessionNonce = n1 → accessTag1 = Poseidon2(ns, n1)
Session 2: sessionNonce = n2 → accessTag2 = Poseidon2(ns, n2)
An observer sees (n1, accessTag1) and (n2, accessTag2). To link these sessions, the observer would need to determine whether accessTag1 and accessTag2 were derived from the same nullifierSecret. This requires inverting Poseidon2 -- finding ns such that Poseidon2(ns, n1) = accessTag1 -- which is computationally infeasible (Poseidon is preimage-resistant).
Comparison with Nullifiers
In GhostRedemption, the nullifier is deterministic: the same commitment always produces the same nullifier. This is by design -- it prevents double-spending. But it also means that if a commitment's nullifier is spent, the commitment is permanently identifiable.
Access tags avoid this tradeoff:
| Property | Nullifier | Access Tag |
|---|---|---|
| Derivation | Poseidon2(Poseidon2(ns, commitment), leafIndex) | Poseidon2(ns, sessionNonce) |
| Deterministic? | Yes (same commitment always gives same nullifier) | No (different nonce each time) |
| Registered on-chain? | Yes (NullifierRegistry) | No |
| Links sessions? | N/A (single use) | No (different tag per session) |
| Prevents replay? | Yes (cannot reuse nullifier) | Yes (nonce uniqueness enforced by verifier) |
Replay Prevention Without Nullifiers
Since access tags are not registered on-chain, how does the system prevent replay? The verifier controls the sessionNonce:
- The verifier generates a fresh random
sessionNoncefor each session - The prover computes
accessTag = Poseidon2(nullifierSecret, sessionNonce)and generates a proof - The verifier checks the proof and records the
(sessionNonce, accessTag)pair - If the same
sessionNonceis reused, the verifier rejects it (nonce uniqueness)
This shifts replay prevention from the blockchain (nullifier registry) to the verifier application. For PersistentKeyVault, the verifier is the on-chain contract that tracks used session nonces.
Use Case: PersistentKeyVault
The PersistentKeyVault uses the Access Proof circuit to enable reusable encrypted keys:
Each session produces a unique (sessionNonce, accessTag) pair. An observer monitoring PersistentKeyVault sees a stream of access proofs but cannot:
- Link any two proofs to the same user
- Determine which commitment in the tree the user is proving knowledge of
- Recover the encryption key from the access tag
Proof Generation Example
import * as snarkjs from "snarkjs";
import { poseidon4, poseidon2 } from "poseidon-lite";
import { ethers } from "ethers";
const BN254_FIELD = 21888242871839275222246405745257275088548364400416034343698204186575808495617n;
// User's persistent key data (stored locally)
const secret = 0x1a2b3c4d5e6f...n;
const nullifierSecret = 0x7a8b9c0d1e2f...n;
const blinding = 0x3a4b5c6d7e8f...n;
const encryptionKey = new Uint8Array([/* ... */]);
const dataHash = BigInt(ethers.keccak256(encryptionKey)) % BN254_FIELD;
// Merkle path (fetched from indexer)
const pathElements = [/* 20 sibling hashes */];
const pathIndices = [/* 20 direction bits */];
// Session nonce (from verifier)
const sessionNonce = 0xdeadbeef...n;
// Compute expected access tag (for verification)
const expectedTag = poseidon2([nullifierSecret, sessionNonce]);
// Generate the proof
const circuitInputs = {
// Public inputs
root: merkleRoot,
dataHash: dataHash,
sessionNonce: sessionNonce,
accessTag: expectedTag,
// Private inputs
secret: secret,
nullifierSecret: nullifierSecret,
blinding: blinding,
pathElements: pathElements,
pathIndices: pathIndices,
};
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
circuitInputs,
"/circuits/AccessProof.wasm",
"/circuits/AccessProof.zkey"
);
// publicSignals = [root, dataHash, sessionNonce, accessTag]
console.log("Access tag:", publicSignals[3]);
// Submit proof to PersistentKeyVault for verification
Security Properties
Soundness
A prover cannot generate a valid Access Proof if:
- They do not know
(secret, nullifierSecret, blinding)for a valid commitment - The commitment is not in the OpenCommitmentTree at the given root
- The
accessTagdoes not equalPoseidon2(nullifierSecret, sessionNonce)
Zero-Knowledge
The proof reveals nothing about:
- Which commitment in the tree the prover knows
- The
secret,nullifierSecret, orblindingvalues - The Merkle path (leaf position)
- Any relationship between access tags from different sessions
Non-Transferability
The access proof demonstrates knowledge of a specific nullifierSecret. This means the proof cannot be generated by someone who only knows the accessTag -- they must know the underlying secret. However, if a user shares their nullifierSecret with another party, that party can generate access proofs. This is a feature for delegation scenarios and a consideration for access control design.
Forward Secrecy Considerations
Access tags from past sessions do not compromise future sessions. Even if an attacker learns the sessionNonce and accessTag from a past session, they cannot:
- Derive the
nullifierSecret(Poseidon preimage resistance) - Predict the
accessTagfor a future session with a different nonce - Link the past session to a specific commitment
However, access tags do not provide backward secrecy: if the nullifierSecret is later compromised, all past access tags can be recomputed and linked. This is inherent to any authentication scheme based on a long-lived secret.