mirror of
				https://github.com/tailscale/tailscale.git
				synced 2025-10-31 08:11:32 +01:00 
			
		
		
		
	This PR ties together pseudoconsoles, user profiles, s4u logons, and process creation into what is (hopefully) a simple API for various Tailscale services to obtain Windows access tokens without requiring knowledge of any Windows passwords. It works both for domain-joined machines (Kerberos) and non-domain-joined machines. The former case is fairly straightforward as it is fully documented. OTOH, the latter case is not documented, though it is fully defined in the C headers in the Windows SDK. The documentation blanks were filled in by reading the source code of Microsoft's Win32 port of OpenSSH. We need to do a bit of acrobatics to make conpty work correctly while creating a child process with an s4u token; see the doc comments above startProcessInternal for details. Updates #12383 Signed-off-by: Aaron Klotz <aaron@tailscale.com>
		
			
				
	
	
		
			400 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			400 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| // Copyright (c) Tailscale Inc & AUTHORS
 | |
| // SPDX-License-Identifier: BSD-3-Clause
 | |
| 
 | |
| package s4u
 | |
| 
 | |
| import (
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"os"
 | |
| 	"os/user"
 | |
| 	"path/filepath"
 | |
| 	"strings"
 | |
| 	"unicode"
 | |
| 	"unsafe"
 | |
| 
 | |
| 	"github.com/dblohm7/wingoes"
 | |
| 	"golang.org/x/sys/windows"
 | |
| 	"tailscale.com/types/lazy"
 | |
| 	"tailscale.com/util/winutil"
 | |
| 	"tailscale.com/util/winutil/winenv"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	_MICROSOFT_KERBEROS_NAME = "Kerberos"
 | |
| 	_MSV1_0_PACKAGE_NAME     = "MICROSOFT_AUTHENTICATION_PACKAGE_V1_0"
 | |
| )
 | |
| 
 | |
| type _LSAHANDLE windows.Handle
 | |
| type _LSA_OPERATIONAL_MODE uint32
 | |
| 
 | |
| type _KERB_LOGON_SUBMIT_TYPE int32
 | |
| 
 | |
| const (
 | |
| 	_KerbInteractiveLogon       _KERB_LOGON_SUBMIT_TYPE = 2
 | |
| 	_KerbSmartCardLogon         _KERB_LOGON_SUBMIT_TYPE = 6
 | |
| 	_KerbWorkstationUnlockLogon _KERB_LOGON_SUBMIT_TYPE = 7
 | |
| 	_KerbSmartCardUnlockLogon   _KERB_LOGON_SUBMIT_TYPE = 8
 | |
| 	_KerbProxyLogon             _KERB_LOGON_SUBMIT_TYPE = 9
 | |
| 	_KerbTicketLogon            _KERB_LOGON_SUBMIT_TYPE = 10
 | |
| 	_KerbTicketUnlockLogon      _KERB_LOGON_SUBMIT_TYPE = 11
 | |
| 	_KerbS4ULogon               _KERB_LOGON_SUBMIT_TYPE = 12
 | |
| 	_KerbCertificateLogon       _KERB_LOGON_SUBMIT_TYPE = 13
 | |
| 	_KerbCertificateS4ULogon    _KERB_LOGON_SUBMIT_TYPE = 14
 | |
| 	_KerbCertificateUnlockLogon _KERB_LOGON_SUBMIT_TYPE = 15
 | |
| 	_KerbNoElevationLogon       _KERB_LOGON_SUBMIT_TYPE = 83
 | |
| 	_KerbLuidLogon              _KERB_LOGON_SUBMIT_TYPE = 84
 | |
| )
 | |
| 
 | |
| type _KERB_S4U_LOGON_FLAGS uint32
 | |
| 
 | |
| const (
 | |
| 	_KERB_S4U_LOGON_FLAG_CHECK_LOGONHOURS _KERB_S4U_LOGON_FLAGS = 0x2
 | |
| 	//lint:ignore U1000 maps to a win32 API
 | |
| 	_KERB_S4U_LOGON_FLAG_IDENTIFY _KERB_S4U_LOGON_FLAGS = 0x8
 | |
| )
 | |
| 
 | |
| type _KERB_S4U_LOGON struct {
 | |
| 	MessageType _KERB_LOGON_SUBMIT_TYPE
 | |
| 	Flags       _KERB_S4U_LOGON_FLAGS
 | |
| 	ClientUpn   windows.NTUnicodeString
 | |
| 	ClientRealm windows.NTUnicodeString
 | |
| }
 | |
| 
 | |
| type _MSV1_0_LOGON_SUBMIT_TYPE int32
 | |
| 
 | |
| const (
 | |
| 	_MsV1_0InteractiveLogon       _MSV1_0_LOGON_SUBMIT_TYPE = 2
 | |
| 	_MsV1_0Lm20Logon              _MSV1_0_LOGON_SUBMIT_TYPE = 3
 | |
| 	_MsV1_0NetworkLogon           _MSV1_0_LOGON_SUBMIT_TYPE = 4
 | |
| 	_MsV1_0SubAuthLogon           _MSV1_0_LOGON_SUBMIT_TYPE = 5
 | |
| 	_MsV1_0WorkstationUnlockLogon _MSV1_0_LOGON_SUBMIT_TYPE = 7
 | |
| 	_MsV1_0S4ULogon               _MSV1_0_LOGON_SUBMIT_TYPE = 12
 | |
| 	_MsV1_0VirtualLogon           _MSV1_0_LOGON_SUBMIT_TYPE = 82
 | |
| 	_MsV1_0NoElevationLogon       _MSV1_0_LOGON_SUBMIT_TYPE = 83
 | |
| 	_MsV1_0LuidLogon              _MSV1_0_LOGON_SUBMIT_TYPE = 84
 | |
| )
 | |
| 
 | |
| type _MSV1_0_S4U_LOGON_FLAGS uint32
 | |
| 
 | |
| const (
 | |
| 	_MSV1_0_S4U_LOGON_FLAG_CHECK_LOGONHOURS _MSV1_0_S4U_LOGON_FLAGS = 0x2
 | |
| )
 | |
| 
 | |
| type _MSV1_0_S4U_LOGON struct {
 | |
| 	MessageType       _MSV1_0_LOGON_SUBMIT_TYPE
 | |
| 	Flags             _MSV1_0_S4U_LOGON_FLAGS
 | |
| 	UserPrincipalName windows.NTUnicodeString
 | |
| 	DomainName        windows.NTUnicodeString
 | |
| }
 | |
| 
 | |
| type _SECURITY_LOGON_TYPE int32
 | |
| 
 | |
| const (
 | |
| 	_UndefinedLogonType      _SECURITY_LOGON_TYPE = 0
 | |
| 	_Interactive             _SECURITY_LOGON_TYPE = 2
 | |
| 	_Network                 _SECURITY_LOGON_TYPE = 3
 | |
| 	_Batch                   _SECURITY_LOGON_TYPE = 4
 | |
| 	_Service                 _SECURITY_LOGON_TYPE = 5
 | |
| 	_Proxy                   _SECURITY_LOGON_TYPE = 6
 | |
| 	_Unlock                  _SECURITY_LOGON_TYPE = 7
 | |
| 	_NetworkCleartext        _SECURITY_LOGON_TYPE = 8
 | |
| 	_NewCredentials          _SECURITY_LOGON_TYPE = 9
 | |
| 	_RemoteInteractive       _SECURITY_LOGON_TYPE = 10
 | |
| 	_CachedInteractive       _SECURITY_LOGON_TYPE = 11
 | |
| 	_CachedRemoteInteractive _SECURITY_LOGON_TYPE = 12
 | |
| 	_CachedUnlock            _SECURITY_LOGON_TYPE = 13
 | |
| )
 | |
| 
 | |
| const _TOKEN_SOURCE_LENGTH = 8
 | |
| 
 | |
| type _TOKEN_SOURCE struct {
 | |
| 	SourceName       [_TOKEN_SOURCE_LENGTH]byte
 | |
| 	SourceIdentifier windows.LUID
 | |
| }
 | |
| 
 | |
| type _QUOTA_LIMITS struct {
 | |
| 	PagedPoolLimit        uintptr
 | |
| 	NonPagedPoolLimit     uintptr
 | |
| 	MinimumWorkingSetSize uintptr
 | |
| 	MaximumWorkingSetSize uintptr
 | |
| 	PagefileLimit         uintptr
 | |
| 	TimeLimit             int64
 | |
| }
 | |
| 
 | |
| var (
 | |
| 	// ErrBadSrcName is returned if srcName contains non-ASCII characters, is
 | |
| 	// empty, or is too long. It may be wrapped with additional information; use
 | |
| 	// errors.Is when checking for it.
 | |
| 	ErrBadSrcName = errors.New("srcName must be ASCII with length > 0 and <= 8")
 | |
| )
 | |
| 
 | |
| // LSA packages (and their IDs) are always initialized during system startup,
 | |
| // so we can retain their resolved IDs for the lifetime of our process.
 | |
| var (
 | |
| 	authPkgIDKerberos lazy.SyncValue[uint32]
 | |
| 	authPkgIDMSV1_0   lazy.SyncValue[uint32]
 | |
| )
 | |
| 
 | |
| type lsaSession struct {
 | |
| 	handle _LSAHANDLE
 | |
| }
 | |
| 
 | |
| func newLSASessionForQuery() (lsa *lsaSession, err error) {
 | |
| 	var h _LSAHANDLE
 | |
| 	if e := wingoes.ErrorFromNTStatus(lsaConnectUntrusted(&h)); e.Failed() {
 | |
| 		return nil, e
 | |
| 	}
 | |
| 
 | |
| 	return &lsaSession{handle: h}, nil
 | |
| }
 | |
| 
 | |
| func newLSASessionForLogon(processName string) (lsa *lsaSession, err error) {
 | |
| 	// processName is used by LSA for audit logging purposes.
 | |
| 	// If empty, the current process name is used.
 | |
| 	if processName == "" {
 | |
| 		exe, err := os.Executable()
 | |
| 		if err != nil {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 
 | |
| 		processName = strings.TrimSuffix(filepath.Base(exe), filepath.Ext(exe))
 | |
| 	}
 | |
| 
 | |
| 	if err := checkASCII(processName); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	logonProcessName, err := windows.NewNTString(processName)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	var h _LSAHANDLE
 | |
| 	var mode _LSA_OPERATIONAL_MODE
 | |
| 	if e := wingoes.ErrorFromNTStatus(lsaRegisterLogonProcess(logonProcessName, &h, &mode)); e.Failed() {
 | |
| 		return nil, e
 | |
| 	}
 | |
| 
 | |
| 	return &lsaSession{handle: h}, nil
 | |
| }
 | |
| 
 | |
| func (ls *lsaSession) getAuthPkgID(pkgName string) (id uint32, err error) {
 | |
| 	ntPkgName, err := windows.NewNTString(pkgName)
 | |
| 	if err != nil {
 | |
| 		return 0, err
 | |
| 	}
 | |
| 
 | |
| 	if e := wingoes.ErrorFromNTStatus(lsaLookupAuthenticationPackage(ls.handle, ntPkgName, &id)); e.Failed() {
 | |
| 		return 0, e
 | |
| 	}
 | |
| 
 | |
| 	return id, nil
 | |
| }
 | |
| 
 | |
| func (ls *lsaSession) Close() error {
 | |
| 	if e := wingoes.ErrorFromNTStatus(lsaDeregisterLogonProcess(ls.handle)); e.Failed() {
 | |
| 		return e
 | |
| 	}
 | |
| 	ls.handle = 0
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func checkASCII(s string) error {
 | |
| 	for _, c := range []byte(s) {
 | |
| 		if c > unicode.MaxASCII {
 | |
| 			return fmt.Errorf("%q must be ASCII but contains value 0x%02X", s, c)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| var (
 | |
| 	thisComputer = []uint16{'.', 0}
 | |
| 	computerName lazy.SyncValue[string]
 | |
| )
 | |
| 
 | |
| func getComputerName() (string, error) {
 | |
| 	var buf [windows.MAX_COMPUTERNAME_LENGTH + 1]uint16
 | |
| 	size := uint32(len(buf))
 | |
| 	if err := windows.GetComputerName(&buf[0], &size); err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	return windows.UTF16ToString(buf[:size]), nil
 | |
| }
 | |
| 
 | |
| // checkDomainAccount strips out the computer name (if any) from
 | |
| // username and returns the result in sanitizedUserName. isDomainAccount is set
 | |
| // to true if username contains a domain component that does not refer to the
 | |
| // local computer.
 | |
| func checkDomainAccount(username string) (sanitizedUserName string, isDomainAccount bool, err error) {
 | |
| 	before, after, hasBackslash := strings.Cut(username, `\`)
 | |
| 	if !hasBackslash {
 | |
| 		return username, false, nil
 | |
| 	}
 | |
| 	if before == "." {
 | |
| 		return after, false, nil
 | |
| 	}
 | |
| 
 | |
| 	comp, err := computerName.GetErr(getComputerName)
 | |
| 	if err != nil {
 | |
| 		return username, false, err
 | |
| 	}
 | |
| 
 | |
| 	if strings.EqualFold(before, comp) {
 | |
| 		return after, false, nil
 | |
| 	}
 | |
| 	return username, true, nil
 | |
| }
 | |
| 
 | |
| // logonAs performs a S4U logon for u on behalf of srcName, and returns an
 | |
| // access token for the user if successful. srcName must be non-empty, ASCII,
 | |
| // and no more than 8 characters long. If srcName does not meet this criteria,
 | |
| // LogonAs will return ErrBadSrcName wrapped with additional information; use
 | |
| // errors.Is to check for it. When capLevel == CapCreateProcess, the logon
 | |
| // enforces the user's logon hours policy (when present).
 | |
| func (ls *lsaSession) logonAs(srcName string, u *user.User, capLevel CapabilityLevel) (token windows.Token, err error) {
 | |
| 	if l := len(srcName); l == 0 || l > _TOKEN_SOURCE_LENGTH {
 | |
| 		return 0, fmt.Errorf("%w, actual length is %d", ErrBadSrcName, l)
 | |
| 	}
 | |
| 	if err := checkASCII(srcName); err != nil {
 | |
| 		return 0, fmt.Errorf("%w: %v", ErrBadSrcName, err)
 | |
| 	}
 | |
| 
 | |
| 	sanitizedUserName, isDomainUser, err := checkDomainAccount(u.Username)
 | |
| 	if err != nil {
 | |
| 		return 0, err
 | |
| 	}
 | |
| 	if isDomainUser && !winenv.IsDomainJoined() {
 | |
| 		return 0, fmt.Errorf("%w: cannot logon as domain user without being joined to a domain", os.ErrInvalid)
 | |
| 	}
 | |
| 
 | |
| 	var pkgID uint32
 | |
| 	var authInfo unsafe.Pointer
 | |
| 	var authInfoLen uint32
 | |
| 	enforceLogonHours := capLevel == CapCreateProcess
 | |
| 	if isDomainUser {
 | |
| 		pkgID, err = authPkgIDKerberos.GetErr(func() (uint32, error) {
 | |
| 			return ls.getAuthPkgID(_MICROSOFT_KERBEROS_NAME)
 | |
| 		})
 | |
| 		if err != nil {
 | |
| 			return 0, err
 | |
| 		}
 | |
| 
 | |
| 		upn16, err := samToUPN16(sanitizedUserName)
 | |
| 		if err != nil {
 | |
| 			return 0, fmt.Errorf("samToUPN16: %w", err)
 | |
| 		}
 | |
| 
 | |
| 		logonInfo, logonInfoLen, slcs := winutil.AllocateContiguousBuffer[_KERB_S4U_LOGON](upn16)
 | |
| 		logonInfo.MessageType = _KerbS4ULogon
 | |
| 		if enforceLogonHours {
 | |
| 			logonInfo.Flags = _KERB_S4U_LOGON_FLAG_CHECK_LOGONHOURS
 | |
| 		}
 | |
| 		winutil.SetNTString(&logonInfo.ClientUpn, slcs[0])
 | |
| 
 | |
| 		authInfo = unsafe.Pointer(logonInfo)
 | |
| 		authInfoLen = logonInfoLen
 | |
| 	} else {
 | |
| 		pkgID, err = authPkgIDMSV1_0.GetErr(func() (uint32, error) {
 | |
| 			return ls.getAuthPkgID(_MSV1_0_PACKAGE_NAME)
 | |
| 		})
 | |
| 		if err != nil {
 | |
| 			return 0, err
 | |
| 		}
 | |
| 
 | |
| 		upn16, err := windows.UTF16FromString(sanitizedUserName)
 | |
| 		if err != nil {
 | |
| 			return 0, err
 | |
| 		}
 | |
| 
 | |
| 		logonInfo, logonInfoLen, slcs := winutil.AllocateContiguousBuffer[_MSV1_0_S4U_LOGON](upn16, thisComputer)
 | |
| 		logonInfo.MessageType = _MsV1_0S4ULogon
 | |
| 		if enforceLogonHours {
 | |
| 			logonInfo.Flags = _MSV1_0_S4U_LOGON_FLAG_CHECK_LOGONHOURS
 | |
| 		}
 | |
| 		for i, nts := range []*windows.NTUnicodeString{&logonInfo.UserPrincipalName, &logonInfo.DomainName} {
 | |
| 			winutil.SetNTString(nts, slcs[i])
 | |
| 		}
 | |
| 
 | |
| 		authInfo = unsafe.Pointer(logonInfo)
 | |
| 		authInfoLen = logonInfoLen
 | |
| 	}
 | |
| 
 | |
| 	var srcContext _TOKEN_SOURCE
 | |
| 	copy(srcContext.SourceName[:], []byte(srcName))
 | |
| 	if err := allocateLocallyUniqueId(&srcContext.SourceIdentifier); err != nil {
 | |
| 		return 0, err
 | |
| 	}
 | |
| 
 | |
| 	originName, err := windows.NewNTString(srcName)
 | |
| 	if err != nil {
 | |
| 		return 0, err
 | |
| 	}
 | |
| 
 | |
| 	var profileBuf uintptr
 | |
| 	var profileBufLen uint32
 | |
| 	var logonID windows.LUID
 | |
| 	var quotas _QUOTA_LIMITS
 | |
| 	var subNTStatus windows.NTStatus
 | |
| 	ntStatus := lsaLogonUser(ls.handle, originName, _Network, pkgID, authInfo, authInfoLen, nil, &srcContext, &profileBuf, &profileBufLen, &logonID, &token, "as, &subNTStatus)
 | |
| 	if e := wingoes.ErrorFromNTStatus(ntStatus); e.Failed() {
 | |
| 		return 0, fmt.Errorf("LsaLogonUser(%q): %w, SubStatus: %v", u.Username, e, subNTStatus)
 | |
| 	}
 | |
| 	if profileBuf != 0 {
 | |
| 		lsaFreeReturnBuffer(profileBuf)
 | |
| 	}
 | |
| 	return token, nil
 | |
| }
 | |
| 
 | |
| // samToUPN16 converts SAM-style account name samName to a UPN account name,
 | |
| // returned as a UTF-16 slice.
 | |
| func samToUPN16(samName string) (upn16 []uint16, err error) {
 | |
| 	_, samAccount, hasSep := strings.Cut(samName, `\`)
 | |
| 	if !hasSep {
 | |
| 		return nil, fmt.Errorf("%w: expected samName to contain a backslash", os.ErrInvalid)
 | |
| 	}
 | |
| 
 | |
| 	// This is essentially the same algorithm used by Win32-OpenSSH:
 | |
| 	// First, try obtaining a UPN directly...
 | |
| 	upn16, err = translateName(samName, windows.NameSamCompatible, windows.NameUserPrincipal)
 | |
| 	if err == nil {
 | |
| 		return upn16, err
 | |
| 	}
 | |
| 
 | |
| 	// Fallback: Try manually composing a UPN. First obtain the canonical name...
 | |
| 	canonical16, err := translateName(samName, windows.NameSamCompatible, windows.NameCanonical)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 	canonical := windows.UTF16ToString(canonical16)
 | |
| 
 | |
| 	// Extract the domain name...
 | |
| 	domain, _, _ := strings.Cut(canonical, "/")
 | |
| 
 | |
| 	// ...and finally create the UPN by joining the samAccount and domain.
 | |
| 	upn := strings.Join([]string{samAccount, domain}, "@")
 | |
| 	return windows.UTF16FromString(upn)
 | |
| }
 | |
| 
 | |
| func translateName(from string, fromFmt uint32, toFmt uint32) (result []uint16, err error) {
 | |
| 	from16, err := windows.UTF16PtrFromString(from)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	var to16Len uint32
 | |
| 	if err := windows.TranslateName(from16, fromFmt, toFmt, nil, &to16Len); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	to16Buf := make([]uint16, to16Len)
 | |
| 	if err := windows.TranslateName(from16, fromFmt, toFmt, unsafe.SliceData(to16Buf), &to16Len); err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return to16Buf, nil
 | |
| }
 |