diff --git a/content/stellar-contracts/accounts/authorization-flow.mdx b/content/stellar-contracts/accounts/authorization-flow.mdx index 2cc57812..89b29612 100644 --- a/content/stellar-contracts/accounts/authorization-flow.mdx +++ b/content/stellar-contracts/accounts/authorization-flow.mdx @@ -20,9 +20,30 @@ pub struct AuthPayload { Each entry in `context_rule_ids` specifies the rule ID to validate against for the corresponding auth context (by index). Its length must equal `auth_contexts.len()`. - -The `context_rule_ids` are bound into the signed digest: `sha256(signature_payload || context_rule_ids.to_xdr())`. This prevents rule-selection downgrade attacks where an attacker could redirect a signature to a less restrictive rule. - +## AuthDigestPreimage + +Signers never sign the raw `signature_payload` that the host passes to `__check_auth`. Instead, the smart account wraps it in a structured message that every signer commits to: + +```rust +#[contracttype] +pub struct AuthDigestPreimage { + /// The smart account performing the authorization check. + pub account: Address, + /// The 32-byte payload provided by the host to `__check_auth`. + pub signature_payload: BytesN<32>, + /// Per-context rule IDs, aligned by index with `auth_contexts`. + pub context_rule_ids: Vec, +} +``` + +How a signer commits to the preimage depends on its type: + +- **External signers** sign the **auth digest**, `sha256(preimage.to_xdr())`, which the smart account passes to their verifier contract. +- **Delegated signers** authorize the preimage itself through `require_auth_for_args((preimage,))`. + +Binding `context_rule_ids` prevents rule-selection downgrade attacks, where an attacker collects signatures under a strict rule and then swaps `context_rule_ids` to point to a less restrictive one: any change to the rule IDs invalidates every signature. Binding `account` scopes each signature to a single smart account. + +See [Auth Digest Computation](/stellar-contracts/accounts/signers-and-verifiers#auth-digest-computation) for how off-chain clients build the preimage and sign it. ## Detailed Flow ```mermaid @@ -35,25 +56,29 @@ sequenceDiagram participant Policy User->>SmartAccount: AuthPayload (signers + context_rule_ids) - SmartAccount->>SmartAccount: Compute auth_digest
sha256(payload || rule_ids.to_xdr()) loop Each auth context SmartAccount->>ContextRule: Look up rule by ID
from context_rule_ids - ContextRule->>ContextRule: Validate not expired
and matches context type + ContextRule->>ContextRule: Validate not expired,
context type and signers + ContextRule-->>SmartAccount: Rule + matched signers + end - Note over ContextRule,DelegatedSigner: Built-in authorization
for delegated signers - ContextRule->>DelegatedSigner: require_auth_for_args() - DelegatedSigner-->>ContextRule: Authorized + SmartAccount->>SmartAccount: Build AuthDigestPreimage
(account, payload, rule_ids) - Note over ContextRule,Verifier: Signature verification for external signers - ContextRule->>Verifier: verify() - Verifier-->>ContextRule: Valid + loop Each signer in AuthPayload (must belong to a selected rule) + alt Delegated signer + SmartAccount->>DelegatedSigner: require_auth_for_args(preimage) + DelegatedSigner-->>SmartAccount: Authorized + else External signer + SmartAccount->>Verifier: verify(sha256(preimage.to_xdr())) + Verifier-->>SmartAccount: Valid + end + end - Note over ContextRule,Policy: Policy enforcement (panics on failure) - ContextRule->>Policy: enforce() + loop Each auth context + Note over SmartAccount,Policy: Policy enforcement (panics on failure) + SmartAccount->>Policy: enforce(matched signers) Policy->>Policy: Validate + update state - - ContextRule-->>SmartAccount: ✓ Authorized end SmartAccount-->>User: Success @@ -67,48 +92,43 @@ For each auth context, the corresponding rule ID is used to look up the context - Exist in the account's storage - Not be expired (if `valid_until` is set, it must be ≥ current ledger sequence) -- Match the context type: a `CallContract(address)` rule matches a `CallContract(address)` context, and `Default` rules match any context +- Match the context type: a `CallContract(address)` rule matches a call to that contract, a `CreateContract(wasm_hash)` rule matches a deployment from that WASM hash, a `CreateContractExternalRef(owner, tag)` rule matches a deployment from that executable reference, and `Default` rules match any context + +### 2. Signer Matching -### 2. Rule Evaluation +For each (context, rule) pair, the smart account determines the rule's **matched signers**: the signers of the rule that appear as keys in `AuthPayload.signers`. No signature is checked at this stage. -For each (context, rule_id) pair: +- **Rule without policies**: every signer of the rule must be present in the payload. Otherwise, the context is rejected with `UnvalidatedContext`. +- **Rule with policies**: any subset of the rule's signers can be present. The policies decide in step 4 whether the matched signers are sufficient. -#### Step 2.1: Signer Filtering +### 3. Signature Authentication -Extract authenticated signers from the rule's signer list. A signer is considered authenticated if: +The smart account then builds the [`AuthDigestPreimage`](#authdigestpreimage) and goes through every entry of `AuthPayload.signers` once, across all auth contexts: -- **Delegated Signer**: The address has authorized the operation via `require_auth_for_args(payload)` -- **External Signer**: The verifier contract confirms the signature is valid for the public key +- A signer that is not part of any selected rule is rejected with `UnauthorizedSigner`. This prevents a payload from making the smart account call arbitrary verifier contracts. +- **Delegated Signer**: The address must have authorized `require_auth_for_args((preimage,))`. +- **External Signer**: The verifier contract must confirm that the signature over the auth digest, `sha256(preimage.to_xdr())`, is valid for the public key. Otherwise, authorization fails with `ExternalVerificationFailed`. -Only authenticated signers proceed to the next step. +Invalid signatures are not skipped: a single failed signature aborts the whole authorization, even if the remaining signers would satisfy every rule. -#### Step 2.2: Policy Enforcement +### 4. Policy Enforcement -If the rule has attached policies, the smart account calls `enforce()` on each policy. The `enforce()` method both validates conditions and applies state changes — it panics if the policy conditions are not satisfied: +Once all signatures are authenticated, the smart account calls `enforce()` on each policy of each selected rule, passing the rule's matched signers. The `enforce()` method both validates conditions and applies state changes — it panics if the policy conditions are not satisfied: ```rust for policy in rule.policies { - policy.enforce(e, context, authenticated_signers, context_rule, smart_account); - // Panics if policy conditions aren't satisfied, causing the rule to fail + policy.enforce(e, context, matched_signers, context_rule, smart_account); + // Panics if policy conditions aren't satisfied, causing authorization to fail } ``` -If any policy panics, authorization fails for that context. +If any policy panics, authorization fails. Policy enforcement requires the smart account's authorization, ensuring that policies can only be enforced by the account itself. -#### Step 2.3: Authorization Check - -The authorization check depends on whether policies are present: - -**With Policies:** -- Success if all policies' `enforce()` calls completed without panicking - -**Without Policies:** -- Success if all signers in the rule are authenticated -- At least one signer must be authenticated for the rule to match +A rule without policies has no further checks: all of its signers are present (step 2) and authenticated (step 3). -### 3. Result +### 5. Result **Success:** Authorization is granted and the transaction proceeds. All policy state changes are committed. @@ -146,9 +166,9 @@ ContextRule { 1. Lookup: Client specifies rule ID 2 in `context_rule_ids` 2. Evaluate Rule 2: - Rule matches `CallContract(dex_address)` context and is not expired - - Signer filtering: Passkey authenticated - - Policy enforcement: Spending limit validates and updates counters - - Authorization check: All policies enforced successfully → Success + - Signer matching: Passkey is a signer of rule 2 + - Signature authentication: Passkey signature over the auth digest is valid + - Policy enforcement: Spending limit validates and updates counters → Success 3. Result: Authorized @@ -180,7 +200,7 @@ ContextRule { **Flow:** 1. Lookup: Client specifies rule ID 1 in `context_rule_ids` (rule 2 is known to be expired) -2. Evaluate Rule 1: Both Alice and Bob authenticated, no policies to enforce → Success +2. Evaluate Rule 1: Alice and Bob, the rule's only signers, are both present and their signatures are valid; no policies to enforce → Success 3. Result: Authorized ### Authorization Failure @@ -203,7 +223,8 @@ ContextRule { **Flow:** 1. Lookup: Client specifies default rule ID in `context_rule_ids` 2. Evaluate: - - Signer filtering: Only Alice authenticated + - Signer matching: Only Alice is present + - Signature authentication: Alice's signature is valid - Policy enforcement: Threshold policy requires 2 signers, only 1 present → Panics 3. Result: Denied (transaction reverts) diff --git a/content/stellar-contracts/accounts/context-rules.mdx b/content/stellar-contracts/accounts/context-rules.mdx index 6537b683..72e94feb 100644 --- a/content/stellar-contracts/accounts/context-rules.mdx +++ b/content/stellar-contracts/accounts/context-rules.mdx @@ -22,6 +22,11 @@ Defines the scope where the rule applies: - `Default`: Applies to any context. Used for admin-like authorization that spans all operations. - `CallContract(Address)`: Applies to specific contract calls. Useful for scoped permissions like session logins to a particular dApp. - `CreateContract(BytesN<32>)`: Applies to contract deployments with a specific WASM hash. Enables control over which contracts can be deployed. +- `CreateContractExternalRef(Address, String)`: Applies to contract deployments from the executable reference that an owner contract stores under a tag (Protocol 28, [CAP-85](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0085.md)). + + +With `CreateContractExternalRef(owner, tag)`, the WASM hash is resolved at deployment time and the `owner` contract can change it later, so the rule trusts the owner contract to point the tag at acceptable code. A deployment is matched by how its executable is referenced, not by the code it resolves to: a `CreateContract(hash)` rule does not cover a deployment through an external reference, even if that reference resolves to the same hash. + #### Valid Until Optional expiration defined by a ledger sequence. Rules with expiration automatically become invalid after the specified ledger, enabling time-limited permissions like 24-hour sessions. @@ -94,22 +99,27 @@ graph TD ```rust -use soroban_sdk::{map, vec, Env, String} -use stellar_accounts::smart_account::{self as smart_account, ContextRuleType}; +use soroban_sdk::{map, vec, Env, String}; +use stellar_accounts::smart_account::{self as smart_account, ContextRuleType, Signer}; + +// ~5 seconds per ledger +const DAY_IN_LEDGERS: u32 = 17_280; + +let current_ledger = e.ledger().sequence(); // This rule applies to all contexts and requires 2-of-3 signatures from Alice, Bob, or Dave. smart_account::add_context_rule( e, - ContextRuleType::Default, - String::from_str(e, "Sudo"), + &ContextRuleType::Default, + &String::from_str(e, "Sudo"), None, // No expiration - vec![ + &vec![ e, - Signer::External(bls_verifier, alice_key), + Signer::External(bls_verifier.clone(), alice_key), Signer::External(bls_verifier, bob_key), Signer::Delegated(dave_addr) ], - map![ + &map![ e, (threshold_policy, threshold_params) // 2-of-3 Threshold ], @@ -119,14 +129,14 @@ smart_account::add_context_rule( // requires a dapp1 key signature, and enforces spending limits. smart_account::add_context_rule( e, - ContextRuleType::CallContract(usdc_addr), - String::from_str(e, "Dapp1 Subscription"), - Some(current_ledger + 1_year), - vec![ + &ContextRuleType::CallContract(usdc_addr), + &String::from_str(e, "Dapp1 Subscription"), + Some(current_ledger + 365 * DAY_IN_LEDGERS), + &vec![ e, - Signer::External(ed25519_verifier, dapp1_key) + Signer::External(ed25519_verifier.clone(), dapp1_key) ], - map![ + &map![ e, (spending_limit_policy, spending_params) ], @@ -136,14 +146,14 @@ smart_account::add_context_rule( // requires a dapp2 key signature, and enforces rate limiting and time window policies. smart_account::add_context_rule( e, - ContextRuleType::CallContract(dapp_addr), - String::from_str(e, "Dapp2 Session"), - Some(current_ledger + 7_days), - vec![ + &ContextRuleType::CallContract(dapp_addr), + &String::from_str(e, "Dapp2 Session"), + Some(current_ledger + 7 * DAY_IN_LEDGERS), + &vec![ e, Signer::External(ed25519_verifier, dapp2_key) ], - map![ + &map![ e, (rate_limit_policy, rate_limit_params), (time_window_policy, time_window_params) @@ -154,14 +164,14 @@ smart_account::add_context_rule( // requires an AI agent key signature, and enforces volume caps. smart_account::add_context_rule( e, - ContextRuleType::CallContract(some_addr), - String::from_str(e, "AI Agent"), - Some(current_ledger + 12_hours), - vec![ + &ContextRuleType::CallContract(some_addr), + &String::from_str(e, "AI Agent"), + Some(current_ledger + DAY_IN_LEDGERS / 2), + &vec![ e, Signer::External(secp256r1_verifier, agent_key) ], - map![ + &map![ e, (volume_cap_policy, volume_cap_params) ], diff --git a/content/stellar-contracts/accounts/policies.mdx b/content/stellar-contracts/accounts/policies.mdx index 9ca34dca..c54a8676 100644 --- a/content/stellar-contracts/accounts/policies.mdx +++ b/content/stellar-contracts/accounts/policies.mdx @@ -134,7 +134,7 @@ fn remove_policy( ); ``` -Removes a policy (identified by its numeric ID) from an existing context rule and calls its `uninstall()` function. The rule must maintain at least one signer OR one policy after removal. +Removes a policy (identified by its numeric ID) from an existing context rule and calls its `uninstall()` function. The rule must maintain at least one signer OR one policy after removal. The ID is returned by `add_policy`, and the `get_policy_id(policy)` view looks it up for an already registered policy. ### Caveats @@ -149,10 +149,12 @@ Threshold policies (both simple and weighted) store authorization requirements t **Adding Signers:** Conversely, if signers are added without updating the threshold, the security guarantee silently weakens. A strict 3-of-3 multisig becomes a 3-of-5 multisig after adding two signers, reducing the required approval from 100% to 60% without any explicit warning. **Resolution:** Administrators must manually update thresholds and weights when modifying signer sets: -1. Before removing signers, verify that the threshold remains achievable +1. Before removing signers, verify that the threshold remains achievable, lowering it first if needed 2. After adding signers, adjust thresholds or assign weights to maintain the desired security level 3. Ideally, bundle these updates in the same transaction as the signer modifications +For weighted thresholds, these updates must also happen in a specific order; see [Required Operation Ordering](#required-operation-ordering). + ## Example Policies The OpenZeppelin Stellar Contracts library provides the necessary utilities for three policy implementations: @@ -190,18 +192,31 @@ The policy requires two configuration parameters: When using threshold policies, be aware of signer set divergence issues. See the [Caveats](#caveats) section above for details on how adding or removing signers affects threshold policies and how to properly manage these changes. +#### Required Operation Ordering + +Both `set_threshold()` and `set_signer_weight()` enforce `threshold <= total signer weight` on every individual call. Because each call is validated in isolation, reconfiguration is order-sensitive: + +- **Adding weight and raising the threshold**: call `set_signer_weight()` first to increase the total weight, then `set_threshold()`. Raising the threshold first reverts if it exceeds the current total weight. +- **Removing a signer or reducing weight**: call `set_threshold()` first to lower the threshold, then `set_signer_weight()`. Reducing the weight first reverts if the total weight drops below the current threshold. + +A call that would leave `threshold > total signer weight` reverts with `InvalidThreshold` (error `3211`), so a wrong-order sequence fails at the first step whose intermediate state violates the invariant. + ### Spending Limit [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/accounts/src/policies/spending_limit.rs) The `spending_limit` policy enforces spending caps over time periods, enabling budget controls, allowances, and rate limiting for smart accounts. This is particularly useful for session keys, sub-accounts, and automated operations that need spending constraints. -The policy tracks cumulative spending within a rolling time window and rejects transactions that would exceed the configured limit. The policy maintains state to track spending and automatically resets when the time window expires. +The policy tracks cumulative spending of `transfer` calls within a rolling window and rejects transactions that would exceed the configured limit. Entries older than the window are evicted before each new transfer is evaluated, so the available budget frees up as the window rolls forward. Any other call under a rule with this policy is rejected. The policy requires two configuration parameters: -- **Limit Amount**: Maximum spending allowed in the time window -- **Time Window**: Duration in seconds for the spending period +- **Spending Limit** (`spending_limit`): Maximum amount that can be spent within the window +- **Period** (`period_ledgers`): Length of the rolling window, in ledgers (e.g. `17280` for roughly one day) + +Zero-amount transfers move no funds and are always permitted, even when the amount already spent exceeds a limit that was lowered in the meantime. + +The spending history is capped at 815 entries (`MAX_HISTORY_ENTRIES`), the largest history that still fits in a single ledger entry. Once the window holds that many transfers, further transfers fail with `HistoryCapacityExceeded` until older entries fall out of the window. ## Best Practices diff --git a/content/stellar-contracts/accounts/signers-and-verifiers.mdx b/content/stellar-contracts/accounts/signers-and-verifiers.mdx index 4c8baf33..6dc9f80a 100644 --- a/content/stellar-contracts/accounts/signers-and-verifiers.mdx +++ b/content/stellar-contracts/accounts/signers-and-verifiers.mdx @@ -22,7 +22,7 @@ pub enum Signer { Signer::Delegated(Address) ``` -Delegated signers represent any Soroban address (C-account, G-account) and delegate authorization checks to that address using the built-in `require_auth_for_args()`. This enables using traditional Stellar account address (G-accounts) as signers, but also makes possible powerful composition patterns such as nested smart accounts (one smart account authorizing on behalf of another) and contract-based signers with custom authorization logic. This variant is particularly useful for building multi-level authorization hierarchies. +Delegated signers represent any Soroban address (C-account, G-account) and delegate authorization checks to that address using the built-in `require_auth_for_args()`, with the [`AuthDigestPreimage`](/stellar-contracts/accounts/authorization-flow#authdigestpreimage) as the single argument. This enables using traditional Stellar account address (G-accounts) as signers, but also makes possible powerful composition patterns such as nested smart accounts (one smart account authorizing on behalf of another) and contract-based signers with custom authorization logic. This variant is particularly useful for building multi-level authorization hierarchies. ```mermaid graph TD @@ -72,16 +72,21 @@ pub struct MySmartAccount; impl CustomAccountInterface for MySmartAccount { fn __check_auth( e: Env, - payload: Hash<32>, + signature_payload: Hash<32>, auth_payload: AuthPayload, auth_contexts: Vec, ) -> Result<(), SmartAccountError> { - for (signer, _) in auth_payload.signers.iter() { + let preimage = AuthDigestPreimage { + account: e.current_contract_address(), + signature_payload: signature_payload.to_bytes(), + context_rule_ids: auth_payload.context_rule_ids.clone(), + }; + for (signer, _) in auth_payload.signers.iter() { match signer { // ... Signer::Delegated(addr) => { - let payload = (payload.clone(),).into_val(e); - addr.require_auth_for_args(payload); + let args = (preimage.clone(),).into_val(&e); + addr.require_auth_for_args(args); } } } @@ -143,7 +148,11 @@ When simulating this transaction, the following authorization entry is returned. The client implementation requires constructing two authorization entries: 1. Replace `"signature": "void"` with the proper `AuthPayload` structure (containing the `signers` map and `context_rule_ids`) -2. Create the missing authorization entry for the delegated signer's `__check_auth` call +2. Create the missing root-level authorization entry for the delegated signer: its root invocation is `__check_auth` on the smart account, with the [`AuthDigestPreimage`](/stellar-contracts/accounts/authorization-flow#authdigestpreimage) as the single argument, and it is signed the way that address normally authorizes (an Ed25519 signature for a G-account, its own `AuthPayload` for a nested smart account) + + +The argument of the delegated signer's entry must be the preimage struct, encoded as an `ScVal::Map`, not the 32-byte digest or the host's `signature_payload`. A missing or wrongly encoded entry fails with `Error(Auth, InvalidAction)` and a trap inside `__check_auth`, not with a `SmartAccountError`. + The following typescript code demonstrates this process: @@ -195,21 +204,41 @@ async function signAndSendTx( simAuth.credentials().address().signatureExpirationLedger(validUntil); signedAuths.push(simAuth); - // 2) Construct the 2nd auth entry for `__check_auth` and sign the invocation - const payload = HashIdPreimage.envelopeTypeSorobanAuthorization( - new HashIdPreimageSorobanAuthorization({ - networkId, - nonce: simAuth.credentials().address().nonce(), - signatureExpirationLedger: validUntil, - invocation: simAuth.rootInvocation(), + // 2) Compute the host's `signature_payload` for the smart account's entry + const smartAccount = Address.fromScAddress(simAuth.credentials().address().address()); + const signaturePayload = hash( + HashIdPreimage.envelopeTypeSorobanAuthorization( + new HashIdPreimageSorobanAuthorization({ + networkId, + nonce: simAuth.credentials().address().nonce(), + signatureExpirationLedger: validUntil, + invocation: simAuth.rootInvocation(), + }), + ).toXDR(), + ); + + // AuthDigestPreimage is a named-field struct serialized as ScVal::Map (keys sorted) + const preimage = ScVal.scvMap([ + new xdr.ScMapEntry({ + key: ScVal.scvSymbol("account"), + val: smartAccount.toScVal(), + }), + new xdr.ScMapEntry({ + key: ScVal.scvSymbol("context_rule_ids"), + val: ScVal.scvVec([ScVal.scvU32(ruleId)]), // same rule IDs as in AuthPayload + }), + new xdr.ScMapEntry({ + key: ScVal.scvSymbol("signature_payload"), + val: ScVal.scvBytes(signaturePayload), }), - ).toXDR(); - const hashed_payload = hash(payload); + ]); + // 3) Construct the 2nd auth entry: the delegated signer authorizes + // `__check_auth` on the smart account with the preimage as its only argument const args = new InvokeContractArgs({ - contractAddress: Address.fromString(contract).toScAddress(), - functionName: "__check_auth", - args: [ScVal.scvBytes(hashed_payload)] + contractAddress: smartAccount.toScAddress(), + functionName: "__check_auth", + args: [preimage], }); const invocation = new SorobanAuthorizedInvocation({ function: SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(args), @@ -313,11 +342,25 @@ After including both authorization entries in `signedAuths` and re-simulating th "function_name": "__check_auth", "args": [ { - "bytes": "ed0cfe2903d64e5383..." // the signature payload + "map": [ // the AuthDigestPreimage + { + "key": { "symbol": "account" }, + "val": { "address": "CBH6XACZFDCJUHX..." } // the Smart Account + }, + { + "key": { "symbol": "context_rule_ids" }, + "val": { "vec": [{ "u32": 1 }] } // same rule IDs as in the AuthPayload + }, + { + "key": { "symbol": "signature_payload" }, + "val": { "bytes": "ed0cfe2903d64e5383..." } // the host's signature payload + } + ] } ] } }, + "sub_invocations": [] } } ] @@ -357,20 +400,26 @@ pub struct MySmartAccount; impl CustomAccountInterface for MySmartAccount { fn __check_auth( e: Env, - payload: Hash<32>, + signature_payload: Hash<32>, signatures: AuthPayload, auth_contexts: Vec, ) -> Result<(), SmartAccountError> { - for (signer, sig_data) in signatures.signers.iter() { + let preimage = AuthDigestPreimage { + account: e.current_contract_address(), + signature_payload: signature_payload.to_bytes(), + context_rule_ids: signatures.context_rule_ids.clone(), + }; + for (signer, sig_data) in signatures.signers.iter() { match signer { Signer::External(verifier, key_data) => { - let sig_payload = Bytes::from_array(e, &signature_payload.to_bytes().to_array()); - if !VerifierClient::new(e, &verifier).verify( - &sig_payload, - &key_data.into_val(e), - &sig_data.into_val(e), + // External signers sign sha256(preimage.to_xdr()), not the raw signature_payload + let auth_digest = preimage.digest(&e).to_bytes(); + if !VerifierClient::new(&e, &verifier).verify( + &auth_digest, + &key_data.into_val(&e), + &sig_data.into_val(&e), ) { - panic_with_error!(e, SmartAccountError::ExternalVerificationFailed) + panic_with_error!(&e, SmartAccountError::ExternalVerificationFailed) } } // ... @@ -434,7 +483,7 @@ In contrast to `Delegated` signers, constructing the auth entry for an `External ] }, "val": { - "bytes": "6ead27ab6e8cab36..." // Signer's signature + "bytes": "6ead27ab6e8cab36..." // Signer's signature over the auth digest } } ] @@ -472,30 +521,46 @@ The `context_rule_ids` field contains one rule ID per auth context, explicitly s ### Auth Digest Computation -Signers must sign the **auth digest**, not the raw `signature_payload`. The context rule IDs are bound into the digest to prevent downgrade attacks: +Signers do not sign the raw `signature_payload`. Every signer commits to an [`AuthDigestPreimage`](/stellar-contracts/accounts/authorization-flow#authdigestpreimage) that binds the smart account address and the selected context rule IDs alongside the payload: -``` -auth_digest = sha256(signature_payload || context_rule_ids.to_xdr()) +```rust +#[contracttype] +pub struct AuthDigestPreimage { + pub account: Address, // the smart account + pub signature_payload: BytesN<32>, // from the host + pub context_rule_ids: Vec, // one per auth context +} ``` +- `External` signers sign the **auth digest**, `sha256(preimage.to_xdr())`. +- `Delegated` signers authorize `__check_auth` on the smart account with the preimage itself as the single argument (see [Transaction Simulation Behavior](#transaction-simulation-behavior)). + Steps: -1. Obtain the 32-byte `signature_payload` from the host -2. XDR-encode the `Vec` of rule IDs (one per auth context) -3. Concatenate both byte sequences -4. SHA-256 hash the result -5. Sign the resulting digest +1. Compute the 32-byte `signature_payload` of the smart account's authorization entry: the SHA-256 hash of the XDR-encoded `HashIdPreimage::SorobanAuthorization { network_id, nonce, signature_expiration_ledger, invocation }` +2. Build the preimage as an `ScVal::Map` with the symbol keys `account`, `context_rule_ids` and `signature_payload` (`ScMap` keys must be sorted, so they appear in this order) +3. XDR-encode the map and SHA-256 hash it to obtain the auth digest +4. Have every `External` signer sign the auth digest In Rust (Soroban SDK): ```rust -use soroban_sdk::xdr::ToXdr; - -let mut preimage = signature_payload.to_bytes().to_bytes(); -preimage.append(&context_rule_ids.to_xdr(e)); -let auth_digest = e.crypto().sha256(&preimage); -// Sign auth_digest, not signature_payload +use stellar_accounts::smart_account::AuthDigestPreimage; + +let preimage = AuthDigestPreimage { + account: smart_account_address, + signature_payload, // BytesN<32> from the host + context_rule_ids, // Vec, one per auth context +}; +let auth_digest = preimage.digest(e); +// External signers sign auth_digest, not signature_payload ``` +The `SmartAccount` trait also exposes an `auth_digest(preimage)` view that returns `sha256(preimage.to_xdr())`. Simulating it lets a client check its locally computed digest before collecting any signatures. + + +Up to v0.7.x, the digest was `sha256(signature_payload || context_rule_ids.to_xdr())` and `Delegated` signers authorized that 32-byte digest. Signatures produced that way are rejected by accounts built with v0.8.0 or later. See the [migration guide](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/accounts#migration-guide-from-v07x-to-080) for details. + + ### XDR Encoding The `AuthPayload` serializes as an `ScVal::Map` with two `Symbol`-keyed entries: @@ -525,6 +590,34 @@ creds.signature = ScVal::Map(Some(ScMap::sorted_from([ ])?)); ``` +The `AuthDigestPreimage` is encoded the same way. Its XDR serialization is what `External` signers hash, and the map itself is the argument of a `Delegated` signer's `__check_auth` entry: + +```rust +let preimage = ScVal::Map(Some(ScMap::sorted_from([ + ( + ScVal::Symbol("account".try_into()?), + ScVal::Address(smart_account), // ScAddress of the smart account + ), + ( + ScVal::Symbol("context_rule_ids".try_into()?), + ScVal::Vec(Some(ScVec(VecM::try_from([ + ScVal::U32(rule_id_for_context_0), + ScVal::U32(rule_id_for_context_1), + ])?))), + ), + ( + ScVal::Symbol("signature_payload".try_into()?), + ScVal::Bytes(ScBytes(signature_payload.to_vec().try_into()?)), + ), +])?)); + +let auth_digest = Sha256::digest(preimage.to_xdr(Limits::none())?); +``` + +### Client Implementations + +The [`auth_entries` test module](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/accounts/src/smart_account/test/auth_entries.rs) builds both the smart account's entry and a delegated signer's nested entry in Rust and drives them through the host's `__check_auth`; it is the reference for client implementations. The [smart-account-kit](https://github.com/stellar/smart-account-kit) TypeScript SDK implements this flow end to end. Check that its version targets the `stellar-accounts` release the account was built with. + ## Signer Management The [`SmartAccount`](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/accounts/src/smart_account/mod.rs) trait provides functions for managing signers within context rules: @@ -543,19 +636,23 @@ Adds a signer to an existing context rule and returns the assigned signer ID. Th ### Batch Adding Signers +Batch adding is not part of the `SmartAccount` trait. The `smart_account` module provides a `batch_add_signer` helper that adds multiple signers to an existing context rule at once, and a contract can expose it through its own entry point: + ```rust -fn batch_add_signer( - e: &Env, - context_rule_id: u32, - signers: Vec, -); -``` +#[contractimpl] +impl MySmartAccount { + pub fn batch_add_signer(e: &Env, context_rule_id: u32, signers: Vec) { + // The helper does not perform any authorization check itself. + e.current_contract_address().require_auth(); -Adds multiple signers to an existing context rule at once. + smart_account::batch_add_signer(e, context_rule_id, &signers); + } +} +``` **Important:** -When adding signers to rules with threshold policies, administrators must manually update policy thresholds to maintain security guarantees. See the Policies documentation for details. +When adding signers to rules with threshold policies, administrators must manually update policy thresholds to maintain security guarantees. See the [Policies](/stellar-contracts/accounts/policies#caveats) documentation for details. ### Removing Signers @@ -568,11 +665,11 @@ fn remove_signer( ); ``` -Removes a signer (identified by its numeric ID) from an existing context rule. The rule must maintain at least one signer OR one policy after removal. +Removes a signer (identified by its numeric ID) from an existing context rule. The rule must maintain at least one signer OR one policy after removal. The ID is returned by `add_signer`, and the `get_signer_id(signer)` view looks it up for an already registered signer. **Important:** -When removing signers from rules with threshold policies, verify that the threshold remains achievable with the remaining signers. +When removing signers from rules with threshold policies, verify that the threshold remains achievable with the remaining signers. With a weighted threshold policy, lower the threshold before reducing weights (see [Required Operation Ordering](/stellar-contracts/accounts/policies#required-operation-ordering)). ## Verifiers @@ -696,7 +793,7 @@ Components: - Signature counter (4 bytes) - **`client_data`**: JSON string from the browser/platform (max 1024 bytes), containing: - `type`: Must be `"webauthn.get"` for authentication - - `challenge`: Base64url-encoded signature payload + - `challenge`: Base64url-encoded (unpadded) auth digest, `sha256(preimage.to_xdr())` (see [Auth Digest Computation](#auth-digest-computation)), not the host's `signature_payload` - `origin`: The origin where authentication occurred The `sig_data` parameter passed to the verifier must be the XDR-encoded representation of this structure to ensure proper serialization and deserialization. diff --git a/content/stellar-contracts/accounts/smart-account.mdx b/content/stellar-contracts/accounts/smart-account.mdx index 29220e83..d876e477 100644 --- a/content/stellar-contracts/accounts/smart-account.mdx +++ b/content/stellar-contracts/accounts/smart-account.mdx @@ -85,4 +85,12 @@ Authorization is determined per authorization context by evaluating the specific 2. **Rule Evaluation**: Validate the rule matches the context, authenticate signers, and enforce policies (policies panic on failure) 3. **Result**: Grant or deny authorization +Signers never sign the host's raw `signature_payload`. They commit to an [`AuthDigestPreimage`](/stellar-contracts/accounts/authorization-flow#authdigestpreimage) that binds the smart account address, the payload, and the selected context rule IDs. + For detailed documentation, see [Authorization Flow](/stellar-contracts/accounts/authorization-flow). + +## Examples + +- [Multisig smart account](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/multisig-smart-account): an account configured through context rules, together with Ed25519 and WebAuthn verifiers and threshold, weighted threshold, and spending limit policy contracts +- [Smart account factory](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/multisig-smart-account/factory): deploys accounts to deterministic addresses derived from their initial signers and policies, so an account's address can be predicted before deployment +- [smart-account-kit](https://github.com/stellar/smart-account-kit): TypeScript SDK for signing and submitting transactions from a smart account; check that its version targets the `stellar-accounts` release the account was built with