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"]
),
]
)
66 changes: 66 additions & 0 deletions macos/Sources/ReverseAPIProxy/CA/CertificateAuthority.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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)
return RootCertificate(certificate: certificate, privateKey: privateKey)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Validate that the loaded certificate and private key belong to the same keypair before returning RootCertificate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At macos/Sources/ReverseAPIProxy/CA/CertificateAuthority.swift, line 64:

<comment>Validate that the loaded certificate and private key belong to the same keypair before returning `RootCertificate`.</comment>

<file context>
@@ -53,4 +57,10 @@ public enum CertificateAuthority {
+    public static func loadRoot(certificatePEM: String, privateKeyPEM: String) throws -> RootCertificate {
+        let certificate = try Certificate(pemEncoded: certificatePEM)
+        let privateKey = try Certificate.PrivateKey(pemEncoded: privateKeyPEM)
+        return RootCertificate(certificate: certificate, privateKey: privateKey)
+    }
 }
</file context>
Suggested change
return RootCertificate(certificate: certificate, privateKey: privateKey)
guard certificate.publicKey == privateKey.publicKey else {
throw NSError(domain: "CertificateAuthority", code: 1, userInfo: [NSLocalizedDescriptionKey: "Certificate and private key do not match"])
}
return RootCertificate(certificate: certificate, privateKey: privateKey)

}
}
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) }
}
}
51 changes: 51 additions & 0 deletions macos/Sources/ReverseAPIProxy/CA/RootCertificateStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

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

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 {
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) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: loadOrCreate uses an unsynchronized check-then-create flow; concurrent calls can persist a mismatched certificate/private-key pair.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At macos/Sources/ReverseAPIProxy/CA/RootCertificateStore.swift, line 30:

<comment>`loadOrCreate` uses an unsynchronized check-then-create flow; concurrent calls can persist a mismatched certificate/private-key pair.</comment>

<file context>
@@ -0,0 +1,51 @@
+        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) {
+            let certificatePEM = try String(contentsOf: certificateURL, encoding: .utf8)
+            let privateKeyPEM = try String(contentsOf: privateKeyURL, encoding: .utf8)
</file context>

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)
}
}
71 changes: 71 additions & 0 deletions macos/Sources/ReverseAPIProxy/Capture/CapturedFlow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import Foundation

public struct HTTPHeader: Sendable, Hashable {
public var name: String
public var value: String

public init(_ name: String, _ value: String) {
self.name = name
self.value = value
}
}

public struct CapturedFlow: Sendable, Identifiable {
public enum Scheme: String, Sendable {
case http
case https
}

public let id: UUID
public let scheme: Scheme
public let method: String
public let host: String
public let port: Int
public let path: String
public var requestHeaders: [HTTPHeader]
public var requestBody: Data
public var responseStatus: Int?
public var responseHeaders: [HTTPHeader]
public var responseBody: Data
public let startedAt: Date
public var finishedAt: Date?
public var error: String?

public init(
id: UUID = UUID(),
scheme: Scheme,
method: String,
host: String,
port: Int,
path: String,
requestHeaders: [HTTPHeader] = [],
startedAt: Date = Date()
) {
self.id = id
self.scheme = scheme
self.method = method
self.host = host
self.port = port
self.path = path
self.requestHeaders = requestHeaders
self.requestBody = Data()
self.responseStatus = nil
self.responseHeaders = []
self.responseBody = Data()
self.startedAt = startedAt
self.finishedAt = nil
self.error = nil
}

public var url: String {
let bracketed = host.contains(":") && !host.hasPrefix("[") ? "[\(host)]" : host
let portSegment: String
switch (scheme, port) {
case (.http, 80), (.https, 443):
portSegment = ""
default:
portSegment = ":\(port)"
}
return "\(scheme.rawValue)://\(bracketed)\(portSegment)\(path)"
}
}
Loading