feat: add issuance sm3 sm2 teaching service
parent
e5e897ce7a
commit
c17cc29db2
@ -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<DenominationItem> denominations, String currency, String timestamp) {
|
||||||
|
requireNonNull(bankCode, "bankCode");
|
||||||
|
requireNonNull(organizationId, "organizationId");
|
||||||
|
requireNonNull(totalAmount, "totalAmount");
|
||||||
|
requireNonNull(denominations, "denominations");
|
||||||
|
requireNonNull(currency, "currency");
|
||||||
|
requireNonNull(timestamp, "timestamp");
|
||||||
|
|
||||||
|
List<DenominationItem> orderedDenominations = new ArrayList<DenominationItem>(denominations);
|
||||||
|
for (DenominationItem denomination : orderedDenominations) {
|
||||||
|
requireNonNull(denomination, "denomination item");
|
||||||
|
}
|
||||||
|
Collections.sort(orderedDenominations, new Comparator<DenominationItem>() {
|
||||||
|
@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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue