Encrypt and decrypt messages

Configure encryption in the Terminal API to protect messages communicated between the terminal and the cash register.

Encrypt messages

To encrypt a message:

  1. Encrypt the body of the message using a Nonce XOR dk.iv initialization vector (IV), where dk.iv is the IV in the derived key material.
    The nonce (number used once), is a random sequence of bytes of the same length as dk.iv generated per message
  2. Place the nonce in the SecurityTrailer as a Base64 encoded string.

Decrypt and validate messages

To decrypt and validate a message:

  1. Decompose the message into its various parts. 
  2. Check the AdyenCryptoVersion in the SecurityTrailer,
  3. Retrieve the key material based on the KeyIdentifier and KeyVersion.
  4. Decrypt the blob using the IV and key in the key material and the Nonce in the SecurityTrailer.
  5. Validate the HMAC by computing it on the on the decrypted blob.

    Do not validate on the serialized form.

  6. Compare the result to the blob in the SecurityTrailer. The message contains a clear text copy of the MessageHeader for routing purposes. 
  7. Pairwise compare the clear text MessageHeader to the MessageHeader in the decrypted body and establish that they are not the same as the MessageHeader in the decrypted body. 

Configure a key in the Customer Area

The key the terminal uses, retrieved with the terminal configuration, is set in the Customer Area. Currently the terminal software is capable of using one key only. If multiple devices are communicating with the same terminal, they must share the same key.

To configure the key:

  1. Open the POS terminal fleet manager.
  2. Click the unique terminal ID to open the configuration page for that terminal.
  3. Under the terminal's page, click the Terminal API tab.
  4. Add your key in the Security Keys field.

Example of the Security Keys property:

{  
   "defaultKey":{  
      "passphrase":"mysupersecretpassphrase",
      "keyIdentifier":"mykey",
      "version":0
   }
}

Examples

The example code implements message encryption, HMAC computation and construction plus the reverse operation: splitting, HMAC validation and decryption:

import java.io.*;
import java.security.*;
import java.security.spec.*;
import java.text.ParseException;
import java.util.Base64;
import java.util.Random;
import javax.crypto.*;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import javax.json.*;

public class NexoCrypto {
   
    public static final int NEXO_HMAC_KEY_LENGTH = 32;
    public static final int NEXO_CIPHER_KEY_LENGTH = 32;
    public static final int NEXO_IV_LENGTH = 16;
   
   /**
    * Where to look for pre-derived key files
    */
   private String keyDirectory;

   /**
    * The keys material to use when we are sending
    */
   private String keyIdentifier;
   private long keyVersion;
   private NexoDerivedKeys derivedKeys;

    /**
     * A container for Nexo derived keys
     * 
     * Nexo derived keys is a 80 byte struct containing key data. These 80
     * bytes are derived from a passsphrase.
     */
   public class NexoDerivedKeys {
      public byte hmac_key[];
      public byte cipher_key[];
      public byte iv[];
      
      public NexoDerivedKeys() {
         hmac_key = new byte[NEXO_HMAC_KEY_LENGTH];
         cipher_key = new byte[NEXO_CIPHER_KEY_LENGTH];
         iv = new byte[NEXO_IV_LENGTH];
      }
   
      /**
       * Read a key material file of 80 bytes, splitting it in the hmac_key, cipher_key and iv
       */
      public void readKeyData(String keyId, long keyV) throws IOException {
         String filename = keyDirectory + '/' + keyId + "." + Long.toString(keyV) + ".key";
                  
         FileInputStream stream = null;
         try {
            stream = new FileInputStream(filename);
            stream.read(this.hmac_key);
            stream.read(this.cipher_key);
            stream.read(this.iv);
         } finally {
            if (stream != null) {
               try {
                  stream.close();
               } 
               catch (Exception ignored) {
               }
            }
         }
      }

   };
   
   /**
    * Use this constructor if you want to be able to both encryption and decryption
    */
   public NexoCrypto(String dir, String keyID, long keyV) throws IOException {
      keyDirectory = dir;
      keyIdentifier = keyID;
      keyVersion = keyV;
      derivedKeys = new NexoDerivedKeys();
      derivedKeys.readKeyData(keyIdentifier, keyVersion);
   }

   /**
    * Use this constructor if you a decrypt-only NexoCrypto object
    */
   public NexoCrypto(String dir) {
      keyDirectory = dir;
   }

   /**
    * Given a passphrase, compute 80 byte key of key material according to crypto.md
    */
   public static byte[] deriveKeyMaterial(char[] passphrase) throws NoSuchAlgorithmException, InvalidKeySpecException {
      byte[] salt = "AdyenNexoV1Salt".getBytes();
      int iterations = 4000;
      PBEKeySpec spec = new PBEKeySpec(passphrase, salt, iterations, (NEXO_HMAC_KEY_LENGTH +
            NEXO_CIPHER_KEY_LENGTH + NEXO_IV_LENGTH) * 8);
      SecretKeyFactory skf = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
      byte[] keymaterial = skf.generateSecret(spec).getEncoded();
      return keymaterial;
   }
         
   /**
    * Encrypt or decrypt data given a iv modifier and using the specified key
    * 
    * The actual iv is computed by taking the iv from the key material and xoring it with ivmod
    */
   private byte[] crypt(byte[] bytes, NexoDerivedKeys dk, byte[] ivmod, int mode) 
         throws NoSuchAlgorithmException, NoSuchPaddingException,
            IllegalBlockSizeException, BadPaddingException, InvalidKeyException, InvalidAlgorithmParameterException {
      
      Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
      SecretKeySpec s = new SecretKeySpec(dk.cipher_key, "AES");
      
      // xor dk.iv and the iv modifier
      byte[] actualIV = new byte[NEXO_IV_LENGTH];
      for (int i = 0; i < NEXO_IV_LENGTH; i++) {
         actualIV[i] = (byte) (dk.iv[i] ^ ivmod[i]);
      }
      
      IvParameterSpec i = new IvParameterSpec(actualIV);
      cipher.init(mode, s, i);
      return cipher.doFinal(bytes);
   }

   /**
    * Compute a hmac using the hmac_key
    */
   private byte[] hmac(byte[] bytes, NexoDerivedKeys dk) throws NoSuchAlgorithmException, InvalidKeyException {
      Mac mac = Mac.getInstance("HmacSHA256");
      SecretKeySpec s = new SecretKeySpec(dk.hmac_key, "HmacSHA256");

      mac.init(s);
      return mac.doFinal(bytes);
   }
      
   /**
    * Encrypt and compose a secured Nexo message
    * 
    * This functions takes the original message, encrypts it and converts the encrypted form to Base64 and
    * names it NexoBlob.
    * After that, a new message is created with a copy of the header, the NexoBlob and an added SecurityTrailer.
    * 
    * @param in is the byte representation of the unprotected Nexo message
    * @returns a byte representation of the secured Nexo message
    */
   public byte[] encrypt_and_hmac(byte in[]) throws InvalidKeyException, NoSuchAlgorithmException, 
         NoSuchPaddingException, IllegalBlockSizeException, BadPaddingException, 
         InvalidAlgorithmParameterException {
      Base64.Encoder encb64 = Base64.getEncoder();

      // parse the json and determined if it is a request or responce
      JsonReader jsonreader = Json.createReader(new ByteArrayInputStream(in));
      JsonObject body = jsonreader.readObject();
      boolean request = true;
      JsonObject saletopoirequest = body.getJsonObject("SaleToPOIRequest");
      if (saletopoirequest == null) {
         request = false;
         saletopoirequest = body.getJsonObject("SaleToPOIResponse");
      }
      // pick up the MessageHeader
      JsonObject messageheader = saletopoirequest.getJsonObject("MessageHeader");

      // Generate a random iv nonce
      byte[] ivmod = new byte[NEXO_IV_LENGTH];
      new Random().nextBytes(ivmod);

      // encrypt taking the original bytes as input
      byte[] encbytes = crypt(in, this.derivedKeys, ivmod, Cipher.ENCRYPT_MODE);

      // compute mac over cleartext bytes
      byte[] hmac = hmac(in, this.derivedKeys);

      // Construct the inner Json object containing a MessageHeader, a NexoBlob and a SecurityTrailer
      JsonObject msg = Json.createObjectBuilder()
            .add("MessageHeader", messageheader)
            .add("NexoBlob", new String(encb64.encode(encbytes)))
            .add("SecurityTrailer", Json.createObjectBuilder()
                  .add("Hmac", new String(encb64.encode(hmac)))
                  .add("KeyIdentifier", keyIdentifier)
                  .add("KeyVersion", keyVersion)
                  .add("AdyenCryptoVersion", 1)
                  .add("Nonce", new String(encb64.encode(ivmod)))
                  ).build();
      // Wrap the inner message in a SaleToPOIRequest or SaleToPOIResponse object
      JsonObject total = Json.createObjectBuilder()
            .add(request ?  "SaleToPOIRequest" : "SaleToPOIResponse" , msg)
            .build();

      ByteArrayOutputStream stream = new ByteArrayOutputStream();
      JsonWriter writer = Json.createWriter(stream);
      writer.writeObject(total);
      writer.close();
      return stream.toByteArray();
   }
   
   /**
    * A helper class to return a decrypted mesasage and the outer header from the 
    * secured Nexo message
    */
   public class BytesAndOuterHeader {
      public byte[] packet;
      public JsonObject outer_header;
      
      public BytesAndOuterHeader(byte[] packet, JsonObject outer_header) {
         this.packet = packet;
         this.outer_header = outer_header;
      }
   }
   
   /*
    * Validate and decrypt a secured Nexo message
    * 
    * @returns a BytesAndOuterHeader object or null on failure
    */
   public BytesAndOuterHeader decrypt_and_validate_hmac(byte in[]) throws InvalidKeyException, 
         NoSuchAlgorithmException, IOException, NoSuchPaddingException, IllegalBlockSizeException, 
         BadPaddingException, InvalidAlgorithmParameterException {
      Base64.Decoder b64dec = Base64.getDecoder();
      
      // Parse bytes and retrieve MessageHeader
      InputStream stream = new ByteArrayInputStream(in);
      JsonReader jsonreader = Json.createReader(stream);
      JsonObject total = jsonreader.readObject();
      if (total == null) {
         throw new IOException("Faulty JSON");
      }
      JsonObject saletopoirequest = total.getJsonObject("SaleToPOIRequest");
      if (saletopoirequest == null) {
         saletopoirequest = total.getJsonObject("SaleToPOIResponse");
      }
      if (saletopoirequest == null) {
         throw new IOException("No SaleToPOIRequest or SaleToPOIResponse");
      }
      JsonObject messageheader = saletopoirequest.getJsonObject("MessageHeader");
      if (messageheader == null) {
         throw new IOException("MessageHeader not found");
      }
      // Get the encrypted actual message and base64 decode it
      JsonString payload = saletopoirequest.getJsonString("NexoBlob");
      if (payload == null) {
         throw new IOException("NexoBlob not found");
      }
      byte[] ciphertext = b64dec.decode(payload.getString());

      // Get the SecurityTrailer and its values
      JsonObject jsonTrailer = saletopoirequest.getJsonObject("SecurityTrailer");
      if (jsonTrailer == null) {
         throw new IOException("SecurityTrailer not found");
      }
      JsonNumber version = jsonTrailer.getJsonNumber("AdyenCryptoVersion");
      if (version == null || version.intValue() != 1) {
         throw new IOException("AdyenCryptoVersion version not found or not supported");
      }
      JsonString nonce = jsonTrailer.getJsonString("Nonce");
      if (nonce == null) {
         throw new IOException("Nonce not found");
      }

      JsonString keyId = jsonTrailer.getJsonString("KeyIdentifier");
      if (keyId == null) {
         throw new IOException("KeyIdentifier not found");
      }
      JsonNumber kversion = jsonTrailer.getJsonNumber("KeyVersion");
      if (kversion == null) {
         throw new IOException("KeyVersion not found");
      }
      JsonString b64 = jsonTrailer.getJsonString("Hmac");
      if (b64 == null) {
         throw new IOException("Hmac not found");
      }
      
      // Read the key from disk
      NexoDerivedKeys dk = new NexoDerivedKeys();
      dk.readKeyData(keyId.getString(), kversion.longValue());
      
      // Decrypt the actual message with the base64 decoded ivmod as found in the securitytrailer
      byte[] ivmod = b64dec.decode(nonce.getString());
      byte[] ret = crypt(ciphertext, dk, ivmod, Cipher.DECRYPT_MODE);
 
      // Base64 decode the received HMAC and compare it to a computed hmac
      // Use a timing safe compare, this is to mitigate a (theoretical) timing based attack
      byte[] receivedmac = b64dec.decode(b64.getString());               
      byte[] hmac = hmac(ret, dk);
      if (receivedmac.length != hmac.length) {
         throw new IOException("Validation failed");
      }
      boolean equal = true;
      for (int i = 0; i < hmac.length; i++) {
         if (receivedmac[i] != hmac[i]) {
            equal = false;
         }
      }
      if (!equal) {
         throw new IOException("Validation failed");
      }
      
      // Return decrypted message and outer header
      return new BytesAndOuterHeader(ret, messageheader);
   }
   
   /**
    * Compare an inner and outer MessageHeader
    * @param the inner  MessageHeader
    * @param outer the outer MessageHeader
    * @return
    */
   public boolean validateInnerAndOuterHeader(JsonObject inner, JsonObject outer) {
      if (inner == null || outer == null) {
         return false;
      }
      String[] fields = { 
            "DeviceID",
            "MessageCategory",
            "MessageClass",
            "MessageType",
            "SaleID",
            "ServiceID",
            "POIID",
            "ProtocolVersion", 
      };
      for (String field : fields) {
         
         try {
            JsonString a = inner.getJsonString(field);
            JsonString b = outer.getJsonString(field);
            if (a == null && b == null) {
               continue;
            }
            if (a == null || !a.equals(b)) {
               return false;
            }
         }
         catch (ClassCastException ex) {
            return false;
         }
      }
      return true;
   }

}