kdc — issuing Kerberos tickets from a directory
How does a realm issue tickets from a directory? AS-REQ and TGS-REQ, over UDP and TCP, with encrypted-timestamp pre-authentication required, for the people a directory knows and the services a keytab holds. It runs under authnd.
go get github.com/go-authn/kdc@v0.4.3At v0.4.3 its go.mod requires at least go-authn/directory v0.12.0 and
gokrb5 v8.4.4: minimums, so a program that requires directory v0.14.0, the
version these pages describe, builds with v0.14.0, as authnd v0.11.0 does. The
accepting half is krb5, and a service
that only verifies tickets needs this module not at all.
Not implemented: cross-realm referrals, PKINIT, FAST, renewable or postdated tickets, and any kadmin protocol. Keys come from the directory and from a keytab, and change where they live.
Only a source holding the password can back a realm
A KDC must decrypt the client’s pre-authentication with that person’s
long-term key, so a directory.Verifier — a bind against somebody else’s
LDAP, a hash comparison — cannot serve, however well it answers is this the
right password. A person whose source holds no password is answered
KDC_ERR_NULL_KEY (null key), never the code a wrong password gets, so it
reads as a configuration to fix rather than a password to retype.
Example
A realm in memory, on loopback:
package main
import (
"crypto/rand"
"fmt"
"log"
"net"
"time"
"github.com/go-authn/directory"
"github.com/go-authn/kdc"
"github.com/jcmturner/gokrb5/v8/iana/etypeID"
"github.com/jcmturner/gokrb5/v8/keytab"
)
func main() {
// People: a source that holds PASSWORDS. A verifier cannot back a realm.
people := &directory.Static{Name: "the configuration file", People: []*directory.Identity{
directory.NewIdentity("alice", directory.WithPassword("correct horse battery staple")),
}}
// Services: the realm's own krbtgt key, and one per service.
kt := keytab.New()
for _, p := range []string{"krbtgt/EXAMPLE.ORG", "nfs/files.example.org"} {
if err := kt.AddEntry(p, "EXAMPLE.ORG", rand.Text(), time.Now(), 1, etypeID.AES256_CTS_HMAC_SHA1_96); err != nil {
log.Fatal(err)
}
}
// A keytab without krbtgt/REALM is refused before anything listens.
_, err := kdc.New(kdc.Config{Realm: "OTHER.ORG", People: people, Services: kt})
fmt.Println(err)
srv, err := kdc.New(kdc.Config{
Realm: "EXAMPLE.ORG",
People: people,
Services: kt,
Logf: log.Printf, // why each request was refused; never a key
})
if err != nil {
log.Fatal(err)
}
pc, err := net.ListenPacket("udp", "127.0.0.1:18088")
if err != nil {
log.Fatal(err)
}
ln, err := net.Listen("tcp", "127.0.0.1:18088")
if err != nil {
log.Fatal(err)
}
go srv.ServeTCP(ln)
fmt.Println("EXAMPLE.ORG answers on", pc.LocalAddr())
log.Fatal(srv.ServeUDP(pc))
}With a krb5.conf naming kdc = tcp/127.0.0.1:18088 for EXAMPLE.ORG, the
Heimdal kinit that macOS ships (1.5.1) gets a ticket-granting ticket, with
KRB5_CONFIG=krb5.conf and KRB5CCNAME=FILE:cc:
$ kinit --password-file=STDIN alice@EXAMPLE.ORG < alice.pw
$ klist
Credentials cache: FILE:cc
Principal: alice@EXAMPLE.ORG
Issued Expires Principal
Oct 10 21:54:46 2026 Oct 11 07:54:46 2026 krbtgt/EXAMPLE.ORG@EXAMPLE.ORG
$ kinit --password-file=STDIN alice@EXAMPLE.ORG < bad.pw
kinit: Password incorrect
$ kinit --password-file=STDIN bob@EXAMPLE.ORG < alice.pw
kinit: krb5_get_init_creds: Error from KDC: no such principal in this realm
$ kgetcred nfs/files.example.org@EXAMPLE.ORG
$ klist
Credentials cache: FILE:cc
Principal: alice@EXAMPLE.ORG
Issued Expires Principal
Oct 10 21:54:46 2026 Oct 11 07:54:46 2026 krbtgt/EXAMPLE.ORG@EXAMPLE.ORG
Oct 10 21:54:47 2026 Oct 11 07:54:46 2026 nfs/files.example.org@EXAMPLE.ORGThe ten hours are Config.Lifetime’s default, and the service ticket ends with
the ticket-granting ticket it was bought with. The KDC said why it refused each
request, in its own log:
kdc: the keytab has no krbtgt key for this realm: matching key not found in keytab. Looking for "krbtgt/OTHER.ORG" realm: OTHER.ORG kvno: 0 etype: 18
EXAMPLE.ORG answers on 127.0.0.1:18088
2026/10/10 21:54:46 refused krbtgt/EXAMPLE.ORG: pre-authentication required (code 25)
2026/10/10 21:54:46 refused krbtgt/EXAMPLE.ORG: pre-authentication required (code 25)
2026/10/10 21:54:46 the timestamp does not decrypt (client etype 18, our key type 18): error decrypting: integrity verification failed
2026/10/10 21:54:46 refused krbtgt/EXAMPLE.ORG: pre-authentication does not decrypt (code 24)
2026/10/10 21:54:47 refused krbtgt/EXAMPLE.ORG: no such principal in this realm (code 6)The first refusal of each exchange is the protocol working: the KRB-ERROR
that demands pre-authentication carries an ETYPE-INFO2 hint naming the
etype and the salt, and the client tries again with a timestamp.
Before v0.4.2, a till of 19700101000000Z got a ticket that had already expired
RFC 4120 §5.4.1: a till of 19700101000000Z asks for the maximum end time
the KDC’s policy permits. Heimdal’s kgetcred sends exactly that. v0.4.1
tested only for Go’s zero time, so it took the Unix epoch for a real end and
issued a service ticket that klist showed as >>>Expired<<<; MIT’s kvno,
the CI’s judge, sends a real till and never met the case. v0.4.2 issues the
longest ticket allowed, as above, and answers a till that has already passed
with KDC_ERR_NEVER_VALID instead of a ticket expired on arrival
(go-authn/kdc#19).
What it refuses, and why
At configuration (New): no realm (ErrNoRealm), no directory
(ErrNoPeople), no keytab (ErrNoServices), or a keytab without
krbtgt/REALM@REALM (ErrNoTGTKey) — rather than starting and failing at the
first kinit, where the error reaches a person who cannot fix it.
At the AS: a request without pre-authentication is answered with the
demand for it, and the person’s key is not derived until there is a timestamp
to check, so a spoofed UDP packet naming a real principal costs no key
derivation. A wrong password, a timestamp under another etype and one from an
hour ago are all answered KDC_ERR_PREAUTH_FAILED, the code a wrong password
gets; the error text names the reason, and so does Logf. A service asking for a TGT of its own is refused: services
authenticate from a keytab.
One encryption type. Tickets and keys are aes256-cts-hmac-sha1-96
(etype 18) only. A request whose etype list lacks 18 is answered
KDC_ERR_ETYPE_NOSUPP before anybody is looked up. With Logf set, the log
records both etypes, so an operator can tell a mismatched cipher from a
mistyped password.
At the TGS (RFC 4120 §3.3.2), a ticket is issued only when:
- the ticket presented is a TGT of this realm, decrypted under the
krbtgt/REALMkey the KDC names — not under the key of whatever service the ticket claims (KRB_AP_ERR_NOT_US); - the authenticator decrypts under its session key, within
MaxSkew; - its checksum is over this request’s body, as the bytes arrived, keyed with
the session key (
KRB_AP_ERR_INAPP_CKSUMwhen missing or unkeyed,KRB_AP_ERR_MODIFIEDwhen wrong); - the TGT is not past its end (
KRB_AP_ERR_TKT_EXPIRED), not before its start and not flagged invalid (KRB_AP_ERR_TKT_NYV), with no skew allowed: this KDC’s clock wrote those times; - the client is of this realm and still in the directory
(
KDC_ERR_C_PRINCIPAL_UNKNOWN).
Lifetimes. Config.Lifetime caps a ticket, ten hours when zero; a client
asking for less gets less, and a ticket bought with a TGT ends no later than
that TGT, so asking for krbtgt again cannot stretch a credential.
What an unauthenticated peer can hold: a TCP message, its length included, must arrive within 30 seconds; at most 256 TCP connections are served at once, and at most 64 datagrams are answered at once — one more is dropped, and the client resends or falls back to TCP.
What it is checked against
MIT’s own kinit and kvno, through pkgx, against a realm built in Go —
no MIT KDC is involved, only the client is borrowed.
TestMITClientGetsATGTAndAServiceTicket gets a TGT and a service ticket and
checks the key version kvno reports. The Linux test lane, the race detector
and the coverage lane set KDC_REQUIRE_JUDGE=1, and a further step fails
unless the log says were served by this realm. The tests also pin the
refusals a real client never triggers: a timestamp under the wrong key, one
from an hour ago, a three-byte cipher, a service asked for straight from an
AS-REQ, a forged service ticket at the TGS, and a realm backed by a verifier.
The coverage lane’s floor is 80%.
Reference
Created on by tannevaled
Modified on by tannevaled