Partially Oblivious Pseudorandom Function — RFC 9497, OPRF(ristretto255,
SHA-512) suite (modePOPRF 0x02).
Thin, idiomatic wrappers over the audited metamorphic-crypto Rust core — the
same primitive used by the browser WASM build, so an evaluation produced or
verified here behaves byte-identically across native Rust, WASM, and this
NIF.
A POPRF lets a client compute a keyed, deterministic pseudorandom value over
its private input without the server ever seeing that input: the client
blinds its input, the server evaluates the blinded element under its secret
key and returns a DLEQ proof, and the client unblinds and verifies. Both
parties additionally bind a public info string (the "partially" in
partially oblivious). It backs MetamorphicLog's CONIKS-style
key-transparency layer, removing the query-time cleartext-label exposure the
classical VRF (MetamorphicCrypto.Vrf) carries.
The oblivious flow
# Client (e.g. browser WASM or this NIF):
%{blind: blind, blinded_element: blinded, tweaked_key: tweaked} =
Poprf.blind(input, info, server_public_key)
# -> send ONLY `blinded` to the server
# Server:
{:ok, {evaluated, proof}} = Poprf.blind_evaluate(server_secret, blinded, info)
# Client:
{:ok, output} = Poprf.finalize(input, blind, evaluated, blinded, proof, info, tweaked)The operator derives the same output non-obliviously with evaluate/3 when it
already holds the cleartext input (directory construction).
Post-quantum posture (honest framing)
Blinding is classical — transcripts are not PQ-private
2HashDH blinding is classical (elliptic-curve discrete log): recorded evaluation transcripts are not post-quantum private (harvest-now/unblind-later). This protects exactly one property: query-time index privacy against the operator today. Authenticity and integrity elsewhere in the stack are post-quantum from day one (ML-DSA hybrid signatures, SHA3-512 commitments) and do not rely on this primitive. Not FIPS-validated.
Encoding & wire format (base64 in, base64 out)
All arguments and results cross as base64 strings, matching the WASM wire format. Raw byte lengths (before base64):
secret key (skS) : 32 bytes (canonical ristretto255 scalar)
public key (pkS) : 32 bytes (ristretto255 element)
blind : 32 bytes (canonical scalar — client-side state, secret)
blinded element : 32 bytes (ristretto255 element)
evaluated element : 32 bytes (ristretto255 element)
tweaked key : 32 bytes (ristretto255 element)
DLEQ proof : 64 bytes = c (32) || s (32)
output : 64 bytes (a SHA-512 digest)Correctness is pinned byte-for-byte by RFC 9497 Appendix A.1.3 test vectors, so independent implementations interoperate exactly.
Summary
Types
The client-side blinding state, all fields base64-encoded. blind and
tweaked_key stay local to the client; only blinded_element is sent to the
server.
Functions
Blind the private input under the public info and the server's
public_key (RFC 9497 §3.3.3 Blind).
The server-side blind evaluation (RFC 9497 §3.3.3 BlindEvaluate): evaluate
a client's blinded_element under secret_key tweaked by the public info.
KAT-only blind_evaluate/3 with an explicit DLEQ nonce (deterministic), for
cross-language known-answer tests.
Derive a POPRF keypair deterministically from a 32-byte seed and a public
key_info string (RFC 9497 §3.2.1 DeriveKeyPair) — how an operator turns a
managed master secret into a stable deployment evaluation key.
The non-oblivious server-side evaluation (RFC 9497 §3.3.3 Evaluate):
compute the output directly from secret_key and the cleartext input — how
an operator derives indices for inputs it already holds (directory
construction). Produces exactly the output a client derives obliviously via
blind/3 → blind_evaluate/3 → finalize/7.
The client-side completion (RFC 9497 §3.3.3 Finalize): verify the server's
DLEQ proof against the tweaked_key, unblind, and return the 64-byte output.
Generate a fresh POPRF keypair from the OS CSPRNG.
Derive the base64 public key for a base64 secret_key.
Types
Functions
@spec blind( input_b64 :: String.t(), info_b64 :: String.t(), public_key_b64 :: String.t() ) :: {:ok, blind_state()} | {:error, String.t()}
Blind the private input under the public info and the server's
public_key (RFC 9497 §3.3.3 Blind).
Returns {:ok, %{blind:, blinded_element:, tweaked_key:}} — keep blind and
tweaked_key local; send only blinded_element to the server.
Example
{:ok, %{blinded_element: blinded} = state} =
MetamorphicCrypto.Poprf.blind(input_b64, info_b64, server_public_b64)
@spec blind_evaluate( secret_key_b64 :: String.t(), blinded_element_b64 :: String.t(), info_b64 :: String.t() ) :: {:ok, {String.t(), String.t()}} | {:error, String.t()}
The server-side blind evaluation (RFC 9497 §3.3.3 BlindEvaluate): evaluate
a client's blinded_element under secret_key tweaked by the public info.
Returns {:ok, {evaluated_element_b64, dleq_proof_b64}} or {:error, reason}. The server learns nothing about the input behind the blinded
element.
@spec blind_evaluate_with_random( secret_key_b64 :: String.t(), blinded_element_b64 :: String.t(), info_b64 :: String.t(), random_b64 :: String.t() ) :: {:ok, {String.t(), String.t()}} | {:error, String.t()}
KAT-only blind_evaluate/3 with an explicit DLEQ nonce (deterministic), for
cross-language known-answer tests.
@spec blind_with_scalar( input_b64 :: String.t(), info_b64 :: String.t(), public_key_b64 :: String.t(), blind_b64 :: String.t() ) :: {:ok, blind_state()} | {:error, String.t()}
KAT-only blind/3 with an explicit blind scalar (deterministic), for
cross-language known-answer tests. Production callers use blind/3.
@spec derive_key_pair(seed_b64 :: String.t(), key_info_b64 :: String.t()) :: {:ok, %{secret_key: String.t(), public_key: String.t()}} | {:error, String.t()}
Derive a POPRF keypair deterministically from a 32-byte seed and a public
key_info string (RFC 9497 §3.2.1 DeriveKeyPair) — how an operator turns a
managed master secret into a stable deployment evaluation key.
Returns {:ok, %{secret_key: base64, public_key: base64}} or {:error, reason}.
Example
{:ok, %{secret_key: sk, public_key: pk}} =
MetamorphicCrypto.Poprf.derive_key_pair(seed_b64, key_info_b64)
@spec evaluate( secret_key_b64 :: String.t(), input_b64 :: String.t(), info_b64 :: String.t() ) :: {:ok, String.t()} | {:error, String.t()}
The non-oblivious server-side evaluation (RFC 9497 §3.3.3 Evaluate):
compute the output directly from secret_key and the cleartext input — how
an operator derives indices for inputs it already holds (directory
construction). Produces exactly the output a client derives obliviously via
blind/3 → blind_evaluate/3 → finalize/7.
Returns {:ok, output_b64} or {:error, reason}.
@spec finalize( input_b64 :: String.t(), blind_b64 :: String.t(), evaluated_element_b64 :: String.t(), blinded_element_b64 :: String.t(), proof_b64 :: String.t(), info_b64 :: String.t(), tweaked_key_b64 :: String.t() ) :: {:ok, String.t()} | :invalid | {:error, String.t()}
The client-side completion (RFC 9497 §3.3.3 Finalize): verify the server's
DLEQ proof against the tweaked_key, unblind, and return the 64-byte output.
Distinguishes a cryptographic rejection from a structural error:
{:ok, output_b64}— the proof is valid;output_b64is the PRF output.:invalid— a cryptographic rejection (wrong key, wronginfo, tampered evaluation, or a forged proof). The inputs were well-formed but the proof does not verify.{:error, reason}— a structural failure (wrong lengths, invalid base64 or a non-canonical encoding).
Generate a fresh POPRF keypair from the OS CSPRNG.
Returns %{secret_key: base64, public_key: base64}. Intended for tests and
tooling; a deployment derives its stable key with derive_key_pair/2.
Example
%{secret_key: sk, public_key: pk} = MetamorphicCrypto.Poprf.generate_keypair()
Derive the base64 public key for a base64 secret_key.
Returns {:ok, public_key_b64} or {:error, reason} (wrong length / invalid
base64 / non-canonical scalar).