Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for SEP-0010. #264

Merged
14 changes: 14 additions & 0 deletions src/main/java/org/stellar/sdk/InvalidSep10ChallengeException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package org.stellar.sdk;

/**
* If the SEP-0010 validation fails, the exception will be thrown.
*/
public class InvalidSep10ChallengeException extends Exception {
public InvalidSep10ChallengeException() {
super();
}

public InvalidSep10ChallengeException(String message) {
super(message);
}
}
336 changes: 336 additions & 0 deletions src/main/java/org/stellar/sdk/Sep10Challenge.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package org.stellar.sdk;

import com.google.common.base.Objects;
import com.google.common.io.BaseEncoding;
import org.stellar.sdk.xdr.DecoratedSignature;

import java.io.IOException;
import java.security.SecureRandom;
import java.util.*;

public class Sep10Challenge {
/**
Expand Down Expand Up @@ -40,4 +44,336 @@ public static String newChallenge(

return transaction.toEnvelopeXdrBase64();
}

/**
* Reads a SEP 10 challenge transaction and returns the decoded transaction envelope and client account ID contained within.
* <p>
* It also verifies that transaction is signed by the server.
* <p>
* It does not verify that the transaction has been signed by the client or
* that any signatures other than the servers on the transaction are valid. Use
* one of the following functions to completely verify the transaction:
* {@link Sep10Challenge#verifyChallengeTransactionSigners(String, String, Network, Set)} or
* {@link Sep10Challenge#verifyChallengeTransactionThreshold(String, String, Network, int, Set)}
*
* @param challengeXdr SEP-0010 transaction challenge transaction in base64.
* @param serverAccountId Account ID for server's account.
* @param network The network to connect to for verifying and retrieving.
* @return {@link ChallengeTransaction}, the decoded transaction envelope and client account ID contained within.
* @throws InvalidSep10ChallengeException If the SEP-0010 validation fails, the exception will be thrown.
* @throws IOException If read XDR string fails, the exception will be thrown.
*/
public static ChallengeTransaction readChallengeTransaction(String challengeXdr, String serverAccountId, Network network) throws InvalidSep10ChallengeException, IOException {
// decode the received input as a base64-urlencoded XDR representation of Stellar transaction envelope
Transaction transaction = Transaction.fromEnvelopeXdr(challengeXdr, network);

// verify that transaction source account is equal to the server's signing key
if (!serverAccountId.equals(transaction.getSourceAccount())) {
throw new InvalidSep10ChallengeException("Transaction source account is not equal to server's account.");
}

// verify that transaction sequenceNumber is equal to zero
if (transaction.getSequenceNumber() != 0L) {
throw new InvalidSep10ChallengeException("The transaction sequence number should be zero.");
}

// verify that transaction has time bounds set, and that current time is between the minimum and maximum bounds.
if (transaction.getTimeBounds() == null) {
throw new InvalidSep10ChallengeException("Transaction requires timebounds.");
}

long maxTime = transaction.getTimeBounds().getMaxTime();
long minTime = transaction.getTimeBounds().getMinTime();
if (maxTime == 0L) {
throw new InvalidSep10ChallengeException("Transaction requires non-infinite timebounds.");
}

long currentTime = System.currentTimeMillis() / 1000L;
if (currentTime < minTime || currentTime > maxTime) {
throw new InvalidSep10ChallengeException("Transaction is not within range of the specified timebounds.");
}

// verify that transaction contains a single Manage Data operation and its source account is not null
if (transaction.getOperations().length != 1) {
throw new InvalidSep10ChallengeException("Transaction requires a single ManageData operation.");
}
Operation operation = transaction.getOperations()[0];
if (!(operation instanceof ManageDataOperation)) {
throw new InvalidSep10ChallengeException("Operation type should be ManageData.");
}
ManageDataOperation manageDataOperation = (ManageDataOperation) operation;

// verify that transaction envelope has a correct signature by server's signing key
String clientAccountId = manageDataOperation.getSourceAccount();
if (clientAccountId == null) {
throw new InvalidSep10ChallengeException("Operation should have a source account.");
}

// verify manage data value
if (manageDataOperation.getValue().length != 64) {
throw new InvalidSep10ChallengeException("Random nonce encoded as base64 should be 64 bytes long.");
}
overcat marked this conversation as resolved.
Show resolved Hide resolved

BaseEncoding base64Encoding = BaseEncoding.base64();
byte[] nonce = base64Encoding.decode(new String(manageDataOperation.getValue()));
if (nonce.length != 48) {
throw new InvalidSep10ChallengeException("Random nonce before encoding as base64 should be 48 bytes long.");
}
overcat marked this conversation as resolved.
Show resolved Hide resolved

if (!verifyTransactionSignature(transaction, serverAccountId)) {
throw new InvalidSep10ChallengeException(String.format("Transaction not signed by server: %s.", serverAccountId));
}

return new ChallengeTransaction(transaction, clientAccountId);
}

/**
* Verifies that for a SEP 10 challenge transaction
* all signatures on the transaction are accounted for. A transaction is
* verified if it is signed by the server account, and all other signatures
* match a signer that has been provided as an argument. Additional signers can
* be provided that do not have a signature, but all signatures must be matched
* to a signer for verification to succeed. If verification succeeds a list of
* signers that were found is returned, excluding the server account ID.
*
* @param challengeXdr SEP-0010 transaction challenge transaction in base64.
* @param serverAccountId Account ID for server's account.
* @param network The network to connect to for verifying and retrieving.
* @param signers The signers of client account.
* @return a list of signers that were found is returned, excluding the server account ID.
* @throws InvalidSep10ChallengeException If the SEP-0010 validation fails, the exception will be thrown.
* @throws IOException If read XDR string fails, the exception will be thrown.
*/
public static LinkedHashSet<String> verifyChallengeTransactionSigners(String challengeXdr, String serverAccountId, Network network, Set<String> signers) throws InvalidSep10ChallengeException, IOException {
if (signers == null || signers.isEmpty()) {
throw new InvalidSep10ChallengeException("No signers provided.");
}

// Read the transaction which validates its structure.
ChallengeTransaction parsedChallengeTransaction = readChallengeTransaction(challengeXdr, serverAccountId, network);
Transaction transaction = parsedChallengeTransaction.getTransaction();

// Ensure the server account ID is an address and not a seed.
KeyPair serverKeyPair = KeyPair.fromAccountId(serverAccountId);

// Deduplicate the client signers and ensure the server is not included
// anywhere we check or output the list of signers.
Set<String> clientSigners = new HashSet<String>();
for (String signer : signers) {
// Ignore non-G... account/address signers.
StrKey.VersionByte versionByte;
try {
versionByte = StrKey.decodedVersionByte(signer);
} catch (Exception e) {
continue;
}

if (!StrKey.VersionByte.ACCOUNT_ID.equals(versionByte)) {
continue;
}

// Ignore the server signer if it is in the signers list. It's
// important when verifying signers of a challenge transaction that we
// only verify and return client signers. If an account has the server
// as a signer the server should not play a part in the authentication
// of the client.
if (serverKeyPair.getAccountId().equals(signer)) {
continue;
}
clientSigners.add(signer);
}

// Don't continue if none of the signers provided are in the final list.
if (clientSigners.isEmpty()) {
throw new InvalidSep10ChallengeException("No verifiable signers provided, at least one G... address must be provided.");
}

// Verify all the transaction's signers (server and client) in one
// hit. We do this in one hit here even though the server signature was
// checked in the readChallengeTx to ensure that every signature and signer
// are consumed only once on the transaction.
Set<String> allSigners = new HashSet<String>(clientSigners);
allSigners.add(serverKeyPair.getAccountId());
LinkedHashSet<String> allSignersFound = verifyTransactionSignatures(transaction, allSigners);

// Confirm the server is in the list of signers found and remove it.
boolean serverSignerFound = allSignersFound.remove(serverKeyPair.getAccountId());
// After removing the server signer we call it clientSignersFound
LinkedHashSet<String> clientSignersFound = allSignersFound;
overcat marked this conversation as resolved.
Show resolved Hide resolved
// Confirm we matched a signature to the server signer.
if (!serverSignerFound) {
throw new InvalidSep10ChallengeException(String.format("Transaction not signed by server: %s.", serverAccountId));
}

overcat marked this conversation as resolved.
Show resolved Hide resolved
// Confirm we matched signatures to the client signers.
if (clientSignersFound.isEmpty()) {
throw new InvalidSep10ChallengeException("Transaction not signed by any client signer.");
}

// Confirm all signatures were consumed by a signer.
if (clientSignersFound.size() != transaction.getSignatures().size() - 1) {
throw new InvalidSep10ChallengeException("Transaction has unrecognized signatures.");
}

return clientSignersFound;
}

/**
* Verifies that for a SEP-0010 challenge transaction
* all signatures on the transaction are accounted for and that the signatures
* meet a threshold on an account. A transaction is verified if it is signed by
* the server account, and all other signatures match a signer that has been
* provided as an argument, and those signatures meet a threshold on the
* account.
*
* @param challengeXdr SEP-0010 transaction challenge transaction in base64.
* @param serverAccountId Account ID for server's account.
* @param network The network to connect to for verifying and retrieving.
* @param threshold The threshold on the client account.
* @param signers The signers of client account.
* @return a list of signers that were found is returned, excluding the server account ID.
* @throws InvalidSep10ChallengeException If the SEP-0010 validation fails, the exception will be thrown.
* @throws IOException If read XDR string fails, the exception will be thrown.
*/
public static LinkedHashSet<String> verifyChallengeTransactionThreshold(String challengeXdr, String serverAccountId, Network network, int threshold, Set<Signer> signers) throws InvalidSep10ChallengeException, IOException {
overcat marked this conversation as resolved.
Show resolved Hide resolved
if (signers == null || signers.isEmpty()) {
throw new InvalidSep10ChallengeException("No signers provided.");
}

Map<String, Signer> accountIdSignerMap = new HashMap<String, Signer>();
overcat marked this conversation as resolved.
Show resolved Hide resolved
for (Signer signer : signers) {
accountIdSignerMap.put(signer.getKey(), signer);
}

LinkedHashSet<String> signersFound = verifyChallengeTransactionSigners(challengeXdr, serverAccountId, network, accountIdSignerMap.keySet());

int weight = 0;
for (String signer : signersFound) {
if (!accountIdSignerMap.containsKey(signer)) {
continue;
}
weight += accountIdSignerMap.get(signer).getWeight();
}

if (weight < threshold) {
throw new InvalidSep10ChallengeException(String.format("Signers with weight %d do not meet threshold %d.", weight, threshold));
}

return signersFound;
}

private static LinkedHashSet<String> verifyTransactionSignatures(Transaction transaction, Set<String> signers) throws InvalidSep10ChallengeException {
List<DecoratedSignature> signatures = transaction.getSignatures();
if (signatures.isEmpty()) {
throw new InvalidSep10ChallengeException("Transaction has no signatures.");
}

byte[] txHash = transaction.hash();

// find and verify signatures
overcat marked this conversation as resolved.
Show resolved Hide resolved
Set<DecoratedSignature> signatureUsed = new HashSet<DecoratedSignature>();
LinkedHashSet<String> signersFound = new LinkedHashSet<String>();
for (String signer : signers) {
KeyPair keyPair = KeyPair.fromAccountId(signer);
for (DecoratedSignature decoratedSignature : transaction.getSignatures()) {
// prevent a signature from being reused
if (signatureUsed.contains(decoratedSignature)) {
continue;
}

if (Arrays.equals(decoratedSignature.getHint().getSignatureHint(), keyPair.getSignatureHint().getSignatureHint()) && keyPair.verify(txHash, decoratedSignature.getSignature().getSignature())) {
signersFound.add(signer);
signatureUsed.add(decoratedSignature);
break;
}
}
}
overcat marked this conversation as resolved.
Show resolved Hide resolved
return signersFound;
}

private static boolean verifyTransactionSignature(Transaction transaction, String accountId) throws InvalidSep10ChallengeException {
return !verifyTransactionSignatures(transaction, Collections.singleton(accountId)).isEmpty();
}

/**
* Used to store the results produced by {@link Sep10Challenge#readChallengeTransaction(String, String, Network)}.
*/
public static class ChallengeTransaction {
private final Transaction transaction;
private final String clientAccountId;

public ChallengeTransaction(Transaction transaction, String clientAccountId) {
this.transaction = transaction;
this.clientAccountId = clientAccountId;
}

public Transaction getTransaction() {
return transaction;
}

public String getClientAccountId() {
return clientAccountId;
}

@Override
public int hashCode() {
return Objects.hashCode(this.transaction, this.clientAccountId);
}

@Override
public boolean equals(Object object) {
if (object == this) {
return true;
}

if (!(object instanceof ChallengeTransaction)) {
return false;
}

ChallengeTransaction other = (ChallengeTransaction) object;
return Objects.equal(this.transaction, other.transaction) &&
Objects.equal(this.clientAccountId, other.clientAccountId);
}
}

/**
* Represents a transaction signer.
*/
public static class Signer {
overcat marked this conversation as resolved.
Show resolved Hide resolved
private final String key;
private final int weight;

public Signer(String key, int weight) {
this.key = key;
this.weight = weight;
}

public String getKey() {
return key;
}

public int getWeight() {
return weight;
}

@Override
public int hashCode() {
return Objects.hashCode(this.key, this.weight);
}

@Override
public boolean equals(Object object) {
if (object == this) {
return true;
}

if (!(object instanceof Signer)) {
return false;
}

Signer other = (Signer) object;
return Objects.equal(this.key, other.key) &&
Objects.equal(this.weight, other.weight);
}
leighmcculloch marked this conversation as resolved.
Show resolved Hide resolved
}
}
15 changes: 15 additions & 0 deletions src/main/java/org/stellar/sdk/StrKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@ public enum VersionByte {
public int getValue() {
return value;
}

public static VersionByte findByValue(byte value) {
for (VersionByte versionByte : values()) {
if (value == versionByte.value) {
return versionByte;
}
}
throw new IllegalArgumentException("No matching versionByte found.");
overcat marked this conversation as resolved.
Show resolved Hide resolved
}
}

private static BaseEncoding base32Encoding = BaseEncoding.base32().upperCase().omitPadding();
Expand All @@ -42,6 +51,12 @@ public static AccountID encodeToXDRAccountId(String data) {
return accountID;
}

public static VersionByte decodedVersionByte(String data) {
byte[] decoded = StrKey.base32Encoding.decode(java.nio.CharBuffer.wrap(data.toCharArray()));
byte decodedVersionByte = decoded[0];
return VersionByte.findByValue(decodedVersionByte);
overcat marked this conversation as resolved.
Show resolved Hide resolved
}

public static byte[] decodeStellarAccountId(String data) {
return decodeCheck(VersionByte.ACCOUNT_ID, data.toCharArray());
}
Expand Down
Loading