Skip to main content

Keys and ZIP 32

1. Why this chapter exists

Sapling has eight named keys that derive from one root, plus a diversifier key, plus the ephemeral keys used for encryption. The relationships among them are not documented in any single comment in the source; you have to read four files (keys.rs, zip32.rs, address.rs, spec.rs) and the protocol spec to reconstruct the tree.

By the end you should be able to draw, from memory, the derivation DAG from seed to PaymentAddress, name each edge function, and locate each node in the source.

1.5 A one-page reminder of how a Sapling spend works

Before naming eight keys it helps to remember what actually happens when a shielded user spends. The keys are not arbitrary; each one exists because some step below needs exactly that capability and no more. Read this section as a checklist of "what must someone be able to do", then read section 2 as "which key gives them that ability".

1.5.1 The state on chain

A shielded balance is not an account. It is a set of notes. A note is a tuple

note=(d,pkd,v,rcm)\mathsf{note} = (\mathsf{d}, \mathsf{pk_d}, v, \mathsf{rcm})

where (d,pkd)(\mathsf{d}, \mathsf{pk_d}) identifies the recipient (the PaymentAddress), vv is the value, and rcm\mathsf{rcm} is the commitment trapdoor. The chain never stores the note. It stores only

  • the note commitment cm=NoteCommit(gd,pkd,v,rcm)\mathsf{cm} = \mathsf{NoteCommit}(\mathsf{g_d}, \mathsf{pk_d}, v, \mathsf{rcm}), appended to a global incremental Merkle tree (the note commitment tree);
  • the nullifier set, a set of opaque 32-byte tags that record which notes have already been spent.

Spending a note is therefore: prove that some note exists in the tree, reveal its nullifier, and create new notes for the recipients of the spend.

1.5.2 The four things the spender must do

For each input note the spender must be able to:

  1. Prove the note is in the tree. Knowledge of a Merkle path from cm\mathsf{cm} to the published anchor. No key needed for the path itself, but the spender must know the note, which means the spender must have been able to decrypt it on receipt.
  2. Prove that the note has not already been spent, by revealing its nullifier nf\mathsf{nf}. The chain refuses any transaction whose nullifier is already in the set. Deriving nf\mathsf{nf} requires a secret tied to the note (otherwise anyone could mark anyone's notes as spent).
  3. Authorize the spend, so that only the legitimate holder of the note can move it. This is a signature over the transaction, tied to a public value committed inside the Spend proof.
  4. Produce the proof itself, a Groth16 Spend proof certifying that (1), (2), and (3) are consistent: the spender knows a note in the tree, the revealed nullifier matches that note, and the signing key matches the note's owner.

The same spender must also, for each new recipient, create an output: build a fresh note, encrypt it to the recipient, and prove (in the Output circuit) that the encrypted note is well-formed and its value commitment is consistent with the spend.

Add to that the bookkeeping side: a wallet observer must be able to recognize own activity without holding the spending key, both on the incoming side (notes addressed to me) and the outgoing side (notes I sent, which I want to remember after I no longer have the plaintext).

1.5.3 Mapping each ability to a key

Each ability in 1.5.2 is granted by exactly one secret, and the names of the keys track that one-to-one mapping:

AbilitySecret usedName
decrypt an incoming note to learn (v,rcm)(v, \mathsf{rcm})incoming viewing keyivk\mathsf{ivk}
compute the nullifier of a note I ownnullifier secretnsk\mathsf{nsk} (or its public form nk\mathsf{nk})
sign the spend authorizationspend authorizing secretask\mathsf{ask} (public form ak\mathsf{ak})
build the Spend proof without holding ask\mathsf{ask}proving capability(ak,nsk)(\mathsf{ak}, \mathsf{nsk})
re-decrypt a note I sent (audit / change tracking)outgoing viewing keyovk\mathsf{ovk}
publish a fresh unlinkable receive addressdiversifier layerd,gd,pkd\mathsf{d}, \mathsf{g_d}, \mathsf{pk_d}
derive many diversifiers deterministically from an HD seeddiversifier keydk\mathsf{dk}
encrypt one output to its recipientper-output ephemeral secretesk\mathsf{esk}

Two consequences fall out of this table and explain the rest of the chapter:

  • The reason ProofGenerationKey = (ak, nsk) exists is that abilities 1 (decrypt), 2 (nullifier), and 4 (proof) can all be done without ability 3 (sign). That is the hardware-wallet split: the slow machine proves, the small machine signs.
  • The reason FullViewingKey = (ak, nk, ovk) exists is that audit needs to recognize both incoming and outgoing activity without ever signing or proving. It cannot move funds because it lacks nsk\mathsf{nsk} (cannot produce a valid in-circuit nullifier preimage) and ask\mathsf{ask} (cannot sign).

Section 2 now restates this as a capability ladder and gives the workflows that motivate each rung.

2. Why so many keys

Sapling could in principle drive everything from one secret. It does not. The wallet is instead split into a capability ladder: each rung holds strictly less power than the rung above, and the names of the keys track which capability they grant. The point is least privilege, so each counterparty (signer, prover, viewer, payment processor, recipient) only ever holds what it needs. That is what produces the long list of keys: each key isolates one capability that some real workflow wants to hand out independently.

2.1 The capability ladder

From most powerful to least powerful:

RungKey bundleWhat it lets you do
SpendExpandedSpendingKey =(ask,nsk,ovk)= (\mathsf{ask}, \mathsf{nsk}, \mathsf{ovk})Sign spends, build Spend proofs, decrypt own outgoing notes. Full control of funds.
ProveProofGenerationKey =(ak,nsk)= (\mathsf{ak}, \mathsf{nsk})Build Spend proofs (and the in-circuit nullifier) without ask. Cannot sign, so cannot move funds.
View (full)FullViewingKey =(ak,nk,ovk)= (\mathsf{ak}, \mathsf{nk}, \mathsf{ovk})Detect own spends, decrypt incoming notes, decrypt own outgoing notes. Read access to all activity.
View (incoming only)ivk\mathsf{ivk}Trial-decrypt incoming notes. Cannot detect own spends, cannot see outgoing.
View (outgoing only)ovk\mathsf{ovk}Re-decrypt notes this wallet sent (change tracking, audit).
Receive (one unlinkable label)PaymentAddress =(d,pkd)= (\mathsf{d}, \mathsf{pk_d})Receive funds. Many d values under one ivk give unlinkable addresses on chain.

Workflows that motivate the splits:

  • A hardware wallet holds ask\mathsf{ask} and signs spends. A faster online machine holds (ak,nsk)(\mathsf{ak}, \mathsf{nsk}) and runs the heavy Groth16 prover. The online machine cannot move funds even if it is compromised.
  • An auditor or watch-only wallet holds the FullViewingKey. It sees the wallet's entire transaction history on both sides but cannot spend.
  • A payment processor holds only ivk\mathsf{ivk} to detect incoming payments and learns nothing about what the receiver later spends.
  • A user advertises many PaymentAddress values (one per counterparty) under one wallet, so that on-chain observers cannot link them.

The picture inside the Spend circuit is the mirror image of this ladder: the circuit witnesses the proving-capability keys (ak,nsk)(\mathsf{ak}, \mathsf{nsk}) and derives every downstream key in zero knowledge, so the proof certifies "I hold the proving capability for this note" without revealing any key directly.

2.2 A mnemonic for the names

The alphabet soup is regular once you see the prefixes:

  • a stands for "auth". ask\mathsf{ask} is the spend-authorizing secret key; ak\mathsf{ak} is the matching validating point.
  • n stands for "nullifier". nsk\mathsf{nsk} is the nullifier secret key; nk=[nsk]Gpgk\mathsf{nk} = [\mathsf{nsk}] G_{\mathsf{pgk}} is its public deriving point.
  • i stands for "incoming". ivk\mathsf{ivk} is the incoming viewing key.
  • o stands for "outgoing". ovk\mathsf{ovk} is the outgoing viewing key.
  • d is the diversifier layer: d\mathsf{d} is the 88-bit diversifier; gd\mathsf{g_d} is its hash to a Jubjub base; pkd=[ivk]gd\mathsf{pk_d} = [\mathsf{ivk}] \mathsf{g_d} is the diversified transmission key; dk\mathsf{dk} is the AES-256 key used by FF1 (ZIP 32) to map indices to diversifiers.

In one sentence: every secret/public pair is "XskX\mathsf{sk} to XkX\mathsf{k} by scalar multiplication on a fixed generator"; ivk\mathsf{ivk} comes from a hash of (ak,nk)(\mathsf{ak}, \mathsf{nk}) because the incoming-view capability is logically downstream of both auth and nullifier; the diversifier layer at the bottom is freshly generated as needed.

2.3 Where each key shows up (cheat sheet)

KeyLives inUsed by Spend circuit?Used by Output circuit?
ask\mathsf{ask}spending key onlyno (signing is out of circuit)no
ak\mathsf{ak}proving / viewingyes: witness, feeds rk\mathsf{rk} and ivk preimageno
nsk\mathsf{nsk}proving / spendingyes: witness, nk=[nsk]G\mathsf{nk} = [\mathsf{nsk}] Gno
nk\mathsf{nk}viewingyes: derived in-circuit, feeds nf\mathsf{nf} + ivkno
ivk\mathsf{ivk}incoming viewingyes: derived in-circuit, gives pkd\mathsf{pk_d}no
ovk\mathsf{ovk}outgoing viewingnono (used by note encryption only)
d\mathsf{d}addressnono
gd\mathsf{g_d}addressyes: witness, small-order check, note commitmentyes: witness, small-order check, epk\mathsf{epk}, commit
pkd\mathsf{pk_d}addressyes: [ivk]gd[\mathsf{ivk}] \mathsf{g_d}, in note commityes: witnessed without on-curve check, in note commit
dk\mathsf{dk}ZIP 32nono
esk\mathsf{esk}per-output secretnoyes: witness, epk=[esk]gd\mathsf{epk} = [\mathsf{esk}] \mathsf{g_d}

The two viewing keys (ivk\mathsf{ivk}, ovk\mathsf{ovk}) never enter a circuit themselves; they live in the note-encryption pipeline. Everything in the "proving" rung (ak\mathsf{ak}, nsk\mathsf{nsk}) is what the Spend circuit actually witnesses.

3. Definitions

3.1 The Sapling key tree (below ZIP 32)

Starting from a 32-256 byte spending key sk\mathsf{sk} (typically output by ZIP 32 child derivation):

Definition 7.1 (ask, ak). The spend authorizing key is ask=PRFExpand,ASK(sk)FrJ\mathsf{ask} = \mathsf{PRF^{Expand,ASK}}(\mathsf{sk}) \in \mathbb{F}_{r_{\mathbb{J}}}, restricted to nonzero scalars, and is wrapped by SpendAuthorizingKey. The spend validating key is ak=[ask]SpendingKeyGeneratorJ(r)\mathsf{ak} = [\mathsf{ask}] \cdot \mathsf{SpendingKeyGenerator} \in \mathbb{J}^{(r)}, wrapped by SpendValidatingKey. The Sapling RedJubjub signature scheme uses (ask,ak)(\mathsf{ask}, \mathsf{ak}) as its signing / verification key pair.

Role: ask\mathsf{ask} signs the spend authorization signature spendAuthSig\mathsf{spendAuthSig} over the SIGHASH out of circuit; it never enters either circuit. ak\mathsf{ak} is witnessed in the Spend circuit and used in two places: the re-randomized form rk=ak+[ar]Gsk\mathsf{rk} = \mathsf{ak} + [\mathsf{ar}] G_{\mathsf{sk}} is exposed as a public input (clause 2 of RSpendR_{\mathsf{Spend}}), and repr(ak)\mathsf{repr}(\mathsf{ak}) is the first half of the in-circuit ivk\mathsf{ivk} preimage (clause 4).

Definition 7.2 (nsk, nk). The nullifier secret key is nsk=PRFExpand,NSK(sk)FrJ\mathsf{nsk} = \mathsf{PRF^{Expand,NSK}}(\mathsf{sk}) \in \mathbb{F}_{r_{\mathbb{J}}}. The nullifier deriving key is nk=[nsk]ProofGenerationKeyGeneratorJ(r)\mathsf{nk} = [\mathsf{nsk}] \cdot \mathsf{ProofGenerationKeyGenerator} \in \mathbb{J}^{(r)}, wrapped by NullifierDerivingKey. The pair (ak,nsk)(\mathsf{ak}, \mathsf{nsk}) is the ProofGenerationKey; the pair (ak,nk)(\mathsf{ak}, \mathsf{nk}) is the viewing key.

Role: nsk\mathsf{nsk} is witnessed in the Spend circuit, where clause 3 of RSpendR_{\mathsf{Spend}} enforces nk=[nsk]Gpgk\mathsf{nk} = [\mathsf{nsk}] G_{\mathsf{pgk}}. The derived nk\mathsf{nk} is then mixed into the in-circuit nullifier nf=BLAKE2s(repr(nk)repr(ρ))\mathsf{nf} = \mathsf{BLAKE2s}(\mathsf{repr}(\mathsf{nk}) \mathbin{\|} \mathsf{repr}(\rho)) (clause 10) and into the ivk\mathsf{ivk} preimage (clause 4). Out of circuit, a holder of nk\mathsf{nk} can recognise their own notes' nullifiers once they have decrypted the note (this is the "detect own spends" capability of the FullViewingKey).

Definition 7.3 (ivk). The incoming viewing key is

ivk=CRHivk(reprJ(ak),reprJ(nk)),\mathsf{ivk} = \mathsf{CRH^{ivk}}(\mathsf{repr}_{\mathbb{J}}(\mathsf{ak}), \mathsf{repr}_{\mathbb{J}}(\mathsf{nk})),

specifically BLAKE2s32,Zcashivk()\mathsf{BLAKE2s}_{32, \mathsf{Zcashivk}}(\cdot) with the most-significant 5 bits cleared so it fits in the Jubjub scalar field. It is computed by crh_ivk and exposed as ViewingKey::ivk.

Role: out of circuit, the receiver uses ivk\mathsf{ivk} to trial-decrypt every new on-chain Output (see Note encryption). Inside the Spend circuit, ivk\mathsf{ivk} is recomputed from ak,nk\mathsf{ak}, \mathsf{nk} (clause 4) and immediately used to derive pkd=[ivk]gd\mathsf{pk_d} = [\mathsf{ivk}] \mathsf{g_d} (clause 6); this is how the proof certifies that the spent note's recipient was indeed the wallet behind this ivk\mathsf{ivk}.

Definition 7.4 (ovk). The outgoing viewing key, OutgoingViewingKey, is a 32-byte opaque value ovk=PRFExpand,OVK(sk)\mathsf{ovk} = \mathsf{PRF^{Expand,OVK}}(\mathsf{sk}) truncated to 32 bytes. It is used to let the sender re-decrypt their own outputs without revealing them to the receiver's ivk.

Role: ovk\mathsf{ovk} is purely out of circuit. Note encryption derives the OutgoingCipherKey from ovk\mathsf{ovk} to wrap (pkd,esk)(\mathsf{pk_d}, \mathsf{esk}) into the out_ciphertext; a wallet that keeps ovk\mathsf{ovk} can later recover the recipient address and amount of any Output it created. Neither the Spend nor the Output circuit sees ovk\mathsf{ovk}.

Definition 7.5 (d, g_d, pk_d). A diversifier Diversifier d{0,1}88\mathsf{d} \in \{0,1\}^{88} defines the base of a payment address. Each diversifier yields gd=DiversifyHash(d)J(r){}\mathsf{g_d} = \mathsf{DiversifyHash}(\mathsf{d}) \in \mathbb{J}^{(r)} \cup \{\bot\}; about 50% of diversifiers fail (g_d = None). The diversified transmission key is pkd=[ivk]gd\mathsf{pk_d} = [\mathsf{ivk}] \cdot \mathsf{g_d}, computed by DiversifiedTransmissionKey::derive. The payment address (d,pkd)(\mathsf{d}, \mathsf{pk_d}) is a PaymentAddress.

Role: the diversifier d\mathsf{d} itself never enters either circuit; only its image gd\mathsf{g_d} does, in both. In the Spend circuit, gd\mathsf{g_d} is witnessed and small-order-checked, then used both to derive pkd=[ivk]gd\mathsf{pk_d} = [\mathsf{ivk}] \mathsf{g_d} (clause 6) and to feed the note commitment (clause 8). In the Output circuit, gd\mathsf{g_d} is witnessed and small-order-checked, then used to expose the sender's ephemeral key epk=[esk]gd\mathsf{epk} = [\mathsf{esk}] \mathsf{g_d} (clause 3) and to feed the note commitment (clause 4); the Output circuit witnesses pkd\mathsf{pk_d} without an on-curve check, because the recipient's decryption will detect any malformed value off chain.

3.2 ZIP 32

Definition 7.6 (ZIP 32 extended key). An extended Sapling spending key is a quintuple

xsk=(depth,parent_fvk_tag,i,c,expsk,dk),\mathsf{xsk} = (\mathsf{depth}, \mathsf{parent\_fvk\_tag}, \mathsf{i}, \mathsf{c}, \mathsf{expsk}, \mathsf{dk}),

where expsk\mathsf{expsk} is the ExpandedSpendingKey and dk\mathsf{dk} is the DiversifierKey. Child derivation uses PRFExpand\mathsf{PRF^{Expand}} instances indexed by ZIP 32's tag constants (ZIP32_SAPLING_*) to mix in the child index and the parent chain code. Hardened-only children: ZIP 32 forbids non-hardened Sapling derivation because the deterministic derivation of a child verification key from a parent's ak\mathsf{ak} is not desired here.

Role of dk\mathsf{dk}: dk\mathsf{dk} is a 32-byte AES-256 key. It is used by FF1 to map a 11-byte diversifier index jj to the 88-bit candidate diversifier dj\mathsf{d}_j (Section 4.3); the same dk\mathsf{dk} therefore enumerates the family of addresses under one wallet. It never enters either circuit and is not part of any viewing key shared with third parties.

Invariant 7.7 (non-zero ask). ask\mathsf{ask} must be nonzero. The probability of a zero ask from PRFExpand,ASK\mathsf{PRF^{Expand,ASK}} is negligible (22522^{-252} ish), but the code explicitly handles it: every constructor of SpendAuthorizingKey checks the scalar and returns None on zero. The ExpandedSpendingKey::from_spending_key constructor panics if this triggers, accepting the negligible probability of an unrecoverable seed.

Invariant 7.8 (ak prime-order). ak\mathsf{ak} must be an element of J(r)\mathbb{J}^{(r)} and not the identity. The deserializer rejects non-prime-order points: SpendValidatingKey::from_bytes uses jubjub::SubgroupPoint::from_bytes and an explicit !p.is_identity() check.

4. The code

4.1 The bottom layer: keys.rs

keys.rs defines every key in the tree except the ZIP 32 extended ones. It is the file you will reread most often. Three regions to internalise:

src/keys.rs (SpendAuthorizingKey)
loading...

The derivation chain seed -> ExpandedSpendingKey -> ProofGenerationKey -> ViewingKey -> SaplingIvk -> PaymentAddress is wired through the Into / method impls:

src/keys.rs (ExpandedSpendingKey::from_spending_key)
loading...
src/keys.rs (ProofGenerationKey -> ViewingKey)
loading...

4.2 The ZIP 32 layer: zip32.rs

zip32.rs is the largest single file in the crate (1876 lines). It mostly does plumbing: serialise/deserialise extended keys, derive children, find diversifiers via FF1 over AES-256.

src/zip32.rs (constants and master derivation)
loading...
src/zip32.rs (internal-vs-external FVK)
loading...

A subtle structural detail is the internal full viewing key: a derivative of a "normal" DiversifiableFullViewingKey used to detect transactions that the wallet itself originated (e.g. change outputs). The derivation mixes in a separate personalisation (ZIP32_SAPLING_INT_PERSONALIZATION) so that the internal and external scopes do not share an ivk.

4.3 Diversifier search via FF1

ZIP 32 reuses FF1 over AES-256 as a 88-bit format-preserving pseudo-random permutation to map a diversifier index to an actual 88-bit diversifier:

src/zip32.rs (DiversifierKey::diversifier)
loading...

The find-loop in DiversifierKey::diversifier walks consecutive indices until it lands on one whose output happens to be a valid Jubjub-friendly diversifier (about 1 in 2). On average two indices are tried per address.

5. Failure modes

  • Confusing ask and ak. The signing key is ask (a scalar); the verification key is ak (a curve point). The From<&SpendAuthorizingKey> for SpendValidatingKey impl in keys.rs makes derivation look like a coercion; if you see SpendValidatingKey::from(&ask), that is a real scalar-multiplication, not a no-op cast. See issue #106. Caught by: review.
  • Trying to derive a non-hardened child. ZIP 32 for Sapling forbids non-hardened derivation. The KeyIndex::new helper rejects this; trying to derive yields DecodingError::UnsupportedChildIndex. Caught by: that error variant and the unit tests in zip32.rs.
  • pk_d = identity. If ivk = 0 (or the diversifier produces an g_d that happens to multiply to the identity), the resulting pk_d is the identity and there is no recipient. The DiversifiedTransmissionKey constructors reject this: PaymentAddress::from_parts_unchecked checks is_identity and returns None. Caught by: that check. See also issue #168 for the related "make ivk = 0 unrepresentable" refactor.
  • Lossy "prepared" form drift. PreparedIncomingViewingKey caches a WnafScalar; if SaplingIvk changes its scalar encoding but the prepared form is not regenerated, decryption silently fails. Caught by: trial decryption tests in note_encryption.rs.

6. Spec pointers

7. Exercises

  1. Trace one derivation. Pick a 32-byte spending key (e.g. all zeros). Step by step, compute ask, ak, nsk, nk, ivk, ovk using the public APIs in keys.rs. Verify each intermediate against ExpandedSpendingKey::from_spending_key.
  2. Add a Display impl for SaplingIvk. Pick a stable encoding (hex of the scalar's little-endian representation) and add impl fmt::Display for SaplingIvk. Add a doctest that matches against a known value derived from a fixed test vector (you can grab one from the existing spend_auth_sig_test_vectors).
  3. Compute a diversifier index round-trip. Use DiversifierKey::diversifier_index on a known diversifier and confirm the index returned, when fed back through diversifier, yields the original byte string. This exercises the FF1 round-trip.

Answers in the code. For exercise 1, the four "PRF expand" calls (SAPLING_ASK, SAPLING_NSK, SAPLING_OVK, SAPLING_ZIP32_*) live in the zcash_spec crate. For exercise 3, note that "valid diversifier" is a property of the 88-bit output, not the index; the index round-trip works on any 11-byte buffer but only some of those buffers represent valid Sapling diversifiers.