One point, many byte strings
A peer sends an Aleo node two compressed curve points. Both carry the infinity flag. One has an all-zero x-coordinate payload; the other hides a nonzero field element beneath the flag.
Both decode to the same mathematical point: the group identity.
Before snarkVM PR #3397, the compressed Short Weierstrass decoder could accept both forms because its infinity branch ignored the decoded x-coordinate. The serializer emitted only the zero form, but the deserializer accepted a much larger language than the serializer produced.
That asymmetry is dangerous anywhere bytes become identities. Hashes, cache keys, signatures, proof transcripts, database indexes, and consensus messages all operate on representations at some stage. If two accepted representations collapse to one point, two subsystems can disagree without disagreeing about the curve arithmetic.
PR #3397 adds a small rule: when the infinity flag is set, x must be zero. Small patch. Good patch. Cryptographic parsers should be fussy.
The identity has no x-coordinate
A finite point on a Short Weierstrass curve satisfies an equation of the form
y^2 = x^3 + ax + b
Compressed serialization normally stores x plus enough information to choose between the two possible y-coordinates. Arkworks models that extra information with SWFlags: one state selects positive y, another selects negative y, and a separate state identifies the point at infinity.
The point at infinity is different from an ordinary affine point. It is the identity element of the elliptic-curve group, usually written O. It has no meaningful affine x-coordinate to compress. A format therefore needs a convention, and the usual convention is an infinity flag with every coordinate bit set to zero.
BLS-style serialization states the rule directly: if the infinity bit is set, the remaining bits of the group-element encoding must be zero. BLS12-377 implementations following that format use a 48-byte compressed G1 representation, with reserved high bits carrying serialization flags and the rest carrying the field element.
Arkworks already prevents one obvious ambiguity in its Short Weierstrass flags. The combination of the negative-y flag and the infinity flag is invalid because infinity must have one flag pattern. That check handles the flag bits. It does not, by itself, prove that the coordinate payload is zero.
snarkVM's old decoding path extracted x and the flags, noticed infinity, and returned the identity. Once the branch chose O, x disappeared. An attacker could vary x while preserving the decoded point.
Canonical encoding requires a tighter contract. Let Enc map points to bytes and Dec map accepted bytes to points. For every accepted byte string b, a strict codec should satisfy:
Enc(Dec(b)) = b
The old infinity path violated that equation:
Dec(infinity_flag || nonzero_x) = O
Enc(O) = infinity_flag || zero_x
The decoder changed the representation merely by reading and writing it again. That failed round trip is the cleanest way to see the bug.
For a base-field point, every accepted field element placed in the ignored x slot could become another spelling of O. The exact count depends on the field decoder and flag layout, but the scale is roughly the field size rather than a handful of aliases. BLS12-377 has a 377-bit base field. The ambiguity was therefore enormous in principle, even though producing one alternate encoding only required changing a single payload bit.
Curve checks do not rescue such a parser. The decoder never tries to recover y when the infinity flag is present, so an on-curve check has no affine coordinates to inspect. A subgroup check also passes because the identity belongs to every subgroup. Canonicality is a separate validation property.
Where the parser went wrong
The vulnerable control flow can be reduced to this schematic Rust:
let (x, flags) = F::deserialize_with_flags(reader)?;
if flags.is_infinity() {
return Ok(Affine::zero());
}
recover_point_from_x(x, flags)
Nothing is wrong with returning early for infinity. The missing condition is on the data being discarded.
A strict version looks like this:
let (x, flags) = F::deserialize_with_flags(reader)?;
if flags.is_infinity() {
if !x.is_zero() {
return Err(SerializationError::InvalidData);
}
return Ok(Affine::zero());
}
recover_point_from_x(x, flags)
The serializer does not need a new representation. It already writes zero into the coordinate slot for O. PR #3397 narrows the decoder so its accepted input matches that existing output.
A good regression suite should cover at least four cases:
- The canonical infinity encoding decodes successfully.
- Infinity with any nonzero x payload fails.
- Ordinary compressed points still round trip byte for byte.
- Invalid flag combinations remain rejected.
Property tests can state the stronger invariant. For every accepted encoding b, decoding and re-encoding must return b. Fuzzers should then mutate flag bits and payload bits independently, especially around zero, the field modulus, and values whose high bits overlap the flag area.
Canonicality does not replace the other checks a protocol may require. A parser may still need to reject coordinates outside the field, points outside the curve, points outside the required prime-order subgroup, or the identity itself when a protocol forbids it. PR #3397 addresses one narrow question: if the protocol accepts the identity, how many byte strings may name it? The answer should be one.
How malleability escapes the curve layer
Malleability means an observer can change bytes without changing the semantic object accepted by verification. No private key is needed. No discrete logarithm is solved. The attacker edits a representation that the parser treats as equivalent.
Consider two encodings:
canonical = infinity_flag || 00 00 00 ... 00
alias = infinity_flag || 00 00 00 ... 01
Under the permissive decoder:
Dec(canonical) = O
Dec(alias) = O
Cryptographic hashes still see different messages:
H(canonical) != H(alias)
Now place that point inside a larger object. One component computes an object ID from the received bytes. Another parses the object, normalizes the point by serializing it again, and computes an ID from the normalized bytes. Both components agree that the point is O, yet they disagree about the enclosing object's hash.
Consensus failures require a reachable disagreement, so accepting aliases is not proof that Aleo had an exploitable fork. Every relevant path might hash raw bytes consistently, or every path might reserialize first. Relying on that global consistency is brittle, though. The parser is the one place that can remove the ambiguity for every caller.
Fiat-Shamir transcripts make the same bug class especially uncomfortable. A prover or verifier may absorb serialized curve points before deriving a challenge. If one implementation absorbs the original bytes while another absorbs a canonical reserialization, alternate encodings produce different challenges. A protocol that explicitly defines which bytes enter the transcript can avoid the split, but a strict decoder gives the safer API contract.
Caches have a quieter failure mode. A cache keyed by input bytes stores separate entries for canonical and alias, while a cache keyed by decoded points treats them as one entry. Attackers may gain a cheap cache-bypass primitive or inflate storage. Neither outcome breaks elliptic-curve security. Both come from the same representational mismatch.
Signatures need careful wording here. A noncanonical point encoding does not automatically forge a signature. The risk appears when an application verifies the decoded point but identifies, deduplicates, or authorizes the signed object using a different byte form. Bitcoin's strict DER and low-S rules address the same broad lesson: mathematically valid alternatives become protocol trouble when transaction identifiers and policy operate on bytes.
The BLS serialization convention avoids that ambiguity by requiring all remaining bits to be zero when infinity is set. Arkworks' SWFlags also rejects the simultaneous negative-y and infinity state. PR #3397 completes the local contract by checking the coordinate payload that accompanies the infinity state.
What Aleo developers should do
Developers using high-level Leo types will rarely parse a compressed BLS12-377 point by hand. The fix still matters to wallets, SDKs, indexers, proving services, bridges, hardware signers, and agents that move Aleo objects across trust boundaries.
At each binary boundary, adopt four rules:
- Reject noncanonical input during parsing rather than accepting and silently normalizing it.
- Define whether hashes and signatures cover wire bytes or a canonical reserialization.
- Keep one codec implementation wherever possible, especially across Rust, WebAssembly, and TypeScript bindings.
- Test adversarial encodings, including ignored fields and impossible flag combinations.
Silent normalization is tempting because it appears user-friendly. In cryptographic code, it can erase evidence that the sender supplied different bytes. Rejecting the input forces the producer to fix its encoder and keeps logs, signatures, hashes, and database records aligned.
Agents deserve extra suspicion. An agent may fetch a proof from one API, decode it through a WASM binding, cache a JSON form, and later ask another service to sign or submit it. Each hop can choose a different representation. Canonical parsing turns many possible byte histories into a single accepted history before the object enters that pipeline.
Consensus code needs the same discipline. Tightening a decoder changes which messages are valid, even when the serializer remains unchanged. If malformed aliases could already appear in blocks or persisted consensus objects, rollout must account for version boundaries. If no accepted chain data contains them, the compatibility cost is much lower. Either way, the validation rule belongs near deserialization, where every caller receives the same answer.
Aleo's deployment-verification bug exposed a related failure shape. Validator behavior depended on data outside the deployment being checked. The cure was deterministic input handling. Canonical serialization applies that idea one layer lower: consensus should not admit several byte strings for one semantic value unless the protocol defines how every downstream operation treats those aliases.
The recent untrusted-edge review made a similar architectural case for explicit limits at node boundaries. PR #3397 adds a semantic limit rather than a numeric one. The accepted language now contains one encoding for infinity.
My rule for cryptographic APIs is strict: serialization may choose a representation, but deserialization must enforce that choice. If serialize(deserialize(bytes)) changes accepted bytes, the format has an ambiguity somebody else will eventually inherit.
For Aleo's identity point, that ambiguity now has a direct answer: set the infinity flag, zero the payload, and reject everything else.
Sources
- ProvableHQ/snarkVM PR #3397: reject noncanonical infinity encoding
- snarkVM compressed Short Weierstrass deserialization path
- arkworks Short Weierstrass serialization flags
- gnark-crypto BLS12-377 point encoding documentation
- BLS point serialization proposal and infinity encoding rule
- IETF pairing-friendly curves draft
- IOTA discussion of noncanonical point encodings and hash inputs
- Bitcoin BIP 62 canonical signature encoding rules
- Why Aleo Deployment Verification Must Never Roll Dice
- Aleo This Week: Hard Limits on a Node's Untrusted Edges