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
This proposal describes the authorization framework built into the smart contract runtime environment.
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
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
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.
See the XDR diffs in the Soroban overview CAP, specifically those referring to
the SorobanAuthorizationEntry
.
This CAP uses several terms specific to the Soroban authorization framework.
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 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'.
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.
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.
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.
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.
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
.
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.
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.
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.
- This case is not executed when there is at least one non-exhausted
- In case if entry has a currently matched node in the invocation tree, try
matching every
SorobanAuthorizedFunction
in itssubInvocations
. 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.
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 contractfunctionName
equals to the name of the entry point function of the currently running contractargs
are equal to arguments of the currently entry point contract function (in case ofrequire_auth
) orargs
passed torequire_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 toCONTRACT_ID_PREIMAGE_FROM_ADDRESS
variant, whereaddress
is thedeployer
passed to the host function andsalt
issalt
value passed to the host function.executable
is set toCONTRACT_EXECUTABLE_WASM
variant, wherehash
value iswasm_hash
value passed to the host function.
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
withSC_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
withSC_ADDRESS_TYPE_CONTRACT
address authentication is delegated to the custom account contract with the corresponding address using the reserved__check_auth
function. Ifcheck_auth
traps or returns an error, the authentication is considered failed.
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).
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.
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.
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 ascurrentLedgerSequence + stateArchivalSettings.maxEntryTTL - 1
, wherestateArchivalSettings
is the value fromCONFIG_SETTING_STATE_ARCHIVAL
ConfigSettingEntry
.
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 specialSCV_LEDGER_KEY_NONCE
variant ofSCVal
.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 toTEMPORARY
body
is set toDATA_ENTRY
withdata.val
set toSCV_VOID
SCVal
anddata.flags
are0
.liveUntilLedgerSeq
is set to thesignatureExpirationLedger
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.
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 ofInvokeHostFunctionOp
. 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 callrequire_auth
, the following combinations ofSorobabAuthorizationEntry
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 callsrequire_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.
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 onInvokerContractAuthEntry
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.
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.
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.
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.
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/auth.rs in Soroban host contains the comprehensive tests for various authorization scenarios.