Skip to content

Capability theory in Pact

Stuart Popejoy edited this page Oct 12, 2019 · 22 revisions

Definitions

"Standard" capability

Definition of standard capability sc of domain d, which is the name of the defcap whose parameters are P and defines the predicate/"guard" function g, which can compose/"import" other capabilities CI.

sc :: d = (P, g, CI)

"Managed" capability

For managed capability mc, it has all the features of c, plus it can decompose P into free or "identifying" variables that further inform its type, and bound or "quantifying" variables that are computed in the manager function m:

mc :: (d, PF) = (PB, g, m, CI)

Module admin capability

"Module admin" describes a privilege escalation that is specified by the module declaration itself, either as keyset-based or governance-based, the latter requiring specification of a defcap to guard module upgrade.

Module admin capability mm has a special type a, is parameterized by the module name n, cannot be managed, allows composition, and specifies either guard function g or keyset k.

mm :: a = (n, Either g k, CI)

Unified capability definition

In practice, managed capabilities are uniform with standard capabilities with only the presence of m to differentiate them, with the operational suggestion that m should match on PF and compute on PB. Module admin remains its own subtype.

c :: d = (P, g, Maybe m, CI)
c :: a = (n, g, CI)

If m is populated in c d, the capability is considered "managed".

defcap as constructor and predicate

The defcap special form plays two roles: it resembles a standard defun with a name, arguments and a body, which serves to implement the guard function g, but it also specifies a "capability reference" that uniquely identifies some inhabitant of d parameterized by P.

When a defcap is used for module governance, it specifies g only, as the module name n is inferred.

Capability reference constructor

The defcap construction operates as a constructor of a capability reference c* d:

P -> Maybe m -> c* d

Definition of g

defcap also defines the lambda function g operating on environment E. In addition to validating whatever is directly required to acquire the capability, it can "compose" or import other capabilities CI. Lastly it is pure in that it cannot update database state.

g :: P -> E -> Either Error CI

As such, g has the same shape as Pact's operation enforce-guard :: guard -> E -> Either Error (). We remark that g is therefore "both an enforcement and definintion of a guard".

Capability slots

Capabilities are collected in slots, which are scoped to indicate their operational semantics. The slot s stores the reference c* d, scope o, and any composed capability slots SI.

s = (o ∈ {Managed|Stack|Composed}, c* d, SI)

Managed scope

Managed capabilities are said to be "installed" as they can only be constructed and evaluated once. They are mutated by m when code requests a capability of matching domain d, described later.

Stack scope

Stack capabilities are said to be "acquired" by user code, and are scoped/managed by the call-stack at acquisition time.

Composed scope

A composed capability inherits the scope of the composing/parent capability and its slot inhabits parent CI.

Manager function m

For a given domain d, the manager function takes the parameters of a managed capability PM and those of a requested stack capability PS, which on success returns updated params for the managed capability P'M. The function is a pure function that is not intended to consider the environment, although this is not forbidden operationally.

m :: PM -> PS -> Either Error P'M

NB the ideal presentation is PMB -> PSB -> Either Error P'MB, where match has occurred on the free parameters.

Signer/witness capability map

Signers/witnesses are associated with capability references as a map W from the witness w to the associated capability references C*w.

W = w -> C*w

Runtime state R

The runtime R stores two sets of capability slots, Managed (SM) and Acquired (SA), the map of signer witnesses W, and a collection to track "seen" managed capabilities _C*M.

R = (SM, SA, W, C*M)

Acquired Capability Stack SA

SA operates as a stack in keeping with the callstack-scope of with-capability.

Operationally, it also accumulates pending capabilities as a stack, to properly handle nested composition, and secure in Pact's terminating design that a failure means the stack being slightly inaccurate during capability acquisition is of no consequence.

Post-validation, the stack is pruned as needed:

  • composed are popped and installed into parent SI;
  • managed are popped and inserted into SM;
  • acquired are left on the stack. After body is executed, the stack is blindly popped.

Operational semantics

Installation of managed capabilities: install-capability

Managed capabilities are said to be installed by one of two mechanisms: install-capability or automatically when specified in W. Only managed capabilities can be installed, so non-managed capabilities in W are ignored.

Construct reference c*M d.

Reference c*M d is either specified in the install-capability call or already constructed in W.

Test for already installed -> no action

Managed capabilities are tracked in runtime C*M with their initially-installed parameterizations to prevent duplicate installs. If this is found, no action is taken.

Not installed -> track, run g.

c*M d is added to C*M for dupe install tracking.

Newly-installed references execute g to validate install. Composed capabilities are added to SI as normal, but are not "manageable" themselves.

Successful install.

A new slot containing c*M d is added to SM.

Lifecycle of Managed capability

Managed capability stays in SM for the entire transaction, although it mutates per the manager function. The tracked reference in C*M is never mutated.

Acquisition of stack-scoped capabilities: with-capability

The with-capability special form attempts to acquire a stack-scoped or acquired capability, which upon success executes a contained body form.

Construct reference.

The first argument of with-capability applies the defcap as a constructor to create reference cA* d.

Test for already acquired -> no action

If a slot with c*A d is already in SA, control returns to the body form with no action.

Not acquired -> test managed

SM is scanned for any managed slots for references of the same domain d. For each, the associated manager function m is executed. The first to succeed wins. Order of execution is the natural order of the capability references.

NB: Ideally, it should be tested for an exact match on (d, PF) as only one match should be possible, and that one m function executed.

Execution of manager function m

For the installed capability cM* d, and the requested stack capability to acquire cA* d, the manager function is invoked, which upon success produces c'M* d.

Success: update of slot cM* d

The slot for cM* d is replaced with the c'M* d result from the manager function. Composed capabilities (SI) in cM* d are "copied" into SI in the requested capability slot.

Failure: acquire fails

In a situation where a capability slot of matching domain d was found but none succeeded, the acquire operation fails.

No match on managed: acquire fails

If d is managed but nothing was installed for d, acquire fails.

No match on non-managed: run g

If d is not managed, g is executed to validate acquisition.

Successful acquire.

If m or g succeeds, a new slot containing c*A d is added to SA, and the body is executed.

Lifecycle of Acquired capability

Once the body of with-capability terminates, the slot for c*A d is popped off of SA and terminates, along with any composed capabilities in SI.

Import of composed capabilities: compose-capability

Import, via compose-capability can only occur in g, so there is always a "parent" capability slot ready to receive the capability in CI.

Construct reference.

The first argument of compose-capability applies the defcap as a constructor to create reference cI* d.

Run identically as for an acquired capability

The procedure from here on out is identical to a stack/acquired capability, with the exception that on success no body form is executed, and failure causes the parent to fail as well.

Successful compose

Install cI* d parent CI.

Lifecycle of composed capabilities.

Composed capabilities go out of scope/terminate with their parent capability.

Testing capabilities: require-capability

require-capability looks for specified reference cR* d in SA, including any composed capabilities. As such it does not modify R so is safe to use anywhere. Also, it does not check SM, which means that acquired capabilities are the only actual "enforcement" mechanism, as possibly managed by caps in SM.

Signature/Witness capabilities

Filtering keys from enforce-keyset

When enforce-keyset is invoked, matching signer witnesses are included if and only if a capability in C*W is "in scope", which is anywhere in SM or SA including all composed capabilities, as well as any "pending" capabilities "coming into scope".

Note that since managed capabilities can only be installed once, those installed by dint of being in W are only validated against specified witnesses in W once. Thus, specifying a managed capability for w ensures that w will only be "used once".

Module admin capability

A module admin capability m a is an automatically acquired and managed capability such that acquisition results in automatic install and the capability effectively remains in scope for the rest of the transaction.

Automatic acquisition and management

Module admin is automatic in the following scenarios:

  • Module upgrade.
  • Direct access to module tables.
  • Invocation of a module guard outside of module code.

Reference construction, installation

Reference c* an is constructed. If a slot is already installed, acquisition is trivially successful (ie, a trivial m function). If not, a new slot for c* an is constructed, and guard is tested.

Guard execution

For keysets, this is simply enforcing the keyset. For governance function g, the function is evaluated.

On success, slot for c* an is installed (added to SM) and acquired (pushed onto SA). The capability reference is also added to "seen" collection C*M.

Manual acquistion/require/reference not currently possible

Currently there is no way to manually acquire module admin. Whether the mechanism is keyset or cap-based governance, while it is possible to replicate governance in a separate capability, it is currently impossible to directly reference module admin in code.

TODO without this, there is no way to bind a signer to module admin, so a key with some other capability won't match this test. It is possible that the syntax for caps in W should admit a special syntax for indicating c* an, namely the bare module name. This could also suffice for other uses needing a module admin reference.

Automatic management

Management of module admin simply allows the capability to come into scope anytime after first acquisition.

Rationale for "sticky" acquisition

Given the extreme power of module admin, it appears alarming that its scope is so lenient. However the rationale is to give module administrators the most power to perform tasks like data migration and threat mitigation, namely by directly accessing database tables outside of module code. Also, module governance is potentially expensive to grant, as in decentralized cases involving tallying votes in the database, so not making this grant sticky can result in prohibitive performance or gas usage by re-acquiring the capability.

Any attempt to allow extra-module code to have a mechanism to acquire module admin manually makes it possible for third-party code to do so as well. Conversely, requiring all administrator code to reside in the module burdens the module author with properly securing those functions with module admin, which as noted above is impossible currently, requiring duplicate methods.

We note that third-party exploits of module admin are very hard to pull off, and indeed can only happen if the admin transaction calls into third-party code which is ready to enact the exploit (which would fail in any normal transaction and draw attention to itself, as there is no way to detect that module admin is acquired).

Finally, this firmly sounds the alarm that putting module-admin credentials in a transaction is a highly sensitive operation which should not call 3rd party code if it can help it, and instead wait for a safe transaction in which to do so.

Runtime Safety

Termination

Pact code is guaranteed to terminate by

  • not allowing anonymous lambdas -- all lambdas must be declared in module code
  • resolving all references at module load, failing on any cycle

However, there are two cases where a module can execute "dynamic" code that has not been previously resolved at runtime:

  1. Keyset predicate evaluation. Custom keyset predicates are stored as a bare string and evaluated in enforce-keyset as an application of the indicated function.
  2. User guard evaluation. User guards can be read from the database or constructed externally, and are evaluated by-name as the application of the indicated function.

In both cases, they are run in a special context that tests the call stack for a repeated occurrence of the application function name, and fails if found.

Thus Pact enforces termination in all cases.

Safety in evaluation of g

The guard function g, when evaluated, runs as a "normal" application of d as a function with P for arguments. The invariants for safe execution of this function are:

Termination

Generally this is implied by being a "normal" application in a module, thus as standard Pact module code.

Acquire and "manual" install (install-capability) in-module only.

The ability to bring a capability into scope, either managed or call-stack, is restricted to in-module code only.

For acquisition, this guarantees that any state-mutating code guarded by with-capability cannot escape the confines of the declaring module. Otherwise, the authority granted by the capability is unknowable.

For installation, this ensures that the scenarios in which a managed capability is installed are only:

  1. Module code controlled by the author
  2. Installation via inclusion in W.

#2 is essential as it allows the end-user who might be affected by the capability to control how and if it is installed. Obviously, #2 will fail if for some reason this install cannot be achieved.

As "unscoped" capabilities, the goal here is to prevent a capability from being installed by untrusted code. Having said that, examples of safe usages of #1 are not known at this time, but the in-module restriction means that if one emerges, it's possible, and otherwise module authors can simply avoid it. (It is also useful for testing to install capabilities by acquiring module admin).

Prevention of acquire or install.

Acquire or install of c* d both change the state of R:

  • acquire pushes a slot for c* d onto _SA.
  • install adds a slot for c* d to SM and adds c* d to "seen" C*M.

If during this operation, a nested acquire or install were to happen:

  1. A nested install within an outer acquire would violate the scope implied by the outer acquire, as the nested install would persist after the outer acquire goes out of scope.
  2. A nested install within an outer install has no obvious defects.
  3. A nested acquire in an outer acquire would allow protected state-mutating activity scoped by the nested acquire to occur, while we were trying to see if we can acquire the outer capability, presumably guarding some other state-mutating activity. Reasoning about state in this scenario is unsatisfying at a minimum and possibly incoherent. It is if nothing else inelegant, although the stack scope would probably be unharmed.
  4. A nested acquire within an outer install has the same concerns as #2 about executing some state-modifying activity in the body of the install.

Since some issue above applies in either nested install (#1) or acquire (#3, #4), install and acquire are prevented while executing g. This is accomplished by testing for a defcap in the call stack when either install-capability or require-capability are invoked.

Obviously, this is what compose-capability is for, importing some other capability into the scope of the outer capability. compose-capability has the reverse invariant that it can only be invoked within a defcap execution, using the same call-stack-checking mechanism.

Safety of require-capability

require-capability does not affect R, as it simply fails if it does not find the indicated capability in SA. It also does not mutate state.

It is therefore safe in any context, acquire or install, as requiring within g does not produce any unwanted effects. It is more logical to compose instead of require in g, but it is otherwise harmless, and there are perhaps scenarios where testing without acquiring is correct. Plus as we will see, require is useful in user guards, which may be tested in the context of g.

This is a happy conclusion, as requiring capabilities in user guards are useful for certain scenarios, like allowing modules to pay gas for some external user: the external user can indicate some account that is controlled by a user guard that tests for a capability in the foreign module, which has been installed by the external user in W.

Safety in dynamic code

As noted above, there are two places where code is dynamically evaluated. While user guards are the more relevant case, anything said about user guards can easily apply to keyset predicates, as they execute in identical contexts:

  • The evaluation context is pure, which means the code cannot mutate state.
  • As noted above, termination is operationally guaranteed.

Acquire/install unsafe in dynamic code

As acquire and install cause execution of g, they are unsafe in dynamic code:

  1. Unsafe failures due to purity violations. Dynamic code is run in a pure context, whereas g is not thus restricted. The outer context of dynamic execution will cause impure code in g to fail.

  2. Unsafe failures if evaluated in g. If g tests a user guard or keyset predicate that attempts to acquire or install, the dynamic code will fail due to the restrictions above on acquire or install in g.

TODO: this is not enforced currently. Dynamic code should be noted as such in the call stack, so that it can be detected and always fail upon embedded acquire or install.

"Semantic safety"

Note that here, "unsafe" refers to a slightly different notion of safety which we will call "semantic safety": an unexpected failure in dynamic code renders the intention of the dynamic code inoperable, in a way that would otherwise be undetectable during module load and potentially testing. In other words, the real safety here is totality, where the cases of the dynamic code failing are feasible to enumerate.

This note is here because "safety" when talking about capabilities often refers to avoiding exploits like privilege escalation. In this context it is more about having total semantics for a user guard or keyset predicate.

Require safe in dynamic code

For the same reasons that require is safe in g, it is safe in dynamic code:

  • Purity is ensured, as require is pure and without any effects on the capability state.
  • Termination and "semantic safety" are ensured, as g is never invoked when requiring.

Notes on runtime vs compile-time safety

None of the enforcements in this section are currently detected at compile time, as there is no type-level representation of capabilities to guide it; while Pact's compilation could detect immediate violations, it cannot detect violations in linked code, even if it is in the same module. Therefore it is not currently possible to do this.

Instead, runtime guarantees ensure that simple coverage of impacted code will immediately cause failure if these invariants are violated.

Clone this wiki locally