id-proto-debugger

PKI — a certificate authority, X.509v3, and a real TLS handshake

Scope: client/public/pki.html and its bundle, client/src/x509.js, client/src/key_material.js, client/src/pki_store.js, client/src/pki_history.js, api/tls_probe.js and the two endpoints it sits behind. Cross-cutting matters — the logging convention, the 80-column rule, the key-material opt-out rule, how the suite is run — stay in the repo-root CLAUDE.md.

Read this before touching any of those files. Almost everything in it is a defect that produced bytes which parse perfectly and are then refused by something else, with an error message naming the wrong thing.

What it is

One page that builds a certificate authority — Root, Intermediate, Issuing — and issues the leaf certificates any of them can sign, with every X.509v3 extension editable including its critical flag. Then it makes a real TLS or mutual-TLS connection with what it issued, which is the only way to find out whether any of it works.

The split across files is not arbitrary, and it is what makes the claim “every combination” testable:

File Holds Has a DOM?
client/src/key_material.js key pairs, PEM↔JWK, PEM/DER/JWK/PKCS#12 export no
client/src/x509.js profiles, extensions, issuing, describing, chain checks no
client/src/pki_store.js the keys and certificates kept for reuse no (localStorage only)
client/src/pki_history.js the Operations History, over op_history.js no
client/src/pki.js the page: the form, the tables, the buttons all of it
api/tls_probe.js the TLS / mutual-TLS connection n/a

So tests/pki_x509.js drives ~240 certificates through the real encoder in node and hands every one of them to OpenSSL, and tests/pki_page.js is left with only the page.

Two ways in, and both stay. The page has had a landing card of its own since 2026-08-18 (PKI / X.509, the twelfth) — it is a workflow rather than a tool of another protocol’s, and being reachable only from somebody else’s tools pane said the opposite. The Tools pane entries on oauth2_oidc_1.html, oauth2_oidc_2.html, saml_request.html, saml_response.html and wsfed_response.html are deliberately kept, because they are the other journey: you are mid-flow and you need a certificate, rather than here because the certificate authority IS the task. Note that those links carry ?from=<page> and this page does not read it — pki.html’s Return is a static link to the landing page, unlike jwt_tools.js’s setReturnLink(). Note too that the card carries no data-not-on-static marker: the CA is Web Crypto and pkijs in the browser and only the TLS test needs the api, which #pki_backend_notice says on the page itself. See client/CLAUDE.md, and the section below for what the page does with that one pane when there is no api behind it.

Both browser tests reach the page by clicking that card rather than by getting its URL — openThePageFromTheLandingCard() in tests/pki_page.js and tests/pki_mutual_tls.js, used for every load including the reloads that check the store survives (localStorage is per origin, so the trip through the landing page preserves what a direct re-get would; verified). tests/navigation.js checks the card too, but once and only against the containerized stack’s base URL, whereas these run wherever the suite is pointed — the deployed static sites included. A card that stops pointing anywhere, or that something is drawn over, then fails here instead of being nobody’s test.

The page is five panes, and it was seven

This page carries more fields than any other in the tree: a key pair (and, optionally, the CSR for it), sixteen certificate fields, a distinguished name, twenty X.509v3 extension cards and a TLS pane. On 2026-08-18 its first three panes — Key Pair, Issue a Certificate, X.509v3 Extensions — became one, pane_config, its fields laid out in three columns, with the extension list flowing in three of its own and the TLS pane in two. Nothing was removed; what changed is where it sits and how much margin it pays.

What it bought, measured at 1366x768 in headless Chrome. The page went from 4941px to 3323px — a third of it — and the Keys & Certificates pane, which is where you go to look at what you just issued, moved from 3802px down the document to 2146px. A second pass the same day took it to ~2600px: the configuration pane went to three columns, the extension list to three, and the TLS pane to two. A third took it to 2196px by moving every standing note and warning into the tooltip of the control it describes — see Every field has a tooltip below. tests/pki_page.js holds a 2700px budget with ~20% of slack in it for font metrics (fonts-liberation in the container, the host’s Arial on a host run), because what it is there to catch is the change that gives a whole saving back at once rather than the tens of pixels a font accounts for.

Why one pane. Those three described one act. Every field in all three is an input to the single Generate Key Pair & Issue Certificate button, at the top of the Key Pair block of the pane they became — you cannot issue anything from Key Pair alone, and the extensions are not a separate operation, they are arguments. Each extra pane costs a title bar, two borders and the gap between them before a field is drawn, and it made the reader collapse three things to reach the store rather than one. This is the same trade kerberos_ap.html made, for the same reasons and with the same consequence, and docs/kerberos.md measures it there at 213px.

What the lost legends became. A pane legend can only say one thing, so Key Pair and X.509v3 Extensions are .pki-group headings over the blocks they named. Without them the key algorithm dropdown and the profile dropdown are two unlabelled selects side by side. A div rather than a <label>, because it labels a group: a <label> with no control to point at is one the browser cannot associate with anything, and pointing it at the first field would claim the heading belongs to that field alone.

The configuration pane is THREE columns, and the third one is why. Key pair, certificate fields, subject DN. It was two, with the DN under the certificate fields, and the arithmetic of a grid is what was wrong with that: a grid row is as tall as its tallest item, so a 392px column sat beside a 675px one and the key pair ended in 283px of nothing. Three blocks of roughly equal height spend that space instead of paying for it. tests/pki_page.js measures both the heights (a column under 60% of the tallest is dead space with a border) and the tops — see the next paragraph for what makes them move.

So a field added to the TALL column is charged twice, and that is the one thing to hold on to before adding one. It costs its own height there, and it costs the same height again as dead space at the foot of each of the other two, because the row is as tall as the tallest item in it. The CSR field is the worked example: three rows of textarea and a checkbox took the key-pair column from 453px to 505px beside a 274px one — under the 60% floor, on a margin that had been 2px. What paid for it was Cryptographic Approach, which moved to the certificate column: it is a property of the certificate rather than of the key (it narrows the Signature Algorithm as well as the Key Algorithm, and the Profile and the issuer above it are already deciding the same question), so it reads better there and it leaves the three columns at 383 / 395 / 374px with the CSR field open and 383 / 339 / 374 with it shut.

Both of those numbers matter, and hiding the field is not what makes them legal. The CSR field is display: none until its box is ticked, so the page the test measures is the shut one — and a layout that only balances while a field is hidden is a layout that breaks the first time somebody uses it, with no test in front of it. The open figure is the one that had to be paid for.

Four traps live in that layout, and all four are invisible until something is narrow. .pki-cols is auto-fit with a 390px minimum, and that minimum — not the design — is what decides whether the pane has three columns, two or one: three need 3*390 + 2*22 = 1214px and there are 1275 at a 1366px window, so raising it to the 420 it used to be silently wraps the third column onto a row of its own and makes the pane taller than the two-column version it replaced. A layout that looks deliberate at 1500px and stacks at 1024 is usually this number biting rather than a broken rule — with the exception of the configuration pane above 1330px, where .pki-cols-3’s media queries take the track sizes over and the minimum stops deciding anything. .pki-col carries min-width: 0, which is not decoration: a grid item’s default minimum is its content’s, and this page’s content includes an unbreakable 64-character line of base64 in a PEM textarea. Without it the column refuses to be narrower than that line, the grid overflows its pane, and the page scrolls sideways — which reads as a broken layout rather than as two missing words of CSS. And .pki-flag’s white-space: nowrap belongs to the flag grids and the card headings, where the labels are single identifiers: the half-dozen flags elsewhere are whole sentences (“also connect without it, and say whether the server requires one”), and a sentence that may not wrap hangs 66px out of a half-width column at 900px — reported by the page as a horizontal scrollbar rather than as a label.

The fourth is a control that does not shrink, and it is the only one of the four with no symptom at all. .pki-col carries min-width: 0 and the fields inside it are flex: 1 1 0, so a control with a fixed width does not narrow with its column — it keeps that width and draws over the column beside it. Nothing reports that: the grid itself still fits, so there is no horizontal scrollbar, nothing is clipped, and the page height does not move. What you see is two controls sharing a few pixels, which reads as a font difference.

The one that had it was input[type="number"]. saml_common.css sizes .saml-pane textarea, input[type="text"], input[type="password"] and select at width: 100% with box-sizing: border-box, and does not name number — so bootstrap.css’s input { width: 206px } plus its own padding and border survived on the two this page has, at 220px each. Validity (years) sat 49px inside the Key Pair column at 1366px, over the Export block’s Keystore Format label and select, and the TLS pane’s Port was the same 220px between a Host and an SNI that were not. Both are now sized by .saml-pane input[type="number"] in css/pki.css, with the shared sheet’s own declarations rather than a second opinion about how wide a field should be. It is fixed here rather than in saml_common.css because the only other number inputs in the tree — jwt_clock_skew on token_detail.html, vc_batch_size on vc-issuance-2.html — sit in table cells carrying a size attribute, where 100% means the width of the cell: deliberately narrow fields that a fix in the shared sheet would stretch for a defect neither of them has.

This was invisible while these were three full-width panes: the overflow had nothing to land on. So tests/pki_page.js now measures every control in every .pki-col against its column’s right edge rather than just the one that had it — any control type the shared sheet does not name arrives the same way.

The TLS pane is two columns, on the same reasoning and with the same .pki-cols. Left is the connection this end makes — host, port, SNI, the version range, the cipher list, ALPN, and the client certificate; right is what the answer is checked against — the truststore, the extra PEM anchors, and the GET that asks the server what it saw. That is also the order the two are decided in. The button, the status line and the report stay full width below, because the report is a table of what both ends saw and half a page is not where it goes. Truststore lost its .saml-sub wrapper on the way: it heads a column now, and a column that opens on a dashed separator reads as the tail of the one beside it.

The extension list is CSS columns, deliberately not a grid. A grid lays its items out in row order, so twenty cards of wildly different heights (subjectKeyIdentifier is one checkbox; subjectAltName is a checkbox, a four-row textarea and a paragraph of syntax) leave a gap under every short one to the height of the tallest in its row. A column flow packs them and keeps document order reading down each column, which is the order the list is in and the order the page’s own prose refers to. columns: 380px 3 is a maximum of three: below about 1180px of pane there is room for two and below ~780px for one, and each collapse happens on its own, so no media query is needed — and, less happily, one card that stops fitting 380px costs a whole column with no symptom except that you scroll further. That is ~400px of page, which is why tests/pki_page.js measures the column count from where the cards actually landed rather than assuming it. What decides whether a card fits is the .pki-flags minimum inside it: at 172px the keyUsage and extendedKeyUsage grids are still two flags wide in a 380px column, and raising it back to the 240 it was is what would take the list from three columns to two. break-inside: avoid is what stops a card being split across the boundary; a subjectAltName whose checkbox is at the foot of one column and whose textarea is at the head of the next is two half-controls, and it happens silently.

The prose is folded, not cut. Seven long notes — the intro’s enumeration of what is on offer, the three block explanations, the TLS pane’s two, the history pane’s — are now <details class="pki-more">, the same mechanism and the same reasoning as .krb-more: a debugger that omits why a thing fails is the thing nobody needed. Where a note has one sentence that must be read before anything is typed, that sentence stays outside the fold and only the reasoning goes in — which is why the TLS pane still says on its face that the API layer makes this connection, the sentence tests/pki_page.js asserts on. The rule in the other direction is stricter and is asserted too: nothing anybody has to operate may be inside a fold. A checkbox behind a summary is a setting nobody finds, and unlike missing prose that costs a certificate rather than an explanation.

One button, and it is at the top. Generate Key Pair and Issue Certificate were two buttons for one act. A pair that is never certified is kept nowhere — generateKeys() says exactly that in its own status line — and issue() refused outright without one, so the second button was a step that could only ever be forgotten and the page had an error message (“Generate a key pair first”) whose whole job was to say so. They are now pki.generateAndIssue(), on one button at the top of the Key Pair block: a reader should not have to scroll past twenty-two extension cards to find out what any of them are for. What the two-button flow gave for free was re-issuing for a key that already exists — a CA renewing its own certificate — so a reuse the key pair below checkbox (pki_reuse_key) sits beside it, cleared by default, and the store’s Use this key pair button ticks it itself rather than loading a pair the next click would throw away. The box is checked together with the fields: ticked over two empty fields it is a request to certify nothing, and the generation happens anyway.

Each extension is one line. issuerAltName, cRLDistributionPoints, freshestCRL and the eleven others with a value were a checkbox row, then a textarea under it, then a note under that; the checkbox row and the textarea are now the same row — name, value, critical — inside .pki-ext-head, with only the syntax note below. The boxes are rows="1" in the markup and min-height in .pki-inline, and neither is enough alone. Note what did not change: they are still <textarea>s, so the multi-line values these extensions take (subjectAltName is one name per line, and always was) are still typed, stored and parsed exactly as before — what was cut is the space an empty one occupies, which was four lines of nothing twenty-two times over. resize: vertical rather than both, because horizontal resizing inside a CSS column pushes the column’s own width around. The 90px floor under the flex basis is what makes a long name — cRLDistributionPoints is 21 characters — wrap the row instead of collapsing the box to a slot.

The key pair and the certificate fields swapped columns, so the pane now reads Issue a Certificate | Key Pair | Subject Distinguished Name: the profile decides what the certificate is, and the key algorithm and the button sit in the middle beside the DN they are about to be bound into.

That middle column is two rows, and both of them are one line. The key algorithm select, the Show as JWK toggle, Generate Key Pair & Issue Certificate and its reuse the key pair below checkbox are one row; the keystore format, the password, include the chain, save private keys here and Download are the other. They were four lines between them — the select had a row of its own above the button, and the format and password a row of their own above the checkboxes and the button — and those two extra lines are the whole of why the key-pair column used to hang ~70px below the two beside it. Everything on both rows is now as wide as what it holds (.pki-narrow, 130px for seven algorithm names, 140px for four format names, 100px for a password) rather than half a column each.

The width for that comes from the other two columns, and it takes two media queries. Nothing above will fit in an equal third of the pane: the key-pair row alone wants ~650px and a third is 410px at a 1366px window, 478px at the container’s 1478px maximum. So .pki-cols-3 — the configuration pane’s grid, and not the TLS pane’s, which is the same class with two columns — gives the middle column 1.3fr against 0.85fr either side from 1330px, and 1.4fr against 0.8fr from 1530px, where the middle reaches ~669px and nothing on either row wraps at all. The side columns lose nothing measurable to it: they are label-and-input rows and eight short DN fields, and at 1366px they measure 354px and 374px against the 352px and 363px they were.

Between 1330px and 1530px the rows shrink instead of wrapping, which is what flex-wrap: nowrap on .pki-actions and .pki-export-row inside that media query is for. Flexbox assigns items to lines by their max-content width before it shrinks anything, so a row that is 100px short does not narrow its widest item — it moves the last control to a line of its own, which is exactly the white space this removes. With nowrap the fixed-width controls keep their width and the checkbox labels take the shortfall as wrapped text: at 1366px reuse the key pair below is two lines of text inside a row that is still one row. The nowrap is confined to the media query on purpose, because the row’s unshrinkable minimum — a 130px select plus a 235px button — is wider than the 390px column the base auto-fit rule hands it below that, and an overflowing flex line is a horizontal scrollbar across the whole page. tests/pki_page.js asserts the property that survives both bands: every control in each row shares one bottom edge, which one flex line always has and two never do.

Measured at 1366x768 in headless Chrome, with an empty store: 2523px of document, a 1159px configuration pane, a 605px extension list still in three columns, and Keys & Certificates 1365px down. The budget in tests/pki_page.js is 2700px and is there to catch a change that gives a whole saving back at once.

The density overrides live in css/pki.css and override css/saml_common.css on purpose. That sheet is linked by pki.html and by nothing else, so a bare .saml-row in it reaches this page only — which is the whole reason they are there rather than in the shared sheet, where they would reach five pages that do not want them. Nothing in that block changes a font size, a border or a colour: what is cut is margin and padding. A row that is hard to read is not denser.

The key-pair pane is jwt_tools’ pane

client/src/key_material.js is not new code. It is the bottom third of client/src/jwt_tools.js, extracted: the algorithm table, generation, the PEM/JWK conversion behind the format toggle, PBES2-encrypted PKCS#8, PKCS#12, and the PBES2 JWE that password-protects a JWK set. jwt_tools.js is now a caller of it — the page lost 340 lines and gained nothing — and the two panes are identical rather than similar.

Two things came out of that extraction that are worth knowing:

The CSR is optional, and this page is the authority that ignores it

Generate a CSR when the certificate is issued, the checkbox in the Key Pair column under Public Key, fills the third field below it: the PKCS#10 certification request this key pair and this subject would have sent to an external authority. Nothing on this page consumes it. That is the point rather than an omission: a CSR is what you send to an authority that will not take a certificate you made yourself, and this page is the authority — it signs with a CA from the store below.

The field is not there until the box is ticked, and unticking clears it as well as hiding it. An empty textarea for a thing the page does not produce unless asked reads as something broken; and a request describes one key pair and one subject, so a CSR kept out of sight across an issue would come back into view describing a certificate other than the one in the store — wrong in a way nothing on the page could contradict. onGenCsrChange() does both, and onload() calls it so a ticked box survives a reload.

The checkbox is beside that field rather than in the action row with the issue button, and that is layout rather than preference — .pki-actions is flex-wrap: nowrap above 1330px and its four controls only fit the column at 1366px because the two checkbox labels wrap inside it. A fifth is drawn outside the column, over the one beside it, with nothing clipped and no scrollbar to say so. With the field hidden by default the label is also the only thing on the page that says the field exists, so it could not go anywhere else in any case.

So the request is for reference, and it is worth having for one reason: the signature on it is by the private key in the field above, and that signature is the proof of possession which lets an authority certify a key it has never seen. x509.js’s own header says it; the field lets you hold the bytes.

It is built from the same objects the certificate is — the same subject, the same key pair, the same spec.extensions — inside issue(), after issueCertificate() returns. A request assembled from a second reading of the form would differ from the certificate in ways nobody could see, and the one question a reader brings to this field (“is this the request for that certificate?”) would have no answer.

Four extensions travel and the rest do not. A CSR carries what the requester ASKS FOR, and an authority is free to ignore all of it — most do. certificationRequest() takes key usage, extended key usage, basic constraints and subjectAltName, and nothing else goes in. subjectKeyIdentifier and authorityKeyIdentifier are the deliberate omissions: they are the issuer’s to compute, and a requester asserting them is asking a CA to certify its own arithmetic.

Two smaller decisions:

The three textareas are three rows each. They were 5 and 4, so the third field costs the pane no height at all — which is the only reason it could be added to a pane the rest of the page is already sized around.

The bug this uncovered, which had been silent since the function was written

The PKI page is the first caller to pass key usage or extended key usage to certificationRequest(). SPIFFE’s five call sites pass subject and subjectAltName and nothing else, which is why nobody had been down this path.

It called buildKeyUsage({ bits: … }) and buildExtKeyUsage({ ekus: … }), and both builders read spec.usages — names those functions have never used. Each returned null, the existing .filter(Boolean) two lines below dropped it, and every CSR this module ever produced carried neither extension while reporting no error anywhere. Nothing threw, the request was valid, openssl req -verify was happy, and the only symptom was an authority granting a key usage nobody had asked for.

It is the exact failure that filter’s own comment describes guarding against, arriving through the argument instead of the return value. Fixed in client/src/x509.js; SPIFFE’s requests are byte-identical before and after, which is what makes the fix safe to make here.

Where this sits beside central key generation

A CSR is the subscriber generates the key direction: the private half never moves, and the request proves possession of it. The opposite model — the authority generates the key pair and the certificate together and delivers both — is equally real and is standardised in EST’s /serverkeygen (RFC 7030 §4.4) and in CMP/CRMF’s central key generation with private-key archival (RFC 4210 / RFC 4211).

What cannot exist is the middle: an authority handed a CSR cannot produce the matching private key, because the request carries only the public half and deriving the private one is precisely what the cryptography prevents.

The page already does both directions, without labelling them. The CA hierarchy builder is central key generation — it makes the key and the certificate in one action — and this checkbox is the artefact of the other. Which is appropriate depends on what the certificate is for: an encryption certificate wants key recovery (lose the key, lose the data, which is what the key-encipherment profile is), and a signing certificate must not have it, or the non-repudiation digital-signature exists to provide is gone.

The profile fills in the subject CN

Every profile carries a cn beside its extension defaults in x509.js’s PROFILESRootCA, IntermediateCA, IssuingCA for the three authorities, server and client for the TLS pair, and Signer, Recipient, CodeSigner, EmailUser, OCSPResponder, TimeStampingAuthority, SmartcardUser, kdc for the rest. It is in the module rather than in the page for the same reason the extension defaults are: the node tests then start from what the browser starts from.

They were Example Root CA and localhost until 2026-08-18, and two things went with the rename. The TLS profiles no longer default to a name a local server answers to — issuing for localhost is now one field to type, which is the trade for a store whose rows read as a hierarchy. And server and client are short enough to be words somebody might type for themselves: isDefaultSubjectCN() cannot tell those apart from one of ours and will replace them on the next profile change, which was already true of localhost and is more likely now.

Nothing fails without it. An empty subject is a legal DN and x509.js will issue one — what it costs is a store holding four rows that read the same and a chain view nobody can follow, after typing four names by hand to get to the first handshake.

The rule is three cases, and two of them are the reason it is a function rather than a line. applyDefaultSubjectCN() in pki.js fills an empty field, replaces a field still holding any profile’s default, and leaves anything else exactly as it is:

That is why the test is x509.isDefaultSubjectCN() — “is this any profile’s default” — rather than a comparison with the current profile’s. By the time the question is asked the dropdown has already moved on, and the value in the field belongs to the profile that was selected a moment ago.

The serial number is filled in, not left blank

pki_serial used to be an optional field: empty meant issueCertificate() called x509.randomSerialHex(16) for you, and the value first appeared in the store’s Serial Number row, after the certificate had been signed. Since 2026-08-18 the field carries a random 128-bit serial from the moment the page loads, newSerial() in pki.js puts a fresh one there after every successful issue, and it is an ordinary editable input the whole time — type a serial and that is what gets signed.

Why it is refilled rather than left holding the last one. Two certificates from one issuer sharing a serial is precisely what a serial exists to prevent: (issuer, serial) is the pair a CRL entry, an OCSP request and most caches and pins are keyed on, so the second certificate is not merely a duplicate, it is indistinguishable from the first to everything that revokes or looks one up. A field that kept its value would produce that on the second press of the button, silently, and with the store showing two rows that differ everywhere else.

Three details in eight lines of code:

theSerialIsFilledInEditableAndRotates() in tests/pki_page.js asserts all four through the form: the value on load, that it survives a reload, that the certificate carries the serial the field was showing, that the field holds a different one afterwards, and that a serial typed by hand is the one signed.

The rest of the DN, and the SAN that has to match the CN

Since 2026-08-18 the CN is not the only field with something in it. O, OU, L, ST and C come from x509.DEFAULT_DNExample Corp, Information Technology, Austin, Texas, US — and they behave differently from the CN on purpose: they do not vary by profile, so applyDefaultSubjectDN() runs once on load and fills only what is EMPTY. There is no “is this still a default” question to answer and no replacement rule to get wrong. What it cannot tell apart is a field somebody deliberately emptied — clear O, reload, and O is back — which is the same trade the CN has always made, and the alternative is remembering a negative in storage for every field on the page.

A DN with nothing in it but a CN is a legal name and a poor example of one. These five put the encoding an operator actually meets — the PrintableString of a country code, the ordered RDNSequence — in the first certificate somebody issues here rather than the fifth.

The subjectAltName is the one that matters most and shows least. The two serverAuth profiles now tick subjectAltName and fill it with dns: + their own CN, because for a TLS server the CN is not what is checked — every current browser ignores it. A server certificate issued here with the CN filled in and the SAN empty is a certificate no client will accept, and neither the page nor the store says so anywhere: it is a valid certificate, it chains, and the only symptom is a browser refusing it for a reason it attributes to the name.

defaultSubjectAltName() derives it from the profile’s extKeyUsage rather than storing it per profile, so a new server-ish profile cannot be added without one. applyDefaultSubjectAltName() in pki.js then applies the same three cases the CN uses — fill an empty field, replace one this page put there, never touch what somebody typed — and the middle case is the one worth having: picking Root CA after TLS Server has to take dns:server away again, because a root asserting that name has no business carrying it and the field is two columns from where the profile was chosen.

It follows the CN, not the profile’s own default, which is the whole point of it. Picking TLS Server and then typing the name you actually want is the ordinary way to use this page, and a SAN left saying dns:server while the CN says www.example.test is the same unusable certificate as no SAN at all — the one this is here to stop. So pki_dn_cn carries an onchange of its own.

That makes “is this still ours?” a question isDefaultSubjectAltName() cannot answer alone: once the CN has been typed over, the SAN derived from it is not any profile’s default and looks exactly like a name a person chose. So subjectAltNameIsOurs() also accepts the last value this page wrote (lastAutoSan, a module variable) and one that already says exactly what the CN says — rewriting that to match a new CN takes nothing away. lastAutoSan is deliberately not persisted: after a reload the page cannot tell its own derived SAN from a typed one, and the safe reading of an unknown is that it belongs to the reader. tests/pki_page.js asserts every one of those directions.

Every field has a tooltip, and that is where the notes went

On 2026-08-18 the standing notes and warnings came off the page and became the title of the control they describe: the syntax lines under twelve extension textareas, the paragraph under the store, the truststore’s, the history’s, the two that printed under the Profile and Signature Algorithm dropdowns, and the warning at the head of the TLS pane. The page went from ~2600px to 2196px at 1366x768, and tests/pki_page.js’s budget came down from 3200 to 2700.

Three rules decided what moved:

The consequence is that a field with no tooltip is now a field with no explanation anywhere, so everyFieldHasATooltip() asserts every one of the 131 controls on the page has one — on itself, on its label[for=…], or on a <label> around it, the three shapes this page uses. Three controls failed it when it was first written, and all three for the same reason: a <label title="…"> with no for attribute is a label the browser associates with nothing, so its tooltip was not the field’s tooltip either.

Not Before and Not After are pickers

The four validity fields — the certificate’s pair and privateKeyUsagePeriod’s — were text inputs holding ISO 8601 until 2026-08-18 and are datetime-local inputs now. The picker’s value is local time and what is encoded is the instant, which new Date("2035-06-07T08:09") gets right without any conversion in validityDates().

Two things in eight lines of code, both of which fail silently:

Five things about certificates that are wrong and self-consistent

Each of these produces a certificate that parses, that openssl x509 -text displays plausibly, and that something else then refuses. Each was real. All five are why tests/pki_x509.js asserts by asking OpenSSL what it reads rather than by reading back what this codebase just wrote.

1. pkijs builds a Name as one multi-valued RDN. A Name is an RDNSequence — a SEQUENCE of SETs, one per attribute. pkijs’s RelativeDistinguishedNames.toSchema() emits a single SET holding all of them, which OpenSSL renders as CN = host + O = Example + C = US; the + is the tell. It parses. It is simply a different name, so nothing chains to it. buildDn() therefore builds the DER itself and hands it to pkijs as a parsed schema, which toSchema() then returns verbatim.

2. pkijs wraps an otherName in a second [0]. GeneralName’s otherName is [0] IMPLICIT OtherName, so the tag replaces the SEQUENCE tag and the OID and the [0] EXPLICIT value sit directly inside it. pkijs.GeneralName’s toSchema() for type 0 wraps whatever it is given in a further [0]. openssl x509 -text prints the name quite happily; openssl verify refuses the whole certificate with:

error:0580009E:x509 certificate routines:ossl_x509v3_cache_extensions:reason(158)

which names no extension, no name and no field. otherNameGeneralName() builds that DER by hand and returns a duck-typed object with the toSchema() its callers use, and buildAltName() emits the GeneralNames SEQUENCE itself so the hand-built name is accepted alongside the pkijs ones.

3. The hash a signing key is IMPORTED with is the hash that lands in the certificate. pkijs reads privateKey.algorithm to build the signatureAlgorithm field, so importing an RSA key as SHA-256 and then asking sign() for SHA-512 produces a certificate whose declared algorithm and actual signature disagree. signerDescriptor() derives the import parameters from the signature algorithm chosen, never from the key. openssl verify is what catches getting this wrong, and it reports it as a bad signature — naming no hash.

4. A date at or after 2050 must be a GeneralizedTime. RFC 5280 §4.1.2.5. pkijs picks UTCTime by default, and a 2050+ UTCTime is read as 1950 — a certificate that expired seventy years ago, reported by every validator as expired and by none as misencoded. issueCertificate() switches the type at the boundary. A twenty-year root issued in 2031 crosses it.

5. An ECDSA signature’s r and s must be MINIMALLY encoded DER INTEGERs, and asn1js strips only one leading zero octet. Web Crypto returns the signature as fixed-width r||s; pkijs converts it with asn1js’s Integer.toDER(), which removes exactly one leading zero. A value small enough to carry two — about one P-521 signature in 256, that curve’s r and s being 66 octets for a 521-bit order — therefore reaches the certificate as 00 1d f9 … where DER requires 1d f9 …. Nothing about the certificate looks wrong, and verifyChain() here accepts it, because pkijs’s own reader right-aligns whatever length it finds. OpenSSL refuses it, because ECDSA_verify re-encodes what it parsed and compares the bytes:

error 7 at 0 depth lookup: certificate signature failure
...asn1 encoding routines:ASN1_item_verify_ctx:EVP lib

naming neither the encoding nor the curve. minimalEcdsaSignature() re-encodes the pair once, in signCertificate(), where the signature is made.

This is also the one of the five that the matrix could only catch by luck, and it is worth knowing why. At ~32 P-521 signatures per run it shows up in roughly one run in eight — it failed the containerized run of 2026-08-21 on ec-p384 under an ec-p521 CA at sha1-ecdsa, and passed on the five runs either side. So the matrix is how it was found and is not what guards it: ecdsaSignatureIntegersAreMinimal() feeds the two-leading-zero case in as a vector, deterministically, on every run.

And a sixth, which is not pkijs’s fault: an IP name constraint is the address followed by its mask — eight bytes for IPv4, not four. It is the one place a general name’s iPAddress is not just an address, and encoding it as one produces a constraint that constrains nothing while looking correct in every dump. ipConstraintBytes() does it; nameConstraintsAreEnforcedByOpenssl() proves it by having OpenSSL refuse a name outside the permitted subtree.

Post-quantum: three ways, and the page makes you choose

The page issues certificates with ML-DSA, SLH-DSA and ML-KEM keys, and it can carry post-quantum cryptography in three different ways that are not variations on one idea. Which is right depends entirely on who has to read the certificate, so the Cryptographic Approach selector is a control rather than a default:

Approach What the certificate holds Who accepts it
Pure one post-quantum key, one post-quantum signature, one OID (RFC 9881, RFC 9909, RFC 9935) OpenSSL 3.5 and later; everything older refuses it outright
Composite one OID naming an ML-DSA key and a traditional key, two signatures concatenated inside the single signatureValue (draft-ietf-lamps-pq-composite-sigs-19) nothing released yet — a verifier either checks both halves or understands neither
Hybrid an ordinary classical certificate plus a second key and signature in three non-critical extensions (ITU-T X.509 (2019) clause 9.8) everything, because a validator that does not know the extensions ignores them

The selector filters the Key Algorithm menu — which is grouped by family, since forty-one flat options is a scroll bar with no landmarks — and Hybrid reveals a second key pair.

The algorithms, and where each identifier comes from

client/src/pqc_x509.js is the PKIX binding of the algorithms pqc.js already had, and it is a separate module because the same algorithm is a different set of bytes in the two worlds: a JOSE ML-DSA key is an AKP JWK whose pub is base64url, and an X.509 one is a SubjectPublicKeyInfo whose BIT STRING is the same octets with no wrapping at all.

The private key is a CHOICE, and which arm you write is interoperability

RFC 9881 section 6 gives an ML-DSA private key three encodings inside the PKCS#8 privateKey OCTET STRING — seed [0], expandedKey, and both — and ML-KEM has the identical CHOICE with a 64-byte seed. The RFC recommends seed and OpenSSL 3.5 writes both, so this build writes seed and reads all three, which is the only combination that is both conformant and able to open what the most widely deployed producer emits. encodePkcs8() takes the arm as an option, because seeing the difference is the sort of thing a debugger is for.

A key written in the expandedKey arm carries no seed and cannot be turned back into one — the derivation is one-way — so signing from it is refused with a sentence that says exactly that rather than a length error from the primitive.

The composite is the same construction as JOSE and three different encodings

The message representative is identical to the one pqc.js already builds — M' = Prefix || Label || len(ctx) || ctx || PH(M), with the label passed down as the ML-DSA context — and the labels are the same strings. What differs is how the traditional component is written down, and each difference is a one-line change that produces a signature nothing else will verify:

  1. the ECDSA public key keeps its 0x04 uncompressed prefix here and loses it in JOSE;
  2. the ECDSA signature is a DER Ecdsa-Sig-Value here and fixed-width r || s in JOSE;
  3. the RSA components exist here and not in JOSE at all.

Two details in the draft’s own table are worth knowing because reading them off the algorithm name gives the wrong answer for four of the sixteen: id-MLDSA65-RSA3072-PSS-SHA512 pre-hashes with SHA-512 and instantiates RSASSA-PSS with SHA-256 (the PSS parameters are fixed per RSA size, the pre-hash per algorithm), and id-MLDSA65-ECDSA-P256-SHA512 signs its ECDSA half with SHA-256.

The hybrid certificate, and the two removals that make it work

Hybrid mode writes the three extensions of ITU-T X.509 (2019) clause 9.8:

subjectAltPublicKeyInfo (2.5.29.72)  mirrors subjectPublicKeyInfo
altSignatureAlgorithm   (2.5.29.73)  mirrors the signature field
altSignatureValue       (2.5.29.74)  mirrors signatureValue

The alternative signature does not cover the TBSCertificate. It covers the preTBSCertificate, which differs in two ways and both are easy to get wrong in a way that only shows up as the other implementation says my signature is invalid:

  1. the signature field — the third element of the SEQUENCE, holding the AlgorithmIdentifier of the conventional algorithm — is removed;
  2. the altSignatureValue extension is absent, necessarily, since it is what is being computed.

Everything else is covered, so the alternative signature binds the alternative public key to the subject.

The hybrid extensions are on certificates and not on PKCS#10 requests. draft-truskovsky-lamps-pq-hybrid-x509 defines the matching three CSR attributes — the alternative public key, the alternative algorithm and an alternative signature over a PreCertificationRequestInfo — and this build does not write them, because nothing here would read them: the five SPIFFE methods that take a CSR ignore even the extensions a request asks for, and a certification request is a proof of possession rather than a document anybody keeps. A post-quantum CSR (a pure or composite key signing its own request) is supported and tested; a hybrid one is not, and that is a decision rather than an oversight.

What this build will not pretend to know is what a failed alternative signature means to a chain: the IETF profile that would have said (draft-truskovsky-lamps-pq-hybrid-x509) expired without progressing. So the Chain pane reports the alternative verdict in a column of its own beside the conventional one and refuses to fold the two into a single boolean.

Post-quantum TLS: what negotiates and what does not

The TLS pane gains two fields, and they are two because “is this connection post-quantum” is two questions with two answers on two timescales:

A connection with an ML-KEM key exchange and an RSA certificate is the normal state of the world in 2026: protected against recording, not against a future forger. The report keeps the two apart and says so.

Three results are worth writing down because they are properties of OpenSSL rather than of this code, and tests/api_tls_probe.js asserts each:

Node cannot name a hybrid group. getEphemeralKeyInfo() describes ECDH and DH and returns an empty object for anything else, which under OpenSSL 3.5 means an ML-KEM hybrid — or a resumed session. The report says both readings rather than guessing between them.

All three of those results need an OpenSSL that HAS the algorithms, so on an older node they are skipped rather than asserted. That is the same argument as “Why the tests ask a different OpenSSL” below, arriving one layer up, and each of the three fails differently enough to be worth naming. A group name OpenSSL does not know is refused where the secure context is built, before a byte is sent, with Failed to set ECDH curve — which names neither the group nor the release that would have it, and which this endpoint used to wrap in a sentence about the client key and certificate not being a pair, sending a reader after a PEM they may not have supplied. POST /tls/connect now attributes a synchronous refusal to the input it was actually about (ETLSGROUPS, ETLSCIPHERS, ETLSSIGALGS, else ETLSCLIENTMATERIAL) and says which OpenSSL this api is linked against. The SLH-DSA case is the one that most needed the gate, because it asserts a refusal: OpenSSL 3.0 refuses too, with decode error — it cannot read the key at all — so the check would have gone on “passing” while proving nothing about TLS signature-algorithm codepoints, and while being indistinguishable from this project having encoded the key wrongly. tests/openssl35.js answers both questions (available() for the certificate algorithms, tlsGroupAvailable() for the supported-groups list), each by probing rather than by reading a version string.

Why the tests ask a different OpenSSL

tests/pki_x509.js asserts by handing certificates to the openssl binary, and nothing pins that: ubuntu:latest ships 3.5 today and shipped 3.0 a release ago, and an Ubuntu 22.04 development host has 3.0.2. On 3.0 there are no post-quantum algorithms at all — an ML-DSA certificate comes back as X509_PUBKEY_get0:decode error, which is a statement about OpenSSL rather than about the certificate — so a test asserting through the binary would pass or fail on the base image’s release date.

Node’s OpenSSL moves with the node version, which every image here pins: 24.16 is linked against OpenSSL 3.5.6, the release that added all three families. So tests/pki_pqc_x509.js reaches the same library through crypto.createPublicKey() and crypto.verify() (see tests/openssl35.js) and gets the same answer on every machine. The hybrid cases go back to the binary on purpose, whichever it is, because their whole claim is that a validator which does not enforce those extensions accepts the certificate anyway — 3.0 has never heard of them, and 3.5 prints them without checking them.

Ed25519 is signed by hand

pkijs cannot import an Ed25519 public key (subjectPublicKeyInfo.importKey() throws “Error during exporting public key: Incorrect key data”) and cannot sign with one. Both are avoidable:

OpenSSL verifies the result, and tests/pki_x509.js issues a whole chain through it.

The browser has to have Ed25519 at all

Everything above is about pkijs; the browser’s own Web Crypto is the other half, and it is younger than this page. Chrome enabled Ed25519 in the Web Cryptography API by default in Chrome 137 — before that the implementation is present but gated, and generateKey, importKey and sign naming { name: 'Ed25519' } all reject with Algorithm: Unrecognized name. So on an older browser this page’s ed25519 key algorithm cannot produce a key, and node — which has had Ed25519 for years — cannot see that: pki_x509.js and pki_key_formats.js both pass. The tests image pinned Chrome 121 until 2026-09-03 and pins 152.0.7977.75 now, which has Ed25519 on by default — so the flag below is a no-op on it today and is kept for a pin moved backwards. tests/pki_page.js asks for the feature by name through browserFlags.addWebCryptoEd25519Flags(), which is a no-op on a browser that already has it.

Reading a certificate is the part that degrades gracefully rather than failing: describeCertificate() falls back to the algorithm named in the SubjectPublicKeyInfo when the key cannot be imported (see the bullet under “View certificate details” below), so such a browser still describes an Ed25519 certificate it cannot verify.

What did not degrade gracefully was issuing, and it is worth knowing why the message was about the wrong call. Generate Key Pair & Issue Certificate is one button — generateAndIssue() — and generateKeys() catches its own error, leaving the two key fields exactly as they were. They therefore still held the PREVIOUS certificate’s pair, of a different algorithm, since changing the dropdown is the only reason to generate again; the emptiness check let that through, and issuing imported that pair under the newly selected algorithm and reported

Could not issue the certificate: Failed to execute 'importKey' on
'SubtleCrypto': Algorithm: Unrecognized name

importKey rather than generateKey, about a key the user had not asked to certify. It now compares the private key field before and after (two successful generations never produce the same key), so a failed generation stops there and its own message stands.

“View certificate details” and the page it opens

The store’s View certificate details button hands the selected PEM to saml_cert.html through localStorage['saml_cert_view'] and opens it in a second tab. That page is shared: ten pages send certificates to it, and until 2026-08-18 it parsed them with node-forge, whose certificateFromPem() reads the SubjectPublicKeyInfo eagerly and supports exactly one algorithm in it. So every EC and every Ed25519 certificate — which is every certificate this page issues except the RSA ones — arrived at

Parse error: Cannot read public key. OID is not RSA.

a message about a public key, on a page that had not got as far as showing the subject, and produced by the page you were sent to rather than the one that issued the certificate.

It parses with x509.js now, which is the module the certificate was issued with, so there is one description of a certificate in this tree rather than two: every extension the page can build it can also read back. Three things came with the change:

The return link needed pki.html in its map as well, or the reader lands on a page whose only way back is the browser’s own button.

theDetailsPageReadsEveryKeyAlgorithm() in tests/pki_page.js asserts this through the button, in the tab it opens, once per key algorithm — EC first, since RSA is the one algorithm the old parser got right. Neither module’s own tests could have caught it: pki_x509.js never opens a browser, and pki_page.js had never left the PKI page.

The store, and the private-key opt-out

pki_store.js keeps every object under one localStorage key as a JSON array — one key rather than one per object, because the order is the hierarchy’s order and a store spread over n keys has no order at all.

The repository’s standing rule is that key material stays out of localStorage, with an opt-out on every protocol that generates a key pair. Here it is applied the way the SD-JWT VC workflow applies it rather than the way the SAML page does, because there is not one key pair but a collection, and the certificates in it are public documents:

tests/pki_page.js reads localStorage directly for all of this, for the same reason tests/keypair_storage_optout.js does on the other four protocols.

The list fields are text, and that is deliberate

subjectAltName, the CRL distribution points, the AIA entries, the policies, the name constraints and the custom extensions are textareas with a small documented syntax rather than rows of widgets. Three reasons: an editor built out of widgets can only express what its widgets anticipated, and the point of this page is issuing the certificate that is wrong in exactly one way; a line of text is something an operator can paste from a ticket; and it is input a test can drive exhaustively. The syntax is documented on the page beside each field, parsed by the parse*() functions in pki.js, and checked directly by tests/pki_page.js.

dns:example.com            ip:10.0.0.1        ip:2001:db8::1
email:user@example.com     uri:https://x/y    rid:1.2.3.4
upn:user@EXAMPLE.COM       krb5:host/h@REALM  dirname:CN=alt,O=Example
othername:<oid>:<base64 DER>

permit dns:example.com     exclude ip:10.0.0.0/8      (name constraints)
ocsp:http://ocsp/          caissuers:http://ca/       (AIA / SIA)
1.3.6.1.4.1.99999.1|cps=https://c/|notice=text        (policies)
<oid>|critical|<base64 DER>                           (any extension at all)

A malformed line is refused by name rather than dropped. A certificate quietly missing a name somebody typed is the failure this whole page exists to avoid.

The TLS test, and why the page has no option to make it itself

Every other page here offers the choice: call from the browser, or proxy through the api. This one does not, and the omission is the design. A browser cannot answer any of the questions the pane exists to ask:

So POST /tls/connect opens the socket and reports both sides. A browser-side option would be a button that answers a different question badly, and every answer it gave would be about the browser. tests/pki_page.js asserts that the pane contains no radio button, so this cannot be “fixed” by mistake.

A completed handshake is not an accepted client certificate

This is the sharpest thing in api/tls_probe.js and the assertion that earns its keep.

Under TLS 1.2 a server that refuses a client certificate refuses it during the handshake, and secureConnect never fires. Under TLS 1.3 the client sends its Certificate and Finished last — the handshake is complete from the client’s point of view the instant it has written them — and the server’s verdict arrives afterwards. It arrives in one of two ways, and neither is visible in the handshake:

An implementation that resolves on secureConnect therefore reports a happy mutual-authentication connection to a server that rejected the certificate a millisecond later, and its mutual-auth probe answers “not required” for every TLS 1.3 server on earth. So the socket is read for a moment after the handshake (POST_HANDSHAKE_GRACE_MS, 750 ms): an alert, a close or the server’s first bytes all end the wait, and only silence spends the whole grace period. handshakeUsable() is what every verdict is computed from, and it is not connected.

A client certificate is sent with its chain

The page sends chainPems() — the leaf and its intermediates, leaf first — and not the leaf alone. A server verifying a client certificate has to build a path from what the client sent to an anchor it holds, so a leaf issued by an Issuing CA and presented by itself is unverifiable to a server holding only the root.

This one is worth knowing because of how it fails. Node’s TLS server answers an unverifiable client certificate by resetting the connection with no alert at all, so the far end reads as “the server refused my certificate” when what it could not do was find the issuer — and the mutual-auth probe duly reports required-and-rejected against a server that would have been perfectly happy. It was found by driving the page end to end against a real server, and it is now asserted from both sides in tests/api_tls_probe.js: the leaf alone must fail, the chain must work. Asserting only the second would pass against a server that verifies nothing.

The root is deliberately not sent: a server that does not already hold it will not trust it because we offered it.

Asking the server what it saw

Everything the pane reports so far is this end’s account of the handshake, and this end is the party that already knows what it sent. Three things exist only on the far end:

So POST /tls/connect takes an optional httpRequest: { path } and, after the handshake, writes one GET on the same socket and reports the response verbatim: status line, headers, body. The same socket is the point — a second connection is a different connection and says nothing about this certificate on this handshake. The pane has a checkbox and a path field for it (/tls/whoami by default) and renders the answer as “What the server saw”, tabulated when the body is the JSON the mock STS publishes and shown as text otherwise, because a pane that discarded an unrecognised body would be useless against every server that is not the mock.

What bounds it is narrower than the rest of the endpoint and needs no setting of its own, for the reason POST /krb5/spnego needs none: the method is GET and the path is the only thing a caller contributes. Every header is built in tls_probe.js; a path with CR, LF or whitespace in it is refused by name (ETLSBADHTTPPATH) rather than escaped, since escaping means deciding which of two things the caller meant and a newline in a request line is somebody else’s header. Connection: close is sent so the end of the response is an event rather than a guess, the body is capped by maxContentLength, and a server that answers and then holds the socket open is bounded by HTTP_RESPONSE_GRACE_MS (2000 ms, restarted on every chunk, and inside callTimeout either way).

Two things in the reader are byte-level on purpose, and both were found rather than anticipated:

The far end of all this in the suite is the mock STS, which grew two HTTPS listeners for it: 8443 asks for a client certificate and never refuses one (so it can report why something did not verify), 9443 requires one (so reaching it is itself the proof). Its client truststore starts empty and is filled at runtime over its plain HTTP port, because the CA in question is generated in a browser minutes before the connection. See docs/mock-sts.md.

usable, not connected

Because of the TLS 1.3 ordering above, “did this connection actually work” has one answer and it is not connected. The api computes it once, as usable on every report, and every caller reads that field — the page included. The page briefly re-derived it, and duly announced “Handshake completed” for a server that had already hung up.

Mutual authentication is measured, not read

Node exposes no CertificateRequest: there is no event and no property, and it is consumed inside OpenSSL. The only way to find out whether a server requires a client certificate is to try both ways, which is what mutualAuthProbe does — one handshake with the certificate and one without, reported side by side, with five possible verdicts:

verdict means
required worked with the certificate, failed without it
not-required worked both ways (it may still have asked optionally)
certificate-rejected worked without the certificate, failed with it
required-and-rejected neither worked, both reached TLS — the server wants one and refused this one, usually an untrusted issuing CA
unknown neither reached a handshake; the errors are about reaching the server

That fourth row is the case an operator hits most, and it is exactly the one a single connection cannot tell from required.

What bounds the endpoint

Same accounting as the Kerberos relay, plus one setting of its own:

The handshake is always made with rejectUnauthorized: false, and the verdict is reported rather than enforced. That looks like the wrong default and is the only useful one: the question is “what does this server present, and does it verify against the truststore I chose”, and aborting on a verification failure throws away the chain that would explain it. Node computes socket.authorized and socket.authorizationError either way, so nothing is lost — the caller gets the certificate, the alert and the verdict, and the response says authorized: false in as many words.

Note also that supplying trust anchors does not quietly add the platform roots. A chain that verifies for a reason the caller did not ask about is not an answer.

On a static deployment the pane is switched off, not left to fail

The three workflows client/static_site.js drops from the deployed sites — Kerberos, SPNEGO and LDAP — are dropped whole, and their landing cards are greyed, because on those sites every button on those pages fails at the network. This page is not that case and its card stays a live link: the certificate authority, all twenty X.509v3 extensions and the whole keystore matrix are Web Crypto and pkijs in the browser and work on idptools.com exactly as they work here. One pane cannot, for the reason this whole section gives — a browser cannot choose which client certificate to present, cannot be given a truststore, and cannot read the handshake it made — so that one pane is switched off.

disableTlsPane() in client/src/pki.js runs from onload() when appconfig.backendAvailable === false and does three things: it adds pki-pane-disabled to #pane_tls, it sets disabled on every control in #pane_tls_body, and it shows #pki_tls_unavailable, which says why.

All three matter and they fail independently, which is why theTlsPaneIsSwitchedOffWithoutAnApi() in tests/pki_page.js asserts each separately rather than checking the class and calling it done:

One thing this uncovered and it is worth knowing, because the shape recurs. show() in pki.js set an inline style.display and nothing else, while #pki_backend_notice is hidden in the markup by saml-hidden — which is display: none in saml_common.css. So the static build asked for the banner, the class kept it off the page, and nothing noticed for as long as the banner existed: the check in tests/pki_page.js read the inline style, so it agreed with the caller rather than with the user. show() now toggles both, the way saml_request.js and wstrust_tools.js always did, and every check of a shown element on this page reads getComputedStyle.

The two TLS jobs are gated per target, on PKI_TLS_AVAILABLE, the same way Kerberos and LDAP are on theirs. remote-run-tests.sh sets it false for a deployed target and true for a local dev server. api_tls_probe.js is the one that needs the gate: it is node-only and brings up its own listeners, so nothing would stop it running against idptools.com and passing — a green TLS probe line in a report about a site whose TLS pane is greyed out is the most misleading thing in it. The page job still runs, and runs the assertion above in place of the section it cannot run there.

Tests

Test Covers Skips?
tests/pki_x509.js ~240 certificates over every (issuer key, signature algorithm) pair × every subject key algorithm; every extension read back by OpenSSL; the whole set at once; all 14 profiles; a four-deep chain, whose every link also reports its own KeyUsage — the extension verifyChain() reads and a caller judges, since a leaf that may sign a document may not sign a certificate, and an absent extension permits everything while one asserting nothing permits nothing; name constraints, pathLen and an unknown critical extension actually refusing something; serials; the 2050 boundary; key identifiers; and the minimal DER encoding of an ECDSA signature’s r and s, fed in as vectors because the matrix only meets that case about one run in eight never
tests/pki_key_formats.js every one of the 41 key algorithms generated and read back (classical by the openssl binary, post-quantum by node’s OpenSSL 3.5); 11 of them × 4 formats × password on/off; a PKCS#12 carrying a whole chain; the refusals by name, including a composite key as a JWK; PEM↔JWK round trips, AKP for the post-quantum families never
tests/pki_pqc_x509.js the post-quantum half: 34 algorithm identifiers checked against a second transcription of the standards; every key’s SPKI and PKCS#8 read by OpenSSL 3.5, all three arms of the private-key CHOICE; pure certificates verified by OpenSSL; the 16 composites with both halves tampered in turn; 126 links of chains that mix classical and post-quantum issuers; the hybrid extensions, verified here and accepted by the OpenSSL 3.0 binary that knows none of it; the preTBSCertificate’s two removals; post-quantum CSRs; all fourteen profiles issued with a post-quantum key, including the key-encipherment one over ML-KEM — which is what an encryption certificate IS, and which can never be self-signed; and the refusals (a self-signed ML-KEM certificate, an algorithm mismatch, signing from a seedless key) never — except the OpenSSL cross-checks, which need node ≥ 24 and say so
tests/api_tls_probe.js the address policy on a raw socket, the port allowlist, truststore selection, the mutual-auth measurement (all five verdicts), the three deadlines, that every path settles, the ask-the-server request — the path refusals, the chunked de-framing in bytes, and a hang-up not reading as success — and the post-quantum section: the hybrid group, the ML-DSA chain, the SLH-DSA refusal node-only, so it never skips for want of a service — but PKI_TLS_AVAILABLE=false (a deployed static target) skips it, because it would pass there and mean nothing, and the three post-quantum activities skip individually on a node whose OpenSSL predates 3.5
tests/pki_page.js the hierarchy built through the form, the View certificate details hand-off read back on saml_cert.html for every key algorithm, the store surviving a reload, the private-key opt-out in both states, the serial number (shown, persisted, signed, rotated, and typed over), the subject-DN defaults and the profile subjectAltName, a tooltip on every one of the 131 controls, the validity pickers and the ISO-8601 values an older build stored, the list-field syntax, the TLS test end to end, no browser-side TLS option, the pane switched off when the build has no api — and the layout: five panes and not six, the three headings the merge left behind, the extension list’s column count measured from where the cards landed, no horizontal scroll, no control drawn outside its column, and every folded note still holding its prose and no control needs the client; without an api the TLS section is replaced by the switched-off assertion rather than skipped
tests/pki_mutual_tls.js the same page against a server that answers back: a client certificate issued through the form, presented with its chain to the mock STS’s two listeners, and the server’s own account of the connection — the chain it built, the anchor it verified against, and required told apart from required-and-rejected by trusting the CA between two otherwise identical runs. Then the same round trip with an ML-DSA-65 CA and client certificate, where the last assertion is that the server’s account names ML-DSA — without it, a silent fallback to something classical would satisfy every other check needs the client, the api and STS_TLS_URL; also gated on PKI_TLS_AVAILABLE

The first three are node-only and need openssl on the PATH (the tests image installs it); they say so rather than skipping if it is absent, because a test that quietly stops asserting is worse than one that fails.

Adding to this page