Spring Boot SM2/SM3/SM4 Integration: Pitfalls, Key Management & Performance Tuning

This article details practical integration of Chinese national cryptographic algorithms SM2, SM3, and SM4 into Spring Boot, covering dependency setup, encryption/decryption, signing/verification, anti-replay protection, JWT implementation, TLS offloading via Nginx, key rotation strategies, and performance optimizations that raised throughput from 300 to 1300 TPS.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Spring Boot SM2/SM3/SM4 Integration: Pitfalls, Key Management & Performance Tuning

Why Integrate Chinese National Cryptography

A project required MLPS (等保) compliance, mandating replacement of RSA+AES with SM2 (256-bit elliptic curve for signatures/key agreement), SM3 (256-bit hash, comparable to SHA-256), and SM4 (128-bit block cipher for symmetric encryption). Migration involved message encryption/decryption, signature verification, token generation/validation, and key management changes.

Dependencies

Use bcprov-jdk15to18:1.78.1 + hutool-all:5.8.25. Hutool's SmUtil wraps BouncyCastle details; register the BC provider once in a static block:

@Configuration
public class GMConfig {
    static {
        if (Security.getProvider("BC") == null) {
            Security.addProvider(new BouncyCastleProvider());
        }
    }
}

SM4 Encryption: Avoid Static/Instance Mix-ups

SM4 objects are stateful; manage keys via Spring injection. Example SM4Util component validates 16-byte key:

@Component
public class SM4Util {
    private SM4 sm4;
    public SM4Util(@Value("${gm.sm4-key}") String sm4Key) {
        byte[] key = sm4Key.getBytes(StandardCharsets.UTF_8);
        if (key.length != 16) throw new IllegalStateException("SM4 key must be 16 bytes");
        this.sm4 = new SM4(key);
    }
    public String encryptToHex(String plainText) { return sm4.encryptHex(plainText); }
    public String decryptFromHex(String cipherText) { return sm4.decryptStr(cipherText); }
}

Critical: Hutool defaults to SM4/ECB/PKCS5Padding. Production must use CBC with random IV. Performance difference is microseconds; security gain is significant. Switch via:

SM4 sm4 = SmUtil.sm4(key);
byte[] iv = SecureUtil.randomBytes(16);
sm4.setIv(iv);
sm4.setMode(SM4.Mode.CBC);

SM2 Sign/Verify: Separate Private/Public Keys

Initial mistake: single SM2 instance with both keys for sign/verify. Real deployment splits signing service (holds private key) and verification service (holds public key).

Signing Service (Private Key)

@Component
public class SignService {
    private final SM2 signer;
    public SignService(@Value("${gm.sm2.private-key}") String privateKeyHex) {
        this.signer = SmUtil.sm2(privateKeyHex, null);
    }
    public String sign(String content) {
        byte[] digest = SmUtil.sm3(content.getBytes(StandardCharsets.UTF_8));
        byte[] signBytes = signer.signDigest(digest, null);
        return HexUtil.encodeHexStr(signBytes);
    }
}

Verification Service (Public Key)

@Component
public class VerifyService {
    private final SM2 verifier;
    public VerifyService(@Value("${gm.sm2.public-key}") String publicKeyHex) {
        this.verifier = SmUtil.sm2(null, publicKeyHex);
    }
    public boolean verify(String content, String signHex) {
        byte[] digest = SmUtil.sm3(content.getBytes(StandardCharsets.UTF_8));
        try {
            return verifier.verifyDigest(digest, HexUtil.decodeHex(signHex));
        } catch (Exception e) { return false; }
    }
}

Pitfalls: Hutool SmUtil.sm2(privateKey, publicKey) with one null may generate random keys in older versions (fixed ~5.7+). signDigest in 5.8.x expects userId (default "1234567812345678"); explicit null may use default. Mismatch with other language implementations often stems from this. Signature format is 64-byte r||s; if counterpart expects C1C3C2 ASN.1 DER, convert before exchange.

Request/Response Encryption: Advice Over Filter

Implement RequestBodyAdvice and ResponseBodyAdvice for JSON-level crypto, avoiding raw byte handling in Filters. Use @Decrypt annotation to select endpoints.

@RestControllerAdvice
public class CryptoRequestBodyAdvice extends RequestBodyAdviceAdapter {
    @Override
    public boolean supports(MethodParameter mp, Type targetType, Class<? extends HttpMessageConverter<?>> ct) {
        return mp.hasMethodAnnotation(Decrypt.class) || mp.hasParameterAnnotation(Decrypt.class);
    }
    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage input, MethodParameter param, Type target, Class<? extends HttpMessageConverter<?>> conv) throws IOException {
        String cipher = new String(input.getBody().readAllBytes(), StandardCharsets.UTF_8);
        String plain = sm4Util.decryptFromHex(cipher);
        return new MappingJacksonInputMessage(new ByteArrayInputStream(plain.getBytes(StandardCharsets.UTF_8)), input.getHeaders());
    }
}

Response encryption in beforeBodyWrite serializes body to JSON, encrypts, returns hex. Warning: large payloads (e.g., 10k+ records) increase transmission time; skip encryption for such endpoints and rely on HTTP compression.

SM2 Verification with Anti-Replay

Three headers: X-SM2-Sign (hex signature), X-Timestamp (ms), X-Nonce (random per request). Signing input: timestamp + "\n" + nonce + "\n" + body → SM3 digest → SM2 sign. Without timestamp/nonce checks, signatures are replayable.

Timestamp tolerance: 5 minutes (300,000 ms). Nonce stored in Redis with key nonce:{value}, TTL 5 minutes; reject if exists. Verification order: timestamp → nonce → signature (saves SM2 CPU ~200-500μs per verify).

long timestamp = Long.parseLong(request.getHeader("X-Timestamp"));
if (Math.abs(System.currentTimeMillis() - timestamp) > 300_000L) throw new BizException("Request expired");
String nonce = request.getHeader("X-Nonce");
if (Boolean.TRUE.equals(redisTemplate.hasKey("nonce:" + nonce))) throw new BizException("Duplicate request");
redisTemplate.opsForValue().set("nonce:" + nonce, "1", Duration.ofMinutes(5));

Frontend/backend JSON serialization must match exactly (field order, null handling). Solution: sign canonicalized query params (sorted) + raw request body JSON.

National Crypto JWT: Correct Algorithm Name

Libraries like java-jwt/jjwt lack native SM2 support; manual JWT creation is straightforward. Header alg must be SM3WITHSM2 (BC algorithm name), not SM2.

public class GmJwt {
    private final SM2 sm2;
    public GmJwt(String privateKeyHex, String publicKeyHex) {
        this.sm2 = SmUtil.sm2(privateKeyHex, publicKeyHex);
    }
    public String createToken(String userId, long expireSeconds) {
        Map<String, Object> header = new HashMap<>();
        header.put("alg", "SM3WITHSM2");
        header.put("typ", "JWT");
        Map<String, Object> payload = new HashMap<>();
        payload.put("sub", userId);
        payload.put("iat", System.currentTimeMillis() / 1000);
        payload.put("exp", System.currentTimeMillis() / 1000 + expireSeconds);
        String headerSeg = base64Url(header);
        String payloadSeg = base64Url(payload);
        String signingInput = headerSeg + "." + payloadSeg;
        byte[] sign = sm2.sign(signingInput.getBytes(StandardCharsets.UTF_8));
        String signature = Base64.getUrlEncoder().withoutPadding().encodeToString(sign);
        return signingInput + "." + signature;
    }
    private String base64Url(Object obj) {
        return Base64.getUrlEncoder().withoutPadding().encodeToString(JSONUtil.toJsonStr(obj).getBytes(StandardCharsets.UTF_8));
    }
}

Verification uses public key only. Store tokens in Redis for revocation (e.g., forced logout); relying solely on exp is insufficient.

TLS Layer: Offload to Nginx with gmssl

Spring Boot embedded Tomcat has zero support for national crypto TLS. BouncyCastle's GMSSLSocketFactory is demo-only. Production solution: Nginx with gmssl patch for TLS termination. Example config:

server {
    listen 443 ssl;
    server_name example.com;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_certificate /etc/gmssl/server.crt;
    ssl_certificate_key /etc/gmssl/server.key;
    ssl_ciphers "SM2-WITH-SMS4-SM3:ECC-SM2-SM4-CBC:ECC-SM2-SM3-SM4-CBC";
    location /api {
        proxy_pass http://spring-boot-service:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

Client-to-Nginx uses national crypto; Nginx-to-Spring Boot uses plain HTTP over trusted internal network. Dual-certificate (national + international) requires two server blocks or multiple ssl_certificate directives; complexity often outweighs benefit.

Key Management: No Disk Storage, Versioned Rotation

Private keys never on disk or in config center plaintext. Centralized key service serves keys at startup; master key injected via env var/startup param, separate from encrypted DB storage.

Rotation uses keyVersion (1-byte prefix on ciphertext). New writes use new key; reads select key by version. Allows parallel old/new keys during transition.

public class KeyHolder {
    private static final Map<Integer, SM2> SM2_MAP = new ConcurrentHashMap<>();
    private static final Map<Integer, SM4> SM4_MAP = new ConcurrentHashMap<>();
    private static volatile int CURRENT_VERSION = 1;
    public static SM4 getSm4(int version) { return SM4_MAP.get(version); }
    public static SM4 getCurrentSm4() { return SM4_MAP.get(CURRENT_VERSION); }
    public static void rotateKey(int version, String sm4KeyHex, String sm2PrivHex, String sm2PubHex) {
        SM4_MAP.put(version, new SM4(HexUtil.decodeHex(sm4KeyHex)));
        SM2_MAP.put(version, SmUtil.sm2(sm2PrivHex, sm2PubHex));
        CURRENT_VERSION = version;
    }
}

Rotation cadence: SM4 every 6 months, SM2 yearly. Retire old keys after at least one full rotation period to ensure legacy data decryptability.

Performance Results & Optimizations

JMeter test: 200 concurrent, 3 minutes, mixed SM2 verify + SM4 decrypt + SM2 sign per request. Baseline (no hardware acceleration): ~300 TPS, bottleneck SM2 verifyDigest ~400μs, CPU saturated.

Optimizations applied:

SM2 instance reuse – singleton instead of per-request allocation.

Fast-fail verification – timestamp/nonce checks before SM2 verify.

Async crypto for non-critical paths – offload to thread pool.

Final result: ~1300 TPS, avg latency 220ms, p95 310ms. Matches theoretical BC/Java limits. For higher throughput, use HSM or CPUs with SM4-NI instructions.

Summary Checklist

Never place public/private keys in same SM2 object; production environments are naturally separated.

Enforce canonical signing content format (SDK-enforced).

Retain old key versions during rotation to avoid decryption failures.

Avoid implementing national crypto TLS in application layer; offload to Nginx.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Performance OptimizationSpring BootSM2SM3SM4Key RotationChinese CryptographyMLPS Compliance
Xiaolin Talks Programming
Written by

Xiaolin Talks Programming

Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.