Skip to content

数据加解密规范 (ECIES)

开放平台在传输敏感数据时,采用 ECIES (Elliptic Curve Integrated Encryption Scheme) 标准流程:

  • 密钥交换:ECDH(P-256)
  • 密钥派生:HKDF-SHA256
  • 加密算法:AES-128-GCM(自带认证标签,确保完整性)
  • 附加认证数据 (AAD):绑定协议上下文(算法套件、临时公钥、IV),防止不同上下文数据混用攻击

开发者需完成以下准备:

  • 在平台侧登记 长期公钥(P-256,X.509 SPKI/DER Base64)。
  • 本地安全保存对应 长期私钥(PKCS#8/DER Base64 或 PEM 格式)。
  • 使用私钥对平台下发的密文进行解密。

算法套件

组件配置
椭圆曲线P-256 (secp256r1)
密钥交换ECDH
密钥派生HKDF-SHA256
对称加密AES-128-GCM
IV 长度12 字节(96 bits)
GCM Tag 长度16 字节(128 bits)
KDF 盐值密文包(salt)中返回

数据包格式

平台下发的密文包(JSON)包含以下字段:

json
{
  "cipher": "BASE64_STRING",   // 密文
  "iv": "BASE64_STRING",           // 12 字节随机 IV
  "ephemeralPublicKey": "BASE64_STRING",// 临时公钥 (X.509 SPKI DER 编码)
  "suit": "ECIESv1|AES-128-GCM|P-256", // 套件标识
  "salt": "ECIESv1-HKDF-SALT"      // HKDF 盐值
}

字段说明

  • ciphertext:AES-GCM 加密输出,包含密文和 16 字节认证标签。
  • iv:平台随机生成的 12 字节 IV。
  • ephemeralPublicKey:平台生成的临时公钥,DER 编码后再 Base64。
  • suit:算法套件标识,需在 AAD 中绑定。
  • salt:HKDF 盐值,固定字符串。

AAD 构造规范

解密时,平台与开发者双方必须构造完全一致的 AAD:

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

其中 TLV 格式定义为:

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

举例:

  • name = "suite",值 = "ECIESv1|AES-128-GCM|P-256".getBytes(UTF-8)
  • name = "eph",值 = ephemeralPublicKey 原始 DER 字节
  • name = "iv",值 = 原始 12 字节 IV

解密流程

  1. Base64 解码:将 ciphertext、iv、ephemeralPublicKey 解码为二进制。
  2. 重建临时公钥:使用 X509EncodedKeySpec(Java)或 serialization.load_der_public_key(Python)解析 ephemeralPublicKey。
  3. ECDH:使用本地私钥与临时公钥计算共享密钥 shared_secret。
  4. 构造 AAD:按 §4 规范拼接。
  5. HKDF 派生 AES 密钥
    aes_key = HKDF-SHA256(shared_secret, salt=salt, info=aad, L=16)
  6. AES-GCM 解密

开发者实现

Java 解密示例

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
 * - 无额外 HMAC;用 GCM AAD 绑定上下文,避免混搭攻击。
 * - 仅依赖 JDK(Java 8u162+ 建议)。
 *
 * @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;
 
    // ---- 公钥/私钥导入(支持 PEM 或裸 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 共享密钥 ----
    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 输出
        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 推荐)
        }
        byte[] prk = hkdfExtract(salt, ikm);
        return hkdfExpand(prk, info, outLen);
    }
 
    // ---- AAD 构造:绑定协议上下文,防“混搭攻击” ----
    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();
    }
 
    // ---- 密钥派生:仅派生 AES key(GCM 自带认证)----
    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");
    }
    // -------- 解密(核心 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 长度必须为 " + GCM_IV_LEN + " 字节(12字节推荐)");
        }
 
        // 1) 解析对端临时公钥
        PublicKey ephPub = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(ephDer));
 
        PrivateKey privateKey = loadPrivateKey(b64PrivateKey);
        // 2) ECDH
        byte[] shared = ecdh(privateKey, ephPub);
 
        // 3) AAD(必须与加密端一致)
        byte[] aad = buildAad(suit, ephDer, iv);
 
        // 4) HKDF -> AES Key
        SecretKeySpec aesKey = deriveAesKey(shared, aad, salt);
 
        // 5) GCM 解密(自动验证 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);
 
    }
 
    // -------- 自测:生成收/发双方,往返一次 --------
    public static void main(String[] args) throws Exception {
        // 模拟接收方(长期密钥)
 
        // 发送方使用 接收方公钥 加密
        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";
        // 接收方使用 私钥 解密
        String plain = decrypt(privateKey, cipherB64, ivB64,ephPubDerB64, suit, salt);
        System.out.println("Decrypted: " + plain);
    }
}

Python 解密示例

依赖库: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
 
 
# ---- 编解码工具: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 由调用方约定)----
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 长度必须为 {GCM_IV_LEN} 字节")
 
    eph_pub = serialization.load_der_public_key(eph_der)
 
    # ECDH
    shared = ecdh_shared(receiver_priv, eph_pub)
 
    # AAD(使用传入的 suit)
    aad = build_aad_with_suite(suit, eph_der, iv)
 
    # HKDF(使用传入的 salt)
    key = derive_aes_key(shared, aad, salt)
 
    aesgcm = AESGCM(key)
    return aesgcm.decrypt(iv, cipher, aad)
 
 
# ---- 自测 ----
if __name__ == "__main__":
    PRIVATE_KEY_B64 = "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgvkifZIGuCSTNigOXRjx/tncaNj5iNtfRzQ7lAFNnh9ahRANCAATwxguEMX7uTGsKjTiYueTsD4fvZqsQVa3LhCs9MH3vPiWyH0u/Ze94wVEz0gOkRYkjitbO2FaixaESJKD3LzqM"
    cipher_b64 = b'3isYeFSdEZJ7sOizfVJAJjn8mwVNZG8FKfL8KirMD7aYgjg1gvWkFXaDP8l7LnpkGJWqTvsgs1JG92Sa5woHyZ2MpLZ1h55+1lt7D5E+88xQXqc4mXSnLeb2he+EAW7EC9Y='
    iv_b64 = b'NZCGSQxXJ80NoIzy'
    eph_pub_der_b64 = b'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiVIX2UmXAQGxcYdFfJbaOMT6tryHtz6QuIx+TPvQ1LrEA+F3epDSKmcTpkVPdmuakVMwGQhZmYPt0gHt/fiong=='
    suit = 'GENSTORE|V1'
    salt = 'GENSTORE-HKDF-SALT'
 
    out = decrypt(PRIVATE_KEY_B64, cipher_b64, iv_b64, eph_pub_der_b64, suit, salt)
    print("Decrypted:     ", out.decode("utf-8"))