Skip to content

Bureau — Blue Team (defensive)

Rotate

A break-glass procedure for when an operator's signing key is leaked, exposed, or otherwise burned.

Posture: 🔵 Blue Team (defensive)   ·   Status: alpha

What it does

When you sign things to a public log like Sigstore Rekor, you cannot un-sign them. The log is a Merkle tree by design – entries cannot be deleted. So what happens when someone steals your signing key and starts publishing fakes alongside your real entries?

Rotate is the answer. It's a two-step compromise-response procedure. First (fast), any trusted Bureau quorum-node freezes the key by publishing a KeyFreeze/v1. Second (slower), you publish a KeyRevocation/v1 from the OLD key – proving you owned it – and optionally announcing your replacement key. Then you walk every prior signed entry and publish ReWitnessReport/v1 annotations classifying each one: was this signed before the compromise (still trusted), during the window (do not trust), or after the replacement (trusted again under the new key)?

This is trust invalidation, not crypto-shred. The bad signatures still exist on Rekor. They always will. But the public record now contains a signed declaration that says "do not trust anything from this fingerprint between time X and time Y," and every Bureau verifier consults that ledger before honoring a historical signature.


Who would use it

  • An operator who pushed their signing key to a public GitHub repo by mistake at 2am.
  • A startup CTO whose laptop was stolen and the operator key on it didn't have at-rest encryption.
  • A vendor's compliance team rotating keys on a scheduled cadence – even with no compromise, you can publish a clean revocation tied to a replacement fingerprint to keep the trust chain crisp.
  • A Bureau quorum-node operator paged at 3am with credible evidence another operator's key is in the wild – they freeze fast while the affected operator is still being woken up.
  • A regulator auditing a vendor's signing history needing to know exactly which entries are trustworthy after a known compromise.

What you'll need

  • The Pluck CLI.
  • For freeze: a quorum-node operator key (any identity with the quorum-node role can freeze).
  • For revoke: the OLD operator key – its signature on the revocation proves you owned the key being revoked.
  • For re-witness: the NEW operator key. Re-witness reports MUST be signed by the post-rotation key.
  • For disclosure-rebuild: the new operator key plus the Rekor uuid of the revocation.
  • Internet access to Sigstore Rekor with --accept-public (entries are PUBLIC and PERMANENT).

Step-by-step

1. (Quorum node, fast) Freeze the key

The instant you have credible evidence of compromise, freeze:

Shell
pluck bureau rotate freeze \
  --previous-fingerprint a1b2c3...b4d5e6 \
  --reason "key exposed in public commit" \
  --keys ./quorum-keys --accept-public

The freeze is signed by the QUORUM key, not the compromised one. It establishes "from this moment, do not trust signatures from this fingerprint" while the affected operator catches up.

2. (Operator) Revoke from the old key

Once the operator confirms compromise, they revoke:

Shell
pluck bureau rotate revoke \
  --previous-fingerprint a1b2c3...b4d5e6 \
  --replacement-fingerprint f7g8h9...j1k2l3 \
  --reason "compromised" \
  --keys ./old-keys --accept-public

Signing the revocation with the OLD key is the proof the operator owned it. The replacement fingerprint is optional but recommended – it tells verifiers which key resumes the operator's trust chain.

3. (Operator) Re-witness prior entries

Walk every Rekor uuid the compromised key signed and classify each one:

Shell
pluck bureau rotate re-witness \
  --revocation 9f3a8b1c4d5e6f7a... \
  --target-uuids ./compromised-uuids.tsv \
  --keys ./new-keys --accept-public

The targets file is one uuid<TAB>votedAt line per target (with optional <TAB>trust-but-flag). Each gets classified: before-revocation / during-window / after-replacement / trust-but-flag.

4. Verify the rotation

Anyone (including you, sanity-checking) can pull and verify the published revocation:

Shell
pluck bureau rotate verify-rotation 9f3a8b1c4d5e6f7a...

Output:

rotate/verify-rotation: OK (revoked=a1b2c3...b4d5e6 since=2026-04-15T08:30:00Z until=unbounded signer=a1b2c3...)

5. (Optional) Rebuild prior disclosures

If the compromised key issued any Disclosure/v1 entries, anchor a new disclosure that cites the previous one and the revocation:

Shell
pluck bureau rotate disclosure-rebuild \
  --previous-fingerprint a1b2c3...b4d5e6 \
  --previous-disclosure-uuid <uuid> \
  --new-disclosure-uuid <uuid> \
  --revocation 9f3a8b1c4d5e6f7a... \
  --new-keys ./new-keys --accept-public

Run it yourself

Drop this into a Node 18+ project (npm install @sizls/pluck-bureau-rotate @sizls/pluck-bureau-core tsx):

TypeScript
// index.ts
import { createRotateSystem } from "@sizls/pluck-bureau-rotate";
import { generateOperatorKey } from "@sizls/pluck-bureau-core";

async function main() {
  const operator = generateOperatorKey();
  const system = createRotateSystem({
    signingKey: operator.privateKeyPem,
    disablePausePoll: true,
    disableLogging: true,
  });

  try {
    // Scenario: the operator's key was exposed at 08:00 UTC.
    // A quorum partner froze it at 08:30. The revocation landed at 09:14.
    // Three signatures landed during this window. How should each be classified?
    const window = {
      since: "2026-04-15T09:14:00Z",   // revocation timestamp
      until: "unbounded" as const,
    };
    const frozenAt = "2026-04-15T08:30:00Z";

    const samples = [
      { name: "before compromise", votedAt: "2026-04-15T08:00:00Z" },
      { name: "during freeze gap", votedAt: "2026-04-15T08:45:00Z" },
      { name: "post revocation",   votedAt: "2026-04-15T10:00:00Z" },
    ];

    for (const sample of samples) {
      const verdict = system.classify({ votedAt: sample.votedAt, window, frozenAt });
      console.log(`${sample.name.padEnd(20)} -> ${verdict}`);
    }
  } finally {
    await system.shutdown();
  }
}

main().catch((err) => { console.error(err); process.exit(1); });

Run with tsx index.ts. Expected output:

before compromise    -> before-freeze
during freeze gap    -> during-freeze
post revocation      -> unbounded

(In production, system.freeze(...) and system.revoke(...) publish signed KeyFreeze/v1 and KeyRevocation/v1 records to Sigstore Rekor; verifiers run system.classify(...) against every prior signature from the burned fingerprint before honoring it.)

▶ Open in StackBlitz – runs in your browser, no install required.


What you get

  • A signed revocation entry anyone in the world can pull and verify – the canonical "this key is burned" record.
  • A re-witness report classifying every prior entry into trustworthy/untrustworthy buckets.
  • A two-phase race-window defense – between detection and revocation, the freeze closes the gap so an attacker holding the same key can't publish damage during the multi-second revocation lag.
  • A disclosure-rebuild chain so any prior trust statement issued by the compromised key gets re-anchored to a new key without losing the evidentiary trail.

What it can't do

  • Rotate cannot remove signed Rekor entries from the public log. That is mathematically impossible against a public Merkle tree. Every program in the Bureau leans on this property; pretending otherwise would damage the entire integrity claim.
  • Verifiers MUST consult the compromise ledger before trusting any historical signature from a revoked fingerprint. Rotate publishes the ledger; it does not retroactively fix consumers who never check it.
  • Re-witness reports cap at 10,000 annotations per report. Compromise events larger than that need to be split across multiple reports – usually fine, but worth knowing.
  • The freeze must come from a quorum-node identity. If your project has zero quorum-node operators registered, the two-phase race window is open by default. Set up at least one quorum partner before you need them.
  • This is alpha. The compromise-ledger UI at studio.pluck.run/bureau/rotate is the consumer-facing source of truth; CLI tooling is the operator-facing source.

A real-world example

In May 2026, a Bureau operator at a regulator pushes their keys/ directory to a public GitHub repo by accident. Within 90 seconds, they realize what they did. They Slack their quorum partner.

Their quorum partner runs pluck bureau rotate freeze from a separate identity. The freeze lands on Rekor in under three seconds. Any verifier consulting the public ledger from that moment forward will refuse to trust new signatures from the burned fingerprint.

Twenty minutes later, the operator revokes from the old key (proving ownership), names a replacement key, and walks the 412 entries the burned key signed. 408 are classified before-revocation and remain trusted. 4 are classified during-freeze – the attacker did manage to publish a few signatures during the freeze window, and those are now flagged untrusted. The operator publishes the re-witness report and continues operations under the new key.

A journalist looking at that operator's history three months later sees the full chain: original key, freeze, revocation, replacement, re-witness reports. Every transition is signed and timestamped against Sigstore Rekor. The compromise is real, public, and survivable.


For developers

Trust invalidation, NOT crypto-shred

A revocation does NOT remove signed Rekor entries from the public log – that's impossible against a public Merkle tree by design. Rotate publishes NEW signed observations that live alongside the originals. Verifiers MUST consult the compromise ledger before trusting any historical signature from a revoked fingerprint.

This is a feature, not a deficiency. Pretending otherwise would damage the integrity claim every other Bureau program leans on.

Predicate URIs

Rotate emits four distinct in-toto Statement v1 predicate-type URIs. Verifiers MUST discriminate by predicateType, not by an inner-body discriminator – two semantically different shapes under one URI is a verifier-rejection hazard.

Predicate type URIVerbVerify helper
https://pluck.run/Rotate.KeyRevocation/v1revokeverifyRotation
https://pluck.run/Rotate.KeyFreeze/v1freezeverifyFreeze
https://pluck.run/Rotate.ReWitnessReport/v1re-witnessverifyReWitness
https://pluck.run/Rotate.DisclosureRebuild/v1disclosure-rebuildverifyDisclosureRebuild

Each verify helper returns ok: boolean plus a stable reason code on failure. verifyReWitness additionally surfaces daysSinceRevocation so a UI can render "this re-witness was issued N days post-revocation."

Programs composed

attest, notarize, disclose, dsseSign. The two-phase freeze→revoke flow uses the underlying receipt and Rekor primitives from Concepts: Act.

Two-phase freeze → revoke

Between operator-detected compromise and the multi-second revocation publish, an attacker holding the same key can publish anything. Rotate's two-phase design closes the race:

  1. Freeze (fast). Any quorum-node identity may publish a KeyFreeze/v1 immediately. The freeze is signed by the quorum node, NOT by the compromised key.
  2. Revoke (slow). The operator publishes KeyRevocation/v1 from the OLD key, proving they owned it.
  3. Re-witness. The NEW key walks every prior Rekor uuid signed by the compromised key and emits ReWitnessReport/v1 annotations.

Verifiers classify entries via classifyCompromiseWithFreeze:

  • before-freeze – pre-detection, trusted.
  • during-freeze – between freeze and revoke since, untrusted.
  • during-window – between revoke since and until, untrusted.
  • after-replacement – post-until, signed by replacement key, trusted.
  • trust-but-flag – boundary case; UI should surface for human review.

Threat model and limits

  • Ed25519 only, signed over RAW PAE bytes – cosign and sigstore-go interop.
  • schemaVersion: 1 literal – no string drift, no parser ambiguity.
  • previousFingerprint cross-checked against the signing key BEFORE publishing – typos are caught at the boundary.
  • reason capped at 1024 chars; note capped at 1024 chars.
  • Re-witness pass capped at MAX_ANNOTATIONS (10,000) per report.
  • Window since / until strict ISO 8601 UTC; until must be strictly after since (or "unbounded").
  • Re-witness reports MUST be signed by the NEW key – the bureau verifier refuses a report signed by the revoked fingerprint.
  • verifyRotation is fail-closed – any unrecognised shape returns { ok: false, reason: <stable code> }.

Studio routes

  • studio.pluck.run/bureau/rotate – compromise ledger across every revoked operator.
  • studio.pluck.run/bureau/rotate/<fingerprint> – full timeline (freeze, revoke, re-witness reports) for one identity.

Library surface

TypeScript
import {
  revokeKey,
  reWitness,
  verifyRotation,
  classifyCompromiseWithFreeze,
} from "@sizls/pluck-bureau-rotate";

const revocation = await revokeKey({
  previousFingerprint,
  replacementFingerprint,
  reason: "compromised",
  signingKey: oldKey,
  rekorUrl: "https://rekor.sigstore.dev",
  acceptPublic: true,
});

const classification = classifyCompromiseWithFreeze({
  votedAt: "2026-04-16T12:00:00Z",
  freeze: { since: "2026-04-15T08:30:00Z" },
  revocation: { since: "2026-04-15T09:14:00Z", until: "unbounded" },
});
// "during-freeze" | "during-window" | ...

See also

Edit this page on GitHub
Previous
SBOM-AI

Ready to build?

Install Pluck and follow the Quick Start guide to wire MCP-first data pipelines into your agents and fleets in minutes.

Get started →