← Back to the tool

Public-key · Under the hood

Certificate chain builder

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

What this tool simplifies, and what's real

A real X.509 certificate is a binary, ASN.1 DER-encoded structure with dozens of possible fields and extensions — producing one requires a dedicated encoding library, not just the Web Crypto API. This tool signs a simplified JSON structure instead ({subject, issuer, publicKey, subjectKeyId, authorityKeyId}), so it can demonstrate the actual chain-of-trust mechanism — including the same key-identifier fields real certificates use to link parent and child — using nothing but real, in-browser ECDSA. Every cryptographic operation here — key generation, hashing, signing, verification — is genuine; only the wire format is simplified.

Subject Key ID and Authority Key ID: how certs actually reference each other

Each certificate carries a Subject Key Identifier (SKI) — a hash of its own public key — and, except for the self-signed root, an Authority Key Identifier (AKI): a hash of the key that issued it. A child's AKI is computed to equal its issuer's SKI, which is how a real implementation holding a pile of unordered certificates quickly finds "which cert issued this one" before doing any signature math at all. RFC 5280 allows a few ways to compute these; this tool uses SHA-256 of the raw public key point, where real certificates more commonly still use legacy SHA-1 for this specific field.

The root's AKI is set to its own SKI — self-referencing, exactly like a real self-signed root certificate, since a root has no separate issuer key to point to.

The actual WebCrypto calls
async function keyId(publicKey) {
  const raw = await crypto.subtle.exportKey("raw", publicKey);     // the uncompressed EC point
  const digest = await crypto.subtle.digest("SHA-256", raw);
  return bufToHex(digest);
}

const rootSki = await keyId(root.publicKey);
// intermediate cert:
const interC = { subject: "Intermediate CA", issuer: "Root CA", ski: interSki, aki: rootSki, ... };

exportKey("raw", ...) on an EC public key returns the SEC1 uncompressed point (0x04 followed by the X and Y coordinates) — 65 bytes for P-256. Hashing that, not the SPKI-wrapped form used elsewhere in this tool, mirrors how RFC 5280's method (1) computes a key identifier.

SKI=SHA-256(raw public key bytes)\mathrm{SKI} = \mathrm{SHA\text{-}256}(\text{raw public key bytes})
AKIchild=SKIissuer\mathrm{AKI_{child}} = \mathrm{SKI_{issuer}}

Issuing a certificate: sign the child's identity with the parent's key

"Generate" builds the chain bottom-up in terms of trust, top-down in terms of signing: the root signs its own subject, public key, and key IDs (a self-signed root, exactly like a real root CA), then signs the intermediate's; the intermediate, in turn, signs the leaf's.

The actual WebCrypto calls
function canonical(c) {
  return JSON.stringify({
    subject: c.subject, issuer: c.issuer, publicKey: c.publicKeyB64,
    subjectKeyId: c.ski, authorityKeyId: c.aki,
  });
}

const interC = { subject: "Intermediate CA", issuer: "Root CA", publicKeyB64: interPub, ski: interSki, aki: rootSki, signatureB64: "" };
interC.signatureB64 = await crypto.subtle.sign(
  { name: "ECDSA", hash: "SHA-256" },
  root.privateKey,           // the PARENT's private key signs the CHILD's data
  new TextEncoder().encode(canonical(interC))
);

canonical() is what actually gets signed — a fixed, deterministic JSON ordering. Real X.509 uses DER's canonical encoding rules for the same reason: signer and verifier must hash the exact identical bytes, or every signature would fail to verify.

Verifying the chain, leaf to root

  1. 1

    Verify the leaf

    Check the leaf's signature using the intermediate's public key.

  2. 2

    Verify the intermediate

    Check the intermediate's signature using the root's public key.

  3. 3

    Verify the root

    Check the root's self-signature using its own public key.

  4. 4

    Trust, if every link checks out

    One broken link anywhere in the chain is enough to reject the whole thing.

signature=ECDSA_sign(issuer’s private key, {subject,issuer,publicKey,ski,aki})\text{signature} = \mathrm{ECDSA\_sign}(\text{issuer's private key},\ \{\text{subject}, \text{issuer}, \text{publicKey}, \text{ski}, \text{aki}\})

The fingerprint: identifying one specific certificate

Each card also shows a fingerprint — a SHA-256 hash of the complete, signed certificate (subject, issuer, key, key IDs, and signature all together). This is the same concept a browser shows under "certificate details": a compact way to identify or pin one exact certificate, distinct from the SKI above, which identifies a key rather than a specific issued certificate.

The actual WebCrypto call
async function computeFingerprint(cert) {
  // cert here includes the signature — the fingerprint covers the fully-issued certificate
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(cert)));
  return bufToHex(digest);
}

Because the signature is part of what's hashed, the fingerprint changes if the certificate is re-issued with a different signature, even with identical subject/issuer/key fields — which is also why it's unsuitable as a key identifier (SKI's job) but well-suited as a specific-certificate identifier.

Why tampering the subject breaks verification

The signature is computed over the exact bytes canonical() produces at issuance time. "Tamper with the leaf's subject" edits that field afterward without re-signing — so verification recomputes what the signature should cover, gets a different result than what was actually signed, and rejects it. This is the identical hash-then-sign tamper-evidence property covered in the hashing and signatures module, applied to a certificate instead of a message. Notice what tampering does not break: the AKI/SKI match from the section above still holds, because those hashes cover only the public key, not the subject field — the fast hash-based lookup and the actual trust decision are genuinely two different checks.

The actual code
const tampered = { ...leafCert, subject: "evil-" + leafCert.subject };
// signatureB64 is deliberately left untouched — it still covers the ORIGINAL subject

const valid = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  intermediateKeys.publicKey,
  base64ToBuf(tampered.signatureB64),
  new TextEncoder().encode(canonical(tampered))   // canonical() now includes the tampered subject
);
// valid === false — the signature was computed over different bytes than these

Nothing about the signature bytes themselves is inspected or altered — verify() simply recomputes what should be true and compares. That's the entire tamper-detection mechanism, here and in every other signature scheme on this site.

Now try it

Back to Certificate chain builder →

Build a real three-link chain of trust — root, intermediate, and leaf — each signed with real ECDSA, then tamper with the leaf and watch verification catch it.