A disabled key-size check we found in Tor’s arti
We reported a security-check bug in Tor’s arti: a misplaced ! disabled
an RSA key-size check, so any key size passed a test meant to allow only
1024-bit keys. Tor rated it low severity and fixed it in merge request
!4231. It is a clean Rust operator-precedence trap that slipped past the
compiler, the tests, and clippy.
On this page: the bug · what the function does · what rust compiled · the honest impact · the fix · why tools missed it · disclosure
The bug in one sentence
create_legacy_rsa_id_cert in crates/tor-cert-x509/src/lib.rs is meant to
reject RSA identity keys that are not 1024 bits, as the Tor channel spec
requires. The check was written if !public.bits() == EXPECT_ID_BITS, which
Rust parses as if (!public.bits()) == EXPECT_ID_BITS, so it is effectively
always false and the rejection never happens.
| Field | Value |
|---|---|
| Project | Tor arti (tor-cert-x509 crate) |
| Class | Operator-precedence bug disabling a key-size check |
| Severity | Low (Tor’s assessment); arti binary unaffected |
| Advisory | No TROVE, no RustSec (Tor’s decision) |
| Fix | Merge request !4231, one-character change plus tests |
| Reported | Privately to security@torproject.org, June 30, 2026 |
What the function was supposed to do
The Tor channel spec is specific about the legacy identity certificate. In the words of the spec, quoted in the report:
Tor channel spec requires RSA_ID_X509 to contain a self-signed certificate with a 1024-bit RSA key and exponent 65537
So create_legacy_rsa_id_cert, which builds that certificate from local
relay identity key material, is documented to return an error unless the
keypair is a 1024-bit RSA key with exponent 65537. The size guard is the
line that enforces the “1024-bit” half of that contract.
The constant was right, the operator was not
EXPECT_ID_BITS is 1024. The intent of if !public.bits() == EXPECT_ID_BITS
reads, in English, as “if the key is not 1024 bits, reject it.” That is a
reasonable sentence and a completely wrong expression, because Rust does not
group it the way the English does.
What Rust actually compiled
Here is the trap. In Rust, ! is logical negation only when its operand is
a bool. Applied to an integer, ! is the bitwise-complement operator.
public.bits() returns an unsigned integer, so !public.bits() does not
mean “not the size,” it means “flip every bit of the size.”
And ! binds tighter than ==, so:
// written
if !public.bits() == EXPECT_ID_BITS { /* reject */ }
// parsed by Rust
if (!public.bits()) == EXPECT_ID_BITS { /* reject */ }
For a 1024-bit key, !1024 as an unsigned integer is an enormous number
(every bit above the low ones set), nowhere near 1024, so the comparison is
false and the key is accepted. For a 2048-bit key, !2048 is also nowhere
near 1024, so that comparison is false too, and the key is accepted again.
The rejection branch is dead code for every realistic key size.
The two behaviors, side by side
| Key | Intended (!(bits == 1024)) | Compiled ((!bits) == 1024) |
|---|---|---|
| 1024-bit | accept | accept (by luck) |
| 2048-bit | reject | accept (the bug) |
The 1024-bit case is right by accident, which is exactly why nobody noticed: the default path behaves correctly, so the check looks like it works.
The honest impact: low severity
This is where we stay accurate, because inflating a Tor finding is the fastest way to lose a technical reader. This is not a remote certificate-validation bypass. The affected function generates a local certificate from local identity key material, and Tor’s maintainers scoped it tightly. As the maintainer wrote on the issue:
I’d say that this is a low-severity issue as it’s not used by any Arti artifacts (such as the arti binary), and is unlikely to be used by users of the tor-cert/tor-cert-x509 crates since the function is bespoke.
Default operation is unaffected because normal arti RSA identity key
generation is hardcoded to 1024 bits in crates/tor-llcrypto/src/pk/rsa.rs.
The real risk is narrow: an imported or externally provisioned relay
identity key with a non-1024-bit modulus would be accepted and used to
generate a certificate that violates the channel spec. Tor confirmed the
bug, briefly labelled it a blocker, then settled on low severity and decided
not to issue a TROVE. We report it the same way.
The fix is one character
The correct expression states the comparison directly instead of negating a value:
// before: precedence makes this always false
if !public.bits() == EXPECT_ID_BITS { /* reject */ }
// after: reject when the size is not the expected size
if public.bits() != EXPECT_ID_BITS { /* reject */ }
Merged in merge request
!4231,
with the regression tests the original had been missing: a 1024-bit key is
accepted, and a 2048-bit key is rejected with InvalidSigningKey("Invalid key length"). The tracking
issue has
the full discussion.
Why the compiler, tests, and clippy all missed it
Nothing here is exotic, which is the point. The expression is valid Rust:
!bits() is a legal integer, and comparing an integer to a constant is
legal, so the compiler
has nothing to warn about. The test suite passed because no test fed the
function a wrong-sized key. And cargo clippy -p tor-cert-x509 -- -D warnings passed without flagging the precedence.
The pattern worth grepping for
A Tor maintainer flagged the general shape as worth hunting across the codebase:
We should search our code for other places where we might be doing ‘if !x == y’.
That is the whole lesson. Code that compiles clean, passes its tests, and survives a strict linter can still do the opposite of what it says, when the gap is between programmer intent and operator precedence. Finding that gap is a reading problem, not a tooling problem, which is what a real penetration test does that a scanner does not.
How we disclosed it
We reported this privately to Tor’s security team on June 30, 2026. Tor’s public tracker keeps external reporters out of the issue by policy: the report notes the title was “intentionally neutered so that a semblance of email confidentiality could happen,” and the public issue is filed under the Tor security contact who triaged it. Coordinated disclosure means the fix ships before the detail is public, which is exactly what happened here.
What this has to do with buying a pentest
The reason we publish findings like this, and the critical remote code execution in velocity.js before it, is that depth is the one thing a security vendor cannot fake. Anyone can claim their testing reads code carefully. A fixed bug in the Tor codebase, credited through the Tor security team, is a claim you can check. That same white-box reading, pointed at your application every month, is what our subscription buys, and our benchmark runs are public for the parts a disclosure cannot show. The pricing is public too: $299 a month for early-stage startups, $2,999 for everyone else, against the $5,000 to $30,000 a single traditional test costs. A startup’s year of continuous testing plus an independent SOC 2 attestation lands near $6,088, versus the $30,000-plus a three-vendor stack runs.
The short version
- We reported a bug in Tor’s
artiwhereif !public.bits() == EXPECT_ID_BITSdisabled an RSA key-size check. - Rust reads
!as bitwise complement on an integer and binds it tighter than==, so the rejection branch was dead code. - Impact is low by Tor’s own assessment: the
artibinary is unaffected, and default key generation is 1024-bit regardless. No TROVE was issued. - The fix is
if public.bits() != EXPECT_ID_BITS, merged in !4231 with regression tests. - Public, credited findings are the check you can run on any tester’s claim of depth. Ours is a subscription, and the engine points at your stack every month.
Frequently asked questions
Was this actually a vulnerability?
It was a real security-check bug, and Tor's own maintainers rated it low severity. The affected function is not used by the arti binary, only by the experimental arti-relay crate and by direct callers of the tor-cert-x509 crate, and normal key generation is hardcoded to 1024 bits anyway. Tor confirmed and fixed it but declined to issue a TROVE advisory. We report it the way they scoped it: a genuine bug worth fixing, not a remote exploit.
What was the actual impact?
The function builds a legacy RSA identity certificate and is documented to reject any key that is not a 1024-bit RSA key with exponent 65537. Because the size check never fired, an imported or externally provisioned relay identity key with a non-1024-bit modulus would be accepted and used to generate a certificate that does not conform to the Tor channel spec. Default operation was unaffected; the risk was for non-default, externally provisioned key material.
How does one ! break a security check in Rust?
In Rust the ! operator is logical negation only on a bool. Applied to an integer it is bitwise complement. bits() returns an unsigned integer, so !public.bits() flips every bit of the number rather than negating a comparison. The line if !public.bits() == EXPECT_ID_BITS parses as if (!public.bits()) == EXPECT_ID_BITS, comparing a huge complemented number against 1024, which is never true, so the reject branch is dead code.
Why did the compiler and clippy not catch it?
The code is valid and well-typed: !bits() is a legal integer expression and comparing it to a constant is legal, so nothing warns. cargo test passed because the existing tests did not feed the function a wrong-sized key, and cargo clippy -p tor-cert-x509 -- -D warnings passed without flagging the precedence. Correct-looking code that compiles clean is exactly the class of bug that needs a human reading intent against behavior.
How does HackZero find bugs like this and still charge $299 a month?
We own the whole testing stack, so the marginal cost of a run is compute plus senior review, not tester-weeks. The attack agents, the exploitation tooling, and the reporting are all built in-house: AI does the continuous coverage no human team can afford monthly, and hackers stay in the loop to confirm a finding is real, reduce it, and report it responsibly. Owning the stack is also why one subscription can fold in SOC 2 controls and connect you with an independent AICPA-member CPA who attests them. Startups pay $299 a month, every other company $2,999.