Skip to main content

Persistent Phantom Keys

Standard phantom keys are one-time instruments: you reveal once and the nullifier is spent forever. Persistent phantom keys are fundamentally different — the nullifier is not spent during access. The commitment remains live in the Merkle tree and can be accessed repeatedly until explicitly revoked.

This enables a category of applications impossible with one-time keys: API keys, software licenses, subscription credentials, and team-shared secrets.

One-Time vs Persistent

PropertyOne-Time KeyPersistent Key
Nullifier spent on useYes — single useNo — only on revocation
Proof circuitCommitReveal (8 public inputs)AccessProof (4 public inputs)
VaultCommitRevealVaultPersistentKeyVault
Data modelToken amount + secretsEncrypted payload + split key
RevocationImplicit (nullifier spent)Explicit (revoke transaction)
Formatghostchain-v2open-ghost-persistent-v1

Access Proof Circuit

The AccessProof circuit proves that the user knows a valid commitment in the Merkle tree without spending it. It outputs 4 public inputs (compared to 8 for the standard CommitReveal circuit).

Public Inputs

IndexInputDescription
0rootMerkle root the proof is verified against
1dataHashHash of the encrypted data (proves which key is being accessed)
2sessionNonceFresh random nonce provided by the verifier
3accessTagPoseidon2(nullifierSecret, sessionNonce) — anti-replay identifier

Circuit Constraints

1. COMMITMENT INTEGRITY
└─ Poseidon4(secret, nullifierSecret, dataHash, blinding) == commitment

2. MERKLE MEMBERSHIP
└─ MerkleProof(commitment, leafIndex, pathElements, pathIndices) == root

3. DATA HASH BINDING
└─ dataHash == public dataHash input

4. ACCESS TAG DERIVATION
└─ accessTag = Poseidon2(nullifierSecret, sessionNonce)
└─ accessTag == public accessTag input

Why No Nullifier?

In the standard CommitReveal circuit, the nullifier is a public output that gets marked as spent on-chain. The AccessProof circuit replaces the nullifier with an accessTag — a value that is unique per session but does not get permanently recorded. This is what makes repeated access possible.

Access Tag Anti-Replay

The access tag prevents replay attacks without spending the nullifier:

accessTag = Poseidon2(nullifierSecret, sessionNonce)
ComponentSourcePurpose
nullifierSecretFrom the phantom key (private)Binds the tag to the key holder
sessionNonceFrom the verifier (public)Ensures each access produces a unique tag

How Anti-Replay Works

Session 1:
Verifier provides: sessionNonce = 0xaaa...
Prover computes: accessTag = Poseidon2(nullifierSecret, 0xaaa...)
Verifier records: accessTag 0x123... for session 0xaaa...

Session 2:
Verifier provides: sessionNonce = 0xbbb... (fresh nonce)
Prover computes: accessTag = Poseidon2(nullifierSecret, 0xbbb...)
Verifier records: accessTag 0x456... for session 0xbbb...

Replay attempt:
Attacker replays proof from Session 1 against sessionNonce 0xbbb...
✗ Proof is invalid — it was generated with nonce 0xaaa...

Attacker replays proof from Session 1 with nonce 0xaaa...
✗ Verifier rejects — nonce 0xaaa... already used

The verifier maintains a set of used session nonces. Each access requires a fresh nonce, and the ZK proof binds the access tag to that specific nonce. Replaying an old proof with a new nonce fails because the circuit constraints would not hold.

PersistentKeyVault Contract

The PersistentKeyVault manages the on-chain state for persistent phantom keys.

Core Functions

storeKeyPartB

Stores the second half of the split encryption key on-chain:

function storeKeyPartB(
uint256 commitment,
bytes calldata encryptedKeyPartB,
uint256 policyId,
uint256 policyParamsHash,
bytes32 quantumCommitment
) external;
ParameterDescription
commitmentThe Poseidon4 commitment for this key
encryptedKeyPartBSecond half of the AES key, encrypted with the commitment
policyIdRevocation policy ID (0 for default BEARER)
policyParamsHashPolicy parameters hash
quantumCommitmentOptional quantum protection

Called during the seal flow. The commitment is inserted into the Merkle tree and Part B is stored on-chain.

accessKeyPartB

Retrieves Part B after verifying an AccessProof — non-destructive:

function accessKeyPartB(
uint256[8] calldata proof,
uint256 root,
uint256 dataHash,
uint256 sessionNonce,
uint256 accessTag
) external view returns (bytes memory encryptedKeyPartB);
ParameterDescription
proofGroth16 AccessProof
rootMerkle root
dataHashHash of the encrypted data
sessionNonceFresh session nonce
accessTagPoseidon2(nullifierSecret, sessionNonce)

This function is view — it does not modify state. The commitment remains in the tree. The key can be accessed again with a new session nonce.

revokeKey

Permanently invalidates the key by spending its nullifier:

function revokeKey(
uint256[8] calldata proof,
uint256 root,
uint256 nullifier,
uint256 dataHash,
bytes calldata policyParams,
bytes calldata quantumSecret
) external;

Revocation uses a standard CommitReveal proof (not an AccessProof). It spends the nullifier, which makes the commitment permanently unusable. The on-chain encrypted key data is also cleared.

Split Encryption

Persistent phantom keys use split-key encryption to ensure that neither the key file alone nor the chain alone is sufficient to access the protected data.

                    AES Key (256 bits)
─────────────────
/ \
Part A (128 bits) Part B (128 bits)
│ │
▼ ▼
Stored in the Stored on-chain
phantom key file (PersistentKeyVault)

Encryption Flow (Seal)

1. Generate random AES-256 key
2. Encrypt the secret payload with AES-256-GCM
3. Split the AES key into Part A (first 16 bytes) and Part B (last 16 bytes)
4. Store Part A in the phantom key file (encKeyPartA field)
5. Encrypt Part B with a key derived from the commitment
6. Store encrypted Part B on-chain via storeKeyPartB()

Decryption Flow (Access)

1. Import the phantom key file (obtain Part A)
2. Generate AccessProof
3. Call accessKeyPartB() with the proof (obtain encrypted Part B)
4. Decrypt Part B using the commitment-derived key
5. Reconstruct the full AES key: Part A || Part B
6. Decrypt the secret payload with AES-256-GCM

Why Split?

ThreatProtection
Key file stolenAttacker has Part A but not Part B. Cannot decrypt.
Chain data scrapedObserver has encrypted Part B but not Part A. Cannot decrypt.
Key file + chain dataNeed ZK proof to retrieve Part B. Without nullifierSecret, proof cannot be generated.

Both pieces are required, and retrieving Part B requires a valid ZK proof — which requires knowing the secrets in the key file. This creates a circular dependency that protects against each individual attack vector.

Revocation Policies

Each persistent phantom key has a revocation policy that controls who can revoke it.

BEARER (Default)

Anyone who possesses the phantom key file can revoke.

This is the default policy. It mirrors the behavior of one-time keys: possession equals control. The person holding the key file can generate the revocation proof and spend the nullifier.

Use cases: Personal API keys, individual licenses, self-managed credentials.

ISSUER_ONLY

Only the wallet that sealed the key can revoke.

The sealer's address is stored on-chain at seal time. Revocation requires both the ZK proof (from the key file) and a transaction from the sealer's wallet.

function revokeKey(...) external {
// ... verify ZK proof ...

if (revokePolicy == RevokePolicy.ISSUER_ONLY) {
require(msg.sender == keyMetadata[commitment].sealer, "Only issuer can revoke");
}

// ... spend nullifier, clear data ...
}

Use cases: Employer-issued credentials (employee has key, but only employer can revoke), subscription providers (user has access key, but only provider can terminate).

Cryptographic Flows

Seal Flow

The sealer creates a persistent phantom key and stores it on-chain:

  Sealer                                    Specter Chain
────── ─────────────
│ │
│ 1. Generate secrets │
│ 2. Encrypt payload with AES-256-GCM │
│ 3. Split AES key → Part A + Part B │
│ 4. Compute dataHash │
│ 5. Compute commitment = │
│ Poseidon4(secret, nullifierSecret, │
│ dataHash, blinding) │
│ 6. Assemble phantom key file │
│ (includes Part A) │
│ 7. Export phantom key (PNG/PDF/NFC) │
│ │
│──── storeKeyPartB(commitment, ...) ──────►│
│ │ 8. Insert commitment into Merkle tree
│ │ 9. Store encrypted Part B
│ │ 10. Store revocation policy
│◄──── tx receipt + leafIndex ─────────────│ 11. Emit event
│ │
│ 12. Update phantom key with leafIndex │
│ 13. Re-export phantom key │
│ │

Access Flow (Connect)

The key holder accesses the encrypted payload without spending the commitment:

  Key Holder                   Verifier            Specter Chain
────────── ──────── ─────────────
│ │ │
│◄── sessionNonce ────────│ │
│ │ │
│ 1. Import phantom key │ │
│ 2. Fetch Merkle proof │ │
│ 3. Compute accessTag = │ │
│ Poseidon2( │ │
│ nullifierSecret, │ │
│ sessionNonce) │ │
│ 4. Generate AccessProof │ │
│ │ │
│──── proof + accessTag ──►│ │
│ │ │
│ │── accessKeyPartB() ─►│
│ │ │ 5. Verify proof
│ │◄── encrypted Part B ─│ 6. Return Part B
│ │ │
│◄── encrypted Part B ────│ │
│ │ │
│ 7. Decrypt Part B │ │
│ 8. Reconstruct AES key │ │
│ 9. Decrypt payload │ │
│ │ │

Revoke Flow

The authorized party permanently invalidates the key:

  Revoker                                   Specter Chain
─────── ─────────────
│ │
│ 1. Import phantom key │
│ 2. Generate CommitReveal proof │
│ (standard nullifier derivation) │
│ │
│──── revokeKey(proof, nullifier, ...) ────►│
│ │ 3. Verify proof
│ │ 4. Check revocation policy
│ │ 5. Verify quantum preimage (if set)
│ │ 6. Spend nullifier permanently
│ │ 7. Clear on-chain encrypted data
│◄──── tx receipt ────────────────────────│ 8. Emit Revoked event
│ │

After revocation:

  • The nullifier is spent — no further access proofs can be generated
  • The encrypted Part B is cleared from on-chain storage
  • The phantom key file becomes useless
  • Anyone attempting to access will fail at the Merkle membership check (commitment is logically removed)

Use Cases

API Keys

┌─────────────┐     seal      ┌──────────────┐
│ API Provider │──────────────►│ Phantom Key │
│ │ │ (PNG file) │
│ Encrypts: │ │ │
│ - API token │ │ Contains: │
│ - Rate limit│ access │ - Part A │
│ - Scope │◄─────────────│ - ZK secrets │
└─────────────┘ └──────────────┘

The API provider seals an API token as a persistent phantom key. The developer imports the key and generates an AccessProof on each API call. The provider verifies the proof, retrieves Part B, decrypts the API token, and services the request. The developer never exposes the raw API token.

Software Licensing

A software vendor seals a license key as a persistent phantom key with ISSUER_ONLY revocation:

  1. Vendor seals the license with the customer's entitlements encrypted inside
  2. Customer imports the phantom key into the software
  3. On launch, the software generates an AccessProof and verifies the license
  4. If the customer violates terms, the vendor revokes the key on-chain
  5. Next license check fails — the commitment's nullifier is spent

Subscription Content

Content providers can gate access behind persistent phantom keys:

  1. Publisher seals content encryption keys as persistent phantom keys
  2. Subscribers receive phantom key files
  3. On each access, the subscriber's client generates an AccessProof
  4. The content is decrypted with the reconstructed AES key
  5. The publisher can revoke access at any time (ISSUER_ONLY policy)
  6. Each access generates a unique accessTag, providing an anonymized access log

Team Credentials

A team lead seals shared credentials (database password, service account key) as a persistent phantom key:

  1. The same phantom key file is distributed to all team members
  2. Each member can independently generate AccessProofs (different access tags)
  3. If a team member leaves, the key is revoked and re-sealed for remaining members
  4. The access tag log shows how many times the credential was accessed, without revealing which team member accessed it