mana-swift-event-sync/Sources/ManaEventSync/Crypto/ScopeVaultClient.swift
Till bfc1ea5310 feat: Per-Mandant-Crypto-Shredding (A3) — ScopedCryptoProvider (v0.6.0)
CryptoContext + optionale context-Varianten (Default-Extension → bestehende
Provider unverändert); ScopeVaultClient; ScopedCryptoProvider (Actor,
scopeResolver, Sub-Key-Cache, Master-Fallback, shredded→wirft);
createScopedKeyProviderFromVault; EventSyncConfig.scopeResolver → Engine baut
Scoped-Provider, Context an emit/pull/reencrypt. AES-GCM-seal/open geteilt.
31 Tests grün, abwärtskompatibel. Parität zu @mana/event-sync 0.7.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:40:52 +02:00

96 lines
4.2 KiB
Swift

import Foundation
import ManaCore
/// Holt/mintet/schreddert Per-Scope-Sub-Keys an den mana-auth-Routen
/// `/api/v1/me/encryption-vault/scopes/:appId/:scopeId/key` (Crypto-Shredding,
/// A3 — gespiegelt aus `@mana/event-sync` `crypto/scope-vault-client.ts`).
actor ScopeVaultClient {
private let auth: AuthClient
private let authBaseURL: URL
init(auth: AuthClient, authBaseURL: URL) {
self.auth = auth
self.authBaseURL = authBaseURL
}
/// Mint-or-get (Encrypt-Pfad). Wirft `scopeShredded` bei Tombstone (410).
func getOrMint(appId: String, scopeId: String) async throws -> Data {
try await call(method: "POST", appId: appId, scopeId: scopeId)
}
/// Fetch (Decrypt-Pfad). 404 → `scopeNotFound`, 410 → `scopeShredded`.
func get(appId: String, scopeId: String) async throws -> Data {
try await call(method: "GET", appId: appId, scopeId: scopeId)
}
/// Schreddert den Sub-Key (sofort) oder plant ihn (`shredAfter`, Firma-Legal-Hold).
func shred(appId: String, scopeId: String, shredAfter: Date? = nil) async throws {
let token = try await auth.freshAccessToken()
var req = URLRequest(url: url(appId: appId, scopeId: scopeId))
req.httpMethod = "DELETE"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
if let shredAfter {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let iso = ISO8601DateFormatter().string(from: shredAfter)
req.httpBody = try JSONEncoder().encode(["shredAfter": iso])
}
let (_, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, (200 ..< 300).contains(http.statusCode) else {
throw ScopeVaultError.shredFailed
}
}
private func url(appId: String, scopeId: String) -> URL {
let enc: (String) -> String = { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 }
return authBaseURL
.appendingPathComponent("api/v1/me/encryption-vault/scopes")
.appendingPathComponent(enc(appId))
.appendingPathComponent(enc(scopeId))
.appendingPathComponent("key")
}
private func call(method: String, appId: String, scopeId: String) async throws -> Data {
let token = try await auth.freshAccessToken()
var req = URLRequest(url: url(appId: appId, scopeId: scopeId))
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 ScopeVaultError.invalidResponse
}
if http.statusCode == 404 { throw ScopeVaultError.scopeNotFound(scopeId) }
if http.statusCode == 410 { throw ScopeVaultError.scopeShredded(scopeId) }
guard (200 ..< 300).contains(http.statusCode) else {
throw ScopeVaultError.httpFailure(status: http.statusCode)
}
struct Body: Decodable { let subKey: String? }
let parsed = try JSONDecoder().decode(Body.self, from: data)
guard let b64 = parsed.subKey, let bytes = Data(base64Encoded: b64), bytes.count == 32 else {
throw ScopeVaultError.malformedResponse
}
return bytes
}
}
enum ScopeVaultError: LocalizedError {
case invalidResponse
case httpFailure(status: Int)
case malformedResponse
case scopeNotFound(String)
case scopeShredded(String)
case shredFailed
var errorDescription: String? {
switch self {
case .invalidResponse: "Scope-Vault: kein HTTP-Response"
case let .httpFailure(status): "Scope-Vault → HTTP \(status)"
case .malformedResponse: "Scope-Vault: malformed JSON / Key-Größe"
case let .scopeNotFound(s): "Scope-Key nicht gefunden: \(s)"
case let .scopeShredded(s): "Scope-Key geschreddert (gone): \(s)"
case .shredFailed: "Scope-Shred fehlgeschlagen"
}
}
}