mana-swift-event-sync/Sources/ManaEventSync/Crypto/VaultClient.swift
Till JS 3be550a154 feat: ManaEventSync E-4.0–E-4.2 — geteilte native Event-Sync-Lib
Extrahiert die zuvor zweimal von Hand gebaute Plumbing aus pageta-native
+ nutriphi-native in ein Paket ManaEventSync (Counterpart zu @mana/event-sync):

- Core: EventEnvelope (+clientId/payloadHash), JSONValue, ULID, JWTSubject, EventSyncError
- EventStorage (SwiftData): parametrierbar (Store/App-Group/additionalModels/In-Memory),
  Outbox, Claim-Reattribute, Anonymous-Retention-Prune
- Transport: SyncHTTPClient (+X-Schema-Hash/422), optionaler SyncWSClient
- Crypto: CryptoProvider/NoOp/MasterKey (AES-GCM enc:1:), VaultClient — opt-in
- EventSyncEngine: besitzt die Identität (makeEnvelope anon/echt), emit offline,
  signIn(realUserId) re-taggt idempotent + drained, Retention 10k/90d

18 Tests grün. Plan: mana/docs/playbooks/MANA_SWIFT_EVENT_SYNC.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:39:59 +02:00

108 lines
4.1 KiB
Swift

import Foundation
import ManaCore
/// Fetcht den Master-Key aus dem mana-auth Encryption-Vault.
///
/// Wire-API (siehe `mana/services/mana-auth/.../encryption-vault.ts`,
/// gespiegelt aus `@mana/event-sync` `crypto/vault-client.ts`):
/// - `GET /api/v1/me/encryption-vault/key`
/// - 200 Standard: `{ masterKey: <b64-32>, formatVersion, kekId }`
/// - 200 ZK: `{ requiresRecoveryCode: true }` → wir werfen (kein Recovery in v1)
/// - 404: vault nicht initialisiert → automatisch `POST /init`
/// - `POST /api/v1/me/encryption-vault/init` — idempotent
actor VaultClient {
private let auth: AuthClient
private let authBaseURL: URL
init(auth: AuthClient, authBaseURL: URL) {
self.auth = auth
self.authBaseURL = authBaseURL
}
struct StandardKey {
let masterKeyBytes: Data
let formatVersion: Int
let kekId: String
}
enum FetchResult {
case standard(StandardKey)
case zeroKnowledge
}
func fetchMasterKey() async throws -> FetchResult {
do {
return try await callKeyEndpoint(method: "GET", path: "/key")
} catch let VaultError.httpFailure(status, _, _) where status == 404 {
return try await callKeyEndpoint(method: "POST", path: "/init")
}
}
private func callKeyEndpoint(method: String, path: String) async throws -> FetchResult {
let token = try await auth.freshAccessToken()
let url = authBaseURL.appendingPathComponent("api/v1/me/encryption-vault\(path)")
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw VaultError.invalidResponse
}
guard (200 ..< 300).contains(http.statusCode) else {
let body = String(data: data, encoding: .utf8) ?? "<binary>"
throw VaultError.httpFailure(status: http.statusCode, path: path, body: body)
}
struct VaultBody: Decodable {
let masterKey: String?
let formatVersion: Int?
let kekId: String?
let requiresRecoveryCode: Bool?
}
let parsed = try JSONDecoder().decode(VaultBody.self, from: data)
if parsed.requiresRecoveryCode == true {
return .zeroKnowledge
}
guard let b64 = parsed.masterKey, let bytes = Data(base64Encoded: b64) else {
throw VaultError.malformedResponse
}
return .standard(StandardKey(
masterKeyBytes: bytes,
formatVersion: parsed.formatVersion ?? 1,
kekId: parsed.kekId ?? ""
))
}
}
enum VaultError: LocalizedError {
case invalidResponse
case httpFailure(status: Int, path: String, body: String)
case malformedResponse
case zeroKnowledgeNotSupported
var errorDescription: String? {
switch self {
case .invalidResponse: "Vault: kein HTTP-Response"
case let .httpFailure(status, path, _): "Vault \(path) → HTTP \(status)"
case .malformedResponse: "Vault: malformed JSON"
case .zeroKnowledgeNotSupported: "Vault im ZK-Modus — Recovery-Code-Flow nicht implementiert"
}
}
}
/// Vault-Key fetchen + `MasterKeyCryptoProvider` bauen. Wirft bei
/// ZK-Mode — Caller fällt auf `NoOpCryptoProvider` zurück.
func createMasterKeyProviderFromVault(auth: AuthClient, authBaseURL: URL) async throws -> MasterKeyCryptoProvider {
let client = VaultClient(auth: auth, authBaseURL: authBaseURL)
switch try await client.fetchMasterKey() {
case .zeroKnowledge:
throw VaultError.zeroKnowledgeNotSupported
case let .standard(key):
let providerId = key.kekId.isEmpty
? "master-key-v\(key.formatVersion)"
: "master-key-v\(key.formatVersion):kek-\(key.kekId)"
return try MasterKeyCryptoProvider(masterKeyBytes: key.masterKeyBytes, providerId: providerId)
}
}