From 616eaaa35598a1af956d95f60fd2450cd0a8a003 Mon Sep 17 00:00:00 2001
From: Erik Geiser
Date: Wed, 19 Feb 2025 11:31:01 +0100
Subject: [PATCH] Initial commit
---
.github/workflows/check.yml | 74 +++++
.gitignore | 2 +
.golangci.yml | 102 +++++++
LICENSE | 8 +
README.md | 101 +++++++
ccachetools/ccache.go | 192 ++++++++++++
ccachetools/ccache_test.go | 271 +++++++++++++++++
credentials.go | 348 ++++++++++++++++++++++
credentials_test.go | 193 ++++++++++++
dcerpcauth/dcerpcauth.go | 189 ++++++++++++
examples/dcerpc/main.go | 115 ++++++++
examples/ldap/main.go | 53 ++++
examples/pkinit/main.go | 64 ++++
go.mod | 35 +++
go.sum | 157 ++++++++++
ldapauth/gssapi.go | 366 +++++++++++++++++++++++
ldapauth/ldap.go | 487 +++++++++++++++++++++++++++++++
ldapauth/ntlm.go | 67 +++++
options.go | 351 ++++++++++++++++++++++
options_test.go | 337 +++++++++++++++++++++
othername/othername.go | 189 ++++++++++++
othername/othername_test.go | 40 +++
pkinit/asn1.go | 165 +++++++++++
pkinit/asrep.go | 167 +++++++++++
pkinit/asreq.go | 135 +++++++++
pkinit/diffie_hellman.go | 43 +++
pkinit/exchange.go | 278 ++++++++++++++++++
pkinit/pkcs7.go | 124 ++++++++
pkinit/unpacthehash.go | 351 ++++++++++++++++++++++
resolver.go | 113 +++++++
resolver_test.go | 98 +++++++
target.go | 188 ++++++++++++
target_test.go | 172 +++++++++++
testdata/empty.ccache | 0
testdata/someuser@domain.tld.pfx | Bin 0 -> 2247 bytes
workspace.code-workspace | 32 ++
36 files changed, 5607 insertions(+)
create mode 100644 .github/workflows/check.yml
create mode 100644 .gitignore
create mode 100644 .golangci.yml
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 ccachetools/ccache.go
create mode 100644 ccachetools/ccache_test.go
create mode 100644 credentials.go
create mode 100644 credentials_test.go
create mode 100644 dcerpcauth/dcerpcauth.go
create mode 100644 examples/dcerpc/main.go
create mode 100644 examples/ldap/main.go
create mode 100644 examples/pkinit/main.go
create mode 100644 go.mod
create mode 100644 go.sum
create mode 100644 ldapauth/gssapi.go
create mode 100644 ldapauth/ldap.go
create mode 100644 ldapauth/ntlm.go
create mode 100644 options.go
create mode 100644 options_test.go
create mode 100644 othername/othername.go
create mode 100644 othername/othername_test.go
create mode 100644 pkinit/asn1.go
create mode 100644 pkinit/asrep.go
create mode 100644 pkinit/asreq.go
create mode 100644 pkinit/diffie_hellman.go
create mode 100644 pkinit/exchange.go
create mode 100644 pkinit/pkcs7.go
create mode 100644 pkinit/unpacthehash.go
create mode 100644 resolver.go
create mode 100644 resolver_test.go
create mode 100644 target.go
create mode 100644 target_test.go
create mode 100644 testdata/empty.ccache
create mode 100644 testdata/someuser@domain.tld.pfx
create mode 100644 workspace.code-workspace
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 0000000..cba6483
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,74 @@
+name: Check
+on:
+ # run tests on push to main, but not when other branches are pushed to
+ push:
+ branches:
+ - main
+
+ # run tests for all pull requests
+ pull_request:
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go
+ uses: actions/setup-go@v3
+ with:
+ go-version: 1.24.x
+ id: go
+
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v3
+ with:
+ version: v1.64
+ args: --verbose --timeout 5m
+
+ - name: Check go.mod
+ run: |
+ echo "check if go.mod is up to date"
+ go mod tidy
+ git diff --exit-code go.mod
+
+ test:
+ name: Test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Set up Go
+ uses: actions/setup-go@v3
+ with:
+ go-version: 1.24.x
+ id: go
+
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Run tests
+ run: |
+ go test ./...
+
+ build:
+ strategy:
+ matrix:
+ go-version:
+ - 1.24.x
+ runs-on: ubuntu-latest
+ name: Build with Go ${{ matrix.go-version }}
+ env:
+ GOPROXY: https://proxy.golang.org
+ steps:
+ - name: Set up Go ${{ matrix.go-version }}
+ uses: actions/setup-go@v3
+ with:
+ go-version: ${{ matrix.go-version }}
+ id: go
+
+ - name: Checkout code
+ uses: actions/checkout@v3
+
+ - name: Build
+ run: go build ./...
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1ab9a5a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/adauth
+*.exe
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000..dbe7d71
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,102 @@
+linters:
+ disable-all: true
+ enable:
+ - asasalint
+ - asciicheck
+ - bidichk
+ - bodyclose
+ - canonicalheader
+ - containedctx
+ - decorder
+ - dupl
+ - dupword
+ - durationcheck
+ - errchkjson
+ - errname
+ - errorlint
+ - fatcontext
+ - forcetypeassert
+ - ginkgolinter
+ - gocheckcompilerdirectives
+ - gochecksumtype
+ - goconst
+ - gocritic
+ - gocyclo
+ - godot
+ - godox
+ - gofmt
+ - goheader
+ - goimports
+ - gosec
+ - gomodguard
+ - goprintffuncname
+ - gosimple
+ - gosmopolitan
+ - govet
+ - grouper
+ - importas
+ - inamedparam
+ - ineffassign
+ - interfacebloat
+ - lll
+ - loggercheck
+ - maintidx
+ - makezero
+ - mirror
+ - misspell
+ - nakedret
+ - nestif
+ - nilerr
+ - nilnil
+ - nlreturn
+ - noctx
+ - nolintlint
+ - nosprintfhostport
+ - prealloc
+ - predeclared
+ - promlinter
+ - protogetter
+ - reassign
+ - sloglint
+ - spancheck
+ - sqlclosecheck
+ - staticcheck
+ - tagalign
+ - tagliatelle
+ - usetesting
+ - testableexamples
+ - testifylint
+ - thelper
+ - tparallel
+ - unconvert
+ - unparam
+ - unused
+ - usestdlibvars
+ - wastedassign
+ - whitespace
+ - wsl
+ - zerologlint
+linters-settings:
+ godox:
+ keywords:
+ - FIXME # FIXME generates a linter warning
+ goconst:
+ min-occurrences: 5
+ tagliatelle:
+ # check the struck tag name case
+ case:
+ rules:
+ json: snake
+ yaml: snake
+ gosec:
+ excludes:
+ - G304 # command execution
+ - G204 # file inclusion
+ - G115 # integer overflow
+ - G401 # weak cryptographic primitive (tell Microsoft, not me)
+ - G501 # weak cryptographic primitive (tell Microsoft, not me)
+ - G505 # weak cryptographic primitive (tell Microsoft, not me)
+ - G402 # InsecureSkipVerify may be true
+
+issues:
+ exclude-use-default: false
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c112984
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,8 @@
+Copyright 2024 RedTeam Pentesting GmbH
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4fe8a85
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+
+
adauth
+ Active Directory Authentication Library
+
+
+
+
+
+
+
+
+
+
+**Warning: The API of this library is not yet stable. Expect breaking changes.**
+
+`adauth` is a Go library for active directory authentication. It can be used to
+quickly set up authentication options:
+
+```go
+var (
+ ctx = context.Background()
+ authOpts = &adauth.Options{}
+)
+
+authOpts.RegisterFlags(pflag.CommandLine)
+pflag.Parse()
+// --aes-key string Kerberos AES key
+// --ccache string Kerberos CCache file name (defaults to $KRB5CCNAME, currently unset)
+// --dc string Domain controller
+// --debug Enable debug output
+// -k, --kerberos Use Kerberos authentication
+// -H, --nt-hash string NT hash
+// -p, --password string Password
+// --pfx string Client certificate and private key in PFX format
+// --pfx-password string Password for PFX file
+// -u, --user string Username ('user@domain', 'domain\user', 'domain/user' or 'user')
+
+// Credentials for an arbitrary target:
+creds, target, err := authOpts.WithTarget(ctx, "smb", pflag.Arg(0))
+if err != nil { /* error handling */ }
+
+
+// Only credentials are need, no specific target:
+creds, err := authOpts.NoTarget()
+if err != nil { /* error handling */ }
+
+// Credentials to authenticate to the corresponding DC:
+creds, dc, err := authOpts.WithDCTarget(ctx, "ldap")
+if err != nil { /* error handling */ }
+```
+
+It deduces as much information from the parameters as possible. For example,
+Kerberos authentication is possible even when specifying the target via IP
+address if reverse lookups are possible. Similarly, the domain can be omitted
+when the target hostname contains the domain.
+
+The library also contains helper packages for LDAP and DCERPC, a Kerebros PKINIT
+implementation as well as helpers for creating and writing CCache files (see
+examples).
+
+## Features
+
+* Kerberos
+ * PKINIT
+ * UnPAC-the-Hash
+ * Pass-the-Hash (RC4/NT or AES key)
+ * CCache (containing TGT or ST)
+* NTLM
+ * Pass-the-Hash
+* LDAP
+ * Kerberos, NTLM, Simple Bind
+ * mTLS Authentication / Pass-the-Certificate (LDAPS or LDAP+StartTLS)
+ * Channel Binding (Kerberos and NTLM)
+* DCERPC:
+ * Kerberos, NTLM
+ * Raw endpoits (with port mapping)
+ * Named pipes (SMB)
+ * Signing
+ * Sealing
+
+## Caveats
+
+**LDAP:**
+
+The LDAP helper package does not support authentication using RC4 service
+tickets from `ccache`, since Windows returns unsupported GSSAPI wrap tokens
+during the SASL handshake when presented with an RC4 service ticket (see
+[github.com/jcmturner/gokrb5/pull/498](https://github.com/jcmturner/gokrb5/pull/498)).
+
+However, it should still be possible to request an AES256 service ticket
+instead, even when an NT hash was used for pre-authentication . Unfortunately,
+[impacket](https://github.com/fortra/impacket) always requests RC4 tickets. This
+behavior can be changed by adding
+`int(constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value),` as the first
+element of [this
+list](https://github.com/fortra/impacket/blob/af91d617c382e1eb132506159debcbc10da7a567/impacket/krb5/kerberosv5.py#L447-L450).
+
+The LDAP library does not (yet) support LDAP signing, but it supports channel
+binding for LDAPS and LDAP+StartTLS which is typically sufficient as a
+workaround unless the server lacks a TLS certificate.
+
diff --git a/ccachetools/ccache.go b/ccachetools/ccache.go
new file mode 100644
index 0000000..21f855c
--- /dev/null
+++ b/ccachetools/ccache.go
@@ -0,0 +1,192 @@
+package ccachetools
+
+import (
+ "encoding/binary"
+ "fmt"
+ "strings"
+ "time"
+
+ "github.com/jcmturner/gokrb5/v8/credentials"
+ "github.com/jcmturner/gokrb5/v8/messages"
+ "github.com/jcmturner/gokrb5/v8/types"
+)
+
+// NewCCache constructs an in-memory CCache.
+func NewCCache(
+ ticket messages.Ticket, key types.EncryptionKey,
+ serverName types.PrincipalName, clientName types.PrincipalName, clientRealm string,
+ authTime time.Time, startTime time.Time, endTime time.Time, renewTill time.Time,
+) (*credentials.CCache, error) {
+ ticketBytes, err := ticket.Marshal()
+ if err != nil {
+ return nil, fmt.Errorf("marshal ticket for ccache: %w", err)
+ }
+
+ entry := &credentials.Credential{
+ Key: key,
+ AuthTime: authTime,
+ StartTime: startTime,
+ EndTime: endTime,
+ RenewTill: renewTill,
+ Ticket: ticketBytes,
+ }
+
+ entry.Client.PrincipalName = clientName
+ entry.Client.Realm = clientRealm
+ entry.Server.PrincipalName = serverName
+ entry.Server.Realm = clientRealm
+
+ ccache := &credentials.CCache{
+ Credentials: []*credentials.Credential{entry},
+ }
+
+ ccache.DefaultPrincipal.PrincipalName = clientName
+ ccache.DefaultPrincipal.Realm = strings.ToUpper(clientRealm)
+
+ return ccache, nil
+}
+
+// NewCCacheFromASRep constructs an in-memory CCache based on the ticket and key
+// in the provided Kerberos ASRep message. The ASRep message must already be
+// decrypted. The CCache will contain only this ticket and the ticket user will
+// be set as the default principal of the CCache.
+func NewCCacheFromASRep(asRep messages.ASRep) (*credentials.CCache, error) {
+ if len(asRep.DecryptedEncPart.Key.KeyValue) == 0 {
+ return nil, fmt.Errorf("ASRep key was not decrypted")
+ }
+
+ return NewCCache(
+ asRep.Ticket, asRep.DecryptedEncPart.Key,
+ asRep.DecryptedEncPart.SName, asRep.CName, asRep.CRealm,
+ asRep.DecryptedEncPart.AuthTime, asRep.DecryptedEncPart.StartTime,
+ asRep.DecryptedEncPart.EndTime, asRep.DecryptedEncPart.RenewTill)
+}
+
+// NewCCacheFromTGSRep constructs an in-memory CCache based on the ticket and
+// key in the provided Kerberos TGSRep message. The TGSRep message must already
+// be decrypted. The CCache will contain only this ticket and the ticket user
+// will be set as the default principal of the CCache.
+func NewCCacheFromTGSRep(tgsRep messages.TGSRep) (*credentials.CCache, error) {
+ if len(tgsRep.DecryptedEncPart.Key.KeyValue) == 0 {
+ return nil, fmt.Errorf("TGSRep key was not decrypted")
+ }
+
+ return NewCCache(
+ tgsRep.Ticket, tgsRep.DecryptedEncPart.Key,
+ tgsRep.DecryptedEncPart.SName, tgsRep.CName, tgsRep.CRealm,
+ tgsRep.DecryptedEncPart.AuthTime, tgsRep.DecryptedEncPart.StartTime,
+ tgsRep.DecryptedEncPart.EndTime, tgsRep.DecryptedEncPart.RenewTill)
+}
+
+// MarshalCCache returns the byte representation of the provided CCache such
+// that it can be saved on-disk.
+func MarshalCCache(ccache *credentials.CCache) ([]byte, error) {
+ switch ccache.Version {
+ case 0, 1, 2, 3, 4:
+ default:
+ return nil, fmt.Errorf("unsupported CCache version: %d", ccache.Version)
+ }
+
+ version := ccache.Version
+ if version == 0 {
+ version = 4
+ }
+
+ var bo binary.AppendByteOrder = binary.BigEndian
+
+ if version == 1 || version == 2 {
+ bo = binary.LittleEndian
+ }
+
+ buf := []byte{5, version}
+
+ // header
+ if version == 4 {
+ buf = bo.AppendUint16(buf, 0)
+ }
+
+ // default principal
+ buf = append(buf, principalBytes(bo, version,
+ ccache.DefaultPrincipal.PrincipalName, ccache.DefaultPrincipal.Realm)...)
+
+ // credentials
+ for _, cred := range ccache.Credentials {
+ buf = append(buf, credentialBytes(bo, version, cred)...)
+ }
+
+ return buf, nil
+}
+
+func principalBytes(bo binary.AppendByteOrder, v uint8, p types.PrincipalName, realm string) (res []byte) {
+ if v != 1 {
+ res = bo.AppendUint32(res, uint32(p.NameType))
+ }
+
+ nCompontents := len(p.NameString)
+ if v == 1 {
+ nCompontents--
+ }
+
+ res = bo.AppendUint32(res, uint32(nCompontents))
+ res = bo.AppendUint32(res, uint32(len(realm)))
+
+ res = append(res, []byte(realm)...)
+
+ for _, part := range p.NameString {
+ res = bo.AppendUint32(res, uint32(len(part)))
+ res = append(res, []byte(part)...)
+ }
+
+ return res
+}
+
+func credentialBytes(bo binary.AppendByteOrder, v uint8, cred *credentials.Credential) (res []byte) {
+ res = append(res, principalBytes(bo, v, cred.Client.PrincipalName, cred.Client.Realm)...)
+ res = append(res, principalBytes(bo, v, cred.Server.PrincipalName, cred.Server.Realm)...)
+
+ res = bo.AppendUint16(res, uint16(cred.Key.KeyType))
+ res = bo.AppendUint32(res, uint32(len(cred.Key.KeyValue)))
+ res = append(res, cred.Key.KeyValue...)
+
+ res = bo.AppendUint32(res, uint32(cred.AuthTime.Unix()))
+ res = bo.AppendUint32(res, uint32(cred.StartTime.Unix()))
+ res = bo.AppendUint32(res, uint32(cred.EndTime.Unix()))
+ res = bo.AppendUint32(res, uint32(cred.RenewTill.Unix()))
+
+ if cred.IsSKey {
+ res = append(res, 1)
+ } else {
+ res = append(res, 0)
+ }
+
+ flags := cred.TicketFlags.Bytes
+ if len(flags) == 0 {
+ flags = make([]byte, 4)
+ }
+
+ res = append(res, flags...)
+
+ res = bo.AppendUint32(res, uint32(len(cred.Addresses)))
+
+ for _, addr := range cred.Addresses {
+ res = bo.AppendUint16(res, uint16(addr.AddrType))
+ res = bo.AppendUint32(res, uint32(len(addr.Address)))
+ res = append(res, addr.Address...)
+ }
+
+ res = bo.AppendUint32(res, uint32(len(cred.AuthData)))
+
+ for _, data := range cred.AuthData {
+ res = bo.AppendUint16(res, uint16(data.ADType))
+ res = bo.AppendUint32(res, uint32(len(data.ADData)))
+ res = append(res, data.ADData...)
+ }
+
+ res = bo.AppendUint32(res, uint32(len(cred.Ticket)))
+ res = append(res, cred.Ticket...)
+
+ res = bo.AppendUint32(res, uint32(len(cred.SecondTicket)))
+ res = append(res, cred.SecondTicket...)
+
+ return res
+}
diff --git a/ccachetools/ccache_test.go b/ccachetools/ccache_test.go
new file mode 100644
index 0000000..78310b9
--- /dev/null
+++ b/ccachetools/ccache_test.go
@@ -0,0 +1,271 @@
+package ccachetools_test
+
+import (
+ "bytes"
+ "slices"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/RedTeamPentesting/adauth/ccachetools"
+ "github.com/jcmturner/gokrb5/v8/credentials"
+ "github.com/jcmturner/gokrb5/v8/iana/nametype"
+ "github.com/jcmturner/gokrb5/v8/messages"
+ "github.com/jcmturner/gokrb5/v8/types"
+)
+
+var (
+ testASRep = messages.ASRep{
+ KDCRepFields: messages.KDCRepFields{
+ PVNO: 5,
+ MsgType: 11,
+ CRealm: "realm",
+ CName: types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "user"),
+ Ticket: messages.Ticket{
+ TktVNO: 5,
+ Realm: "realm",
+ SName: types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "krbtgt/redteam"),
+ DecryptedEncPart: messages.EncTicketPart{
+ Key: types.EncryptionKey{},
+ CName: types.NewPrincipalName(0, ""),
+ StartTime: time.Now(),
+ AuthTime: time.Now(),
+ EndTime: time.Now(),
+ RenewTill: time.Now(),
+ },
+ },
+ EncPart: types.EncryptedData{
+ EType: 18,
+ KVNO: 2,
+ Cipher: []byte{1, 2, 3},
+ },
+ DecryptedEncPart: messages.EncKDCRepPart{
+ Key: types.EncryptionKey{
+ KeyType: 18,
+ KeyValue: []byte{1, 3, 3, 7},
+ },
+ SRealm: "realm",
+ SName: types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "krbtgt/redteam"),
+ StartTime: time.Now(),
+ AuthTime: time.Now(),
+ EndTime: time.Now(),
+ RenewTill: time.Now(),
+ },
+ },
+ }
+ testTGSRep = messages.TGSRep(testASRep)
+)
+
+func TestNewCCacheFromASRep(t *testing.T) {
+ t.Parallel()
+
+ ccache, err := ccachetools.NewCCacheFromASRep(testASRep)
+ if err != nil {
+ t.Fatalf("NewCCacheFromASRep: %v", err)
+ }
+
+ validateCCache(t, ccache)
+}
+
+func TestNewCCacheFromTGSRep(t *testing.T) {
+ t.Parallel()
+
+ ccache, err := ccachetools.NewCCacheFromTGSRep(testTGSRep)
+ if err != nil {
+ t.Fatalf("NewCCacheFromTGSRep: %v", err)
+ }
+
+ validateCCache(t, ccache)
+}
+
+func TestNewCCache(t *testing.T) {
+ t.Parallel()
+
+ ccache, err := ccachetools.NewCCache(
+ testASRep.Ticket, testASRep.DecryptedEncPart.Key,
+ testASRep.DecryptedEncPart.SName, testASRep.CName, testASRep.CRealm,
+ testASRep.DecryptedEncPart.AuthTime, testASRep.DecryptedEncPart.StartTime,
+ testASRep.DecryptedEncPart.EndTime, testASRep.DecryptedEncPart.RenewTill)
+ if err != nil {
+ t.Fatalf("NewCCacheFromTGSRep: %v", err)
+ }
+
+ validateCCache(t, ccache)
+}
+
+func TestMarshalCCache(t *testing.T) {
+ t.Parallel()
+
+ ccache, err := ccachetools.NewCCacheFromASRep(testASRep)
+ if err != nil {
+ t.Fatalf("new CCache: %v", err)
+ }
+
+ ccacheBytes, err := ccachetools.MarshalCCache(ccache)
+ if err != nil {
+ t.Fatalf("marshal CCache: %v", err)
+ }
+
+ var parsedCCache credentials.CCache
+
+ err = parsedCCache.Unmarshal(ccacheBytes)
+ if err != nil {
+ t.Fatalf("unmarshal CCache: %v", err)
+ }
+
+ validateCCache(t, &parsedCCache)
+}
+
+func TestUnencryptedASRep(t *testing.T) {
+ t.Parallel()
+
+ _, err := ccachetools.NewCCacheFromASRep(messages.ASRep{
+ KDCRepFields: messages.KDCRepFields{
+ PVNO: 5,
+ MsgType: 11,
+ CRealm: "realm",
+ CName: types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "user"),
+ Ticket: messages.Ticket{
+ TktVNO: 5,
+ Realm: "realm",
+ SName: types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "krbtgt/redteam"),
+ DecryptedEncPart: messages.EncTicketPart{
+ Key: types.EncryptionKey{},
+ CName: types.NewPrincipalName(0, ""),
+ StartTime: time.Now(),
+ AuthTime: time.Now(),
+ EndTime: time.Now(),
+ RenewTill: time.Now(),
+ },
+ },
+ EncPart: types.EncryptedData{
+ EType: 18,
+ KVNO: 2,
+ Cipher: []byte{1, 2, 3},
+ },
+ },
+ })
+ if err == nil {
+ t.Fatalf("NewCCache did not fail for undecrypted CCache")
+ }
+}
+
+func TestUnencryptedTGSRep(t *testing.T) {
+ t.Parallel()
+
+ _, err := ccachetools.NewCCacheFromTGSRep(messages.TGSRep{
+ KDCRepFields: messages.KDCRepFields{
+ PVNO: 5,
+ MsgType: 11,
+ CRealm: "realm",
+ CName: types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "user"),
+ Ticket: messages.Ticket{
+ TktVNO: 5,
+ Realm: "realm",
+ SName: types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, "krbtgt/redteam"),
+ DecryptedEncPart: messages.EncTicketPart{
+ Key: types.EncryptionKey{},
+ CName: types.NewPrincipalName(0, ""),
+ StartTime: time.Now(),
+ AuthTime: time.Now(),
+ EndTime: time.Now(),
+ RenewTill: time.Now(),
+ },
+ },
+ EncPart: types.EncryptedData{
+ EType: 18,
+ KVNO: 2,
+ Cipher: []byte{1, 2, 3},
+ },
+ },
+ })
+ if err == nil {
+ t.Fatalf("NewCCache did not fail for undecrypted CCache")
+ }
+}
+
+func validateCCache(t *testing.T, ccache *credentials.CCache) {
+ t.Helper()
+
+ if ccache.DefaultPrincipal.Realm != strings.ToUpper(testASRep.CRealm) {
+ t.Errorf("default principal realm %q does not match %q",
+ ccache.DefaultPrincipal.Realm, strings.ToUpper(testASRep.CRealm))
+ }
+
+ if ccache.DefaultPrincipal.PrincipalName.NameType != testASRep.CName.NameType {
+ t.Errorf("default principal name type %d does not match %d",
+ ccache.DefaultPrincipal.PrincipalName.NameType, testASRep.CName.NameType)
+ }
+
+ if !slices.Equal(ccache.DefaultPrincipal.PrincipalName.NameString, testASRep.CName.NameString) {
+ t.Errorf("default principal name string %v does not match %v",
+ ccache.DefaultPrincipal.PrincipalName.NameString, testASRep.CName.NameString)
+ }
+
+ if len(ccache.Credentials) != 1 {
+ t.Fatalf("found %d credentials instead of 1", len(ccache.Credentials))
+ }
+
+ cred := ccache.Credentials[0]
+
+ if cred.Key.KeyType != testASRep.DecryptedEncPart.Key.KeyType {
+ t.Errorf("key type %d does not match %d",
+ cred.Key.KeyType, testASRep.DecryptedEncPart.Key.KeyType)
+ }
+
+ if !bytes.Equal(cred.Key.KeyValue, testASRep.DecryptedEncPart.Key.KeyValue) {
+ t.Errorf("key value does not match")
+ }
+
+ if cred.Ticket == nil {
+ t.Errorf("ticket is empty")
+ }
+
+ if cred.Client.Realm != testASRep.CRealm {
+ t.Errorf("client realm %q does not match %s", cred.Client.Realm, testASRep.CRealm)
+ }
+
+ if cred.Client.PrincipalName.NameType != testASRep.CName.NameType {
+ t.Errorf("client name type %d does not match %d",
+ cred.Client.PrincipalName.NameType, testASRep.CName.NameType)
+ }
+
+ if !slices.Equal(cred.Client.PrincipalName.NameString, testASRep.CName.NameString) {
+ t.Errorf("client name string %v does not match %v",
+ cred.Client.PrincipalName.NameString, testASRep.CName.NameString)
+ }
+
+ if cred.Server.Realm != testASRep.CRealm {
+ t.Errorf("server realm %q does not match %s", cred.Server.Realm, testASRep.CRealm)
+ }
+
+ if cred.Server.PrincipalName.NameType != testASRep.CName.NameType {
+ t.Errorf("server name type %d does not match %d",
+ cred.Server.PrincipalName.NameType, testASRep.DecryptedEncPart.SName.NameType)
+ }
+
+ if !slices.Equal(cred.Server.PrincipalName.NameString, testASRep.DecryptedEncPart.SName.NameString) {
+ t.Errorf("server name string %v does not match %v",
+ cred.Server.PrincipalName.NameString, testASRep.DecryptedEncPart.SName.NameString)
+ }
+
+ if cred.AuthTime.Unix() != testASRep.DecryptedEncPart.AuthTime.Unix() {
+ t.Errorf("auth time %s does not match %s",
+ cred.AuthTime, testASRep.DecryptedEncPart.AuthTime)
+ }
+
+ if cred.StartTime.Unix() != testASRep.DecryptedEncPart.StartTime.Unix() {
+ t.Errorf("start time %s does not match %s",
+ cred.StartTime, testASRep.DecryptedEncPart.StartTime)
+ }
+
+ if cred.EndTime.Unix() != testASRep.DecryptedEncPart.EndTime.Unix() {
+ t.Errorf("end time %s does not match %s",
+ cred.EndTime, testASRep.DecryptedEncPart.EndTime)
+ }
+
+ if cred.RenewTill.Unix() != testASRep.DecryptedEncPart.RenewTill.Unix() {
+ t.Errorf("renew time %s does not match %s",
+ cred.RenewTill, testASRep.DecryptedEncPart.RenewTill)
+ }
+}
diff --git a/credentials.go b/credentials.go
new file mode 100644
index 0000000..d17d770
--- /dev/null
+++ b/credentials.go
@@ -0,0 +1,348 @@
+package adauth
+
+import (
+ "context"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/hex"
+ "fmt"
+ "net"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/RedTeamPentesting/adauth/othername"
+ "github.com/jcmturner/gokrb5/v8/config"
+ "github.com/jcmturner/gokrb5/v8/credentials"
+ "github.com/jcmturner/gokrb5/v8/iana/etypeID"
+ "github.com/jcmturner/gokrb5/v8/keytab"
+ "software.sslmate.com/src/go-pkcs12"
+)
+
+// Credential represents Active Directory credentials.
+type Credential struct {
+ // Username is the username without the domain.
+ Username string
+ // Password contains the users cleartext password if available.
+ Password string
+ // Domain holds the user's domain.
+ Domain string
+ // NTHash holds the user's NT hash or Kerberos RC4 key if available.
+ NTHash string
+ // AESKey holds the user's Kerberos AES128 or AES256 key if available.
+ AESKey string
+ // CCache contains the path to the user's CCache file.
+ CCache string
+ // ClientCert holds a client certificate for Kerberos or LDAP authentication if available.
+ ClientCert *x509.Certificate
+ // ClientCertKey holds the private key that corresponds to ClientCert.
+ ClientCertKey *rsa.PrivateKey
+ // CACerts holds CA certificates that were loaded alongside the ClientCert.
+ CACerts []*x509.Certificate
+ dc string
+ // PasswordIsEmptyString is true when an empty Password field should not be
+ // interpreted as a missing password but as a password that happens to be
+ // empty.
+ PasswordIsEmtpyString bool
+ // CCacheIsFromEnv indicates whether the CCache was set explicitly or
+ // implicitly through an environment variable.
+ CCacheIsFromEnv bool
+
+ // Resolver can be used to set an alternative DNS resolver. If empty,
+ // net.DefaultResolver is used.
+ Resolver Resolver
+}
+
+// CredentialFromPFX creates a Credential structure for certificate-based
+// authentication based on a PFX file.
+func CredentialFromPFX(
+ username string, domain string, pfxFile string, pfxPassword string,
+) (*Credential, error) {
+ pfxData, err := os.ReadFile(pfxFile)
+ if err != nil {
+ return nil, fmt.Errorf("read PFX: %w", err)
+ }
+
+ return CredentialFromPFXBytes(username, domain, pfxData, pfxPassword)
+}
+
+// CredentialFromPFX creates a Credential structure for certificate-based
+// authentication based on PFX data.
+func CredentialFromPFXBytes(
+ username string, domain string, pfxData []byte, pfxPassword string,
+) (*Credential, error) {
+ cred := &Credential{
+ Username: username,
+ Domain: domain,
+ }
+
+ key, cert, caCerts, err := pkcs12.DecodeChain(pfxData, pfxPassword)
+ if err != nil {
+ return nil, fmt.Errorf("decode PFX: %w", err)
+ }
+
+ rsaKey, ok := key.(*rsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("PFX key is not an RSA private key but %T", rsaKey)
+ }
+
+ cred.ClientCert = cert
+ cred.ClientCertKey = rsaKey
+ cred.CACerts = caCerts
+
+ user, domain, err := othername.UserAndDomain(cert)
+ if err == nil {
+ if cred.Username == "" {
+ cred.Username = user
+ }
+
+ if cred.Domain == "" {
+ cred.Domain = domain
+ }
+ }
+
+ return cred, nil
+}
+
+// UPN is the user principal name (username@domain).
+func (c *Credential) UPN() string {
+ return c.Username + "@" + c.Domain
+}
+
+// LogonName is the legacy logon name (domain\username).
+func (c *Credential) LogonName() string {
+ return c.Domain + `\` + c.Username
+}
+
+// LogonNameWithUpperCaseDomain is like LogonName with the domain capitalized
+// for compatibility with the Kerberos library (DOMAIN\username).
+func (c *Credential) LogonNameWithUpperCaseDomain() string {
+ return strings.ToUpper(c.Domain) + `\` + c.Username
+}
+
+// ImpacketLogonName is the Impacket-style logon name (domain/username).
+func (c *Credential) ImpacketLogonName() string {
+ return c.Domain + "/" + c.Username
+}
+
+// SetDC configures a specific domain controller for this credential.
+func (c *Credential) SetDC(dc string) {
+ c.dc = dc
+}
+
+// DC returns the domain controller of the credential's domain as a target.
+func (c *Credential) DC(ctx context.Context, protocol string) (*Target, error) {
+ if c.dc != "" {
+ return newTarget(protocol, c.dc, true, c.CCache, c.Resolver), nil
+ }
+
+ if c.Domain == "" {
+ return nil, fmt.Errorf("domain unknown")
+ }
+
+ _, addrs, err := ensureResolver(c.Resolver, nil).LookupSRV(ctx, "kerberos", "tcp", c.Domain)
+ if err != nil {
+ return nil, fmt.Errorf("lookup %q service of domain %q: %w", "kerberos", c.Domain, err)
+ }
+
+ if len(addrs) == 0 {
+ return nil, fmt.Errorf("no %q services were discovered for domain %q", "kerberos", c.Domain)
+ }
+
+ return newTarget(protocol, strings.TrimRight(addrs[0].Target, "."), true, c.CCache, c.Resolver), nil
+}
+
+func (c *Credential) mustUseKerberos() bool {
+ return c.Password == "" && c.NTHash == "" && (c.CCache != "" || c.AESKey != "" || c.ClientCert != nil)
+}
+
+// Keytab returns the Kerberos keytab containing the AES key and/or NT hash if
+// they were supplied. If a password is supplied, the keys/hashes are not
+// derived and the keytab will be empty.
+func (c *Credential) Keytab() (*keytab.Keytab, error) {
+ kt := newKeytab()
+
+ if c.AESKey != "" {
+ err := addKeyToKeytab(kt, c.Username, c.Domain, c.AESKey, true, 1)
+ if err != nil {
+ return nil, fmt.Errorf("add AES key: %w", err)
+ }
+ }
+
+ if c.NTHash != "" {
+ err := addKeyToKeytab(kt, c.Username, c.Domain, c.NTHash, false, 1)
+ if err != nil {
+ return nil, fmt.Errorf("add RC4 key: %w", err)
+ }
+ }
+
+ return kt, nil
+}
+
+// KerberosConfig returns the Kerberos configuration for the credential's domain.
+func (c *Credential) KerberosConfig(ctx context.Context) (*config.Config, error) {
+ dc, err := c.DC(ctx, "krbtgt")
+ if err != nil {
+ return nil, fmt.Errorf("find DC: %w", err)
+ }
+
+ krbConf := config.New()
+ krbConf.LibDefaults.DefaultRealm = strings.ToUpper(c.Domain)
+ krbConf.LibDefaults.AllowWeakCrypto = true
+ krbConf.LibDefaults.DNSLookupRealm = false
+ krbConf.LibDefaults.DNSLookupKDC = false
+ krbConf.LibDefaults.TicketLifetime = time.Duration(24) * time.Hour
+ krbConf.LibDefaults.RenewLifetime = time.Duration(24*7) * time.Hour
+ krbConf.LibDefaults.Forwardable = true
+ krbConf.LibDefaults.Proxiable = true
+ krbConf.LibDefaults.RDNS = false
+ krbConf.LibDefaults.UDPPreferenceLimit = 1 // Force use of tcp
+
+ if c.NTHash != "" {
+ // use RC4 for pre-auth but AES256 for ephemeral keys, otherwise we get
+ // unsupported GSSAPI tokens during LDAP SASL handshake
+ krbConf.LibDefaults.DefaultTGSEnctypeIDs = []int32{etypeID.AES256_CTS_HMAC_SHA1_96}
+ krbConf.LibDefaults.DefaultTktEnctypeIDs = []int32{etypeID.RC4_HMAC}
+ krbConf.LibDefaults.PermittedEnctypeIDs = []int32{etypeID.AES256_CTS_HMAC_SHA1_96}
+ krbConf.LibDefaults.PreferredPreauthTypes = []int{int(etypeID.RC4_HMAC)}
+ } else {
+ krbConf.LibDefaults.DefaultTGSEnctypeIDs = []int32{etypeID.AES256_CTS_HMAC_SHA1_96}
+ krbConf.LibDefaults.DefaultTktEnctypeIDs = []int32{
+ etypeID.AES256_CTS_HMAC_SHA1_96, etypeID.AES128_CTS_HMAC_SHA1_96, etypeID.RC4_HMAC,
+ }
+ krbConf.LibDefaults.PermittedEnctypeIDs = []int32{etypeID.AES256_CTS_HMAC_SHA1_96}
+ krbConf.LibDefaults.PreferredPreauthTypes = []int{
+ int(etypeID.AES256_CTS_HMAC_SHA1_96), int(etypeID.AES128_CTS_HMAC_SHA1_96), int(etypeID.RC4_HMAC),
+ }
+ }
+
+ krbConf.Realms = []config.Realm{
+ {
+ Realm: strings.ToUpper(c.Domain),
+ DefaultDomain: strings.ToUpper(c.Domain),
+ AdminServer: []string{dc.AddressWithoutPort()},
+ KDC: []string{net.JoinHostPort(dc.AddressWithoutPort(), "88")},
+ KPasswdServer: []string{net.JoinHostPort(dc.AddressWithoutPort(), "464")},
+ MasterKDC: []string{dc.AddressWithoutPort()},
+ },
+ {
+ Realm: c.Domain,
+ DefaultDomain: c.Domain,
+ AdminServer: []string{dc.AddressWithoutPort()},
+ KDC: []string{net.JoinHostPort(dc.AddressWithoutPort(), "88")},
+ KPasswdServer: []string{net.JoinHostPort(dc.AddressWithoutPort(), "464")},
+ MasterKDC: []string{dc.AddressWithoutPort()},
+ },
+ }
+ krbConf.DomainRealm = map[string]string{
+ "." + c.Domain: strings.ToUpper(c.Domain),
+ c.Domain: strings.ToUpper(c.Domain),
+ }
+
+ return krbConf, nil
+}
+
+func splitUserIntoDomainAndUsername(user string) (domain string, username string) {
+ switch {
+ case strings.Contains(user, "@"):
+ parts := strings.Split(user, "@")
+ if len(parts) == 2 {
+ return parts[1], parts[0]
+ }
+
+ return "", user
+ case strings.Contains(user, `\`):
+ parts := strings.Split(user, `\`)
+ if len(parts) == 2 {
+ return parts[0], parts[1]
+ }
+
+ return "", user
+ case strings.Contains(user, "/"):
+ parts := strings.Split(user, "/")
+ if len(parts) == 2 {
+ return parts[0], parts[1]
+ }
+
+ return "", user
+ default:
+ return "", user
+ }
+}
+
+func newKeytab() *keytab.Keytab {
+ kt := &keytab.Keytab{}
+
+ err := kt.Unmarshal([]byte{
+ // header.
+ 0x05, // first-byte
+ 0x02, // version
+ 0x00, 0x00, 0x00, 0x00, // entry-length
+ })
+ if err != nil {
+ panic(err.Error())
+ }
+
+ return kt
+}
+
+func addKeyToKeytab(kt *keytab.Keytab, username string, domain string, key string, aes bool, kvno uint32) error {
+ keyBytes, err := hex.DecodeString(key)
+ if err != nil {
+ return fmt.Errorf("decode AES key: %w", err)
+ }
+
+ var keyType int32
+
+ switch len(keyBytes) {
+ case 32:
+ keyType = etypeID.AES256_CTS_HMAC_SHA1_96
+ case 16:
+ if aes {
+ keyType = etypeID.AES128_CTS_HMAC_SHA1_96
+ } else {
+ keyType = etypeID.RC4_HMAC
+ }
+ default:
+ return fmt.Errorf("invalid AES128/AES256 key")
+ }
+
+ tmp := &keytab.Keytab{}
+
+ err = tmp.Unmarshal([]byte{
+ // header
+ 0x05, // first-byte
+ 0x02, // version
+ 0x00, 0x00, 0x00, 0x11, // entry-length
+ // principal
+ 0x00, 0x00, // num components
+ 0x00, 0x00, // realm length
+ 0x00, 0x00, 0x00, 0x00, // name type
+ // key
+ 0x00, 0x00, 0x00, 0x00, // timestamp
+ 0x00, // kvno8
+ 0x00, 0x00, // key type
+ 0x00, 0x00, // key length
+ })
+ if err != nil {
+ return fmt.Errorf("invalid dummy data: %w", err)
+ }
+
+ e := tmp.Entries[0]
+
+ krbCreds := credentials.New(username, domain)
+ e.Principal.NumComponents = int16(len(krbCreds.CName().NameString))
+ e.Principal.Components = krbCreds.CName().NameString
+ e.Principal.Realm = strings.ToUpper(krbCreds.Realm())
+ e.Principal.NameType = krbCreds.CName().NameType
+
+ e.Timestamp = time.Now()
+ e.KVNO8 = 0
+ e.Key.KeyType = keyType
+ e.Key.KeyValue = keyBytes
+ e.KVNO = kvno
+
+ kt.Entries = append(kt.Entries, e)
+
+ return nil
+}
diff --git a/credentials_test.go b/credentials_test.go
new file mode 100644
index 0000000..74a58bf
--- /dev/null
+++ b/credentials_test.go
@@ -0,0 +1,193 @@
+package adauth_test
+
+import (
+ "context"
+ "encoding/hex"
+ "net"
+ "strconv"
+ "strings"
+ "testing"
+
+ "github.com/RedTeamPentesting/adauth"
+ "github.com/jcmturner/gokrb5/v8/iana/etypeID"
+ "github.com/jcmturner/gokrb5/v8/iana/nametype"
+ "github.com/jcmturner/gokrb5/v8/types"
+)
+
+func TestKeytab(t *testing.T) {
+ expectedNTHash := hex.EncodeToString(make([]byte, 16))
+ expectedAES256Key := hex.EncodeToString(make([]byte, 32))
+ expectedAES128Key := hex.EncodeToString(make([]byte, 16))
+ principal := types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, testUser)
+
+ testCases := []struct {
+ Cred adauth.Credential
+ ShouldHaveRC4Key bool
+ ShouldHaveAES128Key bool
+ ShouldHaveAES256Key bool
+ }{
+ {
+ Cred: adauth.Credential{},
+ ShouldHaveRC4Key: false,
+ ShouldHaveAES128Key: false,
+ ShouldHaveAES256Key: false,
+ },
+ {
+ Cred: adauth.Credential{
+ NTHash: expectedNTHash,
+ },
+ ShouldHaveRC4Key: true,
+ ShouldHaveAES128Key: false,
+ ShouldHaveAES256Key: false,
+ },
+ {
+ Cred: adauth.Credential{
+ NTHash: expectedNTHash,
+ AESKey: expectedAES128Key,
+ },
+ ShouldHaveRC4Key: true,
+ ShouldHaveAES128Key: true,
+ ShouldHaveAES256Key: false,
+ },
+ {
+ Cred: adauth.Credential{
+ NTHash: expectedNTHash,
+ AESKey: expectedAES256Key,
+ },
+ ShouldHaveRC4Key: true,
+ ShouldHaveAES128Key: false,
+ ShouldHaveAES256Key: true,
+ },
+ {
+ Cred: adauth.Credential{
+ AESKey: expectedAES128Key,
+ },
+ ShouldHaveRC4Key: false,
+ ShouldHaveAES128Key: true,
+ ShouldHaveAES256Key: false,
+ },
+ {
+ Cred: adauth.Credential{
+ AESKey: expectedAES256Key,
+ },
+ ShouldHaveRC4Key: false,
+ ShouldHaveAES128Key: false,
+ ShouldHaveAES256Key: true,
+ },
+ }
+
+ for i, testCase := range testCases {
+ testCase := testCase
+
+ t.Run(strconv.Itoa(i), func(t *testing.T) {
+ testCase.Cred.Username = testUser
+ testCase.Cred.Domain = testDomain
+
+ keyTab, err := testCase.Cred.Keytab()
+ if err != nil {
+ t.Fatalf("create keytab: %v", err)
+ }
+
+ rc4Key, _, rc4Err := keyTab.GetEncryptionKey(principal, strings.ToUpper(testDomain), 0, etypeID.RC4_HMAC)
+
+ switch {
+ case testCase.ShouldHaveRC4Key && rc4Err != nil:
+ t.Errorf("expected RC4 key but got error: %v", rc4Err)
+ case testCase.ShouldHaveRC4Key && len(rc4Key.KeyValue) != 16:
+ t.Errorf("RC4 key has %d bytes instead of %d", len(rc4Key.KeyValue), 16)
+ case testCase.ShouldHaveRC4Key && rc4Key.KeyType != etypeID.RC4_HMAC:
+ t.Errorf("RC4 key type is %d instead of %d", rc4Key.KeyType, etypeID.RC4_HMAC)
+ case !testCase.ShouldHaveRC4Key && (rc4Err == nil || len(rc4Key.KeyValue) > 0):
+ t.Errorf("RC4 key should not exist")
+ }
+
+ aes128Key, _, aes128Err := keyTab.GetEncryptionKey(
+ principal, strings.ToUpper(testDomain), 0, etypeID.AES128_CTS_HMAC_SHA1_96)
+
+ switch {
+ case testCase.ShouldHaveAES128Key && aes128Err != nil:
+ t.Errorf("expected AES128 key but got error: %v:\n%#v\n", aes128Err, keyTab.Entries)
+ case testCase.ShouldHaveAES128Key && len(aes128Key.KeyValue) != 16:
+ t.Errorf("AES128 key has %d bytes instead of %d", len(aes128Key.KeyValue), 16)
+ case testCase.ShouldHaveAES128Key && aes128Key.KeyType != etypeID.AES128_CTS_HMAC_SHA1_96:
+ t.Errorf("AES128 key type is %d instead of %d", aes128Key.KeyType, etypeID.AES128_CTS_HMAC_SHA1_96)
+ case !testCase.ShouldHaveAES128Key && (aes128Err == nil || len(aes128Key.KeyValue) > 0):
+ t.Errorf("AES128 key should not exist")
+ }
+
+ aes256Key, _, aes256Err := keyTab.GetEncryptionKey(
+ principal, strings.ToUpper(testDomain), 0, etypeID.AES256_CTS_HMAC_SHA1_96)
+
+ switch {
+ case testCase.ShouldHaveAES256Key && aes256Err != nil:
+ t.Errorf("expected AES256 key but got error: %v", aes256Err)
+ case testCase.ShouldHaveAES256Key && len(aes256Key.KeyValue) != 32:
+ t.Errorf("AES256 key has %d bytes instead of %d", len(aes256Key.KeyValue), 32)
+ case testCase.ShouldHaveAES256Key && aes256Key.KeyType != etypeID.AES256_CTS_HMAC_SHA1_96:
+ t.Errorf("AES256 key type is %d instead of %d", aes256Key.KeyType, etypeID.AES256_CTS_HMAC_SHA1_96)
+ case !testCase.ShouldHaveAES256Key && (aes256Err == nil || len(aes256Key.KeyValue) > 0):
+ t.Errorf("AES256 key should not exist")
+ }
+ })
+ }
+}
+
+func TestSetDC(t *testing.T) {
+ creds := adauth.Credential{
+ Username: testUser,
+ Domain: testDomain,
+ Resolver: &testResolver{},
+ }
+
+ _, err := creds.DC(context.Background(), "host")
+ if err == nil {
+ t.Fatalf("expected creds.DC() to fail initially")
+ }
+
+ dcHostname := "dc." + testDomain
+ creds.SetDC(dcHostname)
+
+ dc, err := creds.DC(context.Background(), "host")
+ if err != nil {
+ t.Fatalf("get DC: %v", err)
+ }
+
+ if dc.Address() != dcHostname {
+ t.Fatalf("DC address is %q instead of %q", dc.Address(), dcHostname)
+ }
+}
+
+func TestLookupDC(t *testing.T) {
+ dcHostname := "dc." + testDomain
+
+ creds := adauth.Credential{
+ Username: testUser,
+ Domain: testDomain,
+ Resolver: &testResolver{
+ SRV: map[string]map[string]map[string]struct {
+ Name string
+ SRV []*net.SRV
+ }{
+ "kerberos": {
+ "tcp": {
+ testDomain: {
+ Name: dcHostname,
+ SRV: []*net.SRV{
+ {Target: dcHostname, Port: 88},
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ dc, err := creds.DC(context.Background(), "host")
+ if err != nil {
+ t.Fatalf("get DC: %v", err)
+ }
+
+ if dc.AddressWithoutPort() != dcHostname {
+ t.Fatalf("DC address is %q instead of %q", dc.Address(), dcHostname)
+ }
+}
diff --git a/dcerpcauth/dcerpcauth.go b/dcerpcauth/dcerpcauth.go
new file mode 100644
index 0000000..124aab9
--- /dev/null
+++ b/dcerpcauth/dcerpcauth.go
@@ -0,0 +1,189 @@
+package dcerpcauth
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/RedTeamPentesting/adauth"
+ "github.com/RedTeamPentesting/adauth/pkinit"
+
+ "github.com/jcmturner/gokrb5/v8/keytab"
+ "github.com/oiweiwei/go-msrpc/dcerpc"
+ "github.com/oiweiwei/go-msrpc/smb2"
+ "github.com/oiweiwei/go-msrpc/ssp"
+ "github.com/oiweiwei/go-msrpc/ssp/credential"
+ "github.com/oiweiwei/go-msrpc/ssp/gssapi"
+ "github.com/oiweiwei/go-msrpc/ssp/krb5"
+)
+
+// Options holds options that modify the behavior of the AuthenticationOptions
+// function.
+type Options struct {
+ // SMBOptions holds options for the SMB dialer. This dialer is only used
+ // with the named pipe transport. If SMBOptions is nil, sealing will be
+ // enabled for the smb dialer, specify an empty slice to disable this
+ // default.
+ SMBOptions []smb2.DialerOption
+ // PKINITOptions can be used to modify the Kerberos PKINIT behavior.
+ PKINITOptions []pkinit.Option
+ // Debug can be set to enable debug output, for example with
+ // adauth.NewDebugFunc(...).
+ Debug func(string, ...any)
+}
+
+func (opts *Options) debug(format string, a ...any) {
+ if opts == nil || opts.Debug == nil {
+ return
+ }
+
+ opts.Debug(format, a...)
+}
+
+// AuthenticationOptions returns dcerpc.Options for dcerpc.Dial or for
+// constructing an DCERPC API client. It is possible to configure the SMB,
+// PKINIT and debug behavior using the optional upstreamOptions argument.
+func AuthenticationOptions(
+ ctx context.Context, creds *adauth.Credential, target *adauth.Target,
+ upstreamOptions *Options,
+) (dcerpcOptions []dcerpc.Option, err error) {
+ if upstreamOptions == nil {
+ upstreamOptions = &Options{}
+ }
+
+ dcerpcCredentials, err := DCERPCCredentials(ctx, creds, upstreamOptions)
+ if err != nil {
+ return nil, err
+ }
+
+ dcerpcOptions = append(dcerpcOptions, dcerpc.WithMechanism(ssp.SPNEGO))
+
+ switch {
+ case target.UseKerberos:
+ spn, err := target.SPN(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("build SPN: %w", err)
+ }
+
+ upstreamOptions.debug("Using Kerberos with SPN %q", spn)
+
+ krbConf, err := creds.KerberosConfig(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("generate Kerberos config: %w", err)
+ }
+
+ smbOptions := upstreamOptions.SMBOptions
+ if smbOptions == nil {
+ smbOptions = append(smbOptions, smb2.WithSeal())
+ }
+
+ smbOptions = append(smbOptions, smb2.WithSecurity(
+ gssapi.WithTargetName(spn),
+ gssapi.WithCredential(dcerpcCredentials),
+ gssapi.WithMechanismFactory(ssp.KRB5, &krb5.Config{
+ KRB5Config: krbConf,
+ CCachePath: creds.CCache,
+ DisablePAFXFAST: true,
+ DCEStyle: true,
+ }),
+ ))
+
+ dcerpcOptions = append(dcerpcOptions,
+ dcerpc.WithTargetName(spn),
+ dcerpc.WithMechanism(ssp.KRB5),
+ dcerpc.WithSecurityConfig(&krb5.Config{
+ KRB5Config: krbConf,
+ CCachePath: creds.CCache,
+ DisablePAFXFAST: true,
+ DCEStyle: true,
+ }),
+ dcerpc.WithSMBDialer(smb2.NewDialer(smbOptions...)),
+ )
+ default:
+ upstreamOptions.debug("Using NTLM")
+
+ dcerpcOptions = append(dcerpcOptions,
+ dcerpc.WithMechanism(ssp.NTLM),
+ )
+
+ spn, err := target.SPN(ctx)
+ if err == nil {
+ dcerpcOptions = append(dcerpcOptions, dcerpc.WithTargetName(spn))
+ }
+ }
+
+ dcerpcOptions = append(dcerpcOptions, dcerpc.WithCredentials(dcerpcCredentials))
+
+ return dcerpcOptions, nil
+}
+
+func DCERPCCredentials(ctx context.Context, creds *adauth.Credential, options *Options) (credential.Credential, error) {
+ switch {
+ case creds.Password != "":
+ options.debug("Authenticating with password")
+
+ return credential.NewFromPassword(creds.LogonNameWithUpperCaseDomain(), creds.Password), nil
+ case creds.AESKey != "":
+ options.debug("Authenticating with AES key")
+
+ keyTab, err := creds.Keytab()
+ if err != nil {
+ return nil, fmt.Errorf("create keytab: %w", err)
+ }
+
+ return &keytabCredentials{username: creds.Username, domain: creds.Domain, keytab: keyTab}, nil
+ case creds.NTHash != "":
+ options.debug("Authenticating with NT hash")
+
+ return credential.NewFromNTHash(creds.LogonNameWithUpperCaseDomain(), creds.NTHash), nil
+ case creds.PasswordIsEmtpyString:
+ options.debug("Authenticating with empty password")
+
+ return credential.NewFromPassword(strings.ToUpper(creds.Domain)+`\`+creds.Username, ""), nil
+ case creds.ClientCert != nil:
+ options.debug("Authenticating with client certificate (PKINIT)")
+
+ krbConf, err := creds.KerberosConfig(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("generate kerberos config: %w", err)
+ }
+
+ ccache, err := pkinit.Authenticate(ctx, creds.Username, strings.ToUpper(creds.Domain),
+ creds.ClientCert, creds.ClientCertKey, krbConf, options.PKINITOptions...)
+ if err != nil {
+ return nil, fmt.Errorf("PKINIT: %w", err)
+ }
+
+ return credential.NewFromCCache(creds.LogonNameWithUpperCaseDomain(), ccache), nil
+ case creds.CCache != "":
+ options.debug("Authenticating with ccache")
+
+ return credential.NewFromPassword(creds.LogonNameWithUpperCaseDomain(), ""), nil
+ default:
+ return nil, fmt.Errorf("no credentials available")
+ }
+}
+
+type keytabCredentials struct {
+ keytab *keytab.Keytab
+ username string
+ domain string
+}
+
+var _ credential.Keytab = &keytabCredentials{}
+
+func (ktc *keytabCredentials) DomainName() string {
+ return strings.ToUpper(ktc.domain)
+}
+
+func (ctc *keytabCredentials) Workstation() string {
+ return ""
+}
+
+func (ctc *keytabCredentials) UserName() string {
+ return ctc.username
+}
+
+func (ctc *keytabCredentials) Keytab() *keytab.Keytab {
+ return ctc.keytab
+}
diff --git a/examples/dcerpc/main.go b/examples/dcerpc/main.go
new file mode 100644
index 0000000..8be9282
--- /dev/null
+++ b/examples/dcerpc/main.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+
+ "github.com/RedTeamPentesting/adauth"
+ "github.com/RedTeamPentesting/adauth/dcerpcauth"
+ "github.com/oiweiwei/go-msrpc/dcerpc"
+ "github.com/oiweiwei/go-msrpc/msrpc/dtyp"
+ "github.com/oiweiwei/go-msrpc/msrpc/epm/epm/v3"
+ "github.com/oiweiwei/go-msrpc/msrpc/samr/samr/v1"
+ "github.com/oiweiwei/go-msrpc/ssp/gssapi"
+ "github.com/spf13/pflag"
+)
+
+func run() error {
+ var (
+ debug bool
+ authOpts = &adauth.Options{
+ Debug: adauth.NewDebugFunc(&debug, os.Stderr, true),
+ }
+ dcerpcauthOpts = &dcerpcauth.Options{
+ Debug: authOpts.Debug,
+ }
+ namedPipe bool
+ )
+
+ pflag.CommandLine.BoolVar(&debug, "debug", false, "Enable debug output")
+ pflag.CommandLine.BoolVar(&namedPipe, "named-pipe", false, "Use named pipe (SMB) as transport")
+ authOpts.RegisterFlags(pflag.CommandLine)
+ pflag.Parse()
+
+ if len(pflag.Args()) != 1 {
+ return fmt.Errorf("usage: %s [options] ", binaryName())
+ }
+
+ creds, target, err := authOpts.WithTarget(context.Background(), "host", pflag.Arg(0))
+ if err != nil {
+ return err
+ }
+
+ ctx := gssapi.NewSecurityContext(context.Background())
+
+ dcerpcOpts, err := dcerpcauth.AuthenticationOptions(ctx, creds, target, dcerpcauthOpts)
+ if err != nil {
+ return err
+ }
+
+ dcerpcOpts = append(dcerpcOpts, epm.EndpointMapper(ctx,
+ net.JoinHostPort(target.AddressWithoutPort(), "135"),
+ dcerpc.WithInsecure(),
+ ))
+
+ proto := "ncacn_ip_tcp:"
+ if namedPipe {
+ proto = "ncacn_np:"
+ }
+
+ conn, err := dcerpc.Dial(ctx, proto+target.Address(), dcerpcOpts...)
+ if err != nil {
+ return fmt.Errorf("dial DCERPC: %w", err)
+ }
+
+ defer conn.Close(ctx) //nolint:errcheck
+
+ samrClient, err := samr.NewSamrClient(ctx, conn, dcerpc.WithSeal())
+ if err != nil {
+ return fmt.Errorf("create SAMR client: %w", err)
+ }
+
+ connectResponse, err := samrClient.Connect(ctx, &samr.ConnectRequest{DesiredAccess: 0x02000000})
+ if err != nil {
+ return fmt.Errorf("SAMR connect: %w", err)
+ }
+
+ lookupDomainResponse, err := samrClient.LookupDomainInSAMServer(ctx, &samr.LookupDomainInSAMServerRequest{
+ Server: connectResponse.Server,
+ Name: &dtyp.UnicodeString{
+ Buffer: creds.Domain,
+ },
+ })
+ if err != nil {
+ return fmt.Errorf("SAMR lookup domain %q: %w", creds.Domain, err)
+ }
+
+ fmt.Println("Domain SID:", lookupDomainResponse.DomainID)
+
+ return nil
+}
+
+func binaryName() string {
+ executable, err := os.Executable()
+ if err == nil {
+ return filepath.Base(executable)
+ }
+
+ if len(os.Args) > 0 {
+ return filepath.Base(os.Args[0])
+ }
+
+ return "msrpc"
+}
+
+func main() {
+ err := run()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+
+ os.Exit(1)
+ }
+}
diff --git a/examples/ldap/main.go b/examples/ldap/main.go
new file mode 100644
index 0000000..a0a3202
--- /dev/null
+++ b/examples/ldap/main.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/RedTeamPentesting/adauth"
+ "github.com/RedTeamPentesting/adauth/ldapauth"
+ "github.com/spf13/pflag"
+)
+
+func run() error {
+ var (
+ debug bool
+ authOpts = &adauth.Options{
+ Debug: adauth.NewDebugFunc(&debug, os.Stderr, true),
+ }
+ ldapOpts = &ldapauth.Options{
+ Debug: adauth.NewDebugFunc(&debug, os.Stderr, true),
+ }
+ )
+
+ pflag.CommandLine.BoolVar(&debug, "debug", false, "Enable debug output")
+ authOpts.RegisterFlags(pflag.CommandLine)
+ ldapOpts.RegisterFlags(pflag.CommandLine)
+ pflag.Parse()
+
+ conn, err := ldapauth.Connect(context.Background(), authOpts, ldapOpts)
+ if err != nil {
+ return fmt.Errorf("%s connect: %w", ldapOpts.Scheme, err)
+ }
+
+ defer conn.Close() //nolint:errcheck
+
+ res, err := conn.WhoAmI(nil)
+ if err != nil {
+ return fmt.Errorf("whoami: %w", err)
+ }
+
+ fmt.Println("whoami:", res.AuthzID)
+
+ return nil
+}
+
+func main() {
+ err := run()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+
+ os.Exit(1)
+ }
+}
diff --git a/examples/pkinit/main.go b/examples/pkinit/main.go
new file mode 100644
index 0000000..30879db
--- /dev/null
+++ b/examples/pkinit/main.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/RedTeamPentesting/adauth/ccachetools"
+ "github.com/RedTeamPentesting/adauth/pkinit"
+ "github.com/spf13/pflag"
+)
+
+func run() error {
+ var (
+ username string
+ domain string
+ pfxFile string
+ pfxPassword string
+ ccacheName string
+ dc string
+ )
+
+ pflag.StringVarP(&username, "username", "u", "", "Username (overrides UPN in PFX)")
+ pflag.StringVarP(&domain, "domain", "d", "", "Domain (overrides UPN in PFX)")
+ pflag.StringVar(&pfxFile, "pfx", "", "PFX file")
+ pflag.StringVarP(&pfxPassword, "pfx-password", "p", "", "PFX file password")
+ pflag.StringVar(&ccacheName, "cache", "", "CCache output file name")
+ pflag.StringVar(&dc, "dc", "", "Domain controller (optional)")
+ pflag.Parse()
+
+ ccache, hash, err := pkinit.UnPACTheHashFromPFX(context.Background(), username, domain, pfxFile, pfxPassword, dc)
+ if err != nil {
+ return fmt.Errorf("UnPAC-the-Hash: %w", err)
+ }
+
+ fmt.Println("Authentication was successful")
+ fmt.Printf("%s\\%s: %s\n", ccache.DefaultPrincipal.Realm,
+ ccache.DefaultPrincipal.PrincipalName.PrincipalNameString(), hash.Combined())
+
+ if ccacheName != "" {
+ ccacheBytes, err := ccachetools.MarshalCCache(ccache)
+ if err != nil {
+ return fmt.Errorf("marshal CCache: %w", err)
+ }
+
+ err = os.WriteFile(ccacheName, ccacheBytes, 0o600)
+ if err != nil {
+ return fmt.Errorf("write CCache: %w", err)
+ }
+
+ fmt.Println("Saved CCache at", ccacheName)
+ }
+
+ return nil
+}
+
+func main() {
+ err := run()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+
+ os.Exit(1)
+ }
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..9c94a43
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,35 @@
+module github.com/RedTeamPentesting/adauth
+
+go 1.23.3
+
+require (
+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
+ github.com/go-ldap/ldap/v3 v3.4.11-0.20250110131057-5d1b644709df
+ github.com/jcmturner/gokrb5/v8 v8.4.4
+ github.com/oiweiwei/go-msrpc v1.1.1
+ github.com/spf13/pflag v1.0.5
+ github.com/vadimi/go-ntlm v1.2.1
+ software.sslmate.com/src/go-pkcs12 v0.5.0
+)
+
+require (
+ github.com/geoffgarside/ber v1.1.0 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/hashicorp/go-uuid v1.0.3 // indirect
+ github.com/hirochachacha/go-smb2 v1.1.0 // indirect
+ github.com/indece-official/go-ebcdic v1.2.0 // indirect
+ github.com/jcmturner/aescts/v2 v2.0.0 // indirect
+ github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
+ github.com/jcmturner/gofork v1.7.6 // indirect
+ github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
+ github.com/jcmturner/rpc/v2 v2.0.3 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/oiweiwei/go-smb2.fork v0.0.0-20241220141821-a5dd82e09d5f // indirect
+ github.com/rs/zerolog v1.33.0 // indirect
+ golang.org/x/crypto v0.32.0 // indirect
+ golang.org/x/net v0.34.0 // indirect
+ golang.org/x/sys v0.29.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..d5c517d
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,157 @@
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
+github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
+github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
+github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
+github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
+github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-ldap/ldap/v3 v3.4.11-0.20250110131057-5d1b644709df h1:d8rpIi5VcFIXam3EpxsxJa8vUQl0C+D16xdY86XdtpE=
+github.com/go-ldap/ldap/v3 v3.4.11-0.20250110131057-5d1b644709df/go.mod h1:WfOSWXTbpVbGgruuVrZQzPR810wu/qBWTzYA5FZA+Es=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
+github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI=
+github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE=
+github.com/indece-official/go-ebcdic v1.2.0 h1:nKCubkNoXrGvBp3MSYuplOQnhANCDEY512Ry5Mwr4a0=
+github.com/indece-official/go-ebcdic v1.2.0/go.mod h1:RBddVJt0Ks0eDLRG5dhPwBDRiTNA7n+yv0dVFpSs46Q=
+github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
+github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
+github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
+github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
+github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/oiweiwei/go-msrpc v1.1.1 h1:QhWTLSFFC+2weuymZsjsHmY/LaaVg3fdTmyn/2ZQHLM=
+github.com/oiweiwei/go-msrpc v1.1.1/go.mod h1:AiQkqNul+mAkvjlI8lla2q7aJCgQ47+PnJubD/d5Tak=
+github.com/oiweiwei/go-smb2.fork v0.0.0-20241220141821-a5dd82e09d5f h1:IETUC4J3re2E3/4on55u4eKiD66+sd92sQkEwjWMIsU=
+github.com/oiweiwei/go-smb2.fork v0.0.0-20241220141821-a5dd82e09d5f/go.mod h1:h0CzLVvGAmq39izdYVHKyI5cLv6aHdbQAMKEe4dz4N8=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
+github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/vadimi/go-ntlm v1.2.1 h1:y2xZf/a5+BJlYNJIIulP1q8F438H9bU7aGcYE53vghQ=
+github.com/vadimi/go-ntlm v1.2.1/go.mod h1:hPTY60eLSKGj9oUJAB+kZiLs2Cg5eKdH60aLczM9rMg=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
+golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
+golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
+golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
+golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M=
+software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
diff --git a/ldapauth/gssapi.go b/ldapauth/gssapi.go
new file mode 100644
index 0000000..c06724a
--- /dev/null
+++ b/ldapauth/gssapi.go
@@ -0,0 +1,366 @@
+package ldapauth
+
+import (
+ "bytes"
+ "context"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/RedTeamPentesting/adauth/pkinit"
+ "github.com/jcmturner/gokrb5/v8/client"
+ "github.com/jcmturner/gokrb5/v8/config"
+ "github.com/jcmturner/gokrb5/v8/iana/chksumtype"
+ "github.com/jcmturner/gokrb5/v8/iana/etypeID"
+ "github.com/jcmturner/gokrb5/v8/iana/flags"
+ "github.com/jcmturner/gokrb5/v8/iana/nametype"
+ "github.com/jcmturner/gokrb5/v8/krberror"
+ "github.com/jcmturner/gokrb5/v8/types"
+
+ krb5GSSAPI "github.com/jcmturner/gokrb5/v8/gssapi"
+ "github.com/jcmturner/gokrb5/v8/spnego"
+
+ krb5Crypto "github.com/jcmturner/gokrb5/v8/crypto"
+ "github.com/jcmturner/gokrb5/v8/iana/keyusage"
+ "github.com/jcmturner/gokrb5/v8/messages"
+
+ "github.com/jcmturner/gokrb5/v8/credentials"
+)
+
+type gssapiClient struct {
+ *client.Client
+ ccache *credentials.CCache
+
+ ekey types.EncryptionKey
+ Subkey types.EncryptionKey
+
+ BindCertificate *x509.Certificate
+}
+
+func newClientFromCCache(
+ username string, domain string, ccachePath string, krb5Conf *config.Config,
+) (*gssapiClient, error) {
+ ccache, err := credentials.LoadCCache(ccachePath)
+ if err != nil {
+ return nil, err
+ }
+
+ c, err := client.NewFromCCache(ccache, krb5Conf, client.DisablePAFXFAST(true))
+ if err != nil && strings.Contains(strings.ToLower(err.Error()), "tgt not found") {
+ // client.NewFromCCache only accepts ccaches that contain at least one
+ // TGT, however, we want to support ccaches that only contain a service
+ // ticket. Therefore, we use a dummy client, and pull the service ticket
+ // from the ccache ourselves instead of asking the client.
+ return &gssapiClient{
+ Client: client.NewWithPassword(username, domain, "", krb5Conf, client.DisablePAFXFAST(true)),
+ ccache: ccache,
+ }, nil
+ }
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &gssapiClient{Client: c}, nil
+}
+
+func newPKINITClient(
+ ctx context.Context, username string, domain string, cert *x509.Certificate, key *rsa.PrivateKey,
+ krb5Conf *config.Config, opts ...pkinit.Option,
+) (*gssapiClient, error) {
+ ccache, err := pkinit.Authenticate(ctx, username, domain, cert, key, krb5Conf, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("pkinit: %w", err)
+ }
+
+ c, err := client.NewFromCCache(ccache, krb5Conf, client.DisablePAFXFAST(true))
+ if err != nil {
+ return nil, fmt.Errorf("initialize Kerberos client from PKINIT ccache: %w", err)
+ }
+
+ return &gssapiClient{Client: c}, nil
+}
+
+// Close deletes any established secure context and closes the client.
+func (client *gssapiClient) Close() error {
+ client.Client.Destroy()
+
+ return nil
+}
+
+// DeleteSecContext destroys any established secure context.
+func (client *gssapiClient) DeleteSecContext() error {
+ client.ekey = types.EncryptionKey{}
+ client.Subkey = types.EncryptionKey{}
+
+ return nil
+}
+
+func (client *gssapiClient) getServiceTicket(target string) (tkt messages.Ticket, key types.EncryptionKey, err error) {
+ // ask our own copy of the ccache for a suitable service ticket before asking the client
+ if client.ccache != nil {
+ entry, ok := client.ccache.GetEntry(types.NewPrincipalName(nametype.KRB_NT_SRV_INST, target))
+ if !ok {
+ return tkt, key, fmt.Errorf("CCACHE does not contain service ticket for %q", target)
+ }
+
+ if entry.Key.KeyType == etypeID.RC4_HMAC {
+ return tkt, key, fmt.Errorf("RC4 tickets from ccache are currently not supported " +
+ "(see https://github.com/jcmturner/gokrb5/pull/498), but you should be able " +
+ "to request an AES256 ticket instead (even with NT hash)")
+ }
+
+ return tkt, entry.Key, tkt.Unmarshal(entry.Ticket)
+ }
+
+ return client.Client.GetServiceTicket(target)
+}
+
+func (client *gssapiClient) newKRB5TokenAPREQ(
+ tkt messages.Ticket, ekey types.EncryptionKey,
+) (*spnego.KRB5Token, error) {
+ gssapiFlags := []int{krb5GSSAPI.ContextFlagInteg, krb5GSSAPI.ContextFlagConf, krb5GSSAPI.ContextFlagMutual}
+
+ // this actually does nothing important, we simply use it to obtain a dummy
+ // KRB5Token with tokID set, which unfortunately is private, so we cannot
+ // initialize it ourselves.
+ token, err := spnego.NewKRB5TokenAPREQ(client.Client, tkt, ekey, gssapiFlags, []int{flags.APOptionMutualRequired})
+ if err != nil {
+ return nil, err
+ }
+
+ // build a custom authenticator that supports channel binding
+ authenticator, err := krb5TokenAuthenticator(
+ client.Credentials.Realm(), client.Credentials.CName(), client.BindCertificate, gssapiFlags)
+ if err != nil {
+ return nil, fmt.Errorf("create authenticator: %w", err)
+ }
+
+ APReq, err := messages.NewAPReq(
+ tkt,
+ ekey,
+ authenticator,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ types.SetFlag(&APReq.APOptions, flags.APOptionMutualRequired)
+
+ // put the APReq with custom authenticator into the dummy RKB5Token
+ token.APReq = APReq
+
+ return &token, nil
+}
+
+// InitSecContext initiates the establishment of a security context for
+// GSS-API between the client and server.
+// See RFC 4752 section 3.1.
+func (client *gssapiClient) InitSecContext(target string, input []byte) ([]byte, bool, error) {
+ switch input {
+ case nil:
+ tkt, ekey, err := client.getServiceTicket(target)
+ if err != nil {
+ return nil, false, err
+ }
+
+ client.ekey = ekey
+
+ token, err := client.newKRB5TokenAPREQ(tkt, ekey)
+ if err != nil {
+ return nil, false, err
+ }
+
+ output, err := token.Marshal()
+ if err != nil {
+ return nil, false, err
+ }
+
+ return output, true, nil
+
+ default:
+ var token spnego.KRB5Token
+
+ err := token.Unmarshal(input)
+ if err != nil {
+ return nil, false, err
+ }
+
+ var completed bool
+
+ if token.IsAPRep() {
+ completed = true
+
+ encpart, err := krb5Crypto.DecryptEncPart(token.APRep.EncPart, client.ekey, keyusage.AP_REP_ENCPART)
+ if err != nil {
+ return nil, false, err
+ }
+
+ part := &messages.EncAPRepPart{}
+
+ if err = part.Unmarshal(encpart); err != nil {
+ return nil, false, err
+ }
+
+ client.Subkey = part.Subkey
+ }
+
+ if token.IsKRBError() {
+ return nil, !false, token.KRBError
+ }
+
+ return make([]byte, 0), !completed, nil
+ }
+}
+
+// Fixed version of SASL authentication based on https://github.com/go-ldap/ldap/pull/537.
+func (client *gssapiClient) NegotiateSaslAuth(input []byte, authzid string) ([]byte, error) {
+ token := &krb5GSSAPI.WrapToken{}
+
+ err := unmarshalWrapToken(token, input, true)
+ if err != nil {
+ return nil, err
+ }
+
+ if (token.Flags & 0b1) == 0 {
+ return nil, fmt.Errorf("got a Wrapped token that's not from the server")
+ }
+
+ key := client.ekey
+ if (token.Flags & 0b100) != 0 {
+ key = client.Subkey
+ }
+
+ _, err = token.Verify(key, keyusage.GSSAPI_ACCEPTOR_SEAL)
+ if err != nil {
+ return nil, err
+ }
+
+ pl := token.Payload
+ if len(pl) != 4 {
+ return nil, fmt.Errorf("server send bad final token for SASL GSSAPI Handshake")
+ }
+
+ // We never want a security layer
+ payload := []byte{0, 0, 0, 0}
+
+ encType, err := krb5Crypto.GetEtype(key.KeyType)
+ if err != nil {
+ return nil, err
+ }
+
+ token = &krb5GSSAPI.WrapToken{
+ Flags: 0b100,
+ EC: uint16(encType.GetHMACBitLength() / 8),
+ RRC: 0,
+ SndSeqNum: 1,
+ Payload: payload,
+ }
+
+ if err := token.SetCheckSum(key, keyusage.GSSAPI_INITIATOR_SEAL); err != nil {
+ return nil, err
+ }
+
+ output, err := token.Marshal()
+ if err != nil {
+ return nil, err
+ }
+
+ return output, nil
+}
+
+func unmarshalWrapToken(wt *krb5GSSAPI.WrapToken, data []byte, expectFromAcceptor bool) error {
+ // Check if we can read a whole header
+ if len(data) < 16 {
+ return errors.New("bytes shorter than header length")
+ }
+
+ // Is the Token ID correct?
+ expectedWrapTokenId := []byte{0x05, 0x04}
+ if !bytes.Equal(expectedWrapTokenId, data[0:2]) {
+ return fmt.Errorf("wrong Token ID. Expected %s, was %s",
+ hex.EncodeToString(expectedWrapTokenId), hex.EncodeToString(data[0:2]))
+ }
+
+ // Check the acceptor flag
+ flags := data[2]
+ isFromAcceptor := flags&0x01 == 1
+
+ if isFromAcceptor && !expectFromAcceptor {
+ return errors.New("unexpected acceptor flag is set: not expecting a token from the acceptor")
+ }
+
+ if !isFromAcceptor && expectFromAcceptor {
+ return errors.New("expected acceptor flag is not set: expecting a token from the acceptor, not the initiator")
+ }
+
+ // Check the filler byte
+ if data[3] != krb5GSSAPI.FillerByte {
+ return fmt.Errorf("unexpected filler byte: expecting 0xFF, was %s ", hex.EncodeToString(data[3:4]))
+ }
+
+ checksumL := binary.BigEndian.Uint16(data[4:6])
+
+ // Sanity check on the checksum length
+ if int(checksumL) > len(data)-krb5GSSAPI.HdrLen {
+ return fmt.Errorf("inconsistent checksum length: %d bytes to parse, checksum length is %d", len(data), checksumL)
+ }
+
+ payloadStart := 16 + checksumL
+
+ wt.Flags = flags
+ wt.EC = checksumL
+ wt.RRC = binary.BigEndian.Uint16(data[6:8])
+ wt.SndSeqNum = binary.BigEndian.Uint64(data[8:16])
+ wt.CheckSum = data[16:payloadStart]
+ wt.Payload = data[payloadStart:]
+
+ return nil
+}
+
+func krb5TokenAuthenticator(
+ realm string, cname types.PrincipalName, cert *x509.Certificate, flags []int,
+) (types.Authenticator, error) {
+ // RFC 4121 Section 4.1.1
+ auth, err := types.NewAuthenticator(realm, cname)
+ if err != nil {
+ return auth, krberror.Errorf(err, krberror.KRBMsgError, "error generating new authenticator")
+ }
+
+ // https://datatracker.ietf.org/doc/html/rfc4121#section-4.1.1
+ checksum := make([]byte, 24)
+
+ if cert != nil {
+ hash := ChannelBindingHash(cert)
+ if len(hash) != 16 {
+ return auth, fmt.Errorf("unexpected channel binding hash size: %d, expected 16", len(hash))
+ }
+
+ binary.LittleEndian.PutUint32(checksum[:4], uint32(len(hash)))
+ copy(checksum[4:20], hash)
+ }
+
+ binary.LittleEndian.PutUint32(checksum[:4], 16)
+
+ for _, flag := range flags {
+ if flag == krb5GSSAPI.ContextFlagDeleg {
+ checksum = append(checksum, make([]byte, 28-len(checksum))...) //nolint:makezero
+ }
+
+ f := binary.LittleEndian.Uint32(checksum[20:24])
+ f |= uint32(flag)
+
+ binary.LittleEndian.PutUint32(checksum[20:24], f)
+ }
+
+ auth.Cksum = types.Checksum{
+ CksumType: chksumtype.GSSAPI,
+ Checksum: checksum,
+ }
+
+ return auth, nil
+}
diff --git a/ldapauth/ldap.go b/ldapauth/ldap.go
new file mode 100644
index 0000000..d5cd9d5
--- /dev/null
+++ b/ldapauth/ldap.go
@@ -0,0 +1,487 @@
+package ldapauth
+
+import (
+ "context"
+ "crypto"
+ "crypto/ecdh"
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/md5"
+ "crypto/rsa"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/binary"
+ "encoding/pem"
+ "fmt"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/RedTeamPentesting/adauth"
+ "github.com/RedTeamPentesting/adauth/othername"
+ "github.com/RedTeamPentesting/adauth/pkinit"
+ "software.sslmate.com/src/go-pkcs12"
+
+ "github.com/go-ldap/ldap/v3"
+ "github.com/jcmturner/gokrb5/v8/client"
+ "github.com/spf13/pflag"
+)
+
+// Options holds LDAP specific options.
+type Options struct {
+ // LDAP scheme (ldap or ldaps).
+ Scheme string
+ // Verify indicates whether TLS verification should be performed.
+ Verify bool
+ // Timeout sets the request timeout for the LDAP connection.
+ Timeout time.Duration
+ // Debug can be set to enable debug output, for example with
+ // adauth.NewDebugFunc(...).
+ Debug func(string, ...any)
+ // SimpleBind indicates that SimpleBind authentication should be used
+ // instead of NTLM, Kerberos or mTLS. For this, a cleartext password is
+ // required.
+ SimpleBind bool
+ // TLSConfig for LDAPS or LDAP+StartTLS. InsecureSkipVerify is ignored and
+ // set according to Options.Verify. MaxVersion will be changed to 1.2 unless
+ // Options.DisableChannelBinding is set.
+ TLSConfig *tls.Config
+ // DisableChannelBinding omits the TLS certificate hash in Kerberos and NTLM
+ // authentication.
+ DisableChannelBinding bool
+ // StartTLS indicates that a TLS connection should be established even for
+ // non-LDAPS connections before authenticating. For client-certificate
+ // authentication on regular LDAP connections, StartTLS will be used even if
+ // this option is disabled.
+ StartTLS bool
+ // DialOptions can be used to customize the connection. However
+ // ldap.DialWithTLSConfig will be ignored, because TLS setup is handled
+ // internally.
+ DialOptions []ldap.DialOpt
+ // PKINITOptions can be used to modify the behavior of PKINIT when it is
+ // used.
+ PKINITOptions []pkinit.Option
+}
+
+// RegisterFlags registers LDAP specific flags to a pflag.FlagSet such as the
+// default flagset pflag.CommandLine.
+func (opts *Options) RegisterFlags(flagset *pflag.FlagSet) {
+ flagset.StringVar(&opts.Scheme, "scheme", "ldaps", "Scheme (ldap or ldaps)")
+ flagset.DurationVar(&opts.Timeout, "timeout", 5*time.Second, "LDAP connection timeout")
+ flagset.BoolVar(&opts.SimpleBind, "simple-bind", false, "Authenticate with simple bind")
+ flagset.BoolVar(&opts.Verify, "verify", false, "Verify LDAP TLS certificate")
+ flagset.BoolVar(&opts.StartTLS, "start-tls", false,
+ "Negotiate StartTLS before authenticating on regular LDAP connection")
+}
+
+// Connect returns an authenticated LDAP connection to the domain controller's
+// LDAP server.
+func Connect(ctx context.Context, authOpts *adauth.Options, ldapOpts *Options) (conn *ldap.Conn, err error) {
+ creds, target, err := authOpts.WithDCTarget(ctx, ldapOpts.Scheme)
+ if err != nil {
+ return nil, err
+ }
+
+ return ConnectTo(ctx, creds, target, ldapOpts)
+}
+
+// Connect returns an authenticated LDAP connection to the specified target.
+func ConnectTo(
+ ctx context.Context, creds *adauth.Credential, target *adauth.Target, ldapOpts *Options,
+) (conn *ldap.Conn, err error) {
+ opts := ldapOpts
+ if opts.Debug == nil {
+ opts.Debug = func(s string, a ...any) {}
+ }
+
+ opts.TLSConfig, err = TLSConfig(ldapOpts, creds.ClientCert, creds.ClientCertKey, creds.CACerts)
+ if err != nil {
+ return nil, fmt.Errorf("configure TLS: %w", err)
+ }
+
+ conn, err = connect(target, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ if opts.Timeout == 0 {
+ conn.SetTimeout(3 * time.Second)
+ } else {
+ conn.SetTimeout(opts.Timeout)
+ }
+
+ err = bind(ctx, conn, creds, target, opts)
+ if err != nil {
+ return nil, err
+ }
+
+ return conn, nil
+}
+
+func connect(target *adauth.Target, opts *Options) (*ldap.Conn, error) {
+ switch strings.ToLower(opts.Scheme) {
+ case "ldaps", "":
+ conn, err := ldap.DialURL("ldaps://"+target.Address(),
+ append(opts.DialOptions, ldap.DialWithTLSConfig(opts.TLSConfig))...)
+ if err != nil {
+ return nil, fmt.Errorf("LDAPS dial: %w", err)
+ }
+
+ opts.Debug("connected to LDAPS server %s", target.Address())
+
+ return conn, nil
+ case "ldap":
+ conn, err := ldap.DialURL("ldap://"+target.Address(), opts.DialOptions...)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP dial: %w", err)
+ }
+
+ opts.Debug("connected to LDAP server %s", target.Address())
+
+ if opts.StartTLS {
+ err = conn.StartTLS(opts.TLSConfig)
+ if err != nil {
+ _ = conn.Close()
+
+ return nil, fmt.Errorf("StartTLS: %w", err)
+ }
+ }
+
+ return conn, nil
+ default:
+ return nil, fmt.Errorf("invalid scheme: %q", opts.Scheme)
+ }
+}
+
+func bind(
+ ctx context.Context, conn *ldap.Conn, creds *adauth.Credential, target *adauth.Target, opts *Options,
+) (err error) {
+ switch {
+ case opts.SimpleBind:
+ opts.Debug("authenticating using simple bind")
+
+ if creds.Password == "" && !creds.PasswordIsEmtpyString {
+ return fmt.Errorf("specify a password for simple bind")
+ }
+
+ _, err = conn.SimpleBind(&ldap.SimpleBindRequest{
+ Username: creds.UPN(),
+ Password: creds.Password,
+ })
+ if err != nil {
+ return fmt.Errorf("simple bind: %w", err)
+ }
+ case !target.UseKerberos && creds.ClientCert == nil:
+ opts.Debug("authenticating using NTLM bind")
+
+ if !creds.PasswordIsEmtpyString && (creds.Password == "" && creds.NTHash == "") {
+ return fmt.Errorf("no credentials available for NTLM")
+ }
+
+ bindRequest := &ldap.NTLMBindRequest{
+ Domain: creds.Domain,
+ Username: creds.Username,
+ Password: creds.Password,
+ Hash: creds.NTHash,
+ AllowEmptyPassword: creds.PasswordIsEmtpyString,
+ }
+
+ tlsState, ok := conn.TLSConnectionState()
+ if ok && !opts.DisableChannelBinding {
+ bindRequest.Negotiator = ntlmNegotiatorWithChannelBinding(tlsState.PeerCertificates[0])
+ }
+
+ _, err = conn.NTLMChallengeBind(bindRequest)
+ if err != nil {
+ return fmt.Errorf("NTLM bind: %w", err)
+ }
+ case target.UseKerberos:
+ authClient, err := kerberosClient(ctx, conn, creds, opts)
+ if err != nil {
+ return err
+ }
+
+ spn, err := target.SPN(ctx)
+ if err != nil {
+ return fmt.Errorf("build SPN: %w", err)
+ }
+
+ err = conn.GSSAPIBind(authClient, spn, creds.NTHash)
+ if err != nil {
+ return fmt.Errorf("GSSAPI bind: %w", err)
+ }
+ case creds.ClientCert != nil && strings.EqualFold(opts.Scheme, "ldap"):
+ opts.Debug("authenticating with client certificate via StartTLS")
+
+ if !opts.TLSConfig.InsecureSkipVerify {
+ hostname, err := target.Hostname(ctx)
+ if err != nil {
+ return fmt.Errorf("determine target hostname for TLS verification: %w", err)
+ }
+
+ opts.TLSConfig.ServerName = hostname
+ }
+
+ err = conn.StartTLS(opts.TLSConfig)
+ if err != nil {
+ return fmt.Errorf("StartTLS: %w", err)
+ }
+
+ err = conn.ExternalBind()
+ if err != nil {
+ if creds.ClientCert.Issuer.CommonName == "" ||
+ strings.EqualFold(creds.ClientCert.Subject.CommonName, creds.ClientCert.Issuer.CommonName) {
+ return fmt.Errorf("external bind: %w "+
+ "(certificate likely belongs to a KeyCredentialLink, try PKINIT with -k instead)", err)
+ }
+
+ return fmt.Errorf("external bind: %w", err)
+ }
+ case creds.ClientCert != nil:
+ opts.Debug("authenticating with client certificate")
+
+ res, err := conn.WhoAmI(nil)
+ if err != nil {
+ return fmt.Errorf("send whoami to verify certificate authentication: %w", err)
+ }
+
+ if res.AuthzID == "" {
+ if creds.ClientCert.Issuer.CommonName == "" ||
+ strings.EqualFold(creds.ClientCert.Subject.CommonName, creds.ClientCert.Issuer.CommonName) {
+ return fmt.Errorf("client certificate authentication failed " +
+ "(certificate likely belongs to a KeyCredentialLink, try PKINIT with -k instead)")
+ }
+
+ return fmt.Errorf("client certificate authentication failed")
+ }
+ default:
+ return fmt.Errorf("no credentials available")
+ }
+
+ return nil
+}
+
+func kerberosClient(
+ ctx context.Context, conn *ldap.Conn, creds *adauth.Credential, opts *Options,
+) (*gssapiClient, error) {
+ krbConf, err := creds.KerberosConfig(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("configure Kerberos: %w", err)
+ }
+
+ var (
+ authClient *gssapiClient
+ cert *x509.Certificate
+ )
+
+ tlsState, ok := conn.TLSConnectionState()
+ if ok && !opts.DisableChannelBinding {
+ cert = tlsState.PeerCertificates[0]
+ }
+
+ switch {
+ case creds.Password != "" || creds.PasswordIsEmtpyString:
+ opts.Debug("authenticating using GSSAPI bind (password)")
+
+ authClient = &gssapiClient{
+ Client: client.NewWithPassword(
+ creds.Username,
+ strings.ToUpper(creds.Domain),
+ creds.Password,
+ krbConf,
+ client.DisablePAFXFAST(true),
+ ),
+ }
+
+ authClient.BindCertificate = cert
+ case creds.NTHash != "" || creds.AESKey != "":
+ opts.Debug("authenticating using GSSAPI bind (key)")
+
+ keyTab, err := creds.Keytab()
+ if err != nil {
+ return nil, fmt.Errorf("create keytab: %w", err)
+ }
+
+ authClient = &gssapiClient{
+ Client: client.NewWithKeytab(
+ creds.Username,
+ strings.ToUpper(creds.Domain),
+ keyTab,
+ krbConf,
+ client.DisablePAFXFAST(true),
+ ),
+ BindCertificate: cert,
+ }
+
+ authClient.BindCertificate = cert
+ case creds.ClientCert != nil:
+ opts.Debug("authenticating using GSSAPI bind (PKINIT)")
+
+ return newPKINITClient(ctx, creds.Username, strings.ToUpper(creds.Domain),
+ creds.ClientCert, creds.ClientCertKey, krbConf, opts.PKINITOptions...)
+ case creds.CCache != "":
+ opts.Debug("authenticating using GSSAPI bind (ccache)")
+
+ authClient, err = newClientFromCCache(
+ creds.Username, strings.ToUpper(creds.Domain), creds.CCache, krbConf)
+ if err != nil {
+ return nil, fmt.Errorf("create GSSAPI client from CCACHE: %w", err)
+ }
+
+ authClient.BindCertificate = cert
+ default:
+ return nil, fmt.Errorf("no credentials available for Kerberos")
+ }
+
+ return authClient, nil
+}
+
+// TLSConfig returns a TLS config based on the default config in the provided
+// LDAP options as well as PFX files.
+func TLSConfig(
+ opts *Options, clientCert *x509.Certificate, clientCertKey crypto.PrivateKey, caCerts []*x509.Certificate,
+) (*tls.Config, error) {
+ tlsConfig := opts.TLSConfig
+ if tlsConfig == nil {
+ tlsConfig = &tls.Config{}
+ }
+
+ tlsConfig.InsecureSkipVerify = !opts.Verify
+ if tlsConfig.MaxVersion == 0 && !opts.DisableChannelBinding {
+ tlsConfig.MaxVersion = tls.VersionTLS12 // channel binding is not supported for TLS1.3
+ }
+
+ if clientCert == nil {
+ return tlsConfig, nil
+ }
+
+ var (
+ keyBytes []byte
+ err error
+ )
+
+ switch v := clientCertKey.(type) {
+ case *rsa.PrivateKey, *ecdsa.PrivateKey, ed25519.PrivateKey, *ecdh.PrivateKey:
+ keyBytes, err = x509.MarshalPKCS8PrivateKey(v)
+ if err != nil {
+ return nil, fmt.Errorf("marshal private key: %w", err)
+ }
+ default:
+ return nil, fmt.Errorf("unsupported client certificate key type: %T", clientCertKey)
+ }
+
+ certPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: clientCert.Raw,
+ })
+ keyPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "PRIVATE KEY",
+ Bytes: keyBytes,
+ })
+
+ clientCertificate, err := tls.X509KeyPair(certPEM, keyPEM)
+ if err != nil {
+ return nil, fmt.Errorf("load client certificate: %w", err)
+ }
+
+ tlsConfig.Certificates = append(tlsConfig.Certificates, clientCertificate)
+
+ if len(caCerts) == 0 {
+ return tlsConfig, nil
+ }
+
+ if tlsConfig.RootCAs == nil {
+ tlsConfig.RootCAs = x509.NewCertPool()
+ }
+
+ for _, cert := range caCerts {
+ tlsConfig.RootCAs.AppendCertsFromPEM(pem.EncodeToMemory(&pem.Block{
+ Type: "CERTIFICATE",
+ Bytes: cert.Raw,
+ }))
+ }
+
+ return tlsConfig, nil
+}
+
+// UserAndDomainFromPFX extracts the username and domain from UPNs in the
+// certificate's otherName SAN extension.
+func UserAndDomainFromPFX(pfxFile string, password string) (user string, domain string, err error) {
+ pfxData, err := os.ReadFile(pfxFile)
+ if err != nil {
+ return "", "", fmt.Errorf("read PFX: %w", err)
+ }
+
+ _, cert, _, err := pkcs12.DecodeChain(pfxData, password)
+ if err != nil {
+ return "", "", fmt.Errorf("decode PFX: %w", err)
+ }
+
+ user, domain = userAndDomainFromCert(cert)
+
+ return user, domain, nil
+}
+
+func userAndDomainFromCert(cert *x509.Certificate) (user string, domain string) {
+ upns, err := othername.UPNs(cert)
+ if err != nil {
+ return "", ""
+ }
+
+ for _, upn := range upns {
+ if !strings.Contains(upn, "@") {
+ continue
+ }
+
+ parts := strings.Split(upn, "@")
+ if len(parts) != 2 {
+ continue
+ }
+
+ return parts[0], parts[1]
+ }
+
+ return "", ""
+}
+
+// ChannelBinding hash computes the channel binding token that can be included
+// in the authentication handshake to make sure that the client has established
+// a TLS connection to the correct server.
+func ChannelBindingHash(cert *x509.Certificate) []byte {
+ hashType := crypto.SHA256
+
+ switch cert.SignatureAlgorithm {
+ case x509.SHA384WithRSA, x509.ECDSAWithSHA384, x509.SHA384WithRSAPSS:
+ hashType = crypto.SHA384
+ case x509.SHA512WithRSA, x509.ECDSAWithSHA512, x509.SHA512WithRSAPSS:
+ hashType = crypto.SHA512
+ }
+
+ certHasher := hashType.New()
+ _, _ = certHasher.Write(cert.Raw)
+ certHash := certHasher.Sum(nil)
+ prefix := "tls-server-end-point:"
+
+ // https://learn.microsoft.com/en-us/windows/win32/api/sspi/ns-sspi-sec_channel_bindings
+ // https://github.com/jborean93/Mailozaurr/blob
+ // /6b565c4a1debdf301a95b93674ff12acdf8c762c/Classes/Class.SaslMechanismWindowsAuth.ps1#L67C19-L68C1
+ channelBindingStructure := []byte{
+ 0, 0, 0, 0, // InitiatorAddrType
+ 0, 0, 0, 0, // InitiatorLength
+
+ 0, 0, 0, 0, // AcceptorAddrType,
+ 0, 0, 0, 0, // AcceptorLength,
+ }
+
+ channelBindingStructure = binary.LittleEndian.AppendUint32(channelBindingStructure,
+ uint32(len(prefix)+len(certHash))) // ApplicationDataLength
+ channelBindingStructure = append(channelBindingStructure, []byte(prefix)...)
+ channelBindingStructure = append(channelBindingStructure, certHash...)
+
+ channelBindingHasher := md5.New()
+ channelBindingHasher.Write(channelBindingStructure)
+
+ hash := channelBindingHasher.Sum(nil)
+
+ return hash
+}
diff --git a/ldapauth/ntlm.go b/ldapauth/ntlm.go
new file mode 100644
index 0000000..75e4203
--- /dev/null
+++ b/ldapauth/ntlm.go
@@ -0,0 +1,67 @@
+package ldapauth
+
+import (
+ "crypto/x509"
+ "fmt"
+
+ "github.com/Azure/go-ntlmssp"
+ "github.com/go-ldap/ldap/v3"
+ "github.com/vadimi/go-ntlm/ntlm"
+)
+
+type ntlmNegotiator struct {
+ cert *x509.Certificate
+}
+
+var _ ldap.NTLMNegotiator = &ntlmNegotiator{}
+
+func ntlmNegotiatorWithChannelBinding(cert *x509.Certificate) ldap.NTLMNegotiator {
+ return &ntlmNegotiator{cert: cert}
+}
+
+func (n *ntlmNegotiator) Negotiate(domain string, worktation string) ([]byte, error) {
+ return ntlmssp.NewNegotiateMessage(domain, worktation)
+}
+
+func (n *ntlmNegotiator) ChallengeResponse(challenge []byte, username string, hash string) ([]byte, error) {
+ if n.cert == nil {
+ // no cert means no channel binding, so Azure/ntlmssp can handle
+ // authentication alone.
+ return ntlmssp.ProcessChallengeWithHash(challenge, username, hash)
+ }
+
+ // The authenticate message needs to include a channel binding hash, but the
+ // Azure/ntlmssp library does not support channel binding and offers no API
+ // to retrofit it. The only way to make it work is too parse the challenge,
+ // add the channel bindings to the challenge's TargetInfo field which will
+ // then be included in the authenticate message. Unfortunately we need
+ // another NTLM library because message marshalling and unmarshalling is
+ // also not exposed in Azure/ntlmssp.
+
+ cm, err := ntlm.ParseChallengeMessage(challenge)
+ if err != nil {
+ return nil, fmt.Errorf("parse NTLM challenge before injecting channel binding AVPair: %w", err)
+ }
+
+ // drop end-of-list marker if present because we want to add another entry
+ if len(cm.TargetInfo.List) > 0 && cm.TargetInfo.List[len(cm.TargetInfo.List)-1].AvId == ntlm.MsvAvEOL {
+ cm.TargetInfo.List = cm.TargetInfo.List[:len(cm.TargetInfo.List)-1]
+ }
+
+ // add channel bindings
+ cm.TargetInfo.AddAvPair(ntlm.MsvChannelBindings, ChannelBindingHash(n.cert))
+ cm.TargetInfo.AddAvPair(ntlm.MsvAvEOL, nil)
+
+ // make sure that the server cannot make cm.Bytes() panic by omitting the
+ // version.
+ if cm.Version == nil {
+ cm.Version = &ntlm.VersionStruct{}
+ }
+
+ cm.TargetInfoPayloadStruct, err = ntlm.CreateBytePayload(cm.TargetInfo.Bytes())
+ if err != nil {
+ return nil, fmt.Errorf("marshal AVPairs with injected channel binding AVPair: %w", err)
+ }
+
+ return ntlmssp.ProcessChallengeWithHash(cm.Bytes(), username, hash)
+}
diff --git a/options.go b/options.go
new file mode 100644
index 0000000..f5cdd80
--- /dev/null
+++ b/options.go
@@ -0,0 +1,351 @@
+package adauth
+
+import (
+ "context"
+ "crypto/rsa"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+
+ "github.com/RedTeamPentesting/adauth/othername"
+ "github.com/spf13/pflag"
+ "software.sslmate.com/src/go-pkcs12"
+)
+
+// Options holds command line options that are used to determine authentication
+// credentials and target.
+type Options struct {
+ // Username (with domain) in one of the following formats:
+ // `UPN`, `domain\user`, `domain/user` or `user`
+ User string
+ Password string
+ NTHash string
+ AESKey string
+ CCache string
+ DomainController string
+ ForceKerberos bool
+ PFXFileName string
+ PFXPassword string
+
+ credential *Credential
+ flagset *pflag.FlagSet
+
+ Debug func(fmt string, a ...any)
+ Resolver Resolver
+}
+
+// AuxiliaryInfo represents information that was obtained elsewhere that can be
+// used to supplement the parsed options. However, parsed options always take
+// precedent.
+type AuxiliaryInfo struct {
+ Domain string
+ User string
+ DC string
+}
+
+// RegisterFlags registers authentication flags to a pflag.FlagSet such as the
+// default flagset `pflag.CommandLine`.
+func (opts *Options) RegisterFlags(flagset *pflag.FlagSet) {
+ defaultCCACHEFile := os.Getenv("KRB5CCNAME")
+ ccacheHint := ""
+
+ if defaultCCACHEFile == "" {
+ ccacheHint = " (defaults to $KRB5CCNAME, currently unset)"
+ }
+
+ flagset.StringVarP(&opts.User, "user", "u", "", "Username ('`user@domain`', 'domain\\user', 'domain/user' or 'user')")
+ flagset.StringVarP(&opts.Password, "password", "p", "", "Password")
+ flagset.StringVarP(&opts.NTHash, "nt-hash", "H", "", "NT `hash` ('NT', ':NT' or 'LM:NT')")
+ flagset.StringVar(&opts.AESKey, "aes-key", "", "Kerberos AES `hex key`")
+ flagset.StringVar(&opts.PFXFileName, "pfx", "", "Client certificate and private key as PFX `file`")
+ flagset.StringVar(&opts.PFXPassword, "pfx-password", "", "Password for PFX file")
+ flagset.StringVar(&opts.CCache, "ccache", defaultCCACHEFile, "Kerberos CCache `file` name"+ccacheHint)
+ flagset.StringVar(&opts.DomainController, "dc", "", "Domain controller")
+ flagset.BoolVarP(&opts.ForceKerberos, "kerberos", "k", false, "Use Kerberos authentication")
+ opts.flagset = flagset
+}
+
+func (opts *Options) debug(format string, a ...any) {
+ if opts.Debug != nil {
+ opts.Debug(format, a...)
+ }
+}
+
+func portForProtocol(protocol string) string {
+ switch strings.ToLower(protocol) {
+ case "ldap":
+ return "389"
+ case "ldaps":
+ return "636"
+ case "http":
+ return "80"
+ case "https":
+ return "443"
+ case "smb":
+ return "445"
+ case "rdp":
+ return "3389"
+ case "kerberos":
+ return "88"
+ default:
+ return ""
+ }
+}
+
+func addPortForProtocolIfMissing(protocol string, addr string) string {
+ host, port, err := net.SplitHostPort(addr)
+ if err != nil || port != "" {
+ return addr
+ }
+
+ port = portForProtocol(protocol)
+ if port == "" {
+ return addr
+ }
+
+ return net.JoinHostPort(host, port)
+}
+
+// WithDCTarget returns credentials and the domain controller for the
+// corresponding domain as the target.
+func (opts *Options) WithDCTarget(ctx context.Context, protocol string) (*Credential, *Target, error) {
+ if opts.DomainController != "" {
+ return opts.WithTarget(ctx, protocol, addPortForProtocolIfMissing(protocol, opts.DomainController))
+ }
+
+ cred, err := opts.preliminaryCredential()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if cred.Domain == "" {
+ return nil, nil, fmt.Errorf("domain unknown")
+ }
+
+ resolver := ensureResolver(opts.Resolver, opts.debug)
+
+ var dcAddr string
+
+ host, port, err := resolver.LookupFirstService(context.Background(), protocol, cred.Domain)
+ if err != nil {
+ lookupSRVErr := fmt.Errorf("could not lookup %q service of domain %q: %w", protocol, cred.Domain, err)
+
+ dcAddr, err = resolver.LookupDCByDomain(context.Background(), cred.Domain)
+ if err != nil {
+ return nil, nil, fmt.Errorf("could not find DC: %w and %w", lookupSRVErr, err)
+ }
+
+ port := portForProtocol(protocol)
+ if port != "" {
+ dcAddr = net.JoinHostPort(dcAddr, port)
+ }
+
+ opts.debug("using DC %s based on domain lookup for %s", dcAddr, cred.Domain)
+ } else {
+ dcAddr = net.JoinHostPort(host, strconv.Itoa(port))
+ opts.debug("using DC %s based on SRV lookup for domain %s", dcAddr, cred.Domain)
+ }
+
+ return cred, newTarget(
+ protocol, dcAddr, opts.ForceKerberos || cred.mustUseKerberos(), opts.CCache, opts.Resolver), nil
+}
+
+// WithTarget returns credentials and the specified target.
+func (opts *Options) WithTarget(ctx context.Context, protocol string, target string) (*Credential, *Target, error) {
+ if protocol == "" {
+ protocol = "host"
+ }
+
+ cred, err := opts.preliminaryCredential()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ t := newTarget(protocol, target, opts.ForceKerberos || cred.mustUseKerberos(), opts.CCache, opts.Resolver)
+
+ if cred.Domain == "" {
+ hostname, err := t.Hostname(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("lookup target hostname to determine domain: %w", err)
+ }
+
+ parts := strings.SplitN(hostname, ".", 2)
+ if len(parts) == 2 {
+ switch {
+ case strings.Contains(parts[1], "."):
+ cred.Domain = parts[1]
+ default:
+ cred.Domain = hostname
+ }
+ }
+ }
+
+ return cred, t, nil
+}
+
+// Username returns the user's name. Username may return an empty string.
+func (opts *Options) Username() string {
+ cred, err := opts.preliminaryCredential()
+ if err != nil {
+ return ""
+ }
+
+ return cred.Username
+}
+
+// UPN returns the user's domain. Domain may return an empty string.
+func (opts *Options) Domain() string {
+ cred, err := opts.preliminaryCredential()
+ if err != nil {
+ return ""
+ }
+
+ return cred.Domain
+}
+
+// UPN returns the user's universal principal name. UPN may return an empty
+// string.
+func (opts *Options) UPN() string {
+ cred, err := opts.preliminaryCredential()
+ if err != nil {
+ return ""
+ }
+
+ return cred.UPN()
+}
+
+// NoTarget returns the user credentials without supplementing it with
+// information from a target.
+func (opts *Options) NoTarget() (*Credential, error) {
+ return opts.preliminaryCredential()
+}
+
+func (opts *Options) preliminaryCredential() (*Credential, error) {
+ if opts.credential != nil {
+ return opts.credential, nil
+ }
+
+ domain, username := splitUserIntoDomainAndUsername(opts.User)
+
+ cleanedNTHash := cleanNTHash(opts.NTHash)
+
+ var ntHash string
+
+ if cleanedNTHash != "" {
+ ntHashBytes, err := hex.DecodeString(cleanedNTHash)
+ if err != nil {
+ return nil, fmt.Errorf("invalid NT hash: parse hex: %w", err)
+ } else if len(ntHashBytes) != 16 {
+ return nil, fmt.Errorf("invalid NT hash: %d bytes instead of 16", len(ntHashBytes))
+ }
+
+ ntHash = cleanedNTHash
+ }
+
+ var aesKey string
+
+ if opts.AESKey != "" {
+ aesKeyBytes, err := hex.DecodeString(opts.AESKey)
+ if err != nil {
+ return nil, fmt.Errorf("invalid AES key: parse hex: %w", err)
+ } else if len(aesKeyBytes) != 16 && len(aesKeyBytes) != 32 {
+ return nil, fmt.Errorf("invalid AES key: %d bytes instead of 16 or 32", len(aesKeyBytes))
+ }
+
+ aesKey = opts.AESKey
+ }
+
+ var ccache string
+
+ if opts.CCache != "" {
+ s, err := os.Stat(opts.CCache)
+ if err == nil && !s.IsDir() {
+ ccache = opts.CCache
+ }
+ }
+
+ cred := &Credential{
+ Username: username,
+ Password: opts.Password,
+ Domain: domain,
+ NTHash: cleanNTHash(ntHash),
+ AESKey: aesKey,
+ CCache: ccache,
+ dc: opts.DomainController,
+ PasswordIsEmtpyString: opts.Password == "" && (opts.flagset != nil && opts.flagset.Changed("password")),
+ CCacheIsFromEnv: opts.CCache != "" && (opts.flagset != nil && !opts.flagset.Changed("ccache")),
+ Resolver: opts.Resolver,
+ }
+
+ if opts.PFXFileName != "" {
+ pfxData, err := os.ReadFile(opts.PFXFileName)
+ if err != nil {
+ return nil, fmt.Errorf("read PFX: %w", err)
+ }
+
+ key, cert, caCerts, err := pkcs12.DecodeChain(pfxData, opts.PFXPassword)
+ if err != nil {
+ return nil, fmt.Errorf("decode PFX: %w", err)
+ }
+
+ rsaKey, ok := key.(*rsa.PrivateKey)
+ if !ok {
+ return nil, fmt.Errorf("PFX key is not an RSA private key but %T", rsaKey)
+ }
+
+ cred.ClientCert = cert
+ cred.ClientCertKey = rsaKey
+ cred.CACerts = caCerts
+ }
+
+ //nolint:nestif
+ if cred.ClientCert != nil {
+ user, domain, err := othername.UserAndDomain(cred.ClientCert)
+ if err == nil {
+ if cred.Username == "" {
+ cred.Username = user
+ }
+
+ if cred.Domain == "" {
+ cred.Domain = domain
+ }
+ }
+ }
+
+ opts.credential = cred
+
+ return cred, nil
+}
+
+// NewDebugFunc creates a debug output handler.
+func NewDebugFunc(enabled *bool, writer io.Writer, colored bool) func(string, ...any) {
+ return func(format string, a ...any) {
+ if enabled == nil || !*enabled {
+ return
+ }
+
+ format = strings.TrimRight(format, "\n")
+ if colored {
+ format = "\033[2m" + format + "\033[0m"
+ }
+
+ _, _ = fmt.Fprintf(writer, format+"\n", a...)
+ }
+}
+
+func cleanNTHash(h string) string {
+ if !strings.Contains(h, ":") {
+ return h
+ }
+
+ parts := strings.Split(h, ":")
+ if len(parts) != 2 {
+ return h
+ }
+
+ return parts[1]
+}
diff --git a/options_test.go b/options_test.go
new file mode 100644
index 0000000..6f57dd9
--- /dev/null
+++ b/options_test.go
@@ -0,0 +1,337 @@
+package adauth_test
+
+import (
+ "context"
+ "encoding/hex"
+ "net"
+ "strconv"
+ "testing"
+
+ "github.com/RedTeamPentesting/adauth"
+)
+
+const (
+ testUser = "someuser"
+ testDomain = "domain.tld"
+)
+
+func TestUsernameAndDomainParsing(t *testing.T) {
+ expectedUPN := testUser + "@" + testDomain
+ expectedLogonName := testDomain + `\` + testUser
+ expectedImpacketLogonName := testDomain + "/" + testUser
+
+ testCases := []struct {
+ Name string
+ Opts adauth.Options
+ }{
+ {
+ Name: "UPN",
+ Opts: adauth.Options{User: testUser + "@" + testDomain},
+ },
+ {
+ Name: "logon-name",
+ Opts: adauth.Options{User: testDomain + `\` + testUser},
+ },
+ {
+ Name: "impacket-style",
+ Opts: adauth.Options{User: testDomain + `/` + testUser},
+ },
+ {
+ Name: "pfx",
+ Opts: adauth.Options{PFXFileName: "testdata/someuser@domain.tld.pfx"},
+ },
+ }
+
+ for _, testCase := range testCases {
+ testCase := testCase
+
+ t.Run(testCase.Name, func(t *testing.T) {
+ creds, err := testCase.Opts.NoTarget()
+ if err != nil {
+ t.Fatalf("get credentials: %v", err)
+ }
+
+ if creds.Username != testUser {
+ t.Errorf("username %q is not %q", creds.Username, testUser)
+ }
+
+ if creds.Domain != testDomain {
+ t.Errorf("domain %q is not %q", creds.Domain, testDomain)
+ }
+
+ upn := creds.UPN()
+ if upn != expectedUPN {
+ t.Errorf("UPN %q is not %q", upn, expectedUPN)
+ }
+
+ logonName := creds.LogonName()
+ if logonName != expectedLogonName {
+ t.Errorf("logon name %q is not %q", logonName, expectedLogonName)
+ }
+
+ impacketLogonName := creds.ImpacketLogonName()
+ if impacketLogonName != expectedImpacketLogonName {
+ t.Errorf("impacket-style logon name %q is not %q", impacketLogonName, expectedImpacketLogonName)
+ }
+ })
+ }
+}
+
+func TestDomainFromTargetHostname(t *testing.T) {
+ opts := adauth.Options{
+ User: testUser,
+ }
+
+ creds, _, err := opts.WithTarget(context.Background(), "cifs", "host."+testDomain)
+ if err != nil {
+ t.Fatalf("get credentials: %v", err)
+ }
+
+ if creds.Domain != testDomain {
+ t.Errorf("domain is %q instead of %q", creds.Domain, testDomain)
+ }
+
+ // ignore domain in hostname for local authentication
+ opts = adauth.Options{
+ User: "./" + testUser,
+ }
+
+ creds, _, err = opts.WithTarget(context.Background(), "cifs", "host."+testDomain)
+ if err != nil {
+ t.Fatalf("get credentials: %v", err)
+ }
+
+ if creds.Domain != "." {
+ t.Errorf("domain is %q instead of %q", creds.Domain, ".")
+ }
+}
+
+func TestDCTarget(t *testing.T) {
+ dcHost := "dc." + testDomain
+ expectedSPN := "ldap/" + dcHost
+
+ opts := adauth.Options{
+ User: testUser + `@` + testDomain,
+ Resolver: &testResolver{
+ SRV: map[string]map[string]map[string]struct {
+ Name string
+ SRV []*net.SRV
+ }{
+ "ldap": {
+ "tcp": {
+ testDomain: {
+ Name: dcHost,
+ SRV: []*net.SRV{
+ {Target: dcHost, Port: 389},
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+
+ _, dc, err := opts.WithDCTarget(context.Background(), "ldap")
+ if err != nil {
+ t.Fatalf("get DC target: %v", err)
+ }
+
+ if dc.Address() != net.JoinHostPort(dcHost, "389") {
+ t.Fatalf("DC address is %q instead of %q", dc.Address(), net.JoinHostPort(dcHost, "389"))
+ }
+
+ if dc.AddressWithoutPort() != dcHost {
+ t.Fatalf("DC address without port is %q instead of %q", dc.Address(), dcHost)
+ }
+
+ if dc.Port != "389" {
+ t.Errorf("DC port is %q instead of %q", dc.Port, "389")
+ }
+
+ spn, err := dc.SPN(context.Background())
+ if err != nil {
+ t.Fatalf("get DC SPN: %v", err)
+ }
+
+ if spn != expectedSPN {
+ t.Errorf("DC SPN is %q instead of %q", spn, expectedSPN)
+ }
+}
+
+func TestDCTargetWithoutSRVRecord(t *testing.T) {
+ dcHost := "dc." + testDomain
+ dcIP := net.ParseIP("10.0.0.1")
+ expectedSPN := "ldap/" + dcHost
+
+ opts := adauth.Options{
+ User: testUser + `@` + testDomain,
+ Resolver: &testResolver{
+ HostToAddr: map[string][]net.IP{
+ testDomain: {dcIP},
+ },
+ AddrToHost: map[string][]string{
+ dcIP.String(): {dcHost},
+ },
+ },
+ }
+
+ _, dc, err := opts.WithDCTarget(context.Background(), "ldap")
+ if err != nil {
+ t.Fatalf("get DC target: %v", err)
+ }
+
+ if dc.Address() != net.JoinHostPort(dcHost, "389") {
+ t.Fatalf("DC address is %q instead of %q", dc.Address(), net.JoinHostPort(dcHost, "389"))
+ }
+
+ if dc.AddressWithoutPort() != dcHost {
+ t.Fatalf("DC address without port is %q instead of %q", dc.Address(), dcHost)
+ }
+
+ if dc.Port != "389" {
+ t.Errorf("DC port is %q instead of %q", dc.Port, "389")
+ }
+
+ spn, err := dc.SPN(context.Background())
+ if err != nil {
+ t.Fatalf("get DC SPN: %v", err)
+ }
+
+ if spn != expectedSPN {
+ t.Errorf("DC SPN is %q instead of %q", spn, expectedSPN)
+ }
+}
+
+func TestDCTargetNoReverseLookup(t *testing.T) {
+ dcIP := net.ParseIP("10.0.0.1")
+
+ opts := adauth.Options{
+ User: testUser + `@` + testDomain,
+ Resolver: &testResolver{
+ HostToAddr: map[string][]net.IP{
+ testDomain: {dcIP},
+ },
+ },
+ }
+
+ _, dc, err := opts.WithDCTarget(context.Background(), "ldap")
+ if err != nil {
+ t.Fatalf("get DC target: %v", err)
+ }
+
+ if dc.Address() != net.JoinHostPort(dcIP.String(), "389") {
+ t.Fatalf("DC address is %q instead of %q", dc.Address(), net.JoinHostPort(dcIP.String(), "389"))
+ }
+}
+
+func TestKerberos(t *testing.T) {
+ upn := testUser + `@` + testDomain
+
+ testCases := []struct {
+ Opts adauth.Options
+ ShouldUseKerberos bool
+ }{
+ {
+ Opts: adauth.Options{
+ User: upn,
+ Password: "pass",
+ },
+ ShouldUseKerberos: false,
+ },
+ {
+ Opts: adauth.Options{
+ User: upn,
+ AESKey: hex.EncodeToString(make([]byte, 16)),
+ },
+ ShouldUseKerberos: true,
+ },
+ {
+ Opts: adauth.Options{
+ User: upn,
+ Password: "test",
+ AESKey: hex.EncodeToString(make([]byte, 16)),
+ },
+ ShouldUseKerberos: false,
+ },
+ {
+ Opts: adauth.Options{
+ User: upn,
+ Password: "pass",
+ ForceKerberos: true,
+ },
+ ShouldUseKerberos: true,
+ },
+ {
+ Opts: adauth.Options{
+ User: upn,
+ PFXFileName: "testdata/someuser@domain.tld.pfx",
+ },
+ ShouldUseKerberos: true,
+ },
+ {
+ Opts: adauth.Options{
+ User: upn,
+ // CCache indicates kerberos usage, but only if file is present
+ // (even though it might be empty)
+ CCache: "testdata/empty.ccache",
+ },
+ ShouldUseKerberos: true,
+ },
+ {
+ Opts: adauth.Options{
+ User: upn,
+ // CCache does not matter if file is not even there
+ CCache: "testdata/doesnotexist",
+ },
+ ShouldUseKerberos: false,
+ },
+ }
+
+ for i, testCase := range testCases {
+ testCase := testCase
+
+ t.Run(strconv.Itoa(i), func(t *testing.T) {
+ testCase.Opts.DomainController = "dc.domain.tld"
+
+ _, target, err := testCase.Opts.WithDCTarget(context.Background(), "ldap")
+ if err != nil {
+ t.Fatalf("get target: %v", err)
+ }
+
+ switch {
+ case testCase.ShouldUseKerberos && !target.UseKerberos:
+ t.Errorf("target would not use Kerberos even though it should: %#v", testCase.Opts)
+ case !testCase.ShouldUseKerberos && target.UseKerberos:
+ t.Errorf("target would use Kerberos even though it should not: %#v", testCase.Opts)
+ }
+ })
+ }
+}
+
+func TestCleanNTHash(t *testing.T) {
+ ntHash := "31d6cfe0d16ae931b73c59d7e0c089c0"
+
+ testCases := []string{
+ ntHash,
+ ":" + ntHash,
+ "aad3b435b51404eeaad3b435b51404ee:" + ntHash,
+ }
+
+ for i, testCase := range testCases {
+ testCase := testCase
+
+ t.Run(strconv.Itoa(i), func(t *testing.T) {
+ creds, err := (&adauth.Options{
+ User: testUser + "@" + testDomain,
+ NTHash: testCase,
+ }).NoTarget()
+ if err != nil {
+ t.Fatalf("get credentials: %v", err)
+ }
+
+ if creds.NTHash != ntHash {
+ t.Errorf("NT hash is %q instead of %q", creds.NTHash, ntHash)
+ }
+ })
+ }
+}
diff --git a/othername/othername.go b/othername/othername.go
new file mode 100644
index 0000000..3e7d5ff
--- /dev/null
+++ b/othername/othername.go
@@ -0,0 +1,189 @@
+// Package othername is a minimal and incomplete implementation of the otherName SAN extension.
+package othername
+
+import (
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/asn1"
+ "fmt"
+ "strings"
+)
+
+var (
+ UPNOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}
+ SANOID = asn1.ObjectIdentifier{2, 5, 29, 17}
+)
+
+type OtherName struct {
+ ID asn1.ObjectIdentifier
+ Value asn1.RawValue
+}
+
+// Extension generates an otherName extension.
+func Extension(names ...*OtherName) (pkix.Extension, error) {
+ ext := pkix.Extension{
+ Id: SANOID,
+ Critical: false,
+ }
+
+ rawValues := make([]asn1.RawValue, 0, len(names))
+
+ for _, name := range names {
+ v := asn1.RawValue{
+ Class: asn1.ClassContextSpecific,
+ IsCompound: true,
+ }
+
+ id, err := asn1.Marshal(name.ID)
+ if err != nil {
+ return ext, fmt.Errorf("marshal oid: %w", err)
+ }
+
+ v.Bytes = append(v.Bytes, id...)
+
+ blob, err := asn1.Marshal(name.Value)
+ if err != nil {
+ return ext, fmt.Errorf("marshal raw name: %w", err)
+ }
+
+ v.Bytes = append(v.Bytes, blob...)
+
+ rawValues = append(rawValues, v)
+ }
+
+ extBytes, err := asn1.Marshal(rawValues)
+ if err != nil {
+ return ext, fmt.Errorf("marshal extension bytes: %w", err)
+ }
+
+ ext.Value = append(ext.Value, extBytes...)
+
+ return ext, nil
+}
+
+// ExtensionFromUPNs build an otherName extension based on the provided UPNs.
+func ExtensionFromUPNs(upns ...string) (ext pkix.Extension, err error) {
+ otherNames := make([]*OtherName, 0, len(upns))
+
+ for _, upn := range upns {
+ utf8Name, err := asn1.MarshalWithParams(upn, "utf8")
+ if err != nil {
+ return ext, fmt.Errorf("marshal UTF8 name: %w", err)
+ }
+
+ otherNames = append(otherNames, &OtherName{
+ ID: UPNOID,
+ Value: asn1.RawValue{
+ Class: asn1.ClassContextSpecific,
+ IsCompound: true,
+ Bytes: utf8Name,
+ },
+ })
+ }
+
+ return Extension(otherNames...)
+}
+
+// Names returns the names from the otherName extension of the provided
+// certificate. If it does not contain such an extension, it will return an
+// empty slice and no error.
+func Names(cert *x509.Certificate) ([]*OtherName, error) {
+ oidSubjectAltName := asn1.ObjectIdentifier{2, 5, 29, 17}
+
+ var otherNames []*OtherName
+
+ for _, extension := range append(cert.Extensions, cert.ExtraExtensions...) {
+ if !extension.Id.Equal(oidSubjectAltName) {
+ continue
+ }
+
+ ons, err := otherNamesFromSANBytes(extension.Value)
+ if err != nil {
+ return nil, fmt.Errorf("parse otherName data: %w", err)
+ }
+
+ otherNames = append(otherNames, ons...)
+ }
+
+ return otherNames, nil
+}
+
+// UPNs returns all UPNs that are stored in certificates otherName extension.
+func UPNs(cert *x509.Certificate) (upns []string, err error) {
+ otherNames, err := Names(cert)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, otherName := range otherNames {
+ if !otherName.ID.Equal(UPNOID) {
+ continue
+ }
+
+ var upn string
+
+ _, err = asn1.UnmarshalWithParams(otherName.Value.Bytes, &upn, "utf8")
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal name: %w", err)
+ }
+
+ upns = append(upns, upn)
+ }
+
+ return upns, nil
+}
+
+// UserAndDomain returns the user and domain from the first valid UPN in the
+// certificate's otherName extension.
+func UserAndDomain(cert *x509.Certificate) (user string, domain string, err error) {
+ upns, err := UPNs(cert)
+ if err != nil {
+ return "", "", err
+ }
+
+ for _, upn := range upns {
+ parts := strings.Split(upn, "@")
+ if len(parts) == 2 {
+ return parts[0], parts[1], nil
+ }
+ }
+
+ return "", "", fmt.Errorf("found no suitable UPN in certificate")
+}
+
+func otherNamesFromSANBytes(bytes []byte) ([]*OtherName, error) {
+ values := []asn1.RawValue{}
+
+ _, err := asn1.Unmarshal(bytes, &values)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal raw values: %w", err)
+ }
+
+ otherNames := make([]*OtherName, 0, len(values))
+
+ for _, value := range values {
+ if value.Tag != 0 {
+ continue
+ }
+
+ otherName := &OtherName{}
+
+ value.Bytes, err = asn1.Unmarshal(value.Bytes, &otherName.ID)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal ID: %w", err)
+ }
+
+ value.Bytes, err = asn1.UnmarshalWithParams(value.Bytes, &otherName.Value, "utf8")
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal raw name: %w", err)
+ }
+
+ if len(value.Bytes) != 0 {
+ return nil, fmt.Errorf("othername: entry contains trailing bytes")
+ }
+
+ otherNames = append(otherNames, otherName)
+ }
+
+ return otherNames, nil
+}
diff --git a/othername/othername_test.go b/othername/othername_test.go
new file mode 100644
index 0000000..e9990b5
--- /dev/null
+++ b/othername/othername_test.go
@@ -0,0 +1,40 @@
+package othername_test
+
+import (
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "testing"
+
+ "github.com/RedTeamPentesting/adauth/othername"
+)
+
+func TestOtherName(t *testing.T) {
+ t.Parallel()
+
+ names := []string{"a", "b", "c"}
+
+ ext, err := othername.ExtensionFromUPNs(names...)
+ if err != nil {
+ t.Fatalf("generate otherName extension: %v", err)
+ }
+
+ cert := &x509.Certificate{
+ ExtraExtensions: []pkix.Extension{ext},
+ }
+
+ parsedNames, err := othername.UPNs(cert)
+ if err != nil {
+ t.Fatalf("parse otherNames: %v", err)
+ }
+
+ if len(names) != len(parsedNames) {
+ t.Fatalf("got %d (%#v) names instead of %d (%#v)",
+ len(parsedNames), parsedNames, len(names), names)
+ }
+
+ for i := 0; i < len(names); i++ {
+ if names[i] != parsedNames[i] {
+ t.Errorf("got %q instead of %q", parsedNames[i], names[i])
+ }
+ }
+}
diff --git a/pkinit/asn1.go b/pkinit/asn1.go
new file mode 100644
index 0000000..cad97b3
--- /dev/null
+++ b/pkinit/asn1.go
@@ -0,0 +1,165 @@
+package pkinit
+
+import (
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/asn1"
+ "fmt"
+ "math/big"
+ "time"
+
+ "github.com/jcmturner/gokrb5/v8/types"
+)
+
+type SignerInfo struct {
+ Version int `asn1:"default:1"`
+ IssuerAndSerialNumber IssuerAndSerial
+ DigestAlgorithm pkix.AlgorithmIdentifier
+ AuthenticatedAttributes []Attribute `asn1:"optional,omitempty,tag:0"`
+ DigestEncryptionAlgorithm pkix.AlgorithmIdentifier
+ EncryptedDigest []byte
+ UnauthenticatedAttributes []pkix.AttributeTypeAndValue `asn1:"optional,omitempty,tag:1"`
+}
+
+type Attribute struct {
+ Type asn1.ObjectIdentifier
+ Value asn1.RawValue `asn1:"set"`
+}
+
+type IssuerAndSerial struct {
+ IssuerName asn1.RawValue
+ SerialNumber *big.Int
+}
+
+type ContentInfo struct {
+ ContentType asn1.ObjectIdentifier
+ Content asn1.RawValue `asn1:"explicit,optional,tag:0"`
+}
+
+type SignedData struct {
+ Version int `asn1:"default:1"`
+ DigestAlgorithmIdentifiers []pkix.AlgorithmIdentifier `asn1:"set"`
+ ContentInfo ContentInfo
+ Certificates RawCertificates `asn1:"optional,tag:0"`
+ CRLs []x509.RevocationList `asn1:"optional,tag:1"`
+ SignerInfos []SignerInfo `asn1:"set"`
+}
+
+type RawCertificates struct {
+ Raw asn1.RawContent
+}
+
+func RawCertificate(cert *x509.Certificate) (RawCertificates, error) {
+ val := asn1.RawValue{Bytes: cert.Raw, Class: 2, Tag: 0, IsCompound: true}
+
+ b, err := asn1.Marshal(val)
+ if err != nil {
+ return RawCertificates{}, err
+ }
+
+ return RawCertificates{Raw: b}, nil
+}
+
+type AuthPack struct {
+ // AuthPack ::= SEQUENCE {
+ // pkAuthenticator [0] PKAuthenticator,
+ // clientPublicValue [1] SubjectPublicKeyInfo OPTIONAL,
+ // supportedCMSTypes [2] SEQUENCE OF AlgorithmIdentifier OPTIONAL,
+ // clientDHNonce [3] DHNonce OPTIONAL,
+ // ...,
+ // supportedKDFs [4] SEQUENCE OF KDFAlgorithmId OPTIONAL,
+ // ...
+ // }
+ PKAuthenticator PKAuthenticator `asn1:"tag:0,explicit"`
+ ClientPublicValue SubjectPublicKeyInfo `asn1:"tag:1,explicit,optional"`
+ SupportedCMSTypes []pkix.AlgorithmIdentifier `asn1:"tag:2,explicit,optional"`
+ ClientDHNonce []byte `asn1:"tag:3,explicit,optional"`
+}
+
+type PKAuthenticator struct {
+ // PKAuthenticator ::= SEQUENCE {
+ // cusec [0] INTEGER -- (0..999999) --,
+ // ctime [1] KerberosTime,
+ // nonce [2] INTEGER (0..4294967295),
+ // paChecksum [3] OCTET STRING OPTIONAL,
+ // ...
+ // asn1
+ CUSec int `asn1:"tag:0,explicit"`
+ CTime time.Time `asn1:"tag:1,explicit,generalized"`
+ Nonce int `asn1:"tag:2,explicit"`
+ Checksum []byte `asn1:"tag:3,explicit,optional"`
+}
+
+type SubjectPublicKeyInfo struct {
+ // SubjectPublicKeyInfo ::= SEQUENCE {
+ // algorithm AlgorithmIdentifier{PUBLIC-KEY,
+ // {PublicKeyAlgorithms}},
+ // subjectPublicKey BIT STRING }
+ Algorithm AlgorithmIdentifier
+ PublicKey asn1.BitString
+}
+
+type AlgorithmIdentifier struct {
+ Algorithm asn1.ObjectIdentifier `asn1:"implicit"`
+ Parameters DomainParameters `asn1:"implicit,optional"`
+}
+
+type DomainParameters struct {
+ // DomainParameters ::= SEQUENCE {
+ // p INTEGER, -- odd prime, p=jq +1
+ // g INTEGER, -- generator, g
+ // q INTEGER, -- factor of p-1
+ // j INTEGER OPTIONAL, -- subgroup factor
+ // validationParams ValidationParams OPTIONAL }
+ P *big.Int
+ G int
+ Q int
+}
+
+type PAPKASRep struct {
+ DHInfo asn1.RawValue
+}
+
+type PAPACRequest struct {
+ IncludePAC bool `asn1:"explicit,tag:0"`
+}
+
+func (p *PAPACRequest) AsPAData() types.PAData {
+ // make sure we marshal the struct and not the pointer to the struct which
+ // would cause an error and also make sure we don't panic when receiver is
+ // nil.
+ pacRequest := PAPACRequest{}
+ if p != nil {
+ pacRequest.IncludePAC = p.IncludePAC
+ }
+
+ pacRequestBytes, err := asn1.Marshal(pacRequest)
+ if err != nil {
+ panic(fmt.Sprintf("unexpected error marshalling PAPACRequest: %v", err))
+ }
+
+ const paDataTypePACRequest = 128
+
+ return types.PAData{
+ PADataType: paDataTypePACRequest,
+ PADataValue: pacRequestBytes,
+ }
+}
+
+type DHRepInfo struct {
+ DHSignedData []byte `asn1:"tag:0"`
+ ServerDHNonce []byte `asn1:"tag:1,explicit,optional"`
+}
+
+type KDCDHKeyInfo struct {
+ SubjectPublicKey asn1.BitString `asn1:"tag:0,explicit"`
+ Nonce *big.Int `asn1:"tag:1,explicit"`
+ DHKeyExpication time.Time `asn1:"tag:2,explicit,optional,generalized"`
+}
+
+type AuthoirzationDataElement struct {
+ ADType int `asn1:"tag:0"`
+ ADData []byte `asn1:"tag:1"`
+}
+
+type AuthoirzationData []AuthoirzationDataElement
diff --git a/pkinit/asrep.go b/pkinit/asrep.go
new file mode 100644
index 0000000..9325221
--- /dev/null
+++ b/pkinit/asrep.go
@@ -0,0 +1,167 @@
+package pkinit
+
+import (
+ "crypto/sha1"
+ "encoding/asn1"
+ "fmt"
+ "math/big"
+
+ krb5crypto "github.com/jcmturner/gokrb5/v8/crypto"
+ "github.com/jcmturner/gokrb5/v8/iana/etypeID"
+ "github.com/jcmturner/gokrb5/v8/iana/keyusage"
+ "github.com/jcmturner/gokrb5/v8/messages"
+ "github.com/jcmturner/gokrb5/v8/types"
+)
+
+// Decrypt decrypts the encrypted parts of an ASRep with the key derived during PKINIT.
+func Decrypt(asRep *messages.ASRep, dhKey *big.Int, dhClientNonce []byte) (pkinitKey types.EncryptionKey, err error) {
+ ekey, err := ExtractNegotiatedKey(asRep, dhKey, dhClientNonce)
+ if err != nil {
+ return pkinitKey, fmt.Errorf("extract negotiated key: %w", err)
+ }
+
+ decrypted, err := krb5crypto.DecryptEncPart(asRep.EncPart, ekey, keyusage.AS_REP_ENCPART)
+ if err != nil {
+ return pkinitKey, fmt.Errorf("decrypt: %w", err)
+ }
+
+ err = asRep.DecryptedEncPart.Unmarshal(decrypted)
+ if err != nil {
+ return pkinitKey, fmt.Errorf("unmarshal encrypted part: %w", err)
+ }
+
+ return ekey, nil
+}
+
+// ExtractNegotiatedKey extracts the key derived during PKINIT.
+func ExtractNegotiatedKey(
+ asRep *messages.ASRep, dhKey *big.Int, dhClientNonce []byte,
+) (ekey types.EncryptionKey, err error) {
+ var paPKASRepBytes []byte
+
+ for _, paData := range asRep.PAData {
+ if paData.PADataType == 17 {
+ paPKASRepBytes = paData.PADataValue
+ }
+ }
+
+ if paPKASRepBytes == nil {
+ return ekey, fmt.Errorf("could not find pA-PK-AS-REP structure")
+ }
+
+ var paPKASRep DHRepInfo
+
+ err = unmarshalFromRawValue(paPKASRepBytes, &paPKASRep)
+ if err != nil {
+ return ekey, fmt.Errorf("unmarshal PA-PK-AS-Rep: %w", err)
+ }
+
+ var contentInfo ContentInfo
+
+ _, err = asn1.Unmarshal(paPKASRep.DHSignedData, &contentInfo)
+ if err != nil {
+ return ekey, fmt.Errorf("unmarshal signed data: %w", err)
+ }
+
+ if !contentInfo.ContentType.Equal(asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}) {
+ return ekey, fmt.Errorf("unexpected outer content type: %s", contentInfo.ContentType)
+ }
+
+ var signedData SignedData
+
+ _, err = asn1.Unmarshal(contentInfo.Content.Bytes, &signedData)
+ if err != nil {
+ return ekey, fmt.Errorf("unmarshal signed data: %w", err)
+ }
+
+ if !signedData.ContentInfo.ContentType.Equal(asn1.ObjectIdentifier{1, 3, 6, 1, 5, 2, 3, 2}) {
+ return ekey, fmt.Errorf("unexpected inner content type: %s", signedData.ContentInfo.ContentType)
+ }
+
+ var keyInfo KDCDHKeyInfo
+
+ err = unmarshalFromRawValue(signedData.ContentInfo.Content.Bytes, &keyInfo)
+ if err != nil {
+ return ekey, fmt.Errorf("unmarshal key info: %w", err)
+ }
+
+ if len(keyInfo.SubjectPublicKey.Bytes) < 7 {
+ return ekey, fmt.Errorf("public key is too short")
+ }
+
+ pubkeyData, err := asn1.Marshal(keyInfo.SubjectPublicKey)
+ if err != nil {
+ return ekey, fmt.Errorf("marshal public key: %w", err)
+ }
+
+ pubKey := big.NewInt(0)
+ pubKey.SetBytes(pubkeyData[7:])
+
+ sharedSecret := DiffieHellmanSharedSecret(dhKey, pubKey)
+ sharedKey := sharedSecret.Bytes()
+ sharedKey = append(sharedKey, dhClientNonce...)
+ sharedKey = append(sharedKey, paPKASRep.ServerDHNonce...)
+
+ var keyType int32
+
+ switch asRep.EncPart.EType {
+ case etypeID.AES256_CTS_HMAC_SHA1_96:
+ keyType = etypeID.AES256_CTS_HMAC_SHA1_96
+ sharedKey = truncateKey(sharedKey, 32)
+ case etypeID.AES128_CTS_HMAC_SHA1_96:
+ keyType = etypeID.AES128_CTS_HMAC_SHA1_96
+ sharedKey = truncateKey(sharedKey, 16)
+ default:
+ return ekey, fmt.Errorf("PKInit is not implemented for EType %d", asRep.EncPart.EType)
+ }
+
+ return types.EncryptionKey{
+ KeyType: keyType,
+ KeyValue: sharedKey,
+ }, nil
+}
+
+func unmarshalFromRawValue(data []byte, v any) error {
+ var raw asn1.RawValue
+
+ rest, err := asn1.Unmarshal(data, &raw)
+ if err != nil {
+ return fmt.Errorf("unmarshal raw: %w", err)
+ }
+
+ if len(rest) != 0 {
+ return fmt.Errorf("remaining data found after unmarshalling raw value")
+ }
+
+ rest, err = asn1.Unmarshal(raw.Bytes, v)
+ if err != nil {
+ return err
+ }
+
+ if len(rest) != 0 {
+ return fmt.Errorf("remaining data found after unmarshalling")
+ }
+
+ return nil
+}
+
+func truncateKey(key []byte, size int) []byte {
+ var output []byte
+
+ idx := byte(0)
+
+ for len(output) < size {
+ digest := sha1.Sum(append([]byte{idx}, key...))
+ if len(output)+len(digest) > size {
+ output = append(output, digest[:size-len(output)]...)
+
+ break
+ }
+
+ output = append(output, digest[:]...)
+
+ idx++
+ }
+
+ return output
+}
diff --git a/pkinit/asreq.go b/pkinit/asreq.go
new file mode 100644
index 0000000..c99cbb8
--- /dev/null
+++ b/pkinit/asreq.go
@@ -0,0 +1,135 @@
+package pkinit
+
+import (
+ cryptRand "crypto/rand"
+ "crypto/rsa"
+ "crypto/sha1"
+ "crypto/x509"
+ "encoding/asn1"
+ "fmt"
+ "math/big"
+ mathRand "math/rand"
+ "time"
+
+ "github.com/jcmturner/gokrb5/v8/config"
+ "github.com/jcmturner/gokrb5/v8/iana/nametype"
+ "github.com/jcmturner/gokrb5/v8/messages"
+ "github.com/jcmturner/gokrb5/v8/types"
+)
+
+// NewASReq generates an ASReq configured for PKINIT.
+func NewASReq(
+ username string, domain string, cert *x509.Certificate, key *rsa.PrivateKey, dhKey *big.Int, config *config.Config,
+) (asReq messages.ASReq, dhClientNonce []byte, err error) {
+ asReq, err = messages.NewASReqForTGT(domain, config, types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, username))
+ if err != nil {
+ return asReq, nil, fmt.Errorf("build generic asreq: %w", err)
+ }
+
+ dhClientNonce = NewDiffieHellmanNonce()
+
+ return asReq, dhClientNonce, ConfigureASReq(&asReq, cert, key, dhKey, dhClientNonce)
+}
+
+// ConfigureASReq configures an ASReq for PKINIT.
+func ConfigureASReq(
+ asReq *messages.ASReq, cert *x509.Certificate, key *rsa.PrivateKey, dhKey *big.Int, dhClientNonce []byte,
+) error {
+ pkAuthenticatorChecksum, err := calculatePKAuthenticatorChecksum(asReq.ReqBody)
+ if err != nil {
+ return fmt.Errorf("calculate checksum: %w", err)
+ }
+
+ publicKey := DiffieHellmanPublicKey(dhKey)
+
+ publicKeyBytes, err := asn1.MarshalWithParams(publicKey, "int")
+ if err != nil {
+ return err
+ }
+
+ now := time.Now().UTC()
+
+ authPack := AuthPack{
+ PKAuthenticator: PKAuthenticator{
+ CUSec: int(now.UnixMicro() - now.Truncate(time.Millisecond).UnixMicro()),
+ CTime: now,
+ Nonce: mathRand.Intn(4294967295), //nolint:gosec
+ Checksum: pkAuthenticatorChecksum,
+ },
+ ClientPublicValue: SubjectPublicKeyInfo{
+ Algorithm: AlgorithmIdentifier{
+ Algorithm: asn1.ObjectIdentifier{1, 2, 840, 10046, 2, 1},
+ Parameters: DomainParameters{
+ P: DiffieHellmanPrime,
+ G: 2,
+ Q: 0,
+ },
+ },
+ PublicKey: asn1.BitString{
+ Bytes: publicKeyBytes,
+ BitLength: len(publicKeyBytes) * 8,
+ },
+ },
+ SupportedCMSTypes: nil,
+ ClientDHNonce: dhClientNonce,
+ }
+
+ authPackBytes, err := asn1.Marshal(authPack)
+ if err != nil {
+ return fmt.Errorf("marshal auth pack: %w", err)
+ }
+
+ signedAuthPack, err := PKCS7Sign(authPackBytes, key, cert)
+ if err != nil {
+ return fmt.Errorf("create signed authpack: %w", err)
+ }
+
+ pkASReq := struct {
+ SignedAuthPack []byte `asn1:"tag:0"`
+ TrustedIdentifiers types.PrincipalName `asn1:"tag:1,explicit,optional"`
+ KDCPKID []byte `asn1:"tag:2,optional"`
+ }{
+ SignedAuthPack: signedAuthPack,
+ }
+
+ pkASReqBytes, err := asn1.Marshal(pkASReq)
+ if err != nil {
+ return fmt.Errorf("marshal ASReq: %w", err)
+ }
+
+ const paDataTypePKASReq = 16
+
+ asReq.PAData = append(asReq.PAData, types.PAData{
+ PADataType: paDataTypePKASReq,
+ PADataValue: pkASReqBytes,
+ })
+
+ return nil
+}
+
+// NewDiffieHellmanNonce generates a nonce for the Diffie Hellman key exchange.
+func NewDiffieHellmanNonce() []byte {
+ return randomBytes(32)
+}
+
+func calculatePKAuthenticatorChecksum(asReqBody messages.KDCReqBody) ([]byte, error) {
+ bodyBytes, err := asReqBody.Marshal()
+ if err != nil {
+ return nil, fmt.Errorf("marshal ASReq body: %w", err)
+ }
+
+ hash := sha1.Sum(bodyBytes)
+
+ return hash[:], nil
+}
+
+func randomBytes(n int) []byte {
+ b := make([]byte, n)
+
+ _, err := cryptRand.Read(b)
+ if err != nil {
+ panic(err.Error())
+ }
+
+ return b
+}
diff --git a/pkinit/diffie_hellman.go b/pkinit/diffie_hellman.go
new file mode 100644
index 0000000..b55afee
--- /dev/null
+++ b/pkinit/diffie_hellman.go
@@ -0,0 +1,43 @@
+package pkinit
+
+import (
+ "encoding/hex"
+ "fmt"
+ "math/big"
+)
+
+var (
+ // DiffieHellmanPrime is the Diffie Hellman prime (P) that is acccepted by PKINIT.
+ DiffieHellmanPrime = big.NewInt(0)
+ // DiffieHellmanPrime is the Diffie Hellman base (G) that is acccepted by PKINIT.
+ DiffieHellmanBase = big.NewInt(2)
+)
+
+func init() {
+ pBytes, err := hex.DecodeString(
+ "00ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020b" +
+ "bea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe135" +
+ "6d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb" +
+ "5a899fa5ae9f24117c4b1fe649286651ece65381ffffffffffffffff")
+ if err != nil {
+ panic(fmt.Sprintf("decode Diffie-Hellman parameter p: %v", err))
+ }
+
+ DiffieHellmanPrime.SetBytes(pBytes)
+}
+
+// DiffieHellmanPublicKey derives the Diffie Hellman public key from the
+// provided private key with the parameters that are accepted by PKINIT.
+func DiffieHellmanPublicKey(privateKey *big.Int) *big.Int {
+ publicKey := new(big.Int).Exp(DiffieHellmanBase, privateKey, DiffieHellmanPrime)
+
+ return publicKey
+}
+
+// DiffieHellmanSharedSecret derives the Diffie Hellman shared secret with the
+// parameters that are accepted by PKINIT.
+func DiffieHellmanSharedSecret(privateKey *big.Int, publicKey *big.Int) *big.Int {
+ sharedSecret := new(big.Int).Exp(publicKey, privateKey, DiffieHellmanPrime)
+
+ return sharedSecret
+}
diff --git a/pkinit/exchange.go b/pkinit/exchange.go
new file mode 100644
index 0000000..3b5ff49
--- /dev/null
+++ b/pkinit/exchange.go
@@ -0,0 +1,278 @@
+package pkinit
+
+import (
+ "context"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "net"
+ "time"
+
+ "github.com/RedTeamPentesting/adauth/ccachetools"
+ "github.com/jcmturner/gokrb5/v8/config"
+ "github.com/jcmturner/gokrb5/v8/credentials"
+ "github.com/jcmturner/gokrb5/v8/messages"
+)
+
+// DefaultKerberosRoundtripDeadline is the maximum time a roundtrip with the KDC
+// can take before it is aborted. This deadline is for each KDC that is
+// considered.
+var DefaultKerberosRoundtripDeadline = 5 * time.Second
+
+// Authenticate obtains a ticket granting ticket using PKINIT and returns it in
+// a CCache which can be serialized using ccachetools.MarshalCCache.
+func Authenticate(
+ ctx context.Context, user string, domain string, cert *x509.Certificate, key *rsa.PrivateKey,
+ krbConfig *config.Config, opts ...Option,
+) (*credentials.CCache, error) {
+ if user == "" {
+ return nil, fmt.Errorf("username is empty")
+ }
+
+ if domain == "" {
+ return nil, fmt.Errorf("domain is empty")
+ }
+
+ dialer, roundtripDeadline, err := processOptions(opts)
+ if err != nil {
+ return nil, err
+ }
+
+ asReq, dhClientNonce, err := NewASReq(user, domain, cert, key, key.D, krbConfig)
+ if err != nil {
+ return nil, fmt.Errorf("build ASReq: %w", err)
+ }
+
+ asRep, err := ASExchange(ctx, asReq, domain, krbConfig, dialer, roundtripDeadline)
+ if err != nil {
+ return nil, fmt.Errorf("exchange: %w", err)
+ }
+
+ _, err = Decrypt(&asRep, key.D, dhClientNonce)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt ASRep: %w", err)
+ }
+
+ return ccachetools.NewCCacheFromASRep(asRep)
+}
+
+// ASExchange sends a ASReq to the KDC for the provided domain and returns the
+// ASRep.
+func ASExchange(
+ ctx context.Context, asReq messages.ASReq, domain string, config *config.Config,
+ dialer ContextDialer, roundtripDeadline time.Duration,
+) (asRep messages.ASRep, err error) {
+ asReqBytes, err := asReq.Marshal()
+ if err != nil {
+ return asRep, fmt.Errorf("marshal ASReq: %w", err)
+ }
+
+ asRepBytes, err := roundtrip(ctx, asReqBytes, config, domain, dialer, roundtripDeadline)
+ if err != nil {
+ return asRep, fmt.Errorf("roundtrip: %w", err)
+ }
+
+ err = asRep.Unmarshal(asRepBytes)
+ if err != nil {
+ return asRep, fmt.Errorf("unmarshal ASRep: %w", err)
+ }
+
+ return asRep, nil
+}
+
+// TGSExchange sends a TGSReq to the KDC for the provided domain and returns the
+// TGSRep.
+func TGSExchange(
+ ctx context.Context, tgsReq messages.TGSReq, config *config.Config, domain string,
+ dialer ContextDialer, roundtripDeadline time.Duration,
+) (tgsRep messages.TGSRep, err error) {
+ asReqBytes, err := tgsReq.Marshal()
+ if err != nil {
+ return tgsRep, fmt.Errorf("marshal ASReq: %w", err)
+ }
+
+ asRepBytes, err := roundtrip(ctx, asReqBytes, config, domain, dialer, roundtripDeadline)
+ if err != nil {
+ return tgsRep, fmt.Errorf("roundtrip: %w", err)
+ }
+
+ err = tgsRep.Unmarshal(asRepBytes)
+ if err != nil {
+ return tgsRep, fmt.Errorf("unmarshal ASRep: %w", err)
+ }
+
+ return tgsRep, nil
+}
+
+func roundtrip(
+ ctx context.Context, request []byte, config *config.Config, domain string,
+ dialer ContextDialer, roundtripDeadline time.Duration,
+) (response []byte, err error) {
+ _, kdcs, err := config.GetKDCs(domain, true)
+ if err != nil {
+ return nil, fmt.Errorf("get KDCs from config: %w", err)
+ } else if len(kdcs) == 0 {
+ return nil, fmt.Errorf("no KDCs found in config")
+ }
+
+ for i := 1; i <= len(kdcs); i++ {
+ if ctx.Err() != nil {
+ return nil, context.Cause(ctx)
+ }
+
+ response, err = roundtripForSingleKDC(ctx, request, kdcs[i], dialer, roundtripDeadline)
+ if err == nil {
+ return response, nil
+ }
+ }
+
+ switch {
+ case err != nil:
+ return nil, err
+ case ctx.Err() != nil:
+ return nil, context.Cause(ctx)
+ default:
+ return nil, fmt.Errorf("unknown error")
+ }
+}
+
+// ContextDialer is a context aware dialer such as net.Dialer or the SOCKS5
+// dialer returned by proxy.SOCKS5.
+type ContextDialer interface {
+ DialContext(ctx context.Context, net string, addr string) (net.Conn, error)
+}
+
+func roundtripForSingleKDC(
+ ctx context.Context, request []byte, address string,
+ dialer ContextDialer, roundtripDeadline time.Duration,
+) ([]byte, error) {
+ if dialer == nil {
+ dialer = &net.Dialer{Timeout: roundtripDeadline}
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, roundtripDeadline)
+ defer cancel()
+
+ conn, err := dialer.DialContext(ctx, "tcp", address)
+ if err != nil {
+ return nil, fmt.Errorf("dial: %w", err)
+ }
+
+ var (
+ responseChan = make(chan []byte)
+ errChan = make(chan error)
+ )
+
+ go func() {
+ _ = conn.SetDeadline(time.Now().Add(roundtripDeadline))
+
+ response, err := sendRecv(conn, request)
+
+ _ = conn.Close()
+
+ switch {
+ case err != nil:
+ errChan <- err
+ default:
+ responseChan <- response
+ }
+ }()
+
+ select {
+ case response := <-responseChan:
+ return response, nil
+ case err := <-errChan:
+ return nil, err
+ case <-ctx.Done():
+ conn.Close() //nolint:gosec
+
+ return nil, context.Cause(ctx)
+ }
+}
+
+func sendRecv(conn net.Conn, request []byte) ([]byte, error) {
+ requestLengthBytes := make([]byte, 4)
+ binary.BigEndian.PutUint32(requestLengthBytes, uint32(len(request)))
+
+ request = append(requestLengthBytes, request...) //nolint:makezero
+
+ _, err := conn.Write(request)
+ if err != nil {
+ return nil, fmt.Errorf("error sending to KDC (%s): %w", conn.RemoteAddr().String(), err)
+ }
+
+ responseLengthBytes := make([]byte, 4)
+
+ _, err = conn.Read(responseLengthBytes)
+ if err != nil {
+ return nil, fmt.Errorf("error reading response size header: %w", err)
+ }
+
+ responseLength := binary.BigEndian.Uint32(responseLengthBytes)
+
+ responseBytes := make([]byte, responseLength)
+
+ _, err = io.ReadFull(conn, responseBytes)
+ if err != nil {
+ return nil, fmt.Errorf("error reading response: %w", err)
+ }
+
+ if len(responseBytes) < 1 {
+ return nil, fmt.Errorf("no response data from KDC %s", conn.RemoteAddr().String())
+ }
+
+ return responseBytes, nil
+}
+
+// Option can be passed to a function to modify the default behavior.
+type Option interface {
+ isPKINITOption()
+}
+
+type option struct{}
+
+func (option) isPKINITOption() {}
+
+type dialerOption struct {
+ option
+ ContextDialer ContextDialer
+}
+
+// WithDialer can be used to set a custom dialer for communication with a DC.
+func WithDialer(dialer ContextDialer) Option {
+ return dialerOption{ContextDialer: dialer}
+}
+
+type deadlineOption struct {
+ option
+ Deadline time.Duration
+}
+
+// WithRoundtripDeadline can be used to set a deadline for a single
+// request-response roundtrip with a single KDC.
+func WithRoundtripDeadline(deadline time.Duration) Option {
+ return deadlineOption{Deadline: deadline}
+}
+
+func processOptions(opts []Option) (dialer ContextDialer, roundtripDeadline time.Duration, err error) {
+ roundtripDeadline = DefaultKerberosRoundtripDeadline
+
+ for _, opt := range opts {
+ switch o := opt.(type) {
+ case dialerOption:
+ dialer = o.ContextDialer
+ case deadlineOption:
+ roundtripDeadline = o.Deadline
+ default:
+ return nil, 0, fmt.Errorf("unknown option: %T", o)
+ }
+ }
+
+ if dialer == nil {
+ dialer = &net.Dialer{Timeout: roundtripDeadline}
+ }
+
+ return dialer, roundtripDeadline, nil
+}
diff --git a/pkinit/pkcs7.go b/pkinit/pkcs7.go
new file mode 100644
index 0000000..c64784d
--- /dev/null
+++ b/pkinit/pkcs7.go
@@ -0,0 +1,124 @@
+package pkinit
+
+import (
+ "crypto"
+ "crypto/rsa"
+ "crypto/sha1"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/asn1"
+ "fmt"
+)
+
+// PKCS7Sign signs the data according to PKCS#7.
+func PKCS7Sign(data []byte, key *rsa.PrivateKey, cert *x509.Certificate) ([]byte, error) {
+ serializedData, err := asn1.Marshal(data)
+ if err != nil {
+ return nil, fmt.Errorf("marshal data: %w", err)
+ }
+
+ rawCert, err := RawCertificate(cert)
+ if err != nil {
+ return nil, fmt.Errorf("marshal certificate: %w", err)
+ }
+
+ digest := sha1.Sum(data)
+
+ serializedDigest, err := asn1.Marshal(digest[:])
+ if err != nil {
+ return nil, fmt.Errorf("marshal digest: %w", err)
+ }
+
+ serializedPKInitOID, err := asn1.Marshal(asn1.ObjectIdentifier{1, 3, 6, 1, 5, 2, 3, 1})
+ if err != nil {
+ return nil, fmt.Errorf("marshal PKInit OID: %w", err)
+ }
+
+ sha1AlgorithmIdentifier := pkix.AlgorithmIdentifier{
+ Algorithm: asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26},
+ }
+
+ rsaWithSHA1AlgorithmIdentifier := pkix.AlgorithmIdentifier{
+ Algorithm: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 5},
+ }
+
+ authenticatedAttributes := []Attribute{
+ {
+ // ContentType
+ Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 3},
+ // id-pkinit-authData
+ Value: asn1.RawValue{Tag: 17, IsCompound: true, Bytes: serializedPKInitOID},
+ },
+ {
+ // MessageDigest
+ Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 9, 4},
+ Value: asn1.RawValue{Tag: 17, IsCompound: true, Bytes: serializedDigest},
+ },
+ }
+
+ signature, err := signAuthenicatedAttributes(authenticatedAttributes, key)
+ if err != nil {
+ return nil, fmt.Errorf("sign authenticated data: %w", err)
+ }
+
+ signedDataBytes, err := asn1.Marshal(SignedData{
+ Version: 3,
+ DigestAlgorithmIdentifiers: []pkix.AlgorithmIdentifier{sha1AlgorithmIdentifier},
+ ContentInfo: ContentInfo{
+ // id-pkinit-authData
+ ContentType: asn1.ObjectIdentifier{1, 3, 6, 1, 5, 2, 3, 1},
+ Content: asn1.RawValue{Class: 2, Tag: 0, Bytes: serializedData, IsCompound: true},
+ },
+ Certificates: rawCert,
+ SignerInfos: []SignerInfo{
+ {
+ Version: 1,
+ IssuerAndSerialNumber: IssuerAndSerial{
+ IssuerName: asn1.RawValue{FullBytes: cert.RawIssuer},
+ SerialNumber: cert.SerialNumber,
+ },
+ DigestAlgorithm: sha1AlgorithmIdentifier,
+ AuthenticatedAttributes: authenticatedAttributes,
+ DigestEncryptionAlgorithm: rsaWithSHA1AlgorithmIdentifier,
+ EncryptedDigest: signature,
+ },
+ },
+ })
+ if err != nil {
+ return nil, fmt.Errorf("marshal signed data: %w", err)
+ }
+
+ contentInfo := ContentInfo{
+ // signed data
+ ContentType: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2},
+ Content: asn1.RawValue{Class: 2, Tag: 0, Bytes: signedDataBytes, IsCompound: true},
+ }
+
+ signedContent, err := asn1.Marshal(contentInfo)
+ if err != nil {
+ return nil, fmt.Errorf("marshal signed content: %w", err)
+ }
+
+ return signedContent, nil
+}
+
+func signAuthenicatedAttributes(attrs []Attribute, key *rsa.PrivateKey) ([]byte, error) {
+ rawAuthenticatedAttributesAsSequence, err := asn1.Marshal(struct {
+ A []Attribute `asn1:"set"`
+ }{A: attrs})
+ if err != nil {
+ return nil, fmt.Errorf("marshal authenticated data: %w", err)
+ }
+
+ // Remove the leading sequence octets
+ var rawAuthenticatedAttributes asn1.RawValue
+
+ _, err = asn1.Unmarshal(rawAuthenticatedAttributesAsSequence, &rawAuthenticatedAttributes)
+ if err != nil {
+ return nil, fmt.Errorf("remove sequence bytes: %w", err)
+ }
+
+ hash := sha1.Sum(rawAuthenticatedAttributes.Bytes)
+
+ return rsa.SignPKCS1v15(nil, key, crypto.SHA1, hash[:])
+}
diff --git a/pkinit/unpacthehash.go b/pkinit/unpacthehash.go
new file mode 100644
index 0000000..5d859bd
--- /dev/null
+++ b/pkinit/unpacthehash.go
@@ -0,0 +1,351 @@
+package pkinit
+
+import (
+ "context"
+ "crypto/rsa"
+ "crypto/x509"
+ "encoding/asn1"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/RedTeamPentesting/adauth"
+ "github.com/RedTeamPentesting/adauth/ccachetools"
+ "github.com/jcmturner/gokrb5/v8/config"
+ "github.com/jcmturner/gokrb5/v8/credentials"
+ "github.com/jcmturner/gokrb5/v8/iana/nametype"
+ "github.com/jcmturner/gokrb5/v8/iana/patype"
+ "github.com/jcmturner/gokrb5/v8/krberror"
+ "github.com/jcmturner/gokrb5/v8/messages"
+ "github.com/jcmturner/gokrb5/v8/types"
+ "github.com/oiweiwei/go-msrpc/msrpc/pac"
+ "github.com/oiweiwei/go-msrpc/ndr"
+)
+
+// UnPACTheHash retrieves the user's NT hash via PKINIT using the provided PFX
+// file. The DC argument is optional.
+func UnPACTheHashFromPFX(
+ ctx context.Context, username string, domain string, pfxFile string, pfxPassword string,
+ dc string, opts ...Option,
+) (*credentials.CCache, *Hash, error) {
+ pfxData, err := os.ReadFile(pfxFile)
+ if err != nil {
+ return nil, nil, fmt.Errorf("read PFX file: %w", err)
+ }
+
+ return UnPACTheHashFromPFXData(ctx, username, domain, pfxData, pfxPassword, dc, opts...)
+}
+
+// UnPACTheHash retrieves the user's NT hash via PKINIT using the provided PFX
+// data. The DC argument is optional.
+func UnPACTheHashFromPFXData(
+ ctx context.Context, username string, domain string, pfxData []byte, pfxPassword string,
+ dc string, opts ...Option,
+) (*credentials.CCache, *Hash, error) {
+ cred, err := adauth.CredentialFromPFXBytes(username, domain, pfxData, pfxPassword)
+ if err != nil {
+ return nil, nil, fmt.Errorf("build credentials from PFX: %w", err)
+ }
+
+ if dc != "" {
+ cred.SetDC(dc)
+ }
+
+ krbConf, err := cred.KerberosConfig(ctx)
+ if err != nil {
+ return nil, nil, fmt.Errorf("configure Kerberos: %w", err)
+ }
+
+ return UnPACTheHash(ctx, cred.Username, cred.Domain, cred.ClientCert, cred.ClientCertKey, krbConf, opts...)
+}
+
+// UnPACTheHash retrieves the user's NT hash via PKINIT using the provided
+// certificates.
+func UnPACTheHash(
+ ctx context.Context, user string, domain string, cert *x509.Certificate, key *rsa.PrivateKey,
+ krbConfig *config.Config, opts ...Option,
+) (*credentials.CCache, *Hash, error) {
+ dialer, roundtripDeadline, err := processOptions(opts)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ krbConfig = &config.Config{
+ LibDefaults: krbConfig.LibDefaults,
+ Realms: krbConfig.Realms,
+ DomainRealm: krbConfig.DomainRealm,
+ }
+
+ krbConfig.LibDefaults.Proxiable = false
+
+ asReq, dhClientNonce, err := NewASReq(user, domain, cert, key, key.D, krbConfig)
+ if err != nil {
+ return nil, nil, fmt.Errorf("build ASReq: %w", err)
+ }
+
+ asReq.PAData = append(asReq.PAData, (&PAPACRequest{IncludePAC: true}).AsPAData())
+
+ asRep, err := ASExchange(ctx, asReq, domain, krbConfig, dialer, roundtripDeadline)
+ if err != nil {
+ return nil, nil, fmt.Errorf("AS exchange: %w", err)
+ }
+
+ pkinitKey, err := Decrypt(&asRep, key.D, dhClientNonce)
+ if err != nil {
+ return nil, nil, fmt.Errorf("decrypt ASRep: %w", err)
+ }
+
+ ccache, err := ccachetools.NewCCacheFromASRep(asRep)
+ if err != nil {
+ return nil, nil, fmt.Errorf("generate CCache: %w", err)
+ }
+
+ tgsReq, err := messages.NewUser2UserTGSReq(
+ types.NewPrincipalName(nametype.KRB_NT_PRINCIPAL, user), strings.ToUpper(asRep.CRealm), krbConfig,
+ asRep.Ticket, asRep.DecryptedEncPart.Key, types.NewPrincipalName(nametype.KRB_NT_UNKNOWN, user), false, asRep.Ticket)
+ if err != nil {
+ return ccache, nil, fmt.Errorf("generate TGSReq: %w", err)
+ }
+
+ tgsReq.ReqBody.CName = types.PrincipalName{}
+
+ tgsReq.PAData, err = generatePAData(user, asRep.Ticket, asRep.DecryptedEncPart.Key)
+ if err != nil {
+ return ccache, nil, fmt.Errorf("generate PAData sequence: %w", err)
+ }
+
+ tgsRep, err := TGSExchange(ctx, tgsReq, krbConfig, domain, dialer, roundtripDeadline)
+ if err != nil {
+ return ccache, nil, fmt.Errorf("TGS exchange: %w", err)
+ }
+
+ err = tgsRep.DecryptEncPart(asRep.DecryptedEncPart.Key)
+ if err != nil {
+ return ccache, nil, fmt.Errorf("decrypt TGSRep: %w", err)
+ }
+
+ err = tgsRep.Ticket.Decrypt(asRep.DecryptedEncPart.Key)
+ if err != nil {
+ return ccache, nil, fmt.Errorf("decrypt service ticket: %w", err)
+ }
+
+ ntHash, err := extractNTHash(tgsRep.Ticket, pkinitKey)
+ if err != nil {
+ return nil, nil, fmt.Errorf("extract NT hash: %w", err)
+ }
+
+ return ccache, ntHash, nil
+}
+
+func extractNTHash(
+ tkt messages.Ticket, pkinitKey types.EncryptionKey,
+) (hash *Hash, err error) {
+ ifRelevant := types.ADIfRelevant{}
+
+ for _, authData := range tkt.DecryptedEncPart.AuthorizationData {
+ if authData.ADType != 1 {
+ continue
+ }
+
+ _, err = asn1.Unmarshal(authData.ADData, &ifRelevant)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal ADIfRelevant container: %w", err)
+ }
+ }
+
+ if len(ifRelevant) == 0 {
+ return nil, fmt.Errorf("no ADIfRelevant container present")
+ }
+
+ var pacData []byte
+
+ for _, authData := range ifRelevant {
+ if authData.ADType != 128 {
+ continue
+ }
+
+ pacData = authData.ADData
+ }
+
+ if len(pacData) == 0 {
+ return nil, fmt.Errorf("no PACTYPE container present: %w", err)
+ }
+
+ pacType := pac.PACType{}
+
+ err = ndr.Unmarshal(pacData, &pacType, ndr.Opaque)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal PACTYPE: %w", err)
+ }
+
+ var pacCredentialInfoBuffer []byte
+
+ for _, buffer := range pacType.Buffers {
+ if buffer.Type != 2 {
+ continue
+ }
+
+ if buffer.Offset+uint64(buffer.BufferLength) > uint64(len(pacData)) {
+ return nil, fmt.Errorf("PAC_CREDENTIAL_INFO buffer offset and length are outside of data range")
+ }
+
+ pacCredentialInfoBuffer = pacData[buffer.Offset : buffer.Offset+uint64(buffer.BufferLength)]
+ }
+
+ if len(pacCredentialInfoBuffer) == 0 {
+ return nil, fmt.Errorf("could not find PAC_CREDENTIAL_INFO buffer")
+ }
+
+ credInfo := pac.PACCredentialInfo{}
+
+ err = ndr.Unmarshal(pacCredentialInfoBuffer, &credInfo, ndr.Opaque)
+ if err != nil {
+ return nil, fmt.Errorf("unmarshal PAC_CREDENTIAL_INFO: %w", err)
+ }
+
+ credData, err := credInfo.DecryptCredentialData(pkinitKey)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt PAC_CREDENTIAL_DATA: %w", err)
+ }
+
+ for _, cred := range credData.Credentials {
+ if cred.NTLMSupplementalCredential == nil {
+ continue
+ }
+
+ return NewHash(cred.NTLMSupplementalCredential)
+ }
+
+ return nil, fmt.Errorf("could not find NTLM_SUPPLEMENTAL_CREDENTIAL in SECPKG_SUPPLEMENTAL_CRED array")
+}
+
+func generatePAData(
+ username string, tgt messages.Ticket, sessionKey types.EncryptionKey,
+) (paData types.PADataSequence, err error) {
+ auth, err := types.NewAuthenticator(tgt.Realm, types.NewPrincipalName(nametype.KRB_NT_UNKNOWN, username))
+ if err != nil {
+ return paData, krberror.Errorf(err, krberror.KRBMsgError, "error generating new authenticator")
+ }
+
+ auth.SeqNumber = 0
+
+ apReq, err := messages.NewAPReq(tgt, sessionKey, auth)
+ if err != nil {
+ return paData, krberror.Errorf(err, krberror.KRBMsgError, "error generating new AP_REQ")
+ }
+
+ apb, err := apReq.Marshal()
+ if err != nil {
+ return paData, krberror.Errorf(err, krberror.EncodingError, "error marshaling AP_REQ for pre-authentication data")
+ }
+
+ return types.PADataSequence{types.PAData{
+ PADataType: patype.PA_TGS_REQ,
+ PADataValue: apb,
+ }}, nil
+}
+
+// Hash represents LM and NT password hashes.
+type Hash struct {
+ nt []byte
+ lm []byte
+}
+
+func NewHash(ntlmSupplementalCredential *pac.NTLMSupplementalCredential) (*Hash, error) {
+ hash := &Hash{}
+
+ if ntlmSupplementalCredential.Flags&1 > 0 {
+ hash.lm = ntlmSupplementalCredential.LMPassword
+ }
+
+ if ntlmSupplementalCredential.Flags&2 > 0 {
+ hash.nt = ntlmSupplementalCredential.NTPassword
+ }
+
+ // according to the flags, there are no hashes, but we better check for ourselves
+ if hash.Empty() {
+ if len(ntlmSupplementalCredential.NTPassword) == 16 && nonZero(ntlmSupplementalCredential.NTPassword) {
+ hash.nt = ntlmSupplementalCredential.NTPassword
+ }
+
+ if len(ntlmSupplementalCredential.LMPassword) == 16 && nonZero(ntlmSupplementalCredential.LMPassword) {
+ hash.lm = ntlmSupplementalCredential.LMPassword
+ }
+ }
+
+ if hash.Empty() {
+ return nil, fmt.Errorf("NTLM_SUPPLEMENTAL_CREDENTIAL does not contain hashes "+
+ "(Flags: 0x%x, NTPassword: 0x%x, LMPassword 0x%x)",
+ ntlmSupplementalCredential.Flags,
+ ntlmSupplementalCredential.NTPassword, ntlmSupplementalCredential.LMPassword)
+ }
+
+ return hash, nil
+}
+
+// NT returns the hex-encoded NT hash or an empty string if no NT hash is
+// present.
+func (h *Hash) NT() string {
+ return hex.EncodeToString(h.nt)
+}
+
+// NTBytes returns the binary NT hash or an empty slice if no NT hash is
+// present.
+func (h *Hash) NTBytes() []byte {
+ return h.nt
+}
+
+// NTPresent indicates whether or not an NT hash is present.
+func (h *Hash) NTPresent() bool {
+ return h.nt != nil
+}
+
+// LM returns the hex-encoded LM hash or an empty string if no LM hash is
+// present.
+func (h *Hash) LM() string {
+ return hex.EncodeToString(h.lm)
+}
+
+// LMPresent indicates whether or not an LM hash is present.
+func (h *Hash) LMPresent() bool {
+ return h.lm != nil
+}
+
+// LMBytes returns the binary LM hash or an empty slice if no LM hash is
+// present.
+func (h *Hash) LMBytes() []byte {
+ return h.lm
+}
+
+// Empty returns true if the structure contains neither LM nor NT hash data.
+func (h *Hash) Empty() bool {
+ return h.nt == nil && h.lm == nil
+}
+
+// Combined returns the hex-encoded hashes in LM:NT format. If any of these
+// hashes is not present, they are replaced by their respective empty hash
+// value.
+func (h *Hash) Combined() string {
+ lm := h.LM()
+ if lm == "" {
+ lm = "aad3b435b51404eeaad3b435b51404ee"
+ }
+
+ nt := h.NT()
+ if nt == "" {
+ nt = "31d6cfe0d16ae931b73c59d7e0c089c0"
+ }
+
+ return lm + ":" + nt
+}
+
+func nonZero(d []byte) bool {
+ for i := 0; i < len(d); i++ {
+ if d[i] != 0 {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/resolver.go b/resolver.go
new file mode 100644
index 0000000..218a6c8
--- /dev/null
+++ b/resolver.go
@@ -0,0 +1,113 @@
+package adauth
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "strings"
+)
+
+type Resolver interface {
+ LookupAddr(ctx context.Context, addr string) ([]string, error)
+ LookupIP(ctx context.Context, network string, host string) ([]net.IP, error)
+ LookupSRV(ctx context.Context, service string, proto string, name string) (string, []*net.SRV, error)
+}
+
+var _ Resolver = net.DefaultResolver
+
+type resolver struct {
+ Resolver
+ debugFn func(string, ...any)
+}
+
+func ensureResolver(r Resolver, debug func(string, ...any)) *resolver {
+ if r == nil {
+ return &resolver{Resolver: net.DefaultResolver, debugFn: debug}
+ }
+
+ return &resolver{Resolver: r, debugFn: debug}
+}
+
+func (r *resolver) debug(format string, a ...any) {
+ if r.debugFn == nil {
+ return
+ }
+
+ r.debugFn(format, a...)
+}
+
+func (r *resolver) LookupFirstService(ctx context.Context, protocol string, domain string) (string, int, error) {
+ _, addrs, err := r.Resolver.LookupSRV(ctx, protocol, "tcp", domain)
+ if err != nil {
+ if strings.EqualFold(protocol, "ldaps") {
+ host, _, srvLDAPErr := r.LookupFirstService(ctx, "ldap", domain)
+ if srvLDAPErr == nil {
+ return host, 636, nil
+ }
+ }
+
+ return "", 0, fmt.Errorf("lookup %q service of domain %q: %w", protocol, domain, err)
+ }
+
+ if len(addrs) == 0 {
+ return "", 0, fmt.Errorf("no %q services were discovered for domain %q", protocol, domain)
+ }
+
+ return strings.TrimRight(addrs[0].Target, "."), int(addrs[0].Port), nil
+}
+
+func (r *resolver) LookupDCByDomain(ctx context.Context, domain string) (string, error) {
+ // Unfortunately, Go does not implement SOA lookups, so we lookup the domain
+ // for DC IPs and reverse lookup their hostnames instead.
+ dcAddrs, err := r.Resolver.LookupIP(context.Background(), "ip", domain)
+ if err != nil {
+ return "", fmt.Errorf("lookup domain itself: %w", err)
+ }
+
+ if len(dcAddrs) == 0 {
+ return "", fmt.Errorf("looking up domain itself returned no results")
+ }
+
+ dcAddr := dcAddrs[0].String()
+
+ names, err := r.Resolver.LookupAddr(context.Background(), dcAddr)
+ if err == nil {
+ domain, names = splitResultsInDomainAndHostname(names, domain)
+
+ switch {
+ case len(names) > 0:
+ dcAddr = strings.TrimRight(names[0], ".")
+ case domain != "":
+ dcAddr = domain
+
+ r.debug("Warning: reverse lookup of DC only returned domain name, using domain name %q as DC hostname",
+ domain)
+ default:
+ r.debug("Warning: reverse lookup of DC did not contain a hostname")
+ }
+ } else {
+ r.debug("Warning: could not reverse lookup DC IP: %v", err)
+ }
+
+ return dcAddr, nil
+}
+
+// A reverse lookup of the DC IP returns both the DCs hostname and the name of
+// the domain. This function splits the results in these categories.
+func splitResultsInDomainAndHostname(
+ hostnames []string, domain string,
+) (domainFromHostnames string, filtered []string) {
+ var d string
+
+ for _, hostname := range hostnames {
+ hostname = strings.TrimRight(hostname, ".")
+
+ if strings.EqualFold(hostname, domain) {
+ d = hostname
+ } else {
+ filtered = append(filtered, hostname)
+ }
+ }
+
+ return d, filtered
+}
diff --git a/resolver_test.go b/resolver_test.go
new file mode 100644
index 0000000..58a9c19
--- /dev/null
+++ b/resolver_test.go
@@ -0,0 +1,98 @@
+package adauth_test
+
+import (
+ "context"
+ "fmt"
+ "net"
+
+ "github.com/RedTeamPentesting/adauth"
+)
+
+type testResolver struct {
+ HostToAddr map[string][]net.IP
+ AddrToHost map[string][]string
+ SRV map[string]map[string]map[string]struct {
+ Name string
+ SRV []*net.SRV
+ }
+ Error error
+}
+
+func (r *testResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
+ if r.Error != nil {
+ return nil, r.Error
+ }
+
+ if r.AddrToHost == nil {
+ return nil, nil
+ }
+
+ return r.AddrToHost[addr], nil
+}
+
+func (r *testResolver) LookupIP(ctx context.Context, network string, host string) ([]net.IP, error) {
+ if r.Error != nil {
+ return nil, r.Error
+ }
+
+ if r.HostToAddr == nil {
+ return nil, nil
+ }
+
+ addrs := r.HostToAddr[host]
+
+ switch network {
+ case "ip":
+ return addrs, nil
+ case "ip4":
+ var ipv4s []net.IP
+
+ for _, addr := range addrs {
+ if addr.To4() != nil {
+ ipv4s = append(ipv4s, addr)
+ }
+ }
+
+ return ipv4s, nil
+ case "ip6":
+ var ipv6s []net.IP
+
+ for _, addr := range addrs {
+ if addr.To4() == nil {
+ ipv6s = append(ipv6s, addr)
+ }
+ }
+
+ return ipv6s, nil
+ default:
+ return nil, fmt.Errorf("invalid network: %q", network)
+ }
+}
+
+func (r *testResolver) LookupSRV(
+ ctx context.Context, service string, proto string, name string,
+) (string, []*net.SRV, error) {
+ if r.Error != nil {
+ return "", nil, r.Error
+ }
+
+ if r.SRV == nil {
+ return "", nil, nil
+ }
+
+ srvForService := r.SRV[service]
+ if srvForService == nil {
+ return "", nil, nil
+ }
+
+ srvForServiceAndProto := srvForService[proto]
+ if srvForServiceAndProto == nil {
+ return "", nil, nil
+ }
+
+ record := srvForServiceAndProto[name]
+
+ return record.Name, record.SRV, nil
+}
+
+var _ adauth.Resolver = &testResolver{}
diff --git a/target.go b/target.go
new file mode 100644
index 0000000..0e14f9c
--- /dev/null
+++ b/target.go
@@ -0,0 +1,188 @@
+package adauth
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "strings"
+
+ "github.com/jcmturner/gokrb5/v8/credentials"
+ "github.com/jcmturner/gokrb5/v8/iana/nametype"
+ "github.com/jcmturner/gokrb5/v8/types"
+)
+
+// Target holds information about the authentication target.
+type Target struct {
+ // Port holds the target's port which may be empty.
+ Port string
+ addr string
+ hostname string
+ ip net.IP
+ // UseKerberos indicated that Kerberos authentication should be used to
+ // authenticate to this target.
+ UseKerberos bool
+ // Protocol is a string that represents the protocol that is used when
+ // communicating with this target. It is used to construct the SPN, however,
+ // some protocol name corrections may be applied in this case, such as 'smb'
+ // -> 'cifs'.
+ Protocol string
+ ccache string
+
+ // Resolver can be used to set an alternative DNS resolver. If empty,
+ // net.DefaultResolver is used.
+ Resolver Resolver
+}
+
+// NewTarget creates a new target. The provided protocol is used to construct
+// the SPN, however, some protocol name corrections may be applied in this case,
+// such as 'smb' -> 'cifs'. The target parameter may or may not contain a port
+// and the protocol string will *not* influence the port of the resulting
+// Target.
+func NewTarget(protocol string, target string) *Target {
+ return newTarget(protocol, target, false, "", nil)
+}
+
+func newTarget(protocol string, target string, useKerberos bool, ccache string, resolver Resolver) *Target {
+ host, port, err := net.SplitHostPort(target)
+ if err != nil {
+ host = target
+ }
+
+ t := &Target{
+ addr: host,
+ Port: port,
+ Protocol: protocol,
+ UseKerberos: useKerberos,
+ ccache: ccache,
+ Resolver: resolver,
+ }
+
+ ip := net.ParseIP(host)
+ if ip == nil {
+ t.hostname = host
+ } else {
+ t.ip = ip
+ }
+
+ return t
+}
+
+// Address returns the address including the port if available. It will contain
+// either a hostname or an IP address depending on how the target was
+// constructed.
+func (t *Target) Address() string {
+ if t.Port != "" {
+ return net.JoinHostPort(t.addr, t.Port)
+ } else {
+ return t.addr
+ }
+}
+
+// AddressWithoutPort is like Address but without the port.
+func (t *Target) AddressWithoutPort() string {
+ return t.addr
+}
+
+// IP returns the target's IP address. If only the hostname is known, a lookup
+// will be performed.
+func (t *Target) IP(ctx context.Context) (net.IP, error) {
+ if t.ip != nil {
+ return t.ip, nil
+ }
+
+ if t.hostname == "" {
+ return nil, fmt.Errorf("no IP or hostname known")
+ }
+
+ addrs, err := ensureResolver(t.Resolver, nil).LookupIP(ctx, "ip", t.hostname)
+
+ switch {
+ case err != nil:
+ return nil, fmt.Errorf("lookup %s: %w", t.hostname, err)
+ case len(addrs) > 1:
+ return nil, fmt.Errorf("lookup of %s returned multiple names", t.hostname)
+ case len(addrs) == 0:
+ return nil, fmt.Errorf("lookup of %s returned no names", t.hostname)
+ }
+
+ t.ip = addrs[0]
+
+ return addrs[0], nil
+}
+
+// Hostname returns the target's hostname. If only the IP address is known, a
+// lookup will be performed.
+func (t *Target) Hostname(ctx context.Context) (string, error) {
+ if t.hostname != "" {
+ return t.hostname, nil
+ }
+
+ if t.ip == nil {
+ return "", fmt.Errorf("no IP or hostname known")
+ }
+
+ names, err := ensureResolver(t.Resolver, nil).LookupAddr(ctx, t.ip.String())
+
+ switch {
+ case err != nil:
+ return "", fmt.Errorf("reverse lookup %s: %w", t.ip, err)
+ case len(names) > 1:
+ return "", fmt.Errorf("reverse lookup of %s returned multiple names", t.ip)
+ case len(names) == 0:
+ return "", fmt.Errorf("reverse lookup of %s returned no names", t.ip)
+ }
+
+ t.hostname = strings.TrimRight(names[0], ".")
+
+ return t.hostname, nil
+}
+
+// SPN returns the target's service principal name. The protocol part of the SPN
+// *may* be changed to the generic 'host' in order to align with the SPN of
+// service tickets in a CCACHE file. Some protocol name translations will also
+// be applied such as 'smb' -> 'cifs'.
+func (t *Target) SPN(ctx context.Context) (string, error) {
+ hostname, err := t.Hostname(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ var spn string
+
+ switch strings.ToLower(t.Protocol) {
+ case "smb":
+ spn = "cifs/" + hostname
+ case "ldap", "ldaps":
+ spn = "ldap/" + hostname
+ case "http", "https":
+ spn = "http/" + hostname
+ case "kerberos":
+ spn = "krbtgt"
+ case "":
+ spn = "host/" + hostname
+ default:
+ spn = t.Protocol + `/` + hostname
+ }
+
+ if t.ccache == "" || strings.HasPrefix(spn, "host/") {
+ return spn, nil
+ }
+
+ ccache, err := credentials.LoadCCache(t.ccache)
+ if err != nil {
+ return spn, nil //nolint:nilerr
+ }
+
+ _, ok := ccache.GetEntry(types.NewPrincipalName(nametype.KRB_NT_SRV_INST, spn))
+ if ok {
+ return spn, nil
+ }
+
+ // change SPN to generic host SPN if a ticket exists ONLY for the generic host SPN
+ _, ok = ccache.GetEntry(types.NewPrincipalName(nametype.KRB_NT_SRV_INST, "host/"+hostname))
+ if ok {
+ return "host/" + hostname, nil
+ }
+
+ return spn, nil
+}
diff --git a/target_test.go b/target_test.go
new file mode 100644
index 0000000..0973078
--- /dev/null
+++ b/target_test.go
@@ -0,0 +1,172 @@
+package adauth_test
+
+import (
+ "context"
+ "net"
+ "strconv"
+ "testing"
+
+ "github.com/RedTeamPentesting/adauth"
+)
+
+func TestNewTarget(t *testing.T) {
+ targetIP := net.ParseIP("10.0.0.1")
+ targetPort := "1234"
+ targetHostname := "computer.tld"
+ resolver := &testResolver{
+ HostToAddr: map[string][]net.IP{
+ targetHostname: {targetIP},
+ },
+ AddrToHost: map[string][]string{
+ targetIP.String(): {targetHostname},
+ },
+ }
+
+ t.Run("hostname_from_ip", func(t *testing.T) {
+ target := adauth.NewTarget("", targetIP.String())
+ target.Resolver = resolver
+
+ if target.Address() != targetIP.String() {
+ t.Errorf("target address is %q instead of %q", target.Address(), targetIP.String())
+ }
+
+ hostname, err := target.Hostname(context.Background())
+ if err != nil {
+ t.Errorf("get hostname: %v", err)
+ }
+
+ if hostname != targetHostname {
+ t.Errorf("hostname is %q instead of %q", hostname, targetHostname)
+ }
+ })
+
+ t.Run("ip_from_hostname", func(t *testing.T) {
+ target := adauth.NewTarget("", targetHostname)
+ target.Resolver = resolver
+
+ if target.Address() != targetHostname {
+ t.Errorf("target address is %q instead of %q", target.Address(), targetIP.String())
+ }
+
+ ip, err := target.IP(context.Background())
+ if err != nil {
+ t.Errorf("get hostname: %v", err)
+ }
+
+ if ip.String() != targetIP.String() {
+ t.Errorf("hostname is %q instead of %q", ip.String(), targetIP.String())
+ }
+ })
+
+ t.Run("hostname_and_port", func(t *testing.T) {
+ target := adauth.NewTarget("", net.JoinHostPort(targetHostname, targetPort))
+ target.Resolver = resolver
+
+ if target.Port != targetPort {
+ t.Errorf("target port is %q instead of %q", target.Port, targetPort)
+ }
+
+ _, port, err := net.SplitHostPort(target.Address())
+ if err != nil {
+ t.Fatalf("split target.Address(): %v", err)
+ }
+
+ if port != targetPort {
+ t.Errorf("target.Address() contains port %q instead of %q", port, targetPort)
+ }
+ })
+ t.Run("ip_and_port", func(t *testing.T) {
+ target := adauth.NewTarget("", net.JoinHostPort(targetIP.String(), targetPort))
+ target.Resolver = resolver
+
+ if target.Port != targetPort {
+ t.Errorf("target port is %q instead of %q", target.Port, targetPort)
+ }
+
+ _, port, err := net.SplitHostPort(target.Address())
+ if err != nil {
+ t.Fatalf("split target.Address(): %v", err)
+ }
+
+ if port != targetPort {
+ t.Errorf("target.Address() contains port %q instead of %q", port, targetPort)
+ }
+ })
+}
+
+func TestTargetAddressWithAndWithoutPort(t *testing.T) {
+ targetHostname := "computer.tld"
+
+ t.Run("with_port", func(t *testing.T) {
+ target := adauth.NewTarget("ldap", targetHostname+":389")
+
+ if target.Address() != targetHostname+":389" {
+ t.Errorf("target.Address() is %q instead of %q", target.Address(), targetHostname)
+ }
+
+ if target.AddressWithoutPort() != targetHostname {
+ t.Errorf("target.AddressWithoutPort() is %q instead of %q", target.AddressWithoutPort(), targetHostname)
+ }
+ })
+ t.Run("without_port", func(t *testing.T) {
+ target := adauth.NewTarget("ldap", targetHostname)
+
+ if target.Address() != targetHostname {
+ t.Errorf("target.Address() is %q instead of %q", target.Address(), targetHostname)
+ }
+
+ if target.AddressWithoutPort() != targetHostname {
+ t.Errorf("target.AddressWithoutPort() is %q instead of %q", target.AddressWithoutPort(), targetHostname)
+ }
+ })
+}
+
+func TestNewTargetSPN(t *testing.T) {
+ hostname := "computer.tld"
+
+ testCases := []struct {
+ InputProtocol string
+ SPNProtocol string
+ }{
+ {
+ InputProtocol: "",
+ SPNProtocol: "host",
+ },
+ {
+ InputProtocol: "ldaps",
+ SPNProtocol: "ldap",
+ },
+ {
+ InputProtocol: "smb",
+ SPNProtocol: "cifs",
+ },
+ {
+ InputProtocol: "foo",
+ SPNProtocol: "foo",
+ },
+ {
+ InputProtocol: "http",
+ SPNProtocol: "http",
+ },
+
+ {
+ InputProtocol: "https",
+ SPNProtocol: "http",
+ },
+ }
+
+ for i, testCase := range testCases {
+ t.Run(strconv.Itoa(i), func(t *testing.T) {
+ spn, err := adauth.NewTarget(testCase.InputProtocol, hostname).SPN(context.Background())
+ if err != nil {
+ t.Fatalf("SPN: %v", err)
+ }
+
+ expectedSPN := testCase.SPNProtocol + `/` + hostname
+
+ if spn != expectedSPN {
+ t.Fatalf("SPN for %q is %q instead of %q", testCase.InputProtocol, spn, expectedSPN)
+ }
+ })
+ }
+}
diff --git a/testdata/empty.ccache b/testdata/empty.ccache
new file mode 100644
index 0000000..e69de29
diff --git a/testdata/someuser@domain.tld.pfx b/testdata/someuser@domain.tld.pfx
new file mode 100644
index 0000000000000000000000000000000000000000..3881c75930a9aca5549f459e841faa1b574247d8
GIT binary patch
literal 2247
zcmeH{`#0448pnO-J2M&gj7v%(WDhpq8G}Sfaxc;*cJ5Iw!|cX&WH94aNuhEZw@hdl
zmqN}Aq6lHn-W;@%o!sy2h#YOs*sIgp`>eCpIe)-epY{3S^Ljqd^Lf_uex46R<8Orl
zAVlMDM50s^EfP0{fqg(BjlTj;<7e`4F+>9s|6&5e;WRLqhl#u$fE4`VB_sfVg)~r$
z2Q`rbUm!|~7Ymf*IUM3~X@~~w7J@JU2IIHWZsjCQh}A;EUxNdJ2tMctRu(#l1Rda@
z5IUF`=o1?3Lo)Uz273AhX@n5GA$e(GoHm5TYV#I^hrT}`io@dYSR4-b1^m|tG~no;
z9|aJ6APo@Z)zAR>)0fbYr2Pwb`=VYy0hi)h9#N23f6L=OSt)
z8&W0D1&NDkDSS{;2DnmHnzt+ajl*+=vs#|W=HM>%E{z5vQXDFWm!vU@l+52L~P1;CA{
zqK%?jAIF&^l0$TZS{HQEC64qf`^Zk+AAOPRUe{EepHFr>YBwmuN@K(?6Mlv49X-)v
z)Mtw2qff&!nbuh!*GD6D**!wCI7R4f7P2;dVr4~6{&esay~Y-YjBv9rm?;h`+!e&
zp5ZQ!8Pkl~dOA*OdHIvY2h3Jf`>m`+MTp$(tG_RgpmRbZk-9ci_M^#Y@ovSis}U~D
zi{O>EKtStW|8UAC)TcCZ#WDGtS*A&=&mty7`9h0#VaDlH$DqQx-dpF)YUYwTi#fXF
z8^YT&wyvI+wU~aErN?D;(+%8iJg4r8qD%q3S^o9zn77^_
zn$yV_;g2wxTfK%kq3`|4Xei4FXQ*O&kCT%GSGemJ28#}i-d$D1g-_Qzb{q@b#_!m1
zJGZ5pmrLdm%6Av&_IX+*Bx!NhD-iWQz!<9xDSn*-lq6OPE}=*kv6KO<^KFZ&KB{^Y
zOG;PTJ%DHk>95!Jh7`dMry<}x%xhlw=RZ#(&j@eh!4??6_ixP(r@_m3mJ*)j|Ly*-
zcK@RixvLbFl&9flHVF@wpGU-x#7x>xD;xmM^OHXoMVMx919zCAS
zuFB}GgQBELGwQEW(W>})3U{We;${3WOWL53%-o3U|=d?i$6h)YYF{p$oAKp^ll{9;qymI4rioqsB{T)BaYgRAZIkkk6xHX*`odEF~028uo{
zNLk!Xxn509NUl~g|CqV@LHUMvNoYa+DLceo4l9ezX6}^P^;(YI%(~n?%pv1%!?jJ2
z-v-kg9b@$FjN1zIzKbs_PRBMYM9d86{$@xf`fYY(cAi33ZygJsMOI@m#Iybz{O
zB~^eO22vYhT#6&l-n9ZKPJS_YMM}qX)6`VEwrZwa>1W&CgFzN)r17}(bz54a#H{+M
zy&Q6}wiALoI|O2GdctFqYp|F7PnzZ8D)EJGQW%<`pln%}U;a+n4pM4sOe6oh3Xk(m
zaK!$l?t&A&r%=A=8F|6^-<3?3jb5B+E&gP?oT~lNYNGR|HS2QsX1%)1rR|*6N72RO
zD(sSPdN}BZ&CG$iJaNWq`h@^OjJ2g+wdDD|99ZPV3!a^1iR^%K^GQdqWQV90QWB!4
zNpfv&Vk~o0zMe6dfi0BtXpeU`;pDR8?;Wis)(p4IdyT_jE&_{Si0W?lYykScDiZoc!Vqk
hCv(-w@AQdg`8hOXX4^bNWF~Rdd?Py9?0@_k{|N`8)P?{6
literal 0
HcmV?d00001
diff --git a/workspace.code-workspace b/workspace.code-workspace
new file mode 100644
index 0000000..bfb6f01
--- /dev/null
+++ b/workspace.code-workspace
@@ -0,0 +1,32 @@
+{
+ "folders": [
+ {
+ "path": "."
+ }
+ ],
+ "settings": {
+ "go.useLanguageServer": true,
+ "go.lintTool": "golangci-lint",
+ "gopls": {
+ "gofumpt": true,
+ },
+ "go.formatTool": "gofumpt",
+ "[go]": {
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "explicit",
+ },
+ },
+ "[go.mod]": {
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "explicit",
+ },
+ }
+ },
+ "extensions": {
+ "recommendations": [
+ "golang.go"
+ ]
+ }
+}