Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}
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) {
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)
}
}
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