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

175 lines
7 KiB
Swift

import CryptoKit
import Foundation
/// Optionaler Event-Kontext, den die Engine an encrypt/decrypt durchreicht.
/// Provider, die ihn nicht brauchen (NoOp, MasterKey), ignorieren ihn; der
/// ``ScopedCryptoProvider`` leitet aus `aggregateId` die scopeId ab.
public struct CryptoContext: Sendable {
public let aggregateId: String
public let attributedToUserId: String
public let appId: String
public init(aggregateId: String, attributedToUserId: String, appId: String) {
self.aggregateId = aggregateId
self.attributedToUserId = attributedToUserId
self.appId = appId
}
}
/// Crypto-Hook für Event-Payloads. Wire-Format kompatibel zu
/// `@mana/event-sync`:
///
/// `enc:1:<base64-iv>.<base64-ciphertext>`
///
/// AES-GCM-256, 12-Byte-IV, Auth-Tag (16 Byte) hinten im Ciphertext
/// (WebCrypto-Layout). Nur der Payload wird verschlüsselt, nie die
/// Envelope-Routing-Felder. Non-encrypted Payloads (kein `enc:1:`-Prefix)
/// gehen unverändert durch → Mixed-Log-Toleranz (Migration NoOp↔MasterKey,
/// und Guest-Klartext ↔ Account-verschlüsselt).
public protocol CryptoProvider: Sendable {
var providerId: String { get }
func encryptPayload(_ payload: JSONValue) async throws -> JSONValue
func decryptPayload(_ payload: JSONValue) async throws -> JSONValue
/// Context-aware Varianten (für ``ScopedCryptoProvider``). Default-
/// Implementierung ignoriert den Kontext → bestehende Provider bleiben
/// unverändert (abwärtskompatibel).
func encryptPayload(_ payload: JSONValue, context: CryptoContext?) async throws -> JSONValue
func decryptPayload(_ payload: JSONValue, context: CryptoContext?) async throws -> JSONValue
}
public extension CryptoProvider {
func encryptPayload(_ payload: JSONValue, context _: CryptoContext?) async throws -> JSONValue {
try await encryptPayload(payload)
}
func decryptPayload(_ payload: JSONValue, context _: CryptoContext?) async throws -> JSONValue {
try await decryptPayload(payload)
}
}
/// Passthrough — keine Encryption. Fallback wenn Vault nicht erreichbar
/// ist oder im Anonymous-Modus (Daten verlassen das Gerät eh nicht).
public struct NoOpCryptoProvider: CryptoProvider {
/// Stabile Provider-Kennung. Die Engine prüft darauf, um „Crypto auf
/// NoOp degradiert" (Vault unerreichbar) zu erkennen — ohne Magic-String.
public static let id = "noop"
public let providerId: String = NoOpCryptoProvider.id
public init() {}
public func encryptPayload(_ payload: JSONValue) async throws -> JSONValue {
payload
}
public func decryptPayload(_ payload: JSONValue) async throws -> JSONValue {
payload
}
}
// MARK: - AES-GCM seal/open (geteilt zwischen Master + Scoped)
let envelopePrefix = "enc:1:"
let ivByteCount = 12
/// Versiegelt einen Payload zur Envelope-Form mit dem gegebenen Key.
func sealPayload(_ payload: JSONValue, key: SymmetricKey) throws -> JSONValue {
let plaintext = try JSONEncoder().encode(payload)
var iv = Data(count: ivByteCount)
let status = iv.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, ivByteCount, ptr.baseAddress!)
}
guard status == errSecSuccess else {
throw CryptoError.randomFailed(status)
}
let nonce = try AES.GCM.Nonce(data: iv)
let sealed = try AES.GCM.seal(plaintext, using: key, nonce: nonce)
let combined = sealed.ciphertext + sealed.tag
return .string("\(envelopePrefix)\(iv.base64EncodedString()).\(combined.base64EncodedString())")
}
/// Öffnet eine Envelope mit dem gegebenen Key. Nicht-Envelope-Payloads gehen
/// unverändert durch (Mixed-Log-Toleranz). Wirft bei falschem Key / Tag-Mismatch.
func openEnvelope(_ payload: JSONValue, key: SymmetricKey) throws -> JSONValue {
guard case let .string(str) = payload, str.hasPrefix(envelopePrefix) else {
return payload
}
let body = String(str.dropFirst(envelopePrefix.count))
guard let dotIdx = body.firstIndex(of: ".") else {
throw CryptoError.malformedEnvelope("missing dot separator")
}
let ivPart = String(body[..<dotIdx])
let ctPart = String(body[body.index(after: dotIdx)...])
guard let iv = Data(base64Encoded: ivPart), let combined = Data(base64Encoded: ctPart) else {
throw CryptoError.malformedEnvelope("invalid base64")
}
guard iv.count == ivByteCount else {
throw CryptoError.malformedEnvelope("invalid IV length \(iv.count)")
}
guard combined.count >= 16 else {
throw CryptoError.malformedEnvelope("ciphertext too short for GCM tag")
}
let ciphertext = combined.prefix(combined.count - 16)
let tag = combined.suffix(16)
let nonce = try AES.GCM.Nonce(data: iv)
let sealed = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag)
let plaintext = try AES.GCM.open(sealed, using: key)
return try JSONDecoder().decode(JSONValue.self, from: plaintext)
}
/// Master-Key-AES-GCM-Provider. Wire-kompatibel zur TS-Variante in
/// `@mana/event-sync` (`crypto/master-key.ts`).
public struct MasterKeyCryptoProvider: CryptoProvider {
public static let envelopePrefix = "enc:1:"
public static let ivByteCount = 12
public let providerId: String
private let key: SymmetricKey
public init(masterKeyBytes: Data, providerId: String) throws {
guard masterKeyBytes.count == 32 else {
throw CryptoError.invalidKeySize(masterKeyBytes.count)
}
key = SymmetricKey(data: masterKeyBytes)
self.providerId = providerId
}
public func encryptPayload(_ payload: JSONValue) async throws -> JSONValue {
try sealPayload(payload, key: key)
}
public func decryptPayload(_ payload: JSONValue) async throws -> JSONValue {
try openEnvelope(payload, key: key)
}
}
/// Entschlüsselt einen Pull-Payload für die lokale Projektion. Wirft auch
/// dann, wenn der Provider den Ciphertext unangetastet durchreicht (NoOp
/// ohne Key) — ein `enc:1:`-String als „Plaintext" würde jede Projektion
/// korrumpieren. Klartext-Payloads gehen unverändert durch
/// (Mixed-Log-Toleranz).
public func decryptPulledPayload(
_ payload: JSONValue,
eventId: String,
crypto: CryptoProvider,
context: CryptoContext? = nil
) async throws -> JSONValue {
let plain = try await crypto.decryptPayload(payload, context: context)
if case let .string(str) = plain, str.hasPrefix(envelopePrefix) {
throw EventSyncError.decryptFailed(eventId: eventId)
}
return plain
}
public enum CryptoError: LocalizedError, Sendable {
case invalidKeySize(Int)
case randomFailed(OSStatus)
case malformedEnvelope(String)
public var errorDescription: String? {
switch self {
case let .invalidKeySize(count):
"AES-GCM-256 erwartet 32-Byte-Key, bekam \(count)"
case let .randomFailed(status):
"SecRandomCopyBytes failed: \(status)"
case let .malformedEnvelope(reason):
"Malformed encryption envelope: \(reason)"
}
}
}