Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions OpenTDFKit/KASRewrapClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -585,10 +585,10 @@ public class KASRewrapClient: KASRewrapClientProtocol {
do {
let publicKey: P256.KeyAgreement.PublicKey

if keyData.count == 65 && keyData[0] == 0x04 {
if keyData.count == 65, keyData[0] == 0x04 {
// Raw uncompressed SEC1 point (0x04 || x || y)
publicKey = try P256.KeyAgreement.PublicKey(x963Representation: keyData)
} else if keyData.count == 33 && (keyData[0] == 0x02 || keyData[0] == 0x03) {
} else if keyData.count == 33, keyData[0] == 0x02 || keyData[0] == 0x03 {
// Compressed SEC1 point (0x02/0x03 || x)
publicKey = try P256.KeyAgreement.PublicKey(compressedRepresentation: keyData)
} else if keyData.count >= 70 {
Expand Down
6 changes: 3 additions & 3 deletions OpenTDFKit/KeyStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,13 +307,13 @@ public actor KeyStore {
throw KeyStoreError.keyAgreementFailed("ECDH key agreement failed: \(error.localizedDescription)")
}

// 3. Derive the symmetric key using HKDF (v13 specific)
// Salt: SHA256("L1" + VERSION)
// 3. Derive the symmetric key using HKDF
// Salt: SHA256("L1" + VERSION) - use v12 for NanoTDF collection compatibility
// Info: empty per spec guidance
// Output Byte Count: 32 (for AES-256)
let symmetricKeyCryptoKit = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: CryptoConstants.hkdfSaltV13,
salt: CryptoConstants.hkdfSaltV12,
sharedInfo: CryptoConstants.hkdfInfoEncryption,
outputByteCount: CryptoConstants.symmetricKeyByteCount,
)
Expand Down
4 changes: 2 additions & 2 deletions OpenTDFKit/NanoTDFCollectionBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,10 @@ public struct NanoTDFCollectionBuilder: Sendable {
)
policy.binding = binding

// Step 5: Build header (v12 format - empty kasPublicKey triggers L1L serialization)
// Step 5: Build header with KAS public key for KeyStore-based decryption
let payloadKeyAccess = PayloadKeyAccess(
kasEndpointLocator: kas.resourceLocator,
kasPublicKey: Data(), // Empty = v12 (L1L) format
kasPublicKey: kasPublicKey, // Include KAS public key for KeyStore lookup
)

let header = Header(
Expand Down
32 changes: 30 additions & 2 deletions OpenTDFKit/TDF/TDFCrypto.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
import CryptoKit
import Foundation

/// Symmetric key size for TDF Archive encryption.
/// AES-128 is required for FairPlay Streaming compatibility.
public enum TDFKeySize: Sendable {
/// 128-bit key (16 bytes) - FairPlay Streaming compatible
case bits128
/// 256-bit key (32 bytes) - default, higher security
case bits256

/// Number of bytes for this key size
public var byteCount: Int {
switch self {
case .bits128: 16
case .bits256: 32
}
}

/// Algorithm string for TDF manifest
public var algorithm: String {
switch self {
case .bits128: "AES-128-GCM"
case .bits256: "AES-256-GCM"
}
}
}

extension Data {
mutating func secureZero() {
withUnsafeMutableBytes { buffer in
Expand All @@ -11,8 +36,11 @@ extension Data {
}

public enum TDFCrypto {
public static func generateSymmetricKey() throws -> SymmetricKey {
let keyData = try randomBytes(count: 32)
/// Generate a symmetric key for TDF encryption.
/// - Parameter size: Key size (default: .bits256 for AES-256-GCM)
/// - Returns: A new symmetric key of the specified size
public static func generateSymmetricKey(size: TDFKeySize = .bits256) throws -> SymmetricKey {
let keyData = try randomBytes(count: size.byteCount)
return SymmetricKey(data: keyData)
}

Expand Down
6 changes: 4 additions & 2 deletions OpenTDFKit/TDF/TDFManifestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public struct TDFManifestBuilder {
tdfSpecVersion: String = "4.3.0",
policyBinding: TDFPolicyBinding,
integrityInformation: TDFIntegrityInformation? = nil,
algorithm: String = "AES-256-GCM",
) -> TDFManifest {
let keyAccessObject = TDFKeyAccessObject(
type: .wrapped,
Expand All @@ -29,7 +30,7 @@ public struct TDFManifestBuilder {
)

let method = TDFMethodDescriptor(
algorithm: "AES-256-GCM",
algorithm: algorithm,
iv: iv,
isStreamable: true,
)
Expand Down Expand Up @@ -66,9 +67,10 @@ public struct TDFManifestBuilder {
mimeType: String = "application/octet-stream",
tdfSpecVersion: String = "4.3.0",
integrityInformation: TDFIntegrityInformation? = nil,
algorithm: String = "AES-256-GCM",
) -> TDFManifest {
let method = TDFMethodDescriptor(
algorithm: "AES-256-GCM",
algorithm: algorithm,
iv: iv,
isStreamable: true,
)
Expand Down
16 changes: 9 additions & 7 deletions OpenTDFKit/TDF/TDFProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@ public struct TDFEncryptionConfiguration: Sendable {
public let policy: TDFPolicy
public let mimeType: String?
public let tdfSpecVersion: String
public let keySize: TDFKeySize

public init(kas: TDFKasInfo, policy: TDFPolicy, mimeType: String? = nil, tdfSpecVersion: String = "4.3.0") {
public init(kas: TDFKasInfo, policy: TDFPolicy, mimeType: String? = nil, tdfSpecVersion: String = "4.3.0", keySize: TDFKeySize = .bits256) {
self.kas = kas
self.policy = policy
self.mimeType = mimeType
self.tdfSpecVersion = tdfSpecVersion
self.keySize = keySize
}
}

Expand All @@ -89,7 +91,7 @@ public struct TDFEncryptor {
configuration: TDFEncryptionConfiguration,
chunkSize: Int = StreamingTDFCrypto.defaultChunkSize,
) throws -> TDFEncryptionResult {
let symmetricKey = try TDFCrypto.generateSymmetricKey()
let symmetricKey = try TDFCrypto.generateSymmetricKey(size: configuration.keySize)
let payloadData: Data
let streamingResult: StreamingTDFCrypto.StreamingEncryptionResult

Expand All @@ -111,7 +113,7 @@ public struct TDFEncryptor {
)

let method = TDFMethodDescriptor(
algorithm: "AES-256-GCM",
algorithm: configuration.keySize.algorithm,
iv: "",
isStreamable: true,
)
Expand Down Expand Up @@ -200,7 +202,7 @@ public struct TDFEncryptor {
throw StreamingCryptoError.invalidSegmentSize
}

let symmetricKey = try TDFCrypto.generateSymmetricKey()
let symmetricKey = try TDFCrypto.generateSymmetricKey(size: configuration.keySize)
let payloadData: Data
let streamingResult: StreamingTDFCrypto.StreamingEncryptionResult

Expand All @@ -222,7 +224,7 @@ public struct TDFEncryptor {
)

let method = TDFMethodDescriptor(
algorithm: "AES-256-GCM",
algorithm: configuration.keySize.algorithm,
iv: "",
isStreamable: true,
)
Expand Down Expand Up @@ -304,7 +306,7 @@ public struct TDFEncryptor {
}

public func encrypt(plaintext: Data, configuration: TDFEncryptionConfiguration) throws -> TDFEncryptionResult {
let symmetricKey = try TDFCrypto.generateSymmetricKey()
let symmetricKey = try TDFCrypto.generateSymmetricKey(size: configuration.keySize)
let (iv, ciphertext, tag) = try TDFCrypto.encryptPayload(plaintext: plaintext, symmetricKey: symmetricKey)

let payloadData = iv + ciphertext + tag
Expand All @@ -318,7 +320,7 @@ public struct TDFEncryptor {
let rootSignature = TDFCrypto.segmentSignature(segmentCiphertext: segmentSignature, symmetricKey: symmetricKey).base64EncodedString()

let method = TDFMethodDescriptor(
algorithm: "AES-256-GCM",
algorithm: configuration.keySize.algorithm,
iv: "",
isStreamable: true,
)
Expand Down
9 changes: 9 additions & 0 deletions OpenTDFKitCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -477,11 +477,20 @@ struct OpenTDFKitCLI {
let policy = try TDFPolicy(json: policyData)
let specVersion = env["TDF_SPEC_VERSION"] ?? "4.3.0"

// Parse key size from environment (default: 256-bit)
let keySize: TDFKeySize = {
if let keySizeEnv = env["TDF_KEY_SIZE"] {
return keySizeEnv == "128" ? .bits128 : .bits256
}
return .bits256
}()

return TDFEncryptionConfiguration(
kas: kasInfo,
policy: policy,
mimeType: mimeType,
tdfSpecVersion: specVersion,
keySize: keySize,
)
}

Expand Down
6 changes: 3 additions & 3 deletions OpenTDFKitTests/KeyStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ final class KeyStoreTests: XCTestCase {
let sharedSecret = try kasPrivKey.sharedSecretFromKeyAgreement(with: clientPubKey)
let expectedSymKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: CryptoConstants.hkdfSaltV13,
salt: CryptoConstants.hkdfSaltV12,
sharedInfo: CryptoConstants.hkdfInfoEncryption,
outputByteCount: CryptoConstants.symmetricKeyByteCount,
)
Expand Down Expand Up @@ -172,7 +172,7 @@ final class KeyStoreTests: XCTestCase {
let sharedSecret = try kasPrivKey.sharedSecretFromKeyAgreement(with: clientPubKey)
let expectedSymKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: CryptoConstants.hkdfSaltV13,
salt: CryptoConstants.hkdfSaltV12,
sharedInfo: CryptoConstants.hkdfInfoEncryption,
outputByteCount: CryptoConstants.symmetricKeyByteCount,
)
Expand Down Expand Up @@ -208,7 +208,7 @@ final class KeyStoreTests: XCTestCase {
let sharedSecret = try kasPrivKey.sharedSecretFromKeyAgreement(with: clientPubKey)
let expectedSymKey = sharedSecret.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: CryptoConstants.hkdfSaltV13,
salt: CryptoConstants.hkdfSaltV12,
sharedInfo: CryptoConstants.hkdfInfoEncryption,
outputByteCount: CryptoConstants.symmetricKeyByteCount,
)
Expand Down
81 changes: 81 additions & 0 deletions OpenTDFKitTests/NanoTDFCollectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,4 +416,85 @@ final class NanoTDFCollectionTests: XCTestCase {
XCTAssertEqual(parsedItems, serializedItems)
XCTAssertEqual(parsedCount, 2)
}

// MARK: - Streaming Sanity Check Tests

/// Simulates the streaming scenario: encrypt many items, then decrypt one from the middle
/// This mimics a late-joining subscriber receiving frames with high IV counters
func testStreamingLateJoinDecryption() async throws {
let policyLocator = ResourceLocator(protocolEnum: .https, body: "kas.example.com/policy")!
let collection = try await NanoTDFCollectionBuilder()
.kasMetadata(kasMetadata)
.policy(.remote(policyLocator))
.wireFormat(.containerFraming)
.build()

// Simulate publisher encrypting 1750 frames (mimics streaming scenario)
var serializedFrames: [Data] = []
let testPlaintexts: [Data] = (0 ..< 1750).map { i in
"Frame \(i) data payload".data(using: .utf8)!
}

for plaintext in testPlaintexts {
let item = try await collection.encryptItem(plaintext: plaintext)
let serialized = await collection.serialize(item: item)
serializedFrames.append(serialized)
}

// Create a separate decryptor (simulating subscriber with same key)
let symmetricKey = await collection.getSymmetricKey()
let decryptor = NanoTDFCollectionDecryptor.withUnwrappedKey(symmetricKey: symmetricKey)

// Try to decrypt frame 1748 (late join scenario - 0-indexed so IV=1749)
let lateFrame = serializedFrames[1748]
let parsed = NanoTDFCollectionParser.parseContainerFramed(from: lateFrame, tagSize: 16)
XCTAssertNotNil(parsed, "Failed to parse late frame")

let (parsedItem, _) = parsed!
XCTAssertEqual(parsedItem.ivCounter, 1749, "IV counter should be 1749 (1-indexed)")

// Decrypt the late frame
let decrypted = try await decryptor.decryptItem(parsedItem)
XCTAssertEqual(decrypted, testPlaintexts[1748], "Decrypted data should match original")

// Also try decrypting the very first frame to ensure both work
let firstFrame = serializedFrames[0]
let parsedFirst = NanoTDFCollectionParser.parseContainerFramed(from: firstFrame, tagSize: 16)!
XCTAssertEqual(parsedFirst.item.ivCounter, 1)
let decryptedFirst = try await decryptor.decryptItem(parsedFirst.item)
XCTAssertEqual(decryptedFirst, testPlaintexts[0])
}

/// Test that manually constructing a CollectionItem with arbitrary IV works
func testManualCollectionItemDecryption() async throws {
let policyLocator = ResourceLocator(protocolEnum: .https, body: "kas.example.com/policy")!
let collection = try await NanoTDFCollectionBuilder()
.kasMetadata(kasMetadata)
.policy(.remote(policyLocator))
.wireFormat(.containerFraming)
.build()

// Encrypt one item
let plaintext = "Test data for manual item".data(using: .utf8)!
let item = try await collection.encryptItem(plaintext: plaintext)

// Serialize to wire format
let serialized = await collection.serialize(item: item)

// Manually parse the wire format (simulating subscriber parsing)
// Format: [3-byte IV][3-byte length][ciphertext+tag]
let ivCounter = UInt32(serialized[0]) << 16 | UInt32(serialized[1]) << 8 | UInt32(serialized[2])
let payloadLength = Int(serialized[3]) << 16 | Int(serialized[4]) << 8 | Int(serialized[5])
let ciphertextWithTag = serialized.subdata(in: 6 ..< (6 + payloadLength))

// Create CollectionItem manually
let manualItem = CollectionItem(ivCounter: ivCounter, ciphertextWithTag: ciphertextWithTag, tagSize: 16)

// Decrypt
let symmetricKey = await collection.getSymmetricKey()
let decryptor = NanoTDFCollectionDecryptor.withUnwrappedKey(symmetricKey: symmetricKey)
let decrypted = try await decryptor.decryptItem(manualItem)

XCTAssertEqual(decrypted, plaintext)
}
}
Loading
Loading