Keys and addresses
The vault root, the spend and viewing keys derived from it, the void address and recovery key formats, and how the root is sealed in the browser.
A Voidcash vault is one random 32-byte root. Every key comes from it, and nothing comes from the user's wallet: a phished wallet signature gives no access to the vault.
The root
The app draws the root's 32 bytes from crypto.getRandomValues in the browser. The root is the only secret: whoever holds
it can rebuild every key and spend every note.
Derived keys
derive(info, len) = HKDF-SHA256(ikm = root, salt = none, info, len bytes)
spendKey = big-endian integer of derive("voidcash/v1/spend", 64) mod r
viewSecret = derive("voidcash/v1/view", 32) an X25519 secret key
viewPublic = X25519 public key of viewSecret 32 bytes
notePubKey = Poseidon(spendKey) one field element
r = 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001 (BN254 scalar field)| Key | What it does | Who needs it |
|---|---|---|
spendKey | Spends notes. The circuit proves knowledge of it, and every nullifier depends on it | Only the vault |
notePubKey | The owner field of every note sent to this vault | Anyone sending to the vault, through the address |
viewSecret | Opens the ciphertexts of notes sent to this vault, change notes included. It cannot spend | Only the vault |
viewPublic | Senders encrypt new notes to it | Anyone sending to the vault, through the address |
The spend key is 64 bytes reduced mod r, so it is uniform in the field. Poseidon is circomlib's, over BN254, the same
the circuit and the program's sol_poseidon syscall use.
A nullifier is Poseidon(commitment, index, Poseidon(spendKey, commitment, index)) (see
Notes), so the viewing secret alone cannot tell which notes are spent.
The private address
A private address lets anyone send notes to the vault. It carries the note public key and the viewing public key:
payload = 0x01 (version) ‖ notePubKey (32 bytes, big-endian) ‖ viewPublic (32 bytes)
checksum = SHA-256(SHA-256(payload))[0..4]
address = "void" ‖ base58(payload ‖ checksum) 69 bytes before encodingBase58 uses the Bitcoin alphabet, the one Solana addresses use. The app refuses an address without the void
prefix, of the wrong length, with a wrong checksum ("check for a typo"), with another version, or whose note public
key is not below r.
An address is public by design. Sending to it reveals nothing on-chain: the recipient's note public key is hidden in a commitment, and the ciphertext carries a fresh ephemeral key.
The recovery key
recovery key = base58(0x81 (version) ‖ root (32 bytes) ‖ SHA-256(SHA-256(0x81 ‖ root))[0..4])It has no prefix. The same checksum rules apply, and a different version byte is refused. The app shows it once, when the vault is created, and restores the vault from it on any device with a new passphrase.
The recovery key is the root itself. Anyone who has it can spend every note in the vault, and without it a vault lost from its browser cannot be restored. Nobody else holds a copy.
The vault at rest
The app seals the root with the user's passphrase through WebCrypto:
salt = 16 random bytes
iv = 12 random bytes
key = PBKDF2(SHA-256, passphrase, salt, 600,000 iterations) -> AES-256-GCM key, not extractable
data = AES-256-GCM(key, iv).encrypt(root) 32 bytes + 16-byte tag
stored = { "v": 1, "salt": base64, "iv": base64, "data": base64 }A wrong passphrase fails the GCM tag check ("wrong passphrase"). The root is held in memory only while the vault is unlocked.
localStorage key | Contents | Encrypted |
|---|---|---|
voidcash.vault.v1 | The sealed root, as above | Yes, with the passphrase |
voidcash.history.<tag> | The vault's activity list, kept in this browser only (the chain cannot give it back) | No |
The history's tag is the first 16 hex digits of Poseidon(notePubKey, 7), so the key name does not show the
vault's address. Removing the vault from the browser deletes every key that starts with voidcash..
Notes are not stored: the app finds them again on every unlock by scanning the pool's leaves with the viewing secret.
Architecture
The parts of Voidcash, what each one holds and sees, and how a shield, a private transfer and an unshield move through them.
Notes, commitments and nullifiers
What a note contains, how its commitment and nullifier are computed, how notes are encrypted and found, and why every transaction has two inputs and two outputs.