Skip to content

Scaffold an initial cardano-crypto-leios package#670

Open
ch1bo wants to merge 24 commits into
masterfrom
ch1bo/cardano-crypto-leios
Open

Scaffold an initial cardano-crypto-leios package#670
ch1bo wants to merge 24 commits into
masterfrom
ch1bo/cardano-crypto-leios

Conversation

@ch1bo

@ch1bo ch1bo commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Adds a new package for leios cryptographic types and operations. This was done in course of IntersectMBO/ouroboros-consensus#2068, I'm currently integrating this with the cardano-ledger master and expect a follow-up PR there.

The digital signature scheme is BLS12-381 and fixed in the module. Contrary to the CIP-164, the certificate does not contain a slot or EbHash anymore. This makes definition in cardano-base a lot easier and in the current block structure design, the "message" against which the certificate is signed would be available from the (block) context in which the certificate is used.

Most importantly, this module contains encoders/decoders for the LeiosCert type including roundtrip and golden tests. This should be enough for the cardano-ledger to use this type confidently in Dijkstra era blocks.

There are also property tests about aggregating and verifying certificates. The Committee is part of this package, but how it is selected is deliberately kept out of scope.

image

@ch1bo ch1bo changed the title WIP: Scaffold an initial cardano-crypto-leios package Scaffold an initial cardano-crypto-leios package Jun 11, 2026
@ch1bo ch1bo force-pushed the ch1bo/cardano-crypto-leios branch from d85df0c to aa227fa Compare June 16, 2026 14:56
@ch1bo ch1bo requested review from lehins and perturbing June 16, 2026 18:10
@ch1bo ch1bo marked this pull request as ready for review June 16, 2026 18:10
@ch1bo ch1bo force-pushed the ch1bo/cardano-crypto-leios branch from bf92c7d to 780e347 Compare June 17, 2026 07:22
@ch1bo ch1bo force-pushed the ch1bo/cardano-crypto-leios branch 2 times, most recently from 1102098 to 25e16ac Compare June 17, 2026 21:21
ch1bo added 12 commits June 18, 2026 11:38
Roundtrip and golden tests for LeiosCert
These are the only means to create and verify leios certificates about a
certain message (a leios vote). Committee selection was deliberately
kept out of scope
The golden test compares 'cardano-crypto-leios/test/golden/LeiosCert'
byte-for-byte against the hex-dump output of 'encodeWithIndex'. Without
this attribute, the default Windows 'core.autocrlf=true' translates LF
to CRLF on checkout and the comparison fails, even though the file is
committed with LF endings.
These were needed/useful in the cardano-ledger-dijkstra integration
@ch1bo ch1bo force-pushed the ch1bo/cardano-crypto-leios branch 2 times, most recently from 73d303e to 745de18 Compare June 18, 2026 19:18
This avoids redundant import warnings on newer GHC versions
@ch1bo ch1bo force-pushed the ch1bo/cardano-crypto-leios branch from 745de18 to 38a3b98 Compare June 18, 2026 19:24

@lehins lehins left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Consistency is one of the most important parts in software development. It is important to use consistent dependencies as the rest of the project, in this case cardano-base repo being that project.

Comment thread cardano-crypto-leios/cardano-crypto-leios.cabal Outdated
Comment thread cardano-crypto-leios/cardano-crypto-leios.cabal Outdated
Comment thread cardano-crypto-leios/cardano-crypto-leios.cabal Outdated
Comment thread cardano-crypto-leios/src/Cardano/Crypto/Leios.hs Outdated
Comment thread cardano-crypto-leios/src/Cardano/Crypto/Leios.hs Outdated
Comment thread cardano-crypto-leios/src/Cardano/Crypto/Leios.hs
Comment thread cardano-crypto-leios/src/Cardano/Crypto/Leios.hs Outdated
Comment thread cardano-crypto-leios/src/Cardano/Crypto/Leios.hs Outdated
Comment on lines +141 to +142
{ signers :: !BitField
, aggregatedSignature :: !LeiosSignature

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is a pretty terrible naming, since signers can easily be a local binding anywhere in the cardano-node codebase. I suggest something more descriptive like:

Suggested change
{ signers :: !BitField
, aggregatedSignature :: !LeiosSignature
{ leisCertSigners :: !BitField
, leisCertSignature :: !LeiosSignature
-- ^ Aggregated BLS signature

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I deliberately wanted to match the CDDL as close as possible. The call sites should be all in this module, outside construction and inspection are not really intended (to be convenient) and we could even choose to not export the field selectors. However, I didn't want to be too prescriptive on this type.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I understand the desire, but I am speaking from experience people doing this precise mistake in the past of trying to name bindings in Haskell the same way as they were in the spec and it is not worth it!
It causes problems in such a huge codebase as ours.
So, there are only two options:

  • If these fields are not used outside of this module, then they should not be exported.
  • If they are used outside of this module, then they need decent names. Do not worry about matching to the CDDL spec exactly, people can figure out what maps to what

Comment thread cardano-crypto-leios/src/Cardano/Crypto/Leios.hs Outdated
ch1bo added 3 commits June 19, 2026 19:41
- Replace indexed-hex golden file with raw binary; drop the .gitattributes
  LF pin and the base16-bytestring dep.
- Extract InsufficientWeight's named fields into a WeightMismatch record so
  no constructor has partial accessors; drop -Wno-partial-fields and
  DuplicateRecordFields.
- Introduce strict LeiosVoter to replace the lazy (Weight, VerKey) tuple
  in Committee; switch BLSAggregationFailed to Text; tighten verifier
  accumulator strictness.
- Don't export field selectors that can easily overlap.
Replaces the list-of-bytes construction in 'mkBitField' (and the
'BS.unpack' list comprehension in 'bitFieldMembers') with direct
mutable 'ByteArray' operations from 'Data.Primitive.ByteArray', so
the ByteArray-backed representation isn't undone by intermediate
list allocations. Wire encode/decode stay zero-copy via the existing
SBS aliasing; on-wire bytes are unchanged (golden test confirms).
ch1bo added 6 commits June 20, 2026 23:17
'enforceSize' from cardano-binary only accepts definite-length lists,
which would reject any producer that emits the 2-element outer array
of a Leios certificate as an indefinite-length CBOR array. Switch to
'decodeListLenOrIndef' + 'matchSize' for the definite branch and a
trailing 'decodeBreakOr' for the indefinite branch.

Adds a QuickCheck property that round-trips through a hand-rolled
indefinite-length encoding to lock the new behaviour in.
Section headers move into the export list; the body's '-- *' /
'-- **' markers are removed to avoid double sections in Haddock.
Doc strings stay at the definitions. 'mkBitField' and
'bitFieldMembers' are no longer exported — they're only callable
through 'aggregateLeiosCert' / 'verifyLeiosCert', which the tests
exercise transitively. Adversarial tests still have the
'bitFieldFromBytes' / 'bitFieldToBytes' wire helpers.
'aggregateLeiosCert' was binding 'entries = Map.toAscList contributions'
just to feed two separate consumers: a range-check over keys and a
signature-aggregation over values. Each consumer can take its
Map.keys / Map.elems input directly, which lets list fusion eliminate
the intermediates per pass.

Adds source/destination type applications to every fromIntegral in the
package (src + test + testlib) so the conversion's intent is explicit
at the call site and silent type-changes during refactors are caught.
@ch1bo ch1bo requested a review from lehins June 21, 2026 18:07
ch1bo added 2 commits June 22, 2026 10:49
This should be a typical size (> 99% of current stake distribution)
@ch1bo ch1bo linked an issue Jun 25, 2026 that may be closed by this pull request
7 tasks

@lehins lehins left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

More feedback, none of which is terribly critical and we can fix it up later if needed.

getVoterId :: LeiosVerificationKey -> Committee -> Maybe VoterId
getVoterId vk committee =
VoterId . fromIntegral @Int @Word16
<$> V.findIndex ((== vk) . voterVKey) committee.committeeVoters

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Where is getVoterId going to be used. There are two issues with this function:

  • It has O(n) complexity, which can lead to significant performance degradation if used in an inner loop.
  • It is susceptible to integer overflow for a committee size larger than 65535

In theory neither one of these is dangerous, as long as we are sure these edge cases are not triggered. For example today we know that we can't have 65K stake pools, but in 20 years time this might change and this function could become a silent killer.

In any case, if it is only used in testing, I'd suggest moving it into the testlib. However if it will be used in production then we need to properly document those points and instead of simply calling fromIntegral, we should make this function partial upon overflow (eg. error "Impossible: committee is too large").

when (got < required) $
Left (InsufficientWeight WeightMismatch {got, required})
aggVk <-
uncheckedAggregateVerKeysDSIGN (reverse vks) -- REVIEW: reverse needed?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It is needed when used with foldlM. Alternatively you could use foldrM

Normally this would not be desired during validation, since:

  • foldrM will not short-circuit on the very first failure. But that is ok for us, since we need to optimize the most common case which is the valid case
  • with foldrM validation happens from the end of the list in the opposite direction, but since we only report MalformedSigners failure without any other information this point is irrelevant

That being said, it would be a whole lot more efficient to implement a manual nested loop over bits in the byte array, instead of converting BitArray to a list of indices and monadically folding over that list. That being said, such optimization can be postpone until a later time.

Suggested change
uncheckedAggregateVerKeysDSIGN (reverse vks) -- REVIEW: reverse needed?
uncheckedAggregateVerKeysDSIGN (reverse vks)

Comment on lines +331 to +332
{ got :: !Weight
, required :: !Weight

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If the definition is exported into a public api, could I please ask you give those bindings decent names. Whatever you come up, but not something that is so short and not descriptive as got. Maybe:

Suggested change
{ got :: !Weight
, required :: !Weight
{ weightReceived :: !Weight
, weightRequired :: !Weight

Comment on lines +141 to +142
{ signers :: !BitField
, aggregatedSignature :: !LeiosSignature

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I understand the desire, but I am speaking from experience people doing this precise mistake in the past of trying to name bindings in Haskell the same way as they were in the spec and it is not worth it!
It causes problems in such a huge codebase as ours.
So, there are only two options:

  • If these fields are not used outside of this module, then they should not be exported.
  • If they are used outside of this module, then they need decent names. Do not worry about matching to the CDDL spec exactly, people can figure out what maps to what

-- skip per-key PoP checks (they use 'uncheckedAggregateVerKeysDSIGN' /
-- 'aggregateSigsDSIGN' under the hood). Passing in unchecked keys defeats
-- the security of the aggregate signature.
newtype Committee = Committee {committeeVoters :: Vector LeiosVoter}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we please call it LeiosCommittee, since we already have Committee in Ledger and it will be too confusing if we do not disambiguate

Suggested change
newtype Committee = Committee {committeeVoters :: Vector LeiosVoter}
newtype LeiosCommittee = LeiosCommittee {leiosCommitteeVoters :: Vector LeiosVoter}

= -- | A voter index in the sigs is past the committee bound.
VoterIdOutOfBounds !VoterId
| -- | BLS signature aggregation failed (e.g. malformed input signature).
BLSAggregationFailed !Text

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ditto

Suggested change
BLSAggregationFailed !Text
BLSAggregationFailed Text

-- Builds directly into a mutable 'ByteArray' via a single allocation and
-- writes one bit per member of the input set.
signers = BitField $ runByteArray $ do
let len = (n + 7) `div` 8

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

n isn't used outside this binding, this will be cleaner:

Suggested change
let len = (n + 7) `div` 8
let n = committeeSize committee
len = (n + 7) `div` 8


-- | Encode a 'BitField' to CBOR bytes.
encodeBitField :: BitField -> Encoding
encodeBitField = encodeBytes . byteArrayToByteString . bitFieldBytes

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Conversion to ByteString is unnecessary.

Suggested change
encodeBitField = encodeBytes . byteArrayToByteString . bitFieldBytes
encodeBitField = toCBOR . bitFieldBytes

-- Accepts both definite-length and indefinite-length encodings of the
-- outer 2-element array.
decodeLeiosCert :: Decoder s LeiosCert
decodeLeiosCert = do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

To be honest with you, looking at this implementation makes me believe that encoders/decoders should be implemented in ledger in cardano-ledger-binary instead, because:

  • we already have proper helpers for this stuff (for this case eg. decodeRecordNamed)
  • we'll probably have to ignore some of those decoders in this module anyways and rewrite them in ledger using versioned decoder.

We stopped using plain decoder/encoder for almost anything in ledger, except for byron era of course.


-- | Decode a 'BitField' from CBOR bytes.
decodeBitField :: Decoder s BitField
decodeBitField = BitField . byteArrayFromByteString <$> decodeBytes

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

To drive my point about defining these decoders in ledger further: we have introduced indefinite length decoding for bytes for Dijkstra era, which plain decodeBytes does not support.

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.

Prototype Voting

2 participants