Chio/Docs
LOGIN · JOIN

Witnessed rotation

Move an authority to a new signing key in two witnessed stages, and resume cleanly when a rotation is interrupted.

Concept chio@5f63e6e

On this page

The key that signs for an authority can change while the door keeps serving, and every receipt signed before the change still verifies. The new key becomes usable only after independent witnesses and both auditors have checked the exact history that proposes it.

A rotation is an event in the durable key log that key transparency establishes, so a verifier who does not trust the operator can follow it. If the process stops partway, recovery resumes the same pending event; nothing generates a second key to get around the decision already in flight.

Key lifecycleChoose a stage.
key historykey logwitnessesreceiptscandidateσ₁σ₂4f1c…a2e9epoch 1epoch 29b07…c41dauthorizesproves possessionerotation eventccheckpointw₁w₂w₃witnesses · 2 of 3 signv₁v₂auditors · both confirmactivationevent + checkpoint + epoch 2verifiesverifiesρ₁signed in epoch 1ρ₂signed in epoch 2keyskey logsignersreceiptscandidateσ₁σ₂4f1c…a2e9epoch 1epoch 29b07…c41dauthorizeseeventccheckpointw₁w₂w₃witnesses2 of 3 signv₁v₂auditorsboth confirmactivationverifiesverifiesρ₁epoch 1ρ₂epoch 2

The active key authorizes its successor

The active key σ₁ signs the rotation event, and the new key σ₂ proves it holds its private half. Appending the event to the key log makes σ₂ a candidate; serving still signs with σ₁. A crash after the append resumes this same candidate, never a fresh key. Witnessed rotation.

One authority's key history on a time axis: a rotation from σ₁ to σ₂ through the key log, three witnesses, and two auditors. Times and fingerprints are illustrative; artifact-time roots, recovery roots, and equivocation evidence are left out.

The active key proposes its successor, and the successor proves possession

A newly published public key alone gives a verifier no history to check: consumers could be shown incompatible histories, and a serving process could start signing with a key nobody accepted. The rotation protocol needs an agreed history as well as a replacement key.

So the proposal is a rotate event that carries the previous key's id and a witness roster binding, signed twice over the same bytes: an OldKeyAuthorization by the active key and a NewKeyProofOfPossession by the candidate. verify_rotation accepts exactly that pair, and refuses a previous-key id that differs from the active key.

begin_rotation appends the event to the durable operator log, obtains its checkpoint, and stages the candidate's signing backend as pending in the router. The candidate is now history a verifier can read, and the router still signs with the old key.

Two of three witnesses and both auditors gate activation

The operator commits a checkpoint covering the event. Activation needs a strict majority of the keyring's witnesses: witness_threshold computes n/2 + 1, and the runtime requires exactly three witnesses and two auditors, so two witnesses must sign. Each compares the checkpoint with its retained history before signing the same view, and both auditors confirm the witnessed head.

collect_witnesses_and_activate gathers those signatures. The activation commit then binds the event, its inclusion position, the checkpoint, the required witness set, and the next signing epoch. Serving switches through a generation-fenced backend, the capability authority's only signer, so no capability token can bypass the activated selector.

The new selector fences stale live signing epochs: a signature requested under the old epoch is refused. Receipts signed before the change keep their original bytes and signatures. A candidate can wait while a witness or auditor is unavailable: losing two witnesses prevents activation, and losing either auditor blocks activation and the production readiness report. Startup needs signed readiness from all five services, so one missing witness also blocks a trust-control restart. Treat the wait as a pending rotation, distinct from a failure of the active key, and preserve the original proposal.

Read more in Key log witnesses, which runs the services this stage needs.

An interrupted rotation resumes the candidate it already proposed

A restart at a pending tail boots from the latest accepted checkpoint and retains the pending operator head for synchronization. The router accepts a resumed signing backend only when its key id, public key, and algorithm match the durable pending key, so a process that lost its memory cannot substitute a fresh key. Each cutpoint has one continuation:

Interrupted atRetainContinue by
Appended, not activatedOriginal event id, candidate key, durable logResuming that candidate
begin returned an errorThe same, since the append may have landedInspecting the tail, then resuming if pending
Witnesses or auditors incompleteThe checkpoint and each witness recordThe majority and both auditors on that checkpoint
Recovered key differsOriginal event, checkpoint, custody diagnosticRestoring the original key
Topology or guard differsOriginal policy binding, last retained stageRestoring the configured topology and posture
Activated, serving behindActivation commit, signing epoch, audit pinSynchronizing the selector under its generation contract
Old artifact presentedOriginal signed bytes, key historyVerifying authority at artifact time

An activated tail takes the activated-tail recovery path; resuming a pending rotation there is the wrong transition. Keep each witness and audit service's state through a restore, and rehearse pending-tail recovery before relying on rotation in operation.

Begin and resume a rotation from a Rust host

The integration begins a signed rotation and, in a replacement process, resumes the same durable pending event before collecting witnesses and activating. It calls the runtime and never selects the serving backend itself. Provide a configured WitnessedRotationRuntime and independent services under the same durable policy binding; enterprise routers also need the enterprise runtime, the original stores, the guarded migration posture, and the retained receipt sink. The first call takes the dual-signed event and the new key's backend; after a restart, reopen the same validated log and router and recover the same candidate backend from key custody. Download resume_rotation.rs.

security-host/resume_rotation.rsrust
use chio_core_types::SigningBackend;
use chio_keyring::{
    EventId, IndependentKeyLogServices, KeyringError, SignedKeyLogEvent, WitnessedRotationOutcome,
    WitnessedRotationRuntime,
};

pub fn begin_reviewed_rotation(
    runtime: &WitnessedRotationRuntime,
    original_signed_event: &SignedKeyLogEvent,
    new_key_backend: Box<dyn SigningBackend>,
    services: &IndependentKeyLogServices,
) -> chio_keyring::Result<WitnessedRotationOutcome> {
    let mut pending = runtime.begin_rotation(original_signed_event, new_key_backend)?;
    runtime.collect_witnesses_and_activate(&mut pending, services)
}

/// Reopen the SAME validated durable log/router and recover the SAME pending
/// key from custody before calling. A begin error may already have appended.
pub fn resume_original_rotation(
    runtime: &WitnessedRotationRuntime,
    original_event_id: &EventId,
    same_pending_key_backend: Box<dyn SigningBackend>,
    services: &IndependentKeyLogServices,
) -> chio_keyring::Result<WitnessedRotationOutcome> {
    let mut pending = runtime.resume_pending_rotation(same_pending_key_backend)?;
    if &pending.event_id != original_event_id {
        return Err(KeyringError::IdentityMismatch);
    }
    // The real runtime persists witness progress, waits for auditors and runs
    // the final activation guard. Neither a staged key nor a quorum alone
    // grants the application permission to select this backend directly.
    runtime.collect_witnesses_and_activate(&mut pending, services)
}

A successful WitnessedRotationOutcome reports the checkpoint hash, signing epoch, and audit pin. The witness majority is one dependency of that result; both auditors and the final configured guard also take part. The file is compiled integration code and activates nothing until a running service calls it.

Trust control runs the whole rotation behind one route

Serve trust control with keyring custody. chio trust serve always takes the service token, here as CHIO_TRUST_SERVICE_TOKEN. --authority-keyring-config adds the global --authority-seed-file and --receipt-db, its own --authority-keyring-receipt-anchor-root on a filesystem device separate from the receipt database, and a distinct --authority-workload-token, supplied here as CHIO_TRUST_AUTHORITY_WORKLOAD_TOKEN. Every configured witness and audit service proves readiness before the listener opens.

Terminalbash
export CHIO_TRUST_SERVICE_TOKEN="$(cat /run/secrets/chio_service)"
export CHIO_TRUST_AUTHORITY_WORKLOAD_TOKEN="$(cat /run/secrets/chio_authority_workload)"
chio trust serve \
  --authority-seed-file /run/credentials/chio/authority.seed \
  --receipt-db /var/lib/chio/receipts.sqlite3 \
  --authority-keyring-config /etc/chio/authority-keyring.yaml \
  --authority-keyring-receipt-anchor-root /mnt/chio-anchors/keyring

POST /v1/authority then drives the rotation end to end. It stages the new key behind the pending selector: for the enterprise_keyring profile it writes a crash-safe pending seed handoff, obtains the witness quorum and both auditor acknowledgements, activates the selector, and only then replaces the active seed file, the sequence the operations runbook records. After a timeout or partial response, post again; rotating the seed file by hand bypasses the witnessed history.

Every refused rotation returns the same 503 on the route

Whichever keyring check refuses, the route answers 503 with one body. A Rust host that drives chio-keyring directly receives the typed KeyringError variant instead.

RefusalCondition
503 {"error": "witnessed capability authority rotation failed"} from KeyringError::StateInvariant "pending rotation blocks this transition"
Another rotation became pending first; posting again resumes and finishes it
503 {"error": "witnessed capability authority rotation failed"} from KeyringError::InvalidWitnessActivation
The activation lacks a strict majority of valid witness signatures
"invalid signature: key-log state invariant failed: stale signing epoch" from KeyringError::StateInvariant, wrapped as chio_core_types::Error::InvalidSignature
A signer used a key after its epoch was fenced

A recover event follows the same witnessed contract under a different authorization set, described with the other operations below. Cluster authority shows how a cluster consumes the active key once activation lands.

Key-log operations and activation guards

crates/security/chio-keyring/src/event.rs216–239rust
pub enum KeyLogOperation {
    Genesis,
    Rotate {
        previous_key_id: KeyId,
        witness_roster_id: WitnessRosterId,
        witness_roster_binding: Hash,
    },
    AbortRotation {
        previous_key_id: KeyId,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        recovery_policy_id: Option<RecoveryPolicyId>,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        recovery_policy_binding: Option<Hash>,
    },
    Retire,
    Revoke,
    Recover {
        previous_key_id: KeyId,
        witness_roster_id: WitnessRosterId,
        witness_roster_binding: Hash,
        recovery_policy_id: RecoveryPolicyId,
        recovery_policy_binding: Hash,
    },
}

genesis

Starts the log with the first authorized key, under the bootstrap root.

rotate

Proposes a successor key. The old key authorizes it, the new key proves possession, and the event binds a witness roster through witness_roster_id and witness_roster_binding.

abort_rotation

Withdraws a pending proposal before activation, optionally bound to a recovery policy.

retire

Ends a non-active key's signing authority from a point in the log.

revoke

Marks a non-active key revoked, the operator's declared response to compromise.

recover

Restores authority through the recovery path, bound to a witness roster and a recovery policy. Replay accepts it only with signatures from the configured recovery threshold, the loaded witness roster and recovery-policy bindings, and the active key named as its previous key; it stays pending until a witnessed activation commit covers it. It changes authority-key state and leaves the operator, witness, auditor, and artifact-time roots in the loaded policy as they are.

witness_threshold

Computes the strict majority n/2 + 1 of the configured witnesses. A control-plane keyring configuration that names other than exactly three witness keys and endpoints and exactly two audit keys and endpoints is rejected before it can serve.

activate_rotation_with_fence

A private function that swaps the KeyringSigningRouter's active key and advances its signing epoch only after its activation guard succeeds. In the trust-control profile that guard is KeyLogMigrationBinding::require_activation, which requires an enforced enterprise migration and otherwise refuses selector activation.

sign_bytes_bound

Refuses a signature when the caller's expected epoch differs from the router's current epoch. The store refuses a stale-epoch signature independently when it persists an artifact signature against a time anchor. Activation and stale-epoch refusal are separate guards.

Next: run the witnesses a rotation needs