Capability Delegation

July 16, 2026 ยท View on GitHub

A capsule that holds a capability can pass a subset of its authority to another capsule for a bounded time. The object that carries that transfer is a Delegation, a separate authenticated structure from the capability token, with its own material, its own domain-separated MAC, and its own set of rules enforced at creation and re-checked at use. This page documents the delegation module in full: the structure, every constructor guard, the exact subset and expiry rules, the MAC and its material, the three verification entry points, and every error the module can return. It lives at src/capabilities/delegation/.

The Delegation structure

A delegation is a signed statement that one module has granted a set of capabilities to another (src/capabilities/delegation/types.rs:23):

  Delegation
    delegator      u64                 the module granting authority
    delegatee      u64                 the module receiving it
    capabilities   Vec<Capability>     the delegated subset
    expires_at_ms  Option<u64>         when the delegation lapses, if ever
    parent_nonce   u64                 the nonce of the delegator's token
    signature      [u8; 64]            the keyed MAC over the above

Its predicates mirror the token's. is_expired returns false for a delegation with no expiry and otherwise compares the current time against expires_at_ms (types.rs:34); is_valid is simply !is_expired; remaining_ms reports the time left with a saturating subtraction; and grants, grants_all, grants_any, and capability_count test the delegated set. Note what the structure does not carry: it has no capability bitmask cache and no address-space binding of its own. Its authority is entirely the capabilities vector, and its binding to a live authority is the parent_nonce.

Creating a delegation

The checked constructor is create_delegation (src/capabilities/delegation/create_checked.rs:24), and every guard in it is a rule of the delegation model:

  create_delegation(parent_token, delegatee, caps, ttl_ms):
      if caps is empty                         -> NoCapabilities
      if !is_token_valid(parent):
          if parent is expired                 -> ParentExpired
          else                                 -> InvalidParentToken
      for cap in caps:
          if !parent.grants(cap)               -> CapabilityNotHeld
      now = timestamp_millis()
      expiry = ttl_ms.map(|t| now.saturating_add(t))
      if parent has an expiry:
          expiry = min(expiry or parent_expiry, parent_expiry)
      build Delegation { delegator: parent.owner_module, delegatee,
                         capabilities: caps, expires_at_ms: expiry,
                         parent_nonce: parent.nonce, signature: [0; 64] }
      sign_delegation(&mut delegation)

Three properties are enforced here and are worth stating as invariants.

First, a delegation can only be created from a valid parent token. The parent must authenticate and not be expired, and the error distinguishes the two cases so a caller can tell an expired parent from a forged one.

Second, a delegation is always a subset. Every capability in the delegated set must be one the parent token actually grants, or creation fails with CapabilityNotHeld. A capsule cannot delegate authority it does not hold, so delegation can only attenuate, never amplify.

Third, a delegation never outlives its parent. If the caller passes a TTL it becomes now + ttl with a saturating add, and if the parent token itself has an expiry the delegation's expiry is clamped to the minimum of the two. A delegation whose caller asked for a longer life than the parent has is silently shortened to the parent's, and a delegation off a parent with an expiry inherits that bound even if no TTL was requested.

The sign_delegation call (src/capabilities/delegation/sign.rs) stamps the MAC. The module also carries an unchecked constructor (src/capabilities/delegation/create_unchecked.rs) that builds and signs a delegation without the parent-validity and subset checks above; the checked constructor is the enforced path, and the unchecked one exists for internal construction where the caller has already established those properties.

The MAC and its material

A delegation is authenticated the same way a token is, under the same boot signing key, but over its own material and with its own domain tag so the two MACs are not interchangeable.

The material is a 48-byte buffer (src/capabilities/delegation/material.rs:22):

  0..8    parent_nonce
  8..16   delegator
  16..24  delegatee
  24..32  the delegated capabilities as a bitmask
  32..40  expires_at_ms (0 if none)
  40..48  parent_nonce

As with the token, the capability field is the bitmask derived from the capabilities vector by caps_to_bits at the moment the material is built, so the vector and its bits are covered together. The parent nonce appears at both the head and the tail of the material.

The signature is compute_delegation_signature (material.rs:33), the same two-pass keyed BLAKE3 construction as the token MAC, except the second pass absorbs the suffix DELEG rather than CAP2:

  compute_delegation_signature(key, material):
      mac1 = blake3_keyed_hash(key, material)
      mac2 = keyed Blake3 of (material then "DELEG")
      return mac1 || mac2                              64 bytes

The distinct suffix is domain separation between two uses of the one signing key: a valid token MAC can never be reinterpreted as a valid delegation MAC and vice versa, because the material and the tag both differ.

Verifying a delegation

Three entry points verify a delegation, differing in what they take and what they return (src/capabilities/delegation/verify.rs).

verify_delegation(d, parent) (verify.rs:24) is the primary check. It returns false if the delegation has expired, if d.parent_nonce does not equal the parent token's current nonce, or if d.delegator does not equal the parent token's owner_module; then it fails closed on a missing signing key; then it recomputes the expected MAC over the material and compares it in constant time with ct_eq_64. The nonce check is the important one for revocation: a delegation is bound to the exact nonce the parent token had when it was created, so if the parent's token is re-minted with a new nonce, as it is on a revoke, the delegation no longer matches and stops verifying.

verify_delegation_strict(d, parent) (verify.rs:35) performs the same checks but returns a Result<(), DelegationError> that names the first failure: DelegationExpired, InvalidParentToken for a nonce or delegator mismatch, MissingSigningKey, or InvalidSignature.

verify_delegation_standalone(d) (verify.rs:55) verifies a delegation without a parent token in hand, checking expiry and the signature over the material using the delegation's own recorded parent_nonce. It confirms the delegation is internally authentic and unexpired but cannot confirm the parent is still the one it names, since it has no parent token to compare against.

The errors

The module's error type is closed and each variant has a fixed message (src/capabilities/delegation/error.rs:18):

  MissingSigningKey     "Signing key not available"
  InvalidParentToken    "Parent token is invalid"
  ParentExpired         "Parent token has expired"
  CapabilityNotHeld     "Cannot delegate capability not held"
  DelegationExpired     "Delegation has expired"
  InvalidSignature      "Signature verification failed"
  NoCapabilities        "No capabilities specified"

is_recoverable (error.rs:41) marks DelegationExpired and ParentExpired as recoverable, since both can be resolved by minting a fresh token or delegation, while the others indicate a malformed or unauthorised request.

Delegation depth

The single Delegation above is one hop. Re-delegation, passing on a capability that was itself received by delegation, is bounded by the delegation_depth field of the capability token and is handled by the chain module (src/capabilities/chain/), which caps how deep a delegation chain may run so that authority cannot be forwarded without limit. The syscalls that expose grant and revoke to capsules, MkCapGrant and MkCapRevoke, are gated by the IPC capability and handled in src/syscall/microkernel/capability/.

Debugging a refused delegation

A delegation is refused at one of two moments, creation or use, and the DelegationError variant (src/capabilities/delegation/error.rs:18) names which. The strict verifier is the one to reach for, because the primary verify_delegation returns a bare false and hides the cause; verify_delegation_strict (verify.rs:35) returns the first failing variant instead.

At creation, create_delegation (create_checked.rs:24) refuses an over-broad or unauthorised grant. NoCapabilities means the requested set was empty. CapabilityNotHeld is the subset rule firing: the caller asked to delegate a capability its own parent token does not grant, which is the refusal that makes delegation attenuate-only. InvalidParentToken and ParentExpired split the two ways a parent can be unusable, a token that does not authenticate versus one that has simply lapsed, and is_recoverable (error.rs:41) marks only the expired cases as fixable by re-minting, so a CapabilityNotHeld or a plain InvalidParentToken is a request the caller was never entitled to make.

At use, the failure is usually revocation showing through. verify_delegation (verify.rs:24) checks that d.parent_nonce still equals the parent token's current nonce and that d.delegator equals its owner_module; a mismatch on either returns InvalidParentToken from the strict path. This is the important one to recognise: a delegation is bound to the exact nonce the parent held when it was signed, so when the parent's token is re-minted with a new nonce, which is what a revoke does, the delegation stops matching and every copy of it dies at once without being individually hunted down. So a delegation that verified a moment ago and now fails InvalidParentToken is very likely a revoked or re-minted parent, not a corrupted delegation. DelegationExpired is the delegation's own TTL, which was clamped at creation never to outlive the parent's. InvalidSignature is the MAC not matching, which after the nonce and delegator checks have passed means the delegation bytes themselves were altered, and MissingSigningKey is the fail-closed guard on a kernel whose boot signing key is not set.

One subtlety worth stating: verify_delegation_standalone (verify.rs:55) can confirm a delegation is internally authentic and unexpired without a parent token in hand, but it cannot see a parent revocation, since it compares against the delegation's own recorded parent_nonce rather than a live one. A delegation that passes standalone but fails the parent-taking verifier is exactly a revoked parent.

Source map

  src/capabilities/delegation/types.rs           the Delegation structure
  src/capabilities/delegation/create_checked.rs  create_delegation and its guards
  src/capabilities/delegation/create_unchecked.rs the internal constructor
  src/capabilities/delegation/material.rs         the 48-byte material and MAC
  src/capabilities/delegation/sign.rs             sign_delegation
  src/capabilities/delegation/verify.rs           the three verify entry points
  src/capabilities/delegation/error.rs            DelegationError and is_recoverable
  src/capabilities/chain/                          multi-hop depth bounding
  src/syscall/microkernel/capability/             the MkCapGrant/Revoke handlers

The parent-token re-mint that quietly voids a delegation is the revocation epoch and nonce machinery on the revocation page; the token the parent nonce lives in is on the capability model page.