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>
96 lines
4.2 KiB
Swift
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"
|
|
}
|
|
}
|
|
}
|