mana-swift-llm/Sources/ManaLLM/Byok/ByokKeyVault.swift
till fa79a55fe6 feat(byok): native Bring Your Own Key — device-direct, key never touches mana
Symmetric to the web BYOK backend (@mana/browser-llm) and @mana/byok-providers.
A new LLMBackendID.byok lets the user bring their own third-party API key
(OpenAI/Anthropic/Gemini/Mistral); the call streams device-direct to the
provider via URLSession — the key never reaches mana infrastructure.

New Sources/ManaLLM/Byok/:
- ByokTypes: ByokProviderID, ByokSelection, ByokMessage, ByokKeyResolver,
  ByokProvider protocol, ByokError.
- ByokProviders: 4 URLSession SSE adapters (OpenAI/Mistral share an
  OpenAI-compat path; Anthropic + Gemini have their own schemas).
- ByokKeyVault: Keychain store — deliberately app-private, NOT the shared
  group.ev.mana.session; no cross-device sync. makeResolver(…) helper.
- ByokBackend: LLMBackend conformance; unavailable without a resolver;
  prompt/key never logged (PII/secret).

LLMRouter: setByokResolver / setAllowByokInPick. BYOK is never picked
silently (privacy discipline, mirror of web) — reachable via
backend(for: .byok). LLMBackendID.byok.isOnDeviceLLM == false.

Breaking for apps: LLMBackendID has a new .byok case — exhaustive switches
(e.g. settings UI) must handle it.

swift build + swift test green (23 tests, 11 new — network/keychain-free:
provider registry, availability gating, router pick discipline).
Compliance rule (sensitive content not via BYOK by default) in
mana/docs/COMPLIANCE.md §5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:49:37 +02:00

114 lines
4.3 KiB
Swift

import Foundation
import Security
/// Speichert BYOK-Schlüssel in der **Keychain** — verschlüsselt, gerät-
/// lokal, app-privat.
///
/// **Compliance-Invariante:** BYOK-Keys gehören NICHT in die geteilte
/// `group.ev.mana.session`-Keychain-Gruppe. Fremde Provider-Secrets sind
/// sensibler als die mana-Session; sie bleiben pro App (oder in einer
/// eigenen, bewusst gesetzten Access-Group). Default = app-privat, keine
/// Gruppe. Keine Cross-Device-Synchronisation (`kSecAttrSynchronizable`
/// bleibt false) — der Nutzer trägt pro Gerät ein.
///
/// Ein Eintrag pro Provider (`account = provider.rawValue`), Wert ist
/// die JSON-kodierte Auswahl (Key + Modell).
public struct ByokKeyVault: Sendable {
private let service: String
private let accessGroup: String?
/// - Parameters:
/// - service: Keychain-Service-Bezeichner. Default `ev.mana.byok`.
/// - accessGroup: Optionale Keychain-Access-Group. **Nicht** die
/// Session-Gruppe verwenden (s.o.). Default `nil` = app-privat.
public init(service: String = "ev.mana.byok", accessGroup: String? = nil) {
self.service = service
self.accessGroup = accessGroup
}
private struct Stored: Codable {
let model: String
let apiKey: String
}
/// Legt den Schlüssel für einen Provider ab (überschreibt vorhandenen).
public func save(_ selection: ByokSelection) throws {
let payload = try JSONEncoder().encode(Stored(model: selection.model, apiKey: selection.apiKey))
var query = baseQuery(account: selection.provider.rawValue)
// Erst löschen, dann neu hinzufügen — idempotent über Updates hinweg.
SecItemDelete(query as CFDictionary)
query[kSecValueData as String] = payload
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else { throw vaultError(status) }
}
/// Lädt die Auswahl für einen Provider, oder `nil` wenn keiner da ist.
public func load(_ provider: ByokProviderID) -> ByokSelection? {
var query = baseQuery(account: provider.rawValue)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
let data = item as? Data,
let stored = try? JSONDecoder().decode(Stored.self, from: data)
else { return nil }
return ByokSelection(provider: provider, apiKey: stored.apiKey, model: stored.model)
}
/// Provider, für die ein Schlüssel hinterlegt ist.
public func storedProviders() -> [ByokProviderID] {
ByokProviderID.allCases.filter { load($0) != nil }
}
/// Entfernt den Schlüssel eines Providers.
public func delete(_ provider: ByokProviderID) {
SecItemDelete(baseQuery(account: provider.rawValue) as CFDictionary)
}
/// Entfernt alle BYOK-Schlüssel.
public func deleteAll() {
for p in ByokProviderID.allCases { delete(p) }
}
private func baseQuery(account: String) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
if let accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
private func vaultError(_ status: OSStatus) -> NSError {
let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain-Fehler \(status)"
return NSError(domain: "ev.mana.byok.vault", code: Int(status), userInfo: [NSLocalizedDescriptionKey: message])
}
}
public extension ByokKeyVault {
/// Bequemer Resolver für `LLMRouter` / `ByokBackend`: nimmt den
/// gewünschten Provider (oder einen Default), liest aus der Keychain.
///
/// ```swift
/// let vault = ByokKeyVault()
/// await router.setByokResolver(vault.makeResolver(defaultProvider: .anthropic))
/// ```
func makeResolver(defaultProvider: ByokProviderID? = nil) -> ByokKeyResolver {
// `self` ist Sendable (immutable) — Capture ist Swift-6-safe.
let vault = self
return { preferred in
if let preferred, let hit = vault.load(preferred) { return hit }
if let defaultProvider, let hit = vault.load(defaultProvider) { return hit }
// Sonst: erster hinterlegter Provider.
for p in ByokProviderID.allCases {
if let hit = vault.load(p) { return hit }
}
return nil
}
}
}