Skip to content

Credit card transaction encryption and decryption

The open platform uses the ECIES (Elliptic Curve Integrated Encryption Scheme) standard process for transmitting sensitive data:

  • Key exchange: ECDH(P-256)
  • Key derivation: HKDF-SHA256
  • Encryption algorithm: AES-128-GCM (includes an authentication tag to ensure integrity)
  • Additional authenticated data (AAD): Binds protocol context (algorithm suite, ephemeral public key, IV) to prevent cross-context data mixing attacks

Developers need to complete the following preparations:

  • Register the long-term public key (P-256, X.509 SPKI/DER Base64) on the platform side.
  • Securely store the corresponding long-term private key (PKCS#8/DER Base64 or PEM format) locally.
  • Use the private key to decrypt the ciphertext issued by the platform.

Algorithm suite

ComponentConfiguration
Elliptic curveP-256 (secp256r1)
Key exchangeECDH
Key derivationHKDF-SHA256
Symmetric encryptionAES-128-GCM
IV length12 bytes (96 bits)
GCM Tag length16 bytes (128 bits)
KDF saltReturned in the ciphertext package (salt)

Data packet format

The ciphertext package issued by the platform (JSON) includes the following fields:

json
{
  "cipher": "BASE64_STRING",   // Ciphertext
  "iv": "BASE64_STRING",       // 12-byte random IV
  "ephemeralPublicKey": "BASE64_STRING", // Ephemeral public key (X.509 SPKI DER encoded)
  "suit": "ECIESv1|AES-128-GCM|P-256",  // Suite identifier
  "salt": "ECIESv1-HKDF-SALT"   // HKDF salt
}

Field descriptions

  • ciphertext: AES-GCM encrypted output, including the ciphertext and the 16-byte authentication tag.
  • iv: 12-byte random IV generated by the platform.
  • ephemeralPublicKey: Ephemeral public key generated by the platform, encoded in DER and then Base64.
  • suit: Algorithm suite identifier, which must be bound in the AAD.
  • salt: HKDF salt, a fixed string.

AAD construction specification

During decryption, both the platform and developer must construct exactly the same AAD:

AAD = TLV("suite", suit) || TLV("eph", ephemeralPublicKey) || TLV("iv", iv)

The TLV format is defined as:

[name_len:2B-BE] [name ASCII] [val_len:4B-BE] [val]

Example:

  • name = "suite", value = "ECIESv1|AES-128-GCM|P-256".getBytes(UTF-8)
  • name = "eph", value = raw ephemeral public key DER bytes
  • name = "iv", value = raw 12-byte IV

Decryption process

  1. Base64 decode: Decode the ciphertext, iv, and ephemeralPublicKey into binary.
  2. Rebuild ephemeral public key: Use X509EncodedKeySpec (Java) or serialization.load_der_public_key (Python) to parse ephemeralPublicKey.
  3. ECDH: Use the local private key and the ephemeral public key to calculate the shared secret shared_secret.
  4. Construct AAD: Concatenate according to the §4 specification.
  5. HKDF derive AES key:
    aes_key = HKDF-SHA256(shared_secret, salt=salt, info=aad, L=16)
  6. AES-GCM decryption

Developer implementation

Java decryption example

java
import javax.crypto.Cipher;
import javax.crypto.KeyAgreement;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.ECGenParameterSpec;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Objects;
 
/**
 * ECDH + HKDF(SHA-256) + AES/GCM/NoPadding
 * - No additional HMAC; GCM AAD binds context to avoid cross-context attacks.
 * - Only relies on JDK (Java 8u162+ recommended).
 *
 * @author Bing
 */
public class EncryptUtils {
 
    private static final int AES_KEY_LEN = 16;
    private static final int GCM_TAG_BITS = 128;
    private static final int GCM_IV_LEN = 12;
 
    // ---- Public/Private Key Import (supports PEM or raw Base64) ----
    public static PrivateKey loadPrivateKey(String pemOrB64) throws Exception {
        byte[] der = decodePemOrB64(pemOrB64);
        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der);
        return KeyFactory.getInstance("EC").generatePrivate(spec);
    }
 
    public static PublicKey loadPublicKey(String pemOrB64) throws Exception {
        byte[] der = decodePemOrB64(pemOrB64);
        X509EncodedKeySpec spec = new X509EncodedKeySpec(der);
        return KeyFactory.getInstance("EC").generatePublic(spec);
    }
 
    private static byte[] decodePemOrB64(String in) {
        String clean = in.replaceAll("-----BEGIN[^-]+-----", "")
                .replaceAll("-----END[^-]+-----", "")
                .replaceAll("\\s", "");
        return Base64.getDecoder().decode(clean);
    }
    // ---- ECDH Shared Secret ----
    private static byte[] ecdh(PrivateKey myPriv, PublicKey peerPub) throws Exception {
        KeyAgreement ka = KeyAgreement.getInstance("ECDH");
        ka.init(myPriv);
        ka.doPhase(peerPub, true);
        return ka.generateSecret();
    }
 
    private static byte[] hkdfExtract(byte[] salt, byte[] ikm) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(salt, "HmacSHA256"));
        return mac.doFinal(ikm);
    }
 
    private static byte[] hkdfExpand(byte[] prk, byte[] info, int outLen) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(prk, "HmacSHA256"));
        int n = (int) Math.ceil(outLen / 32.0); // 32 = SHA-256 output
        byte[] t = new byte[0];
        byte[] okm = new byte[outLen];
        int pos = 0;
        for (int i = 1; i <= n; i++) {
            mac.reset();
            mac.update(t);
            mac.update(info);
            mac.update((byte) i);
            t = mac.doFinal();
            int copy = Math.min(32, outLen - pos);
            System.arraycopy(t, 0, okm, pos, copy);
            pos += copy;
        }
        return okm;
    }
 
    private static byte[] hkdf(byte[] ikm, byte[] salt, byte[] info, int outLen) throws Exception {
        if (salt == null) {
            salt = new byte[32]; // all-zero salt if absent (RFC 5869 recommended)
        }
        byte[] prk = hkdfExtract(salt, ikm);
        return hkdfExpand(prk, info, outLen);
    }
 
    // ---- AAD Construction: Binding context to avoid "mixing attacks" ----
    private static byte[] buildAad(String salt, byte[] ephPubDer, byte[] iv) {
        // AAD = SUITE || ephPubDer || iv
        byte[] suite = salt.getBytes(StandardCharsets.UTF_8);
        ByteBuffer bb = ByteBuffer.allocate(
                4 + suite.length +
                        4 + ephPubDer.length +
                        4 + iv.length
        ).order(ByteOrder.BIG_ENDIAN);
        bb.putInt(suite.length).put(suite);
        bb.putInt(ephPubDer.length).put(ephPubDer);
        bb.putInt(iv.length).put(iv);
        return bb.array();
    }
 
    // ---- Key Derivation: Only derive AES key (GCM includes authentication) ----
    private static SecretKeySpec deriveAesKey(byte[] sharedSecret, byte[] aad, String saltKey) throws Exception {
        byte[] salt = saltKey.getBytes(StandardCharsets.UTF_8);
        byte[] okm = hkdf(sharedSecret, salt, aad, AES_KEY_LEN);
        return new SecretKeySpec(okm, "AES");
    }
    // -------- Decryption (core API) --------
    public static String decrypt(String b64PrivateKey,
                                 String b64Cipher, String b64Iv, String b64EphPub, String suit, String salt) throws Exception {
 
        byte[] cipher = Base64.getDecoder().decode(Objects.requireNonNull(b64Cipher));
        byte[] iv = Base64.getDecoder().decode(Objects.requireNonNull(b64Iv));
        byte[] ephDer = Base64.getDecoder().decode(Objects.requireNonNull(b64EphPub));
 
        if (iv.length != GCM_IV_LEN) {
            throw new IllegalArgumentException("IV length must be " + GCM_IV_LEN + " bytes");
        }
 
        // 1) Parse the ephemeral public key
        PublicKey ephPub = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(ephDer));
 
        PrivateKey privateKey = loadPrivateKey(b64PrivateKey);
        // 2) ECDH
        byte[] shared = ecdh(privateKey, ephPub);
 
        // 3) AAD (must be the same as the encryption side)
        byte[] aad = buildAad(suit, ephDer, iv);
 
        // 4) HKDF -> AES Key
        SecretKeySpec aesKey = deriveAesKey(shared, aad, salt);
 
        // 5) GCM Decryption (automatically verifies tag)
        Cipher gcm = Cipher.getInstance("AES/GCM/NoPadding");
        gcm.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
        gcm.updateAAD(aad);
        return new String(gcm.doFinal(cipher), StandardCharsets.UTF_8);
 
    }
 
    // -------- Self-Test: Simulate send/receive, round-trip --------
    public static void main(String[] args) throws Exception {
        // Simulate receiver (long-term key)
 
        // Sender uses receiver's public key to encrypt
        String cipherB64 = "sJhR1XeVpZUoqy+PpnutV8WGTvqsDxLC04Qv+Lq4Wday18+Ui0MA5Doi4jLp1oBnt0wsAmX6lRetKGRPVpAtonyb0scxjo0Q88HszeH6X8hyzl5MNxxh1FFlNrC/cglC48E=";
        String ivB64 = "BZPhE9zdw7shN0XW";
        String ephPubDerB64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEE7zuVCKVqzD8YU40ASNOtJjlFlNXeH2jNqyEftj9f4GgJJUfRTLRPi/OkyRIKA5JoEzIH8KfPVENT6fR+2z1+A==";
        String suit = "GENSTORE|V1";
        String salt = "GENSTORE-HKDF-SALT";
 
 
        String privateKey = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvkifZIGuCSTNigOXRjx/tncaNj5iNtfRzQ7lAFNnh9ahRANCAATwxguEMX7uTGsKjTiYueTsD4fvZqsQVa3LhCs9MH3vPiWyH0u/Ze94wVEz0gOkRYkjitbO2FaixaESJKD3LzqM";
        // Receiver uses private key to decrypt
        String plain = decrypt(privateKey, cipherB64, ivB64,ephPubDerB64, suit, salt);
        System.out.println("Decrypted: " + plain);
    }
}

Python decryption example

Dependencies: pip install cryptography

python
# ecies_gcm_var.py
from __future__ import annotations
 
import base64
import os
from dataclasses import dataclass
from typing import Tuple
 
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
 
 
CURVE_NAME = "secp256r1"                       # P-256
AES_KEY_LEN = 16                               # 128-bit
GCM_IV_LEN = 12                                # 12 bytes
 
 
# ---- Encoding/Decoding Tools: PEM / Base64(DER) ----
def _is_pem(s: str) -> bool:
    return s.strip().startswith("-----BEGIN")
 
def _b64d(s: str) -> bytes:
    return base64.b64decode(s)
 
def _b64e(b: bytes) -> str:
    return base64.b64encode(b).decode("ascii")
 
def load_private_key(pem_or_b64: str):
    raw = pem_or_b64.strip().encode("utf-8") if _is_pem(pem_or_b64) else _b64d(pem_or_b64)
    if _is_pem(pem_or_b64):
        return serialization.load_pem_private_key(raw, password=None)
    return serialization.load_der_private_key(raw, password=None)
 
def load_public_key(pem_or_b64: str):
    raw = pem_or_b64.strip().encode("utf-8") if _is_pem(pem_or_b64) else _b64d(pem_or_b64)
    if _is_pem(pem_or_b64):
        return serialization.load_pem_public_key(raw)
    return serialization.load_der_public_key(raw)
 
def gen_keypair() -> Tuple[ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey]:
    priv = ec.generate_private_key(ec.SECP256R1())
    return priv, priv.public_key()
 
# ---- TLV + AAD----
def _tlv(name: bytes, value: bytes) -> bytes:
    # [name_len:2B-BE][name][val_len:4B-BE][value]
    return len(name).to_bytes(2, "big") + name + len(value).to_bytes(4, "big") + value
 
def build_aad_with_suite(suite: str, eph_der: bytes, iv: bytes) -> bytes:
    # AAD = TLV(suite) || TLV(eph) || TLV(iv)
    return _tlv(b"suite", suite.encode("utf-8")) + _tlv(b"eph", eph_der) + _tlv(b"iv", iv)
 
def build_aad(eph_der: bytes, iv: bytes) -> bytes:
    return build_aad_with_suite(SUITE, eph_der, iv)
 
# ---- HKDF (info = AAD, salt is provided by the caller) ----
def derive_aes_key(shared_secret: bytes, aad: bytes, salt_str: str) -> bytes:
    hkdf = HKDF(
        algorithm=hashes.SHA256(),
        length=AES_KEY_LEN,
        salt=salt_str.encode("utf-8"),
        info=aad,
    )
    return hkdf.derive(shared_secret)
 
# ---- ECDH ----
def ecdh_shared(my_priv: ec.EllipticCurvePrivateKey, peer_pub: ec.EllipticCurvePublicKey) -> bytes:
    return my_priv.exchange(ec.ECDH(), peer_pub)
 
def decrypt(
    receiver_priv_pem_or_b64: str,
    cipher_b64: str,
    iv_b64: str,
    eph_pub_der_b64: str,
    suit: str,
    salt: str,
) -> bytes:
    receiver_priv = load_private_key(receiver_priv_pem_or_b64)
 
    cipher = _b64d(cipher_b64)
    iv = _b64d(iv_b64)
    eph_der = _b64d(eph_pub_der_b64)
 
    if len(iv) != GCM_IV_LEN:
        raise ValueError(f"IV length must be {GCM_IV_LEN} bytes")
 
    eph_pub = serialization.load_der_public_key(eph_der)
 
    # ECDH
    shared = ecdh_shared(receiver_priv, eph_pub)
 
    # AAD (must match encryption side)
    aad = build_aad_with_suite(suit, eph_der, iv)
 
    # HKDF (using the provided salt)
    key = derive_aes_key(shared, aad, salt)
 
    aesgcm = AESGCM(key)
    return aesgcm.decrypt(iv, cipher, aad)
 
# ---- Self-test ----
if __name__ == "__main__":
    PRIVATE_KEY_B64 = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvkifZIGuCSTNigOXRjx/tncaNj5iNtfRzQ7lAFNnh9ahRANCAATwxguEMX7uTGsKjTiYueTsD4fvZqsQVa3LhCs9MH3vPiWyH0u/Ze94wVEz0gOkRYkjitbO2FaixaESJKD3LzqM"
    cipher_b64 = b'3isYeFSdEZJ7sOizfVJAJjn8mwVNZG8FKfL8KirMD7aYgjg1gvWkFXaDP8l7LnpkGJWqTvsgs1JG92Sa5woHyZ2MpLZ1h55+1lt7D5E+88xQXqc4mXSnLeb2