Revocation

July 16, 2026 · View on GitHub

Authority that is granted must be withdrawable, and NØNOS withdraws it at four different scopes, each suited to a different need: an entire boot's worth of tokens at once, every token a single process holds, one specific token, and a whole publisher or a single certificate. This page documents each mechanism from the source, the exact state it keeps, the function that changes that state, and the point at which the change takes effect on a running capsule.

The four scopes, from widest to narrowest:

  cross-boot     the per-boot signing key and nonce      every token, on reboot
  per-process    the revocation epoch                    every token of one pid
  per-token      the revoked (owner, nonce) set          one token
  publisher      the trust anchor revocation lists       a cert, id, or key

Per-token: the revoked set

The narrowest revocation is a set of (owner, nonce) pairs (src/capabilities/token/revocation.rs:22):

  static REVOKED: RwLock<BTreeSet<(u64, u64)>> = ...

  revoke_token(owner, nonce)       insert the pair
  is_revoked(owner, nonce)         membership test
  revoked_count()                  the set size
  clear_revocations()              empty the set
  revoke_all_for_owner(owner)      keep only pairs whose owner differs

A token carries an owner_module and a nonce, and a token whose pair is in this set is dead: it still authenticates, but validity fails. revoke_all_for_owner rebuilds the set keeping only the pairs that do not belong to the named owner, which revokes every currently listed token of a single owner in one call. The set is behind an RwLock, so the common case, a read during validation, does not contend with other readers.

Token validity

The revoked set is consulted through the validity predicate (src/capabilities/token/validate.rs:21):

  is_token_valid(tok) =
      verify_token(tok) and tok.not_expired() and not is_revoked(owner, nonce)

A token is valid only if its MAC verifies, it has not expired, and its (owner, nonce) is not revoked. The module also exposes the three parts separately, is_token_signature_valid, is_token_not_revoked, and a validate_token_full that returns a Result naming the first failure as "Invalid signature", "Token expired", or "Token revoked". This predicate is the one consulted by the first step of the syscall resolver chain, so a revoked token fails the resolver on the capsule's next call.

Per-process: the revocation epoch

The revoked set is direct but does not scale: withdrawing authority from a busy capsule would mean listing every token it ever held. The revocation epoch solves that with a single counter, and its whole implementation is in the process capability module (src/process/caps.rs).

Every token binds the value of the process's revocation counter at the moment it was minted. new_token (caps.rs:38) reads it from the process control block and stamps it into the token's revocation_epoch field:

  new_token(pid, bits):
      boot_nonce = boot_session::nonce()?                 fail-closed
      asid = lookup_asid_for_process(pid) or 0
      revocation_epoch = pcb.revocation_epoch (load)
      build token { owner_module: pid, permissions: bits_to_caps(bits),
                    subject_asid: asid, boot_session_nonce: boot_nonce,
                    revocation_epoch, token_id: next id, nonce: 0, ... }
      sign_token(&mut token)
      Arc::new(token)

This is also the mint that binds the address space: unlike the base create_token, which leaves subject_asid and revocation_epoch zero, this process-level mint reads the real ASID and epoch and covers them with the MAC. rebind_address_space (caps.rs:71) re-mints after the process's address space is established, so the token reflects the real ASID before the process becomes reachable.

Revoking is then a single increment (caps.rs:99):

  revoke(pid, mask):
      pcb.revocation_epoch += 1                    bump first
      new_bits = pcb.caps_bits & !mask             drop the revoked bits
      fresh = new_token(pid, new_bits)             carries the new epoch
      install_token(pcb, fresh)

The increment happens before the fresh token is minted, so the new token carries the higher epoch and every token minted before it now carries a lower one. The resolver's check_revocation_epoch rejects any token whose epoch is behind the process's current one, so that one increment retires every outstanding token of the process at once, without enumerating them. Granting is the same shape without the increment (grant, caps.rs:89): it ORs in the new bits and re-mints, but does not bump the epoch, because adding authority does not invalidate the old token's scope.

install_token (caps.rs:61) is where the fresh token and its derived bitmask cache are swapped in together, under the token's write lock, so the authoritative Arc<CapabilityToken> and the caps_bits fast-path cache never disagree. And install_spawn (caps.rs:113) installs the verified-manifest token exactly once, using a compare-and-exchange on a caps_manifest_installed flag so a replayed or stale spawn path cannot re-issue authority a second time.

Publisher: the anchor lists

The broadest revocation short of a reboot lives in the trust anchor. Its policy carries three lists, checked during verified spawn rather than at each syscall: revoked_cert_serials retires one certificate, revoked_nonos_ids retires an entire publisher identity, and revoked_publisher_key_ids retires one manifest-signing key while leaving its certificate otherwise valid. Because these are consulted when a capsule is admitted, they stop a revoked publisher from spawning new capsules; they do not reach into capsules already running, which is what the per-process and per-token mechanisms above are for. The anchor also carries a trust_anchor_epoch that retires every certificate of an older generation at once, the certificate-level counterpart of the token revocation epoch.

Cross-boot: the signing key and nonce

The widest revocation of all is implicit. The token signing key is minted fresh each boot, and every token binds the per-boot session nonce. A token from a previous boot verifies against a different key over a different nonce, so it fails both the signature check and the session binding. Nothing survives a reboot: the entire authority state of the previous boot is void the moment the new one latches its key and nonce.

Where each is enforced

The scopes are enforced at two different points, and the capability model resolver chain is where the runtime ones land:

  check_token             signature, expiry, and the revoked (owner, nonce) set
  check_session_binding   the per-boot nonce, which voids prior-boot tokens
  check_revocation_epoch  the per-process epoch, which voids pre-revoke tokens

The anchor lists are enforced earlier and only once, at verified spawn, because a capsule that is already running was admitted under an anchor state that has since changed, and it is the per-process and per-token mechanisms, not the anchor lists, that reach it.

Debugging a revocation

The symptom of a revocation is the same as any other token failure: the capsule's next syscall comes back EPERM with a [CAP-DENY] line, because all three runtime scopes land in the resolver chain. What separates a revoke from an unrelated denial is the resolver step that fired, and the eight ResolverError variants (src/syscall/contract/resolver/error.rs) name them even though the [CAP-DENY] log does not print which one. The three revocation scopes map to three of them.

TokenRevoked is the per-token set: the token's (owner, nonce) pair is in REVOKED (token/revocation.rs), which is_token_valid consults through is_token_not_revoked. The full validator validate_token_full (token/validate.rs) is the tool here, since it returns the failure as the string "Token revoked" rather than a bare false, so a diagnostic path that calls it can tell a revoked token apart from an expired one ("Token expired") or a forged one ("Invalid signature"). RevocationEpochStale is the per-process scope: the token authenticates and is not in the revoked set, but its revocation_epoch is behind the process's current counter because revoke (process/caps.rs:99) bumped the counter and re-minted. A capsule that suddenly loses several capabilities at once, all with the same [CAP-DENY] pattern, is the epoch case, not a per-token revoke, because one increment retires every outstanding token of the process. And BootSessionMismatch is the cross-boot scope: a token carried from a previous boot fails the session-nonce check against this boot's latched value.

To confirm which fired without instrumentation, use the order. If the capsule was freshly re-minted (a grant or revoke just ran) and old handles fail while new ones work, it is the epoch. If a specific token dies but the process keeps working, it is the per-token set. If everything the capsule holds is dead from its first syscall, and this is the first boot after a reboot, it is the session nonce. The anchor lists never show up here at all: a revoked publisher or certificate is refused at verified spawn as a [RUNTIME-LOAD] FAILED reason=id_cert (variant Revoked or NonosIdRevoked), before the capsule ever holds a token, so a running capsule losing authority is never the anchor list.

Source map

  src/capabilities/token/revocation.rs   the revoked (owner, nonce) set
  src/capabilities/token/validate.rs     is_token_valid and validate_token_full
  src/process/caps.rs                    new_token, grant, revoke, install_token
  src/security/nonos_trust_anchor/schema.rs  the anchor revocation lists
  src/syscall/contract/resolver/         where the runtime checks run
  src/syscall/contract/resolver/error.rs the ResolverError variants a revoke maps to

The resolver chain and the [CAP-DENY]/EPERM surface are on the capability model page; the anchor-list revocations that fire at spawn are on the trust anchor and verified spawn pages; the parent-nonce binding a revoke breaks for delegations is on the delegation page.