Skip to content

Latest commit

 

History

History
605 lines (485 loc) · 28 KB

cap-0046-11.md

File metadata and controls

605 lines (485 loc) · 28 KB

Preamble

CAP: 0046-11
Title: Soroban Authorization Framework
Working Group:
    Owner: Dmytro Kozhevin <@dmkozh>
    Authors: Dmytro Kozhevin <@dmkozh>
    Consulted: Nicolas Barry <@MonsieurNicolas>
Status: Final
Created: 2023-07-28
Discussion:
Protocol version: 20

Simple Summary

This proposal describes the authorization framework built into the smart contract runtime environment.

Motivation

See the Soroban overview CAP for overall Soroban motivation.

Any smart contract that directly produces side effects (ledger modification and events) requires some sort of authentication and authorization procedure. Without it, any party could produce any side effects, making the contract pointless.

A lot of the authorization tasks are common for almost every contract: authentication, context verification, and signature replay protection are very likely to be implemented in the same fashion. A common solution for these tasks would be beneficial for the contract developers.

On the other hand, bespoke authorization protocols require bespoke client support, which makes it hard to present the information about what exactly is being authorized to the user.

Both of the above points serve as motivation for providing the authorization framework that is built into the core protocol and is not just defined by SEP. Built-in authorization framework has the following benefits:

  • Safety guarantees that are hard (or impossible) to achieve on the contract side (for example, advanced context tracking between cross-contract calls)
  • Built-in contracts and authorized host functions can use exactly the same authorization standard as any other contract
  • Common procedures can be abstracted away and thus lower risk of contract developers missing an important authorization piece
  • Clients can rely on a single authorization standard that most of the contracts use
  • Better performance thanks to using the native code and thus lower fees

Goals Alignment

This CAP is aligned with the following Stellar Network Goals:

  • The Stellar Network should make it easy for developers of Stellar projects to create highly usable products

Abstract

This CAP introduces the Soroban Authorization Framework. It consists of an authorization host module that performs common authorization tasks, custom account standard and specification, and transaction-level support for authorization entries.

Specification

XDR

See the XDR diffs in the Soroban overview CAP, specifically those referring to the SorobanAuthorizationEntry.

Terminology

This CAP uses several terms specific to the Soroban authorization framework.

Address

This is a universal built-in identifier in Soroban. In XDR addresses are represented by SCAddress XDR that has two variants:

  • SC_ADDRESS_TYPE_ACCOUNT for the Stellar account identifier (AccountID).
  • SC_ADDRESS_TYPE_CONTRACT for the 32-byte smart contract identifer

Account

Account in the context of Soroban Authorization is any address that performs authorization (not to confuse with the classic Stellar AccountID). For example, in case if payment is performed from address A to address B, address A needs to authorize the payment. Thus we can say 'account A authorizes the payment to address B'.

Custom account

Accounts identified with the contract identifier (i.e. with SC_ADDRESS_TYPE_CONTRACT address) are referred to as 'custom accounts'. As opposed to the classic Stellar accounts, implementation of the custom accounts is provided by the arbitrary smart contract logic.

Components

On a high level, the authorization framework comprises the following components:

  • Transaction-level definitions of authorization entries. These allow users to provide authorization for arbitrary trees of the contract calls on behalf of arbitrary accounts using the standardized data structure.
  • Host functions that allow contracts to interact with host's authorization module.
  • Authorization module of Soroban host. It consumes the transaction-level authorization data, performs authentication, provides replay protection, and enforces the authorization context.

In the following sections each of the components is described in detail.

Authorization Payload in Transaction

InvokeHostFunctionOp contains an arbitrarily sized array of SorobanAuthorizationEntry alongside the host function.

Every SorobanAuthorizationEntry contains a single tree of contract calls represented by SorobanAuthorizedInvocation XDR and SorobanCredentials that contain the necessary information for the account to authorize the tree.

SorobanAuthorizedInvocation only contains the contract calls that require authorization. More details on how this tree is matched to the actual contract calls are provided in the following section. Every node in SorobanAuthorizedInvocation tree contains an authorized function specification and an arbitrary number subInvocations that specify the authorized call trees spawned from the function.

SorobanAuthorizedFunction allows authorizing the contract invocations with SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN and contract creation operations with SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_HOST_FN. Specification for both is defined by the respective XDR structures used in InvokeHostFunctionOp: InvokeContractArgs for the contract invocation and CreateContractArgs for the contract creation.

SorobanCredentials are a union with 2 supported values: SOROBAN_CREDENTIALS_SOURCE_ACCOUNT with no additional payload and SOROBAN_CREDENTIALS_ADDRESS with SorobanAddressCredentials payload.

SOROBAN_CREDENTIALS_SOURCE_ACCOUNT identifies that the transaction source account authorizes the corresponding call tree. Since Soroban authorization data is signed by the source account as a part of the transaction, the source account can authorize all SOROBAN_CREDENTIALS_SOURCE_ACCOUNT entries at once without any additional signatures or replay protection.

SorobanAddressCredentials includes the account address, nonce, signature expiration ledger and the signature itself. Signature is only considered valid until the signature expiration ledger (inclusive). Nonce is an arbitrary int64 number used for replay prevention. Nonce can't be reused until the signature expires. More details about the credentials authentication and nonce consumption are provided in the following sections.

Authorization Payload is Standalone

The specification described above is not associated with transaction in any way. This allows decoupling transaction source from the Soroban operation actors, so that they can only sign the Soroban authorization payload. The only exception are entries with SOROBAN_CREDENTIALS_SOURCE_ACCOUNT that infer the authorizer credentials from the transaction source account (the entry itself doesn't have any explicit identifiers connecting it to the transaction though).

This puts a restriction on the authorization enforcement mechanism: the entries have to only be used (i.e. consume nonces) when they are actually being used. This way frontrunning someone else's authorization entry becomes a no-op, unless exactly the same operation is performed, which is similar to just submitting a signed transaction for someone.

Soroban Authorization Signature Payload

The authentication of SorobanAddressCredentials is performed by verifying the signature (or signatures) of a SHA-256 hash of a protocol-defined payload in an account-specific way. The common payload is defined as ENVELOPE_TYPE_SOROBAN_AUTHORIZATION variant of HashIDPreimage. The sorobanAuthorization envelope has to be filled with information corresponding to the respective SorobanAuthorizationEntry and networkID. networkID is a SHA-256 hash of the of the network name. nonce and signatureExpirationLedger fields have to match the respective fields in SorobanAddressCredentials. invocation field has to match rootInvocation of the SorobanAuthorizationEntry.

Authorization Host Functions

There are two similar host functions for smart contracts to use in order to require authorization from an address:

  • require_auth_for_args(address: AddressObject, args: VecObject) - require address to have authorized call of the current contract function with provided arguments.
  • require_auth(address: AddressObject) - require address to have authorized call of the current contract function with all the arguments it has been called with.

require_auth and require_auth_for_args have equivalent functionality with the only difference being the automatic argument inference for require_auth. Going forward we refer to both as require_auth.

'Requiring authorization' means that a respective entry SorobanAuthorizedFunction has to be present in an authenticated SorobanAuthorizationEntry corresponding to the address. Contract address and function name are inferred automatically from the current contract's address and the entry point contract function, i.e. the function that has been invoked through the host. Internal function calls that are done with Wasm are not considered for the authorization purposes.

Another authorization-related host function is authorize_as_curr_contract(auth_entries: VecObject). This function authorizes the subcontract calls made on behalf of the current contract from the next (and only next) contract call that current contract performs. More specifically, if the current contract A calls function f of the contract B, then authorize_as_curr_contract allows A to specify authorizations on calls that B.f performs. Any authorization B.f itself performs on behalf of A is implicitly successful (more details in the following section). This function expects a vector of InvokerContractAuthEntry Soroban contract types defined as follows:

#[contracttype]
pub enum InvokerContractAuthEntry {
    Contract(SubContractInvocation),
    CreateContractHostFn(CreateContractHostFnContext),
}
#[contracttype]
pub struct SubContractInvocation {
    pub context: ContractAuthorizationContext,
    pub sub_invocations: Vec<InvokerContractAuthEntry>,
}
#[contracttype]
pub struct ContractAuthorizationContext {
    pub contract: Address,
    pub fn_name: Symbol,
    pub args: Vec<Val>,
}
#[contracttype]
pub struct CreateContractHostFnContext {
    pub executable: ContractExecutable,
    pub salt: BytesN<32>,
}

InvokerContractAuthEntry has the same semantics as SorobanAuthorizedFunction XDR, but expressed with contract types.

Soroban Host Authorization Module

All the authorization logic is implemented in the Soroban host and we refer to it as 'authorization module' in this CAP.

Soroban host is initialized with all the SorobanAuthorizationEntry entries from the input InvokeHostFunctionOp operation. These entries are validated structurally (i.e. host ensures that XDR is valid and that function arguments are valid host values), but not semantically, i.e. no authorization-related validations happen at this point.

There are two distinct authorization scenarios: authorization for the accounts via SorobanAuthorizationEntry transaction payload and authorization of invoker contracts. Invoker contract authorization is attempted before proceeding to more general account authorization flow. However, we start with specifying the account authorization algorithm, as the invoker contract authorization is very similar and builds on top of that algorithm.

Authorization Algorithm for Accounts

Whenever a contract calls require_auth for an account, host iterates over every non-exhausted SorobanAuthorizationEntry where credentials match the account's address. The address is just compared to SorobanAddressCredentials and transaction source account is used for comparison of SOROBAN_CREDENTIALS_SOURCE_ACCOUNT. Matching happens in exactly the same order as specified in transaction.

create_contract host function pushes a special stack frame identifying that 'create contract' host function is running and implicitly calls a special implementation of require_auth(deployer) with the arguments being CreateContractArgs inferred from the create_contract arguments. The same procedure is also applied to the InvokeHostFunctionOp operations with HOST_FUNCTION_TYPE_CREATE_CONTRACT host function variant.

For every entry with the matching address host proceeds to matching current authorized invocation in SorobanAuthorizationEntry to the current require_auth request. The matching algorithm is as follows (the details of sub-operations are specified in the sub-sections):

  • In case if entry hasn't been matched yet, try matching the root SorobanAuthorizedFunction of the entry to the current invocation. If the entry matches, perform authentication and consume nonce. Trap contract in case of authentication failure or incorrect nonce. Mark the root node as currently matched node and take a note of the host invocation frame where the root node was matched.
    • This case is not executed when there is at least one non-exhausted SorobanAuthorizationEntry with a currently matched node.
  • In case if entry has a currently matched node in the invocation tree, try matching every SorobanAuthorizedFunction in its subInvocations. In case of a match, mark the first match in iteration order as currently matched node.
  • Every time host pops an invocation stack frame, mark all the SorobanAuthorizationEntry entries that have a root node matched within that frame as exhausted.
SorobanAuthorizedFunction matching

The SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN variant is considered succesfully matched when all of the following is true:

  • contractAddress equals to the address of the currently running contract
  • functionName equals to the name of the entry point function of the currently running contract
  • args are equal to arguments of the currently entry point contract function (in case of require_auth) or args passed to require_auth_for_args.

All the equality comparisons must be equivalent to comparing the binary representation of XDR of every one of these fields.

SOROBAN_AUTHORIZED_FUNCTION_TYPE_CREATE_CONTRACT_HOST_FN variant is considered succesfully matched when CreateContractArgs are equal between the authorization entry and the contract creation operation.

In case of HOST_FUNCTION_TYPE_CREATE_CONTRACT operation two CreateContractArgs XDR structs are directly compared.

In case of create_contract host function CreateContractArgs are built as follows:

  • contractIDPreimage is set to CONTRACT_ID_PREIMAGE_FROM_ADDRESS variant, where address is the deployer passed to the host function and salt is salt value passed to the host function.
  • executable is set to CONTRACT_EXECUTABLE_WASM variant, where hash value is wasm_hash value passed to the host function.
Authentication

There are 3 different supported approaches to authentication, depending on credentials in SorobanAuthorizationEntry:

  • For SOROBAN_CREDENTIALS_SOURCE_ACCOUNT authentication is automatically considered successful, as host trusts the Core to authenticate the transaction source account.
  • For SOROBAN_CREDENTIALS_ADDRESS with SC_ADDRESS_TYPE_ACCOUNT address authenticate operation using the standard Stellar account authentication procedure. Medium signature threshold has to be reached. Account sequence number is not increased as Soroban nonce is used for replay prevention.
  • For SOROBAN_CREDENTIALS_ADDRESS with SC_ADDRESS_TYPE_CONTRACT address authentication is delegated to the custom account contract with the corresponding address using the reserved __check_auth function. If check_auth traps or returns an error, the authentication is considered failed.
Stellar Account Authentication

In case of SC_ADDRESS_TYPE_ACCOUNT address credentials the value of the signature SCVal is supposed to be the SCVal::Vec of the following contract type structures:

#[contracttype]
pub struct AccountEd25519Signature {
    pub public_key: BytesN<32>,
    pub signature: BytesN<64>,
}

The vector has to be sorted in increasing order of public keys and contain no duplicate public keys. Every public key has to be a valid signer of the Stellar account being authenticated and every signature has to be a valid signature of SHA-256 of the expected signature payload specified above. The total weight of the signatures has to be equal to or greater than the medium threshold of the Stellar account. The maximum allowed amount of signatures is 20 (consistently with the maximum number of transaction signers).

Custom Account Authentication

In case of SC_ADDRESS_TYPE_CONTRACT address credentials the value of the signature SCVal is arbitrary value that a custom account contract can interpret.

Any contract that implements the special reserved __check_auth function is treated as a custom account for the purpose of authorization.

The signature of the __check_auth function must be defined as follows:

fn __check_auth(
    signature_payload: BytesN<32>,
    signature: Val,
    auth_contexts: Vec<Context>,
) -> Result<(), Error>;

If __check_auth doesn't return () (i.e. returns error or traps), authentication is considered failed.

Context provides information about the context in which require_auth has been called and is defined as follows:

#[contracttype]
pub enum Context {
    Contract(ContractContext),
    CreateContractHostFn(CreateContractHostFnContext),
}
#[contracttype]
pub struct ContractContext {
    pub contract: Address,
    pub fn_name: Symbol,
    pub args: Vec<Val>,
}
#[contracttype]
pub struct CreateContractHostFnContext {
    pub executable: ContractExecutable,
    pub salt: BytesN<32>,
}
#[contracttype]
pub enum ContractExecutable {
    Wasm(BytesN<32>),
}

Note, that the ContractContext and ContractAuthorizationContext structure definitions are exactly the same as the structures defined for authorize_as_curr_contract function.

auth_contexts vector is produced via pre-order depth-first search of the rootInvocation in SorobanAuthorizationEntry that is being authenticated. The subInvocations are iterated in the same order as specified in authorization entry. For example, if an entry has the following invocation tree structure: A->[B->[D, E], C->[F->[G]] (where every letter corresponds to SorobanAuthorizedFunction and subInvocations are listed inside []), then the auth_contexts will contain functions in the following order: A,B,D,E,C,F,G.

Context directly corresponds to the SorobanAuthorizedFunction.

SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN is converted to Context::Contract. InvokeContractArgs are directly converted to ContractContext: contractAddress is written to the contract field, functionName to fn_name, and args vector of SCVal to args vector of Val.

HOST_FUNCTION_TYPE_CREATE_CONTRACT is converted to Context::CreateContractHostFn. CreateContractArgs are converted to CreateContractHostFnContext with omission of some implied fields (such as deployer address from CONTRACT_ID_PREIMAGE_FROM_ADDRESS, that matches the address of the account performing authentication due to the matching algorithm). CreateContractArgs executable is written to CreateContractHostFnContext executable. Token executable causes authentication to fail (as built-in token contract creation doesn't need authorization). contractIDPreimage.fromAddress.salt is written to the salt field of the struct. Entries with CONTRACT_ID_PREIMAGE_FROM_ASSET contractIDPreimage are not allowed and will cause authentication to fail.

Self-reentrancy for Custom Accounts

The custom account authentication is the only case of contract invocation with self-reentrancy allowed. More specifically, the contract with address A can call require_auth(A), which would result in calling A.__check_auth via the host invocation. Only self-reentrancy is allowed, i.e. re-entering any other contract than A in the example is not still not allowed.

Signature Expiration Verification

Before trying to consume the nonce, host makes sure that the signature expiration ledger is valid.

  • If the signature expirationLedger value is strictly less than the current ledger sequence number, then the signature is considered expired and authorization fails.
  • If the signature expirationLedger value is strictly greater than maximum allowed expiration ledger, signature is considered to be too early and authorization fails. Maximum allowed expiration ledger is determined by the network configuration and computed as currentLedgerSequence + stateArchivalSettings.maxEntryTTL - 1, where stateArchivalSettings is the value from CONFIG_SETTING_STATE_ARCHIVAL ConfigSettingEntry.
Nonce Consumption

Nonce is consumed only for the authorization entries that have the root invocation matched to an actual call, have passed authentication and don't have an expired signature. As mentioned in the authorization algorithm specification, nonce is not consumed (and not provided) for SOROBAN_CREDENTIALS_SOURCE_ACCOUNT credentials.

Nonces are stored in CONTRACT_DATA LedgerEntry with a special format. The contract data entry for a given nonce value N for address A is built in the following fashion:

  • contract address is set to A (this includes the Stellar account addresses)
  • key is set to the special SCV_LEDGER_KEY_NONCE variant of SCVal. nonceKey payload of the value is set to N. Only authorization module is allowed to create contract data entries with this key.
  • durability is set to TEMPORARY
  • body is set to DATA_ENTRY with data.val set to SCV_VOID SCVal and data.flags are 0.
  • liveUntilLedgerSeq is set to the signatureExpirationLedger from the credentials.

Before trying to consume the nonce host checks if it already exists in the ledger. If it does, then the signature is potentially replayed and thus the authentication check fails.

If nonce doesn't exist in the ledger yet, host creates the entry as per speicifcation and writes it to the ledger.

Due to the signature expiration verification procedure the nonce entry is guaranteed to stay in the ledger until the signature expires. After the nonce entry (and thus the signature) expires, nonce value can be reused for in the new SorobanAddressCredentials entry for the account with a new expiration ledger.

Important Propeties of the Algorithm

The authorization algorithm specified above has the following important properties:

  • There is no requirement for the root invocation of SorobanAuthorizationEntry to match the root invocation of InvokeHostFunctionOp. This allows users to bundle the signed operations in non-atomic fashion via a custom contract without requiring any additional authorization. Addresses also don't have to be unique in such scenario.
  • Contract invocations that don't call require_auth are ignored by the algorithm, which makes router-type contracts that just pass the invocation through without doing any writes transparent for the signer.
  • It is possible to have multiple authorized trees for the same address with the root in the same stack frame. In such case the inner nodes can be interchanged between trees while still satisfying the algorithm. For example, if contract A calls require_auth twice, then calls B and C both of which call require_auth, the following combinations of SorobabAuthorizationEntry invocations will pass authorization algorithm: A->[B, C], A, A->B, A->C, A->C, A->B, A, A->[B, C]. Note, that sequencing the calls changes the requirements, for example if A calls require_auth right before calling B and C, only the following combinations would pass: A->B, A->C, A->[B, C], A.
  • Authorized call trees can't be broken down into separate SorobanAuthorizationEntry entries (unless there are multiple valid trees, as described above). This means that if contract A calls contract B and both require authorization, the signature for 'A invokes B' has to be provided. Signatures for 'only A' and 'only B' won't be matched by the algorithm. This makes it hard to create a set of disjoint frontrunnable signatures.

Authorization Algorithm for Invoker Contracts

Whenver a contract calls require_auth host verifies if the address for which authorization is required is the invoker contract, i.e. if its address is an address of the contract that invoked the current contract. If that's the case, the call immediately succeeds.

In case if authorized address is not the direct invoker, it still might have indirectly authorized the call using authorize_as_curr_contract host function specified in the respective section. Thus host executes the same algorithm as the one described in the section above with the following differences:

  • Instead of SorobanAuthorizationEntry entries, operate on InvokerContractAuthEntry structures.
  • Don't perform any additional authentication or replay prevention. Host stores InvokerContractAuthEntry attributed to the currently running contract and thus these can be considered authentic in later calls.

All the relevant properties of the account-specific algorithm also apply to the invoker contract algorithm.

Invoker Contracts vs Custom Account Contracts Prioritization

A contract may be an invoker contract and a custom account contract at the same time. Following the specified algorithms, the priority is always given to the invoker contract auhtorization. For example, if contract A invokes contract B and contract B calls require_auth(A), then the call will immediately succeed because A is invoker of B. Thus if there is a SorobanAuthorizationEntry payload for contract A address with a matching invocation of B contract, it will stay non-matched and non-exhausted.

Resource Utilization

As mentioned in the Motivation section, authorization is necessary for most of the smart contracts, so moving the implementation to the compiled code (as opposed to Wasm) should on average reduce the resource utilization compared to the contracts that use Wasm-based authorization approach.

Granular nonces consume more ledger space than autoincrement nonces, however they are evicted from the ledger after expiration as any other temporary entries. Thus given short enough signature expirations these shouldn't cause significant ledger bloat and in the end may be more efficient than persistent autoincrement entries.

Security Concerns

Authorization framework built into protocol introduces a single potential common point of failure for all the contracts that are using it. However, it can can be evaluated and reviewed much more than an average contract, thus likely reducing the vulnerability probability compared to the manual implementation. Contracts are also not in any way forced to use the authorization framework beyond the interactions with the built-in token contract.

There are no specific security concerns from the protocol standpoint. As with any piece of Soroban host, most of the potential issues are isolated to the scope of the invoked contract.

Built-in token contract uses authorization framework as well, and there is a potential attack surface for unauthorized access to the trustlines and Stellar account balances, but the authorization logic has to be in the host anyway, so this CAP doesn't significantly change the risks for the built-in token.

Implementation

auth.rs file of Soroban host contains the implementation for the authorization framework.

account_contract.rs file of Soroban host contains the implementation of Stellar account authentication as well as the harness for calling the custom contracts.

Test Cases

test/auth.rs in Soroban host contains the comprehensive tests for various authorization scenarios.