CPN

How-to: Encrypt Travel Rule and Beneficiary Data

This guide explains how to set up encryption and decryption between an OFI and a BFI so that messages can be passed securely during payment flow. CPN uses JSON Web Encryption (JWE) for encrypting data in a secure and compact manner. This encryption is implemented after a quote is accepted: the JWK data and public key for encryption are shared in the quote response. The encryption is also used when communicating RFI data.

Both OFI and BFI can implement this encryption system in any programming language that supports the required primitives. This encryption scheme is required when performing the following tasks in CPN:

  • Creating a payment: for encrypting the travel rule and beneficiary account data
  • Submitting an RFI: for encrypting the RFI JSON response

Regardless of the language used to implement the encryption, the following parameters must be followed:

  • Key agreement: ECDH-ES+A128KW
  • Encryption method: A128-GCM

The following sections describe the steps necessary to encrypt a message sent from an OFI to a BFI. Note that this example uses beneficiary account data and travel rule data, but the same encryption scheme applies for RFI data as well.

Quote responses include a certificate field with required parameters to establish encryption with the BFI. A sample (truncated) quote response is below:

JSON
{
  "data": [
    {
      "id": "6e4c7e85-39eb-4411-8c4a-683ff73846d6",
      "paymentMethodType": "FPS",
      "blockchain": "MATIC-AMOY",
      "senderCountry": "US",
      "destinationCountry": "HK",
      "certificate": {
        "id": "0cc3c5fe-fa88-4e79-b5eb-1c5194a19b08",
        "certPem": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tTUlJQmpUQ0NBVE9nQXdJQkFnSVVMaWk2Mk5KME0rdTZOTDZWV0hWRkhIZmJCWUl3Q2dZSUtvWkl6ajBFQXdJd0tqRVhNQlVHQTFVRUF3d09ZWEJwTG1OcGNtTnNaUzVqYjIweER6QU5CZ05WQkFvTUJrTnBjbU5zWlRBZUZ3MHlOVEF6TVRjeU1EQXdNVFJhRncweU5qQXpNVGN5TURBd01UUmFNQ294RnpBVkJnTlZCQU1NRG1Gd2FTNWphWEpqYkdVdVkyOXRNUTh3RFFZRFZRUUtEQVpEYVhKamJHVXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBUmgyTTU0Q2FVMTlaWFRFaXZJVUNLOXluMmgvYld6Uno0bUhJWVE0ZzFYWnArdHRiM3Z6bGY2ZDQzYUhNYlRaQUpPTG1pbkdFZGwxbUZMdFRUTXdYb3ZvemN3TlRBekJnTlZIUkVFTERBcWdnNWhjR2t1WTJseVkyeGxMbU52YllJU2QzZDNMbUZ3YVM1amFYSmpiR1V1WTI5dGh3UUtBQUFCTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSUExbksrNUxBUC9ueUlxRFlUaVVLYmlHNWYwTjVPUmFMb2Y1VXpXU0dsUEJBaUVBaEVOcDFxakRydG41aGFpMHdKeTNORzJKZ2xra084Y1QzellhN21mRTBiST0tLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t",
        "domain": "api.circle.com",
        "jwk": {
          "kty": "EC",
          "crv": "P-256",
          "kid": "263521881931753643998528753619816524468853605762",
          "x": "YdjOeAmlNfWV0xIryFAivcp9of21s0c-JhyGEOINV2Y",
          "y": "n621ve_OV_p3jdocxtNkAk4uaKcYR2XWYUu1NMzBei8"
        }
      }
    }
  ]
}

From the certificate object in the quote response, extract the base64 encoded format of the certificate from the certPem field, decode it, and verify the following:

  • The certificate is not expired
  • The certificate is signed by a Circle-approved Certificate Authority listed in the Circle-provided CA bundle file
  • The common name of the certificate is for a CPN BFI and matches the domain field in the response
  • The verifiable public key of the certificate matches the jwk field in the response
Java
public static void verify() throws Exception {
    // Assume the API response is stored in a file "response.json"
    String responseStr = new String(Files.readAllBytes(Paths.get("response.json")));
    JSONObject response = new JSONObject(responseStr);
    JSONObject certObj = response.getJSONArray("data").getJSONObject(0).getJSONObject("certificate");

    String caBundlePath = "ca_bundle.pem"; // CA bundle file provided by Circle

    // ---------- 1. Check certificate expiration ----------
    String certPemB64 = certObj.getString("certPem");
    byte[] pemData = Base64.getDecoder().decode(certPemB64);
    CertificateFactory cf = CertificateFactory.getInstance("X.509");
    InputStream in = new ByteArrayInputStream(pemData);
    X509Certificate certificate = (X509Certificate) cf.generateCertificate(in);
    Date expirationDate = certificate.getNotAfter();
    Date currentDate = new Date();
    if (currentDate.after(expirationDate)) {
        System.out.println("❌ Certificate has expired on " + expirationDate);
        System.exit(1);
    } else {
        System.out.println("✅ Certificate is valid until " + expirationDate);
    }

    // ---------- 2. Verify that the certificate was signed by a Circle approved CA ----------
    List<X509Certificate> caCerts = loadCABundle(caBundlePath);
    boolean trusted = false;
    for (X509Certificate caCert : caCerts) {
        if (certificate.getIssuerX500Principal().equals(caCert.getSubjectX500Principal())) {
            trusted = true;
            System.out.println("✅ Certificate is signed by trusted CA: " + caCert.getSubjectX500Principal());
            break;
        }
    }
    if (!trusted) {
        System.out.println("❌ Certificate verification failed. CA is not trusted.");
        System.exit(1);
    }

    // ---------- 3. Verify that the certificate's common name (CN) matches the expected domain ----------
    String certCN = getCommonName(certificate);
    String expectedDomain = certObj.getString("domain");
    if (certCN != null && certCN.equals(expectedDomain)) {
        System.out.println("✅ Certificate common name matches expected domain.");
    } else {
        System.out.println("❌ Certificate common name does not match expected domain.");
        System.exit(1);
    }

    // ---------- 4. Verify that the verifiable public key of the certificate is the same as the provided JWK ----------
    JSONObject providedJWK = certObj.getJSONObject("jwk");
    String xProvided = providedJWK.getString("x");
    String yProvided = providedJWK.getString("y");
    PublicKey pubKey = certificate.getPublicKey();
    if (pubKey instanceof ECPublicKey) {
        ECPublicKey ecPub = (ECPublicKey) pubKey;
        ECPoint point = ecPub.getW();
        String xCert = toBase64URL(point.getAffineX(), 32);
        String yCert = toBase64URL(point.getAffineY(), 32);
        if (xCert.equals(xProvided) && yCert.equals(yProvided)) {
            System.out.println("✅ Certificate public key matches provided JWK.");
        } else {
            System.out.println("❌ Certificate public key does not match provided JWK.");
            System.exit(1);
        }
    } else {
        System.out.println("❌ Certificate public key is not EC type.");
        System.exit(1);
    }
}

From the certificate field, extract the JWK parameters: kty, crv, kid, x, y and create the JWK object in your code using a suitable library (for example, Nimbus JOSE+JWT in Java).

Java
import com.nimbusds.jose.jwk.JWK;

public static JWK parseJwkFromJson() throws ParseException {
    String jwkJson = """
        {
            "kty": "EC",
            "crv": "P-256",
            "kid": "263521881931753643998528753619816524468853605762",
            "x": "YdjOeAmlNfWV0xIryFAivcp9of21s0c-JhyGEOINV2Y",
            "y": "n621ve_OV_p3jdocxtNkAk4uaKcYR2XWYUu1NMzBei8"
        }
        """;

    JWK jwk = JWK.parse(jwkJson);

    return jwk;
}

Create the payload for travel rule data and beneficiary account data. Follow the steps in How-to: Integrate with JSON Schema to get the required fields from the requirements endpoint and construct them to the correct format. An example of each is shown below:

travelRule

JSON
{
  "BENEFICIARY_ADDRESS": {
    "city": "San Francisco",
    "country": "US",
    "postalCode": "94105",
    "stateProvince": "CA",
    "street": "123 Market Street"
  },
  "BENEFICIARY_NAME": "Alice Johnson",
  "ORIGINATOR_ACCOUNT_NUMBER": "9876543210",
  "ORIGINATOR_ADDRESS": {
    "city": "New York",
    "country": "US",
    "postalCode": "10001",
    "stateProvince": "NY",
    "street": "456 Madison Avenue"
  },
  "ORIGINATOR_FINANCIAL_INSTITUTION_ADDRESS": {
    "city": "Chicago",
    "country": "US",
    "postalCode": "60603",
    "stateProvince": "IL",
    "street": "789 Apple Drive"
  },
  "ORIGINATOR_FINANCIAL_INSTITUTION_NAME": "First National Bank",
  "ORIGINATOR_NAME": "Robert Smith"
}

beneficiaryAccount

JSON
{
  "BANK_NAME": "Test Bank",
  "RECIPIENT_ADDRESS": {
    "street": "123 Test St",
    "city": "Sacramento",
    "state": "CA"
  },
  "RECIPIENT_CITY": "Sacramento"
}

Convert the data from the previous step into a JSON string, then use the JWK to encrypt it. Ensure that the following parameters are used:

  • Algorithm: ECDH-ES+A128KW
  • Encryption method: A128GCM
Java
import com.nimbusds.jose.EncryptionMethod;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWEAlgorithm;
import com.nimbusds.jose.JWEEncrypter;
import com.nimbusds.jose.JWEHeader;
import com.nimbusds.jose.JWEObject;
import com.nimbusds.jose.Payload;
import com.nimbusds.jose.crypto.ECDHEncrypter;
import com.nimbusds.jose.jwk.JWK;


   /**
     * Encrypts a payload using JWE (JSON Web Encryption) with ECDH-ES key agreement.
     *
     * This method creates a JWE compact string with the provided payload and encrypts it using
     * the recipient's public key. The encryption algorithm used is ECDH-ES with AES-128 key wrap,
     * and the content encryption method is AES GCM with 128-bit key.
     *
     * @param <T> the type of the payload to encrypt
     * @param payload the data to be encrypted
     * @param recipientJwk the recipient's JWK (JSON Web Key) containing the public key for encryption
     * @return the serialized JWE in compact form
     * @throws JOSEException if an error occurs during the encryption process
     */
public static <T> String encrypt(T payload, JWK recipientJwk) throws JOSEException {
  String plainText = JsonUtils.toJson(payload);

  // Create the JWEHeader using ECDH_ES+AS128KW and AES-128-GCM
  JWEHeader header = new JWEHeader(
      JWEAlgorithm.ECDH_ES_A128KW,
      EncryptionMethod.A128GCM
  );

  // Create the JWE object with the payload to encrypt
  JWEObject jweObject = new JWEObject(
      header,
      new Payload(plainText)
  );

  // Create an encrypter with the recipient's public key
  JWEEncrypter encrypter = new ECDHEncrypter(recipientJwk.toECKey());

  // Encrypt the JWE
  jweObject.encrypt(encrypter);

// Return the serialized JWE string in compact form
  return new jweObject.serialize();
}

Send the encrypted payload in the API request to create a payment. The API returns a 200 response if the data is properly encrypted and can be decrypted by the BFI, otherwise an encryption-related error code is returned.

Did this page help you?
© 2023-2025 Circle Technology Services, LLC. All rights reserved.