noircashdocs

Keys

How the privacy keys are derived from one wallet signature, why every spend needs a fresh wallet signature, and how keys are registered.

noircash has no separate seed phrase. The app derives every privacy key from one signature of a fixed message, and binds the wallet's own secp256k1 key into the owner key, so the wallet must also sign every spend.

The activation message

The wallet signs (personal_sign) a Sign-In with Ethereum (EIP-4361) message:

{domain} wants you to sign in with your Ethereum account:
{address, EIP-55 checksummed}

Activate privacy for the noircash vault {vault, lowercase}. This signature derives the keys that reveal your private balance. Sign it only on {domain}.

URI: {https|http}://{domain}
Version: 1
Chain ID: {chainId}
Nonce: {first 16 hex digits of the vault address after 0x, lowercase}
Issued At: 2026-01-01T00:00:00.000Z

The scheme is http only for localhost and 127.0.0.1. Every field is fixed, so the same wallet always signs the same text. The domain is noircash.app. Changing the domain, the address, the chain or the vault changes every key. Because the message is SIWE, wallets warn when a site on another domain asks for it.

Derivation

seed        = keccak256(signature bytes)
sk          = HKDF-SHA256(ikm = seed, no salt, info = "noircash/v1/sk", 64 bytes) mod p
nk          = H(sk)
wallet key  = secp256k1 public key recovered from the same signature: (x, y), 32 bytes each, big-endian
opk         = H(nk, x_hi, x_lo, y_hi, y_lo)        the four 128-bit halves of x and y
view secret = HKDF-SHA256(ikm = seed, no salt, info = "noircash/v1/view", 32 bytes)
view public = X25519(view secret)

H is Poseidon2 over BN254 and p the BN254 scalar field (see Notes).

KeyStays whereUsed for
skDevice memoryRoot secret; only nk is derived from it
nk (nullifier key)Device memory, and inside proofsComputing nullifiers and proving ownership of a note
Wallet keyPublic (recoverable from any signature)Every spend must carry a signature by it, checked in the circuit
opk (owner key)Public, in the registryOwner field of every note
Viewing key (X25519)Secret half on the device, public half in the registryEncrypting and finding notes

At activation the app checks that the address recovered from the signature is the connected account. A smart-contract wallet fails this check.

Determinism

The keys come back on a new device only if the wallet produces the same signature for the same message (RFC 6979 deterministic ECDSA). PrivacyClient.activate enforces this:

  • Already registered: one signature. The derived opk must equal the registered one, or activation fails.
  • First time: two signatures of the same message. They must give the same opk before anything is registered.

A wallet that does not sign deterministically is refused. There is no other key source.

Any wallet that signs with an EOA key works, including EIP-7702 accounts and MPC wallets that output one ordinary ECDSA signature. Smart-contract accounts and contract multisigs cannot activate: the circuit checks an EOA signature.

Per-spend signature

For every private transaction the wallet signs EIP-712 typed data (eth_signTypedData_v4):

struct Spend { bytes32 transaction; }

// transaction = H(root, nf0, nf1, cm0, cm1, exitShares, extDataHash)   (Poseidon2, as bytes32 big-endian)
// domain      = EIP712Domain(name = "noircash", version = "1", chainId, verifyingContract = vault)
// digest      = keccak256(0x1901 || domainSeparator || keccak256(SPEND_TYPEHASH || transaction))
// SPEND_TYPEHASH = keccak256("Spend(bytes32 transaction)")

The circuit recomputes the digest with keccak256 and verifies the ECDSA secp256k1 signature against the wallet key hashed into opk, without revealing the key. The domain separator is not chosen by the prover: the contract passes its own _domainSeparatorV4() as the last two public inputs, so a signature is valid for one vault on one chain. extDataHash covers the recipient, submitter, fee, sale terms and ciphertexts (see The circuit), so the wallet approves exactly one transaction.

Hardware wallets work: the spend is a standard EIP-712 signature on the device. The typed data shows only an opaque bytes32, so the wallet cannot display the amount; the app shows it.

What a leaked activation signature allows

The activation signature is the root of sk, nk and the viewing key. Whoever obtains it can:

  • decrypt every note sent to the owner, past and future, and read amounts;
  • compute each note's nullifier, and so see when each note is spent;
  • link the owner's shields and private buys to the later spends of those notes.

They cannot spend: a spend needs a fresh Spend signature by the wallet key, which the activation signature does not reveal. They also cannot change the registered keys, because register needs the address itself and registerFor needs a separate EIP-712 signature. The response to a leak is to move the notes to keys the attacker does not know, which today means a different wallet.

Registration

A key pair becomes usable for incoming payments once it is in the vault's registry:

function register(uint256 opk, bytes32 viewKey) external;

function registerFor(address owner, uint256 opk, bytes32 viewKey, uint256 deadline, bytes calldata signature)
    external;

// EIP-712, under the vault's domain:
// Register(address owner,uint256 opk,bytes32 viewKey,uint256 nonce,uint256 deadline)

function keysOf(address owner) external view returns (uint256 opk, bytes32 viewKey);
  • register records keys for msg.sender.
  • registerFor lets anyone submit a registration signed by owner (EOA or ERC-1271 through OpenZeppelin's SignatureChecker). The nonce is registerNonces[owner], incremented on use, and the signature expires at deadline.
  • opk must be non-zero and below p. The call emits KeysRegistered(owner, opk, viewKey).
  • Registering again replaces the keys. Notes already created for the old opk stay spendable with the old keys.

Registration is public: it shows that an address uses noircash. It lets others send that address private notes, because a sender needs the recipient's opk and viewing key to build and encrypt a note. It changes nothing for the address's public NOIR: tokens sent to it stay an ordinary ERC-20 balance until the owner shields them.

On this page