diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index 3c7ae30..7b7d2f6 100644 --- a/OpenTDFKit/KASRewrapClient.swift +++ b/OpenTDFKit/KASRewrapClient.swift @@ -210,6 +210,66 @@ public class KASRewrapClient: KASRewrapClientProtocol { let schemaVersion: String? } + // MARK: - KAS Public Key Response + + /// Response structure for KAS EC public key endpoint + /// Handles both snake_case (public_key) and camelCase (publicKey) field names + public struct KasEcPublicKeyResponse: Decodable { + /// The PEM-encoded EC public key + public let publicKey: String + + /// Key ID (optional, may be returned by some KAS implementations) + public let kid: String? + + private enum CodingKeys: String, CodingKey { + case publicKey + case publicKeySnake = "public_key" + case kid + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Try camelCase first, then snake_case + if let key = try container.decodeIfPresent(String.self, forKey: .publicKey) { + publicKey = key + } else if let key = try container.decodeIfPresent(String.self, forKey: .publicKeySnake) { + publicKey = key + } else { + throw DecodingError.keyNotFound( + CodingKeys.publicKey, + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Neither 'publicKey' nor 'public_key' found in response", + ), + ) + } + + kid = try container.decodeIfPresent(String.self, forKey: .kid) + } + + /// Initialize directly (for testing) + public init(publicKey: String, kid: String? = nil) { + self.publicKey = publicKey + self.kid = kid + } + } + + /// Result of fetching and validating a KAS EC public key + public struct KasEcPublicKeyResult { + /// Compressed P-256 public key (33 bytes) + public let compressedKey: Data + + /// The original PEM string from the KAS + public let pem: String + + /// Key ID if provided by the KAS + public let kid: String? + + /// The parsed CryptoKit public key + public let cryptoKitKey: P256.KeyAgreement.PublicKey + } + // MARK: - Properties private let kasURL: URL @@ -502,6 +562,176 @@ public class KASRewrapClient: KASRewrapClientProtocol { } } + // MARK: - KAS Public Key Fetching + + /// Fetch the KAS EC public key for NanoTDF encryption + /// - Parameter algorithm: The EC algorithm to request (defaults to P-256/secp256r1) + /// - Returns: KasEcPublicKeyResult containing the validated compressed key and metadata + /// - Throws: KASRewrapError if fetching or validation fails + public func fetchKasEcPublicKey( + algorithm: RewrapAlgorithm = .ecP256, + ) async throws -> KasEcPublicKeyResult { + // Validate algorithm is EC-based + guard algorithm == .ecP256 || algorithm == .ecP384 || algorithm == .ecP521 else { + throw KASRewrapError.unsupportedKeyAlgorithm(algorithm.rawValue) + } + + // Build the URL with algorithm query parameter + let keyEndpoint = kasURL.appendingPathComponent("v2/kas_public_key") + var components = URLComponents(url: keyEndpoint, resolvingAgainstBaseURL: false) + components?.queryItems = [URLQueryItem(name: "algorithm", value: algorithm.rawValue)] + + guard let requestURL = components?.url else { + throw KASRewrapError.keyFetchFailed("Failed to construct KAS public key URL") + } + + // Create HTTP request + var request = URLRequest(url: requestURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.addValue("Bearer \(oauthToken)", forHTTPHeaderField: "Authorization") + request.addValue("application/json", forHTTPHeaderField: "Accept") + + // Perform request + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw KASRewrapError.invalidResponse + } + + switch httpResponse.statusCode { + case 200: + // Parse the JSON response + let keyResponse: KasEcPublicKeyResponse + do { + keyResponse = try JSONDecoder().decode(KasEcPublicKeyResponse.self, from: data) + } catch { + throw KASRewrapError.keyFetchFailed("Failed to parse response: \(error.localizedDescription)") + } + + // Validate and parse the PEM + let result = try Self.validateEcPublicKeyPEM(keyResponse.publicKey, expectedAlgorithm: algorithm) + + return KasEcPublicKeyResult( + compressedKey: result.compressedKey, + pem: keyResponse.publicKey, + kid: keyResponse.kid, + cryptoKitKey: result.cryptoKitKey, + ) + case 401: + throw KASRewrapError.authenticationFailed + case 403: + let message = String(data: data, encoding: .utf8) + throw KASRewrapError.accessDenied(message ?? "Forbidden") + case 404: + throw KASRewrapError.keyFetchFailed("KAS public key endpoint not found") + default: + let message = String(data: data, encoding: .utf8) + throw KASRewrapError.httpError(httpResponse.statusCode, message) + } + } + + /// Validate a PEM-encoded EC public key and extract the compressed representation + /// - Parameters: + /// - pem: PEM-encoded public key string + /// - expectedAlgorithm: The expected EC algorithm (for validation) + /// - Returns: Tuple containing compressed key data and CryptoKit public key + /// - Throws: KASRewrapError if validation fails + public static func validateEcPublicKeyPEM( + _ pem: String, + expectedAlgorithm: RewrapAlgorithm = .ecP256, + ) throws -> (compressedKey: Data, cryptoKitKey: P256.KeyAgreement.PublicKey) { + // Currently only P-256 is supported for validation + guard expectedAlgorithm == .ecP256 else { + throw KASRewrapError.unsupportedKeyAlgorithm( + "Validation only supports P-256, got: \(expectedAlgorithm.rawValue)", + ) + } + + // Normalize line endings and trim whitespace + let normalizedPEM = pem + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + + // Support multiple PEM header formats + let beginMarkers = [ + "-----BEGIN PUBLIC KEY-----", + "-----BEGIN EC PUBLIC KEY-----", + "-----BEGIN ECDSA PUBLIC KEY-----", + ] + let endMarkers = [ + "-----END PUBLIC KEY-----", + "-----END EC PUBLIC KEY-----", + "-----END ECDSA PUBLIC KEY-----", + ] + + // Extract base64 content by removing all markers + var base64Content = normalizedPEM + for marker in beginMarkers + endMarkers { + base64Content = base64Content.replacingOccurrences(of: marker, with: "") + } + + // Remove all whitespace and newlines + base64Content = base64Content.components(separatedBy: .whitespacesAndNewlines).joined() + + // Validate we have content + guard !base64Content.isEmpty else { + throw KASRewrapError.invalidEcPublicKey("Empty PEM content") + } + + // Decode base64 + guard let keyData = Data(base64Encoded: base64Content) else { + throw KASRewrapError.invalidEcPublicKey("Invalid base64 encoding") + } + + // Parse public key - support multiple formats + // KAS server may return SEC1 bytes wrapped in SPKI PEM, or standard SPKI DER + let publicKey: P256.KeyAgreement.PublicKey + + do { + if keyData.count == 65, keyData[0] == 0x04 { + // Raw uncompressed SEC1 point (0x04 || x || y) - 65 bytes + // This is the format some KAS servers return inside the PEM wrapper + publicKey = try P256.KeyAgreement.PublicKey(x963Representation: keyData) + } else if keyData.count == 33, keyData[0] == 0x02 || keyData[0] == 0x03 { + // Compressed SEC1 point (0x02/0x03 || x) - 33 bytes + publicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: keyData) + } else if keyData.count >= 59, keyData.count <= 91 { + // SPKI DER format (typically 91 bytes for P-256 with uncompressed point, + // or ~59 bytes with compressed point) + publicKey = try P256.KeyAgreement.PublicKey(derRepresentation: keyData) + } else { + throw KASRewrapError.invalidEcPublicKey( + "Unrecognized key format: \(keyData.count) bytes", + ) + } + } catch let error as KASRewrapError { + throw error + } catch { + throw KASRewrapError.invalidEcPublicKey("Failed to parse key: \(error.localizedDescription)") + } + + // Get compressed representation + let compressedKey = publicKey.compressedRepresentation + + // Validate compressed key size (33 bytes for P-256) + guard compressedKey.count == 33 else { + throw KASRewrapError.invalidEcPublicKey("Invalid compressed key size: \(compressedKey.count), expected 33") + } + + // Validate first byte is valid compressed point prefix + guard compressedKey[0] == 0x02 || compressedKey[0] == 0x03 else { + throw KASRewrapError.invalidEcPublicKey( + "Invalid compressed point prefix: 0x\(String(format: "%02x", compressedKey[0]))", + ) + } + + return (compressedKey, publicKey) + } + + // MARK: - Private Helpers + private func matchesKasURL(_ otherURLString: String) -> Bool { guard let otherURL = URL(string: otherURLString) else { return false } guard let baseScheme = kasURL.scheme?.lowercased(), @@ -694,6 +924,9 @@ public enum KASRewrapError: Error, CustomStringConvertible { case jwtSigningFailed(Error) case httpError(Int, String?) case invalidTDFRequest(String) + case keyFetchFailed(String) + case invalidEcPublicKey(String) + case unsupportedKeyAlgorithm(String) public var description: String { switch self { @@ -719,6 +952,12 @@ public enum KASRewrapError: Error, CustomStringConvertible { "HTTP error \(code)" + (message.map { ": \($0)" } ?? "") case let .invalidTDFRequest(reason): "Invalid standard TDF rewrap request: \(reason)" + case let .keyFetchFailed(reason): + "Failed to fetch KAS public key: \(reason)" + case let .invalidEcPublicKey(reason): + "Invalid EC public key: \(reason)" + case let .unsupportedKeyAlgorithm(algorithm): + "Unsupported key algorithm: \(algorithm)" } } } diff --git a/OpenTDFKit/NanoTDF.swift b/OpenTDFKit/NanoTDF.swift index bea0053..e4513e9 100644 --- a/OpenTDFKit/NanoTDF.swift +++ b/OpenTDFKit/NanoTDF.swift @@ -4,6 +4,10 @@ import Security /// Represents a NanoTDF (Nano Trusted Data Format) object, containing a header, payload, and optional signature. /// Conforms to `Sendable` for safe use in concurrent contexts. +/// +/// - Important: NanoTDF is deprecated. Use ``TDFCBORBuilder`` and ``TDFCBORContainer`` instead. +/// See the migration guide at `docs/NANOTDF_MIGRATION.md` for details. +@available(*, deprecated, message: "NanoTDF is deprecated. Use TDFCBORBuilder instead. See docs/NANOTDF_MIGRATION.md") public struct NanoTDF: Sendable { /// The header section of the NanoTDF, containing metadata like KAS info, policy, and ephemeral key. public var header: Header @@ -75,12 +79,17 @@ public struct NanoTDF: Sendable { /// Creates a NanoTDF v1.2 (L1L) object for compatibility with otdfctl and other implementations. /// The v1.2 format does not include the KAS public key in the header. +/// +/// - Important: NanoTDF is deprecated. Use ``TDFCBORBuilder`` instead. +/// See the migration guide at `docs/NANOTDF_MIGRATION.md` for details. +/// /// - Parameters: /// - kas: The `KasMetadata` containing the KAS URL and public key information. /// - policy: An `inout` `Policy` struct. The function will calculate and set the `binding` property on this policy object. /// - plaintext: The `Data` to be encrypted and included in the NanoTDF payload. /// - Returns: A newly created `NanoTDF` object in v1.2 format. /// - Throws: `CryptoHelperError` if key generation or derivation fails, or errors from `CryptoKit` during cryptographic operations. +@available(*, deprecated, message: "NanoTDF is deprecated. Use TDFCBORBuilder instead. See docs/NANOTDF_MIGRATION.md") public func createNanoTDFv12(kas: KasMetadata, policy: inout Policy, plaintext: Data) async throws -> NanoTDF { // Step 1: Generate an ephemeral key pair based on the KAS curve guard let keyPair = await NanoTDF.sharedCryptoHelper.generateEphemeralKeyPair(curveType: kas.curve) else { diff --git a/OpenTDFKit/NanoTDFCollectionBuilder.swift b/OpenTDFKit/NanoTDFCollectionBuilder.swift index 3265b42..5121a9e 100644 --- a/OpenTDFKit/NanoTDFCollectionBuilder.swift +++ b/OpenTDFKit/NanoTDFCollectionBuilder.swift @@ -21,6 +21,9 @@ public enum CollectionPolicyConfiguration: Sendable { /// /// Performs single ECDH + HKDF derivation per collection for efficiency. /// +/// - Important: NanoTDF is deprecated. Use ``TDFCBORBuilder`` instead. +/// See the migration guide at `docs/NANOTDF_MIGRATION.md` for details. +/// /// ## Example Usage /// ```swift /// let collection = try await NanoTDFCollectionBuilder() @@ -29,6 +32,7 @@ public enum CollectionPolicyConfiguration: Sendable { /// .configuration(.default) /// .build() /// ``` +@available(*, deprecated, message: "NanoTDF is deprecated. Use TDFCBORBuilder instead. See docs/NANOTDF_MIGRATION.md") public struct NanoTDFCollectionBuilder: Sendable { private var kasMetadata: KasMetadata? private var policyConfig: CollectionPolicyConfiguration? diff --git a/OpenTDFKit/TDF/TDFCBORContainer.swift b/OpenTDFKit/TDF/TDFCBORContainer.swift new file mode 100644 index 0000000..1347dd9 --- /dev/null +++ b/OpenTDFKit/TDF/TDFCBORContainer.swift @@ -0,0 +1,306 @@ +import CryptoKit +import Foundation + +// MARK: - TDF-CBOR Container + +/// Container for TDF-CBOR format that conforms to TrustedDataContainer. +public struct TDFCBORContainer: TrustedDataContainer, Sendable { + /// The TDF-CBOR envelope + public let envelope: TDFCBOREnvelope + + public var formatKind: TrustedDataFormatKind { .cbor } + + public init(envelope: TDFCBOREnvelope) { + self.envelope = envelope + } + + /// Serialize the container to CBOR data + public func serializedData() throws -> Data { + try envelope.toCBORData() + } + + /// Get the manifest from the envelope + public var manifest: TDFCBORManifest { + envelope.manifest + } + + /// Get the encryption information + public var encryptionInformation: TDFEncryptionInformation { + envelope.manifest.encryptionInformation + } + + /// Get the raw payload data + public var payloadData: Data { + envelope.payload.value + } + + /// Get the MIME type of the payload + public var mimeType: String? { + envelope.payload.mimeType + } +} + +// MARK: - TDF-CBOR Builder + +/// Builder for creating TDF-CBOR containers with encryption. +public struct TDFCBORBuilder: Sendable { + private var kasURL: URL? + private var kasPublicKeyPEM: String? + private var kasKid: String? + private var mimeType: String? + private var includeCreated: Bool = true + private var policy: TDFPolicy? + + /// Creates a new TDF-CBOR builder with default settings + public init() { + // Intentionally empty - configuration is done via builder methods + } + + /// Set the KAS URL + public func kasURL(_ url: URL) -> TDFCBORBuilder { + var copy = self + copy.kasURL = url + return copy + } + + /// Set the KAS public key PEM + public func kasPublicKey(_ pem: String) -> TDFCBORBuilder { + var copy = self + copy.kasPublicKeyPEM = pem + return copy + } + + /// Set the KAS key ID + public func kasKid(_ kid: String) -> TDFCBORBuilder { + var copy = self + copy.kasKid = kid + return copy + } + + /// Set the MIME type of the plaintext + public func mimeType(_ type: String) -> TDFCBORBuilder { + var copy = self + copy.mimeType = type + return copy + } + + /// Whether to include the created timestamp + public func includeCreated(_ include: Bool) -> TDFCBORBuilder { + var copy = self + copy.includeCreated = include + return copy + } + + /// Set the policy + public func policy(_ policy: TDFPolicy) -> TDFCBORBuilder { + var copy = self + copy.policy = policy + return copy + } + + /// Build a TDF-CBOR container by encrypting the provided plaintext + public func encrypt(plaintext: Data) throws -> TDFCBOREncryptionResult { + guard let kasURL else { + throw TDFCBORError.encryptionFailed("KAS URL is required") + } + guard let kasPublicKeyPEM else { + throw TDFCBORError.encryptionFailed("KAS public key is required") + } + guard let policy else { + throw TDFCBORError.encryptionFailed("Policy is required") + } + + // Generate symmetric key + let symmetricKey = SymmetricKey(size: .bits256) + + // Encrypt the plaintext + let (iv, ciphertext, tag) = try TDFCrypto.encryptPayload( + plaintext: plaintext, + symmetricKey: symmetricKey, + ) + + // Combine IV + ciphertext + tag for the payload (as raw bytes) + let payloadData = iv + ciphertext + tag + + // Wrap the symmetric key using EC (ECDH + HKDF + AES-GCM) + let ecWrapped = try TDFCrypto.wrapSymmetricKeyWithEC( + publicKeyPEM: kasPublicKeyPEM, + symmetricKey: symmetricKey, + ) + + // Create policy binding + let policyBinding = TDFCrypto.policyBinding( + policy: policy.json, + symmetricKey: symmetricKey, + ) + + // Calculate segment signature (GMAC) + let segmentSignature = try TDFCrypto.segmentSignatureGMAC( + segmentCiphertext: payloadData, + symmetricKey: symmetricKey, + ) + + // Calculate root signature + let rootSignature = TDFCrypto.segmentSignature( + segmentCiphertext: segmentSignature, + symmetricKey: symmetricKey, + ) + + // Create key access object with EC ephemeral public key + let keyAccessObject = TDFKeyAccessObject( + type: .wrapped, + url: kasURL.absoluteString, + protocolValue: .kas, + wrappedKey: ecWrapped.wrappedKey, + policyBinding: policyBinding, + encryptedMetadata: nil, + kid: kasKid, + sid: nil, + schemaVersion: "1.0", + ephemeralPublicKey: ecWrapped.ephemeralPublicKey, + ) + + // Create integrity information + let integrityInfo = TDFIntegrityInformation( + rootSignature: TDFRootSignature(alg: "HS256", sig: rootSignature.base64EncodedString()), + segmentHashAlg: "GMAC", + segmentSizeDefault: Int64(plaintext.count), + encryptedSegmentSizeDefault: Int64(payloadData.count), + segments: [ + TDFSegment( + hash: segmentSignature.base64EncodedString(), + segmentSize: Int64(plaintext.count), + encryptedSegmentSize: Int64(payloadData.count), + ), + ], + ) + + // Create method descriptor + let method = TDFMethodDescriptor( + algorithm: "AES-256-GCM", + iv: iv.base64EncodedString(), + isStreamable: true, + ) + + // Create encryption information + let encryptionInfo = TDFEncryptionInformation( + type: .split, + keyAccess: [keyAccessObject], + method: method, + integrityInformation: integrityInfo, + policy: policy.base64String, + ) + + // Create manifest + let manifest = TDFCBORManifest( + encryptionInformation: encryptionInfo, + assertions: nil, + ) + + // Create binary payload (not base64) + let payload = TDFBinaryPayload( + type: "inline", + protocol: "binary", + mimeType: mimeType, + isEncrypted: true, + value: payloadData, + ) + + // Create timestamp + let created: UInt64? = includeCreated ? UInt64(Date().timeIntervalSince1970) : nil + + // Create envelope + let envelope = TDFCBOREnvelope( + tdf: "cbor", + version: [1, 0, 0], + created: created, + manifest: manifest, + payload: payload, + ) + + let container = TDFCBORContainer(envelope: envelope) + + return TDFCBOREncryptionResult( + container: container, + symmetricKey: symmetricKey, + iv: iv, + tag: tag, + ) + } +} + +// MARK: - TDF-CBOR Encryption Result + +/// Result of TDF-CBOR encryption containing the container and key material +public struct TDFCBOREncryptionResult: Sendable { + /// The encrypted TDF-CBOR container + public let container: TDFCBORContainer + + /// The symmetric key used for encryption (save for later decryption) + public let symmetricKey: SymmetricKey + + /// The IV used for encryption + public let iv: Data + + /// The authentication tag + public let tag: Data + + public init(container: TDFCBORContainer, symmetricKey: SymmetricKey, iv: Data, tag: Data) { + self.container = container + self.symmetricKey = symmetricKey + self.iv = iv + self.tag = tag + } +} + +// MARK: - TDF-CBOR Loader + +/// Loader for parsing TDF-CBOR from data or files +public struct TDFCBORLoader: Sendable { + /// Creates a new TDF-CBOR loader + public init() { + // Intentionally empty - stateless loader + } + + /// Load a TDF-CBOR container from data + public func load(from data: Data) throws -> TDFCBORContainer { + let envelope = try TDFCBOREnvelope.fromCBORData(data) + return TDFCBORContainer(envelope: envelope) + } + + /// Load a TDF-CBOR container from a URL + public func load(from url: URL) throws -> TDFCBORContainer { + let data = try Data(contentsOf: url) + return try load(from: data) + } +} + +// MARK: - TDF-CBOR Decryptor + +/// Decryptor for TDF-CBOR containers +public struct TDFCBORDecryptor: Sendable { + /// Creates a new TDF-CBOR decryptor + public init() { + // Intentionally empty - stateless decryptor + } + + /// Decrypt a TDF-CBOR container with a symmetric key + public func decrypt(container: TDFCBORContainer, symmetricKey: SymmetricKey) throws -> Data { + try TDFCrypto.decryptCombinedPayload(container.payloadData, symmetricKey: symmetricKey) + } + + /// Decrypt a TDF-CBOR container with a private key (unwraps the symmetric key first) + public func decrypt(container: TDFCBORContainer, privateKeyPEM: String) throws -> Data { + let keyAccess = container.encryptionInformation.keyAccess + guard !keyAccess.isEmpty else { + throw TDFCBORError.decryptionFailed("No key access objects found") + } + + let symmetricKey = try TDFCrypto.unwrapSymmetricKeyWithRSA( + privateKeyPEM: privateKeyPEM, + wrappedKey: keyAccess[0].wrappedKey, + ) + + return try decrypt(container: container, symmetricKey: symmetricKey) + } +} diff --git a/OpenTDFKit/TDF/TDFCBORFormat.swift b/OpenTDFKit/TDF/TDFCBORFormat.swift new file mode 100644 index 0000000..ac429bf --- /dev/null +++ b/OpenTDFKit/TDF/TDFCBORFormat.swift @@ -0,0 +1,1065 @@ +import Foundation +import SwiftCBOR + +// MARK: - TDF-CBOR Magic Bytes + +/// Self-describe CBOR tag (55799) +/// D9 D9F7 = tag(55799) +public let TDF_CBOR_MAGIC: [UInt8] = [0xD9, 0xD9, 0xF7] + +/// Integer key mappings per TDF-CBOR spec section 3.1 +public enum TDFCBORKey: Int, CodingKey { + case tdf = 1 + case version = 2 + case created = 3 + case manifest = 4 + case payload = 5 + + public var intValue: Int? { rawValue } + public var stringValue: String { String(rawValue) } + + public init?(intValue: Int) { + self.init(rawValue: intValue) + } + + public init?(stringValue: String) { + guard let intVal = Int(stringValue) else { return nil } + self.init(rawValue: intVal) + } +} + +/// Payload integer key mappings per TDF-CBOR spec section 3.1 +public enum TDFCBORPayloadKey: Int { + case type = 1 + case `protocol` = 2 + case mimeType = 3 + case isEncrypted = 4 + case length = 5 + case value = 6 +} + +/// Enumerated values per TDF-CBOR spec section 1.5 +public enum TDFCBOREnums { + // Payload type: 0=inline, 1=reference + public static let payloadTypeInline: UInt64 = 0 + public static let payloadTypeReference: UInt64 = 1 + + // Payload protocol: 0=binary, 1=binary-chunked + public static let payloadProtocolBinary: UInt64 = 0 + public static let payloadProtocolBinaryChunked: UInt64 = 1 + + // Encryption type: 0=split, 1=remote + public static let encryptionTypeSplit: UInt64 = 0 + public static let encryptionTypeRemote: UInt64 = 1 + + // Key access type: 0=wrapped, 1=remote + public static let keyAccessTypeWrapped: UInt64 = 0 + public static let keyAccessTypeRemote: UInt64 = 1 + + // Key protocol: 0=kas + public static let keyProtocolKas: UInt64 = 0 + + // Symmetric algorithm: 0=AES-256-GCM + public static let symmetricAlgAes256Gcm: UInt64 = 0 + + // Hash/Signature algorithm + public static let hashAlgHS256: UInt64 = 0 + public static let hashAlgHS384: UInt64 = 1 + public static let hashAlgHS512: UInt64 = 2 + public static let hashAlgGMAC: UInt64 = 3 + public static let hashAlgSHA256: UInt64 = 4 + public static let hashAlgES256: UInt64 = 5 + public static let hashAlgES384: UInt64 = 6 + public static let hashAlgES512: UInt64 = 7 +} + +/// Manifest integer key mappings per TDF-CBOR spec section 3.1 +public enum TDFCBORManifestKey: Int { + case encryptionInformation = 1 + case assertions = 2 +} + +/// EncryptionInformation integer key mappings +public enum TDFCBOREncInfoKey: Int { + case type = 1 + case keyAccess = 2 + case method = 3 + case integrityInformation = 4 + case policy = 5 +} + +/// KeyAccess integer key mappings +public enum TDFCBORKeyAccessKey: Int { + case type = 1 + case url = 2 + case `protocol` = 3 + case wrappedKey = 4 + case policyBinding = 5 + case encryptedMetadata = 6 + case kid = 7 + case ephemeralPublicKey = 8 + case schemaVersion = 9 +} + +/// PolicyBinding integer key mappings +public enum TDFCBORPolicyBindingKey: Int { + case alg = 1 + case hash = 2 +} + +/// Method integer key mappings +public enum TDFCBORMethodKey: Int { + case algorithm = 1 + case iv = 2 + case isStreamable = 3 +} + +/// IntegrityInformation integer key mappings +public enum TDFCBORIntegrityKey: Int { + case rootSignature = 1 + case segmentHashAlg = 2 + case segments = 3 + case segmentSizeDefault = 4 + case encryptedSegmentSizeDefault = 5 +} + +/// RootSignature integer key mappings +public enum TDFCBORRootSigKey: Int { + case alg = 1 + case sig = 2 +} + +/// Segment integer key mappings +public enum TDFCBORSegmentKey: Int { + case hash = 1 + case segmentSize = 2 + case encryptedSegmentSize = 3 +} + +// MARK: - TDF-CBOR Envelope + +/// TDF-CBOR envelope for binary payload transmission (spec-compliant) +/// +/// This structure represents a complete TDF-CBOR package per the TDF-CBOR +/// specification draft-00. The format uses integer keys and binary payloads +/// for optimal size and parsing efficiency. +/// +/// ## Integer Key Mapping +/// +/// | Key | Field | Type | +/// |-----|----------|---------------------| +/// | 1 | tdf | string "cbor" | +/// | 2 | version | [UInt8] semver | +/// | 3 | created | UInt64 Unix timestamp | +/// | 4 | manifest | TDFCBORManifest | +/// | 5 | payload | TDFBinaryPayload | +public struct TDFCBOREnvelope: Sendable { + /// Format identifier. MUST be "cbor" for TDF-CBOR documents. + public let tdf: String + + /// Semantic version as [major, minor, patch] array + public let version: [UInt8] + + /// Unix timestamp of document creation (optional) + public var created: UInt64? + + /// TDF manifest containing encryption and policy information + public let manifest: TDFCBORManifest + + /// Binary encrypted payload container + public let payload: TDFBinaryPayload + + public init( + tdf: String = "cbor", + version: [UInt8] = [1, 0, 0], + created: UInt64? = nil, + manifest: TDFCBORManifest, + payload: TDFBinaryPayload, + ) { + self.tdf = tdf + self.version = version + self.created = created + self.manifest = manifest + self.payload = payload + } + + /// Format identifier (always "cbor") + public var formatId: String { tdf } +} + +// MARK: - TDF-CBOR Manifest + +/// TDF manifest for TDF-CBOR format. +/// +/// Contains encryption information serialized as JSON string within CBOR. +public struct TDFCBORManifest: Sendable { + /// Encryption information including key access and policy + public let encryptionInformation: TDFEncryptionInformation + + /// Optional assertions for additional metadata + public var assertions: [TDFAssertion]? + + public init( + encryptionInformation: TDFEncryptionInformation, + assertions: [TDFAssertion]? = nil, + ) { + self.encryptionInformation = encryptionInformation + self.assertions = assertions + } +} + +// MARK: - TDF Binary Payload + +/// Binary payload for TDF-CBOR transport. +/// +/// Contains the encrypted data directly as binary bytes (no base64 encoding). +public struct TDFBinaryPayload: Sendable { + /// Payload type. MUST be "inline" for TDF-CBOR + public let type: String + + /// Protocol. MUST be "binary" for TDF-CBOR (not "base64") + public let `protocol`: String + + /// MIME type of the original (unencrypted) data + public var mimeType: String? + + /// Whether the payload is encrypted. MUST be true + public let isEncrypted: Bool + + /// Raw encrypted bytes (not base64 encoded) + public let value: Data + + public init( + type: String = "inline", + protocol: String = "binary", + mimeType: String? = nil, + isEncrypted: Bool = true, + value: Data, + ) { + self.type = type + self.protocol = `protocol` + self.mimeType = mimeType + self.isEncrypted = isEncrypted + self.value = value + } +} + +// MARK: - TDF-CBOR Error Types + +/// Errors specific to TDF-CBOR parsing and validation. +public enum TDFCBORError: Error, CustomStringConvertible, Sendable { + case invalidMagicBytes + case invalidTdfIdentifier(String) + case unexpectedKey(expected: Int, got: Int) + case cborDecodingFailed(String) + case cborEncodingFailed(String) + case binaryPayloadExpected + case missingField(String) + case encryptionFailed(String) + case decryptionFailed(String) + + public var description: String { + switch self { + case .invalidMagicBytes: + "Invalid or missing CBOR magic bytes" + case let .invalidTdfIdentifier(id): + "Invalid tdf identifier: expected 'cbor', got '\(id)'" + case let .unexpectedKey(expected, got): + "Expected integer key \(expected), got \(got)" + case let .cborDecodingFailed(e): + "CBOR decoding failed: \(e)" + case let .cborEncodingFailed(e): + "CBOR encoding failed: \(e)" + case .binaryPayloadExpected: + "Binary payload expected but got base64" + case let .missingField(field): + "Missing required field: \(field)" + case let .encryptionFailed(e): + "Encryption failed: \(e)" + case let .decryptionFailed(e): + "Decryption failed: \(e)" + } + } +} + +// MARK: - TDF-CBOR Extensions + +public extension TDFCBOREnvelope { + /// Check if data starts with CBOR self-describe tag + static func hasMagicBytes(_ data: Data) -> Bool { + guard data.count >= 3 else { return false } + return data[0] == 0xD9 && data[1] == 0xD9 && data[2] == 0xF7 + } + + /// Convert to standard TDF manifest format (for KAS integration) + func toStandardManifest() -> TDFManifest { + TDFManifest( + schemaVersion: "1.0.0", + payload: TDFPayloadDescriptor( + type: .reference, + url: "inline", + protocolValue: .zip, + isEncrypted: true, + mimeType: payload.mimeType, + ), + encryptionInformation: manifest.encryptionInformation, + assertions: manifest.assertions, + ) + } +} + +// MARK: - CBOR Coding + +extension TDFCBOREnvelope { + /// Encode to CBOR bytes with self-describe tag + public func toCBORData() throws -> Data { + // Build manifest as native CBOR with integer keys and enums + let manifestCBOR = try encodeManifestToCBOR() + + // Build payload map with integer keys and enum values per spec section 1.5 + let payloadTypeEnum: UInt64 = payload.type == "inline" ? TDFCBOREnums.payloadTypeInline : TDFCBOREnums.payloadTypeReference + let protocolEnum: UInt64 = payload.protocol == "binary" ? TDFCBOREnums.payloadProtocolBinary : TDFCBOREnums.payloadProtocolBinaryChunked + + var payloadMap: [CBOR: CBOR] = [ + CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.type.rawValue)): .unsignedInt(payloadTypeEnum), + CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.protocol.rawValue)): .unsignedInt(protocolEnum), + CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.isEncrypted.rawValue)): .boolean(payload.isEncrypted), + CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.value.rawValue)): .byteString(Array(payload.value)), + ] + if let mimeType = payload.mimeType { + payloadMap[CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.mimeType.rawValue))] = .utf8String(mimeType) + } + + // Build main map with integer keys + var mainMap: [CBOR: CBOR] = [ + CBOR.unsignedInt(UInt64(TDFCBORKey.tdf.rawValue)): .utf8String(tdf), + CBOR.unsignedInt(UInt64(TDFCBORKey.version.rawValue)): .array(version.map { .unsignedInt(UInt64($0)) }), + CBOR.unsignedInt(UInt64(TDFCBORKey.manifest.rawValue)): manifestCBOR, + CBOR.unsignedInt(UInt64(TDFCBORKey.payload.rawValue)): .map(payloadMap), + ] + + if let created { + mainMap[CBOR.unsignedInt(UInt64(TDFCBORKey.created.rawValue))] = .unsignedInt(created) + } + + let cborValue = CBOR.map(mainMap) + let encoded = cborValue.encode() + + // Prepend self-describe tag + var result = Data(TDF_CBOR_MAGIC) + result.append(contentsOf: encoded) + return result + } + + /// Encode manifest to native CBOR with integer keys and enums + private func encodeManifestToCBOR() throws -> CBOR { + let encInfo = manifest.encryptionInformation + + // Decode policy from base64 to raw bytes + guard let policyData = Data(base64Encoded: encInfo.policy) else { + throw TDFCBORError.cborEncodingFailed("Invalid policy base64") + } + + // Encode key access array + let keyAccessArray: [CBOR] = try encInfo.keyAccess.map { try encodeKeyAccessToCBOR($0) } + + // Encode method + let methodCBOR = try encodeMethodToCBOR(encInfo.method) + + // Encode integrity information (if present) + guard let integrityInfo = encInfo.integrityInformation else { + throw TDFCBORError.missingField("integrityInformation") + } + let integrityCBOR = try encodeIntegrityToCBOR(integrityInfo) + + // Encryption type enum: "split" -> 0, "remote" -> 1 + let encTypeEnum: UInt64 = encInfo.type == .split ? TDFCBOREnums.encryptionTypeSplit : TDFCBOREnums.encryptionTypeRemote + + // Build encryptionInformation map + let encInfoMap: [CBOR: CBOR] = [ + .unsignedInt(UInt64(TDFCBOREncInfoKey.type.rawValue)): .unsignedInt(encTypeEnum), + .unsignedInt(UInt64(TDFCBOREncInfoKey.keyAccess.rawValue)): .array(keyAccessArray), + .unsignedInt(UInt64(TDFCBOREncInfoKey.method.rawValue)): methodCBOR, + .unsignedInt(UInt64(TDFCBOREncInfoKey.integrityInformation.rawValue)): integrityCBOR, + .unsignedInt(UInt64(TDFCBOREncInfoKey.policy.rawValue)): .byteString(Array(policyData)), + ] + + // Build manifest map + var manifestMap: [CBOR: CBOR] = [ + .unsignedInt(UInt64(TDFCBORManifestKey.encryptionInformation.rawValue)): .map(encInfoMap), + ] + + // Add assertions if present + if let assertions = manifest.assertions, !assertions.isEmpty { + let assertionsArray = try encodeAssertionsToCBOR(assertions) + manifestMap[.unsignedInt(UInt64(TDFCBORManifestKey.assertions.rawValue))] = .array(assertionsArray) + } + + return .map(manifestMap) + } + + /// Encode a single key access object to CBOR + private func encodeKeyAccessToCBOR(_ ka: TDFKeyAccessObject) throws -> CBOR { + // Key access type enum + let kaTypeEnum: UInt64 = ka.type == .wrapped ? TDFCBOREnums.keyAccessTypeWrapped : TDFCBOREnums.keyAccessTypeRemote + + // Protocol enum: "kas" -> 0 (currently only kas is supported) + let protocolEnum: UInt64 = TDFCBOREnums.keyProtocolKas + + // Decode wrapped key from base64 to raw bytes + guard let wrappedKeyData = Data(base64Encoded: ka.wrappedKey) else { + throw TDFCBORError.cborEncodingFailed("Invalid wrappedKey base64") + } + + // Encode policy binding + let bindingAlgEnum = hashAlgToEnum(ka.policyBinding.alg) + guard let bindingHashData = Data(base64Encoded: ka.policyBinding.hash) else { + throw TDFCBORError.cborEncodingFailed("Invalid policy binding hash base64") + } + + let policyBindingMap: [CBOR: CBOR] = [ + .unsignedInt(UInt64(TDFCBORPolicyBindingKey.alg.rawValue)): .unsignedInt(bindingAlgEnum), + .unsignedInt(UInt64(TDFCBORPolicyBindingKey.hash.rawValue)): .byteString(Array(bindingHashData)), + ] + + var kaMap: [CBOR: CBOR] = [ + .unsignedInt(UInt64(TDFCBORKeyAccessKey.type.rawValue)): .unsignedInt(kaTypeEnum), + .unsignedInt(UInt64(TDFCBORKeyAccessKey.url.rawValue)): .utf8String(ka.url), + .unsignedInt(UInt64(TDFCBORKeyAccessKey.protocol.rawValue)): .unsignedInt(protocolEnum), + .unsignedInt(UInt64(TDFCBORKeyAccessKey.wrappedKey.rawValue)): .byteString(Array(wrappedKeyData)), + .unsignedInt(UInt64(TDFCBORKeyAccessKey.policyBinding.rawValue)): .map(policyBindingMap), + ] + + // Add optional fields + if let kid = ka.kid { + kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.kid.rawValue))] = .utf8String(kid) + } + + if let epk = ka.ephemeralPublicKey, let epkData = Data(base64Encoded: epk) { + kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.ephemeralPublicKey.rawValue))] = .byteString(Array(epkData)) + } + + if let sv = ka.schemaVersion { + kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.schemaVersion.rawValue))] = .utf8String(sv) + } + + return .map(kaMap) + } + + /// Encode method to CBOR + private func encodeMethodToCBOR(_ method: TDFMethodDescriptor) throws -> CBOR { + // Algorithm enum: "AES-256-GCM" -> 0 (currently only AES-256-GCM is supported) + let algEnum: UInt64 = TDFCBOREnums.symmetricAlgAes256Gcm + + // Decode IV from base64 to raw bytes + guard let ivData = Data(base64Encoded: method.iv) else { + throw TDFCBORError.cborEncodingFailed("Invalid IV base64") + } + + let methodMap: [CBOR: CBOR] = [ + .unsignedInt(UInt64(TDFCBORMethodKey.algorithm.rawValue)): .unsignedInt(algEnum), + .unsignedInt(UInt64(TDFCBORMethodKey.iv.rawValue)): .byteString(Array(ivData)), + .unsignedInt(UInt64(TDFCBORMethodKey.isStreamable.rawValue)): .boolean(method.isStreamable ?? true), + ] + + return .map(methodMap) + } + + /// Encode integrity information to CBOR + private func encodeIntegrityToCBOR(_ integrity: TDFIntegrityInformation) throws -> CBOR { + // Encode root signature + let rootAlgEnum = hashAlgToEnum(integrity.rootSignature.alg) + guard let rootSigData = Data(base64Encoded: integrity.rootSignature.sig) else { + throw TDFCBORError.cborEncodingFailed("Invalid root signature base64") + } + + let rootSigMap: [CBOR: CBOR] = [ + .unsignedInt(UInt64(TDFCBORRootSigKey.alg.rawValue)): .unsignedInt(rootAlgEnum), + .unsignedInt(UInt64(TDFCBORRootSigKey.sig.rawValue)): .byteString(Array(rootSigData)), + ] + + // Segment hash algorithm enum + let segHashAlgEnum = hashAlgToEnum(integrity.segmentHashAlg) + + // Encode segments + let segmentsArray: [CBOR] = try integrity.segments.map { seg in + guard let hashData = Data(base64Encoded: seg.hash) else { + throw TDFCBORError.cborEncodingFailed("Invalid segment hash base64") + } + + var segMap: [CBOR: CBOR] = [ + .unsignedInt(UInt64(TDFCBORSegmentKey.hash.rawValue)): .byteString(Array(hashData)), + .unsignedInt(UInt64(TDFCBORSegmentKey.segmentSize.rawValue)): .unsignedInt(UInt64(seg.segmentSize)), + ] + + if let size = seg.encryptedSegmentSize { + segMap[.unsignedInt(UInt64(TDFCBORSegmentKey.encryptedSegmentSize.rawValue))] = .unsignedInt(UInt64(size)) + } + + return .map(segMap) + } + + var integrityMap: [CBOR: CBOR] = [ + .unsignedInt(UInt64(TDFCBORIntegrityKey.rootSignature.rawValue)): .map(rootSigMap), + .unsignedInt(UInt64(TDFCBORIntegrityKey.segmentHashAlg.rawValue)): .unsignedInt(segHashAlgEnum), + .unsignedInt(UInt64(TDFCBORIntegrityKey.segments.rawValue)): .array(segmentsArray), + .unsignedInt(UInt64(TDFCBORIntegrityKey.segmentSizeDefault.rawValue)): .unsignedInt(UInt64(integrity.segmentSizeDefault)), + ] + + // Add optional encryptedSegmentSizeDefault if present + if let encSize = integrity.encryptedSegmentSizeDefault { + integrityMap[.unsignedInt(UInt64(TDFCBORIntegrityKey.encryptedSegmentSizeDefault.rawValue))] = .unsignedInt(UInt64(encSize)) + } + + return .map(integrityMap) + } + + /// Convert hash algorithm string to enum value + private func hashAlgToEnum(_ alg: String) -> UInt64 { + switch alg { + case "HS256": TDFCBOREnums.hashAlgHS256 + case "HS384": TDFCBOREnums.hashAlgHS384 + case "HS512": TDFCBOREnums.hashAlgHS512 + case "GMAC": TDFCBOREnums.hashAlgGMAC + case "SHA256": TDFCBOREnums.hashAlgSHA256 + case "ES256": TDFCBOREnums.hashAlgES256 + case "ES384": TDFCBOREnums.hashAlgES384 + case "ES512": TDFCBOREnums.hashAlgES512 + default: TDFCBOREnums.hashAlgHS256 + } + } + + /// Convert enum value to hash algorithm string + private static func enumToHashAlg(_ val: UInt64) -> String { + switch val { + case TDFCBOREnums.hashAlgHS256: "HS256" + case TDFCBOREnums.hashAlgHS384: "HS384" + case TDFCBOREnums.hashAlgHS512: "HS512" + case TDFCBOREnums.hashAlgGMAC: "GMAC" + case TDFCBOREnums.hashAlgSHA256: "SHA256" + case TDFCBOREnums.hashAlgES256: "ES256" + case TDFCBOREnums.hashAlgES384: "ES384" + case TDFCBOREnums.hashAlgES512: "ES512" + default: "HS256" + } + } + + /// Decode from CBOR bytes + public static func fromCBORData(_ data: Data) throws -> TDFCBOREnvelope { + // Verify magic bytes + guard hasMagicBytes(data) else { + throw TDFCBORError.invalidMagicBytes + } + + // Skip self-describe tag and decode + let cborBytes = Array(data.dropFirst(3)) + guard let decoded = try? CBOR.decode(cborBytes) else { + throw TDFCBORError.cborDecodingFailed("Failed to decode CBOR") + } + + guard case let .map(mainMap) = decoded else { + throw TDFCBORError.cborDecodingFailed("Expected CBOR map at root") + } + + // Extract tdf field + guard let tdfValue = mainMap[CBOR.unsignedInt(UInt64(TDFCBORKey.tdf.rawValue))], + case let .utf8String(tdf) = tdfValue + else { + throw TDFCBORError.missingField("tdf") + } + guard tdf == "cbor" else { + throw TDFCBORError.invalidTdfIdentifier(tdf) + } + + // Extract version + guard let versionValue = mainMap[CBOR.unsignedInt(UInt64(TDFCBORKey.version.rawValue))], + case let .array(versionArray) = versionValue + else { + throw TDFCBORError.missingField("version") + } + let version: [UInt8] = versionArray.compactMap { cbor -> UInt8? in + if case let .unsignedInt(v) = cbor { return UInt8(v) } + return nil + } + + // Extract created (optional) + var created: UInt64? + if let createdValue = mainMap[CBOR.unsignedInt(UInt64(TDFCBORKey.created.rawValue))], + case let .unsignedInt(ts) = createdValue + { + created = ts + } + + // Extract manifest - support both native CBOR map (new) and JSON string (legacy) + guard let manifestValue = mainMap[CBOR.unsignedInt(UInt64(TDFCBORKey.manifest.rawValue))] else { + throw TDFCBORError.missingField("manifest") + } + + let manifest: TDFCBORManifest + switch manifestValue { + case let .map(manifestMap): + // Native CBOR manifest with integer keys + manifest = try decodeManifestFromCBOR(manifestMap) + case let .utf8String(manifestJSON): + // Legacy JSON string format + guard let manifestData = manifestJSON.data(using: .utf8) else { + throw TDFCBORError.cborDecodingFailed("Invalid manifest JSON encoding") + } + let manifestWrapper = try JSONDecoder().decode(ManifestWrapper.self, from: manifestData) + manifest = manifestWrapper.toManifest() + default: + throw TDFCBORError.cborDecodingFailed("Expected manifest as map or string") + } + + // Extract payload + guard let payloadValue = mainMap[CBOR.unsignedInt(UInt64(TDFCBORKey.payload.rawValue))], + case let .map(payloadMap) = payloadValue + else { + throw TDFCBORError.missingField("payload") + } + + // Support both integer keys (new spec) and string keys (legacy) + let payloadType: String = { + // Try integer key first (new spec) + if let v = payloadMap[CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.type.rawValue))] { + // Support both integer enum (new) and string (legacy) + switch v { + case let .unsignedInt(i): + return i == TDFCBOREnums.payloadTypeInline ? "inline" : "reference" + case let .utf8String(s): + return s + default: + break + } + } + // Fall back to string key (legacy) + if let v = payloadMap["type"], case let .utf8String(s) = v { return s } + return "inline" + }() + + let payloadProtocol: String = { + // Try integer key first (new spec) + if let v = payloadMap[CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.protocol.rawValue))] { + // Support both integer enum (new) and string (legacy) + switch v { + case let .unsignedInt(i): + return i == TDFCBOREnums.payloadProtocolBinary ? "binary" : "binary-chunked" + case let .utf8String(s): + return s + default: + break + } + } + // Fall back to string key (legacy) + if let v = payloadMap["protocol"], case let .utf8String(s) = v { return s } + return "binary" + }() + + let payloadMimeType: String? = { + // Try integer key first (new spec) + if let v = payloadMap[CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.mimeType.rawValue))], + case let .utf8String(s) = v + { + return s + } + // Fall back to string key (legacy) + if let v = payloadMap["mimeType"], case let .utf8String(s) = v { return s } + return nil + }() + + let payloadIsEncrypted: Bool = { + // Try integer key first (new spec) + if let v = payloadMap[CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.isEncrypted.rawValue))], + case let .boolean(b) = v + { + return b + } + // Fall back to string key (legacy) + if let v = payloadMap["isEncrypted"], case let .boolean(b) = v { return b } + return true + }() + + // Try integer key first (new spec), then string key (legacy) + let bytes: [UInt8] + if let valueField = payloadMap[CBOR.unsignedInt(UInt64(TDFCBORPayloadKey.value.rawValue))], + case let .byteString(b) = valueField + { + bytes = b + } else if let valueField = payloadMap["value"], + case let .byteString(b) = valueField + { + bytes = b + } else { + throw TDFCBORError.binaryPayloadExpected + } + + let payload = TDFBinaryPayload( + type: payloadType, + protocol: payloadProtocol, + mimeType: payloadMimeType, + isEncrypted: payloadIsEncrypted, + value: Data(bytes), + ) + + return TDFCBOREnvelope( + tdf: tdf, + version: version, + created: created, + manifest: manifest, + payload: payload, + ) + } + + /// Decode manifest from native CBOR map + private static func decodeManifestFromCBOR(_ manifestMap: [CBOR: CBOR]) throws -> TDFCBORManifest { + guard let encInfoValue = manifestMap[.unsignedInt(UInt64(TDFCBORManifestKey.encryptionInformation.rawValue))], + case let .map(encInfoMap) = encInfoValue + else { + throw TDFCBORError.missingField("encryptionInformation") + } + + let encInfo = try decodeEncryptionInfo(encInfoMap) + + // Decode assertions if present + var assertions: [TDFAssertion]? + if let assertionsValue = manifestMap[.unsignedInt(UInt64(TDFCBORManifestKey.assertions.rawValue))], + case let .array(assertionsArray) = assertionsValue + { + assertions = try decodeAssertionsFromCBOR(assertionsArray) + } + + return TDFCBORManifest( + encryptionInformation: encInfo, + assertions: assertions, + ) + } + + /// Decode encryption information from CBOR + private static func decodeEncryptionInfo(_ encMap: [CBOR: CBOR]) throws -> TDFEncryptionInformation { + // Encryption type + var encType: TDFEncryptionInformation.KeyAccessType = .split + if let typeValue = encMap[.unsignedInt(UInt64(TDFCBOREncInfoKey.type.rawValue))], + case let .unsignedInt(typeEnum) = typeValue + { + encType = typeEnum == TDFCBOREnums.encryptionTypeSplit ? .split : .remote + } + + // Key access + var keyAccess: [TDFKeyAccessObject] = [] + if let kaValue = encMap[.unsignedInt(UInt64(TDFCBOREncInfoKey.keyAccess.rawValue))], + case let .array(kaArray) = kaValue + { + for kaItem in kaArray { + if case let .map(kaMap) = kaItem { + try keyAccess.append(decodeKeyAccess(kaMap)) + } + } + } + + // Method + guard let methodValue = encMap[.unsignedInt(UInt64(TDFCBOREncInfoKey.method.rawValue))], + case let .map(methodMap) = methodValue + else { + throw TDFCBORError.missingField("method") + } + let method = try decodeMethod(methodMap) + + // Integrity information + guard let integrityValue = encMap[.unsignedInt(UInt64(TDFCBOREncInfoKey.integrityInformation.rawValue))], + case let .map(integrityMap) = integrityValue + else { + throw TDFCBORError.missingField("integrityInformation") + } + let integrity = try decodeIntegrity(integrityMap) + + // Policy + var policy = "" + if let policyValue = encMap[.unsignedInt(UInt64(TDFCBOREncInfoKey.policy.rawValue))], + case let .byteString(policyBytes) = policyValue + { + policy = Data(policyBytes).base64EncodedString() + } + + return TDFEncryptionInformation( + type: encType, + keyAccess: keyAccess, + method: method, + integrityInformation: integrity, + policy: policy, + ) + } + + /// Decode key access from CBOR + private static func decodeKeyAccess(_ kaMap: [CBOR: CBOR]) throws -> TDFKeyAccessObject { + // Type + var accessType: TDFKeyAccessObject.AccessType = .wrapped + if let typeValue = kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.type.rawValue))], + case let .unsignedInt(typeEnum) = typeValue + { + accessType = typeEnum == TDFCBOREnums.keyAccessTypeWrapped ? .wrapped : .remote + } + + // URL + var url = "" + if let urlValue = kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.url.rawValue))], + case let .utf8String(urlStr) = urlValue + { + url = urlStr + } + + // Protocol (currently only "kas" is supported) + let protocolStr = "kas" + + // Wrapped key + var wrappedKey = "" + if let wkValue = kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.wrappedKey.rawValue))], + case let .byteString(wkBytes) = wkValue + { + wrappedKey = Data(wkBytes).base64EncodedString() + } + + // Policy binding + guard let pbValue = kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.policyBinding.rawValue))], + case let .map(pbMap) = pbValue + else { + throw TDFCBORError.missingField("policyBinding") + } + let policyBinding = try decodePolicyBinding(pbMap) + + // Optional fields + var kid: String? + if let kidValue = kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.kid.rawValue))], + case let .utf8String(kidStr) = kidValue + { + kid = kidStr + } + + var ephemeralPublicKey: String? + if let epkValue = kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.ephemeralPublicKey.rawValue))], + case let .byteString(epkBytes) = epkValue + { + ephemeralPublicKey = Data(epkBytes).base64EncodedString() + } + + var schemaVersion: String? + if let svValue = kaMap[.unsignedInt(UInt64(TDFCBORKeyAccessKey.schemaVersion.rawValue))], + case let .utf8String(svStr) = svValue + { + schemaVersion = svStr + } + + return TDFKeyAccessObject( + type: accessType, + url: url, + protocolValue: TDFKeyAccessObject.AccessProtocol(rawValue: protocolStr) ?? .kas, + wrappedKey: wrappedKey, + policyBinding: policyBinding, + encryptedMetadata: nil, + kid: kid, + sid: nil, + schemaVersion: schemaVersion, + ephemeralPublicKey: ephemeralPublicKey, + ) + } + + /// Decode policy binding from CBOR + private static func decodePolicyBinding(_ pbMap: [CBOR: CBOR]) throws -> TDFPolicyBinding { + var alg = "HS256" + if let algValue = pbMap[.unsignedInt(UInt64(TDFCBORPolicyBindingKey.alg.rawValue))], + case let .unsignedInt(algEnum) = algValue + { + alg = enumToHashAlg(algEnum) + } + + var hash = "" + if let hashValue = pbMap[.unsignedInt(UInt64(TDFCBORPolicyBindingKey.hash.rawValue))], + case let .byteString(hashBytes) = hashValue + { + hash = Data(hashBytes).base64EncodedString() + } + + return TDFPolicyBinding(alg: alg, hash: hash) + } + + /// Decode method from CBOR + private static func decodeMethod(_ methodMap: [CBOR: CBOR]) throws -> TDFMethodDescriptor { + // Currently only AES-256-GCM is supported + let algorithm = "AES-256-GCM" + + var iv = "" + if let ivValue = methodMap[.unsignedInt(UInt64(TDFCBORMethodKey.iv.rawValue))], + case let .byteString(ivBytes) = ivValue + { + iv = Data(ivBytes).base64EncodedString() + } + + var isStreamable: Bool? = true + if let streamValue = methodMap[.unsignedInt(UInt64(TDFCBORMethodKey.isStreamable.rawValue))], + case let .boolean(streamBool) = streamValue + { + isStreamable = streamBool + } + + return TDFMethodDescriptor(algorithm: algorithm, iv: iv, isStreamable: isStreamable) + } + + /// Decode integrity information from CBOR + private static func decodeIntegrity(_ intMap: [CBOR: CBOR]) throws -> TDFIntegrityInformation { + // Root signature + guard let rootSigValue = intMap[.unsignedInt(UInt64(TDFCBORIntegrityKey.rootSignature.rawValue))], + case let .map(rootSigMap) = rootSigValue + else { + throw TDFCBORError.missingField("rootSignature") + } + let rootSig = try decodeRootSignature(rootSigMap) + + // Segment hash algorithm + var segmentHashAlg = "GMAC" + if let segAlgValue = intMap[.unsignedInt(UInt64(TDFCBORIntegrityKey.segmentHashAlg.rawValue))], + case let .unsignedInt(segAlgEnum) = segAlgValue + { + segmentHashAlg = enumToHashAlg(segAlgEnum) + } + + // Segments + var segments: [TDFSegment] = [] + if let segsValue = intMap[.unsignedInt(UInt64(TDFCBORIntegrityKey.segments.rawValue))], + case let .array(segsArray) = segsValue + { + for segItem in segsArray { + if case let .map(segMap) = segItem { + try segments.append(decodeSegment(segMap)) + } + } + } + + // Segment size defaults + var segmentSizeDefault = 0 + if let ssdValue = intMap[.unsignedInt(UInt64(TDFCBORIntegrityKey.segmentSizeDefault.rawValue))], + case let .unsignedInt(ssd) = ssdValue + { + segmentSizeDefault = Int(ssd) + } + + var encryptedSegmentSizeDefault = 0 + if let essdValue = intMap[.unsignedInt(UInt64(TDFCBORIntegrityKey.encryptedSegmentSizeDefault.rawValue))], + case let .unsignedInt(essd) = essdValue + { + encryptedSegmentSizeDefault = Int(essd) + } + + return TDFIntegrityInformation( + rootSignature: rootSig, + segmentHashAlg: segmentHashAlg, + segmentSizeDefault: Int64(segmentSizeDefault), + encryptedSegmentSizeDefault: Int64(encryptedSegmentSizeDefault), + segments: segments, + ) + } + + /// Decode root signature from CBOR + private static func decodeRootSignature(_ sigMap: [CBOR: CBOR]) throws -> TDFRootSignature { + var alg = "HS256" + if let algValue = sigMap[.unsignedInt(UInt64(TDFCBORRootSigKey.alg.rawValue))], + case let .unsignedInt(algEnum) = algValue + { + alg = enumToHashAlg(algEnum) + } + + var sig = "" + if let sigValue = sigMap[.unsignedInt(UInt64(TDFCBORRootSigKey.sig.rawValue))], + case let .byteString(sigBytes) = sigValue + { + sig = Data(sigBytes).base64EncodedString() + } + + return TDFRootSignature(alg: alg, sig: sig) + } + + /// Decode segment from CBOR + private static func decodeSegment(_ segMap: [CBOR: CBOR]) throws -> TDFSegment { + var hash = "" + if let hashValue = segMap[.unsignedInt(UInt64(TDFCBORSegmentKey.hash.rawValue))], + case let .byteString(hashBytes) = hashValue + { + hash = Data(hashBytes).base64EncodedString() + } + + var segmentSize: Int64 = 0 + if let ssValue = segMap[.unsignedInt(UInt64(TDFCBORSegmentKey.segmentSize.rawValue))], + case let .unsignedInt(ss) = ssValue + { + segmentSize = Int64(ss) + } + + var encryptedSegmentSize: Int64? + if let essValue = segMap[.unsignedInt(UInt64(TDFCBORSegmentKey.encryptedSegmentSize.rawValue))], + case let .unsignedInt(ess) = essValue + { + encryptedSegmentSize = Int64(ess) + } + + return TDFSegment(hash: hash, segmentSize: segmentSize, encryptedSegmentSize: encryptedSegmentSize) + } + + // MARK: - Assertions Encoding/Decoding + + /// Encode assertions to CBOR array using JSON as intermediate format + private func encodeAssertionsToCBOR(_ assertions: [TDFAssertion]) throws -> [CBOR] { + // Use JSON encoder to serialize assertions, then convert to CBOR byte strings + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + + var cborArray: [CBOR] = [] + for assertion in assertions { + let jsonData = try encoder.encode(assertion) + cborArray.append(.byteString(Array(jsonData))) + } + return cborArray + } + + /// Decode assertions from CBOR array using JSON as intermediate format + private static func decodeAssertionsFromCBOR(_ cborArray: [CBOR]) throws -> [TDFAssertion] { + let decoder = JSONDecoder() + + var assertions: [TDFAssertion] = [] + for item in cborArray { + switch item { + case let .byteString(bytes): + let jsonData = Data(bytes) + let assertion = try decoder.decode(TDFAssertion.self, from: jsonData) + assertions.append(assertion) + case let .utf8String(jsonString): + // Support legacy string format + guard let jsonData = jsonString.data(using: .utf8) else { + throw TDFCBORError.cborDecodingFailed("Invalid assertion JSON encoding") + } + let assertion = try decoder.decode(TDFAssertion.self, from: jsonData) + assertions.append(assertion) + default: + throw TDFCBORError.cborDecodingFailed("Expected assertion as byte string or UTF-8 string") + } + } + return assertions + } +} + +// MARK: - Helper Types + +/// Wrapper for manifest encoding +private struct ManifestWrapper: Codable { + let encryptionInformation: TDFEncryptionInformation + let assertions: [TDFAssertion]? + + init(manifest: TDFCBORManifest) { + encryptionInformation = manifest.encryptionInformation + assertions = manifest.assertions + } + + func toManifest() -> TDFCBORManifest { + TDFCBORManifest( + encryptionInformation: encryptionInformation, + assertions: assertions, + ) + } +} diff --git a/OpenTDFKit/TDF/TDFCrypto.swift b/OpenTDFKit/TDF/TDFCrypto.swift index 659eae8..0ed5220 100644 --- a/OpenTDFKit/TDF/TDFCrypto.swift +++ b/OpenTDFKit/TDF/TDFCrypto.swift @@ -79,6 +79,35 @@ public enum TDFCrypto { return try CryptoKit.AES.GCM.open(sealed, using: symmetricKey) } + /// Decrypt AES-GCM payload with combined IV + ciphertext + tag format + /// - Parameters: + /// - combinedPayload: Data containing IV (12 bytes) + ciphertext + tag (16 bytes) + /// - symmetricKey: The decryption key + /// - Returns: Decrypted plaintext + public static func decryptCombinedPayload( + _ combinedPayload: Data, + symmetricKey: SymmetricKey, + ) throws -> Data { + let ivSize = 12 + let tagSize = 16 + let minSize = ivSize + tagSize + + guard combinedPayload.count >= minSize else { + throw TDFCryptoError.decryptionFailed("Malformed payload: insufficient data") + } + + let iv = combinedPayload.prefix(ivSize) + let ciphertext = combinedPayload.dropFirst(ivSize).dropLast(tagSize) + let tag = combinedPayload.suffix(tagSize) + + return try decryptPayload( + ciphertext: Data(ciphertext), + iv: Data(iv), + tag: Data(tag), + symmetricKey: symmetricKey, + ) + } + // MARK: - AES-CBC Encryption (FairPlay Compatible) /// Encrypt payload using AES-CBC with PKCS7 padding. @@ -278,20 +307,35 @@ public enum TDFCrypto { /// - Parameters: /// - privateKey: The recipient's private key for ECDH /// - wrappedKey: The wrapped key data (base64-encoded nonce + ciphertext + tag) - /// - ephemeralPublicKeyPEM: The sender's ephemeral public key in PEM format + /// - ephemeralPublicKey: The sender's ephemeral public key (PEM or base64 SEC1 compressed) /// - curve: The EC curve used /// - Returns: The unwrapped symmetric key public static func unwrapSymmetricKeyWithEC( privateKey: P256.KeyAgreement.PrivateKey, wrappedKey: String, - ephemeralPublicKeyPEM: String, + ephemeralPublicKey ephemeralKeyString: String, ) throws -> SymmetricKey { guard let wrappedData = Data(base64Encoded: wrappedKey) else { throw TDFCryptoError.invalidWrappedKey } - // Extract ephemeral public key from PEM - let ephemeralPublicKey = try loadECPublicKeyP256(fromPEM: ephemeralPublicKeyPEM) + // Parse ephemeral public key - supports both PEM and base64 SEC1 (compressed/uncompressed) + let ephemeralPublicKey: P256.KeyAgreement.PublicKey + if ephemeralKeyString.contains("-----BEGIN") { + // PEM format (legacy) + ephemeralPublicKey = try loadECPublicKeyP256(fromPEM: ephemeralKeyString) + } else { + // Base64 SEC1 format (compressed or uncompressed) + guard let keyData = Data(base64Encoded: ephemeralKeyString) else { + throw TDFCryptoError.ecKeyAgreementFailed("Invalid ephemeral key encoding") + } + // Try compressed first (33 bytes), then uncompressed (65 bytes) + if keyData.count == 33 { + ephemeralPublicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: keyData) + } else { + ephemeralPublicKey = try P256.KeyAgreement.PublicKey(x963Representation: keyData) + } + } // Perform ECDH to get shared secret let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: ephemeralPublicKey) @@ -350,10 +394,11 @@ public enum TDFCrypto { // Combined format: nonce + ciphertext + tag let wrappedKey = sealed.combined!.base64EncodedString() - // Convert ephemeral public key to PEM - let ephemeralPEM = ephemeralPublicKey.pemRepresentation + // Convert ephemeral public key to compressed SEC1 format (33 bytes for P-256) + // Much smaller than PEM (~140 bytes) or uncompressed (65 bytes) + let ephemeralCompressed = ephemeralPublicKey.compressedRepresentation.base64EncodedString() - return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralPEM) + return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralCompressed) } // MARK: - P-384 EC Wrapping @@ -379,9 +424,10 @@ public enum TDFCrypto { let sealed = try AES.GCM.seal(keyData, using: wrapKey, nonce: nonce) let wrappedKey = sealed.combined!.base64EncodedString() - let ephemeralPEM = ephemeralPublicKey.pemRepresentation + // Compressed SEC1 format (49 bytes for P-384) + let ephemeralCompressed = ephemeralPublicKey.compressedRepresentation.base64EncodedString() - return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralPEM) + return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralCompressed) } // MARK: - P-521 EC Wrapping @@ -407,9 +453,10 @@ public enum TDFCrypto { let sealed = try AES.GCM.seal(keyData, using: wrapKey, nonce: nonce) let wrappedKey = sealed.combined!.base64EncodedString() - let ephemeralPEM = ephemeralPublicKey.pemRepresentation + // Compressed SEC1 format (67 bytes for P-521) + let ephemeralCompressed = ephemeralPublicKey.compressedRepresentation.base64EncodedString() - return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralPEM) + return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralCompressed) } // MARK: - EC Public Key Loading @@ -512,6 +559,7 @@ public enum TDFCryptoError: Error, CustomStringConvertible { case cbcDecryptionFailed(String) case ecKeyAgreementFailed(String) case unsupportedCurve(String) + case decryptionFailed(String) public var description: String { switch self { @@ -552,6 +600,8 @@ public enum TDFCryptoError: Error, CustomStringConvertible { return "EC key agreement failed: \(reason)" case let .unsupportedCurve(curve): return "Unsupported EC curve: \(curve)" + case let .decryptionFailed(reason): + return "Decryption failed: \(reason)" } } } diff --git a/OpenTDFKit/TDF/TDFFormatDetector.swift b/OpenTDFKit/TDF/TDFFormatDetector.swift new file mode 100644 index 0000000..ab234b3 --- /dev/null +++ b/OpenTDFKit/TDF/TDFFormatDetector.swift @@ -0,0 +1,190 @@ +import Foundation + +/// Format detector for TDF documents. +/// +/// Detects the format of TDF data by examining magic bytes and structure. +/// Detection order: CBOR (magic bytes) → ZIP (magic bytes) → JSON (starts with '{') +public enum TDFFormatDetector { + /// CBOR magic bytes: self-describe CBOR tag + map + key 1 + /// D9 D9F7 = tag(55799) for self-describe CBOR + /// A5 = map(5) for 5 elements + /// 01 = unsigned int 1 (first key) + public static let cborMagic: [UInt8] = [0xD9, 0xD9, 0xF7, 0xA5] + + /// ZIP magic bytes (PK signature) + public static let zipMagic: [UInt8] = [0x50, 0x4B, 0x03, 0x04] + + /// JSON start character '{' + public static let jsonStart: UInt8 = 0x7B + + /// NanoTDF magic bytes (version signature) + public static let nanoMagic: [UInt8] = [0x4C, 0x31] // "L1" for version 1.0 + + /// Detect the TDF format from raw data + /// + /// - Parameter data: Raw bytes to analyze + /// - Returns: Detected format kind, or nil if unknown + public static func detect(from data: Data) -> TrustedDataFormatKind? { + guard !data.isEmpty else { return nil } + + // Check CBOR magic bytes first (definitive) + if data.count >= 4 { + let header = [UInt8](data.prefix(4)) + if header == cborMagic { + return .cbor + } + } + + // Check ZIP magic bytes (definitive for archive TDF) + if data.count >= 4 { + let header = [UInt8](data.prefix(4)) + if header == zipMagic { + return .archive + } + } + + // Check NanoTDF magic + if data.count >= 2 { + let header = [UInt8](data.prefix(2)) + if header == nanoMagic { + return .nano + } + } + + // Check for JSON (speculative - starts with '{') + if data.first == jsonStart { + // Try to validate it's actually JSON with TDF structure + if isTDFJSON(data) { + return .json + } + } + + return nil + } + + /// Detect the TDF format from a file URL + /// + /// - Parameter url: File URL to analyze + /// - Returns: Detected format kind, or nil if unknown + public static func detect(from url: URL) -> TrustedDataFormatKind? { + // First try by extension + let ext = url.pathExtension.lowercased() + switch ext { + case "tdf": + // Could be archive or other - need to check content + break + case "ntdf": + return .nano + case "tdfjson": + return .json + case "tdfcbor": + return .cbor + default: + // Check compound extensions + let filename = url.lastPathComponent.lowercased() + if filename.hasSuffix(".tdf.json") { + return .json + } else if filename.hasSuffix(".tdf.cbor") { + return .cbor + } + } + + // Fall back to content detection + guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { + return nil + } + return detect(from: data) + } + + /// Check if data appears to be TDF-JSON + private static func isTDFJSON(_ data: Data) -> Bool { + // Quick validation: try to parse and check for "tdf": "json" + guard let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return false + } + return jsonObject["tdf"] as? String == "json" + } +} + +// MARK: - TDF Format Detection Result + +/// Result of TDF format detection with confidence level +public struct TDFFormatDetectionResult: Sendable { + /// Detected format kind + public let format: TrustedDataFormatKind + + /// Confidence level of detection + public let confidence: DetectionConfidence + + /// Detection method used + public let method: DetectionMethod + + public enum DetectionConfidence: Sendable { + case definitive // Magic bytes match exactly + case high // Strong structural indicators + case medium // File extension or partial match + case low // Speculative parse + } + + public enum DetectionMethod: Sendable { + case magicBytes + case fileExtension + case structuralParse + } +} + +public extension TDFFormatDetector { + /// Detect format with detailed result + static func detectWithDetails(from data: Data) -> TDFFormatDetectionResult? { + guard !data.isEmpty else { return nil } + + // Check CBOR magic bytes first + if data.count >= 4 { + let header = [UInt8](data.prefix(4)) + if header == cborMagic { + return TDFFormatDetectionResult( + format: .cbor, + confidence: .definitive, + method: .magicBytes, + ) + } + } + + // Check ZIP magic bytes + if data.count >= 4 { + let header = [UInt8](data.prefix(4)) + if header == zipMagic { + return TDFFormatDetectionResult( + format: .archive, + confidence: .definitive, + method: .magicBytes, + ) + } + } + + // Check NanoTDF magic + if data.count >= 2 { + let header = [UInt8](data.prefix(2)) + if header == nanoMagic { + return TDFFormatDetectionResult( + format: .nano, + confidence: .definitive, + method: .magicBytes, + ) + } + } + + // Check for JSON + if data.first == jsonStart { + if isTDFJSON(data) { + return TDFFormatDetectionResult( + format: .json, + confidence: .high, + method: .structuralParse, + ) + } + } + + return nil + } +} diff --git a/OpenTDFKit/TDF/TDFJSONContainer.swift b/OpenTDFKit/TDF/TDFJSONContainer.swift new file mode 100644 index 0000000..a3ed96c --- /dev/null +++ b/OpenTDFKit/TDF/TDFJSONContainer.swift @@ -0,0 +1,313 @@ +import CryptoKit +import Foundation + +// MARK: - TDF-JSON Container + +/// Container for TDF-JSON format that conforms to TrustedDataContainer. +public struct TDFJSONContainer: TrustedDataContainer, Sendable { + /// The TDF-JSON envelope + public let envelope: TDFJSONEnvelope + + /// Raw payload data (encrypted ciphertext) + public let payloadData: Data + + public var formatKind: TrustedDataFormatKind { .json } + + public init(envelope: TDFJSONEnvelope, payloadData: Data) { + self.envelope = envelope + self.payloadData = payloadData + } + + /// Serialize the container to JSON data + public func serializedData() throws -> Data { + try envelope.toJSONData() + } + + /// Get the manifest from the envelope + public var manifest: TDFJSONManifest { + envelope.manifest + } + + /// Get the encryption information + public var encryptionInformation: TDFEncryptionInformation { + envelope.manifest.encryptionInformation + } + + /// Get the MIME type of the payload + public var mimeType: String? { + envelope.payload.mimeType + } +} + +// MARK: - TDF-JSON Builder + +/// Builder for creating TDF-JSON containers with encryption. +public struct TDFJSONBuilder: Sendable { + private var kasURL: URL? + private var kasPublicKeyPEM: String? + private var kasKid: String? + private var mimeType: String? + private var includeCreated: Bool = true + private var policy: TDFPolicy? + + /// Creates a new TDF-JSON builder with default settings + public init() { + // Intentionally empty - configuration is done via builder methods + } + + /// Set the KAS URL + public func kasURL(_ url: URL) -> TDFJSONBuilder { + var copy = self + copy.kasURL = url + return copy + } + + /// Set the KAS public key PEM + public func kasPublicKey(_ pem: String) -> TDFJSONBuilder { + var copy = self + copy.kasPublicKeyPEM = pem + return copy + } + + /// Set the KAS key ID + public func kasKid(_ kid: String) -> TDFJSONBuilder { + var copy = self + copy.kasKid = kid + return copy + } + + /// Set the MIME type of the plaintext + public func mimeType(_ type: String) -> TDFJSONBuilder { + var copy = self + copy.mimeType = type + return copy + } + + /// Whether to include the created timestamp + public func includeCreated(_ include: Bool) -> TDFJSONBuilder { + var copy = self + copy.includeCreated = include + return copy + } + + /// Set the policy + public func policy(_ policy: TDFPolicy) -> TDFJSONBuilder { + var copy = self + copy.policy = policy + return copy + } + + /// Build a TDF-JSON container by encrypting the provided plaintext + public func encrypt(plaintext: Data) throws -> TDFJSONEncryptionResult { + guard let kasURL else { + throw TDFJSONError.encryptionFailed("KAS URL is required") + } + guard let kasPublicKeyPEM else { + throw TDFJSONError.encryptionFailed("KAS public key is required") + } + guard let policy else { + throw TDFJSONError.encryptionFailed("Policy is required") + } + + // Generate symmetric key + let symmetricKey = SymmetricKey(size: .bits256) + + // Encrypt the plaintext + let (iv, ciphertext, tag) = try TDFCrypto.encryptPayload( + plaintext: plaintext, + symmetricKey: symmetricKey, + ) + + // Combine IV + ciphertext + tag for the payload + let payloadData = iv + ciphertext + tag + + // Wrap the symmetric key using EC (ECDH + HKDF + AES-GCM) + let ecWrapped = try TDFCrypto.wrapSymmetricKeyWithEC( + publicKeyPEM: kasPublicKeyPEM, + symmetricKey: symmetricKey, + ) + + // Create policy binding + let policyBinding = TDFCrypto.policyBinding( + policy: policy.json, + symmetricKey: symmetricKey, + ) + + // Calculate segment signature (GMAC) + let segmentSignature = try TDFCrypto.segmentSignatureGMAC( + segmentCiphertext: payloadData, + symmetricKey: symmetricKey, + ) + + // Calculate root signature + let rootSignature = TDFCrypto.segmentSignature( + segmentCiphertext: segmentSignature, + symmetricKey: symmetricKey, + ) + + // Create key access object with EC ephemeral public key + let keyAccessObject = TDFKeyAccessObject( + type: .wrapped, + url: kasURL.absoluteString, + protocolValue: .kas, + wrappedKey: ecWrapped.wrappedKey, + policyBinding: policyBinding, + encryptedMetadata: nil, + kid: kasKid, + sid: nil, + schemaVersion: "1.0", + ephemeralPublicKey: ecWrapped.ephemeralPublicKey, + ) + + // Create integrity information + let integrityInfo = TDFIntegrityInformation( + rootSignature: TDFRootSignature(alg: "HS256", sig: rootSignature.base64EncodedString()), + segmentHashAlg: "GMAC", + segmentSizeDefault: Int64(plaintext.count), + encryptedSegmentSizeDefault: Int64(payloadData.count), + segments: [ + TDFSegment( + hash: segmentSignature.base64EncodedString(), + segmentSize: Int64(plaintext.count), + encryptedSegmentSize: Int64(payloadData.count), + ), + ], + ) + + // Create method descriptor + let method = TDFMethodDescriptor( + algorithm: "AES-256-GCM", + iv: iv.base64EncodedString(), + isStreamable: true, + ) + + // Create encryption information + let encryptionInfo = TDFEncryptionInformation( + type: .split, + keyAccess: [keyAccessObject], + method: method, + integrityInformation: integrityInfo, + policy: policy.base64String, + ) + + // Create manifest + let manifest = TDFJSONManifest( + encryptionInformation: encryptionInfo, + assertions: nil, + ) + + // Create payload + let payload = TDFInlinePayload( + type: "inline", + protocol: "base64", + mimeType: mimeType, + isEncrypted: true, + length: UInt64(payloadData.count), + value: payloadData.base64EncodedString(), + ) + + // Create envelope + let created = includeCreated ? ISO8601DateFormatter().string(from: Date()) : nil + let envelope = TDFJSONEnvelope( + tdf: "json", + version: "1.0.0", + created: created, + manifest: manifest, + payload: payload, + ) + + let container = TDFJSONContainer(envelope: envelope, payloadData: payloadData) + + return TDFJSONEncryptionResult( + container: container, + symmetricKey: symmetricKey, + iv: iv, + tag: tag, + ) + } +} + +// MARK: - TDF-JSON Encryption Result + +/// Result of TDF-JSON encryption containing the container and key material +public struct TDFJSONEncryptionResult: Sendable { + /// The encrypted TDF-JSON container + public let container: TDFJSONContainer + + /// The symmetric key used for encryption (save for later decryption) + public let symmetricKey: SymmetricKey + + /// The IV used for encryption + public let iv: Data + + /// The authentication tag + public let tag: Data + + public init(container: TDFJSONContainer, symmetricKey: SymmetricKey, iv: Data, tag: Data) { + self.container = container + self.symmetricKey = symmetricKey + self.iv = iv + self.tag = tag + } +} + +// MARK: - TDF-JSON Loader + +/// Loader for parsing TDF-JSON from data or files +public struct TDFJSONLoader: Sendable { + /// Creates a new TDF-JSON loader + public init() { + // Intentionally empty - stateless loader + } + + /// Load a TDF-JSON container from data + public func load(from data: Data) throws -> TDFJSONContainer { + let envelope = try TDFJSONEnvelope.parse(from: data) + let payloadData = try envelope.decodePayloadValue() + return TDFJSONContainer(envelope: envelope, payloadData: payloadData) + } + + /// Load a TDF-JSON container from a URL + public func load(from url: URL) throws -> TDFJSONContainer { + let data = try Data(contentsOf: url) + return try load(from: data) + } + + /// Load a TDF-JSON container from a JSON string + public func load(from jsonString: String) throws -> TDFJSONContainer { + guard let data = jsonString.data(using: .utf8) else { + throw TDFJSONError.payloadDecodeError("Invalid UTF-8 string") + } + return try load(from: data) + } +} + +// MARK: - TDF-JSON Decryptor + +/// Decryptor for TDF-JSON containers +public struct TDFJSONDecryptor: Sendable { + /// Creates a new TDF-JSON decryptor + public init() { + // Intentionally empty - stateless decryptor + } + + /// Decrypt a TDF-JSON container with a symmetric key + public func decrypt(container: TDFJSONContainer, symmetricKey: SymmetricKey) throws -> Data { + try TDFCrypto.decryptCombinedPayload(container.payloadData, symmetricKey: symmetricKey) + } + + /// Decrypt a TDF-JSON container with a private key (unwraps the symmetric key first) + public func decrypt(container: TDFJSONContainer, privateKeyPEM: String) throws -> Data { + let keyAccess = container.encryptionInformation.keyAccess + guard !keyAccess.isEmpty else { + throw TDFJSONError.decryptionFailed("No key access objects found") + } + + let symmetricKey = try TDFCrypto.unwrapSymmetricKeyWithRSA( + privateKeyPEM: privateKeyPEM, + wrappedKey: keyAccess[0].wrappedKey, + ) + + return try decrypt(container: container, symmetricKey: symmetricKey) + } +} diff --git a/OpenTDFKit/TDF/TDFJSONFormat.swift b/OpenTDFKit/TDF/TDFJSONFormat.swift new file mode 100644 index 0000000..cd61257 --- /dev/null +++ b/OpenTDFKit/TDF/TDFJSONFormat.swift @@ -0,0 +1,228 @@ +import Foundation + +// MARK: - TDF-JSON Envelope (per TDF-JSON specification draft-00) + +/// TDF-JSON envelope for inline payload transmission. +/// +/// This structure represents a complete TDF-JSON package per the TDF-JSON +/// specification draft-00. The format is optimized for JSON-RPC protocols, +/// REST APIs, and streaming scenarios. +/// +/// ## Structure +/// ```json +/// { +/// "tdf": "json", +/// "version": "1.0.0", +/// "created": "2026-01-17T12:00:00Z", +/// "manifest": { ... }, +/// "payload": { ... } +/// } +/// ``` +public struct TDFJSONEnvelope: Codable, Sendable { + /// Format identifier. MUST be "json" for TDF-JSON documents. + public let tdf: String + + /// Semantic version of the TDF-JSON specification (e.g., "1.0.0") + public let version: String + + /// ISO 8601 timestamp of document creation (optional) + public var created: String? + + /// TDF manifest containing encryption and policy information + public let manifest: TDFJSONManifest + + /// Inline encrypted payload container + public let payload: TDFInlinePayload + + public init( + tdf: String = "json", + version: String = "1.0.0", + created: String? = nil, + manifest: TDFJSONManifest, + payload: TDFInlinePayload, + ) { + self.tdf = tdf + self.version = version + self.created = created + self.manifest = manifest + self.payload = payload + } + + /// Format identifier (always "json") + public var formatId: String { tdf } +} + +// MARK: - TDF-JSON Manifest + +/// TDF manifest for TDF-JSON format. +/// +/// Contains encryption information and optional assertions, but NOT the payload +/// (which is at the top level in TDF-JSON per the specification). +public struct TDFJSONManifest: Codable, Sendable { + /// Encryption information including key access and policy + public let encryptionInformation: TDFEncryptionInformation + + /// Optional assertions for additional metadata + public var assertions: [TDFAssertion]? + + public init( + encryptionInformation: TDFEncryptionInformation, + assertions: [TDFAssertion]? = nil, + ) { + self.encryptionInformation = encryptionInformation + self.assertions = assertions + } +} + +// MARK: - TDF-JSON Payload + +/// JSON payload for TDF-JSON transport. +/// +/// Contains the encrypted data inline as a base64-encoded string. +/// Per the TDF-JSON spec, the payload is at the top level (not nested in manifest). +public struct TDFInlinePayload: Codable, Sendable { + /// Payload type. MUST be "inline" for TDF-JSON + public let type: String + + /// Encoding protocol. MUST be "base64" for TDF-JSON + public let `protocol`: String + + /// MIME type of the original (unencrypted) data + public var mimeType: String? + + /// Whether the payload is encrypted. MUST be true + public let isEncrypted: Bool + + /// Length of ciphertext in bytes (before base64) + public var length: UInt64? + + /// Base64-encoded ciphertext + public let value: String + + public init( + type: String = "inline", + protocol: String = "base64", + mimeType: String? = nil, + isEncrypted: Bool = true, + length: UInt64? = nil, + value: String, + ) { + self.type = type + self.protocol = `protocol` + self.mimeType = mimeType + self.isEncrypted = isEncrypted + self.length = length + self.value = value + } +} + +// MARK: - TDF-JSON Error Types + +/// Errors specific to TDF-JSON parsing and validation. +public enum TDFJSONError: Error, CustomStringConvertible, Sendable { + case missingTdfField + case invalidTdfIdentifier(String) + case unsupportedVersion(String) + case payloadDecodeError(String) + case manifestParsingFailed(String) + case encryptionFailed(String) + case decryptionFailed(String) + + public var description: String { + switch self { + case .missingTdfField: + "Missing 'tdf' field in envelope" + case let .invalidTdfIdentifier(id): + "Invalid tdf identifier: expected 'json', got '\(id)'" + case let .unsupportedVersion(v): + "Unsupported version: \(v)" + case let .payloadDecodeError(e): + "Payload decode error: \(e)" + case let .manifestParsingFailed(e): + "Manifest parsing failed: \(e)" + case let .encryptionFailed(e): + "Encryption failed: \(e)" + case let .decryptionFailed(e): + "Decryption failed: \(e)" + } + } +} + +// MARK: - TDF-JSON Extensions + +public extension TDFJSONEnvelope { + /// Parse a TDF-JSON envelope from JSON data + static func parse(from data: Data) throws -> TDFJSONEnvelope { + let decoder = JSONDecoder() + let envelope = try decoder.decode(TDFJSONEnvelope.self, from: data) + + // Validate tdf field + guard envelope.tdf == "json" else { + throw TDFJSONError.invalidTdfIdentifier(envelope.tdf) + } + + return envelope + } + + /// Parse a TDF-JSON envelope from a JSON string + static func parse(from jsonString: String) throws -> TDFJSONEnvelope { + guard let data = jsonString.data(using: .utf8) else { + throw TDFJSONError.payloadDecodeError("Invalid UTF-8 string") + } + return try parse(from: data) + } + + /// Serialize to JSON data + func toJSONData(prettyPrinted: Bool = false) throws -> Data { + let encoder = JSONEncoder() + if prettyPrinted { + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } else { + encoder.outputFormatting = [.sortedKeys] + } + return try encoder.encode(self) + } + + /// Serialize to JSON string + func toJSONString(prettyPrinted: Bool = false) throws -> String { + let data = try toJSONData(prettyPrinted: prettyPrinted) + guard let string = String(data: data, encoding: .utf8) else { + throw TDFJSONError.payloadDecodeError("Failed to encode as UTF-8") + } + return string + } + + /// Convert to standard TDF manifest format (for KAS integration) + func toStandardManifest() -> TDFManifest { + TDFManifest( + schemaVersion: "1.0.0", + payload: TDFPayloadDescriptor( + type: .reference, + url: "inline", + protocolValue: .zip, + isEncrypted: true, + mimeType: payload.mimeType, + ), + encryptionInformation: manifest.encryptionInformation, + assertions: manifest.assertions, + ) + } + + /// Decode the base64 payload value to raw bytes + func decodePayloadValue() throws -> Data { + guard let data = Data(base64Encoded: payload.value) else { + throw TDFJSONError.payloadDecodeError("Invalid base64 encoding") + } + return data + } +} + +// MARK: - Conversion from Standard Manifest + +public extension TDFJSONManifest { + /// Create a TDF-JSON manifest from a standard TDF manifest + init(from manifest: TDFManifest) { + encryptionInformation = manifest.encryptionInformation + assertions = manifest.assertions + } +} diff --git a/OpenTDFKit/TDF/TrustedDataFormat.swift b/OpenTDFKit/TDF/TrustedDataFormat.swift index 36ee50a..2555a1f 100644 --- a/OpenTDFKit/TDF/TrustedDataFormat.swift +++ b/OpenTDFKit/TDF/TrustedDataFormat.swift @@ -5,6 +5,8 @@ import Foundation public enum TrustedDataFormatKind: Sendable { case nano case archive + case json // TDF-JSON format (spec draft-00) + case cbor // TDF-CBOR format (spec draft-00) } /// Format-agnostic container interface allowing shared tooling. diff --git a/OpenTDFKitCLI/Commands.swift b/OpenTDFKitCLI/Commands.swift index ea31dc5..0911cb7 100644 --- a/OpenTDFKitCLI/Commands.swift +++ b/OpenTDFKitCLI/Commands.swift @@ -1027,3 +1027,309 @@ enum CollectionCLIError: Error, CustomStringConvertible { } } } + +// MARK: - TDF-JSON and TDF-CBOR Commands + +/// Result type for TDF-JSON/CBOR encryption +struct TDFInlineEncryptionResult { + let data: Data + let symmetricKey: SymmetricKey +} + +extension Commands { + /// Encrypt plaintext to TDF-JSON format + static func encryptTDFJSON( + plaintext: Data, + inputURL _: URL, + ) throws -> TDFInlineEncryptionResult { + print("TDF-JSON Encryption") + print("===================") + print("Plaintext size: \(plaintext.count) bytes") + + let env = ProcessInfo.processInfo.environment + guard let kasURLString = env["TDF_KAS_URL"] ?? env["KASURL"], + let kasURL = URL(string: kasURLString) + else { + throw EncryptError.missingConfiguration("TDF_KAS_URL or KASURL environment variable required") + } + + let publicKeyPEM = try loadKASPublicKeyPEM() + let policy = try loadPolicy() + + let builder = TDFJSONBuilder() + .kasURL(kasURL) + .kasPublicKey(publicKeyPEM) + + let builderWithKid: TDFJSONBuilder = if let kid = env["TDF_KAS_KID"] { + builder.kasKid(kid) + } else { + builder + } + + let builderWithMime: TDFJSONBuilder = if let mimeType = env["TDF_MIME_TYPE"] { + builderWithKid.mimeType(mimeType) + } else { + builderWithKid + } + + let result = try builderWithMime + .policy(policy) + .encrypt(plaintext: plaintext) + + let jsonData = try result.container.serializedData() + + print("✓ Created TDF-JSON (\(jsonData.count) bytes)") + + return TDFInlineEncryptionResult( + data: jsonData, + symmetricKey: result.symmetricKey, + ) + } + + /// Encrypt plaintext to TDF-CBOR format + static func encryptTDFCBOR( + plaintext: Data, + inputURL _: URL, + ) throws -> TDFInlineEncryptionResult { + print("TDF-CBOR Encryption") + print("===================") + print("Plaintext size: \(plaintext.count) bytes") + + let env = ProcessInfo.processInfo.environment + guard let kasURLString = env["TDF_KAS_URL"] ?? env["KASURL"], + let kasURL = URL(string: kasURLString) + else { + throw EncryptError.missingConfiguration("TDF_KAS_URL or KASURL environment variable required") + } + + let publicKeyPEM = try loadKASPublicKeyPEM() + let policy = try loadPolicy() + + let builder = TDFCBORBuilder() + .kasURL(kasURL) + .kasPublicKey(publicKeyPEM) + + let builderWithKid: TDFCBORBuilder = if let kid = env["TDF_KAS_KID"] { + builder.kasKid(kid) + } else { + builder + } + + let builderWithMime: TDFCBORBuilder = if let mimeType = env["TDF_MIME_TYPE"] { + builderWithKid.mimeType(mimeType) + } else { + builderWithKid + } + + let result = try builderWithMime + .policy(policy) + .encrypt(plaintext: plaintext) + + let cborData = try result.container.serializedData() + + print("✓ Created TDF-CBOR (\(cborData.count) bytes)") + + return TDFInlineEncryptionResult( + data: cborData, + symmetricKey: result.symmetricKey, + ) + } + + /// Decrypt TDF-JSON format + static func decryptTDFJSON( + data: Data, + filename: String, + symmetricKey: SymmetricKey?, + privateKeyPEM: String?, + ) throws -> Data { + print("TDF-JSON Decryption") + print("===================") + print("File: \(filename)") + print("Size: \(data.count) bytes\n") + + let loader = TDFJSONLoader() + let container = try loader.load(from: data) + + print("✓ TDF-JSON loaded") + print(" Version: \(container.envelope.version)") + + let decryptor = TDFJSONDecryptor() + + if let symmetricKey { + print(" Using provided symmetric key for decryption") + return try decryptor.decrypt(container: container, symmetricKey: symmetricKey) + } + + guard let privateKeyPEM else { + throw DecryptError.missingSymmetricMaterial + } + + print(" Using private key for decryption") + return try decryptor.decrypt(container: container, privateKeyPEM: privateKeyPEM) + } + + /// Decrypt TDF-CBOR format + static func decryptTDFCBOR( + data: Data, + filename: String, + symmetricKey: SymmetricKey?, + privateKeyPEM: String?, + ) throws -> Data { + print("TDF-CBOR Decryption") + print("===================") + print("File: \(filename)") + print("Size: \(data.count) bytes\n") + + let loader = TDFCBORLoader() + let container = try loader.load(from: data) + + print("✓ TDF-CBOR loaded") + print(" Version: \(container.envelope.version.map { String($0) }.joined(separator: "."))") + + let decryptor = TDFCBORDecryptor() + + if let symmetricKey { + print(" Using provided symmetric key for decryption") + return try decryptor.decrypt(container: container, symmetricKey: symmetricKey) + } + + guard let privateKeyPEM else { + throw DecryptError.missingSymmetricMaterial + } + + print(" Using private key for decryption") + return try decryptor.decrypt(container: container, privateKeyPEM: privateKeyPEM) + } + + /// Verify TDF-JSON structure + static func verifyTDFJSON(data: Data, filename: String) throws { + print("TDF-JSON Verification Report") + print("============================") + print("File: \(filename)") + print("Size: \(data.count) bytes\n") + + let loader = TDFJSONLoader() + let container = try loader.load(from: data) + + print("✓ TDF-JSON parsed successfully") + print(" TDF Type: \(container.envelope.tdf)") + print(" Version: \(container.envelope.version)") + if let created = container.envelope.created { + print(" Created: \(created)") + } + + let enc = container.encryptionInformation + print("\nEncryption Information:") + print(" Type: \(enc.type.rawValue)") + print(" Key Access Objects: \(enc.keyAccess.count)") + print(" Algorithm: \(enc.method.algorithm)") + + print("\nPayload:") + print(" Type: \(container.envelope.payload.type)") + print(" Protocol: \(container.envelope.payload.protocol)") + print(" Encrypted: \(container.envelope.payload.isEncrypted)") + if let mimeType = container.envelope.payload.mimeType { + print(" MIME Type: \(mimeType)") + } + + print("\n✓ TDF-JSON structure validated") + } + + /// Verify TDF-CBOR structure + static func verifyTDFCBOR(data: Data, filename: String) throws { + print("TDF-CBOR Verification Report") + print("============================") + print("File: \(filename)") + print("Size: \(data.count) bytes\n") + + // Check magic bytes + guard TDFCBOREnvelope.hasMagicBytes(data) else { + throw TDFCBORError.invalidMagicBytes + } + print("✓ CBOR magic bytes verified") + + let loader = TDFCBORLoader() + let container = try loader.load(from: data) + + print("✓ TDF-CBOR parsed successfully") + print(" TDF Type: \(container.envelope.tdf)") + print(" Version: \(container.envelope.version.map { String($0) }.joined(separator: "."))") + if let created = container.envelope.created { + print(" Created: \(created)") + } + + let enc = container.encryptionInformation + print("\nEncryption Information:") + print(" Type: \(enc.type.rawValue)") + print(" Key Access Objects: \(enc.keyAccess.count)") + print(" Algorithm: \(enc.method.algorithm)") + + print("\nPayload:") + print(" Type: \(container.envelope.payload.type)") + print(" Protocol: \(container.envelope.payload.protocol)") + print(" Encrypted: \(container.envelope.payload.isEncrypted)") + print(" Size: \(container.envelope.payload.value.count) bytes") + if let mimeType = container.envelope.payload.mimeType { + print(" MIME Type: \(mimeType)") + } + + print("\n✓ TDF-CBOR structure validated") + } + + // MARK: - Private Helpers + + private static func loadKASPublicKeyPEM() throws -> String { + let env = ProcessInfo.processInfo.environment + if let inline = env["TDF_KAS_PUBLIC_KEY"], + !inline.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return inline + } + + if let path = env["TDF_KAS_PUBLIC_KEY_PATH"] { + let url = URL(fileURLWithPath: path) + return try String(contentsOf: url, encoding: .utf8) + } + + throw EncryptError.missingConfiguration("TDF_KAS_PUBLIC_KEY or TDF_KAS_PUBLIC_KEY_PATH required") + } + + private static func loadPolicy() throws -> TDFPolicy { + let env = ProcessInfo.processInfo.environment + + if let policyPath = env["TDF_POLICY_PATH"] { + let url = URL(fileURLWithPath: policyPath) + let data = try Data(contentsOf: url) + return try TDFPolicy(json: data) + } + + if let policyBase64 = env["TDF_POLICY_BASE64"], + let data = Data(base64Encoded: policyBase64) + { + return try TDFPolicy(json: data) + } + + if let inlineJSON = env["TDF_POLICY_JSON"], + let data = inlineJSON.data(using: .utf8) + { + return try TDFPolicy(json: data) + } + + // Create default policy + let policy: [String: Any] = [ + "uuid": UUID().uuidString.lowercased(), + "body": [ + "dataAttributes": [] as [Any], + "dissem": [] as [Any], + ], + ] + + guard JSONSerialization.isValidJSONObject(policy), + let data = try? JSONSerialization.data(withJSONObject: policy, options: [.sortedKeys]) + else { + throw EncryptError.missingConfiguration("Unable to create default policy") + } + + return try TDFPolicy(json: data) + } +} diff --git a/OpenTDFKitCLI/main.swift b/OpenTDFKitCLI/main.swift index 5b05691..cc86961 100644 --- a/OpenTDFKitCLI/main.swift +++ b/OpenTDFKitCLI/main.swift @@ -10,6 +10,8 @@ enum CLIDataFormat: String { case nanoCollection = "nano-collection" case tdf case ztdf + case json + case cbor static func parse(_ rawValue: String) throws -> CLIDataFormat { let normalized = rawValue.lowercased() @@ -19,6 +21,12 @@ enum CLIDataFormat: String { if normalized == "nano_collection" || normalized == "nano-collection" { return .nanoCollection } + if normalized == "tdf-json" || normalized == "tdfjson" { + return .json + } + if normalized == "tdf-cbor" || normalized == "tdfcbor" { + return .cbor + } guard let format = CLIDataFormat(rawValue: normalized) else { throw CLIError.unsupportedFormat(rawValue) } @@ -125,13 +133,15 @@ struct OpenTDFKitCLI { nano-collection NanoTDF Collection (single key, multiple payloads) tdf Standard ZIP-based TDF (supports streaming) ztdf ZTDF alias for standard TDF + json TDF-JSON format (inline base64 payload) + cbor TDF-CBOR format (binary payload) Options: --chunk-size Chunk size for streaming (2m, 5m, 25m, or bytes) --segments Comma-separated segment sizes (e.g., 2m,5m,2m) Features (for supports command): - nano, nano_ecdsa, nano_collection, ztdf, etc. + nano, nano_ecdsa, nano_collection, ztdf, json, cbor, etc. Environment Variables: CLIENTID OAuth client ID @@ -144,10 +154,14 @@ struct OpenTDFKitCLI { OpenTDFKitCLI encrypt input.txt output.ntdf nano-collection OpenTDFKitCLI encrypt large.dat output.tdf tdf --chunk-size 5m OpenTDFKitCLI encrypt large.dat output.tdf tdf --segments 2m,5m,25m + OpenTDFKitCLI encrypt input.txt output.json json + OpenTDFKitCLI encrypt input.txt output.cbor cbor OpenTDFKitCLI decrypt output.ntdf recovered.txt nano-collection OpenTDFKitCLI decrypt output.tdf recovered.dat tdf --chunk-size 5m + OpenTDFKitCLI decrypt output.json recovered.txt json + OpenTDFKitCLI decrypt output.cbor recovered.txt cbor OpenTDFKitCLI benchmark test.dat tdf - OpenTDFKitCLI supports nano_collection + OpenTDFKitCLI supports json OpenTDFKitCLI verify test.ntdf """) } @@ -198,10 +212,21 @@ struct OpenTDFKitCLI { } let data = try Data(contentsOf: fileURL) - if Commands.isLikelyArchiveTDF(data: data) { + + // Use TDFFormatDetector for automatic format detection + guard let format = TDFFormatDetector.detect(from: data) else { + throw CLIError.unsupportedFormat("Unable to detect TDF format from file contents") + } + + switch format { + case .archive: try Commands.verifyTDF(data: data, filename: fileURL.lastPathComponent) - } else { + case .nano: try Commands.verifyNanoTDF(data: data, filename: fileURL.lastPathComponent) + case .json: + try Commands.verifyTDFJSON(data: data, filename: fileURL.lastPathComponent) + case .cbor: + try Commands.verifyTDFCBOR(data: data, filename: fileURL.lastPathComponent) } } @@ -287,6 +312,26 @@ struct OpenTDFKitCLI { outputData = result.archiveData try outputData.write(to: outputURL) } + case .json: + let inputData = try Data(contentsOf: inputURL) + let result = try Commands.encryptTDFJSON( + plaintext: inputData, + inputURL: inputURL, + ) + outputData = result.data + try outputData.write(to: outputURL) + try persistSymmetricKeyIfRequested(result.symmetricKey) + return + case .cbor: + let inputData = try Data(contentsOf: inputURL) + let result = try Commands.encryptTDFCBOR( + plaintext: inputData, + inputURL: inputURL, + ) + outputData = result.data + try outputData.write(to: outputURL) + try persistSymmetricKeyIfRequested(result.symmetricKey) + return } if let result = standardTDFResult { @@ -353,6 +398,28 @@ struct OpenTDFKitCLI { oauthToken: oauthToken, ) usedStandardTDF = true + case .json: + let symmetricKey = try loadSymmetricKeyFromEnvironment() + let privateKey = try loadPrivateKeyPEMFromEnvironment() + + plaintext = try Commands.decryptTDFJSON( + data: data, + filename: inputURL.lastPathComponent, + symmetricKey: symmetricKey, + privateKeyPEM: privateKey, + ) + usedStandardTDF = true + case .cbor: + let symmetricKey = try loadSymmetricKeyFromEnvironment() + let privateKey = try loadPrivateKeyPEMFromEnvironment() + + plaintext = try Commands.decryptTDFCBOR( + data: data, + filename: inputURL.lastPathComponent, + symmetricKey: symmetricKey, + privateKeyPEM: privateKey, + ) + usedStandardTDF = true } // Write recovered file @@ -627,6 +694,10 @@ struct OpenTDFKitCLI { return 0 case "tdf", "ztdf": return 0 + case "json", "tdf-json", "tdfjson": + return 0 + case "cbor", "tdf-cbor", "tdfcbor": + return 0 case "ztdf-ecwrap", "assertions", "assertion_verification", "autoconfigure", "better-messages-2024", "bulk_rewrap", "connectrpc", "ecwrap", "hexless", "hexaflexible", diff --git a/Package.resolved b/Package.resolved index 4e191ab..d0354b3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0541d55c2c085e4c554acbdd50f39ef40f477b515d34921b17eb7f01c58d5c14", + "originHash" : "e162d320824b9248ebfeb30cb7f17b093e71137b2c8a5592f98627b4857824b0", "pins" : [ { "identity" : "cryptoswift", @@ -10,6 +10,15 @@ "version" : "1.9.0" } }, + { + "identity" : "swiftcbor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/valpackett/SwiftCBOR", + "state" : { + "revision" : "fd929a6d8f7651ce0f63fc99397fd5762358cc29", + "version" : "0.6.0" + } + }, { "identity" : "zipfoundation", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 2968d89..de00a51 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/krzyzanowskim/CryptoSwift", from: "1.8.0"), .package(url: "https://github.com/weichsel/ZIPFoundation", from: "0.9.0"), + .package(url: "https://github.com/valpackett/SwiftCBOR", from: "0.4.0"), ], targets: [ .target( @@ -33,6 +34,7 @@ let package = Package( dependencies: [ "CryptoSwift", .product(name: "ZIPFoundation", package: "ZIPFoundation"), + .product(name: "SwiftCBOR", package: "SwiftCBOR"), ], path: "OpenTDFKit", ), diff --git a/docs/NANOTDF_MIGRATION.md b/docs/NANOTDF_MIGRATION.md new file mode 100644 index 0000000..f83a965 --- /dev/null +++ b/docs/NANOTDF_MIGRATION.md @@ -0,0 +1,321 @@ +# NanoTDF to TDF-CBOR Migration Guide + +## Deprecation Notice + +**NanoTDF is deprecated as of OpenTDFKit 2.0.** New applications should use TDF-CBOR instead. NanoTDF will be removed in a future major version. + +### Why TDF-CBOR? + +| Feature | NanoTDF | TDF-CBOR | +|---------|---------|----------| +| Format | Custom binary | Standard CBOR (RFC 8949) | +| Manifest | Binary-encoded, limited | Full TDF manifest, extensible | +| Policy | Embedded binding only | Full JSON policy with attributes | +| Assertions | Not supported | Fully supported | +| Multi-KAS | Not supported | Supported | +| Tooling | Custom parsers needed | Standard CBOR tools work | +| Size | ~250 bytes overhead | ~460 bytes overhead | +| Cross-SDK | Complex, version-sensitive | Simple, well-defined | + +### When to Still Use NanoTDF + +- Existing systems with NanoTDF infrastructure +- Extreme size constraints (< 500 bytes total overhead) +- Legacy compatibility requirements + +--- + +## Migration Steps + +### 1. Update Imports + +NanoTDF uses low-level primitives scattered across files. TDF-CBOR uses a unified builder pattern. + +```swift +// Old: NanoTDF +import OpenTDFKit +// Uses: NanoTDF, Header, Payload, KasMetadata, createNanoTDFv12() + +// New: TDF-CBOR +import OpenTDFKit +// Uses: TDFCBORBuilder, TDFCBORContainer, TDFPolicy +``` + +### 2. Encryption + +#### NanoTDF (Deprecated) + +```swift +// Create KAS metadata +let kasMetadata = KasMetadata( + url: URL(string: "https://kas.example.com")!, + publicKey: kasPublicKey, + curve: .secp256r1 +) + +// Create policy +var policy = Policy( + type: .remote, + body: .remote(RemotePolicyBody(url: policyUrl)), + binding: nil // Will be set during creation +) + +// Encrypt +let nanoTDF = try await createNanoTDFv12( + kas: kasMetadata, + policy: &policy, + plaintext: plaintextData +) + +// Serialize +let encryptedData = nanoTDF.toData() +``` + +#### TDF-CBOR (Recommended) + +```swift +// Create policy +let policy = TDFPolicy( + uuid: UUID().uuidString, + body: TDFPolicyBody( + dataAttributes: [], + dissem: ["user@example.com"] + ) +) + +// Build and encrypt in one fluent chain +let result = try TDFCBORBuilder() + .kasURL(URL(string: "https://kas.example.com")!) + .kasPublicKey(kasPublicKeyPEM) + .policy(policy) + .mimeType("application/octet-stream") + .encrypt(plaintext: plaintextData) + +// Get the encrypted container and symmetric key +let container = result.container +let symmetricKey = result.symmetricKey // Save for offline decryption + +// Serialize to CBOR bytes +let encryptedData = try container.serializedData() +``` + +### 3. Decryption + +#### NanoTDF (Deprecated) + +```swift +// Parse the NanoTDF +let parser = BinaryParser(data: encryptedData) +let nanoTDF = try parser.parseNanoTDF() + +// Method 1: Using KeyStore (offline) +let keyStore = KeyStore() +keyStore.addPrivateKey(privateKey, for: kasPublicKey) +let plaintext = try await nanoTDF.getPlaintext(using: keyStore) + +// Method 2: Using KAS rewrap +let rewrapClient = KASRewrapClient(kasURL: kasURL, token: accessToken) +let unwrappedKey = try await rewrapClient.rewrap(header: nanoTDF.header) +let symmetricKey = try deriveSymmetricKey(from: unwrappedKey) +let plaintext = try await nanoTDF.getPayloadPlaintext(symmetricKey: symmetricKey) +``` + +#### TDF-CBOR (Recommended) + +```swift +// Parse the TDF-CBOR +let envelope = try TDFCBOREnvelope.fromCBORData(encryptedData) +let container = TDFCBORContainer(envelope: envelope) + +// Method 1: Offline decryption with symmetric key +let plaintext = try TDFCrypto.decryptPayload( + ciphertext: container.payloadData, + symmetricKey: symmetricKey +) + +// Method 2: Using KAS rewrap (TBD - similar pattern to NanoTDF) +// The key access object contains all info needed for KAS rewrap +let keyAccess = container.encryptionInformation.keyAccess.first! +// Send rewrap request to keyAccess.url with wrapped key +``` + +### 4. Policy Handling + +#### NanoTDF (Deprecated) + +```swift +// Policies are binary-encoded and limited +let policy = Policy( + type: .remote, + body: .remote(RemotePolicyBody(url: policyUrl)), + binding: nil +) +// Only supports remote policy URLs or embedded policy bytes +``` + +#### TDF-CBOR (Recommended) + +```swift +// Full JSON policy with attributes and dissemination +let policy = TDFPolicy( + uuid: UUID().uuidString, + body: TDFPolicyBody( + dataAttributes: [ + TDFAttribute(attribute: "https://example.com/attr/classification/value/secret") + ], + dissem: ["user@example.com", "team@example.com"] + ) +) + +// Access policy from decrypted container +let encInfo = container.encryptionInformation +let policyJSON = encInfo.policy // Base64-encoded JSON +``` + +### 5. File Operations + +#### NanoTDF (Deprecated) + +```swift +// Write NanoTDF to file +let nanoTDF = try await createNanoTDFv12(kas: kas, policy: &policy, plaintext: data) +try nanoTDF.toData().write(to: fileURL) + +// Read NanoTDF from file +let data = try Data(contentsOf: fileURL) +let parser = BinaryParser(data: data) +let nanoTDF = try parser.parseNanoTDF() +``` + +#### TDF-CBOR (Recommended) + +```swift +// Write TDF-CBOR to file +let result = try TDFCBORBuilder() + .kasURL(kasURL) + .kasPublicKey(kasPublicKeyPEM) + .policy(policy) + .encrypt(plaintext: data) +try result.container.serializedData().write(to: fileURL) + +// Read TDF-CBOR from file +let data = try Data(contentsOf: fileURL) +let envelope = try TDFCBOREnvelope.fromCBORData(data) +let container = TDFCBORContainer(envelope: envelope) +``` + +--- + +## CLI Migration + +### NanoTDF (Deprecated) + +```bash +# Encrypt +OpenTDFKitCLI encrypt input.txt output.ntdf nano + +# Decrypt +OpenTDFKitCLI decrypt output.ntdf recovered.txt nano +``` + +### TDF-CBOR (Recommended) + +```bash +# Encrypt +export KASURL="https://kas.example.com" +export TDF_KAS_PUBLIC_KEY_PATH="/path/to/kas-public.pem" +OpenTDFKitCLI encrypt input.txt output.cbor cbor + +# Decrypt +export TDF_SYMMETRIC_KEY_PATH="/path/to/key.txt" +OpenTDFKitCLI decrypt output.cbor recovered.txt cbor + +# Verify structure +OpenTDFKitCLI verify output.cbor +``` + +--- + +## Error Handling Changes + +### NanoTDF Errors + +```swift +do { + let nanoTDF = try await createNanoTDFv12(kas: kas, policy: &policy, plaintext: data) +} catch CryptoHelperError.keyDerivationFailed { + // Handle key derivation failure +} catch CryptoHelperError.invalidKey { + // Handle invalid key +} +``` + +### TDF-CBOR Errors + +```swift +do { + let result = try TDFCBORBuilder() + .kasURL(kasURL) + .kasPublicKey(pem) + .policy(policy) + .encrypt(plaintext: data) +} catch TDFCBORError.encryptionFailed(let message) { + // Handle encryption failure with descriptive message +} catch TDFCBORError.missingField(let field) { + // Handle missing required field +} catch TDFCBORError.cborEncodingFailed(let reason) { + // Handle CBOR encoding issues +} +``` + +--- + +## Size Comparison + +For a 100-byte payload: + +| Format | Total Size | Overhead | +|--------|------------|----------| +| NanoTDF | ~350 bytes | ~250 bytes | +| TDF-CBOR | ~560 bytes | ~460 bytes | +| TDF-JSON | ~1,300 bytes | ~1,200 bytes | +| TDF Archive (ZIP) | ~1,500 bytes | ~1,400 bytes | + +TDF-CBOR offers the best balance of features and size efficiency for most use cases. + +--- + +## Interoperability + +TDF-CBOR provides better cross-SDK interoperability: + +```swift +// Swift SDK creates TDF-CBOR +let swiftCBOR = try TDFCBORBuilder() + .kasURL(kasURL) + .kasPublicKey(pem) + .policy(policy) + .encrypt(plaintext: data) + +// Rust SDK can read it directly +// cargo run --example tdf_cbor_example --features cbor + +// And vice versa - Swift can read Rust-created TDF-CBOR +let rustData = try Data(contentsOf: URL(fileURLWithPath: "rust_created.cbor")) +let envelope = try TDFCBOREnvelope.fromCBORData(rustData) +``` + +--- + +## Timeline + +- **Now**: NanoTDF marked as deprecated +- **OpenTDFKit 2.x**: NanoTDF continues to work with deprecation warnings +- **OpenTDFKit 3.0**: NanoTDF removed + +--- + +## Need Help? + +- [OpenTDFKit GitHub Issues](https://github.com/opentdf/openTDFKit/issues) +- [TDF-CBOR Specification](../../specifications/tdf-cbor/draft-00.md)