From f10053fe2e1e1037bd7944a5071b0d2145178503 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Thu, 29 Oct 2026 23:47:35 +0800 Subject: [PATCH] fix: support LibreSSL when issuing local certificates --- Dory/Net/LocalCA.swift | 33 ++++++++++++++++--- DoryTests/LocalCATests.swift | 15 ++++++++- .../Sources/DorydKit/LocalCA.swift | 10 ++++-- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Dory/Net/LocalCA.swift b/Dory/Net/LocalCA.swift index 7ad6d82d1..9c989955f 100644 --- a/Dory/Net/LocalCA.swift +++ b/Dory/Net/LocalCA.swift @@ -4,6 +4,7 @@ nonisolated enum ShellError: Error, Sendable { case launchFailed(String) case nonZeroExit(Int32, String) case toolNotFound(String) + case invalidArgument(String) } nonisolated enum Shell { @@ -112,11 +113,18 @@ nonisolated struct LocalCA: Sendable { @discardableResult func issue(domain: String, extraSANs: [String] = []) throws -> CertificatePair { guard let openssl = opensslPath else { throw ShellError.toolNotFound("openssl") } + try Self.validateCertificateName(domain) + for name in extraSANs where !name.isEmpty { try Self.validateCertificateName(name) } try ensureCA() - let certificate = directory.appendingPathComponent("\(domain).crt") - let key = directory.appendingPathComponent("\(domain).key") - let csr = directory.appendingPathComponent("\(domain).csr") - defer { try? FileManager.default.removeItem(at: csr) } + let safeName = domain.replacingOccurrences(of: "/", with: "_") + let certificate = directory.appendingPathComponent("\(safeName).crt") + let key = directory.appendingPathComponent("\(safeName).key") + let csr = directory.appendingPathComponent("\(safeName).csr") + let extensionsFile = directory.appendingPathComponent("\(safeName).extensions-\(UUID().uuidString).cnf") + defer { + try? FileManager.default.removeItem(at: csr) + try? FileManager.default.removeItem(at: extensionsFile) + } // TLS wildcards match a single label, so a `*.dory.local` cert does NOT cover multi-level // names like `web.default.k8s.dory.local`. Callers pass those explicitly as extra SANs. @@ -127,9 +135,11 @@ nonisolated struct LocalCA: Sendable { "-keyout", key.path, "-out", csr.path, "-subj", "/CN=\(domain)", "-addext", san, ]) + try Data("[dory_leaf]\n\(san)\n".utf8).write(to: extensionsFile) try Shell.run(openssl, [ "x509", "-req", "-in", csr.path, "-CA", caCertificate.path, "-CAkey", caKey.path, - "-CAcreateserial", "-out", certificate.path, "-days", "825", "-copy_extensions", "copyall", + "-CAcreateserial", "-out", certificate.path, "-days", "825", + "-extfile", extensionsFile.path, "-extensions", "dory_leaf", ]) return CertificatePair(certificate: certificate, privateKey: key) } @@ -160,4 +170,17 @@ nonisolated struct LocalCA: Sendable { return try Shell.run(openssl, ["x509", "-in", certificate.path, "-noout", "-text"]) } + private static func validateCertificateName(_ name: String) throws { + var value = name + if value.hasPrefix("*.") { value = String(value.dropFirst(2)) } + let labels = value.split(separator: ".", omittingEmptySubsequences: false) + guard !labels.isEmpty else { throw ShellError.invalidArgument("certificate name: \(name)") } + for label in labels { + guard !label.isEmpty, + label.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) else { + throw ShellError.invalidArgument("certificate name: \(name)") + } + } + } + } diff --git a/DoryTests/LocalCATests.swift b/DoryTests/LocalCATests.swift index d2ef97a3f..253ff7b69 100644 --- a/DoryTests/LocalCATests.swift +++ b/DoryTests/LocalCATests.swift @@ -15,7 +15,7 @@ struct LocalCATests { #expect(ca.caExists) #expect(FileManager.default.fileExists(atPath: ca.caCertificate.path)) - let pair = try ca.issue(domain: "web.dory.local") + let pair = try ca.issue(domain: "web.dory.local", extraSANs: ["api.dory.local"]) #expect(FileManager.default.fileExists(atPath: pair.certificate.path)) #expect(FileManager.default.fileExists(atPath: pair.privateKey.path)) @@ -25,9 +25,22 @@ struct LocalCATests { // The SAN must include the requested domain. let text = try ca.certificateText(pair.certificate) #expect(text.contains("web.dory.local")) + #expect(text.contains("api.dory.local")) #expect(text.contains("Dory Local CA")) } + @Test func rejectsCertificateNamesThatCouldInjectOpenSSLConfiguration() throws { + let directory = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dory-ca-invalid-name-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + + let ca = LocalCA(directory: directory) + guard ca.opensslPath != nil else { return } + #expect(throws: ShellError.self) { + try ca.issue(domain: "web.dory.local\n[attacker]") + } + } + @Test func localTrustParserAcceptsOnlyAValidDoryCA() throws { let directory = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("dory-trust-parser-\(UUID().uuidString)") diff --git a/dory-core-swift/Sources/DorydKit/LocalCA.swift b/dory-core-swift/Sources/DorydKit/LocalCA.swift index af9cd1836..0d1e21799 100644 --- a/dory-core-swift/Sources/DorydKit/LocalCA.swift +++ b/dory-core-swift/Sources/DorydKit/LocalCA.swift @@ -136,7 +136,11 @@ public struct DoryLocalCA { let certificate = directory.appendingPathComponent("\(safeName).crt") let key = directory.appendingPathComponent("\(safeName).key") let csr = directory.appendingPathComponent("\(safeName).csr") - defer { try? fileManager.removeItem(at: csr) } + let extensionsFile = directory.appendingPathComponent("\(safeName).extensions-\(UUID().uuidString).cnf") + defer { + try? fileManager.removeItem(at: csr) + try? fileManager.removeItem(at: extensionsFile) + } var san = "subjectAltName=DNS:\(domain),DNS:*.\(domain)" for name in extraSANs where !name.isEmpty { @@ -147,9 +151,11 @@ public struct DoryLocalCA { "-keyout", key.path, "-out", csr.path, "-subj", "/CN=\(domain)", "-addext", san, ]) + try Data("[dory_leaf]\n\(san)\n".utf8).write(to: extensionsFile) try DoryShell.run(openssl, [ "x509", "-req", "-in", csr.path, "-CA", caCertificate.path, "-CAkey", caKey.path, - "-CAcreateserial", "-out", certificate.path, "-days", "825", "-copy_extensions", "copyall", + "-CAcreateserial", "-out", certificate.path, "-days", "825", + "-extfile", extensionsFile.path, "-extensions", "dory_leaf", ]) try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: key.path) try fileManager.setAttributes([.posixPermissions: 0o644], ofItemAtPath: certificate.path)