Witnessed rotation
Move an authority to a new signing key in two witnessed stages, and resume cleanly when a rotation is interrupted.
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.
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.
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 at | Retain | Continue by |
|---|---|---|
| Appended, not activated | Original event id, candidate key, durable log | Resuming that candidate |
begin returned an error | The same, since the append may have landed | Inspecting the tail, then resuming if pending |
| Witnesses or auditors incomplete | The checkpoint and each witness record | The majority and both auditors on that checkpoint |
| Recovered key differs | Original event, checkpoint, custody diagnostic | Restoring the original key |
| Topology or guard differs | Original policy binding, last retained stage | Restoring the configured topology and posture |
| Activated, serving behind | Activation commit, signing epoch, audit pin | Synchronizing the selector under its generation contract |
| Old artifact presented | Original signed bytes, key history | Verifying 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.
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.
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/keyringPOST /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.
| Refusal | Condition |
|---|---|
503 {"error": "witnessed capability authority rotation failed"} from Keyring "pending rotation blocks this transition" | Another rotation became pending first; posting again resumes and finishes it |
503 {"error": "witnessed capability authority rotation failed"} from Keyring | The activation lacks a strict majority of valid witness signatures |
"invalid signature: key-log state invariant failed: stale signing epoch" from Keyring, wrapped as chio_ | 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
Search for rotate, recover, epoch, or threshold.
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.