Language Runtimes · Runtime Security

Attenuation and Revocation of Delegated Authority

How a capability can be narrowed before it is passed on, why revocation in a capability system is either impossible or requires indirection, and what each indirection costs.

Granting authority is the easy direction. A holder passes a reference, and the recipient can do what the reference permits. Two operations make the grant governable: attenuation, which narrows the authority before the pass, and revocation, which invalidates it afterwards. Attenuation is a pure function of the reference and costs nothing at use time. Revocation is where capability systems diverge, because an unforgeable reference that has been copied cannot be recalled without some form of indirection.

Attenuation is a construction, not a check

An attenuated capability is a new reference with a subset of the original’s rights. It is created by a call on the original, and the original remains valid with its full rights. The new reference’s rights are fixed at construction: it cannot be widened later, and the recipient cannot derive more from it than it was given.

The mechanism is a partial order over rights. If the original permits read, write, and truncate, the attenuated form might permit read only, or read and write with a maximum offset. The new reference satisfies the same interface as the original but rejects operations outside the subset, and the rejection is a property of the object the recipient holds, not of a policy consulted at call time.

This is a meaningful distinction. A policy consulted at call time must be evaluated correctly for every call, and its evaluation is part of the runtime’s trusted base. An attenuated object’s restriction is structural: the operation is simply not implemented, or is implemented to fail, and there is no policy to get wrong. A read-only view over a writable resource implemented as a wrapper that has no write method cannot be made to write by any argument the recipient supplies.

use std::fs::File;
use std::io;
use std::os::unix::fs::FileExt;

pub struct ReadAt<'a> {
    inner: &'a File,
    limit: u64,
}

impl<'a> ReadAt<'a> {
    // Attenuate: same underlying file, restricted range.
    pub fn new(inner: &'a File, limit: u64) -> Self {
        ReadAt { inner, limit }
    }
    pub fn read_at(&self, buf: &mut [u8], offset: u64) -> io::Result<usize> {
        let end = offset.saturating_add(buf.len() as u64);
        if end > self.limit {
            return Err(io::Error::from(io::ErrorKind::PermissionDenied));
        }
        self.inner.read_at(buf, offset)
    }
}

The wrapper narrows the range and nothing else. It is not a capability system in the full sense, because the recipient may still hold the original reference; attenuation only helps when the recipient receives the attenuated form and nothing else. That precondition is the whole design: attenuation works when authority is distributed by construction rather than by policy.

The composition of attenuations

Attenuations compose, and the composition is a meet in the rights lattice. The lattice’s elements are the rights a capability carries with it, and the meet is set intersection. Two successive narrowings yield the intersection, so the order does not matter for the final authority, though it may matter for what intermediate objects exist.

Composition is what makes attenuation practical for layered software. A supervisor holds a full capability, attenuates it to a service’s needs, and the service attenuates further for a plugin. The plugin’s authority is the intersection of all the restrictions on the path from the supervisor, and no single layer needs to know the others’ restrictions. Each layer’s argument is local: it gave away less than it held.

The lattice must have a bottom. A capability attenuated to no rights is either an object that rejects every operation or a null reference. If the system has no representation of the empty capability, a chain of attenuations can accidentally leave a right that no layer intended to grant. Having an explicit empty element makes the arithmetic total.

The dual operation, amplification, does not exist. There is no call that takes an attenuated capability and returns a broader one, because that would make the restriction advisory. This asymmetry is the reason the model is useful: authority decreases monotonically along every path away from the grantor.

Why revocation needs indirection

A copied reference is a copy of the authority. If the recipient has stored the reference in a data structure, passed it to another recipient, or embedded it in a closure, then invalidating “the” reference does not invalidate the copies. The system has no record of where they are.

Two designs address this, and both insert an indirection between the reference the recipient holds and the object it designates.

The first is a forwarding or validating layer. The recipient holds a reference to a proxy, and the proxy holds the real reference plus a revocation flag. Revocation sets the flag, and every subsequent operation through the proxy fails. The cost is one extra indirection per operation and one extra object per grant. The correctness condition is that the recipient cannot reach the real reference except through the proxy, which means the proxy must be the only thing handed over, and the real reference must not leak through any method the proxy exposes. A proxy method that returns the underlying object defeats the mechanism, and such methods are easy to add by accident.

The second is a table with generation counters. The reference is an index into a table, and the entry contains the object plus a generation number. A revoked entry’s generation is incremented, so a stale index paired with the old generation fails the check. The recipient’s reference is the pair (index, generation), and forging either is prevented by unforgeability. This is how some kernel capability systems implement revocation: the table is outside the subject’s address space, so the subject cannot modify the entry, only present an index.

A third design is to make the grantee’s authority depend on the grantor’s continued existence. If the reference is a child of the grantor in a hierarchy, revoking the grantor’s node revokes the subtree. This is coarse but cheap, and it fits systems where authority is naturally tree-shaped, such as a session with sub-sessions.

The cost of revocation, stated honestly

Every revocation mechanism pays per-operation overhead, and the overhead is on the hot path.

A proxy adds a pointer chase and a flag test to every operation. In a language runtime the chase is likely to miss cache if proxies are numerous, and the flag test is a branch that is predictable but not free. A generation check adds a comparison and, in a table-based design, a bounds check on the index.

The alternative is to not revoke. Many capability systems accept that a grant is permanent for the life of the object, and handle revocation by destroying the object rather than the reference. If the resource is closed, every reference to it fails, regardless of how many copies exist. This is what closing a file descriptor does at the kernel level, and it is why descriptor passing is safe in the sense that the underlying open file description can be invalidated globally.

The choice between these is a choice about what revocation means. Revoking the reference means the recipient loses access while other holders keep it. Revoking the object means everyone loses access. The first is finer and more expensive; the second is coarse and nearly free. Systems that need the first are systems with per-grantee policy, and those are the systems that can afford a proxy.

Revocation and the lifetime of delegated work

A subtle case is revocation during an operation. A recipient begins a long computation using a capability, and the capability is revoked mid-way. The operation has already read some data and is about to write.

If the proxy checks only at entry, the operation completes with authority that was valid when it began. If it checks at every access, the operation fails part-way with partial effects. Neither is obviously right, and the choice must be made explicitly, because a system that checks at entry but not at exit can be used to extend authority past revocation by starting an operation just before the revoke.

The workable pattern is to make the revocable unit a transaction with an explicit commit, so that a mid-operation revocation aborts the transaction and the partial effects are discarded. This requires the resource to support abort, which not every resource does. Where it does not, the system must either accept the partial effect or hold the revocation until the operation completes, and holding it means the revoke is asynchronous with an unbounded delay.

The interaction with attenuation

Attenuation and revocation compose awkwardly. An attenuated capability derived from a revoked one must also be revoked, and the derivation graph must be tracked or the revocation must propagate through the proxy chain.

Proxy chaining gives propagation for free if each proxy holds its parent proxy rather than the underlying object: revoking the root invalidates the whole chain, because the first hop fails. This is the natural design and the reason proxies should be built on proxies rather than on the object. A proxy that holds the object directly breaks the chain, and revocation of an ancestor does not reach it.

The generation-table design handles this less naturally, because the derived capability has its own table entry, and revoking the parent requires walking the derivation. Tracking derivations costs space per grant and requires the derivation to be recorded at creation, which means attenuation must go through the system rather than being a local wrapper.

The practical conclusion is that revocation is a system-level property and cannot be added to an existing capability design afterwards. A system designed with proxies from the start can revoke cheaply; a system designed with direct references cannot revoke at all without changing what a reference is. The same structural argument applies to fault isolation between tenants, where the choice of how a tenant names a resource determines whether the tenant can be shut down without stopping the process.

References

  1. Capability Myths Demolished — Mark S. Miller, Ka-Ping Yee, Jonathan Shapiro
  2. Capability-Based Computer Systems — Henry M. Levy, Digital Press, 1984
  3. The Protection of Information in Computer Systems — Jerome H. Saltzer and Michael D. Schroeder, Proceedings of the IEEE, 1975