Adyen / adyen-terminal-api-ios

Adyen Terminal API for iOS
https://docs.adyen.com/point-of-sale/terminal-api-fundamentals
MIT License
10 stars 5 forks source link

Encryption helpers for local terminal API #7

Closed JarnoRFB closed 2 years ago

JarnoRFB commented 2 years ago

Is your feature request related to a problem? Please describe. In other SDKs, e.g. node, there exists a layer to abstract the local terminal API including the encryption. Is something similar planned for iOS or are there any guidelines on how to implement the encryption oneself by following https://docs.adyen.com/point-of-sale/choose-your-architecture/local/protect ? Any help is much appreciated.

Describe the solution you'd like An abstraction over the local terminal API that is a easy to use as the node version.

kaphacius commented 2 years ago

Hi @JarnoRFB

Thank you for raising your feature request. This is definitely something on our radar, but I can't share any concrete timelines. Is this something you urgently need?

Regards, Yurii Adyen

JarnoRFB commented 2 years ago

@kaphacius thanks for the update! Yes we would need this rather urgently, but for now we would attempt to do this ourself by following https://docs.adyen.com/point-of-sale/choose-your-architecture/local/protect and translating the code to swift, if nothing else is availble right now.

kaphacius commented 2 years ago

@JarnoRFB I do have some bits of the implementation already, but it is not yet ready to be open-sourced. If you have any concrete questions - I might be able to answer them.

JarnoRFB commented 2 years ago

@kaphacius Glad about any bits that you can share already. I am happy to provide feedback once the implementation is working on our side.

kaphacius commented 2 years ago

@JarnoRFB alright, this is what I have for you right now. have you been able extract the hmac key data, cipher key data and the static initialization vector?

/// Encrypts the payload
///
/// - Parameter payload: The payload data to be encrypted
/// - Parameter iv: The initialization vector
/// - Throws: `CipherAlgorithmError.encryptionFailure`
/// - Returns: The cipher data.
func encrypt(_ payload: Data, iv: Data) throws -> Data {
    let dataOutLength = payload.count + kCCBlockSizeAES128
    guard let dataOut = NSMutableData(length: dataOutLength) else {
        throw CipherAlgorithmError.encryptionFailure
    }

    let operation = CCOperation(kCCEncrypt)
    return try aesCrypt(
        operation: operation,
        keyData: key.data,
        iv: iv,
        dataIn: payload,
        dataOut: dataOut
    )
}

/// Decrypts the cipher data.
///
/// - Parameter cipherText: The cipher data to be decrypted
/// - Parameter iv: The initialization vector
/// - Throws: `CipherAlgorithmError.decryptionFailure`
/// - Returns: The plain decrypted data.
func decrypt(_ cipherText: Data, iv: Data) throws -> Data {
    let dataOutLength = cipherText.count
    let dataIn = cipherText.subdata(in: 0..<dataOutLength)
    guard let dataOut = NSMutableData(length: dataOutLength) else {
        throw CipherAlgorithmError.decryptionFailure
    }

    let operation = CCOperation(kCCDecrypt)
    return try aesCrypt(
        operation: operation,
        keyData: key.data,
        iv: iv,
        dataIn: dataIn,
        dataOut: dataOut
    )
}

/// Executes a cryptographic operation
/// - Parameters:
///   - operation: The operation to execute
///   - keyData: The key data
///   - iv: The initialization vector
///   - dataIn: Data to execute the operation with
///   - dataOut: Data indicating the expected length of the operation result
/// - Throws: CipherAlgorithmError.operationFailure if the operation fails
/// - Returns: The result of the operation
internal func aesCrypt(
    operation: CCOperation,
    keyData: Data,
    iv: Data,
    dataIn: Data,
    dataOut: NSMutableData
) throws -> Data {
    let keyData = keyData as NSData
    let dataIn = dataIn as NSData
    let iv = iv as NSData
    var numBytesOut: size_t = 0
    let algorithm = CCAlgorithm(kCCAlgorithmAES)
    let options = CCOptions(kCCOptionPKCS7Padding)
    let status: CCCryptorStatus = CCCrypt(
        operation,
        algorithm,
        options,
        keyData.bytes,
        keyData.length,
        iv.bytes,
        dataIn.bytes,
        dataIn.length,
        dataOut.mutableBytes,
        dataOut.length,
        &numBytesOut
    )
    guard status == kCCSuccess else {
        // Handle error
        throw CipherAlgorithmError.operationFailure("\(operation)")
    }
    return (dataOut as Data)[0..<numBytesOut]
}
JarnoRFB commented 2 years ago

@kaphacius thanks for sharing. Here is what we have so far. We are using a combination of CryptoSwift and CommonCrypto. Both work for deriving the key material, but no idea if we use it in the right way. Also the sample code is a bit convoluted now, as it handles the encryption and sends the response in one go, but it is good for testing for now. Currently we just get an empty response with a status of 400 back from the terminal server, which seems a bit weird, given that the got Nexo Service: crypto error before, when the encryption had other bugs.

Haven't tried out your code for encryption until now though.

import AdyenNetworking
import CommonCrypto
import CryptoSwift
import Foundation

class LocalNexoConnection {
    static let hmacLength = 32
    static let cipherLength = 32
    static let ivLength = 16
    let session: URLSession

    init(session: URLSession) {
        self.session = session
    }

    func send() {
        Logging.isEnabled = true

        let poiId = "V400cPlus-..."
        let saleId = "visiolab-sample-app"
        let password = "..."
        let salt = "AdyenNexoV1Salt"

        let keyLength = Self.hmacLength + Self.cipherLength + Self.ivLength
        print("key length \(keyLength)")
        do {
            let key = pbkdf2SHA1(password: password, salt: salt.data(using: .utf8)!, keyByteCount: keyLength, rounds: 4000)!.bytes
            print("key generated \(key)")

//            let key2 = try PKCS5.PBKDF2(
//                password: Array(password.utf8),
//                salt: Array(salt.utf8), iterations: 4000, keyLength: 80, variant: .sha1)
//                .calculate()
//            print("key2 generated \(key)")

            let hmacKey = Array(key[0 ..< Self.hmacLength])
            let cipherKey = Array(key[Self.hmacLength ..< Self.hmacLength + Self.cipherLength])
            let iv = Array(key[Self.hmacLength + Self.cipherLength ..< Self.hmacLength + Self.cipherLength + Self.ivLength])

            var actualIv: [UInt8] = []
            let nonce = AES.randomIV(Self.ivLength)
            for (x1, x2) in zip(iv, nonce) {
                actualIv.append(x1 ^ x2)
            }

            let aes = try AES(key: cipherKey, blockMode: CBC(iv: actualIv), padding: .pkcs7)

            let myrequest = SaleToPOIRequest(
                messageHeader: MessageHeader(
                    ServiceID: String(UUID().uuidString.prefix(7)), SaleID: saleId, POIID: poiId
                ),
                paymentRequest: PaymentRequest(
                    saleData: SaleData(
                        saleTransaction: SaleTransaction(
                            transactionId: String(UUID().uuidString.prefix(7)),
                            timestamp: Date().ISO8601Format()
                        )),
                    paymentTransaction: PaymentTransaction(
                        amountsReq: AmountsReq(
                            currency: "EUR",
                            requestedAmount: 5
                        ))
                )
            )

            let jsonData = try JSONEncoder().encode(myrequest)
            print("json request", String(data: jsonData, encoding: .utf8)!)
            let nexoBlob = try aes.encrypt(jsonData.bytes)
            let hmac = hashHmac(message: jsonData, key: Data(hmacKey))

            let encryptedRequest = EncrytedSaleToPOIRequest(
                MessageHeader: myrequest.messageHeader,
                NexoBlob: nexoBlob.toBase64(),
                SecurityTrailer: SecurityTrailer(
                    KeyVersion: 1,
                    KeyIdentifier: "...",
                    Hmac: hmac.bytes.toBase64(),
                    Nonce: nonce.toBase64(),
                    AdyenCryptoVersion: 1
                )
            )

            let Url = String(format: "https://192.168.0.105:8443/nexo")
            guard let serviceUrl = URL(string: Url) else { return }
            let parameterDictionary = ["SaleToPOIRequest": encryptedRequest]
            var request = URLRequest(url: serviceUrl)
            request.httpMethod = "POST"
            request.setValue("Application/json", forHTTPHeaderField: "Content-Type")
            guard let httpBody = try? JSONEncoder().encode(parameterDictionary) else {
                return
            }
            print("httpBody \(String(data: httpBody, encoding: .utf8)!)")
            request.httpBody = httpBody

            session.dataTask(with: request) { data, response, error in
                if let response = response {
                    print("response", response)
                }
                if let data = data {
                    print("reponse data", String(data: data, encoding: .utf8))
                    do {
                        let json = try JSONSerialization.jsonObject(with: data, options: [])
                        print(json)
                    } catch {
                        print("json error", error)
                    }
                }
                if let error = error {
                    print("error", error)
                }
            }.resume()

        } catch {
            print(error)
        }
    }
}

func pbkdf2SHA1(password: String, salt: Data, keyByteCount: Int, rounds: Int) -> Data? {
    return pbkdf2(hash: CCPBKDFAlgorithm(kCCPRFHmacAlgSHA1), password: password, salt: salt, keyByteCount: keyByteCount, rounds: rounds)
}

func pbkdf2(hash: CCPBKDFAlgorithm, password: String, salt: Data, keyByteCount: Int, rounds: Int) -> Data? {
    let passwordData = password.data(using: String.Encoding.utf8)!
    var derivedKeyData = Data(repeating: 0, count: keyByteCount)
    let count = derivedKeyData.count

    let derivationStatus = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in
        salt.withUnsafeBytes { saltBytes in

            CCKeyDerivationPBKDF(
                CCPBKDFAlgorithm(kCCPBKDF2),
                password, passwordData.count,
                saltBytes, salt.count,
                hash,
                UInt32(rounds),
                derivedKeyBytes, count
            )
        }
    }
    if derivationStatus != 0 {
        print("Error: \(derivationStatus)")
        return nil
    }

    return derivedKeyData
}

func hashHmac(message: Data, key: Data) -> Data {
    let hashAlgorithm = kCCHmacAlgSHA256
    let length = CC_SHA256_DIGEST_LENGTH
    var macData = Data(count: Int(length))

    macData.withUnsafeMutableBytes { macBytes in
        message.withUnsafeBytes { messageBytes in
            key.withUnsafeBytes { keyBytes in
                CCHmac(CCHmacAlgorithm(hashAlgorithm),
                       keyBytes, key.count,
                       messageBytes, message.count,
                       macBytes)
            }
        }
    }
    return macData
}
JarnoRFB commented 2 years ago

Just found the error. Just the SaleToPOI field was missing from the message.

The following is working now

import AdyenNetworking
import CommonCrypto
import CryptoSwift
import Foundation

class LocalNexoConnection {
    static let hmacLength = 32
    static let cipherLength = 32
    static let ivLength = 16
    let session: URLSession

    init(session: URLSession) {
        self.session = session
    }

    func send() {
        Logging.isEnabled = true

        let poiId = "V400cPlus-..."
        let saleId = "visiolab-sample-app"
        let password = "..."
        let salt = "AdyenNexoV1Salt"

        let keyLength = Self.hmacLength + Self.cipherLength + Self.ivLength
        print("key length \(keyLength)")
        do {
            let key = pbkdf2SHA1(password: password, salt: salt.data(using: .utf8)!, keyByteCount: keyLength, rounds: 4000)!.bytes
//            print("key2 generated \(Array(key!))")
//            let key = try PKCS5.PBKDF2(
//                password: Array(password.utf8),
//                salt: Array(salt.utf8), iterations: 4000, keyLength: 80, variant: .sha1)
//                .calculate()
            print("key generated \(key)")
            let hmacKey = Array(key[0 ..< Self.hmacLength])
            let cipherKey = Array(key[Self.hmacLength ..< Self.hmacLength + Self.cipherLength])
            let iv = Array(key[Self.hmacLength + Self.cipherLength ..< Self.hmacLength + Self.cipherLength + Self.ivLength])
            print(hmacKey)

            var actualIv: [UInt8] = []
            let nonce = AES.randomIV(Self.ivLength)
            for (x1, x2) in zip(iv, nonce) {
                actualIv.append(x1 ^ x2)
            }

            let aes = try AES(key: cipherKey, blockMode: CBC(iv: actualIv), padding: .pkcs7)

            let myrequest = SaleToPOIRequest(
                messageHeader: MessageHeader(
                    ServiceID: String(UUID().uuidString.prefix(7)), SaleID: saleId, POIID: poiId
                ),
                paymentRequest: PaymentRequest(
                    saleData: SaleData(
                        saleTransaction: SaleTransaction(
                            transactionId: String(UUID().uuidString.prefix(7)),
                            timestamp: Date().ISO8601Format()
                        )),
                    paymentTransaction: PaymentTransaction(
                        amountsReq: AmountsReq(
                            currency: "EUR",
                            requestedAmount: 5
                        ))
                )
            )

            let jsonData = try JSONEncoder().encode(["SaleToPOIRequest": myrequest])
            print("json request", String(data: jsonData, encoding: .utf8)!)
            let nexoBlob = try aes.encrypt(jsonData.bytes)
            let hmac = hashHmac(message: jsonData, key: Data(hmacKey))

            let encryptedRequest = EncrytedSaleToPOIRequest(
                MessageHeader: myrequest.messageHeader,
                NexoBlob: nexoBlob.toBase64(),
                SecurityTrailer: SecurityTrailer(
                    KeyVersion: 1,
                    KeyIdentifier: "...",
                    Hmac: hmac.bytes.toBase64(),
                    Nonce: nonce.toBase64(),
                    AdyenCryptoVersion: 1
                )
            )

            let Url = String(format: "https://192.168.0.105:8443/nexo")
            guard let serviceUrl = URL(string: Url) else { return }
            let parameterDictionary = ["SaleToPOIRequest": encryptedRequest]
            var request = URLRequest(url: serviceUrl)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            guard let httpBody = try? JSONEncoder().encode(parameterDictionary) else {
                return
            }
            print("httpBody \(String(data: httpBody, encoding: .utf8)!)")
            request.httpBody = httpBody

            session.dataTask(with: request) { data, response, error in
                if let response = response {
                    print("response", response)
                }
                if let data = data {
                    print("reponse data", String(data: data, encoding: .utf8))
                    do {
                        let json = try JSONSerialization.jsonObject(with: data, options: [])
                        print(json)
                    } catch {
                        print("json error", error)
                    }
                }
                if let error = error {
                    print("error", error)
                }
            }.resume()

        } catch {
            print(error)
        }
    }
}

func pbkdf2SHA1(password: String, salt: Data, keyByteCount: Int, rounds: Int) -> Data? {
    return pbkdf2(hash: CCPBKDFAlgorithm(kCCPRFHmacAlgSHA1), password: password, salt: salt, keyByteCount: keyByteCount, rounds: rounds)
}

func pbkdf2(hash: CCPBKDFAlgorithm, password: String, salt: Data, keyByteCount: Int, rounds: Int) -> Data? {
    let passwordData = password.data(using: String.Encoding.utf8)!
    var derivedKeyData = Data(repeating: 0, count: keyByteCount)
    let count = derivedKeyData.count

    let derivationStatus = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in
        salt.withUnsafeBytes { saltBytes in

            CCKeyDerivationPBKDF(
                CCPBKDFAlgorithm(kCCPBKDF2),
                password, passwordData.count,
                saltBytes, salt.count,
                hash,
                UInt32(rounds),
                derivedKeyBytes, count
            )
        }
    }
    if derivationStatus != 0 {
        print("Error: \(derivationStatus)")
        return nil
    }

    return derivedKeyData
}

func hashHmac(message: Data, key: Data) -> Data {
    let hashAlgorithm = kCCHmacAlgSHA256
    let length = CC_SHA256_DIGEST_LENGTH
    var macData = Data(count: Int(length))

    macData.withUnsafeMutableBytes { macBytes in
        message.withUnsafeBytes { messageBytes in
            key.withUnsafeBytes { keyBytes in
                CCHmac(CCHmacAlgorithm(hashAlgorithm),
                       keyBytes, key.count,
                       messageBytes, message.count,
                       macBytes)
            }
        }
    }
    return macData
}
kaphacius commented 2 years ago

@JarnoRFB happy to hear it works for you, but looks like you already had everything implemented yourself :)

you can use this to make sure it is always wrapped inside SaleToPOIRequest

let paymentRequest = PaymentRequest(...)
let header = MessageHeader(...)
let message = Message(header:  header, body: paymentRequest)
try Coder.encode(message)

my idea is to have something like this to create encrypted requests:

struct SecurityTrailer: Codable {
    let Hmac: Data
    let KeyIdentifier: String
    let KeyVersion: Int
    let AdyenCryptoVersion: Int
    let Nonce: Data
}

struct KeyRepresentation {
    let hmacKey: SymmetricKey
    let cipherKey: AESKey
    let nonceData: Data

    init(data: Data) throws {
        hmacKey = SymmetricKey(data: data[0..<32])
        cipherKey = try AESKey(data: data[32..<64])
        nonceData = data[64..<80]
    }

let encryptedMessage = EncryptedMessage(message: Message<Body>, keyRepresentation: ...., securityTrailer: ....)
kaphacius commented 2 years ago

hey @JarnoRFB . turns out majority of the code was already written some time ago. Could you please check the branch local_tapi_encryption_helpers and see if it works on your side? Thanks.

JarnoRFB commented 2 years ago

@kaphacius quite cool that this moving inside the package! Unfortunately I am currently trapped on an old Mac where I can't use Swift 5.6, but I will try it out as soon as I get a new one. Our custom implementation is working on our side now, but we will be happy to transition to the one provided by you once that is available.

kaphacius commented 2 years ago

@JarnoRFB not a problem. I lowered the Swift version to 5.5, we were not using anything from the newer versions i believe. would that work for you?

JarnoRFB commented 2 years ago

Cool, yes 5.5 should work.

JarnoRFB commented 2 years ago

@kaphacius Just tried it out, and it works nicely! The code becomes super sleek. One nice addition would be a utility function that generates the key. Currently I am using

    func generateKey(passphrase: String, identifier: String, version: UInt) throws -> EncryptionKey {
        guard let key = pbkdf2SHA1(
            password: passphrase, salt: "AdyenNexoV1Salt".data(using: .utf8)!, keyByteCount: 80, rounds: 4000
        ) else { throw NexoError.failedKeyDerivation }

        return EncryptionKey(identifier: identifier, version: version, data: key)
    }

    func pbkdf2SHA1(password: String, salt: Data, keyByteCount: Int, rounds: Int) -> Data? {
        return pbkdf2(
            hash: CCPBKDFAlgorithm(kCCPRFHmacAlgSHA1),
            password: password, salt: salt, keyByteCount: keyByteCount, rounds: rounds
        )
    }

    func pbkdf2(hash: CCPBKDFAlgorithm, password: String, salt: Data, keyByteCount: Int, rounds: Int) -> Data? {
        let passwordData = password.data(using: String.Encoding.utf8)!
        var derivedKeyData = Data(repeating: 0, count: keyByteCount)
        let count = derivedKeyData.count

        let derivationStatus = derivedKeyData.withUnsafeMutableBytes { derivedKeyBytes in
            salt.withUnsafeBytes { saltBytes in

                CCKeyDerivationPBKDF(
                    CCPBKDFAlgorithm(kCCPBKDF2),
                    password, passwordData.count,
                    saltBytes, salt.count,
                    hash,
                    UInt32(rounds),
                    derivedKeyBytes, count
                )
            }
        }

        if derivationStatus != 0 {
            print("Error: \(derivationStatus)")
            return nil
        }

        return derivedKeyData
    }

That works, but could be definitely improved upon.

kaphacius commented 2 years ago

@JarnoRFB did not mean to close it, i think it closed automatically when the PR got merged. happy to hear it helps and works for you! regarding the keys, this is indeed something we could add. could you please open a separate issue? i'll take a look when i have time

kaphacius commented 2 years ago

hey @JarnoRFB , we now have the changes you asked for merged into develop. i mostly used your code, so thanks for that!

JarnoRFB commented 2 years ago

@kaphacius Awesome, thanks for letting me know! The terminal integration should be pretty complete with that.

kaphacius commented 2 years ago

@JarnoRFB i was wondering, how are you doing the certificate validation? are you installing the root certificate on your devices or checking the bundled certificate in the URLSessionDelegate? i think that part could still be added, or at least explain in the docs