Skip to content

feat: Extend ct.rs Condition to unsigned widths -- RSA groundwork - #63

Open
laruizlo wants to merge 16 commits into
bcgit:release/0.1.3alphafrom
laruizlo:luis/utils/ct-unsigned-masks
Open

feat: Extend ct.rs Condition to unsigned widths -- RSA groundwork#63
laruizlo wants to merge 16 commits into
bcgit:release/0.1.3alphafrom
laruizlo:luis/utils/ct-unsigned-masks

Conversation

@laruizlo

@laruizlo laruizlo commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Extend ct.rs Condition to unsigned widths (u64 parity, new u32)

Why

Groundwork for the upcoming RSA implementation. The RSA bigint layer uses u64 limbs on 64-bit targets and u32 limbs elsewhere, and needs identical constant-time mask constructors for both widths so the dual-width code is written once. Before this PR Condition support was uneven: Condition<i64> had the full constructor set, Condition<u64> had only from_bool/select/is_true, and Condition<u32> did not exist.

This PR is deliberately separate from the RSA work so the shared security-critical utils code gets its own review, and the RSA PRs stay pure-RSA.

What

The PR has grown through review; the final state is:

  • Four fully-populated widths. Condition<i64>, <i32>, <u64> and <u32>, each registered in the sealed SupportedMaskType scheme (registration alone gives every width the generic boolean operator impls & | ^ ! and their assign forms).
  • The unsigned constructor set, identical at u64 and u32 where meaning is width-independent:
    • TRUE / FALSE
    • from_bool, from_bool_var: mask from a boolean (wrapping-sub construction)
    • from_lsb: mask from bit 0 (parity/oddness tests)
    • from_msb: mask from the top bit (adaptor for borrow/carry words coming out of wrapping subtraction chains: from_msb(borrow) is the lt mask, no post-processing)
    • is_bit_set, is_zero, is_not_zero, is_equal
    • select, mov, swap, to_bool
    • all const fn except mov
  • The signed set at full i64/i32 parity, including a review-driven correctness fix: is_lt now uses the standard overflow-free signed-comparison identity. The previous is_negative(x - y) construction overflowed for operands more than half the range apart (debug panic, opposite answer in release). With overflow gone, the whole comparison family (is_lt, is_lte, is_gt, is_gte, is_within_range) is const fn.
  • Every impl is written out by hand, one per width; ct.rs contains no macro_rules!. Earlier revisions macro-generated the per-width impls, but cargo mutants parses with syn and cannot see into macro bodies, so the macro form hid every mask identity from mutation testing: the Condition constructors produced 0 mutants. Per review feedback the widths are now individually defined, taking the package census from 75 to 301 mutants (226 on the constructors). See Verification below for the run results.
  • The cost of that choice is drift between the copies, and it is a known, accepted trade: the four impls land byte-identical modulo the width token (produced by mechanical expansion of the former macros). The per-width tests differential every width against the native operators, so a functional regression applied to one width fails on any covered input; what no test catches is a method added to one width and not the other, a functionally equal but non-constant-time rewrite of a single width, or drift outside the tested boundary sets. Header comments and a CLAUDE.md note state that the widths in a group must be edited together. A small mechanical parity check (normalise the impls, fail on divergence) is planned as its own follow-up PR rather than growing this one.

IMPORTANT / API changes relative to the pre-PR Condition<u64>:

  • Commit 0202982 removes Condition<u64>::is_true(). It duplicated the boolean accessor under a second name and was the API's only &self receiver. A workspace-wide search found no production callers (its only uses were this crate's own tests, migrated in the same commit). The removal is deliberately isolated in its own commit: if reviewers prefer to keep is_true, dropping that single commit restores it without affecting the rest of the PR.
  • Commit bfc0b82 renames to_bool_var() to to_bool() on all widths. The _var suffix on from_bool_var distinguishes the runtime form from the const-generic from_bool::<VALUE>(); there is no const-generic counterpart on the output side, so the suffix distinguished nothing. No production callers; out-of-tree or in-flight code calling is_true() or to_bool_var() must switch to to_bool().

Design notes for review

  • The unsigned constructions do NOT reuse the signed shapes. The signed constructors rely on two's-complement sign reasoning (is_negative via arithmetic shift, the sign-based is_lt identity) that has no meaning for full-range unsigned values. The unsigned versions use the standard mask identities: wrapping_sub from a 0/1 bit, and MSB-extraction of x | x.wrapping_neg() for the zero test.
  • Ordering comparisons are deliberately omitted from the unsigned set. Multi-word callers (the RSA bigint) derive lt from their subtraction borrow chain and convert with from_msb; a word-level unsigned is_lt would invite exactly the overflow-style misuse the signed identity has to defend against.
  • Constructors are const fn throughout; black_box hygiene stays where it already lives (the byte-level helpers), since black_box is not const-compatible and the mask constructors don't use it on any width.
  • Six mutation survivors are OR/XOR equivalences by construction: in is_lt the two operands fire on disjoint sign cases, and in select the mask and its complement never both pass a bit, so | and ^ compute the same function and no test can separate them. This is the survivor class QUALITY_AND_STYLE.md names as acceptable. The | spelling is nevertheless the contract: because tests cannot distinguish the two, they also cannot catch a functionally-equal but non-constant-time rewrite of a single width, so the mask-based shapes must be preserved exactly.

Tests

One hand-written test module per width, mirroring the impls they exercise (the test macros were expanded for the same consistency reasons; .cargo/mutants.toml excludes tests/**, so no mutation coverage was at stake):

  • Boundary coverage for every constructor: 0, 1, MAX, MAX-1, 1 << (BITS-1) unsigned and 0, +/-1, MIN, MIN+1, MAX, MAX-1 signed, the values where leaked cross-signedness reasoning or overflow would produce wrong masks.
  • Mask-canonicality assert on every constructor result: select between a pattern and its complement, which differ in every bit, must return one of them exactly. This proves the mask is all-ones/all-zeros through the public API and kills the wrong-mask mutants a plain truthiness check passes (e.g. a constructor returning 1 instead of -1).
  • Differential checks against the native operators (<, <=, ==, ranges) at every width over all boundary pairs, plus a far-apart-operand regression for the old is_lt overflow and a dense strided sweep of the full i32 range.
  • A borrow-adaptor cross-check: from_msb of a widening-subtraction borrow word agrees with the < operator over all boundary pairs.
  • The legacy i64_tests/u64_tests modules are folded into the per-width modules; deleting them was measured as a zero change in the mutation outcome. ct_tests runs 66 tests; the bouncycastle-utils package runs 83.

Verified additionally against a dual-width bigint prototype implementing the RSA phase-1 predicate/conditional set (ct_is_zero, ct_eq, ct_lt via sbb chain, is_odd, bit(i), select/assign/swap, conditional modular correction, masked table scan) — compiles and passes written-once against Condition<Word> in both the native-u64 and forced-u32 lanes.

Consumers

hex and base64 are the only production consumers of Condition masks (all via the signed is_within_range/is_in_list paths, untouched). The byte-level helpers (ct_eq_bytes, ct_eq_zero_bytes, conditional_copy_bytes) used by core, hmac, mlkem, mldsa and the lowmemory variants are behaviourally untouched.

Pre-push verification (final tree):

  • cargo test --workspace: 71 suites ok, 0 failures
  • cargo fmt --all --check clean; cargo doc -p bouncycastle-utils --no-deps clean under #![forbid(missing_docs)]
  • cargo mutants -p bouncycastle-utils -j 2 --timeout 20: 301 mutants: 220 caught, 15 missed, 66 unviable (macro-form baseline: 75 mutants, constructors invisible). The 15 missed:
    • 6 OR/XOR equivalences in is_lt (i64, i32) and select (all four widths), triaged above; new only in the sense that they were previously invisible
    • 8 pre-existing OR/XOR equivalences in conditional_copy_bytes (disjoint masked operands), unchanged from before this PR
    • 1 pre-existing test gap: Secret::drop (the suite exercises explicit zeroize() but never observes drop-time scrubbing), unchanged from before this PR
    • the 66 unviable are Default::default() replacements on constructors; Condition has no Default
  • Quality stats: unwrap/Err() counts flat (491/260 core-code baseline)

Co-authored with Claude Code

laruizlo and others added 5 commits July 24, 2026 18:24
Groundwork for the RSA bigint layer: identical mask constructors for u64/u32 limbs so dual-width code is written once.

- Register u32 (and i32 for symmetry) in supported_mask_type!.
- Macro-generated impl for u64/u32 parity: from_bool, from_bool_var, from_lsb, from_msb, is_zero, is_not_zero, is_equal, select, mov, swap, to_bool_var (const fn except mov).
- Unsigned constructions use two's-complement mask identities, not the i64 sign tricks, which are invalid for full-range unsigned values.
- Ordering comparisons deliberately omitted: callers derive lt from subtraction borrow chains via from_msb.
- Condition<u64>::select becomes const; is_true kept for backward compatibility; Condition<i64> untouched.
- Tests: parity suites for both widths with boundary coverage, mask-canonicality asserts, and a borrow-adaptor cross-check against the < operator.

Non-goals deferred: generic impl<T> refactor, Condition<u8>, is_in_list CT research question.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s_bit_set

- is_negative and from_msb rustdoc now point at each other as the signed/unsigned spellings of the top-bit mask; is_bit_set and from_lsb likewise for the bit-0 case.
- New is_bit_set(value, bit) on Condition<u64>/Condition<u32> for shape parity with the signed impl, delegating to from_lsb; index type follows the u32 shift-count convention of core.
- Real doc text on the previously empty is_bit_set/is_negative doc comments.
- Tests: is_bit_set agrees with from_lsb at bit 0 and from_msb at the top bit, and each single-bit value reports exactly its own bit, both widths.
- Comment style fix in the i32 registration note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Remove Condition<u64>::is_true, which duplicated to_bool_var under a different name and the only &self receiver in the API; its sole callers were this crate's tests.
- One accessor across all widths keeps the is_* prefix meaning "data in, mask out", preserves the from_bool_var/to_bool_var boundary pair, and keeps the _var suffix warning that converting to bool exits constant-time discipline.
- Migrated the u64 test call sites accordingly; no production callers existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- i64 mask-canonicality test: select(-1, 0) must return exactly -1 or 0, catching the delete-unary-minus mutants in from_bool_var and is_bit_set that truthiness checks let survive (same shape as the unsigned suites' assert_canonical).
- ct_eq_zero_bytes had no tests; added zero/nonzero coverage at first, last, and high-bit positions, killing all four of its mutants including the or-assign to and-assign fold corruption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Formatting-only sweep of pre-existing drift in 21 files (trailing whitespace, blank-line collapses, rewraps); zero code changes (git diff -w shows only 4 blank-line deletions). Needed because rust-style.yml runs cargo fmt --all --check on every PR, which gates on files this branch never touched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@laruizlo laruizlo changed the title Luis/utils/ct unsigned masks Extend ct.rs Condition to unsigned widths -- RSA groundwork Jul 27, 2026
@laruizlo laruizlo changed the title Extend ct.rs Condition to unsigned widths -- RSA groundwork feat: Extend ct.rs Condition to unsigned widths -- RSA groundwork Jul 27, 2026

@ounsworth ounsworth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is great groundwork!

The Condition class was clearly in need of some attention. The section "Observed during review, out of scope for this PR" lists things that are all good changes / cleanup. If you are willing to tackle those as part of this PR, that would be good improvements.

I also left a few comments below.

Comment thread crypto/utils/src/ct.rs Outdated
supported_mask_type!(i64, u64);
// i32 is registered for width symmetry with the u64/i64 pair (it gets the generic boolean
// operator impls below); it has no inherent constructors yet: add them when a consumer needs them.
supported_mask_type!(i64, u64, u32, i32);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Claude Code likes to make these sorts of comments that explain what it's doing right now, but these comments seem like an odd thing to check in. Is this saying that there is unfinished work ("it has no inherent constructors yet: add them when a consumer needs them.")? Why not just add them now?

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'll also chime in here - I think it keeps the cognitive load lower/reduces complexity to keep the implementation styles of the various Conditions consistent - either all via macro or all individually-defined. Would it be possible for i32 and i64 to be done together here in an equivalent signed_condition_impl macro?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I didn't want to change much in the pre-existing code without consulting with you all, but fully agree that a single style would make it much cleaner. I would vote for going all macro, and can add the signed variant as well.

// here even though it passes a truthiness check via to_bool_var.
fn assert_canonical(c: Condition<i64>, expected: bool) {
assert_eq!(c.select(-1, 0), if expected { -1 } else { 0 });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

But I don't understand the comment. I think the comment is wrong.

I tried replacing this with

        fn assert_canonical(c: Condition<i64>, expected: bool) {
            // assert_eq!(c.select(-1, 0), if expected { -1 } else { 0 });
            assert_eq!(c.to_bool_var(), expected)
        }

and the test still passes. So it's not clear to me why this test needs to use the .select() function, or what that has to do with the raw bit mask (which I don't think is what select() is returning).

Comment thread crypto/utils/tests/ct_tests.rs Outdated
assert_eq!(Condition::<u64>::TRUE.is_true(), true);
assert_eq!(Condition::<u64>::FALSE.is_true(), false);
assert_eq!(Condition::<u64>::TRUE.to_bool_var(), true);
assert_eq!(Condition::<u64>::FALSE.to_bool_var(), false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The naming of this feels weird to me. Could we call this .to_bool() instead of .to_bool_var()? Is there some significance in the _var() part of that?
(I know this is pre-existing before this PR, but if you agree, then we could clean it up at the same time)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agree! It seems that Claude Code added the _var() to keep the symmetry with from_bool_var, but the reverse conversion doesn't have a const version, so it's more clear if we remove that, I think. I'll add that to the cleanup.

laruizlo and others added 11 commits August 4, 2026 10:39
- replace impl Condition<i64> with signed_condition_impl!(i64, i32), mirroring unsigned_condition_impl!
- give i32 the full constructor set instead of a deferred-work comment
- drop or_halves: is_zero/is_not_zero now use the width-generic value | value.wrapping_neg() identity
- type the signed is_bit_set index as bit: u32, matching the unsigned side
- add from_lsb to the signed widths for name parity
- add signed_condition_tests! generating boundary-driven test modules for i64 and i32

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- is_lt used is_negative(x - y), which overflows when the operands are more than half the range apart: debug builds panic, release builds return the opposite answer
- replace it with the standard overflow-free identity: sign of x when the signs differ, sign of x - y when they agree
- is_lte/is_gte become const fn by complementing the inner mask instead of using the Not operator; is_within_range follows
- add boundary differentials against the native operators, far-apart regression cases, and a strided full-range i32 sweep

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- the _var suffix on from_bool_var distinguishes the runtime form from the const-generic from_bool::<VALUE>(); there is no const-generic counterpart on the output side, so the suffix distinguished nothing
- no production callers, test-only churn

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rpose

- rewrite the masks_are_canonical comment to name the mutant it defends against: a constructor returning 1 instead of -1 passes to_bool and then corrupts every select it feeds
- select between a pattern and its complement, which differ in every bit, so the assertion holds exactly when the mask is canonical
- use the same helper shape in the signed and unsigned test macros
- verified by hand-mutating from_bool_var to return 1: the truthiness test passes, the canonicality tests fail

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- compress the unsigned macro preamble to the ordering-comparison rationale; the signed-trick contrast no longer applies after the is_lt fix
- align the unsigned is_bit_set doc with the signed one
- shorten the u64 select test comment to the mask-width fact it checks

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- write impl Condition<i64> and Condition<i32> out by hand; delete signed_condition_impl!
- cargo mutants skips macro_rules! bodies, so the macro hid every signed mask identity from mutation
- Condition::<$t>::is_zero in is_in_list becomes Self::is_zero, the right spelling on a concrete impl
- mechanical expansion (script in the plan docs), behaviour-preserving: ct_tests unchanged at 88

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- write impl Condition<u64> and Condition<u32> out by hand; delete unsigned_condition_impl!
- same rationale as the signed expansion: macro bodies are invisible to cargo mutants
- package mutant census now 301 total, 284 in ct.rs, 226 on the Condition constructors (was 0)
- mechanical expansion, behaviour-preserving: ct_tests unchanged at 88

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elled out

- expand the trait-registration macro into its eight impl lines; ct.rs now contains no macro_rules!
- replace the stale macro-era headers: the per-width duplication exists so cargo mutants can see the mask identities, and the widths in a group must be edited together
- keep the Condition<u8> TODO and the unsigned ordering-comparison rationale

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- write signed_i64/i32 and unsigned_u64/u32 test modules out by hand; delete both test macros
- no mutation coverage at stake (mutants.toml excludes tests/**); this is the consistency half of the reviewers' ask
- reword the group headers: the modules are hand-written now and each width pair must be edited together
- 88 tests, same names, same results

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- delete i64_tests and u64_tests: every assertion they made is subsumed by signed_i64_tests and unsigned_u64_tests, measured as a zero change in the mutation outcome
- fix the two copy-pasted | operators in generic_impl_tests::test_bit_xor to ^; XOR coverage otherwise lives in the per-width boolean_operators tests
- test count drops 88 to 66 with no coverage loss

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- a contributor who tidies the per-width impls back into a macro would silently erase the mutation coverage they exist for
- widths in a group must be changed together

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants