diff --git a/OpenTDFKit/CryptoHelper.swift b/OpenTDFKit/CryptoHelper.swift index 38687c2..80ef56e 100644 --- a/OpenTDFKit/CryptoHelper.swift +++ b/OpenTDFKit/CryptoHelper.swift @@ -315,79 +315,17 @@ public actor CryptoHelper { } /// Generates an ECDSA signature (specifically P256) for a given message. - /// Extracts the raw R and S components from the DER-encoded signature provided by CryptoKit. + /// Returns the raw R || S signature (64 bytes for P256). /// - Parameters: /// - privateKey: The `P256.Signing.PrivateKey` to use for signing. /// - message: The `Data` to sign. - /// - Returns: The raw signature as `Data` (concatenated R and S values, each 32 bytes), or `nil` if extraction fails. - /// - Throws: `CryptoKitError` if the signing operation itself fails. - func generateECDSASignature(privateKey: P256.Signing.PrivateKey, message: Data) throws -> Data? { - // Generate the signature in DER format using CryptoKit - let derSignature = try privateKey.signature(for: message).derRepresentation - // Extract the raw R || S components from the DER structure - return extractRawECDSASignature(from: derSignature) - } - - /// Extracts the raw R and S components (each expected to be 32 bytes for P256) from a DER-encoded ECDSA signature. - /// This function manually parses the ASN.1 structure of the DER signature. - /// **Note:** This assumes a standard P256 ECDSA signature format. It might be fragile if the DER encoding varies. - /// Consider using `rawRepresentation` on the `P256.Signing.ECDSASignature` object directly if available and suitable. - /// - Parameter derSignature: The DER-encoded signature `Data`. - /// - Returns: The concatenated 64-byte raw signature (R || S), or `nil` if parsing fails or lengths are incorrect. - private func extractRawECDSASignature(from derSignature: Data) -> Data? { - var r: Data? - var s: Data? - - // Basic validation of DER structure (SEQUENCE tag, etc.) - guard derSignature.count > 8 else { return nil } // Minimal length check - - var index = 0 - // Expect SEQUENCE tag (0x30) - guard derSignature[index] == 0x30 else { return nil } - index += 1 - - // Skip length byte (we don't strictly need it here) - // let sequenceLength = derSignature[index] - index += 1 - - // Expect INTEGER tag (0x02) for R - guard derSignature[index] == 0x02 else { return nil } - index += 1 - - // Get length of R - let rLength = Int(derSignature[index]) - index += 1 - - // Extract R value bytes - guard index + rLength <= derSignature.count else { return nil } // Bounds check - r = derSignature[index ..< (index + rLength)] - index += rLength - - // Expect INTEGER tag (0x02) for S - guard derSignature[index] == 0x02 else { return nil } - index += 1 - - // Get length of S - let sLength = Int(derSignature[index]) - index += 1 - - // Extract S value bytes - guard index + sLength <= derSignature.count else { return nil } // Bounds check - s = derSignature[index ..< (index + sLength)] - - // Ensure R and S were extracted - guard let rData = r, let sData = s else { return nil } - - // Handle potential leading zero byte in R or S if the value is positive - // but the high bit is set. Trim to expected 32 bytes for P256. - let rTrimmed = rData.count == 33 && rData.first == 0x00 ? rData.dropFirst() : rData - let sTrimmed = sData.count == 33 && sData.first == 0x00 ? sData.dropFirst() : sData - - // Validate final lengths are exactly 32 bytes each for P256 raw signature - guard rTrimmed.count == 32, sTrimmed.count == 32 else { return nil } - - // Concatenate R and S for the raw signature format - return rTrimmed + sTrimmed + /// - Returns: The raw signature as `Data` (concatenated R and S values, each 32 bytes). + /// - Throws: `CryptoKitError` if the signing operation fails. + func generateECDSASignature(privateKey: P256.Signing.PrivateKey, message: Data) throws -> Data { + // Generate the signature using CryptoKit and get raw representation directly + // rawRepresentation returns the 64-byte R || S format + let signature = try privateKey.signature(for: message) + return signature.rawRepresentation } /// Performs HKDF (HMAC-based Key Derivation Function) using SHA256. diff --git a/OpenTDFKit/KASRewrapClient.swift b/OpenTDFKit/KASRewrapClient.swift index ab4e05d..3c7ae30 100644 --- a/OpenTDFKit/KASRewrapClient.swift +++ b/OpenTDFKit/KASRewrapClient.swift @@ -91,6 +91,24 @@ public class KASRewrapClient: KASRewrapClientProtocol { let keyAccessObject: StandardKeyAccessObject } + /// Algorithm type for KAS rewrap requests + public enum RewrapAlgorithm: String, Sendable { + case rsa2048 = "rsa:2048" + case ecP256 = "ec:secp256r1" + case ecP384 = "ec:secp384r1" + case ecP521 = "ec:secp521r1" + + /// Detect algorithm from key access type + public static func from(accessType: TDFKeyAccessObject.AccessType) -> RewrapAlgorithm { + switch accessType { + case .ecWrapped: + .ecP256 // Default to P-256 for EC + case .wrapped, .remote, .remoteWrapped: + .rsa2048 + } + } + } + public struct StandardPolicyRequest: Codable { let keyAccessObjects: [StandardKeyAccessObjectWrapper] let policy: Policy @@ -396,11 +414,15 @@ public class KASRewrapClient: KASRewrapClientProtocol { wrappers.append(wrapper) } + // Detect algorithm from first key access entry (all should use same algorithm) + let algorithm: RewrapAlgorithm = keyAccessEntries.first + .map { RewrapAlgorithm.from(accessType: $0.type) } ?? .rsa2048 + let policy = Policy(body: policyBody) let policyRequest = StandardPolicyRequest( keyAccessObjects: wrappers, policy: policy, - algorithm: "rsa:2048", // Specify RSA algorithm for KAS + algorithm: algorithm.rawValue, ) let unsignedRequest = StandardUnsignedRewrapRequest( diff --git a/OpenTDFKit/NanoTDF.swift b/OpenTDFKit/NanoTDF.swift index ede12ed..bea0053 100644 --- a/OpenTDFKit/NanoTDF.swift +++ b/OpenTDFKit/NanoTDF.swift @@ -403,14 +403,11 @@ public func addSignatureToNanoTDF(nanoTDF: inout NanoTDF, privateKey: P256.Signi let message = nanoTDF.header.toData() + nanoTDF.payload.toData() // Generate the ECDSA signature using the provided private key. - // The helper function abstracts away DER encoding details if necessary. - guard let signatureData = try await NanoTDF.sharedCryptoHelper.generateECDSASignature( + // Returns the raw 64-byte R || S signature directly. + let signatureData = try await NanoTDF.sharedCryptoHelper.generateECDSASignature( privateKey: privateKey, message: message, - ) else { - // Throw an error if signature generation unexpectedly returns nil - throw SignatureError.invalidSigning - } + ) // Get the compressed public key corresponding to the private signing key. let publicKeyData = privateKey.publicKey.compressedRepresentation diff --git a/OpenTDFKit/TDF/TDFCrypto.swift b/OpenTDFKit/TDF/TDFCrypto.swift index 1dc04cc..659eae8 100644 --- a/OpenTDFKit/TDF/TDFCrypto.swift +++ b/OpenTDFKit/TDF/TDFCrypto.swift @@ -249,6 +249,254 @@ public enum TDFCrypto { throw TDFCryptoError.weakKey(keySize: keySize, minimum: minimumBits) } } + + // MARK: - EC Key Wrapping (ECIES) + + /// Wrap a symmetric key using EC (ECIES: ECDH + HKDF + AES-GCM). + /// This generates an ephemeral key pair and uses ECDH with the recipient's public key. + /// - Parameters: + /// - publicKeyPEM: The recipient's EC public key in PEM format + /// - symmetricKey: The symmetric key to wrap + /// - curve: The EC curve to use (default: P-256) + /// - Returns: ECWrappedKeyResult containing the wrapped key and ephemeral public key + public static func wrapSymmetricKeyWithEC( + publicKeyPEM: String, + symmetricKey: SymmetricKey, + curve: TDFECCurve = .p256, + ) throws -> ECWrappedKeyResult { + switch curve { + case .p256: + try wrapWithP256(publicKeyPEM: publicKeyPEM, symmetricKey: symmetricKey) + case .p384: + try wrapWithP384(publicKeyPEM: publicKeyPEM, symmetricKey: symmetricKey) + case .p521: + try wrapWithP521(publicKeyPEM: publicKeyPEM, symmetricKey: symmetricKey) + } + } + + /// Unwrap a symmetric key using EC (ECIES: ECDH + HKDF + AES-GCM). + /// - 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 + /// - curve: The EC curve used + /// - Returns: The unwrapped symmetric key + public static func unwrapSymmetricKeyWithEC( + privateKey: P256.KeyAgreement.PrivateKey, + wrappedKey: String, + ephemeralPublicKeyPEM: 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) + + // Perform ECDH to get shared secret + let sharedSecret = try privateKey.sharedSecretFromKeyAgreement(with: ephemeralPublicKey) + + // Derive wrapping key using HKDF-SHA256 (empty salt and info for TDF compatibility) + let wrapKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: Data(), + sharedInfo: Data(), + outputByteCount: 32, + ) + + // Unwrap using AES-GCM (format: nonce[12] + ciphertext + tag[16]) + guard wrappedData.count > 28 else { + throw TDFCryptoError.invalidWrappedKey + } + + let sealedBox = try AES.GCM.SealedBox(combined: wrappedData) + var decryptedKey = try AES.GCM.open(sealedBox, using: wrapKey) + + defer { + decryptedKey.secureZero() + } + + return SymmetricKey(data: decryptedKey) + } + + // MARK: - P-256 EC Wrapping + + private static func wrapWithP256(publicKeyPEM: String, symmetricKey: SymmetricKey) throws -> ECWrappedKeyResult { + // Load recipient's public key + let recipientPublicKey = try loadECPublicKeyP256(fromPEM: publicKeyPEM) + + // Generate ephemeral key pair + let ephemeralPrivateKey = P256.KeyAgreement.PrivateKey() + let ephemeralPublicKey = ephemeralPrivateKey.publicKey + + // Perform ECDH to get shared secret + let sharedSecret = try ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: recipientPublicKey) + + // Derive wrapping key using HKDF-SHA256 (empty salt and info for TDF compatibility) + let wrapKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: Data(), + sharedInfo: Data(), + outputByteCount: 32, + ) + + // Extract key data + let keyData = symmetricKey.withUnsafeBytes { Data($0) } + + // Wrap using AES-GCM + let nonce = try AES.GCM.Nonce(data: randomBytes(count: 12)) + let sealed = try AES.GCM.seal(keyData, using: wrapKey, nonce: nonce) + + // Combined format: nonce + ciphertext + tag + let wrappedKey = sealed.combined!.base64EncodedString() + + // Convert ephemeral public key to PEM + let ephemeralPEM = ephemeralPublicKey.pemRepresentation + + return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralPEM) + } + + // MARK: - P-384 EC Wrapping + + private static func wrapWithP384(publicKeyPEM: String, symmetricKey: SymmetricKey) throws -> ECWrappedKeyResult { + let recipientPublicKey = try loadECPublicKeyP384(fromPEM: publicKeyPEM) + + let ephemeralPrivateKey = P384.KeyAgreement.PrivateKey() + let ephemeralPublicKey = ephemeralPrivateKey.publicKey + + let sharedSecret = try ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: recipientPublicKey) + + let wrapKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: Data(), + sharedInfo: Data(), + outputByteCount: 32, + ) + + let keyData = symmetricKey.withUnsafeBytes { Data($0) } + + let nonce = try AES.GCM.Nonce(data: randomBytes(count: 12)) + let sealed = try AES.GCM.seal(keyData, using: wrapKey, nonce: nonce) + + let wrappedKey = sealed.combined!.base64EncodedString() + let ephemeralPEM = ephemeralPublicKey.pemRepresentation + + return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralPEM) + } + + // MARK: - P-521 EC Wrapping + + private static func wrapWithP521(publicKeyPEM: String, symmetricKey: SymmetricKey) throws -> ECWrappedKeyResult { + let recipientPublicKey = try loadECPublicKeyP521(fromPEM: publicKeyPEM) + + let ephemeralPrivateKey = P521.KeyAgreement.PrivateKey() + let ephemeralPublicKey = ephemeralPrivateKey.publicKey + + let sharedSecret = try ephemeralPrivateKey.sharedSecretFromKeyAgreement(with: recipientPublicKey) + + let wrapKey = sharedSecret.hkdfDerivedSymmetricKey( + using: SHA256.self, + salt: Data(), + sharedInfo: Data(), + outputByteCount: 32, + ) + + let keyData = symmetricKey.withUnsafeBytes { Data($0) } + + let nonce = try AES.GCM.Nonce(data: randomBytes(count: 12)) + let sealed = try AES.GCM.seal(keyData, using: wrapKey, nonce: nonce) + + let wrappedKey = sealed.combined!.base64EncodedString() + let ephemeralPEM = ephemeralPublicKey.pemRepresentation + + return ECWrappedKeyResult(wrappedKey: wrappedKey, ephemeralPublicKey: ephemeralPEM) + } + + // MARK: - EC Public Key Loading + + /// Load a P-256 EC public key from PEM format + public static func loadECPublicKeyP256(fromPEM pem: String) throws -> P256.KeyAgreement.PublicKey { + let stripped = pem + .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let data = Data(base64Encoded: stripped) else { + throw TDFCryptoError.invalidPEM + } + + do { + return try P256.KeyAgreement.PublicKey(derRepresentation: data) + } catch { + throw TDFCryptoError.ecKeyAgreementFailed("Failed to parse P-256 public key: \(error.localizedDescription)") + } + } + + /// Load a P-384 EC public key from PEM format + public static func loadECPublicKeyP384(fromPEM pem: String) throws -> P384.KeyAgreement.PublicKey { + let stripped = pem + .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let data = Data(base64Encoded: stripped) else { + throw TDFCryptoError.invalidPEM + } + + do { + return try P384.KeyAgreement.PublicKey(derRepresentation: data) + } catch { + throw TDFCryptoError.ecKeyAgreementFailed("Failed to parse P-384 public key: \(error.localizedDescription)") + } + } + + /// Load a P-521 EC public key from PEM format + public static func loadECPublicKeyP521(fromPEM pem: String) throws -> P521.KeyAgreement.PublicKey { + let stripped = pem + .replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + guard let data = Data(base64Encoded: stripped) else { + throw TDFCryptoError.invalidPEM + } + + do { + return try P521.KeyAgreement.PublicKey(derRepresentation: data) + } catch { + throw TDFCryptoError.ecKeyAgreementFailed("Failed to parse P-521 public key: \(error.localizedDescription)") + } + } +} + +/// Result of EC key wrapping containing wrapped key and ephemeral public key +public struct ECWrappedKeyResult: Sendable { + /// The wrapped symmetric key (nonce + ciphertext + tag) + public let wrappedKey: String + /// The ephemeral public key in PEM format + public let ephemeralPublicKey: String +} + +/// Supported EC curves for TDF3 EC key wrapping +public enum TDFECCurve: String, Sendable { + case p256 = "ec:secp256r1" + case p384 = "ec:secp384r1" + case p521 = "ec:secp521r1" + + /// The size of compressed public key in bytes + public var compressedKeySize: Int { + switch self { + case .p256: 33 + case .p384: 49 + case .p521: 67 + } + } } public enum TDFCryptoError: Error, CustomStringConvertible { @@ -262,6 +510,8 @@ public enum TDFCryptoError: Error, CustomStringConvertible { case invalidIVSize(expected: Int, actual: Int) case cbcEncryptionFailed(String) case cbcDecryptionFailed(String) + case ecKeyAgreementFailed(String) + case unsupportedCurve(String) public var description: String { switch self { @@ -298,6 +548,10 @@ public enum TDFCryptoError: Error, CustomStringConvertible { return "AES-CBC encryption failed: \(reason)" case let .cbcDecryptionFailed(reason): return "AES-CBC decryption failed: \(reason)" + case let .ecKeyAgreementFailed(reason): + return "EC key agreement failed: \(reason)" + case let .unsupportedCurve(curve): + return "Unsupported EC curve: \(curve)" } } } diff --git a/OpenTDFKit/TDF/TDFManifest.swift b/OpenTDFKit/TDF/TDFManifest.swift index aa2a99d..ec5db52 100644 --- a/OpenTDFKit/TDF/TDFManifest.swift +++ b/OpenTDFKit/TDF/TDFManifest.swift @@ -94,6 +94,7 @@ public struct TDFKeyAccessObject: Codable, Sendable { case wrapped case remote case remoteWrapped + case ecWrapped = "ec-wrapped" } public enum AccessProtocol: String, Codable, Sendable {