Skip to content

Conversation

rozbb
Copy link
Contributor

@rozbb rozbb commented Sep 6, 2025

The Lizard encoding/decoding algorithms specify a map from 16-byte strings to the Ristretto group, and back. This is useful for ElGamal encryption, where plaintexts are group elements.

Signal relies on Lizard, and has been vendoring curve25519-dalek for years now (bc we don't export FieldElement). I figured upstreaming would be a valuable addition to dalek, so long as it is extensively documented so as to be maintainable. It also doesn't add any new dependencies.

Some highlights of this PR:

  • Implements and exposes the Lizard algorithms, as well as some lower-level map_to_curve algorithms necessary for downstream. Most of this is from the Signal repo
  • Includes a detailed writeup of the Lizard algorithm, how it works, and how the pseudocode maps to the Rust code
  • Upgrades our Sage reference file to Python3 (wow)

This was prepared jointly with @rolfe-signal and with input from @jrose-signal. Thank you all so much for your work on this!

@rozbb rozbb requested a review from tarcieri September 6, 2025 02:55
/// This function does not produce cryptographically random-looking Ristretto points. Use
/// [`Self::hash_from_bytes`] for that. DO NOT USE THIS FUNCTION unless you really know what
/// you're doing.
pub fn map_to_curve(mut bytes: [u8; 32]) -> RistrettoPoint {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jrose-signal I've modified this function a little bit from from_uniform_bytes_single_elligator. It does the masking here rather than requiring the caller to do it. I think that should simplify your code but let me know if it doesn't

Copy link
Contributor

@jrose-signal jrose-signal Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alas, we have one place where we are not masking, where we are not using this as a bidirectional Elligator map but merely as a performance hack for a faster from_uniform_bytes (and changing that would be incompatible with existing clients). I wish we didn't, but we do.

Copy link
Contributor

@jrose-signal jrose-signal Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While here, I also suggest the doc comment be very specific about what "bottom bit" and "top two bits" means; since the argument is just "bytes", a client wouldn't immediately think it's going to be used as a little-endian Curve25519 scalar value, and a bytestring doesn't usually have a "top". Though maybe that's at odds with "we (Signal) still need a non-masking version".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alas, we have one place where we are not masking, where we are not using this as a bidirectional Elligator map but merely as a performance hack for a faster from_uniform_bytes (and changing that would be incompatible with existing clients). I wish we didn't, but we do.

Oof ok I think I can make something work. Can you share that code by any chance?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While here, I also suggest the doc comment be very specific about what "bottom bit" and "top two bits" means; since the argument is just "bytes", a client wouldn't immediately think it's going to be used as a little-endian Curve25519 scalar value, and a bytestring doesn't usually have a "top". Though maybe that's at odds with "we (Signal) still need a non-masking version".

Great catch. Will fix

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is here https://github.com/signalapp/libsignal/blob/43a23efa1118ac32a1434ab317025adfa2b91e4a/rust/zkgroup/src/common/sho.rs#L38

with exactly one caller here https://github.com/signalapp/libsignal/blob/main/rust/zkgroup/src/crypto/profile_key_struct.rs#L47

which needs to be fast because it's in the "try all 64 possibilities for recovering a full 32 bytes loop" here https://github.com/signalapp/libsignal/blob/main/rust/zkgroup/src/crypto/profile_key_encryption.rs#L100

(My non-cryptographer self suspects once we already gave up on hitting the entire curve we could have used a simpler function to produce points from bytes, but it's too late now.)


/// Computes the possible bytestrings that could have produced this point via
/// [`Self::map_to_curve`].
pub fn map_to_curve_inverse(&self) -> [CtOption<[u8; 32]>; 8] {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jrose-signal another API change: I've renamed decode_253_bits to this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very reasonable, and definitely a nicer signature. Rolfe and I talked about going even further and returning [[u8; 32]; 8] by duplicating present values over absent ones, since there can already be duplicates; however, it's definitely extra work over the basic API.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I think the explicitness here is nice fwiw

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants