Keys and identities
Every key, secret, identity, and capability zkCoins defines — first how they relate to one another, then the complete list with each value's origin, holder, and reach. Each value is defined normatively in the Specification; this page explains and indexes those definitions.
Every long-term key of the account descends from one seed. The name does not — it is a label the API and app layers issue and the holder attests — and neither does the fresh randomness a transition draws, nor any key belonging to another party. That split is what the rest of this page organises itself around.
How the keys fit together
The inventory below lists each value on its own. This section explains how they relate — what produces what, and what binds what to what.
Three kinds of arrow, and the difference between them is the whole point. Solid is derivation — one value computed from another, so the target cannot exist without the source. Thick is carriage — the profile publishes a copy of a value it did not compute; a wrong copy is caught by the signatures and by nothing else. Dotted is a signature by sk₀, the key that never leaves the device. The name hangs off the profile by carriage and off sk₀ by signature; nothing derives it.
One seed, one tree, five jobs
A user writes down twelve words. Those become one long secret number, the seed, and every other key is computed from it by a fixed recipe. That is why the twelve words are enough to restore an account: whoever holds the seed can recompute every long-term key it owns. What the seed does not cover is the name, the fresh per-transition randomness esk and npk_rand, and any key belonging to someone else — a grantee, a replica holder, a network trust root. nav_rand is the one piece of randomness that is reproducible from the seed, which is why it has a branch of its own.
The tree exists because an account does five separable jobs, and only the first of them must stay on the user's own device:
| Job | Branch | Who should be able to do it |
|---|---|---|
| Spend money | A/0' | the user's device alone |
| See incoming coins | A/1' | also the node, so it can watch around the clock |
| Speak — chat, profile, discovery | A/2' | also the node, so the account is reachable while the user sleeps |
| Void spent coins | A/3' | also the node, so it can build proofs |
| Reproduce a past proof's randomness | A/4' | also the node — op_secret keys the nav_rand derivation, so a rebuilt node can reopen any prior commitment |
The separations are hardened, which makes the tree a one-way street: a parent can compute its children, but a child can reach neither its parent nor its siblings. A node holding the view, speak, void, and reproduce branches therefore cannot compute the spend branch. It sees everything and can take nothing.
Two recurring notions
Secret and public key. From a secret number sk a public number Pk can be computed, never the reverse. Lower case means secret (sk₀, ivk, op, nk); a capitalised name or a pubkey suffix means public (Pk₀, IVPK, op_pubkey). The public half can be printed anywhere.
Commitment. A hash of a secret. nk_commit is a commitment to nk: it can be published, it proves later that the same nk is meant, and it reveals nothing about it — a sealed bet.
The address is two things in one
address = hash( Pk₀ ‖ nk_commit )
│ │
"who may spend" "which serial numbers belong to this account"
Pk₀ alone would say who may sign. Folding in nk_commit also fixes which nullifier the account will use to void a coin. Without that second ingredient a holder could register two accounts, with two different nk, under one address — the correspondence address ↔ account would stop being one-to-one. Double-spending is closed separately, by Bitcoin first-occurrence on Pk₀; nk_commit closes the equivocation. Because both are baked in at account creation, the address is immutable for the life of the account.
Note what is not in the address: neither IVPK nor op_pubkey. Those are joined to it by signature, not by arithmetic — see Two identities below.
Paying someone you have never spoken to
A sender knows only the recipient's public IVPK. That is enough to produce a secret only the recipient can reconstruct:
sender draws: esk a throwaway secret, one per coin
sender computes: epk = esk·G published alongside the coin
sender computes: ss = esk × IVPK a shared secret
recipient: ss = ivk × epk the same value, by a different route
Two different computations, one result, no interaction. A relay that sees epk cannot reach ss, because that would take either esk or ivk.
From ss come two separate things:
ss ──┬── K_tx the key that opens this one coin SECRET
└── detect_tag a sticker saying "this one is yours" PUBLIC
The sticker has to be public, or the recipient would have to trial-decrypt every message on the network. Instead it does one small computation per candidate and knows immediately whether the message concerns it. And because the sender draws a fresh esk for every coin, every sticker differs — so no observer can group two payments as going to the same recipient.
The two are safe to derive from one ss because they are separated by distinct context strings and distinct primitives: the public sticker does not lead back to the secret key.
Why the spend key changes every time
Each time the account moves, a new spend key is used — sk₀, then sk₁, sk₂, and so on. The old public key is spent and written to Bitcoin, which is what marks that state as consumed:
state 0 ──[Pk₀ published]──▶ state 1 ──[Pk₁]──▶ state 2 ──▶ …
A chain observer sees a sequence of unrelated numbers. It cannot attribute Pk₀ and Pk₁ to one account, because the next key never appears in the clear — it lives only inside a hashed, off-chain state.
Pk₀ is the exception: it is fixed in the address. That is why sk₀ — the very first key — is the one that proves ownership of this address. It signs addr_sig, name_sig, and every OwnershipProof. The rotating skᵢ sign only the individual transition.
Two identities, and the two signatures that join them
An account has two identities, both seed-derived, both immutable, with different jobs:
address | op_pubkey | |
|---|---|---|
| Is | where money goes | the Nostr identity — who speaks, sends, and is found |
| Appears in | profiles, invoices, coins, and proofs — not on Bitcoin, which carries the per-transition nullifier (Pkᵢ, Rᵢ) instead | Nostr relays |
| Needs | sk₀, on the device | the node may hold op |
op_pubkey is an ordinary Nostr account of the kind any Nostr client understands. Everything human runs on it: encrypted chat under NIP-17, the kind-0 profile that carries the payment fields, the kind-10050 relay list that says where to reach the account, and the NIP-05 name that resolves to it. It is also what a contact is pinned to.
The two identities are joined by two signatures, both made with sk₀:
addr_sig — "I, the holder of this address, confirm: encrypt to this IVPK, reach me on these relays, and the Nostr identity is this op_pubkey."
It is needed because Pk₀ and nk_commit are public. Without it, an attacker could publish a profile quoting the genuine address but substituting its own IVPK and op_pubkey; a payer would then encrypt the coin to the attacker. addr_sig makes that forgery infeasible, and it is why issuing an Invoice requires the wallet.
name_sig — "I, the holder of this seed, consent to user@domain belonging to this op_pubkey."
What it closes: the node holds op and signs the profile, so without name_sig a hosting provider could attach a name to a payment identity it does not own and whose holder never agreed. With it, only the seed holder can.
What it does not close, and this matters: it says nothing about who is entitled to the name. A malicious NIP-05 provider — or whoever controls that domain — can point [email protected] at an identity that is wholly its own, sign a perfectly valid name_sig with that identity's sk₀, and every check passes. On first contact, DNS and the name provider are trusted for name ownership; name_sig narrows who can bind a name to an existing identity, not who may claim a name. Nothing but an out-of-band check of the key itself closes that.
Both use sk₀ rather than op because op is node-held: an op signature over a name would attest only that the node asserted it.
What a payer actually checks
user@domain
│ resolve via NIP-05
▼
op_pubkey the Nostr identity
│ fetch the profile authored by it
▼
profile { address, Pk₀, nk_commit, IVPK, relays }
│
├── hash(Pk₀ ‖ nk_commit) == address ? the parts match the address
├── addr_sig valid under Pk₀ ? the holder authorised these fields
├── event signature valid under op_pubkey ? it came from this identity
└── name_sig valid under Pk₀ ? the name really belongs to it
│
▼
pin op_pubkey and {address, Pk₀, nk_commit, IVPK}, then encrypt to IVPK
If any check fails, the profile is not payable. What gets pinned is the key, never the name: a contact who changes name keeps the conversation, and the same name under a different key is a different contact.
The rest of this page is the inventory.
1 · Seed and roots
| Name | Type | Origin | Held by |
|---|---|---|---|
mnemonic | 12-word BIP-39 phrase | generated by the user | user |
seed (seed64) | 512-bit secret | PBKDF2-HMAC-SHA512(mnemonic, 2048 iterations, empty passphrase) | wallet |
m | BIP-32 master node | from seed | wallet |
A | account root | m/1798'/account', hardened; 1798' is the zkCoins BIP-43 purpose | wallet |
prf_salt | 32-byte constant | SHA-256("zkCoins/v1/PasskeyPRF") | public — v2 feature |
prf_output | 32-byte secret | WebAuthn PRF extension, eval.first = prf_salt | authenticator — v2 feature |
A v1 wallet derives the seed exclusively from the mnemonic with an empty passphrase; the BIP-39 passphrase and the Passkey-PRF seed source are both v2 and a v1 wallet must not use them. Account indices are assigned densely and monotonically so seed recovery terminates. See §1.2.
2 · The derivation tree
seed ─ BIP-32 ─▶ m
└─ m/1798'/account' = A account root
├─ A/0' SPEND branch wallet only
│ ├─ A/0'/0' = sk₀ → Pk₀ fixes the address
│ └─ A/0'/i' = skᵢ → Pkᵢ rotates per transition
├─ A/1' VIEW branch delegable to a node
│ ├─ A/1'/0' = ivk → IVPK = ivk·G incoming
│ └─ A/1'/1' = ovk outgoing
├─ A/2' = op → op_pubkey = op·G Nostr identity
├─ A/3' = nk nullifier key
├─ A/4' = op_secret keys the nav_rand HKDF
└─ A/5'/j' = op_j RESERVED, unused in v1
Every branch separation is hardened, so a party holding the VIEW branch, op, nk, or op_secret cannot derive the SPEND branch.
SPEND branch — A/0', wallet only
| Name | Type | Origin | Role |
|---|---|---|---|
sk₀ | secp256k1 scalar, secret | A/0'/0' | signs addr_sig, name_sig, and every OwnershipProof |
Pk₀ | BIP-340 x-only, 32 B | sk₀·G | fixes the address; is creator_pubkey at issuance |
skᵢ | secp256k1 scalar, secret | A/0'/i', i = send_counter | signs transition i's txn_sig |
Pkᵢ | x-only, 32 B | skᵢ·G | the state's current_pubkey; published as the on-chain nullifier key |
Pkᵢ₊₁ (next_pubkey) | x-only, 32 B | A/0'/(i+1)' | the rotation target; never appears on-chain |
There is no per-coin signing key. One transition signature authorises the whole transition (§1.2 Spend-key model).
VIEW branch — A/1', delegable
| Name | Type | Origin | Can |
|---|---|---|---|
ivk | scalar, secret | A/1'/0' | detect and decrypt incoming coins; it is also the recovery scan key |
IVPK | x-only, 32 B, public | ivk·G | the target of every ECDH encryption to this account |
ovk | scalar, secret | A/1'/1' | recover outgoing coin plaintext through out_ciphertext |
Operational keys — A/2' to A/4'
| Name | Type | Origin | Role |
|---|---|---|---|
op | scalar, secret | A/2' | the Nostr identity key; held by the node |
op_pubkey | x-only, 32 B | op·G | the Nostr identity itself |
nk | scalar, secret | A/3' | computes nullifiers in the proving witness; links the account's own spends |
nk_commit | Poseidon digest, 32 B, public | Hc("NkCommit", nk) | a committed AccountState field and part of the address preimage |
op_secret | 256-bit secret | A/4' | keys the nav_rand HKDF; kept separate from op so NAV randomness never shares material with the Nostr signature |
Who holds what
| Key | Held by | Can do | Cannot do |
|---|---|---|---|
skᵢ | wallet only | authorise spends | — |
nk | wallet and the wallet's own node | compute nullifiers | spend; it links the account's own spends, so it goes to no foreign node |
ivk | wallet and any node it delegates to | detect and decrypt incoming coins | spend |
ovk | same | recover outgoing plaintext | spend |
op | the node | act as the Nostr identity, send and read NIP-17, sign profiles, relay lists, grants, and acknowledgements | spend, decrypt others' coins |
K_tx | derived per coin, shareable | decrypt exactly one coin | spend, see any other coin |
The operational bundle is {ivk, ovk, op, nk, op_secret} — what a wallet entrusts to its own node so the node can receive, prove, and serve on its behalf. None of it can spend. A foreign node receives a scoped view grant instead (§6.2, §5.2).
3 · Per-coin keys
Fresh for every output coin, so one per-coin capability discloses one coin and nothing else (§1.3).
| Name | Type | Origin | Reach |
|---|---|---|---|
esk | fresh random scalar | sender, per coin | one coin |
epk | x-only, 32 B, public | esk·G | published with the coin; carried as the zkepk scan tag |
ss | 32-byte shared secret | ECDH(esk, IVPK) = ECDH(ivk, epk) | one coin |
K_tx | 32-byte symmetric, secret | HKDF("zkCoins/v1/NoteKey", ss ‖ epk) | decrypts exactly one coin |
detect_tag | Poseidon digest, public | Hc("zkCoins/v1/DetectTag", ss ‖ epk) | the scan tag; all of one recipient's tags are distinct |
K_out | 32-byte symmetric, secret | HKDF("zkCoins/v1/OutKey", ovk ‖ epk) | the sender's own recovery of K_tx |
kb | 32-byte ChaCha20-Poly1305 key | HKDF("zkCoins/v1/BlobKey", K_tx) | one ZBE blob |
K_tx and detect_tag share ss but are domain-separated by distinct context strings and distinct primitives — HKDF-SHA-256 against Poseidon — so the public tag does not weaken the secret key. The tag must derive from ss because the sender sets it and holds only the recipient's public IVPK. A K_tx must encrypt at most one bundle plaintext, or (kb, nonce_i) repeats (§4.2.1).
4 · Other secrets and blinding values
| Name | Type | Origin | Rule |
|---|---|---|---|
npk_rand | 32 unmodified CSPRNG bytes | drawn fresh per proving attempt | never derived deterministically, never reused; fail-closed if no CSPRNG |
npk_commit | SHA-256 digest, public | H("zkCoins/v1/NpkCommit" ‖ next_pubkey ‖ npk_rand) | the sixth ProofData field, recomputable by a thin wallet |
nav_rand | 32 bytes | HKDF("zkCoins/v1/NavRand", op_secret ‖ u64-be(send_counter)) | deterministic, so a fresh node rebuilds any prior opening; never derived from nav |
nav / nav_commitment | accumulator value / Hc("NavCommit", nav_root ‖ nav_rand) | chain-derived | the opening {nav, nav_rand} travels only to a coin's recipient or a disclosure verifier |
terms_salt | 32-byte secret | issuer, token standard 2 only | blinds cap_total in AssetIdV2 and terms_hash |
R' / R | secp256k1 points | R = R' + H(bytes(R') ‖ H(ProofData))·G | R is the on-chain nonce; the pre-tweak R' goes to the publisher |
npk_rand is why a hosted prover cannot capture the rotation: the wallet computes npk_commit from its own next_pubkey and its own randomness and refuses to sign unless the surfaced value matches (§2.1 clause 2).
5 · Identities
| Identity | Origin | Role |
|---|---|---|
address = H(Pk₀ ‖ nk_commit) | seed-derived, fixed at account creation | Bech32m HRP zk; the protocol's only value-bearing payment identifier |
op_pubkey = op·G | seed-derived | the Nostr identity, and what a contact is pinned to |
Name user@domain | not seed-derived; issued by the API and app layers | the public label; exactly one in force, attested by name_sig |
nprofile | op_pubkey plus relay hints | the NIP-19 identifier, the DNS-free route to a contact |
publisher_pubkey | the publisher's op_pubkey — a role of that identity, not a separate one | the author of kind 30421; how a spender names a publisher |
operator_id — a role of an op_pubkey, not a separate identity | the operator's own identifier in OperatorEndpointV1 | endpoint gossip; global infrastructure only, never account- or blob-specific |
Binding nk_commit into the address preimage makes the nullifier key part of the payment identity, so a coin sent to an address has exactly one valid nullifier and a holder cannot equivocate two accounts under one address (§1.4). Accounts and addresses are one-to-one: there are no diversified addresses, sub-addresses, or change addresses, so the account is the unit of every isolation boundary (§1.2).
A client pins op_pubkey and the payment identity {address, pk0, nk_commit, ivpk} on first successful verification. A later profile may silently update only zkcoins.relays; a changed address, pk0, nk_commit, or ivpk must raise a warning even under a valid signature, because the holder of op is not necessarily the holder of the address. A changed name is adopted only once its name_sig verifies under the pinned pk0 and a forward NIP-05 lookup returns the pinned op_pubkey; while that lookup is unavailable the contact shows as unresolved and neither the old nor the new name is presented, though messaging and payment continue by key (§4.3).
6 · Bearer capabilities
Each is a Bech32m string under its own HRP, so the encodings can never be confused; a value under the wrong HRP must be rejected (§1.7.7, §5.4).
| HRP | Payload | Reach | Forward-stoppable? |
|---|---|---|---|
zk | address, 32 B | — | — |
zkview | K_tx, 32 B | exactly one coin | no — pure bearer |
zkavk | ivk ‖ ovk (64 B), or ivk alone (32 B, incoming-only) | the whole account history | no |
zkgrant | the full ViewGrant serialization, op-signed | an asset list × a time window, bound to grantee key D | yes at nodes you operate — never retroactive |
zkbid | blob_id = H(ciphertext), 32 B | a locator, not a key | — |
zkatt | SHA-256(BalanceAttestationV1), 32 B | a balance attestation's content handle | — |
The 90-character limit of BIP-173/BIP-350 does not apply to these HRPs: a zkavk and a zkgrant payload exceed it by construction.
zkview, zkavk, and the balance attestation are not node authorisations. They are client-side decryption secrets an explorer applies to material it obtains from the relay mesh, so they widen only what their holder can already decrypt. Each must therefore travel in the URL fragment, never in a path or query string (§5.1, §5.6).
For an account-wide disclosure, prefer a scoped zkgrant over zkavk — not because it can be retracted, since nothing un-sees what a grantee already read, but because it carries an expiry and can be refused for future pulls at nodes you operate (§5 Consent is one-way).
7 · Request authentication
| Item | Key | Purpose |
|---|---|---|
nonce | 32 bytes, node-issued | the challenge; consumed on use, 60-second window |
chan_bind | from the API layer's authoritative public hostname or onion key — never re-derived by the kernel from forwarded request metadata | channel binding that survives TLS-terminating proxies and is computable in a browser |
tls-exporter | RFC 9266, 32 bytes | optional extra hardening; must never be required |
| OwnershipProof | sk₀ over chal | the only authority for /v1/pull, /v1/attest/balance, and /v1/grants |
| GrantProof | the grantee's secret d for D | delegated pull; must not mint further grants |
| Pull session token | node-generated, opaque | bearer credential for the follow-up GETs, with its own expiry independent of the nonce window; the client cannot mint, narrow, or forge it |
D is an ordinary BIP-340 key pair belonging to the grantee — it is not derived from the subject's seed. Possession of the operational bundle on a hosted node never substitutes for this authentication: the bundle enables the node to perform the action, the proof authenticates who may trigger it (§5.1, §7.5).
8 · Signatures
| Signature | Key | Attests |
|---|---|---|
txn_sig (in the SpendRecord) | skᵢ | the transition, over the per-network constant m_state, sign-to-contract-bound to H(ProofData) |
addr_sig | sk₀ | that the address holder authorised exactly these payment fields, including the choice of ivpk and op_pubkey |
name_sig | sk₀ | that the seed holder consented to this name |
sig (on an Invoice) | op | the per-issuance authorisation by the recipient's online key |
| Event signatures | op | kinds 0, 10050, 30421, and the NIP-59 kind-13 seal |
| Gift-wrap signature | a fresh one-time key, per event | the kind-1059 wrapper — never op, which is what keeps the two copies unlinkable |
op_sig | op | that the recipient acknowledged a delivery (kind-1421 ACK) |
op_signature (in a ViewGrant) | op | the scope and window of a delegation |
op_sig (in OperatorEndpointV1) | the operator's op | endpoint gossip |
receipt_sig (in ReplicaReceiptV1) | the replica holder's op, verified under holder_op_pubkey | that both the blob and its delivery event are durably committed |
manifest_sig (in BootstrapManifestV1) | bootstrap_pubkey | the network's bootstrap infrastructure list |
name_message = H( "zkCoins/v1/NameConsent" ‖ network ‖ u32-be name_len ‖ UTF-8(name) ‖ op_pubkey )
name_sig = BIP-340(sk₀, name_message)
addr_sig and name_sig come from sk₀ rather than op because op is node-held: an op signature over a name attests only that the node asserted it, while an sk₀ signature attests the seed holder. The operational consequence is that issuing an Invoice requires the wallet, the same custody boundary that governs sending. txn_sig is the only Schnorr object placed on-chain and half-aggregated — there is no separate publisher proof or publisher signature over shared state (§1.4, §3.2, §4.3).
9 · Network-level keys
| Name | Type | Origin |
|---|---|---|
bootstrap_pubkey | BIP-340 x-only, 32 B | one per network, pinned in the frozen network parameter set; mainnet pins at genesis, testnet and regtest pin their fixtures |
| Network parameter set | {network_tag, circuit_digest(C), circuit_digest(C_balance), activation_height, finality_confirmations = 6, bootstrap_pubkey} | content-addressed and byte-identical across every node of a network |
| Reveal-transaction key | a Bitcoin key | the publisher's Bitcoin identity |
bootstrap_pubkey is the only authority permitted to sign a BootstrapManifestV1; a manifest that does not verify under the pinned key is rejected, and there is no other trust root for bootstrap (§3.6, §4.3).
The publisher has no protocol key material of its own. Because v1 publishing is sponsored and carries no fee (§3.8), there is no fee address to bind and therefore no payment-identity block to sign: its kind-30421 profile is {version, relays} under its op key, and its Bitcoin identity is the reveal-transaction key. Running a publisher takes an op key, a relay presence, and an HTTP endpoint for the hand-off — no account, no address, no sk₀, no wallet. The deferred paid variant would need all four (§3.8.1).
10 · Derived values that are not keys
These appear alongside the key material and are sometimes mistaken for it. All are public digests or commitments, reproducible from their inputs (§1.4, §1.5).
ash (account state hash) · ocr (output coins root) · inr (input nullifiers root) · coin_history_root · nf (the in-circuit per-coin nullifier) · coin.identifier · asset_id and AssetIdV2 · terms_hash · blob_id · record_id and coin_id · manifest_id · network_id · m_state · circuit_digest.
Where to read further
- Specification §1.2 — the normative key hierarchy and the who-holds-what table
- Specification §1.3 — per-coin derivation, detection, and outgoing recovery
- Specification §1.4 — every identifier's exact derivation
- Specification §4.3 — names, name consent, and contact pinning
- Specification §5 — capabilities, grants, and disclosure
- Architecture — which component holds which of these