diff --git a/CHANGELOG.md b/CHANGELOG.md index e07c2cd2f..d1d6ec109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Update: - feat: add `pollTransaction` method to `SorobanServer` to poll transaction status with retry strategy. ([#696](https://github.com/stellar/java-stellar-sdk/pull/696)) +- feat: implement message signing and verification according to [SEP-53](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md), check `KeyPair.signMessage` and `KeyPair.verifyMessage` for more details. ([#698](https://github.com/stellar/java-stellar-sdk/pull/698)) ## 1.5.0 diff --git a/src/main/java/org/stellar/sdk/KeyPair.java b/src/main/java/org/stellar/sdk/KeyPair.java index 66974fd28..02cf255a5 100644 --- a/src/main/java/org/stellar/sdk/KeyPair.java +++ b/src/main/java/org/stellar/sdk/KeyPair.java @@ -1,6 +1,7 @@ package org.stellar.sdk; import static java.lang.System.arraycopy; +import static java.nio.charset.StandardCharsets.UTF_8; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -354,4 +355,72 @@ public int hashCode() { Arrays.hashCode(publicKey.getEncoded()), privateKey == null ? null : Arrays.hashCode(privateKey.getEncoded())); } + + /** + * Calculate the hash of a message according to SEP-53. + * + * @param message The message to hash + * @return The SHA-256 hash of the prefixed message. + */ + private static byte[] calculateMessageHash(byte[] message) { + final byte[] messagePrefix = "Stellar Signed Message:\n".getBytes(UTF_8); + byte[] signedMessageBase = new byte[messagePrefix.length + message.length]; + System.arraycopy(messagePrefix, 0, signedMessageBase, 0, messagePrefix.length); + System.arraycopy(message, 0, signedMessageBase, messagePrefix.length, message.length); + return Util.hash(signedMessageBase); + } + + /** + * Sign a message according to SEP-53. + * + * @param message The message to sign. + * @return The signature bytes. + */ + public byte[] signMessage(String message) { + return signMessage(message.getBytes(UTF_8)); + } + + /** + * Sign a message according to SEP-53. + * + * @param message The message to sign. + * @return The signature bytes. + */ + public byte[] signMessage(byte[] message) { + byte[] messageHash = calculateMessageHash(message); + return sign(messageHash); + } + + /** + * Verify a SEP-53 signed message. + * + * @param message The original message. + * @param signature The signature to verify. + * @return True if the signature is valid for the given message, false otherwise. + */ + public boolean verifyMessage(byte[] message, byte[] signature) { + byte[] messageHash = calculateMessageHash(message); + return verify(messageHash, signature); + } + + /** + * Verify a SEP-53 signed message. + * + * @param message The original message. + * @param signature The signature to verify. + * @return True if the signature is valid for the given message, false otherwise. + */ + public boolean verifyMessage(String message, byte[] signature) { + return verifyMessage(message.getBytes(UTF_8), signature); + } } diff --git a/src/test/java/org/stellar/sdk/KeyPairTest.java b/src/test/java/org/stellar/sdk/KeyPairTest.java index 5ed64263d..70d5ab8b8 100644 --- a/src/test/java/org/stellar/sdk/KeyPairTest.java +++ b/src/test/java/org/stellar/sdk/KeyPairTest.java @@ -8,6 +8,7 @@ import static org.junit.Assert.fail; import java.util.Arrays; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -212,4 +213,48 @@ public void testFromSecretSeedThrowsWithBytes() { assertThrows( IllegalArgumentException.class, () -> KeyPair.fromSecretSeed(Util.hexToBytes("00"))); } + + @Test + public void testSignAndVerifyMessage() { + KeyPair kp = KeyPair.fromSecretSeed("SAKICEVQLYWGSOJS4WW7HZJWAHZVEEBS527LHK5V4MLJALYKICQCJXMW"); + + String inputEnglish = "Hello, World!"; + byte[] expectedEnglishSig = + Base64.getDecoder() + .decode( + "fO5dbYhXUhBMhe6kId/cuVq/AfEnHRHEvsP8vXh03M1uLpi5e46yO2Q8rEBzu3feXQewcQE5GArp88u6ePK6BA=="); + String inputJapanese = "こんにちは、世界!"; + byte[] expectedJapaneseSig = + Base64.getDecoder() + .decode( + "CDU265Xs8y3OWbB/56H9jPgUss5G9A0qFuTqH2zs2YDgTm+++dIfmAEceFqB7bhfN3am59lCtDXrCtwH2k1GBA=="); + byte[] inputBytes = Base64.getDecoder().decode("2zZDP1sa1BVBfLP7TeeMk3sUbaxAkUhBhDiNdrksaFo="); + byte[] expectedBytesSig = + Base64.getDecoder() + .decode( + "VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ=="); + + assertArrayEquals(expectedEnglishSig, kp.signMessage(inputEnglish)); + assertArrayEquals(expectedJapaneseSig, kp.signMessage(inputJapanese)); + assertArrayEquals(expectedBytesSig, kp.signMessage(inputBytes)); + + assertTrue(kp.verifyMessage(inputEnglish, expectedEnglishSig)); + assertTrue(kp.verifyMessage(inputJapanese, expectedJapaneseSig)); + assertTrue(kp.verifyMessage(inputBytes, expectedBytesSig)); + } + + @Test + public void testVerifyMessageWithInvalidSignature() { + KeyPair kp = KeyPair.fromAccountId("GBXFXNDLV4LSWA4VB7YIL5GBD7BVNR22SGBTDKMO2SBZZHDXSKZYCP7L"); + String message = "Hello, World!"; + String[] invalidSigs = + new String[] { + "VA1+7hefNwv2NKScH6n+Sljj15kLAge+M2wE7fzFOf+L0MMbssA1mwfJZRyyrhBORQRle10X1Dxpx+UOI4EbDQ==", + "MTI=" + }; + for (String sigB64 : invalidSigs) { + byte[] sig = Base64.getDecoder().decode(sigB64); + assertFalse(kp.verifyMessage(message, sig)); + } + } }