Skip to content

Commit 6e9d1d3

Browse files
committed
OF-3324: Encode IP-literal SANs as iPAddress rather than dNSName
When the server hostname is an IP literal (e.g. 198.51.100.3) instead of a fully qualified domain name, certificate Subject Alternative Names were written as dNSName (GeneralName type 2) entries. This is not compliant with RFC 5280, which requires IP addresses to be encoded as iPAddress (type 7) entries holding the raw address octets. Because TLS clients (including Java's HostnameChecker) only match an IP literal against an iPAddress SAN, certificates generated for IP-based hosts failed verification when a peer connected by IP.
1 parent f5b9964 commit 6e9d1d3

2 files changed

Lines changed: 241 additions & 5 deletions

File tree

xmppserver/src/main/java/org/jivesoftware/util/CertificateManager.java

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ public static List<String> getClientIdentities(X509Certificate x509Certificate)
172172
/**
173173
* Returns the identities of the remote server as defined in the specified certificate. The
174174
* identities are mapped by the classes in the "provider.serverCertIdentityMap.classList" property.
175-
* By default, identities are derived from SubjectAlternativeName entries (xmppAddr, dnsSRV, DNSName, URI)
175+
* By default, identities are derived from SubjectAlternativeName entries (xmppAddr, dnsSRV, DNSName, iPAddress, URI)
176176
*
177177
* @param x509Certificate the certificate that holds the identities of the remote server.
178178
* @return the identities of the remote server as defined in the specified certificate.
@@ -304,6 +304,10 @@ public static String createSigningRequest(X509Certificate cert, PrivateKey privK
304304
// URI
305305
subjectAlternativeNames.add( new GeneralName( GeneralName.uniformResourceIdentifier, (String) value ) );
306306
break;
307+
case 7:
308+
// IP address
309+
subjectAlternativeNames.add( new GeneralName( GeneralName.iPAddress, (String) value ) );
310+
break;
307311
default:
308312
// Not applicable to XMPP, so silently ignore them
309313
break;
@@ -613,8 +617,9 @@ protected static GeneralNames getSubjectAlternativeNames( Set<String> sanDnsName
613617
{
614618
for ( final String dnsNameValue : sanDnsNames )
615619
{
620+
final int tag = IpUtils.isValidIpAddress(dnsNameValue) ? GeneralName.iPAddress : GeneralName.dNSName;
616621
subjectAlternativeNames.add(
617-
new GeneralName( GeneralName.dNSName, dnsNameValue )
622+
new GeneralName( tag, dnsNameValue )
618623
);
619624
}
620625
}
@@ -625,8 +630,8 @@ protected static GeneralNames getSubjectAlternativeNames( Set<String> sanDnsName
625630
}
626631

627632
/**
628-
* Finds all values that ought to be added as a Subject Alternate Name of the dnsName type to a certificate that
629-
* identifies this XMPP server.
633+
* Finds all values that ought to be added as a Subject Alternate Name of the dnsName (or ipAddress) type to a
634+
* certificate that identifies this XMPP server.
630635
*
631636
* @return A set of names, possibly empty, never null.
632637
*/

xmppserver/src/test/java/org/jivesoftware/util/CertificateManagerTest.java

Lines changed: 232 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,16 @@
1515
*/
1616
package org.jivesoftware.util;
1717

18-
import org.bouncycastle.asn1.*;
18+
import org.bouncycastle.asn1.ASN1Encodable;
19+
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
20+
import org.bouncycastle.asn1.DERIA5String;
21+
import org.bouncycastle.asn1.DEROctetString;
22+
import org.bouncycastle.asn1.DERUTF8String;
23+
import org.bouncycastle.asn1.pkcs.Attribute;
24+
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
25+
import org.bouncycastle.asn1.x509.Extensions;
26+
import org.bouncycastle.openssl.PEMParser;
27+
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
1928
import org.bouncycastle.asn1.x500.X500Name;
2029
import org.bouncycastle.asn1.x500.X500NameBuilder;
2130
import org.bouncycastle.asn1.x509.Extension;
@@ -35,7 +44,9 @@
3544
import javax.naming.ldap.LdapName;
3645
import javax.naming.ldap.Rdn;
3746
import java.io.InputStream;
47+
import java.io.StringReader;
3848
import java.math.BigInteger;
49+
import java.net.InetAddress;
3950
import java.security.KeyPair;
4051
import java.security.KeyPairGenerator;
4152
import java.security.PrivateKey;
@@ -322,6 +333,226 @@ public void testServerIdentitiesXmppAddrAndDNS() throws Exception
322333
assertFalse( serverIdentities.contains( subjectCommonName ) );
323334
}
324335

336+
/**
337+
* Asserts that when an IP literal is supplied as a subject alternative name to
338+
* {@link CertificateManager#createX509V3Certificate(KeyPair, int, String, String, String, String, Set)},
339+
* it is encoded as an {@code iPAddress} (GeneralName type 7) SAN entry rather than a
340+
* {@code dNSName} (type 2) entry, while non-IP values remain {@code dNSName}.
341+
*
342+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc5280">RFC 5280</a>
343+
*/
344+
@Test
345+
public void testGenerateCertificateWithIpAddressSAN() throws Exception
346+
{
347+
// Setup fixture.
348+
final KeyPair keyPair = subjectKeyPair;
349+
final int days = 2;
350+
final String issuerCommonName = "issuer common name";
351+
final String subjectCommonName = "subject common name";
352+
final String domain = "domain.example.org";
353+
final String ipLiteral = "198.51.100.3";
354+
final String dnsName = "alternative-a.example.org";
355+
final Set<String> sanNames = Stream.of( ipLiteral, dnsName ).collect( Collectors.toSet() );
356+
357+
// Execute system under test.
358+
final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, SIGNATURE_ALGORITHM, sanNames );
359+
360+
// Verify results.
361+
assertNotNull( result );
362+
363+
final Collection<List<?>> sans = result.getSubjectAlternativeNames();
364+
assertNotNull( sans, "Expected the generated certificate to contain subject alternative names (but it does not)." );
365+
366+
// The IP literal must appear as a type-7 (iPAddress) entry, not type-2 (dNSName).
367+
assertThat( "Expected the IP literal to be encoded as an iPAddress (type 7) SAN entry (but it was not).",
368+
sans, hasItem( Arrays.asList( 7, ipLiteral ) ) );
369+
assertThat( "Did not expect the IP literal to be encoded as a dNSName (type 2) SAN entry (but it was).",
370+
sans, not( hasItem( Arrays.asList( 2, ipLiteral ) ) ) );
371+
372+
// The DNS name must still appear as a type-2 (dNSName) entry.
373+
assertThat( "Expected the DNS name to be encoded as a dNSName (type 2) SAN entry (but it was not).",
374+
sans, hasItem( Arrays.asList( 2, dnsName ) ) );
375+
}
376+
377+
/**
378+
* Asserts that an IPv6 literal supplied as a subject alternative name is encoded as an
379+
* {@code iPAddress} (GeneralName type 7) SAN entry. Note that the JDK normalises the textual
380+
* representation of the address that it returns from
381+
* {@link X509Certificate#getSubjectAlternativeNames()}, so this test asserts on the SAN type
382+
* rather than on an exact textual match of the input.
383+
*
384+
* @see <a href="https://datatracker.ietf.org/doc/html/rfc5280">RFC 5280</a>
385+
*/
386+
@Test
387+
public void testGenerateCertificateWithIpv6AddressSAN() throws Exception
388+
{
389+
// Setup fixture.
390+
final KeyPair keyPair = subjectKeyPair;
391+
final int days = 2;
392+
final String issuerCommonName = "issuer common name";
393+
final String subjectCommonName = "subject common name";
394+
final String domain = "domain.example.org";
395+
final String ipv6Literal = "2001:db8::1";
396+
final Set<String> sanNames = Stream.of( ipv6Literal ).collect( Collectors.toSet() );
397+
398+
// Execute system under test.
399+
final X509Certificate result = CertificateManager.createX509V3Certificate( keyPair, days, issuerCommonName, subjectCommonName, domain, SIGNATURE_ALGORITHM, sanNames );
400+
401+
// Verify results.
402+
assertNotNull( result );
403+
404+
final Collection<List<?>> sans = result.getSubjectAlternativeNames();
405+
assertNotNull( sans, "Expected the generated certificate to contain subject alternative names (but it does not)." );
406+
407+
final Set<Integer> types = new HashSet<>();
408+
for ( final List<?> san : sans ) {
409+
types.add( (Integer) san.get( 0 ) );
410+
}
411+
412+
assertTrue( types.contains( 7 ), "Expected the IPv6 literal to be encoded as an iPAddress (type 7) SAN entry (but no type-7 entry was found)." );
413+
assertFalse( types.contains( 2 ), "Did not expect any dNSName (type 2) SAN entry for an IPv6-only input (but one was found)." );
414+
}
415+
416+
/**
417+
* Asserts that an {@code iPAddress} (type 7) subject alternative name survives a CSR round-trip through
418+
* {@link CertificateManager#createSigningRequest(X509Certificate, PrivateKey)}: the IP SAN present on the source
419+
* certificate must be reproduced in the generated signing request rather than silently discarded.
420+
*
421+
* @see <a href="https://igniterealtime.atlassian.net/browse/OF-3324">OF-3324: IP addresses are encoded as dNSName instead of iPAddress in certificate SANs</a>
422+
*/
423+
@Test
424+
public void testCreateSigningRequestPreservesIpAddressSAN() throws Exception
425+
{
426+
// Setup fixture: a self-signed certificate carrying an iPAddress SAN.
427+
final String ipLiteral = "198.51.100.3";
428+
final String dnsName = "yourdomain.example.org";
429+
430+
final X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
431+
new X500Name( "CN=MyIssuer" ),
432+
BigInteger.valueOf( Math.abs( new SecureRandom().nextInt() ) ),
433+
Date.from( Instant.now().minus( Duration.ofDays( 1 ) ) ),
434+
Date.from( Instant.now().plus( Duration.ofDays( 99 ) ) ),
435+
new X500Name( "CN=MySubject" ),
436+
subjectKeyPair.getPublic()
437+
);
438+
439+
final GeneralNames sans = new GeneralNames( new GeneralName[] {
440+
new GeneralName( GeneralName.iPAddress, ipLiteral ),
441+
new GeneralName( GeneralName.dNSName, dnsName )
442+
} );
443+
builder.addExtension( Extension.subjectAlternativeName, false, sans );
444+
445+
// The CSR is signed with the subject's key, so build the cert with a matching signer.
446+
final ContentSigner subjectSigner = new JcaContentSignerBuilder( SIGNATURE_ALGORITHM ).build( subjectKeyPair.getPrivate() );
447+
final X509CertificateHolder holder = builder.build( subjectSigner );
448+
X509Certificate cert = new JcaX509CertificateConverter().getCertificate( holder );
449+
450+
// FIXME: as elsewhere in this class, round-trip through PEM to avoid Java 17 parsing quirks.
451+
final String pem = CertificateManager.toPemRepresentation( cert );
452+
cert = CertificateManager.parseCertificates( pem ).iterator().next();
453+
454+
// Execute system under test.
455+
final String csrPem = CertificateManager.createSigningRequest( cert, subjectKeyPair.getPrivate() );
456+
457+
// Verify results: parse the CSR back and extract its SAN extension.
458+
assertNotNull( csrPem );
459+
final Set<String> csrSanTypesAndValues = extractCsrSubjectAltNames( csrPem );
460+
461+
assertTrue( csrSanTypesAndValues.contains( "7:" + ipLiteral ), "Expected the IP address SAN to survive the CSR round-trip as an iPAddress (type 7) entry (but it did not). Found: " + csrSanTypesAndValues );
462+
assertTrue( csrSanTypesAndValues.contains( "2:" + dnsName ), "Expected the DNS SAN to survive the CSR round-trip as a dNSName (type 2) entry (but it did not). Found: " + csrSanTypesAndValues );
463+
}
464+
465+
/**
466+
* Asserts that {@link SANCertificateIdentityMapping#mapIdentity(X509Certificate)} does NOT surface an
467+
* {@code iPAddress} (type 7) subject alternative name. An IP literal is not a valid XMPP domain, so it must not be
468+
* returned as an XMPP identity (used for S2S / SASL EXTERNAL comparison). DNS names on the same certificate must
469+
* still be surfaced.
470+
*
471+
* @see <a href="https://igniterealtime.atlassian.net/browse/OF-3324">OF-3324: IP addresses are encoded as dNSName instead of iPAddress in certificate SANs</a>
472+
*/
473+
@Test
474+
public void testMapIdentityIgnoresIpAddressSAN() throws Exception
475+
{
476+
// Setup fixture.
477+
final String ipLiteral = "198.51.100.3";
478+
final String dnsName = "yourdomain.example.org";
479+
480+
final X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder(
481+
new X500Name( "CN=MyIssuer" ),
482+
BigInteger.valueOf( Math.abs( new SecureRandom().nextInt() ) ),
483+
Date.from( Instant.now().minus( Duration.ofDays( 1 ) ) ),
484+
Date.from( Instant.now().plus( Duration.ofDays( 99 ) ) ),
485+
new X500Name( "CN=MySubject" ),
486+
subjectKeyPair.getPublic()
487+
);
488+
489+
final GeneralNames sans = new GeneralNames( new GeneralName[] {
490+
new GeneralName( GeneralName.iPAddress, ipLiteral ),
491+
new GeneralName( GeneralName.dNSName, dnsName )
492+
} );
493+
builder.addExtension( Extension.subjectAlternativeName, false, sans );
494+
495+
final X509CertificateHolder holder = builder.build( contentSigner );
496+
X509Certificate cert = new JcaX509CertificateConverter().getCertificate( holder );
497+
498+
// FIXME: as elsewhere in this class, round-trip through PEM to avoid Java 17 parsing quirks.
499+
final String pem = CertificateManager.toPemRepresentation( cert );
500+
cert = CertificateManager.parseCertificates( pem ).iterator().next();
501+
502+
// Execute system under test.
503+
final List<String> identities = new SANCertificateIdentityMapping().mapIdentity( cert );
504+
505+
// Verify results.
506+
assertFalse( identities.contains( ipLiteral ), "Did not expect mapIdentity to surface the iPAddress SAN '" + ipLiteral + "' as an XMPP identity (but it did). Found: " + identities );
507+
assertTrue( identities.contains( dnsName ), "Expected mapIdentity to still surface the dNSName SAN '" + dnsName + "' (but it did not). Found: " + identities );
508+
}
509+
510+
/**
511+
* Helper that parses a PEM-encoded PKCS#10 CSR and returns the subject alternative names it
512+
* carries, each formatted as "{tag}:{value}" (e.g. "7:198.51.100.3", "2:example.org").
513+
*/
514+
private static Set<String> extractCsrSubjectAltNames( String csrPem ) throws Exception
515+
{
516+
final Set<String> result = new HashSet<>();
517+
518+
final PKCS10CertificationRequest csr;
519+
try ( final PEMParser parser = new PEMParser( new StringReader( csrPem ) ) )
520+
{
521+
csr = (PKCS10CertificationRequest) parser.readObject();
522+
}
523+
assertNotNull( csr, "Unable to parse the generated CSR." );
524+
525+
for ( final Attribute attribute : csr.getAttributes( PKCSObjectIdentifiers.pkcs_9_at_extensionRequest ) )
526+
{
527+
for ( final ASN1Encodable value : attribute.getAttributeValues() )
528+
{
529+
final Extensions extensions = Extensions.getInstance( value );
530+
final GeneralNames names = GeneralNames.fromExtensions( extensions, Extension.subjectAlternativeName );
531+
if ( names == null )
532+
{
533+
continue;
534+
}
535+
for ( final GeneralName name : names.getNames() )
536+
{
537+
final int tag = name.getTagNo();
538+
final String textValue;
539+
if ( tag == GeneralName.iPAddress )
540+
{
541+
// iPAddress is carried as an OCTET STRING of raw address octets.
542+
final byte[] octets = DEROctetString.getInstance( name.getName() ).getOctets();
543+
textValue = InetAddress.getByAddress( octets ).getHostAddress();
544+
}
545+
else
546+
{
547+
textValue = name.getName().toString();
548+
}
549+
result.add( tag + ":" + textValue );
550+
}
551+
}
552+
}
553+
return result;
554+
}
555+
325556
/**
326557
* Tests a PEM generated by OpenSSL using this config file:
327558
*

0 commit comments

Comments
 (0)