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>
108 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|