id-proto-debugger

Kerberos v5 — six pages, one codec, and a KDC that is wrong on purpose

Read this before changing anything under common/krb5/, client/src/kerberos*, api/krb5_* or the mock KDC. The plan that produced it is docs/kerberos-plan.md, which now holds only the reasoning and what is still outstanding.

This is the only workflow here that cannot run on the deployed static sites, and the reason is not a policy: Kerberos speaks DER over TCP and UDP port 88, and a browser cannot open a socket. Everything except the decoder needs api/. The Lambda@Edge trick that rescued WS-Federation and SAML (infra/CLAUDE.md) cannot rescue this one — there is no HTTP request to catch.

So none of these six pages is IN a static build, decoder included, and the landing page’s two cards for it — Kerberos and SPNEGO — are greyed out and unclickable there. SPNEGO looks like the exception and is not: its own exchange is HTTP, but the ticket it carries comes from a KDC on port 88 and the pages that obtain one are not there either, so a SPNEGO page on a static site would be a page whose only button says “no service ticket held” for ever. The list is client/static_site.js; client/build.js deletes those pages (and css/kerberos.css) from dist/, skips their bundles, strips the card’s href and fails the build if a page that still ships links to one that does not. tests/static_site_exclusions.js checks the whole arrangement with no browser, including that client/Dockerfile still builds all six — the exclusion is static-only, and the container is where the workflow runs. The decoder goes with them although it needs no network: it has no card of its own, and the only route to it is the link on kerberos.html. Give it a landing card and it can ship on its own; until then, shipping it would be shipping an unreachable page.

Against a deployed static target remote-run-tests.sh sets KERBEROS_AVAILABLE=false and run-report.js skips every Kerberos job naming that, rather than failing on a 404. That is every one of them, not just the five pages: the codec, the crypto vectors, the PAC layout, the decoder output, the codec sync, the relay and the mock-KDC exchanges are node-only and would otherwise run happily against a site that has no Kerberos — exercising local code, reporting nothing about the deployment, and (because remote-run-tests.sh sets a CONFIG_FILE that does not resolve inside the sts/ submodule) failing for a reason that names a config file. The gate was called KERBEROS_PAGES_AVAILABLE while it applied only to the pages; the old name is still read so an existing environment keeps working.

Page Does Needs
kerberos_decoder.html Decodes pasted bytes — messages, tickets, GSS tokens, keytabs, the PAC, a KRB-CRED — and opens the encrypted parts when you supply a key. nothing at runtime, but see above: it is not on the static sites either.
kerberos.html The AS exchange: turn a password into a TGT, watching both messages of the two-message dance. api + a KDC
kerberos_tgs.html The TGS exchange: spend the TGT on a service ticket. api + a KDC
kerberos_ap.html The AP exchange: present the ticket to a service, with mutual authentication and per-message tokens. api + a service
kerberos_delegation.html S4U2Self, S4U2Proxy (classic and resource-based), forwarding, renewal. api + a KDC
spnego.html The same service ticket, presented over HTTP rather than a socket: RFC 4559’s Negotiate header, RFC 4178’s negotiation around the AP-REQ, and the ticket inside it. See docs/spnego.md. api + an HTTP service

The DOM helpers, the credential cache and the relay calls are one module (client/src/kerberos_panes.js) shared by all six, for the same reason webauthn_panes.js exists: a pane that disagreed between two pages would be a bug visible only by comparing them. Two panes are modules of their own beside it and shared the same way — kerberos_tickets.js (Ticket Cache & History, on all six pages) and kerberos_keys.js (the three routes to a decryption key, used by the SPNEGO page and the decoder and by nothing else — see below), both described below.

SPNEGO is a SIBLING of the AP exchange rather than a successor, which is why it is step 5 in the trail while belonging beside step 3 in the protocol: both present a service ticket and the only difference is the transport. It is numbered last rather than inserted because renumbering the three exchanges would move ids every Kerberos test locates by. It is also the only page that ROUTES you elsewhere — it spends a ticket and cannot obtain one, so it links to the AS and TGS pages with ?return=spnego and each of those offers a link back (panes.noteReturnTarget() / renderReturnBanner(), both in the shared module). docs/spnego.md says why that is a link and never a redirect, and why the banner has to render before a ticket exists.


The furniture every page carries, and the three panes that span the workflow

The chrome is the other workflows’ chrome, deliberately: a step trail at the top (partials/krb_steps.html, each page marking its own entry through panes.markCurrentStep()), a collapse-all / expand-all toggle, and a tooltip on every field. The trail’s class is krb-trail, not krb-steps.krb-steps was already taken on kerberos.html, where it lays step 1 and step 2 out side by side, and the unscoped rule for that was reaching the trail on all six pages. Tests locate the trail by its id (krb_steps), which is why the rename cost nothing. panes.wirePanes() pairs each <legend id="x_expand_button"> with its <fieldset id="x_fieldset"> by convention rather than by a registration list, which is what lets a pane added later be clickable with nothing registered for it — and which avoids the failure the older workflows have, where the same id appears in two onclick attributes and one of them silently does nothing.

A page’s inputs live in ONE pane, and it is called Configuration Parameters. kerberos.html set that shape and kerberos_ap.html was brought to it on 2026-08-17: what were Where the service is and What to ask for — the GSS flags are now two columns of one pane_config, in a krb-grid, with the prose folded into the krb-more <details> beside them and the Present the ticket button at the foot of the pane it belongs to. Splitting parameters across panes costs a title bar, two borders and the vertical space between them for each extra pane — 213px here, which is the button moving from 1049px to 836px — and it makes the reader collapse two things to see the exchange rather than one. What the old legends said is not lost: it becomes a krb-group heading on its column, because the six checkboxes read as unexplained capitals if nothing on screen says they are the GSS flags of the 0x8003 checksum. Two traps live in that layout, and both are invisible until a column is narrow. .tooltip is bootstrap’s inline-block, so a stack of checkboxes flows and any two labels that happen to fit share a line — .krb-checks exists to stop that, and .krb-opts is the same mechanism where the flowing is wanted. And .krb-grid’s auto-fit minimum decides the column count, so a pane that looks deliberate at 1366px is the minimum biting at 1024.

Both of those strips are spent from a fixed budget, and adding them overspent it. theConfigurationAndBothControlsFitOnOneScreen() in tests/kerberos_as_page.js requires the configuration pane and both exchange buttons inside 640px at 1366x768 — the house figure, see the note in docs/’s layout entries. The trail (41px), the collapse-all row (25px) and a <div class="tooltip"> around each of fourteen field labels (~4px each) took step 1’s button from 494px to 642px and step 2’s further still. It is back inside with headroom, and the space came from margins and padding rather than from content: tighter trail/pane/legend spacing, no dotted underline under a form label, and the deletion of one krb-note on step 2 that repeated its own Password tooltip word for word. Every number here was measured in headless Chrome at that viewport, not estimated. If you add anything above those buttons, measure.

That budget now covers all six pages, and the pass that extended it found the fix for it had stopped applying. tests/kerberos_page_density.js measures every page at 1366x768 and requires the control the page exists for — the AS page’s two buttons, Request a service ticket, Present the ticket, S4U2Self’s button, Decode, and both SPNEGO round trips — inside the same 640px. It needs the site and nothing else, which is the point of it being a job of its own: the five page tests skip without a KDC, and a layout regression has nothing to do with a KDC. Two of the four biggest costs it found were rules that matched nothing rather than content:

The rest came from layout and from folding: the TGS configuration pane and the delegation page’s Where the KDC is went into krb-grid columns (one field per line had made the first 558px tall, with its own button below the fold), the AP page’s six GSS flags take two columns of a krb-span-2 cell, and the orientation prose on five pages plus the four shared panes went into krb-more folds. Nothing was cut — that is the half of this the test also checks, since deleting the explanations would make every page fit and remove the reason the workflow is worth using. Six pages came down 32–50%: 2,291→1,552, 3,113→1,572, 3,347→2,115, 3,892→2,132, 2,448→1,339 and 3,244→1,937 pixels of document. Measure before trimming — walking the container’s children printing height and top finds this in seconds, and guessing cuts prose that turns out to be cheap.

The message panes have a Decoded tab and a Hex tab, and the hex one answers a different question. Decoded says what the message says; hex says which bytes say it — the question you actually have when a KDC rejects a message you believe is correct, or when a capture disagrees with a decoder. It is on kerberos.html (request, reply, and the held ticket), kerberos_tgs.html and kerberos_ap.html (request and reply), and on the AP page the bytes shown are the whole GSS InitialContextToken, wrapper included, because that is what went to the service. The delegation page has five views of each of its three exchanges, which is the entry below.

Four things about it are worth knowing before touching it:

The delegation page shows each exchange FIVE ways, and three of them are not in the message. S4U2Self, S4U2Proxy and the forwarding pane each carry Decoded, Hex (the whole reply), Hex: Ticket, Hex: enc-part and Hex: EncTGSRepPart; the forwarding pane has a sixth for the KRB-CRED it wraps, which is the artifact of unconstrained delegation — its EncKrbCredPart opens in place, because the page generates the subkey it seals it with and hands that to the pane as well as printing it, so the bytes stay pasteable into the decoder later without needing to be. The renewal pane keeps the plain two.

The last of those views is the one worth having: it is the decrypted enc-part, named field by field — the only view in this workflow of a message that never crossed the wire in the clear. Three things about the bytes:

tests/krb5_field_naming.js holds the naming, and the assertion that carries it is name-to-bytes: slice the buffer at the range called AS-REQ → req-body → cname → name-string[0] and those bytes must decode to the principal that was encoded. A table with two fields transposed passes every other check and fails that one — which matters because a wrong name here does not crash, it says till while highlighting rtime, plausibly, and somebody reads a value off the screen that belongs to something else. Beside it: every context tag our own encoders emit must be named (the drift check between that table and krb5_messages.js, which carry the same tag numbers in different shapes), and a static half that fails when a page grows a _hex div nothing fills or a tab strip nothing wires — a hex tab that renders empty is indistinguishable from an exchange nobody has run. The page list is discovered from the pages that link css/kerberos.css rather than written out, which is how it already covers a sixth page added while it was being written.

Two things that static half has learned since, both from the delegation page’s five-button strips. A tab name must be unique within its group and every button must have a panel: selectTab() shows every panel whose name matches, so two panels called hex in one group open together, stacked, with both buttons lit — which reads as a rendering bug in the pane rather than as two attributes with the same value. And a check must be scoped to the strip it is about: with three panes carrying the same tab names, a page-wide regex for “a button called hex_encpart exists” was satisfied by a different pane’s button, so deleting one left its panel unreachable and the check green. Both are mutation-tested by deleting exactly one button. The other lesson is the plainest one: these files describe their own calls in prose, so codeOf() strips whole-line comments before anything asks whether a call is present — commenting out panes.wireTabs() left its check passing until it did.

A ticket’s contents are shown WITHOUT its key, and that is the default everywhere a ticket appears. The oldest complaint about this workflow was that the EncTicketPart said not attempted or not possible: it is encrypted at key usage 2 under the long-term key of the principal in the ticket’s own snamekrbtgt/REALM for a TGT — and a client never holds that key, so the message was true, useless, and (worse) accompanied by an instruction to go and use the decoder page. The answer is not a key at all: the KDC repeats the ticket’s contents to the client in EncKDCRepPart, encrypted under the client’s own key, precisely because the client cannot read the ticket. That part is already decrypted on every one of these pages, so the flags, the session key, both realms, both principals and all four times are available with nothing typed and nothing clicked. krb5_describe.js’s describeReportedTicketContents() renders them and describeKdcRep() feeds it, which is why this happens in the reply pane of the AS and TGS exchanges for free.

Three things about it are deliberate and must survive an edit:

kerberos.html also keeps its own copy on The Ticket pane, below the credential-cache summary, and it is fed from the stored entry rather than from the live reply — so it is identical after a reload and after Make active on a row from the Ticket Cache & History pane, neither of which has a reply in hand. That is what starttime and sessionKeyEtypeName are doing on the cache entry. reportedTicketContents() in client/src/kerberos.js is the normaliser; the describer’s reported option takes primitives (strings, Dates, a hex string) precisely so the two callers — a decrypted EncKDCRepPart there, a stored entry here — can share one renderer.

No exchange page asks for a decryption key, and that settles a question this document left open for a day. For about twenty-four hours in August 2026 a shared Decryption keys pane (partials/krb_keys.html) sat at the foot of the TGS, AP, delegation and SPNEGO pages. It was the wrong answer to the right complaint: what a reader wanted out of a ticket was already in the browser, and asking them to paste a key to see it made a debugger into a homework exercise. The pane and the partial are gone; what replaced it is that the report above is now supplied for every ticket on every page rather than only for one arriving in a reply. kerberos_panes.js’s ticketReports() hands describe() the KDC’s account of every ticket in the credential cache, keyed by the ticket’s own DER, and reportedFor() in the describer matches each ticket to its record — so the TGT three envelopes down inside a TGS-REQ’s PA-TGS-REQ, and the service ticket inside a SPNEGO mechToken, read exactly like one in a reply. heldSessionKeys() does the same for keys the workflow earned, which is what opens an Authenticator, an AP-REP and a KRB-CRED with nothing typed.

The match is on the ticket’s bytes and never on its principal, and that is load-bearing: two tickets for one service are not one ticket, and a report shown under the wrong ciphertext would be a confident lie of exactly the kind this whole section exists to avoid. tests/krb5_describe_output.js asserts the miss case as hard as the hit.

One page still collects a key, and only one: SPNEGO. The fields are back where they were before the shared pane existed — inside its Ticket pane, beside the ticket they open — because that page presents a service ticket whose keytab a reader plausibly holds, and because the one thing a key still buys anywhere is the PAC. client/src/kerberos_keys.js remains the single implementation of the three routes (raw key + etype, password + salt, keytab), shared with the decoder page, and mount() registers it with kerberos_panes.js so the key reaches every message pane on that page. Everywhere else the answer to “how do I see the PAC” is the Decoder, which is the page for bytes and a key and nothing else.

Two things are worth knowing before touching either of the panes at the bottom of these pages, because they sit one above the other, look alike, and are governed by opposite rules.

Ticket Cache & History (all six pages, partials/krb_tickets.html, rendered by client/src/kerberos_tickets.js) is a credential store. Every ticket the workflow obtains lands in it — from every page: the AS exchange’s TGTs, the TGS exchange’s service tickets, and the delegation page’s S4U2Self evidence, delegated tickets and renewals — and Make active puts one back in the live slot unchanged, so two tickets can be compared or one from before a configuration change returned to. Each row therefore holds a session key, so it obeys the same krb_save_ccache checkbox the live ticket does: sessionStorage unless ticked, and unticking purges the whole list on the spot (enforceStoragePreference()). Capped at 100, oldest dropped. It was the AS page’s pane, called Ticket History, until 2026-08-17; what forced it onto all of them is that every page both fills the list and has something to do with a row in it, so a pane on one page was a pane you had to navigate away from to use.

Which rows a page can activate is the per-page part, and the only part. The pane is one module mounted with the slots that page holds: ["TGT"] on the AS and TGS pages, ["service", "delegated"] on the AP page (a ticket from S4U2Proxy is a service ticket, and that page presents it like any other), ["TGT", "evidence"] on the delegation page, and read-only on the decoder, which holds nothing and must write nothing — there the Clear button is removed and each row instead offers its bytes to the decoder’s own input, which is a read of storage and a write to a textarea. Where an activated ticket is written comes from TICKET_SLOTS in kerberos_panes.js and from the ticket’s own kind, never from a caller: the kind is derived from the service name (krbtgt/… is a TGT) rather than taken from a label, because a service ticket accepted into the TGT slot is accepted silently and fails one page later, naming an encryption type. A row a page cannot take is named, with the page that can use it in its title — a control that is simply absent reads as a pane that failed to render. activateTicket(index, kind)’s second argument is a check, not an instruction.

The row for a ticket the workflow is holding right now says which slot has it (heldTickets()), and that is the Cache half of the title: the pane answers “what do I hold, and what did I hold” in one table.

Operations History (all six pages, partials/krb_history.html) is a log, holds no key material, and is therefore not touched by that checkbox: purging the record of what was attempted because somebody stopped storing session keys removes it exactly when it is most wanted. It is the fourth workflow over client/src/op_history.js, after SAML, WS-Trust and WS-Federation, and the log is one list shown identically on every page — a Kerberos exchange is a chain across the pages, so the useful question is never “what did this page do” but “what happened, in order, and against which KDC”.

Its User / principal column has one trap, and it caught four of the eleven rows. Every page here revives a cached credential before it can spend it, and revive() turns the stored client string into a parsed principal{ type, name: ["alice"], realm } — because that is what the builders need (buildTgsReq() reads .name; a string there encodes as a one-component name containing nothing). Hand that same object to the log and the cell reads

AP-REQ (presented to a service)   [object Object]   HTTP/web.example.com@EXAMPLE.COM at 127.0.0.1:8888

Nothing errors — [object Object] is what JavaScript is required to produce — and the row is otherwise complete and correct, so the pane looks like it is working while the one question that column answers goes unanswered. It is fixed at both ends, and both are load-bearing. The call sites pass msgs.principalToString(x.client, x.realm), because they are the only place that still has the realm: revive() splits it into its own field, so the parsed name alone renders alice beside a Target column saying EXAMPLE.COM — the quiet version of the same bug. And kerberos_history.js normalizes what it is given in begin() and note() (principalText(), exported for the delegation page’s one detail string that is built by concatenation and so cannot be reached from inside the log), because eleven call sites written months apart is a list that grows, and the failure is invisible to every check that does not read that one cell. Note what the layering buys: with the backstop in place a regressed call site can no longer produce [object Object] at all — it produces a name with no realm, which is what the delegation page test asserts against. principalText() deliberately does not require krb5_messages.js for its four-line join: tests/krb5_operation_history.js loads that module out of client/src, where a relative require of a common/krb5 module does not resolve, and adding one would fail that test with Cannot find module before it asserted anything.

How a row is closed is the one design decision in it. The obvious shape — record on the way in, update on the way out — does not survive these handlers: onS4u2Self() alone has five return false paths before its request is built, and a row nobody closes stays Sent for ever. In this pane Sent means “no answer reached this debugger”, so a forgotten update does not read as a missing log line, it reads as a broken KDC. So the close is driven from panes.status() — the one place every one of those exits already goes through, because a handler that returns without setting a status leaves the page saying “Sending…”. begin() names the status line that will report the operation, and only a status on that line closes it; the delegation page has four lines and can have four operations outstanding, so the open rows are a map. The status class is the verdict: krb-bad → Failure, krb-ok/krb-good/krb-warn → Success (a krb-warn on the delegation page is an exchange that worked and produced a ticket with an unwelcome property — recording it as a failure would say the KDC refused a request it granted), anything else leaves the row open.

tests/krb5_operation_history.js is where that is checked, and the half that matters is static: per bundle, every status line an operation is opened against must have both a success and a failure path, and every page must both include the partial and mount() it against the partial’s own ids. tests/krb5_ticket_history.js does the same for the pane above it, and one thing more: that each page mounts only the slots it holds, and that the decoder mounts read-only. Neither is checkable at runtime and neither would fail a browser test — the pane renders, the row is there, and it says Sent, which is a legitimate value; a page that includes the pane and never mounts it shows an empty div, which looks exactly like a workflow that has done nothing.

One more thing about the pane’s classes. op_history.js renders saml-* by default and these five pages do not link css/saml_common.css, so it is configured with classPrefix: "krb-op"not "krb", because it emits <prefix>-history and <prefix>-table and both of those names are already the Ticket Cache & History table’s on the same page. The three result colours are the pages’ own krb-ok / krb-bad / krb-pending, so the row cannot say something different from the status line above it.


The one architectural decision everything follows from

The protocol runs in the browser. The api is a guarded byte relay that holds no state, derives no keys and never sees a password.

  browser                                api/                    network
  common/krb5/*  ──── base64 ────►  POST /krb5/kdc  ──── TCP/UDP 88 ────►  KDC
  DER · messages · RFC 3961         address policy
  crypto · PAC · NDR · GSS          size + time caps
                 ◄─── base64 ────   TCP framing     ◄──────────────────

Two consequences worth knowing before proposing a change:


common/krb5/ — the modules, and what each is for

Module Holds
krb5_primitives.js MD4, MD5, HMAC-MD5, RC4, n-fold, UTF-16LE — the things neither Web Crypto nor Node’s OpenSSL 3 will do
krb5_crypto.js RFC 3961’s framework and etypes 17, 18, 19, 20, 23: unpadded CBC and CTS over Web Crypto, string-to-key, the key-usage table
krb5_asn1.js DER for Kerberos’s ASN.1, plus the generic TLV tree the decoder needs for bytes it cannot identify
krb5_messages.js Every message, the error catalogue with diagnostic meanings, and KRB-CRED
krb5_gss.js RFC 4121: the InitialContextToken, the 0x8003 checksum, MIC and Wrap
krb5_spnego.js RFC 4178: the negotiation around that token — NegTokenInit, NegTokenResp, the OID coder Kerberos’s own grammar never needed, and the mechListMIC. See docs/spnego.md
krb5_ndr.js Just enough NDR ([MS-RPCE]) to read and write the PAC’s logon information
krb5_pac.js [MS-PAC]: the buffer table, KERB_VALIDATION_INFO, the four signatures, S4U_DELEGATION_INFO
krb5_client.js The client half: TGS requests, AP requests, S4U, renewals, forwarding
krb5_describe.js “Here are these bytes, explained” — the decoder page’s whole content, with no DOM in it
krb5_keytab.js Keytabs, in both byte orders
krb5_ranges.js Which BYTES each ASN.1 element occupies — one absolute range per element, and the innermost owner of every byte. What the hex tab colours with
krb5_field_names.js What those elements are CALLED: RFC 4120’s field names per structure, and the four-state walk that turns [APPLICATION 10] → SEQUENCE → [4] → SEQUENCE → [1] into AS-REQ → req-body → cname

krb5_describe.js returning a plain document rather than markup is what lets tests/krb5_describe_output.js check every fact the page shows with no browser. Every value it returns is already a string: the renderer never formats and never interprets, which is what keeps hostile bytes out of anything but textContent.

Eight of these are vendored into the mock STS and the copies are byte-identical — see The mock KDC below.


The traps. Each of these has cost a debugging round, and each fails by naming something else

The salt is not the principal name, and it is not guessable. Active Directory salts a user as the realm followed by the sAMAccountName (EXAMPLE.COMalice) and a computer account as the realm, the literal host, the short name in lower case and the DNS domain (EXAMPLE.COMhostws01.example.com). An implementation that derives the salt from the principal name works until the first machine account. The KDC tells the client in PA-ETYPE-INFO2, which arrives inside the KDC_ERR_PREAUTH_REQUIRED error — so a client that treats that error as a failure cannot authenticate to AD at all.

Key usage numbers are invisible when wrong. A wrong one produces a well-formed message that fails integrity at the far end, and the error a KDC returns for it is the same one it returns for a wrong password. They are named constants in krb5_crypto.js for that reason. The ones that bite: 1 for PA-ENC-TIMESTAMP, 6 and 7 for a TGS request’s checksum and authenticator, 8 or 9 for a TGS reply depending on whether a subkey was sent (a client that always tries one fails half the time), 14 for KRB-CRED, and 17 for both PA-FOR-USER and all four PAC signatures.

The 0x8003 checksum is not a checksum. It carries channel bindings and the GSS flags, its integers are little-endian in a protocol where everything else is big-endian, and Bnd is sixteen zero bytes rather than absent when there are no bindings.

KerberosTime carries no fractional seconds. Sub-second precision lives in cusec. Comparing an AP-REP’s echoed ctime against a millisecond-precision Date accuses a correct service of not being itself — which it did, on the first run of krb5_tgs_ap.js.

NDR is not DER. Inside the PAC everything is little-endian C structs, and the logon information is NDR: referent-id pointers that are not offsets, alignment measured from the start of the stream, and FILETIME that is two ULONGs and therefore aligns to 4, not 8. krb5_ndr.js’s header lists all five rules; every one of them is a silent misread rather than an error.

The PAC’s four signatures cover four different things with two keys, and the order they are generated in is load-bearing. The server signature uses the key the ticket is encrypted under, which is what lets a service verify it alone — and on a TGT that is the krbtgt key, so a mix-up only shows up on a service ticket. The KDC signature covers the server signature’s bytes only, so altering the PAC’s contents leaves it verifying: that is CVE-2022-37967’s shape, and the extended KDC signature exists to close it.

Two flag tables share their names and not their values. [MS-SAMR]’s USER_ACCOUNT codes are what a PAC carries; LDAP’s userAccountControl bits are not (NORMAL_ACCOUNT is 0x10 in one and 0x200 in the other). And [MS-PAC]’s bit diagrams number the leftmost cell bit 0 with the rightmost as least significant, so UserFlags D is 0x20 and SE_GROUP_RESOURCE is 0x20000000.

Web Crypto cannot do most of this. No MD5, no unpadded CBC, no CTS; Node’s OpenSSL 3 has no MD4 or RC4. Hence krb5_primitives.js. And globalThis.crypto is used rather than require("crypto"), because browserify substitutes the latter with a shim that drags in elliptic — see client/CLAUDE.md.


Delegation: four mechanisms, and which attribute decides

kerberos_delegation.html puts them on one page because the interesting part is the comparison.

  What it does What authorizes it
S4U2Self A service gets a ticket to itself, naming a user who takes no part — no password, no ticket of theirs, no consent. nothing. It is not a privilege: the ticket is to yourself.
S4U2Proxy, classic That service reaches another service as the user. msDS-AllowedToDelegateTo on the front end. Only a domain admin can set it.
S4U2Proxy, resource-based Same thing. msDS-AllowedToActOnBehalfOfOtherIdentity on the back end. Whoever controls that object can set it.
Forwarding The client hands over its whole TGT in a KRB-CRED; the holder can reach anything as it. nothing, ever again. The KDC is not consulted after issue.

That inversion in the middle two rows is the entire security story of RBCD: it turns “I can write to this computer account” into “I can reach this service as anybody”. The asymmetries that make it the easier path are real and reproduced: classic needs forwardable evidence (and so needs TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION on the front end), resource-based needs neither — but does need PA-PAC-OPTIONS, without which [MS-SFU] requires KDC_ERR_BADOPTION.

Every refusal on that page is KDC_ERR_BADOPTION, whatever the cause. The error names none of them, so the page’s job is to narrow it: it lists both attributes on both accounts, the missing padata, and the evidence ticket’s forwardability. That text is the product, and tests/kerberos_delegation_page.js asserts it rather than merely asserting the request failed.

Forwardability is reported when the evidence ARRIVES, not two steps later. A missing TRUSTED_TO_AUTHENTICATE_FOR_DELEGATION lets S4U2Self succeed and returns a ticket that merely is not forwardable — so classic S4U2Proxy then complains about the evidence ticket, which is not where the problem is.

For forwarding, the one enforceable control is NOT_DELEGATED (“account is sensitive and cannot be delegated”), and it sits on the account being protected rather than on any service. The KDC withholds the forwardable flag at the AS exchange — not as an error, so the user still logs on — and re-checks at the TGS exchange, because a forwardable ticket issued before the flag was set would otherwise keep working. A service’s ok-as-delegate flag is only advice to the client; nothing enforces it.


The mock KDC, and the two things about it that are deliberate

It lives in the sts/ submodule (docs/mock-sts.md has the detail, including why five codec modules are vendored there and how tests/krb5_codec_sync.js keeps the copies honest). Two design points matter when reading its tests:

Its principals are misconfigured on purpose. locked, expired, aesonly (a hardened account offering no RC4), rc4only (the legacy account a 2025 baseline breaks), noreauth (answered in one message rather than two), a computer account for the host-shaped salt, sensitive (flagged NOT_DELEGATED), and four service accounts differing by one delegation attribute each. The whole point is producing failures deliberately, and each account’s PAC records the same misconfiguration its behaviour shows.

It answers for any SPN in a configured domain, and that is what makes the SPNEGO workflow usable. A client derives HTTP/<the URL's host>, so every way of reaching this stack asked for a name nobody had configured (HTTP/localhost, HTTP/sts, HTTP/127.0.0.1) and got KDC_ERR_S_PRINCIPAL_UNKNOWN. KRB5_SERVICE_DOMAINS now registers a service on first sight for those hosts and for anything in the realm’s own domain, the acceptor holds the key for what the KDC issued, and an SPN outside the list is still refused so the error stays reachable. docs/mock-sts.md has the design and the three things that keep it scoped; docs/spnego.md has the workflow defect it was fixing, including the test that was hiding it.

It serves TWO realms with a trust between them, which a real KDC never does. The simplification hides finding the other realm’s KDC — DNS and SRV records — and none of the protocol, and it buys the whole cross-realm referral without a second container. A trust is not a setting: it is one principal whose key both KDCs consult.


Testing

Most of the twenty-three Kerberos test files — all but the five page tests — need no browser and no services — the KDC and the protected service are started in-process on ephemeral ports — so they never skip. docs/test-suite-map.md has the per-file notes; two things about the approach are worth stating here.

A round trip proves almost nothing for a wire codec. A reader and a writer sharing one misunderstanding agree perfectly with each other and with nothing else in the world. So the assertions are byte offsets hand-derived from the specifications, structures with more than one element (an ExtraSids array defers all its pointers past the end of the array, so a reader that follows each as it goes is right for one element and wrong for two), and cross-checks where one copy of the codec encrypts and the other decrypts.

Mutation testing is the standard of evidence. Every section of the Kerberos tests was developed by breaking the thing it tests and confirming the test fails — around eighty mutations across the six phases. That is also how four real coverage gaps were found, each needing new fixture data rather than a new assertion: a realm-aware lookup that needed a client native to the second realm, a PAC domain SID nothing asserted, a delegation flag invisible where it is set, and an authtime comparison that compared a value with itself. When a mutation is not caught, confirm the mutation actually reached the source and changed behaviour before concluding the test is weak — twice it was the mutation that was inert.


What is not here

SPNEGO is now here and has its own file: docs/spnego.md. It cost what that seam promised — common/krb5/krb5_spnego.js holds RFC 4178 and nothing else, krb5_gss.js was not touched beyond a comment, and the mock’s acceptor performs every Kerberos check it already performed. What was added is two wrappers, a transport, and a sixth page.

No PKINIT, FAST, NTLM, CredSSP, kadmin or kpasswd. No DES and there never will be: Windows Server 2025 removed it, so it is decode-only, present so a capture from an old estate still renders.

No SID filtering across a trust, which is the control that stops a trusted realm asserting membership of groups in yours. Its absence is stated in the code, in docs/mock-sts.md and here rather than left as a silence.

Interoperability against a real domain controller is now testable, but is not tested on every run. Every other claim in this file rests on this project’s own KDC, and the mock was written from the same reading of RFC 4120 and [MS-PAC] as the client it checks — so the two agree by construction and a shared misreading is invisible to all of them. tests/krb5_real_dc.js is the answer: it drives a real Windows Server 2025 domain controller, standing up a single-DC forest with infra/terraform-krb5 and checking the chain end to end — a bare AS-REQ refused with PREAUTH_REQUIRED (asserting the salt is AD’s <REALM><samAccountName>, no separator, which is the first place a client and a real DC part company), an AS-REQ with PA-ENC-TIMESTAMP yielding a TGT, a TGS-REQ for a mapped SPN, the service ticket decrypted with the key Microsoft’s own ktpass wrote into a keytab (so krb5_keytab.js is parsing a real Microsoft file rather than one this project generated), the PAC inside it parsed with its server signature verified, and finally an AP-REQ built here and opened with the same key.

It has been run, and it passed. On 2026-08-16, against Windows Server 2025 Datacenter (Windows_Server-2025-English-Full-Base-2026.08.12): the salt came back as KRB5TEST.LOCALkuser, a TGT and a service ticket were issued at etype 18, the ktpass keytab opened the ticket, and the PAC parsed with no problems reported and its server signature verified with the service key. That is the strongest evidence in this file, because the PAC is the structure a mock is least likely to have got right — the NDR layout, the signature coverage and the zeroing all agree with Microsoft.

Three things the real DC taught us that the mock had not, and one is a trap.

The evidence outlives the domain controller. tests/krb5_capture_real_dc.js recorded every message of that exchange into tests/captures/windows-server-2025.json, and tests/krb5_windows_vectors.js asserts it on every ordinary run with no AWS and no network. So the expensive test’s result is a permanent regression check rather than an afternoon that happened once. What the capture does not cover: cross-realm referrals, S4U2Self/S4U2Proxy, renewals, RC4, and the KDC signature — that one needs the krbtgt key, which a service never has and this capture deliberately does not carry.

It does not run in the ordinary suite, and that is deliberate rather than an omission. The domain controller costs money — free tier caps at 1 GiB and a forest promotion needs more, so the smallest size that works is t3.medium — and nothing should start one by accident. run-report.js skips the job unless KRB5_DC_HOST names a KDC that already exists, and ./infra/krb5-test.sh is the only thing that brings one up — or ./local-run-tests.sh --krb5-real-dc, which is a thin wrapper on it and needs no docker at all, since this test loads the api’s relay modules in-process rather than talking to a running service. Either way it applies the stack, waits for the forest, runs the work and destroys everything again, with the teardown on an EXIT trap so a failed test still removes the instance. --krb5-real-dc=capture re-records the fixture instead of running the test, and =both does the test first — a capture taken from a broken run would be worse than none, because krb5_windows_vectors.js would then assert the breakage. Until that script has been run against a change, “correct” here still means “self-consistent and matching the specification as read here”.