mana-swift-event-sync/Sources/ManaEventSync/Crypto/ScopedCryptoProvider.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

94 lines
3.9 KiB
Swift

import CryptoKit
import Foundation
import ManaCore
/// Per-Scope-Sub-Key-Verschlüsselung für Crypto-Shredding (A3 — gespiegelt aus
/// `@mana/event-sync` `crypto/scoped-provider.ts`). Jeder Scope (z.B. ein
/// saldo-Mandant) wird mit einem eigenen Sub-Key versiegelt; den Sub-Key zu
/// vernichten macht dessen Events unentschlüsselbar.
///
/// - `scopeResolver` bildet eine aggregateId auf eine scopeId ab (z.B.
/// `buchung:<mandantId>:<id>` → `mandant:<mandantId>`); `nil` → Master-Key.
/// - Sub-Keys kommen vom ``ScopeVaultClient`` und werden pro Scope gecacht.
/// - Decrypt versucht erst den Scope-Key; ist der Scope geschreddert (410),
/// wird NICHT zurückgefallen (Event ist tot). Sonst Master-Fallback
/// (Alt-Events vor dem Cutover).
public actor ScopedCryptoProvider: CryptoProvider {
public nonisolated let providerId: String
private let appId: String
private let master: any CryptoProvider
private let scopeVault: ScopeVaultClient
private let scopeResolver: @Sendable (String) -> String?
private var cache: [String: SymmetricKey] = [:]
init(
appId: String,
master: any CryptoProvider,
scopeVault: ScopeVaultClient,
scopeResolver: @escaping @Sendable (String) -> String?
) {
self.appId = appId
self.master = master
self.scopeVault = scopeVault
self.scopeResolver = scopeResolver
providerId = "\(master.providerId):scoped"
}
// Ohne Kontext kein Scope → Master-Verhalten.
public func encryptPayload(_ payload: JSONValue) async throws -> JSONValue {
try await master.encryptPayload(payload)
}
public func decryptPayload(_ payload: JSONValue) async throws -> JSONValue {
try await master.decryptPayload(payload)
}
public func encryptPayload(_ payload: JSONValue, context: CryptoContext?) async throws -> JSONValue {
guard let scopeId = context.flatMap({ scopeResolver($0.aggregateId) }) else {
return try await master.encryptPayload(payload)
}
let key = try await subKey(scopeId, mint: true)
return try sealPayload(payload, key: key)
}
public func decryptPayload(_ payload: JSONValue, context: CryptoContext?) async throws -> JSONValue {
guard case let .string(str) = payload, str.hasPrefix(envelopePrefix) else {
return payload
}
if let scopeId = context.flatMap({ scopeResolver($0.aggregateId) }) {
do {
let key = try await subKey(scopeId, mint: false)
return try openEnvelope(payload, key: key)
} catch let e as ScopeVaultError {
if case .scopeShredded = e { throw e } // tot → kein Fallback
// notFound / sonst → evtl. Alt-Event, Master probieren
} catch {
// falscher Scope-Key → Master-Fallback
}
}
return try await master.decryptPayload(payload)
}
private func subKey(_ scopeId: String, mint: Bool) async throws -> SymmetricKey {
if let cached = cache[scopeId] { return cached }
let bytes = mint
? try await scopeVault.getOrMint(appId: appId, scopeId: scopeId)
: try await scopeVault.get(appId: appId, scopeId: scopeId)
let key = SymmetricKey(data: bytes)
cache[scopeId] = key
return key
}
}
/// Master-Provider aus dem Vault + Scope-Vault-Client → ScopedCryptoProvider.
/// Wirft im ZK-Modus (wie ``createMasterKeyProviderFromVault``).
func createScopedKeyProviderFromVault(
auth: AuthClient,
authBaseURL: URL,
appId: String,
scopeResolver: @escaping @Sendable (String) -> String?
) async throws -> ScopedCryptoProvider {
let master = try await createMasterKeyProviderFromVault(auth: auth, authBaseURL: authBaseURL)
let scopeVault = ScopeVaultClient(auth: auth, authBaseURL: authBaseURL)
return ScopedCryptoProvider(appId: appId, master: master, scopeVault: scopeVault, scopeResolver: scopeResolver)
}