Cracking RSA keys by brute force, on all twelve threads

CSC 482 — Homework 5. An RSA key is the product n = p · q of two big primes, and it is only safe because nobody can get p and q back out of n. This program tries anyway: starting at √n it tests every prime upward until one divides n, with the search range split across a Java fork/join pool. The assignment: time it for 16-bit through 28-bit primes, plot it, and say what the curve means for a real 2048-bit key.

See the results → Read the code

Line graph of crack time in seconds against RSA bit length from 16 to 28 bits, flat until 22 bits then climbing to 82 seconds at 28 bits

The required plot: bit length of each prime on the x axis, seconds to find a factor on the y axis, each point the average of three runs. Below 22 bits the crack is over before the JIT has warmed up; above it the time roughly doubles with every bit.

Results

Measured October 21, 2026 with nothing else running. The program prints only two integers per line, the bit length and the milliseconds, exactly as the assignment asked; the full output files are linked at the bottom.

One run each — java Rsa 16 28 1

bitsmsseconds
162260.23
172250.23
1880.01
192070.21
205410.54
217800.78
221,1681.17
232,6822.68
243,1593.16
259,9649.96
2635,06635.07
2752,92352.92
28137,682137.68

Whole pass: 4 min 5 s.

Average of three runs — java Rsa

bitsmsseconds
162380.24
171060.11
181430.14
193640.36
204270.43
212350.24
221,5571.56
233,8503.85
244,8434.84
255,1815.18
2618,00418.00
2737,25437.25
2882,15182.15

Whole pass: 7 min 44 s. The 28-bit average, 82.2 s, landed almost on top of the assignment's own example of 82.3 s.

Environment

Extra credit: 29 to 32 bits, and what comes after

The assignment's extra credit was to push the bit length higher and estimate the sizes that are out of reach. Four more lengths, one run each, took 67 minutes of wall-clock time with every core busy:

bitsmstime
29268,8244 min 29 s
30424,3627 min 4 s
311,045,34317 min 25 s
322,267,64437 min 48 s

Estimating the rest

Chaining all the points from 22 to 32 bits, the time grows by ×2.07 per bit (geometric mean of the bit-to-bit ratios, counting only pairs where both times are over a second; unrounded, ×2.072). Starting from the measured 37.8 minutes at 32 bits, at the unrounded rate (with ×2.07 the 64-bit row would read 928,000 years):

bits in each primeat ×2.072 per bitat an exact ×2.00
36about 12 hours10 hours
40about 9 days7 days
48about 8 years5 years
64about 950,000 years310,000 years

So what about a real key? A 2048-bit RSA key means each prime is 1024 bits. That is 992 more doublings past the 38-minute crack of a 32-bit prime, and 2992 is far more than the number of atoms in the observable universe, so no amount of hardware or patience gets there by brute force. A madlabs computer would not change that: the search is embarrassingly parallel, so k times the cores buys about log2(k) more bits in the same wall time (64 cores against this machine's 12 threads is about 2.4 bits), which is nothing against 992. Buying lottery tickets does no better: there are about 21013.5 primes with 1024 bits (21023 / ln 21024), so one random 1024-bit prime hits a particular factor one time in about 21013.5, and either of the two factors one time in about 21012.5. The keys that have actually fallen (RSA-250, 829 bits, in 2020) fell to the number field sieve, a far cleverer algorithm, and still took about 2,700 core-years.

Do the results match expectations?

Yes, once the times are big enough to measure. Each extra bit doubles the width of the range the search has to cover (from √n up to max = 2^(bits+1) − 1), so the time should roughly double per bit. Measured growth per bit:

rangegrowth per bithow measured
22 to 28 bits×1.94averages of 3 runs
29 to 32 bits×2.04single runs
22 to 32 bits×2.07all points chained
expected×2.00the range doubles

A geometric mean of neighbouring ratios telescopes to the ratio of the two end points, so each figure really rests on its first and last time (×1.94 = (82151 / 1557)1/6). A least-squares line through log2(time) over the same points, which uses every point, gives ×1.88, ×2.07 and ×2.10. Either way the answer is about ×2.

On the log-scale plot the points from 22 bits up sit on a straight line next to the dotted “doubles every bit” guide, which is what this growth looks like.

Why the small sizes are noisy

Below about a second the numbers bounce around: 18 bits took 8 ms in the single run and 17 bits took 225 ms. Two reasons, both ones the assignment warned about. The first cracks also pay for class loading and the JIT compiler warming up. And where the factor happens to land in the range decides how much of it the threads burn through before one of them hits it. Averaging three runs smooths that but does not remove it: the single 28-bit run took 138 s and the three-run average was 82 s, and the jump from 28 to 29 bits (×3.3) compares an average with a single run. The same luck is why the bit-to-bit ratios wander between ×1.1 and ×3.5 even though the trend is ×2.

Why it is not exactly ×2

Two smaller effects, each about 4% per bit, pull in opposite directions and cancel. Primes get sparser as the numbers grow (the density falls as bits/(bits+1), which on its own would make the growth ×1.92 per bit instead of ×2), and each candidate costs more because BigInteger.nextProbablePrime runs a primality test on bigger numbers (the cost per candidate rises by about the same factor, (bits+1)/bits). Their product is ×2.00, so the doubling is still the expectation. There is also something the instructor flagged in a comment, “It's likely overly large. Can it be smaller?”: the code searches up to max = 2^(bits+1) − 1, while the larger factor q is always below 2bits and √n is at least 2bits−0.5, so at least the top three quarters of the range is work that can never succeed. I left max alone so these timings are for the code as given.

An honest footnote. Before the extra-credit run I predicted 32 bits at about 39 minutes, and it took 37 min 48 s, within 3%. But that prediction used a growth of ×2.3 per bit, which I later found included the noisy jump from 21 to 22 bits (the 21-bit average was only 235 ms). With the clean ×1.94 the estimate would have been 19 minutes; the 28-to-32 stretch actually grew ×2.29 per bit. Both are “about double” with one-run noise on top. The lesson from HW3 holds: one run at each size is a wide error bar.

How the crack works

The program, Rsa.java from the course page, makes two random primes of the requested bit length with BigInteger.probablePrime, multiplies them into n, and then forgets them. The crack starts at n.sqrt() and walks upward through probable primes, because one of the two factors must be at or above √n. To use every core, the range is handed to a RecursiveAction that keeps cutting it in half, forking one half to the pool and computing the other itself, until a piece is under 1,000 wide, and then just loops through it. The thread that finds a divisor flips a shared flag, cont, guarded by static synchronized methods, and every other thread checks that flag each time round its loop and stops.

protected void compute() {
    BigInteger diff = max.subtract(min);
    if (diff.compareTo(THRESHOLD) < 0) {               // small enough: just loop through it
        BigInteger currentPrime = min;
        do {
            if (n.mod(currentPrime).equals(BigInteger.ZERO)) {
                endCont(currentPrime);                  // found it; tell every thread to stop
                break;
            }
            currentPrime = currentPrime.nextProbablePrime();
        } while (getCont() && currentPrime.compareTo(max) <= 0);
    }
    else if (getCont()) {                               // still searching: split in half
        BigInteger mid = (max.add(min)).divide(BigInteger.TWO);
        ForkJoinRsaTask taskA = new ForkJoinRsaTask(n, min, mid);
        ForkJoinRsaTask taskB = new ForkJoinRsaTask(n, mid.add(BigInteger.ONE), max);
        taskA.fork();                                   // hand one half to the pool
        taskB.compute();                                // do the other half ourselves
        taskA.join();
    }
}

The same divide-and-conquer shape as a multithreaded quicksort: fork one side, recurse on the other, join. The pool's work-stealing keeps all twelve threads fed, which is why the order the pieces get searched in, and so the time to the lucky hit, varies from run to run.

What I changed

Every change is marked // HW5 in the source. The thread coordination is the instructor's; I did not touch it.

Usage: java Rsa for the assignment's run, java Rsa 16 28 1 for one run per length, java Rsa 29 32 1 for the extra credit.

Code and files

Also: environment.txt, the 16 to 28 run log, and make_plot.py. The starting point was Rsa.java from the CSC 482 course page (J. Hastings), kept unchanged apart from the marked lines.

Sources