Bladeren bron

util/winutil: add package for logging into Windows via Service-for-User (S4U)

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 <[email protected]>
Aaron Klotz 1 jaar geleden
bovenliggende
commit
da078b4c09

+ 7 - 4
util/winutil/restartmgr_windows.go

@@ -23,8 +23,7 @@ import (
 )
 )
 
 
 var (
 var (
-	// ErrDefunctProcess is returned by (*UniqueProcess).AsRestartableProcess
-	// when the process no longer exists.
+	// ErrDefunctProcess is returned when the process no longer exists.
 	ErrDefunctProcess = errors.New("process is defunct")
 	ErrDefunctProcess = errors.New("process is defunct")
 	// ErrProcessNotRestartable is returned by (*UniqueProcess).AsRestartableProcess
 	// ErrProcessNotRestartable is returned by (*UniqueProcess).AsRestartableProcess
 	// when the process has previously indicated that it must not be restarted
 	// when the process has previously indicated that it must not be restarted
@@ -799,7 +798,7 @@ func startProcessInSessionInternal(sessID SessionID, cmdLineInfo CommandLineInfo
 	if err != nil {
 	if err != nil {
 		return nil, fmt.Errorf("token environment: %w", err)
 		return nil, fmt.Errorf("token environment: %w", err)
 	}
 	}
-	env16 := newEnvBlock(env)
+	env16 := NewEnvBlock(env)
 
 
 	// The privileges in privNames are required for CreateProcessAsUser to be
 	// The privileges in privNames are required for CreateProcessAsUser to be
 	// able to start processes as other users in other logon sessions.
 	// able to start processes as other users in other logon sessions.
@@ -826,7 +825,11 @@ func startProcessInSessionInternal(sessID SessionID, cmdLineInfo CommandLineInfo
 	return &pi, nil
 	return &pi, nil
 }
 }
 
 
-func newEnvBlock(env []string) *uint16 {
+// NewEnvBlock processes a slice of strings containing "NAME=value" pairs
+// representing a process envionment into the environment block format used by
+// Windows APIs such as CreateProcess. env must be sorted case-insensitively
+// by variable name.
+func NewEnvBlock(env []string) *uint16 {
 	// Intentionally using bytes.Buffer here because we're writing nul bytes (the standard library does this too).
 	// Intentionally using bytes.Buffer here because we're writing nul bytes (the standard library does this too).
 	var buf bytes.Buffer
 	var buf bytes.Buffer
 	for _, v := range env {
 	for _, v := range env {

+ 399 - 0
util/winutil/s4u/lsa_windows.go

@@ -0,0 +1,399 @@
+// 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, &quotas, &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
+}

+ 16 - 0
util/winutil/s4u/mksyscall.go

@@ -0,0 +1,16 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package s4u
+
+//go:generate go run golang.org/x/sys/windows/mkwinsyscall -output zsyscall_windows.go mksyscall.go
+//go:generate go run golang.org/x/tools/cmd/goimports -w zsyscall_windows.go
+
+//sys allocateLocallyUniqueId(luid *windows.LUID) (err error) [int32(failretval)==0] = advapi32.AllocateLocallyUniqueId
+//sys impersonateLoggedOnUser(token windows.Token) (err error) [int32(failretval)==0] = advapi32.ImpersonateLoggedOnUser
+//sys lsaConnectUntrusted(lsaHandle *_LSAHANDLE) (ret windows.NTStatus) = secur32.LsaConnectUntrusted
+//sys lsaDeregisterLogonProcess(lsaHandle _LSAHANDLE) (ret windows.NTStatus) = secur32.LsaDeregisterLogonProcess
+//sys lsaFreeReturnBuffer(buffer uintptr) (ret windows.NTStatus) = secur32.LsaFreeReturnBuffer
+//sys lsaLogonUser(lsaHandle _LSAHANDLE, originName *windows.NTString, logonType _SECURITY_LOGON_TYPE, authenticationPackage uint32, authenticationInformation unsafe.Pointer, authenticationInformationLength uint32, localGroups *windows.Tokengroups, sourceContext *_TOKEN_SOURCE, profileBuffer *uintptr, profileBufferLength *uint32, logonID *windows.LUID, token *windows.Token, quotas *_QUOTA_LIMITS, subStatus *windows.NTStatus) (ret windows.NTStatus) = secur32.LsaLogonUser
+//sys lsaLookupAuthenticationPackage(lsaHandle _LSAHANDLE, packageName *windows.NTString, authenticationPackage *uint32) (ret windows.NTStatus) = secur32.LsaLookupAuthenticationPackage
+//sys lsaRegisterLogonProcess(logonProcessName *windows.NTString, lsaHandle *_LSAHANDLE, securityMode *_LSA_OPERATIONAL_MODE) (ret windows.NTStatus) = secur32.LsaRegisterLogonProcess

+ 941 - 0
util/winutil/s4u/s4u_windows.go

@@ -0,0 +1,941 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+// Package s4u is an API for accessing Service-For-User (S4U) functionality on Windows.
+package s4u
+
+import (
+	"encoding/binary"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"math"
+	"os"
+	"os/user"
+	"runtime"
+	"slices"
+	"strconv"
+	"strings"
+	"sync/atomic"
+	"unsafe"
+
+	"golang.org/x/sys/windows"
+	"tailscale.com/cmd/tailscaled/childproc"
+	"tailscale.com/types/logger"
+	"tailscale.com/util/winutil"
+	"tailscale.com/util/winutil/conpty"
+)
+
+func init() {
+	childproc.Add("s4u", beRelay)
+}
+
+var errInsufficientCapabilityLevel = errors.New("insufficient capability level")
+
+// ListGroupIDsForSSHPreAuthOnly returns user u's group memberships as a slice
+// containing group SIDs. srcName must contain the name of the service that is
+// retrieving this information. srcName must be non-empty, ASCII-only, and no
+// longer than 8 characters.
+//
+// NOTE: This should only be used by Tailscale SSH! It is not a generic
+// mechanism for access checks!
+func ListGroupIDsForSSHPreAuthOnly(srcName string, u *user.User) ([]string, error) {
+	tok, err := createToken(srcName, u, tokenTypeIdentification, CapImpersonateOnly)
+	if err != nil {
+		return nil, err
+	}
+	defer tok.Close()
+
+	tokenGroups, err := tok.GetTokenGroups()
+	if err != nil {
+		return nil, err
+	}
+
+	result := make([]string, 0, tokenGroups.GroupCount)
+	for _, group := range tokenGroups.AllGroups() {
+		if group.Attributes&windows.SE_GROUP_ENABLED != 0 {
+			result = append(result, group.Sid.String())
+		}
+	}
+
+	return result, nil
+}
+
+type tokenType uint
+
+const (
+	tokenTypeIdentification tokenType = iota
+	tokenTypeImpersonation
+)
+
+// createToken creates a new S4U access token for user u for the purposes
+// specified by s4uType, with capability capLevel. srcName must contain the name
+// of the service that is intended to use the token. srcName must be non-empty,
+// ASCII-only, and no longer than 8 characters.
+//
+// When s4uType is tokenTypeImpersonation, the current OS thread's access token must have SeTcbPrivilege.
+func createToken(srcName string, u *user.User, s4uType tokenType, capLevel CapabilityLevel) (tok windows.Token, err error) {
+	if u == nil {
+		return 0, os.ErrInvalid
+	}
+
+	var lsa *lsaSession
+	switch s4uType {
+	case tokenTypeIdentification:
+		lsa, err = newLSASessionForQuery()
+	case tokenTypeImpersonation:
+		lsa, err = newLSASessionForLogon("")
+	default:
+		return 0, os.ErrInvalid
+	}
+	if err != nil {
+		return 0, err
+	}
+	defer lsa.Close()
+
+	return lsa.logonAs(srcName, u, capLevel)
+}
+
+// Session encapsulates an S4U login session.
+type Session struct {
+	refCnt      atomic.Int32
+	logf        logger.Logf
+	token       windows.Token
+	userProfile *winutil.UserProfile
+	capLevel    CapabilityLevel
+}
+
+// CapabilityLevel specifies the desired capabilities that will be supported by a Session.
+type CapabilityLevel uint
+
+const (
+	// The Session supports Do but none of the StartProcess* methods.
+	CapImpersonateOnly CapabilityLevel = iota
+	// The Session supports both Do and the StartProcess* methods.
+	CapCreateProcess
+)
+
+// Login logs user u into Windows on behalf of service srcName, loads the user's
+// profile, and returns a Session that may be used for impersonating that user,
+// or optionally creating processes as that user. Logs will be written to logf,
+// if provided.  srcName must be non-empty, ASCII-only, and no longer than 8
+// characters.
+//
+// The current OS thread's access token must have SeTcbPrivilege.
+func Login(logf logger.Logf, srcName string, u *user.User, capLevel CapabilityLevel) (sess *Session, err error) {
+	token, err := createToken(srcName, u, tokenTypeIdentification, capLevel)
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		if err != nil {
+			token.Close()
+		}
+	}()
+
+	sessToken := token
+	if capLevel == CapCreateProcess {
+		// Obtain token's security descriptor so that it may be applied to
+		// a primary token.
+		sd, err := windows.GetSecurityInfo(windows.Handle(token),
+			windows.SE_KERNEL_OBJECT, windows.DACL_SECURITY_INFORMATION)
+		if err != nil {
+			return nil, err
+		}
+
+		sa := windows.SecurityAttributes{
+			Length:             uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
+			SecurityDescriptor: sd,
+		}
+
+		// token is an impersonation token. Upgrade us to a primary token so that
+		// our StartProcess* methods will work correctly.
+		var dupToken windows.Token
+		if err := windows.DuplicateTokenEx(token, 0, &sa, windows.SecurityImpersonation,
+			windows.TokenPrimary, &dupToken); err != nil {
+			return nil, err
+		}
+		sessToken = dupToken
+		defer func() {
+			if err != nil {
+				sessToken.Close()
+			}
+		}()
+	}
+
+	userProfile, err := winutil.LoadUserProfile(sessToken, u)
+	if err != nil {
+		return nil, err
+	}
+
+	if logf == nil {
+		logf = logger.Discard
+	} else {
+		logf = logger.WithPrefix(logf, "(s4u) ")
+	}
+
+	return &Session{logf: logf, token: sessToken, userProfile: userProfile, capLevel: capLevel}, nil
+}
+
+// Close unloads the user profile and S4U access token associated with the
+// session. The close operation is not guaranteed to have finished when Close
+// returns; it may remain alive until all processes created by ss have
+// themselves been closed, and no more Do requests are pending.
+func (ss *Session) Close() error {
+	refs := ss.refCnt.Load()
+	if (refs & 1) != 0 {
+		// Close already called
+		return nil
+	}
+
+	// Set the low bit to indicate that a close operation has been requested.
+	// We don't have atomic OR so we need to use CAS. Sigh.
+	for !ss.refCnt.CompareAndSwap(refs, refs|1) {
+		refs = ss.refCnt.Load()
+	}
+
+	if refs > 1 {
+		// Still active processes, just return.
+		return nil
+	}
+
+	return ss.closeInternal()
+}
+
+func (ss *Session) closeInternal() error {
+	if ss.userProfile != nil {
+		if err := ss.userProfile.Close(); err != nil {
+			return err
+		}
+		ss.userProfile = nil
+	}
+
+	if ss.token != 0 {
+		if err := ss.token.Close(); err != nil {
+			return err
+		}
+		ss.token = 0
+	}
+	return nil
+}
+
+// CapabilityLevel returns the CapabilityLevel that was specified when the
+// session was created.
+func (ss *Session) CapabilityLevel() CapabilityLevel {
+	return ss.capLevel
+}
+
+// Do executes fn while impersonating ss's user. Impersonation only affects
+// the current goroutine; any new goroutines spawned by fn will not be
+// impersonated. Do may be called concurrently by multiple goroutines.
+//
+// Do returns an error if impersonation did not succeed and fn could not be run.
+// If called after ss has already been closed, it will panic.
+func (ss *Session) Do(fn func()) error {
+	if fn == nil {
+		return os.ErrInvalid
+	}
+
+	ss.addRef()
+	defer ss.release()
+
+	// Impersonation touches thread-local state.
+	runtime.LockOSThread()
+	defer runtime.UnlockOSThread()
+	if err := impersonateLoggedOnUser(ss.token); err != nil {
+		return err
+	}
+	defer func() {
+		if err := windows.RevertToSelf(); err != nil {
+			// This is not recoverable in any way, shape, or form!
+			panic(fmt.Sprintf("RevertToSelf failed: %v", err))
+		}
+	}()
+
+	fn()
+	return nil
+}
+
+func (ss *Session) addRef() {
+	if (ss.refCnt.Add(2) & 1) != 0 {
+		panic("addRef after Close")
+	}
+}
+
+func (ss *Session) release() {
+	rc := ss.refCnt.Add(-2)
+	if rc < 0 {
+		panic("negative refcount")
+	}
+	if rc == 1 {
+		ss.closeInternal()
+	}
+}
+
+type startProcessOpts struct {
+	token    windows.Token
+	extraEnv map[string]string
+	ptySize  windows.Coord
+	pipes    bool
+}
+
+// StartProcess creates a new process running under ss via cmdLineInfo.
+// The process will be started with its working directory set to the S4U user's
+// profile directory and its environment set to the S4U user's environment.
+// extraEnv, when specified, contains any additional environment variables to
+// be added to the process's environment.
+//
+// If called after ss has already been closed, StartProcess will panic.
+func (ss *Session) StartProcess(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string) (psp *Process, err error) {
+	if ss.capLevel != CapCreateProcess {
+		return nil, errInsufficientCapabilityLevel
+	}
+
+	opts := startProcessOpts{
+		token:    ss.token,
+		extraEnv: extraEnv,
+	}
+	return startProcessInternal(ss, ss.logf, cmdLineInfo, opts)
+}
+
+// StartProcessWithPTY creates a new process running under ss via cmdLineInfo
+// with a pseudoconsole initialized to initialPtySize. The resulting Process
+// will return non-nil values from Stdin and Stdout, but Stderr will return nil.
+// The process will be started with its working directory set to the S4U user's
+// profile directory and its environment set to the S4U user's environment.
+// extraEnv, when specified, contains any additional environment variables to
+// be added to the process's environment.
+//
+// If called after ss has already been closed, StartProcessWithPTY will panic.
+func (ss *Session) StartProcessWithPTY(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string, initialPtySize windows.Coord) (psp *Process, err error) {
+	if ss.capLevel != CapCreateProcess {
+		return nil, errInsufficientCapabilityLevel
+	}
+
+	opts := startProcessOpts{
+		token:    ss.token,
+		extraEnv: extraEnv,
+		ptySize:  initialPtySize,
+	}
+	return startProcessInternal(ss, ss.logf, cmdLineInfo, opts)
+}
+
+// StartProcessWithPipes creates a new process running under ss via cmdLineInfo
+// with all standard handles set to pipes. The resulting Process will return
+// non-nil values from Stdin, Stdout, and Stderr.
+// The process will be started with its working directory set to the S4U user's
+// profile directory and its environment set to the S4U user's environment.
+// extraEnv, when specified, contains any additional environment variables to
+// be added to the process's environment.
+//
+// If called after ss has already been closed, StartProcessWithPipes will panic.
+func (ss *Session) StartProcessWithPipes(cmdLineInfo winutil.CommandLineInfo, extraEnv map[string]string) (psp *Process, err error) {
+	if ss.capLevel != CapCreateProcess {
+		return nil, errInsufficientCapabilityLevel
+	}
+
+	opts := startProcessOpts{
+		token:    ss.token,
+		extraEnv: extraEnv,
+		pipes:    true,
+	}
+	return startProcessInternal(ss, ss.logf, cmdLineInfo, opts)
+}
+
+// startProcessInternal is the common implementation behind Session's exported
+// StartProcess* methods. It uses opts to distinguish between the various
+// requested modes of operation.
+//
+// A note on pseudoconsoles:
+// The conpty API currently does not provide a way to create a pseudoconsole for
+// a different user than the current process. The way we deal with this is
+// to first create a "relay" process running with the desired user token,
+// and then create the actual requested process as a child of the relay,
+// at which time we create the pseudoconsole. The relay simply copies the
+// PTY's I/O into/out of its own stdin and stdout, which are piped to the
+// parent still running as LocalSystem. We also relay pseudoconsole resize requests.
+func startProcessInternal(ss *Session, logf logger.Logf, cmdLineInfo winutil.CommandLineInfo, opts startProcessOpts) (psp *Process, err error) {
+	var sib winutil.StartupInfoBuilder
+	defer sib.Close()
+
+	var sp Process
+	defer func() {
+		if err != nil {
+			sp.Close()
+		}
+	}()
+
+	var zeroCoord windows.Coord
+	ptySizeValid := opts.ptySize != zeroCoord
+	useToken := opts.token != 0
+	usePty := ptySizeValid && !useToken
+	useRelay := ptySizeValid && useToken
+	useSystem32WD := useToken && opts.token.IsElevated()
+
+	if usePty {
+		sp.pty, err = conpty.NewPseudoConsole(opts.ptySize)
+		if err != nil {
+			return nil, err
+		}
+
+		if err := sp.pty.ConfigureStartupInfo(&sib); err != nil {
+			return nil, err
+		}
+
+		sp.wStdin = sp.pty.InputPipe()
+		sp.rStdout = sp.pty.OutputPipe()
+	} else if useRelay || opts.pipes {
+		if sp.wStdin, sp.rStdout, sp.rStderr, err = createStdPipes(&sib); err != nil {
+			return nil, err
+		}
+	}
+
+	var relayStderr io.ReadCloser
+	if useRelay {
+		// Later on we're going to use stderr for logging instead of providing it to the caller.
+		relayStderr = sp.rStderr
+		sp.rStderr = nil
+		defer func() {
+			if err != nil {
+				relayStderr.Close()
+			}
+		}()
+
+		// Set up a pipe to send PTY resize requests.
+		var resizeRead, resizeWrite windows.Handle
+		if err := windows.CreatePipe(&resizeRead, &resizeWrite, nil, 0); err != nil {
+			return nil, err
+		}
+		sp.wResize = os.NewFile(uintptr(resizeWrite), "wPTYResizePipe")
+		defer windows.CloseHandle(resizeRead)
+		if err := sib.InheritHandles(resizeRead); err != nil {
+			return nil, err
+		}
+
+		// Revise the command line. First, get the existing one.
+		_, _, strCmdLine, err := cmdLineInfo.Resolve()
+		if err != nil {
+			return nil, err
+		}
+
+		// Now rebuild it, passing the strCmdLine as the --cmd argument...
+		newArgs := []string{
+			"be-child", "s4u",
+			"--resize", fmt.Sprintf("0x%x", uintptr(resizeRead)),
+			"--x", strconv.Itoa(int(opts.ptySize.X)),
+			"--y", strconv.Itoa(int(opts.ptySize.Y)),
+			"--cmd", strCmdLine,
+		}
+
+		// ...to be passed in as arguments to our own executable.
+		cmdLineInfo.ExePath, err = os.Executable()
+		if err != nil {
+			return nil, err
+		}
+		cmdLineInfo.SetArgs(newArgs)
+	}
+
+	exePath, cmdLine, cmdLineStr, err := cmdLineInfo.Resolve()
+	if err != nil {
+		return nil, err
+	}
+	logf("starting %s", cmdLineStr)
+
+	var env []string
+	var wd16 *uint16
+	if useToken {
+		env, err = opts.token.Environ(false)
+		if err != nil {
+			return nil, err
+		}
+
+		folderID := windows.FOLDERID_Profile
+		if useSystem32WD {
+			folderID = windows.FOLDERID_System
+		}
+		wd, err := opts.token.KnownFolderPath(folderID, windows.KF_FLAG_DEFAULT)
+		if err != nil {
+			return nil, err
+		}
+		wd16, err = windows.UTF16PtrFromString(wd)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		env = os.Environ()
+	}
+
+	env = mergeEnv(env, opts.extraEnv)
+
+	var env16 *uint16
+	if useToken || len(opts.extraEnv) > 0 {
+		env16 = winutil.NewEnvBlock(env)
+	}
+
+	if useToken {
+		// We want the child process to be assigned to job such that when it exits,
+		// its descendents within the job will be terminated as well.
+		job, err := createJob()
+		if err != nil {
+			return nil, err
+		}
+		// We don't need to hang onto job beyond this func...
+		defer job.Close()
+
+		if err := sib.AssignToJob(job.Handle()); err != nil {
+			return nil, err
+		}
+
+		// ...because we're now gonna make a read-only copy...
+		qjob, err := job.QueryOnlyClone()
+		if err != nil {
+			return nil, err
+		}
+		defer qjob.Close()
+
+		// ...which will be inherited by the child process.
+		// When the child process terminates, the job will too.
+		if err := sib.InheritHandles(qjob.Handle()); err != nil {
+			return nil, err
+		}
+	}
+
+	si, inheritHandles, creationFlags, err := sib.Resolve()
+	if err != nil {
+		return nil, err
+	}
+
+	var pi windows.ProcessInformation
+	if useToken {
+		// DETACHED_PROCESS so that the child does not receive a console.
+		// CREATE_NEW_PROCESS_GROUP so that the child's console group is isolated from ours.
+		creationFlags |= windows.DETACHED_PROCESS | windows.CREATE_NEW_PROCESS_GROUP
+		doCreate := func() {
+			err = windows.CreateProcessAsUser(opts.token, exePath, cmdLine, nil, nil, inheritHandles, creationFlags, env16, wd16, si, &pi)
+		}
+		switch {
+		case useRelay:
+			doCreate()
+		case ss != nil:
+			// We want to ensure that the executable is accessible via the token's
+			// security context, not ours.
+			if err := ss.Do(doCreate); err != nil {
+				return nil, err
+			}
+		default:
+			panic("should not have reached here")
+		}
+	} else {
+		err = windows.CreateProcess(exePath, cmdLine, nil, nil, inheritHandles, creationFlags, env16, wd16, si, &pi)
+	}
+	if err != nil {
+		return nil, err
+	}
+	windows.CloseHandle(pi.Thread)
+
+	if relayStderr != nil {
+		logw := logger.FuncWriter(logger.WithPrefix(logf, fmt.Sprintf("(s4u relay process %d [0x%x]) ", pi.ProcessId, pi.ProcessId)))
+		go func() {
+			defer relayStderr.Close()
+			io.Copy(logw, relayStderr)
+		}()
+	}
+
+	sp.hproc = pi.Process
+	sp.pid = pi.ProcessId
+	if ss != nil {
+		ss.addRef()
+		sp.sess = ss
+	}
+	return &sp, nil
+}
+
+type jobObject windows.Handle
+
+func createJob() (job *jobObject, err error) {
+	hjob, err := windows.CreateJobObject(nil, nil)
+	if err != nil {
+		return nil, err
+	}
+	defer func() {
+		if err != nil {
+			windows.CloseHandle(hjob)
+		}
+	}()
+
+	limitInfo := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
+		BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
+			// We want every process within the job to terminate when the job is closed.
+			// We also want to allow processes within the job to create child processes
+			// that are outside the job (otherwise you couldn't leave background
+			// processes running after exiting a session, for example).
+			// These flags also match those used by the Win32 port of OpenSSH.
+			LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | windows.JOB_OBJECT_LIMIT_BREAKAWAY_OK,
+		},
+	}
+	_, err = windows.SetInformationJobObject(hjob,
+		windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limitInfo)),
+		uint32(unsafe.Sizeof(limitInfo)))
+	if err != nil {
+		return nil, err
+	}
+
+	jo := jobObject(hjob)
+	return &jo, nil
+}
+
+func (job *jobObject) Close() error {
+	if hjob := job.Handle(); hjob != 0 {
+		windows.CloseHandle(hjob)
+		*job = 0
+	}
+	return nil
+}
+
+func (job *jobObject) Handle() windows.Handle {
+	if job == nil {
+		return 0
+	}
+	return windows.Handle(*job)
+}
+
+const _JOB_OBJECT_QUERY = 0x0004
+
+func (job *jobObject) QueryOnlyClone() (*jobObject, error) {
+	hjob := job.Handle()
+	cp := windows.CurrentProcess()
+
+	var dupe windows.Handle
+	err := windows.DuplicateHandle(cp, hjob, cp, &dupe, _JOB_OBJECT_QUERY, true, 0)
+	if err != nil {
+		return nil, err
+	}
+
+	result := jobObject(dupe)
+	return &result, nil
+}
+
+func createStdPipes(sib *winutil.StartupInfoBuilder) (stdin io.WriteCloser, stdout, stderr io.ReadCloser, err error) {
+	var rStdin, wStdin windows.Handle
+	if err := windows.CreatePipe(&rStdin, &wStdin, nil, 0); err != nil {
+		return nil, nil, nil, err
+	}
+	defer func() {
+		if err != nil {
+			windows.CloseHandle(rStdin)
+			windows.CloseHandle(wStdin)
+		}
+	}()
+
+	var rStdout, wStdout windows.Handle
+	if err := windows.CreatePipe(&rStdout, &wStdout, nil, 0); err != nil {
+		return nil, nil, nil, err
+	}
+	defer func() {
+		if err != nil {
+			windows.CloseHandle(rStdout)
+			windows.CloseHandle(wStdout)
+		}
+	}()
+
+	var rStderr, wStderr windows.Handle
+	if err := windows.CreatePipe(&rStderr, &wStderr, nil, 0); err != nil {
+		return nil, nil, nil, err
+	}
+	defer func() {
+		if err != nil {
+			windows.CloseHandle(rStderr)
+			windows.CloseHandle(wStderr)
+		}
+	}()
+
+	if err := sib.SetStdHandles(rStdin, wStdout, wStderr); err != nil {
+		return nil, nil, nil, err
+	}
+
+	stdin = os.NewFile(uintptr(wStdin), "wStdin")
+	stdout = os.NewFile(uintptr(rStdout), "rStdout")
+	stderr = os.NewFile(uintptr(rStderr), "rStderr")
+	return stdin, stdout, stderr, nil
+}
+
+// Process encapsulates a child process started with a Session.
+type Process struct {
+	sess    *Session
+	wStdin  io.WriteCloser
+	rStdout io.ReadCloser
+	rStderr io.ReadCloser
+	wResize io.WriteCloser
+	pty     *conpty.PseudoConsole
+	hproc   windows.Handle
+	pid     uint32
+}
+
+// Stdin returns the write side of a pipe connected to the child process's
+// stdin, or nil if no I/O was requested.
+func (sp *Process) Stdin() io.WriteCloser {
+	return sp.wStdin
+}
+
+// Stdout returns the read side of a pipe connected to the child process's
+// stdout, or nil if no I/O was requested.
+func (sp *Process) Stdout() io.ReadCloser {
+	return sp.rStdout
+}
+
+// Stderr returns the read side of a pipe connected to the child process's
+// stderr, or nil if no I/O was requested.
+func (sp *Process) Stderr() io.ReadCloser {
+	return sp.rStderr
+}
+
+// Terminate kills the process.
+func (sp *Process) Terminate() {
+	if sp.hproc != 0 {
+		windows.TerminateProcess(sp.hproc, 255)
+	}
+}
+
+// Close waits for sp to complete and then cleans up any resources owned by it.
+// Close must wait because the Session associated with sp should not be destroyed
+// until all its processes have terminated. If necessary, call Terminate to
+// forcibly end the process.
+//
+// If the process was created with a pseudoconsole then the caller must continue
+// concurrently draining sp's stdout until either Close finishes executing, or EOF.
+func (sp *Process) Close() error {
+	for _, pc := range []*io.WriteCloser{&sp.wStdin, &sp.wResize} {
+		if *pc == nil {
+			continue
+		}
+		(*pc).Close()
+		(*pc) = nil
+	}
+
+	if sp.pty != nil {
+		if err := sp.pty.Close(); err != nil {
+			return err
+		}
+		sp.pty = nil
+	}
+
+	if sp.hproc != 0 {
+		if _, err := sp.Wait(); err != nil {
+			return err
+		}
+		windows.CloseHandle(sp.hproc)
+		sp.hproc = 0
+		sp.pid = 0
+		if sp.sess != nil {
+			sp.sess.release()
+			sp.sess = nil
+		}
+	}
+
+	// Order is important here. Do not close sp.rStdout until _after_
+	// ss.pty (when present) has been closed! We're going to do one better by
+	// doing this after the process is done.
+	for _, pc := range []*io.ReadCloser{&sp.rStdout, &sp.rStderr} {
+		if *pc == nil {
+			continue
+		}
+		(*pc).Close()
+		(*pc) = nil
+	}
+	return nil
+}
+
+// Wait blocks the caller until sp terminates. It returns the process exit code.
+// exitCode will be set to 254 if the process terminated but the exit code could
+// not be retrieved.
+func (sp *Process) Wait() (exitCode uint32, err error) {
+	_, err = windows.WaitForSingleObject(sp.hproc, windows.INFINITE)
+	if err == nil {
+		if err := windows.GetExitCodeProcess(sp.hproc, &exitCode); err != nil {
+			exitCode = 254
+		}
+	}
+	return exitCode, err
+}
+
+// OSProcess returns an *os.Process associated with sp. This is useful for
+// integration with external code that expects an os.Process.
+func (sp *Process) OSProcess() (*os.Process, error) {
+	if sp.hproc == 0 {
+		return nil, winutil.ErrDefunctProcess
+	}
+	return os.FindProcess(int(sp.pid))
+}
+
+// PTYResizer returns a function to be called to resize the pseudoconsole.
+// It returns nil if no pseudoconsole was requested when creating sp.
+func (sp *Process) PTYResizer() func(windows.Coord) error {
+	if sp.wResize != nil {
+		wResize := sp.wResize
+		return func(c windows.Coord) error {
+			return binary.Write(wResize, binary.LittleEndian, c)
+		}
+	}
+
+	if sp.pty != nil {
+		pty := sp.pty
+		return func(c windows.Coord) error {
+			return pty.Resize(c)
+		}
+	}
+
+	return nil
+}
+
+type relayArgs struct {
+	command string
+	resize  string
+	ptyX    int
+	ptyY    int
+}
+
+func parseRelayArgs(args []string) (a relayArgs) {
+	flags := flag.NewFlagSet("", flag.ExitOnError)
+	flags.StringVar(&a.command, "cmd", "", "the command to run")
+	flags.StringVar(&a.resize, "resize", "", "handle to resize pipe")
+	flags.IntVar(&a.ptyX, "x", 80, "initial width of pty")
+	flags.IntVar(&a.ptyY, "y", 25, "initial height of pty")
+	flags.Parse(args)
+	return a
+}
+
+func flagSizeErr(flagName byte) error {
+	return fmt.Errorf("--%c must be greater than zero and less than %d", flagName, math.MaxInt16)
+}
+
+const debugRelay = false
+
+func beRelay(args []string) error {
+	ra := parseRelayArgs(args)
+	if ra.command == "" {
+		return fmt.Errorf("--cmd must be specified")
+	}
+
+	bitSize := int(unsafe.Sizeof(windows.Handle(0)) * 8)
+	resize64, err := strconv.ParseUint(ra.resize, 0, bitSize)
+	if err != nil {
+		return err
+	}
+	hResize := windows.Handle(resize64)
+	if ft, _ := windows.GetFileType(hResize); ft != windows.FILE_TYPE_PIPE {
+		return fmt.Errorf("--resize is an invalid handle type")
+	}
+	resize := os.NewFile(uintptr(hResize), "rPTYResizePipe")
+	defer resize.Close()
+
+	switch {
+	case ra.ptyX <= 0 || ra.ptyX > math.MaxInt16:
+		return flagSizeErr('x')
+	case ra.ptyY <= 0 || ra.ptyY > math.MaxInt16:
+		return flagSizeErr('y')
+	default:
+	}
+
+	logf := logger.Discard
+	if debugRelay {
+		// Our parent process will write our stderr to its log.
+		logf = func(format string, args ...any) {
+			fmt.Fprintf(os.Stderr, format, args...)
+		}
+	}
+
+	logf("starting")
+	argv, err := windows.DecomposeCommandLine(ra.command)
+	if err != nil {
+		logf("DecomposeCommandLine failed: %v", err)
+		return err
+	}
+
+	cli := winutil.CommandLineInfo{
+		ExePath: argv[0],
+	}
+	cli.SetArgs(argv[1:])
+
+	opts := startProcessOpts{
+		ptySize: windows.Coord{X: int16(ra.ptyX), Y: int16(ra.ptyY)},
+	}
+	psp, err := startProcessInternal(nil, logf, cli, opts)
+	if err != nil {
+		logf("startProcessInternal failed: %v", err)
+		return err
+	}
+	defer psp.Close()
+
+	go resizeLoop(logf, resize, psp.PTYResizer())
+	if debugRelay {
+		go debugLogPTYInput(logf, psp.wStdin, os.Stdin)
+		go debugLogPTYOutput(logf, os.Stdout, psp.rStdout)
+	} else {
+		go io.Copy(psp.wStdin, os.Stdin)
+		go io.Copy(os.Stdout, psp.rStdout)
+	}
+
+	exitCode, err := psp.Wait()
+	if err != nil {
+		logf("waiting on relayed process: %v", err)
+		return err
+	}
+	if exitCode > 0 {
+		logf("relayed process returned %v", exitCode)
+	}
+
+	if err := psp.Close(); err != nil {
+		logf("s4u.Process.Close error: %v", err)
+		return err
+	}
+	return nil
+}
+
+func resizeLoop(logf logger.Logf, resizePipe io.Reader, resizeFn func(windows.Coord) error) {
+	var coord windows.Coord
+	for binary.Read(resizePipe, binary.LittleEndian, &coord) == nil {
+		logf("resizing pty window to %#v", coord)
+		resizeFn(coord)
+	}
+}
+
+func debugLogPTYInput(logf logger.Logf, w io.Writer, r io.Reader) {
+	logw := logger.FuncWriter(logger.WithPrefix(logf, "(pty input) "))
+	io.Copy(io.MultiWriter(w, logw), r)
+}
+
+func debugLogPTYOutput(logf logger.Logf, w io.Writer, r io.Reader) {
+	logw := logger.FuncWriter(logger.WithPrefix(logf, "(pty output) "))
+	io.Copy(w, io.TeeReader(r, logw))
+}
+
+// mergeEnv returns the union of existingEnv and extraEnv, deduplicated and
+// sorted.
+func mergeEnv(existingEnv []string, extraEnv map[string]string) []string {
+	if len(extraEnv) == 0 {
+		return existingEnv
+	}
+
+	mergedMap := make(map[string]string, len(existingEnv)+len(extraEnv))
+	for _, line := range existingEnv {
+		k, v, _ := strings.Cut(line, "=")
+		mergedMap[strings.ToUpper(k)] = v
+	}
+
+	for k, v := range extraEnv {
+		mergedMap[strings.ToUpper(k)] = v
+	}
+
+	result := make([]string, 0, len(mergedMap))
+	for k, v := range mergedMap {
+		result = append(result, strings.Join([]string{k, v}, "="))
+	}
+
+	slices.SortFunc(result, func(l, r string) int {
+		kl, _, _ := strings.Cut(l, "=")
+		kr, _, _ := strings.Cut(r, "=")
+		return strings.Compare(kl, kr)
+	})
+	return result
+}

+ 104 - 0
util/winutil/s4u/zsyscall_windows.go

@@ -0,0 +1,104 @@
+// Code generated by 'go generate'; DO NOT EDIT.
+
+package s4u
+
+import (
+	"syscall"
+	"unsafe"
+
+	"golang.org/x/sys/windows"
+)
+
+var _ unsafe.Pointer
+
+// Do the interface allocations only once for common
+// Errno values.
+const (
+	errnoERROR_IO_PENDING = 997
+)
+
+var (
+	errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
+	errERROR_EINVAL     error = syscall.EINVAL
+)
+
+// errnoErr returns common boxed Errno values, to prevent
+// allocations at runtime.
+func errnoErr(e syscall.Errno) error {
+	switch e {
+	case 0:
+		return errERROR_EINVAL
+	case errnoERROR_IO_PENDING:
+		return errERROR_IO_PENDING
+	}
+	// TODO: add more here, after collecting data on the common
+	// error values see on Windows. (perhaps when running
+	// all.bat?)
+	return e
+}
+
+var (
+	modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
+	modsecur32  = windows.NewLazySystemDLL("secur32.dll")
+
+	procAllocateLocallyUniqueId        = modadvapi32.NewProc("AllocateLocallyUniqueId")
+	procImpersonateLoggedOnUser        = modadvapi32.NewProc("ImpersonateLoggedOnUser")
+	procLsaConnectUntrusted            = modsecur32.NewProc("LsaConnectUntrusted")
+	procLsaDeregisterLogonProcess      = modsecur32.NewProc("LsaDeregisterLogonProcess")
+	procLsaFreeReturnBuffer            = modsecur32.NewProc("LsaFreeReturnBuffer")
+	procLsaLogonUser                   = modsecur32.NewProc("LsaLogonUser")
+	procLsaLookupAuthenticationPackage = modsecur32.NewProc("LsaLookupAuthenticationPackage")
+	procLsaRegisterLogonProcess        = modsecur32.NewProc("LsaRegisterLogonProcess")
+)
+
+func allocateLocallyUniqueId(luid *windows.LUID) (err error) {
+	r1, _, e1 := syscall.Syscall(procAllocateLocallyUniqueId.Addr(), 1, uintptr(unsafe.Pointer(luid)), 0, 0)
+	if int32(r1) == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
+func impersonateLoggedOnUser(token windows.Token) (err error) {
+	r1, _, e1 := syscall.Syscall(procImpersonateLoggedOnUser.Addr(), 1, uintptr(token), 0, 0)
+	if int32(r1) == 0 {
+		err = errnoErr(e1)
+	}
+	return
+}
+
+func lsaConnectUntrusted(lsaHandle *_LSAHANDLE) (ret windows.NTStatus) {
+	r0, _, _ := syscall.Syscall(procLsaConnectUntrusted.Addr(), 1, uintptr(unsafe.Pointer(lsaHandle)), 0, 0)
+	ret = windows.NTStatus(r0)
+	return
+}
+
+func lsaDeregisterLogonProcess(lsaHandle _LSAHANDLE) (ret windows.NTStatus) {
+	r0, _, _ := syscall.Syscall(procLsaDeregisterLogonProcess.Addr(), 1, uintptr(lsaHandle), 0, 0)
+	ret = windows.NTStatus(r0)
+	return
+}
+
+func lsaFreeReturnBuffer(buffer uintptr) (ret windows.NTStatus) {
+	r0, _, _ := syscall.Syscall(procLsaFreeReturnBuffer.Addr(), 1, uintptr(buffer), 0, 0)
+	ret = windows.NTStatus(r0)
+	return
+}
+
+func lsaLogonUser(lsaHandle _LSAHANDLE, originName *windows.NTString, logonType _SECURITY_LOGON_TYPE, authenticationPackage uint32, authenticationInformation unsafe.Pointer, authenticationInformationLength uint32, localGroups *windows.Tokengroups, sourceContext *_TOKEN_SOURCE, profileBuffer *uintptr, profileBufferLength *uint32, logonID *windows.LUID, token *windows.Token, quotas *_QUOTA_LIMITS, subStatus *windows.NTStatus) (ret windows.NTStatus) {
+	r0, _, _ := syscall.Syscall15(procLsaLogonUser.Addr(), 14, uintptr(lsaHandle), uintptr(unsafe.Pointer(originName)), uintptr(logonType), uintptr(authenticationPackage), uintptr(authenticationInformation), uintptr(authenticationInformationLength), uintptr(unsafe.Pointer(localGroups)), uintptr(unsafe.Pointer(sourceContext)), uintptr(unsafe.Pointer(profileBuffer)), uintptr(unsafe.Pointer(profileBufferLength)), uintptr(unsafe.Pointer(logonID)), uintptr(unsafe.Pointer(token)), uintptr(unsafe.Pointer(quotas)), uintptr(unsafe.Pointer(subStatus)), 0)
+	ret = windows.NTStatus(r0)
+	return
+}
+
+func lsaLookupAuthenticationPackage(lsaHandle _LSAHANDLE, packageName *windows.NTString, authenticationPackage *uint32) (ret windows.NTStatus) {
+	r0, _, _ := syscall.Syscall(procLsaLookupAuthenticationPackage.Addr(), 3, uintptr(lsaHandle), uintptr(unsafe.Pointer(packageName)), uintptr(unsafe.Pointer(authenticationPackage)))
+	ret = windows.NTStatus(r0)
+	return
+}
+
+func lsaRegisterLogonProcess(logonProcessName *windows.NTString, lsaHandle *_LSAHANDLE, securityMode *_LSA_OPERATIONAL_MODE) (ret windows.NTStatus) {
+	r0, _, _ := syscall.Syscall(procLsaRegisterLogonProcess.Addr(), 3, uintptr(unsafe.Pointer(logonProcessName)), uintptr(unsafe.Pointer(lsaHandle)), uintptr(unsafe.Pointer(securityMode)))
+	ret = windows.NTStatus(r0)
+	return
+}