Skip to content

Commit 853f08e

Browse files
karesclaude
andcommitted
[compat] implement nameConstraints verification
OpenSSL checks nameConstraints (RFC 5280 Section 4.2.1.10) during chain verification, rejecting certs whose subject DN or SANs violate permitted/excluded subtrees. JRuby was treating nameConstraints as an unhandled critical extension, rejecting ALL certs from constrained CAs. Implementation uses BouncyCastle's PKIXNameConstraintValidator which supports DNS, email, IP, URI, directoryName, and otherName matching. Walks the chain from trust anchor to leaf, accumulating constraints from each CA and checking each non-self-issued cert against them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 390e343 commit 853f08e

File tree

3 files changed

+225
-4
lines changed

3 files changed

+225
-4
lines changed

src/main/java/org/jruby/ext/openssl/x509store/StoreContext.java

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,20 @@
4545

4646
import org.bouncycastle.asn1.ASN1InputStream;
4747
import org.bouncycastle.asn1.ASN1Integer;
48+
import org.bouncycastle.asn1.ASN1OctetString;
4849
import org.bouncycastle.asn1.ASN1Sequence;
50+
import org.bouncycastle.asn1.x500.X500Name;
51+
import org.bouncycastle.asn1.x509.GeneralName;
52+
import org.bouncycastle.asn1.x509.GeneralNames;
53+
import org.bouncycastle.asn1.x509.GeneralSubtree;
54+
import org.bouncycastle.asn1.x509.NameConstraints;
55+
import org.bouncycastle.asn1.x509.PKIXNameConstraintValidator;
56+
import org.bouncycastle.asn1.x509.NameConstraintValidatorException;
4957
import org.jruby.ext.openssl.OpenSSL;
5058
import org.jruby.ext.openssl.SecurityHelper;
5159
import org.jruby.util.SafePropertyAccessor;
5260

61+
import static org.jruby.ext.openssl.OpenSSL.debugStackTrace;
5362
import static org.jruby.ext.openssl.x509store.X509Error.addError;
5463
import static org.jruby.ext.openssl.x509store.X509Utils.*;
5564

@@ -972,7 +981,8 @@ int verify_chain_legacy() throws Exception {
972981
int ok = checkChainExtensions();
973982
if ( ok == 0 ) return ok;
974983

975-
/* TODO: Check name constraints (from 1.0.0) */
984+
ok = checkNameConstraints();
985+
if ( ok == 0 ) return ok;
976986

977987
// The chain extensions are OK: check trust
978988
if ( verifyParameter.trust > 0 ) ok = checkTrust();
@@ -1031,8 +1041,8 @@ int verify_chain() throws Exception {
10311041
ok = verify != null ? verify.call(this) : internal_verify();
10321042
if (ok == 0) return ok;
10331043

1034-
//if ((ok = check_name_constraints(ctx)) == 0)
1035-
// return ok;
1044+
if ((ok = checkNameConstraints()) == 0)
1045+
return ok;
10361046

10371047
/* If we get this far evaluate policies */
10381048
if ((getParam().flags & V_FLAG_POLICY_CHECK) != 0) {
@@ -1359,12 +1369,98 @@ private X509AuxCertificate find_issuer(List<X509AuxCertificate> sk, X509AuxCerti
13591369
return rv;
13601370
}
13611371

1372+
private static final String OID_NAME_CONSTRAINTS = "2.5.29.30";
1373+
private static final String OID_SUBJECT_ALT_NAME = "2.5.29.17";
1374+
1375+
/**
1376+
* c: check_name_constraints
1377+
*
1378+
* Checks that each certificate's subject DN and SANs are within the
1379+
* name constraints (permitted/excluded subtrees) of all CA certificates
1380+
* higher in the chain. Uses BouncyCastle's PKIXNameConstraintValidator
1381+
* which implements RFC 5280 Section 4.2.1.10.
1382+
*/
1383+
private int checkNameConstraints() throws Exception {
1384+
final int num = chain.size();
1385+
1386+
for (int i = num - 1; i >= 0; i--) {
1387+
final X509AuxCertificate x = chain.get(i);
1388+
1389+
// Skip self-issued intermediates (not the leaf)
1390+
if (i != 0 && (x.getExFlags() & EXFLAG_SI) != 0) continue;
1391+
1392+
// Check x against nameConstraints from every cert higher in the chain
1393+
for (int j = i + 1; j < num; j++) {
1394+
final X509AuxCertificate issuer = chain.get(j);
1395+
final byte[] ncBytes = issuer.getExtensionValue(OID_NAME_CONSTRAINTS);
1396+
if (ncBytes == null) continue;
1397+
1398+
final NameConstraints nc;
1399+
try {
1400+
// Extension value is OCTET STRING wrapping the actual ASN.1
1401+
ASN1OctetString oct = ASN1OctetString.getInstance(ncBytes);
1402+
nc = NameConstraints.getInstance(ASN1Sequence.getInstance(oct.getOctets()));
1403+
} catch (Exception e) {
1404+
if (verify_cb_cert(x, i, V_ERR_UNSPECIFIED) == 0) return 0;
1405+
continue;
1406+
}
1407+
1408+
final PKIXNameConstraintValidator validator = new PKIXNameConstraintValidator();
1409+
GeneralSubtree[] permitted = nc.getPermittedSubtrees();
1410+
if (permitted != null) {
1411+
validator.intersectPermittedSubtree(permitted);
1412+
}
1413+
GeneralSubtree[] excluded = nc.getExcludedSubtrees();
1414+
if (excluded != null) {
1415+
for (GeneralSubtree sub : excluded) {
1416+
validator.addExcludedSubtree(sub);
1417+
}
1418+
}
1419+
1420+
// Check subject DN as directoryName
1421+
try {
1422+
javax.security.auth.x500.X500Principal subj = x.getSubjectX500Principal();
1423+
if (subj != null && subj.getEncoded().length > 2) {
1424+
X500Name dn = X500Name.getInstance(subj.getEncoded());
1425+
GeneralName dnName = new GeneralName(GeneralName.directoryName, dn);
1426+
validator.checkPermitted(dnName);
1427+
validator.checkExcluded(dnName);
1428+
}
1429+
} catch (NameConstraintValidatorException e) {
1430+
int err = e.getMessage() != null && e.getMessage().contains("excluded")
1431+
? V_ERR_EXCLUDED_VIOLATION : V_ERR_PERMITTED_VIOLATION;
1432+
if (verify_cb_cert(x, i, err) == 0) return 0;
1433+
}
1434+
1435+
// Check all Subject Alternative Names
1436+
try {
1437+
byte[] sanBytes = x.getExtensionValue(OID_SUBJECT_ALT_NAME);
1438+
if (sanBytes != null) {
1439+
ASN1OctetString sanOct = ASN1OctetString.getInstance(sanBytes);
1440+
GeneralNames sans = GeneralNames.getInstance(sanOct.getOctets());
1441+
for (GeneralName san : sans.getNames()) {
1442+
validator.checkPermitted(san);
1443+
validator.checkExcluded(san);
1444+
}
1445+
}
1446+
} catch (NameConstraintValidatorException e) {
1447+
debugStackTrace(e);
1448+
int err = e.getMessage() != null && e.getMessage().contains("excluded")
1449+
? V_ERR_EXCLUDED_VIOLATION : V_ERR_PERMITTED_VIOLATION;
1450+
if (verify_cb_cert(x, i, err) == 0) return 0;
1451+
}
1452+
}
1453+
}
1454+
return 1;
1455+
}
1456+
13621457
private final static Set<String> CRITICAL_EXTENSIONS = new HashSet<String>(8);
13631458
static {
13641459
CRITICAL_EXTENSIONS.add("2.16.840.1.113730.1.1"); // netscape cert type, NID 71
13651460
CRITICAL_EXTENSIONS.add("2.5.29.15"); // key usage, NID 83
1366-
CRITICAL_EXTENSIONS.add("2.5.29.17"); // subject alt name, NID 85
1461+
CRITICAL_EXTENSIONS.add(OID_SUBJECT_ALT_NAME); // subject alt name, NID 85
13671462
CRITICAL_EXTENSIONS.add("2.5.29.19"); // basic constraints, NID 87
1463+
CRITICAL_EXTENSIONS.add(OID_NAME_CONSTRAINTS); // name constraints, NID 666
13681464
CRITICAL_EXTENSIONS.add("2.5.29.37"); // ext key usage, NID 126
13691465
CRITICAL_EXTENSIONS.add("1.3.6.1.5.5.7.1.14"); // proxy cert info, NID 661
13701466
}

src/main/java/org/jruby/ext/openssl/x509store/X509Utils.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ public static String verifyCertificateErrorString(final int error) {
191191
return("invalid or inconsistent certificate policy extension");
192192
case V_ERR_NO_EXPLICIT_POLICY:
193193
return("no explicit policy");
194+
case V_ERR_PERMITTED_VIOLATION:
195+
return("permitted subtree violation");
196+
case V_ERR_EXCLUDED_VIOLATION:
197+
return("excluded subtree violation");
198+
case V_ERR_SUBTREE_MINMAX:
199+
return("name constraints minimum and maximum not supported");
194200
case V_ERR_APPLICATION_VERIFICATION:
195201
return("application verification failure");
196202
case V_ERR_PATH_LOOP:
@@ -421,6 +427,10 @@ else if (maybeCertFile != null && new File(maybeCertFile).exists()) {
421427
public static final int V_ERR_INVALID_POLICY_EXTENSION = 42;
422428
public static final int V_ERR_NO_EXPLICIT_POLICY = 43;
423429

430+
public static final int V_ERR_PERMITTED_VIOLATION = 47;
431+
public static final int V_ERR_EXCLUDED_VIOLATION = 48;
432+
public static final int V_ERR_SUBTREE_MINMAX = 49;
433+
424434
public static final int V_ERR_APPLICATION_VERIFICATION = 50;
425435

426436
/* Another issuer check debug option */

src/test/ruby/x509/test_x509store.rb

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,4 +592,119 @@ def test_v_flag_partial_chain
592592
assert_equal OpenSSL::X509::V_OK, store2.error
593593
end
594594

595+
# Helper: build a proper ASN.1 nameConstraints extension since
596+
# JRuby's create_extension doesn't encode nameConstraints correctly yet.
597+
private
598+
599+
def build_name_constraints_ext(permitted_dns: nil, excluded_dns: nil)
600+
subtrees = []
601+
if permitted_dns
602+
dns_names = Array(permitted_dns).map do |name|
603+
dns = OpenSSL::ASN1::IA5String.new(name, 2, :IMPLICIT, :CONTEXT_SPECIFIC)
604+
OpenSSL::ASN1::Sequence.new([dns])
605+
end
606+
subtrees << OpenSSL::ASN1::Sequence.new(dns_names, 0, :IMPLICIT, :CONTEXT_SPECIFIC)
607+
end
608+
if excluded_dns
609+
dns_names = Array(excluded_dns).map do |name|
610+
dns = OpenSSL::ASN1::IA5String.new(name, 2, :IMPLICIT, :CONTEXT_SPECIFIC)
611+
OpenSSL::ASN1::Sequence.new([dns])
612+
end
613+
subtrees << OpenSSL::ASN1::Sequence.new(dns_names, 1, :IMPLICIT, :CONTEXT_SPECIFIC)
614+
end
615+
nc = OpenSSL::ASN1::Sequence.new(subtrees)
616+
OpenSSL::X509::Extension.new("nameConstraints", nc.to_der, true)
617+
end
618+
619+
def build_cert_with_san(name, serial, san_dns, issuer_cert, issuer_key)
620+
key = OpenSSL::PKey::RSA.new(2048)
621+
cert = OpenSSL::X509::Certificate.new
622+
cert.version = 2; cert.serial = serial
623+
cert.subject = OpenSSL::X509::Name.parse("/CN=#{name}")
624+
cert.issuer = issuer_cert.subject
625+
cert.not_before = Time.now - 3600; cert.not_after = Time.now + 3600
626+
cert.public_key = key.public_key
627+
ef = OpenSSL::X509::ExtensionFactory.new
628+
ef.subject_certificate = cert; ef.issuer_certificate = issuer_cert
629+
cert.add_extension(ef.create_extension("subjectAltName", "DNS:#{san_dns}"))
630+
cert.sign(issuer_key, "SHA256")
631+
cert
632+
end
633+
634+
public
635+
636+
# jruby/jruby#3502: nameConstraints verification
637+
def test_name_constraints_permitted_dns
638+
now = Time.now
639+
ca_key = OpenSSL::PKey::RSA.new(2048)
640+
ca_cert = issue_cert(OpenSSL::X509::Name.parse("/CN=CA"), ca_key, 1,
641+
[["basicConstraints","CA:TRUE",true],["keyUsage","cRLSign,keyCertSign",true]],
642+
nil, nil, not_before: now, not_after: now + 3600)
643+
ca_cert.add_extension(build_name_constraints_ext(permitted_dns: [".example.com"]))
644+
ca_cert.sign(ca_key, "SHA256") # re-sign after adding extension
645+
646+
good = build_cert_with_san("good", 10, "good.example.com", ca_cert, ca_key)
647+
bad = build_cert_with_san("bad", 11, "evil.attacker.com", ca_cert, ca_key)
648+
649+
store = OpenSSL::X509::Store.new; store.add_cert(ca_cert)
650+
assert_equal true, store.verify(good), "cert within permitted DNS subtree should verify"
651+
assert_equal OpenSSL::X509::V_OK, store.error
652+
653+
assert_equal false, store.verify(bad), "cert outside permitted DNS subtree should fail"
654+
assert_equal OpenSSL::X509::V_ERR_PERMITTED_VIOLATION, store.error
655+
end
656+
657+
def test_name_constraints_excluded_dns
658+
now = Time.now
659+
ca_key = OpenSSL::PKey::RSA.new(2048)
660+
ca_cert = issue_cert(OpenSSL::X509::Name.parse("/CN=CA"), ca_key, 1,
661+
[["basicConstraints","CA:TRUE",true],["keyUsage","cRLSign,keyCertSign",true]],
662+
nil, nil, not_before: now, not_after: now + 3600)
663+
ca_cert.add_extension(build_name_constraints_ext(excluded_dns: [".evil.com"]))
664+
ca_cert.sign(ca_key, "SHA256")
665+
666+
good = build_cert_with_san("good", 10, "good.example.com", ca_cert, ca_key)
667+
bad = build_cert_with_san("bad", 11, "bad.evil.com", ca_cert, ca_key)
668+
669+
store = OpenSSL::X509::Store.new; store.add_cert(ca_cert)
670+
assert_equal true, store.verify(good), "cert not in excluded subtree should verify"
671+
672+
assert_equal false, store.verify(bad), "cert in excluded DNS subtree should fail"
673+
assert_equal OpenSSL::X509::V_ERR_EXCLUDED_VIOLATION, store.error
674+
end
675+
676+
def test_name_constraints_no_constraints_passes
677+
now = Time.now
678+
ca_key = OpenSSL::PKey::RSA.new(2048)
679+
ca_cert = issue_cert(OpenSSL::X509::Name.parse("/CN=CA"), ca_key, 1,
680+
[["basicConstraints","CA:TRUE",true],["keyUsage","cRLSign,keyCertSign",true]],
681+
nil, nil, not_before: now, not_after: now + 3600)
682+
# No nameConstraints at all
683+
leaf = build_cert_with_san("leaf", 10, "anything.example.com", ca_cert, ca_key)
684+
685+
store = OpenSSL::X509::Store.new; store.add_cert(ca_cert)
686+
assert_equal true, store.verify(leaf), "cert without name constraints should verify"
687+
end
688+
689+
def test_name_constraints_permitted_and_excluded_combined
690+
now = Time.now
691+
ca_key = OpenSSL::PKey::RSA.new(2048)
692+
ca_cert = issue_cert(OpenSSL::X509::Name.parse("/CN=CA"), ca_key, 1,
693+
[["basicConstraints","CA:TRUE",true],["keyUsage","cRLSign,keyCertSign",true]],
694+
nil, nil, not_before: now, not_after: now + 3600)
695+
# Permit .example.com but exclude .bad.example.com
696+
ca_cert.add_extension(build_name_constraints_ext(
697+
permitted_dns: [".example.com"], excluded_dns: [".bad.example.com"]))
698+
ca_cert.sign(ca_key, "SHA256")
699+
700+
good = build_cert_with_san("good", 10, "good.example.com", ca_cert, ca_key)
701+
bad = build_cert_with_san("bad", 11, "test.bad.example.com", ca_cert, ca_key)
702+
outside = build_cert_with_san("outside", 12, "other.org", ca_cert, ca_key)
703+
704+
store = OpenSSL::X509::Store.new; store.add_cert(ca_cert)
705+
assert_equal true, store.verify(good)
706+
assert_equal false, store.verify(bad)
707+
assert_equal false, store.verify(outside)
708+
end
709+
595710
end

0 commit comments

Comments
 (0)