Skip to content

Commit b51452f

Browse files
committed
Key Flags are a statement about the key the carrying signature refers to, so a subkey no longer inherits them from the primary key's certificate-wide signatures - a Subkey Binding signature that omits the subpacket now leaves the subkey with no capabilities rather than the primary's, which brings the flags consulted when requiring a signing subkey's embedded PrimaryKeyBinding signature into line with the flags every other capability decision reads.
1 parent eb57122 commit b51452f

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

docs/releasenotes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Date: 2026, TBD
1515

1616
### 2.1.2 Defects Fixed
1717

18+
- The high-level OpenPGP API (org.bouncycastle.openpgp.api) let a subkey inherit the primary key's Key Flags when its own Subkey Binding signature carried no Key Flags subpacket, which made the two capability decisions taken for one subkey disagree. OpenPGPCertificate.OpenPGPComponentKey.isSigningKey() reads the effective flags, which fell back to the primary key's direct-key or primary user ID self-signature, so a subkey bound with no flags of its own counted as signing-capable; verifyEmbeddedPrimaryKeyBinding reads the binding signature's own flags, found no signing capability there, and so skipped the embedded Primary Key Binding (cross-certification) signature that RFC 9580 sec. 5.2.1.8 and sec. 10.1.3 require of a subkey that can issue signatures. A data signature made by such a subkey was therefore attributed to the certificate and reported valid by OpenPGPSignature.OpenPGPDocumentSignature.isValid() with the cross-certification requirement never applied, where GnuPG refuses the same certificate and message as not cross-certified. An attacker holding a third party's public signing subkey - which is public material - could bind it to their own primary key with a Subkey Binding signature they are able to make, carrying no Key Flags and no embedded Primary Key Binding signature, which they cannot make without the subkey's private key, and have that party's genuine signatures verify as valid under the attacker's own identity: misattribution of a real signature rather than a forgery of a new one, since the signature still has to be one the subkey actually made. Key Flags are a statement about the key the carrying signature refers to (RFC 9580 sec. 5.2.3.29), so a subkey no longer inherits them from the certificate-wide signatures of the primary key: a Subkey Binding signature that omits the subpacket now leaves the subkey with no capabilities rather than the primary's, which makes the flags the cross-certification check consults the same flags every other decision consults. Preferences and the other subpackets a direct-key signature carries are inherited as before, and the primary key itself - whose flags legitimately come from its direct-key or user ID self-signature - is unaffected. The low-level PGPSignature / PGPPublicKeyRing API performs no binding checks by design and is unchanged.
1819
- Neither the HSS nor the XMSS^MT private key decoder checked its declared index against the traversal state stored beside it, although the two are independent records of the same position in the key and so can be compared. For HSS the records are the top-level index and the component keys' one-time indices q; for XMSS^MT they are the global index and the per-layer BDS states. A stored key whose index had been rolled back while its state stayed advanced - a partial write, a restore from backup, a buggy storage layer - was therefore accepted, and it then signed a second message under a one-time key the key had already used, producing a signature that verified, so nothing anywhere surfaced the reuse. RFC 8554 sec. 1 and RFC 8391 sec. 1.1 both require each one-time key to be used exactly once, and this is the failure those requirements exist to prevent; the single-tree XMSS decoder has tied its BDS state to its index since that state was first validated, and this brings the two multi-tree schemes into line. HSS decode now requires the declared index to equal the position the component q values imply - a level above the last contributes (q - 1) leaves of the levels beneath it, since its q has already advanced past the subtree it signed - and XMSS^MT decode now requires each present layer's BDS index to equal the leaf index that layer derives from the global index, allowing the one position where a layer has moved into a new subtree and its state legitimately still carries the previous subtree's final index. A layer with no state yet is unaffected, since those are built lazily at signing time. Related, and the same shape of omission: an XMSS or XMSS^MT private key encoding carries the tree root twice - the key's own root field and the root node of the BDS state stored beside it, which for XMSS^MT is the top layer's - and the two were never compared either. A corrupted root was accepted and then poisoned every signature the key made, because the root is hashed into the message digest: the signature did not verify and nothing indicated why. Decode now requires the two copies to agree. The BDS node values themselves are not checkable the way the LMS tree cache above is - a BDS authentication path, stack, retain or keep node does not have its children stored alongside it, so recomputing one means building a subtree, which is the work the state exists to avoid. Both checks are integer comparisons over the levels of the key, too small to measure against the surrounding decode, and both were verified not to reject any legitimate key by walking every index a key can reach: the full key space of the two-level HSS and the h=4/d=2, h=6/d=2, h=6/d=3, h=9/d=3 and h=8/d=4 XMSS^MT parameter sets, plus a three-level HSS key across a subtree boundary and an HSS shard. Since those node values cannot be recomputed, the encoded state now carries a checksum over itself instead, with the owning key's public seed hashed in front of it. Any corruption of the stored state is refused at decode rather than being loaded and then producing signatures that silently do not verify, and because the public seed is bound in, a state transplanted between two keys of the same parameter set is refused too, even though it is internally consistent and arrives with its own matching root. The public seed is bound rather than the secret seed or the PRF key deliberately: the state's own root and index are inside the encoding and so are already covered, hashing secret material would make the stored checksum a commitment to it for no gain in detection, and the PRF key does not influence the state at all. **This is an error-detecting code and not integrity protection** - anyone able to rewrite the stored key recomputes it, so it establishes that the state is unchanged since it was written, never that it was correct when written, and the allocation bounds on the encoding remain the guard against a crafted one. It costs one SHA-256 over the state, measured at 5 to 8 microseconds each way for the h=10 and h=16 parameter sets, and 32 bytes of encoding. The state encoding was added earlier in this same cycle and has not been released, so the checksum is simply part of it rather than a new version: a state written by a 1.86 beta is rejected, which is recovered from by re-exporting the key. The deprecated org.bouncycastle.pqc.crypto.lms copy carries the HSS check as well (github #2414).
1920
- The S/MIME example smoke test in the misc module (org.bouncycastle.mail.smime.examples.test.AllTests) drove SendSignedAndEncryptedMail against smtp.gmail.com, and that example finishes with Transport.send() under JavaMail's default settings, which have no connect timeout. Where outbound port 25 is refused the failure was swallowed and the test passed; where it is silently dropped, as on many home networks, the connect blocked and ./gradlew build hung in :misc:test indefinitely with "0 tests completed". The test now delivers to an SMTP stub on a loopback port, with connect / read / write timeouts as a backstop, and asserts the message arrived (github #2407).
2021
- Composite ML-KEM encapsulation took the traditional component public key bytes it feeds the KEM combiner from the recipient key's own encoding, while decapsulation recomputes the point from the private key and so always produced an uncompressed one. Section 4 of draft-ietf-lamps-pq-composite-kem requires an EC component to be carried as an uncompressed point, but a component key that encodes itself compressed - a BC EC key whose point format has been set through org.bouncycastle.jce.interfaces.ECPointEncoder, or a key from a provider that preserves a compressed encoding - was passed through as it came. Both sides then combined a different tradPK and derived different shared secrets, with no error reported on either: encapsulation and decapsulation both succeeded and the recipient simply could not decrypt. The EC component is now normalised to an uncompressed point wherever the engine serialises one, which covers the ephemeral key that forms the ciphertext as well. X25519 and X448 components have a single encoding and were unaffected, as were EC keys left in their default (uncompressed) format, whose shared secrets are unchanged. CompositePublicKey.getEncoded() took its component bytes the same way, so such a key also encoded to a composite key other implementations reject and whose bytes changed across an encode / decode / encode round trip - 1238 bytes rather than 1270 for MLKEM768-ECDH-P256, and for the composite ML-DSA keys sharing that method, 2006 rather than 2038 for MLDSA65-ECDSA-P256. It now normalises the component the same way. This is a write-side change only: a composite key carrying a compressed EC component is still decoded, since the component key factories accept either form, and continues to verify signatures as before - it simply re-encodes in the normalised form. The shared normalisation is org.bouncycastle.jcajce.provider.asymmetric.util.ECUtil.getUncompressedSubjectPublicKeyBytes.

pg/src/main/java/org/bouncycastle/openpgp/api/OpenPGPCertificate.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1753,6 +1753,12 @@ protected OpenPGPSignature.OpenPGPSignatureSubpacket getApplyingSubpacket(Date e
17531753
PGPSignatureSubpacketVector hashedSubpackets = keySignature.getSignature().getHashedSubPackets();
17541754
if (hashedSubpackets == null || !hashedSubpackets.hasSubpacket(subpacketType))
17551755
{
1756+
if (subpacketType == SignatureSubpacketTags.KEY_FLAGS && this instanceof OpenPGPSubkey)
1757+
{
1758+
// Key Flags apply to the key their signature refers to (RFC9580, section 5.2.3.29) - not inherited
1759+
return null;
1760+
}
1761+
17561762
// If the subkey binding signature doesn't carry the desired subpacket,
17571763
// check direct-key or primary uid sig instead
17581764
OpenPGPSignatureChain preferenceBinding = getCertificate().getPreferenceSignature(evaluationTime);

0 commit comments

Comments
 (0)