Signer.java

package com.shengzhiai.yugu;

import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.util.Base64;
import java.util.Map;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

/**
 * Request signature of the platform (CONTRACT section 0.2), identical in every SDK and checked
 * against {@code spec/fixtures/sign/vectors.json}.
 *
 * <ol>
 *   <li>take the business parameters, drop null and empty values;</li>
 *   <li>sort by key in natural (TreeMap) order;</li>
 *   <li>join as {@code k1=v1&k2=v2} without URL encoding;</li>
 *   <li>{@code Base64(HMAC_SHA256(payload_utf8, secretKey_utf8))}.</li>
 * </ol>
 *
 * <p>Signed sets per call: native evaluate signs {@code {config: <exact JSON text of the config part>}};
 * TTS signs the top level scalar JSON fields; compat REST signs the form fields; the report query
 * signs nothing; WebSocket handshakes sign every query parameter except {@code signature}.
 */
public final class Signer {
    private static final String HMAC_SHA256 = "HmacSHA256";

    private Signer() {
    }

    /**
     * @param params business parameters; null and empty values are dropped
     * @return payload such as {@code coreType=sent.eval.cn&language=zh-CN&refText=北京你好}
     */
    public static String buildPayload(Map<String, ?> params) {
        TreeMap<String, String> sorted = new TreeMap<>();
        if (params != null) {
            for (Map.Entry<String, ?> e : params.entrySet()) {
                if (e.getKey() == null || e.getValue() == null) {
                    continue;
                }
                String v = String.valueOf(e.getValue());
                if (!v.isEmpty()) {
                    sorted.put(e.getKey(), v);
                }
            }
        }
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, String> e : sorted.entrySet()) {
            if (sb.length() > 0) {
                sb.append('&');
            }
            sb.append(e.getKey()).append('=').append(e.getValue());
        }
        return sb.toString();
    }

    /**
     * @param payload   payload built by {@link #buildPayload(Map)}
     * @param secretKey secret key
     * @return Base64 HMAC-SHA256 signature
     */
    public static String signPayload(String payload, String secretKey) {
        try {
            Mac mac = Mac.getInstance(HMAC_SHA256);
            mac.init(new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
            return Base64.getEncoder().encodeToString(mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException("HmacSHA256 unavailable", e);
        }
    }

    /**
     * @param params    business parameters
     * @param secretKey secret key
     * @return signature for {@code X-Signature} or the {@code signature} query parameter
     */
    public static String sign(Map<String, ?> params, String secretKey) {
        return signPayload(buildPayload(params), secretKey);
    }
}