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");
+ }
+}