From c17cc29db25f3073390d99226711c09468f1db60 Mon Sep 17 00:00:00 2001 From: chenyuan Date: Mon, 3 Aug 2026 21:24:42 +0800 Subject: [PATCH] feat: add issuance sm3 sm2 teaching service --- pom.xml | 5 ++ .../service/IssuanceMessageComposer.java | 56 +++++++++++++ .../service/IssuanceSignatureService.java | 8 ++ .../domain/service/SignedIssuancePayload.java | 28 +++++++ .../BouncyCastleIssuanceSignatureService.java | 79 +++++++++++++++++++ .../crypto/InMemorySm2SigningKeyProvider.java | 71 +++++++++++++++++ ...ncyCastleIssuanceSignatureServiceTest.java | 62 +++++++++++++++ 7 files changed, 309 insertions(+) create mode 100644 src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceMessageComposer.java create mode 100644 src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceSignatureService.java create mode 100644 src/main/java/com/yau/digitalrmb/issuance/domain/service/SignedIssuancePayload.java create mode 100644 src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureService.java create mode 100644 src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/InMemorySm2SigningKeyProvider.java create mode 100644 src/test/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureServiceTest.java diff --git a/pom.xml b/pom.xml index 918239d..782f04c 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,11 @@ springdoc-openapi-ui ${springdoc.version} + + org.bouncycastle + bcprov-jdk18on + 1.84 + org.projectlombok lombok diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceMessageComposer.java b/src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceMessageComposer.java new file mode 100644 index 0000000..e05ba66 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceMessageComposer.java @@ -0,0 +1,56 @@ +package com.yau.digitalrmb.issuance.domain.service; + +import com.yau.digitalrmb.issuance.domain.model.DenominationItem; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +public final class IssuanceMessageComposer { + + public String compose(String bankCode, String organizationId, BigDecimal totalAmount, + List denominations, String currency, String timestamp) { + requireNonNull(bankCode, "bankCode"); + requireNonNull(organizationId, "organizationId"); + requireNonNull(totalAmount, "totalAmount"); + requireNonNull(denominations, "denominations"); + requireNonNull(currency, "currency"); + requireNonNull(timestamp, "timestamp"); + + List orderedDenominations = new ArrayList(denominations); + for (DenominationItem denomination : orderedDenominations) { + requireNonNull(denomination, "denomination item"); + } + Collections.sort(orderedDenominations, new Comparator() { + @Override + public int compare(DenominationItem left, DenominationItem right) { + return Integer.compare(right.getDenomination(), left.getDenomination()); + } + }); + + StringBuilder denominationText = new StringBuilder(); + for (DenominationItem denomination : orderedDenominations) { + if (denominationText.length() > 0) { + denominationText.append(','); + } + denominationText.append(denomination.getDenomination()) + .append(':') + .append(denomination.getQuantity()); + } + + return "ISSUE|" + bankCode + + "|" + organizationId + + "|" + totalAmount.setScale(2, RoundingMode.UNNECESSARY).toPlainString() + + "|" + denominationText + + "|" + currency + + "|" + timestamp; + } + + private static void requireNonNull(Object value, String name) { + Objects.requireNonNull(value, name + " must not be null"); + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceSignatureService.java b/src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceSignatureService.java new file mode 100644 index 0000000..1960030 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/service/IssuanceSignatureService.java @@ -0,0 +1,8 @@ +package com.yau.digitalrmb.issuance.domain.service; + +public interface IssuanceSignatureService { + + SignedIssuancePayload sign(String keyRef, String plainText); + + boolean verify(String keyRef, String plainText, SignedIssuancePayload payload); +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/domain/service/SignedIssuancePayload.java b/src/main/java/com/yau/digitalrmb/issuance/domain/service/SignedIssuancePayload.java new file mode 100644 index 0000000..e64b81b --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/domain/service/SignedIssuancePayload.java @@ -0,0 +1,28 @@ +package com.yau.digitalrmb.issuance.domain.service; + +import java.util.Objects; + +public final class SignedIssuancePayload { + + private final String digest; + private final String signature; + private final String signingKeyRef; + + public SignedIssuancePayload(String digest, String signature, String signingKeyRef) { + this.digest = Objects.requireNonNull(digest, "digest must not be null"); + this.signature = Objects.requireNonNull(signature, "signature must not be null"); + this.signingKeyRef = Objects.requireNonNull(signingKeyRef, "signingKeyRef must not be null"); + } + + public String getDigest() { + return digest; + } + + public String getSignature() { + return signature; + } + + public String getSigningKeyRef() { + return signingKeyRef; + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureService.java b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureService.java new file mode 100644 index 0000000..dae0ddb --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureService.java @@ -0,0 +1,79 @@ +package com.yau.digitalrmb.issuance.infrastructure.crypto; + +import com.yau.digitalrmb.issuance.domain.service.IssuanceSignatureService; +import com.yau.digitalrmb.issuance.domain.service.SignedIssuancePayload; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.PublicKey; +import java.security.Signature; +import java.util.Base64; +import java.util.Objects; + +public final class BouncyCastleIssuanceSignatureService implements IssuanceSignatureService { + + private static final char[] HEX = "0123456789ABCDEF".toCharArray(); + private final InMemorySm2SigningKeyProvider keyProvider; + + public BouncyCastleIssuanceSignatureService() { + this(new InMemorySm2SigningKeyProvider()); + } + + public BouncyCastleIssuanceSignatureService(InMemorySm2SigningKeyProvider keyProvider) { + this.keyProvider = Objects.requireNonNull(keyProvider, "keyProvider must not be null"); + InMemorySm2SigningKeyProvider.ensureBouncyCastleProvider(); + } + + @Override + public SignedIssuancePayload sign(String keyRef, String plainText) { + requireNonNull(keyRef, "keyRef"); + requireNonNull(plainText, "plainText"); + byte[] textBytes = plainText.getBytes(StandardCharsets.UTF_8); + String digest = digest(textBytes); + String signature = Base64.getEncoder().encodeToString(keyProvider.sign(keyRef, textBytes)); + return new SignedIssuancePayload(digest, signature, keyRef); + } + + @Override + public boolean verify(String keyRef, String plainText, SignedIssuancePayload payload) { + requireNonNull(keyRef, "keyRef"); + requireNonNull(plainText, "plainText"); + requireNonNull(payload, "payload"); + PublicKey publicKey = keyProvider.publicKeyFor(keyRef); + if (!keyRef.equals(payload.getSigningKeyRef())) { + return false; + } + try { + Signature signature = Signature.getInstance("SM3withSM2", "BC"); + signature.initVerify(publicKey); + signature.update(plainText.getBytes(StandardCharsets.UTF_8)); + return signature.verify(Base64.getDecoder().decode(payload.getSignature())); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to verify the SM2 signature", exception); + } + } + + private static String digest(byte[] textBytes) { + try { + MessageDigest messageDigest = MessageDigest.getInstance("SM3", "BC"); + return toUpperCaseHex(messageDigest.digest(textBytes)); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to calculate the SM3 digest", exception); + } + } + + private static String toUpperCaseHex(byte[] bytes) { + char[] characters = new char[bytes.length * 2]; + for (int index = 0; index < bytes.length; index++) { + int value = bytes[index] & 0xFF; + characters[index * 2] = HEX[value >>> 4]; + characters[index * 2 + 1] = HEX[value & 0x0F]; + } + return new String(characters); + } + + private static void requireNonNull(Object value, String name) { + Objects.requireNonNull(value, name + " must not be null"); + } +} diff --git a/src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/InMemorySm2SigningKeyProvider.java b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/InMemorySm2SigningKeyProvider.java new file mode 100644 index 0000000..9776ab1 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/issuance/infrastructure/crypto/InMemorySm2SigningKeyProvider.java @@ -0,0 +1,71 @@ +package com.yau.digitalrmb.issuance.infrastructure.crypto; + +import org.bouncycastle.jce.provider.BouncyCastleProvider; + +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.Security; +import java.security.Signature; +import java.security.spec.ECGenParameterSpec; +import java.util.Objects; + +public final class InMemorySm2SigningKeyProvider { + + public static final String TEACHING_KEY_REF = "sm2-key-02"; + private static final String PROVIDER_NAME = BouncyCastleProvider.PROVIDER_NAME; + private final KeyPair keyPair; + + public InMemorySm2SigningKeyProvider() { + ensureBouncyCastleProvider(); + this.keyPair = generateKeyPair(); + } + + public byte[] sign(String keyRef, byte[] plainText) { + requireKnownKey(keyRef); + Objects.requireNonNull(plainText, "plainText must not be null"); + try { + Signature signature = Signature.getInstance("SM3withSM2", PROVIDER_NAME); + signature.initSign(keyPair.getPrivate()); + signature.update(plainText); + return signature.sign(); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to sign with the in-memory SM2 key", exception); + } + } + + public PublicKey publicKeyFor(String keyRef) { + requireKnownKey(keyRef); + return keyPair.getPublic(); + } + + static void ensureBouncyCastleProvider() { + if (Security.getProvider(PROVIDER_NAME) != null) { + return; + } + synchronized (InMemorySm2SigningKeyProvider.class) { + if (Security.getProvider(PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + } + + private KeyPair generateKeyPair() { + try { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC", PROVIDER_NAME); + keyPairGenerator.initialize(new ECGenParameterSpec("sm2p256v1"), new SecureRandom()); + return keyPairGenerator.generateKeyPair(); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Unable to generate the in-memory SM2 key", exception); + } + } + + private static void requireKnownKey(String keyRef) { + Objects.requireNonNull(keyRef, "keyRef must not be null"); + if (!TEACHING_KEY_REF.equals(keyRef)) { + throw new IllegalArgumentException("Unknown SM2 key reference: " + keyRef); + } + } +} diff --git a/src/test/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureServiceTest.java b/src/test/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureServiceTest.java new file mode 100644 index 0000000..594ad9f --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/issuance/infrastructure/crypto/BouncyCastleIssuanceSignatureServiceTest.java @@ -0,0 +1,62 @@ +package com.yau.digitalrmb.issuance.infrastructure.crypto; + +import com.yau.digitalrmb.issuance.domain.model.DenominationItem; +import com.yau.digitalrmb.issuance.domain.service.IssuanceMessageComposer; +import com.yau.digitalrmb.issuance.domain.service.SignedIssuancePayload; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BouncyCastleIssuanceSignatureServiceTest { + + private static final String KEY_REF = "sm2-key-02"; + private static final String PLAIN_TEXT = + "ISSUE|BKCHCNBJ00001|ORG_3A4B5C6D7E8F|50000.00|100:400,50:100|DC|20260801103218"; + + private final IssuanceMessageComposer composer = new IssuanceMessageComposer(); + private final BouncyCastleIssuanceSignatureService service = + new BouncyCastleIssuanceSignatureService(new InMemorySm2SigningKeyProvider()); + + @Test + void composesPrototypeCompatiblePlainTextInDescendingDenominationOrder() { + assertThat(composer.compose( + "BKCHCNBJ00001", + "ORG_3A4B5C6D7E8F", + new BigDecimal("50000.00"), + Arrays.asList(new DenominationItem(50, 100), new DenominationItem(100, 400)), + "DC", + "20260801103218")) + .isEqualTo(PLAIN_TEXT); + } + + @Test + void signsWithSm3AndSm2UsingTheTeachingKey() { + SignedIssuancePayload payload = service.sign(KEY_REF, PLAIN_TEXT); + + assertThat(payload.getDigest()).matches("[0-9A-F]{64}"); + assertThat(payload.getSignature()).isNotBlank(); + assertThat(payload.getSigningKeyRef()).isEqualTo(KEY_REF); + assertThat(service.verify(KEY_REF, PLAIN_TEXT, payload)).isTrue(); + assertThat(service.verify(KEY_REF, PLAIN_TEXT + "0", payload)).isFalse(); + } + + @Test + void rejectsUnknownKeyReferences() { + assertThatThrownBy(() -> service.sign("sm2-key-unknown", PLAIN_TEXT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown SM2 key reference"); + } + + @Test + void rejectsUnknownKeyReferencesDuringVerification() { + SignedIssuancePayload payload = service.sign(KEY_REF, PLAIN_TEXT); + + assertThatThrownBy(() -> service.verify("sm2-key-unknown", PLAIN_TEXT, payload)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown SM2 key reference"); + } +}