From 5674fde5eeea7e3c34a08e6d90c42508ddc85376 Mon Sep 17 00:00:00 2001 From: deepak1556 Date: Fri, 3 Apr 2026 20:09:47 +0900 Subject: [PATCH 1/5] crypto: fix macOS default for missing kSecTrustSettingsResult When kSecTrustSettingsResult is absent from a trust settings dictionary, Apple specifies kSecTrustSettingsResultTrustRoot as the default value. Previously, the trust result evaluation (deny check, self-issued check, TrustAsRoot check) was inside the block that only executed when kSecTrustSettingsResult was explicitly present. When the key was absent, the function fell through to return UNSPECIFIED, incorrectly rejecting self-signed certificates that should have been trusted via the default. Move the trust result evaluation outside the conditional block so the default value of kSecTrustSettingsResultTrustRoot flows through the same code path as explicit values. This aligns with Chromium's trust_store_mac.cc implementation. --- src/crypto/crypto_context.cc | 60 ++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index 9e453c5e173ade..7bee30997704e5 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -386,35 +386,41 @@ TrustStatus IsTrustDictionaryTrustedForPolicy(CFDictionaryRef trust_dict, &trust_settings_result)) { return TrustStatus::UNSPECIFIED; } - - if (trust_settings_result == kSecTrustSettingsResultDeny) { - return TrustStatus::DISTRUSTED; - } - - // This is a bit of a hack: if the cert is self-issued allow either - // kSecTrustSettingsResultTrustRoot or kSecTrustSettingsResultTrustAsRoot on - // the basis that SecTrustSetTrustSettings should not allow creating an - // invalid trust record in the first place. (The spec is that - // kSecTrustSettingsResultTrustRoot can only be applied to root(self-signed) - // certs and kSecTrustSettingsResultTrustAsRoot is used for other certs.) - // This hack avoids having to check the signature on the cert which is slow - // if using the platform APIs, and may require supporting MD5 signature - // algorithms on some older OSX versions or locally added roots, which is - // undesirable in the built-in signature verifier. - if (is_self_issued) { - return trust_settings_result == kSecTrustSettingsResultTrustRoot || - trust_settings_result == kSecTrustSettingsResultTrustAsRoot - ? TrustStatus::TRUSTED - : TrustStatus::UNSPECIFIED; - } - - // kSecTrustSettingsResultTrustAsRoot can only be applied to non-root certs. - return (trust_settings_result == kSecTrustSettingsResultTrustAsRoot) - ? TrustStatus::TRUSTED - : TrustStatus::UNSPECIFIED; } - return TrustStatus::UNSPECIFIED; + // When kSecTrustSettingsResult is absent from the trust dict, + // Apple docs specify kSecTrustSettingsResultTrustRoot as the default. + // Refs https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/trust/headers/SecTrustSettings.h#L119-L122 + // This is also enforced at write time for self-signed certs get TrustRoot, + // and non-self-signed certs cannot have an empty settings, + // Refs https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/OSX/sec/Security/SecTrustStore.c#L196-L207 + + if (trust_settings_result == kSecTrustSettingsResultDeny) { + return TrustStatus::DISTRUSTED; + } + + // From https://source.chromium.org/chromium/chromium/src/+/main:net/cert/internal/trust_store_mac.cc;l=144-146 + // This is a bit of a hack: if the cert is self-issued allow either + // kSecTrustSettingsResultTrustRoot or kSecTrustSettingsResultTrustAsRoot on + // the basis that SecTrustSetTrustSettings should not allow creating an + // invalid trust record in the first place. (The spec is that + // kSecTrustSettingsResultTrustRoot can only be applied to root(self-signed) + // certs and kSecTrustSettingsResultTrustAsRoot is used for other certs.) + // This hack avoids having to check the signature on the cert which is slow + // if using the platform APIs, and may require supporting MD5 signature + // algorithms on some older OSX versions or locally added roots, which is + // undesirable in the built-in signature verifier. + if (is_self_issued) { + return (trust_settings_result == kSecTrustSettingsResultTrustRoot || + trust_settings_result == kSecTrustSettingsResultTrustAsRoot) + ? TrustStatus::TRUSTED + : TrustStatus::UNSPECIFIED; + } + + // kSecTrustSettingsResultTrustAsRoot can only be applied to non-root certs. + return (trust_settings_result == kSecTrustSettingsResultTrustAsRoot) + ? TrustStatus::TRUSTED + : TrustStatus::UNSPECIFIED; } TrustStatus IsTrustSettingsTrustedForPolicy(CFArrayRef trust_settings, From 01fa6e4dff86265963eb4e054f1233d19778aab6 Mon Sep 17 00:00:00 2001 From: deepak1556 Date: Fri, 3 Apr 2026 20:27:00 +0900 Subject: [PATCH 2/5] crypto: fix leak and improve cert enumeration 1. Fix CFRelease leak in IsTrustDictionaryTrustedForPolicy: the CFDictionaryRef returned by SecPolicyCopyProperties(policy_ref) was not released when the policy OID matched kSecPolicyAppleSSL. 2. Deduplicate certificates: SecItemCopyMatching can return the same certificate from multiple keychains. 3. Filter expired certificates. --- src/crypto/crypto_context.cc | 47 +++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index 7bee30997704e5..072ecef9fc8c45 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -369,7 +369,10 @@ TrustStatus IsTrustDictionaryTrustedForPolicy(CFDictionaryRef trust_dict, CFStringRef policy_oid = reinterpret_cast( const_cast(CFDictionaryGetValue(policy_dict, kSecPolicyOid))); - if (!CFEqual(policy_oid, kSecPolicyAppleSSL)) { + bool matches_ssl = CFEqual(policy_oid, kSecPolicyAppleSSL); + CFRelease(policy_dict); + + if (!matches_ssl) { return TrustStatus::UNSPECIFIED; } } @@ -522,6 +525,21 @@ bool IsCertificateTrustedForPolicy(X509* cert, SecCertificateRef ref) { return false; } +// Checks if a certificate has expired. +// Returns true if the certificate's notAfter date is in the past. +static bool IsCertificateExpired(X509* cert) { + // X509_cmp_current_time returns: + // -1 if the time is in the past (expired) + // 0 if there was an error + // 1 if the time is in the future (not yet expired) + ASN1_TIME* not_after = X509_get_notAfter(cert); + if (not_after == nullptr) { + return false; + } + int cmp = X509_cmp_current_time(not_after); + return cmp < 0; +} + void ReadMacOSKeychainCertificates( std::vector* system_root_certificates_X509) { CFTypeRef search_keys[] = {kSecClass, kSecMatchLimit, kSecReturnRef}; @@ -549,6 +567,10 @@ void ReadMacOSKeychainCertificates( CFIndex count = CFArrayGetCount(curr_anchors); + // Track seen certificates to detect duplicates (same cert in multiple + // keychains). + std::set seen_certs; + for (int i = 0; i < count; ++i) { SecCertificateRef cert_ref = reinterpret_cast( const_cast(CFArrayGetValueAtIndex(curr_anchors, i))); @@ -574,11 +596,28 @@ void ReadMacOSKeychainCertificates( } bool is_valid = IsCertificateTrustedForPolicy(cert, cert_ref); - if (is_valid) { - system_root_certificates_X509->emplace_back(cert); - } else { + if (!is_valid) { + X509_free(cert); + continue; + } + + // Skip duplicate certificates. + auto [it, inserted] = seen_certs.insert(cert); + if (!inserted) { X509_free(cert); + continue; } + + // Skip expired certificates. + if (IsCertificateExpired(cert)) { + per_process::Debug(DebugCategory::CRYPTO, + "Skipping expired system certificate\n"); + seen_certs.erase(it); + X509_free(cert); + continue; + } + + system_root_certificates_X509->emplace_back(cert); } CFRelease(curr_anchors); } From b3b14fb5f3e0df8c632f663b946c18e5792ae9ab Mon Sep 17 00:00:00 2001 From: deepak1556 Date: Fri, 3 Apr 2026 21:00:49 +0900 Subject: [PATCH 3/5] crypto: add comment for SecTrustEvaluateWithError policy --- src/crypto/crypto_context.cc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index 072ecef9fc8c45..288a5b706637f4 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -456,6 +456,16 @@ bool IsCertificateTrustValid(SecCertificateRef ref) { CFArrayCreateMutable(nullptr, 1, &kCFTypeArrayCallBacks); CFArraySetValueAtIndex(subj_certs, 0, ref); + // SecTrustEvaluateWithError is used to check whether an individual + // certificate is trusted by the system — not to validate it for a + // specific role (server, intermediate, etc.). We just need a minimal + // policy that guarantees the certificate can be chained to a known + // trust anchor while filtering out irrelevant certificates. + // + // Refs https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/OSX/sec/Security/SecPolicy.c#L1855-L1890 + // SecPolicyCreateSSL (both mark EKU optional): + // server=true -> BasicX509 + serverAuth + anyExtendedKeyUsage + SGC + // server=false -> BasicX509 + clientAuth + anyExtendedKeyUsage SecPolicyRef policy = SecPolicyCreateSSL(false, nullptr); OSStatus ortn = SecTrustCreateWithCertificates(subj_certs, policy, &sec_trust); From 2871528ce5b0ad3fb8a4b86c26f642f6091f17f5 Mon Sep 17 00:00:00 2001 From: deepak1556 Date: Fri, 3 Apr 2026 22:59:12 +0900 Subject: [PATCH 4/5] src: fix formatting in crypto_context.cc --- src/crypto/crypto_context.cc | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index 288a5b706637f4..ecf38e2b076c60 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -393,16 +393,19 @@ TrustStatus IsTrustDictionaryTrustedForPolicy(CFDictionaryRef trust_dict, // When kSecTrustSettingsResult is absent from the trust dict, // Apple docs specify kSecTrustSettingsResultTrustRoot as the default. - // Refs https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/trust/headers/SecTrustSettings.h#L119-L122 + // Refs + // https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/trust/headers/SecTrustSettings.h#L119-L122 // This is also enforced at write time for self-signed certs get TrustRoot, // and non-self-signed certs cannot have an empty settings, - // Refs https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/OSX/sec/Security/SecTrustStore.c#L196-L207 + // Refs + // https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/OSX/sec/Security/SecTrustStore.c#L196-L207 if (trust_settings_result == kSecTrustSettingsResultDeny) { return TrustStatus::DISTRUSTED; } - // From https://source.chromium.org/chromium/chromium/src/+/main:net/cert/internal/trust_store_mac.cc;l=144-146 + // From + // https://source.chromium.org/chromium/chromium/src/+/main:net/cert/internal/trust_store_mac.cc;l=144-146 // This is a bit of a hack: if the cert is self-issued allow either // kSecTrustSettingsResultTrustRoot or kSecTrustSettingsResultTrustAsRoot on // the basis that SecTrustSetTrustSettings should not allow creating an @@ -416,8 +419,8 @@ TrustStatus IsTrustDictionaryTrustedForPolicy(CFDictionaryRef trust_dict, if (is_self_issued) { return (trust_settings_result == kSecTrustSettingsResultTrustRoot || trust_settings_result == kSecTrustSettingsResultTrustAsRoot) - ? TrustStatus::TRUSTED - : TrustStatus::UNSPECIFIED; + ? TrustStatus::TRUSTED + : TrustStatus::UNSPECIFIED; } // kSecTrustSettingsResultTrustAsRoot can only be applied to non-root certs. @@ -462,7 +465,8 @@ bool IsCertificateTrustValid(SecCertificateRef ref) { // policy that guarantees the certificate can be chained to a known // trust anchor while filtering out irrelevant certificates. // - // Refs https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/OSX/sec/Security/SecPolicy.c#L1855-L1890 + // Refs + // https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/OSX/sec/Security/SecPolicy.c#L1855-L1890 // SecPolicyCreateSSL (both mark EKU optional): // server=true -> BasicX509 + serverAuth + anyExtendedKeyUsage + SGC // server=false -> BasicX509 + clientAuth + anyExtendedKeyUsage From 46d1a2acc1b9ed38642a6c76b3925a1e63075c54 Mon Sep 17 00:00:00 2001 From: deepak1556 Date: Sun, 5 Apr 2026 04:45:13 +0900 Subject: [PATCH 5/5] test: add macOS certificate filtering tests --- test/fixtures/keys/expired-root-cert.pem | 20 ++++ test/fixtures/keys/expired-root-key.pem | 28 ++++++ .../keys/selfsigned-no-result-root-cert.pem | 20 ++++ .../keys/selfsigned-no-result-root-key.pem | 28 ++++++ test/system-ca/README.md | 34 +++++++ test/system-ca/test-macos-cert-filtering.mjs | 93 +++++++++++++++++++ 6 files changed, 223 insertions(+) create mode 100644 test/fixtures/keys/expired-root-cert.pem create mode 100644 test/fixtures/keys/expired-root-key.pem create mode 100644 test/fixtures/keys/selfsigned-no-result-root-cert.pem create mode 100644 test/fixtures/keys/selfsigned-no-result-root-key.pem create mode 100644 test/system-ca/test-macos-cert-filtering.mjs diff --git a/test/fixtures/keys/expired-root-cert.pem b/test/fixtures/keys/expired-root-cert.pem new file mode 100644 index 00000000000000..fb55cdfe68de4b --- /dev/null +++ b/test/fixtures/keys/expired-root-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDSzCCAjOgAwIBAgIUJmC4/h9L3c8Arrxyo/+O73Owt9AwDQYJKoZIhvcNAQEL +BQAwNTEhMB8GA1UEAwwYTm9kZUpTLVRlc3QtRXhwaXJlZC1Sb290MRAwDgYDVQQK +DAdOb2RlLmpzMB4XDTIwMDEwMTAwMDAwMFoXDTIwMTIzMTIzNTk1OVowNTEhMB8G +A1UEAwwYTm9kZUpTLVRlc3QtRXhwaXJlZC1Sb290MRAwDgYDVQQKDAdOb2RlLmpz +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArkeWKdWP/YG89KiMCTYe +9Q3DSMwzKeYrjtK5FeVxSHqo5za01VYCYsHpmTZFTM5P6nU6c889vZ0+C5lS1QAa +JLgkUYpMMTk7ycAO0VKYugB9vHI3mQUH3Fkk7cilq5r1KyR1RqaGx7xhe49iFyXo +zIE7C9fAQ4/DhqkuZlCnFg9PQLzKikgRdUUZsR/kvEAK30YyCcVs5po5oTGZB1VB +gRoNYLPqSBCUtgfHEw2EIqRNQtgVwEuJVEclwbijkgIm4NFDCFKixbVE8KQFAuxr +SFO8msDonubXJhCXZkNeh+diBMl7lglZEQU8o6ax0lqrV8mCPselXc0ipR/eD6aG +XQIDAQABo1MwUTAdBgNVHQ4EFgQUN+lQVSG+5zPWNm/XiMcDMk9GVnowHwYDVR0j +BBgwFoAUN+lQVSG+5zPWNm/XiMcDMk9GVnowDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAQEASj1j/atg8qr3aZEsukmFaBf7eu4c0O0WwogTwCaSAEtn +6et9G9rbg++X3JDHBv2PKiYdhSKTz2Kdsaau0fw0fmDkrFB5fwvzi6JU+LZ0Q8Ur +jPlcks8sIdByX9mAbmf0Hur1qkPBoOm4BQNHrhEA+ExD8jbTAUukAOwH0mZV9ZzC +B/7qVXwV6JmvdLXKDeinScnu1AIcYJVPUFEO2ZSkl+4XsdFogN+t4ryisosgFCtL +AWkxA2/InBPBncDOUiceagJQdkGiyhsySxj/niLe8XUUo0p6/bYgqV7loJV6qkDA +6KPH1RyigZMJ0SxNvj5oW1wcS3N2i/uX5csq9/w2nA== +-----END CERTIFICATE----- diff --git a/test/fixtures/keys/expired-root-key.pem b/test/fixtures/keys/expired-root-key.pem new file mode 100644 index 00000000000000..a7c1e414e5bc56 --- /dev/null +++ b/test/fixtures/keys/expired-root-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCuR5Yp1Y/9gbz0 +qIwJNh71DcNIzDMp5iuO0rkV5XFIeqjnNrTVVgJiwemZNkVMzk/qdTpzzz29nT4L +mVLVABokuCRRikwxOTvJwA7RUpi6AH28cjeZBQfcWSTtyKWrmvUrJHVGpobHvGF7 +j2IXJejMgTsL18BDj8OGqS5mUKcWD09AvMqKSBF1RRmxH+S8QArfRjIJxWzmmjmh +MZkHVUGBGg1gs+pIEJS2B8cTDYQipE1C2BXAS4lURyXBuKOSAibg0UMIUqLFtUTw +pAUC7GtIU7yawOie5tcmEJdmQ16H52IEyXuWCVkRBTyjprHSWqtXyYI+x6VdzSKl +H94PpoZdAgMBAAECggEAAoFI6UUGktBAlQuvJ5q9iywteGhm+90xFxZ0TppDrJUG +xHwG0WIxGpZK80bSbC4y+92/f1alPop6D9SeWi1sMsbqzrk8KyD1eQrnq56ST2oe +ZI0Hu41U9ZfabgiKSRMrHvmhLejK2ygcBpijAk4rMHVTEfKB8vaoCtF3t0TFgg2k +MkliPU9hCkrr17aYZSw8R2HiTM8hqixm7GrTihZzGFUa+N4NHZEbVkLQpCnifBly +1ZyNL03FDSBClYRGvp2W6qO8uUi3cQuXyfXhoeHZx5AW+pVsmmZsxhNnyeJwCDcU ++WgxU9uNeHWEYwVg3fTP3LLgRhnVBr3UuYI4GtWBiQKBgQDhRC9cTlOG9SrgHPZT +reZNDQ9hhrcb1kbo1yJBIHFTihv0UIS6S36PmN+OgshbF5+G8Y8fvJJ8vuzMpDfC +GNR53F6DHH/bngv3lgws/6F0X41TrzOA6+MkA4D6s5uGscSODCGuxkwjuZuKQzwo +zAhx8t6RX/w1k4uXFPfYgX5vBwKBgQDGDppVCyRGpijdAdXVmmiQz/7q9M6YdFDX +53pt8CIdCfYiT3+iOwPqHAddQdwVewC3WrEZ7S3PdxpKgDDpYkJ0+NtHhlYdGLR9 +Ti6Cvmfc+r8yYnmEAqIXAeac5A4MYYTh+FadgtzqVPhp8Q61xkXFwajo/tV1EcSt +/A+rC0XiewKBgQCqniBZA6JUJ8FvucAApUg3t9qcfZKW7PcMSFXTiiULpyGBLLM6 +/w8+6AT7RadHB192r+M9oHA7N8jXPtJUmsXj/rs/Bwj4aH6b6fQS6RN6txyt85dI +4GFL17OLLxpvLJm5FQs1+0+UB3L9h+s64z7KP6+/4DmAwt4JcoI+Y+ZFZQKBgHbE +YwAEgmhrU63UX+qLgZD1aaRz0T/S4HfYM66hhZNsWdERYzRht2M4E6J00AmBjVhm +ZjVp6UKz5WwvmyUY60lBwh0ODa29Ft7ddz6n95ioNOd97eifu5uYZDZI+7Oo9wqa +5TXnN5q+AYlmKLAQid6g1y2BQ3fEg/DhanPjerDpAoGBAM7Sm4E4UMOCzHFCHehh +bTzfz8oNGpkWI+5UctXZOkzQ3YVtTwB/+b5nuG7D5EI8mRGHmhXerkM2uAmShmWl +a9Xyo9nswhDjanBcji+ZWIXFqOodclK4/3VE+VnbzlZMqpGnxUl88wWeBXEualZ0 +Z3lq9DNf/Fywlcl69thCNXQs +-----END PRIVATE KEY----- diff --git a/test/fixtures/keys/selfsigned-no-result-root-cert.pem b/test/fixtures/keys/selfsigned-no-result-root-cert.pem new file mode 100644 index 00000000000000..2b28cf684c4a9b --- /dev/null +++ b/test/fixtures/keys/selfsigned-no-result-root-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDUTCCAjmgAwIBAgIUapTwHjAOdsC1qQUcKRKprxW7GvswDQYJKoZIhvcNAQEL +BQAwNzEjMCEGA1UEAwwaTm9kZUpTLVRlc3QtTm8tUmVzdWx0LVJvb3QxEDAOBgNV +BAoMB05vZGUuanMwIBcNMjYwNDA0MTgzMjUyWhgPMjEyNjAzMTExODMyNTJaMDcx +IzAhBgNVBAMMGk5vZGVKUy1UZXN0LU5vLVJlc3VsdC1Sb290MRAwDgYDVQQKDAdO +b2RlLmpzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAomij2nGU2Lg0 +sD9OitlQlIAC6QEm3ziS8c3yX+wIhV/sZ/mRFt1cvFfIX7LFYl6zzDMI0Wgho422 +H9T4I5RRZ2Ms6IZ1e6+ERlqWDm3pS2nFglOR3AA1QUDt0pSfrtQjIc2yxeTMP7EO +/LRZuGp1VOrR2kDP9JZC0+WLBBvtz/yFUICdrfDnm2d9b632Gl3CU/GiotMvt+oZ +8J3WEh4y0vVdIwzMLV/jhoIgi0e9sZzSkB4ZmHCBOwl4asj8Yj8PS4q4AvXH2jms +HmrsxUezsRrw9seccisexcC+akrJ4s1cqGXmi6MxqP3FO6yhSBdge6fMSNAETZ70 +qKL/Kk1t6QIDAQABo1MwUTAdBgNVHQ4EFgQULJNqn+YwH/flAsr8460+8ej1XQkw +HwYDVR0jBBgwFoAULJNqn+YwH/flAsr8460+8ej1XQkwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAMl8+Y1xRagBBKOluPcvTyV5BTleXLtF95wsj +6SCTsSzmJiYQtBZA/pSoTS8+gEQb9hjN6dbYRDVY2pGyLrEVI/YE4zr4+Ug8vBXw +UnZx4a76bUiT8iC7rBsqSeui7R56lbPQlxYjEKyX3oZgW9WzZ9NT3z9/u3mfTrW/ +TculuifSQHAi1X5r9IXFFABMyD8gDtQSfG+0e1E9KLyCMO3p8H/snz1hXIAUFnBD +2b5QYiQRxUN2aO4PqJakxhDN844Vv6O+vQX618Fn+MqaL6qyPzJRo9qJe70/5xIu +4Xs3ajp8Y3c5bb9vGtgLWsb3eUJ+AcZ2kttaBaIgiKqk4hTrDA== +-----END CERTIFICATE----- diff --git a/test/fixtures/keys/selfsigned-no-result-root-key.pem b/test/fixtures/keys/selfsigned-no-result-root-key.pem new file mode 100644 index 00000000000000..7e0b22c3009c0d --- /dev/null +++ b/test/fixtures/keys/selfsigned-no-result-root-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCiaKPacZTYuDSw +P06K2VCUgALpASbfOJLxzfJf7AiFX+xn+ZEW3Vy8V8hfssViXrPMMwjRaCGjjbYf +1PgjlFFnYyzohnV7r4RGWpYObelLacWCU5HcADVBQO3SlJ+u1CMhzbLF5Mw/sQ78 +tFm4anVU6tHaQM/0lkLT5YsEG+3P/IVQgJ2t8OebZ31vrfYaXcJT8aKi0y+36hnw +ndYSHjLS9V0jDMwtX+OGgiCLR72xnNKQHhmYcIE7CXhqyPxiPw9LirgC9cfaOawe +auzFR7OxGvD2x5xyKx7FwL5qSsnizVyoZeaLozGo/cU7rKFIF2B7p8xI0ARNnvSo +ov8qTW3pAgMBAAECggEAFFg1W7zqCh1GhGQ5y4zzhNa7BpiIAmMiN+Y2RtaDhBRY +bKHyZJcwRxZcBG5EPvwMBo1yEla6uAlILO6kcu3hJf63dnK5hC1KzaFgC3NbS9Yh +JsqfNUcAD3+PCy0RCnk1OXEnbut9ZpEghn7Kf8kzj4Km7RySBa/5CSBHwikEKQ85 +jO4dvtx0wDYbbvTrIcCKS74KoM10hioR3478m/6sx3sYau1bOUAUgvBBQc4WmdwJ +Mxxz2ZqTQp4IyTGLIIS7vhyJLG9dcb1fapuMXjRYUn9i2ku9mbB+p/n514RLS+2W +iS9IokYY2NwYv5Ue2cxvtmE5uVqx7Hkm4FEeFQJCHQKBgQDWLaT5mWRZaIMGyoas +lmqqYM0gLlb9E9mxyPMc/BV9flV/8dTzSVarvGDJtefVQB4LHBOCXQYj9ZiqUsND +5b7ZH/sVBNBfm/FR6rebKYXc0HOzhmCsoiuVSMRmpLZsWVpeX2BzCVJzES5ejm0o +t3nGFzzLyOwWxrvp5zcGLwsNWwKBgQDCHyMHQ052QBujBs6PICd6pQ+VtcTSmXbX +TUcUFptzdRRIZdnuM2r2ZhUcBV1RSrHFfpzeyMuTW1+sU4I+ggOeM15ui1XRJjHI +FId6Ahj35QR/Jqrlyhde5g/xElY7/4Vlvw27DlrqrSa8QzsM32J8RoQB3SppuPlz +lnZoB5uBCwKBgBuYbfUq6l8KtDcfyRJbnwqsxkErN1IMSLQ7a/eEE1DEAkgl5IYk +IOKntuDGa0RyqmxMBcd6LNxdPHpVh4ssAtb+497la+OluAYR8+4t/21f/khXPAWC +L5NgeM2w00BKkvYt28N2pATnZc4RE8d3PF1liRPIo4KbwIJ2pARL82SZAoGAW8cu +33sx+HR83IoWVNLl93VctfJ3eP53knmF1niNzHuZOFV3QMhslMxUxKfAo/OFsxMW +hbo3jZbQ1/+vf3Am17//sJIN49GEDc2u879UILfVdWxJtlTi0cpB1T9PKBS59A3t +Jvg1geiVfMLog0CGJq2MMfln2Q5MWhrUJoEaQ1sCgYBGIrbT0Tpb/09w1qZ21kDk +4iBAnKiy3KAeXlq3bVpfV+u/Ah6K7nvKIFtende/FoKFhiIu7YAFkOhIWRvBsRZ0 +KWt2rlHfjlk+iZN9NqaWQqfmUvemiuBxie7ddFmMJwK2YYmmSI1hnTQlVLHuqMs6 +fDfGqsm1Edj00rLA7f6ylg== +-----END PRIVATE KEY----- diff --git a/test/system-ca/README.md b/test/system-ca/README.md index 422b8af1db049f..8a3783b1ce1e40 100644 --- a/test/system-ca/README.md +++ b/test/system-ca/README.md @@ -18,6 +18,34 @@ security add-certificates \ security add-certificates \ -k /Users/$USER/Library/Keychains/login.keychain-db \ test/fixtures/keys/non-trusted-intermediate-ca.pem +security add-trusted-cert \ + -k /Users/$USER/Library/Keychains/login.keychain-db \ + test/fixtures/keys/expired-root-cert.pem +# Self-signed cert with trust settings that lack kSecTrustSettingsResult. +security add-trusted-cert \ + -k /Users/$USER/Library/Keychains/login.keychain-db \ + test/fixtures/keys/selfsigned-no-result-root-cert.pem +security trust-settings-export /tmp/node-trust-settings.plist +CERT_SHA1=$(openssl x509 \ + -in test/fixtures/keys/selfsigned-no-result-root-cert.pem \ + -fingerprint -sha1 -noout | sed 's/.*=//;s/://g') +/usr/libexec/PlistBuddy \ + -c "Delete :trustList:${CERT_SHA1}:trustSettings" \ + /tmp/node-trust-settings.plist +/usr/libexec/PlistBuddy \ + -c "Add :trustList:${CERT_SHA1}:trustSettings array" \ + /tmp/node-trust-settings.plist +/usr/libexec/PlistBuddy \ + -c "Add :trustList:${CERT_SHA1}:trustSettings:0 dict" \ + /tmp/node-trust-settings.plist +security trust-settings-import /tmp/node-trust-settings.plist +rm /tmp/node-trust-settings.plist +# Duplicate cert in a second keychain +security create-keychain -p "test" /tmp/node-test-dup.keychain +security add-certificates \ + -k /tmp/node-test-dup.keychain \ + test/fixtures/keys/fake-startcom-root-cert.pem +security list-keychains -d user -s login.keychain-db /tmp/node-test-dup.keychain ``` **Removing the certificate** @@ -29,6 +57,12 @@ security delete-certificate -c 'NodeJS-Test-Intermediate-CA' \ -t /Users/$USER/Library/Keychains/login.keychain-db security delete-certificate -c 'NodeJS-Non-Trusted-Test-Intermediate-CA' \ -t /Users/$USER/Library/Keychains/login.keychain-db +security delete-certificate -c 'NodeJS-Test-Expired-Root' \ + -t /Users/$USER/Library/Keychains/login.keychain-db +security delete-certificate -c 'NodeJS-Test-No-Result-Root' \ + -t /Users/$USER/Library/Keychains/login.keychain-db +security list-keychains -d user -s login.keychain-db +security delete-keychain /tmp/node-test-dup.keychain ``` ## Windows diff --git a/test/system-ca/test-macos-cert-filtering.mjs b/test/system-ca/test-macos-cert-filtering.mjs new file mode 100644 index 00000000000000..582891a7b21ea1 --- /dev/null +++ b/test/system-ca/test-macos-cert-filtering.mjs @@ -0,0 +1,93 @@ +// Flags: --use-system-ca + +import * as common from '../common/index.mjs'; +import assert from 'node:assert/strict'; +import * as fixtures from '../common/fixtures.mjs'; +import { it, describe } from 'node:test'; +import { includesCert, extractMetadata } from '../common/tls.js'; +import { execFileSync } from 'node:child_process'; + +if (!common.hasCrypto) { + common.skip('requires crypto'); +} + +if (process.platform !== 'darwin') { + common.skip('macOS-specific test'); +} + +function isCertInKeychain(cn) { + try { + execFileSync('security', ['find-certificate', '-c', cn], { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +function isDupKeychainPresent() { + try { + const out = execFileSync( + 'security', ['list-keychains', '-d', 'user'], + { encoding: 'utf8' }, + ); + return out.includes('node-test-dup.keychain'); + } catch { + return false; + } +} + +const { default: tls } = await import('node:tls'); + +const systemCerts = tls.getCACertificates('system'); +const fakeStartcomCert = fixtures.readKey('fake-startcom-root-cert.pem'); +if (!includesCert(systemCerts, fakeStartcomCert)) { + common.skip( + 'fake-startcom-root-cert.pem not found in system CA store. ' + + 'Please follow setup instructions in test/system-ca/README.md', + ); +} +if (!isDupKeychainPresent()) { + common.skip( + 'Duplicate keychain not set up. ' + + 'Please follow setup instructions in test/system-ca/README.md', + ); +} +if (!isCertInKeychain('NodeJS-Test-Expired-Root')) { + common.skip( + 'Expired cert not installed. ' + + 'Please follow setup instructions in test/system-ca/README.md', + ); +} + +describe('macOS certificate filtering', () => { + it('includes self-signed cert with absent kSecTrustSettingsResult', () => { + const noResultCert = fixtures.readKey('selfsigned-no-result-root-cert.pem'); + assert.ok( + includesCert(systemCerts, noResultCert), + 'Self-signed cert with absent kSecTrustSettingsResult ' + + '(defaulting to TrustRoot) should be in system CA list', + ); + }); + + it('deduplicates certificates from multiple keychains', () => { + const target = extractMetadata(fakeStartcomCert); + const matches = systemCerts.filter((c) => { + const m = extractMetadata(c); + return m.serialNumber === target.serialNumber && + m.issuer === target.issuer && + m.subject === target.subject; + }); + assert.strictEqual( + matches.length, 1, + `Expected exactly 1 copy of fake-startcom-root-cert, found ${matches.length}`, + ); + }); + + it('filters out expired certificates', () => { + const expiredCert = fixtures.readKey('expired-root-cert.pem'); + assert.ok( + !includesCert(systemCerts, expiredCert), + 'Expired certificate should not be in system CA list', + ); + }); +});