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>
94 lines
3.9 KiB
Swift
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)
|
|
}
|