← Back to the tool

Public-key · Under the hood

Certificate revocation: valid signature, still not trusted

What each button in the tool actually computes, step by step — with the real formulas.

Two separate checks, easy to conflate

This tool builds the identical real ECDSA root → intermediate → leaf chain as the certificate chain builder, with the same Subject Key ID, Authority Key ID, and fingerprint fields — nothing about the key generation, signing, or hashing changes. What's added is a second, entirely non-cryptographic check that real verification always runs alongside the mathematical one: is every certificate in this chain still supposed to be trusted, independent of whether its signature checks out?

Same chain-building code as the certificate chain builder, SKI/AKI/fingerprint included
const root = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const inter = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
const leaf = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);

async function keyId(publicKey) {
  const raw = await crypto.subtle.exportKey("raw", publicKey);
  const digest = await crypto.subtle.digest("SHA-256", raw);
  return bufToHex(digest);
}
const rootSki = await keyId(root.publicKey);
const interSki = await keyId(inter.publicKey);

const interCert = {
  subject: "Intermediate CA",
  issuer: "Root CA",
  publicKeyB64: interPub,
  ski: interSki,
  aki: rootSki, // points at the issuing (root) key's SKI
  signatureB64: "",
};
interCert.signatureB64 = await crypto.subtle.sign(
  { name: "ECDSA", hash: "SHA-256" },
  root.privateKey,
  new TextEncoder().encode(JSON.stringify({
    subject: interCert.subject,
    issuer: interCert.issuer,
    publicKey: interCert.publicKeyB64,
    subjectKeyId: interCert.ski,
    authorityKeyId: interCert.aki,
  })),
);
// fingerprint: a hash of the complete, signed certificate — computed last,
// after the signature exists, since it covers the signature bytes too
const fingerprint = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(interCert)));

The generateKey/sign calls, and the keyId()/fingerprint hashing, are unchanged from the certificate chain builder — revocation adds a check next to this, not inside it.

What "Revoke" actually does here — and doesn't

Clicking "Revoke intermediate" doesn't touch a single key or signature. It adds the intermediate's identifier to a revocation list held only in this tool's local state — the same role a real CRL or OCSP responder plays. The intermediate's signature over the leaf is exactly as mathematically valid after revocation as before; revocation is a statement about trust, layered on top of, not baked into, the signature math.

What the Revoke button runs — no crypto.subtle call at all
const [revoked, setRevoked] = useState<Set<"root" | "intermediate" | "leaf">>(new Set());

function toggleRevoke(key: "root" | "intermediate" | "leaf") {
  setRevoked((prev) => {
    const next = new Set(prev);
    if (next.has(key)) next.delete(key);
    else next.add(key);
    return next;
  });
}

No key, signature, or WebCrypto call is touched — revocation here is a plain in-memory set, standing in for a CRL or an OCSP responder's own record.

Signature check

  • •Purely mathematical — did the claimed issuer's private key produce this exact signature?
  • •Unaffected by revocation — a revoked cert's old signatures remain mathematically valid forever

Revocation check

  • •An operational lookup — is this certificate (or one above it in the chain) on a revocation list?
  • •The only thing that changes when you click Revoke — no keys or signatures are touched

Why revoking the intermediate — not the leaf — still blocks everything under it

"Verify chain" runs the signature checks first (they still pass — nothing was tampered), then separately walks every certificate in the chain against the revocation list. Revoking the intermediate fails that second check for the leaf too, even though the leaf itself was never directly revoked: trust doesn't survive a revoked link anywhere above it in the chain. This is exactly the blast-radius containment the PKI-in-production use case describes — revoking one compromised intermediate immediately untrusts everything it ever issued, without needing to touch the root or re-issue anything at the root level. Notice the intermediate's SKI, AKI, and fingerprint on screen don't change when you revoke it either — those are properties of the certificate's bytes, and revocation never rewrites a single byte.

The two checks, run separately, then combined
const leafSigOk = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  intKeys.publicKey,
  base64ToBuf(leafCert.signatureB64),
  utf8ToBuf(canonical(leafCert)),
);

// Signature validity and revocation are entirely separate questions —
// a cert being revoked doesn't touch whether its signature is mathematically valid.
function statusFor(sigOk: boolean, key: "root" | "intermediate" | "leaf", ancestors: typeof key[]) {
  if (!sigOk) return "pending";
  const chainRevoked = [key, ...ancestors].some((k) => revoked.has(k));
  return chainRevoked ? "revoked" : "trusted";
}

// leaf's ancestors are everything above it — revoking any one of them fails the leaf too
statusFor(leafSigOk, "leaf", ["root", "intermediate"]);

verify() only ever answers "did this key sign this data" — walking the chain against the revocation list is separate application logic layered on top.

Now try it

Back to Certificate revocation: valid signature, still not trusted →

Build the same real three-link ECDSA chain as the certificate chain builder, then revoke the intermediate and watch a mathematically perfect signature still get rejected.