Skip to content
NOLAIPublic

About

Library for polymorphic pseudonymization and encryption

Topics

Resources

Code of conduct

Stars

2 stars

Watchers

2 watching

Forks

Latest commit

 

History

456 Commits

Folders and files

Repository files navigation

libpep: Library for polymorphic encryption and pseudonymization

Crates.io Downloads PyPI Downloads npm Downloads License Documentation Dependencies

libpep implements n-PEP, a scheme for end-to-end encrypted, pseudonymized data sharing. Every party knows its data subjects under its own pseudonyms, which cannot be linked to another party's. Encrypted data is blindly re-encrypted (transcrypted) for a receiving party by semi-trusted transcryptors, which convert the pseudonyms in it to the receiver's domain without decrypting anything. Transcryption can be distributed over n transcryptors, so that every transcryptor can independently monitor and block data exchanges, while confidentiality and pseudonym unlinkability hold as long as at least one of them remains uncompromised.

The library is the reference implementation of the n-PEP paper (see Background). It ships as a Rust crate with Python and JavaScript/WebAssembly bindings that mirror the Rust API.

Installation

Rust, in Cargo.toml:

[dependencies]
libpep = "0.14"

Python, importable as libpep:

pip install libpep-py

JavaScript, for Node.js and browsers:

npm install @nolai/libpep-wasm

The peppy command-line tool is installed with cargo install libpep --features cli.

Quick start

A transcryptor holds two secrets from which all its factors derive. Every party works in its own pseudonymization domain (typically its role or user group) and encryption context (typically a session), and holds session keys for that context.

use libpep::client::{decrypt, encrypt};
use libpep::contexts::{EncryptionContext, PseudonymizationDomain};
use libpep::data::simple::{ElGamalEncryptable, Pseudonym};
use libpep::factors::{EncryptionSecret, PseudonymizationSecret};
use libpep::keys::{make_global_keys, make_session_keys};
use libpep::transcryptor::Transcryptor;

let rng = &mut rand::rng();

// System setup: global keys, and the secrets of the transcryptor.
let (_global_public, global_secret) = make_global_keys(rng);
let transcryptor = Transcryptor::new(
    PseudonymizationSecret::from(b"pseudonymization secret".to_vec()),
    EncryptionSecret::from(b"encryption secret".to_vec()),
);

// Party A and party B each have a pseudonymization domain, an encryption context and session keys.
let (domain_a, session_a) = (PseudonymizationDomain::from("hospital"), EncryptionContext::from("session-a"));
let (domain_b, session_b) = (PseudonymizationDomain::from("research"), EncryptionContext::from("session-b"));
let keys_a = make_session_keys(&global_secret, &session_a, transcryptor.rekeying_secret());
let keys_b = make_session_keys(&global_secret, &session_b, transcryptor.rekeying_secret());

// A encrypts one of its pseudonyms for its own session.
let pseudonym_a = Pseudonym::random(rng);
let encrypted = encrypt(&pseudonym_a, &keys_a.public(), rng);

// The transcryptor converts it to B's domain and session, without decrypting it.
// Every transcryption rerandomizes the ciphertext first, which needs the key it is currently
// encrypted under (A's session public key here; with the `elgamal3` feature the ciphertext carries it).
let info = transcryptor.transcryption_info(&domain_a, &domain_b, &session_a, &session_b);
let transcrypted = transcryptor.transcrypt(&encrypted, &info, &keys_a.public(), rng);

// B decrypts its own pseudonym for the same subject, which is unlinkable to A's.
let pseudonym_b = decrypt(&transcrypted, &keys_b);
assert_ne!(pseudonym_a, pseudonym_b);

// Transcryption is reversible: converting back yields A's pseudonym again.
let back = transcryptor.transcrypt(&transcrypted, &info.reverse(), &keys_b.public(), rng);
assert_eq!(pseudonym_a, decrypt(&back, &keys_a));

Every key operation takes either the one key it needs or a bundle to take it from: keys_a.public() for the session's public keys, keys_a itself where a secret key is needed. The data's type selects the half, so a pseudonym is always encrypted and decrypted under the pseudonym key and never the attribute one; &keys_a.pseudonym.public remains available where naming the exact key is clearer.

Attributes (Attribute, LongAttribute) are encrypted and transcrypted the same way, but only rekeyed, never converted between domains. With the json feature, whole JSON documents with nested pseudonyms and attributes are encrypted and transcrypted as one value.

In the distributed setting, keys::distribution::make_distributed_global_keys produces a blinded global key and one blinding factor per transcryptor, each transcryptor::DistributedTranscryptor hands a client a session key share, and client::Client::from_shares reconstructs the session keys from the shares. No party ever holds the global secret key. The public key a ciphertext is encrypted under travels with it from transcryptor to transcryptor: each obtains the next key with rekey_public_key (the factors::RekeyPublicKey trait) on its transcryption info (the CLI prints it as key). tests/distributed.rs walks through this flow; the Python and JavaScript test suites under bindings/ do the same in those languages.

Command line

The peppy tool exposes the library on the command line. The first word names what is operated on, the second the operation:

Group Operations
keys global, keys session, keys distributed generate; derive; setup, share, reconstruct, update
factors reshuffle, rekey, info, commitments
pseudonym, attribute random, encode, decode, encrypt, decrypt, rerandomize, rekey, transcrypt, verify-rekey, and pseudonymize, verify-pseudonymize for pseudonyms
json encrypt, decrypt, transcrypt, verify-transcrypt
elgamal encrypt, decrypt, rr, rs, rk, rsk, rrsk, rs2, rk2, rsk2, rrsk2
scalar, point random, invert, mul, from-hash; random, base, mul, from-hash, hash-to-group

point hash-to-group domain-separates its hash with the ciphersuite this build implements (ristretto255-SHA512); there is one, so there is nothing to select. Factor derivation is separated by the transcryptor secrets, the domain and the session.

Values print one per line on standard output, labelled when that is a terminal and bare when piped; --json prints all values of a command as one object, which is the form to use when a command prints several values. Any value argument may be - to read standard input or @path to read a file. Scalars and group elements are hex, ciphertexts base64, and long values are given as space-separated (plain) or |-separated (encrypted) blocks. Malformed input exits with status 1 (a usage error with 2, from clap); with the elgamal3 feature a decryption with the wrong key exits with status 3; a proof that does not verify exits with status 4.

The quick start above, on the command line:

peppy keys global generate --json > global.json
peppy keys session derive --pseudonym-global-secret "$(jq -r .pseudonym_secret_key global.json)" \
    --encryption-secret "encryption secret" --context session-a --json > session-a.json
peppy keys session derive --pseudonym-global-secret "$(jq -r .pseudonym_secret_key global.json)" \
    --encryption-secret "encryption secret" --context session-b --json > session-b.json

value=$(peppy pseudonym encode patient-42)
encrypted=$(peppy pseudonym encrypt --key "$(jq -r .pseudonym_public_key session-a.json)" "$value")
transcrypted=$(peppy pseudonym transcrypt \
    --pseudonymization-secret "pseudonymization secret" --encryption-secret "encryption secret" \
    --from-domain hospital --to-domain research --from-context session-a --to-context session-b \
    --key "$(jq -r .pseudonym_public_key session-a.json)" "$encrypted" --json | jq -r .ciphertext)
peppy pseudonym decrypt --key "$(jq -r .pseudonym_secret_key session-b.json)" "$transcrypted"

Transcryption is verifiable on the command line as well: factors commitments prints the commitments a transcryptor publishes for a transition, --prove on rekey, pseudonymize and transcrypt adds a proof (JSON) to the output, and verify-rekey, verify-pseudonymize and json verify-transcrypt check a proof against the original ciphertext and the commitments, printing the transcrypted ciphertext only if it holds. A proof of pseudonym transcrypt is a pseudonymization proof, a proof of attribute transcrypt a rekey proof.

peppy factors commitments \
    --pseudonymization-secret "pseudonymization secret" --encryption-secret "encryption secret" \
    --from-domain hospital --to-domain research --from-context session-a --to-context session-b \
    --json > commitments.json
peppy pseudonym transcrypt --prove \
    --pseudonymization-secret "pseudonymization secret" --encryption-secret "encryption secret" \
    --from-domain hospital --to-domain research --from-context session-a --to-context session-b \
    --key "$(jq -r .pseudonym_public_key session-a.json)" "$encrypted" --json > transcrypted.json
jq -c .proof transcrypted.json > proof.json
peppy pseudonym verify-pseudonymize \
    --reshuffle-commitment "$(jq -r .reshuffle_commitment commitments.json)" \
    --rekey-commitment "$(jq -r .pseudonym_rekey_commitment commitments.json)" \
    --key "$(jq -r .pseudonym_public_key session-a.json)" --proof @proof.json "$encrypted"

peppy keys distributed setup -n 3 sets up a system with three transcryptors; each one then answers a client with keys distributed share, and the client combines the shares with keys distributed reconstruct. peppy completions <shell> prints a completion script and peppy man the man page.

How it works

In the ElGamal scheme, a message M is encrypted for a receiver with public key Y, belonging to secret key y. Encryption is randomized (polymorphic): every fresh random b gives a different ciphertext for the same message. We write it as Enc(b, M, Y).

The library provides three homomorphic operations on a ciphertext in = Enc(b, M, Y):

  • rekey(in, k): if in can be decrypted with y, the result can be decrypted with k*y, and both decrypt to M. Enc(b, M, Y) becomes Enc(k^-1*b, M, k*Y).
  • reshuffle(in, s): changes the message so that decrypting the result yields s*M. Enc(b, M, Y) becomes Enc(s*b, s*M, Y).
  • rerandomize(in, r): changes the binary form of the ciphertext only; it still decrypts with y to M. Enc(b, M, Y) becomes Enc(b+r, M, Y).

Because these operations work on encrypted data, the receiver does not need to be known at encryption time. Data is encrypted once and later rekeyed, and reshuffled if it is an identifier, for whichever party is granted access: non-interactive, asynchronous end-to-end encryption with built-in pseudonymization.

Pseudonymization

Every party knows a data subject under its own local pseudonym, and reshuffling converts a pseudonym from one party's domain to another's, applying s = s_from^-1 * s_to, without a global pseudonym existing in between. The factor s is tied to the pseudonymization domain of a party.

Pseudonym unlinkability rests on the fact that F_s(M) = s*M is the Diffie–Hellman pseudorandom function (DH-PRF): as long as s stays secret, s*M is indistinguishable from a random group element under the Decisional Diffie–Hellman (DDH) assumption, so pseudonyms in different domains cannot be linked without knowing the ratio of their domain factors. Reshuffling an encrypted pseudonym is an encryption-based oblivious evaluation of this PRF: because the operation is homomorphic, the transcryptor applies its secret factor s without ever seeing the pseudonym or the result, and the sender and recipient never learn s.

Note

Strictly, the DH-PRF is F_s(M) = s*H(M) for a hash H into the group: without H, F_s is linear (F_s(a*M) = a*F_s(M)) and thus not pseudorandom for inputs with known discrete-log relations. This library omits H because origin identifiers are expected to be uniformly random group elements, either sampled directly or produced by the elligator2-based lizard encoding, which makes it infeasible to construct identifiers with known relations. Importing group elements with other distributions (via from_bytes or from_hex) preserves any discrete-log relation between origin identifiers in every domain (if M1 = 2*M2, then also s*M1 = 2*(s*M2)). Individual pseudonyms remain unlinkable across domains under DDH, so this is acceptable as long as no party knows the relations between origin identifiers.

A reshuffle alone is not enough, as the pseudonym is still encrypted for a key the receiving party does not possess. A rekey with k makes it decryptable with k*y, in combination with a protocol to hand the party that secret key. The factor k is tied to the encryption context of a party, typically its current session. rsk(in, s, k) performs both at once, and reshuffle2, rekey2 and rsk2 are the transitive and reversible n-PEP variants that convert directly between two domains or sessions, applying s = s_from^-1 * s_to and k = k_from^-1 * k_to.

When the same encrypted pseudonym is used more than once, it is rerandomized every time, so that comparing ciphertexts byte for byte reveals nothing. Mixing fresh randomness into a ciphertext before reshuffling also protects against plaintext injection at transcryption, which is why pseudonymization and transcryption always rerandomize, and in the default ciphertext mode take the public key the ciphertext is encrypted under.

Verifiable transcryption

With the verifiable feature a transcryptor publishes a commitment s·G and k·G to each factor, and every transcryption can produce a zero-knowledge proof that exactly the committed factors were applied. The proofs are Schnorr-style proofs of discrete-log equality, made non-interactive with the Fiat–Shamir transform, and can be verified in batch. Pseudonyms are transformed with the paper's VRRSK (rerandomize, then reshuffle and rekey in one integrated proof, Section 3.4.2); attributes with its VRK (Definition 10), which rekeys without rerandomization, so a party that sees both sides of a verifiable transcryption can match attribute ciphertexts. Session key shares carry a proof of correct construction against the transcryptor's blinding commitment as well (Section 3.5). This closes the gap noted under As a three-party OPRF: a transcryptor that applies a wrong factor is now detected. The construction is described in Verifiable Blind Pseudonymization via Polymorphic Encryption Using Zero-Knowledge Proofs (WPES 2026).

Relation to other primitives

libpep combines two familiar primitives: reshuffle is an OPRF evaluation and rekey is a proxy re-encryption, applied to the same ciphertext.

As a three-party OPRF

RFC 9497 specifies oblivious pseudorandom functions over prime-order groups as a two-party protocol: a client blinds an input, a server applies its secret key, and the client unblinds to obtain the PRF output, with the server learning neither input nor output.

libpep evaluates the same DH-PRF F_s(M) = s*M, but splits the client role over a sender and a recipient, with the transcryptor as evaluator. ElGamal encryption takes the place of blinding: the sender's encryption randomness b blinds the pseudonym, the transcryptor reshuffles with its domain factor s, and the recipient's decryption unblinds. The transcryptor learns neither M nor s*M, and neither sender nor recipient learns s. Because blinding and unblinding are performed by different parties, evaluation is non-interactive and asynchronous: the sender can encrypt before the recipient is known, and the PRF output appears only in the recipient's domain.

Two differences with the RFC are worth noting. RFC 9497 hashes the input into the group first; libpep omits that hash and relies on origin identifiers being uniformly random group elements, as described above. And the RFC's VOPRF and POPRF modes provide verifiability, whereas plain PEP transcryption does not: a transcryptor that applies a wrong factor produces an incorrect pseudonym undetectably.

As proxy re-encryption with built-in pseudonymization

rekey is ordinary unidirectional proxy re-encryption over ElGamal: it transforms Enc(b, M, Y) into Enc(k^-1*b, M, k*Y), decryptable by k*y, with the transcryptor acting as the proxy without learning the message or holding either secret key. reshuffle adds the one transformation a plain proxy cannot do, mapping M to s*M. Attributes are never reshuffled, so for attributes the scheme is exactly proxy re-encryption; for identifiers, the same homomorphic step that re-encrypts a pseudonym for a new recipient also rewrites it into that recipient's domain, so no global identifier is ever exposed.

This is what the name refers to: polymorphic encryption is the proxy re-encryption, and pseudonymization is the PRF evaluation carried out in the same step.

API overview

Module Description
client Encryption and decryption with session keys, or with global public keys for offline encryption; the Distributed extension reconstructs session keys from shares
transcryptor Pseudonymization, rekeying and transcryption, single and in batch; DistributedTranscryptor produces session key shares; with the verifiable feature, the same operations with zero-knowledge proofs of correct transcryption
data Pseudonym and Attribute, their long (multi-block) variants, records, and JSON documents with nested pseudonyms and attributes
keys Global and session key types and generation, and the distributed key setup with blinding factors and shares
contexts PseudonymizationDomain and EncryptionContext, the identifiers that data is pseudonymized and encrypted for
factors Reshuffle, rekey and rerandomize factors, the transcryption info that bundles them for one transcryption, and their derivation from secrets and contexts (DeriveFactor of draft-doesburg-cfrg-coprf)
elgamal The ElGamal ciphertext, the PEP primitives (rekey, reshuffle, rerandomize and their combinations) in elgamal::primitives, and in elgamal::arithmetic the Group trait (the prime-order group API of RFC 9497) with its ristretto255 instance, the Ristretto scalar and group element arithmetic, and in elgamal::arithmetic::hashing the hashing of RFC 9380 and RFC 9497 (expand_message_xmd, hash_to_group_with_dst, hash_to_scalar_with_dst) together with hash_to_group, the hash_to_group encoding of an identifier under the group's Ciphersuite (elgamal::arithmetic::Ciphersuite, reached as Group::CIPHERSUITE, which supplies the domain separation tags of the protocol's hashes); the verifiable variants of the primitives with their proofs are in elgamal::verifiable (verifiable feature)
verifier Verification of transcryption proofs against factor commitments, with a cache of verified commitments (verifiable feature)

The library is generic over the group: every type that holds group material has a generic version with a G: Group parameter in a generic submodule next to it (data::simple::generic::Pseudonym<G>, keys::types::generic::SessionKeys<G>, ...), and the names at the module level are aliases pinning Ristretto255. Code that uses ristretto255 never needs the generic names. The prelude::client and prelude::transcryptor modules re-export what each role needs. The Python and JavaScript bindings expose the same modules and names. Full API documentation is on docs.rs/libpep.

Features

Default features:

  • long: pseudonyms and attributes over 15 bytes, using PKCS#7 padding.
  • offline: encryption towards global public keys, for when no session key is available or using one would leak information.
  • batch: batch transcryption with shuffling, so that outputs cannot be linked to inputs by position.
  • serde: serialization and deserialization support via Serde.
  • json: JSON documents with nested pseudonyms and attributes.
  • batch-pk: batches carry the public key their ciphertexts are encrypted under, so batch operations that rerandomize do not need it as a separate argument. No effect under elgamal3, where every ciphertext carries its key.
  • verifiable: verifiable transcryption. Every operation gets a counterpart that also emits a Schnorr-style discrete-log-equality proof, made non-interactive with Fiat–Shamir, so a verifier can check that a transcryptor applied the factors it committed to without learning them. See Verifiable transcryption.

Optional features:

  • cli: the peppy command-line tool (enables long, offline, serde and verifiable).
  • verifiable-derivation: derive factors from a pair of master secrets with a Carter–Wegman universal hash, so that a verifier can compute the commitment for any domain or context from two master public keys instead of storing one commitment per factor (Section 3.3.1 of the paper). This is not the preferred scheme: the factors are linear in two public hashes, so two leaked factors give the master secret, and two pseudonyms of one subject (or two session keys) give all of them. Use the published commitments of the verifiable feature unless commitment storage is the binding constraint and that trade-off is acceptable.
  • elgamal3: ciphertexts additionally encode the public key they were encrypted for, the (B, C, Y) triple encoding of the original PEP framework. This makes decryption with a mismatched key detectable, at the cost of larger ciphertexts and slower operations. Decryption functions return an Option (or an error for batches) instead of a plain value. The two modes are not wire-compatible; choose one per deployment.
  • legacy: compatibility with the legacy PEP repository implementation, which derives scalars from domains, contexts and secrets differently. Implies elgamal3, offline and global-pseudonyms.

The two derivations (default and legacy) produce different factors and session keys from the same secrets; a deployment picks one.

  • insecure: methods that use global secret keys directly, such as decrypt_global.
  • global-pseudonyms: pseudonyms in a global pseudonymization domain, using reshuffle factor 1.

Warning

In the intended security model the global secret key is discarded after distributed setup and never exists in one place; insecure retains it, which gives its holder the ability to decrypt everything. Global pseudonyms are linkable across all domains, which defeats the purpose of domain-specific pseudonymization. Use insecure only for testing, global-pseudonyms only when that linkability is an explicit requirement, and legacy only for interoperability with existing legacy PEP deployments.

Security

The library uses the Ristretto group over Curve25519 as implemented by curve25519-dalek, offering 128 bits of security; other prime-order groups can be added by implementing the Group trait. Confidentiality rests on the semantic security of ElGamal, and pseudonym unlinkability on the pseudorandomness of the DH-PRF evaluated by reshuffling; both hold under the DDH assumption in the Ristretto group. All scalar and group arithmetic is constant time, and randomness comes from the caller's cryptographically secure random number generator. Secret keys, secrets and factors are plain values: they implement Debug and are not zeroized when dropped, so an application that logs or dumps them, or that needs secrets wiped from memory, has to take care of that itself. The library has been designed for production use but has not undergone a formal security audit.

The minimum supported Rust version is 1.88; raising it is considered a semver-relevant change.

Development

cargo test
cargo clippy --workspace --all-targets
cargo test --features elgamal3   # the other ciphertext mode

The bindings live in bindings/python and bindings/wasm; their READMEs describe how to build and test them.

Background

This library implements the n-PEP scheme, described in:

Job Doesburg, Bernard van Gastel and Erik Poll, n-PEP: Secure Data Sharing with Transitive and Distributed Blind Pseudonymization. In Security and Trust Management. STM 2026, Lecture Notes in Computer Science, vol. 17172, Springer, Cham, 2027. doi:10.1007/978-3-032-41330-7_5 · PDF

n-PEP extends the PEP framework with reversible and transitive pseudonymization between domains (the reshuffle2, rekey2 and rsk2 operations), eliminating the polymorphic pseudonyms that act as a linking oracle in basic PEP, and distributes transcryption and session key establishment over n semi-trusted transcryptors, so that confidentiality and pseudonym unlinkability hold as long as at least one transcryptor remains uncompromised.

The verifiable operations (the verifiable feature: the DLEQ proofs, the factor commitments and the verifier) are specified in:

Job Doesburg, Bernard van Gastel and Erik Poll, Verifiable Blind Pseudonymization via Polymorphic Encryption Using Zero-Knowledge Proofs. In 25th Workshop on Privacy in the Electronic Society (WPES '26), November 15-19, 2026, The Hague, Netherlands, ACM, New York, NY, USA, 13 pages. doi:10.1145/3847192.3847358

That paper guarantees integrity without breaking the unlinkability of PEP: each transcryptor proves in zero knowledge that it transformed a ciphertext with the factors it committed to, so no single transcryptor is a point of failure for integrity. Table 1 of the paper is the reference for the cost of each verifiable operation.

The original PEP framework was introduced in:

Eric Verheul and Bart Jacobs, Polymorphic Encryption and Pseudonymisation in Identity Management and Medical Research. In Nieuw Archief voor Wiskunde (NAW), 5/18, nr. 3, 2017, p. 168-172. PDF

Citing

If you use this library in academic work, please cite the n-PEP paper (see also CITATION.cff).

License

Apache License 2.0. Authors: Bernard van Gastel and Job Doesburg.

About

Library for polymorphic pseudonymization and encryption

Topics

Resources

Code of conduct

Stars

2 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages