Chio/Docs
LOGIN · JOIN

Credential broker

Let an agent use a provider credential through one authorized request while the secret, the connection, and the attempt history stay in the broker.

Concept chio@5f63e6e

On this page

Opening an issue, sending a message, or calling another provider operation takes a credential the agent never holds. The credential broker keeps the secret in its own service and performs the one request the agent was authorized to make.

The broker attaches the stored credential to the authorized HTTPS request, and the confined tool receives the bounded result with signed evidence. The running example has the triage agent open issue #419 on the pricing repository through the downloadable broker integration, with a repository token it never sees.

Broker routeChoose a stage.
authenticated hostbroker processproviderrequestprepared connectionexact requestresponse, ρauthority RPCresolveHTTPS, injected headerdirect routesecond dispatchaagentKkerneldconfined toolβbrokersencrypted storefproviderauthenticated hostbroker processproviderrequestpreparedexact requestresponse, ρauthority RPCresolveHTTPSdirect routere-dispatchaagentKkerneldtoolβbrokersstorefprovider

The provider receives the approved request

The tool sends the credential reference and the committed request. The broker resolves the stored version, opens HTTPS to the bound destination with redirects disabled, and returns a bounded response with signed execution evidence.

One native broker call drawn across three domains: the authenticated host, the broker process, and the provider. The capability issuer, credential provisioning, and the receipt the kernel signs are left out.

The agent holds permission for one request, and the broker holds the credential

A copied API key authorizes whatever the provider permits until it is withdrawn. A broker capability authorizes one subject to send one exact body to one endpoint, before an expiry, under a shared execution budget. The provider still applies its own permissions, so keep the stored credential narrowly scoped there too.

Vault secrets engines generate dynamic credentials on demand. The broker also performs the request: it owns both the injection and the execution, so the worker uses a provider integration without ever holding its secret.

That choice puts the broker on the trusted execution path: its availability, state restoration, destination parser, and provider adapter all carry security weight. Credential custody covers how an operator runs it.

Four objects decide each use of a credential

Each object makes one claim and has one owner.

  1. Credential reference. The operator provisions a provider, a credential ID, and a version in the broker's tenant. The reference selects which encrypted value to resolve; knowing it grants no use.
  2. Broker capability. The accepted issuer signs the subject, the parent grant, the audience, the destination, the request constraints, and the quota. It states the permitted request and its maximum uses.
  3. Caller proof. The process host signs with the retained process subject key, committing this caller to these headers, this body, these options, and a fresh nonce.
  4. Original admission. The serving kernel owns the operation together with its parent, family, and broker budget capture. It records current permission and the attempt already consumed.
crates/security/chio-secret-broker/src/protocol.rs46–50rust
pub struct CredentialRef {
    pub provider: String,
    pub credential_id: String,
    pub version: u64,
}

The broker decrypts the material inside its own process, places it in the provider-owned authentication header, and opens the provider connection itself. The agent and the confined tool carry the reference and the request; neither needs the tenant master key or a copy of the provider credential.

The capability binds the request the provider will receive

Two endpoints on one host can create issues in different repositories or perform unrelated administrative work, so a hostname is too coarse a boundary. Chio binds the normalized host, the explicit port, the method, and the exact path and query. The production parser requires HTTPS and refuses URL user information and fragments.

Broker destination checksrust
use chio_secret_broker::protocol::BrokerDestination;

fn check_destination() -> Result<(), Box<dyn std::error::Error>> {
    let destination = BrokerDestination::parse(
        "https://API.EXAMPLE.COM/v1/repos/larkfield/pricing?mode=triage",
        "post",
        false,
    )?;
    assert_eq!(destination.normalized_host, "api.example.com");
    assert_eq!(destination.explicit_port, 443);
    assert_eq!(destination.method, "POST");
    assert_eq!(destination.exact_path_and_query,
        "/v1/repos/larkfield/pricing?mode=triage");
    assert!(BrokerDestination::parse("https://user@example.com/", "GET", false).is_err());
    assert!(BrokerDestination::parse("https://example.com/#token", "GET", false).is_err());
    assert!(BrokerDestination::parse("http://example.com/", "GET", false).is_err());
    Ok(())
}

The body and the caller-controlled headers are bound the same way. The example reads a reviewed issue document, builds its destination through this parser, and commits to those exact bytes, with an 8 KiB response limit, a three-second provider timeout, and streaming disabled. The selected adapter owns Authorization and the caller may supply only Content-Type, so a tool can neither replace the provider header nor ask for the credential in its own response. Redirects are disabled, and a provider response cannot move this authorization to another destination.

A review of the request therefore describes the provider effect: the path names the repository, the method the operation, and the body the content, so the example prepares the body before it asks for authority. Parsing grants nothing: the capability, the constraints, current authority, and the original operation decide whether the request runs.

In Rust: the destination, constraints, and evidence the broker signs
crates/security/chio-secret-broker/src/protocol.rs84–90rust
pub struct BrokerDestination {
    pub scheme: BrokerScheme,
    pub normalized_host: String,
    pub explicit_port: u16,
    pub exact_path_and_query: String,
    pub method: String,
}
crates/security/chio-secret-broker/src/protocol.rs270–281rust
pub struct RequestConstraints {
    pub allowed_caller_headers: Vec<String>,
    pub provider_owned_headers: Vec<String>,
    pub maximum_body_bytes: u64,
    pub required_body_sha256: String,
    pub required_preview_sha256: Option<String>,
    pub redirect_policy: RedirectPolicy,
    /// Maximum exact HTTP response-head bytes plus decoded response-body bytes.
    pub maximum_response_bytes: u64,
    pub streaming_allowed: bool,
    pub maximum_timeout_ms: u64,
}
crates/security/chio-secret-broker/src/protocol.rs505–520rust
pub struct BrokerExecutionEvidence {
    pub schema: String,
    pub attempt_id: String,
    pub invocation_id: String,
    pub hold_id: String,
    pub request_digest: String,
    pub capability_digest: String,
    pub revocation_set_digest: String,
    pub budget_commit_index: u64,
    pub revocation_commit_index: u64,
    pub authority_commit_index: u64,
    pub leader_epoch: u64,
    pub upstream_status: u16,
    pub response_body_sha256: String,
    pub response_headers_sha256: String,
}

One capture covers the kernel, the host, and the broker

The capability in the example sets maximum_executions: 1 with AttemptConsumption::CaptureBeforeDispatch. That one use is consumed when the original operation crosses its durable capture boundary, and a failed provider response mints no second use. The capability lives five minutes, capped by the parent's expiry; the caller proof carries its own sixty-second nonce lifetime.

  1. Admission. A supplemental quota verifier checks the broker capability and the request commitments. It decrypts nothing and sends no HTTP.
  2. Capture. The host's broker admission participant registers the durable attempt and joins it to the kernel's original parent, aggregate-family, and broker quota capture.
  3. Prepared connection. The host passes the confined tool one authenticated broker connection, bound to the socket, the peer identity, the request commitment, and an absolute deadline.
  4. Execution. The broker confirms the original capture over authenticated authority RPC, resolves the credential, and executes the bound request.

Every retry has one accounting owner. The broker retains attempt and receipt history and the serving authority retains live grants and budgets, so reopening the broker's stores never restores withdrawn authority or refills a captured allowance.

Read more in Prepared connections, which covers the connection's single-use lifetime.

The triage agent opens issue #419 with a token it never reads

The broker integration pairs a trusted request issuer with an authenticated worker on the native process host, which owns registration, admission, confinement, and recovery. The issuer checks the parent's signature against an independently retained kernel key and its own key against the host's configured broker issuer, then signs one issue request under the same parent and subject, so approval stays apart from submission.

Set up the host, the broker, and the credential

The kit holds the issuer source, the worker client, and the setup and input contract. The issuer builds with Rust 1.94.1 or later against the published Chio crates, and the native broker route runs on its supported Linux confinement profile. Populate operator/deployment.json from the deployment plan and keep the generated host configuration private:

  • Broker configuration and credential version: the custody deployment and the signed provisioning receipt for that exact stored version.
  • Launch policy and public signer: the runtime provision and its retained result, refreshed for the broker process serving this epoch.
  • Authority seed and public pin: the separate admission-authority identity, whose key matches the broker's trusted authority, at broker-authority.sock.
  • Parent capability, kernel key, and request issuer seed: the retained bootstrap and initialization result, and the pinned issuer's seed, all outside the worker directory.

The supervisor owns the launch policy, the broker socket, and the daemon's PID, UID, and GID; the daemon configuration pins the issuer, authority, receipt signer, tenant, and adapter. The host assembler copies those bindings into one native broker route and refuses a mismatched audience, placement, or authority socket. The policy allows two invocations of native-broker/send, initialization sets the aggregate family allowance separately, and the issue capability narrows that to one provider attempt. A second issue needs its own request approval and remaining parent and family capacity.

Terminalbash
chio new security-broker --example security-broker
cd security-broker
cargo build --locked --bins
export PATH="$PWD/target/debug:$PATH"

python3 configure_host.py \
  --deployment operator/deployment.json \
  --output operator/host
chio process init \
  --config operator/host/host.json --state .chio \
  --aggregate-invocations 2 > operator/init.json
jq -r '.kernel_key' operator/init.json > operator/kernel.pub
jq -r '.action.parameters.runtime_id' .chio/process-bootstrap.json \
  > operator/runtime-id

The host's IPC timeout and flow policy lifetime default to three and ten seconds, up to thirty each; capture must finish while the policy is current, and a longer lifetime renews no expired caller proof. Size these, the daemon's own IPC settings, and the signed provider timeout separately from observed latency. Replace the example's one-input classifier rule with the deployment's own classification policy before admitting general workloads.

The operator computes an exact provisioning intent with the credential-provisioning program and collects the deployment's threshold approvals for it; its private plan selects the tenant, the credential version, the secret file, the live peer identity, the independent receipt key, and the response path. Review the issue body and HTTPS endpoint in the request plan. Then, with the process host stopped and its state held exclusively, issue the request capability and prepare the caller proof under one operation key. Preparation signs with the retained process key, derives the invocation identity from the process and the operation key, and writes a prepared file without sending anything.

Terminalbash
target/debug/provision-credential intent operator/provision.json \
  > operator/provision-intent.json
# Obtain the configured governance approvals for that exact intent.

target/debug/issue-request operator/request-plan.json
chio process prepare-broker-call \
  --state .chio --process root --operation-key create-issue \
  --capability approved-issue/broker-capability.json \
  --request approved-issue/provider-request.json \
  --out approved-issue/prepared.json
chio process credential \
  --state .chio --process root --socket "$PWD/process.sock" \
  --out worker.json
chio process serve --state .chio --socket "$PWD/process.sock"

Start the supervised broker against that authority endpoint, confirm readiness, and apply the approved provisioning. The program authenticates the broker socket and Linux peer before sending secret bytes, then verifies the mutation receipt against the intent, envelope, tenant, credential, and an independently pinned signer. The receipt shows the version is stored and grants the worker nothing.

Terminalbash
systemctl show chio-secret-broker.service \
  -p ActiveState -p SubState -p MainPID
journalctl -u chio-secret-broker.service -n 30 --no-pager
target/debug/provision-credential apply operator/provision.json

Readiness comes from the daemon's listening socket, and the tool's launch policy must name that daemon's peer, so a restarted broker needs the supervisor's fresh binding. The caller proof must reach admission within its sixty-second window: an unused expired proof is prepared again, and a submitted request has its original outcome resolved before anyone signs another attempt.

With both services ready, hand the worker its private descriptor and prepared file, then attest and verify the call from the operator side:

Terminalbash
python3 send.py \
  --connection worker.json \
  --prepared approved-issue/prepared.json \
  --operation-key create-issue \
  --output evidence/create-issue
jq '{request_id, verdict, terminal_state, output}' \
  evidence/create-issue/response.json

# Stop the serving host so the operator can acquire its state.
chio process attest-call --state .chio \
  --request evidence/create-issue/request.json \
  --context evidence/create-issue/context.json \
  --response evidence/create-issue/response.json \
  --out evidence/create-issue/observation.json
chio process verify-call \
  --artifact evidence/create-issue/observation.json \
  --trusted-kernel-pubkey operator/kernel.pub \
  --runtime-id "$(cat operator/runtime-id)" \
  --request evidence/create-issue/request.json \
  --context evidence/create-issue/context.json \
  --trusted-broker-host-config operator/host/host.json
python3 inspect_issue.py evidence/create-issue/response.json

You should see the client save the request, the invocation context, the original response, and the receipt before it reports completion. It reads neither the host databases nor the issuer seed, and the saved files omit the bearer credential in its connection descriptor. An interrupted socket leaves the original operation and the prepared file intact. Verification uses the kernel key and host configuration retained at setup, since a proof that supplies its own trusted key cannot show who authorized the call; governed keyring deployments add their key-log verifier.

Terminalbash
jq '{httpStatus, providerResult}' security-broker-observation.json
Output
{
  "httpStatus": 201,
  "providerResult": {
    "number": 419,
    "state": "open",
    "title": "Refund rounds a half cent down on order-77"
  }
}

The observation record comes from a controlled-provider run of the native kernel-to-broker path on ARM Linux with a test TLS root and no diagnostic observer. The provider returned HTTP 201 and recorded one connection, the exact approved body, and one broker-injected authorization header; replaying the call added no connection and no budget event. inspect_issue.py decodes the byte body as JSON, so non-ASCII titles survive, and reports http_status apart from provider_result, because a completed call can carry an application error and still consume its attempt.

A lost reply keeps its capture, and no second request is sent

A completed call leaves three records: the kernel receipt for admission and the guarded tool result, the broker's signed evidence binding destination, credential reference, request, and response, and the provider's issue number. In a controlled MCP run with a forwarding test observer that preserved each call and result, the reply was dropped after the broker completed the provider operation.

Completion presented to the hostProvider observationRetained host state
Original signed completionOne issue, one connectionCompleted; the original result replays
Reply lost after broker completionThe same issue, one connectionOutcomeUnknownAfterDispatch; capture stays consumed
Changed body, headers, or extra MCP contentOne issue; original response retainedSubstitution refused; replay authorizes no second effect

The provider kept issue #419, and the host kept the parent, family, and broker allowances each captured once. Preserve the request, operation key, and captures, let the operation's owner reconcile the broker and authority state, and check the provider's record through an independently authorized observation. A signed broker failure carries its diagnostic code and dispatch knowledge; a missing receipt never shows that the provider did nothing.

A fresh nonce, a new operation key, or another one-use capability describes new authority and reveals nothing about the first attempt. Completed evidence replays without a provider call, and a permanently ambiguous attempt stays non-dispatchable. The guarantee covers the route whose custody, admission, and dispatch the broker owns, so remove parallel copies of the provider key from the tool's environment and filesystem grants and let confinement close the direct network route. Host administration and the provider stay trusted for their own resources.

Read more in Recover an operation, which takes the owner from an unknown outcome to its next action.

Changing one committed field breaks the original proof

The comparison program signs one issue request, verifies it under an independently held issuer key, then changes one committed field at a time. Add broker_request_cases.rs to a Chio Rust host with chio-core-types and chio-secret-broker and call compare_broker_requests().

The complete signing and substitution comparison
security-host/broker_request_cases.rsrust
use chio_core_types::{Ed25519Backend, Keypair};
use chio_secret_broker::capability::{issue_capability, verify_capability};
use chio_secret_broker::proof::{body_digest, issue_request_proof, verify_request_proof};
use chio_secret_broker::protocol::{
    AttemptConsumption, BrokerCapabilityBody, BrokerDestination, BrokerRequest, CallerOptions,
    CredentialRef, HeaderField, ProofBinding, ProofMode, RedirectPolicy, RequestConstraints,
    BROKER_CAPABILITY_SCHEMA,
};

/// Portable signature/request comparison. No secret is provisioned and no
/// provider connection, current-authority lookup or durable attempt is made.
pub fn compare_broker_requests() -> Result<(), Box<dyn std::error::Error>> {
    let issuer = Keypair::generate();
    let caller = Keypair::generate();
    let issuer_pin = issuer.public_key(); // fixture-owned independent pin
    let request = BrokerRequest {
        destination: BrokerDestination::parse(
            "https://api.example.com/v1/repos/larkfield/pricing/issues",
            "POST",
            false,
        )?,
        headers: vec![HeaderField::normalized(
            "Content-Type",
            b"application/json",
        )?],
        body: br#"{"title":"Refund rounds a half cent down on order-77"}"#.to_vec(),
        approved_preview_sha256: None,
        options: CallerOptions {
            timeout_ms: 5_000,
            streaming: false,
            response_limit_bytes: 16_384,
        },
    };
    let capability = issue_capability(
        BrokerCapabilityBody {
            schema: BROKER_CAPABILITY_SCHEMA.into(),
            issuer: issuer_pin.clone(),
            capability_id: "issue-419".into(),
            parent_capability_id: "triage-parent".into(),
            subject: caller.public_key(),
            audience: "larkfield-broker".into(),
            issued_at_unix_seconds: 1_800_000_000,
            not_before_unix_seconds: 1_800_000_000,
            expires_at_unix_seconds: 1_800_000_120,
            credential: CredentialRef {
                provider: "code-host".into(),
                credential_id: "pricing-issues".into(),
                version: 1,
            },
            provider_adapter_id: "generic-https".into(),
            provider_adapter_version: 1,
            destination: request.destination.clone(),
            constraints: RequestConstraints {
                allowed_caller_headers: vec!["content-type".into()],
                provider_owned_headers: vec!["authorization".into()],
                maximum_body_bytes: request.body.len() as u64,
                required_body_sha256: body_digest(&request.body),
                required_preview_sha256: None,
                redirect_policy: RedirectPolicy::Disabled,
                maximum_response_bytes: 16_384,
                streaming_allowed: false,
                maximum_timeout_ms: 5_000,
            },
            broker_quota_key_id: "triage-quota".into(),
            maximum_executions: 1,
            consumption: AttemptConsumption::CaptureBeforeDispatch,
            revocation_id: "triage-withdrawal".into(),
            proof: ProofBinding {
                mode: ProofMode::PublicKey,
                caller_public_key: caller.public_key(),
                nonce_ttl_seconds: 60,
            },
        },
        &Ed25519Backend::new(issuer),
        true,
    )?;
    let proof = issue_request_proof(
        &capability,
        &request,
        "original-triage-nonce".into(),
        1_800_000_001,
        &caller,
    )?;
    verify_capability(
        &capability,
        &issuer_pin,
        "larkfield-broker",
        1_800_000_002,
        true,
    )?;
    verify_request_proof(&proof, &capability, &request, 1_800_000_002, 0)?;
    println!("VERIFY original issue request: body, endpoint, headers and options remain bound");

    let mut neighbors = Vec::new();
    let mut body = request.clone();
    body.body.push(b' ');
    neighbors.push(body);
    let mut path = request.clone();
    path.destination.exact_path_and_query = "/v1/repos/larkfield/storefront/issues".into();
    neighbors.push(path);
    let mut method = request.clone();
    method.destination.method = "DELETE".into();
    neighbors.push(method);
    let mut headers = request.clone();
    headers.headers[0].value = b"text/plain".to_vec();
    neighbors.push(headers);
    let mut options = request.clone();
    options.options.timeout_ms = 4_000;
    neighbors.push(options);
    for changed in neighbors {
        assert!(verify_request_proof(&proof, &capability, &changed, 1_800_000_002, 0).is_err());
    }
    println!("REFUSE original-proof reuse: changed body, repository, method, header or timeout");
    let mut credential = capability.clone();
    credential.body.credential.version = 2;
    assert!(verify_capability(
        &credential,
        &issuer_pin,
        "larkfield-broker",
        1_800_000_002,
        true
    )
    .is_err());
    assert!(verify_request_proof(&proof, &capability, &request, 1_800_000_062, 0).is_err());
    assert!(BrokerDestination::parse("https://user@example.com/", "GET", false).is_err());
    assert!(BrokerDestination::parse("https://example.com/#secret", "GET", false).is_err());
    assert!(BrokerDestination::parse("http://example.com/", "GET", false).is_err());
    println!("REFUSE substituted credential version, stale proof and unsafe production URL");
    Ok(())
}
Output
VERIFY original issue request: body, endpoint, headers and options remain bound
REFUSE original-proof reuse: changed body, repository, method, header or timeout
REFUSE substituted credential version, stale proof and unsafe production URL

The original proof verifies. A trailing space in the body, another repository path, DELETE for POST, another content type, or a shorter timeout each change the commitment, and the proof is refused. A substituted credential version invalidates the capability, and the proof expires after sixty seconds.

The program generates its own keys on a fixed clock, provisions no credential, and makes no network request. It shows exact-request authentication; current authority, replay consumption, quota capture, custody, and the provider's observation belong to the brokered host.

Next: follow the connection the tool receives