From 4160f95784abdfc2eaf997bbd3fd744c1d0142ac Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 8 Oct 2026 18:01:38 -0400 Subject: [PATCH 1/8] feat(tdf): spec-aligned multi-segment TDF, verified decrypt, e2e benchmark Standard TDF - In-memory segmented encrypt (encrypt(plaintext:configuration:segmentSize:)); the CLI writes 2 MiB segments like the Go SDK. - decrypt(container:symmetricKey:) opens any segment count and verifies segment hashes and an HS256 root signature (hexless 4.3.0 only); rejects reordered/truncated segments, keyless (GMAC) roots, missing integrity information and payload algorithms other than AES-GCM. File decrypt paths route through it. - Spec alignment: always write schemaVersion 4.3.0 (tdfSpecVersion parameter and TDF_SPEC_VERSION removed), method.iv = first segment nonce; parse string/alg-less policy bindings, alg-less roots, segments without segmentSize, hybrid-wrapped/mlkem-wrapped KAOs, zipstream, any assertion statement format. - Key splits follow sid semantics (same sid = alternatives, XOR across sids): TDFDecryptor.combineKeyShares, KASRewrapClient.rewrapAndUnwrapTDFShares; rewrapAndUnwrapTDF refuses multi-split manifests. - encryptFileMultiSegment no longer drops input past the listed sizes. - ZIP I/O: 1 MiB chunks, slices instead of subdata, preallocated storage. KAS / CLI - Live-platform integration tests and CLI NanoTDF: https locators, real KAS keys/kid, env bearer tokens, compressed client key, nano encrypt writes its output file; per-result KAS error surfaced. - `benchmark e2e`: in-process encrypt -> KAS rewrap -> decrypt timing. NanoTDF - CryptoHelper's stateless crypto is nonisolated and KeyStore does ECDH off the actor: 8-task NanoTDF round trips 407 -> 96 us each. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef --- CLAUDE.md | 6 +- OpenTDFKit/CryptoHelper.swift | 29 +- OpenTDFKit/KASRewrapClient.swift | 86 +++- OpenTDFKit/KeyStore.swift | 11 +- OpenTDFKit/NanoTDF.swift | 32 +- OpenTDFKit/NanoTDFCollectionBuilder.swift | 8 +- OpenTDFKit/NanoTDFCollectionDecryptor.swift | 4 +- OpenTDFKit/TDF/StreamingTDFCrypto.swift | 20 +- OpenTDFKit/TDF/TDFArchive.swift | 25 +- OpenTDFKit/TDF/TDFCBORFormat.swift | 13 +- OpenTDFKit/TDF/TDFManifest.swift | 102 +++- OpenTDFKit/TDF/TDFManifestBuilder.swift | 6 +- OpenTDFKit/TDF/TDFProcessor.swift | 450 ++++++++++++------ OpenTDFKitCLI/Commands.swift | 373 ++++++--------- OpenTDFKitCLI/Config.swift | 17 +- OpenTDFKitCLI/E2EBenchmark.swift | 304 ++++++++++++ OpenTDFKitCLI/main.swift | 22 +- OpenTDFKitTests/CryptoHelperTests.swift | 2 +- OpenTDFKitTests/IntegrationTests.swift | 355 ++++++-------- .../KASRewrapAndUnwrapTDFTests.swift | 41 +- .../KASServiceBenchmarkTests.swift | 12 +- OpenTDFKitTests/KeyStoreTests.swift | 4 +- OpenTDFKitTests/NanoTDFBenchmarkTests.swift | 87 +++- .../TDFSegmentedPayloadTests.swift | 298 ++++++++++++ OpenTDFKitTests/TDFSpecAlignmentTests.swift | 177 +++++++ 25 files changed, 1784 insertions(+), 700 deletions(-) create mode 100644 OpenTDFKitCLI/E2EBenchmark.swift create mode 100644 OpenTDFKitTests/TDFSegmentedPayloadTests.swift create mode 100644 OpenTDFKitTests/TDFSpecAlignmentTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 75953b9..3681c53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,7 +167,6 @@ export TDF_CLIENT_PUBLIC_KEY_PATH=/path/to/client-public.pem # RSA public key export TDF_MIME_TYPE=application/pdf # Content MIME type export TDF_POLICY_JSON='{"uuid":"...","body":{...}}' # Custom policy JSON export TDF_POLICY_PATH=/path/to/policy.json # Or load from file -export TDF_SPEC_VERSION=4.3.0 # TDF spec version (default: 4.3.0) ``` ### Integration Testing @@ -413,7 +412,7 @@ swift test --configuration release --filter "BenchmarkTests" **Use Cases:** - Document encryption - Large file storage -- Multi-segment files (future) +- Multi-segment files (2 MiB segments, interoperable with the Go SDK) - Cross-platform workflows **File Size Overhead:** @@ -438,7 +437,8 @@ OpenTDFKit is designed for high-performance cryptographic operations. When imple - **Memory Usage**: Current implementation loads entire payload into memory - **Large Files**: For files >100MB, consider external chunking before encryption -- **Single-Segment**: Only single-segment TDFs currently supported +- **Segments**: `TDFEncryptor.encrypt(plaintext:configuration:segmentSize:)` writes multi-segment payloads; `TDFDecryptor.decrypt(container:symmetricKey:)` opens any segment count and verifies segment hashes and the root signature (hexless TDF 4.3.0+ encoding only) +- **End-to-end benchmark**: `OpenTDFKitCLI benchmark e2e [--sizes 1m,10m,50m] [--samples 5] [--warmups 5] [--segment-size 2m] [--offline] [--json path] [--keep dir]` times encrypt → KAS rewrap → decrypt in-process (needs `TDF_KAS_URL`/`KASURL`, `PLATFORMURL` and a bearer token via `TDF_OAUTH_TOKEN`/`OAUTH_TOKEN`) - **RSA Operations**: Key wrapping/unwrapping is computationally intensive ## Version Compatibility diff --git a/OpenTDFKit/CryptoHelper.swift b/OpenTDFKit/CryptoHelper.swift index 8044443..f3a54de 100644 --- a/OpenTDFKit/CryptoHelper.swift +++ b/OpenTDFKit/CryptoHelper.swift @@ -50,6 +50,9 @@ enum CryptoConstants { /// An actor providing helper functions for common cryptographic operations needed for NanoTDF. /// Encapsulates key generation, key derivation (ECDH, HKDF), encryption/decryption (AES-GCM), /// nonce handling, and signature generation. +/// Stateless cryptographic helpers. Instance methods are `nonisolated`, so calls run +/// on the caller's executor with no actor hop and concurrent callers are not +/// serialized through a shared instance. public actor CryptoHelper { /// Computes HKDF salt for a given NanoTDF version by hashing the magic number and version byte. /// - Parameter version: The NanoTDF version byte (currently 0x4C for v12). @@ -128,14 +131,10 @@ public actor CryptoHelper { publicKey.compressedRepresentation } - /// Note: `activeSessions` is declared but not currently used in the provided methods. - /// It might be intended for future stateful operations. - private var activeSessions: [String: EphemeralKeyPair] = [:] - /// Generates a new ephemeral key pair for the specified elliptic curve. /// - Parameter curveType: The `Curve` enum value specifying the desired curve (e.g., `.secp256r1`). /// - Returns: An `EphemeralKeyPair` containing the raw private key and compressed public key data, or `nil` if the curve is unsupported (`.xsecp256k1`). - func generateEphemeralKeyPair(curveType: Curve) -> EphemeralKeyPair? { + nonisolated func generateEphemeralKeyPair(curveType: Curve) -> EphemeralKeyPair? { switch curveType { case .secp256r1: let privateKey = P256.KeyAgreement.PrivateKey() @@ -168,7 +167,7 @@ public actor CryptoHelper { /// - recipientPublicKey: The recipient's public key (compressed representation as `Data`). /// - Returns: The derived `SharedSecret` object, or `nil` if the curve is unsupported. /// - Throws: `CryptoKitError` if key reconstruction or the key agreement process fails. - func deriveSharedSecret(keyPair: EphemeralKeyPair, recipientPublicKey: Data) throws -> SharedSecret? { + nonisolated func deriveSharedSecret(keyPair: EphemeralKeyPair, recipientPublicKey: Data) throws -> SharedSecret? { switch keyPair.curve { case .secp256r1: let privateKey = try P256.KeyAgreement.PrivateKey(rawRepresentation: keyPair.privateKey) @@ -193,7 +192,7 @@ public actor CryptoHelper { /// - info: Info data for HKDF. Defaults to `CryptoConstants.hkdfInfoEncryption`. /// - outputByteCount: Desired length of the derived key. Defaults to `CryptoConstants.symmetricKeyByteCount` (32 bytes for AES-256). /// - Returns: A `SymmetricKey` object. - func deriveSymmetricKey( + nonisolated func deriveSymmetricKey( sharedSecret: SharedSecret, salt: Data = CryptoConstants.hkdfSalt, info: Data = CryptoConstants.hkdfInfoEncryption, @@ -217,7 +216,7 @@ public actor CryptoHelper { /// - symmetricKey: The symmetric key (derived from ECDH/HKDF) to use for generating the tag. /// - Returns: The calculated GMAC tag truncated to 8 bytes as `Data`. /// - Throws: `CryptoKitError` if the AES-GCM seal operation fails. - func createGMACBinding(policyBody: Data, symmetricKey: SymmetricKey) throws -> Data { + nonisolated func createGMACBinding(policyBody: Data, symmetricKey: SymmetricKey) throws -> Data { // Seal empty data, authenticating the policyBody. The tag is the GMAC binding. // Use a deterministic zero nonce so the binding can be recomputed and verified later. // Security note: this is safe only because every NanoTDF derives a unique symmetric key @@ -233,7 +232,7 @@ public actor CryptoHelper { /// Generates a cryptographically secure random nonce (IV) of the specified length. /// - Parameter length: The desired length of the nonce in bytes. Defaults to `CryptoConstants.aesGcmNonceSize` (12 bytes). /// - Returns: The generated nonce as `Data`. - func generateNonce(length: Int = CryptoConstants.aesGcmNonceSize) throws -> Data { + nonisolated func generateNonce(length: Int = CryptoConstants.aesGcmNonceSize) throws -> Data { var nonce = Data(count: length) // Use SecRandomCopyBytes for generating secure random data. let status = nonce.withUnsafeMutableBytes { buffer -> OSStatus in @@ -256,7 +255,7 @@ public actor CryptoHelper { /// - nonce: The input nonce `Data`. /// - length: The target length in bytes. /// - Returns: The adjusted nonce `Data`. - func adjustNonce(_ nonce: Data, to length: Int) -> Data { + nonisolated func adjustNonce(_ nonce: Data, to length: Int) -> Data { if nonce.count == length { return nonce // Already correct length } else if nonce.count > length { @@ -276,7 +275,7 @@ public actor CryptoHelper { /// - nonce: The `AES.GCM.Nonce` to use. Must be unique for each encryption with the same key. /// - Returns: A tuple containing the `ciphertext` and the authentication `tag`. /// - Throws: `CryptoKitError` if encryption fails. - func encryptPayload(plaintext: Data, symmetricKey: SymmetricKey, nonce: CryptoKit.AES.GCM.Nonce) throws -> (ciphertext: Data, tag: Data) { + nonisolated func encryptPayload(plaintext: Data, symmetricKey: SymmetricKey, nonce: CryptoKit.AES.GCM.Nonce) throws -> (ciphertext: Data, tag: Data) { let sealedBox = try CryptoKit.AES.GCM.seal(plaintext, using: symmetricKey, nonce: nonce) return (sealedBox.ciphertext, sealedBox.tag) } @@ -289,7 +288,7 @@ public actor CryptoHelper { /// - nonce: The nonce as `Data`. Must be unique for each encryption with the same key. /// - Returns: A tuple containing the `ciphertext` and the authentication `tag`. /// - Throws: `CryptoKitError` if encryption fails or if nonce data cannot be converted. - func encryptPayload(plaintext: Data, symmetricKey: SymmetricKey, nonce: Data) throws -> (ciphertext: Data, tag: Data) { + nonisolated func encryptPayload(plaintext: Data, symmetricKey: SymmetricKey, nonce: Data) throws -> (ciphertext: Data, tag: Data) { // Convert Data to AES.GCM.Nonce before sealing let aesNonce = try CryptoKit.AES.GCM.Nonce(data: nonce) let sealedBox = try CryptoKit.AES.GCM.seal(plaintext, using: symmetricKey, nonce: aesNonce) @@ -305,7 +304,7 @@ public actor CryptoHelper { /// - tag: The authentication tag generated during encryption. /// - Returns: The original plaintext `Data`. /// - Throws: `CryptoKitError` if decryption fails (e.g., incorrect key, invalid tag, corrupted data). - func decryptPayload(ciphertext: Data, symmetricKey: SymmetricKey, nonce: CryptoKit.AES.GCM.Nonce, tag: Data) throws -> Data { + nonisolated func decryptPayload(ciphertext: Data, symmetricKey: SymmetricKey, nonce: CryptoKit.AES.GCM.Nonce, tag: Data) throws -> Data { let sealedBox = try CryptoKit.AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext, tag: tag) return try CryptoKit.AES.GCM.open(sealedBox, using: symmetricKey) } @@ -319,7 +318,7 @@ public actor CryptoHelper { /// - tag: The authentication tag generated during encryption. /// - Returns: The original plaintext `Data`. /// - Throws: `CryptoKitError` if decryption fails or if nonce data cannot be converted. - func decryptPayload(ciphertext: Data, symmetricKey: SymmetricKey, nonce: Data, tag: Data) throws -> Data { + nonisolated func decryptPayload(ciphertext: Data, symmetricKey: SymmetricKey, nonce: Data, tag: Data) throws -> Data { // Convert Data nonce to AES.GCM.Nonce before constructing SealedBox let aesNonce = try CryptoKit.AES.GCM.Nonce(data: nonce) let sealedBox = try CryptoKit.AES.GCM.SealedBox(nonce: aesNonce, ciphertext: ciphertext, tag: tag) @@ -333,7 +332,7 @@ public actor CryptoHelper { /// - message: The `Data` to sign. /// - Returns: The raw signature as `Data` (concatenated R and S values, each 32 bytes). /// - Throws: `CryptoKitError` if the signing operation fails. - func generateECDSASignature(privateKey: P256.Signing.PrivateKey, message: Data) throws -> Data { + nonisolated func generateECDSASignature(privateKey: P256.Signing.PrivateKey, message: Data) throws -> Data { // Generate the signature using CryptoKit and get raw representation directly // rawRepresentation returns the 64-byte R || S format let signature = try privateKey.signature(for: message) diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index 53d1a71..3dbb2bd 100644 --- a/OpenTDFKit/KASRewrapClient.swift +++ b/OpenTDFKit/KASRewrapClient.swift @@ -25,6 +25,18 @@ public protocol KASRewrapClientProtocol { func rewrapNanoTDF(header: Data, parsedHeader: Header, clientKeyPair: EphemeralKeyPair) async throws -> (wrappedKey: Data, sessionPublicKey: Data) } +/// One key share returned by a KAS rewrap, with the split ID (`sid`) of the key +/// access object it came from. +public struct TDFKeyShare: Sendable { + public let sid: String? + public let key: SymmetricKey + + public init(sid: String?, key: SymmetricKey) { + self.sid = sid + self.key = key + } +} + /// Client for interacting with KAS rewrap endpoint for NanoTDF public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { // MARK: - Request/Response Structures @@ -117,6 +129,10 @@ public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { .ecP256 // Default to P-256 for EC case .wrapped, .remote, .remoteWrapped: .rsa2048 + case .hybridWrapped, .mlkemWrapped: + // The Go SDK sends no algorithm hint for KEM-wrapped keys; send the + // KAS's default and leave key selection to the key access type and kid. + .rsa2048 } } } @@ -204,6 +220,9 @@ public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { let status: String // "permit" or "fail" let kasWrappedKey: String? let entityWrappedKey: String? // Legacy field + /// Per-result failure detail (e.g. "invalid_argument: … wrapped key is empty") + /// that the platform sets alongside `status: "fail"`. + let error: String? /// Platform may return string, array, or object metadata values /// (e.g. `X-Required-Obligations` is a string array). let metadata: [String: RewrapMetadataValue]? @@ -508,7 +527,8 @@ public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { } guard firstResult.status == "permit" else { - let reason = firstResult.metadata?["error"]?.stringValue + let reason = firstResult.error + ?? firstResult.metadata?["error"]?.stringValue ?? Self.rewrapDenyReason(firstResult.metadata) ?? "status=\(firstResult.status)" throw KASRewrapError.accessDenied(reason) @@ -641,7 +661,8 @@ public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { for policyEntry in rewrapResponse.responses { for result in policyEntry.results { guard result.status == "permit" else { - let reason = result.metadata?["error"]?.stringValue + let reason = result.error + ?? result.metadata?["error"]?.stringValue ?? Self.rewrapDenyReason(result.metadata) ?? "status=\(result.status)" throw KASRewrapError.accessDenied(reason) @@ -676,36 +697,61 @@ public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { /// KEK with the standard-TDF HKDF salt (`standardTDFSessionSalt`), which /// differs from the NanoTDF salt `unwrapKey` defaults to. /// - /// The manifest must carry exactly one key access object for this client's - /// KAS: with none, `rewrapTDF` throws `KASRewrapError.invalidTDFRequest` - /// before any request is sent; with several (a split-key manifest, whose - /// per-KAO results are shares rather than the DEK) this throws - /// `KASRewrapError.multipleWrappedKeys` — use `rewrapTDF` and reconstruct - /// the key yourself. A permit without a session public key throws - /// `KASRewrapError.missingSessionKey`; a wrapped key that fails to open - /// (wrong salt, wrong client key, corrupt bytes) throws - /// `KASRewrapError.keyUnwrapFailed`. + /// The manifest must hold a single key split: every key access object shares + /// one split ID (`sid`, absent counts as one ID), so any of them yields the + /// whole DEK and the first returned is used. A split-key manifest throws + /// `KASRewrapError.invalidTDFRequest` before any request is sent — call + /// `rewrapAndUnwrapTDFShares` at each KAS and combine with + /// `TDFDecryptor.combineKeyShares`. With no key access object for this KAS, + /// `rewrapTDF` throws `KASRewrapError.invalidTDFRequest`. A permit without a + /// session public key throws `KASRewrapError.missingSessionKey`; a wrapped + /// key that fails to open (wrong salt, wrong client key, corrupt bytes) + /// throws `KASRewrapError.keyUnwrapFailed`. /// - Parameter manifest: The parsed TDF manifest containing key access entries. /// - Returns: The data encryption key for the TDF payload. public func rewrapAndUnwrapTDF(manifest: TDFManifest) async throws -> SymmetricKey { + let splitIDs = Set(manifest.encryptionInformation.keyAccess.map { $0.sid ?? "" }) + guard splitIDs.count <= 1 else { + throw KASRewrapError.invalidTDFRequest( + "Split-key manifest (\(splitIDs.count) splits): use rewrapAndUnwrapTDFShares at each KAS and TDFDecryptor.combineKeyShares", + ) + } + let shares = try await rewrapAndUnwrapTDFShares(manifest: manifest) + guard let first = shares.first else { + throw KASRewrapError.emptyResponse + } + return first.key + } + + /// Rewraps every key access object this KAS holds and unwraps each returned + /// key with the session key, tagged with its object's split ID. For a + /// split-key manifest, collect shares from every KAS and pass them to + /// `TDFDecryptor.combineKeyShares`. + /// - Parameter manifest: The parsed TDF manifest containing key access entries. + /// - Returns: One share per permitted key access object at this KAS. + public func rewrapAndUnwrapTDFShares(manifest: TDFManifest) async throws -> [TDFKeyShare] { + let entries = manifest.encryptionInformation.keyAccess.filter { matchesKasURL($0.url) } let clientPrivateKey = P256.KeyAgreement.PrivateKey() let result = try await rewrapTDF(manifest: manifest, clientPrivateKey: clientPrivateKey) - guard result.wrappedKeys.count == 1, let wrappedKey = result.wrappedKeys.values.first else { - throw KASRewrapError.multipleWrappedKeys(result.wrappedKeys.count) - } guard let sessionPEM = result.sessionPublicKeyPEM, !sessionPEM.isEmpty else { throw KASRewrapError.missingSessionKey } do { let (sessionPublicKey, _) = try Self.validateEcPublicKeyPEM(sessionPEM) - return try Self.unwrapKey( - wrappedKey: wrappedKey, - sessionPublicKey: sessionPublicKey, - clientPrivateKey: clientPrivateKey.rawRepresentation, - salt: Self.standardTDFSessionSalt, - ) + return try result.wrappedKeys.sorted { $0.key < $1.key }.map { objectID, wrappedKey in + // Request ids are `kao-`. + let index = Int(objectID.dropFirst("kao-".count)) + let sid = index.flatMap { entries.indices.contains($0) ? entries[$0].sid : nil } + let key = try Self.unwrapKey( + wrappedKey: wrappedKey, + sessionPublicKey: sessionPublicKey, + clientPrivateKey: clientPrivateKey.rawRepresentation, + salt: Self.standardTDFSessionSalt, + ) + return TDFKeyShare(sid: sid, key: key) + } } catch let error as KASRewrapError { throw error } catch { diff --git a/OpenTDFKit/KeyStore.swift b/OpenTDFKit/KeyStore.swift index b47f16f..ff195bf 100644 --- a/OpenTDFKit/KeyStore.swift +++ b/OpenTDFKit/KeyStore.swift @@ -258,7 +258,7 @@ public actor KeyStore { /// - header: The NanoTDF header containing the necessary keys for decryption. /// - Returns: The derived symmetric key for AES-256-GCM as SymmetricKey. /// - Throws: KeyStoreError or other errors if key derivation fails. - public func derivePayloadSymmetricKey(header: Header) async throws -> SymmetricKey { + public nonisolated func derivePayloadSymmetricKey(header: Header) async throws -> SymmetricKey { let kasPublicKey = header.payloadKeyAccess.kasPublicKey let tdfEphemeralPublicKey = header.ephemeralPublicKey @@ -275,13 +275,14 @@ public actor KeyStore { /// This corresponds to `header.ephemeralPublicKey`. /// - Returns: The derived symmetric key for AES-256-GCM as SymmetricKey. /// - Throws: KeyStoreError or other errors if key derivation fails. - public func derivePayloadSymmetricKey( + public nonisolated func derivePayloadSymmetricKey( kasPublicKey: Data, tdfEphemeralPublicKey: Data, ) async throws -> SymmetricKey { - // 1. Get the KAS's private key from this KeyStore - // The kasPublicKeyForLookup is the KAS's own public key, used to identify its private key. - guard let kasPrivateKeyData = getPrivateKey(forPublicKey: kasPublicKey) else { + // 1. Get the KAS's private key from this KeyStore (the only actor-isolated step; + // the ECDH and HKDF below run on the caller's executor so concurrent + // decrypts are not serialized through the store). + guard let kasPrivateKeyData = await getPrivateKey(forPublicKey: kasPublicKey) else { throw KeyStoreError.keyNotFound("Private key for KAS Public Key \(kasPublicKey.hexString) not found in this KeyStore.") } diff --git a/OpenTDFKit/NanoTDF.swift b/OpenTDFKit/NanoTDF.swift index 9f9736e..167f52e 100644 --- a/OpenTDFKit/NanoTDF.swift +++ b/OpenTDFKit/NanoTDF.swift @@ -64,7 +64,7 @@ public struct NanoTDF: Sendable { /// - Throws: Errors from `CryptoHelper` or `CryptoKit` if decryption fails (e.g., incorrect key, corrupted data). public func getPayloadPlaintext(symmetricKey: SymmetricKey) async throws -> Data { // Pad the 3-byte NanoTDF IV to the 12 bytes required by AES-GCM - let paddedIV = await NanoTDF.sharedCryptoHelper.adjustNonce(payload.iv, to: 12) + let paddedIV = NanoTDF.sharedCryptoHelper.adjustNonce(payload.iv, to: 12) // Use cipher-aware decryption that routes to CryptoSwift for non-128-bit tags // (CryptoKit only supports 16-byte tags; NanoTDF default is 8-byte/64-bit) let cipher = header.payloadSignatureConfig.payloadCipher ?? .aes256GCM64 @@ -107,14 +107,14 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat // Use shared CryptoHelper instance to avoid per-call instantiation overhead // Step 1: Generate an ephemeral key pair based on the KAS curve - guard let keyPair = await NanoTDF.sharedCryptoHelper.generateEphemeralKeyPair(curveType: kas.curve) else { + guard let keyPair = NanoTDF.sharedCryptoHelper.generateEphemeralKeyPair(curveType: kas.curve) else { throw CryptoHelperError.keyDerivationFailed // Or a more specific error } // Step 2: Derive the shared secret using ECDH between the ephemeral key pair and the KAS public key let kasPublicKey = try kas.getPublicKey() // Get the KAS public key data - guard let sharedSecret = try await NanoTDF.sharedCryptoHelper.deriveSharedSecret( + guard let sharedSecret = try NanoTDF.sharedCryptoHelper.deriveSharedSecret( keyPair: keyPair, recipientPublicKey: kasPublicKey, ) else { @@ -124,7 +124,7 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat // Step 3: Derive the symmetric TDF key from the shared secret using HKDF // Salt is SHA256(MAGIC_NUMBER + VERSION) per spec section 4 let salt = CryptoHelper.computeHKDFSalt(version: Header.versionV12) // v12 only - let tdfSymmetricKey = await NanoTDF.sharedCryptoHelper.deriveSymmetricKey( + let tdfSymmetricKey = NanoTDF.sharedCryptoHelper.deriveSymmetricKey( sharedSecret: sharedSecret, salt: salt, info: Data(), // Empty per spec section 4 @@ -167,7 +167,7 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat let policyKasPublicKey = try kas.getPublicKey() // Generate a new ephemeral key pair specifically for policy encryption - guard let policyEphemeralKeyPair = await NanoTDF.sharedCryptoHelper.generateEphemeralKeyPair(curveType: kas.curve) else { + guard let policyEphemeralKeyPair = NanoTDF.sharedCryptoHelper.generateEphemeralKeyPair(curveType: kas.curve) else { throw CryptoHelperError.keyGenerationFailed } @@ -179,7 +179,7 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat ) // Derive a shared secret between our ephemeral private key and the Policy KAS public key - guard let policySharedSecret = try await NanoTDF.sharedCryptoHelper.deriveSharedSecret( + guard let policySharedSecret = try NanoTDF.sharedCryptoHelper.deriveSharedSecret( keyPair: policyEphemeralKeyPair, recipientPublicKey: policyKasPublicKey, ) else { @@ -188,7 +188,7 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat // Derive symmetric key for policy encryption // Using same salt computation as payload encryption per spec - let policySymmetricKey = await NanoTDF.sharedCryptoHelper.deriveSymmetricKey( + let policySymmetricKey = NanoTDF.sharedCryptoHelper.deriveSymmetricKey( sharedSecret: policySharedSecret, salt: salt, // Use same computed salt as payload encryption info: Data(), // Empty per spec section 4 @@ -197,10 +197,10 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat // NanoTDF spec requires IV of 0x000000 for policy encryption let policyIV = Data([0, 0, 0]) - let adjustedIV = await NanoTDF.sharedCryptoHelper.adjustNonce(policyIV, to: 12) + let adjustedIV = NanoTDF.sharedCryptoHelper.adjustNonce(policyIV, to: 12) // Encrypt the policy data - let (encryptedPolicyData, _) = try await NanoTDF.sharedCryptoHelper.encryptPayload( + let (encryptedPolicyData, _) = try NanoTDF.sharedCryptoHelper.encryptPayload( plaintext: body.body, symmetricKey: policySymmetricKey, nonce: adjustedIV, @@ -222,10 +222,10 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat // NanoTDF spec requires IV of 0x000000 for policy encryption let policyIV = Data([0, 0, 0]) - let adjustedIV = await NanoTDF.sharedCryptoHelper.adjustNonce(policyIV, to: 12) + let adjustedIV = NanoTDF.sharedCryptoHelper.adjustNonce(policyIV, to: 12) // Encrypt the policy data using the main TDF symmetric key - let (encryptedPolicyData, _) = try await NanoTDF.sharedCryptoHelper.encryptPayload( + let (encryptedPolicyData, _) = try NanoTDF.sharedCryptoHelper.encryptPayload( plaintext: body.body, symmetricKey: tdfSymmetricKey, // Use the main TDF symmetric key nonce: adjustedIV, @@ -248,7 +248,7 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat } // Create the GMAC policy binding using the derived TDF symmetric key - let gmacTag = try await NanoTDF.sharedCryptoHelper.createGMACBinding( + let gmacTag = try NanoTDF.sharedCryptoHelper.createGMACBinding( policyBody: policyBody, symmetricKey: tdfSymmetricKey, ) @@ -256,12 +256,12 @@ public func createNanoTDF(kas: KasMetadata, policy: inout Policy, plaintext: Dat policy.binding = gmacTag // Step 4: Generate a 3-byte nonce/IV for the payload encryption - let nonce = try await NanoTDF.sharedCryptoHelper.generateNonce(length: 3) + let nonce = try NanoTDF.sharedCryptoHelper.generateNonce(length: 3) // Adjust the 3-byte nonce to 12 bytes for AES-GCM compatibility - let nonce12 = await NanoTDF.sharedCryptoHelper.adjustNonce(nonce, to: 12) + let nonce12 = NanoTDF.sharedCryptoHelper.adjustNonce(nonce, to: 12) // Step 5: Encrypt the plaintext payload using AES-GCM with the derived TDF key and adjusted nonce - let (ciphertext, tag) = try await NanoTDF.sharedCryptoHelper.encryptPayload( + let (ciphertext, tag) = try NanoTDF.sharedCryptoHelper.encryptPayload( plaintext: plaintext, symmetricKey: tdfSymmetricKey, nonce: nonce12, @@ -316,7 +316,7 @@ public func addSignatureToNanoTDF(nanoTDF: inout NanoTDF, privateKey: P256.Signi // Generate the ECDSA signature using the provided private key. // Returns the raw 64-byte R || S signature directly. - let signatureData = try await NanoTDF.sharedCryptoHelper.generateECDSASignature( + let signatureData = try NanoTDF.sharedCryptoHelper.generateECDSASignature( privateKey: privateKey, message: message, ) diff --git a/OpenTDFKit/NanoTDFCollectionBuilder.swift b/OpenTDFKit/NanoTDFCollectionBuilder.swift index 5121a9e..126d7c7 100644 --- a/OpenTDFKit/NanoTDFCollectionBuilder.swift +++ b/OpenTDFKit/NanoTDFCollectionBuilder.swift @@ -127,13 +127,13 @@ public struct NanoTDFCollectionBuilder: Sendable { let cryptoHelper = NanoTDF.sharedCryptoHelper // Step 1: Generate ephemeral key pair (expensive - ~50us) - guard let keyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: kas.curve) else { + guard let keyPair = cryptoHelper.generateEphemeralKeyPair(curveType: kas.curve) else { throw NanoTDFCollectionError.keyDerivationFailed("Failed to generate ephemeral key pair") } // Step 2: Derive shared secret via ECDH (expensive - ~100us) let kasPublicKey = try kas.getPublicKey() - guard let sharedSecret = try await cryptoHelper.deriveSharedSecret( + guard let sharedSecret = try cryptoHelper.deriveSharedSecret( keyPair: keyPair, recipientPublicKey: kasPublicKey, ) else { @@ -143,7 +143,7 @@ public struct NanoTDFCollectionBuilder: Sendable { // Step 3: Derive symmetric key via HKDF (moderate - ~10us) // Use v12 salt for KAS compatibility (L1L format) let salt = CryptoHelper.computeHKDFSalt(version: Header.versionV12) - let symmetricKey = await cryptoHelper.deriveSymmetricKey( + let symmetricKey = cryptoHelper.deriveSymmetricKey( sharedSecret: sharedSecret, salt: salt, info: Data(), @@ -166,7 +166,7 @@ public struct NanoTDFCollectionBuilder: Sendable { } // Create GMAC policy binding - let binding = try await cryptoHelper.createGMACBinding( + let binding = try cryptoHelper.createGMACBinding( policyBody: policyBody, symmetricKey: symmetricKey, ) diff --git a/OpenTDFKit/NanoTDFCollectionDecryptor.swift b/OpenTDFKit/NanoTDFCollectionDecryptor.swift index 427c3bf..6d6467d 100644 --- a/OpenTDFKit/NanoTDFCollectionDecryptor.swift +++ b/OpenTDFKit/NanoTDFCollectionDecryptor.swift @@ -93,7 +93,7 @@ public actor NanoTDFCollectionDecryptor { ) // Perform ECDH with the TDF's ephemeral public key - guard let sharedSecret = try await cryptoHelper.deriveSharedSecret( + guard let sharedSecret = try cryptoHelper.deriveSharedSecret( keyPair: keyPair, recipientPublicKey: header.ephemeralPublicKey, ) else { @@ -102,7 +102,7 @@ public actor NanoTDFCollectionDecryptor { // Derive symmetric key via HKDF using v12 salt let salt = CryptoHelper.computeHKDFSalt(version: Header.versionV12) - let symmetricKey = await cryptoHelper.deriveSymmetricKey( + let symmetricKey = cryptoHelper.deriveSymmetricKey( sharedSecret: sharedSecret, salt: salt, info: Data(), diff --git a/OpenTDFKit/TDF/StreamingTDFCrypto.swift b/OpenTDFKit/TDF/StreamingTDFCrypto.swift index 2171bcc..3a80ba8 100644 --- a/OpenTDFKit/TDF/StreamingTDFCrypto.swift +++ b/OpenTDFKit/TDF/StreamingTDFCrypto.swift @@ -75,6 +75,9 @@ public enum StreamingTDFCrypto { symmetricKey: SymmetricKey, segmentSizes: [Int], ) throws -> (encryptedPayload: Data, result: StreamingEncryptionResult) { + guard !segmentSizes.isEmpty, segmentSizes.allSatisfy({ $0 > 0 }) else { + throw StreamingCryptoError.invalidSegmentSize + } try inputHandle.seek(toOffset: 0) var encryptedPayload = Data() @@ -88,7 +91,12 @@ public enum StreamingTDFCrypto { var firstIV: Data? var lastTag: Data? - for segmentSize in segmentSizes { + var sizeIndex = 0 + while true { + // The listed sizes first, then the last one repeated until EOF, so + // input longer than their sum is not dropped. + let segmentSize = segmentSizes[min(sizeIndex, segmentSizes.count - 1)] + sizeIndex += 1 var plaintextAccumulator = Data() plaintextAccumulator.reserveCapacity(segmentSize) var segmentPlaintextSize: Int64 = 0 @@ -261,6 +269,9 @@ public enum StreamingTDFCrypto { symmetricKey: SymmetricKey, segmentSizes: [Int], ) throws -> StreamingEncryptionResult { + guard !segmentSizes.isEmpty, segmentSizes.allSatisfy({ $0 > 0 }) else { + throw StreamingCryptoError.invalidSegmentSize + } try inputHandle.seek(toOffset: 0) var segments: [EncryptedSegment] = [] @@ -270,7 +281,12 @@ public enum StreamingTDFCrypto { var firstIV: Data? var lastTag: Data? - for segmentSize in segmentSizes { + var sizeIndex = 0 + while true { + // The listed sizes first, then the last one repeated until EOF, so + // input longer than their sum is not dropped. + let segmentSize = segmentSizes[min(sizeIndex, segmentSizes.count - 1)] + sizeIndex += 1 var plaintextAccumulator = Data() plaintextAccumulator.reserveCapacity(segmentSize) var segmentPlaintextSize: Int64 = 0 diff --git a/OpenTDFKit/TDF/TDFArchive.swift b/OpenTDFKit/TDF/TDFArchive.swift index 4583fd5..75af7f3 100644 --- a/OpenTDFKit/TDF/TDFArchive.swift +++ b/OpenTDFKit/TDF/TDFArchive.swift @@ -131,7 +131,7 @@ public struct TDFArchiveReader { public func payloadData() throws -> Data { let entry = try payloadEntry() var result = Data(capacity: Int(entry.uncompressedSize)) - let _ = try archive.extract(entry) { chunk in + let _ = try archive.extract(entry, bufferSize: TDFArchiveWriter.ioChunkSize) { chunk in result.append(chunk) } return result @@ -162,16 +162,26 @@ public struct TDFArchiveWriter { self.compressionMethod = compressionMethod } + /// Chunk size for ZIP entry I/O. Larger than the stdio buffer behind + /// ZIPFoundation's in-memory archive, so chunks pass straight through to it + /// instead of being staged in 16 KiB pieces. + static let ioChunkSize = 1 << 20 + public func buildArchive(manifest: TDFManifest, payload: Data) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let manifestData = try encoder.encode(manifest) + + // Reserve the whole archive up front (entries plus ZIP headers and + // directory) so the in-memory archive is not reallocated as it grows. + var storage = Data() + storage.reserveCapacity(payload.count + manifestData.count + 4096) let archive: ZIPFoundation.Archive do { - archive = try ZIPFoundation.Archive(data: Data(), accessMode: .create) + archive = try ZIPFoundation.Archive(data: storage, accessMode: .create) } catch { throw TDFArchiveError.creationFailed } - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let manifestData = try encoder.encode(manifest) let payloadName = try TDFArchiveEntryNames.payloadEntry(for: manifest) try addEntry(named: TDFArchiveEntryNames.manifest, data: manifestData, to: archive) try addEntry(named: payloadName, data: payload, to: archive) @@ -204,14 +214,15 @@ public struct TDFArchiveWriter { type: .file, uncompressedSize: Int64(data.count), compressionMethod: compressionMethod, - bufferSize: ZIPFoundation.defaultWriteChunkSize, + bufferSize: Self.ioChunkSize, provider: { position, size -> Data in let start = Int(position) guard start < data.count, size > 0 else { return Data() } let upper = min(start + size, data.count) - return data.subdata(in: start ..< upper) + // A slice shares `data`'s storage; `subdata` would copy each chunk. + return data[data.startIndex + start ..< data.startIndex + upper] }, ) } diff --git a/OpenTDFKit/TDF/TDFCBORFormat.swift b/OpenTDFKit/TDF/TDFCBORFormat.swift index fe79819..e43a852 100644 --- a/OpenTDFKit/TDF/TDFCBORFormat.swift +++ b/OpenTDFKit/TDF/TDFCBORFormat.swift @@ -413,11 +413,14 @@ extension TDFCBOREnvelope { /// Encode a single key access object to CBOR private func encodeKeyAccessToCBOR(_ ka: TDFKeyAccessObject) throws -> CBOR { // Key access type enum - let kaTypeEnum: UInt64 = switch ka.type { - case .wrapped: TDFCBOREnums.keyAccessTypeWrapped - case .remote: TDFCBOREnums.keyAccessTypeRemote - case .remoteWrapped: TDFCBOREnums.keyAccessTypeRemoteWrapped - case .ecWrapped: TDFCBOREnums.keyAccessTypeEcWrapped + let kaTypeEnum: UInt64 + switch ka.type { + case .wrapped: kaTypeEnum = TDFCBOREnums.keyAccessTypeWrapped + case .remote: kaTypeEnum = TDFCBOREnums.keyAccessTypeRemote + case .remoteWrapped: kaTypeEnum = TDFCBOREnums.keyAccessTypeRemoteWrapped + case .ecWrapped: kaTypeEnum = TDFCBOREnums.keyAccessTypeEcWrapped + case .hybridWrapped, .mlkemWrapped: + throw TDFCBORError.cborEncodingFailed("Key access type \(ka.type.rawValue) has no TDF-CBOR encoding") } // Protocol enum: "kas" -> 0 (currently only kas is supported) diff --git a/OpenTDFKit/TDF/TDFManifest.swift b/OpenTDFKit/TDF/TDFManifest.swift index b2803f2..69279f9 100644 --- a/OpenTDFKit/TDF/TDFManifest.swift +++ b/OpenTDFKit/TDF/TDFManifest.swift @@ -70,6 +70,7 @@ public struct TDFPayloadDescriptor: Codable, Sendable { public enum PayloadProtocol: String, Codable, Sendable { case zip + case zipstream case file case http case https @@ -160,6 +161,10 @@ public struct TDFKeyAccessObject: Codable, Sendable { case remote case remoteWrapped case ecWrapped = "ec-wrapped" + /// Wrapped to a KAS hybrid (classical + ML-KEM) key; the KAS unwraps it. + case hybridWrapped = "hybrid-wrapped" + /// Wrapped to a KAS ML-KEM key; the KAS unwraps it. + case mlkemWrapped = "mlkem-wrapped" } public enum AccessProtocol: String, Codable, Sendable { @@ -223,6 +228,25 @@ public struct TDFPolicyBinding: Codable, Sendable { self.alg = alg self.hash = hash } + + private enum CodingKeys: String, CodingKey { + case alg + case hash + } + + /// Accepts the `{alg, hash}` object and the bare-string form some writers + /// emit. An absent or empty `alg` means HS256 (OpenTDF spec PR #70, Go SDK). + public init(from decoder: Decoder) throws { + if let hash = try? decoder.singleValueContainer().decode(String.self) { + alg = "HS256" + self.hash = hash + return + } + let c = try decoder.container(keyedBy: CodingKeys.self) + hash = try c.decode(String.self, forKey: .hash) + let decodedAlg = try c.decodeIfPresent(String.self, forKey: .alg) ?? "" + alg = decodedAlg.isEmpty ? "HS256" : decodedAlg + } } public struct TDFMethodDescriptor: Codable, Sendable { @@ -258,6 +282,39 @@ public struct TDFIntegrityInformation: Codable, Sendable { self.segments = segments } + private enum CodingKeys: String, CodingKey { + case rootSignature + case segmentHashAlg + case segmentSizeDefault + case encryptedSegmentSizeDefault + case segments + } + + /// Segment entry as written on the wire: `segmentSize` is optional (spec + /// integrity_information.md) and some writers emit `encryptedSegmentSize: 0` + /// for "use the default". + private struct WireSegment: Decodable { + let hash: String + let segmentSize: Int64? + let encryptedSegmentSize: Int64? + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + rootSignature = try c.decode(TDFRootSignature.self, forKey: .rootSignature) + segmentHashAlg = try c.decode(String.self, forKey: .segmentHashAlg) + segmentSizeDefault = try c.decode(Int64.self, forKey: .segmentSizeDefault) + encryptedSegmentSizeDefault = try c.decodeIfPresent(Int64.self, forKey: .encryptedSegmentSizeDefault) + let defaultSize = segmentSizeDefault + segments = try c.decode([WireSegment].self, forKey: .segments).map { segment in + TDFSegment( + hash: segment.hash, + segmentSize: segment.segmentSize ?? defaultSize, + encryptedSegmentSize: segment.encryptedSegmentSize.flatMap { $0 > 0 ? $0 : nil }, + ) + } + } + /// Minimal integrity information for simple use cases without segment hashing public static var minimal: TDFIntegrityInformation { TDFIntegrityInformation( @@ -278,6 +335,19 @@ public struct TDFRootSignature: Codable, Sendable { self.alg = alg self.sig = sig } + + private enum CodingKeys: String, CodingKey { + case alg + case sig + } + + /// An absent or empty `alg` means HS256 (OpenTDF spec PR #70, Go SDK). + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + sig = try c.decode(String.self, forKey: .sig) + let decodedAlg = try c.decodeIfPresent(String.self, forKey: .alg) ?? "" + alg = decodedAlg.isEmpty ? "HS256" : decodedAlg + } } public struct TDFSegment: Codable, Sendable { @@ -318,10 +388,20 @@ public struct TDFAssertion: Codable, Sendable { } public struct TDFAssertionStatement: Codable, Sendable { - public enum StatementFormat: String, Codable, Sendable { - case jsonStructured = "json-structured" - case string - case binary + /// Statement format. The spec lists `json-structured`, `base64binary` and + /// `string`, and writers use others (the Go SDK's system-metadata assertion + /// writes `json`), so any value round-trips. + public struct StatementFormat: RawRepresentable, Codable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let jsonStructured = StatementFormat(rawValue: "json-structured") + public static let string = StatementFormat(rawValue: "string") + public static let binary = StatementFormat(rawValue: "binary") + public static let base64Binary = StatementFormat(rawValue: "base64binary") } public var format: StatementFormat @@ -333,6 +413,20 @@ public struct TDFAssertionStatement: Codable, Sendable { self.schema = schema self.value = value } + + private enum CodingKeys: String, CodingKey { + case format + case schema + case value + } + + /// `format` may be omitted (the Go SDK writes it `omitempty`). + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + format = try c.decodeIfPresent(StatementFormat.self, forKey: .format) ?? StatementFormat(rawValue: "") + schema = try c.decodeIfPresent(String.self, forKey: .schema) + value = try c.decode(CodableValue.self, forKey: .value) + } } public struct TDFAssertionBinding: Codable, Sendable { diff --git a/OpenTDFKit/TDF/TDFManifestBuilder.swift b/OpenTDFKit/TDF/TDFManifestBuilder.swift index 10e34db..8a2ee95 100644 --- a/OpenTDFKit/TDF/TDFManifestBuilder.swift +++ b/OpenTDFKit/TDF/TDFManifestBuilder.swift @@ -11,7 +11,6 @@ public struct TDFManifestBuilder { policy: String, iv: String, mimeType: String = "application/octet-stream", - tdfSpecVersion: String = "4.3.0", policyBinding: TDFPolicyBinding, integrityInformation: TDFIntegrityInformation? = nil, algorithm: String = "AES-256-GCM", @@ -52,7 +51,7 @@ public struct TDFManifestBuilder { ) return TDFManifest( - schemaVersion: tdfSpecVersion, + schemaVersion: TDFEncryptionConfiguration.specVersion, payload: payloadDescriptor, encryptionInformation: encryptionInformation, assertions: nil, @@ -65,7 +64,6 @@ public struct TDFManifestBuilder { policy: String, iv: String, mimeType: String = "application/octet-stream", - tdfSpecVersion: String = "4.3.0", integrityInformation: TDFIntegrityInformation? = nil, algorithm: String = "AES-256-GCM", ) -> TDFManifest { @@ -92,7 +90,7 @@ public struct TDFManifestBuilder { ) return TDFManifest( - schemaVersion: tdfSpecVersion, + schemaVersion: TDFEncryptionConfiguration.specVersion, payload: payloadDescriptor, encryptionInformation: encryptionInformation, assertions: nil, diff --git a/OpenTDFKit/TDF/TDFProcessor.swift b/OpenTDFKit/TDF/TDFProcessor.swift index 4c82774..f8147f1 100644 --- a/OpenTDFKit/TDF/TDFProcessor.swift +++ b/OpenTDFKit/TDF/TDFProcessor.swift @@ -67,17 +67,23 @@ public enum TDFPolicyError: Error, CustomStringConvertible { } public struct TDFEncryptionConfiguration: Sendable { + /// The only TDF spec version OpenTDFKit writes (manifest `schemaVersion`). + public static let specVersion = "4.3.0" + public let kas: TDFKasInfo public let policy: TDFPolicy public let mimeType: String? - public let tdfSpecVersion: String public let keySize: TDFKeySize - public init(kas: TDFKasInfo, policy: TDFPolicy, mimeType: String? = nil, tdfSpecVersion: String = "4.3.0", keySize: TDFKeySize = .bits256) { + /// Always `TDFEncryptionConfiguration.specVersion`. + public var tdfSpecVersion: String { + Self.specVersion + } + + public init(kas: TDFKasInfo, policy: TDFPolicy, mimeType: String? = nil, keySize: TDFKeySize = .bits256) { self.kas = kas self.policy = policy self.mimeType = mimeType - self.tdfSpecVersion = tdfSpecVersion self.keySize = keySize } } @@ -114,7 +120,7 @@ public struct TDFEncryptor { let method = TDFMethodDescriptor( algorithm: configuration.keySize.algorithm, - iv: "", + iv: streamingResult.iv.base64EncodedString(), isStreamable: true, ) @@ -226,7 +232,7 @@ public struct TDFEncryptor { let method = TDFMethodDescriptor( algorithm: configuration.keySize.algorithm, - iv: "", + iv: streamingResult.iv.base64EncodedString(), isStreamable: true, ) @@ -311,34 +317,120 @@ public struct TDFEncryptor { let payloadData = iv + ciphertext + tag + let segmentSignature = try TDFCrypto.segmentSignatureGMAC(encryptedSegment: payloadData) + let segment = TDFSegment( + hash: segmentSignature.base64EncodedString(), + segmentSize: Int64(plaintext.count), + encryptedSegmentSize: Int64(payloadData.count), + ) + + return try makeResult( + payloadData: payloadData, + segments: [segment], + rawSegmentSignatures: [segmentSignature], + segmentSizeDefault: 2_097_152, + symmetricKey: symmetricKey, + configuration: configuration, + iv: iv, + tag: tag, + ) + } + + /// Encrypt in memory into `segmentSize`-byte segments (the last may be shorter), + /// each sealed with its own AES-GCM nonce and recorded in the manifest with its + /// GMAC. This is the layout other OpenTDF SDKs produce by default (2 MiB segments). + /// - Parameters: + /// - plaintext: Data to protect. + /// - configuration: KAS, policy and format settings. + /// - segmentSize: Plaintext bytes per segment; must be positive. + /// - Returns: The container plus the generated DEK, first segment IV and last segment tag. + public func encrypt( + plaintext: Data, + configuration: TDFEncryptionConfiguration, + segmentSize: Int, + ) throws -> TDFEncryptionResult { + guard segmentSize > 0 else { + throw StreamingCryptoError.invalidSegmentSize + } + + let symmetricKey = try TDFCrypto.generateSymmetricKey(size: configuration.keySize) + let segmentCount = max(1, (plaintext.count + segmentSize - 1) / segmentSize) + + var payloadData = Data() + payloadData.reserveCapacity(plaintext.count + segmentCount * 28) + var segments: [TDFSegment] = [] + segments.reserveCapacity(segmentCount) + var tags: [Data] = [] + tags.reserveCapacity(segmentCount) + var firstIV = Data() + + var offset = plaintext.startIndex + repeat { + let end = min(offset + segmentSize, plaintext.endIndex) + let nonce = AES.GCM.Nonce() + let sealed = try AES.GCM.seal(plaintext[offset ..< end], using: symmetricKey, nonce: nonce) + let segmentStart = payloadData.count + payloadData.append(contentsOf: nonce) + payloadData.append(sealed.ciphertext) + payloadData.append(sealed.tag) + + if firstIV.isEmpty { + firstIV = Data(nonce) + } + tags.append(sealed.tag) + segments.append(TDFSegment( + hash: sealed.tag.base64EncodedString(), + segmentSize: Int64(end - offset), + encryptedSegmentSize: Int64(payloadData.count - segmentStart), + )) + offset = end + } while offset < plaintext.endIndex + + return try makeResult( + payloadData: payloadData, + segments: segments, + rawSegmentSignatures: tags, + segmentSizeDefault: Int64(segmentSize), + symmetricKey: symmetricKey, + configuration: configuration, + iv: firstIV, + tag: tags[tags.count - 1], + ) + } + + /// Wrap the DEK, bind the policy and assemble the manifest around an encrypted payload. + private func makeResult( + payloadData: Data, + segments: [TDFSegment], + rawSegmentSignatures: [Data], + segmentSizeDefault: Int64, + symmetricKey: SymmetricKey, + configuration: TDFEncryptionConfiguration, + iv: Data, + tag: Data, + ) throws -> TDFEncryptionResult { let policyBinding = TDFCrypto.policyBinding(policy: configuration.policy.json, symmetricKey: symmetricKey) let wrappedKey = try TDFCrypto.wrapSymmetricKeyWithRSA(publicKeyPEM: configuration.kas.publicKeyPEM, symmetricKey: symmetricKey) - let segmentSignature = try TDFCrypto.segmentSignatureGMAC(encryptedSegment: payloadData) - let segmentSignatureBase64 = segmentSignature.base64EncodedString() let rootSignature = TDFCrypto.rootSignatureBase64( - rawSegmentSignatures: [segmentSignature], + rawSegmentSignatures: rawSegmentSignatures, symmetricKey: symmetricKey, ) + // Spec method.md: `iv` is required; record the first segment's nonce + // (each segment carries its own nonce inline, which is what readers use). let method = TDFMethodDescriptor( algorithm: configuration.keySize.algorithm, - iv: "", + iv: iv.base64EncodedString(), isStreamable: true, ) - let segment = TDFSegment( - hash: segmentSignatureBase64, - segmentSize: Int64(plaintext.count), - encryptedSegmentSize: Int64(payloadData.count), - ) - let integrity = TDFIntegrityInformation( rootSignature: TDFRootSignature(alg: "HS256", sig: rootSignature), segmentHashAlg: "GMAC", - segmentSizeDefault: 2_097_152, - encryptedSegmentSizeDefault: 2_097_180, - segments: [segment], + segmentSizeDefault: segmentSizeDefault, + encryptedSegmentSizeDefault: segmentSizeDefault + 28, + segments: segments, ) let kasObject = TDFKeyAccessObject( @@ -409,77 +501,24 @@ public struct TDFDecryptor { symmetricKey: SymmetricKey, chunkSize _: Int = StreamingTDFCrypto.defaultChunkSize, ) throws { - let loader = TDFLoader() - let container = try loader.load(from: inputURL) - - let payloadData = container.payload - let ivSize = 12 - let tagSize = 16 - let minSize = ivSize + tagSize - guard payloadData.count >= minSize else { - throw TDFDecryptError.malformedPayload - } - - let iv = payloadData.prefix(ivSize) - let ciphertext = payloadData.dropFirst(ivSize).dropLast(tagSize) - let tag = payloadData.suffix(tagSize) - - let plaintext = try TDFCrypto.decryptPayload( - ciphertext: Data(ciphertext), - iv: Data(iv), - tag: Data(tag), - symmetricKey: symmetricKey, - ) - - try plaintext.write(to: outputURL) + let container = try TDFLoader().load(from: inputURL) + try decrypt(container: container, symmetricKey: symmetricKey).write(to: outputURL) } public func decryptFile( inputURL: URL, outputURL: URL, privateKeyPEM: String, - chunkSize: Int = StreamingTDFCrypto.defaultChunkSize, + chunkSize _: Int = StreamingTDFCrypto.defaultChunkSize, ) throws { let loader = TDFLoader() let container = try loader.load(from: inputURL) - let keyAccess = container.manifest.encryptionInformation.keyAccess - guard !keyAccess.isEmpty else { - throw TDFDecryptError.missingKeyAccess - } - - let symmetricKey: SymmetricKey - if keyAccess.count == 1 { - symmetricKey = try TDFCrypto.unwrapSymmetricKeyWithRSA( - privateKeyPEM: privateKeyPEM, - wrappedKey: keyAccess[0].wrappedKey, - ) - } else { - var combinedKeyData: Data? - for kasObject in keyAccess.sorted(by: { ($0.kid ?? "") < ($1.kid ?? "") }) { - let symmetricKeyPart = try TDFCrypto.unwrapSymmetricKeyWithRSA( - privateKeyPEM: privateKeyPEM, - wrappedKey: kasObject.wrappedKey, - ) - let keyData = TDFCrypto.data(from: symmetricKeyPart) - - if let existing = combinedKeyData { - guard existing.count == keyData.count else { - throw TDFDecryptError.keyShareSizeMismatch - } - combinedKeyData = xorKeyData(existing, keyData) - } else { - combinedKeyData = keyData - } - } - - guard let finalKeyData = combinedKeyData else { - throw TDFDecryptError.missingKeyAccess - } - symmetricKey = SymmetricKey(data: finalKeyData) - } - - try decryptFile(inputURL: inputURL, outputURL: outputURL, symmetricKey: symmetricKey, chunkSize: chunkSize) + let symmetricKey = try unwrapDEK( + keyAccess: container.manifest.encryptionInformation.keyAccess, + privateKeyPEM: privateKeyPEM, + ) + try decrypt(container: container, symmetricKey: symmetricKey).write(to: outputURL) } public func decryptFileMultiSegment( @@ -488,89 +527,213 @@ public struct TDFDecryptor { symmetricKey: SymmetricKey, chunkSize _: Int = StreamingTDFCrypto.defaultChunkSize, ) throws { - let loader = TDFLoader() - let container = try loader.load(from: inputURL) - - guard let integrityInfo = container.manifest.encryptionInformation.integrityInformation else { - throw TDFDecryptError.missingIntegrityInformation - } - - let segments = integrityInfo.segments.enumerated().map { index, seg in - StreamingTDFCrypto.EncryptedSegment( - segmentIndex: index, - plaintextSize: seg.segmentSize, - encryptedSize: seg.encryptedSegmentSize ?? (seg.segmentSize + 28), - hash: seg.hash, - ) - } - - let plaintext = try StreamingTDFCrypto.decryptPayloadMultiSegmentFromMemory( - encryptedPayload: container.payload, - segments: segments, - symmetricKey: symmetricKey, - ) - - try plaintext.write(to: outputURL) + let container = try TDFLoader().load(from: inputURL) + try decrypt(container: container, symmetricKey: symmetricKey).write(to: outputURL) } public func decrypt(container: TDFContainer, privateKeyPEM: String) throws -> Data { - let keyAccess = container.manifest.encryptionInformation.keyAccess - guard !keyAccess.isEmpty else { - throw TDFDecryptError.missingKeyAccess - } + let symmetricKey = try unwrapDEK( + keyAccess: container.manifest.encryptionInformation.keyAccess, + privateKeyPEM: privateKeyPEM, + ) + return try decrypt(container: container, symmetricKey: symmetricKey) + } - if keyAccess.count == 1 { - let symmetricKey = try TDFCrypto.unwrapSymmetricKeyWithRSA( - privateKeyPEM: privateKeyPEM, - wrappedKey: keyAccess[0].wrappedKey, - ) - return try decrypt(container: container, symmetricKey: symmetricKey) + /// Payload algorithms `decrypt` accepts. AES-128-GCM is only written by + /// OpenTDFKit (`TDFKeySize.bits128`); other SDKs write AES-256-GCM. + static let supportedPayloadAlgorithms: Set = ["AES-256-GCM", "AES-128-GCM"] + + /// Reconstructs a DEK from key access objects per OpenTDF split semantics + /// (spec concepts/security.md; Go SDK): objects that share a split ID (`sid`, + /// absent treated as "") are alternatives for one share and the first that + /// unwraps is used; the shares of distinct splits are XORed together. Every + /// split must yield a share. + /// - Parameter candidates: Each key access object's split ID and a closure + /// that unwraps its share; closures are only called until their split has a share. + /// - Returns: The combined key bytes. + /// - Throws: The last unwrap error of a split with no share, or + /// `TDFDecryptError.keyShareSizeMismatch` / `.missingKeyAccess`. + public static func combineKeyShares(_ candidates: [(sid: String?, unwrap: () throws -> Data)]) throws -> Data { + var shares: [String: Data] = [:] + var failures: [String: Error] = [:] + var splitOrder: [String] = [] + for candidate in candidates { + let sid = candidate.sid ?? "" + if !splitOrder.contains(sid) { + splitOrder.append(sid) + } + guard shares[sid] == nil else { continue } + do { + shares[sid] = try candidate.unwrap() + } catch { + failures[sid] = error + } } - var combinedKeyData: Data? - for kasObject in keyAccess.sorted(by: { ($0.kid ?? "") < ($1.kid ?? "") }) { - let symmetricKeyPart = try TDFCrypto.unwrapSymmetricKeyWithRSA( - privateKeyPEM: privateKeyPEM, - wrappedKey: kasObject.wrappedKey, - ) - let keyData = TDFCrypto.data(from: symmetricKeyPart) - - if let existing = combinedKeyData { - guard existing.count == keyData.count else { + var combined: Data? + for sid in splitOrder { + guard let share = shares[sid] else { + throw failures[sid] ?? TDFDecryptError.missingKeyAccess + } + if let current = combined { + guard current.count == share.count else { throw TDFDecryptError.keyShareSizeMismatch } - combinedKeyData = xorKeyData(existing, keyData) + combined = Data(zip(current, share).map { $0 ^ $1 }) } else { - combinedKeyData = keyData + combined = share } } - - guard let finalKeyData = combinedKeyData else { + guard let combined else { throw TDFDecryptError.missingKeyAccess } - - let finalSymmetricKey = SymmetricKey(data: finalKeyData) - return try decrypt(container: container, symmetricKey: finalSymmetricKey) + return combined } - private func xorKeyData(_ lhs: Data, _ rhs: Data) -> Data { - Data(zip(lhs, rhs).map { $0 ^ $1 }) + /// Unwraps every key access object with the RSA private key and combines the + /// shares per split ID (see `combineKeyShares`). + private func unwrapDEK(keyAccess: [TDFKeyAccessObject], privateKeyPEM: String) throws -> SymmetricKey { + let dek = try Self.combineKeyShares(keyAccess.map { kasObject in + (sid: kasObject.sid, unwrap: { + try TDFCrypto.data(from: TDFCrypto.unwrapSymmetricKeyWithRSA( + privateKeyPEM: privateKeyPEM, + wrappedKey: kasObject.wrappedKey, + )) + }) + }) + return SymmetricKey(data: dek) } + /// Decrypt a Standard TDF payload with its DEK. + /// + /// Every segment is opened with AES-GCM, and the manifest's segment hashes and + /// root signature are checked against the payload so dropped, reordered or + /// substituted segments are rejected. + /// - Throws: `TDFDecryptError.missingIntegrityInformation` when the manifest has + /// no segments, `.malformedPayload` when segment sizes do not tile the payload, + /// `.integrityCheckFailed` when a hash or the root signature does not match, + /// or a CryptoKit error when a segment fails authentication. public func decrypt(container: TDFContainer, symmetricKey: SymmetricKey) throws -> Data { - let payloadData = container.payload + let algorithm = container.manifest.encryptionInformation.method.algorithm + guard Self.supportedPayloadAlgorithms.contains(algorithm.uppercased()) else { + throw TDFDecryptError.unsupportedPayloadAlgorithm(algorithm) + } + guard let integrity = container.manifest.encryptionInformation.integrityInformation, + !integrity.segments.isEmpty + else { + throw TDFDecryptError.missingIntegrityInformation + } + return try decryptSegments(payload: container.payload, integrity: integrity, symmetricKey: symmetricKey) + } + + /// Open each `IV || ciphertext || tag` segment laid out back to back in `payload`, + /// using the manifest's per-segment encrypted sizes (the last segment defaults to + /// the remaining bytes). The segments must cover the payload exactly. + private func decryptSegments( + payload: Data, + integrity: TDFIntegrityInformation, + symmetricKey: SymmetricKey, + ) throws -> Data { let ivSize = 12 let tagSize = 16 - let minSize = ivSize + tagSize - guard payloadData.count >= minSize else { + + var plaintext = Data() + plaintext.reserveCapacity(payload.count) + var signatures: [Data] = [] + signatures.reserveCapacity(integrity.segments.count) + var offset = payload.startIndex + + for (index, segment) in integrity.segments.enumerated() { + let remaining = payload.endIndex - offset + let isLast = index == integrity.segments.count - 1 + guard let encryptedSize = segment.encryptedSegmentSize + ?? (isLast ? Int64(remaining) : integrity.encryptedSegmentSizeDefault), + encryptedSize >= ivSize + tagSize, + encryptedSize <= remaining + else { + throw TDFDecryptError.malformedPayload + } + let segmentEnd = offset + Int(encryptedSize) + let encryptedSegment = payload[offset ..< segmentEnd] + try signatures.append(segmentSignature( + encryptedSegment, + algorithm: integrity.segmentHashAlg, + symmetricKey: symmetricKey, + )) + + let nonce = try AES.GCM.Nonce(data: payload[offset ..< offset + ivSize]) + let sealed = try AES.GCM.SealedBox( + nonce: nonce, + ciphertext: payload[offset + ivSize ..< segmentEnd - tagSize], + tag: payload[segmentEnd - tagSize ..< segmentEnd], + ) + try plaintext.append(AES.GCM.open(sealed, using: symmetricKey)) + offset = segmentEnd + } + + guard offset == payload.endIndex else { throw TDFDecryptError.malformedPayload } + try verifyIntegrity(signatures: signatures, integrity: integrity, symmetricKey: symmetricKey) + return plaintext + } - let iv = payloadData.prefix(ivSize) - let ciphertext = payloadData.dropFirst(ivSize).dropLast(tagSize) - let tag = payloadData.suffix(tagSize) + // MARK: - Integrity verification + + /// Check the manifest's per-segment hashes and root signature against the + /// signatures recomputed from the payload, in the hexless TDF 4.3.0+ encoding + /// (`base64(raw signature)`). + /// + /// The root must be HS256: HMAC-SHA256 with the DEK over the segment signatures + /// in order, which binds segment order and count. A keyless root (e.g. GMAC, + /// which is just the last segment's tag) is rejected so a manifest cannot + /// downgrade the check. + private func verifyIntegrity( + signatures: [Data], + integrity: TDFIntegrityInformation, + symmetricKey: SymmetricKey, + ) throws { + let rootAlgorithm = integrity.rootSignature.alg + guard rootAlgorithm.isEmpty || rootAlgorithm.uppercased() == "HS256" else { + throw TDFDecryptError.unsupportedIntegrityAlgorithm(integrity.rootSignature.alg) + } + let root = Data(HMAC.authenticationCode(for: Data(signatures.joined()), using: symmetricKey)) + guard let manifestRoot = Data(base64Encoded: integrity.rootSignature.sig), + constantTimeEquals(manifestRoot, root) + else { + throw TDFDecryptError.integrityCheckFailed + } - return try TDFCrypto.decryptPayload(ciphertext: Data(ciphertext), iv: Data(iv), tag: Data(tag), symmetricKey: symmetricKey) + for (segment, signature) in zip(integrity.segments, signatures) { + guard let manifestHash = Data(base64Encoded: segment.hash), + constantTimeEquals(manifestHash, signature) + else { + throw TDFDecryptError.integrityCheckFailed + } + } + } + + /// Segment signature per OpenTDF: GMAC is the segment's AES-GCM tag (its last + /// 16 bytes); HS256 is HMAC-SHA256 over the encrypted segment with the DEK. + private func segmentSignature(_ encryptedSegment: Data, algorithm: String, symmetricKey: SymmetricKey) throws -> Data { + switch algorithm.uppercased() { + case "GMAC": + return Data(encryptedSegment.suffix(16)) + case "HS256": + return Data(HMAC.authenticationCode(for: encryptedSegment, using: symmetricKey)) + default: + throw TDFDecryptError.unsupportedIntegrityAlgorithm(algorithm) + } + } + + /// Length is public; only the contents are compared in constant time. + private func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool { + guard lhs.count == rhs.count else { return false } + var difference: UInt8 = 0 + for (a, b) in zip(lhs, rhs) { + difference |= a ^ b + } + return difference == 0 } } @@ -579,6 +742,9 @@ public enum TDFDecryptError: Error, CustomStringConvertible, Equatable { case malformedPayload case keyShareSizeMismatch case missingIntegrityInformation + case integrityCheckFailed + case unsupportedIntegrityAlgorithm(String) + case unsupportedPayloadAlgorithm(String) public var description: String { switch self { @@ -589,7 +755,13 @@ public enum TDFDecryptError: Error, CustomStringConvertible, Equatable { case .keyShareSizeMismatch: "Key share size mismatch: all key shares must have the same length for XOR reconstruction" case .missingIntegrityInformation: - "Multi-segment decryption requires integrity information with segment metadata" + "Decryption requires integrity information with segment metadata" + case .integrityCheckFailed: + "Integrity check failed: segment hashes or root signature do not match the payload" + case let .unsupportedIntegrityAlgorithm(algorithm): + "Unsupported integrity algorithm: \(algorithm)" + case let .unsupportedPayloadAlgorithm(algorithm): + "Unsupported payload encryption algorithm: \(algorithm)" } } } diff --git a/OpenTDFKitCLI/Commands.swift b/OpenTDFKitCLI/Commands.swift index b67f96e..b908a90 100644 --- a/OpenTDFKitCLI/Commands.swift +++ b/OpenTDFKitCLI/Commands.swift @@ -12,32 +12,30 @@ extension Data { struct CLIConfig { let kasURL: String let platformURL: String - let clientID: String - let clientSecret: String + /// Optional: NanoTDF commands authenticate with a bearer token + /// (`TDF_OAUTH_TOKEN` / `OAUTH_TOKEN` / token file), so client credentials + /// are only needed by callers that run a client-credentials grant. + let clientID: String? + let clientSecret: String? let withECDSABinding: Bool let withPlaintextPolicy: Bool static func fromEnvironment() throws -> CLIConfig { - guard let kasURL = ProcessInfo.processInfo.environment["KASURL"] else { + let env = ProcessInfo.processInfo.environment + guard let kasURL = env["KASURL"] else { throw CLIConfigError.missingEnvironmentVariable("KASURL") } - guard let platformURL = ProcessInfo.processInfo.environment["PLATFORMURL"] else { + guard let platformURL = env["PLATFORMURL"] else { throw CLIConfigError.missingEnvironmentVariable("PLATFORMURL") } - guard let clientID = ProcessInfo.processInfo.environment["CLIENTID"] else { - throw CLIConfigError.missingEnvironmentVariable("CLIENTID") - } - guard let clientSecret = ProcessInfo.processInfo.environment["CLIENTSECRET"] else { - throw CLIConfigError.missingEnvironmentVariable("CLIENTSECRET") - } return CLIConfig( kasURL: kasURL, platformURL: platformURL, - clientID: clientID, - clientSecret: clientSecret, - withECDSABinding: ProcessInfo.processInfo.environment["XT_WITH_ECDSA_BINDING"] == "true", - withPlaintextPolicy: ProcessInfo.processInfo.environment["XT_WITH_PLAINTEXT_POLICY"] == "true", + clientID: env["CLIENTID"], + clientSecret: env["CLIENTSECRET"], + withECDSABinding: env["XT_WITH_ECDSA_BINDING"] == "true", + withPlaintextPolicy: env["XT_WITH_PLAINTEXT_POLICY"] == "true", ) } } @@ -79,6 +77,67 @@ enum Commands { return oauthToken } + /// Resolve the bearer token for NanoTDF commands from the environment. + /// + /// Token: `providedToken`, else `TDF_OAUTH_TOKEN`, else `OAUTH_TOKEN`. + /// File (when no token value is set): `tokenPath`, else `TDF_OAUTH_TOKEN_PATH`, + /// else `OAUTH_TOKEN_PATH`, else `fresh_token.txt`. Mirrors the Standard TDF + /// decrypt token lookup; the token is treated as opaque (JWT or CWT). + static func resolveEnvironmentOAuthToken(providedToken: String? = nil, tokenPath: String? = nil) throws -> String { + let env = ProcessInfo.processInfo.environment + func nonEmpty(_ value: String?) -> String? { + guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return value + } + let token = nonEmpty(providedToken) ?? nonEmpty(env["TDF_OAUTH_TOKEN"]) ?? nonEmpty(env["OAUTH_TOKEN"]) + let path = nonEmpty(tokenPath) + ?? nonEmpty(env["TDF_OAUTH_TOKEN_PATH"]) + ?? nonEmpty(env["OAUTH_TOKEN_PATH"]) + ?? "fresh_token.txt" + return try resolveOAuthToken(providedToken: token, tokenPath: path) + } + + /// Build the NanoTDF KAS resource-locator body (`host[:port]`) from `KASURL`. + /// The port is included only when `KASURL` names one explicitly, so + /// `http://localhost:8080/kas` → `localhost:8080` and + /// `https://platform.example.com/kas` → `platform.example.com`. + static func nanoKasLocatorBody(kasURL: URL) -> String? { + guard let host = kasURL.host, !host.isEmpty else { + return nil + } + if let port = kasURL.port { + return "\(host):\(port)" + } + return host + } + + /// NanoTDF resource-locator protocol for a KAS URL scheme (`https` or `http`). + static func nanoProtocol(forScheme scheme: String?) -> ProtocolEnum { + scheme?.lowercased() == "https" ? .https : .http + } + + /// KAS key identifier for the NanoTDF locator: the KAS-reported `kid` when it + /// fits a locator identifier (2, 8, or 32 bytes), else `nil`. + static func nanoKasIdentifier(kid: String?) -> Data? { + guard let kid else { + return nil + } + let data = Data(kid.utf8) + return [2, 8, 32].contains(data.count) ? data : nil + } + + /// Rebuild the KAS URL (`scheme://body[/kas]`) from a NanoTDF header locator. + /// The scheme follows the locator protocol; a body without a `/kas` path + /// (this CLI's `host[:port]` form) gets `/kas` appended. + static func nanoKasURL(from locator: ResourceLocator) -> URL? { + let scheme = locator.protocolEnum == .https ? "https" : "http" + let body = locator.body + let urlString = body.contains("/kas") ? "\(scheme)://\(body)" : "\(scheme)://\(body)/kas" + return URL(string: urlString) + } + /// Parse and report details about a ZIP-based TDF container. static func verifyTDF(data: Data, filename: String) throws { print("Standard TDF Verification Report") @@ -160,10 +219,16 @@ enum Commands { print(" Requesting rewrap from KAS (ephemeral P-256 session key)") - var keyShares: [Data] = [] - let uniqueKasURLs = Set(container.manifest.encryptionInformation.keyAccess.map(\.url)) + // Shares tagged with their split ID; combined per OpenTDF split semantics + // (same sid = alternatives, distinct sids XORed). + var keyShares: [TDFKeyShare] = [] + let keyAccess = container.manifest.encryptionInformation.keyAccess + var kasURLs: [String] = [] + for kao in keyAccess where !kasURLs.contains(kao.url) { + kasURLs.append(kao.url) + } - for kasURLString in uniqueKasURLs { + for kasURLString in kasURLs { guard let kasURL = URL(string: kasURLString) else { continue } @@ -174,42 +239,43 @@ enum Commands { guard let privateKeyPEM else { // Stage-1 path: the library generates the ephemeral key, rewraps, and // unwraps with go `tdfSalt()` = SHA256("TDF"). - let share = try await client.rewrapAndUnwrapTDF(manifest: container.manifest) - keyShares.append(TDFCrypto.data(from: share)) + try await keyShares.append(contentsOf: client.rewrapAndUnwrapTDFShares(manifest: container.manifest)) continue } // A legacy RSA client key needs the raw wrapped bytes, so run the rewrap // here; the EC session unwrap is still preferred when the KAS offers one. + let entries = keyAccess.filter { $0.url == kasURLString } let ephemeralPrivateKey = P256.KeyAgreement.PrivateKey() let result = try await client.rewrapTDF( manifest: container.manifest, clientPrivateKey: ephemeralPrivateKey, ) + let sessionKey = try result.sessionPublicKeyPEM.flatMap { pem in + pem.isEmpty ? nil : try KASRewrapClient.validateEcPublicKeyPEM(pem).0 + } + if sessionKey == nil { + print(" Falling back to RSA private-key unwrap of rewrap response") + } - // Prefer Stage-1 path: EC session unwrap with go `tdfSalt()` = SHA256("TDF"). - if let sessionPEM = result.sessionPublicKeyPEM, !sessionPEM.isEmpty { - let (compressedSessionKey, _) = try KASRewrapClient.validateEcPublicKeyPEM(sessionPEM) - for (_, wrappedKeyData) in result.wrappedKeys.sorted(by: { $0.key < $1.key }) { - let share = try KASRewrapClient.unwrapKey( + for (objectID, wrappedKeyData) in result.wrappedKeys.sorted(by: { $0.key < $1.key }) { + // Request ids are `kao-`. + let index = Int(objectID.dropFirst("kao-".count)) + let sid = index.flatMap { entries.indices.contains($0) ? entries[$0].sid : nil } + let share: SymmetricKey = if let sessionKey { + try KASRewrapClient.unwrapKey( wrappedKey: wrappedKeyData, - sessionPublicKey: compressedSessionKey, + sessionPublicKey: sessionKey, clientPrivateKey: ephemeralPrivateKey.rawRepresentation, salt: KASRewrapClient.standardTDFSessionSalt, ) - keyShares.append(TDFCrypto.data(from: share)) - } - } else { - // Legacy offline RSA unwrap of rewrap response (older KAS shapes) - print(" Falling back to RSA private-key unwrap of rewrap response") - for (_, wrappedKeyData) in result.wrappedKeys.sorted(by: { $0.key < $1.key }) { - let base64 = wrappedKeyData.base64EncodedString() - let share = try TDFCrypto.unwrapSymmetricKeyWithRSA( + } else { + try TDFCrypto.unwrapSymmetricKeyWithRSA( privateKeyPEM: privateKeyPEM, - wrappedKey: base64, + wrappedKey: wrappedKeyData.base64EncodedString(), ) - keyShares.append(TDFCrypto.data(from: share)) } + keyShares.append(TDFKeyShare(sid: sid, key: share)) } } @@ -217,16 +283,10 @@ enum Commands { throw DecryptError.missingWrappedKey } - var combinedKeyData = keyShares[0] - for share in keyShares.dropFirst() { - guard combinedKeyData.count == share.count else { - throw DecryptError.invalidWrappedKeyFormat - } - combinedKeyData = xorKeyData(combinedKeyData, share) - } - - let finalSymmetricKey = SymmetricKey(data: combinedKeyData) - return try decryptor.decrypt(container: container, symmetricKey: finalSymmetricKey) + let dek = try TDFDecryptor.combineKeyShares(keyShares.map { share in + (sid: share.sid, unwrap: { TDFCrypto.data(from: share.key) }) + }) + return try decryptor.decrypt(container: container, symmetricKey: SymmetricKey(data: dek)) } /// Acquire an OAuth access token via client_credentials. @@ -375,27 +435,6 @@ enum Commands { ) } - private static func xorKeyData(_ lhs: Data, _ rhs: Data) -> Data { - precondition(lhs.count == rhs.count, "Key share lengths must match") - var result = Data(count: lhs.count) - result.withUnsafeMutableBytes { resPtr in - lhs.withUnsafeBytes { lhsPtr in - rhs.withUnsafeBytes { rhsPtr in - guard let resBytes = resPtr.bindMemory(to: UInt8.self).baseAddress, - let lhsBytes = lhsPtr.bindMemory(to: UInt8.self).baseAddress, - let rhsBytes = rhsPtr.bindMemory(to: UInt8.self).baseAddress - else { - return - } - for index in 0 ..< lhs.count { - resBytes[index] = lhsBytes[index] ^ rhsBytes[index] - } - } - } - } - return result - } - /// Encrypt plaintext to NanoTDF v1.2 format (L1L) using OpenTDFKit's NanoTDF API static func encryptNanoTDF(plaintext: Data, useECDSA: Bool) async throws -> Data { print("NanoTDF Encryption") @@ -406,44 +445,33 @@ enum Commands { // Get configuration from environment let config = try CLIConfig.fromEnvironment() - // Parse KAS URL + // Parse KAS URL. The NanoTDF ResourceLocator carries host[:port] only + // (no path); the port is kept only when KASURL names one explicitly. guard let kasURL = URL(string: config.kasURL), - let kasHost = kasURL.host + let kasBody = nanoKasLocatorBody(kasURL: kasURL) else { throw EncryptError.invalidKASURL } - let kasPort = kasURL.port ?? 8080 - // For NanoTDF ResourceLocator, only include host:port, not the path - let kasBody = "\(kasHost):\(kasPort)" + // Bearer token for fetching the KAS public key (env or token file) + let oauthToken = try resolveEnvironmentOAuthToken() - // Get OAuth token for fetching KAS public key - let tokenURL = URL(fileURLWithPath: "fresh_token.txt") - let tokenData = try Data(contentsOf: tokenURL) - let oauthToken = String(data: tokenData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - // Fetch KAS public key - construct full URL for the API call, preserving the configured scheme - let scheme = kasURL.scheme ?? "http" - let kasFullURL = URL(string: "\(scheme)://\(kasBody)/kas")! - let kasPublicKeyData = try await fetchKASPublicKey( - kasURL: kasFullURL, - token: oauthToken, - ) - print("✓ Retrieved KAS public key") + // Fetch KAS public key (endpoints resolved from PLATFORMURL / KASURL) + let kasKey = try await fetchKASPublicKey(kasURL: kasURL, token: oauthToken) + print("✓ Retrieved KAS public key\(kasKey.kid.map { " (kid: \($0))" } ?? "")") - // Create resource locator for KAS, matching the configured scheme - let protocolEnum: ProtocolEnum = scheme == "https" ? .https : .http + // Create resource locator for KAS, matching the configured scheme. The + // identifier is the KAS key id ("e1" when the KAS does not report one). guard let kasLocator = ResourceLocator( - protocolEnum: protocolEnum, + protocolEnum: nanoProtocol(forScheme: kasURL.scheme), body: kasBody, - identifier: Data([0x65, 0x31]), // "e1" for EC key + identifier: nanoKasIdentifier(kid: kasKey.kid ?? "e1"), ) else { throw EncryptError.invalidKASURL } // Convert compressed key data to CryptoKit public key - let kasPublicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: kasPublicKeyData) + let kasPublicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: kasKey.compressedKey) // Create KAS metadata with the public key let kasMetadata = try KasMetadata( @@ -512,7 +540,11 @@ enum Commands { print("KAS URL: \(configuration.kas.url.absoluteString)") let encryptor = TDFEncryptor() - let result = try encryptor.encrypt(plaintext: plaintext, configuration: configuration) + let result = try encryptor.encrypt( + plaintext: plaintext, + configuration: configuration, + segmentSize: StreamingTDFCrypto.defaultChunkSize, + ) let archiveData = try result.container.serializedData() print("✓ Created Standard TDF archive (\(archiveData.count) bytes)") @@ -573,12 +605,12 @@ enum Commands { return stripKasPath(platformBase.isEmpty ? kasURL.absoluteString : platformBase) } - /// Fetch KAS public key - static func fetchKASPublicKey(kasURL: URL, token: String) async throws -> Data { + /// Fetch the KAS EC (P-256) public key and its key id, if the KAS reports one. + static func fetchKASPublicKey(kasURL: URL, token: String) async throws -> (compressedKey: Data, kid: String?) { let configuration = await resolveConfiguration(kasURL: kasURL, token: token) let client = try KASRewrapClient(configuration: configuration, oauthToken: token) let result = try await client.fetchKasEcPublicKey(algorithm: .ecP256) - return result.compressedKey + return (result.compressedKey, result.kid) } /// Verify and parse a NanoTDF file using OpenTDFKit's parser @@ -661,7 +693,7 @@ enum Commands { } /// Decrypt a NanoTDF file with verbose console output - static func decryptNanoTDF(data: Data, filename: String, token: String? = nil, tokenPath: String = "fresh_token.txt") async throws { + static func decryptNanoTDF(data: Data, filename: String, token: String? = nil, tokenPath: String? = nil) async throws { print("NanoTDF Decryption") print("==================") print("File: \(filename)") @@ -681,7 +713,7 @@ enum Commands { data: Data, verbose: Bool, token: String? = nil, - tokenPath: String = "fresh_token.txt", + tokenPath: String? = nil, ) async throws -> Data { let parser = BinaryParser(data: data) let header: Header @@ -697,18 +729,12 @@ enum Commands { throw DecryptError.invalidFormat } - // Extract KAS URL - handle both formats: host:port and host:port/kas - let kasURLString = header.payloadKeyAccess.kasLocator.body - let kasURLWithPath = if kasURLString.contains("/kas") { - // otdfctl format: already includes /kas path - "http://\(kasURLString)" - } else { - // Our format: just host:port, need to add /kas path - "http://\(kasURLString)/kas" - } - guard let kasURL = URL(string: kasURLWithPath) else { + // Extract KAS URL - handle both formats: host[:port] and host[:port]/kas; + // the scheme follows the header locator protocol (http / https). + let kasLocator = header.payloadKeyAccess.kasLocator + guard let kasURL = nanoKasURL(from: kasLocator) else { if verbose { - print("❌ Invalid KAS URL: \(kasURLString)") + print("❌ Invalid KAS URL: \(kasLocator.body)") } throw DecryptError.invalidKASURL } @@ -716,28 +742,20 @@ enum Commands { print("KAS URL: \(kasURL)") } - // Get OAuth token (from parameter or file) - let oauthToken: String - oauthToken = try resolveOAuthToken(providedToken: token, tokenPath: tokenPath) + // Get OAuth token (parameter, TDF_OAUTH_TOKEN / OAUTH_TOKEN, or token file) + let oauthToken = try resolveEnvironmentOAuthToken(providedToken: token, tokenPath: tokenPath) if verbose { print("✓ OAuth token loaded") } - // Generate client ephemeral key pair + // Generate client ephemeral key pair. KASRewrapClient expects the + // compressed public key and builds the request PEM itself. let privateKey = P256.KeyAgreement.PrivateKey() let clientKeyPair = EphemeralKeyPair( privateKey: privateKey.rawRepresentation, publicKey: privateKey.publicKey.compressedRepresentation, curve: .secp256r1, ) - - // Convert to PEM format for KAS request - let publicKeyPEM = try convertToSPKIPEM(compressedKey: clientKeyPair.publicKey) - let pemKeyPair = EphemeralKeyPair( - privateKey: clientKeyPair.privateKey, - publicKey: publicKeyPEM.data(using: String.Encoding.utf8)!, - curve: .secp256r1, - ) if verbose { print("✓ Generated client ephemeral key pair") } @@ -758,7 +776,7 @@ enum Commands { (wrappedKey, sessionPublicKey) = try await kasClient.rewrapNanoTDF( header: rawHeader, parsedHeader: header, - clientKeyPair: pemKeyPair, + clientKeyPair: clientKeyPair, ) if verbose { print("✓ KAS rewrap successful") @@ -901,59 +919,6 @@ enum EncryptError: Error, CustomStringConvertible { } } -/// Convert compressed P256 public key to SPKI PEM format with proper DER encoding -func convertToSPKIPEM(compressedKey: Data) throws -> String { - guard compressedKey.count == 33 else { - throw DecryptError.keyFormatError - } - - // Convert compressed to uncompressed using x963Representation (65 bytes) - let tempKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: compressedKey) - let x963Key = tempKey.x963Representation // This is the uncompressed format (0x04 + X + Y) - - // Standard SPKI DER structure for P-256 - var derData = Data() - - // SEQUENCE header for SubjectPublicKeyInfo - derData.append(0x30) // SEQUENCE - derData.append(0x59) // Total length (89 bytes) - - // Algorithm Identifier SEQUENCE - derData.append(0x30) // SEQUENCE - derData.append(0x13) // Length (19 bytes) - - // OID for ecPublicKey - derData.append(contentsOf: [0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01]) - - // OID for prime256v1/secp256r1 - derData.append(contentsOf: [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]) - - // BIT STRING for public key - derData.append(0x03) // BIT STRING - derData.append(0x42) // Length (66 bytes) - derData.append(0x00) // No unused bits - - // Add the uncompressed public key point (65 bytes) - derData.append(x963Key) - - // Verify DER structure is correct size - guard derData.count == 91 else { - throw DecryptError.keyFormatError - } - - // Convert to PEM format with proper padding - let base64String = derData.base64EncodedString(options: [ - .lineLength64Characters, - .endLineWithLineFeed, - ]) - - return """ - -----BEGIN PUBLIC KEY----- - \(base64String) - -----END PUBLIC KEY----- - """ -} - extension String { func chunked(into size: Int) -> [String] { stride(from: 0, to: count, by: size).map { @@ -979,41 +944,31 @@ extension Commands { // Get configuration from environment let config = try CLIConfig.fromEnvironment() - // Parse KAS URL + // Parse KAS URL (locator body is host[:port]; port only when explicit) guard let kasURL = URL(string: config.kasURL), - let kasHost = kasURL.host + let kasBody = nanoKasLocatorBody(kasURL: kasURL) else { throw EncryptError.invalidKASURL } - let kasPort = kasURL.port ?? 8080 - let kasBody = "\(kasHost):\(kasPort)" + // Get OAuth token (env or token file) + let oauthToken = try resolveEnvironmentOAuthToken() - // Get OAuth token - let tokenURL = URL(fileURLWithPath: "fresh_token.txt") - let tokenData = try Data(contentsOf: tokenURL) - let oauthToken = String(data: tokenData, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - // Fetch KAS public key - let kasFullURL = URL(string: "http://\(kasBody)/kas")! - let kasPublicKeyData = try await fetchKASPublicKey( - kasURL: kasFullURL, - token: oauthToken, - ) - print("✓ Retrieved KAS public key") + // Fetch KAS public key (endpoints resolved from PLATFORMURL / KASURL) + let kasKey = try await fetchKASPublicKey(kasURL: kasURL, token: oauthToken) + print("✓ Retrieved KAS public key\(kasKey.kid.map { " (kid: \($0))" } ?? "")") - // Create resource locator for KAS + // Create resource locator for KAS, matching the configured scheme guard let kasLocator = ResourceLocator( - protocolEnum: ProtocolEnum(rawValue: 0x00)!, + protocolEnum: nanoProtocol(forScheme: kasURL.scheme), body: kasBody, - identifier: Data([0x65, 0x31]), + identifier: nanoKasIdentifier(kid: kasKey.kid ?? "e1"), ) else { throw EncryptError.invalidKASURL } // Convert compressed key data to CryptoKit public key - let kasPublicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: kasPublicKeyData) + let kasPublicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: kasKey.compressedKey) // Create KAS metadata let kasMetadata = try KasMetadata( @@ -1073,7 +1028,7 @@ extension Commands { data: Data, filename: String, token: String? = nil, - tokenPath: String = "fresh_token.txt", + tokenPath: String? = nil, ) async throws -> [Data] { print("NanoTDF Collection Decryption") print("==============================") @@ -1091,36 +1046,24 @@ extension Commands { let header = try parser.parseHeader() print("✓ Parsed NanoTDF header") - // Get KAS URL - let kasURLString = header.payloadKeyAccess.kasLocator.body - let kasURLWithPath = if kasURLString.contains("/kas") { - "http://\(kasURLString)" - } else { - "http://\(kasURLString)/kas" - } - guard let kasURL = URL(string: kasURLWithPath) else { + // Get KAS URL (scheme from the header locator protocol) + guard let kasURL = nanoKasURL(from: header.payloadKeyAccess.kasLocator) else { throw DecryptError.invalidKASURL } print(" KAS URL: \(kasURL)") - // Get OAuth token - let oauthToken = try resolveOAuthToken(providedToken: token, tokenPath: tokenPath) + // Get OAuth token (parameter, TDF_OAUTH_TOKEN / OAUTH_TOKEN, or token file) + let oauthToken = try resolveEnvironmentOAuthToken(providedToken: token, tokenPath: tokenPath) print("✓ OAuth token loaded") - // Generate client ephemeral key pair + // Generate client ephemeral key pair (compressed public key; the rewrap + // client builds the request PEM itself) let privateKey = P256.KeyAgreement.PrivateKey() let clientKeyPair = EphemeralKeyPair( privateKey: privateKey.rawRepresentation, publicKey: privateKey.publicKey.compressedRepresentation, curve: .secp256r1, ) - - let publicKeyPEM = try convertToSPKIPEM(compressedKey: clientKeyPair.publicKey) - let pemKeyPair = EphemeralKeyPair( - privateKey: clientKeyPair.privateKey, - publicKey: publicKeyPEM.data(using: .utf8)!, - curve: .secp256r1, - ) print("✓ Generated client ephemeral key pair") // Call KAS rewrap endpoint (single rewrap for entire collection) @@ -1131,7 +1074,7 @@ extension Commands { let (wrappedKey, sessionPublicKey) = try await kasClient.rewrapNanoTDF( header: headerBytes, parsedHeader: header, - clientKeyPair: pemKeyPair, + clientKeyPair: clientKeyPair, ) print("✓ KAS rewrap successful (single key for all items)") @@ -1186,7 +1129,7 @@ extension Commands { inputURL: URL, outputURL: URL, token: String? = nil, - tokenPath: String = "fresh_token.txt", + tokenPath: String? = nil, ) async throws { let data = try Data(contentsOf: inputURL) let decryptedItems = try await decryptNanoTDFCollection( diff --git a/OpenTDFKitCLI/Config.swift b/OpenTDFKitCLI/Config.swift index 53901db..889120f 100644 --- a/OpenTDFKitCLI/Config.swift +++ b/OpenTDFKitCLI/Config.swift @@ -2,8 +2,9 @@ import Foundation /// Configuration from xtest environment variables struct Config { - let clientId: String - let clientSecret: String + /// Optional: token-only (bearer) deployments have no client-credentials grant. + let clientId: String? + let clientSecret: String? let kasURL: String let platformURL: String @@ -23,13 +24,7 @@ struct Config { static func fromEnvironment() throws -> Config { let env = ProcessInfo.processInfo.environment - // Required environment variables - guard let clientId = env["CLIENTID"] else { - throw ConfigError.missingRequired("CLIENTID") - } - guard let clientSecret = env["CLIENTSECRET"] else { - throw ConfigError.missingRequired("CLIENTSECRET") - } + // Required environment variables (CLIENTID / CLIENTSECRET are optional) guard let kasURL = env["KASURL"] else { throw ConfigError.missingRequired("KASURL") } @@ -43,8 +38,8 @@ struct Config { ) return Config( - clientId: clientId, - clientSecret: clientSecret, + clientId: env["CLIENTID"], + clientSecret: env["CLIENTSECRET"], kasURL: kasURL, platformURL: platformURL, withMimeType: env["XT_WITH_MIME_TYPE"], diff --git a/OpenTDFKitCLI/E2EBenchmark.swift b/OpenTDFKitCLI/E2EBenchmark.swift new file mode 100644 index 0000000..8f09be3 --- /dev/null +++ b/OpenTDFKitCLI/E2EBenchmark.swift @@ -0,0 +1,304 @@ +import CryptoKit +import Foundation +import OpenTDFKit + +// MARK: - End-to-end Standard TDF benchmark + +/// `benchmark e2e`: in-process Standard TDF encrypt → KAS rewrap → decrypt timing. +/// +/// Mirrors the cross-SDK harness in github.com/eugenioenko/opentdf-sdk +/// (`tests/bench`): one contiguous monotonic timer covers public encryption +/// (including archive serialization) followed by loading and decrypting that +/// same archive through a real KAS rewrap, ending with the plaintext fully +/// materialized. Input generation, OAuth, well-known discovery, KAS public-key +/// fetch and the plaintext check are outside the timer. Per-pair phase +/// timestamps split the interval into encrypt / load / rewrap / decrypt so the +/// network share is visible. +/// +/// `--offline` skips the KAS and decrypts with the DEK returned by encryption, +/// isolating SDK compute. +enum E2EBenchmark { + struct Options { + var sizes: [Int] = [1 << 20, 10 << 20, 50 << 20] + var samples = 5 + var warmups = 5 + var segmentSize = 2 << 20 + var offline = false + var jsonPath: String? + var keepDirectory: String? + } + + struct PairTiming { + var totalMs: Double + var encryptMs: Double + var loadMs: Double + var rewrapMs: Double + var decryptMs: Double + } + + static func run(args: [String]) async throws { + let options = try parseOptions(args) + let env = ProcessInfo.processInfo.environment + + guard let kasURLString = env["TDF_KAS_URL"] ?? env["KASURL"], let kasURL = URL(string: kasURLString) else { + throw CLIError.missingEnvironmentVariable("TDF_KAS_URL or KASURL") + } + let platformURL = env["PLATFORMURL"] + + let token = try? Commands.resolveOAuthToken( + providedToken: env["TDF_OAUTH_TOKEN"] ?? env["OAUTH_TOKEN"], + tokenPath: env["TDF_OAUTH_TOKEN_PATH"] ?? env["OAUTH_TOKEN_PATH"] ?? "fresh_token.txt", + ) + if !options.offline, token == nil { + throw CLIError.missingEnvironmentVariable("TDF_OAUTH_TOKEN, OAUTH_TOKEN or a token file (required unless --offline)") + } + + // Untimed setup: KAS RSA public key, policy, discovery. + let kasKey: (pem: String, kid: String?) + if let path = env["TDF_KAS_PUBLIC_KEY_PATH"] { + kasKey = try (String(contentsOfFile: path, encoding: .utf8), env["TDF_KAS_KID"]) + } else if let token { + let fetched = try await Commands.fetchKASRSAPublicKey(kasURL: kasURL, platformURL: platformURL, token: token) + kasKey = (fetched.pem, env["TDF_KAS_KID"] ?? fetched.kid) + } else { + throw CLIError.missingEnvironmentVariable("TDF_KAS_PUBLIC_KEY_PATH (or a token to fetch the KAS key)") + } + + let configuration = try TDFEncryptionConfiguration( + kas: TDFKasInfo(url: kasURL, publicKeyPEM: kasKey.pem, kid: kasKey.kid), + policy: TDFPolicy(json: Config.defaultPolicyData(env: env)), + mimeType: "application/octet-stream", + ) + let kasConfiguration: OpenTDFConfiguration? = if options.offline { + nil + } else { + await Commands.resolveConfiguration(kasURL: kasURL, token: token ?? "") + } + + var results: [[String: Any]] = [] + for size in options.sizes { + let input = deterministicInput(count: size) + log("size \(size) bytes: \(options.warmups) warmups + \(options.samples) samples, \(options.offline ? "offline (no KAS)" : "real KAS rewrap")") + + var warmups: [PairTiming] = [] + var samples: [PairTiming] = [] + var lastArchive = Data() + for index in 0 ..< options.warmups + options.samples { + let (timing, archive) = try await measurePair( + input: input, + configuration: configuration, + segmentSize: options.segmentSize, + kasConfiguration: kasConfiguration, + token: token, + ) + if index < options.warmups { + warmups.append(timing) + } else { + samples.append(timing) + } + lastArchive = archive + } + if let directory = options.keepDirectory { + try lastArchive.write(to: URL(fileURLWithPath: directory).appendingPathComponent("opentdfkit-\(size).tdf")) + } + + let totals = samples.map(\.totalMs) + log(String( + format: " median %.2f ms (min %.2f, max %.2f) | encrypt %.2f, load %.2f, rewrap %.2f, decrypt %.2f", + median(totals), totals.min() ?? 0, totals.max() ?? 0, + median(samples.map(\.encryptMs)), median(samples.map(\.loadMs)), + median(samples.map(\.rewrapMs)), median(samples.map(\.decryptMs)), + )) + + results.append([ + "size_bytes": size, + "samples_ms": totals, + "warmup_ms": warmups.map(\.totalMs), + "median_ms": median(totals), + "minimum_ms": totals.min() ?? 0, + "maximum_ms": totals.max() ?? 0, + "phases_ms": [ + "encrypt": samples.map(\.encryptMs), + "load": samples.map(\.loadMs), + "rewrap": samples.map(\.rewrapMs), + "decrypt": samples.map(\.decryptMs), + ], + "peak_rss_bytes_so_far": peakRSSBytes(), + ]) + } + + let report: [String: Any] = [ + "sdk": "OpenTDFKit", + "mode": options.offline ? "offline" : "e2e", + "build": isReleaseBuild ? "release" : "debug", + "kas_url": kasURL.absoluteString, + "kid": kasKey.kid ?? "", + "wrapping": "rsa:2048", + "response_session": options.offline ? "none" : "ec:secp256r1 (ephemeral per rewrap)", + "segment_size": options.segmentSize, + "segment_integrity": "GMAC", + "root_integrity": "HS256", + "warmups": options.warmups, + "samples": options.samples, + "cpu": sysctlString("machdep.cpu.brand_string") ?? "unknown", + "os": ProcessInfo.processInfo.operatingSystemVersionString, + "results": results, + "peak_rss_bytes": peakRSSBytes(), + "peak_footprint_bytes": peakFootprintBytes() ?? -1, + ] + let json = try JSONSerialization.data(withJSONObject: report, options: [.sortedKeys]) + if let path = options.jsonPath { + try json.write(to: URL(fileURLWithPath: path)) + } + FileHandle.standardOutput.write(json) + FileHandle.standardOutput.write(Data([0x0A])) + } + + /// One encrypt → decrypt pair under a single contiguous timer. The plaintext + /// comparison runs after the timer stops. + private static func measurePair( + input: Data, + configuration: TDFEncryptionConfiguration, + segmentSize: Int, + kasConfiguration: OpenTDFConfiguration?, + token: String?, + ) async throws -> (PairTiming, Data) { + let clock = ContinuousClock() + let start = clock.now + + let encrypted = try TDFEncryptor().encrypt(plaintext: input, configuration: configuration, segmentSize: segmentSize) + let archive = try encrypted.container.serializedData() + let afterEncrypt = clock.now + + let container = try TDFLoader().load(from: archive) + let afterLoad = clock.now + + let dek: SymmetricKey + if let kasConfiguration, let token { + // A fresh client per pair (new ES256 request-signing key and ephemeral + // P-256 session key); HTTP connections are reused via URLSession.shared. + let client = try KASRewrapClient(configuration: kasConfiguration, oauthToken: token) + dek = try await client.rewrapAndUnwrapTDF(manifest: container.manifest) + } else { + dek = encrypted.symmetricKey + } + let afterRewrap = clock.now + + let plaintext = try TDFDecryptor().decrypt(container: container, symmetricKey: dek) + let end = clock.now + + guard plaintext == input else { + throw CLIError.notYetSupported("benchmark plaintext mismatch") + } + + let timing = PairTiming( + totalMs: milliseconds(end - start), + encryptMs: milliseconds(afterEncrypt - start), + loadMs: milliseconds(afterLoad - afterEncrypt), + rewrapMs: milliseconds(afterRewrap - afterLoad), + decryptMs: milliseconds(end - afterRewrap), + ) + return (timing, archive) + } + + // MARK: - Options + + private static func parseOptions(_ args: [String]) throws -> Options { + var options = Options() + if let value = OpenTDFKitCLI.findFlag("--sizes", in: args).value { + options.sizes = try OpenTDFKitCLI.parseSegmentSizes(value) + } + if let value = OpenTDFKitCLI.findFlag("--samples", in: args).value { + guard let samples = Int(value), samples > 0 else { throw CLIError.missingArgument("--samples must be a positive integer") } + options.samples = samples + } + if let value = OpenTDFKitCLI.findFlag("--warmups", in: args).value { + guard let warmups = Int(value), warmups >= 0 else { throw CLIError.missingArgument("--warmups must be a non-negative integer") } + options.warmups = warmups + } + if let value = OpenTDFKitCLI.findFlag("--segment-size", in: args).value { + options.segmentSize = try OpenTDFKitCLI.parseChunkSize(value) + } + options.offline = args.contains("--offline") + options.jsonPath = OpenTDFKitCLI.findFlag("--json", in: args).value + options.keepDirectory = OpenTDFKitCLI.findFlag("--keep", in: args).value + return options + } + + // MARK: - Helpers + + /// Deterministic input shared with the cross-SDK harness: byte i = (i*131 + (i>>8)*17) & 255. + static func deterministicInput(count: Int) -> Data { + var data = Data(count: count) + data.withUnsafeMutableBytes { buffer in + for index in 0 ..< count { + buffer[index] = UInt8(truncatingIfNeeded: index &* 131 &+ (index >> 8) &* 17) + } + } + return data + } + + private static func milliseconds(_ duration: Duration) -> Double { + let components = duration.components + return Double(components.seconds) * 1000 + Double(components.attoseconds) / 1e15 + } + + private static func median(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + let middle = sorted.count / 2 + return sorted.count.isMultiple(of: 2) ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] + } + + private static func peakRSSBytes() -> Int { + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + #if os(Linux) + return Int(usage.ru_maxrss) * 1024 + #else + return Int(usage.ru_maxrss) + #endif + } + + /// Peak physical footprint (the figure Jetsam and Xcode report). Unlike + /// `ru_maxrss` it excludes freed pages the allocator keeps cached, which + /// otherwise make the peak grow with the number of pairs run. + private static func peakFootprintBytes() -> Int? { + #if os(macOS) || os(iOS) + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let status = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) + } + } + return status == KERN_SUCCESS ? Int(info.ledger_phys_footprint_peak) : nil + #else + return nil + #endif + } + + private static func sysctlString(_ name: String) -> String? { + #if os(macOS) + var size = 0 + guard sysctlbyname(name, nil, &size, nil, 0) == 0, size > 0 else { return nil } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname(name, &buffer, &size, nil, 0) == 0 else { return nil } + return String(cString: buffer) + #else + return nil + #endif + } + + private static var isReleaseBuild: Bool { + #if DEBUG + false + #else + true + #endif + } + + private static func log(_ message: String) { + FileHandle.standardError.write(Data((message + "\n").utf8)) + } +} diff --git a/OpenTDFKitCLI/main.swift b/OpenTDFKitCLI/main.swift index 67dfc9d..151dcc3 100644 --- a/OpenTDFKitCLI/main.swift +++ b/OpenTDFKitCLI/main.swift @@ -128,6 +128,7 @@ struct OpenTDFKitCLI { OpenTDFKitCLI supports Check if feature is supported OpenTDFKitCLI verify Parse and validate a TDF file OpenTDFKitCLI benchmark Benchmark different chunk sizes + OpenTDFKitCLI benchmark e2e [options] Standard TDF encrypt -> KAS rewrap -> decrypt timing OpenTDFKitCLI --help Show this help message Formats: @@ -142,6 +143,17 @@ struct OpenTDFKitCLI { --chunk-size Chunk size for streaming (2m, 5m, 25m, or bytes) --segments Comma-separated segment sizes (e.g., 2m,5m,2m) + benchmark e2e options: + --sizes Input sizes (default 1m,10m,50m) + --samples Measured pairs per size (default 5) + --warmups Unmeasured pairs per size first (default 5) + --segment-size Plaintext bytes per segment (default 2m) + --offline Skip KAS; decrypt with the DEK from encryption + --json Also write the JSON report to + --keep Write each size's last measured archive to + Needs TDF_KAS_URL/KASURL and a bearer token (TDF_OAUTH_TOKEN, OAUTH_TOKEN + or TDF_OAUTH_TOKEN_PATH); PLATFORMURL selects the well-known root. + Features (for supports command): nano, nano_ecdsa, nano_collection, tdf, json, cbor, hexless, etc. @@ -269,12 +281,14 @@ struct OpenTDFKitCLI { plaintext: inputData, useECDSA: false, ) + try outputData.write(to: outputURL) case .nanoWithECDSA: let inputData = try Data(contentsOf: inputURL) outputData = try await Commands.encryptNanoTDF( plaintext: inputData, useECDSA: true, ) + try outputData.write(to: outputURL) case .nanoCollection: try await Commands.encryptFileToCollection(inputURL: inputURL, outputURL: outputURL) return @@ -445,6 +459,11 @@ struct OpenTDFKitCLI { } static func benchmarkCommand(args: [String]) async throws { + if args.count >= 3, args[2] == "e2e" { + try await E2EBenchmark.run(args: args) + return + } + guard args.count >= 4 else { throw CLIError.missingArgument("benchmark requires: ") } @@ -588,8 +607,6 @@ struct OpenTDFKitCLI { ) let policy = try TDFPolicy(json: policyData) - // Do not fall back to XT_WITH_TARGET_MODE — that is a target-mode name (nano/zip/hexless), not a schema version. - let specVersion = env["TDF_SPEC_VERSION"] ?? "4.3.0" // Parse key size from environment (default: 256-bit) let keySize: TDFKeySize = { @@ -603,7 +620,6 @@ struct OpenTDFKitCLI { kas: kasInfo, policy: policy, mimeType: mimeType, - tdfSpecVersion: specVersion, keySize: keySize, ) } diff --git a/OpenTDFKitTests/CryptoHelperTests.swift b/OpenTDFKitTests/CryptoHelperTests.swift index 98e82cb..05997cf 100644 --- a/OpenTDFKitTests/CryptoHelperTests.swift +++ b/OpenTDFKitTests/CryptoHelperTests.swift @@ -16,7 +16,7 @@ final class CryptoHelperTests: XCTestCase { } // Generate ephemeral key pair for P256 - guard let keyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: .secp256r1) else { + guard let keyPair = cryptoHelper.generateEphemeralKeyPair(curveType: .secp256r1) else { XCTFail("Failed to generate ephemeral key pair") return } diff --git a/OpenTDFKitTests/IntegrationTests.swift b/OpenTDFKitTests/IntegrationTests.swift index b2cac25..6f134ce 100644 --- a/OpenTDFKitTests/IntegrationTests.swift +++ b/OpenTDFKitTests/IntegrationTests.swift @@ -20,18 +20,18 @@ final class IntegrationTests: XCTestCase { } private func skipIfEnvironmentNotConfigured() throws { + let hasToken = !(oauthToken?.isEmpty ?? true) + let hasClientCredentials = clientID != nil && clientSecret != nil guard kasURL != nil, platformURL != nil, - clientID != nil, - clientSecret != nil + hasToken || hasClientCredentials else { throw XCTSkip(""" Integration tests require environment variables: - KASURL: KAS endpoint URL (e.g., http://localhost:8080/kas) - - PLATFORMURL: Platform endpoint URL (e.g., http://localhost:8080) - - CLIENTID: OAuth client ID (e.g., opentdf-client) - - CLIENTSECRET: OAuth client secret - - OAUTH_TOKEN: (optional) Pre-acquired OAuth token + - PLATFORMURL: Platform root URL (e.g., http://localhost:8080) + - and either OAUTH_TOKEN (a pre-acquired bearer token: JWT or CWT) + or CLIENTID + CLIENTSECRET (client-credentials grant at PLATFORMURL/token) To run these tests, set the environment variables before running: export KASURL=http://localhost:8080/kas @@ -43,25 +43,107 @@ final class IntegrationTests: XCTestCase { } } - func testEndToEndNanoTDFWithKASRewrap() async throws { - try skipIfEnvironmentNotConfigured() + // MARK: - Platform helpers - guard let kasURL, - let platformURL - else { - XCTFail("Environment not configured") - return + /// PLATFORMURL without trailing slashes (the platform root, not the `/kas` path). + private func platformRoot() throws -> String { + var root = try XCTUnwrap(platformURL).absoluteString + while root.hasSuffix("/") { + root.removeLast() } + return root + } - let testPlaintext = "Integration test: NanoTDF with KAS rewrap".data(using: .utf8)! + /// Resolve KAS endpoints from the platform root, mirroring the CLI's + /// `resolveConfiguration`: well-known discovery (Connect preferred), with + /// synthesized Connect endpoints at the root when well-known is missing or + /// has no usable `kas` block (e.g. a local Go platform). + private func resolveKasConfiguration() async throws -> OpenTDFConfiguration { + let root = try platformRoot() + if let configuration = try? await fetchWellKnown(platformURL: root) { + return configuration.withKasFallback(baseURL: root) + } + return OpenTDFConfiguration.forKasConnect(root) + } - let keyStore = KeyStore(curve: .secp256r1) - let kasService = KASService(keyStore: keyStore, baseURL: platformURL) + /// Fetch a KAS public key over REST: `GET {root}/kas/v2/kas_public_key?algorithm=…`. + /// Decodes both `publicKey` (Go platform) and `public_key` (arkavo-rs) spellings, plus `kid`. + private func fetchKasPublicKey(algorithm: String, token: String) async throws -> KASRewrapClient.KasEcPublicKeyResponse { + var components = try XCTUnwrap(URLComponents(string: "\(platformRoot())/kas/v2/kas_public_key")) + components.queryItems = [URLQueryItem(name: "algorithm", value: algorithm)] + var request = try URLRequest(url: XCTUnwrap(components.url)) + request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Accept") - let kasMetadata = try await kasService.generateKasMetadata() + let (data, response) = try await URLSession.shared.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + guard status == 200 else { + let body = String(data: data, encoding: .utf8) ?? "" + throw NSError(domain: "IntegrationTests", code: status, userInfo: [ + NSLocalizedDescriptionKey: "GET kas_public_key?algorithm=\(algorithm) failed: HTTP \(status) \(body)", + ]) + } + return try JSONDecoder().decode(KASRewrapClient.KasEcPublicKeyResponse.self, from: data) + } + + /// `KasMetadata` for the platform KAS's real EC (P-256) key, with the locator the + /// CLI's `encryptNanoTDF` writes: scheme from KASURL, body `host[:port]` (port only + /// when KASURL names one), identifier = the KAS `kid` (when it is 2, 8, or 32 bytes). + private func makePlatformKasMetadata(token: String) async throws -> KasMetadata { + let kasURL = try XCTUnwrap(kasURL) + let host = try XCTUnwrap(kasURL.host) + let body = kasURL.port.map { "\(host):\($0)" } ?? host + + let keyResponse = try await fetchKasPublicKey(algorithm: "ec:secp256r1", token: token) + let (compressedKey, _) = try KASRewrapClient.validateEcPublicKeyPEM(keyResponse.publicKey) + let identifier = keyResponse.kid + .map { Data($0.utf8) } + .flatMap { [2, 8, 32].contains($0.count) ? $0 : nil } + + let locator = try XCTUnwrap(ResourceLocator( + protocolEnum: kasURL.scheme?.lowercased() == "https" ? .https : .http, + body: body, + identifier: identifier, + )) + return try KasMetadata( + resourceLocator: locator, + publicKey: P256.KeyAgreement.PublicKey(compressedRepresentation: compressedKey), + curve: .secp256r1, + ) + } + + /// Open-access embedded policy (no data attributes), as the CLI writes. + private func makeOpenPolicyJSON() -> Data { + Data(""" + {"uuid":"\(UUID().uuidString.lowercased())","body":{"dataAttributes":[],"dissem":[]}} + """.utf8) + } + + /// Fresh client ephemeral key pair (private and public from the same key). + private func makeClientKeyPair() -> EphemeralKeyPair { + let privateKey = P256.KeyAgreement.PrivateKey() + return EphemeralKeyPair( + privateKey: privateKey.rawRepresentation, + publicKey: privateKey.publicKey.compressedRepresentation, + curve: .secp256r1, + ) + } + + func testEndToEndNanoTDFWithKASRewrap() async throws { + try skipIfEnvironmentNotConfigured() + + let testPlaintext = "Integration test: NanoTDF with KAS rewrap".data(using: .utf8)! - let remotePolicy = try XCTUnwrap(ResourceLocator(protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/integration-test")) - var policy = Policy(type: .remote, body: nil, remote: remotePolicy, binding: nil) + let token = try await getOAuthToken() + + // Encrypt to the platform KAS's real EC key with an embedded policy the KAS can read. + let kasMetadata = try await makePlatformKasMetadata(token: token) + var policy = Policy( + type: .embeddedPlaintext, + body: EmbeddedPolicyBody(body: makeOpenPolicyJSON()), + remote: nil, + binding: nil, + ) let nanoTDF = try await createNanoTDF( kas: kasMetadata, @@ -72,18 +154,12 @@ final class IntegrationTests: XCTestCase { XCTAssertNotNil(nanoTDF) XCTAssertEqual(nanoTDF.header.toData()[2], Header.versionV12, "NanoTDF should use v12") - let token = try await getOAuthToken() - - let kasRewrapClient = try KASRewrapClient( - configuration: OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString), + let kasRewrapClient = try await KASRewrapClient( + configuration: resolveKasConfiguration(), oauthToken: token, ) - let clientKeyPair = EphemeralKeyPair( - privateKey: P256.KeyAgreement.PrivateKey().rawRepresentation, - publicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation, - curve: .secp256r1, - ) + let clientKeyPair = makeClientKeyPair() let (wrappedKey, sessionPublicKey) = try await kasRewrapClient.rewrapNanoTDF( header: nanoTDF.header.toData(), @@ -108,22 +184,17 @@ final class IntegrationTests: XCTestCase { func testKASRewrapWithInvalidToken() async throws { try skipIfEnvironmentNotConfigured() - guard let kasURL, - let platformURL - else { - XCTFail("Environment not configured") - return - } - let testPlaintext = "Integration test: Invalid token".data(using: .utf8)! - let keyStore = KeyStore(curve: .secp256r1) - let kasService = KASService(keyStore: keyStore, baseURL: platformURL) - - let kasMetadata = try await kasService.generateKasMetadata() - - let remotePolicy = try XCTUnwrap(ResourceLocator(protocolEnum: .https, body: "\(platformURL.host ?? "localhost")/policy/test")) - var policy = Policy(type: .remote, body: nil, remote: remotePolicy, binding: nil) + // A valid token is only used to fetch the KAS public key; the rewrap uses a bogus one. + let token = try await getOAuthToken() + let kasMetadata = try await makePlatformKasMetadata(token: token) + var policy = Policy( + type: .embeddedPlaintext, + body: EmbeddedPolicyBody(body: makeOpenPolicyJSON()), + remote: nil, + binding: nil, + ) let nanoTDF = try await createNanoTDF( kas: kasMetadata, @@ -133,16 +204,12 @@ final class IntegrationTests: XCTestCase { let invalidToken = "invalid_token_12345" - let kasRewrapClient = try KASRewrapClient( - configuration: OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString), + let kasRewrapClient = try await KASRewrapClient( + configuration: resolveKasConfiguration(), oauthToken: invalidToken, ) - let clientKeyPair = EphemeralKeyPair( - privateKey: P256.KeyAgreement.PrivateKey().rawRepresentation, - publicKey: P256.KeyAgreement.PrivateKey().publicKey.compressedRepresentation, - curve: .secp256r1, - ) + let clientKeyPair = makeClientKeyPair() do { _ = try await kasRewrapClient.rewrapNanoTDF( @@ -222,34 +289,18 @@ final class IntegrationTests: XCTestCase { func testKASPublicKeyRetrieval() async throws { try skipIfEnvironmentNotConfigured() - guard let platformURL else { - XCTFail("Environment not configured") - return - } - let token = try await getOAuthToken() - let kasPublicKeyURL = platformURL.appendingPathComponent("/kas/v2/kas_public_key") - var request = URLRequest(url: kasPublicKeyURL) - request.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.addValue("ec:secp256r1", forHTTPHeaderField: "algorithm") - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - XCTFail("Failed to retrieve KAS public key") - return - } + // `algorithm` is a query parameter (not a header). + let keyResponse = try await fetchKasPublicKey(algorithm: "ec:secp256r1", token: token) - let pemKey = String(data: data, encoding: .utf8) - XCTAssertNotNil(pemKey, "KAS public key should be in PEM format") - XCTAssertTrue(pemKey?.contains("-----BEGIN PUBLIC KEY-----") ?? false, "PEM should have proper header") + XCTAssertTrue(keyResponse.publicKey.contains("-----BEGIN PUBLIC KEY-----"), "PEM should have proper header") + let (compressedKey, _) = try KASRewrapClient.validateEcPublicKeyPEM(keyResponse.publicKey) + XCTAssertEqual(compressedKey.count, 33, "ec:secp256r1 should return a P-256 key") } private func getOAuthToken() async throws -> String { - if let token = oauthToken { + if let token = oauthToken, !token.isEmpty { return token } @@ -287,48 +338,21 @@ final class IntegrationTests: XCTestCase { func testEndToEndStandardTDFWithKASRewrap() async throws { try skipIfEnvironmentNotConfigured() - guard let platformURL else { - XCTFail("Environment not configured") - return - } - + let kasURL = try XCTUnwrap(kasURL) let testPlaintext = "Integration test: Standard TDF with KAS rewrap".data(using: .utf8)! let token = try await getOAuthToken() - let kasRSAPublicKeyURL = platformURL.appendingPathComponent("/kas/v2/kas_public_key") - var keyRequest = URLRequest(url: kasRSAPublicKeyURL) - keyRequest.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + // RSA wrapping key and its kid from the KAS (`publicKey` or `public_key`). + let rsaKey = try await fetchKasPublicKey(algorithm: "rsa:2048", token: token) - let (keyData, keyResponse) = try await URLSession.shared.data(for: keyRequest) - - guard let httpKeyResponse = keyResponse as? HTTPURLResponse, - httpKeyResponse.statusCode == 200 - else { - throw XCTSkip("Failed to retrieve KAS RSA public key") - } - - struct KASPublicKeyResponse: Codable { - let publicKey: String - } - - let kasPublicKeyResponse = try JSONDecoder().decode(KASPublicKeyResponse.self, from: keyData) - let kasPublicKeyPEM = kasPublicKeyResponse.publicKey - - let policyJSON = """ - { - "uuid": "integration-test-\(UUID().uuidString)", - "body": { - "dataAttributes": [], - "dissem": [] - } - } - """.data(using: .utf8)! + // The KAS parses `uuid` as a UUID; a non-UUID value fails rewrap with "bad request". + let policyJSON = makeOpenPolicyJSON() let kasInfo = TDFKasInfo( - url: platformURL.appendingPathComponent("/kas"), - publicKeyPEM: kasPublicKeyPEM, - kid: "kas-integration-test", + url: kasURL, + publicKeyPEM: rsaKey.publicKey, + kid: rsaKey.kid, schemaVersion: "1.0", ) @@ -337,7 +361,6 @@ final class IntegrationTests: XCTestCase { kas: kasInfo, policy: policy, mimeType: "text/plain", - tdfSpecVersion: "4.3.0", ) let encryptor = TDFEncryptor() @@ -347,54 +370,20 @@ final class IntegrationTests: XCTestCase { XCTAssertGreaterThan(tdfData.count, 0, "TDF data should not be empty") XCTAssertTrue(tdfData.starts(with: [0x50, 0x4B]), "TDF should be a ZIP archive") - let clientPrivateKey = try generateTestRSAKeyPair() - let loader = TDFLoader() let container = try loader.load(from: tdfData) + XCTAssertEqual(container.manifest.encryptionInformation.keyAccess.count, 1) - guard let kasURL = URL(string: container.manifest.encryptionInformation.keyAccess[0].url) else { - XCTFail("Invalid KAS URL in manifest") - return - } - - // Generate ephemeral P-256 key for JWT signing in rewrap request - let ephemeralPrivateKey = P256.KeyAgreement.PrivateKey() - - let kasClient = try KASRewrapClient( - configuration: OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString), + // One-call Standard TDF path: ephemeral P-256 session key, rewrap, and + // EC session unwrap with the Standard TDF salt. + let kasClient = try await KASRewrapClient( + configuration: resolveKasConfiguration(), oauthToken: token, ) - let rewrapResult = try await kasClient.rewrapTDF( - manifest: container.manifest, - clientPrivateKey: ephemeralPrivateKey, - ) - - XCTAssertFalse(rewrapResult.wrappedKeys.isEmpty, "Should receive wrapped keys from KAS") - - var reconstructedKeyData: Data? - for (_, wrappedKey) in rewrapResult.wrappedKeys.sorted(by: { $0.key < $1.key }) { - let unwrappedKey = try TDFCrypto.unwrapSymmetricKeyWithRSA( - privateKeyPEM: clientPrivateKey.privateKeyPEM, - wrappedKey: wrappedKey.base64EncodedString(), - ) - let keyData = TDFCrypto.data(from: unwrappedKey) - - if let existing = reconstructedKeyData { - reconstructedKeyData = Data(zip(existing, keyData).map { $0 ^ $1 }) - } else { - reconstructedKeyData = keyData - } - } - - guard let finalKeyData = reconstructedKeyData else { - XCTFail("Failed to reconstruct key") - return - } - - let reconstructedKey = SymmetricKey(data: finalKeyData) + let symmetricKey = try await kasClient.rewrapAndUnwrapTDF(manifest: container.manifest) let decryptor = TDFDecryptor() - let decryptedPlaintext = try decryptor.decrypt(container: container, symmetricKey: reconstructedKey) + let decryptedPlaintext = try decryptor.decrypt(container: container, symmetricKey: symmetricKey) XCTAssertEqual(decryptedPlaintext, testPlaintext, "Decrypted plaintext should match original") } @@ -404,13 +393,6 @@ final class IntegrationTests: XCTestCase { func testEndToEndNanoTDFCollectionWithKASRewrap() async throws { try skipIfEnvironmentNotConfigured() - guard let kasURL, - let platformURL - else { - XCTFail("Environment not configured") - return - } - // Test with multiple items let testItems = try [ XCTUnwrap("Collection item 1: Hello".data(using: .utf8)), @@ -418,21 +400,16 @@ final class IntegrationTests: XCTestCase { XCTUnwrap("Collection item 3: NanoTDF Collection Test".data(using: .utf8)), ] - let keyStore = KeyStore(curve: .secp256r1) - let kasService = KASService(keyStore: keyStore, baseURL: platformURL) - - let kasMetadata = try await kasService.generateKasMetadata() + // Get token (used for the KAS public key and the rewrap) + let token = try await getOAuthToken() - // Create policy locator - let policyLocator = try XCTUnwrap(ResourceLocator( - protocolEnum: .https, - body: "\(platformURL.host ?? "localhost")/policy/collection-test", - )) + // Encrypt to the platform KAS's real EC key with an embedded policy + let kasMetadata = try await makePlatformKasMetadata(token: token) // Build the collection let collection = try await NanoTDFCollectionBuilder() .kasMetadata(kasMetadata) - .policy(.remote(policyLocator)) + .policy(.embeddedPlaintext(makeOpenPolicyJSON())) .build() // Encrypt all items @@ -449,24 +426,16 @@ final class IntegrationTests: XCTestCase { XCTAssertEqual(encryptedItems[1].ivCounter, 2) XCTAssertEqual(encryptedItems[2].ivCounter, 3) - // Get token for KAS rewrap - let token = try await getOAuthToken() - // Get header for rewrap request let header = await collection.header let headerBytes = await collection.getHeaderBytes() - let kasRewrapClient = try KASRewrapClient( - configuration: OpenTDFConfiguration.forKasLegacyRest(kasURL.absoluteString), + let kasRewrapClient = try await KASRewrapClient( + configuration: resolveKasConfiguration(), oauthToken: token, ) - let clientPrivateKey = P256.KeyAgreement.PrivateKey() - let clientKeyPair = EphemeralKeyPair( - privateKey: clientPrivateKey.rawRepresentation, - publicKey: clientPrivateKey.publicKey.compressedRepresentation, - curve: .secp256r1, - ) + let clientKeyPair = makeClientKeyPair() // Single rewrap call for entire collection let (wrappedKey, sessionPublicKey) = try await kasRewrapClient.rewrapNanoTDF( @@ -614,38 +583,4 @@ final class IntegrationTests: XCTestCase { XCTAssertEqual(decrypted, testItems[index]) } } - - private func generateTestRSAKeyPair() throws -> (privateKeyPEM: String, publicKeyPEM: String) { - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/openssl") - task.arguments = ["genrsa", "2048"] - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = Pipe() - - try task.run() - task.waitUntilExit() - - let privateKeyPEM = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)! - - let pubTask = Process() - pubTask.executableURL = URL(fileURLWithPath: "/usr/bin/openssl") - pubTask.arguments = ["rsa", "-pubout"] - - let pubPipe = Pipe() - let inPipe = Pipe() - pubTask.standardInput = inPipe - pubTask.standardOutput = pubPipe - pubTask.standardError = Pipe() - - try pubTask.run() - inPipe.fileHandleForWriting.write(privateKeyPEM.data(using: .utf8)!) - try inPipe.fileHandleForWriting.close() - pubTask.waitUntilExit() - - let publicKeyPEM = String(data: pubPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8)! - - return (privateKeyPEM, publicKeyPEM) - } } diff --git a/OpenTDFKitTests/KASRewrapAndUnwrapTDFTests.swift b/OpenTDFKitTests/KASRewrapAndUnwrapTDFTests.swift index 147eeff..de2e95e 100644 --- a/OpenTDFKitTests/KASRewrapAndUnwrapTDFTests.swift +++ b/OpenTDFKitTests/KASRewrapAndUnwrapTDFTests.swift @@ -70,19 +70,44 @@ final class KASRewrapAndUnwrapTDFTests: XCTestCase { XCTAssertEqual(fakeKAS.requestCount, 0, "no request should reach the KAS") } - /// Several permitted key access objects at one KAS are split-key shares, - /// not a DEK; the one-call API refuses rather than guessing. - func testRewrapAndUnwrapTDFThrowsOnMultipleWrappedKeys() async throws { + /// Key access objects with distinct split IDs hold shares, not the DEK; the + /// one-call API refuses before contacting the KAS. + func testRewrapAndUnwrapTDFRefusesSplitKeyManifest() async throws { let fakeKAS = FakeKAS(salt: Self.standardTDFSalt, dek: SymmetricKey(size: .bits256)) MockURLProtocol.handler = fakeKAS.handler let client = try makeClient() do { _ = try await client.rewrapAndUnwrapTDF(manifest: makeManifest(kaoCount: 2)) - XCTFail("expected throw for two wrapped keys") - } catch let KASRewrapError.multipleWrappedKeys(count) { - XCTAssertEqual(count, 2) + XCTFail("expected throw for a split-key manifest") + } catch let error as KASRewrapError { + guard case .invalidTDFRequest = error else { + return XCTFail("expected invalidTDFRequest, got \(error)") + } } + XCTAssertEqual(fakeKAS.requestCount, 0, "no request should reach the KAS") + } + + /// Key access objects sharing one split ID are alternatives for the same + /// DEK; any permitted one is enough. + func testRewrapAndUnwrapTDFAcceptsAlternativesInOneSplit() async throws { + let dek = SymmetricKey(size: .bits256) + let fakeKAS = FakeKAS(salt: Self.standardTDFSalt, dek: dek) + MockURLProtocol.handler = fakeKAS.handler + + let client = try makeClient() + let unwrapped = try await client.rewrapAndUnwrapTDF(manifest: makeManifest(kaoCount: 2, sharedSplit: true)) + XCTAssertEqual(TDFCrypto.data(from: unwrapped), TDFCrypto.data(from: dek)) + } + + /// The shares API tags each unwrapped key with its object's split ID. + func testRewrapAndUnwrapTDFSharesTagsSplitIDs() async throws { + let fakeKAS = FakeKAS(salt: Self.standardTDFSalt, dek: SymmetricKey(size: .bits256)) + MockURLProtocol.handler = fakeKAS.handler + + let client = try makeClient() + let shares = try await client.rewrapAndUnwrapTDFShares(manifest: makeManifest(kaoCount: 2)) + XCTAssertEqual(shares.map(\.sid), ["split-0", "split-1"]) } /// A permit without `sessionPublicKey` cannot be unwrapped with ECDH. @@ -120,7 +145,7 @@ final class KASRewrapAndUnwrapTDFTests: XCTestCase { ) } - private func makeManifest(kaoCount: Int) -> TDFManifest { + private func makeManifest(kaoCount: Int, sharedSplit: Bool = false) -> TDFManifest { let keyAccess = (0 ..< kaoCount).map { index in TDFKeyAccessObject( type: .ecWrapped, @@ -129,7 +154,7 @@ final class KASRewrapAndUnwrapTDFTests: XCTestCase { wrappedKey: Data(repeating: UInt8(index), count: 48).base64EncodedString(), policyBinding: TDFPolicyBinding(alg: "HS256", hash: "binding"), kid: "kid-\(index)", - sid: "split-\(index)", + sid: sharedSplit ? "split" : "split-\(index)", ) } return TDFManifest( diff --git a/OpenTDFKitTests/KASServiceBenchmarkTests.swift b/OpenTDFKitTests/KASServiceBenchmarkTests.swift index c4f7c4f..da66e9a 100644 --- a/OpenTDFKitTests/KASServiceBenchmarkTests.swift +++ b/OpenTDFKitTests/KASServiceBenchmarkTests.swift @@ -62,12 +62,12 @@ final class KASServiceBenchmarkTests: XCTestCase { for _ in 0 ..< iterations { // Generate an ephemeral key pair - guard let ephemeralKeyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { + guard let ephemeralKeyPair = cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { continue } // Create a shared secret for encryption - guard let sharedSecret = try await cryptoHelper.deriveSharedSecret( + guard let sharedSecret = try cryptoHelper.deriveSharedSecret( keyPair: ephemeralKeyPair, recipientPublicKey: kasPublicKey, ) else { @@ -75,15 +75,15 @@ final class KASServiceBenchmarkTests: XCTestCase { } // Derive a symmetric key - let symmetricKey = await cryptoHelper.deriveSymmetricKey( + let symmetricKey = cryptoHelper.deriveSymmetricKey( sharedSecret: sharedSecret, salt: CryptoConstants.hkdfSalt, info: CryptoConstants.hkdfInfoEncryption, ) // Encrypt sample data with proper format - let nonce = try await cryptoHelper.generateNonce() - let paddedNonce = await cryptoHelper.adjustNonce(nonce, to: 12) + let nonce = try cryptoHelper.generateNonce() + let paddedNonce = cryptoHelper.adjustNonce(nonce, to: 12) let sealedBox = try AES.GCM.seal(plaintext, using: symmetricKey, nonce: AES.GCM.Nonce(data: paddedNonce)) // Prepare encrypted key data in the expected format @@ -203,7 +203,7 @@ final class KASServiceBenchmarkTests: XCTestCase { let accessStartTime = DispatchTime.now() for i in 0 ..< iterations { - let ephemeralKeyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: .secp256r1)! + let ephemeralKeyPair = cryptoHelper.generateEphemeralKeyPair(curveType: .secp256r1)! let kasPublicKey = testKeys[i] // Create dummy encrypted key data diff --git a/OpenTDFKitTests/KeyStoreTests.swift b/OpenTDFKitTests/KeyStoreTests.swift index b9b7411..c01f309 100644 --- a/OpenTDFKitTests/KeyStoreTests.swift +++ b/OpenTDFKitTests/KeyStoreTests.swift @@ -103,7 +103,7 @@ final class KeyStoreTests: XCTestCase { /// Helper to generate client ephemeral key pair for tests private func generateClientEphemeralKeyPair(curve: Curve) async throws -> EphemeralKeyPair { let cryptoHelper = CryptoHelper() - guard let keyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { + guard let keyPair = cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { throw TestError.keyGenerationFailed("Client ephemeral key pair generation failed for curve \(curve)") } return keyPair @@ -224,7 +224,7 @@ final class KeyStoreTests: XCTestCase { // Generate a KAS public key that is NOT in the store let cryptoHelper = CryptoHelper() - guard let nonStoredKasKeyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { + guard let nonStoredKasKeyPair = cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { XCTFail("Failed to generate non-stored KAS key pair for test setup") return } diff --git a/OpenTDFKitTests/NanoTDFBenchmarkTests.swift b/OpenTDFKitTests/NanoTDFBenchmarkTests.swift index 90dc5a5..cbf9ba5 100644 --- a/OpenTDFKitTests/NanoTDFBenchmarkTests.swift +++ b/OpenTDFKitTests/NanoTDFBenchmarkTests.swift @@ -34,7 +34,7 @@ final class NanoTDFBenchmarkTests: XCTestCase { let expectation = expectation(description: "Key generation completed") Task { - let _ = await cryptoHelper.generateEphemeralKeyPair(curveType: .secp256r1) + let _ = cryptoHelper.generateEphemeralKeyPair(curveType: .secp256r1) expectation.fulfill() } @@ -90,7 +90,7 @@ final class NanoTDFBenchmarkTests: XCTestCase { print("- Operations per second: \(1000 / avgTime)") } - func testEncryptionPerformanceWithDifferentCurves() async throws { + func testEncryptionPerformanceWithDifferentCurves() throws { let curves: [Curve] = [.secp256r1, .secp384r1, .secp521r1] let plaintext = String(repeating: "Test message for encryption. ", count: 100).data(using: .utf8)! let cryptoHelper = CryptoHelper() @@ -102,27 +102,27 @@ final class NanoTDFBenchmarkTests: XCTestCase { let iterations = 20 for _ in 0 ..< iterations { - guard let keyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { + guard let keyPair = cryptoHelper.generateEphemeralKeyPair(curveType: curve) else { continue } // Create a recipient public key of the same curve type - let recipientKeyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: curve)! + let recipientKeyPair = cryptoHelper.generateEphemeralKeyPair(curveType: curve)! // Simple encryption with symmetric key derivation - let sharedSecret = try await cryptoHelper.deriveSharedSecret( + let sharedSecret = try cryptoHelper.deriveSharedSecret( keyPair: keyPair, recipientPublicKey: recipientKeyPair.publicKey, )! - let symmetricKey = await cryptoHelper.deriveSymmetricKey( + let symmetricKey = cryptoHelper.deriveSymmetricKey( sharedSecret: sharedSecret, salt: Data("test".utf8), info: Data("benchmark".utf8), ) - let nonce = try await cryptoHelper.generateNonce() - _ = try await cryptoHelper.encryptPayload( + let nonce = try cryptoHelper.generateNonce() + _ = try cryptoHelper.encryptPayload( plaintext: plaintext, symmetricKey: symmetricKey, nonce: nonce, @@ -137,13 +137,13 @@ final class NanoTDFBenchmarkTests: XCTestCase { } } - func testDecryptionPerformance() async throws { + func testDecryptionPerformance() throws { let cryptoHelper = CryptoHelper() let plaintext = String(repeating: "Test message for decryption benchmark. ", count: 100).data(using: .utf8)! let symmetricKey = SymmetricKey(size: .bits256) - let nonce = try await cryptoHelper.generateNonce() - let (ciphertext, tag) = try await cryptoHelper.encryptPayload( + let nonce = try cryptoHelper.generateNonce() + let (ciphertext, tag) = try cryptoHelper.encryptPayload( plaintext: plaintext, symmetricKey: symmetricKey, nonce: nonce, @@ -154,7 +154,7 @@ final class NanoTDFBenchmarkTests: XCTestCase { let startTime = DispatchTime.now() for _ in 0 ..< iterations { - _ = try await cryptoHelper.decryptPayload( + _ = try cryptoHelper.decryptPayload( ciphertext: ciphertext, symmetricKey: symmetricKey, nonce: nonce, @@ -216,7 +216,7 @@ final class NanoTDFBenchmarkTests: XCTestCase { policyBody: Data, ) async throws -> (encryptedData: Data, policyBinding: Data) { // 1. Derive shared secret - guard let sharedSecret = try await cryptoHelper.deriveSharedSecret( + guard let sharedSecret = try cryptoHelper.deriveSharedSecret( keyPair: keyPair, recipientPublicKey: recipientPublicKey, ) else { @@ -224,18 +224,18 @@ final class NanoTDFBenchmarkTests: XCTestCase { } // 2. Derive symmetric key - let symmetricKey = await cryptoHelper.deriveSymmetricKey( + let symmetricKey = cryptoHelper.deriveSymmetricKey( sharedSecret: sharedSecret, salt: CryptoConstants.hkdfSalt, info: CryptoConstants.hkdfInfoEncryption, ) // 3. Create policy binding - let binding = try await cryptoHelper.createGMACBinding(policyBody: policyBody, symmetricKey: symmetricKey) + let binding = try cryptoHelper.createGMACBinding(policyBody: policyBody, symmetricKey: symmetricKey) // 4. Encrypt payload - let nonce = try await cryptoHelper.generateNonce() - let (ciphertext, tag) = try await cryptoHelper.encryptPayload( + let nonce = try cryptoHelper.generateNonce() + let (ciphertext, tag) = try cryptoHelper.encryptPayload( plaintext: plaintext, symmetricKey: symmetricKey, nonce: nonce, @@ -250,6 +250,57 @@ final class NanoTDFBenchmarkTests: XCTestCase { return (encryptedData, binding) } + /// Create → decrypt round trips through the public API, sequentially and from + /// 8 concurrent tasks, so per-call overhead and cross-task serialization show up. + func testNanoTDFRoundTripThroughput() async throws { + let keyStore = KeyStore(curve: .secp256r1) + let kasService = try KASService(keyStore: keyStore, baseURL: XCTUnwrap(URL(string: "https://kas.example.com"))) + let kasMetadata = try await kasService.generateKasMetadata() + let kasPublicKey = try kasMetadata.getPublicKey() + let plaintext = Data(String(repeating: "NanoTDF throughput. ", count: 20).utf8) + let policyBody = Data(#"{"body":{"dataAttributes":[],"dissem":[]}}"#.utf8) + + @Sendable func roundTrip() async throws { + var policy = Policy(type: .embeddedPlaintext, body: EmbeddedPolicyBody(body: policyBody), remote: nil, binding: nil) + let nanoTDF = try await createNanoTDF(kas: kasMetadata, policy: &policy, plaintext: plaintext) + let symmetricKey = try await keyStore.derivePayloadSymmetricKey( + kasPublicKey: kasPublicKey, + tdfEphemeralPublicKey: nanoTDF.header.ephemeralPublicKey, + ) + let decrypted = try await nanoTDF.getPayloadPlaintext(symmetricKey: symmetricKey) + XCTAssertEqual(decrypted, plaintext) + } + + for _ in 0 ..< 20 { + try await roundTrip() + } + + let iterations = 400 + let sequentialStart = DispatchTime.now() + for _ in 0 ..< iterations { + try await roundTrip() + } + let sequentialMs = Double(DispatchTime.now().uptimeNanoseconds - sequentialStart.uptimeNanoseconds) / 1e6 + + let tasks = 8 + let concurrentStart = DispatchTime.now() + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0 ..< tasks { + group.addTask { + for _ in 0 ..< iterations / tasks { + try await roundTrip() + } + } + } + try await group.waitForAll() + } + let concurrentMs = Double(DispatchTime.now().uptimeNanoseconds - concurrentStart.uptimeNanoseconds) / 1e6 + + print("\nNanoTDF create+decrypt round trip (P-256, \(plaintext.count)-byte payload):") + print("- sequential: \(String(format: "%.1f", sequentialMs * 1000 / Double(iterations))) µs per round trip") + print("- \(tasks) concurrent tasks: \(String(format: "%.1f", concurrentMs * 1000 / Double(iterations))) µs per round trip (wall clock / total)") + } + private func runEncryptionBenchmark(messageSize: Int, label: String) throws { let cryptoHelper = CryptoHelper() let recipientBase64 = "A2ifhGOpE0DjR4R0FPXvZ6YBOrcjayIpxwtxeXTudOts" @@ -264,7 +315,7 @@ final class NanoTDFBenchmarkTests: XCTestCase { let expectation = expectation(description: "\(label) encryption completed") Task { - let keyPair = await cryptoHelper.generateEphemeralKeyPair(curveType: .secp256r1)! + let keyPair = cryptoHelper.generateEphemeralKeyPair(curveType: .secp256r1)! let _ = try await NanoTDFBenchmarkTests.deriveKeysAndEncryptBenchmark( cryptoHelper: cryptoHelper, keyPair: keyPair, diff --git a/OpenTDFKitTests/TDFSegmentedPayloadTests.swift b/OpenTDFKitTests/TDFSegmentedPayloadTests.swift new file mode 100644 index 0000000..c326d7f --- /dev/null +++ b/OpenTDFKitTests/TDFSegmentedPayloadTests.swift @@ -0,0 +1,298 @@ +import CryptoKit +import Foundation +@testable import OpenTDFKit +import XCTest + +/// In-memory multi-segment Standard TDF: `TDFEncryptor.encrypt(plaintext:configuration:segmentSize:)` +/// and `TDFDecryptor.decrypt(container:symmetricKey:)` over multi-segment payloads. +final class TDFSegmentedPayloadTests: XCTestCase { + private static let segmentSize = 2 * 1024 * 1024 + private static let kasPublicKeyPEM: String? = try? makeRSAPublicKeyPEM() + + // MARK: - Encrypt + + func testSegmentedEncryptSplitsPayloadIntoSegments() throws { + let plaintext = Self.deterministicBytes(count: 5 * 1024 * 1024 + 123) + let result = try TDFEncryptor().encrypt( + plaintext: plaintext, + configuration: makeConfiguration(), + segmentSize: Self.segmentSize, + ) + + let integrity = try XCTUnwrap(result.container.manifest.encryptionInformation.integrityInformation) + XCTAssertEqual(integrity.segments.map(\.segmentSize), [2_097_152, 2_097_152, 1_048_699]) + XCTAssertEqual(integrity.segments.map(\.encryptedSegmentSize), [2_097_180, 2_097_180, 1_048_727]) + XCTAssertEqual(integrity.segmentSizeDefault, 2_097_152) + XCTAssertEqual(integrity.encryptedSegmentSizeDefault, 2_097_180) + XCTAssertEqual(integrity.segmentHashAlg, "GMAC") + XCTAssertEqual(result.container.payload.count, 2_097_180 * 2 + 1_048_727) + + // Each segment hash is the GMAC (AES-GCM tag) of that segment; the root + // signature is HMAC-SHA256(DEK, concat(tags)). + var offset = result.container.payload.startIndex + var tags: [Data] = [] + for segment in integrity.segments { + let encryptedSize = try Int(XCTUnwrap(segment.encryptedSegmentSize)) + let segmentData = result.container.payload[offset ..< offset + encryptedSize] + let tag = Data(segmentData.suffix(16)) + XCTAssertEqual(segment.hash, tag.base64EncodedString()) + tags.append(tag) + offset += encryptedSize + } + XCTAssertEqual( + integrity.rootSignature.sig, + TDFCrypto.rootSignatureBase64(rawSegmentSignatures: tags, symmetricKey: result.symmetricKey), + ) + } + + func testSegmentedEncryptExactMultipleHasNoEmptyTrailingSegment() throws { + let plaintext = Self.deterministicBytes(count: 2 * Self.segmentSize) + let result = try TDFEncryptor().encrypt( + plaintext: plaintext, + configuration: makeConfiguration(), + segmentSize: Self.segmentSize, + ) + + let integrity = try XCTUnwrap(result.container.manifest.encryptionInformation.integrityInformation) + XCTAssertEqual(integrity.segments.map(\.segmentSize), [2_097_152, 2_097_152]) + } + + func testSegmentedEncryptRejectsNonPositiveSegmentSize() throws { + XCTAssertThrowsError(try TDFEncryptor().encrypt( + plaintext: Data([1, 2, 3]), + configuration: makeConfiguration(), + segmentSize: 0, + )) + } + + // MARK: - Decrypt + + func testSegmentedRoundTripThroughArchive() throws { + let plaintext = Self.deterministicBytes(count: 5 * 1024 * 1024 + 123) + let result = try TDFEncryptor().encrypt( + plaintext: plaintext, + configuration: makeConfiguration(), + segmentSize: Self.segmentSize, + ) + + let archive = try result.container.serializedData() + let loaded = try TDFLoader().load(from: archive) + let decrypted = try TDFDecryptor().decrypt(container: loaded, symmetricKey: result.symmetricKey) + + XCTAssertEqual(decrypted, plaintext) + } + + func testSegmentedRoundTripEmptyPlaintext() throws { + let result = try TDFEncryptor().encrypt( + plaintext: Data(), + configuration: makeConfiguration(), + segmentSize: Self.segmentSize, + ) + + let integrity = try XCTUnwrap(result.container.manifest.encryptionInformation.integrityInformation) + XCTAssertEqual(integrity.segments.count, 1) + let decrypted = try TDFDecryptor().decrypt(container: result.container, symmetricKey: result.symmetricKey) + XCTAssertEqual(decrypted, Data()) + } + + func testInMemoryDecryptHandlesFileMultiSegmentOutput() throws { + let plaintext = Self.deterministicBytes(count: 2 * 1024 * 1024 + 512 * 1024) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let inputURL = directory.appendingPathComponent("input.bin") + let outputURL = directory.appendingPathComponent("output.tdf") + try plaintext.write(to: inputURL) + + let result = try TDFEncryptor().encryptFileMultiSegment( + inputURL: inputURL, + outputURL: outputURL, + configuration: makeConfiguration(), + segmentSizes: [1024 * 1024, 1024 * 1024, 1024 * 1024], + ) + + let loaded = try TDFLoader().load(from: outputURL) + XCTAssertEqual(loaded.manifest.encryptionInformation.integrityInformation?.segments.count, 3) + let decrypted = try TDFDecryptor().decrypt(container: loaded, symmetricKey: result.symmetricKey) + XCTAssertEqual(decrypted, plaintext) + } + + func testSegmentedDecryptRejectsTamperedSegment() throws { + let plaintext = Self.deterministicBytes(count: 5 * 1024 * 1024) + let result = try TDFEncryptor().encrypt( + plaintext: plaintext, + configuration: makeConfiguration(), + segmentSize: Self.segmentSize, + ) + + var payload = result.container.payload + let secondSegmentCiphertext = payload.startIndex + 2_097_180 + 12 + 100 + payload[secondSegmentCiphertext] ^= 0x01 + let tampered = TDFContainer(manifest: result.container.manifest, payload: payload) + + XCTAssertThrowsError(try TDFDecryptor().decrypt(container: tampered, symmetricKey: result.symmetricKey)) + } + + func testSegmentedDecryptRejectsPayloadSizeMismatch() throws { + let plaintext = Self.deterministicBytes(count: 3 * 1024 * 1024) + let result = try TDFEncryptor().encrypt( + plaintext: plaintext, + configuration: makeConfiguration(), + segmentSize: Self.segmentSize, + ) + + let truncated = TDFContainer( + manifest: result.container.manifest, + payload: result.container.payload.dropLast(1), + ) + + XCTAssertThrowsError(try TDFDecryptor().decrypt(container: truncated, symmetricKey: result.symmetricKey)) + } + + // MARK: - Integrity + + func testDecryptRejectsReorderedSegments() throws { + let result = try encryptThreeSegments() + let payload = result.container.payload + let segment = 2_097_180 + var swapped = Data() + swapped.append(payload[payload.startIndex + segment ..< payload.startIndex + 2 * segment]) + swapped.append(payload[payload.startIndex ..< payload.startIndex + segment]) + swapped.append(payload[(payload.startIndex + 2 * segment)...]) + let reordered = TDFContainer(manifest: result.container.manifest, payload: swapped) + + assertIntegrityFailure(try TDFDecryptor().decrypt(container: reordered, symmetricKey: result.symmetricKey)) + } + + func testDecryptRejectsTruncationToSingleSegment() throws { + let result = try encryptThreeSegments() + var manifest = result.container.manifest + var integrity = try XCTUnwrap(manifest.encryptionInformation.integrityInformation) + integrity.segments = [integrity.segments[0]] + manifest.encryptionInformation.integrityInformation = integrity + let firstSegment = result.container.payload.prefix(2_097_180) + let truncated = TDFContainer(manifest: manifest, payload: Data(firstSegment)) + + assertIntegrityFailure(try TDFDecryptor().decrypt(container: truncated, symmetricKey: result.symmetricKey)) + } + + func testDecryptRejectsDroppedTrailingSegment() throws { + let result = try encryptThreeSegments() + var manifest = result.container.manifest + var integrity = try XCTUnwrap(manifest.encryptionInformation.integrityInformation) + integrity.segments.removeLast() + manifest.encryptionInformation.integrityInformation = integrity + let truncated = TDFContainer(manifest: manifest, payload: Data(result.container.payload.prefix(2 * 2_097_180))) + + assertIntegrityFailure(try TDFDecryptor().decrypt(container: truncated, symmetricKey: result.symmetricKey)) + } + + func testDecryptRejectsTamperedSegmentHash() throws { + let result = try encryptThreeSegments() + var manifest = result.container.manifest + var integrity = try XCTUnwrap(manifest.encryptionInformation.integrityInformation) + integrity.segments[1].hash = Data(count: 16).base64EncodedString() + manifest.encryptionInformation.integrityInformation = integrity + let tampered = TDFContainer(manifest: manifest, payload: result.container.payload) + + assertIntegrityFailure(try TDFDecryptor().decrypt(container: tampered, symmetricKey: result.symmetricKey)) + } + + func testDecryptRejectsKeylessRootSignatureDowngrade() throws { + // A GMAC root is just the last segment's tag; accepting it would let a + // truncated payload carry a forged root. + let result = try encryptThreeSegments() + var manifest = result.container.manifest + var integrity = try XCTUnwrap(manifest.encryptionInformation.integrityInformation) + integrity.segments.removeLast() + let truncatedPayload = Data(result.container.payload.prefix(2 * 2_097_180)) + integrity.rootSignature = TDFRootSignature(alg: "GMAC", sig: Data(truncatedPayload.suffix(16)).base64EncodedString()) + manifest.encryptionInformation.integrityInformation = integrity + let forged = TDFContainer(manifest: manifest, payload: truncatedPayload) + + XCTAssertThrowsError(try TDFDecryptor().decrypt(container: forged, symmetricKey: result.symmetricKey)) { error in + XCTAssertEqual(error as? TDFDecryptError, .unsupportedIntegrityAlgorithm("GMAC")) + } + } + + func testDecryptRejectsMissingIntegrityInformation() throws { + let result = try encryptThreeSegments() + var manifest = result.container.manifest + manifest.encryptionInformation.integrityInformation = nil + let stripped = TDFContainer(manifest: manifest, payload: result.container.payload) + + XCTAssertThrowsError(try TDFDecryptor().decrypt(container: stripped, symmetricKey: result.symmetricKey)) { error in + XCTAssertEqual(error as? TDFDecryptError, .missingIntegrityInformation) + } + } + + // MARK: - Helpers + + private func encryptThreeSegments() throws -> TDFEncryptionResult { + try TDFEncryptor().encrypt( + plaintext: Self.deterministicBytes(count: 5 * 1024 * 1024), + configuration: makeConfiguration(), + segmentSize: Self.segmentSize, + ) + } + + private func assertIntegrityFailure( + _ expression: @autoclosure () throws -> some Any, + file: StaticString = #filePath, + line: UInt = #line, + ) { + XCTAssertThrowsError(try expression(), file: file, line: line) { error in + XCTAssertEqual(error as? TDFDecryptError, .integrityCheckFailed, file: file, line: line) + } + } + + private func makeConfiguration() throws -> TDFEncryptionConfiguration { + let pem = try XCTUnwrap(Self.kasPublicKeyPEM, "openssl RSA key generation failed") + let kasInfo = try TDFKasInfo( + url: XCTUnwrap(URL(string: "https://kas.example.com/kas")), + publicKeyPEM: pem, + kid: "r1", + ) + let policy = try TDFPolicy(json: Data(#"{"uuid":"segmented-test","body":{"dataAttributes":[],"dissem":[]}}"#.utf8)) + return TDFEncryptionConfiguration(kas: kasInfo, policy: policy) + } + + /// Same deterministic pattern as the cross-SDK E2E benchmark: byte i = (i*131 + (i>>8)*17) & 255. + static func deterministicBytes(count: Int) -> Data { + Data((0 ..< count).map { UInt8(truncatingIfNeeded: $0 &* 131 &+ ($0 >> 8) &* 17) }) + } + + /// RSA-2048 SPKI public key PEM for tests that need a KAS wrapping key. + static func makeRSAPublicKeyPEM() throws -> String { + let attributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits as String: 2048, + ] + var error: Unmanaged? + guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error), + let publicKey = SecKeyCopyPublicKey(privateKey), + let pkcs1 = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? + else { + throw error?.takeRetainedValue() ?? NSError(domain: "TDFSegmentedPayloadTests", code: 1) + } + // Wrap PKCS#1 RSAPublicKey in an SPKI header for rsaEncryption. + let algorithmIdentifier: [UInt8] = [0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00] + let bitString = derElement(tag: 0x03, content: Data([0x00]) + pkcs1) + let spki = derElement(tag: 0x30, content: Data(algorithmIdentifier) + bitString) + let base64 = spki.base64EncodedString(options: [.lineLength64Characters, .endLineWithLineFeed]) + return "-----BEGIN PUBLIC KEY-----\n\(base64)\n-----END PUBLIC KEY-----\n" + } + + private static func derElement(tag: UInt8, content: Data) -> Data { + var out = Data([tag]) + let length = content.count + if length < 0x80 { + out.append(UInt8(length)) + } else { + let lengthBytes = withUnsafeBytes(of: UInt32(length).bigEndian) { Data($0) }.drop { $0 == 0 } + out.append(0x80 | UInt8(lengthBytes.count)) + out.append(contentsOf: lengthBytes) + } + return out + content + } +} diff --git a/OpenTDFKitTests/TDFSpecAlignmentTests.swift b/OpenTDFKitTests/TDFSpecAlignmentTests.swift new file mode 100644 index 0000000..1a9bfa7 --- /dev/null +++ b/OpenTDFKitTests/TDFSpecAlignmentTests.swift @@ -0,0 +1,177 @@ +import CryptoKit +import Foundation +@testable import OpenTDFKit +import XCTest + +/// Manifest shapes the OpenTDF spec (and in-flight spec PR #70) allows and other +/// SDKs write, which OpenTDFKit must parse and decrypt. +final class TDFSpecAlignmentTests: XCTestCase { + // MARK: - Manifest decoding + + func testDecodesPolicyBindingStringFormAsHS256() throws { + let manifest = try decodeManifest(policyBinding: #""Zm9v""#) + let binding = manifest.encryptionInformation.keyAccess[0].policyBinding + XCTAssertEqual(binding.alg, "HS256") + XCTAssertEqual(binding.hash, "Zm9v") + } + + func testDecodesPolicyBindingWithAbsentOrEmptyAlgAsHS256() throws { + for binding in [#"{"hash":"Zm9v"}"#, #"{"alg":"","hash":"Zm9v"}"#] { + let manifest = try decodeManifest(policyBinding: binding) + XCTAssertEqual(manifest.encryptionInformation.keyAccess[0].policyBinding.alg, "HS256") + } + } + + func testDecodesRootSignatureWithAbsentAlgAsHS256() throws { + let manifest = try decodeManifest(rootSignature: #"{"sig":"Zm9v"}"#) + XCTAssertEqual(manifest.encryptionInformation.integrityInformation?.rootSignature.alg, "HS256") + } + + func testDecodesSegmentsWithoutPlaintextSizeUsingDefault() throws { + let manifest = try decodeManifest(segments: #"[{"hash":"Zm9v","encryptedSegmentSize":0}]"#) + let segment = try XCTUnwrap(manifest.encryptionInformation.integrityInformation?.segments.first) + XCTAssertEqual(segment.segmentSize, 2_097_152) + XCTAssertNil(segment.encryptedSegmentSize, "encryptedSegmentSize 0 means omitted") + } + + func testDecodesNewKeyAccessTypesAndZipstream() throws { + for type in ["hybrid-wrapped", "mlkem-wrapped", "ec-wrapped"] { + let manifest = try decodeManifest(keyAccessType: type, payloadProtocol: "zipstream") + XCTAssertEqual(manifest.encryptionInformation.keyAccess[0].type.rawValue, type) + XCTAssertEqual(manifest.payload.protocolValue, .zipstream) + } + } + + func testDecodesAnyAssertionStatementFormat() throws { + let assertion = #"[{"id":"system-metadata","type":"other","scope":"payload","appliesToState":"unencrypted","statement":{"format":"json","schema":"system-metadata-v1","value":"{}"},"binding":{"method":"jws","signature":"x"}}]"# + let manifest = try decodeManifest(assertions: assertion) + XCTAssertEqual(manifest.assertions?.first?.statement.format.rawValue, "json") + } + + // MARK: - Encryption output + + func testEncryptWritesFirstSegmentNonceAsMethodIV() throws { + let result = try TDFEncryptor().encrypt( + plaintext: Data(count: 3 * 1024 * 1024), + configuration: makeConfiguration(), + segmentSize: 2 * 1024 * 1024, + ) + let iv = try XCTUnwrap(Data(base64Encoded: result.container.manifest.encryptionInformation.method.iv)) + XCTAssertEqual(iv, result.container.payload.prefix(12)) + } + + func testEncryptAlwaysWritesSpecVersion430() throws { + let configuration = try makeConfiguration() + let result = try TDFEncryptor().encrypt(plaintext: Data("x".utf8), configuration: configuration) + XCTAssertEqual(result.container.manifest.schemaVersion, "4.3.0") + } + + // MARK: - Decryption checks + + func testDecryptAcceptsRootSignatureWithEmptyAlg() throws { + let result = try TDFEncryptor().encrypt(plaintext: Data("hello".utf8), configuration: makeConfiguration(), segmentSize: 1024) + var manifest = result.container.manifest + manifest.encryptionInformation.integrityInformation?.rootSignature.alg = "" + let container = TDFContainer(manifest: manifest, payload: result.container.payload) + + XCTAssertEqual(try TDFDecryptor().decrypt(container: container, symmetricKey: result.symmetricKey), Data("hello".utf8)) + } + + func testDecryptRejectsUnsupportedPayloadAlgorithm() throws { + let result = try TDFEncryptor().encrypt(plaintext: Data("hello".utf8), configuration: makeConfiguration(), segmentSize: 1024) + var manifest = result.container.manifest + manifest.encryptionInformation.method.algorithm = "AES-256-CBC" + let container = TDFContainer(manifest: manifest, payload: result.container.payload) + + XCTAssertThrowsError(try TDFDecryptor().decrypt(container: container, symmetricKey: result.symmetricKey)) { error in + XCTAssertEqual(error as? TDFDecryptError, .unsupportedPayloadAlgorithm("AES-256-CBC")) + } + } + + // MARK: - Key splits + + func testCombinesOneSharePerSplitID() throws { + let share1 = Data((0 ..< 32).map { UInt8($0) }) + let share2 = Data((0 ..< 32).map { UInt8(255 - $0) }) + // Two alternatives for split "a" (same share), one object for split "b". + let shares: [(sid: String?, share: Data?)] = [("a", nil), ("a", share1), ("b", share2)] + let combined = try TDFDecryptor.combineKeyShares(shares.map { entry in + (sid: entry.sid, unwrap: { () throws -> Data in + guard let share = entry.share else { throw TDFDecryptError.missingKeyAccess } + return share + }) + }) + XCTAssertEqual(combined, Data(zip(share1, share2).map { $0 ^ $1 })) + } + + func testTreatsObjectsWithoutSplitIDAsAlternatives() throws { + let share = Data(repeating: 0x5A, count: 32) + let combined = try TDFDecryptor.combineKeyShares([ + (sid: nil, unwrap: { share }), + (sid: nil, unwrap: { share }), + ]) + XCTAssertEqual(combined, share, "same-split objects must not be XORed together") + } + + func testCombineFailsWhenASplitHasNoUsableShare() { + XCTAssertThrowsError(try TDFDecryptor.combineKeyShares([ + (sid: "a", unwrap: { Data(count: 32) }), + (sid: "b", unwrap: { throw TDFDecryptError.missingKeyAccess }), + ])) + } + + // MARK: - File multi-segment encryption + + func testFileMultiSegmentEncryptsInputBeyondListedSizes() throws { + let plaintext = Data((0 ..< 5000).map { UInt8(truncatingIfNeeded: $0) }) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let inputURL = directory.appendingPathComponent("in.bin") + let outputURL = directory.appendingPathComponent("out.tdf") + try plaintext.write(to: inputURL) + + let result = try TDFEncryptor().encryptFileMultiSegment( + inputURL: inputURL, + outputURL: outputURL, + configuration: makeConfiguration(), + segmentSizes: [1000, 1500], + ) + + let segments = try XCTUnwrap(result.container.manifest.encryptionInformation.integrityInformation?.segments) + XCTAssertEqual(segments.map(\.segmentSize), [1000, 1500, 1500, 1000], "the last size repeats until EOF") + let loaded = try TDFLoader().load(from: outputURL) + XCTAssertEqual(try TDFDecryptor().decrypt(container: loaded, symmetricKey: result.symmetricKey), plaintext) + } + + // MARK: - Helpers + + private static let kasPublicKeyPEM: String? = try? TDFSegmentedPayloadTests.makeRSAPublicKeyPEM() + + private func makeConfiguration() throws -> TDFEncryptionConfiguration { + let pem = try XCTUnwrap(Self.kasPublicKeyPEM) + let kasInfo = try TDFKasInfo(url: XCTUnwrap(URL(string: "https://kas.example.com/kas")), publicKeyPEM: pem, kid: "r1") + let policy = try TDFPolicy(json: Data(#"{"uuid":"3f1c2d4e-5a6b-4c7d-8e9f-0a1b2c3d4e5f","body":{"dataAttributes":[],"dissem":[]}}"#.utf8)) + return TDFEncryptionConfiguration(kas: kasInfo, policy: policy) + } + + private func decodeManifest( + policyBinding: String = #"{"alg":"HS256","hash":"Zm9v"}"#, + rootSignature: String = #"{"alg":"HS256","sig":"Zm9v"}"#, + segments: String = #"[{"hash":"Zm9v","segmentSize":5,"encryptedSegmentSize":33}]"#, + keyAccessType: String = "wrapped", + payloadProtocol: String = "zip", + assertions: String = "null", + ) throws -> TDFManifest { + let json = """ + {"schemaVersion":"4.3.0", + "payload":{"type":"reference","url":"0.payload","protocol":"\(payloadProtocol)","isEncrypted":true,"mimeType":"application/octet-stream"}, + "encryptionInformation":{"type":"split","policy":"e30=", + "keyAccess":[{"type":"\(keyAccessType)","url":"https://kas.example.com/kas","protocol":"kas","wrappedKey":"Zm9v","policyBinding":\(policyBinding),"kid":"r1"}], + "method":{"algorithm":"AES-256-GCM","iv":"","isStreamable":true}, + "integrityInformation":{"rootSignature":\(rootSignature),"segmentHashAlg":"GMAC","segmentSizeDefault":2097152,"encryptedSegmentSizeDefault":2097180,"segments":\(segments)}}, + "assertions":\(assertions)} + """ + return try JSONDecoder().decode(TDFManifest.self, from: Data(json.utf8)) + } +} From ab2c08ce5f96676ae7219c6561976c6077b9191e Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 8 Oct 2026 19:28:55 -0400 Subject: [PATCH 2/8] fix(tdf): emit spec Base64(HMAC) policy binding; pin xtest platform to #4081 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TDFCrypto.policyBinding now writes Base64(HMAC-SHA256(DEK, Base64(policy))) (44 chars, 32 raw bytes) per the OpenTDF spec, dropping the Go SDK's legacy Base64(hex(HMAC)) form. Known-answer test derived from the old vector. Rewrap requires a KAS that accepts the spec form: arkavo-org/opentdf-platform #54 (deployed to platform.arkavo.net; verified live — spec form permitted, tampered binding refused; stock Go decrypts the result). xtest runs the upstream platform service, so pin it to opentdf/platform#4081 (ca178b05) until a service/* release includes that fix. Closes #56 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef --- .github/workflows/xtest.yml | 9 +++++++-- OpenTDFKit/TDF/TDFCrypto.swift | 22 ++++++++-------------- OpenTDFKitTests/TDFTests.swift | 21 +++++++-------------- 3 files changed, 22 insertions(+), 30 deletions(-) diff --git a/.github/workflows/xtest.yml b/.github/workflows/xtest.yml index 10678da..0af84af 100644 --- a/.github/workflows/xtest.yml +++ b/.github/workflows/xtest.yml @@ -40,7 +40,9 @@ on: platform-ref: required: false type: string - default: latest + # TODO: back to `latest` once a service/* release includes + # opentdf/platform#4081 (KAS accepts spec Base64(HMAC) policy bindings). + default: ca178b05ee15c0ba5b075f0a2b05d1da6dd585bc description: "opentdf/platform ref: 'latest'/'lts', branch, tag, or SHA" otdfctl-ref: required: false @@ -79,7 +81,10 @@ jobs: fork-channel: ${{ steps.resolve.outputs.fork-channel }} env: TESTS_REF_IN: ${{ inputs.tests-ref || 'latest' }} - PLATFORM_REF_IN: ${{ inputs.platform-ref || 'latest' }} + # Pinned until a service/* release includes opentdf/platform#4081: + # OpenTDFKit writes spec-form policy bindings, which released KAS + # versions reject (arkavo-org/OpenTDFKit#56). + PLATFORM_REF_IN: ${{ inputs.platform-ref || 'ca178b05ee15c0ba5b075f0a2b05d1da6dd585bc' }} OTDFCTL_REF_IN: ${{ inputs.otdfctl-ref || 'latest' }} FORK_REF_IN: ${{ inputs.fork-ref || 'main' }} GH_TOKEN: ${{ github.token }} diff --git a/OpenTDFKit/TDF/TDFCrypto.swift b/OpenTDFKit/TDF/TDFCrypto.swift index c881ede..ee9c3fd 100644 --- a/OpenTDFKit/TDF/TDFCrypto.swift +++ b/OpenTDFKit/TDF/TDFCrypto.swift @@ -162,22 +162,16 @@ public enum TDFCrypto { return Data(plaintext) } - /// Compute the Standard TDF policy binding hash (OpenTDF / go SDK format). + /// Compute the Standard TDF policy binding: `Base64(HMAC-SHA256(DEK, Base64(policyJSON)))`, + /// 44 characters decoding to the raw 32-byte HMAC (OpenTDF spec + /// `schema/OpenTDF/key_access_object.md`, `concepts/security.md`). /// - /// 1. Base64-encode the raw policy JSON bytes - /// 2. HMAC-SHA256 the base64 string as UTF-8 using the payload DEK - /// 3. Hex-encode the 32-byte HMAC (64 lowercase hex chars) - /// 4. Base64-encode that hex string for the manifest `policyBinding.hash` - /// - /// Using raw HMAC base64 (previous behavior) fails KAS rewrap with - /// "tamper detected" when go/java decrypt a Swift-produced TDF. + /// The Go SDK's legacy `Base64(hex(HMAC))` form is not written. Rewrapping + /// these TDFs needs a KAS that accepts the spec form (opentdf/platform#4081). public static func policyBinding(policy: Data, symmetricKey: SymmetricKey) -> TDFPolicyBinding { - let policyBase64 = policy.base64EncodedString() - let policyBase64Bytes = Data(policyBase64.utf8) - let hmac = HMAC.authenticationCode(for: policyBase64Bytes, using: symmetricKey) - let hex = Data(hmac).map { String(format: "%02x", $0) }.joined() - let hash = Data(hex.utf8).base64EncodedString() - return TDFPolicyBinding(alg: "HS256", hash: hash) + let policyBase64 = Data(policy.base64EncodedString().utf8) + let hmac = HMAC.authenticationCode(for: policyBase64, using: symmetricKey) + return TDFPolicyBinding(alg: "HS256", hash: Data(hmac).base64EncodedString()) } public static func data(from symmetricKey: SymmetricKey) -> Data { diff --git a/OpenTDFKitTests/TDFTests.swift b/OpenTDFKitTests/TDFTests.swift index edb378d..2259776 100644 --- a/OpenTDFKitTests/TDFTests.swift +++ b/OpenTDFKitTests/TDFTests.swift @@ -45,27 +45,20 @@ final class StandardTDFTests: XCTestCase { XCTAssertFalse(binding.alg.isEmpty, "Policy binding algorithm should not be empty") XCTAssertFalse(binding.hash.isEmpty, "Policy binding hash should not be empty") XCTAssertEqual(binding.alg, "HS256") - // Go format: base64(hex(HMAC(base64(policyJSON)))) → 88 chars of base64 - // (64 hex chars of HMAC-SHA256 → 88 base64 chars with padding). - XCTAssertEqual(binding.hash.count, 88, "OpenTDF policy binding hash is base64(hex(hmac))") - // Must decode as base64 of a 64-char hex string, not raw 32-byte HMAC. + // Spec: Base64(HMAC-SHA256(DEK, Base64(policyJSON))) → 44 chars, 32 raw bytes. + XCTAssertEqual(binding.hash.count, 44, "OpenTDF policy binding hash is Base64(HMAC)") let hashData = try XCTUnwrap(Data(base64Encoded: binding.hash)) - let hex = try XCTUnwrap(String(data: hashData, encoding: .utf8)) - XCTAssertEqual(hex.count, 64) - XCTAssertTrue(hex.allSatisfy(\.isHexDigit)) + XCTAssertEqual(hashData.count, 32) } - func testPolicyBindingMatchesGoFormatVector() { - // Fixed DEK + policy so we can lock the go/rust/python wire format. + func testPolicyBindingMatchesSpecVector() { + // Fixed DEK + policy; the raw HMAC under the Go SDK's legacy hex vector + // OGE3MDAy…YTE3Yg== (hex 8a70026b…ab69a17b), Base64-encoded per the spec. let keyBytes = Data(repeating: 0x30, count: 32) // ASCII '0' * 32 let symmetricKey = SymmetricKey(data: keyBytes) let policyJSON = #"{"uuid":"u","body":{"dataAttributes":[],"dissem":[]}}"#.data(using: .utf8)! let binding = TDFCrypto.policyBinding(policy: policyJSON, symmetricKey: symmetricKey) - // Go: base64(hex(HMAC-SHA256(key, base64(policy)))) - XCTAssertEqual( - binding.hash, - "OGE3MDAyNmI3OWFiMWVhMTYyMzU4MWViN2E5MmEwNzlmYzJkNWU1OTFhZmU5M2JlMTE3YWJiYjVhYjY5YTE3Yg==", - ) + XCTAssertEqual(binding.hash, "inACa3mrHqFiNYHrepKgefwtXlka/pO+EXq7tatpoXs=") } func testStandardTDFSessionSaltIsSHA256OfTDF() { From 0c7f4f9988862437f1159ddbb4f8ea26a4e10fcd Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 8 Oct 2026 20:14:54 -0400 Subject: [PATCH 3/8] fix(cli): disambiguate Data.withUnsafeMutableBytes for Swift 6.3 CI's Swift 6.3.3 resolves the untyped closure in deterministicInput to both the raw-buffer overload and the deprecated typed-pointer one ("ambiguous use of 'withUnsafeMutableBytes'"), failing build, test, benchmark and both xtest legs. Name the parameter type, as the library's other call sites do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef --- OpenTDFKitCLI/E2EBenchmark.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenTDFKitCLI/E2EBenchmark.swift b/OpenTDFKitCLI/E2EBenchmark.swift index 8f09be3..4e21708 100644 --- a/OpenTDFKitCLI/E2EBenchmark.swift +++ b/OpenTDFKitCLI/E2EBenchmark.swift @@ -230,7 +230,7 @@ enum E2EBenchmark { /// Deterministic input shared with the cross-SDK harness: byte i = (i*131 + (i>>8)*17) & 255. static func deterministicInput(count: Int) -> Data { var data = Data(count: count) - data.withUnsafeMutableBytes { buffer in + data.withUnsafeMutableBytes { (buffer: UnsafeMutableRawBufferPointer) in for index in 0 ..< count { buffer[index] = UInt8(truncatingIfNeeded: index &* 131 &+ (index >> 8) &* 17) } From 4e40cabf420f6ba60f0a74110a9233aafc490133 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 8 Oct 2026 20:41:30 -0400 Subject: [PATCH 4/8] revert(tdf): drop ZIP I/O tuning that raised retained memory CI's testMemoryUsageWith2MBChunk (resident memory left after encryptFile) went from 64 MB on main to 100 MB with this branch's archive changes. Dispatching the Swift workflow on isolating branches pinned it down: all three changes reverted 43 MB, preallocation + slices 64 MB, plus the 1 MiB entry chunks 100 MB. Restore main's TDFArchive.swift; the 7-9% offline TDF gain isn't worth the retained memory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef --- OpenTDFKit/TDF/TDFArchive.swift | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/OpenTDFKit/TDF/TDFArchive.swift b/OpenTDFKit/TDF/TDFArchive.swift index 75af7f3..4583fd5 100644 --- a/OpenTDFKit/TDF/TDFArchive.swift +++ b/OpenTDFKit/TDF/TDFArchive.swift @@ -131,7 +131,7 @@ public struct TDFArchiveReader { public func payloadData() throws -> Data { let entry = try payloadEntry() var result = Data(capacity: Int(entry.uncompressedSize)) - let _ = try archive.extract(entry, bufferSize: TDFArchiveWriter.ioChunkSize) { chunk in + let _ = try archive.extract(entry) { chunk in result.append(chunk) } return result @@ -162,26 +162,16 @@ public struct TDFArchiveWriter { self.compressionMethod = compressionMethod } - /// Chunk size for ZIP entry I/O. Larger than the stdio buffer behind - /// ZIPFoundation's in-memory archive, so chunks pass straight through to it - /// instead of being staged in 16 KiB pieces. - static let ioChunkSize = 1 << 20 - public func buildArchive(manifest: TDFManifest, payload: Data) throws -> Data { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let manifestData = try encoder.encode(manifest) - - // Reserve the whole archive up front (entries plus ZIP headers and - // directory) so the in-memory archive is not reallocated as it grows. - var storage = Data() - storage.reserveCapacity(payload.count + manifestData.count + 4096) let archive: ZIPFoundation.Archive do { - archive = try ZIPFoundation.Archive(data: storage, accessMode: .create) + archive = try ZIPFoundation.Archive(data: Data(), accessMode: .create) } catch { throw TDFArchiveError.creationFailed } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let manifestData = try encoder.encode(manifest) let payloadName = try TDFArchiveEntryNames.payloadEntry(for: manifest) try addEntry(named: TDFArchiveEntryNames.manifest, data: manifestData, to: archive) try addEntry(named: payloadName, data: payload, to: archive) @@ -214,15 +204,14 @@ public struct TDFArchiveWriter { type: .file, uncompressedSize: Int64(data.count), compressionMethod: compressionMethod, - bufferSize: Self.ioChunkSize, + bufferSize: ZIPFoundation.defaultWriteChunkSize, provider: { position, size -> Data in let start = Int(position) guard start < data.count, size > 0 else { return Data() } let upper = min(start + size, data.count) - // A slice shares `data`'s storage; `subdata` would copy each chunk. - return data[data.startIndex + start ..< data.startIndex + upper] + return data.subdata(in: start ..< upper) }, ) } From 0cd1fb9206ff61b4550a5c4cd56df3f0784f84dc Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 8 Oct 2026 20:52:41 -0400 Subject: [PATCH 5/8] ci(xtest): pin upstream go peer to opentdf/platform#4063 Released otdfctl (v0.38.1) reads only 0.manifest.json and cannot open OpenTDFKit's spec-named manifest.json, so the upstream swift -> go leg has failed since #53. Build the go CLI from the #4063 merge commit (a36b5dfd), the first upstream SDK that reads manifest.json, until an otdfctl/* tag includes it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef --- .github/workflows/xtest.yml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/xtest.yml b/.github/workflows/xtest.yml index 0af84af..e8a000f 100644 --- a/.github/workflows/xtest.yml +++ b/.github/workflows/xtest.yml @@ -5,7 +5,8 @@ # for swift × go Base TDF only. # # Two legs run side by side (matrix `go-source`): -# upstream — go CLI from the released opentdf/platform `otdfctl/*` tag. +# upstream — go CLI from opentdf/platform (pinned to the #4063 merge until +# an `otdfctl/*` tag includes it; then the latest tag). # arkavo-fork — go CLI (otdfctl + sdk) built from arkavo-org/opentdf-platform # at `fork-ref` via the fork's go.work. # The platform SERVICE is upstream in both legs: the fork's service auth is @@ -47,7 +48,9 @@ on: otdfctl-ref: required: false type: string - default: latest + # TODO: back to `latest` once an otdfctl/* release includes + # opentdf/platform#4063 (Go reads the spec manifest.json entry). + default: a36b5dfd59a11e76c8a3ac10466b5f1b5a5bce27 description: "go/otdfctl peer ref: 'latest'/'lts', branch, tag, or SHA" fork-ref: required: false @@ -85,7 +88,10 @@ jobs: # OpenTDFKit writes spec-form policy bindings, which released KAS # versions reject (arkavo-org/OpenTDFKit#56). PLATFORM_REF_IN: ${{ inputs.platform-ref || 'ca178b05ee15c0ba5b075f0a2b05d1da6dd585bc' }} - OTDFCTL_REF_IN: ${{ inputs.otdfctl-ref || 'latest' }} + # Pinned until an otdfctl/* release includes opentdf/platform#4063: + # released go reads only 0.manifest.json; this repo writes the spec + # name manifest.json. + OTDFCTL_REF_IN: ${{ inputs.otdfctl-ref || 'a36b5dfd59a11e76c8a3ac10466b5f1b5a5bce27' }} FORK_REF_IN: ${{ inputs.fork-ref || 'main' }} GH_TOKEN: ${{ github.token }} # Harness main; SDK PRs pick up infra fixes without a re-tag. @@ -182,9 +188,8 @@ jobs: strategy: fail-fast: false matrix: - # upstream: released opentdf/platform otdfctl tag (stays red until - # opentdf/platform#4063 ships in a tag — released go only reads - # 0.manifest.json, this repo writes the spec name manifest.json). + # upstream: go CLI from opentdf/platform, pinned to the #4063 merge + # (reads the spec manifest.json) until an otdfctl/* tag ships it. # arkavo-fork: otdfctl + sdk from arkavo-org/opentdf-platform@fork-ref. go-source: [upstream, arkavo-fork] permissions: From 1db7113b294069aa7a2bbb9e16f22471fc723ae3 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 8 Oct 2026 21:01:25 -0400 Subject: [PATCH 6/8] ci(xtest): pin upstream go peer via refs/pull/4063/head otdf-sdk-mgr treats a bare historical SHA as a release tag (no artifact, no source build), so the a36b5dfd pin left no go CLI. A PR ref resolves as a head and is built from source under tag pull-; pin to #4063's head (immutable for the merged PR) and map PR refs to that channel name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef --- .github/workflows/xtest.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/xtest.yml b/.github/workflows/xtest.yml index e8a000f..12d21ea 100644 --- a/.github/workflows/xtest.yml +++ b/.github/workflows/xtest.yml @@ -5,7 +5,7 @@ # for swift × go Base TDF only. # # Two legs run side by side (matrix `go-source`): -# upstream — go CLI from opentdf/platform (pinned to the #4063 merge until +# upstream — go CLI from opentdf/platform (pinned to PR #4063's head until # an `otdfctl/*` tag includes it; then the latest tag). # arkavo-fork — go CLI (otdfctl + sdk) built from arkavo-org/opentdf-platform # at `fork-ref` via the fork's go.work. @@ -50,7 +50,7 @@ on: type: string # TODO: back to `latest` once an otdfctl/* release includes # opentdf/platform#4063 (Go reads the spec manifest.json entry). - default: a36b5dfd59a11e76c8a3ac10466b5f1b5a5bce27 + default: refs/pull/4063/head description: "go/otdfctl peer ref: 'latest'/'lts', branch, tag, or SHA" fork-ref: required: false @@ -91,7 +91,7 @@ jobs: # Pinned until an otdfctl/* release includes opentdf/platform#4063: # released go reads only 0.manifest.json; this repo writes the spec # name manifest.json. - OTDFCTL_REF_IN: ${{ inputs.otdfctl-ref || 'a36b5dfd59a11e76c8a3ac10466b5f1b5a5bce27' }} + OTDFCTL_REF_IN: ${{ inputs.otdfctl-ref || 'refs/pull/4063/head' }} FORK_REF_IN: ${{ inputs.fork-ref || 'main' }} GH_TOKEN: ${{ github.token }} # Harness main; SDK PRs pick up infra fixes without a re-tag. @@ -148,6 +148,11 @@ jobs: else GO_REF="$OTDFCTL_REF_IN" GO_CHANNEL="$OTDFCTL_REF_IN" + # otdf-sdk-mgr builds a PR ref from source under the tag pull- + # (sdk/go/dist/pull-); a bare SHA is treated as a release tag. + if [[ "$OTDFCTL_REF_IN" =~ ^refs/pull/([0-9]+)/ ]]; then + GO_CHANNEL="pull-${BASH_REMATCH[1]}" + fi fi # arkavo-fork leg: the go CLI is built from source, so the channel is @@ -188,7 +193,7 @@ jobs: strategy: fail-fast: false matrix: - # upstream: go CLI from opentdf/platform, pinned to the #4063 merge + # upstream: go CLI from opentdf/platform, pinned to PR #4063's head # (reads the spec manifest.json) until an otdfctl/* tag ships it. # arkavo-fork: otdfctl + sdk from arkavo-org/opentdf-platform@fork-ref. go-source: [upstream, arkavo-fork] From 15898b8eabb36b2669c78dcfd498cc1704e7811d Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 8 Oct 2026 21:02:35 -0400 Subject: [PATCH 7/8] ci(xtest): pin upstream go peer to #4063's head commit SHA Pin the immutable commit (29498d20) instead of refs/pull/4063/head. otdf-sdk-mgr resolves it through refs/pull/4063/head as a PR head, so it is still built from source, under the tag pull-4063; map that channel explicitly for the pinned SHA. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef --- .github/workflows/xtest.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/xtest.yml b/.github/workflows/xtest.yml index 12d21ea..61abdb6 100644 --- a/.github/workflows/xtest.yml +++ b/.github/workflows/xtest.yml @@ -50,7 +50,7 @@ on: type: string # TODO: back to `latest` once an otdfctl/* release includes # opentdf/platform#4063 (Go reads the spec manifest.json entry). - default: refs/pull/4063/head + default: 29498d207c8b7b80ab820ba50b0f674026ab277f description: "go/otdfctl peer ref: 'latest'/'lts', branch, tag, or SHA" fork-ref: required: false @@ -91,7 +91,7 @@ jobs: # Pinned until an otdfctl/* release includes opentdf/platform#4063: # released go reads only 0.manifest.json; this repo writes the spec # name manifest.json. - OTDFCTL_REF_IN: ${{ inputs.otdfctl-ref || 'refs/pull/4063/head' }} + OTDFCTL_REF_IN: ${{ inputs.otdfctl-ref || '29498d207c8b7b80ab820ba50b0f674026ab277f' }} FORK_REF_IN: ${{ inputs.fork-ref || 'main' }} GH_TOKEN: ${{ github.token }} # Harness main; SDK PRs pick up infra fixes without a re-tag. @@ -148,9 +148,12 @@ jobs: else GO_REF="$OTDFCTL_REF_IN" GO_CHANNEL="$OTDFCTL_REF_IN" - # otdf-sdk-mgr builds a PR ref from source under the tag pull- - # (sdk/go/dist/pull-); a bare SHA is treated as a release tag. - if [[ "$OTDFCTL_REF_IN" =~ ^refs/pull/([0-9]+)/ ]]; then + # otdf-sdk-mgr builds PR heads from source under the tag pull- + # (sdk/go/dist/pull-). The default pin is PR #4063's head commit, + # which it resolves through refs/pull/4063/head. + if [[ "$OTDFCTL_REF_IN" == "29498d207c8b7b80ab820ba50b0f674026ab277f" ]]; then + GO_CHANNEL="pull-4063" + elif [[ "$OTDFCTL_REF_IN" =~ ^refs/pull/([0-9]+)/ ]]; then GO_CHANNEL="pull-${BASH_REMATCH[1]}" fi fi @@ -194,7 +197,8 @@ jobs: fail-fast: false matrix: # upstream: go CLI from opentdf/platform, pinned to PR #4063's head - # (reads the spec manifest.json) until an otdfctl/* tag ships it. + # commit 29498d20 (reads the spec manifest.json) until an otdfctl/* + # tag ships it. # arkavo-fork: otdfctl + sdk from arkavo-org/opentdf-platform@fork-ref. go-source: [upstream, arkavo-fork] permissions: From 4a3a9d42237fc292adc3f05bc334d269b737fa1a Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Thu, 8 Oct 2026 21:18:51 -0400 Subject: [PATCH 8/8] fix(cli): map legacy rewrap results to KAOs with the client's KAS match The CLI's RSA-key rewrap path picked the key access objects for a KAS by exact URL string, while rewrapTDF numbers its kao- ids over objects matched by scheme, host and effective port. When one KAS appears under two URL spellings the lists diverge, shares get the wrong sid and the reconstructed DEK is wrong. Expose KASRewrapClient.keyAccessObjects(in:), the objects a rewrap sends in request order, and use it in the library and the CLI. (Gitar review on #57.) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BW3jJb6WzgesNADnPgGaef --- OpenTDFKit/KASRewrapClient.swift | 13 ++++++- OpenTDFKitCLI/Commands.swift | 2 +- .../KASRewrapAndUnwrapTDFTests.swift | 37 +++++++++++++++++-- 3 files changed, 46 insertions(+), 6 deletions(-) diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index 3dbb2bd..e70938c 100644 --- a/OpenTDFKit/KASRewrapClient.swift +++ b/OpenTDFKit/KASRewrapClient.swift @@ -573,7 +573,7 @@ public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { guard !policyBody.isEmpty, Data(base64Encoded: policyBody) != nil else { throw KASRewrapError.invalidTDFRequest("Policy must be non-empty base64") } - let keyAccessEntries = manifest.encryptionInformation.keyAccess.filter { matchesKasURL($0.url) } + let keyAccessEntries = keyAccessObjects(in: manifest) guard !keyAccessEntries.isEmpty else { throw KASRewrapError.invalidTDFRequest("No key access entries for KAS \(kasIdentityURL)") @@ -723,6 +723,15 @@ public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { return first.key } + /// The manifest's key access objects held by this client's KAS (matched by + /// scheme, host and effective port), in rewrap request order: the request + /// and result id `kao-` refers to element `i`. + /// - Parameter manifest: The parsed TDF manifest containing key access entries. + /// - Returns: The key access objects a `rewrapTDF` call sends. + public func keyAccessObjects(in manifest: TDFManifest) -> [TDFKeyAccessObject] { + manifest.encryptionInformation.keyAccess.filter { matchesKasURL($0.url) } + } + /// Rewraps every key access object this KAS holds and unwraps each returned /// key with the session key, tagged with its object's split ID. For a /// split-key manifest, collect shares from every KAS and pass them to @@ -730,7 +739,7 @@ public final class KASRewrapClient: KASRewrapClientProtocol, Sendable { /// - Parameter manifest: The parsed TDF manifest containing key access entries. /// - Returns: One share per permitted key access object at this KAS. public func rewrapAndUnwrapTDFShares(manifest: TDFManifest) async throws -> [TDFKeyShare] { - let entries = manifest.encryptionInformation.keyAccess.filter { matchesKasURL($0.url) } + let entries = keyAccessObjects(in: manifest) let clientPrivateKey = P256.KeyAgreement.PrivateKey() let result = try await rewrapTDF(manifest: manifest, clientPrivateKey: clientPrivateKey) diff --git a/OpenTDFKitCLI/Commands.swift b/OpenTDFKitCLI/Commands.swift index b908a90..dd2018f 100644 --- a/OpenTDFKitCLI/Commands.swift +++ b/OpenTDFKitCLI/Commands.swift @@ -245,7 +245,7 @@ enum Commands { // A legacy RSA client key needs the raw wrapped bytes, so run the rewrap // here; the EC session unwrap is still preferred when the KAS offers one. - let entries = keyAccess.filter { $0.url == kasURLString } + let entries = client.keyAccessObjects(in: container.manifest) let ephemeralPrivateKey = P256.KeyAgreement.PrivateKey() let result = try await client.rewrapTDF( manifest: container.manifest, diff --git a/OpenTDFKitTests/KASRewrapAndUnwrapTDFTests.swift b/OpenTDFKitTests/KASRewrapAndUnwrapTDFTests.swift index de2e95e..4033b94 100644 --- a/OpenTDFKitTests/KASRewrapAndUnwrapTDFTests.swift +++ b/OpenTDFKitTests/KASRewrapAndUnwrapTDFTests.swift @@ -110,6 +110,31 @@ final class KASRewrapAndUnwrapTDFTests: XCTestCase { XCTAssertEqual(shares.map(\.sid), ["split-0", "split-1"]) } + /// The objects a client sends are chosen by scheme, host and effective + /// port, so differently spelled URLs for one KAS are included and other + /// hosts are not; request id `kao-` is element `i`. + func testKeyAccessObjectsMatchKASBySchemeHostAndPort() throws { + let manifest = makeManifest(entries: [ + (url: "\(kasBaseURL)/kas", sid: "a"), + (url: "https://other.example.com/kas", sid: "x"), + (url: "https://KAS.example.com:443/kas/", sid: "b"), + ]) + XCTAssertEqual(try makeClient().keyAccessObjects(in: manifest).map(\.sid), ["a", "b"]) + } + + /// Shares from differently spelled URLs for one KAS keep their own split IDs. + func testRewrapAndUnwrapTDFSharesTagAliasURLsBySplit() async throws { + let fakeKAS = FakeKAS(salt: Self.standardTDFSalt, dek: SymmetricKey(size: .bits256)) + MockURLProtocol.handler = fakeKAS.handler + + let shares = try await makeClient().rewrapAndUnwrapTDFShares(manifest: makeManifest(entries: [ + (url: "\(kasBaseURL)/kas", sid: "a"), + (url: "https://other.example.com/kas", sid: "x"), + (url: "https://KAS.example.com:443/kas/", sid: "b"), + ])) + XCTAssertEqual(shares.map(\.sid), ["a", "b"]) + } + /// A permit without `sessionPublicKey` cannot be unwrapped with ECDH. func testRewrapAndUnwrapTDFThrowsWhenSessionKeyMissing() async throws { let fakeKAS = FakeKAS(salt: Self.standardTDFSalt, dek: SymmetricKey(size: .bits256), @@ -146,15 +171,21 @@ final class KASRewrapAndUnwrapTDFTests: XCTestCase { } private func makeManifest(kaoCount: Int, sharedSplit: Bool = false) -> TDFManifest { - let keyAccess = (0 ..< kaoCount).map { index in + makeManifest(entries: (0 ..< kaoCount).map { index in + (url: "\(kasBaseURL)/kas", sid: sharedSplit ? "split" : "split-\(index)") + }) + } + + private func makeManifest(entries: [(url: String, sid: String)]) -> TDFManifest { + let keyAccess = entries.enumerated().map { index, entry in TDFKeyAccessObject( type: .ecWrapped, - url: "\(kasBaseURL)/kas", + url: entry.url, protocolValue: .kas, wrappedKey: Data(repeating: UInt8(index), count: 48).base64EncodedString(), policyBinding: TDFPolicyBinding(alg: "HS256", hash: "binding"), kid: "kid-\(index)", - sid: sharedSplit ? "split" : "split-\(index)", + sid: entry.sid, ) } return TDFManifest(