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 { keypair.verify("test".toByteArray(), ByteArray(64)) } + } + + test("should throw for public key with wrong length") { + shouldThrow { KeyPair.fromPublicKey(ByteArray(31)) } + shouldThrow { KeyPair.fromPublicKey(ByteArray(33)) } + } } context("random") {