checker-kerberos/checker/collect_test.go

332 lines
9.7 KiB
Go

package checker
import (
"errors"
"strings"
"testing"
"time"
asn1 "github.com/jcmturner/gofork/encoding/asn1"
"github.com/jcmturner/gokrb5/v8/iana/errorcode"
"github.com/jcmturner/gokrb5/v8/iana/etypeID"
"github.com/jcmturner/gokrb5/v8/iana/nametype"
"github.com/jcmturner/gokrb5/v8/iana/patype"
"github.com/jcmturner/gokrb5/v8/messages"
"github.com/jcmturner/gokrb5/v8/types"
)
// buildKRBError constructs a marshaled KRB-ERROR with the given code and
// optional EData payload.
func buildKRBError(t *testing.T, realm string, code int32, edata []byte) []byte {
t.Helper()
sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/"+realm)
k := messages.NewKRBError(sname, realm, code, "")
k.STime = time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
k.Susec = 0
k.EData = edata
raw, err := k.Marshal()
if err != nil {
t.Fatalf("marshal KRBError: %v", err)
}
return raw
}
// buildETypeInfo2EData marshals a PADataSequence containing one
// PA_ETYPE_INFO2 entry per supplied (etype, salt) pair.
func buildETypeInfo2EData(t *testing.T, entries []types.ETypeInfo2Entry) []byte {
t.Helper()
value, err := asn1.Marshal(types.ETypeInfo2(entries))
if err != nil {
t.Fatalf("marshal ETypeInfo2: %v", err)
}
pas := types.PADataSequence{
{PADataType: patype.PA_ETYPE_INFO2, PADataValue: value},
{PADataType: patype.PA_PK_AS_REQ, PADataValue: []byte{0x00}},
}
raw, err := asn1.Marshal(pas)
if err != nil {
t.Fatalf("marshal PADataSequence: %v", err)
}
return raw
}
func TestParseASResponse_KRBErrorPreauthRequiredWithEData(t *testing.T) {
edata := buildETypeInfo2EData(t, []types.ETypeInfo2Entry{
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "EXAMPLE.COMuser"},
{EType: etypeID.RC4_HMAC, Salt: ""},
})
raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, edata)
var out ASProbeResult
parseASResponse(raw, &out)
if out.Error != "" {
t.Fatalf("unexpected parse error: %s", out.Error)
}
if out.ErrorCode != errorcode.KDC_ERR_PREAUTH_REQUIRED {
t.Errorf("ErrorCode = %d, want KDC_ERR_PREAUTH_REQUIRED", out.ErrorCode)
}
if !out.PreauthReq {
t.Error("PreauthReq should be true for KDC_ERR_PREAUTH_REQUIRED")
}
if out.ServerRealm != "EXAMPLE.COM" {
t.Errorf("ServerRealm = %q, want EXAMPLE.COM", out.ServerRealm)
}
if out.ServerTime.IsZero() {
t.Error("ServerTime should be populated from STime")
}
if !out.PKINITOffered {
t.Error("PKINITOffered should be true (PA_PK_AS_REQ present)")
}
if len(out.Enctypes) != 2 {
t.Fatalf("Enctypes len = %d, want 2", len(out.Enctypes))
}
var sawAES, sawRC4 bool
for _, e := range out.Enctypes {
switch e.ID {
case etypeID.AES256_CTS_HMAC_SHA1_96:
sawAES = true
if e.Weak {
t.Error("AES256 should not be flagged weak")
}
if e.Source != "etype-info2" {
t.Errorf("AES256 Source = %q, want etype-info2", e.Source)
}
case etypeID.RC4_HMAC:
sawRC4 = true
if !e.Weak {
t.Error("RC4_HMAC should be flagged weak")
}
}
}
if !sawAES || !sawRC4 {
t.Errorf("missing expected enctypes (sawAES=%v sawRC4=%v)", sawAES, sawRC4)
}
}
func TestParseASResponse_KRBErrorPrincipalUnknownNoEData(t *testing.T) {
raw := buildKRBError(t, "EXAMPLE.COM", errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN, nil)
var out ASProbeResult
parseASResponse(raw, &out)
if out.Error != "" {
t.Fatalf("unexpected parse error: %s", out.Error)
}
if out.ErrorCode != errorcode.KDC_ERR_C_PRINCIPAL_UNKNOWN {
t.Errorf("ErrorCode = %d, want KDC_ERR_C_PRINCIPAL_UNKNOWN", out.ErrorCode)
}
if out.PreauthReq {
t.Error("PreauthReq should be false")
}
if len(out.Enctypes) != 0 {
t.Errorf("Enctypes should be empty, got %d", len(out.Enctypes))
}
}
func TestParseASResponse_GarbageBytes(t *testing.T) {
var out ASProbeResult
parseASResponse([]byte{0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe}, &out)
if out.Error == "" {
t.Fatal("expected an Error string for unparsable bytes")
}
if !strings.Contains(out.Error, "deadbeefcafe") {
t.Errorf("Error should include hex prefix of payload, got %q", out.Error)
}
}
func TestExtractEData_ETypeInfoFallback(t *testing.T) {
// PA_ETYPE_INFO (legacy) only. Salt is octet-string here.
value, err := asn1.Marshal(types.ETypeInfo{
{EType: etypeID.AES128_CTS_HMAC_SHA1_96, Salt: []byte("salty")},
})
if err != nil {
t.Fatalf("marshal ETypeInfo: %v", err)
}
edata, err := asn1.Marshal(types.PADataSequence{
{PADataType: patype.PA_ETYPE_INFO, PADataValue: value},
})
if err != nil {
t.Fatalf("marshal PADataSequence: %v", err)
}
enctypes, pkinit := extractEData(edata)
if pkinit {
t.Error("PKINIT should not be reported when no PA_PK_AS_REQ is present")
}
if len(enctypes) != 1 {
t.Fatalf("got %d enctypes, want 1", len(enctypes))
}
if enctypes[0].Source != "etype-info" {
t.Errorf("Source = %q, want etype-info", enctypes[0].Source)
}
if enctypes[0].Salt != "salty" {
t.Errorf("Salt = %q, want salty", enctypes[0].Salt)
}
}
func TestExtractEData_ETypeInfo2WinsOverInfo(t *testing.T) {
// Both PA_ETYPE_INFO2 and PA_ETYPE_INFO advertise the same enctype.
// The legacy info should be skipped (de-duplicated).
v2, _ := asn1.Marshal(types.ETypeInfo2{
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: "fromInfo2"},
})
v1, _ := asn1.Marshal(types.ETypeInfo{
{EType: etypeID.AES256_CTS_HMAC_SHA1_96, Salt: []byte("fromInfo")},
})
edata, _ := asn1.Marshal(types.PADataSequence{
{PADataType: patype.PA_ETYPE_INFO2, PADataValue: v2},
{PADataType: patype.PA_ETYPE_INFO, PADataValue: v1},
})
got, _ := extractEData(edata)
if len(got) != 1 {
t.Fatalf("got %d entries, want 1 (de-duplicated)", len(got))
}
if got[0].Salt != "fromInfo2" {
t.Errorf("salt = %q, want fromInfo2 (etype-info2 must take precedence)", got[0].Salt)
}
}
func TestExtractEData_BadASN1(t *testing.T) {
enctypes, pkinit := extractEData([]byte{0xff, 0x00})
if enctypes != nil || pkinit {
t.Errorf("expected nil/false on garbage, got %v / %v", enctypes, pkinit)
}
}
func TestEtypeName(t *testing.T) {
if got := etypeName(etypeID.AES256_CTS_HMAC_SHA1_96); !strings.Contains(strings.ToLower(got), "aes256") {
t.Errorf("AES256 name = %q, want it to mention aes256", got)
}
if got := etypeName(99999); got != "etype-99999" {
t.Errorf("unknown etype = %q, want etype-99999", got)
}
}
func TestErrorcodeNameAndKRBErrorInfo(t *testing.T) {
name := errorcodeName(errorcode.KDC_ERR_PREAUTH_REQUIRED)
if !strings.Contains(name, "PREAUTH") {
t.Errorf("errorcodeName = %q, want it to contain PREAUTH", name)
}
// Typed KRBError: errors.As path.
sname := types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "krbtgt/EXAMPLE.COM")
krb := messages.NewKRBError(sname, "EXAMPLE.COM", errorcode.KDC_ERR_PREAUTH_REQUIRED, "")
code, n, ok := krbErrorInfo(krb)
if !ok || code != errorcode.KDC_ERR_PREAUTH_REQUIRED || !strings.Contains(n, "PREAUTH") {
t.Errorf("krbErrorInfo(typed) = %d %q %v", code, n, ok)
}
// String fallback: gokrb5 sometimes wraps the code only inside the message.
wrapped := errors.New("login failed: KRB Error: (24) KDC_ERR_PREAUTH_FAILED - bla")
code, n, ok = krbErrorInfo(wrapped)
if !ok || code != 24 {
t.Errorf("krbErrorInfo(string) code=%d ok=%v", code, ok)
}
if !strings.Contains(n, "PREAUTH_FAILED") {
t.Errorf("krbErrorInfo(string) name = %q", n)
}
if _, _, ok := krbErrorInfo(nil); ok {
t.Error("krbErrorInfo(nil) should return ok=false")
}
if _, _, ok := krbErrorInfo(errors.New("plain old error")); ok {
t.Error("krbErrorInfo on a non-KRB error should return ok=false")
}
}
func TestRoleForPrefix(t *testing.T) {
cases := map[string]string{
"_kerberos._tcp.": "kdc",
"_kerberos._udp.": "kdc",
"_kerberos-master._tcp.": "master",
"_kerberos-adm._tcp.": "kadmin",
"_kpasswd._tcp.": "kpasswd",
"_kpasswd._udp.": "kpasswd",
}
for in, want := range cases {
if got := roleForPrefix(in); got != want {
t.Errorf("roleForPrefix(%q) = %q, want %q", in, got, want)
}
}
}
func TestOptFloat(t *testing.T) {
cases := []struct {
in any
want float64
}{
{float64(2.5), 2.5},
{float32(1.5), 1.5},
{int(7), 7},
{int64(8), 8},
{"3.14", 3.14},
{"nope", 42}, // falls back to default
{nil, 42}, // missing key path is exercised below
}
for _, c := range cases {
opts := map[string]any{"k": c.in}
got := optFloat(opts, "k", 42)
if got != c.want {
t.Errorf("optFloat(%v) = %v, want %v", c.in, got, c.want)
}
}
if got := optFloat(map[string]any{}, "missing", 99); got != 99 {
t.Errorf("optFloat(missing) = %v, want 99", got)
}
}
func TestOptBool(t *testing.T) {
cases := []struct {
in any
def bool
want bool
}{
{true, false, true},
{false, true, false},
{"true", false, true},
{"1", false, true},
{"false", true, false}, // unrecognized string -> default
{nil, true, true},
{42, false, false}, // unsupported type -> default
}
for _, c := range cases {
opts := map[string]any{}
if c.in != nil {
opts["k"] = c.in
}
got := optBool(opts, "k", c.def)
if got != c.want {
t.Errorf("optBool(%v, def=%v) = %v, want %v", c.in, c.def, got, c.want)
}
}
}
func TestSmallHelpers(t *testing.T) {
if got := abs(-3 * time.Second); got != 3*time.Second {
t.Errorf("abs negative = %v", got)
}
if got := abs(2 * time.Second); got != 2*time.Second {
t.Errorf("abs positive = %v", got)
}
if got := firstNonEmpty("", "", "x", "y"); got != "x" {
t.Errorf("firstNonEmpty = %q", got)
}
if got := firstNonEmpty("", ""); got != "" {
t.Errorf("firstNonEmpty(all empty) = %q", got)
}
if got := first([]byte{1, 2, 3}, 16); len(got) != 3 {
t.Errorf("first(short) len = %d, want 3", len(got))
}
if got := first([]byte{1, 2, 3, 4, 5}, 2); len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Errorf("first(long) = %v", got)
}
list := []EnctypeEntry{{ID: 18}, {ID: 17}}
if !hasEnctype(list, 17) {
t.Error("hasEnctype should find 17")
}
if hasEnctype(list, 23) {
t.Error("hasEnctype should not find 23")
}
}