summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLee Clements <lclements0@gmail.com>2026-06-26 10:48:38 -0400
committerLee Clements <lclements0@gmail.com>2026-06-27 01:34:17 -0400
commit78bec494bd6df824875ff132920f6f293d82ac0c (patch)
tree14979628f0e6e96a4d4f470f0d07b3c07cac1471
parent477b1b9137ef42407e18b68684485ae623834456 (diff)
downloadvyos-1x-78bec494bd6df824875ff132920f6f293d82ac0c.tar.gz
vyos-1x-78bec494bd6df824875ff132920f6f293d82ac0c.zip
https: T9022: serve the full CA certificate chain
When a certificate is assigned to the HTTPS service, nginx was only sent the leaf certificate. An intermediate CA was included only when an operator manually configured "ca-certificate", and even then just that single CA - the rest of the issuer chain was never followed. As a result, clients that do not already trust the issuing intermediate CA (for example Let's Encrypt's newer E- and R-series intermediates) could not build a path to a trusted root and rejected the connection. Build the complete chain from the CA certificates present in the PKI using find_chain(), the same helper already used by HAProxy, OpenConnect, stunnel and the other PKI consumers. The intermediate chain is now discovered automatically, so configuring "ca-certificate" is no longer required; it remains accepted for backwards compatibility.
-rwxr-xr-xsmoketest/scripts/cli/test_service_https.py101
-rwxr-xr-xsrc/conf_mode/service_https.py23
2 files changed, 117 insertions, 7 deletions
diff --git a/smoketest/scripts/cli/test_service_https.py b/smoketest/scripts/cli/test_service_https.py
index b29be9eda..147d007bd 100755
--- a/smoketest/scripts/cli/test_service_https.py
+++ b/smoketest/scripts/cli/test_service_https.py
@@ -30,6 +30,9 @@ from vyos.utils.file import write_file
from vyos.utils.process import call
from vyos.utils.process import cmd
from vyos.utils.process import process_named_running
+from vyos.pki import CERT_BEGIN
+from vyos.pki import encode_certificate
+from vyos.pki import load_certificate
from vyos.xml_ref import default_value
from vyos.configsession import ConfigSessionError
@@ -61,6 +64,50 @@ MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgPLpD0Ohhoq0g4nhx
u8/3jHMM7sDwL3aWzW/zp54/LhCWUoLMjDdDEEigK4fal4ZF9aA9F0Ww
"""
+# A self-contained CA chain (root CA -> intermediate CA -> server leaf
+# certificate) used to verify that the HTTPS server emits the full chain.
+# EC/prime256v1, valid until 2124; the leaf is a serverAuth certificate with
+# its matching private key.
+chain_root_ca_data = """
+MIIBajCCAQ+gAwIBAgIUBTkKqMGnfCxrlO9xcoNVop6rZbMwCgYIKoZIzj0EAwIw
+ITEfMB0GA1UEAwwWVnlPUyBzbW9rZXRlc3Qgcm9vdCBDQTAgFw0yNDAxMDEwMDAw
+MDBaGA8yMTI0MDEwMTAwMDAwMFowITEfMB0GA1UEAwwWVnlPUyBzbW9rZXRlc3Qg
+cm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMtqjpv32fV22Wc42mEo
+OIuja0g2dFWC5GZfgUqlATuJYnWT8CQIUrsU5rkZ17/QAKYV8j15uyzu7Z/y1lh4
+Qs6jIzAhMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49
+BAMCA0kAMEYCIQC1jmfldcNt7YVWPYkEIIpEBGAF3YDQSoKw3W5AE0dAVwIhAMGD
+oZ3lJTJDQRN5RvNEgUxAcIJGk6eI5JPbzdY50M+A
+"""
+
+chain_intermediate_ca_data = """
+MIIBdDCCARqgAwIBAgIURw6/dUwu0DpNWs3tWchVfugh7OAwCgYIKoZIzj0EAwIw
+ITEfMB0GA1UEAwwWVnlPUyBzbW9rZXRlc3Qgcm9vdCBDQTAgFw0yNDAxMDEwMDAw
+MDBaGA8yMTI0MDEwMTAwMDAwMFowKTEnMCUGA1UEAwweVnlPUyBzbW9rZXRlc3Qg
+aW50ZXJtZWRpYXRlIENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEYKjeAwqK
+w2zmRmxLSFVFWwHATyUYpFI8EyzuU3laPSy/FrOI2aDbJxeAStJzdZK7I9i/9mT7
+M/WpLjo/0KRok6MmMCQwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMC
+AQYwCgYIKoZIzj0EAwIDSAAwRQIhAL411m9RwX18+u4Th4XEmVOyX9KFuWjM0xdt
+AwlBC15jAiAK1UC0h3w17EnsTJLOXVhs2zUjTAH1g79sL8YvUxrrTQ==
+"""
+
+chain_cert_data = """
+MIIBpTCCAUygAwIBAgIUPUP2dDDjMOzeArLqGnnhqPaMJGAwCgYIKoZIzj0EAwIw
+KTEnMCUGA1UEAwweVnlPUyBzbW9rZXRlc3QgaW50ZXJtZWRpYXRlIENBMCAXDTI0
+MDEwMTAwMDAwMFoYDzIxMjQwMTAxMDAwMDAwWjAhMR8wHQYDVQQDDBZ2eW9zLnNt
+b2tldGVzdC5leGFtcGxlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwVklSiSf
+VpgZVHN7m5jRGXHjoFj/v+srfmUEn1/IzRoF8KGwOWIvxcSeS26AkHKXYGzkaWpH
+ayBufJhUBvf41qNYMFYwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYD
+VR0lBAwwCgYIKwYBBQUHAwEwIQYDVR0RBBowGIIWdnlvcy5zbW9rZXRlc3QuZXhh
+bXBsZTAKBggqhkjOPQQDAgNHADBEAiBqMUNw9Q3LIefYYZ31EpNmHz6R2j7bhDb3
+7Lgx28vhyAIgUC6hwMpFwH7gcj34Vy7+ga1ZvkpHyY1ZyQUaIi89F44=
+"""
+
+chain_key_data = """
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgYe5FRCBfdQostO/7
+8TZa4FF3I4mikSj+gifZWwWqsbuhRANCAATBWSVKJJ9WmBlUc3ubmNEZceOgWP+/
+6yt+ZQSfX8jNGgXwobA5Yi/FxJ5LboCQcpdgbORpakdrIG58mFQG9/jW
+"""
+
dh_1024 = """
MIGHAoGBAM3nvMkHGi/xmRs8cYg4pcl5sAanxel9EM+1XobVhUViXw8JvlmSEVOj
n2aXUifc4SEs3WDzVPRC8O8qQWjvErpTq/HOgt3aqBCabMgvflmt706XP0KiqnpW
@@ -243,6 +290,60 @@ class TestHTTPSService(VyOSUnitTestSHIM.TestCase):
self.assertTrue(process_named_running(PROCESS_NAME))
self.debug = False
+ def test_certificate_chain(self):
+ # The HTTPS server must serve the full certificate chain: the server
+ # certificate followed by every intermediate CA certificate available
+ # in the PKI, up to and including the root. Without the intermediate
+ # CA, clients that do not already trust it can not validate the
+ # presented server certificate (e.g. Let's Encrypt intermediates).
+ cert_name = 'https-chain'
+ root_ca_name = 'https-chain-root'
+ intermediate_ca_name = 'https-chain-intermediate'
+
+ root_ca_path = pki_base + ['ca', root_ca_name, 'certificate']
+ self.cli_set(root_ca_path + [chain_root_ca_data.replace('\n', '')])
+
+ intermediate_ca_path = pki_base + ['ca', intermediate_ca_name, 'certificate']
+ self.cli_set(
+ intermediate_ca_path + [chain_intermediate_ca_data.replace('\n', '')]
+ )
+
+ cert_path = pki_base + ['certificate', cert_name, 'certificate']
+ self.cli_set(cert_path + [chain_cert_data.replace('\n', '')])
+
+ cert_key_path = pki_base + ['certificate', cert_name, 'private', 'key']
+ self.cli_set(cert_key_path + [chain_key_data.replace('\n', '')])
+
+ # Reference only the leaf certificate - the intermediate CA chain must
+ # be discovered from the PKI and served automatically.
+ self.cli_set(base_path + ['certificates', 'certificate', cert_name])
+ self.cli_commit()
+
+ self.assertTrue(process_named_running(PROCESS_NAME))
+
+ # The served bundle must be the leaf certificate, then the intermediate
+ # CA, then the root CA, in that exact order - a plain certificate count
+ # would not catch a re-ordered chain.
+ expected_chain = '\n'.join(
+ encode_certificate(load_certificate(cert.replace('\n', '')))
+ for cert in (
+ chain_cert_data,
+ chain_intermediate_ca_data,
+ chain_root_ca_data,
+ )
+ ).strip()
+ cert_file = f'/run/nginx/certs/{cert_name}_cert.pem'
+ self.assertEqual(read_file(cert_file).count(CERT_BEGIN), 3)
+ self.assertEqual(read_file(cert_file), expected_chain)
+
+ # Explicitly configuring the issuing CA via "ca-certificate" must stay
+ # backwards compatible and must not duplicate or re-order certificates.
+ self.cli_set(
+ base_path + ['certificates', 'ca-certificate', intermediate_ca_name]
+ )
+ self.cli_commit()
+ self.assertEqual(read_file(cert_file), expected_chain)
+
def test_api_missing_keys(self):
self.cli_set(base_path + ['api'])
self.assertRaises(ConfigSessionError, self.cli_commit)
diff --git a/src/conf_mode/service_https.py b/src/conf_mode/service_https.py
index 13a4930fd..4a9311bfb 100755
--- a/src/conf_mode/service_https.py
+++ b/src/conf_mode/service_https.py
@@ -30,7 +30,9 @@ from vyos.configverify import verify_pki_ca_certificate
from vyos.configverify import verify_pki_dh_parameters
from vyos.configdiff import get_config_diff
from vyos.defaults import api_config_state
-from vyos.pki import wrap_certificate
+from vyos.pki import encode_certificate
+from vyos.pki import find_chain
+from vyos.pki import load_certificate
from vyos.pki import wrap_private_key
from vyos.pki import wrap_dh_parameters
from vyos.template import render
@@ -176,12 +178,19 @@ def generate(https):
cert_path = os.path.join(cert_dir, f'{cert_name}_cert.pem')
key_path = os.path.join(cert_dir, f'{cert_name}_key.pem')
- server_cert = str(wrap_certificate(pki_cert['certificate']))
-
- # Append CA certificate if specified to form a full chain
- if 'ca_certificate' in https['certificates']:
- ca_cert = https['certificates']['ca_certificate']
- server_cert += '\n' + str(wrap_certificate(https['pki']['ca'][ca_cert]['certificate']))
+ # Build the full certificate chain (server certificate followed by any
+ # intermediate CA certificates up to the root) from the CA certificates
+ # available in the PKI. Serving the complete chain lets clients that do
+ # not yet trust the issuing intermediate CA validate the presented
+ # certificate. This mirrors the other PKI consumers (HAProxy, stunnel).
+ loaded_ca_certs = {
+ load_certificate(cert_data['certificate'])
+ for cert_data in dict_search('pki.ca', https, default={}).values()
+ }
+
+ loaded_pki_cert = load_certificate(pki_cert['certificate'])
+ cert_full_chain = find_chain(loaded_pki_cert, loaded_ca_certs)
+ server_cert = '\n'.join(encode_certificate(c) for c in cert_full_chain)
write_file(cert_path, server_cert, user=user, group=group, mode=0o644)
write_file(key_path, wrap_private_key(pki_cert['private']['key']),