Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
77 changes: 61 additions & 16 deletions src/main/java/org/stellar/sdk/KeyPair.java
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -113,18 +134,19 @@ public static KeyPair fromAccountId(String accountId) {
/**
* Creates a new Stellar keypair from a 32 byte address.
*
* <p>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);
}

/**
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -303,22 +325,45 @@ 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;
if (object == null || getClass() != object.getClass()) {
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
Expand All @@ -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()));
}

Expand Down
30 changes: 30 additions & 0 deletions src/test/java/org/stellar/sdk/MuxedAccountTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
34 changes: 33 additions & 1 deletion src/test/kotlin/org/stellar/sdk/KeyPairTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ class KeyPairTest :
"GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUACUSI",
"GA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVSGZA",
"GAXDYNIBA5E4DXR5TJN522RRYESFQ5UNUXHIPTFGVLLD5O5K552DF5Z",
"GAH6H2XPCZS27WMKPTZJPTDN7JMBCDHTLU5WQP7TUI2ORA2M5FY5DHNU",
"masterpassphrasemasterpassphrase",
"gsYRSEQhTffqA9opPepAENCr2WG6z5iBHHubxxbRzWaHf8FBWcu",
)
Expand All @@ -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<IllegalStateException> { keypair.verify("test".toByteArray(), ByteArray(64)) }
}

test("should throw for public key with wrong length") {
shouldThrow<IllegalArgumentException> { KeyPair.fromPublicKey(ByteArray(31)) }
shouldThrow<IllegalArgumentException> { KeyPair.fromPublicKey(ByteArray(33)) }
}
}

context("random") {
Expand Down
Loading