Capability-based access control for Soroban smart contracts on the Stellar network.
Instead of every business contract hard-coding "is this the owner?", a keygate deployment answers one question: may this address perform this action on this resource right now?
A capability is a triple (resource, action, holder) carrying an expiry. resource and action are free-form strings chosen by the integrator ("vault:main" / "withdraw", "issuer:acme" / "mint"), so the registry is not tied to any particular notion of a role.
Three contracts compose into an authorisation stack:
caller ──▶ keygate-policy ──▶ keygate-core ──▶ keygate-circuit-breaker
"do we permit "does the "has this resource
this action at holder still exceeded its budget
all right now?" hold it?" in this window?"
| crate | role |
|---|---|
keygate-core |
Capability registry: issue, renew, revoke, check. Two-step admin handover, freeze switch. |
keygate-policy |
Allow/deny rules per (resource, action) in front of a registry. Deny takes an action out of service without touching hundreds of grants. |
keygate-circuit-breaker |
Rolling-window budgets that latch closed when breached, and stay closed until an explicit reset. |
keygate-multisig |
m-of-n administration. Relays arbitrary calls (target, func, encoded args) so the registry's admin key is never held by one person. A proposal that targets the multisig itself is applied inline, because Soroban will not let a contract re-enter itself. |
Keeping policy apart from the registry answers two different questions. The registry asks does this holder have the capability; policy asks do we want that capability to be usable at all right now. An incident response is one Deny rule rather than a bulk revocation, and deleting the rule restores exactly the prior state.
The breaker trips instead of merely rejecting. A require(amount <= limit) line stops one withdrawal; a latched breaker stops the draining that follows.
cargo build --workspace # host build, for tests
make optimize # release wasm for deployment
make check # fmt + clippy -D warnings + testRequires a current stable Rust toolchain; rust-toolchain.toml pins rustfmt, clippy and the wasm32-unknown-unknown target, so rustup provisions everything on first build. Cargo.lock is committed and CI builds --locked — the SDK's own dependency ranges do not resolve to a compilable graph on their own, so the lockfile is part of the build, not a nicety.
cargo build --release --target wasm32-unknown-unknown
# then, with the Soroban CLI:
soroban contract deploy --wasm target/wasm32-unknown-unknown/release/keygate_core.wasm
soroban contract invoke --id <core> -- initialize --admin <GADDRESS>
soroban contract invoke --id <core> -- issue \
--resource "vault:main" --action "withdraw" --holder <GADDRESS> --ttl 3600
soroban contract invoke --id <core> -- check \
--resource "vault:main" --action "withdraw" --holder <GADDRESS>
# truerequire_access is the entry point other contracts should call — it authenticates the caller and fails closed:
let core = KeygateCoreClient::new(env, &core_address);
core.require_access(&caller, &resource, &action);
let breaker = KeygateBreakerClient::new(env, &breaker_address);
if breaker.enforce(&resource, &amount) == Outcome::Breached {
// Skip the transfer, do not fail the call. A caller that returns an error
// here reverts the breaker's own frame, and the latch goes with it.
return Ok(());
}Admin operations on keygate contracts should be routed through keygate-multisig and authorised by the multisig's own contract address rather than by a single user key.
Version 0.1.0. All four crates are covered by unit tests — sixty-one of them, against the SDK's own Env — but this code is unaudited — read SECURITY.md before holding real value with it. Deliberate limitations:
- Capability expiry is reclaimed lazily, on the first
checkof an expired grant. There is no background sweep, so a grant that is never queried stays in storage. keygate-policytrusts the registry address it was configured with. Repointing it is an admin action and is emitted as an event for indexers.- A breaker breach is a value (
Outcome::Breached), not an error, because a call that errors loses its writes and would lose the latch with them. That means a guarded call is stopped by the caller honouring the outcome; a caller that ignores it spends past its limit once, before the latch shuts everyone out.enforcehas no way to close that gap on its own — see SECURITY.md. - Authorisation itself (
require_auth) is enforced by the Soroban host. Unit tests mock all auths, so they cover state transitions, not signature validity.
- CONTRIBUTING.md — development setup, code style, how to add a crate
- SECURITY.md — vulnerability reporting and threat model
- SUPPORT.md — where to ask questions
- CODE_OF_CONDUCT.md
Apache-2.0. See LICENSE.