diff --git a/CHANGELOG.md b/CHANGELOG.md index 65dbf86f0..b0b4dce83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,27 @@ # Changelog ## Pending + +### Update - chore: upgrade generated XDR definitions to Protocol 27. +- feat: add CAP-0071 (Protocol 27) Soroban authorization support. + - New credential types (from the Protocol 27 XDR): + - `SOROBAN_CREDENTIALS_ADDRESS_V2` (CAP-71-02) — same fields as the legacy `ADDRESS`, but the signed payload is bound to the signer's address. + - `SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES` (CAP-71-01) — delegated / multi-party signing via a (possibly nested) tree of delegate signatures. + - `Auth.authorizeEntry`: + - Signs all three address-based credential types, selecting the signature payload from the credential type: legacy `ADDRESS` keeps the non-address-bound preimage; `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` sign the new address-bound `ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS` preimage. + - Gains an optional `forAddress` parameter that writes the signature into a specific (possibly nested) delegate node. All signers of one delegated entry sign the same payload, bound to the top-level address. + - `Auth.authorizeInvocation`: + - Still builds legacy `ADDRESS` entries by default, so its output stays valid on every network regardless of protocol 27 activation. + - New `credentialsType` overloads opt in to `ADDRESS_V2`. The default will flip to V2 once Protocol 28 makes it mandatory. + - New helpers in `Auth`: + - `buildAuthorizationEntryPreimage` — builds the exact payload a signer must sign for a given entry. + - `buildWithDelegatesEntry` / `Auth.DelegateSignature` — wrap an `ADDRESS`/`ADDRESS_V2` entry together with delegate signers, sorting each delegates level by address and rejecting duplicates, as the protocol requires. + - `getAddressCredentials` — extracts the inner `SorobanAddressCredentials` from any address-based credential type. + - `AssembledTransaction`: `signAuthEntries` and `needsNonInvokerSigningBy` handle all address-based credential types. + - SEP-45 (`Sep45Challenge`): + - Challenge parsing and building accept `ADDRESS_V2` entries in addition to the legacy type; delegated entries are rejected. + - Challenge building fails fast on unsupported credential types instead of passing the entries through unsigned. ## 3.1.0 diff --git a/src/main/java/org/stellar/sdk/Auth.java b/src/main/java/org/stellar/sdk/Auth.java index 179f3ab26..faf14ab5a 100644 --- a/src/main/java/org/stellar/sdk/Auth.java +++ b/src/main/java/org/stellar/sdk/Auth.java @@ -2,21 +2,30 @@ import java.io.IOException; import java.security.SecureRandom; +import java.util.AbstractMap; +import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.Builder; import lombok.Value; +import org.jetbrains.annotations.Nullable; import org.stellar.sdk.exception.UnexpectedException; import org.stellar.sdk.scval.Scv; import org.stellar.sdk.xdr.EnvelopeType; import org.stellar.sdk.xdr.Hash; import org.stellar.sdk.xdr.HashIDPreimage; import org.stellar.sdk.xdr.Int64; +import org.stellar.sdk.xdr.SCAddress; import org.stellar.sdk.xdr.SCVal; import org.stellar.sdk.xdr.SorobanAddressCredentials; +import org.stellar.sdk.xdr.SorobanAddressCredentialsWithDelegates; import org.stellar.sdk.xdr.SorobanAuthorizationEntry; import org.stellar.sdk.xdr.SorobanAuthorizedInvocation; import org.stellar.sdk.xdr.SorobanCredentials; import org.stellar.sdk.xdr.SorobanCredentialsType; +import org.stellar.sdk.xdr.SorobanDelegateSignature; import org.stellar.sdk.xdr.Uint32; import org.stellar.sdk.xdr.XdrUnsignedInteger; @@ -52,13 +61,46 @@ public class Auth { */ public static SorobanAuthorizationEntry authorizeEntry( String entry, KeyPair signer, Long validUntilLedgerSeq, Network network) { + return authorizeEntry(entry, signer, validUntilLedgerSeq, network, null); + } + + /** + * Actually authorizes an existing authorization entry using the given the credentials and + * expiration details, returning a signed copy. + * + *

This "fills out" the authorization entry with a signature, indicating to the {@link + * org.stellar.sdk.operations.InvokeHostFunctionOperation} it's attached to that: + * + *

+ * + * @param entry a base64 encoded unsigned Soroban authorization entry + * @param signer a {@link KeyPair} which should correspond to the address in the `entry` + * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this + * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired) + * @param network the network is incorprated into the signature + * @param forAddress which credential node the signature should be written to, see {@link + * Auth#authorizeEntry(SorobanAuthorizationEntry, Signer, Long, Network, String)} + * @return a signed Soroban authorization entry + */ + public static SorobanAuthorizationEntry authorizeEntry( + String entry, + KeyPair signer, + Long validUntilLedgerSeq, + Network network, + @Nullable String forAddress) { SorobanAuthorizationEntry entryXdr; try { entryXdr = SorobanAuthorizationEntry.fromXdrBase64(entry); } catch (IOException e) { throw new IllegalArgumentException("Unable to convert entry to SorobanAuthorizationEntry", e); } - return authorizeEntry(entryXdr, signer, validUntilLedgerSeq, network); + return authorizeEntry(entryXdr, signer, validUntilLedgerSeq, network, forAddress); } /** @@ -76,7 +118,7 @@ public static SorobanAuthorizationEntry authorizeEntry( *
  • until a particular ledger sequence is reached. * * - * @param entry a base64 encoded unsigned Soroban authorization entry + * @param entry an unsigned Soroban authorization entry * @param signer a {@link KeyPair} which should correspond to the address in the `entry` * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired) @@ -85,6 +127,39 @@ public static SorobanAuthorizationEntry authorizeEntry( */ public static SorobanAuthorizationEntry authorizeEntry( SorobanAuthorizationEntry entry, KeyPair signer, Long validUntilLedgerSeq, Network network) { + return authorizeEntry(entry, signer, validUntilLedgerSeq, network, null); + } + + /** + * Actually authorizes an existing authorization entry using the given the credentials and + * expiration details, returning a signed copy. + * + *

    This "fills out" the authorization entry with a signature, indicating to the {@link + * org.stellar.sdk.operations.InvokeHostFunctionOperation} it's attached to that: + * + *

    + * + * @param entry an unsigned Soroban authorization entry + * @param signer a {@link KeyPair} which should correspond to the address in the `entry` + * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this + * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired) + * @param network the network is incorprated into the signature + * @param forAddress which credential node the signature should be written to, see {@link + * Auth#authorizeEntry(SorobanAuthorizationEntry, Signer, Long, Network, String)} + * @return a signed Soroban authorization entry + */ + public static SorobanAuthorizationEntry authorizeEntry( + SorobanAuthorizationEntry entry, + KeyPair signer, + Long validUntilLedgerSeq, + Network network, + @Nullable String forAddress) { Signer entrySigner = preimage -> { byte[] data; @@ -98,7 +173,7 @@ public static SorobanAuthorizationEntry authorizeEntry( return new Signature(signer.getAccountId(), signature); }; - return authorizeEntry(entry, entrySigner, validUntilLedgerSeq, network); + return authorizeEntry(entry, entrySigner, validUntilLedgerSeq, network, forAddress); } /** @@ -126,13 +201,47 @@ public static SorobanAuthorizationEntry authorizeEntry( */ public static SorobanAuthorizationEntry authorizeEntry( String entry, Signer signer, Long validUntilLedgerSeq, Network network) { + return authorizeEntry(entry, signer, validUntilLedgerSeq, network, null); + } + + /** + * Actually authorizes an existing authorization entry using the given the credentials and + * expiration details, returning a signed copy. + * + *

    This "fills out" the authorization entry with a signature, indicating to the {@link + * org.stellar.sdk.operations.InvokeHostFunctionOperation} it's attached to that: + * + *

    + * + * @param entry a base64 encoded unsigned Soroban authorization entry + * @param signer A function which takes a payload (a {@link HashIDPreimage}) and returns the + * signature of the hash of the raw payload bytes, see {@link Signer} + * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this + * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired) + * @param network the network is incorprated into the signature + * @param forAddress which credential node the signature should be written to, see {@link + * Auth#authorizeEntry(SorobanAuthorizationEntry, Signer, Long, Network, String)} + * @return a signed Soroban authorization entry + */ + public static SorobanAuthorizationEntry authorizeEntry( + String entry, + Signer signer, + Long validUntilLedgerSeq, + Network network, + @Nullable String forAddress) { SorobanAuthorizationEntry entryXdr; try { entryXdr = SorobanAuthorizationEntry.fromXdrBase64(entry); } catch (IOException e) { throw new IllegalArgumentException("Unable to convert entry to SorobanAuthorizationEntry", e); } - return authorizeEntry(entryXdr, signer, validUntilLedgerSeq, network); + return authorizeEntry(entryXdr, signer, validUntilLedgerSeq, network, forAddress); } /** @@ -160,6 +269,55 @@ public static SorobanAuthorizationEntry authorizeEntry( */ public static SorobanAuthorizationEntry authorizeEntry( SorobanAuthorizationEntry entry, Signer signer, Long validUntilLedgerSeq, Network network) { + return authorizeEntry(entry, signer, validUntilLedgerSeq, network, null); + } + + /** + * Actually authorizes an existing authorization entry using the given the credentials and + * expiration details, returning a signed copy. + * + *

    This "fills out" the authorization entry with a signature, indicating to the {@link + * org.stellar.sdk.operations.InvokeHostFunctionOperation} it's attached to that: + * + *

    + * + *

    All address-based credential types are supported: {@code SOROBAN_CREDENTIALS_ADDRESS}, + * {@code SOROBAN_CREDENTIALS_ADDRESS_V2}, and {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES}. + * The signature payload differs per type, see {@link + * Auth#buildAuthorizationEntryPreimage(SorobanAuthorizationEntry, long, Network)}. Source-account + * credentials are returned unchanged (they are covered by the transaction envelope signature). + * + * @param entry an unsigned Soroban authorization entry + * @param signer A function which takes a payload (a {@link HashIDPreimage}) and returns the + * signature of the hash of the raw payload bytes, see {@link Signer} + * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this + * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired) + * @param network the network is incorprated into the signature + * @param forAddress which credential node the signature should be written to. Only relevant for + * {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES}, where a single entry can be signed by + * the top-level account and/or any of its (possibly nested) delegates. Per CAP-71-01 every + * one of these signers signs the same payload (bound to the top-level address), so + * the signature produced here is written to whichever node(s) carry {@code forAddress}. + * Because that shared payload commits to {@code validUntilLedgerSeq}, every signer of one + * entry must use the same value — signing with a different value invalidates the signatures + * collected so far. When {@code null}, the signature is written to the top-level credentials, + * which preserves the behavior for {@code SOROBAN_CREDENTIALS_ADDRESS} / {@code + * SOROBAN_CREDENTIALS_ADDRESS_V2} and for accounts whose signing key differs from the + * credential address (e.g. multisig). + * @return a signed Soroban authorization entry + */ + public static SorobanAuthorizationEntry authorizeEntry( + SorobanAuthorizationEntry entry, + Signer signer, + Long validUntilLedgerSeq, + Network network, + @Nullable String forAddress) { SorobanAuthorizationEntry clone; try { clone = SorobanAuthorizationEntry.fromXdrByteArray(entry.toXdrByteArray()); @@ -168,25 +326,23 @@ public static SorobanAuthorizationEntry authorizeEntry( } if (clone.getCredentials().getDiscriminant() - != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) { + == SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) { return clone; } - SorobanAddressCredentials addressCredentials = clone.getCredentials().getAddress(); + SorobanAddressCredentials addressCredentials = getAddressCredentials(clone.getCredentials()); + if (addressCredentials == null) { + throw new IllegalArgumentException( + "Unsupported credentials type: " + clone.getCredentials().getDiscriminant()); + } + + // Set the expiration before building the preimage, so the hash that gets signed commits to + // the same expiration ledger stored in the credentials. Otherwise the network reconstructs + // the preimage from the (updated) credentials and the signature no longer matches. addressCredentials.setSignatureExpirationLedger( new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq))); - HashIDPreimage preimage = - HashIDPreimage.builder() - .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) - .sorobanAuthorization( - HashIDPreimage.HashIDPreimageSorobanAuthorization.builder() - .networkID(new Hash(network.getNetworkId())) - .nonce(addressCredentials.getNonce()) - .invocation(clone.getRootInvocation()) - .signatureExpirationLedger(addressCredentials.getSignatureExpirationLedger()) - .build()) - .build(); + HashIDPreimage preimage = buildAuthorizationEntryPreimage(clone, validUntilLedgerSeq, network); Signature signature = signer.sign(preimage); @@ -213,7 +369,22 @@ public static SorobanAuthorizationEntry authorizeEntry( put(Scv.toSymbol("signature"), Scv.toBytes(signature.getSignature())); } }); - addressCredentials.setSignature(Scv.toVec(Collections.singleton(sigScVal))); + SCVal signatureScVal = Scv.toVec(Collections.singleton(sigScVal)); + + // CAP-71-01: the signature payload is shared across the top-level address and every + // (possibly nested) delegate, so this signer's signature is written to whichever credential + // node(s) carry `forAddress`. When no `forAddress` is given the signature goes to the + // top-level credentials. + if (forAddress == null) { + addressCredentials.setSignature(signatureScVal); + } else { + SCAddress forScAddress = new Address(forAddress).toSCAddress(); + int filled = fillMatchingSignatureNodes(clone.getCredentials(), forScAddress, signatureScVal); + if (filled == 0) { + throw new IllegalArgumentException( + "the authorization entry has no credential node for address " + forAddress); + } + } return clone; } @@ -231,6 +402,12 @@ public static SorobanAuthorizationEntry authorizeEntry( *

    This is in contrast to {@link Auth#authorizeEntry}, which signs an existing entry "in * place". * + *

    The returned entry uses legacy {@code SOROBAN_CREDENTIALS_ADDRESS} credentials, which are + * valid on every network. To opt in to the address-bound {@code SOROBAN_CREDENTIALS_ADDRESS_V2} + * credentials (CAP-71-02, requires a protocol 27 network), use {@link + * Auth#authorizeInvocation(KeyPair, Long, SorobanAuthorizedInvocation, Network, + * SorobanCredentialsType)}. The default will flip to V2 once protocol 28 makes it mandatory. + * * @param signer a {@link KeyPair} used to sign the entry * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired) @@ -244,6 +421,47 @@ public static SorobanAuthorizationEntry authorizeInvocation( Long validUntilLedgerSeq, SorobanAuthorizedInvocation invocation, Network network) { + return authorizeInvocation( + signer, + validUntilLedgerSeq, + invocation, + network, + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS); + } + + /** + * This builds an entry from scratch, allowing you to express authorization as a function of: + * + *

    + * + *

    This is in contrast to {@link Auth#authorizeEntry}, which signs an existing entry "in + * place". + * + * @param signer a {@link KeyPair} used to sign the entry + * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this + * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired) + * @param invocation invocation the invocation tree that we're authorizing (likely, this comes + * from transaction simulation) + * @param network the network is incorprated into the signature + * @param credentialsType the credential type for the new entry, either the legacy {@code + * SOROBAN_CREDENTIALS_ADDRESS} (the default of the shorter overloads, valid on every network) + * or the address-bound {@code SOROBAN_CREDENTIALS_ADDRESS_V2} (CAP-71-02, requires a protocol + * 27 network). To build a {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entry, use + * {@link Auth#buildWithDelegatesEntry(SorobanAuthorizationEntry, long, List, SCVal)} instead + * @return a signed Soroban authorization entry + */ + public static SorobanAuthorizationEntry authorizeInvocation( + KeyPair signer, + Long validUntilLedgerSeq, + SorobanAuthorizedInvocation invocation, + Network network, + SorobanCredentialsType credentialsType) { Signer entrySigner = preimage -> { try { @@ -255,7 +473,12 @@ public static SorobanAuthorizationEntry authorizeInvocation( } }; return authorizeInvocation( - entrySigner, signer.getAccountId(), validUntilLedgerSeq, invocation, network); + entrySigner, + signer.getAccountId(), + validUntilLedgerSeq, + invocation, + network, + credentialsType); } /** @@ -272,6 +495,12 @@ public static SorobanAuthorizationEntry authorizeInvocation( *

    This is in contrast to {@link Auth#authorizeEntry}, which signs an existing entry "in * place". * + *

    The returned entry uses legacy {@code SOROBAN_CREDENTIALS_ADDRESS} credentials, which are + * valid on every network. To opt in to the address-bound {@code SOROBAN_CREDENTIALS_ADDRESS_V2} + * credentials (CAP-71-02, requires a protocol 27 network), use {@link + * Auth#authorizeInvocation(Signer, String, Long, SorobanAuthorizedInvocation, Network, + * SorobanCredentialsType)}. The default will flip to V2 once protocol 28 makes it mandatory. + * * @param signer A function which takes a payload (a {@link HashIDPreimage}) and returns the * signature of the hash of the raw payload bytes, see {@link Signer} * @param publicKey the public identity of the signer @@ -288,26 +517,371 @@ public static SorobanAuthorizationEntry authorizeInvocation( Long validUntilLedgerSeq, SorobanAuthorizedInvocation invocation, Network network) { + return authorizeInvocation( + signer, + publicKey, + validUntilLedgerSeq, + invocation, + network, + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS); + } + + /** + * This builds an entry from scratch, allowing you to express authorization as a function of: + * + *

    + * + *

    This is in contrast to {@link Auth#authorizeEntry}, which signs an existing entry "in + * place". + * + * @param signer A function which takes a payload (a {@link HashIDPreimage}) and returns the + * signature of the hash of the raw payload bytes, see {@link Signer} + * @param publicKey the public identity of the signer + * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this + * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired) + * @param invocation invocation the invocation tree that we're authorizing (likely, this comes + * from transaction simulation) + * @param network the network is incorprated into the signature + * @param credentialsType the credential type for the new entry, either the legacy {@code + * SOROBAN_CREDENTIALS_ADDRESS} (the default of the shorter overloads, valid on every network) + * or the address-bound {@code SOROBAN_CREDENTIALS_ADDRESS_V2} (CAP-71-02, requires a protocol + * 27 network). To build a {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entry, use + * {@link Auth#buildWithDelegatesEntry(SorobanAuthorizationEntry, long, List, SCVal)} instead + * @return a signed Soroban authorization entry + */ + public static SorobanAuthorizationEntry authorizeInvocation( + Signer signer, + String publicKey, + Long validUntilLedgerSeq, + SorobanAuthorizedInvocation invocation, + Network network, + SorobanCredentialsType credentialsType) { long nonce = new SecureRandom().nextLong(); + SorobanAddressCredentials addressCredentials = + SorobanAddressCredentials.builder() + .address(new Address(publicKey).toSCAddress()) + .nonce(new Int64(nonce)) + .signatureExpirationLedger(new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq))) + .signature(Scv.toVoid()) + .build(); + SorobanCredentials credentials; + if (credentialsType == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) { + credentials = + SorobanCredentials.builder() + .discriminant(credentialsType) + .address(addressCredentials) + .build(); + } else if (credentialsType == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) { + credentials = + SorobanCredentials.builder() + .discriminant(credentialsType) + .addressV2(addressCredentials) + .build(); + } else { + throw new IllegalArgumentException( + "credentialsType must be SOROBAN_CREDENTIALS_ADDRESS or SOROBAN_CREDENTIALS_ADDRESS_V2; use buildWithDelegatesEntry to build SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES entries"); + } SorobanAuthorizationEntry entry = SorobanAuthorizationEntry.builder() - .credentials( - SorobanCredentials.builder() - .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) - .address( - SorobanAddressCredentials.builder() - .address(new Address(publicKey).toSCAddress()) - .nonce(new Int64(nonce)) - .signatureExpirationLedger( - new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq))) - .signature(Scv.toVoid()) - .build()) - .build()) + .credentials(credentials) .rootInvocation(invocation) .build(); return authorizeEntry(entry, signer, validUntilLedgerSeq, network); } + /** + * Extracts the {@link SorobanAddressCredentials} from any address-based Soroban credential, + * regardless of which credential type variant is used. + * + *

    This unifies access across {@code SOROBAN_CREDENTIALS_ADDRESS}, {@code + * SOROBAN_CREDENTIALS_ADDRESS_V2} (which carries identical fields but binds the address into the + * signature payload, CAP-71-02), and {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} (which + * wraps the same address credentials alongside a set of delegate signatures, CAP-71-01). + * + * @param credentials the credentials to inspect + * @return the inner address credentials, or {@code null} for source-account credentials (which + * carry no address payload) + */ + @Nullable + public static SorobanAddressCredentials getAddressCredentials(SorobanCredentials credentials) { + switch (credentials.getDiscriminant()) { + case SOROBAN_CREDENTIALS_ADDRESS: + return credentials.getAddress(); + case SOROBAN_CREDENTIALS_ADDRESS_V2: + return credentials.getAddressV2(); + case SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + return credentials.getAddressWithDelegates().getAddressCredentials(); + default: + return null; + } + } + + /** + * Builds the {@link HashIDPreimage} whose hash a signer must sign to authorize {@code entry}. + * This is the low-level signature payload used by {@link Auth#authorizeEntry}, exposed for + * callers that drive signing themselves — most notably for {@code + * SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES}, where the client (not simulation) decides which + * delegates sign and how. + * + *

    For {@code SOROBAN_CREDENTIALS_ADDRESS} this is the legacy, non-address-bound {@code + * ENVELOPE_TYPE_SOROBAN_AUTHORIZATION} preimage. For {@code SOROBAN_CREDENTIALS_ADDRESS_V2} and + * {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} it is the address-bound {@code + * ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS} preimage (CAP-71). For the delegates variant + * this single payload — bound to the top-level address — is what the top-level account + * and every (nested) delegate each sign. + * + *

    To get the raw bytes to sign, hash the XDR: {@code Util.hash(preimage.toXdrByteArray())}. + * + * @param entry the authorization entry to build the payload for + * @param validUntilLedgerSeq the expiration ledger committed into the payload (must match the + * {@code signatureExpirationLedger} on the credentials you submit) + * @param network the network whose id is mixed into the payload + * @return the preimage to hash and sign + * @throws IllegalArgumentException if {@code entry} carries source-account or otherwise + * non-address credentials + */ + public static HashIDPreimage buildAuthorizationEntryPreimage( + SorobanAuthorizationEntry entry, long validUntilLedgerSeq, Network network) { + SorobanCredentials credentials = entry.getCredentials(); + SorobanAddressCredentials addressCredentials = getAddressCredentials(credentials); + if (addressCredentials == null) { + throw new IllegalArgumentException( + "cannot build a signature payload for credentials type: " + + credentials.getDiscriminant()); + } + + Uint32 signatureExpirationLedger = new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)); + switch (credentials.getDiscriminant()) { + // legacy address credentials are not address-bound + case SOROBAN_CREDENTIALS_ADDRESS: + return HashIDPreimage.builder() + .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) + .sorobanAuthorization( + HashIDPreimage.HashIDPreimageSorobanAuthorization.builder() + .networkID(new Hash(network.getNetworkId())) + .nonce(addressCredentials.getNonce()) + .signatureExpirationLedger(signatureExpirationLedger) + .invocation(entry.getRootInvocation()) + .build()) + .build(); + // ADDRESS_V2 and ADDRESS_WITH_DELEGATES bind the address into the signed payload via the + // WithAddress preimage (CAP-71) + case SOROBAN_CREDENTIALS_ADDRESS_V2: + case SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES: + return HashIDPreimage.builder() + .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS) + .sorobanAuthorizationWithAddress( + HashIDPreimage.HashIDPreimageSorobanAuthorizationWithAddress.builder() + .networkID(new Hash(network.getNetworkId())) + .nonce(addressCredentials.getNonce()) + .signatureExpirationLedger(signatureExpirationLedger) + .address(addressCredentials.getAddress()) + .invocation(entry.getRootInvocation()) + .build()) + .build(); + default: + throw new IllegalArgumentException( + "unsupported credentials type: " + credentials.getDiscriminant()); + } + } + + /** + * Builds a {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} authorization entry (CAP-71-01) by + * wrapping the address credentials of an existing {@code SOROBAN_CREDENTIALS_ADDRESS} or {@code + * SOROBAN_CREDENTIALS_ADDRESS_V2} entry (e.g. one returned by simulation) together with a + * caller-provided set of delegate signers. + * + *

    Simulation never emits the delegates variant on its own — which accounts use delegated + * authentication is account-specific policy known only to the client (much like a multisig + * policy). This helper just assembles the wrapper XDR; you supply the delegate tree (addresses + * and, optionally, signatures). To produce the signatures, build the shared payload with {@link + * Auth#buildAuthorizationEntryPreimage(SorobanAuthorizationEntry, long, Network)} on the returned + * entry and sign it, or fill each node afterwards with {@link + * Auth#authorizeEntry(SorobanAuthorizationEntry, Signer, Long, Network, String)} (passing the + * signer's address as {@code forAddress} and the same {@code validUntilLedgerSeq} as given here — + * the shared payload commits to it). + * + *

    Which delegates must actually sign is decided by the top-level account contract: per + * CAP-71-01 the host verifies a listed delegate only when the account's {@code __check_auth} + * consumes it, so a delegate left with its void placeholder is valid as long as the account's + * policy does not require it. + * + *

    Each delegates list (the top-level set and every {@code nestedDelegates}) is sorted by + * address in ascending order, and duplicate addresses within a list are rejected, as the protocol + * requires (CAP-71-01) — otherwise the host rejects the entry. + * + * @param entry an existing {@code SOROBAN_CREDENTIALS_ADDRESS} or {@code + * SOROBAN_CREDENTIALS_ADDRESS_V2} entry whose address credentials should be wrapped + * @param validUntilLedgerSeq the expiration ledger sequence stored on the top-level credentials + * @param delegates the delegate signers to attach + * @param signature the top-level account's signature, or {@code null} for an {@code SCV_VOID} + * placeholder, which is valid for accounts that authorize purely via delegated signers + * @return a new {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} authorization entry + * @throws IllegalArgumentException if {@code entry} does not carry {@code ADDRESS} / {@code + * ADDRESS_V2} credentials, or if any delegates list contains a duplicate address + */ + public static SorobanAuthorizationEntry buildWithDelegatesEntry( + SorobanAuthorizationEntry entry, + long validUntilLedgerSeq, + List delegates, + @Nullable SCVal signature) { + SorobanCredentials credentials = entry.getCredentials(); + if (credentials.getDiscriminant() != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS + && credentials.getDiscriminant() != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) { + throw new IllegalArgumentException( + "buildWithDelegatesEntry expects SOROBAN_CREDENTIALS_ADDRESS or SOROBAN_CREDENTIALS_ADDRESS_V2 credentials, got " + + credentials.getDiscriminant()); + } + SorobanAddressCredentials addressCredentials = getAddressCredentials(credentials); + + return SorobanAuthorizationEntry.builder() + .credentials( + SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) + .addressWithDelegates( + SorobanAddressCredentialsWithDelegates.builder() + .addressCredentials( + SorobanAddressCredentials.builder() + .address(addressCredentials.getAddress()) + .nonce(addressCredentials.getNonce()) + .signatureExpirationLedger( + new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq))) + .signature(signature != null ? signature : Scv.toVoid()) + .build()) + .delegates(buildDelegateNodes(delegates)) + .build()) + .build()) + .rootInvocation(entry.getRootInvocation()) + .build(); + } + + /** + * Recursively converts {@link DelegateSignature} descriptors into {@link + * SorobanDelegateSignature} nodes, sorting each level by address and rejecting duplicates + * (CAP-71-01). + */ + private static SorobanDelegateSignature[] buildDelegateNodes( + @Nullable List delegates) { + if (delegates == null || delegates.isEmpty()) { + return new SorobanDelegateSignature[0]; + } + + // Sort keys are the XDR encoding of each address; serialize each address once up front + // instead of inside the comparator. + List> keyedNodes = + new ArrayList<>(delegates.size()); + for (DelegateSignature delegate : delegates) { + SorobanDelegateSignature node = + SorobanDelegateSignature.builder() + .address(new Address(delegate.getAddress()).toSCAddress()) + .signature(delegate.getSignature() != null ? delegate.getSignature() : Scv.toVoid()) + .nestedDelegates(buildDelegateNodes(delegate.getNestedDelegates())) + .build(); + keyedNodes.add( + new AbstractMap.SimpleImmutableEntry<>(scAddressXdrBytes(node.getAddress()), node)); + } + + keyedNodes.sort((a, b) -> compareBytes(a.getKey(), b.getKey())); + + SorobanDelegateSignature[] nodes = new SorobanDelegateSignature[keyedNodes.size()]; + for (int i = 0; i < keyedNodes.size(); i++) { + if (i > 0 && compareBytes(keyedNodes.get(i - 1).getKey(), keyedNodes.get(i).getKey()) == 0) { + throw new IllegalArgumentException( + "duplicate delegate address " + + Address.fromSCAddress(keyedNodes.get(i).getValue().getAddress()).toString()); + } + nodes[i] = keyedNodes.get(i).getValue(); + } + + return nodes; + } + + /** + * Writes {@code signature} to every credential node whose address equals {@code forAddress}: the + * top-level address credentials and, for the delegates variant, each (possibly nested) {@link + * SorobanDelegateSignature}. Returns the number of nodes filled. + */ + private static int fillMatchingSignatureNodes( + SorobanCredentials credentials, SCAddress forAddress, SCVal signature) { + int filled = 0; + SorobanAddressCredentials addressCredentials = getAddressCredentials(credentials); + if (addressCredentials != null && addressCredentials.getAddress().equals(forAddress)) { + addressCredentials.setSignature(signature); + filled++; + } + if (credentials.getDiscriminant() + == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + for (SorobanDelegateSignature delegate : + credentials.getAddressWithDelegates().getDelegates()) { + filled += fillMatchingDelegateNodes(delegate, forAddress, signature); + } + } + return filled; + } + + private static int fillMatchingDelegateNodes( + SorobanDelegateSignature node, SCAddress forAddress, SCVal signature) { + int filled = 0; + if (node.getAddress().equals(forAddress)) { + node.setSignature(signature); + filled++; + } + if (node.getNestedDelegates() != null) { + for (SorobanDelegateSignature nested : node.getNestedDelegates()) { + filled += fillMatchingDelegateNodes(nested, forAddress, signature); + } + } + return filled; + } + + private static byte[] scAddressXdrBytes(SCAddress address) { + try { + return address.toXdrByteArray(); + } catch (IOException e) { + throw new UnexpectedException(e); + } + } + + /** Compares two byte arrays lexicographically, treating bytes as unsigned. */ + private static int compareBytes(byte[] a, byte[] b) { + int minLength = Math.min(a.length, b.length); + for (int i = 0; i < minLength; i++) { + int cmp = Integer.compare(a[i] & 0xff, b[i] & 0xff); + if (cmp != 0) { + return cmp; + } + } + return Integer.compare(a.length, b.length); + } + + /** + * A delegate signer to attach to a {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entry via + * {@link Auth#buildWithDelegatesEntry(SorobanAuthorizationEntry, long, List, SCVal)} (CAP-71-01). + */ + @Value + @Builder + public static class DelegateSignature { + /** The delegate's address ({@code G...} account or {@code C...} contract). */ + String address; + + /** + * The delegate's signature value, or {@code null} for an {@code SCV_VOID} placeholder, which + * you can fill afterwards with {@link Auth#authorizeEntry(SorobanAuthorizationEntry, Signer, + * Long, Network, String)} (passing this address as {@code forAddress}) or by editing the entry + * directly. + */ + @Nullable SCVal signature; + + /** Signers this delegate in turn delegates to (recursive), or {@code null} for none. */ + @Nullable List nestedDelegates; + } + /** A signature, consisting of a public key and a signature. */ @Value public static class Signature { diff --git a/src/main/java/org/stellar/sdk/Sep45Challenge.java b/src/main/java/org/stellar/sdk/Sep45Challenge.java index 793e0d73d..0dedf2cf1 100644 --- a/src/main/java/org/stellar/sdk/Sep45Challenge.java +++ b/src/main/java/org/stellar/sdk/Sep45Challenge.java @@ -6,8 +6,10 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumSet; import java.util.LinkedHashMap; import java.util.List; +import java.util.Set; import lombok.Builder; import lombok.NonNull; import lombok.Value; @@ -49,6 +51,17 @@ public class Sep45Challenge { public static final String NULL_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + // Credential types accepted in SEP-45 challenge entries. CAP-71 (protocol 27): simulation may + // return SOROBAN_CREDENTIALS_ADDRESS_V2 entries in addition to the legacy + // SOROBAN_CREDENTIALS_ADDRESS. Delegated credentials + // (SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) are rejected until the ecosystem defines how + // they interact with SEP-45. + private static final Set ALLOWED_CREDENTIAL_TYPES = + Collections.unmodifiableSet( + EnumSet.of( + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS, + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)); + private Sep45Challenge() { // no instance } @@ -230,15 +243,18 @@ public static SorobanAuthorizationEntries buildChallengeAuthorizationEntries( "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); - } + if (!ALLOWED_CREDENTIAL_TYPES.contains(entry.getCredentials().getDiscriminant())) { + throw new InvalidSep45ChallengeException( + "Unsupported SorobanCredentialsType: " + entry.getCredentials().getDiscriminant()); + } + + // Check if this entry needs to be signed by the server. + Address address = + Address.fromSCAddress(Auth.getAddressCredentials(entry.getCredentials()).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); @@ -472,12 +488,12 @@ public static ChallengeAuthorizationEntries readChallengeAuthorizationEntries( for (SorobanAuthorizationEntry entry : entries) { SorobanCredentials credentials = entry.getCredentials(); - if (credentials.getDiscriminant() != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) { + if (!ALLOWED_CREDENTIAL_TYPES.contains(credentials.getDiscriminant())) { throw new InvalidSep45ChallengeException( - "All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS type"); + "All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS or SOROBAN_CREDENTIALS_ADDRESS_V2 type"); } - SorobanAddressCredentials addressCredentials = credentials.getAddress(); + SorobanAddressCredentials addressCredentials = Auth.getAddressCredentials(credentials); Address credentialAddress = Address.fromSCAddress(addressCredentials.getAddress()); String encodedAddress = credentialAddress.getEncodedAddress(); diff --git a/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java b/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java index 9c3c11b63..a20bbb6b9 100644 --- a/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java +++ b/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java @@ -17,6 +17,7 @@ import lombok.Value; import org.jetbrains.annotations.Nullable; import org.stellar.sdk.*; +import org.stellar.sdk.Auth; import org.stellar.sdk.TimeBounds; import org.stellar.sdk.Transaction; import org.stellar.sdk.contract.exception.*; @@ -149,6 +150,10 @@ public T signAndSubmit(@Nullable KeyPair transactionSigner, boolean force) { /** * Signs the transaction. * + *

    Entries whose credential address is a contract ({@code C...}) are excluded from the + * missing-signature check: contract-account authorization is evaluated on-chain by the account's + * {@code __check_auth} and cannot be verified client-side. + * * @param transactionSigner the keypair to sign the transaction with, or null to use * the signer provided in the constructor * @param force whether to sign and submit even if the transaction is a read call @@ -215,6 +220,13 @@ public AssembledTransaction signAuthEntries(KeyPair authEntriesSigner) { /** * Signs the transaction's authorization entries. * + *

    Only entries whose top-level credential address matches the signer are signed. Delegate + * nodes of {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entries (CAP-71-01) are not + * traversed — which delegates must sign is the account contract's policy. Fill them with {@link + * Auth#authorizeEntry(SorobanAuthorizationEntry, Auth.Signer, Long, Network, String)}, passing + * the delegate's address as {@code forAddress} and the same expiration ledger for every signer of + * one entry (the shared signature payload commits to it). + * * @param authEntriesSigner the keypair to sign the authorization entries with * @param validUntilLedgerSequence the ledger sequence number until which the authorization * entries are valid, or null to set it to the current ledger sequence + 100 @@ -239,14 +251,13 @@ public AssembledTransaction signAuthEntries( for (int i = 0; i < invokeHostFunctionOp.getAuth().size(); i++) { SorobanAuthorizationEntry e = invokeHostFunctionOp.getAuth().get(i); - if (SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT.equals( - e.getCredentials().getDiscriminant())) { + SorobanAddressCredentials addressCredentials = Auth.getAddressCredentials(e.getCredentials()); + if (addressCredentials == null) { + // source-account credentials don't need an explicit signature here, since the tx + // envelope is already signed by the source account continue; } - if (e.getCredentials().getAddress() == null) { - throw new IllegalStateException("Expected address in credentials"); - } - if (!Address.fromSCAddress(e.getCredentials().getAddress().getAddress()) + if (!Address.fromSCAddress(addressCredentials.getAddress()) .toString() .equals(authEntriesSigner.getAccountId())) { continue; @@ -264,6 +275,12 @@ public AssembledTransaction signAuthEntries( /** * Get the addresses that need to sign the authorization entries. * + *

    Only the top-level address credentials of each entry are considered. Delegate signers of + * {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entries (CAP-71-01) are not reported: which + * delegates must sign is the account contract's policy, which the SDK cannot know — per CAP-71-01 + * a listed delegate is only verified when the account's {@code __check_auth} consumes it, so a + * delegate left with a void signature may be perfectly valid. + * * @param includeAlreadySigned whether to include addresses that have already signed the * authorization entries * @return The addresses that need to sign the authorization entries. @@ -281,18 +298,17 @@ public Set needsNonInvokerSigningBy(boolean includeAlreadySigned) { InvokeHostFunctionOperation invokeHostFunctionOp = (InvokeHostFunctionOperation) op; return invokeHostFunctionOp.getAuth().stream() + .map(entry -> Auth.getAddressCredentials(entry.getCredentials())) .filter( - entry -> - entry.getCredentials().getDiscriminant() - == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) - .filter( - entry -> - includeAlreadySigned - || entry.getCredentials().getAddress().getSignature().getDiscriminant() - == SCValType.SCV_VOID) + addressCredentials -> + // skip source-account credentials (no address payload), which are covered by + // the envelope signature on the source account + addressCredentials != null + && (includeAlreadySigned + || addressCredentials.getSignature().getDiscriminant() + == SCValType.SCV_VOID)) .map( - entry -> - Address.fromSCAddress(entry.getCredentials().getAddress().getAddress()).toString()) + addressCredentials -> Address.fromSCAddress(addressCredentials.getAddress()).toString()) .collect(Collectors.toSet()); } diff --git a/src/test/java/org/stellar/sdk/AuthTest.java b/src/test/java/org/stellar/sdk/AuthTest.java index 793c13847..cdb321398 100644 --- a/src/test/java/org/stellar/sdk/AuthTest.java +++ b/src/test/java/org/stellar/sdk/AuthTest.java @@ -1,10 +1,14 @@ package org.stellar.sdk; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import java.io.IOException; +import java.util.Arrays; import java.util.Collections; import java.util.LinkedHashMap; import org.junit.Test; @@ -14,15 +18,18 @@ import org.stellar.sdk.xdr.HashIDPreimage; import org.stellar.sdk.xdr.Int64; import org.stellar.sdk.xdr.InvokeContractArgs; +import org.stellar.sdk.xdr.SCAddress; import org.stellar.sdk.xdr.SCVal; import org.stellar.sdk.xdr.SCValType; import org.stellar.sdk.xdr.SorobanAddressCredentials; +import org.stellar.sdk.xdr.SorobanAddressCredentialsWithDelegates; 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.SorobanDelegateSignature; import org.stellar.sdk.xdr.Uint32; import org.stellar.sdk.xdr.XdrUnsignedInteger; @@ -806,4 +813,828 @@ public void testSignAuthorizeEntryWithFunctionSignerNotEqualCredentialAddress() assertEquals(expectedEntry, signedEntry); assertNotSame(entry, signedEntry); } + + @Test + public void testSignAuthorizeEntryWithAddressV2Credentials() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + + SorobanAuthorizedInvocation invocation = buildInvocation(); + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(invocation) + .build(); + + SorobanAuthorizationEntry signedEntry = + Auth.authorizeEntry(entry, signer, validUntilLedgerSeq, network); + + // CAP-71-02: the V2 payload is the address-bound + // ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS preimage. + HashIDPreimage preimage = + HashIDPreimage.builder() + .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS) + .sorobanAuthorizationWithAddress( + HashIDPreimage.HashIDPreimageSorobanAuthorizationWithAddress.builder() + .networkID(new Hash(network.getNetworkId())) + .nonce(new Int64(123456789L)) + .signatureExpirationLedger( + new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq))) + .address(new Address(signer.getAccountId()).toSCAddress()) + .invocation(invocation) + .build()) + .build(); + byte[] signature = signer.sign(Util.hash(preimage.toXdrByteArray())); + + SorobanCredentials expectedCredentials = + SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) + .addressV2( + SorobanAddressCredentials.builder() + .address(new Address(signer.getAccountId()).toSCAddress()) + .nonce(new Int64(123456789L)) + .signatureExpirationLedger( + new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq))) + .signature(buildSignatureScVal(signer, signature)) + .build()) + .build(); + SorobanAuthorizationEntry expectedEntry = + SorobanAuthorizationEntry.builder() + .credentials(expectedCredentials) + .rootInvocation(invocation) + .build(); + assertEquals(expectedEntry, signedEntry); + assertNotSame(entry, signedEntry); + } + + @Test + public void testBuildAuthorizationEntryPreimageWithAddressCredentials() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + SorobanAuthorizedInvocation invocation = buildInvocation(); + + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressCredentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(invocation) + .build(); + + HashIDPreimage preimage = + Auth.buildAuthorizationEntryPreimage(entry, validUntilLedgerSeq, network); + assertEquals(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, preimage.getDiscriminant()); + assertEquals(new Int64(123456789L), preimage.getSorobanAuthorization().getNonce()); + assertEquals(invocation, preimage.getSorobanAuthorization().getInvocation()); + assertEquals( + validUntilLedgerSeq, + preimage + .getSorobanAuthorization() + .getSignatureExpirationLedger() + .getUint32() + .getNumber() + .longValue()); + } + + @Test + public void testBuildAuthorizationEntryPreimageWithAddressV2Credentials() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + SorobanAuthorizedInvocation invocation = buildInvocation(); + + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(invocation) + .build(); + + HashIDPreimage preimage = + Auth.buildAuthorizationEntryPreimage(entry, validUntilLedgerSeq, network); + assertEquals( + EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS, preimage.getDiscriminant()); + assertEquals( + new Address(signer.getAccountId()).toSCAddress(), + preimage.getSorobanAuthorizationWithAddress().getAddress()); + assertEquals(new Int64(123456789L), preimage.getSorobanAuthorizationWithAddress().getNonce()); + assertEquals(invocation, preimage.getSorobanAuthorizationWithAddress().getInvocation()); + assertEquals( + validUntilLedgerSeq, + preimage + .getSorobanAuthorizationWithAddress() + .getSignatureExpirationLedger() + .getUint32() + .getNumber() + .longValue()); + } + + @Test + public void testBuildAuthorizationEntryPreimageWithSourceAccountCredentialsThrows() { + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) + .build()) + .rootInvocation(buildInvocation()) + .build(); + try { + Auth.buildAuthorizationEntryPreimage(entry, 654656L, Network.TESTNET); + fail(); + } catch (IllegalArgumentException ignored) { + } + } + + @Test + public void testAddressAndAddressV2ProduceDifferentPayloads() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + SorobanAuthorizedInvocation invocation = buildInvocation(); + SCAddress address = new Address(signer.getAccountId()).toSCAddress(); + + SorobanAuthorizationEntry legacyEntry = + SorobanAuthorizationEntry.builder() + .credentials(buildAddressCredentials(address, 0L)) + .rootInvocation(invocation) + .build(); + SorobanAuthorizationEntry v2Entry = + SorobanAuthorizationEntry.builder() + .credentials(buildAddressV2Credentials(address, 0L)) + .rootInvocation(invocation) + .build(); + + byte[] legacyPayload = + Util.hash( + Auth.buildAuthorizationEntryPreimage(legacyEntry, validUntilLedgerSeq, network) + .toXdrByteArray()); + byte[] v2Payload = + Util.hash( + Auth.buildAuthorizationEntryPreimage(v2Entry, validUntilLedgerSeq, network) + .toXdrByteArray()); + assertFalse(Arrays.equals(legacyPayload, v2Payload)); + } + + @Test + public void testBuildAuthorizationEntryPreimageMatchesAuthorizeEntryPayload() { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(buildInvocation()) + .build(); + + HashIDPreimage[] captured = new HashIDPreimage[1]; + Auth.Signer entrySigner = + preimage -> { + captured[0] = preimage; + byte[] data; + try { + data = preimage.toXdrByteArray(); + } catch (IOException e) { + throw new IllegalArgumentException("Unable to convert preimage to bytes", e); + } + return new Auth.Signature(signer.getAccountId(), signer.sign(Util.hash(data))); + }; + + Auth.authorizeEntry(entry, entrySigner, validUntilLedgerSeq, network); + assertEquals( + Auth.buildAuthorizationEntryPreimage(entry, validUntilLedgerSeq, network), captured[0]); + } + + @Test + public void testSignAuthorizeEntryWithDelegatesCredentialsSignsTopLevel() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair delegate = KeyPair.fromAccountId(StrKey.encodeEd25519PublicKey(new byte[32])); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + SorobanAuthorizedInvocation invocation = buildInvocation(); + + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildWithDelegatesCredentials( + new Address(signer.getAccountId()).toSCAddress(), + 0L, + new SCAddress[] {new Address(delegate.getAccountId()).toSCAddress()})) + .rootInvocation(invocation) + .build(); + + SorobanAuthorizationEntry signedEntry = + Auth.authorizeEntry(entry, signer, validUntilLedgerSeq, network); + + // The payload is bound to the *top-level* address via the WithAddress preimage. + HashIDPreimage preimage = + HashIDPreimage.builder() + .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS) + .sorobanAuthorizationWithAddress( + HashIDPreimage.HashIDPreimageSorobanAuthorizationWithAddress.builder() + .networkID(new Hash(network.getNetworkId())) + .nonce(new Int64(123456789L)) + .signatureExpirationLedger( + new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq))) + .address(new Address(signer.getAccountId()).toSCAddress()) + .invocation(invocation) + .build()) + .build(); + byte[] signature = signer.sign(Util.hash(preimage.toXdrByteArray())); + + SorobanAddressCredentialsWithDelegates withDelegates = + signedEntry.getCredentials().getAddressWithDelegates(); + // top-level credentials carry the signature ... + assertEquals( + buildSignatureScVal(signer, signature), + withDelegates.getAddressCredentials().getSignature()); + assertEquals( + validUntilLedgerSeq, + withDelegates + .getAddressCredentials() + .getSignatureExpirationLedger() + .getUint32() + .getNumber() + .longValue()); + // ... while the delegate node remains untouched + assertEquals(1, withDelegates.getDelegates().length); + assertEquals( + SCValType.SCV_VOID, withDelegates.getDelegates()[0].getSignature().getDiscriminant()); + } + + @Test + public void testSignAuthorizeEntryWithDelegatesForAddressFillsDelegateNode() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair delegate = deterministicKeyPair((byte) 1); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + SorobanAuthorizedInvocation invocation = buildInvocation(); + + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildWithDelegatesCredentials( + new Address(signer.getAccountId()).toSCAddress(), + 0L, + new SCAddress[] {new Address(delegate.getAccountId()).toSCAddress()})) + .rootInvocation(invocation) + .build(); + + SorobanAuthorizationEntry signedEntry = + Auth.authorizeEntry(entry, delegate, validUntilLedgerSeq, network, delegate.getAccountId()); + + // CAP-71-01: the delegate signs the same payload, bound to the *top-level* address. + HashIDPreimage preimage = + Auth.buildAuthorizationEntryPreimage(signedEntry, validUntilLedgerSeq, network); + assertEquals( + new Address(signer.getAccountId()).toSCAddress(), + preimage.getSorobanAuthorizationWithAddress().getAddress()); + byte[] payload = Util.hash(preimage.toXdrByteArray()); + + SorobanAddressCredentialsWithDelegates withDelegates = + signedEntry.getCredentials().getAddressWithDelegates(); + // the top-level signature remains a void placeholder ... + assertEquals( + SCValType.SCV_VOID, withDelegates.getAddressCredentials().getSignature().getDiscriminant()); + // ... and the delegate node carries a verifiable signature over the shared payload + SCVal delegateSignature = withDelegates.getDelegates()[0].getSignature(); + SCVal sigStruct = Scv.fromVec(delegateSignature).iterator().next(); + byte[] signatureBytes = Scv.fromBytes(Scv.fromMap(sigStruct).get(Scv.toSymbol("signature"))); + assertTrue(delegate.verify(payload, signatureBytes)); + } + + @Test + public void testSignAuthorizeEntryWithDelegatesForAddressFillsNestedDelegateNode() + throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair delegate = deterministicKeyPair((byte) 1); + KeyPair nestedDelegate = deterministicKeyPair((byte) 2); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + + SorobanDelegateSignature nestedNode = + SorobanDelegateSignature.builder() + .address(new Address(nestedDelegate.getAccountId()).toSCAddress()) + .signature(Scv.toVoid()) + .nestedDelegates(new SorobanDelegateSignature[0]) + .build(); + SorobanDelegateSignature delegateNode = + SorobanDelegateSignature.builder() + .address(new Address(delegate.getAccountId()).toSCAddress()) + .signature(Scv.toVoid()) + .nestedDelegates(new SorobanDelegateSignature[] {nestedNode}) + .build(); + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) + .addressWithDelegates( + SorobanAddressCredentialsWithDelegates.builder() + .addressCredentials( + SorobanAddressCredentials.builder() + .address(new Address(signer.getAccountId()).toSCAddress()) + .nonce(new Int64(123456789L)) + .signatureExpirationLedger( + new Uint32(new XdrUnsignedInteger(0L))) + .signature(Scv.toVoid()) + .build()) + .delegates(new SorobanDelegateSignature[] {delegateNode}) + .build()) + .build()) + .rootInvocation(buildInvocation()) + .build(); + + SorobanAuthorizationEntry signedEntry = + Auth.authorizeEntry( + entry, nestedDelegate, validUntilLedgerSeq, network, nestedDelegate.getAccountId()); + + byte[] payload = + Util.hash( + Auth.buildAuthorizationEntryPreimage(signedEntry, validUntilLedgerSeq, network) + .toXdrByteArray()); + + SorobanAddressCredentialsWithDelegates withDelegates = + signedEntry.getCredentials().getAddressWithDelegates(); + assertEquals( + SCValType.SCV_VOID, withDelegates.getAddressCredentials().getSignature().getDiscriminant()); + assertEquals( + SCValType.SCV_VOID, withDelegates.getDelegates()[0].getSignature().getDiscriminant()); + SCVal nestedSignature = withDelegates.getDelegates()[0].getNestedDelegates()[0].getSignature(); + SCVal sigStruct = Scv.fromVec(nestedSignature).iterator().next(); + byte[] signatureBytes = Scv.fromBytes(Scv.fromMap(sigStruct).get(Scv.toSymbol("signature"))); + assertTrue(nestedDelegate.verify(payload, signatureBytes)); + } + + @Test + public void testSignAuthorizeEntryWithForAddressNoMatchThrows() { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair other = deterministicKeyPair((byte) 1); + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(buildInvocation()) + .build(); + try { + Auth.authorizeEntry(entry, other, 654656L, Network.TESTNET, other.getAccountId()); + fail(); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("no credential node for address")); + } + } + + @Test + public void testBuildWithDelegatesEntryWrapsAddressV2Credentials() { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair delegate = deterministicKeyPair((byte) 1); + long validUntilLedgerSeq = 654656L; + SorobanAuthorizedInvocation invocation = buildInvocation(); + + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(invocation) + .build(); + + SorobanAuthorizationEntry wrapped = + Auth.buildWithDelegatesEntry( + entry, + validUntilLedgerSeq, + Collections.singletonList( + new Auth.DelegateSignature(delegate.getAccountId(), null, null)), + null); + + assertEquals( + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES, + wrapped.getCredentials().getDiscriminant()); + assertEquals(invocation, wrapped.getRootInvocation()); + + SorobanAddressCredentialsWithDelegates withDelegates = + wrapped.getCredentials().getAddressWithDelegates(); + assertEquals( + new Address(signer.getAccountId()).toSCAddress(), + withDelegates.getAddressCredentials().getAddress()); + assertEquals(new Int64(123456789L), withDelegates.getAddressCredentials().getNonce()); + assertEquals( + validUntilLedgerSeq, + withDelegates + .getAddressCredentials() + .getSignatureExpirationLedger() + .getUint32() + .getNumber() + .longValue()); + // the top-level signature defaults to a void placeholder, valid for accounts that + // authorize purely via delegated signers + assertEquals( + SCValType.SCV_VOID, withDelegates.getAddressCredentials().getSignature().getDiscriminant()); + assertEquals(1, withDelegates.getDelegates().length); + assertEquals( + new Address(delegate.getAccountId()).toSCAddress(), + withDelegates.getDelegates()[0].getAddress()); + assertEquals( + SCValType.SCV_VOID, withDelegates.getDelegates()[0].getSignature().getDiscriminant()); + assertEquals(0, withDelegates.getDelegates()[0].getNestedDelegates().length); + } + + @Test + public void testBuildWithDelegatesEntrySortsDelegatesByAddress() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + String contractId = "CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK"; + byte[] lowKey = new byte[32]; + byte[] highKey = new byte[32]; + highKey[0] = (byte) 0xff; + String gLow = StrKey.encodeEd25519PublicKey(lowKey); + String gHigh = StrKey.encodeEd25519PublicKey(highKey); + + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(buildInvocation()) + .build(); + + // accounts (discriminant 0) sort before contracts (discriminant 1), and accounts sort by + // their ed25519 bytes + SorobanAuthorizationEntry wrapped = + Auth.buildWithDelegatesEntry( + entry, + 654656L, + Arrays.asList( + new Auth.DelegateSignature(contractId, null, null), + new Auth.DelegateSignature( + gHigh, + null, + Arrays.asList( + new Auth.DelegateSignature(gHigh, null, null), + new Auth.DelegateSignature(gLow, null, null))), + new Auth.DelegateSignature(gLow, null, null)), + null); + + SorobanDelegateSignature[] delegates = + wrapped.getCredentials().getAddressWithDelegates().getDelegates(); + assertEquals(3, delegates.length); + assertEquals(new Address(gLow).toSCAddress(), delegates[0].getAddress()); + assertEquals(new Address(gHigh).toSCAddress(), delegates[1].getAddress()); + assertEquals(new Address(contractId).toSCAddress(), delegates[2].getAddress()); + // nested levels are sorted too + SorobanDelegateSignature[] nested = delegates[1].getNestedDelegates(); + assertEquals(2, nested.length); + assertEquals(new Address(gLow).toSCAddress(), nested[0].getAddress()); + assertEquals(new Address(gHigh).toSCAddress(), nested[1].getAddress()); + } + + @Test + public void testBuildWithDelegatesEntryWithDuplicateDelegateAddressThrows() { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair delegate = deterministicKeyPair((byte) 1); + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(buildInvocation()) + .build(); + try { + Auth.buildWithDelegatesEntry( + entry, + 654656L, + Arrays.asList( + new Auth.DelegateSignature(delegate.getAccountId(), null, null), + new Auth.DelegateSignature(delegate.getAccountId(), null, null)), + null); + fail(); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("duplicate delegate address")); + } + } + + @Test + public void testBuildWithDelegatesEntryRejectsWithDelegatesCredentials() { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + buildWithDelegatesCredentials( + new Address(signer.getAccountId()).toSCAddress(), 0L, new SCAddress[0])) + .rootInvocation(buildInvocation()) + .build(); + try { + Auth.buildWithDelegatesEntry(entry, 654656L, Collections.emptyList(), null); + fail(); + } catch (IllegalArgumentException ignored) { + } + } + + @Test + public void testBuildWithDelegatesEntryComposesWithAuthorizeEntry() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair delegate = deterministicKeyPair((byte) 1); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + + SorobanAuthorizationEntry simulatedEntry = + SorobanAuthorizationEntry.builder() + .credentials( + buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L)) + .rootInvocation(buildInvocation()) + .build(); + + SorobanAuthorizationEntry wrapped = + Auth.buildWithDelegatesEntry( + simulatedEntry, + validUntilLedgerSeq, + Collections.singletonList( + new Auth.DelegateSignature(delegate.getAccountId(), null, null)), + null); + SorobanAuthorizationEntry signedEntry = + Auth.authorizeEntry( + wrapped, delegate, validUntilLedgerSeq, network, delegate.getAccountId()); + + byte[] payload = + Util.hash( + Auth.buildAuthorizationEntryPreimage(signedEntry, validUntilLedgerSeq, network) + .toXdrByteArray()); + SCVal delegateSignature = + signedEntry.getCredentials().getAddressWithDelegates().getDelegates()[0].getSignature(); + SCVal sigStruct = Scv.fromVec(delegateSignature).iterator().next(); + byte[] signatureBytes = Scv.fromBytes(Scv.fromMap(sigStruct).get(Scv.toSymbol("signature"))); + assertTrue(delegate.verify(payload, signatureBytes)); + } + + @Test + public void testWithDelegatesCredentialsXdrRoundTrip() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair delegate = deterministicKeyPair((byte) 1); + KeyPair nestedDelegate = deterministicKeyPair((byte) 2); + + SorobanAuthorizationEntry entry = + SorobanAuthorizationEntry.builder() + .credentials( + SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) + .addressWithDelegates( + SorobanAddressCredentialsWithDelegates.builder() + .addressCredentials( + SorobanAddressCredentials.builder() + .address(new Address(signer.getAccountId()).toSCAddress()) + .nonce(new Int64(123456789L)) + .signatureExpirationLedger( + new Uint32(new XdrUnsignedInteger(654656L))) + .signature(Scv.toVoid()) + .build()) + .delegates( + new SorobanDelegateSignature[] { + SorobanDelegateSignature.builder() + .address(new Address(delegate.getAccountId()).toSCAddress()) + .signature(Scv.toVoid()) + .nestedDelegates( + new SorobanDelegateSignature[] { + SorobanDelegateSignature.builder() + .address( + new Address(nestedDelegate.getAccountId()) + .toSCAddress()) + .signature(Scv.toVoid()) + .nestedDelegates(new SorobanDelegateSignature[0]) + .build() + }) + .build() + }) + .build()) + .build()) + .rootInvocation(buildInvocation()) + .build(); + + assertEquals(entry, SorobanAuthorizationEntry.fromXdrBase64(entry.toXdrBase64())); + } + + @Test + public void testGetAddressCredentials() { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + SCAddress address = new Address(signer.getAccountId()).toSCAddress(); + + assertEquals( + address, Auth.getAddressCredentials(buildAddressCredentials(address, 0L)).getAddress()); + assertEquals( + address, Auth.getAddressCredentials(buildAddressV2Credentials(address, 0L)).getAddress()); + assertEquals( + address, + Auth.getAddressCredentials(buildWithDelegatesCredentials(address, 0L, new SCAddress[0])) + .getAddress()); + assertNull( + Auth.getAddressCredentials( + SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) + .build())); + } + + @Test + public void testAuthorizeEntryMatchesJsSdkVectors() throws IOException { + // Vectors generated with @stellar/stellar-sdk v16.0.0-rc.1 (CAP-71): the exact same inputs + // (signer, nonce 123456789, expiration ledger 654656, TESTNET, "increment" invocation) must + // produce byte-identical signed entries in both SDKs. + String addressSignedVector = + "AAAAAQAAAAAAAAAAWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAAB1vNFQAJ/UAAAAAQAAAAAQAAAAEAAAARAAAAAQAAAAIAAAAPAAAACnB1YmxpY19rZXkAAAAAAA0AAAAgWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAPAAAACXNpZ25hdHVyZQAAAAAAAA0AAABACOR9aTfdmCyXluY4UFurnM36CX91IL7qogZxFJ+fsFzKssUpDyHB0kxfPZVq9plTLfB14HoboD5tB71CgdWuBgAAAAAAAAABxYsr+8TwVOcyT2vyDK0+Am5Bu60abSDD19SRje0WVBEAAAAJaW5jcmVtZW50AAAAAAAAAAAAAAA="; + String addressV2SignedVector = + "AAAAAgAAAAAAAAAAWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAAB1vNFQAJ/UAAAAAQAAAAAQAAAAEAAAARAAAAAQAAAAIAAAAPAAAACnB1YmxpY19rZXkAAAAAAA0AAAAgWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAPAAAACXNpZ25hdHVyZQAAAAAAAA0AAABAUT1dqOx7TCk7ZHTruqLGInEw9QkSI7XrxlE1fVwbiU+viyQtrvIAP6vPPHOexFGnqmoPjkMwdeK0kWWUt90xBgAAAAAAAAABxYsr+8TwVOcyT2vyDK0+Am5Bu60abSDD19SRje0WVBEAAAAJaW5jcmVtZW50AAAAAAAAAAAAAAA="; + String delegatesSignedVector = + "AAAAAwAAAAAAAAAAWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAAB1vNFQAJ/UAAAAABAAAAAQAAAAAAAAAAiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wAAAAQAAAAAQAAAAEAAAARAAAAAQAAAAIAAAAPAAAACnB1YmxpY19rZXkAAAAAAA0AAAAgiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wAAAAPAAAACXNpZ25hdHVyZQAAAAAAAA0AAABAvI04jRWOZ3J6mWQPMcXE0s26eR9FrpZ9iTcqBic4trJ3FcViAU4qpRoue3Ew2ViqvYKbzHwHtNbdFLYR5MXfCwAAAAAAAAAAAAAAAcWLK/vE8FTnMk9r8gytPgJuQbutGm0gw9fUkY3tFlQRAAAACWluY3JlbWVudAAAAAAAAAAAAAAA"; + + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + KeyPair delegate = deterministicKeyPair((byte) 1); + assertEquals( + "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR", delegate.getAccountId()); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + SCAddress signerAddress = new Address(signer.getAccountId()).toSCAddress(); + SorobanAuthorizedInvocation invocation = buildInvocation(); + + SorobanAuthorizationEntry legacyEntry = + SorobanAuthorizationEntry.builder() + .credentials(buildAddressCredentials(signerAddress, 0L)) + .rootInvocation(invocation) + .build(); + assertEquals( + addressSignedVector, + Auth.authorizeEntry(legacyEntry, signer, validUntilLedgerSeq, network).toXdrBase64()); + + SorobanAuthorizationEntry v2Entry = + SorobanAuthorizationEntry.builder() + .credentials(buildAddressV2Credentials(signerAddress, 0L)) + .rootInvocation(invocation) + .build(); + assertEquals( + addressV2SignedVector, + Auth.authorizeEntry(v2Entry, signer, validUntilLedgerSeq, network).toXdrBase64()); + + SorobanAuthorizationEntry wrapped = + Auth.buildWithDelegatesEntry( + v2Entry, + validUntilLedgerSeq, + Collections.singletonList( + new Auth.DelegateSignature(delegate.getAccountId(), null, null)), + null); + assertEquals( + delegatesSignedVector, + Auth.authorizeEntry( + wrapped, delegate, validUntilLedgerSeq, network, delegate.getAccountId()) + .toXdrBase64()); + } + + @Test + public void authorizeInvocationWithAddressV2CredentialsType() throws IOException { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + long validUntilLedgerSeq = 654656L; + Network network = Network.TESTNET; + SorobanAuthorizedInvocation invocation = buildInvocation(); + + SorobanAuthorizationEntry signedEntry = + Auth.authorizeInvocation( + signer, + validUntilLedgerSeq, + invocation, + network, + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2); + + assertEquals(signedEntry.getRootInvocation(), invocation); + assertEquals( + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + signedEntry.getCredentials().getDiscriminant()); + SorobanAddressCredentials addressCredentials = signedEntry.getCredentials().getAddressV2(); + assertEquals(new Address(signer.getAccountId()).toSCAddress(), addressCredentials.getAddress()); + assertEquals(SCValType.SCV_VEC, addressCredentials.getSignature().getDiscriminant()); + + // The signature verifies against the address-bound (CAP-71-02) payload. + HashIDPreimage preimage = + Auth.buildAuthorizationEntryPreimage(signedEntry, validUntilLedgerSeq, network); + assertEquals( + EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS, preimage.getDiscriminant()); + byte[] payload = Util.hash(preimage.toXdrByteArray()); + SCVal sigStruct = Scv.fromVec(addressCredentials.getSignature()).iterator().next(); + byte[] signatureBytes = Scv.fromBytes(Scv.fromMap(sigStruct).get(Scv.toSymbol("signature"))); + assertTrue(signer.verify(payload, signatureBytes)); + } + + @Test + public void authorizeInvocationWithUnsupportedCredentialsTypeThrows() { + KeyPair signer = + KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN"); + try { + Auth.authorizeInvocation( + signer, + 654656L, + buildInvocation(), + Network.TESTNET, + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES); + fail(); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("credentialsType must be")); + } + } + + private static KeyPair deterministicKeyPair(byte fill) { + byte[] seed = new byte[32]; + Arrays.fill(seed, fill); + return KeyPair.fromSecretSeed(seed); + } + + private static SorobanAuthorizedInvocation buildInvocation() { + String contractId = "CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK"; + return SorobanAuthorizedInvocation.builder() + .function( + SorobanAuthorizedFunction.builder() + .discriminant( + SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) + .contractFn( + InvokeContractArgs.builder() + .contractAddress(new Address(contractId).toSCAddress()) + .functionName(Scv.toSymbol("increment").getSym()) + .args(new SCVal[0]) + .build()) + .build()) + .subInvocations(new SorobanAuthorizedInvocation[0]) + .build(); + } + + private static SorobanAddressCredentials buildInnerAddressCredentials( + SCAddress address, long signatureExpirationLedger) { + return SorobanAddressCredentials.builder() + .address(address) + .nonce(new Int64(123456789L)) + .signatureExpirationLedger(new Uint32(new XdrUnsignedInteger(signatureExpirationLedger))) + .signature(Scv.toVoid()) + .build(); + } + + private static SorobanCredentials buildAddressCredentials( + SCAddress address, long signatureExpirationLedger) { + return SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) + .address(buildInnerAddressCredentials(address, signatureExpirationLedger)) + .build(); + } + + private static SorobanCredentials buildAddressV2Credentials( + SCAddress address, long signatureExpirationLedger) { + return SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) + .addressV2(buildInnerAddressCredentials(address, signatureExpirationLedger)) + .build(); + } + + private static SorobanCredentials buildWithDelegatesCredentials( + SCAddress address, long signatureExpirationLedger, SCAddress[] delegateAddresses) { + SorobanDelegateSignature[] delegates = new SorobanDelegateSignature[delegateAddresses.length]; + for (int i = 0; i < delegateAddresses.length; i++) { + delegates[i] = + SorobanDelegateSignature.builder() + .address(delegateAddresses[i]) + .signature(Scv.toVoid()) + .nestedDelegates(new SorobanDelegateSignature[0]) + .build(); + } + return SorobanCredentials.builder() + .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) + .addressWithDelegates( + SorobanAddressCredentialsWithDelegates.builder() + .addressCredentials( + buildInnerAddressCredentials(address, signatureExpirationLedger)) + .delegates(delegates) + .build()) + .build(); + } + + private static SCVal buildSignatureScVal(KeyPair signer, byte[] signature) { + return Scv.toVec( + Collections.singleton( + Scv.toMap( + new LinkedHashMap() { + { + put(Scv.toSymbol("public_key"), Scv.toBytes(signer.getPublicKey())); + put(Scv.toSymbol("signature"), Scv.toBytes(signature)); + } + }))); + } } diff --git a/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java index 6d3072ec4..2019f8f83 100644 --- a/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java +++ b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java @@ -24,7 +24,9 @@ import org.stellar.sdk.xdr.InvokeContractArgs; import org.stellar.sdk.xdr.SCSymbol; import org.stellar.sdk.xdr.SCVal; +import org.stellar.sdk.xdr.SCValType; import org.stellar.sdk.xdr.SorobanAddressCredentials; +import org.stellar.sdk.xdr.SorobanAddressCredentialsWithDelegates; import org.stellar.sdk.xdr.SorobanAuthorizationEntries; import org.stellar.sdk.xdr.SorobanAuthorizationEntry; import org.stellar.sdk.xdr.SorobanAuthorizedFunction; @@ -32,6 +34,7 @@ import org.stellar.sdk.xdr.SorobanAuthorizedInvocation; import org.stellar.sdk.xdr.SorobanCredentials; import org.stellar.sdk.xdr.SorobanCredentialsType; +import org.stellar.sdk.xdr.SorobanDelegateSignature; import org.stellar.sdk.xdr.Uint32; import org.stellar.sdk.xdr.XdrString; import org.stellar.sdk.xdr.XdrUnsignedInteger; @@ -99,6 +102,14 @@ private SorobanAuthorizedInvocation buildValidInvocation(boolean includeClientDo */ private SorobanAuthorizationEntry buildAuthorizationEntry( String address, SorobanAuthorizedInvocation invocation) { + return buildAuthorizationEntry( + address, invocation, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS); + } + + private SorobanAuthorizationEntry buildAuthorizationEntry( + String address, + SorobanAuthorizedInvocation invocation, + SorobanCredentialsType credentialsType) { Address addr = new Address(address); SorobanAddressCredentials addressCredentials = @@ -109,14 +120,25 @@ private SorobanAuthorizationEntry buildAuthorizationEntry( .signature(Scv.toVoid()) .build(); - SorobanCredentials credentials = - SorobanCredentials.builder() - .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) - .address(addressCredentials) - .build(); + SorobanCredentials.SorobanCredentialsBuilder credentialsBuilder = + SorobanCredentials.builder().discriminant(credentialsType); + if (credentialsType == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) { + credentialsBuilder.address(addressCredentials); + } else if (credentialsType == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) { + credentialsBuilder.addressV2(addressCredentials); + } else if (credentialsType + == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) { + credentialsBuilder.addressWithDelegates( + SorobanAddressCredentialsWithDelegates.builder() + .addressCredentials(addressCredentials) + .delegates(new SorobanDelegateSignature[0]) + .build()); + } else { + throw new IllegalArgumentException("Unsupported credentials type: " + credentialsType); + } return SorobanAuthorizationEntry.builder() - .credentials(credentials) + .credentials(credentialsBuilder.build()) .rootInvocation(invocation) .build(); } @@ -800,7 +822,62 @@ public void testReadChallengeUnsupportedCredentialsType() { } catch (InvalidSep45ChallengeException e) { assertTrue( e.getMessage() - .contains("All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS type")); + .contains( + "All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS or SOROBAN_CREDENTIALS_ADDRESS_V2 type")); + } + } + + @Test + public void testReadChallengeSuccessWithAddressV2Credentials() { + // CAP-71 (protocol 27): SOROBAN_CREDENTIALS_ADDRESS_V2 entries are accepted in addition to + // the legacy SOROBAN_CREDENTIALS_ADDRESS. + SorobanAuthorizedInvocation invocation = buildValidInvocation(false); + + List entries = new ArrayList<>(); + entries.add( + buildAuthorizationEntry( + CLIENT_CONTRACT, invocation, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)); + entries.add( + buildAuthorizationEntry( + SERVER_ACCOUNT, invocation, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)); + + 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(NONCE, result.getNonce()); + assertEquals(2, result.getAuthorizationEntries().size()); + } + + @Test + public void testReadChallengeRejectsWithDelegatesCredentials() { + // Delegated credentials (CAP-71-01) are rejected until the ecosystem defines how they + // interact with SEP-45. + SorobanAuthorizedInvocation invocation = buildValidInvocation(false); + + List entries = new ArrayList<>(); + entries.add( + buildAuthorizationEntry( + CLIENT_CONTRACT, + invocation, + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)); + 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 or SOROBAN_CREDENTIALS_ADDRESS_V2 type")); } } @@ -917,6 +994,95 @@ public void testBuildChallengeAuthorizationEntriesWithoutClientDomain() throws I server.close(); } + @Test + public void testBuildChallengeAuthorizationEntriesSignsAddressV2Entries() throws IOException { + // CAP-71 (protocol 27): a P27 RPC may return SOROBAN_CREDENTIALS_ADDRESS_V2 entries from + // simulation; the server entry must still get signed. + KeyPair serverSigner = KeyPair.random(); + String serverAccountId = serverSigner.getAccountId(); + + String mockResponse = + buildMockSimulateResponse( + serverAccountId, false, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2); + + 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); + + // The server's V2 entry is signed, the client entry remains an unsigned placeholder. + boolean serverEntryChecked = false; + for (SorobanAuthorizationEntry entry : result.getSorobanAuthorizationEntries()) { + assertEquals( + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2, + entry.getCredentials().getDiscriminant()); + SorobanAddressCredentials addressCredentials = + Auth.getAddressCredentials(entry.getCredentials()); + String entryAddress = Address.fromSCAddress(addressCredentials.getAddress()).toString(); + if (entryAddress.equals(serverAccountId)) { + assertEquals(SCValType.SCV_VEC, addressCredentials.getSignature().getDiscriminant()); + serverEntryChecked = true; + } else { + assertEquals(SCValType.SCV_VOID, addressCredentials.getSignature().getDiscriminant()); + } + } + assertTrue(serverEntryChecked); + + mockWebServer.close(); + server.close(); + } + + @Test + public void testBuildChallengeAuthorizationEntriesRejectsUnsupportedCredentialsType() + throws IOException { + // Delegated credentials are not allowed in SEP-45 challenges; challenge building must fail + // fast instead of emitting an unsigned entry. + KeyPair serverSigner = KeyPair.random(); + String serverAccountId = serverSigner.getAccountId(); + + String mockResponse = + buildMockSimulateResponse( + serverAccountId, + false, + SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES); + + 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("Unsupported SorobanCredentialsType")); + } + } + } + @Test public void testBuildChallengeAuthorizationEntriesClientDomainWithoutAccount() throws IOException { @@ -1208,6 +1374,12 @@ private String buildEntriesWithCustomArgs(LinkedHashMap argsMap) { * @return JSON string of the mock response */ private String buildMockSimulateResponse(String serverAccountId, boolean includeClientDomain) { + return buildMockSimulateResponse( + serverAccountId, includeClientDomain, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS); + } + + private String buildMockSimulateResponse( + String serverAccountId, boolean includeClientDomain, SorobanCredentialsType credentialsType) { // Build mock authorization entries for the response List mockEntries = new ArrayList<>(); @@ -1244,11 +1416,11 @@ private String buildMockSimulateResponse(String serverAccountId, boolean include .build(); // Client entry - mockEntries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation)); + mockEntries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation, credentialsType)); // Server entry - mockEntries.add(buildAuthorizationEntry(serverAccountId, invocation)); + mockEntries.add(buildAuthorizationEntry(serverAccountId, invocation, credentialsType)); if (includeClientDomain) { - mockEntries.add(buildAuthorizationEntry(CLIENT_DOMAIN_ACCOUNT, invocation)); + mockEntries.add(buildAuthorizationEntry(CLIENT_DOMAIN_ACCOUNT, invocation, credentialsType)); } // Convert entries to XDR base64 strings