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