diff --git a/Packages/OsaurusCore/Identity/HPKEKeyStore.swift b/Packages/OsaurusCore/Identity/HPKEKeyStore.swift new file mode 100644 index 000000000..b4f8fae32 --- /dev/null +++ b/Packages/OsaurusCore/Identity/HPKEKeyStore.swift @@ -0,0 +1,207 @@ +// +// HPKEKeyStore.swift +// osaurus +// +// X25519 keypair used as the static recipient key for HPKE-encrypted +// relay/Bonjour traffic. +// +// The keypair is deterministically derived from the user's Master Key +// via HMAC-SHA512 with domain separator "osaurus-hpke-v1" — same KDF +// pattern used by `AgentKey`. Once derived, the 32-byte private key is +// cached in a (non-biometric) Keychain item so subsequent server +// launches load it without re-prompting. +// +// Why deterministic and not "fresh per launch": +// Relay-only paired clients pin the server's HPKE public key during +// the LAN-pairing step. If the server regenerated its key on every +// launch, those clients would silently fail to encrypt against the new +// key after a restart. Deterministic derivation makes the public key +// stable across launches as long as the Master Key is stable. +// +// When `MasterKey.exists()` is false (tests, fresh installs before +// onboarding), the store falls back to a fresh in-memory keypair so +// HPKE primitives still function. This fallback path is by design +// ephemeral — it gets replaced as soon as `warmUp(masterKey:)` runs. +// + +import CryptoKit +import Foundation + +public final class HPKEKeyStore: @unchecked Sendable { + public static let shared = HPKEKeyStore() + + /// Wire identifier for the negotiated suite. Sent as `hpke_suite` in + /// Bonjour TXT records and as a parameter on the `X-Osaurus-Encryption` + /// HTTP header. A single suite is currently supported; new versions + /// must publish a new identifier so clients can fall back cleanly. + public static let suiteIdentifier = "x25519-sha256-chachapoly" + + /// Apple CryptoKit ciphersuite matching `suiteIdentifier`. + public static let ciphersuite: HPKE.Ciphersuite = .init( + kem: .Curve25519_HKDF_SHA256, + kdf: .HKDF_SHA256, + aead: .chaChaPoly + ) + + private static let kdfDomain = Data("osaurus-hpke-v1".utf8) + private static let keychainService = "com.osaurus.hpke" + private static let keychainAccount = "x25519.v1" + + private let lock = NSLock() + private var _privateKey: Curve25519.KeyAgreement.PrivateKey? + private var _publicKeyBytes: Data? + private var _publicKeyEncoded: String? + /// True when `_privateKey` came from a deterministic source (keychain + /// or master-key derivation). False = ephemeral fallback that should + /// be replaced as soon as `warmUp` is callable. + private var _isDeterministic: Bool = false + + private init() {} + + /// Currently-cached private key. Loads from the keychain on first + /// access (no biometric prompt); generates an ephemeral keypair when + /// nothing is persisted yet. Always returns a usable key. + public var privateKey: Curve25519.KeyAgreement.PrivateKey { + lock.lock() + defer { lock.unlock() } + return privateKeyLocked() + } + + /// 32-byte raw public key — what Bonjour publishes and what clients + /// pass to `HPKE.Sender`. Cached so each Bonjour-advertised agent + /// doesn't re-derive the public key from the private key. + public var publicKeyBytes: Data { + lock.lock() + defer { lock.unlock() } + if let cached = _publicKeyBytes { return cached } + let bytes = privateKeyLocked().publicKey.rawRepresentation + _publicKeyBytes = bytes + return bytes + } + + /// Base64url (no padding) encoding of `publicKeyBytes`. + public var publicKeyEncoded: String { + lock.lock() + defer { lock.unlock() } + if let cached = _publicKeyEncoded { return cached } + let encoded = (_publicKeyBytes ?? privateKeyLocked().publicKey.rawRepresentation).base64urlEncoded + _publicKeyEncoded = encoded + return encoded + } + + /// True when the cached key was derived from the master key (and + /// therefore stable across launches). Useful for telling callers + /// whether to trust the published key for long-term pairing. + public var isDeterministic: Bool { + lock.lock() + defer { lock.unlock() } + _ = privateKeyLocked() + return _isDeterministic + } + + /// Derive the deterministic key from the master key bytes and + /// persist it. Call from a context where the master key is already + /// in scope (e.g., right after `MasterKey.getPrivateKey(context:)`) + /// so this runs without a separate biometric prompt. + /// + /// Idempotent: re-running with the same master key yields the same + /// derived bytes. + public func warmUp(masterKey: Data) { + var bytes = Self.derive(from: masterKey) + defer { + bytes.withUnsafeMutableBytes { ptr in + if let base = ptr.baseAddress { memset(base, 0, ptr.count) } + } + } + Self.saveKeychain(bytes) + + lock.lock() + defer { lock.unlock() } + if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: bytes) { + _privateKey = key + _publicKeyBytes = key.publicKey.rawRepresentation + _publicKeyEncoded = nil + _isDeterministic = true + } + } + + /// Wipe the cached key (in-memory + keychain). Next access falls + /// back to a fresh ephemeral keypair until `warmUp` runs again. Use + /// when the master key has changed or the user has reset identity. + public func reset() { + Self.deleteKeychain() + lock.lock() + defer { lock.unlock() } + _privateKey = nil + _publicKeyBytes = nil + _publicKeyEncoded = nil + _isDeterministic = false + } + + // MARK: - Private helpers + + /// Returns the cached private key, lazily loading from keychain or + /// minting an ephemeral fallback. The caller must already hold `lock`. + private func privateKeyLocked() -> Curve25519.KeyAgreement.PrivateKey { + if let cached = _privateKey { return cached } + if let bytes = Self.loadKeychain(), + let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: bytes) + { + _privateKey = key + _isDeterministic = true + return key + } + let ephemeral = Curve25519.KeyAgreement.PrivateKey() + _privateKey = ephemeral + _isDeterministic = false + return ephemeral + } + + private static func derive(from masterKey: Data) -> Data { + let mac = HMAC.authenticationCode( + for: kdfDomain, + using: SymmetricKey(data: masterKey) + ) + return Data(mac.prefix(32)) + } + + private static func loadKeychain() -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data, data.count == 32 else { + return nil + } + return data + } + + private static func saveKeychain(_ bytes: Data) { + // Idempotent: delete then add. Deletion is silent on missing. + deleteKeychain() + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + kSecValueData as String: bytes, + kSecAttrLabel as String: "Osaurus HPKE Recipient Key", + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + SecItemAdd(query as CFDictionary, nil) + } + + @discardableResult + private static func deleteKeychain() -> OSStatus { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keychainAccount, + ] + return SecItemDelete(query as CFDictionary) + } +} diff --git a/Packages/OsaurusCore/Identity/MasterKey.swift b/Packages/OsaurusCore/Identity/MasterKey.swift index a96674d23..deda5e69e 100644 --- a/Packages/OsaurusCore/Identity/MasterKey.swift +++ b/Packages/OsaurusCore/Identity/MasterKey.swift @@ -40,6 +40,11 @@ public struct MasterKey: Sendable { } } + // Derive and persist the HPKE keypair while the master key is in + // scope, so future server launches can publish a stable public + // key without prompting for biometric again. + HPKEKeyStore.shared.warmUp(masterKey: keyData) + return osaurusId } diff --git a/Packages/OsaurusCore/Managers/Chat/ChatWindowState.swift b/Packages/OsaurusCore/Managers/Chat/ChatWindowState.swift index 022da8402..44b4fcd1f 100644 --- a/Packages/OsaurusCore/Managers/Chat/ChatWindowState.swift +++ b/Packages/OsaurusCore/Managers/Chat/ChatWindowState.swift @@ -271,7 +271,8 @@ final class ChatWindowState: ObservableObject { id: agentId, name: provider.name, remoteAgentAddress: relayAddress, - providerId: provider.id + providerId: provider.id, + supportsEncryption: provider.supportsEncryption ) } } diff --git a/Packages/OsaurusCore/Models/Chat/ResponseWriters.swift b/Packages/OsaurusCore/Models/Chat/ResponseWriters.swift index a03682115..a97307a9b 100644 --- a/Packages/OsaurusCore/Models/Chat/ResponseWriters.swift +++ b/Packages/OsaurusCore/Models/Chat/ResponseWriters.swift @@ -10,6 +10,46 @@ import IkigaJSON import NIOCore import NIOHTTP1 +/// Wraps the per-event NIO body write so it can transparently route +/// through an `HPKEServerContext` when one is attached. When encryption +/// is active the buffer is sealed with ChaChaPoly (key derived from the +/// HPKE base context exporter) and emitted as a single SSE event of the +/// form `data: :\n\n`. +private func emitEvent( + plaintext buffer: ByteBuffer, + encryption: HPKEServerContext?, + context: ChannelHandlerContext +) { + if let enc = encryption { + let bytes = buffer.getBytes(at: buffer.readerIndex, length: buffer.readableBytes) ?? [] + let plaintext = Data(bytes) + guard let result = try? enc.sealStreamChunk(plaintext) else { + context.close(promise: nil) + return + } + var out = context.channel.allocator.buffer(capacity: result.base64.count + 32) + out.writeString("data: ") + out.writeString(String(result.counter)) + out.writeString(":") + out.writeString(result.base64) + out.writeString("\n\n") + context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(out))), promise: nil) + context.flush() + } else { + context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) + context.flush() + } +} + +/// Adds the X-Osaurus-Encryption header in stream mode when encryption +/// is active, so the client knows to run each `data:` line through +/// `HPKEClientContext.openStreamChunk`. +private func appendEncryptionHeaders(_ headers: inout HTTPHeaders, encryption: HPKEServerContext?) { + if encryption != nil { + headers.add(name: HPKEHeader.encryption, value: HPKEHeader.streamValue) + } +} + protocol ResponseWriter { func writeHeaders(_ context: ChannelHandlerContext, extraHeaders: [(String, String)]?) func writeRole( @@ -39,6 +79,10 @@ protocol ResponseWriter { } final class SSEResponseWriter: ResponseWriter { + /// Set by handlers when the inbound request was HPKE-encrypted; each + /// SSE event is then sealed and emitted as a single encrypted + /// `data: :\n\n` line. + var encryption: HPKEServerContext? func writeHeaders(_ context: ChannelHandlerContext, extraHeaders: [(String, String)]? = nil) { var head = HTTPResponseHead(version: .http1_1, status: .ok) @@ -51,6 +95,7 @@ final class SSEResponseWriter: ResponseWriter { if let extraHeaders { for (n, v) in extraHeaders { headers.add(name: n, value: v) } } + appendEncryptionHeaders(&headers, encryption: encryption) head.headers = headers context.write(NIOAny(HTTPServerResponsePart.head(head)), promise: nil) context.flush() @@ -255,8 +300,7 @@ final class SSEResponseWriter: ResponseWriter { do { try encoder.encodeAndWrite(chunk, into: &buffer) buffer.writeString("\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } catch { // Log encoding error and close connection gracefully print("Error encoding SSE chunk: \(error)") @@ -279,23 +323,21 @@ final class SSEResponseWriter: ResponseWriter { ) try encoder.encodeAndWrite(err, into: &buffer) buffer.writeString("\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } catch { // As a last resort, send a minimal JSON error payload buffer.clear() buffer.writeString("data: {\"error\":{\"message\":\"") buffer.writeString(message) buffer.writeString("\",\"type\":\"internal_error\"}}\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } } func writeEnd(_ context: ChannelHandlerContext) { var tail = context.channel.allocator.buffer(capacity: 16) tail.writeString("data: [DONE]\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(tail))), promise: nil) + emitEvent(plaintext: tail, encryption: encryption, context: context) let ctx = NIOLoopBound(context, eventLoop: context.eventLoop) context.writeAndFlush(NIOAny(HTTPServerResponsePart.end(nil as HTTPHeaders?))).whenComplete { _ in @@ -307,6 +349,8 @@ final class SSEResponseWriter: ResponseWriter { /// lines start with `:` and are ignored by clients per the SSE spec, but /// they keep intermediate proxies / load balancers from idling out long /// tool/thinking pauses. Safe to call mid-stream. + /// Always plaintext: the comment carries no payload and skipping + /// encryption avoids burning AEAD counters on heartbeats. func writePing(_ context: ChannelHandlerContext) { var buf = context.channel.allocator.buffer(capacity: 16) buf.writeString(": ping\n\n") @@ -343,6 +387,8 @@ final class SSEResponseWriter: ResponseWriter { } final class NDJSONResponseWriter: ResponseWriter { + var encryption: HPKEServerContext? + func writeHeaders(_ context: ChannelHandlerContext, extraHeaders: [(String, String)]? = nil) { var head = HTTPResponseHead(version: .http1_1, status: .ok) var headers = HTTPHeaders() @@ -353,6 +399,7 @@ final class NDJSONResponseWriter: ResponseWriter { if let extraHeaders { for (n, v) in extraHeaders { headers.add(name: n, value: v) } } + appendEncryptionHeaders(&headers, encryption: encryption) head.headers = headers context.write(NIOAny(HTTPServerResponsePart.head(head)), promise: nil) context.flush() @@ -411,8 +458,7 @@ final class NDJSONResponseWriter: ResponseWriter { var buffer = context.channel.allocator.buffer(capacity: 256) buffer.writeBytes(jsonData) buffer.writeString("\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } } @@ -428,8 +474,7 @@ final class NDJSONResponseWriter: ResponseWriter { var buffer = context.channel.allocator.buffer(capacity: 256) buffer.writeBytes(jsonData) buffer.writeString("\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } } @@ -447,6 +492,7 @@ final class NDJSONResponseWriter: ResponseWriter { /// SSE Response Writer for Anthropic Messages API format /// Emits events: message_start, content_block_start, content_block_delta, content_block_stop, message_delta, message_stop final class AnthropicSSEResponseWriter { + var encryption: HPKEServerContext? private var messageId: String = "" private var model: String = "" private var inputTokens: Int = 0 @@ -466,6 +512,7 @@ final class AnthropicSSEResponseWriter { if let extraHeaders { for (n, v) in extraHeaders { headers.add(name: n, value: v) } } + appendEncryptionHeaders(&headers, encryption: encryption) head.headers = headers context.write(NIOAny(HTTPServerResponsePart.head(head)), promise: nil) context.flush() @@ -610,8 +657,7 @@ final class AnthropicSSEResponseWriter { do { try encoder.encodeAndWrite(error, into: &buffer) buffer.writeString("\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } catch { buffer.clear() buffer.writeString( @@ -619,8 +665,7 @@ final class AnthropicSSEResponseWriter { ) buffer.writeString(message) buffer.writeString("\"}}\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } } @@ -659,8 +704,7 @@ final class AnthropicSSEResponseWriter { do { try encoder.encodeAndWrite(payload, into: &buffer) buffer.writeString("\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } catch { print("Error encoding Anthropic SSE event: \(error)") context.close(promise: nil) @@ -673,6 +717,7 @@ final class AnthropicSSEResponseWriter { /// SSE Response Writer for Open Responses API format /// Emits semantic events: response.created, response.output_item.added, response.output_text.delta, etc. final class OpenResponsesSSEWriter { + var encryption: HPKEServerContext? private var responseId: String = "" private var model: String = "" private var inputTokens: Int = 0 @@ -702,6 +747,7 @@ final class OpenResponsesSSEWriter { if let extraHeaders { for (n, v) in extraHeaders { headers.add(name: n, value: v) } } + appendEncryptionHeaders(&headers, encryption: encryption) head.headers = headers context.write(NIOAny(HTTPServerResponsePart.head(head)), promise: nil) context.flush() @@ -1035,7 +1081,7 @@ final class OpenResponsesSSEWriter { func writeEnd(_ context: ChannelHandlerContext) { var tail = context.channel.allocator.buffer(capacity: 16) tail.writeString("data: [DONE]\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(tail))), promise: nil) + emitEvent(plaintext: tail, encryption: encryption, context: context) let ctx = NIOLoopBound(context, eventLoop: context.eventLoop) context.writeAndFlush(NIOAny(HTTPServerResponsePart.end(nil as HTTPHeaders?))).whenComplete { _ in @@ -1055,8 +1101,7 @@ final class OpenResponsesSSEWriter { do { try encoder.encodeAndWrite(payload, into: &buffer) buffer.writeString("\n\n") - context.write(NIOAny(HTTPServerResponsePart.body(.byteBuffer(buffer))), promise: nil) - context.flush() + emitEvent(plaintext: buffer, encryption: encryption, context: context) } catch { print("Error encoding Open Responses SSE event: \(error)") context.close(promise: nil) diff --git a/Packages/OsaurusCore/Models/Configuration/RemoteProviderConfiguration.swift b/Packages/OsaurusCore/Models/Configuration/RemoteProviderConfiguration.swift index cffadbe8f..4c9c802b2 100644 --- a/Packages/OsaurusCore/Models/Configuration/RemoteProviderConfiguration.swift +++ b/Packages/OsaurusCore/Models/Configuration/RemoteProviderConfiguration.swift @@ -97,10 +97,19 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable { /// Only used when providerType == .osaurus. public var remoteAgentAddress: String? + /// Base64url-encoded X25519 public key of the remote peer (HPKE recipient). + /// Captured from Bonjour TXT at pairing time; nil for peers that don't advertise one. + public var hpkePublicKeyB64: String? + + /// Wire identifier of the encryption suite the peer supports (e.g. "x25519-sha256-chachapoly"). + /// Must match `HPKEKeyStore.suiteIdentifier` to be usable. + public var hpkeSuite: String? + private enum CodingKeys: String, CodingKey { case id, name, host, providerProtocol, port, basePath case customHeaders, authType, providerType, enabled, autoConnect, timeout case secretHeaderKeys, remoteAgentId, remoteAgentAddress + case hpkePublicKeyB64, hpkeSuite } public init( @@ -118,7 +127,9 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable { timeout: TimeInterval = 60, secretHeaderKeys: [String] = [], remoteAgentId: UUID? = nil, - remoteAgentAddress: String? = nil + remoteAgentAddress: String? = nil, + hpkePublicKeyB64: String? = nil, + hpkeSuite: String? = nil ) { self.id = id self.name = name @@ -135,6 +146,8 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable { self.secretHeaderKeys = secretHeaderKeys self.remoteAgentId = remoteAgentId self.remoteAgentAddress = remoteAgentAddress + self.hpkePublicKeyB64 = hpkePublicKeyB64 + self.hpkeSuite = hpkeSuite } /// Custom decoder – uses `decodeIfPresent` for backward compatibility with older config files. @@ -157,8 +170,24 @@ public struct RemoteProvider: Codable, Identifiable, Sendable, Equatable { secretHeaderKeys = try container.decodeIfPresent([String].self, forKey: .secretHeaderKeys) ?? [] remoteAgentId = try container.decodeIfPresent(UUID.self, forKey: .remoteAgentId) remoteAgentAddress = try container.decodeIfPresent(String.self, forKey: .remoteAgentAddress) + hpkePublicKeyB64 = try container.decodeIfPresent(String.self, forKey: .hpkePublicKeyB64) + hpkeSuite = try container.decodeIfPresent(String.self, forKey: .hpkeSuite) } + /// Raw 32-byte X25519 public key, decoded from `hpkePublicKeyB64`, + /// usable only when `hpkeSuite` matches `HPKEKeyStore.suiteIdentifier`. + public var hpkePublicKey: Data? { + guard let b64 = hpkePublicKeyB64, + hpkeSuite == HPKEKeyStore.suiteIdentifier, + let data = Data(base64urlEncoded: b64), + data.count == 32 + else { return nil } + return data + } + + /// True when the provider has a usable encryption key on file. + public var supportsEncryption: Bool { hpkePublicKey != nil } + /// Get the effective port (uses protocol default if not specified) public var effectivePort: Int { port ?? providerProtocol.defaultPort diff --git a/Packages/OsaurusCore/Networking/BonjourAdvertiser.swift b/Packages/OsaurusCore/Networking/BonjourAdvertiser.swift index abdf143c9..4a5d1205a 100644 --- a/Packages/OsaurusCore/Networking/BonjourAdvertiser.swift +++ b/Packages/OsaurusCore/Networking/BonjourAdvertiser.swift @@ -101,6 +101,11 @@ public final class BonjourAdvertiser: NSObject { if let address = agent.agentAddress { fields["address"] = address.data(using: .utf8) } + // E2E encryption key — base64url(32B X25519 public). Process-scoped, + // regenerated each launch. Clients see this and switch to HPKE on + // any traffic to this agent (LAN or relay). + fields["hpke"] = HPKEKeyStore.shared.publicKeyEncoded.data(using: .utf8) + fields["hpke_suite"] = HPKEKeyStore.suiteIdentifier.data(using: .utf8) return NetService.data(fromTXTRecord: fields) } } diff --git a/Packages/OsaurusCore/Networking/BonjourBrowser.swift b/Packages/OsaurusCore/Networking/BonjourBrowser.swift index 61e4a9e69..dcb78b72a 100644 --- a/Packages/OsaurusCore/Networking/BonjourBrowser.swift +++ b/Packages/OsaurusCore/Networking/BonjourBrowser.swift @@ -21,6 +21,23 @@ public struct PairedRelayAgent: Identifiable, Equatable, Sendable { public let remoteAgentAddress: String /// The local provider ID used to connect to this agent. public let providerId: UUID + /// True when the backing provider has a usable HPKE public key on file + /// (captured at pairing time when the peer was on the local network). + public let supportsEncryption: Bool + + public init( + id: UUID, + name: String, + remoteAgentAddress: String, + providerId: UUID, + supportsEncryption: Bool = false + ) { + self.id = id + self.name = name + self.remoteAgentAddress = remoteAgentAddress + self.providerId = providerId + self.supportsEncryption = supportsEncryption + } } // MARK: - DiscoveredAgent @@ -33,9 +50,20 @@ public struct DiscoveredAgent: Identifiable, Equatable, Sendable { public let address: String? public let host: String? public let port: Int + /// Raw 32-byte X25519 public key (HPKE recipient) when the peer + /// published one in its TXT record. nil = unencrypted peer. + public let hpkePublicKey: Data? + /// Wire identifier of the encryption suite (must match + /// `HPKEKeyStore.suiteIdentifier` to be usable). nil = unencrypted. + public let hpkeSuite: String? /// Internal key that matches the NetService name for lookup/removal. internal let serviceName: String + + /// True when the peer published a usable HPKE key. + public var supportsEncryption: Bool { + hpkePublicKey != nil && hpkeSuite == HPKEKeyStore.suiteIdentifier + } } // MARK: - BonjourBrowser @@ -89,6 +117,11 @@ public final class BonjourBrowser: NSObject, ObservableObject { let desc = fields["description"].flatMap { String(data: $0, encoding: .utf8) } ?? "" let addr = fields["address"].flatMap { String(data: $0, encoding: .utf8) } + let hpkeSuite = fields["hpke_suite"].flatMap { String(data: $0, encoding: .utf8) } + let hpkePub = fields["hpke"] + .flatMap { String(data: $0, encoding: .utf8) } + .flatMap { Data(base64urlEncoded: $0) } + .flatMap { $0.count == 32 ? $0 : nil } let agent = DiscoveredAgent( id: agentId, @@ -97,6 +130,8 @@ public final class BonjourBrowser: NSObject, ObservableObject { address: addr, host: service.hostName, port: Int(service.port), + hpkePublicKey: hpkePub, + hpkeSuite: hpkeSuite, serviceName: service.name ) diff --git a/Packages/OsaurusCore/Networking/HPKEEncryption.swift b/Packages/OsaurusCore/Networking/HPKEEncryption.swift new file mode 100644 index 000000000..30d84b0b0 --- /dev/null +++ b/Packages/OsaurusCore/Networking/HPKEEncryption.swift @@ -0,0 +1,437 @@ +// +// HPKEEncryption.swift +// osaurus +// +// E2E request/response encryption between an Osaurus client and an +// Osaurus inference server, using HPKE (RFC 9180) with the +// DHKEM(X25519, HKDF-SHA256) / HKDF-SHA256 / ChaCha20-Poly1305 suite. +// +// Wire format: +// +// Request: +// X-Osaurus-Encryption: hpke;suite=x25519-sha256-chachapoly;v=1 +// X-Osaurus-Encapsulated-Key: +// X-Osaurus-Encryption-Nonce: +// X-Osaurus-Encryption-Timestamp: +// X-Osaurus-Body-Encoding: base64 +// Content-Type: application/octet-stream +// : base64(HPKE.seal(plaintext_body, info=requestInfo, aad=requestAAD)) +// +// Non-streaming response: +// X-Osaurus-Encryption: hpke;suite=...;v=1 +// X-Osaurus-Body-Encoding: base64 +// : base64(ChaChaPoly.seal(plaintext, key=K, nonce=N0)) +// +// Streaming (SSE) response: +// X-Osaurus-Encryption: hpke;suite=...;v=1;mode=stream +// Content-Type: text/event-stream +// Each event: data: :\n\n +// Plaintext keepalives (": ping\n\n") pass through unencrypted. +// +// Where K and N0 are derived from the same HPKE base context the request +// used, via exporters with distinct labels — meaning sender and recipient +// produce identical response keys without any extra round trip. +// +// Replay protection: AAD includes method, path, nonce, and timestamp. +// Servers reject requests whose timestamp is older than `replayWindow` +// or whose nonce has been seen within that window. +// + +import CryptoKit +import Foundation + +// MARK: - Header Constants + +public enum HPKEHeader { + public static let encryption = "X-Osaurus-Encryption" + public static let encapsulatedKey = "X-Osaurus-Encapsulated-Key" + public static let nonce = "X-Osaurus-Encryption-Nonce" + public static let timestamp = "X-Osaurus-Encryption-Timestamp" + public static let bodyEncoding = "X-Osaurus-Body-Encoding" + + public static let baseValue = "hpke;suite=\(HPKEKeyStore.suiteIdentifier);v=1" + public static let streamValue = "hpke;suite=\(HPKEKeyStore.suiteIdentifier);v=1;mode=stream" +} + +// MARK: - Errors + +public enum HPKEError: LocalizedError { + case unsupportedSuite(String) + case missingHeader(String) + case invalidEncodedKey + case invalidBodyEncoding + case timestampOutOfWindow + case replayedNonce + case openFailed + case sealFailed + case malformedStreamEvent + + public var errorDescription: String? { + switch self { + case .unsupportedSuite(let s): return "Unsupported encryption suite: \(s)" + case .missingHeader(let h): return "Missing required header \(h)" + case .invalidEncodedKey: return "Invalid encapsulated key encoding" + case .invalidBodyEncoding: return "Invalid body encoding" + case .timestampOutOfWindow: return "Encryption timestamp outside accepted window" + case .replayedNonce: return "Replayed encryption nonce" + case .openFailed: return "HPKE decryption failed" + case .sealFailed: return "HPKE encryption failed" + case .malformedStreamEvent: return "Malformed encrypted stream event" + } + } +} + +// MARK: - Info / Exporter Labels + +private enum HPKELabel { + static let requestInfo = Data("osaurus/req/v1".utf8) + static let responseKey = Data("osaurus/resp/v1/key".utf8) + static let responseNonce = Data("osaurus/resp/v1/nonce".utf8) +} + +// MARK: - Request AAD + +/// Builds the additional authenticated data covering the parts of a +/// request that the server has to trust to route correctly. Anyone +/// modifying method, path, nonce, or timestamp causes `HPKE.open` to +/// fail with an authentication error. +public func hpkeRequestAAD( + method: String, + path: String, + nonce: String, + timestamp: String +) -> Data { + Data("\(method.uppercased())\n\(path)\n\(nonce)\n\(timestamp)".utf8) +} + +// MARK: - Replay Cache + +/// Bounded LRU of recently-seen `(nonce, timestamp)` pairs. Used by the +/// server to reject requests replayed within `replayWindow`. +final class HPKEReplayCache: @unchecked Sendable { + static let shared = HPKEReplayCache() + + private let lock = NSLock() + private var seen: [String: Date] = [:] + private let capacity = 8192 + + /// Returns false if `nonce` was already used recently. + func observe(nonce: String, ttl: TimeInterval) -> Bool { + lock.lock() + defer { lock.unlock() } + + let now = Date() + if let prior = seen[nonce], now.timeIntervalSince(prior) < ttl { + return false + } + + // Cheap eviction: if we exceed capacity, drop entries older than ttl. + if seen.count >= capacity { + let cutoff = now.addingTimeInterval(-ttl) + seen = seen.filter { $0.value > cutoff } + } + + seen[nonce] = now + return true + } +} + +// MARK: - Server Side + +/// Per-request encryption context held by the server between request +/// decryption and response writing. Owns the HPKE recipient context so +/// the same exporter view is used for response symmetric key derivation. +public final class HPKEServerContext: @unchecked Sendable { + public let method: String + public let path: String + private var recipient: HPKE.Recipient + private let responseKey: SymmetricKey + private let responseNonceBase: Data // 12 bytes + private let responseLock = NSLock() + private var responseCounter: UInt64 = 0 + + fileprivate init( + method: String, + path: String, + recipient: HPKE.Recipient + ) throws { + self.method = method + self.path = path + self.recipient = recipient + + self.responseKey = try recipient.exportSecret( + context: HPKELabel.responseKey, + outputByteCount: 32 + ) + let nonceKey = try recipient.exportSecret( + context: HPKELabel.responseNonce, + outputByteCount: 12 + ) + self.responseNonceBase = nonceKey.withUnsafeBytes { Data($0) } + } + + /// Decrypt the request body. The HPKE recipient is single-shot for + /// requests — repeated calls advance the AEAD counter and would fail + /// to decrypt a body sealed at counter 0. + public func openRequestBody(_ ciphertext: Data, aad: Data) throws -> Data { + do { + return try recipient.open(ciphertext, authenticating: aad) + } catch { + throw HPKEError.openFailed + } + } + + /// Derive the AEAD nonce for response chunk index `i`. Lower 64 bits + /// of the 12-byte exported nonce base are XOR'd with `i` (little-endian). + /// Wraps the CryptoKit-only `ChaChaPoly.Nonce(data:)` throw as + /// `HPKEError.sealFailed` since reaching it would mean the exporter + /// returned the wrong size — a CryptoKit invariant, not a runtime + /// condition the caller can act on differently. + private func nonce(for counter: UInt64) throws -> ChaChaPoly.Nonce { + var bytes = [UInt8](responseNonceBase) + var c = counter.littleEndian + withUnsafeBytes(of: &c) { src in + for i in 0..<8 { bytes[i] ^= src[i] } + } + do { + return try ChaChaPoly.Nonce(data: Data(bytes)) + } catch { + throw HPKEError.sealFailed + } + } + + /// Seal a non-streaming response body. Uses counter 0; do not mix + /// with `sealStreamChunk`. + public func sealNonStreaming(_ plaintext: Data) throws -> Data { + let n = try nonce(for: 0) + let sealed = try ChaChaPoly.seal(plaintext, using: responseKey, nonce: n) + return sealed.combined + } + + /// Seal one SSE chunk (full event bytes including any trailing \n\n). + /// Returns `(counter, base64ciphertext)` ready to splice into a + /// `data: :\n\n` line. + public func sealStreamChunk(_ plaintext: Data) throws -> (counter: UInt64, base64: String) { + responseLock.lock() + let counter = responseCounter + responseCounter += 1 + responseLock.unlock() + + let n = try nonce(for: counter) + let sealed = try ChaChaPoly.seal(plaintext, using: responseKey, nonce: n) + return (counter, sealed.combined.base64urlEncoded) + } +} + +// MARK: - Server Entry Point + +public enum HPKEServerDecoder { + /// Inspect request headers and, if the request is encrypted, build a + /// `HPKEServerContext` and return the plaintext body. If the request + /// is not encrypted, returns nil. + /// + /// - Parameters: + /// - headerLookup: case-insensitive header lookup + /// - method: request method + /// - path: request path + /// - rawBody: HTTP body bytes as received (may be base64-encoded + /// ciphertext per `X-Osaurus-Body-Encoding`) + /// - replayWindow: maximum age of an accepted timestamp; nonces + /// are also de-duplicated within this window + public static func decodeIfNeeded( + headerLookup: (String) -> String?, + method: String, + path: String, + rawBody: Data, + replayWindow: TimeInterval = 60 + ) throws -> (context: HPKEServerContext, plaintextBody: Data)? { + guard let headerValue = headerLookup(HPKEHeader.encryption), !headerValue.isEmpty else { + return nil + } + + // Suite check + guard headerValue.contains("suite=\(HPKEKeyStore.suiteIdentifier)") else { + throw HPKEError.unsupportedSuite(headerValue) + } + + guard let encB64 = headerLookup(HPKEHeader.encapsulatedKey) else { + throw HPKEError.missingHeader(HPKEHeader.encapsulatedKey) + } + guard let encData = Data(base64urlEncoded: encB64) else { + throw HPKEError.invalidEncodedKey + } + + guard let nonce = headerLookup(HPKEHeader.nonce) else { + throw HPKEError.missingHeader(HPKEHeader.nonce) + } + guard let tsString = headerLookup(HPKEHeader.timestamp), + let ts = TimeInterval(tsString) + else { + throw HPKEError.missingHeader(HPKEHeader.timestamp) + } + + let now = Date().timeIntervalSince1970 + guard abs(now - ts) <= replayWindow else { + throw HPKEError.timestampOutOfWindow + } + guard HPKEReplayCache.shared.observe(nonce: nonce, ttl: replayWindow * 2) else { + throw HPKEError.replayedNonce + } + + // Body decoding + let bodyBytes: Data + if let encoding = headerLookup(HPKEHeader.bodyEncoding)?.lowercased(), encoding == "base64" { + let asString = String(decoding: rawBody, as: UTF8.self) + guard let decoded = Data(base64urlEncoded: asString) + ?? Data(base64Encoded: asString) + else { + throw HPKEError.invalidBodyEncoding + } + bodyBytes = decoded + } else { + bodyBytes = rawBody + } + + let recipient = try HPKE.Recipient( + privateKey: HPKEKeyStore.shared.privateKey, + ciphersuite: HPKEKeyStore.ciphersuite, + info: HPKELabel.requestInfo, + encapsulatedKey: encData + ) + + let context = try HPKEServerContext( + method: method, + path: path, + recipient: recipient + ) + let aad = hpkeRequestAAD(method: method, path: path, nonce: nonce, timestamp: tsString) + let plaintext = try context.openRequestBody(bodyBytes, aad: aad) + return (context, plaintext) + } +} + +// MARK: - Client Side + +/// Client-side encryption context. One instance per outbound request. +/// `requestHeaders` and `encryptedBody` produce the wire request; the +/// matching response symmetric state lives behind `decryptResponseBody` +/// and `decryptStreamChunk`. +public final class HPKEClientContext: @unchecked Sendable { + public let nonce: String + public let timestamp: String + public let method: String + public let path: String + public let encapsulatedKey: Data + + private let responseKey: SymmetricKey + + /// - Parameters: + /// - recipientPublicKey: 32-byte X25519 public key from the peer + /// - method: HTTP method + /// - path: request path (the relay-routed path, not the local path) + public init(recipientPublicKey: Data, method: String, path: String) throws { + guard recipientPublicKey.count == 32 else { + throw HPKEError.invalidEncodedKey + } + let pub = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: recipientPublicKey) + let sender = try HPKE.Sender( + recipientKey: pub, + ciphersuite: HPKEKeyStore.ciphersuite, + info: HPKELabel.requestInfo + ) + self.encapsulatedKey = sender.encapsulatedKey + + // Generate replay-protection metadata up front so the AAD is + // stable across `seal` and the eventual server-side `open`. + var nonceBytes = Data(count: 16) + _ = nonceBytes.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 16, ptr.baseAddress!) + } + self.nonce = nonceBytes.base64urlEncoded + self.timestamp = String(Int(Date().timeIntervalSince1970)) + self.method = method + self.path = path + + // Pre-derive the response AEAD key via the HPKE exporter (which + // doesn't advance the seal counter). The matching nonce is + // embedded in each `combined` ChaChaPoly box on the wire, so we + // don't need to derive a nonce base on this side. + self.responseKey = try sender.exportSecret( + context: HPKELabel.responseKey, + outputByteCount: 32 + ) + self._sender = sender + } + + /// Consumed on the first `sealRequestBody` call: a second seal would + /// land at AEAD counter 1 and fail to open server-side, so the API + /// surface forbids it instead of silently corrupting ciphertext. + private var _sender: HPKE.Sender? + + /// Seal the request body. Consumes the sender — to retry, build a + /// fresh `HPKEClientContext` (which also gets a fresh nonce + timestamp + /// for replay protection). + public func sealRequestBody(_ plaintext: Data) throws -> Data { + guard var sender = _sender else { throw HPKEError.sealFailed } + _sender = nil + let aad = hpkeRequestAAD(method: method, path: path, nonce: nonce, timestamp: timestamp) + do { + return try sender.seal(plaintext, authenticating: aad) + } catch { + throw HPKEError.sealFailed + } + } + + /// Headers to attach to the outgoing HTTP request. Caller still + /// needs to set `Content-Type: application/octet-stream` and + /// `Content-Length` based on the sealed-and-base64ed body. + public var requestHeaders: [String: String] { + [ + HPKEHeader.encryption: HPKEHeader.baseValue, + HPKEHeader.encapsulatedKey: encapsulatedKey.base64urlEncoded, + HPKEHeader.nonce: nonce, + HPKEHeader.timestamp: timestamp, + HPKEHeader.bodyEncoding: "base64", + ] + } + + /// Decrypt a non-streaming response body sealed via `sealNonStreaming`. + public func openResponseBody(_ ciphertext: Data) throws -> Data { + let box = try ChaChaPoly.SealedBox(combined: ciphertext) + do { + return try ChaChaPoly.open(box, using: responseKey) + } catch { + throw HPKEError.openFailed + } + } + + /// Decrypt a single SSE stream chunk encoded as + /// `:`. The counter is informational + /// (used by callers to detect dropped/reordered chunks); the nonce + /// is embedded in the combined ChaChaPoly output and tamper-evident + /// via the AEAD tag. + public func openStreamChunk(_ encoded: String) throws -> (counter: UInt64, plaintext: Data) { + guard let colon = encoded.firstIndex(of: ":") else { + throw HPKEError.malformedStreamEvent + } + guard let counter = UInt64(encoded[.. @@ -157,6 +161,59 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { let method = head.method.rawValue let userAgent = head.headers.first(name: "User-Agent") + // HPKE decrypt: if the client signaled encryption, open the + // body now and replace the buffered request body with + // plaintext so all downstream handlers stay encryption-blind. + // Failures here short-circuit the request with 400. Plaintext + // requests skip the entire branch so they don't pay for an + // extra body copy. + if head.headers.contains(name: HPKEHeader.encryption) { + var bodyData = Data() + if var b = stateRef.value.requestBodyBuffer { + bodyData = b.readData(length: b.readableBytes) ?? Data() + } + do { + if let decrypted = try HPKEServerDecoder.decodeIfNeeded( + headerLookup: { head.headers.first(name: $0) }, + method: method, + path: path, + rawBody: bodyData + ) { + var plaintextBuffer = context.channel.allocator.buffer( + capacity: decrypted.plaintextBody.count + ) + plaintextBuffer.writeBytes(decrypted.plaintextBody) + stateRef.value.requestBodyBuffer = plaintextBuffer + stateRef.value.encryptionContext = decrypted.context + } + } catch { + var headers = [("Content-Type", "application/json; charset=utf-8")] + headers.append(contentsOf: stateRef.value.corsHeaders) + let msg = (error as? LocalizedError)?.errorDescription ?? "Encryption error" + let body = #"{"error":{"message":"\#(msg)","type":"encryption_error"}}"# + sendResponse( + context: context, + version: head.version, + status: .badRequest, + headers: headers, + body: body + ) + logRequest( + method: method, + path: path, + userAgent: userAgent, + requestBody: nil, + responseBody: body, + responseStatus: 400, + startTime: startTime + ) + stateRef.value.requestHead = nil + stateRef.value.requestBodyBuffer = nil + stateRef.value.encryptionContext = nil + return + } + } + // Handle CORS preflight (OPTIONS) if head.method == .OPTIONS { let cors = computeCORSHeaders(for: head, isPreflight: true) @@ -417,6 +474,11 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { stateRef.value.requestHead = nil stateRef.value.requestBodyBuffer = nil + // Defensive: handlers that respond async snapshot the + // context locally, so clearing here is safe and stops a + // prior request's context from leaking into a later one if + // keep-alive is ever re-enabled. + stateRef.value.encryptionContext = nil } } @@ -1284,6 +1346,10 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { ) { let loop = context.eventLoop let ctx = NIOLoopBound(context, eventLoop: loop) + // Snapshot the encryption context now (on the event loop): the + // request handler may have already cleared per-request state by + // the time the executeOnLoop closure runs. + let encryption = stateRef.value.encryptionContext let bodyCopy = body let headersCopy = headers executeOnLoop(loop) { @@ -1291,14 +1357,28 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { // Create response head var responseHead = HTTPResponseHead(version: version, status: status) - // Create body buffer + // Build body — encrypted (binary, base64 on the wire) or + // plain text. Filter Content-Type out when encrypting since + // the original payload type is hidden inside the ciphertext. var buffer = context.channel.allocator.buffer(capacity: bodyCopy.utf8.count) - buffer.writeString(bodyCopy) - - // Build headers var nioHeaders = HTTPHeaders() - for (name, value) in headersCopy { - nioHeaders.add(name: name, value: value) + if let enc = encryption { + let plaintext = Data(bodyCopy.utf8) + let sealed = (try? enc.sealNonStreaming(plaintext)) ?? Data() + let encoded = sealed.base64urlEncoded + buffer.writeString(encoded) + + for (name, value) in headersCopy where name.lowercased() != "content-type" { + nioHeaders.add(name: name, value: value) + } + nioHeaders.add(name: "Content-Type", value: "application/octet-stream") + nioHeaders.add(name: HPKEHeader.encryption, value: HPKEHeader.baseValue) + nioHeaders.add(name: HPKEHeader.bodyEncoding, value: "base64") + } else { + buffer.writeString(bodyCopy) + for (name, value) in headersCopy { + nioHeaders.add(name: name, value: value) + } } nioHeaders.add(name: "Content-Length", value: String(buffer.readableBytes)) nioHeaders.add(name: "Connection", value: "close") @@ -2276,6 +2356,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { let cors = stateRef.value.corsHeaders let loop = context.eventLoop let writer = SSEResponseWriter() + writer.encryption = stateRef.value.encryptionContext let writerBound = NIOLoopBound(writer, eventLoop: loop) let ctx = NIOLoopBound(context, eventLoop: loop) let hop = Self.makeHop(channel: context.channel, loop: loop) @@ -3149,6 +3230,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { if wantsSSE { let writer = SSEResponseWriter() + writer.encryption = stateRef.value.encryptionContext let cors = stateRef.value.corsHeaders let loop = context.eventLoop let writerBound = NIOLoopBound(writer, eventLoop: loop) @@ -3582,6 +3664,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { } let writer = NDJSONResponseWriter() + writer.encryption = stateRef.value.encryptionContext let cors = stateRef.value.corsHeaders let loop = context.eventLoop let writerBound = NIOLoopBound(writer, eventLoop: loop) @@ -4579,6 +4662,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { requestBodyString: String? ) { let writer = AnthropicSSEResponseWriter() + writer.encryption = stateRef.value.encryptionContext let cors = stateRef.value.corsHeaders let loop = context.eventLoop let writerBound = NIOLoopBound(writer, eventLoop: loop) @@ -5191,6 +5275,7 @@ final class HTTPHandler: ChannelInboundHandler, Sendable { requestBodyString: String? ) { let writer = OpenResponsesSSEWriter() + writer.encryption = stateRef.value.encryptionContext let cors = stateRef.value.corsHeaders let loop = context.eventLoop let writerBound = NIOLoopBound(writer, eventLoop: loop) diff --git a/Packages/OsaurusCore/Networking/RelayTunnelManager.swift b/Packages/OsaurusCore/Networking/RelayTunnelManager.swift index 1e5e637ed..6f9b21d75 100644 --- a/Packages/OsaurusCore/Networking/RelayTunnelManager.swift +++ b/Packages/OsaurusCore/Networking/RelayTunnelManager.swift @@ -184,6 +184,11 @@ public final class RelayTunnelManager: ObservableObject { return } + // Opportunistically warm the HPKE keypair while we have the + // master key unlocked. Idempotent — covers the post-upgrade case + // where the keychain entry is empty even though identity exists. + HPKEKeyStore.shared.warmUp(masterKey: masterKey) + let session = URLSession(configuration: .default) let task = session.webSocketTask(with: Self.relayURL) self.urlSession = session diff --git a/Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift b/Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift index 6c068436d..4983176f6 100644 --- a/Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift +++ b/Packages/OsaurusCore/Services/Provider/RemoteProviderService.swift @@ -176,18 +176,25 @@ public actor RemoteProviderService: ToolCapableService { ) try await refreshCodexOAuthIfNeeded() - let (data, response) = try await session.data(for: try buildURLRequest(for: request)) + let outgoing = try buildOutgoingRequest(for: request) + let (data, response) = try await session.data(for: outgoing.request) guard let httpResponse = response as? HTTPURLResponse else { throw RemoteProviderServiceError.invalidResponse } + let plaintext = try Self.decryptResponseIfNeeded( + data: data, + response: httpResponse, + hpke: outgoing.encryption + ) + if httpResponse.statusCode >= 400 { - let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + let errorMessage = String(data: plaintext, encoding: .utf8) ?? "Unknown error" throw RemoteProviderServiceError.requestFailed("HTTP \(httpResponse.statusCode): \(errorMessage)") } - let (content, _) = try parseResponse(data) + let (content, _) = try parseResponse(plaintext) return content ?? "" } @@ -261,18 +268,25 @@ public actor RemoteProviderService: ToolCapableService { } try await refreshCodexOAuthIfNeeded() - let (data, response) = try await session.data(for: try buildURLRequest(for: request)) + let outgoing = try buildOutgoingRequest(for: request) + let (data, response) = try await session.data(for: outgoing.request) guard let httpResponse = response as? HTTPURLResponse else { throw RemoteProviderServiceError.invalidResponse } + let plaintext = try Self.decryptResponseIfNeeded( + data: data, + response: httpResponse, + hpke: outgoing.encryption + ) + if httpResponse.statusCode >= 400 { - let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + let errorMessage = String(data: plaintext, encoding: .utf8) ?? "Unknown error" throw RemoteProviderServiceError.requestFailed("HTTP \(httpResponse.statusCode): \(errorMessage)") } - let (content, toolCalls) = try parseResponse(data) + let (content, toolCalls) = try parseResponse(plaintext) // Check for tool calls if let toolCalls = toolCalls, let firstCall = toolCalls.first { @@ -529,6 +543,89 @@ public actor RemoteProviderService: ToolCapableService { } } + /// Wrap a byte stream with HPKE decryption. The server emits each + /// SSE event as `data: :\n\n` with the original + /// event bytes (which may themselves be multi-line) inside; plaintext + /// keepalives stay as `: ping\n\n`. Returns a chunk stream of the + /// recovered plaintext SSE bytes — drop-in replacement for + /// `makeChunkStream` so the existing line parser sees plaintext. + static func makeDecryptingChunkStream( + from bytes: URLSession.AsyncBytes, + hpke: HPKEClientContext + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let pumpTask = Task { + let separator = Data("\n\n".utf8) + let dataPrefix = Data("data: ".utf8) + let commentPrefix = Data(":".utf8) + var buffer = Data() + // Index in `buffer` from which the next \n\n search may + // start. Avoids the O(n²) cost of re-scanning bytes that + // were already known not to contain the separator. + var searchStart = 0 + do { + for try await byte in bytes { + if Task.isCancelled { break } + buffer.append(byte) + while let r = buffer.range( + of: separator, + in: searchStart.. Data { + guard let hpke, + let header = response.value(forHTTPHeaderField: HPKEHeader.encryption), + header.contains("hpke;") + else { + return data + } + let encoded = String(data: data, encoding: .utf8) ?? "" + guard let combined = Data(base64urlEncoded: encoded) ?? Data(base64Encoded: encoded) else { + throw HPKEError.invalidBodyEncoding + } + return try hpke.openResponseBody(combined) + } + /// Mutable holder for an `AsyncThrowingStream` iterator so it /// can be passed into escaping closures (which cannot capture `inout` /// parameters directly). Safe because the consumer is single-threaded. @@ -1147,7 +1244,9 @@ public actor RemoteProviderService: ToolCapableService { if !stopSequences.isEmpty { request.stop = stopSequences } try await refreshCodexOAuthIfNeeded() - let urlRequest = try buildURLRequest(for: request) + let outgoing = try buildOutgoingRequest(for: request) + let urlRequest = outgoing.request + let hpkeCtx = outgoing.encryption let currentSession = self.session let providerType = self.provider.providerType let inactivityTimeout = self.streamInactivityTimeout @@ -1181,7 +1280,12 @@ public actor RemoteProviderService: ToolCapableService { for try await byte in bytes { errorData.append(byte) } - let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error" + let plaintextError = (try? Self.decryptResponseIfNeeded( + data: errorData, + response: httpResponse, + hpke: hpkeCtx + )) ?? errorData + let errorMessage = String(data: plaintextError, encoding: .utf8) ?? "Unknown error" continuation.finish( throwing: RemoteProviderServiceError.requestFailed( "HTTP \(httpResponse.statusCode): \(errorMessage)" @@ -1197,7 +1301,15 @@ public actor RemoteProviderService: ToolCapableService { // chunk arrival — no intermediate AsyncStream layer. var sseEventData = "" var lineParser = SSELineParser() - let chunkStream = Self.makeChunkStream(from: bytes) + let chunkStream: AsyncThrowingStream + if let hpkeCtx, + let header = httpResponse.value(forHTTPHeaderField: HPKEHeader.encryption), + header.contains("hpke;") + { + chunkStream = Self.makeDecryptingChunkStream(from: bytes, hpke: hpkeCtx) + } else { + chunkStream = Self.makeChunkStream(from: bytes) + } let chunkIter = ChunkIteratorRef(chunkStream.makeAsyncIterator()) chunkLoop: while true { @@ -1612,22 +1724,28 @@ public actor RemoteProviderService: ToolCapableService { request.stop = stopSequences } - let urlRequest = try buildURLRequest(for: request) + let outgoing = try buildOutgoingRequest(for: request) let currentSession = self.session let (stream, continuation) = AsyncThrowingStream.makeStream() let producerTask = Task { do { - let (data, response) = try await currentSession.data(for: urlRequest) + let (data, response) = try await currentSession.data(for: outgoing.request) guard let httpResponse = response as? HTTPURLResponse else { continuation.finish(throwing: RemoteProviderServiceError.invalidResponse) return } + let plaintext = try Self.decryptResponseIfNeeded( + data: data, + response: httpResponse, + hpke: outgoing.encryption + ) + if httpResponse.statusCode >= 400 { - let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + let errorMessage = String(data: plaintext, encoding: .utf8) ?? "Unknown error" continuation.finish( throwing: RemoteProviderServiceError.requestFailed( "HTTP \(httpResponse.statusCode): \(errorMessage)" @@ -1638,7 +1756,7 @@ public actor RemoteProviderService: ToolCapableService { let geminiResponse = try JSONDecoder().decode( GeminiGenerateContentResponse.self, - from: data + from: plaintext ) if let parts = geminiResponse.candidates?.first?.content?.parts { @@ -1690,7 +1808,21 @@ public actor RemoteProviderService: ToolCapableService { } /// Build a URLRequest for the chat completions endpoint + /// Result of building a remote URL request. When `encryption` is + /// non-nil the request body has been HPKE-sealed and base64-encoded; + /// callers must run the corresponding response back through the + /// returned context (`openResponseBody` for non-streaming, the + /// decrypting chunk stream for SSE). + struct OutgoingRequest { + let request: URLRequest + let encryption: HPKEClientContext? + } + private func buildURLRequest(for request: RemoteChatRequest) throws -> URLRequest { + try buildOutgoingRequest(for: request).request + } + + private func buildOutgoingRequest(for request: RemoteChatRequest) throws -> OutgoingRequest { let url: URL if provider.providerType == .gemini { @@ -1808,8 +1940,31 @@ public actor RemoteProviderService: ToolCapableService { // Both providers consume the unmodified OpenAI-compatible body. bodyData = try encoder.encode(request) } + + // HPKE wrap: when the peer published an X25519 key, replace the + // plaintext body with the sealed-and-base64 form and stamp the + // request with the encryption headers. Path used in the AAD is + // the URL path *as the relay will forward it* — `url.path` matches + // what the inference server sees in `HTTPHandler.normalize(...)`. + if let recipientKey = provider.hpkePublicKey { + let path = url.path + (url.query.map { "?\($0)" } ?? "") + let hpke = try HPKEClientContext( + recipientPublicKey: recipientKey, + method: "POST", + path: path + ) + let sealed = try hpke.sealRequestBody(bodyData) + let b64 = sealed.base64urlEncoded + urlRequest.httpBody = Data(b64.utf8) + urlRequest.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + for (name, value) in hpke.requestHeaders { + urlRequest.setValue(value, forHTTPHeaderField: name) + } + return OutgoingRequest(request: urlRequest, encryption: hpke) + } + urlRequest.httpBody = bodyData - return urlRequest + return OutgoingRequest(request: urlRequest, encryption: nil) } /// Parse response based on provider type diff --git a/Packages/OsaurusCore/Tests/Networking/HPKEEncryptionTests.swift b/Packages/OsaurusCore/Tests/Networking/HPKEEncryptionTests.swift new file mode 100644 index 000000000..48a891955 --- /dev/null +++ b/Packages/OsaurusCore/Tests/Networking/HPKEEncryptionTests.swift @@ -0,0 +1,254 @@ +// +// HPKEEncryptionTests.swift +// osaurusTests +// +// Round-trip and tamper-resistance tests for the HPKE relay-encryption +// layer. Exercises the same client/server APIs that +// RemoteProviderService and HTTPHandler use in production. +// + +import CryptoKit +import Foundation +import Testing + +@testable import OsaurusCore + +// Tests share `HPKEKeyStore.shared` and `warmUp_isDeterministicAcrossCalls` +// rotates that singleton's keypair, so parallel execution would race +// against the round-trip tests. Run serially. +@Suite(.serialized) +struct HPKEEncryptionTests { + + // MARK: - Helpers + + /// Run the full server-side decode path against a wire-shaped header + /// dictionary and base64-encoded body. Mirrors what `HTTPHandler` + /// does at the top of `channelRead.end` for an inbound request. + private func decodeOnServer( + headers: [String: String], + method: String, + path: String, + rawBody: Data, + replayWindow: TimeInterval = 60 + ) throws -> (HPKEServerContext, Data)? { + let lookup: (String) -> String? = { name in + headers.first(where: { $0.key.caseInsensitiveCompare(name) == .orderedSame })?.value + } + guard let res = try HPKEServerDecoder.decodeIfNeeded( + headerLookup: lookup, + method: method, + path: path, + rawBody: rawBody, + replayWindow: replayWindow + ) else { return nil } + return (res.context, res.plaintextBody) + } + + // MARK: - Tests + + @Test func requestRoundTrip_recoversPlaintext() throws { + let recipientPub = HPKEKeyStore.shared.publicKeyBytes + let body = Data(""" + {"model":"foo","messages":[{"role":"user","content":"ping"}]} + """.utf8) + + let client = try HPKEClientContext( + recipientPublicKey: recipientPub, + method: "POST", + path: "/v1/chat/completions" + ) + let sealed = try client.sealRequestBody(body) + let wireBody = Data(sealed.base64urlEncoded.utf8) + + let result = try decodeOnServer( + headers: client.requestHeaders, + method: client.method, + path: client.path, + rawBody: wireBody + ) + let unwrapped = try #require(result) + #expect(unwrapped.1 == body) + } + + @Test func tamperedAAD_failsToOpen() throws { + let recipientPub = HPKEKeyStore.shared.publicKeyBytes + let body = Data("hello".utf8) + + let client = try HPKEClientContext( + recipientPublicKey: recipientPub, + method: "POST", + path: "/v1/chat/completions" + ) + let sealed = try client.sealRequestBody(body) + let wireBody = Data(sealed.base64urlEncoded.utf8) + + // Server sees a different path than the client signed for. + #expect(throws: HPKEError.self) { + try self.decodeOnServer( + headers: client.requestHeaders, + method: client.method, + path: "/some/other/path", + rawBody: wireBody + ) + } + } + + @Test func staleTimestamp_isRejected() throws { + let recipientPub = HPKEKeyStore.shared.publicKeyBytes + + let client = try HPKEClientContext( + recipientPublicKey: recipientPub, + method: "POST", + path: "/x" + ) + var headers = client.requestHeaders + // Set timestamp far enough in the past to fall outside the window. + headers[HPKEHeader.timestamp] = String(Int(Date().timeIntervalSince1970) - 300) + + let sealed = try client.sealRequestBody(Data("body".utf8)) + let wireBody = Data(sealed.base64urlEncoded.utf8) + + let thrown = #expect(throws: HPKEError.self) { + try self.decodeOnServer( + headers: headers, + method: client.method, + path: client.path, + rawBody: wireBody, + replayWindow: 60 + ) + } + if case .timestampOutOfWindow = thrown { } else { + Issue.record("expected timestampOutOfWindow, got \(String(describing: thrown))") + } + } + + @Test func nonStreamingResponse_roundTrips() throws { + let recipientPub = HPKEKeyStore.shared.publicKeyBytes + let client = try HPKEClientContext( + recipientPublicKey: recipientPub, + method: "POST", + path: "/v1/chat/completions" + ) + let reqBody = Data("ping".utf8) + let sealed = try client.sealRequestBody(reqBody) + let wireBody = Data(sealed.base64urlEncoded.utf8) + + let unwrapped = try #require(try decodeOnServer( + headers: client.requestHeaders, + method: client.method, + path: client.path, + rawBody: wireBody + )) + let serverCtx = unwrapped.0 + + // Server-side response sealing + let respPlain = Data(""" + {"choices":[{"message":{"role":"assistant","content":"pong"}}]} + """.utf8) + let sealedResp = try serverCtx.sealNonStreaming(respPlain) + + // Client opens + let opened = try client.openResponseBody(sealedResp) + #expect(opened == respPlain) + } + + @Test func streamChunks_preserveOrderAndDecryptIndependently() throws { + let recipientPub = HPKEKeyStore.shared.publicKeyBytes + let client = try HPKEClientContext( + recipientPublicKey: recipientPub, + method: "POST", + path: "/v1/chat/completions" + ) + let sealed = try client.sealRequestBody(Data("req".utf8)) + let wireBody = Data(sealed.base64urlEncoded.utf8) + + let unwrapped = try #require(try decodeOnServer( + headers: client.requestHeaders, + method: client.method, + path: client.path, + rawBody: wireBody + )) + let serverCtx = unwrapped.0 + + // Several SSE events sealed in order. + let events: [Data] = [ + Data("data: {\"delta\":\"a\"}\n\n".utf8), + Data("data: {\"delta\":\"bc\"}\n\n".utf8), + Data("event: foo\ndata: {\"x\":1}\n\n".utf8), + Data("data: [DONE]\n\n".utf8), + ] + var encoded: [(UInt64, String)] = [] + for e in events { + encoded.append(try serverCtx.sealStreamChunk(e)) + } + + // Counters are monotonic. + #expect(encoded.map { $0.0 } == [0, 1, 2, 3]) + + // Client opens each one independently and gets the original bytes back. + for (i, (counter, b64)) in encoded.enumerated() { + let opened = try client.openStreamChunk("\(counter):\(b64)") + #expect(opened.counter == UInt64(i)) + #expect(opened.plaintext == events[i]) + } + } + + @Test func warmUp_isDeterministicAcrossCalls() throws { + let store = HPKEKeyStore.shared + // Reset so we don't read a leftover key from another test. + store.reset() + + // Two distinct 32-byte master-key candidates. + var ms1 = Data(count: 32) + var ms2 = Data(count: 32) + _ = ms1.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) + } + _ = ms2.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) + } + + store.warmUp(masterKey: ms1) + let pub1 = store.publicKeyBytes + + // Re-warming with the same master key reproduces the same public key. + store.warmUp(masterKey: ms1) + let pub1again = store.publicKeyBytes + #expect(pub1 == pub1again) + + // Different master key → different public key. + store.warmUp(masterKey: ms2) + let pub2 = store.publicKeyBytes + #expect(pub1 != pub2) + + // Reset returns the store to ephemeral mode. + store.reset() + } + + @Test func keyMismatch_failsToOpen() throws { + // Client uses a randomly-chosen recipient pub key that doesn't + // match any server's private key. + let foreign = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation + let client = try HPKEClientContext( + recipientPublicKey: foreign, + method: "POST", + path: "/x" + ) + let sealed = try client.sealRequestBody(Data("hi".utf8)) + let wireBody = Data(sealed.base64urlEncoded.utf8) + + // The shared HPKEKeyStore has its own keypair; encapsulation + // built for `foreign` will not yield a context that can open. + let thrown = #expect(throws: HPKEError.self) { + try self.decodeOnServer( + headers: client.requestHeaders, + method: client.method, + path: client.path, + rawBody: wireBody + ) + } + if case .openFailed = thrown { } else { + Issue.record("expected openFailed, got \(String(describing: thrown))") + } + } +} diff --git a/Packages/OsaurusCore/Views/Chat/ChatView.swift b/Packages/OsaurusCore/Views/Chat/ChatView.swift index 1d1d12d87..5dd60a81b 100644 --- a/Packages/OsaurusCore/Views/Chat/ChatView.swift +++ b/Packages/OsaurusCore/Views/Chat/ChatView.swift @@ -2364,6 +2364,9 @@ struct ChatView: View { let host = rawHost.hasSuffix(".") ? String(rawHost.dropLast()) : rawHost let manager = RemoteProviderManager.shared + let hpkeB64 = agent.hpkePublicKey?.base64urlEncoded + let hpkeSuite = agent.hpkeSuite + let providerId: UUID // Reuse an existing Osaurus provider that already targets the same agent if let existing = manager.configuration.providers.first(where: { @@ -2376,6 +2379,11 @@ struct ChatView: View { updated.port = agent.port updated.enabled = true if let address = agent.address { updated.remoteAgentAddress = address } + // Refresh the encryption key on every reconnection — the + // remote process regenerates X25519 each launch, so a stale + // saved key would prevent every encrypted request. + updated.hpkePublicKeyB64 = hpkeB64 + updated.hpkeSuite = hpkeSuite if !token.isEmpty { updated.authType = .apiKey manager.updateProvider(updated, apiKey: token) @@ -2396,7 +2404,9 @@ struct ChatView: View { enabled: true, autoConnect: true, remoteAgentId: agent.id, - remoteAgentAddress: agent.address + remoteAgentAddress: agent.address, + hpkePublicKeyB64: hpkeB64, + hpkeSuite: hpkeSuite ) providerId = provider.id manager.addProvider(provider, apiKey: token.isEmpty ? nil : token, isEphemeral: isEphemeral) diff --git a/Packages/OsaurusCore/Views/Common/SharedHeaderComponents.swift b/Packages/OsaurusCore/Views/Common/SharedHeaderComponents.swift index 9524c5c3e..4ebb8eaac 100644 --- a/Packages/OsaurusCore/Views/Common/SharedHeaderComponents.swift +++ b/Packages/OsaurusCore/Views/Common/SharedHeaderComponents.swift @@ -141,9 +141,36 @@ struct AgentPill: View { var parts = [agent.name] if let host = agent.host { parts.append("(\(shortHost(host)))") } if !agent.agentDescription.isEmpty { parts.append("– \(agent.agentDescription)") } + if !agent.supportsEncryption { parts.append("· unencrypted") } return parts.joined(separator: " ") } + /// Lock-flavored icon for an encrypted peer; plain network/antenna + /// icon for an unencrypted one. The active row shows a checkmark + /// instead so the selection state remains the dominant signal. + private func icon(for remote: DiscoveredAgent) -> String { + if activeDiscoveredAgent?.id == remote.id { return "checkmark" } + return remote.supportsEncryption ? "lock.shield" : "network" + } + + private func icon(for relay: PairedRelayAgent) -> String { + if activeRelayAgent?.id == relay.id { return "checkmark" } + return relay.supportsEncryption ? "lock.shield" : "antenna.radiowaves.left.and.right" + } + + /// Short status string appended to the active selection in the pill + /// label so the user sees `(end-to-end encrypted)` or `(unencrypted)` + /// without opening the menu. + private var encryptionBadge: String? { + if let r = activeDiscoveredAgent { + return r.supportsEncryption ? "end-to-end encrypted" : "unencrypted" + } + if let r = activeRelayAgent { + return r.supportsEncryption ? "end-to-end encrypted" : "unencrypted" + } + return nil + } + private var displayName: String { if let relay = activeRelayAgent { return relay.name } guard let discovered = activeDiscoveredAgent else { return activeAgent.name } @@ -177,10 +204,7 @@ struct AgentPill: View { Section { ForEach(discoveredAgents) { remote in Button(action: { onSelectDiscoveredAgent?(remote) }) { - Label( - label(for: remote), - systemImage: activeDiscoveredAgent?.id == remote.id ? "checkmark" : "network" - ) + Label(label(for: remote), systemImage: icon(for: remote)) } } } header: { @@ -194,9 +218,10 @@ struct AgentPill: View { ForEach(pairedRelayAgents) { relay in Button(action: { onSelectRelayAgent?(relay) }) { Label( - relay.name, - systemImage: activeRelayAgent?.id == relay.id - ? "checkmark" : "antenna.radiowaves.left.and.right" + relay.supportsEncryption + ? relay.name + : "\(relay.name) · unencrypted", + systemImage: icon(for: relay) ) } } @@ -218,9 +243,19 @@ struct AgentPill: View { } } label: { HStack(spacing: 6) { - Image(systemName: isRemoteActive ? "network" : "person.fill") + let pillIcon: String = { + if let r = activeDiscoveredAgent { + return r.supportsEncryption ? "lock.shield" : "network" + } + if let r = activeRelayAgent { + return r.supportsEncryption ? "lock.shield" : "antenna.radiowaves.left.and.right" + } + return "person.fill" + }() + Image(systemName: pillIcon) .font(.system(size: 12, weight: .medium)) .foregroundColor(isHovered ? theme.accentColor : theme.secondaryText) + .help(encryptionBadge ?? "") Text(displayName) .font(theme.font(size: CGFloat(theme.bodySize), weight: .medium))