diff --git a/CHANGELOG.md b/CHANGELOG.md index e2cb0ef83..7cbefee1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Pending +- fix: `KeyPair.fromPublicKey` now accepts any 32-byte public key, even if it is not a valid Ed25519 public key point (e.g., all zeros like `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`). Such keypairs can still be used for address representation but will throw `IllegalStateException` when attempting to verify signatures. ## 2.2.0 diff --git a/src/main/java/org/stellar/sdk/KeyPair.java b/src/main/java/org/stellar/sdk/KeyPair.java index e6ecefa21..d9f8c8368 100644 --- a/src/main/java/org/stellar/sdk/KeyPair.java +++ b/src/main/java/org/stellar/sdk/KeyPair.java @@ -25,24 +25,45 @@ /** Holds a Stellar keypair. */ public class KeyPair { - @NonNull private final Ed25519PublicKeyParameters publicKey; + @NonNull private final byte[] publicKeyBytes; @Nullable private final Ed25519PrivateKeyParameters privateKey; + /** + * Cached Ed25519PublicKeyParameters for signature verification. Lazily initialized because some + * valid Stellar addresses (like GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF which is + * 32 zero bytes) are not valid Ed25519 public keys. + */ + @Nullable private Ed25519PublicKeyParameters ed25519PublicKey; + static { Security.addProvider(new BouncyCastleProvider()); } /** - * Creates a new KeyPair from the given public and private keys. + * Creates a new KeyPair from the given public key bytes and optional private key. + * + * @param publicKeyBytes The 32-byte public key for this KeyPair. + * @param privateKey The private key for this KeyPair or null if you want a public key only + */ + private KeyPair( + @NonNull byte[] publicKeyBytes, @Nullable Ed25519PrivateKeyParameters privateKey) { + this.publicKeyBytes = publicKeyBytes; + this.privateKey = privateKey; + this.ed25519PublicKey = null; + } + + /** + * Creates a new KeyPair from an Ed25519 public key and optional private key. * - * @param publicKey The public key for this KeyPair. + * @param ed25519PublicKey The Ed25519 public key for this KeyPair. * @param privateKey The private key for this KeyPair or null if you want a public key only */ private KeyPair( - @NonNull Ed25519PublicKeyParameters publicKey, + @NonNull Ed25519PublicKeyParameters ed25519PublicKey, @Nullable Ed25519PrivateKeyParameters privateKey) { - this.publicKey = publicKey; + this.publicKeyBytes = ed25519PublicKey.getEncoded(); this.privateKey = privateKey; + this.ed25519PublicKey = ed25519PublicKey; } /** Returns true if this Keypair is capable of signing */ @@ -113,18 +134,19 @@ public static KeyPair fromAccountId(String accountId) { /** * Creates a new Stellar keypair from a 32 byte address. * + *
Note: This method accepts any 32-byte array as a public key, even if it is not a valid
+ * Ed25519 public key point (e.g., all zeros). Such keypairs can still be used for address
+ * representation but will throw an exception when attempting to verify signatures.
+ *
* @param publicKey The 32 byte public key.
* @return {@link KeyPair}
- * @throws IllegalArgumentException if the provided public key is invalid
+ * @throws IllegalArgumentException if the provided public key is not 32 bytes
*/
public static KeyPair fromPublicKey(byte[] publicKey) {
- Ed25519PublicKeyParameters ed25519PublicKeyParameters;
- try {
- ed25519PublicKeyParameters = new Ed25519PublicKeyParameters(publicKey, 0);
- } catch (Exception e) {
- throw new IllegalArgumentException("Public key is invalid", e);
+ if (publicKey.length != 32) {
+ throw new IllegalArgumentException("Public key must be 32 bytes");
}
- return new KeyPair(ed25519PublicKeyParameters, null);
+ return new KeyPair(Arrays.copyOf(publicKey, 32), null);
}
/**
@@ -183,7 +205,7 @@ public char[] getSecretSeed() {
/** Returns the raw 32 byte public key. */
public byte[] getPublicKey() {
- return publicKey.getEncoded();
+ return Arrays.copyOf(publicKeyBytes, 32);
}
/** Returns the signature hint for this keypair. */
@@ -303,14 +325,37 @@ public DecoratedSignature signPayloadDecorated(byte[] signerPayload) {
* @param data The data that was signed.
* @param signature The signature.
* @return True if they match, false otherwise.
+ * @throws IllegalStateException if the public key is not a valid Ed25519 public key (e.g., all
+ * zeros)
*/
public boolean verify(byte[] data, byte[] signature) {
+ Ed25519PublicKeyParameters ed25519Key = getEd25519PublicKey();
Ed25519Signer verifier = new Ed25519Signer();
- verifier.init(false, publicKey);
+ verifier.init(false, ed25519Key);
verifier.update(data, 0, data.length);
return verifier.verifySignature(signature);
}
+ /**
+ * Gets the Ed25519PublicKeyParameters, lazily initializing it if necessary.
+ *
+ * @return The Ed25519PublicKeyParameters for this keypair.
+ * @throws IllegalStateException if the public key bytes do not represent a valid Ed25519 public
+ * key
+ */
+ private Ed25519PublicKeyParameters getEd25519PublicKey() {
+ if (ed25519PublicKey == null) {
+ try {
+ ed25519PublicKey = new Ed25519PublicKeyParameters(publicKeyBytes, 0);
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ "Public key is not a valid Ed25519 public key. This can happen for special addresses like GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF (32 zero bytes).",
+ e);
+ }
+ }
+ return ed25519PublicKey;
+ }
+
@Override
public boolean equals(Object object) {
if (this == object) return true;
@@ -318,7 +363,7 @@ public boolean equals(Object object) {
return false;
}
KeyPair keyPair = (KeyPair) object;
- if (!Arrays.equals(publicKey.getEncoded(), keyPair.publicKey.getEncoded())) {
+ if (!Arrays.equals(publicKeyBytes, keyPair.publicKeyBytes)) {
return false;
}
// privateKey can be null
@@ -330,7 +375,7 @@ public boolean equals(Object object) {
@Override
public int hashCode() {
return Objects.hash(
- Arrays.hashCode(publicKey.getEncoded()),
+ Arrays.hashCode(publicKeyBytes),
privateKey == null ? null : Arrays.hashCode(privateKey.getEncoded()));
}
diff --git a/src/test/java/org/stellar/sdk/MuxedAccountTest.java b/src/test/java/org/stellar/sdk/MuxedAccountTest.java
index 53cf9a23b..b8bac575d 100644
--- a/src/test/java/org/stellar/sdk/MuxedAccountTest.java
+++ b/src/test/java/org/stellar/sdk/MuxedAccountTest.java
@@ -90,4 +90,34 @@ public void testFromAccountInvalidAccountRaise() {
assertThrows(IllegalArgumentException.class, () -> new MuxedAccount(invalidAccount));
}
}
+
+ @Test
+ public void testZeroPublicKeyAccount() {
+ // GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF is a valid Stellar address
+ // that corresponds to 32 zero bytes. While not a valid Ed25519 point, it should still
+ // be usable for address representation purposes.
+ String zeroAccountId = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+
+ // Test creating MuxedAccount without muxed ID
+ MuxedAccount muxedAccount1 = new MuxedAccount(zeroAccountId, null);
+ assertEquals(zeroAccountId, muxedAccount1.getAccountId());
+ assertNull(muxedAccount1.getMuxedId());
+ assertEquals(zeroAccountId, muxedAccount1.getAddress());
+
+ // Test creating MuxedAccount from address string
+ MuxedAccount muxedAccount2 = new MuxedAccount(zeroAccountId);
+ assertEquals(zeroAccountId, muxedAccount2.getAccountId());
+ assertNull(muxedAccount2.getMuxedId());
+
+ // Test creating MuxedAccount with muxed ID
+ BigInteger muxedId = BigInteger.valueOf(12345);
+ MuxedAccount muxedAccount3 = new MuxedAccount(zeroAccountId, muxedId);
+ assertEquals(zeroAccountId, muxedAccount3.getAccountId());
+ assertEquals(muxedId, muxedAccount3.getMuxedId());
+
+ // Test XDR round-trip
+ org.stellar.sdk.xdr.MuxedAccount xdr = muxedAccount3.toXdr();
+ MuxedAccount fromXdr = MuxedAccount.fromXdr(xdr);
+ assertEquals(muxedAccount3, fromXdr);
+ }
}
diff --git a/src/test/kotlin/org/stellar/sdk/KeyPairTest.kt b/src/test/kotlin/org/stellar/sdk/KeyPairTest.kt
index f7cc3570d..71c8aeead 100644
--- a/src/test/kotlin/org/stellar/sdk/KeyPairTest.kt
+++ b/src/test/kotlin/org/stellar/sdk/KeyPairTest.kt
@@ -75,7 +75,6 @@ class KeyPairTest :
"GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUACUSI",
"GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZA",
"GAXDYNIBA5E4DXR5TJN522RRYESFQ5UNUXHIPTFGVLLD5O5K552DF5Z",
- "GAH6H2XPCZS27WMKPTZJPTDN7JMBCDHTLU5WQP7TUI2ORA2M5FY5DHNU",
"masterpassphrasemasterpassphrase",
"gsYRSEQhTffqA9opPepAENCr2WG6z5iBHHubxxbRzWaHf8FBWcu",
)
@@ -102,6 +101,39 @@ class KeyPairTest :
keypair.canSign() shouldBe false
keypair.accountId shouldBe mainAccount
}
+
+ test("should create keypair from all-zero public key bytes") {
+ // GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF is a valid Stellar address
+ // that corresponds to 32 zero bytes. While not a valid Ed25519 point, it should still
+ // be creatable for address representation purposes.
+ val zeroPublicKey = ByteArray(32)
+ val keypair = KeyPair.fromPublicKey(zeroPublicKey)
+
+ keypair.canSign() shouldBe false
+ keypair.accountId shouldBe "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"
+ keypair.publicKey shouldBe zeroPublicKey
+ }
+
+ test("should create keypair from account ID with invalid Ed25519 point") {
+ // This address has a valid checksum but the public key is not a valid Ed25519 point
+ val keypair =
+ KeyPair.fromAccountId("GAH6H2XPCZS27WMKPTZJPTDN7JMBCDHTLU5WQP7TUI2ORA2M5FY5DHNU")
+
+ keypair.canSign() shouldBe false
+ keypair.accountId shouldBe "GAH6H2XPCZS27WMKPTZJPTDN7JMBCDHTLU5WQP7TUI2ORA2M5FY5DHNU"
+ }
+
+ test("should throw when verifying signature with invalid Ed25519 public key") {
+ val zeroPublicKey = ByteArray(32)
+ val keypair = KeyPair.fromPublicKey(zeroPublicKey)
+
+ shouldThrow