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.

Diagram showing how the Tor arti source line if not public bits equals expected bits was meant to reject any key that is not 1024 bits, but Rust applied the not operator to the bit count first, so the comparison is always false and the rejection branch never runs

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.

FieldValue
ProjectTor arti (tor-cert-x509 crate)
ClassOperator-precedence bug disabling a key-size check
SeverityLow (Tor’s assessment); arti binary unaffected
AdvisoryNo TROVE, no RustSec (Tor’s decision)
FixMerge request !4231, one-character change plus tests
ReportedPrivately 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

KeyIntended (!(bits == 1024))Compiled ((!bits) == 1024)
1024-bitacceptaccept (by luck)
2048-bitrejectaccept (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

Timeline of the coordinated disclosure: privately reported to the Tor security team on June 30 2026, triaged and briefly labelled a blocker, assessed as low severity with no TROVE, and fixed and closed through merge request 4231

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 arti where if !public.bits() == EXPECT_ID_BITS disabled 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 arti binary 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.