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.
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.
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.
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
Verify the leaf
Check the leaf's signature using the intermediate's public key.
- 2
Verify the intermediate
Check the intermediate's signature using the root's public key.
- 3
Verify the root
Check the root's self-signature using its own public key.
- 4
Trust, if every link checks out
One broken link anywhere in the chain is enough to reject the whole thing.
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.
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.
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 theseNothing 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.