[CAP-71] Authentication delegation for custom accounts #1784
Replies: 4 comments 8 replies
-
It only requires manually building signature payloads because the soroban-simulation, soroban-env-host, and the surrounding tooling (contract specs, etc) do not support a way to do iterative auth discovery where auths are nested. I think we should explore ways to address that before complicating the auth interface by adding another way to do auth with delegates, given delegates seem like they could be supported by the existing auth framework. |
Beta Was this translation helpful? Give feedback.
-
|
This CAP is a welcome step toward providing the prerequisites for the more intuitive usage of custom accounts in some more complex cases. As the motivation of the CAP explains, the main drawbacks in the current flow of nesting
This CAP addresses only the first drawback and lays the groundwork for the CAP-72. Below I focus only on how CAP-71 may change or improve the implementation and usability of smart accounts in the OpenZeppelin library, which currently relies on this nesting pattern. Smart Account OverviewIn the OpenZeppelin library, a smart account contains multiple rules. Each rule defines a context and a set of signers authorized for that context. Signers can be:
ImplementationIn our current implementation, the smart account branches the authentication logic based on the type of the signer. I’m providing here a slightly modified and simplified example for demonstration purposes: #[contractype]
pub enum Singer {
Crypto(Bytes),
Addr(Address)
}
fn __check_auth(
e: Env,
signature_payload: Hash<32>,
signatures: Map<Signer, Bytes>,
auth_contexts: Vec<Context>,
) -> Result<(), SmartAccountError> {
for (signer, _) in signatures.iter() {
check_signer_is_allowed(signer);
match signer {
Signer::Crypto(pub_key) => { /* verify signature */ }
Signer::Addr(addr) => {
let payload = (signature_payload.clone(),).into_val(e);
addr.require_auth_for_args(payload);
}
}
}
Ok(())
}With CAP-71 ...
match signer {
Signer::Crypto(pub_key) => { /* verify signature */ }
Signer::Addr(addr) => {
addr.delegate_account_auth();
}
}
...This change is positive: the signer will receive the whole context for free (although we don’t use it in the previous example), enabling more fine-grained authorization controls within that signer. UsabilityThe example below demonstrates the current client authorization flow for a smart account with only one signer ( async function signAndSendTx(contract: string, fnName: string, fnArgs: ScVal[], signer: Keypair) {
const baseTx = new TransactionBuilder(...)
.addOperation(
Operation.invokeContractFunction({ contract, function: fnName, args: fnArgs }),
)
.setTimeout(60)
.build();
const simTx = await server.simulateTransaction(baseTx);
// assume only one authorization is returned
const simAuth = simTx.result.auth[0];
const signedAuths: SorobanAuthorizationEntry[] = [];
// construct `sigMap` to fit `Map<Signer, Bytes>` with `Signer::Addr(Address)`
simAuth.credentials().address().signature(sigMap);
simAuth.credentials().address().signatureExpirationLedger(validUntil);
signedAuths.push(simAuth);
const payload = HashIdPreimage.envelopeTypeSorobanAuthorization(
new HashIdPreimageSorobanAuthorization({
networkId,
nonce: simAuth.credentials().address().nonce(),
signatureExpirationLedger: validUntil,
invocation: simAuth.rootInvocation(),
}),
).toXDR();
const hashed_payload = hash(payload);
const signedEntry = await signInvocation(
signer,
contract,
"__check_auth",
[ScVal.scvBytes(hashed_payload)],
);
signedAuths.push(signedEntry);
// rebuild transaction with both auth entries in signedAuths
// sign and send
}
async function signInvocation(
signer: Keypair,
contract: string,
functionName: string,
fnArgs: ScVal[],
): Promise<SorobanAuthorizationEntry> {
const contractAddress = Address.fromString(contract).toScAddress();
const args = new InvokeContractArgs({ contractAddress, functionName, args: fnArgs });
const invocation = new SorobanAuthorizedInvocation({
function: SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(args),
subInvocations: [],
});
return await authorizeInvocation(
signer,
validUntil,
invocation,
signer.publicKey(),
Networks.TESTNET,
);
}We manually craft and sign a second After CAP-71: async function signAndSendTx(contract: string, fnName: string, fnArgs: ScVal[], signer: Keypair) {
let baseTx = new TransactionBuilder(...)
.addOperation(
Operation.invokeContractFunction({ contract, function: fnName, args: fnArgs }),
)
.setTimeout(60)
.build();
const simTx = await server.simulateTransaction(baseTx);
// assume only one authorization is returned
const simAuth = simTx.result.auth[0];
const payload = HashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress(
new HashIdPreimageSorobanAuthorizationWithAddress({
networkId,
address: Address.fromString(contract).toScAddress(), // <-- new field
nonce: simAuth.credentials().address().nonce(),
signatureExpirationLedger: validUntil,
invocation: simAuth.rootInvocation(),
}),
).toXDR();
const hashed_payload = hash(payload);
const delegateSignature = keypair.sign(hashed_payload);
// construct `sigMap` to fit `Map<Signer, Bytes>` with `Signer::Addr(Address)`
simAuth.credentials().address().signature(sigMap);
simAuth.credentials().address().signatureExpirationLedger(validUntil);
simAuth.delegates([delegateSignature]);
// rebuild transaction with a single signed auth entry
// sign and send
}We still need a second simulation round, but we eliminate the need for an extra authorization entry and the confusing authorization to If the examples above correctly reflect CAP-71, it should deliver tangible benefits to both users and developers in implementation, usability, and readability, alongside the resource optimizations not covered here. Do these examples and explanations accurately capture the intention behind CAP-71? |
Beta Was this translation helpful? Give feedback.
-
|
An attribute of this proposal is it introduces function colouring to some degree for auth. I am using the term function colouring here in a way that is not so common since it is usually used to refer to functions that are only usable in asynchronous or synchronous contexts, and not the other. But the concept matches. For example: When a dev wants to auth outside a When a dev wants to auth inside a This means if a dev is building a library that initially gets used only in call trees outside |
Beta Was this translation helpful? Give feedback.
-
|
How does this proposal interact with cross-contract calls where |
Beta Was this translation helpful? Give feedback.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Discussion thread for CAP-71
The motivation behind this change is described in details in CAP, but here is the gist of it.
Since inception of Soroban, custom (contract-based) accounts could delegate authentication to other accounts (
Addresses) by usingrequire_auth_for_argswithin the__check_authfunction that performs custom account authentication.The approach works, but it has drawbacks:
This is inspired by the work on CAP-72 (https://github.com/orgs/stellar/discussions/1763), which allows any G-account to use delegated contract signers. Without this proposal the developer experience of using this functionality would likely be too cumbersome to get wide adoption.
Most of the issues can be fixed with a small protocol change proposed in the CAP. It introduces a new authorization host fn, and a new type of credentials that support the delegated signers explicitly. This allows users to sign a single authorization entry using different addresses, each of which may have its own authentication logic.
See details in the CAP itself.
Beta Was this translation helpful? Give feedback.
All reactions