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