Skip to content

codec and x.509 hardening#2323

Draft
tob-scott-a wants to merge 4 commits intoRustCrypto:masterfrom
tob-scott-a:x509-limbo-integration
Draft

codec and x.509 hardening#2323
tob-scott-a wants to merge 4 commits intoRustCrypto:masterfrom
tob-scott-a:x509-limbo-integration

Conversation

@tob-scott-a
Copy link
Copy Markdown

Note

This is all being submitted as one branch for discussion. If you prefer each to be individual branches / pull requests, I will decompose this into multiple. I'm submitting this as a draft PR for now. If you're happy with all 3 changes and want them in one go, I'm also happy to flip it into a ready-to-merge PR.

During the course of testing internal Claude skills I'm developing, I tasked them with finding/mitigating side-channels across RustCrypto. It yielded a lot of false positives and not much worth writing about.

Some follow-up work:

  • base32ct's remove_padding had a TODO; 4e5982c aspires to address it
  • ct-disasm is a new developer tool that aims to do the same kind of analysis as one of Trail of Bits' public skills, but without Python or LLMs involved; b3f9c3a
  • 81804a3 adds the C2SP/x509-limbo test harness to the x509-cert crate to improve test coverage

tob-scott-a and others added 4 commits April 29, 2026 13:08
…leak

The previous implementation used `match input.split_last()` and broke out
of the loop on the first non-`=` byte, so the iteration count revealed
which trailing position held the first non-`=`. For well-formed Base32
this leaks only `len % 5` (already public via the documented threat
model), but for malformed inputs an attacker can distinguish "first
trailing char is `=`" vs not by timing.

Replace it with a fixed six-byte fold over the trailing window. The
sticky `all_pad` mask collapses to 0 the first time a non-`=` byte is
seen, contributing nothing to `pad_count` for the rest of the loop,
while every iteration runs unconditionally. Same set of valid/invalid
inputs are still accepted (the chunk loop's `err |= (src_rem.len() ==
N)` checks continue to reject pad-count `{2,5}` indirectly).

All existing tests pass — 2 unit + 5 proptest + 5 vector cases — and
the change leaves the behavioral contract unchanged. Closes the timing
half of the maintainer TODO at the old line 246.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a research-preview gate that compiles wrapper shims around the
public encode/decode APIs of `base16ct`, `base32ct`, and `base64ct`
with fixed-length, `core::hint::black_box`-anchored buffers, dumps the
emitted assembly via `rustc --emit=asm`, and reports per-wrapper
conditional-branch counts.

The CT-critical inner functions (`decode_nibble`, `decode_5bits`,
`decode_6bits`, `is_pad_ct`) are `#[inline(always)]` and not exported.
What matters for CT correctness is what they look like *after* LLVM has
inlined them into the public path real callers use — so the wrappers
exist to anchor that path so the asm scanner sees the actual machinery.

Snapshotted baseline (`aarch64-apple-darwin`, rustc 1.85): five
`base16ct` wrappers and two each of `base32ct`/`base64ct` are fully
branch-free; remaining counts are length-conditional or
panic-trampoline branches that LLVM didn't fold (not CT bugs). The
intended use is `./check.sh --baseline | diff baseline.<arch>.txt` as a
regression-detection gate — any nonzero diff in a `branches=0` row
is a CT-violation candidate worth manual review.

Known limitations are documented in the README: panic-target filtering
is not yet implemented, conditional moves (`cmov`/`csel`) are not
flagged, memory-access-pattern leaks aren't caught, and not all
`base64ct` variants are separately wrapped (they share machinery via
the `Alphabet` trait).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the C2SP x509-limbo certificate corpus into `x509-cert/tests/`.
The harness loads `limbo.json`, walks every testcase, decodes every
embedded certificate via `Certificate::from_der`, and exercises the
`BasicConstraints` / `KeyUsage` / `ExtendedKeyUsage` / `SubjectAltName`
/ `NameConstraints` field decoders. Two assertions guard the suite:

  1. Every `expected_result == SUCCESS` testcase's certs MUST decode.
  2. Every `expected_result == FAILURE` testcase tagged as
     `malformed-cert` MUST be rejected by `Certificate::from_der`.

Chain-validation tests (CRL handling, path-length, EKU policy, name
constraints, signature verification) are out of scope — `x509-cert` is
a decoder, not a validator. Those testcases are counted but not
asserted; the harness's gap report (`tests/limbo/README.md`) classifies
each limbo feature as EXERCISED / PUNTED / OUT-OF-SCOPE.

Results against `C2SP/x509-limbo@086b0da8b83d78ed0f491d6df6672b2673406500`
(9,773 testcases, 39,497 individual certificates):

  expected-pass decoded:                  928 / 928   (100%)
  expected-fail rejected at from_der:       1 / 8,845
  malformed-cert decoded silently:          0         (assertion holds)

The 8,844 expected-fail testcases that decode without complaint are
chain-validation-layer failures (e.g. `crl::*`, `pathlen::*`,
`invalid::invalid-issuer-key`); not bugs in `x509-cert`.

The 39MB `limbo.json` fixture is gitignored. `tests/limbo/fetch.sh`
shallow-clones a pinned upstream SHA via `gh repo clone` + `git fetch`,
copies the fixture into place, and verifies the SHA before fetching.
The harness skips cleanly when the fixture is absent so offline builds
and consumers who don't run `fetch.sh` are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the `Certificate` re-export ahead of the `der` and `ext` modules
in the `use x509_cert { ... }` group so `cargo fmt --all -- --check`
passes. Pure formatting; no behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,119 @@
# ct-disasm — constant-time disassembly check (research preview)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

For something like this it would probably be better to put it in https://github.com/rustcrypto/utils though it does look interesting

Comment on lines +3 to +4
A regression-detection tool for the constant-time hot paths in `base16ct`,
`base32ct`, and `base64ct`. Compiles a small wrapper crate that drives each
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh hmm, if it's specialized to these I guess it can go here

@baloo
Copy link
Copy Markdown
Member

baloo commented May 2, 2026

My understanding is that x509-limbo is more geared towards validation logic. x509-cert does not handle validation.

@carl-wallace made a validator (which I recommend and use) over in https://github.com/carl-wallace/rust-pki/tree/main/certval

I'd love if this could be adopted here at some point.
You'll find a branch there that does make use of x509-limbo.

I'd rather see this dropped from here for now.

continue
fi

COUNT=$(printf '%s\n' "$BODY" | grep -cE "(^|[[:space:]])($BRANCH_REGEX)([[:space:]]|$)" || true)
Copy link
Copy Markdown
Member

@baloo baloo May 2, 2026

Choose a reason for hiding this comment

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

Suggested change
COUNT=$(printf '%s\n' "$BODY" | grep -cE "(^|[[:space:]])($BRANCH_REGEX)([[:space:]]|$)" || true)
COUNT=$(printf '%s\n' "$BODY" | grep -cE "^[[:space:]]*($BRANCH_REGEX)[[:space:]]*$" || true)

Am I reading this regexp wrong?

Surely there is a better way to read assembly than a regex anyway? That seems like it's looking for false-positives.

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