Chio/Docs
LOGIN · JOIN

Remote signing

Sign with a key a separate service holds, refuse any answer that fails to match the request, and issue capabilities from verified authenticator assertions.

Concept chio@5f63e6e

On this page

An application can sign with a key it never holds. A separate HTTP or Vault Transit service keeps the private key, and the host checks every returned signature against a key the operator pinned, so moving custody keeps proof that the service signed the intended bytes under the intended identity.

The service connection and the signing identity answer different questions. An authenticated HTTPS endpoint establishes which service the host reached; a signature that verifies under an independently pinned public key establishes which key signed the message. The host stays responsible for the exact statement it asks the service to sign.

Remote signer pinChoose an outcome.
hostsigner servicepinsrequestresponseverifiedkhσspinned keyhandle · version · keyhostreviewed bytesremote signerHTTP or Vault TransitsignatureSigningOutcome✓binding✓signatureverified locally under the pinhostsigner servicekhσs✓binding✓signatureverified locally under the pin

kpinned keyhhostσremote signerssignature

The host checks the signer's answer under a key it chose

The request binds the key handle, version, algorithm, and message digest under chio.signing.http-request.v1. The response echoes every binding under chio.signing.http-response.v1, and its signature verifies under the pinned Ed25519 key. Custody model.

One remote signing call: the host's independent pin, the request and response bindings, and the two checks the host runs before it uses a signature. The Vault Transit wire format, service authentication, and timeouts are left out.

The operator pins the signer's identity before any signature

The operator provisions the signer's key handle, a nonzero version, and its exact Ed25519 public key, independently of any signing response. RemoteSigningKey holds that pin:

crates/trust/chio-signing-remote/src/lib.rs53–57rust
pub struct RemoteSigningKey {
    key_handle: String,
    key_version: u32,
    public_key: PublicKey,
}

The constructor refuses a zero version, an invalid handle, and any public key that is not a non-weak Ed25519 key, so a remote signer is always configured for one algorithm. A response can carry no new key, algorithm, or version into that pin, whatever bearer credential the service holds.

Every response must answer the exact request

Before sending reviewed bytes, HttpSigningBackend binds the key handle, version, algorithm, and message digest into a request identity under chio.signing.http-request.v1. The response must carry chio.signing.http-response.v1 and echo the request id and every binding, so a responder speaking another protocol is refused.

The backend then verifies the signature locally against the pinned public key. A valid signature for another request or another key version is still a refusal: the test generic_http_signer_rejects_response_substitution forges a response with a different key version behind the correct request id, and sign_bytes refuses it.

Two transports carry remote signing: HTTP and Vault Transit

HttpSigningBackend posts to a generic Chio signing endpoint, and VaultTransitSigningBackend posts to a HashiCorp Vault Transit mount. Each holds its bearer credential or Vault token as a value the caller supplies at construction, apart from the pinned key.

Chio has no HSM or PKCS#11 signing backend. A deployment runs its own signer process behind one of these two transports and pins it by handle, version, and public key. Local signing, including the kernel's selectable classical or hybrid backend on Signing authority, is a separate configuration.

The hosted Finding profile chooses the transport at deployment time. Its load_signer looks up the FindingHostedSignerProfile configured for a requested FindingHostedSigningRole, such as Kernel or Worker, builds the transport that profile names, and calls verify_key before returning it, so a signer that no longer exposes the pinned key or version is caught at load time.

The prepared function signs reviewed bytes under an independent pin

Add this file to an existing host with chio-signing-remote and chio-core-types. It takes a provisioned HTTPS signer origin, a positive key version, a non-weak Ed25519 public key, and the private service token from the operator, then preflights that exact key and signs for that identity:

security-host/remote_receipt_signer.rsrust
use chio_core_types::{PublicKey, SigningBackend, SigningOutcome};
use chio_signing_remote::{HttpSigningBackend, RemoteSigningKey};
use std::time::Duration;

/// The owner supplies an independently provisioned key pin and a private
/// service credential. Do not derive the pin from the response being checked.
pub fn sign_reviewed_bytes(
    service_origin: &str,
    key_handle: &str,
    key_version: u32,
    independent_key_pin: PublicKey,
    private_service_token: String,
    exact_reviewed_bytes: &[u8],
) -> chio_core_types::Result<SigningOutcome> {
    let key = RemoteSigningKey::new(key_handle, key_version, independent_key_pin.clone())?;
    let backend = HttpSigningBackend::new(service_origin, key, private_service_token)?
        .with_timeout(Duration::from_secs(5));
    backend.verify_key()?;
    // This atomic identity operation validates the algorithm, signer and exact
    // returned signature. The HTTP backend also checks its response binding.
    backend.sign_bytes_for_identity(&independent_key_pin, exact_reviewed_bytes)
}

Download remote_receipt_signer.rs. The function consumes the token as a runtime value and prints neither it nor the message. A successful call returns a SigningOutcome with the pinned identity, the algorithm, and the locally verified signature; the application's artifact encoder owns its own domain separation and canonical bytes.

Each changed binding fails at its own check, as the figure's outcomes show. A preflight that reports another key or version is refused as a substituted identity. A response naming another digest or version fails the binding check before its signature is used; keep the request identity and the service diagnostic private. A signature over different bytes fails local verification; keep the reviewed preimage exactly as it was. A network or authentication failure returns its own error; the signer may still have performed the operation, so it stays unresolved under its owner's contract and the host selects no other signing authority.

Authenticator issuance mints only behind revocation and replay gates

A passkey issuer signs each PasskeyCapability with a server-side backend, and wires revocation and replay checking through with_durable_stores, or with_revocation_oracle and with_nonce_store individually. Finish construction with enforce_revocation_replay, which returns CustodyError unless both gates are wired; a bare IssuerService::with_signer skips them and can mint for a revoked credential or a replayed (credential_id, challenge_nonce) pair. Passkey capabilities gives the assertion and scope contract.

For mobile attestation, MobileChallengeAuthority issues a random, audience-bound challenge and returns verified evidence only after its store atomically consumes that challenge. The App Attest counter advances in the same critical section, so a verified assertion and its one-time challenge succeed or fail together:

crates/trust/chio-custody-hw/src/mobile_challenge.rs254–257rust
pub struct MobileChallengeAuthority {
    store: Arc<dyn MobileChallengeStore>,
    lifetime_seconds: u64,
}

Use SqliteMobileChallengeStore for production; the in-memory InMemoryMobileChallengeStore serves tests and single-process development. Give it an absolute, normalized path in a private, caller-owned Unix directory, and keep consumed challenge identities across restart. The store checks the file's owner, mode, link count, device, and inode before and after every operation. A server signature leaves passkey assertion verification, durable challenge consumption, and mobile attestation each in place as its own check.

Remote signing refusals reach the caller as typed Rust errors

Each row is what a direct Rust caller receives at the call that raises it. None passes through the kernel's tool-call evaluation, so none carries a Deny verdict.

RefusalCondition
Error::InvalidSignature "remote signer response binding is invalid"
The response lacks chio.signing.http-response.v1, or does not echo the request id, key handle, key version, algorithm, and message sha256 that sign_bytes sent
MobileChallengeError::Replayed "mobile attestation challenge `<id>` was already consumed"
The mobile attestation challenge was already consumed
MobileChallengeError::Attestation "app attest: counter rollback"
The new assertion's App Attest counter did not advance past the stored counter

Repair a signer's pinned configuration independently of the response that failed. Keep a consumed challenge consumed, and investigate a counter rollback with the trusted counters left as they are.

Source reference: call sites, store races, and loader error shapes

HttpSigningBackend is declared at lib.rs:102 and VaultTransitSigningBackend at :263; the substitution test is at lib.rs:572. HttpSigningBackend::sign_bytes returns its typed Result straight to its caller, and MobileChallengeAuthority::verify_app_attest_and_consume does the same for both mobile rows, as verify_play_integrity_and_consume does for a replay. The counter-rollback row carries AttestationError::CounterRollback (attestation/errors.rs:41) as its source.

MobileChallengeAuthority is at mobile_challenge.rs:254, and MobileChallengeAuthority::new takes its store as an argument. SqliteMobileChallengeStore is at mobile_challenge/sqlite.rs:9. sqlite_parallel_consumers_admit_exactly_one (mobile_challenge/tests.rs:238) opens one SQLite file from two independent store handles, races both against the same issued challenge, and confirms that exactly one consume_verified call succeeds.

The hosted Finding profile's load_signer (chio-control-plane/src/trust_control/finding_hosted_profile.rs:518-576) collapses every construction and preflight error it touches, including a failed verify_key call's Error::InvalidSignature, into a plain String such as "remote HTTP signer preflight failed". Its caller sees only the fixed message for the branch that failed.

Next: publish key events that witnesses check