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>
175 lines
7 KiB
Swift
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)"
|
|
}
|
|
}
|
|
}
|