Crux Daemon · 14. Identity and capability
A passport is a named identity record with an associated key and a reputation tier. It is not a per-request credential. Under jwt_hs256 or jwt_jwks a JWT authenticates the caller; under off or dev_scopes nothing does. The passport is the label attached to the resulting principal, not the thing that proves it. There is no per-request Ed25519 challenge over a passport key anywhere on the HTTP or MCP request path.
Two things in this chapter deserve reading before you deploy: the shipped config.example.env runs with scope checks bypassed (§14.1), and RCX capability tokens are the one control in this documentation set that genuinely fails closed (§14.6).
This chapter is reference. Auth modes, scopes and the route-auth middleware are covered as configuration in chapter 7; this chapter covers identity as a subsystem. Every flag named here is listed in chapter 5, and verified defects are in chapter 16.
14.0 In plain English
Two different things in this chapter both sound like security, and they do very different jobs. A passport is closer to an entry in a staff directory than to a door key. It is a record the daemon keeps saying that an identity by this name exists, here is a key associated with it, and here is the standing it has earned. Because it exists, work stored in the daemon can be attributed to a named actor rather than to "someone". What it does not do is prove that the caller in front of you right now is that actor. Under the JWT modes that proof comes from the token on the request; under off and dev_scopes there is no proof at all, and the passport is simply the label attached to whatever principal results.
The second thing is a capability token, and it is a door key in the ordinary sense. It says what its bearer is permitted to do, it is signed, it can be narrowed as it is passed along so that a delegate ends up with strictly less authority than the party who handed it over, and it carries an expiry. It is the strongest access control described anywhere in this documentation set, and it is the one control here that genuinely fails closed: when a verifier meets a token whose contextual rules it does not understand, it rejects the token rather than waving it through.
Why does the split exist? Because attribution and permission are different problems and conflating them produces a system that is bad at both. You want to know who wrote a fact months after the session that wrote it ended, and you want to constrain what a particular agent can do in the next thirty seconds. A durable record answers the first; a short-lived signed grant answers the second.
You will read this chapter at two points. When you add a second agent and start caring which one wrote what, which is §14.2 and §14.5. And, more urgently, before you deploy anywhere other than your own machine, because the shipped config.example.env runs with scope checks bypassed. It is an example file tuned for a first local run, not a starting point for a deployment, and §14.1 says so with the specifics.
The thing people get wrong is assuming that because passports exist and involve keys, requests are authenticated by them. They are not. There is no per-request challenge over a passport key anywhere on the HTTP or MCP request path. Whatever authenticates your callers is configured in chapter 7; the passport is the name written next to the result.
14.1 The outer gate, and a warning about the shipped example
AuthMode (auth.rs:24) has four values, parsed with generous aliasing at auth.rs:57.
| Mode | Wire value | What authenticates the caller |
|---|---|---|
Off | off | nothing |
DevScopes | dev_scopes | scopes read from a client header, unverified |
JwtHs256 | jwt_hs256 | an HS256 JWT signature |
JwtJwks | jwt_jwks | a JWKS/OIDC JWT signature |
Startup is fail-closed on this variable. The daemon aborts if CORECRUXD_AUTH_MODE was never set (main.rs:304) and aborts separately on an unknown value (main.rs:313), so a typo can never silently degrade to dev scopes. There is genuinely no default, the AuthMode::DevScopes at config.rs:886 is a placeholder main never reaches. That is a good design and it is worth saying so.
Under AuthMode::Off, http_ctx returns an AuthContext with subject: None, passport_id: None, an empty scope set and tenants: TenantAllow::Any. passport_bound_context then sets auth_enforced: false and scope_bypass: true (auth.rs:1170, auth.rs:1174). scope_bypass short-circuits every scope check: has_scope returns true unconditionally (auth.rs:1096).
Warning, the shipped example configuration runs with scope checks bypassed.
config.example.envline 19 isCORECRUXD_AUTH_MODE=off(config.example.env:19). A reader who copies the example and starts the daemon is running an unauthenticated daemon withscope_bypass: true, every scope check on every route passes, and theX-Corecrux-Passport-Idheader is trusted verbatim. The only thing standing between that daemon and the network is the loopback bind default,CORECRUXD_HTTP_HOST=127.0.0.1. Do not expose anAUTH_MODE=offdaemon on any interface other than loopback, and do not treat a passport id in a log line from such a daemon as evidence of who did anything. Move tojwt_hs256orjwt_jwksbefore the daemon leaves your machine.
Under DevScopes the position is only marginally better: scopes come from X-Corecrux-Scopes or Authorization: Bearer <scopes> with no verification whatsoever (auth.rs:1173). A caller declares its own permissions. That is a development affordance, not an access control.
14.2 What a passport actually is
There are two passport stores. They share the __passport__:: entity prefix and use different keys, which is documented at passport.rs:100.
(a) The MCP reputation passport, entity __passport__::{agent_name}, key passport, value a JSON PassportRecord (passport.rs:32) carrying principal_id, sponsor_id, reputation_tier, receipt_count, issued_at, passport_hash, tenant_group, revoked_at and revoked_reason.
Tiers are a receipt-count ladder (passport.rs:25, passport.rs:66):
| Tier | Threshold |
|---|---|
elite | ≥ 2000 receipts |
trusted | ≥ 500 |
established | ≥ 100 |
basic | ≥ 10 |
unverified | below 10 |
tenant_group is explicitly RECORDED ONLY and is consulted for no visibility decision (passport.rs:44).
(b) The daemon passport, entity __passport__::{id}, key record, value corecruxd::passports::PassportRecord (passports.rs:62) carrying id, principal_id, public_key_hex, category (personal, work or public, validated at passports.rs:127), sponsor_id, reputation_tier, receipt_count, agent_work_gate, is_default_for_category, name, owner, position, company, notes and issued_at_unix_ms. The daemon seeds personal-default, work-default and public-default (passports.rs:16).
Passports are records, not per-request credentials
public_key_hex is populated from a generated keypair (passports.rs:302), and the node's own key is a single LocalPassportKey loaded from CORECRUXD_PASSPORT_KEY_PATH, default <state_dir>/passport.key (config.rs:887).
Every non-test use of public_key_hex across corecruxd and crux-mcp falls into three categories: it is serialised into responses (rcx_publish.rs:199), stamped into state (legal_holds.rs:251), or carried in usage-submission bodies (usage_submit.rs:145).
It is never used to verify an inbound request signature. There is no challenge, no request-signing scheme and no proof-of-possession on either the HTTP or the MCP request path.
The consequence for anyone building an audit story: a passport id in a receipt or a fact's actor field records which label the daemon attached to a call. Under JWT modes that label is bound to a verified token claim, and the daemon rejects a mismatched X-Corecrux-Passport-Id header with 403 PASSPORT_HEADER_MISMATCH. Under off or dev_scopes the label is whatever the client asked for.
14.3 MCP identity is a bearer token
Separately from HTTP auth, the MCP surface matches a bearer token against a registry:
CRUX_AGENT_TOKEN, a single agent nameddefault.CRUX_AGENT_TOKENS=alice:<tok>,bob:<tok>, a named registry.
Token policy is 32 to 256 bytes from [A-Za-z0-9._~-], and startup is fail-closed: if either variable is set but any token is invalid, the daemon refuses to boot. The only override is the explicit dev escape CRUX_MCP_ALLOW_EMPTY_AGENT_REGISTRY=1 (main.rs:322).
If neither variable is set, MCP runs with no auth and every caller is anonymous. get_agent_identity (mod.rs:2855) then returns the literal string "anonymous". This is also the condition under which no private fact is visible to anyone (see chapter 10 §10.7), anonymous callers see no private facts at all, which is a useful safety property of the default local posture.
14.4 Revocation
Representation. revoked_at (RFC 3339) plus revoked_reason (passport.rs:49). Revocation is terminal and supersede-don't-delete: the fact stays for audit, a revoked passport is never un-revoked, and a re-grant is a new passport (passport.rs:50). revoke_passport is idempotent, re-revoking is a no-op (passport.rs:507).
Who may revoke, can_revoke (passport.rs:99): the passport itself, or a caller holding the top elite tier, or the target's sponsor. There is no third-party revoke.
Enforcement flag. CRUX_PASSPORT_REVOCATION is read by revocation_enforced_from_env (dispatch.rs:115):
std::env::var("CRUX_PASSPORT_REVOCATION")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(true)
The default is ON. Two footguns follow directly from that code:
- Any value other than
1ortrueevaluates to false.CRUX_PASSPORT_REVOCATION=yes,=on,=enabledall silently disable enforcement. This is the opposite of the convention used by the freshness and consolidation flags, which treat only0/false/off/noas disabling. If you want it on, leave it unset or set it to exactly1. - An in-code comment contradicts the implementation. The comment at mod.rs:2871 reads "
CRUX_PASSPORT_REVOCATION=1, default-off". That comment is wrong. The implementation is.unwrap_or(true)and the doc comment on the resolver itself says "Launch default ON (proven live)" (dispatch.rs:109). The code is authoritative: default on. This correction is recorded here because the misleading comment is in shipped source.
The gate. At call_tool (mod.rs:2869): when enforcement is on and the tool is not in passport::REVOKED_AGENT_ALLOWLIST, caller_revocation_reason is consulted and a revoked caller gets a revoked_call_error. The allowlist is a tiny read-only set, get_passport and get_agent_identity, so a revoked agent can still learn why it was revoked (mod.rs:2872).
Three scope limits, all of which matter:
- MCP only. The gate lives in the MCP
call_tooldispatcher. The HTTP surface has no equivalent revocation check. A revoked passport can still drive the HTTP API. - Fail-open. The comment is explicit: "Fail-open: only an explicit
revoked_atblocks" (mod.rs:2876). If the passport record cannot be read, the call proceeds. - Not "reduced to read-only" in the general sense. A revoked passport is refused everything except the two-tool allowlist. Calling it read-only is accurate only because those two tools happen to be reads.
Wiring: main threads revocation_enforced_from_env() into McpContext::with_revocation_enforced (main.rs:1148). Both McpContext constructors default the field to false (dispatch.rs:135, dispatch.rs:173), so a test harness or an embedding context has enforcement off unless it is set explicitly.
14.5 Agent passports and scope aliasing
When CORECRUXD_AGENT_PASSPORTS is on, the caller's raw agent name resolves to a passport_id, and that becomes both the fact actor (fact_store.rs:214) and the scope identity.
To avoid stranding private facts written under the raw name while the flag was off, visible_entity_for_identity (scope.rs:57) matches the __agent::<owner>:: key against either the resolved identity or a set of legacy aliases. Visibility remains owning-identity-only: a different passport matches neither and is denied. Group-shared private visibility is explicitly not implemented and is deferred (scope.rs:52).
14.6 RCX capability tokens
This is the strongest access control in this documentation set, and it deserves its due. rcx-capability-token is a schema-lock and strict-verification crate for a custom CBOR/JSON capability token, not a JWT, not a macaroon, though it borrows macaroon-style attenuation.
Versions and sizes. RCX_CT_SPEC_VERSION = "rcx-ct/1.0" and RCX_CT_DELEGATION_SPEC_VERSION = "rcx-ct/1.1" (lib.rs:25). Sizes are fixed: signature 64 bytes, hash 32, public key 32 (lib.rs:27).
Canonicalisation. RcxCapabilityToken (lib.rs:982) serialises via to_canonical_cbor, to_signing_cbor and to_canonical_json, with token_hash and token_hash_hex computed over the canonical form. Unlike the receipt path (see chapter 13 §13.1), canonicalisation here is a real function rather than a producer convention.
Structure. The token carries an Issuer, a Subject, a TenantScope, an optional TeamScope with a TeamSeatRole, an EnterpriseScope, a list of PermittedCapability each with a CreditCost, a Backend, Credits with a CreditRefill, a FallbackPolicy, a Revocation block and a Signature.
Attenuation and delegation (v1.1). Caveat, DelegationPolicy, DelegationPresentation, DelegationAudience and DelegationEnvelope, bounded at ≤16 caveats, ≤64 scopes, ≤64 principals and ≤128-byte values (lib.rs:44). Two domain-separated signing contexts:
DELEGATION_BINDING_DOMAIN = "rcx-capability-token/delegation-envelope/v1\0" lib.rs:478
PRESENTATION_PROOF_DOMAIN = "rcx-capability-token/presentation-proof/v1\0" lib.rs:479
Signature checks use ed25519_dalek::verify_strict (lib.rs:696, lib.rs:1453). Clock skew tolerance is DEFAULT_CLOCK_SKEW_LEEWAY_SECS = 30 (lib.rs:1193).
The fail-closed guarantee
Stated in the module header (lib.rs:14): a v1.1 token is contextual, and the generic verify_token path fails it closed. A delegation-aware verifier, verify_token_attenuated (lib.rs:642), must be deployed before any v1.1 token is minted. This is mint-before-verify, deliberately.
The guarantee for older verifiers comes from #[serde(deny_unknown_fields)] plus the contextual gate, not from a version field, which is the right choice, because a version field is advisory and a deserialisation failure is not. requires_contextual_verification (lib.rs:1079) is the predicate.
That combination, canonical serialisation, domain-separated signing contexts, verify_strict, bounded attenuation, deny_unknown_fields, and a verifier that refuses what it does not fully understand, is a materially stronger design than anything else described in chapters 10 to 15. It is the one place where the audit found the code exceeding rather than trailing its description.
Capability vocabulary
Sync capabilities are corecrux.sync.pull and corecrux.sync.push (lib.rs:38) with a passport_bound attestation required. Hosted capabilities include vaultcrux.retrieve, team constraint and decision sync, and an enterprise encrypted-blob mirror.
Retrieval lanes are addressed as corecrux.lane.<slug> (lib.rs:71). The slug set enumerates the hosted plane's premium retrieval lanes, and it is not published here: the lane inventory and which lanes are gated is closed-system detail about CoreCrux, and 2.12 states that it is deliberately and permanently absent from this documentation. What you need in order to use a token is the address shape and the fact that a slug your token does not name is refused; you do not need the roster to do that.
The comment at lib.rs:64 is a useful honesty anchor and matches what chapter 11 documents: the free baseline lanes are never minted and never gated, local dense retrieval is free and uncapped by design.
Daemon wiring, and when it is inert
The consumer is crux-router, which is "deliberately pure for Phase 1 … network refresh and revocation IO land in later phases" (crux-router/lib.rs:8).
RouterMode (crux-router/lib.rs:28) is one of local, hosted, customer_hosted, degraded-local, degraded-queued or refused. DenialReason (crux-router/lib.rs:51) enumerates eight causes, each mapping to a denied:* reason code:
DenialReason | Meaning |
|---|---|
token_invalid | signature or structure failed verification |
token_expired | outside validity, beyond the 30-second leeway |
capability_not_permitted | the capability is not in the token |
egress_not_permitted | the data-egress class forbids it |
attestation_missing | a required attestation (e.g. passport_bound) was absent |
insufficient_credit | the credit balance will not cover the call |
receipt_class_side_effect_denied | the receipt class forbids the side effect |
backend_unavailable | the routed backend could not be reached |
In crux-mcp the router filters the advertised tool list (mod.rs:2495) and gates calls, returning a CAPABILITY_DENIED JSON-RPC error carrying reason_code, mode, token_id, token_hash, a stamp including revocation_checked, an optional refusal_receipt, and an upgrade_hint for metered denials (dispatch.rs:664).
But ctx.rcx_router is None in both default constructors (dispatch.rs:135, dispatch.rs:173). On a daemon with no RCX token configured the router is absent and the base tool list is served unfiltered (mod.rs:2490).
So: the mechanism fails closed when it is present, and it is inert when no token is configured, which is the default local posture. It gates the metered and hosted lanes and the tool list. It is not a general request authorisation layer, and it does not substitute for setting CORECRUXD_AUTH_MODE to a JWT mode.
14.7 Status summary
| Capability | Status |
|---|---|
CORECRUXD_AUTH_MODE startup fail-closed | SHIPPED, no default; unknown values abort |
config.example.env starting posture | AUTH_MODE=off ⇒ scope_bypass: true; loopback bind is the only protection |
| Passport minting (record plus keypair) | SHIPPED |
| Reputation tiers | SHIPPED, a receipt-count ladder |
| Per-request cryptographic passport enforcement | NOT WIRED: public_key_hex never verifies an inbound request |
| MCP bearer-token identity | SHIPPED, fail-closed on malformed tokens; no auth at all when unset |
Revocation representation and revoke_passport | SHIPPED |
| Revocation enforcement | FLAG CRUX_PASSPORT_REVOCATION, default on; MCP-only and fail-open; only 1/true count as on |
In-code comment at tools/mod.rs:2871 | Wrong, says "default-off"; the code says on. Corrected in §14.4 |
CORECRUXD_AGENT_PASSPORTS scope aliasing | SHIPPED; group-shared visibility not implemented |
| RCX capability token crate | SHIPPED: canonical CBOR, domain-separated signing, verify_strict, genuine fail-closed contextual verification |
| RCX daemon-side gating | SHIPPED but scoped, metered/hosted lanes and the tool-list filter; inert when no token is configured |
| RCX network revocation IO | ROADMAP, the crate states it lands in a later phase |
Sources
- crates/corecruxd/src/auth.rs:24,
AuthMode - crates/corecruxd/src/auth.rs:1174,
scope_bypassunderAuthMode::Off - config.example.env:19, the shipped
CORECRUXD_AUTH_MODE=off - crates/crux-mcp/src/tools/passport.rs:32, the MCP
PassportRecord - crates/corecruxd/src/passports.rs:62, the daemon
PassportRecord - crates/crux-mcp/src/dispatch.rs:115,
revocation_enforced_from_env,.unwrap_or(true) - crates/crux-mcp/src/tools/mod.rs:2871, the incorrect "default-off" comment
- crates/rcx-capability-token/src/lib.rs:14, the fail-closed contextual gate
- crates/rcx-capability-token/src/lib.rs:478, the delegation domain tags
- crates/crux-router/src/lib.rs:51,
DenialReason - crates/crux-mcp/src/dispatch.rs:135,
rcx_router: Noneby default

