← All notes
·10 min read·oprf / svr / zero-knowledge / ristretto255

OPRFs, secure value recovery, and zero-knowledge credentials

The mathematics Enchant uses to do the impossible jobs: recover your secrets from a server that must never learn them, and prove who you are without revealing who you are.

/ security notes

In the annals of applied cryptography, few problems are as elegantly framed as the one Enchant's backup system solves. The client wants to store an encrypted copy of its master key with a server. The server will one day be called upon to return it. And the entire arrangement must work even though the server is not to be trusted with the password, the key, or even the ability to test guesses offline. That last clause is the hard one, and it is why a plain "password-derived-key encrypts everything" scheme — the industry default for decades — is not good enough.

The tool that makes it possible is called an oblivious pseudorandom function, or OPRF. This post derives the construction exactly as it runs in the LibEnchant cryptographic library: the OPRF itself, the 2-of-3 secret sharing that protects it, and the zero-knowledge credential machinery that lets a client prove things about itself without ever revealing what it is.

The problem: a server that must not be able to guess

Set up the threat model first. A backup server holds, at all times, three things: a secret-shared evaluation key, a copy of the user's encrypted master key, and a counter of how many guesses the user has made. An attacker who steals the entire database — the classic server compromise — gets the encrypted blob and the shares. They do not get the password, and they must not be able to find it: a scheme where an offline attacker can run a dictionary of a billion passwords against the stolen blob is a scheme that has already failed. Every password manager that encrypts with a plain PBKDF-derived key has exactly this failure mode — offline guessing is limited only by the attacker's hardware.

The OPRF kills that attack at the root: the server holds a secret that is mixed into the key derivation, and it refuses to perform the mix more than a handful of times per account. Offline guessing becomes impossible because the missing ingredient (the server's secret evaluation key) is not in the stolen database. Online guessing is limited to ten tries, because the server counts them.

The building block: an oblivious pseudorandom function

What it is. An OPRF is a function that lets the client evaluate Fk(password)F_k(\mathrm{password}) with the server's secret key kk — without the server learning the password, and without the client learning kk.

Why it is the right tool. The client and the server both need to contribute to the final key: the password (known only to the client) and the secret key (known only to the server). A normal PRF would require one side to hand its secret to the other. The OPRF's trick — the blindness — is that the client encrypts its input with a random scalar, the server evaluates on the blinded value, and the client unblinds the result. The server sees a random-looking group element and learns nothing about the password; the client receives Fk(p)F_k(p) but never sees kk.

How it is computed. All of this happens in the group of ristretto255 points — a prime-order group built on Curve25519, chosen because prime order means no cofactor attacks, no small-subgroup tricks, and a compact 32-byte representation for every point. The client's password is mapped into the group deterministically — hashed into a point using HKDF-SHA256 with the domain-separation label "enchant_OPRFS_HashToGroup_20250101" (info = "hash_to_point"), so the mapping cannot collide with any other protocol output:

P=RistrettoPoint:from_hash(HKDF(password, salt=“enchant_OPRFS_HashToGroup_20250101", L=64))P = \mathrm{RistrettoPoint\mathbin{:}from_hash}\big(\mathrm{HKDF}(\mathrm{password},\ \mathrm{salt}=\text{``enchant_OPRFS_HashToGroup_20250101"},\ L=64)\big)

The client chooses a uniform random scalar rr — the blind — and sends B=rPB = r \cdot P. The server multiplies by its secret key and returns B=kB=krPB' = k \cdot B = k \cdot r \cdot P. The client divides out its blind (r1r^{-1}) and recovers kPk \cdot P — while the server has only ever seen rPr \cdot P, a uniformly random point that carries no information about the password:

   Client (password)                  Server (secret k)
   ────────────────                   ─────────────────
   P = hash_to_group(password)
   r ← random scalar
   B = r·P  ──────────────────────►   B' = k·B          (sees only B)
   ◄──────────────────────────────    return B'
   k·P = r⁻¹·B'
   out = HKDF(P ‖ k·P, ...)          (knows password, not k)

The final OPRF output is derived from both the original point PP and the evaluated point kPk \cdot P, through HKDF-SHA256 with the label "enchant_OPRFS_Output_20250101" (info = "output"):

out=HKDF(PkP, salt=“enchant_OPRFS_Output_20250101", info=“output", L=32)\mathrm{out} = \mathrm{HKDF}(P \parallel k \cdot P,\ \mathrm{salt}=\text{enchant\_OPRFS\_Output\_20250101"},\ \mathrm{info}=\text{output"},\ L=32)

Two properties fall out of this construction. First, the server can evaluate without knowing what it evaluated — it cannot learn the password from BB, because the blindness is information-theoretically hiding (a random group point). Second, the client's output is uncorrelated with anyone else's: the hash-to-group step and the final HKDF both bind the password to the output, so two users with the same password get different keys. Enchant exposes this directly: enchant_oprf_evaluate (server side) and enchant_oprf_finalize (client side).

SVR3: splitting the server's secret

A single server with kk is a single point of failure — and a single point of abuse. If kk lives in one database, that database becomes the target of every coercion, every warrant, every break-in. The Secure Value Recovery scheme (SVR3) removes the single point: the evaluation key is split into three additive shares, and only two of three are needed to reconstruct it.

Where does the key come from? The subtle move in SVR3 is that the server's secret is derived from the password itself. Both the client and the server compute the same 64 bytes from the password:

(oprf_keyhint)=HKDF(password, salt=“enchant_SVR3_OprfKey_20250101", info=“key_sharing", L=64)(\mathit{oprf_key} \parallel \mathit{hint}) = \mathrm{HKDF}(\mathrm{password},\ \mathrm{salt}=\text{enchant\_SVR3\_OprfKey\_20250101"},\ \mathrm{info}=\text{key_sharing"},\ L=64)

The first 32 bytes become the OPRF key kk (reduced modulo the group order); the second 32 bytes are a key-confirmation hint the client uses to verify the password without the server learning it. Neither side stores this key: it is re-derived on every backup and restore.

How the shares work. The OPRF key kk is split into three additive shares over the scalar field of ristretto255 — random values whose sum is the key:

k=s1+s2+s3(mod)k = s_1 + s_2 + s_3 \pmod{\ell}

Each share is delivered to a different server. The three servers never hold the whole key; they hold shares that are individually meaningless. Two shares reconstruct the key (2-of-3 threshold); one share reconstructs nothing. This is deliberately not Shamir secret sharing — the threshold is fixed at 2, the arithmetic is ordinary addition mod the group order, and the combination function is a plain sum. In LibEnchant the operations are literally split_additive_shares and combine_additive_shares.

What the split gives. An attacker who compromises one server gets one share — enough to prove they compromised the server, useless for recovering anything. An attacker who compromises two servers can reconstruct kk, but they still cannot recover the backup without the password, and they still cannot test passwords offline (the blinded evaluation requires the client's cooperation). And an attacker who wants to brute-force a password must now break two servers that each rate-limit — the online brute-force surface shrinks to whatever the weakest two servers allow.

The full backup and restore flows

Backup. The client has a master key (the seed for the whole keyring). To back it up:

  1. Derive oprf_keyhint\mathit{oprf_key} \parallel \mathit{hint} from the password (both client and server do this independently).
  2. Split oprf_key\mathit{oprf_key} into three additive shares, one per server.
  3. Blind the password (B=rPB = r \cdot P), send the blinded point to each server, and have each server evaluate with its share: Bi=siBB'_i = s_i \cdot B.
  4. Combine any two responses (r1(Bi+Bj)=(si+sj)P=kPr^{-1} \cdot (B'_i + B'_j) = (s_i + s_j) \cdot P = k \cdot P) and derive the OPRF output, exactly as above.
  5. Derive the encryption key from the OPRF output: enckey=HKDF(oprfout, salt=“enchant_SVR3_EncKey_20250101", info=“enc_key", L=32)\mathrm{enc_key} = \mathrm{HKDF}(\mathrm{oprf_out},\ \mathrm{salt}=\text{enchant\_SVR3\_EncKey\_20250101"},\ \mathrm{info}=\text{enc_key"},\ L=32)
  6. Encrypt the master key with XChaCha20-Poly1305 under enc_key\mathrm{enc_key}; store the masked secret plus the hint on the servers.

Restore runs the same ladder in reverse: blind the password, get evaluations from two servers, unblind, derive enc_key\mathrm{enc_key}, decrypt the masked secret. The password never leaves the device in any usable form, the servers never see it, and every guess the attacker makes is met with a rate limit, not an oracle. Enchant's C API exposes the full flow as enchant_svr_create_backup, enchant_svr_restore_backup, and enchant_svr_change_pin.

The rate limit is load-bearing. Each server keeps a per-account counter of failed guesses (SVR3_MAX_ATTEMPTS = 10). Ten wrong passwords, and the server stops cooperating on that account. This is not a minor feature; it is the only thing standing between an attacker and an online dictionary attack. The blinding means the server cannot verify a guess itself — it can only count attempts and refuse further service. Authentication of legitimate clients rides on a server-held HMAC proof (enchant_svr_generate_auth_proof / enchant_svr_verify_auth_proof): a keyed MAC over the client ID and a fresh nonce, so the server knows who it is talking to without a static token that could be replayed.

Zero-knowledge credentials: proving without revealing

The OPRF protects one secret. A separate layer — the zkgroup system in LibEnchant — protects identity. The problem: a client must prove to a server that it holds some credential (a group membership, a profile key, a receipt) — without revealing which credential, or anything else about itself. The server must learn exactly one bit: valid or invalid.

The mechanism is a POKSHO (proof of knowledge of a signature with hidden origin) with blind credentials:

  1. Issuance. The server signs a credential — a group membership, a profile key, a call-link ticket — producing a signature over the credential's attributes. Enchant's zkgroup package carries a full suite of these: auth_credential, group_credential, group_send_endorsement (the proof that lets a member endorse a group message), call_link_credential, profile_key_credential, receipt_credential, and expiring_profile_key_credential.
  2. Blinding. The client blinds the credential before showing it, via enchant_blind_keygen / enchant_blind_point / enchant_unblind_point: the signer's public key is blinded with a random scalar so the server cannot recognize the signature it issued.
  3. Presentation. The client produces a zero-knowledge proof (enchant_poksho_prove) that it holds a valid signature on the required attributes — without revealing the signature or the attributes. The server verifies (enchant_poksho_verify) and learns only "this client holds a valid credential of this type."

The math is the standard Schnorr-family trick lifted to ristretto255: the prover commits to blinded group elements, the verifier challenges, the prover answers with scalar linear combinations that reveal nothing about the underlying secrets. Because everything lives in the same prime-order group as the OPRF, the whole system shares one library, one implementation, and one threat model.

The unified picture

Job Tool What it guarantees
Backup without offline guessing OPRF + additive 2-of-3 shares Stolen DB ≠ guessable password; servers never see it
Limited online guessing Per-server rate limit (10) Brute force dies at two compromised-but-counting servers
Key confirmation Password-derived hint Client verifies its password without server learning it
Proving membership zkgroup POKSHO proofs Server learns only "valid," never "who"
Authenticating clients Server HMAC auth proofs No replayable static tokens

The through-line of every construction in this post is the same: the server holds exactly as much information as the math forces it to, and no more. The OPRF is built so the server evaluates without knowing; the shares are split so one server holds nothing; the proofs are written so the verifier learns one bit. This is zero-access by construction, and it is the same philosophy that powers zero-access servers and the Veil. In LibEnchant, it is not a diagram in a whitepaper — it is the code path a backup takes.

Continue reading

X3DH and the Triple Envelope: the full mathematics of an Enchant session9 min ↗Post-quantum security: ML-KEM, PQXDH, and the hybrid Triple Envelope8 min ↗The mathematics of the Veil: anonymous sender delivery8 min ↗