Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions macos/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
.build/
.swiftpm/
DerivedData/
*.xcodeproj
*.xcworkspace
xcuserdata/
*.profraw
42 changes: 42 additions & 0 deletions macos/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// swift-tools-version: 5.10
import PackageDescription

let package = Package(
name: "ReverseAPI",
platforms: [.macOS(.v14)],
products: [
.library(name: "ReverseAPIProxy", targets: ["ReverseAPIProxy"]),
.executable(name: "rae-proxy", targets: ["rae-proxy"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"),
.package(url: "https://github.com/apple/swift-certificates.git", from: "1.5.0"),
.package(url: "https://github.com/apple/swift-crypto.git", from: "3.7.0"),
],
targets: [
.target(
name: "ReverseAPIProxy",
dependencies: [
.product(name: "NIO", package: "swift-nio"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "NIOFoundationCompat", package: "swift-nio"),
.product(name: "NIOConcurrencyHelpers", package: "swift-nio"),
.product(name: "NIOSSL", package: "swift-nio-ssl"),
.product(name: "X509", package: "swift-certificates"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "_CryptoExtras", package: "swift-crypto"),
]
),
.executableTarget(
name: "rae-proxy",
dependencies: ["ReverseAPIProxy"]
),
.testTarget(
name: "ReverseAPIProxyTests",
dependencies: ["ReverseAPIProxy"]
),
]
)
78 changes: 78 additions & 0 deletions macos/Sources/ReverseAPIProxy/CA/CertificateAuthority.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Foundation
import Crypto
import X509
import SwiftASN1

public struct RootCertificate: Sendable {
public let certificate: Certificate
public let privateKey: Certificate.PrivateKey

public func derBytes() throws -> [UInt8] {
var serializer = DER.Serializer()
try serializer.serialize(certificate)
return serializer.serializedBytes
}

public func pem() throws -> String {
let pemDoc = PEMDocument(type: "CERTIFICATE", derBytes: try derBytes())
return pemDoc.pemString
}

public func privateKeyPEM() throws -> String {
try privateKey.serializeAsPEM().pemString
}
}

public enum CertificateAuthority {
public static func generateRoot(commonName: String = "ReverseAPI Local Root") throws -> RootCertificate {
let signingKey = P256.Signing.PrivateKey()
let privateKey = Certificate.PrivateKey(signingKey)

let name = try DistinguishedName {
CommonName(commonName)
OrganizationName("ReverseAPI")
}

let now = Date()
let notValidAfter = now.addingTimeInterval(10 * 365 * 24 * 60 * 60)

let extensions = try Certificate.Extensions {
Critical(BasicConstraints.isCertificateAuthority(maxPathLength: 0))
Critical(KeyUsage(keyCertSign: true, cRLSign: true))
SubjectKeyIdentifier(hash: privateKey.publicKey)
}

let certificate = try Certificate(
version: .v3,
serialNumber: Certificate.SerialNumber(),
publicKey: privateKey.publicKey,
notValidBefore: now.addingTimeInterval(-60),
notValidAfter: notValidAfter,
issuer: name,
subject: name,
signatureAlgorithm: .ecdsaWithSHA256,
extensions: extensions,
issuerPrivateKey: privateKey
)

return RootCertificate(certificate: certificate, privateKey: privateKey)
}

public static func loadRoot(certificatePEM: String, privateKeyPEM: String) throws -> RootCertificate {
let certificate = try Certificate(pemEncoded: certificatePEM)
let privateKey = try Certificate.PrivateKey(pemEncoded: privateKeyPEM)
// Reject mismatched pairs at load time. Without this, a
// de-synced cert/key on disk (concurrent writes, manual edit,
// partial restore) silently breaks every TLS interception
// until the next regen — and the breakage looks like an
// upstream cert error, not a local config issue.
guard privateKey.publicKey == certificate.publicKey else {
throw CertificateAuthorityError.keyPairMismatch
}
return RootCertificate(certificate: certificate, privateKey: privateKey)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}

public enum CertificateAuthorityError: Error, Equatable {
case keyPairMismatch
}
144 changes: 144 additions & 0 deletions macos/Sources/ReverseAPIProxy/CA/LeafCertificateFactory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import Foundation
import Crypto
import X509
import SwiftASN1
import NIOSSL

public actor LeafCertificateFactory {
public struct Materials: Sendable {
public let certificate: NIOSSLCertificate
public let privateKey: NIOSSLPrivateKey
public let rootCertificate: NIOSSLCertificate
}

public static let defaultCacheLimit = 256

private let root: RootCertificate
private let rootSSL: NIOSSLCertificate
private let rootKeyIdentifier: ArraySlice<UInt8>
private let cacheLimit: Int
private var cache: [String: Materials] = [:]
private var order: [String] = []

public init(root: RootCertificate, cacheLimit: Int = LeafCertificateFactory.defaultCacheLimit) throws {
self.root = root
var serializer = DER.Serializer()
try serializer.serialize(root.certificate)
self.rootSSL = try NIOSSLCertificate(bytes: serializer.serializedBytes, format: .der)
self.rootKeyIdentifier = SubjectKeyIdentifier(hash: root.privateKey.publicKey).keyIdentifier
self.cacheLimit = max(1, cacheLimit)
}

public func materials(for host: String) throws -> Materials {
if let cached = cache[host] {
touch(host)
return cached
}
let minted = try mint(host: host)
insert(host: host, materials: minted)
return minted
}

public func cacheCount() -> Int {
cache.count
}

private func insert(host: String, materials: Materials) {
cache[host] = materials
order.removeAll(where: { $0 == host })
order.append(host)
while order.count > cacheLimit {
let evict = order.removeFirst()
cache.removeValue(forKey: evict)
}
}

private func touch(_ host: String) {
if let idx = order.firstIndex(of: host) {
order.remove(at: idx)
order.append(host)
}
}

private func mint(host: String) throws -> Materials {
let leafSigning = P256.Signing.PrivateKey()
let leafPrivateKey = Certificate.PrivateKey(leafSigning)

let subject = try DistinguishedName {
CommonName(host)
OrganizationName("ReverseAPI")
}

let now = Date()
let notValidAfter = now.addingTimeInterval(397 * 24 * 60 * 60)

let extensions = try Certificate.Extensions {
Critical(BasicConstraints.notCertificateAuthority)
Critical(KeyUsage(digitalSignature: true, keyEncipherment: true))
try ExtendedKeyUsage([.serverAuth, .clientAuth])
subjectAlternativeNames(for: host)
SubjectKeyIdentifier(hash: leafPrivateKey.publicKey)
AuthorityKeyIdentifier(keyIdentifier: rootKeyIdentifier)
}

let certificate = try Certificate(
version: .v3,
serialNumber: Certificate.SerialNumber(),
publicKey: leafPrivateKey.publicKey,
notValidBefore: now.addingTimeInterval(-60),
notValidAfter: notValidAfter,
issuer: root.certificate.subject,
subject: subject,
signatureAlgorithm: .ecdsaWithSHA256,
extensions: extensions,
issuerPrivateKey: root.privateKey
)

var certSerializer = DER.Serializer()
try certSerializer.serialize(certificate)
let certBytes = certSerializer.serializedBytes
let nioCert = try NIOSSLCertificate(bytes: certBytes, format: .der)

let keyPEM = try leafPrivateKey.serializeAsPEM().pemString
let nioKey = try NIOSSLPrivateKey(bytes: Array(keyPEM.utf8), format: .pem)

return Materials(certificate: nioCert, privateKey: nioKey, rootCertificate: rootSSL)
}

private func subjectAlternativeNames(for host: String) -> SubjectAlternativeNames {
if let bytes = ipv4Bytes(host) {
return SubjectAlternativeNames([.ipAddress(ASN1OctetString(contentBytes: ArraySlice(bytes)))])
}
if let bytes = ipv6Bytes(host) {
return SubjectAlternativeNames([.ipAddress(ASN1OctetString(contentBytes: ArraySlice(bytes)))])
}
return SubjectAlternativeNames([.dnsName(host)])
}
}

private func ipv4Bytes(_ host: String) -> [UInt8]? {
let parts = host.split(separator: ".")
guard parts.count == 4 else { return nil }
var bytes = [UInt8]()
for part in parts {
guard let value = UInt8(part) else { return nil }
bytes.append(value)
}
return bytes
}

private func ipv6Bytes(_ host: String) -> [UInt8]? {
guard host.contains(":") else { return nil }
var hints = addrinfo()
hints.ai_family = AF_INET6
hints.ai_flags = AI_NUMERICHOST
var result: UnsafeMutablePointer<addrinfo>?
let status = getaddrinfo(host, nil, &hints, &result)
guard status == 0, let info = result else { return nil }
defer { freeaddrinfo(info) }
guard let sockaddr = info.pointee.ai_addr else { return nil }
return sockaddr.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { ptr in
var addr = ptr.pointee.sin6_addr
return withUnsafeBytes(of: &addr) { Array($0) }
}
}
62 changes: 62 additions & 0 deletions macos/Sources/ReverseAPIProxy/CA/RootCertificateStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import Foundation

public struct RootCertificateStore: Sendable {
public let directory: URL
public let certificateURL: URL
public let privateKeyURL: URL

/// Serialises `loadOrCreate` across the process. The previous
/// check-then-create pattern could let two concurrent callers
/// both miss the file existence check, both generate fresh roots,
/// and race to overwrite each other — leaving one writer holding
/// an in-memory `RootCertificate` whose private key no longer
/// matches what's on disk.
private static let loadOrCreateLock = NSLock()

public init(directory: URL) {
self.directory = directory
self.certificateURL = directory.appendingPathComponent("reverseapi-root.pem")
self.privateKeyURL = directory.appendingPathComponent("reverseapi-root-key.pem")
}

public static func defaultDirectory(fileManager: FileManager = .default) throws -> URL {
guard let appSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
throw CocoaError(.fileNoSuchFile)
}
return appSupport.appendingPathComponent("ReverseAPI", isDirectory: true)
}

public static func `default`() throws -> RootCertificateStore {
try RootCertificateStore(directory: defaultDirectory())
}

public func loadOrCreate(commonName: String = "ReverseAPI Local Root") throws -> RootCertificate {
Self.loadOrCreateLock.lock()
defer { Self.loadOrCreateLock.unlock() }

let fileManager = FileManager.default
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)

if fileManager.fileExists(atPath: certificateURL.path), fileManager.fileExists(atPath: privateKeyURL.path) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
let certificatePEM = try String(contentsOf: certificateURL, encoding: .utf8)
let privateKeyPEM = try String(contentsOf: privateKeyURL, encoding: .utf8)
return try CertificateAuthority.loadRoot(certificatePEM: certificatePEM, privateKeyPEM: privateKeyPEM)
}

let root = try CertificateAuthority.generateRoot(commonName: commonName)
try save(root)
return root
}

public func save(_ root: RootCertificate) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)

try root.pem().write(to: certificateURL, atomically: true, encoding: .utf8)
try root.privateKeyPEM().write(to: privateKeyURL, atomically: true, encoding: .utf8)
try fileManager.setAttributes([.posixPermissions: 0o644], ofItemAtPath: certificateURL.path)
try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: privateKeyURL.path)
}
}
Loading