Security Model
zKPnote is designed with a zero-knowledge architecture. The server and blockchain never have access to plaintext note content.
Encryption
Algorithm
- Cipher: XChaCha20-Poly1305 (via libsodium)
- Key Derivation: HKDF from BIP-39 seed or Phantom wallet signature
- Key Size: 256-bit
- Nonce: 24-byte random nonce per encryption operation
What Is Encrypted
- Note title and content (individually encrypted)
- Folder names
- User profile (username, email, bio, profile picture)
What Is NOT Encrypted
- Note ID, folder ID (UUIDs, non-sensitive)
- Timestamps (createdAt, updatedAt)
- Folder hierarchy (parentId relationships)
- On-chain proof hash (SHA-256 of the plaintext note, not the encryption key)
Key Management
Unified Derivation
Both seed phrase and Phantom modes produce the same encryption key and auth keypair for the same wallet. This means a user can log in either way and access the same vault with the same notes.
Seed Phrase Mode
- 12-word BIP-39 mnemonic generates a 64-byte seed
- BIP-44 derivation at
m/44'/501'/0'/0'produces the Solana keypair (same address as Phantom and all standard Solana wallets) - The keypair signs a deterministic message:
"ChainNotes-vault-encryption-key-v1" - First 32 bytes of the signature derive the encryption key via HKDF
- Last 32 bytes derive an auth keypair for silent API authentication
- The seed phrase is the single root of trust; losing it means losing access
Phantom Mode
- User signs the same deterministic message:
"ChainNotes-vault-encryption-key-v1" - Ed25519 signatures are deterministic, so the same wallet always produces the same 64-byte signature
- First 32 bytes of the signature derive the encryption key (identical to seed phrase mode)
- Last 32 bytes derive an auth keypair for silent API authentication (identical to seed phrase mode)
- The connected Phantom wallet handles on-chain transaction signing
Key Storage
- Keys are held in memory only (React state)
- No keys are persisted to localStorage or cookies
- Locking the vault clears all key material from memory
- Page refresh requires re-entering the seed phrase or reconnecting Phantom
Phantom Signature Caching
- The Phantom wallet signature used for key derivation is cached in
sessionStorage - This avoids requiring a Phantom popup on every page refresh within the same session
sessionStorageis scoped to the browser tab and is automatically cleared when the tab or browser window is closed- This is the only value stored in
sessionStorage; no encryption keys or private keys are cached
On-Chain Security
Access Control
- NoteProof: PDA seeded with
["proof", note_hash]. The first wallet to register a given hash owns the proof. Once created, the proof is immutable. - ProgramConfig: PDA seeded with
["config"]+has_one = authority. Only the authority (deployer) can update treasury/fees. - ExecuteSale: Treasury account validated on-chain against config via
constraint = treasury.key() == config.treasury.
Per-Note Proof System
- Each note can have an on-chain proof registered via
register_proof - The proof hash is
SHA-256(title + "\n" + content), computed client-side - PDA derivation from the hash ensures exactly one proof can exist per unique content
- First-to-register wins: once a proof is created, no other wallet can claim the same hash
- This prevents content theft claims — the on-chain timestamp proves who registered the content first
Arithmetic Safety
- Fee calculation uses
checked_mulandchecked_subto prevent overflow/underflow - Price must be greater than zero (enforced by
require!macro)
Transaction Atomicity
execute_saleperforms two CPI transfers in a single transaction- If either transfer fails, the entire transaction reverts
- No partial payment states are possible
API Security
Rate Limiting
All API routes (/api/vault, /api/marketplace, /api/proof, /api/rpc) are rate-limited using Upstash Redis (@upstash/ratelimit). This provides distributed rate limiting across Vercel edge instances — requests from the same IP are tracked globally, not per-instance. In local development, falls back to an in-memory store.
RPC Proxy Whitelist
Browser-side Solana RPC calls are routed through /api/rpc to avoid exposing the RPC endpoint directly. The proxy enforces a strict whitelist of 11 permitted methods:
getBalance,getLatestBlockhash,getRecentBlockhash,sendTransaction,confirmTransaction,getTransaction,getSignatureStatuses,getAccountInfo,getMinimumBalanceForRentExemption,requestAirdrop,getMultipleAccounts
Any method not on this list returns HTTP 403.
API Authentication
- API requests are authenticated using Ed25519 signatures from the auth keypair
- The server verifies the signature against the claimed public key
- In Phantom mode, the auth keypair is derived from the wallet signature, enabling silent API calls without Phantom popups
Remote MCP Endpoint (/api/mcp)
The remote MCP endpoint at https://www.zkpnote.com/api/mcp gives Claude mobile and claude.ai direct vault access. Its security model differs from the stdio MCP server (which keeps the seed phrase local) in ways worth calling out:
Trust model
- Seed phrase is server-side. The endpoint is single-tenant: one
ZKPNOTE_SEED_PHRASEper Vercel deployment. You are trusting the host (yourself, if self-deployed) with the key material the same way you'd trust any self-hosted app's master secret. - Bearer token gate. Every request must include
Authorization: Bearer <ZKPNOTE_MCP_TOKEN>or?token=<...>matching the server-side env var. The token acts as a shared secret equivalent to a password — anyone who holds it has full vault read/write access. - No session. Auth is checked on every request; there are no cookies or JWTs. Rotation = change
ZKPNOTE_MCP_TOKENin Vercel, redeploy, update connector URL. - Fail-closed on misconfig. If
ZKPNOTE_MCP_TOKENis unset, the endpoint returns HTTP 500 (Server misconfigured) rather than silently accepting unauthenticated requests. - Transport: HTTPS only (enforced by Vercel), stateless per-request — no event-store, no session persistence, no background connections.
Additional threats beyond the stdio model
| Threat | Mitigation |
|---|---|
| Connector URL + token leak (screenshot, shared config) | Rotate ZKPNOTE_MCP_TOKEN; revoke the old one by redeploy |
| Mobile client stores token in app logs / cloud-backup | Treat token as sensitive; rotate after device resale or account change |
| Vercel env-var exposure (insider / platform compromise) | Seed phrase is at platform risk; defense-in-depth = rotate seed + re-encrypt vault (future accounts re-key flow) |
| Redirect auth-header stripping | Always use https://www.zkpnote.com/api/mcp (not bare zkpnote.com — 307 redirect drops Authorization) |
Rate limiting on /api/mcp | Not yet enforced on MCP route; planned when multi-tenant auth lands |
What's not yet implemented
- Per-user authentication — the alpha endpoint is shared-secret single-tenant, not per-account. Multi-tenant token or OAuth flow is the next roadmap item.
- Rate limiting on
/api/mcp— Upstash Redis rate limits already exist on/api/vault(which the MCP tools call internally), so vault operations are indirectly rate-limited, but the MCP route itself has no per-token quota yet. - Audit log — MCP tool invocations are not yet recorded server-side.
Proof Verification Privacy
- The
/verifypage is public and requires no authentication - Hash computation happens entirely client-side — the plaintext content entered for verification never leaves the browser
- Only the computed hash is sent to the server for lookup
- This means a user can verify whether content has been registered without exposing the content to the server
Content Protection
Similarity Detection
Two-tier similarity checking protects content originality:
- Marketplace tier (70%): New listings are compared against all existing marketplace listings using word-trigram Jaccard similarity. Listings with >= 70% similarity to another seller's content are rejected.
- Proof tier (90%): New listings are also compared against all on-chain proved content from other authors. Listings with >= 90% similarity to another author's proved work are rejected, even if that content was never listed for sale.
- Sellers are excluded from both comparisons against their own content (same wallet address)
Purchase Tagging
- Notes acquired from the marketplace are tagged with the source listing ID
- Tagged notes are blocked from being listed for sale at both the client and server level
Per-Note Proof
- On-chain proofs provide cryptographic evidence of authorship with a Solana-timestamped record
- If a content theft dispute arises, the proof with the earlier
created_attimestamp establishes priority - Proofs are stored in the Supabase
proofstable for fast lookup and similarity search via the/api/proofendpoint
Threat Model
| Threat | Mitigation |
|---|---|
| Server compromise | Server only stores encrypted data; no plaintext access |
| Man-in-the-middle | All encryption happens client-side before transmission |
| Unauthorized vault access | Requires seed phrase or Phantom wallet signature |
| Content theft via marketplace | Similarity detection + purchase tagging |
| Content theft claims | Per-note on-chain proof with first-to-register timestamp |
| Treasury theft | Treasury address validated on-chain; only authority can change it |
| Fee manipulation | Fee basis points stored on-chain; only authority can update |
| Replay attacks | API auth uses fresh signatures with timestamp validation |
| Session hijacking via cached signature | Phantom signature cached in sessionStorage only; cleared on tab close |
| API abuse / DDoS | Distributed rate limiting via Upstash Redis across all edge instances |
| RPC endpoint abuse | Whitelist restricts Solana RPC proxy to 11 permitted methods; all others return 403 |
| Remote MCP endpoint abuse | Bearer-token gate (ZKPNOTE_MCP_TOKEN) on every request; misconfigured deployments return 500 (fail closed) rather than allowing anonymous vault access |
| Remote MCP seed-phrase exposure | Seed phrase lives only in the Vercel deployment's env vars and memory; never logged, never persisted to disk, never sent to the MCP client |
Limitations
- Browser memory: Keys exist in JavaScript memory during a session, which is subject to browser-level attacks (malicious extensions, XSS)
- Similarity detection: Trigram-based comparison may not catch content that has been substantially rewritten
- Off-chain bids: Auction bids are not escrowed on-chain; the winner pays at settlement
- No key rotation: Changing the seed phrase creates a new vault; migrating an existing vault to a new key is not currently supported
- Proof immutability: Once a proof is registered on-chain, it cannot be updated or deleted. If content changes, a new proof must be registered with the updated hash.