diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ff40e82..78b42a7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Update: - feat: add `AUTH_CLAWBACK_ENABLED_FLAG` to `AccountFlag`. - fix: remove `diagnosticEventsXdr` from `org.stellar.sdk.responses.sorobanrpc.Events`. Use `GetTransactionResponse.diagnosticEventsXdr` and `GetTransactionsResponse.Transaction.diagnosticEventsXdr` instead. ([#744](https://github.com/stellar/java-stellar-sdk/pull/744)) +- feat: add [SEP-45](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0045.md) (Stellar Web Authentication for Contract Accounts) support. Check `Sep45Challenge` class for more details. ([#746](https://github.com/stellar/java-stellar-sdk/pull/746)) ## 2.1.0 diff --git a/src/main/java/org/stellar/sdk/Sep45Challenge.java b/src/main/java/org/stellar/sdk/Sep45Challenge.java new file mode 100644 index 000000000..30437e5ba --- /dev/null +++ b/src/main/java/org/stellar/sdk/Sep45Challenge.java @@ -0,0 +1,678 @@ +package org.stellar.sdk; + +import java.io.IOException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; +import org.jetbrains.annotations.Nullable; +import org.stellar.sdk.exception.InvalidSep45ChallengeException; +import org.stellar.sdk.operations.InvokeHostFunctionOperation; +import org.stellar.sdk.responses.sorobanrpc.SimulateTransactionResponse; +import org.stellar.sdk.scval.Scv; +import org.stellar.sdk.xdr.HostFunction; +import org.stellar.sdk.xdr.HostFunctionType; +import org.stellar.sdk.xdr.InvokeContractArgs; +import org.stellar.sdk.xdr.SCVal; +import org.stellar.sdk.xdr.SCValType; +import org.stellar.sdk.xdr.SorobanAddressCredentials; +import org.stellar.sdk.xdr.SorobanAuthorizationEntries; +import org.stellar.sdk.xdr.SorobanAuthorizationEntry; +import org.stellar.sdk.xdr.SorobanAuthorizedFunctionType; +import org.stellar.sdk.xdr.SorobanAuthorizedInvocation; +import org.stellar.sdk.xdr.SorobanCredentials; +import org.stellar.sdk.xdr.SorobanCredentialsType; + +/** + * Stellar Web Authentication Utilities for Contract Accounts (SEP-0045). + * + *

This class provides utilities for building, reading, and verifying SEP-45 challenge + * authorization entries for contract account authentication on the Stellar network. + * + * @see SEP-0045 + */ +public class Sep45Challenge { + /** The expected function name for SEP-45 web authentication. */ + public static final String WEB_AUTH_VERIFY_FUNCTION_NAME = "web_auth_verify"; + + /** + * A null account used for simulation purposes. This is the account + * "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF". + */ + public static final String NULL_ACCOUNT = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + + private Sep45Challenge() { + // no instance + } + + /** Default number of ledgers until authorization expires (~15 minutes at 5 seconds/ledger). */ + public static final long DEFAULT_EXPIRE_IN_LEDGERS = 180; + + /** + * Builds a SEP-45 challenge authorization entries for a client contract account. + * + *

This method creates challenge authorization entries that can be used to authenticate a + * contract account. + * + * @param server The Soroban RPC server to use for simulating the transaction. + * @param serverSigner The server's signing keypair. + * @param clientContractId The client's contract account ID (C... address). + * @param homeDomain The home domain of the service requiring authentication. + * @param webAuthDomain The domain of the web authentication service. + * @param webAuthContractId The contract ID for the web authentication contract. + * @param network The Stellar network. + * @param nonce Optional nonce value. If null, a random 48-byte value will be generated and + * base64-encoded. + * @param expireInLedgers Number of ledgers from current ledger until authorization expires. If + * null, defaults to {@link #DEFAULT_EXPIRE_IN_LEDGERS} (~15 minutes). + * @return A SorobanAuthorizationEntries containing the authentication entries. + * @throws InvalidSep45ChallengeException If building the challenge fails. + */ + public static SorobanAuthorizationEntries buildChallengeAuthorizationEntries( + @NonNull SorobanServer server, + @NonNull KeyPair serverSigner, + @NonNull String clientContractId, + @NonNull String homeDomain, + @NonNull String webAuthDomain, + @NonNull String webAuthContractId, + @NonNull Network network, + @Nullable String nonce, + @Nullable Long expireInLedgers) { + return buildChallengeAuthorizationEntries( + server, + serverSigner, + clientContractId, + homeDomain, + webAuthDomain, + webAuthContractId, + network, + nonce, + expireInLedgers, + null, + null); + } + + /** + * Builds a SEP-45 challenge authorization entries for a client contract account with optional + * client domain verification. + * + *

This method creates challenge authorization entries that can be used to authenticate a + * contract account, optionally including client domain verification. + * + * @param server The Soroban RPC server to use for simulating the transaction. + * @param serverSigner The server's signing keypair. + * @param clientContractId The client's contract account ID (C... address). + * @param homeDomain The home domain of the service requiring authentication. + * @param webAuthDomain The domain of the web authentication service. + * @param webAuthContractId The contract ID for the web authentication contract. + * @param network The Stellar network. + * @param nonce Optional nonce value. If null, a random 48-byte value will be generated and + * base64-encoded. + * @param expireInLedgers Number of ledgers from current ledger until authorization expires. If + * null, defaults to {@link #DEFAULT_EXPIRE_IN_LEDGERS} (~15 minutes). + * @param clientDomain Optional client domain for client domain verification. + * @param clientDomainAccountId Optional client domain account ID (G... address) for verification. + * @return A SorobanAuthorizationEntries containing the authentication entries. + * @throws InvalidSep45ChallengeException If building the challenge fails. + */ + public static SorobanAuthorizationEntries buildChallengeAuthorizationEntries( + @NonNull SorobanServer server, + @NonNull KeyPair serverSigner, + @NonNull String clientContractId, + @NonNull String homeDomain, + @NonNull String webAuthDomain, + @NonNull String webAuthContractId, + @NonNull Network network, + @Nullable String nonce, + @Nullable Long expireInLedgers, + @Nullable String clientDomain, + @Nullable String clientDomainAccountId) { + + // Use default expiration if not specified + if (expireInLedgers == null) { + expireInLedgers = DEFAULT_EXPIRE_IN_LEDGERS; + } + + // Validate client contract ID + if (!StrKey.isValidContract(clientContractId)) { + throw new InvalidSep45ChallengeException( + "clientContractId: " + clientContractId + " is not a valid contract id"); + } + + // Validate web auth contract ID + if (!StrKey.isValidContract(webAuthContractId)) { + throw new InvalidSep45ChallengeException( + "webAuthContractId: " + webAuthContractId + " is not a valid contract id"); + } + + // Validate client domain and client domain account ID consistency + if ((clientDomain == null) != (clientDomainAccountId == null)) { + throw new InvalidSep45ChallengeException( + "clientDomain and clientDomainAccountId must both be provided or both be null"); + } + + // Generate nonce if not provided (48 random bytes, base64-encoded) + if (nonce == null) { + byte[] nonceBytes = new byte[48]; + new SecureRandom().nextBytes(nonceBytes); + nonce = Base64Factory.getInstance().encodeToString(nonceBytes); + } + + // Build the function arguments as a Map + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(clientContractId)); + if (clientDomain != null) { + argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(clientDomain)); + argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(clientDomainAccountId)); + } + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(homeDomain)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(nonce)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(webAuthDomain)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(serverSigner.getAccountId())); + + // Build the invocation with a single Map argument + InvokeHostFunctionOperation operation = + InvokeHostFunctionOperation.invokeContractFunctionOperationBuilder( + webAuthContractId, + WEB_AUTH_VERIFY_FUNCTION_NAME, + Collections.singletonList(Scv.toMap(argsMap))) + .build(); + + // Create a transaction for simulation + Account nullAccount = new Account(NULL_ACCOUNT, 0L); + Transaction transaction = + new TransactionBuilder(nullAccount, network) + .setBaseFee(100) + .addOperation(operation) + .addPreconditions( + TransactionPreconditions.builder().timeBounds(new TimeBounds(0, 0)).build()) + .build(); + + // Simulate the transaction + SimulateTransactionResponse simulateResponse = server.simulateTransaction(transaction); + + if (simulateResponse.getError() != null) { + throw new InvalidSep45ChallengeException( + "Transaction simulation failed: " + simulateResponse.getError()); + } + + if (simulateResponse.getResults() == null || simulateResponse.getResults().isEmpty()) { + throw new InvalidSep45ChallengeException("Transaction simulation did not return any results"); + } + + SimulateTransactionResponse.SimulateHostFunctionResult result = + simulateResponse.getResults().get(0); + + if (result.getAuth() == null || result.getAuth().isEmpty()) { + throw new InvalidSep45ChallengeException( + "Transaction simulation did not return any authorization entries"); + } + + // Calculate the absolute signature expiration ledger + long signatureExpirationLedger = simulateResponse.getLatestLedger() + expireInLedgers; + + // Parse and sign the authorization entries + List signedEntries = new ArrayList<>(); + for (String authXdr : result.getAuth()) { + SorobanAuthorizationEntry entry; + try { + entry = SorobanAuthorizationEntry.fromXdrBase64(authXdr); + } catch (IOException e) { + throw new InvalidSep45ChallengeException( + "Failed to parse authorization entry: " + authXdr, e); + } + + // Check if this entry needs to be signed by the server + if (entry.getCredentials().getDiscriminant() + == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) { + Address address = Address.fromSCAddress(entry.getCredentials().getAddress().getAddress()); + if (address.getAddressType() == Address.AddressType.ACCOUNT + && address.getEncodedAddress().equals(serverSigner.getAccountId())) { + // Sign this entry with the server signer + entry = Auth.authorizeEntry(entry, serverSigner, signatureExpirationLedger, network); + } + } + + signedEntries.add(entry); + } + + return new SorobanAuthorizationEntries(signedEntries.toArray(new SorobanAuthorizationEntry[0])); + } + + /** + * Reads and validates a SEP-45 challenge authorization entries without verifying signatures. + * + *

This method decodes the authorization entries, validates their structure, and extracts the + * challenge data. It does not verify the signatures; use {@link + * #verifyChallengeAuthorizationEntries} for full verification. + * + * @param authorizationEntriesXdr The base64 XDR-encoded authorization entries. + * @param serverAccountId The expected server account ID (G... address). + * @param webAuthContractId The expected web authentication contract ID (C... address). + * @param homeDomains A list of acceptable home domains. + * @param webAuthDomain The expected web auth domain. + * @return A {@link ChallengeAuthorizationEntries} object containing the parsed challenge data. + * @throws InvalidSep45ChallengeException If the challenge is invalid. + */ + public static ChallengeAuthorizationEntries readChallengeAuthorizationEntries( + @NonNull String authorizationEntriesXdr, + @NonNull String serverAccountId, + @NonNull String webAuthContractId, + @NonNull String[] homeDomains, + @NonNull String webAuthDomain) { + + if (homeDomains.length == 0) { + throw new InvalidSep45ChallengeException( + "At least one domain name must be included in homeDomains."); + } + + // Validate server account ID + if (!StrKey.isValidEd25519PublicKey(serverAccountId)) { + throw new InvalidSep45ChallengeException( + "serverAccountId: " + serverAccountId + " is not a valid account id"); + } + + // Validate web auth contract ID + if (!StrKey.isValidContract(webAuthContractId)) { + throw new InvalidSep45ChallengeException( + "webAuthContractId: " + webAuthContractId + " is not a valid contract id"); + } + + // Decode the authorization entries + List entries; + try { + org.stellar.sdk.xdr.SorobanAuthorizationEntries xdrEntries = + org.stellar.sdk.xdr.SorobanAuthorizationEntries.fromXdrBase64(authorizationEntriesXdr); + entries = Arrays.asList(xdrEntries.getSorobanAuthorizationEntries()); + } catch (IOException e) { + throw new InvalidSep45ChallengeException("Failed to parse authorization entries XDR", e); + } + + // Validate we have at least 2 entries (server + client) + if (entries.size() < 2) { + throw new InvalidSep45ChallengeException( + "Authorization entries must contain at least 2 entries (server and client)"); + } + + // Get the reference invocation from the first entry + SorobanAuthorizedInvocation referenceInvocation = entries.get(0).getRootInvocation(); + + // Validate and extract the contract call information + if (referenceInvocation.getFunction().getDiscriminant() + != SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) { + throw new InvalidSep45ChallengeException( + "Authorization entry function type must be SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN"); + } + + // Validate no sub-invocations + if (referenceInvocation.getSubInvocations() != null + && referenceInvocation.getSubInvocations().length > 0) { + throw new InvalidSep45ChallengeException("Authorization entry must not have sub-invocations"); + } + + InvokeContractArgs contractArgs = referenceInvocation.getFunction().getContractFn(); + + // Validate contract address + Address contractAddress = Address.fromSCAddress(contractArgs.getContractAddress()); + if (!contractAddress.getEncodedAddress().equals(webAuthContractId)) { + throw new InvalidSep45ChallengeException( + String.format( + "Contract address mismatch: expected %s, got %s", + webAuthContractId, contractAddress.getEncodedAddress())); + } + + // Validate function name + String functionName = contractArgs.getFunctionName().getSCSymbol().toString(); + if (!WEB_AUTH_VERIFY_FUNCTION_NAME.equals(functionName)) { + throw new InvalidSep45ChallengeException( + String.format( + "Function name mismatch: expected %s, got %s", + WEB_AUTH_VERIFY_FUNCTION_NAME, functionName)); + } + + // Parse the function arguments - expect exactly one Map argument + SCVal[] args = contractArgs.getArgs(); + if (args.length != 1) { + throw new InvalidSep45ChallengeException( + "Expected exactly one argument in contract function call"); + } + + SCVal argsVal = args[0]; + if (argsVal.getDiscriminant() != SCValType.SCV_MAP) { + throw new InvalidSep45ChallengeException("Expected Map argument in contract function call"); + } + + LinkedHashMap argsMap = Scv.fromMap(argsVal); + + String account = null; + String homeDomain = null; + String nonce = null; + String webAuthDomainValue = null; + String webAuthDomainAccount = null; + String clientDomain = null; + String clientDomainAccount = null; + + for (java.util.Map.Entry entry : argsMap.entrySet()) { + SCVal keyVal = entry.getKey(); + SCVal valueVal = entry.getValue(); + + if (keyVal.getDiscriminant() != SCValType.SCV_SYMBOL) { + throw new InvalidSep45ChallengeException("Argument key must be a symbol"); + } + + String key = Scv.fromSymbol(keyVal); + + switch (key) { + case "account": + account = new String(Scv.fromString(valueVal)); + break; + case "home_domain": + homeDomain = new String(Scv.fromString(valueVal)); + break; + case "nonce": + nonce = new String(Scv.fromString(valueVal)); + break; + case "web_auth_domain": + webAuthDomainValue = new String(Scv.fromString(valueVal)); + break; + case "web_auth_domain_account": + webAuthDomainAccount = new String(Scv.fromString(valueVal)); + break; + case "client_domain": + clientDomain = new String(Scv.fromString(valueVal)); + break; + case "client_domain_account": + clientDomainAccount = new String(Scv.fromString(valueVal)); + break; + default: + // Unknown argument, ignore + break; + } + } + + // Validate required fields + if (account == null) { + throw new InvalidSep45ChallengeException("Missing required argument: account"); + } + if (homeDomain == null) { + throw new InvalidSep45ChallengeException("Missing required argument: home_domain"); + } + if (nonce == null) { + throw new InvalidSep45ChallengeException("Missing required argument: nonce"); + } + if (webAuthDomainValue == null) { + throw new InvalidSep45ChallengeException("Missing required argument: web_auth_domain"); + } + if (webAuthDomainAccount == null) { + throw new InvalidSep45ChallengeException( + "Missing required argument: web_auth_domain_account"); + } + + // Validate client contract ID + if (!StrKey.isValidContract(account)) { + throw new InvalidSep45ChallengeException( + "account: " + account + " is not a valid contract id"); + } + + // Validate home domain + boolean homeDomainMatched = false; + for (String domain : homeDomains) { + if (domain.equals(homeDomain)) { + homeDomainMatched = true; + break; + } + } + if (!homeDomainMatched) { + throw new InvalidSep45ChallengeException( + "home_domain: " + homeDomain + " is not in the list of allowed home domains"); + } + + // Validate web auth domain + if (!webAuthDomain.equals(webAuthDomainValue)) { + throw new InvalidSep45ChallengeException( + String.format( + "web_auth_domain mismatch: expected %s, got %s", webAuthDomain, webAuthDomainValue)); + } + + // Validate web auth domain account + if (!serverAccountId.equals(webAuthDomainAccount)) { + throw new InvalidSep45ChallengeException( + String.format( + "web_auth_domain_account mismatch: expected %s, got %s", + serverAccountId, webAuthDomainAccount)); + } + + // Validate client domain and client domain account consistency + if ((clientDomain == null) != (clientDomainAccount == null)) { + throw new InvalidSep45ChallengeException( + "client_domain and client_domain_account must both be present or both be absent"); + } + + // Validate all entries have the same root invocation + for (int i = 1; i < entries.size(); i++) { + SorobanAuthorizedInvocation currentInvocation = entries.get(i).getRootInvocation(); + if (!referenceInvocation.equals(currentInvocation)) { + throw new InvalidSep45ChallengeException( + "All authorization entries must have the same root invocation"); + } + } + + // Validate credentials types + boolean foundServerCredential = false; + boolean foundClientCredential = false; + boolean foundClientDomainCredential = false; + + for (SorobanAuthorizationEntry entry : entries) { + SorobanCredentials credentials = entry.getCredentials(); + if (credentials.getDiscriminant() != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) { + throw new InvalidSep45ChallengeException( + "All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS type"); + } + + SorobanAddressCredentials addressCredentials = credentials.getAddress(); + Address credentialAddress = Address.fromSCAddress(addressCredentials.getAddress()); + String encodedAddress = credentialAddress.getEncodedAddress(); + + if (encodedAddress.equals(serverAccountId)) { + foundServerCredential = true; + } else if (encodedAddress.equals(account)) { + foundClientCredential = true; + } else if (encodedAddress.equals(clientDomainAccount)) { + foundClientDomainCredential = true; + } + } + + if (!foundServerCredential) { + throw new InvalidSep45ChallengeException( + "Authorization entries must include a credential for the server account"); + } + + if (!foundClientCredential) { + throw new InvalidSep45ChallengeException( + "Authorization entries must include a credential for the client account"); + } + + if (clientDomainAccount != null && !foundClientDomainCredential) { + throw new InvalidSep45ChallengeException( + "Authorization entries must include a credential for client_domain_account: " + + clientDomainAccount); + } + + return ChallengeAuthorizationEntries.builder() + .authorizationEntries(entries) + .clientContractId(account) + .serverAccountId(serverAccountId) + .webAuthContractId(webAuthContractId) + .homeDomain(homeDomain) + .webAuthDomain(webAuthDomainValue) + .nonce(nonce) + .clientDomain(clientDomain) + .clientDomainAccountId(clientDomainAccount) + .build(); + } + + /** + * Reads and validates a SEP-45 challenge authorization entries without verifying signatures. + * + * @param authorizationEntriesXdr The base64 XDR-encoded authorization entries. + * @param serverAccountId The expected server account ID (G... address). + * @param webAuthContractId The expected web authentication contract ID (C... address). + * @param homeDomain The expected home domain. + * @param webAuthDomain The expected web auth domain. + * @return A {@link ChallengeAuthorizationEntries} object containing the parsed challenge data. + * @throws InvalidSep45ChallengeException If the challenge is invalid. + */ + public static ChallengeAuthorizationEntries readChallengeAuthorizationEntries( + @NonNull String authorizationEntriesXdr, + @NonNull String serverAccountId, + @NonNull String webAuthContractId, + @NonNull String homeDomain, + @NonNull String webAuthDomain) { + return readChallengeAuthorizationEntries( + authorizationEntriesXdr, + serverAccountId, + webAuthContractId, + new String[] {homeDomain}, + webAuthDomain); + } + + /** + * Verifies a SEP-45 challenge authorization entries by simulating the transaction. + * + *

Since contract accounts cannot be queried for signers like traditional Stellar accounts, we + * verify signatures by simulating the transaction. A successful simulation indicates valid + * signatures. + * + * @param server The Soroban RPC server to use for simulating the transaction. + * @param authorizationEntriesXdr The base64 XDR-encoded authorization entries. + * @param serverAccountId The expected server account ID (G... address). + * @param webAuthContractId The expected web authentication contract ID (C... address). + * @param homeDomains A list of acceptable home domains. + * @param webAuthDomain The expected web auth domain. + * @param network The Stellar network. + * @return A {@link ChallengeAuthorizationEntries} object containing the verified challenge data. + * @throws InvalidSep45ChallengeException If the challenge is invalid or verification fails. + */ + public static ChallengeAuthorizationEntries verifyChallengeAuthorizationEntries( + @NonNull SorobanServer server, + @NonNull String authorizationEntriesXdr, + @NonNull String serverAccountId, + @NonNull String webAuthContractId, + @NonNull String[] homeDomains, + @NonNull String webAuthDomain, + @NonNull Network network) { + + // First, read and validate the challenge structure + ChallengeAuthorizationEntries challenge = + readChallengeAuthorizationEntries( + authorizationEntriesXdr, + serverAccountId, + webAuthContractId, + homeDomains, + webAuthDomain); + + // Build a transaction with the authorization entries + List entries = challenge.getAuthorizationEntries(); + SorobanAuthorizedInvocation invocation = entries.get(0).getRootInvocation(); + InvokeContractArgs contractArgs = invocation.getFunction().getContractFn(); + + InvokeHostFunctionOperation operation = + InvokeHostFunctionOperation.builder() + .hostFunction( + HostFunction.builder() + .discriminant(HostFunctionType.HOST_FUNCTION_TYPE_INVOKE_CONTRACT) + .invokeContract(contractArgs) + .build()) + .auth(entries) + .build(); + + Account nullAccount = new Account(NULL_ACCOUNT, 0L); + Transaction transaction = + new TransactionBuilder(nullAccount, network) + .setBaseFee(100) + .addOperation(operation) + .addPreconditions( + TransactionPreconditions.builder().timeBounds(new TimeBounds(0, 0)).build()) + .build(); + + // Simulate the transaction to verify signatures + SimulateTransactionResponse simulateResponse = server.simulateTransaction(transaction); + + if (simulateResponse.getError() != null) { + throw new InvalidSep45ChallengeException( + "Signature verification failed: " + simulateResponse.getError()); + } + + return challenge; + } + + /** + * Verifies a SEP-45 challenge authorization entries by simulating the transaction. + * + * @param server The Soroban RPC server to use for simulating the transaction. + * @param authorizationEntriesXdr The base64 XDR-encoded authorization entries. + * @param serverAccountId The expected server account ID (G... address). + * @param webAuthContractId The expected web authentication contract ID (C... address). + * @param homeDomain The expected home domain. + * @param webAuthDomain The expected web auth domain. + * @param network The Stellar network. + * @return A {@link ChallengeAuthorizationEntries} object containing the verified challenge data. + * @throws InvalidSep45ChallengeException If the challenge is invalid or verification fails. + */ + public static ChallengeAuthorizationEntries verifyChallengeAuthorizationEntries( + @NonNull SorobanServer server, + @NonNull String authorizationEntriesXdr, + @NonNull String serverAccountId, + @NonNull String webAuthContractId, + @NonNull String homeDomain, + @NonNull String webAuthDomain, + @NonNull Network network) { + return verifyChallengeAuthorizationEntries( + server, + authorizationEntriesXdr, + serverAccountId, + webAuthContractId, + new String[] {homeDomain}, + webAuthDomain, + network); + } + + /** Contains the parsed data from a SEP-45 challenge. */ + @Value + @Builder + public static class ChallengeAuthorizationEntries { + /** The list of authorization entries. */ + @NonNull List authorizationEntries; + + /** The client contract account ID (C... address). */ + @NonNull String clientContractId; + + /** The server account ID (G... address). */ + @NonNull String serverAccountId; + + /** The web authentication contract ID (C... address). */ + @NonNull String webAuthContractId; + + /** The home domain. */ + @NonNull String homeDomain; + + /** The web auth domain. */ + @NonNull String webAuthDomain; + + /** The nonce value. */ + @NonNull String nonce; + + /** The client domain (optional, for client domain verification). */ + @Nullable String clientDomain; + + /** The client domain account ID (optional, for client domain verification). */ + @Nullable String clientDomainAccountId; + } +} diff --git a/src/main/java/org/stellar/sdk/exception/InvalidSep45ChallengeException.java b/src/main/java/org/stellar/sdk/exception/InvalidSep45ChallengeException.java new file mode 100644 index 000000000..7a7e2f6d3 --- /dev/null +++ b/src/main/java/org/stellar/sdk/exception/InvalidSep45ChallengeException.java @@ -0,0 +1,16 @@ +package org.stellar.sdk.exception; + +/** Thrown when building, reading, or verifying a SEP-0045 challenge fails. */ +public class InvalidSep45ChallengeException extends SdkException { + public InvalidSep45ChallengeException() { + super(); + } + + public InvalidSep45ChallengeException(String message) { + super(message); + } + + public InvalidSep45ChallengeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java new file mode 100644 index 000000000..6d3072ec4 --- /dev/null +++ b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java @@ -0,0 +1,1305 @@ +package org.stellar.sdk; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import lombok.NonNull; +import okhttp3.HttpUrl; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.Test; +import org.stellar.sdk.exception.InvalidSep45ChallengeException; +import org.stellar.sdk.scval.Scv; +import org.stellar.sdk.xdr.Int64; +import org.stellar.sdk.xdr.InvokeContractArgs; +import org.stellar.sdk.xdr.SCSymbol; +import org.stellar.sdk.xdr.SCVal; +import org.stellar.sdk.xdr.SorobanAddressCredentials; +import org.stellar.sdk.xdr.SorobanAuthorizationEntries; +import org.stellar.sdk.xdr.SorobanAuthorizationEntry; +import org.stellar.sdk.xdr.SorobanAuthorizedFunction; +import org.stellar.sdk.xdr.SorobanAuthorizedFunctionType; +import org.stellar.sdk.xdr.SorobanAuthorizedInvocation; +import org.stellar.sdk.xdr.SorobanCredentials; +import org.stellar.sdk.xdr.SorobanCredentialsType; +import org.stellar.sdk.xdr.Uint32; +import org.stellar.sdk.xdr.XdrString; +import org.stellar.sdk.xdr.XdrUnsignedInteger; + +public class Sep45ChallengeTest { + + private static final String WEB_AUTH_CONTRACT = + "CCSZMK2C2B7UVP3Q4JRWLS3JY7ZB4YTTRSWQXGWQVS24W3PPZXOUHH4R"; + private static final String SERVER_ACCOUNT = + "GCSNRCUM6EDEKSSBQNIOP66ONIM26FVCYP3GHYGD4NR3DK4F635Z32WQ"; + private static final String CLIENT_CONTRACT = + "CBUCJMHBZHQ3EXQ2LMSFVZUWCPH7BCTCYGOQ6LIIO2OUVKU3XDDOO2HN"; + private static final String CLIENT_DOMAIN_ACCOUNT = + "GAFQLAZMGGZT4KSDIGBEIC5IXOVH6ITIFKUQ5ZTDWP3MQWLGVJWTH3TX"; + private static final String HOME_DOMAIN = "example.com"; + private static final String WEB_AUTH_DOMAIN = "auth.example.com"; + private static final String CLIENT_DOMAIN = "client.example.com"; + private static final String NONCE = + "dGVzdG5vbmNlMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU="; + + /** + * Builds a valid SorobanAuthorizedInvocation for the web_auth_verify function. + * + * @param includeClientDomain whether to include client domain fields + * @return the authorized invocation + */ + private SorobanAuthorizedInvocation buildValidInvocation(boolean includeClientDomain) { + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + if (includeClientDomain) { + argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(CLIENT_DOMAIN)); + argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(CLIENT_DOMAIN_ACCOUNT)); + } + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + InvokeContractArgs contractArgs = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME))) + .args(new SCVal[] {Scv.toMap(argsMap)}) + .build(); + + SorobanAuthorizedFunction function = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs) + .build(); + + return SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + } + + /** + * Builds a SorobanAuthorizationEntry with address credentials. + * + * @param address the address for the credential + * @param invocation the root invocation + * @return the authorization entry + */ + private SorobanAuthorizationEntry buildAuthorizationEntry( + String address, SorobanAuthorizedInvocation invocation) { + Address addr = new Address(address); + + SorobanAddressCredentials addressCredentials = + SorobanAddressCredentials.builder() + .address(addr.toSCAddress()) + .nonce(new Int64(0L)) + .signatureExpirationLedger(new Uint32(new XdrUnsignedInteger(0L))) + .signature(Scv.toVoid()) + .build(); + + SorobanCredentials credentials = + SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) + .address(addressCredentials) + .build(); + + return SorobanAuthorizationEntry.builder() + .credentials(credentials) + .rootInvocation(invocation) + .build(); + } + + /** + * Builds valid authorization entries for testing. + * + * @param includeClientDomain whether to include client domain + * @return list of authorization entries + */ + private List buildValidEntries(boolean includeClientDomain) { + SorobanAuthorizedInvocation invocation = buildValidInvocation(includeClientDomain); + + List entries = new ArrayList<>(); + // Client entry + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + // Server entry + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation)); + + if (includeClientDomain) { + // Client domain entry + entries.add(buildAuthorizationEntry(CLIENT_DOMAIN_ACCOUNT, invocation)); + } + + return entries; + } + + @Test + public void testReadChallengeSuccessWithClientDomain() { + List entries = buildValidEntries(true); + String xdr = authorizationEntriesToXdr(entries); + + Sep45Challenge.ChallengeAuthorizationEntries result = + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + + assertEquals(CLIENT_CONTRACT, result.getClientContractId()); + assertEquals(SERVER_ACCOUNT, result.getServerAccountId()); + assertEquals(WEB_AUTH_CONTRACT, result.getWebAuthContractId()); + assertEquals(HOME_DOMAIN, result.getHomeDomain()); + assertEquals(WEB_AUTH_DOMAIN, result.getWebAuthDomain()); + assertEquals(NONCE, result.getNonce()); + assertEquals(CLIENT_DOMAIN, result.getClientDomain()); + assertEquals(CLIENT_DOMAIN_ACCOUNT, result.getClientDomainAccountId()); + assertEquals(3, result.getAuthorizationEntries().size()); + } + + @Test + public void testReadChallengeSuccessWithoutClientDomain() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + Sep45Challenge.ChallengeAuthorizationEntries result = + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + + assertEquals(CLIENT_CONTRACT, result.getClientContractId()); + assertEquals(SERVER_ACCOUNT, result.getServerAccountId()); + assertEquals(WEB_AUTH_CONTRACT, result.getWebAuthContractId()); + assertEquals(HOME_DOMAIN, result.getHomeDomain()); + assertEquals(WEB_AUTH_DOMAIN, result.getWebAuthDomain()); + assertEquals(NONCE, result.getNonce()); + assertNull(result.getClientDomain()); + assertNull(result.getClientDomainAccountId()); + assertEquals(2, result.getAuthorizationEntries().size()); + } + + @Test + public void testReadChallengeSuccessWithMultipleHomeDomains() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + String[] homeDomains = new String[] {"other.com", HOME_DOMAIN, "another.com"}; + + Sep45Challenge.ChallengeAuthorizationEntries result = + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, homeDomains, WEB_AUTH_DOMAIN); + + assertEquals(HOME_DOMAIN, result.getHomeDomain()); + assertEquals(CLIENT_CONTRACT, result.getClientContractId()); + } + + @Test(expected = InvalidSep45ChallengeException.class) + public void testReadChallengeInvalidXdrFormat() { + // Use empty XDR which should fail to parse + Sep45Challenge.readChallengeAuthorizationEntries( + "AAAAAAAAAA==", SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + } + + @Test + public void testReadChallengeLessThanTwoEntries() { + SorobanAuthorizedInvocation invocation = buildValidInvocation(false); + List entries = new ArrayList<>(); + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains( + "Authorization entries must contain at least 2 entries (server and client)")); + } + } + + @Test + public void testReadChallengeInconsistentRootInvocation() { + SorobanAuthorizedInvocation invocation1 = buildValidInvocation(false); + + // Create a different invocation with different nonce + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString("different_nonce")); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + InvokeContractArgs contractArgs2 = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME))) + .args(new SCVal[] {Scv.toMap(argsMap)}) + .build(); + + SorobanAuthorizedFunction function2 = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs2) + .build(); + + SorobanAuthorizedInvocation invocation2 = + SorobanAuthorizedInvocation.builder() + .function(function2) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + + List entries = new ArrayList<>(); + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation1)); + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation2)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage().contains("All authorization entries must have the same root invocation")); + } + } + + @Test + public void testReadChallengeHasSubInvocations() { + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + InvokeContractArgs contractArgs = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME))) + .args(new SCVal[] {Scv.toMap(argsMap)}) + .build(); + + SorobanAuthorizedFunction function = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs) + .build(); + + // Create a sub-invocation + SorobanAuthorizedInvocation subInvocation = + SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + + // Create invocation with sub-invocations + SorobanAuthorizedInvocation invocationWithSubs = + SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[] {subInvocation}) + .build(); + + List entries = new ArrayList<>(); + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocationWithSubs)); + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocationWithSubs)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Authorization entry must not have sub-invocations")); + } + } + + @Test + public void testReadChallengeWrongContractAddress() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + String wrongContract = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, wrongContract, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Contract address mismatch")); + } + } + + @Test + public void testReadChallengeWrongFunctionName() { + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + InvokeContractArgs contractArgs = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString("wrong_function"))) + .args(new SCVal[] {Scv.toMap(argsMap)}) + .build(); + + SorobanAuthorizedFunction function = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs) + .build(); + + SorobanAuthorizedInvocation invocation = + SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + + List entries = new ArrayList<>(); + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Function name mismatch")); + } + } + + @Test + public void testReadChallengeWrongArgsCount() { + InvokeContractArgs contractArgs = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME))) + .args(new SCVal[] {Scv.toSymbol("arg1"), Scv.toSymbol("arg2")}) + .build(); + + SorobanAuthorizedFunction function = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs) + .build(); + + SorobanAuthorizedInvocation invocation = + SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + + List entries = new ArrayList<>(); + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Expected exactly one argument")); + } + } + + @Test + public void testReadChallengeInvalidStruct() { + // Use a symbol instead of a map as argument + InvokeContractArgs contractArgs = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME))) + .args(new SCVal[] {Scv.toSymbol("not_a_map")}) + .build(); + + SorobanAuthorizedFunction function = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs) + .build(); + + SorobanAuthorizedInvocation invocation = + SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + + List entries = new ArrayList<>(); + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Expected Map argument")); + } + } + + @Test + public void testReadChallengeMissingAccount() { + LinkedHashMap argsMap = new LinkedHashMap<>(); + // Missing "account" field + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + String xdr = buildEntriesWithCustomArgs(argsMap); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Missing required argument: account")); + } + } + + @Test + public void testReadChallengeMissingHomeDomain() { + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + // Missing "home_domain" field + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + String xdr = buildEntriesWithCustomArgs(argsMap); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Missing required argument: home_domain")); + } + } + + @Test + public void testReadChallengeMissingNonce() { + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + // Missing "nonce" field + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + String xdr = buildEntriesWithCustomArgs(argsMap); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Missing required argument: nonce")); + } + } + + @Test + public void testReadChallengeMissingWebAuthDomain() { + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + // Missing "web_auth_domain" field + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + String xdr = buildEntriesWithCustomArgs(argsMap); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Missing required argument: web_auth_domain")); + } + } + + @Test + public void testReadChallengeMissingWebAuthDomainAccount() { + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + // Missing "web_auth_domain_account" field + + String xdr = buildEntriesWithCustomArgs(argsMap); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Missing required argument: web_auth_domain_account")); + } + } + + @Test + public void testReadChallengeServerAccountMismatch() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + String wrongServer = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, wrongServer, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("web_auth_domain_account mismatch")); + } + } + + @Test + public void testReadChallengeWebAuthDomainMismatch() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, "wrong.domain.com"); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("web_auth_domain mismatch")); + } + } + + @Test + public void testReadChallengeHomeDomainMismatchString() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, "wrong.domain.com", WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("is not in the list of allowed home domains")); + } + } + + @Test + public void testReadChallengeHomeDomainMismatchList() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + String[] homeDomains = new String[] {"other.com", "another.com"}; + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, homeDomains, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("is not in the list of allowed home domains")); + } + } + + @Test + public void testReadChallengeClientDomainWithoutAccount() { + // Create entries where client_domain is present but client_domain_account is missing + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(CLIENT_DOMAIN)); + // Missing client_domain_account + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + String xdr = buildEntriesWithCustomArgs(argsMap); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains( + "client_domain and client_domain_account must both be present or both be absent")); + } + } + + @Test + public void testReadChallengeClientDomainAccountWithoutDomain() { + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + // Missing client_domain + argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(CLIENT_DOMAIN_ACCOUNT)); + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + String xdr = buildEntriesWithCustomArgs(argsMap); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains( + "client_domain and client_domain_account must both be present or both be absent")); + } + } + + @Test + public void testReadChallengeMissingServerEntry() { + SorobanAuthorizedInvocation invocation = buildValidInvocation(false); + + List entries = new ArrayList<>(); + // Only client entry, missing server entry + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + // Add another random entry that's not the server (use a valid random account) + String randomAccount = KeyPair.random().getAccountId(); + entries.add(buildAuthorizationEntry(randomAccount, invocation)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains("Authorization entries must include a credential for the server account")); + } + } + + @Test + public void testReadChallengeMissingClientEntry() { + SorobanAuthorizedInvocation invocation = buildValidInvocation(false); + + List entries = new ArrayList<>(); + // Only server entry, missing client entry + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation)); + // Add another random entry that's not the client (use a valid random account) + String randomAccount = KeyPair.random().getAccountId(); + entries.add(buildAuthorizationEntry(randomAccount, invocation)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains("Authorization entries must include a credential for the client account")); + } + } + + @Test + public void testReadChallengeMissingClientDomainEntry() { + // Create entries with client_domain in args but missing the client_domain_account entry + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(CLIENT_DOMAIN)); + argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(CLIENT_DOMAIN_ACCOUNT)); + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT)); + + InvokeContractArgs contractArgs = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME))) + .args(new SCVal[] {Scv.toMap(argsMap)}) + .build(); + + SorobanAuthorizedFunction function = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs) + .build(); + + SorobanAuthorizedInvocation invocation = + SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + + List entries = new ArrayList<>(); + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation)); + // Missing client domain account entry! + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains( + "Authorization entries must include a credential for client_domain_account")); + } + } + + @Test + public void testReadChallengeUnsupportedCredentialsType() { + SorobanAuthorizedInvocation invocation = buildValidInvocation(false); + + // Create entry with SOURCE_ACCOUNT credentials type + SorobanCredentials sourceCredentials = + SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) + .build(); + + SorobanAuthorizationEntry sourceEntry = + SorobanAuthorizationEntry.builder() + .credentials(sourceCredentials) + .rootInvocation(invocation) + .build(); + + List entries = new ArrayList<>(); + entries.add(sourceEntry); + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation)); + + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains("All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS type")); + } + } + + @Test + public void testReadChallengeInvalidServerAccountId() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + // Use a contract address instead of account address for server account + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, CLIENT_CONTRACT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("is not a valid account id")); + } + } + + @Test + public void testReadChallengeInvalidWebAuthContractId() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, "invalid_contract", HOME_DOMAIN, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("is not a valid contract id")); + } + } + + @Test + public void testReadChallengeEmptyHomeDomains() { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + try { + Sep45Challenge.readChallengeAuthorizationEntries( + xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, new String[] {}, WEB_AUTH_DOMAIN); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("At least one domain name must be included")); + } + } + + @Test + public void testBuildChallengeAuthorizationEntriesWithClientDomain() throws IOException { + KeyPair serverSigner = KeyPair.random(); + String serverAccountId = serverSigner.getAccountId(); + + // Mock the simulate transaction response + String mockResponse = buildMockSimulateResponse(serverAccountId, true); + + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse)); + mockWebServer.start(); + + HttpUrl baseUrl = mockWebServer.url(""); + SorobanServer server = new SorobanServer(baseUrl.toString()); + + SorobanAuthorizationEntries result = + Sep45Challenge.buildChallengeAuthorizationEntries( + server, + serverSigner, + CLIENT_CONTRACT, + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + WEB_AUTH_CONTRACT, + Network.TESTNET, + NONCE, + 180L, + CLIENT_DOMAIN, + CLIENT_DOMAIN_ACCOUNT); + + assertNotNull(result); + assertTrue(result.getSorobanAuthorizationEntries().length >= 2); + + mockWebServer.close(); + server.close(); + } + + @Test + public void testBuildChallengeAuthorizationEntriesWithoutClientDomain() throws IOException { + KeyPair serverSigner = KeyPair.random(); + String serverAccountId = serverSigner.getAccountId(); + + // Mock the simulate transaction response + String mockResponse = buildMockSimulateResponse(serverAccountId, false); + + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse)); + mockWebServer.start(); + + HttpUrl baseUrl = mockWebServer.url(""); + SorobanServer server = new SorobanServer(baseUrl.toString()); + + SorobanAuthorizationEntries result = + Sep45Challenge.buildChallengeAuthorizationEntries( + server, + serverSigner, + CLIENT_CONTRACT, + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + WEB_AUTH_CONTRACT, + Network.TESTNET, + null, + null); + + assertNotNull(result); + assertTrue(result.getSorobanAuthorizationEntries().length >= 2); + + mockWebServer.close(); + server.close(); + } + + @Test + public void testBuildChallengeAuthorizationEntriesClientDomainWithoutAccount() + throws IOException { + KeyPair serverSigner = KeyPair.random(); + + try (MockWebServer mockWebServer = new MockWebServer()) { + mockWebServer.start(); + HttpUrl baseUrl = mockWebServer.url(""); + try (SorobanServer server = new SorobanServer(baseUrl.toString())) { + Sep45Challenge.buildChallengeAuthorizationEntries( + server, + serverSigner, + CLIENT_CONTRACT, + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + WEB_AUTH_CONTRACT, + Network.TESTNET, + null, + null, + CLIENT_DOMAIN, + null); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains( + "clientDomain and clientDomainAccountId must both be provided or both be null")); + } + } + } + + @Test + public void testBuildChallengeAuthorizationEntriesClientDomainAccountWithoutDomain() + throws IOException { + KeyPair serverSigner = KeyPair.random(); + + try (MockWebServer mockWebServer = new MockWebServer()) { + mockWebServer.start(); + HttpUrl baseUrl = mockWebServer.url(""); + try (SorobanServer server = new SorobanServer(baseUrl.toString())) { + Sep45Challenge.buildChallengeAuthorizationEntries( + server, + serverSigner, + CLIENT_CONTRACT, + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + WEB_AUTH_CONTRACT, + Network.TESTNET, + null, + null, + null, + CLIENT_DOMAIN_ACCOUNT); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue( + e.getMessage() + .contains( + "clientDomain and clientDomainAccountId must both be provided or both be null")); + } + } + } + + @Test + public void testBuildChallengeAuthorizationEntriesInvalidClientContractId() throws IOException { + KeyPair serverSigner = KeyPair.random(); + + try (MockWebServer mockWebServer = new MockWebServer()) { + mockWebServer.start(); + HttpUrl baseUrl = mockWebServer.url(""); + try (SorobanServer server = new SorobanServer(baseUrl.toString())) { + Sep45Challenge.buildChallengeAuthorizationEntries( + server, + serverSigner, + "invalid_contract_id", + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + WEB_AUTH_CONTRACT, + Network.TESTNET, + null, + null); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("is not a valid contract id")); + } + } + } + + @Test + public void testBuildChallengeAuthorizationEntriesInvalidWebAuthContractId() throws IOException { + KeyPair serverSigner = KeyPair.random(); + + try (MockWebServer mockWebServer = new MockWebServer()) { + mockWebServer.start(); + HttpUrl baseUrl = mockWebServer.url(""); + try (SorobanServer server = new SorobanServer(baseUrl.toString())) { + Sep45Challenge.buildChallengeAuthorizationEntries( + server, + serverSigner, + CLIENT_CONTRACT, + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + "invalid_contract_id", + Network.TESTNET, + null, + null); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("is not a valid contract id")); + } + } + } + + @Test + public void testBuildChallengeAuthorizationEntriesSimulationError() throws IOException { + KeyPair serverSigner = KeyPair.random(); + + String mockResponse = + new String( + Files.readAllBytes( + Paths.get("src/test/resources/sep45/simulate_transaction_build_error.json"))); + + try (MockWebServer mockWebServer = new MockWebServer()) { + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse)); + mockWebServer.start(); + + HttpUrl baseUrl = mockWebServer.url(""); + try (SorobanServer server = new SorobanServer(baseUrl.toString())) { + Sep45Challenge.buildChallengeAuthorizationEntries( + server, + serverSigner, + CLIENT_CONTRACT, + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + WEB_AUTH_CONTRACT, + Network.TESTNET, + null, + null); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Transaction simulation failed")); + assertTrue(e.getMessage().contains("HostError: Error(WasmVm, MissingValue)")); + } + } + } + + @Test + public void testVerifyChallengeAuthorizationEntriesSuccess() throws IOException { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + String mockResponse = + new String( + Files.readAllBytes( + Paths.get("src/test/resources/sep45/simulate_transaction_success.json"))); + + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse)); + mockWebServer.start(); + + HttpUrl baseUrl = mockWebServer.url(""); + SorobanServer server = new SorobanServer(baseUrl.toString()); + + Sep45Challenge.ChallengeAuthorizationEntries result = + Sep45Challenge.verifyChallengeAuthorizationEntries( + server, + xdr, + SERVER_ACCOUNT, + WEB_AUTH_CONTRACT, + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + Network.TESTNET); + + assertEquals(CLIENT_CONTRACT, result.getClientContractId()); + assertEquals(SERVER_ACCOUNT, result.getServerAccountId()); + + mockWebServer.close(); + server.close(); + } + + @Test + public void testVerifyChallengeAuthorizationEntriesFailed() throws IOException { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + String mockResponse = + new String( + Files.readAllBytes( + Paths.get("src/test/resources/sep45/simulate_transaction_failed.json"))); + + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse)); + mockWebServer.start(); + + HttpUrl baseUrl = mockWebServer.url(""); + SorobanServer server = new SorobanServer(baseUrl.toString()); + + try { + Sep45Challenge.verifyChallengeAuthorizationEntries( + server, + xdr, + SERVER_ACCOUNT, + WEB_AUTH_CONTRACT, + HOME_DOMAIN, + WEB_AUTH_DOMAIN, + Network.TESTNET); + fail("Expected InvalidSep45ChallengeException"); + } catch (InvalidSep45ChallengeException e) { + assertTrue(e.getMessage().contains("Signature verification failed")); + } + + mockWebServer.close(); + server.close(); + } + + @Test + public void testVerifyChallengeAuthorizationEntriesWithMultipleHomeDomains() throws IOException { + List entries = buildValidEntries(false); + String xdr = authorizationEntriesToXdr(entries); + + String mockResponse = + new String( + Files.readAllBytes( + Paths.get("src/test/resources/sep45/simulate_transaction_success.json"))); + + MockWebServer mockWebServer = new MockWebServer(); + mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse)); + mockWebServer.start(); + + HttpUrl baseUrl = mockWebServer.url(""); + SorobanServer server = new SorobanServer(baseUrl.toString()); + + String[] homeDomains = new String[] {"other.com", HOME_DOMAIN}; + + Sep45Challenge.ChallengeAuthorizationEntries result = + Sep45Challenge.verifyChallengeAuthorizationEntries( + server, + xdr, + SERVER_ACCOUNT, + WEB_AUTH_CONTRACT, + homeDomains, + WEB_AUTH_DOMAIN, + Network.TESTNET); + + assertEquals(HOME_DOMAIN, result.getHomeDomain()); + + mockWebServer.close(); + server.close(); + } + + /** + * Builds entries with custom args map for testing validation errors. + * + * @param argsMap the custom args map + * @return XDR string of the entries + */ + private String buildEntriesWithCustomArgs(LinkedHashMap argsMap) { + InvokeContractArgs contractArgs = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME))) + .args(new SCVal[] {Scv.toMap(argsMap)}) + .build(); + + SorobanAuthorizedFunction function = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs) + .build(); + + SorobanAuthorizedInvocation invocation = + SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + + List entries = new ArrayList<>(); + entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation)); + + return authorizationEntriesToXdr(entries); + } + + /** + * Builds a mock simulate transaction response. + * + * @param serverAccountId the server account ID + * @param includeClientDomain whether to include client domain + * @return JSON string of the mock response + */ + private String buildMockSimulateResponse(String serverAccountId, boolean includeClientDomain) { + // Build mock authorization entries for the response + List mockEntries = new ArrayList<>(); + + // Build invocation with the server account + LinkedHashMap argsMap = new LinkedHashMap<>(); + argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT)); + if (includeClientDomain) { + argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(CLIENT_DOMAIN)); + argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(CLIENT_DOMAIN_ACCOUNT)); + } + argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN)); + argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE)); + argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN)); + argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(serverAccountId)); + + InvokeContractArgs contractArgs = + InvokeContractArgs.builder() + .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress()) + .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME))) + .args(new SCVal[] {Scv.toMap(argsMap)}) + .build(); + + SorobanAuthorizedFunction function = + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn(contractArgs) + .build(); + + SorobanAuthorizedInvocation invocation = + SorobanAuthorizedInvocation.builder() + .function(function) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + + // Client entry + mockEntries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + // Server entry + mockEntries.add(buildAuthorizationEntry(serverAccountId, invocation)); + if (includeClientDomain) { + mockEntries.add(buildAuthorizationEntry(CLIENT_DOMAIN_ACCOUNT, invocation)); + } + + // Convert entries to XDR base64 strings + List authXdrList = new ArrayList<>(); + for (SorobanAuthorizationEntry entry : mockEntries) { + try { + authXdrList.add(entry.toXdrBase64()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + StringBuilder authArrayJson = new StringBuilder("["); + for (int i = 0; i < authXdrList.size(); i++) { + authArrayJson.append("\"").append(authXdrList.get(i)).append("\""); + if (i < authXdrList.size() - 1) { + authArrayJson.append(","); + } + } + authArrayJson.append("]"); + + return "{\n" + + " \"jsonrpc\": \"2.0\",\n" + + " \"id\": 1,\n" + + " \"result\": {\n" + + " \"transactionData\": \"AAAAAAAAAAIAAAAGAAAAAem354u9STQWq5b3Ed1j9tOemvL7xV0NPwhn4gXg0AP8AAAAFAAAAAEAAAAH8dTe2OoI0BnhlDbH0fWvXmvprkBvBAgKIcL9busuuMEAAAABAAAABgAAAAHpt+eLvUk0FquW9xHdY/bTnpry+8VdDT8IZ+IF4NAD/AAAABAAAAABAAAAAgAAAA8AAAAHQ291bnRlcgAAAAASAAAAAAAAAABYt8SiyPKXqo89JHEoH9/M7K/kjlZjMT7BjhKnPsqYoQAAAAEAHifGAAAFlAAAAIgAAAAAAAAAAg==\",\n" + + " \"minResourceFee\": \"58181\",\n" + + " \"events\": [],\n" + + " \"results\": [{\"auth\": " + + authArrayJson + + ", \"xdr\": \"AAAAAwAAABQ=\"}],\n" + + " \"latestLedger\": 14245\n" + + " }\n" + + "}"; + } + + /** + * Converts a list of {@link SorobanAuthorizationEntry} to a base64-encoded XDR string. + * + * @param entries The list of authorization entries to encode. + * @return The base64-encoded XDR string. + */ + public static String authorizationEntriesToXdr( + @NonNull Collection entries) { + org.stellar.sdk.xdr.SorobanAuthorizationEntries xdrEntries = + new org.stellar.sdk.xdr.SorobanAuthorizationEntries(); + xdrEntries.setSorobanAuthorizationEntries(entries.toArray(new SorobanAuthorizationEntry[0])); + try { + return xdrEntries.toXdrBase64(); + } catch (IOException e) { + throw new IllegalArgumentException("Failed to encode authorization entries", e); + } + } +} diff --git a/src/test/resources/sep45/simulate_transaction_build_error.json b/src/test/resources/sep45/simulate_transaction_build_error.json new file mode 100644 index 000000000..82f81d4aa --- /dev/null +++ b/src/test/resources/sep45/simulate_transaction_build_error.json @@ -0,0 +1,12 @@ +{ + "jsonrpc": "2.0", + "id": "227dedbc6e564f9d80c40ffcc5805138", + "result": { + "error": "HostError: Error(WasmVm, MissingValue)\n\nEvent log (newest first):\n 0: [Diagnostic Event] contract:CBCRYOUDISSUEJDJWYXUUWOFZCX24TMBPR2FFI2N7NV4LBD7RWQQ6CSS, topics:[error, Error(WasmVm, MissingValue)], data:[\"trying to invoke non-existent contract function\", web_auth_verify]\n 1: [Diagnostic Event] topics:[fn_call, CBCRYOUDISSUEJDJWYXUUWOFZCX24TMBPR2FFI2N7NV4LBD7RWQQ6CSS, web_auth_verify], data:{account: \"CBUCJMHBZHQ3EXQ2LMSFVZUWCPH7BCTCYGOQ6LIIO2OUVKU3XDDOO2HN\", client_domain: \"client.example.com\", client_domain_account: \"GAFQLAZMGGZT4KSDIGBEIC5IXOVH6ITIFKUQ5ZTDWP3MQWLGVJWTH3TX\", home_domain: \"example.com\", nonce: \"random-nonce\", web_auth_domain: \"auth.example.com\", web_auth_domain_account: \"GCSNRCUM6EDEKSSBQNIOP66ONIM26FVCYP3GHYGD4NR3DK4F635Z32WQ\"}\n", + "events": [ + "AAAAAAAAAAAAAAAAAAAAAgAAAAAAAAADAAAADwAAAAdmbl9jYWxsAAAAAA0AAAAgRRw6g0SlQiRpti9KWcXIr65NgXx0UqNN+2vFhH+NoQ8AAAAPAAAAD3dlYl9hdXRoX3ZlcmlmeQAAAAARAAAAAQAAAAcAAAAPAAAAB2FjY291bnQAAAAADgAAADhDQlVDSk1IQlpIUTNFWFEyTE1TRlZaVVdDUEg3QkNUQ1lHT1E2TElJTzJPVVZLVTNYRERPTzJITgAAAA8AAAANY2xpZW50X2RvbWFpbgAAAAAAAA4AAAASY2xpZW50LmV4YW1wbGUuY29tAAAAAAAPAAAAFWNsaWVudF9kb21haW5fYWNjb3VudAAAAAAAAA4AAAA4R0FGUUxBWk1HR1pUNEtTRElHQkVJQzVJWE9WSDZJVElGS1VRNVpURFdQM01RV0xHVkpXVEgzVFgAAAAPAAAAC2hvbWVfZG9tYWluAAAAAA4AAAALZXhhbXBsZS5jb20AAAAADwAAAAVub25jZQAAAAAAAA4AAAAMcmFuZG9tLW5vbmNlAAAADwAAAA93ZWJfYXV0aF9kb21haW4AAAAADgAAABBhdXRoLmV4YW1wbGUuY29tAAAADwAAABd3ZWJfYXV0aF9kb21haW5fYWNjb3VudAAAAAAOAAAAOEdDU05SQ1VNNkVERUtTU0JRTklPUDY2T05JTTI2RlZDWVAzR0hZR0Q0TlIzREs0RjYzNVozMldR", + "AAAAAAAAAAAAAAABRRw6g0SlQiRpti9KWcXIr65NgXx0UqNN+2vFhH+NoQ8AAAACAAAAAAAAAAIAAAAPAAAABWVycm9yAAAAAAAAAgAAAAEAAAADAAAAEAAAAAEAAAACAAAADgAAAC90cnlpbmcgdG8gaW52b2tlIG5vbi1leGlzdGVudCBjb250cmFjdCBmdW5jdGlvbgAAAAAPAAAAD3dlYl9hdXRoX3ZlcmlmeQA=" + ], + "latestLedger": 113558 + } +} diff --git a/src/test/resources/sep45/simulate_transaction_failed.json b/src/test/resources/sep45/simulate_transaction_failed.json new file mode 100644 index 000000000..10312cf81 --- /dev/null +++ b/src/test/resources/sep45/simulate_transaction_failed.json @@ -0,0 +1,8 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "error": "Signature verification failed", + "latestLedger": 14245 + } +} diff --git a/src/test/resources/sep45/simulate_transaction_success.json b/src/test/resources/sep45/simulate_transaction_success.json new file mode 100644 index 000000000..c3daaa91b --- /dev/null +++ b/src/test/resources/sep45/simulate_transaction_success.json @@ -0,0 +1,11 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "transactionData": "AAAAAAAAAAIAAAAGAAAAAem354u9STQWq5b3Ed1j9tOemvL7xV0NPwhn4gXg0AP8AAAAFAAAAAEAAAAH8dTe2OoI0BnhlDbH0fWvXmvprkBvBAgKIcL9busuuMEAAAABAAAABgAAAAHpt+eLvUk0FquW9xHdY/bTnpry+8VdDT8IZ+IF4NAD/AAAABAAAAABAAAAAgAAAA8AAAAHQ291bnRlcgAAAAASAAAAAAAAAABYt8SiyPKXqo89JHEoH9/M7K/kjlZjMT7BjhKnPsqYoQAAAAEAHifGAAAFlAAAAIgAAAAAAAAAAg==", + "minResourceFee": "58181", + "events": [], + "results": [], + "latestLedger": 14245 + } +}