Occasional invalid RSA signature under repeated signing (gpg 2.3.7 / libgcrypt 1.10.1)
Diggory Blake
diggsey at googlemail.com
Tue Jul 7 19:03:19 CEST 2026
Hi all — I hit an odd signing failure and did enough digging that I
thought it was worth writing up in case it's useful. Happy to be told
this is already known, or that I'm barking up the wrong tree.
## What happened
I signed a git commit with `git commit --amend --no-edit` (which calls
`gpg -bsau <KEY>` under the hood), and later noticed `git verify-commit`
reports it as `BAD signature`. The key is the right one, and every
other commit I've signed around that time verifies fine — only this one
is broken.
When I dug into it, `gpg --debug 4 --verify` shows the signature is
well-formed but `s^e mod n` doesn't match the PKCS#1 v1.5-padded
expected value. Re-signing the same bytes with the same gpg immediately
after produces a good signature, so it was a one-off. If it were just
that I'd have shrugged and moved on, but the shape of `cmp` turned out
to be pretty specific.
## Environment
- Windows 10 Pro 22H2
- gpg (GnuPG) 2.3.7 (the standalone installer from gnupg.org, not the
one bundled with Git for Windows)
- libgcrypt 1.10.1
- RSA-4096 key, SHA-256 signatures
- No custom `gpg-agent.conf`, defaults throughout
## The signature that failed
Comparing what gpg computed the expected padded value should be against
what it got from `s^e mod n`:
```
data: 01ff…ff003031300d0609608648016503040201050004200c8d58d745…ad3409
(expected)
cmp : a0ed2c0ca3a3401628b3eb3436885a1992b551c589176ef0adddfccea17aef24
ad6e76df617bf9e4e5a6ecf455feb0cb939030402b9e986e37b2243431070c7e
f86a1b3e1ee98da51d8250bd215274d98ec3ec329930b65880c2b4c3f7db8559
… (matches n bytewise for ~480 bytes with byte 1 offset by −2) …
<32-byte tail varies per failure>
```
The public modulus starts:
```
n: a0ef2c0ca3a3401628b3eb3436885a1992b551c589176ef0adddfccea17aef24
ad6e76df617bf9e4e5a6ecf455feb0cb939030402b9e986e37b2243431070c7e
…
```
Byte 1 of `cmp` is `0xed`, byte 1 of `n` is `0xef`, so `cmp` looks like
`n − 2^4081` in the high half. Middle 460+ bytes match `n` exactly.
Only the last 32 bytes differ from a straight copy of `n`.
I checked (with an independent BigInt reimplementation) that this is
the actual `s^e mod n`, not just something libgcrypt's debug printer
was showing me — same result.
## Reproducer
I wondered how rare this actually was, so I wrote a small stress
harness — a Node script that loops `gpg -bsau <KEY>` on a fixed 1305-byte
payload and verifies each signature. Rough hit rates:
- Sequential (1 sign at a time): **1 BADSIG in 500**
- 5-way concurrent: **1 BADSIG in ~100–200**
So concurrency isn't necessary — it just makes it faster to reproduce.
I collected two more BADSIGs this way. Both have the same shape as the
original: the first 480 bytes of `s^e mod n` are byte-for-byte identical
to the original failure (`a0ed2c0ca3a3401628b3eb…`), and only the
trailing 32 bytes vary. Three independent failures agreeing to that
extent doesn't look like random memory noise; it looks like a specific
intermediate value is leaking through.
## Fault-attack safety
Before sharing failing signatures I checked they don't enable a
Bellcore-style CRT fault attack. For each failure:
```
gcd(|s^e mod n − PKCS1_pad(hash)|, n) = 1
```
so the corruption affects both CRT branches (mod p *and* mod q)
coherently, not one branch only. The private key isn't recoverable
from these signatures.
That's also an interesting data point about the mechanism, I think —
it's not a fault in one exponentiation but something that survives
CRT recombination.
## Things I ruled out
- **Race condition** — sequential reproduces at 1/500
- **git → object corruption** — base64 CRC-24 verifies over the sig
packet; git writes exactly what gpg returned
- **Wrong payload / encoding** — the LEFT16 field in the sig packet
matches the SHA-256 prefix of the exact bytes git presents to gpg,
and I brute-forced ~thousands of nearby payload variants (CRLF, BOM,
encoding header, timestamp sweeps, every tree hash in the repo) —
none verify
- **Wrong key** — issuer fingerprint is correct
- **Transient bad state** — re-signing the same bytes moments later
succeeds
## Where I got to on the mechanism
I read some of `libgcrypt-1.10.1/mpi/mpi-pow.c`, `mpi/mpiutil.c`, and
`cipher/rsa.c`, but I didn't manage to pin down the specific line.
The candidates I noticed (may be wrong):
- `_gcry_mpi_powm`'s W==1 branch skips the `MPN_ZERO(rp, rsize)` the
W>1 branch does (though I'm not sure W==1 is even reached for
RSA-4096 sign)
- `_gcry_mpi_alloc_limb_space` doesn't zero the buffer except in the
`nlimbs==0` case
- `secret_core_crt`'s output MPI might reuse limbs from a previous
operation whose contents happened to be shaped like `n`
The fact that `s^e mod n` is *deterministic* across failures except for
32 bytes at the tail suggests something in the blinding intermediate
path, since the blinding factor `r` is the only fresh input per sign.
libgcrypt 1.11 rewrote the modexp as `_gcry_mpih_powm_lli` which might
already have addressed this class of bug — I haven't tested that yet.
## Attachments
Files in this gist:
https://gist.github.com/Diggsey/ac9232486c7347f60dc77970521ec0bd
- `bad.payload.bin` — the exact 1305-byte payload
- `bad_original.asc`, `bad_concurrent.asc`, `bad_sequential.asc` — three
failing signatures (one from the original git commit, two from the
stress reproducer)
- `stress.js` — the reproducer (~90 lines of Node)
- `decode-sigs.js` — the independent BigInt verifier that also does the
Bellcore-safety `gcd` check
Thanks for taking a look, and apologies if this turns out to already
be fixed in a version I haven't tested.
More information about the Gnupg-devel
mailing list