Kerberos
Kerberos is a ticket-based authentication protocol from the 1980s that still runs most corporate networks. Learn the AS, TGS, and AP exchanges, the DER message format, and the AES encryption types of RFC 3962 and RFC 8009, with Python code that reproduces the MIT reference encodings and runs a full login.
Pashalis Laoutaris 40 min read
Try it
Interactive Kerberos Authentication Visualizer
🔐 Kerberos
Kerberos
Introduction
Kerberos is a protocol for proving your identity over a network without sending your password across it. It was built at MIT in the 1980s. It is named after Kerberos, or Cerberus in Latin spelling, the three-headed dog of Greek myth. The three heads stand for its three parties: a client, a server offering a service, and a trusted third party called the Key Distribution Center. It is the authentication protocol behind Microsoft Active Directory, and it is used in most large Unix and Linux deployments as well.
The core idea is a ticket: an object that a client cannot read or forge, that proves to a service who the client is. The Key Distribution Center issues tickets. A client gets one ticket to talk to the Key Distribution Center itself, and uses that to get further tickets to individual services, without ever sending its password again during the session. This guide builds a working implementation from RFC 4120, the AES encryption types of RFC 3962 and RFC 8009, and the DER message format, and checks it against the reference encodings that ship with the MIT Kerberos source code.
Table of Contents
- History and Standards
- The Three Exchanges
- Keys: Nothing Is Stored in the Clear
- DER: the Message Format
- The AS Exchange
- The TGS Exchange
- The AP Exchange
- Key Usage Numbers
- Interactive Visualizer
- A Worked Example
- Python Implementation
- Where It Stands
- Limitations
- Security Status
- FAQ
- References
History and Standards
Kerberos version 5 is specified in RFC 4120, published in 2005, which obsoletes the earlier RFC 1510. The cryptographic side is specified separately. RFC 3961 defines a framework of key derivation that lets one AES or HMAC primitive serve several purposes inside the protocol. RFC 3962 fits AES into that framework, giving the encryption types aes128-cts-hmac-sha1-96 and aes256-cts-hmac-sha1-96. RFC 8009, from 2016, adds aes128-cts-hmac-sha256-128 and aes256-cts-hmac-sha384-192, which replace the SHA-1 based HMAC with SHA-2.
The reference implementation is MIT Kerberos. Its source tree includes a file of known-answer encodings, tests/asn.1/reference_encode.out, produced by a small test program against a set of fixed sample values. This guide’s DER encoder reproduces that file exactly.
The Three Exchanges
A Kerberos login has three message exchanges.
- AS: the Authentication Service exchange. The client asks the Key Distribution Center for a ticket-granting ticket, authenticating with a key derived from its password.
- TGS: the Ticket-Granting Service exchange. The client presents the ticket-granting ticket and asks for a ticket to a specific service.
- AP: the Application exchange. The client presents the service ticket directly to the service, with no further contact with the Key Distribution Center.
Each exchange sends a request and gets back either the next ticket or a KRB-ERROR message. The three roles, client, Key Distribution Center, and service, are not symmetric. Only the Key Distribution Center can issue a ticket. A service can check a ticket using only its own long-term key, without talking to the Key Distribution Center at all.
Keys: Nothing Is Stored in the Clear
Every principal, whether a user or a service, has a long-term key. For a user, this key comes from a password with string-to-key. The function is PBKDF2 with HMAC-SHA1, salted with the realm and the principal name, run for 4,096 iterations by default (32,768 for the SHA-2 encryption types of RFC 8009). The result passes through one more step of the key-derivation function.
The long-term key is never used directly to encrypt a message. Section 5.1 of RFC 3961 defines a derivation function DK, which turns a base key and a small constant called the key usage number into a fresh key for one specific purpose. The same long-term key, derived three different ways, gives three keys: Kc for checksums, Ke for encryption, and Ki for the integrity check that goes with the encryption. A single ticket’s session key is itself used this way whenever it encrypts something. This is why compromising one derived key, in principle, does not expose the base key. It is also why the same base key can be reused safely for an authenticator, an encrypted part, and a checksum within one exchange, as long as the usage numbers differ.
Encryption itself is AES in CBC mode with ciphertext stealing, which RFC 3962 calls CBC-CS3. The ciphertext has the same length as a padded plaintext, with no block-size expansion. The last two blocks are swapped, so that no padding needs to travel on the wire. A random 16-byte confounder is prepended to the plaintext before encryption, which plays the role that a random initialization vector would in ordinary CBC. The ciphertext is followed by a truncated HMAC: 96 bits for the SHA-1 encryption types, 128 or 192 bits for the two SHA-2 types of RFC 8009.
DER: the Message Format
Every Kerberos message is a value of an ASN.1 type, encoded with the Distinguished Encoding Rules (DER). The specification writes its types with explicit tags, so every field, and every structure that is itself a message, carries its own tag wrapper in addition to the usual SEQUENCE tag. A Ticket, for instance, is tagged [APPLICATION 1] on top of its SEQUENCE, and inside that SEQUENCE every field is tagged [0], [1], [2], and so on, in the order the specification lists them.
This guide’s encoder is driven by a small table of schemas, one entry per ASN.1 type in appendix A of RFC 4120, rather than by code written separately for each message. A schema says whether a value is an integer, an octet string, a Kerberos string, a time, a bit-flag field, a sequence of some other schema, or an application-tagged wrapper around one. The same table decodes a message: it reads the outer tag, dispatches on the schema, and for a SEQUENCE walks the inner tags to find which field is which. Because the specification requires every optional field to be present or absent as a whole TLV, a decoder built this way accepts exactly the structures the specification allows.
The AS Exchange
The client’s first message, AS-REQ, names itself, names the service it wants a ticket for (the ticket-granting service itself, for a login), and sends a random nonce that it will check in the reply. Most deployments require pre-authentication: the client must prove it knows the password before the Key Distribution Center spends any effort or reveals any key material. The common form of this, PA-ENC-TIMESTAMP, is simple: the client encrypts the current time under its own long-term key and attaches the ciphertext.
If the first AS-REQ carries no pre-authentication data, the Key Distribution Center replies with KRB-ERROR, code KDC_ERR_PREAUTH_REQUIRED, and attaches an ETYPE-INFO2 structure in the error’s e-data field. This tells the client the salt and the iteration count to use for the password it is about to try. The client derives its key, encrypts a timestamp with it, and sends AS-REQ again. The Key Distribution Center decrypts the timestamp; if it does not decrypt, or if it is too far from the Key Distribution Center’s own clock, the exchange fails with KDC_ERR_PREAUTH_FAILED or KRB_AP_ERR_SKEW. If it succeeds, the Key Distribution Center knows the client holds the right key. It generates a fresh session key and returns AS-REP. This has two encrypted parts: a ticket-granting ticket under the krbtgt account’s key, which the client cannot read, and a copy of the session key and other details under the client’s own key, which it can.
The TGS Exchange
Having the ticket-granting ticket, the client can get a ticket to any service without presenting its password again. It builds TGS-REQ: the ticket-granting ticket, a fresh authenticator that proves it knows the session key from that ticket, and the body of the request, naming the service it wants.
The authenticator carries a checksum of the request body, keyed with the session key, so that the Key Distribution Center can detect if the body was changed in transit. This is the one place the specification uses a keyed checksum rather than full encryption of the body, because the body also travels unencrypted for logging and routing. The Key Distribution Center decrypts the ticket-granting ticket with its own key and recovers the session key. It checks the authenticator’s name, time, and checksum. If they are all good, it issues a service ticket in the usual way: encrypted under the long-term key of the requested service, with a fresh session key inside that is visible to the client in TGS-REP.
The AP Exchange
Now the client talks to the service directly. It sends AP-REQ: the service ticket, and a fresh authenticator encrypted under the service ticket’s session key. The service decrypts the ticket with its own long-term key, which no one else holds, recovers the session key, and uses it to decrypt the authenticator. If the authenticator’s name matches the ticket, its timestamp is close enough to the service’s own clock, and it has not been seen before, the service accepts the client’s identity.
If the client asked for mutual authentication, the service replies with AP-REP: the client’s own timestamp from the authenticator, encrypted again under the session key. Only a party that holds the session key could produce that reply, and only the real Key Distribution Center and the real service ever saw it. The client now has evidence that it is talking to the real service, not an impostor.
A service that accepts a ticket keeps a small replay cache: the client name, and the timestamp and microsecond field of every authenticator it has accepted recently. An authenticator with a name, time, and microsecond triple already in the cache is refused as a replay, which is why RFC 4120 recommends incrementing the microsecond field rather than reusing a timestamp.
Key Usage Numbers
Section 7.5.1 of RFC 4120 lists the key usage numbers that this guide implements, among others reserved for extensions:
| Number | Used for |
|---|---|
| 1 | PA-ENC-TIMESTAMP, encrypted with the client’s key |
| 2 | the ticket, encrypted with the service’s key |
| 3 | the AS-REP encrypted part, encrypted with the client’s key |
| 6 | the checksum of the TGS-REQ body, keyed with the ticket-granting session key |
| 7 | the TGS-REQ authenticator, encrypted with the ticket-granting session key |
| 8 | the TGS-REP encrypted part, encrypted with the ticket-granting session key |
| 11 | the AP-REQ authenticator, encrypted with the service session key |
| 12 | the AP-REP encrypted part, encrypted with the service session key |
A derived key is specific to one usage number. RFC 4120 also reserves 512 to 1023 for uses internal to an implementation, and 1024 to 2047 for applications. This guide uses the eight numbers above, which are all that the three exchanges need.
Interactive Visualizer
The visualizer above runs a complete login: the AS exchange with pre-authentication, the TGS exchange, and the AP exchange with mutual authentication, using aes256-cts-hmac-sha1-96.
- Seed. A word that stands in for every random choice the protocol makes (confounders and session keys). It is not part of Kerberos; it exists only so a run on this page can be checked against a fixed answer.
- Password. What the client “alice” types. The Key Distribution Center was set up with the password
hunter2for alice, so a different password here fails pre-authentication, the same way a wrong password does in real life. - Service. Two names separated by a slash, such as
host/files.example.com. The Key Distribution Center knows only the service named in the fields’ default. - Time. The Unix time that every message in the run uses.
- Damage. Try a wrong password (change the password field), replay the last message, flip a bit of the
AP-REQ, or send the ticket to a service it was not issued for, and watch which check catches it.
The log shows every message of the exchange with its size, and the final outcome: mutual authentication succeeded, or the error code and name that stopped it.
A Worked Example
With the seed page-default, the client “alice” with password hunter2, and a fixed Unix time, the first AS-REQ is sent with no pre-authentication data and is 131 bytes, starting with 6a8180307ea103020105a20302010aa4723070a007030500.
The Key Distribution Center answers with KRB-ERROR, code 25 (KDC_ERR_PREAUTH_REQUIRED), 159 bytes. Alice derives her key from the password and the salt the error carries:
5bd1dd046d7970a661907e5f95f0df75a05a5af7abe7a3c7abc20e020a3dd25a
With this key, alice encrypts a timestamp and sends AS-REQ again, 212 bytes. This time the Key Distribution Center succeeds and replies with AS-REP, 588 bytes. Inside it, encrypted under alice’s key, is the session key of the new ticket-granting ticket:
83491c11b4ed3b9b294b33322140186de9bf5a910c18e25f9290de4817c5b791
and the ticket itself, whose flags are 0x00600000 (forwardable, initial, pre-authenticated) and which is good until Unix time 1800036000. The ticket’s own encrypted part, readable only by the krbtgt account, starts with d304e9aa39e58b6aad63d998bf29bffe.
Alice now asks for a ticket to host/files.example.com. The TGS-REQ is 587 bytes and the TGS-REP that comes back is 591 bytes, carrying a new session key for this one service:
8fa96e6776213e1080d42469f41334115228202dc50177f1034d655d5004131a
Finally, alice sends AP-REQ (422 bytes) to the file server, which replies with AP-REP (84 bytes) because mutual authentication was requested. Checking that reply against the timestamp alice sent, the mutual authentication is confirmed: yes.
Python Implementation
The implementation has three files. It deliberately leaves out the PAC, address checks, cross-realm trust, renewal and forwarding, FAST and the other padata types, real randomness, and constant-time cryptography. The Limitations section lists these in full. The first file is the AES encryption types: AES itself (both 128-bit and 256-bit keys), ciphertext stealing, n-fold, the two key-derivation functions (RFC 3961’s DK and RFC 8009’s KDF-HMAC-SHA2), PBKDF2-based string-to-key, and the encryption and checksum profile.
# krb_crypto.py
# The Kerberos encryption types that use AES, written from the specifications:
# aes128-cts-hmac-sha1-96 and aes256-cts-hmac-sha1-96 (RFC 3962, with the simplified profile of RFC 3961), and
# aes128-cts-hmac-sha256-128 and aes256-cts-hmac-sha384-192 (RFC 8009).
# It holds AES with both directions, CBC with ciphertext stealing (CBC-CS3), n-fold, the two key derivation functions,
# PBKDF2, the string-to-key functions, and the encryption and checksum profiles.
# Teaching code. It is not constant time. The confounder comes from the caller so that the vectors can be tested.
import hashlib
import hmac as _hmac
# ---------------------------------------------------------------- AES with 128-bit and 256-bit keys (FIPS 197)
def _xtime(a):
a <<= 1
return (a ^ 0x11B) & 0xFF if a & 0x100 else a
def _gmul(a, b):
result = 0
while b:
if b & 1:
result ^= a
a, b = _xtime(a), b >> 1
return result
def _make_sbox():
sbox = []
for x in range(256):
inv = next((y for y in range(1, 256) if _gmul(x, y) == 1), 0)
value = inv
for shift in range(1, 5):
value ^= ((inv << shift) | (inv >> (8 - shift))) & 0xFF
sbox.append(value ^ 0x63)
return sbox
SBOX = _make_sbox()
INV_SBOX = [0] * 256
for _i, _v in enumerate(SBOX):
INV_SBOX[_v] = _i
def expand_key(key):
nk = len(key) // 4
rounds = nk + 6
words = [list(key[4 * i:4 * i + 4]) for i in range(nk)]
rcon = 1
for i in range(nk, 4 * (rounds + 1)):
word = list(words[i - 1])
if i % nk == 0:
word = [SBOX[b] for b in word[1:] + word[:1]]
word[0] ^= rcon
rcon = _xtime(rcon)
elif nk > 6 and i % nk == 4:
word = [SBOX[b] for b in word]
words.append([a ^ b for a, b in zip(words[i - nk], word)])
return [sum(words[4 * r:4 * r + 4], []) for r in range(rounds + 1)]
def _mix_column(a, matrix):
return [_gmul(a[0], matrix[i][0]) ^ _gmul(a[1], matrix[i][1]) ^ _gmul(a[2], matrix[i][2]) ^ _gmul(a[3], matrix[i][3]) for i in range(4)]
MIX = [[2, 3, 1, 1], [1, 2, 3, 1], [1, 1, 2, 3], [3, 1, 1, 2]]
INV_MIX = [[14, 11, 13, 9], [9, 14, 11, 13], [13, 9, 14, 11], [11, 13, 9, 14]]
def encrypt_block(round_keys, block):
rounds = len(round_keys) - 1
state = [b ^ k for b, k in zip(block, round_keys[0])]
for rnd in range(1, rounds + 1):
state = [SBOX[b] for b in state]
state = [state[4 * ((c + r) % 4) + r] for c in range(4) for r in range(4)] # ShiftRows
if rnd < rounds:
state = sum((_mix_column(state[4 * c:4 * c + 4], MIX) for c in range(4)), [])
state = [b ^ k for b, k in zip(state, round_keys[rnd])]
return bytes(state)
def decrypt_block(round_keys, block):
rounds = len(round_keys) - 1
state = [b ^ k for b, k in zip(block, round_keys[rounds])]
for rnd in range(rounds - 1, -1, -1):
state = [state[4 * ((c - r) % 4) + r] for c in range(4) for r in range(4)] # InvShiftRows
state = [INV_SBOX[b] for b in state]
state = [b ^ k for b, k in zip(state, round_keys[rnd])]
if rnd > 0:
state = sum((_mix_column(state[4 * c:4 * c + 4], INV_MIX) for c in range(4)), [])
return bytes(state)
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
# ---------------------------------------------------------------- CBC with ciphertext stealing, RFC 3962 section 5 (CBC-CS3)
def cts_encrypt(key, data, iv=bytes(16)):
rk = expand_key(key)
if len(data) < 16:
data = data.ljust(16, b"\x00")
if len(data) == 16:
return encrypt_block(rk, data)
blocks = [data[i:i + 16] for i in range(0, len(data), 16)]
last = blocks[-1]
r = len(last)
blocks[-1] = last.ljust(16, b"\x00")
out, previous = [], iv
for block in blocks[:-1]: # plain CBC up to the penultimate block
previous = encrypt_block(rk, xor(block, previous))
out.append(previous)
x = out.pop() # the encryption of the penultimate block
y = encrypt_block(rk, xor(blocks[-1], x))
return b"".join(out) + y + x[:r]
def cts_decrypt(key, data, iv=bytes(16)):
rk = expand_key(key)
if len(data) == 16:
return decrypt_block(rk, data)
assert len(data) > 16
n_full = (len(data) - 1) // 16 # blocks before the final partial one, counting the stolen pair as one block
r = len(data) - 16 * n_full
head, y, x_part = data[:16 * (n_full - 1)], data[16 * (n_full - 1):16 * n_full], data[16 * n_full:]
out, previous = [], iv
for i in range(0, len(head), 16):
block = head[i:i + 16]
out.append(xor(decrypt_block(rk, block), previous))
previous = block
dy = decrypt_block(rk, y)
x = x_part + dy[r:] # the stolen bytes are the tail of D(Y) because the padding was zero
last = xor(dy, x.ljust(16, b"\x00"))[:r]
penultimate = xor(decrypt_block(rk, x), previous)
return b"".join(out) + penultimate + last
# ---------------------------------------------------------------- n-fold, RFC 3961 section 5.1 (from Blumenthal 1996)
def nfold(data, nbytes):
inbits, nbits = len(data) * 8, nbytes * 8
from math import gcd
lcm = inbits * nbits // gcd(inbits, nbits)
x = int.from_bytes(data, "big")
mask = (1 << inbits) - 1
def rotate_right(value, n):
n %= inbits
return ((value >> n) | (value << (inbits - n))) & mask
total = 0
pieces = []
for i in range(lcm // inbits): # replicate the input, rotating by 13 bits before each copy
pieces.append(rotate_right(x, 13 * i))
stream = 0
for piece in pieces:
stream = (stream << inbits) | piece
chunk_mask = (1 << nbits) - 1
for i in range(lcm // nbits): # add the n-bit chunks with end-around carry
total += (stream >> (nbits * (lcm // nbits - 1 - i))) & chunk_mask
while total >> nbits:
total = (total & chunk_mask) + (total >> nbits)
return total.to_bytes(nbytes, "big")
# ---------------------------------------------------------------- PBKDF2 (RFC 8018) and HMAC
def hmac(hash_name, key, data):
return _hmac.new(key, data, hash_name).digest()
def pbkdf2(hash_name, password, salt, iterations, length):
out = b""
block = 1
while len(out) < length:
u = hmac(hash_name, password, salt + block.to_bytes(4, "big"))
t = int.from_bytes(u, "big")
for _ in range(iterations - 1):
u = hmac(hash_name, password, u)
t ^= int.from_bytes(u, "big")
out += t.to_bytes(len(u), "big")
block += 1
return out[:length]
# ---------------------------------------------------------------- the four encryption types
class EncType:
def __init__(self, number, name, key_len, sha2_hash=None):
self.number, self.name, self.key_len, self.hash = number, name, key_len, sha2_hash
self.sha2 = sha2_hash is not None
self.mac_hash = sha2_hash or "sha1"
self.mac_len = {"sha1": 12, "sha256": 16, "sha384": 24}[self.mac_hash]
self.default_iterations = 32768 if self.sha2 else 4096
# ---- key derivation
def dr(self, key, constant):
# RFC 3961 section 5.1: encrypt the n-folded constant again and again, and keep the first key_len bytes
rk = expand_key(key)
block = nfold(constant, 16)
out = b""
while len(out) < self.key_len:
block = encrypt_block(rk, block)
out += block
return out[:self.key_len]
def kdf_hmac_sha2(self, key, label, context, bits):
# RFC 8009 section 3, the counter mode of SP 800-108 with one iteration
return hmac(self.hash, key, (1).to_bytes(4, "big") + label + b"\x00" + context + bits.to_bytes(4, "big"))[:bits // 8]
def derive(self, base_key, usage, kind):
constant = usage.to_bytes(4, "big") + bytes([kind])
if not self.sha2:
return self.dr(base_key, constant)
bits = {0x99: self.mac_len * 8, 0xAA: self.key_len * 8, 0x55: self.mac_len * 8}[kind]
return self.kdf_hmac_sha2(base_key, constant, b"", bits)
# ---- string-to-key
def string_to_key(self, password, salt, iterations=None):
iterations = iterations or self.default_iterations
if not self.sha2:
tkey = pbkdf2("sha1", password, salt, iterations, self.key_len)
return self.dr(tkey, b"kerberos")
saltp = self.name.encode() + b"\x00" + salt
tkey = pbkdf2(self.hash, password, saltp, iterations, self.key_len)
return self.kdf_hmac_sha2(tkey, b"kerberos", b"", self.key_len * 8)
# ---- the cryptosystem and the checksum
def encrypt(self, base_key, usage, plaintext, confounder, iv=bytes(16)):
ke, ki = self.derive(base_key, usage, 0xAA), self.derive(base_key, usage, 0x55)
if not self.sha2:
body = confounder + plaintext
return cts_encrypt(ke, body, iv) + hmac("sha1", ki, body)[:self.mac_len]
c = cts_encrypt(ke, confounder + plaintext, iv)
return c + hmac(self.hash, ki, iv + c)[:self.mac_len]
def decrypt(self, base_key, usage, ciphertext, iv=bytes(16)):
ke, ki = self.derive(base_key, usage, 0xAA), self.derive(base_key, usage, 0x55)
c, h = ciphertext[:-self.mac_len], ciphertext[-self.mac_len:]
if self.sha2:
if not _hmac.compare_digest(hmac(self.hash, ki, iv + c)[:self.mac_len], h):
raise ValueError("integrity check failed")
return cts_decrypt(ke, c, iv)[16:]
body = cts_decrypt(ke, c, iv)
if not _hmac.compare_digest(hmac("sha1", ki, body)[:self.mac_len], h):
raise ValueError("integrity check failed")
return body[16:]
def prf(self, key, data):
# RFC 8009 section 5; the SHA-1 types use a different construction that I do not implement
assert self.sha2
return self.kdf_hmac_sha2(key, b"prf", data, hashlib.new(self.hash).digest_size * 8)
def checksum(self, base_key, usage, data):
return hmac(self.mac_hash, self.derive(base_key, usage, 0x99), data)[:self.mac_len]
AES128_SHA1 = EncType(17, "aes128-cts-hmac-sha1-96", 16)
AES256_SHA1 = EncType(18, "aes256-cts-hmac-sha1-96", 32)
AES128_SHA2 = EncType(19, "aes128-cts-hmac-sha256-128", 16, "sha256")
AES256_SHA2 = EncType(20, "aes256-cts-hmac-sha384-192", 32, "sha384")
ENCTYPES = {e.number: e for e in (AES128_SHA1, AES256_SHA1, AES128_SHA2, AES256_SHA2)}
The second file is the DER encoder and decoder, driven by the schema table described above.
# krb_asn1.py
# DER encoding and decoding of the Kerberos messages, driven by a table that follows the ASN.1 module in appendix A of
# RFC 4120 ("DEFINITIONS EXPLICIT TAGS": every context tag [n] adds a wrapper of its own).
# A schema is a tuple: ("int",), ("octets",), ("gstr",), ("time",), ("flags",), ("seq", fields), ("seqof", schema),
# or ("app", number, schema). A field is (name, tag, schema, optional).
# Teaching code. It handles the types that Kerberos uses, not ASN.1 in general.
import calendar
import time as _time
INT, OCTETS, GSTR, TIME, FLAGS = ("int",), ("octets",), ("gstr",), ("time",), ("flags",)
def Seq(*fields):
return ("seq", fields)
def F(name, tag, schema, optional=False):
return (name, tag, schema, optional)
def SeqOf(schema):
return ("seqof", schema)
def App(number, schema):
return ("app", number, schema)
# ---------------------------------------------------------------- the module of RFC 4120, appendix A
PRINCIPAL = Seq(F("name_type", 0, INT), F("name_string", 1, SeqOf(GSTR)))
HOST_ADDRESS = Seq(F("addr_type", 0, INT), F("address", 1, OCTETS))
HOST_ADDRESSES = SeqOf(HOST_ADDRESS)
AUTH_DATA = SeqOf(Seq(F("ad_type", 0, INT), F("ad_data", 1, OCTETS)))
PA_DATA = Seq(F("padata_type", 1, INT), F("padata_value", 2, OCTETS))
ENCRYPTED_DATA = Seq(F("etype", 0, INT), F("kvno", 1, INT, True), F("cipher", 2, OCTETS))
ENCRYPTION_KEY = Seq(F("keytype", 0, INT), F("keyvalue", 1, OCTETS))
CHECKSUM = Seq(F("cksumtype", 0, INT), F("checksum", 1, OCTETS))
TICKET = App(1, Seq(F("tkt_vno", 0, INT), F("realm", 1, GSTR), F("sname", 2, PRINCIPAL), F("enc_part", 3, ENCRYPTED_DATA)))
ENC_TICKET_PART = App(3, Seq(
F("flags", 0, FLAGS), F("key", 1, ENCRYPTION_KEY), F("crealm", 2, GSTR), F("cname", 3, PRINCIPAL),
F("transited", 4, Seq(F("tr_type", 0, INT), F("contents", 1, OCTETS))), F("authtime", 5, TIME), F("starttime", 6, TIME, True),
F("endtime", 7, TIME), F("renew_till", 8, TIME, True), F("caddr", 9, HOST_ADDRESSES, True), F("authorization_data", 10, AUTH_DATA, True)))
KDC_REQ_BODY = Seq(
F("kdc_options", 0, FLAGS), F("cname", 1, PRINCIPAL, True), F("realm", 2, GSTR), F("sname", 3, PRINCIPAL, True), F("from", 4, TIME, True),
F("till", 5, TIME), F("rtime", 6, TIME, True), F("nonce", 7, INT), F("etype", 8, SeqOf(INT)), F("addresses", 9, HOST_ADDRESSES, True),
F("enc_authorization_data", 10, ENCRYPTED_DATA, True), F("additional_tickets", 11, SeqOf(TICKET), True))
def kdc_req(number):
return App(number, Seq(F("pvno", 1, INT), F("msg_type", 2, INT), F("padata", 3, SeqOf(PA_DATA), True), F("req_body", 4, KDC_REQ_BODY)))
AS_REQ, TGS_REQ = kdc_req(10), kdc_req(12)
def kdc_rep(number):
return App(number, Seq(F("pvno", 0, INT), F("msg_type", 1, INT), F("padata", 2, SeqOf(PA_DATA), True), F("crealm", 3, GSTR),
F("cname", 4, PRINCIPAL), F("ticket", 5, TICKET), F("enc_part", 6, ENCRYPTED_DATA)))
AS_REP, TGS_REP = kdc_rep(11), kdc_rep(13)
LAST_REQ = SeqOf(Seq(F("lr_type", 0, INT), F("lr_value", 1, TIME)))
def enc_kdc_rep_part(number):
return App(number, Seq(
F("key", 0, ENCRYPTION_KEY), F("last_req", 1, LAST_REQ), F("nonce", 2, INT), F("key_expiration", 3, TIME, True), F("flags", 4, FLAGS),
F("authtime", 5, TIME), F("starttime", 6, TIME, True), F("endtime", 7, TIME), F("renew_till", 8, TIME, True), F("srealm", 9, GSTR),
F("sname", 10, PRINCIPAL), F("caddr", 11, HOST_ADDRESSES, True)))
ENC_AS_REP_PART, ENC_TGS_REP_PART = enc_kdc_rep_part(25), enc_kdc_rep_part(26)
AP_REQ = App(14, Seq(F("pvno", 0, INT), F("msg_type", 1, INT), F("ap_options", 2, FLAGS), F("ticket", 3, TICKET), F("authenticator", 4, ENCRYPTED_DATA)))
AUTHENTICATOR = App(2, Seq(
F("authenticator_vno", 0, INT), F("crealm", 1, GSTR), F("cname", 2, PRINCIPAL), F("cksum", 3, CHECKSUM, True), F("cusec", 4, INT),
F("ctime", 5, TIME), F("subkey", 6, ENCRYPTION_KEY, True), F("seq_number", 7, INT, True), F("authorization_data", 8, AUTH_DATA, True)))
AP_REP = App(15, Seq(F("pvno", 0, INT), F("msg_type", 1, INT), F("enc_part", 2, ENCRYPTED_DATA)))
ENC_AP_REP_PART = App(27, Seq(F("ctime", 0, TIME), F("cusec", 1, INT), F("subkey", 2, ENCRYPTION_KEY, True), F("seq_number", 3, INT, True)))
KRB_ERROR = App(30, Seq(
F("pvno", 0, INT), F("msg_type", 1, INT), F("ctime", 2, TIME, True), F("cusec", 3, INT, True), F("stime", 4, TIME), F("susec", 5, INT),
F("error_code", 6, INT), F("crealm", 7, GSTR, True), F("cname", 8, PRINCIPAL, True), F("realm", 9, GSTR), F("sname", 10, PRINCIPAL),
F("e_text", 11, GSTR, True), F("e_data", 12, OCTETS, True)))
PA_ENC_TS_ENC = Seq(F("patimestamp", 0, TIME), F("pausec", 1, INT, True))
ETYPE_INFO2 = SeqOf(Seq(F("etype", 0, INT), F("salt", 1, GSTR, True), F("s2kparams", 2, OCTETS, True)))
METHOD_DATA = SeqOf(PA_DATA)
# ---------------------------------------------------------------- DER primitives
def der_length(n):
if n < 128:
return bytes([n])
body = n.to_bytes((n.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(body)]) + body
def tlv(tag, content):
return bytes([tag]) + der_length(len(content)) + content
def der_int(n):
length = ((n if n >= 0 else ~n).bit_length() + 8) // 8 # one more bit for the sign
return n.to_bytes(length, "big", signed=True)
def format_time(seconds):
return _time.strftime("%Y%m%d%H%M%SZ", _time.gmtime(seconds)).encode()
def parse_time(text):
return calendar.timegm(_time.strptime(text.decode(), "%Y%m%d%H%M%SZ"))
def encode(schema, value):
kind = schema[0]
if kind == "int":
return tlv(0x02, der_int(value))
if kind == "octets":
return tlv(0x04, value)
if kind == "gstr":
return tlv(0x1B, value.encode() if isinstance(value, str) else value)
if kind == "time":
return tlv(0x18, format_time(value))
if kind == "flags":
return tlv(0x03, b"\x00" + value.to_bytes(4, "big")) # 32 bits, no unused bits
if kind == "seqof":
return tlv(0x30, b"".join(encode(schema[1], item) for item in value))
if kind == "app":
inner = encode(schema[2], value)
return tlv(0x60 | schema[1], inner)
body = b""
for name, tag, sub, optional in schema[1]:
item = value.get(name)
if item is None:
assert optional, "missing field " + name
continue
body += tlv(0xA0 | tag, encode(sub, item))
return tlv(0x30, body)
def read_tlv(data, pos=0):
tag = data[pos]
length = data[pos + 1]
pos += 2
if length & 0x80:
count = length & 0x7F
length = int.from_bytes(data[pos:pos + count], "big")
pos += count
return tag, data[pos:pos + length], pos + length
def decode(schema, data):
tag, content, end = read_tlv(data)
assert end == len(data), "trailing bytes"
return _decode_content(schema, tag, content)
def _decode_content(schema, tag, content):
kind = schema[0]
if kind == "int":
assert tag == 0x02
return int.from_bytes(content, "big", signed=True)
if kind == "octets":
assert tag == 0x04
return content
if kind == "gstr":
assert tag == 0x1B
return content.decode()
if kind == "time":
assert tag == 0x18
return parse_time(content)
if kind == "flags":
assert tag == 0x03 and content[0] == 0
return int.from_bytes(content[1:5], "big")
if kind == "seqof":
assert tag == 0x30
items, pos = [], 0
while pos < len(content):
t, c, pos = read_tlv(content, pos)
items.append(_decode_content(schema[1], t, c))
return items
if kind == "app":
assert tag == (0x60 | schema[1]), "wrong application tag %02x" % tag
t, c, end = read_tlv(content)
assert end == len(content)
return _decode_content(schema[2], t, c)
assert tag == 0x30
out, pos = {}, 0
fields = {f[1]: f for f in schema[1]}
while pos < len(content):
t, c, pos = read_tlv(content, pos)
name, _, sub, _ = fields[t & 0x1F]
inner_tag, inner, end = read_tlv(c)
assert end == len(c)
out[name] = _decode_content(sub, inner_tag, inner)
for name, _, _, optional in schema[1]:
assert optional or name in out, "missing field " + name
return out
The third file is the protocol itself: the Key Distribution Center, a client, an application service, the three exchanges, and the errors they can produce.
# krb_proto.py
# The three Kerberos 5 exchanges of RFC 4120 with real messages: the Authentication Service exchange (AS), the Ticket-Granting
# Service exchange (TGS), and the Client/Server exchange (AP). The messages are DER encoded with krb_asn1.py, and the
# encrypted parts use the AES encryption types of krb_crypto.py with the key usage numbers of section 7.5.1.
# It models one realm with a KDC, clients, and a service. It leaves out the PAC, addresses, renewal, forwarding, cross-realm
# trust, and the other padata types.
# Teaching code. Randomness and the clock come from the caller.
import krb_asn1 as asn
import krb_crypto as kc
NT_PRINCIPAL, NT_SRV_INST = 1, 2
PA_TGS_REQ, PA_ENC_TIMESTAMP, PA_ETYPE_INFO2 = 1, 2, 19
SKEW = 300 # the allowable clock skew, 5 minutes
MAX_LIFE = 10 * 3600
KDC_ERR_C_PRINCIPAL_UNKNOWN, KDC_ERR_S_PRINCIPAL_UNKNOWN = 6, 7
KDC_ERR_ETYPE_NOSUPP, KDC_ERR_PREAUTH_FAILED, KDC_ERR_PREAUTH_REQUIRED = 14, 24, 25
KRB_AP_ERR_BAD_INTEGRITY, KRB_AP_ERR_TKT_EXPIRED, KRB_AP_ERR_TKT_NYV, KRB_AP_ERR_REPEAT = 31, 32, 33, 34
KRB_AP_ERR_BADMATCH, KRB_AP_ERR_SKEW = 36, 37
ERROR_NAMES = {6: "KDC_ERR_C_PRINCIPAL_UNKNOWN", 7: "KDC_ERR_S_PRINCIPAL_UNKNOWN", 14: "KDC_ERR_ETYPE_NOSUPP", 24: "KDC_ERR_PREAUTH_FAILED",
25: "KDC_ERR_PREAUTH_REQUIRED", 31: "KRB_AP_ERR_BAD_INTEGRITY", 32: "KRB_AP_ERR_TKT_EXPIRED", 33: "KRB_AP_ERR_TKT_NYV",
34: "KRB_AP_ERR_REPEAT", 36: "KRB_AP_ERR_BADMATCH", 37: "KRB_AP_ERR_SKEW"}
CHECKSUM_TYPES = {17: 15, 18: 16, 19: 19, 20: 20} # hmac-sha1-96-aes128, hmac-sha1-96-aes256, hmac-sha256-128-aes128, hmac-sha384-192-aes256
# key usage numbers, RFC 4120 section 7.5.1
KU_PA_ENC_TIMESTAMP, KU_TICKET, KU_AS_REP, KU_TGS_CKSUM, KU_TGS_AUTH, KU_TGS_REP, KU_AP_AUTH, KU_AP_REP = 1, 2, 3, 6, 7, 8, 11, 12
class KerberosError(Exception):
def __init__(self, code, message=""):
super().__init__("%s (%d) %s" % (ERROR_NAMES.get(code, "error"), code, message))
self.code = code
def flag(bit):
return 1 << (31 - bit)
FORWARDABLE, RENEWABLE, INITIAL, PRE_AUTHENTICATED = flag(1), flag(8), flag(9), flag(10)
MUTUAL_REQUIRED = flag(2)
def principal(*components, name_type=NT_PRINCIPAL):
return {"name_type": name_type, "name_string": list(components)}
def default_salt(realm, components):
# RFC 4120 section 4: the realm followed by the name components, with no separator
return realm + "".join(components)
def error_message(code, now, realm, server, ctime=None, cusec=None, client=None, edata=None, text=None):
return asn.encode(asn.KRB_ERROR, {"pvno": 5, "msg_type": 30, "ctime": ctime, "cusec": cusec, "stime": now, "susec": 0, "error_code": code,
"crealm": realm if client else None, "cname": client, "realm": realm, "sname": server, "e_text": text, "e_data": edata})
def encrypted_data(etype_obj, key, usage, plaintext, rng, kvno=None):
cipher = etype_obj.encrypt(key, usage, plaintext, rng.randbytes(16))
return {"etype": etype_obj.number, "kvno": kvno, "cipher": cipher}
def decrypt_data(enc, key, usage):
try:
return kc.ENCTYPES[enc["etype"]].decrypt(key, usage, enc["cipher"])
except ValueError:
raise KerberosError(KRB_AP_ERR_BAD_INTEGRITY, "the integrity check of the encrypted part failed")
class Entry:
def __init__(self, realm, components, etype, password=None, key=None, require_preauth=True, iterations=None, salt=None):
self.realm, self.components, self.etype = realm, tuple(components), kc.ENCTYPES[etype]
self.salt = salt if salt is not None else default_salt(realm, components)
self.iterations = iterations or self.etype.default_iterations
self.key = key or self.etype.string_to_key(password.encode(), self.salt.encode(), self.iterations)
self.kvno = 1
self.require_preauth = require_preauth
class KDC:
def __init__(self, realm, rng):
self.realm, self.rng, self.db, self.replay = realm, rng, {}, set()
def add(self, entry):
self.db[entry.components] = entry
return entry
def random_key(self, etype):
return self.rng.randbytes(etype.key_len)
def error(self, code, now, server, **kw):
return error_message(code, now, self.realm, server, **kw)
# ---------------------------------------------------------------- the AS exchange, section 3.1
def handle_as_req(self, raw, now):
req = asn.decode(asn.AS_REQ, raw)
body = req["req_body"]
cname, sname = body["cname"], body["sname"]
client = self.db.get(tuple(cname["name_string"]))
server = self.db.get(tuple(sname["name_string"]))
if client is None:
return self.error(KDC_ERR_C_PRINCIPAL_UNKNOWN, now, sname)
if server is None:
return self.error(KDC_ERR_S_PRINCIPAL_UNKNOWN, now, sname)
if client.etype.number not in body["etype"]:
return self.error(KDC_ERR_ETYPE_NOSUPP, now, sname)
pre_authenticated = False
if client.require_preauth:
padata = {p["padata_type"]: p["padata_value"] for p in req.get("padata") or []}
if PA_ENC_TIMESTAMP not in padata:
hint = asn.encode(asn.ETYPE_INFO2, [{"etype": client.etype.number, "salt": client.salt,
"s2kparams": client.iterations.to_bytes(4, "big")}])
edata = asn.encode(asn.METHOD_DATA, [{"padata_type": PA_ETYPE_INFO2, "padata_value": hint},
{"padata_type": PA_ENC_TIMESTAMP, "padata_value": b""}])
return self.error(KDC_ERR_PREAUTH_REQUIRED, now, sname, edata=edata)
try:
enc = asn.decode(asn.ENCRYPTED_DATA, padata[PA_ENC_TIMESTAMP])
stamp = asn.decode(asn.PA_ENC_TS_ENC, decrypt_data(enc, client.key, KU_PA_ENC_TIMESTAMP))
except KerberosError:
return self.error(KDC_ERR_PREAUTH_FAILED, now, sname)
if abs(stamp["patimestamp"] - now) > SKEW:
return self.error(KRB_AP_ERR_SKEW, now, sname)
pre_authenticated = True
return self.issue(AS_REP_KIND, body, cname, server, client.etype, client.etype, client.key, KU_AS_REP, client.kvno, now,
INITIAL | (PRE_AUTHENTICATED if pre_authenticated else 0))
def issue(self, kind, body, cname, server, session_et, reply_et, reply_key, reply_usage, reply_kvno, now, flags, authtime=None, tgt_end=None):
session = self.random_key(session_et)
endtime = min(body["till"], now + MAX_LIFE, tgt_end if tgt_end else now + MAX_LIFE)
authtime = authtime or now
key = {"keytype": session_et.number, "keyvalue": session}
tkt_part = {"flags": flags, "key": key, "crealm": self.realm, "cname": cname, "transited": {"tr_type": 1, "contents": b""},
"authtime": authtime, "starttime": now, "endtime": endtime}
sname = principal(*server.components, name_type=NT_SRV_INST if len(server.components) > 1 else NT_PRINCIPAL)
ticket = {"tkt_vno": 5, "realm": self.realm, "sname": sname,
"enc_part": encrypted_data(server.etype, server.key, KU_TICKET, asn.encode(asn.ENC_TICKET_PART, tkt_part), self.rng, server.kvno)}
rep_part = {"key": key, "last_req": [{"lr_type": 0, "lr_value": now}], "nonce": body["nonce"], "flags": flags, "authtime": authtime,
"starttime": now, "endtime": endtime, "srealm": self.realm, "sname": sname}
as_kind = kind == AS_REP_KIND
rep = {"pvno": 5, "msg_type": 11 if as_kind else 13, "crealm": self.realm, "cname": cname, "ticket": ticket,
"enc_part": encrypted_data(reply_et, reply_key, reply_usage, asn.encode(asn.ENC_AS_REP_PART if as_kind else asn.ENC_TGS_REP_PART, rep_part),
self.rng, reply_kvno)}
return asn.encode(asn.AS_REP if as_kind else asn.TGS_REP, rep)
# ---------------------------------------------------------------- the TGS exchange, section 3.3
def handle_tgs_req(self, raw, now):
req = asn.decode(asn.TGS_REQ, raw)
body = req["req_body"]
sname = body["sname"]
padata = {p["padata_type"]: p["padata_value"] for p in req.get("padata") or []}
try:
ap_req = asn.decode(asn.AP_REQ, padata[PA_TGS_REQ])
tgt_server = self.db[("krbtgt", self.realm)]
tkt_part = asn.decode(asn.ENC_TICKET_PART, decrypt_data(ap_req["ticket"]["enc_part"], tgt_server.key, KU_TICKET))
session = tkt_part["key"]["keyvalue"]
et = kc.ENCTYPES[tkt_part["key"]["keytype"]]
auth = asn.decode(asn.AUTHENTICATOR, decrypt_data(ap_req["authenticator"], session, KU_TGS_AUTH))
if auth["cname"] != tkt_part["cname"] or auth["crealm"] != tkt_part["crealm"]:
raise KerberosError(KRB_AP_ERR_BADMATCH)
if abs(auth["ctime"] - now) > SKEW:
raise KerberosError(KRB_AP_ERR_SKEW)
if now > tkt_part["endtime"]:
raise KerberosError(KRB_AP_ERR_TKT_EXPIRED)
key = (tuple(tkt_part["cname"]["name_string"]), auth["ctime"], auth["cusec"])
if key in self.replay:
raise KerberosError(KRB_AP_ERR_REPEAT)
self.replay.add(key)
expected = et.checksum(session, KU_TGS_CKSUM, asn.encode(asn.KDC_REQ_BODY, body))
if auth["cksum"]["cksumtype"] != CHECKSUM_TYPES[et.number] or auth["cksum"]["checksum"] != expected:
raise KerberosError(KRB_AP_ERR_BAD_INTEGRITY, "the checksum of the request body is wrong")
except KerberosError as err:
return self.error(err.code, now, sname)
server = self.db.get(tuple(sname["name_string"]))
if server is None:
return self.error(KDC_ERR_S_PRINCIPAL_UNKNOWN, now, sname)
wanted = kc.ENCTYPES.get(body["etype"][0]) if body["etype"] else None
session_et = wanted if wanted is not None and wanted.key_len == server.etype.key_len else server.etype
return self.issue(TGS_REP_KIND, body, tkt_part["cname"], server, session_et, et, session, KU_TGS_REP, None, now, tkt_part["flags"] & ~INITIAL,
authtime=tkt_part["authtime"], tgt_end=tkt_part["endtime"])
AS_REP_KIND, TGS_REP_KIND = "as", "tgs"
class Service:
"""An application server with a long-term key and a replay cache."""
def __init__(self, entry):
self.entry, self.replay = entry, set()
def handle_ap_req(self, raw, now):
ap_req = asn.decode(asn.AP_REQ, raw)
tkt_part = asn.decode(asn.ENC_TICKET_PART, decrypt_data(ap_req["ticket"]["enc_part"], self.entry.key, KU_TICKET))
session = tkt_part["key"]["keyvalue"]
et = kc.ENCTYPES[tkt_part["key"]["keytype"]]
auth = asn.decode(asn.AUTHENTICATOR, decrypt_data(ap_req["authenticator"], session, KU_AP_AUTH))
if auth["cname"] != tkt_part["cname"] or auth["crealm"] != tkt_part["crealm"]:
raise KerberosError(KRB_AP_ERR_BADMATCH, "the name in the authenticator differs from the name in the ticket")
if abs(auth["ctime"] - now) > SKEW:
raise KerberosError(KRB_AP_ERR_SKEW, "the clock of the client differs by more than %d seconds" % SKEW)
key = (tuple(auth["cname"]["name_string"]), auth["ctime"], auth["cusec"])
if key in self.replay:
raise KerberosError(KRB_AP_ERR_REPEAT, "this authenticator was already seen")
self.replay.add(key)
if tkt_part["starttime"] - now > SKEW:
raise KerberosError(KRB_AP_ERR_TKT_NYV)
if now > tkt_part["endtime"]:
raise KerberosError(KRB_AP_ERR_TKT_EXPIRED, "the ticket has expired")
reply = None
if ap_req["ap_options"] & MUTUAL_REQUIRED:
part = asn.encode(asn.ENC_AP_REP_PART, {"ctime": auth["ctime"], "cusec": auth["cusec"]})
reply = asn.encode(asn.AP_REP, {"pvno": 5, "msg_type": 15, "enc_part": encrypted_data(et, session, KU_AP_REP, part, _ZeroRng())})
return tkt_part, auth, reply
class _ZeroRng:
def randbytes(self, n):
return bytes(n)
class Client:
def __init__(self, realm, components, password, rng):
self.realm, self.components, self.password, self.rng = realm, tuple(components), password, rng
self.nonce = 0
def next_nonce(self):
self.nonce = int.from_bytes(self.rng.randbytes(4), "big") % (2 ** 31 - 1) + 1
return self.nonce
def as_req(self, etype, now, till, padata=None, server=None):
body = {"kdc_options": FORWARDABLE, "cname": principal(*self.components), "realm": self.realm,
"sname": server or principal("krbtgt", self.realm, name_type=NT_SRV_INST), "till": till, "nonce": self.next_nonce(), "etype": [etype]}
return asn.encode(asn.AS_REQ, {"pvno": 5, "msg_type": 10, "padata": padata, "req_body": body})
def key_from_hint(self, etype, error_raw):
# the KRB-ERROR with PREAUTH_REQUIRED tells the salt and the iteration count in PA-ETYPE-INFO2
err = asn.decode(asn.KRB_ERROR, error_raw)
method = asn.decode(asn.METHOD_DATA, err["e_data"])
info = asn.decode(asn.ETYPE_INFO2, next(p["padata_value"] for p in method if p["padata_type"] == PA_ETYPE_INFO2))
entry = next(i for i in info if i["etype"] == etype)
iterations = int.from_bytes(entry["s2kparams"], "big") if entry.get("s2kparams") else None
return kc.ENCTYPES[etype].string_to_key(self.password.encode(), entry["salt"].encode(), iterations)
def timestamp_padata(self, etype, key, now, usec=0):
stamp = asn.encode(asn.PA_ENC_TS_ENC, {"patimestamp": now, "pausec": usec})
enc = encrypted_data(kc.ENCTYPES[etype], key, KU_PA_ENC_TIMESTAMP, stamp, self.rng)
return [{"padata_type": PA_ENC_TIMESTAMP, "padata_value": asn.encode(asn.ENCRYPTED_DATA, enc)}]
def read_as_rep(self, raw, key):
rep = asn.decode(asn.AS_REP, raw)
part = asn.decode(asn.ENC_AS_REP_PART, decrypt_data(rep["enc_part"], key, KU_AS_REP))
assert part["nonce"] == self.nonce, "the nonce of the reply does not match the request"
return rep["ticket"], part
def tgs_req(self, tgt, tgt_part, service, now, usec, till):
et = kc.ENCTYPES[tgt_part["key"]["keytype"]]
body = {"kdc_options": FORWARDABLE, "realm": self.realm, "sname": service, "till": till, "nonce": self.next_nonce(), "etype": [et.number]}
cksum = et.checksum(tgt_part["key"]["keyvalue"], KU_TGS_CKSUM, asn.encode(asn.KDC_REQ_BODY, body))
auth = {"authenticator_vno": 5, "crealm": self.realm, "cname": principal(*self.components),
"cksum": {"cksumtype": CHECKSUM_TYPES[et.number], "checksum": cksum}, "cusec": usec, "ctime": now}
ap_req = {"pvno": 5, "msg_type": 14, "ap_options": 0, "ticket": tgt,
"authenticator": encrypted_data(et, tgt_part["key"]["keyvalue"], KU_TGS_AUTH, asn.encode(asn.AUTHENTICATOR, auth), self.rng)}
padata = [{"padata_type": PA_TGS_REQ, "padata_value": asn.encode(asn.AP_REQ, ap_req)}]
return asn.encode(asn.TGS_REQ, {"pvno": 5, "msg_type": 12, "padata": padata, "req_body": body})
def read_tgs_rep(self, raw, tgt_part):
rep = asn.decode(asn.TGS_REP, raw)
part = asn.decode(asn.ENC_TGS_REP_PART, decrypt_data(rep["enc_part"], tgt_part["key"]["keyvalue"], KU_TGS_REP))
assert part["nonce"] == self.nonce
return rep["ticket"], part
def ap_req(self, ticket, part, now, usec, mutual=True):
et = kc.ENCTYPES[part["key"]["keytype"]]
auth = {"authenticator_vno": 5, "crealm": self.realm, "cname": principal(*self.components), "cusec": usec, "ctime": now}
enc = encrypted_data(et, part["key"]["keyvalue"], KU_AP_AUTH, asn.encode(asn.AUTHENTICATOR, auth), self.rng)
return asn.encode(asn.AP_REQ, {"pvno": 5, "msg_type": 14, "ap_options": MUTUAL_REQUIRED if mutual else 0, "ticket": ticket, "authenticator": enc})
def check_ap_rep(self, raw, part, now, usec):
rep = asn.decode(asn.AP_REP, raw)
inner = asn.decode(asn.ENC_AP_REP_PART, decrypt_data(rep["enc_part"], part["key"]["keyvalue"], KU_AP_REP))
return inner["ctime"] == now and inner["cusec"] == usec
The decoder and the client stop on malformed input with a plain assert. That keeps the teaching code short, but a real implementation would answer such input with a proper KRB-ERROR code instead.
I checked this code in layers, each against a different source.
- DER encoding. The encoder reproduces all 36 messages of the MIT Kerberos test file
tests/asn.1/reference_encode.out, byte for byte, built from the same fixed sample values the MIT test program uses (tests/asn.1/ktest.c). Every message also decodes back to the same bytes. - n-fold. All 11 vectors of RFC 3961 appendix A.1, including the awkward one-bit-off cases that the appendix calls out as the ones a naive implementation gets wrong.
- String-to-key for AES. All 7 vectors of RFC 3962 appendix B, for both the 128-bit and 256-bit keys, including a passphrase that is a single four-byte UTF-8 character and a salt given as raw bytes rather than text.
- Ciphertext stealing. All 6 vectors of RFC 3962 appendix B, for plaintexts from one block up to several blocks long, encrypted and decrypted.
- Key derivation. 12 known values from
lib/crypto/crypto_tests/t_derive.cof the MIT source, covering Kc, Ke, and Ki for all four AES encryption types, including the two from RFC 8009 that use the SP 800-108 construction instead of RFC 3961’s. - RFC 8009’s encryption and checksums. 8 encryption vectors from its appendix A, for messages of different lengths, plus its checksum and pseudorandom-function vectors.
- AES itself. 200 random blocks for both key sizes, checked against OpenSSL in both directions.
- The protocol. 292 random messages across the four encryption types decrypt correctly, and a changed ciphertext bit, a wrong key usage number, or a wrong key is always rejected. A full run of the AS, TGS, and AP exchanges, for all four encryption types, produces a working mutual authentication.
The page code ports the same three layers to JavaScript, restricted to aes256-cts-hmac-sha1-96: its own SHA-1, AES, ciphertext stealing, PBKDF2, key derivation, DER codec, and the three exchanges. Its primitives match RFC 3961 and RFC 3962’s vectors, and match OpenSSL. The full login flow, run with a deterministic stand-in for randomness, covers 11 scenarios: a normal login, a wrong password, several seeds, a replayed AP-REQ, a tampered one, and a ticket presented to the wrong service. Each one reproduces the DER bytes of every message byte for byte against the same flow run in Python. I ran the page in a simulated browser.
Where It Stands
- Still the backbone of Active Directory. Every Windows domain login, by default, is a Kerberos AS and TGS exchange, followed by an AP exchange to each server the user touches.
- A close relative of SPNEGO and GSS-API. Applications rarely call the three exchanges directly; a negotiation layer picks Kerberos (or another mechanism) and wraps its messages.
- Pre-authentication closed an old gap. RFC 1510 made it optional; a Key Distribution Center that skips it hands out key material to an unauthenticated requester, which is still a live issue, covered below.
- The encryption types keep moving. RFC 3962’s SHA-1 based types are still the most common in practice; RFC 8009’s SHA-2 based types exist for environments that no longer want SHA-1 anywhere in the protocol.
Limitations
This is a teaching implementation, not a production one:
- One encryption type in the browser. The Python code implements all four AES encryption types; the page implements only
aes256-cts-hmac-sha1-96. - No real randomness in the page. The confounders and session keys come from a seed word, so a run can be reproduced and checked. The Python code uses an ordinary pseudorandom source, which is also not suitable for issuing real tickets.
- No renewable or forwardable use, no cross-realm, no PAC. The flags exist in the messages, but nothing in this code acts on them, and tickets are not actually renewed or forwarded anywhere.
- No address restrictions and no transited realm checking. Both fields exist in the ticket and are encoded, but the code does not fill or check them meaningfully.
- No real clock, and no persistence. The Key Distribution Center and the service keep their replay caches only for the length of one Python process or one page load.
Security Status
RFC 4120’s security considerations, and the practice of the last two decades, point at the same handful of issues.
- AS-REP roasting. If pre-authentication is disabled for an account, anyone can request an
AS-REPfor that account without proving any password, and the reply’s encrypted part is a known-plaintext target for an offline guessing attack against the account’s key. My tests confirm this directly. With pre-authentication turned off for the account, trying candidate passwords offline against the returned ciphertext found the password used in this guide’s examples. It succeeded after a few thousand guesses, well under a minute on ordinary hardware. - A sniffed pre-authentication timestamp is the same kind of target. The encrypted timestamp that proves a client’s password is, by construction, something an eavesdropper on the network can collect and then attack offline, at the same cost as AS-REP roasting. This is why RFC 3962’s security section already discusses the iteration count as a defense against exactly this, not as an afterthought.
- Kerberoasting. Any authenticated user can ask for a service ticket to any service principal, and the ticket’s encrypted part is similarly a known-plaintext target for the service account’s password. In my tests, a logged-in, unprivileged user requested a ticket to an account with a weak password and the password was found the same way. Because the TGS exchange needs no special privilege, this is one reason production directories give service accounts long, randomly generated passwords, or a key that was never derived from a password at all.
- The iteration count is the main defense against both. Raising it raises the cost of every offline guess proportionally, at the cost of raising the time a legitimate login takes to derive its key. My tests measured roughly an order of magnitude more time per guess between the default iteration counts of RFC 3962 and RFC 8009.
- A compromised Key Distribution Center key is total compromise. Whoever holds the krbtgt account’s key can mint a ticket-granting ticket for any principal, with any lifetime. Every service in the realm will accept the tickets issued from it. This is the structural reason that key is treated as the most sensitive secret in a Kerberos realm. My tests built such a ticket directly and had it accepted.
- The older encryption types are deprecated. RFC 8429, from 2018, deprecated the triple-DES and RC4 encryption types and moved RC4’s RFC 4757 to Historic. That leaves the AES types of this guide, and RFC 8009’s SHA-2 types are the choice for a deployment that wants SHA-1 out entirely.
- Clock skew is a real operational constraint, not just a cryptographic one. Every check in this guide that depends on time uses the same five-minute allowance that RFC 4120 suggests. A network whose hosts drift further than that will see logins fail for reasons that have nothing to do with a wrong password.
FAQ
Why does the Key Distribution Center ask the client to try twice?
So that it never has to do any work, or reveal any salt or iteration count, for a request it cannot authenticate. The first AS-REQ is free to send and free to answer with an error; only the second one, carrying proof of the password, triggers ticket issuance.
What is inside a ticket that the client cannot read?
The session key for that ticket, the client’s own name and realm, the validity times, and the ticket’s flags. All of it is encrypted under the key of whichever principal the ticket is for: the krbtgt account for a ticket-granting ticket, or the service account for a service ticket.
Why does TGS-REQ use a checksum instead of full encryption for its body?
The body names the requested service and other routing information that may need to be read, logged, or acted on before decryption would make sense. A keyed checksum lets the Key Distribution Center detect tampering without needing to decrypt first.
Is a Kerberos password ever sent over the network?
No, a Kerberos password itself is never sent. Only values derived from it, by PBKDF2 and the key-derivation function, ever leave the client. Even those are used only to encrypt a timestamp or decrypt a ticket, never transmitted directly.
What stops someone from reusing a captured AP-REQ?
The service’s replay cache, keyed on the client’s name and the authenticator’s timestamp and microsecond field, and the five-minute clock skew window: the same authenticator cannot be presented again within that window without matching an entry already in the cache.
Does mutual authentication require a second round trip?
No, mutual authentication costs no extra round trip. AP-REP is a reply to AP-REQ, so the whole application exchange is one round trip. Mutual authentication is an extra message inside that round trip, not an extra one.
References
-
RFC 4120, “The Kerberos Network Authentication Service (V5),” by Neuman, Yu, Hartman, and Raeburn, July 2005. Available at: https://www.rfc-editor.org/rfc/rfc4120
-
RFC 3961, “Encryption and Checksum Specifications for Kerberos 5,” by Raeburn, February 2005. Available at: https://www.rfc-editor.org/rfc/rfc3961
-
RFC 3962, “Advanced Encryption Standard (AES) Encryption for Kerberos 5,” by Raeburn, February 2005. Available at: https://www.rfc-editor.org/rfc/rfc3962
-
RFC 8009, “AES Encryption with HMAC-SHA2 for Kerberos 5,” by Jenkins, Peck, and Burgin, October 2016. Available at: https://www.rfc-editor.org/rfc/rfc8009
-
The MIT Kerberos source code: https://github.com/krb5/krb5. The test files this guide uses are src/tests/asn.1/reference_encode.out, src/tests/asn.1/ktest.c, and src/lib/crypto/crypto_tests/t_derive.c.
-
RFC 8429, “Deprecate Triple-DES (3DES) and RC4 in Kerberos,” by Kaduk and Short, October 2018. Available at: https://www.rfc-editor.org/rfc/rfc8429