返回 DeepSeek-Reasonix
credential_windows.go
根目录 / internal / winaclresidue / credential_windows.go
1 //go:build windows
2
3 package winaclresidue
4
5 import (
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "unsafe"
13
14 "golang.org/x/sys/windows"
15 )
16
17 const legacyCredentialDenyMask = windows.ACCESS_MASK(windows.FILE_GENERIC_READ | windows.FILE_GENERIC_EXECUTE)
18
19 // RepairLegacyCredentialDeny removes the exact current-user DENY RX ACE that
20 // the retired backend placed on the credential file, provided a dead run's
21 // marker recorded this path. A mask alone cannot prove origin, so ambiguous
22 // ACLs and markers with live or unverifiable owners are never modified.
23 func RepairLegacyCredentialDeny(path string) error {
24 if path == "" {
25 return nil
26 }
27 if _, err := os.Stat(path); err != nil {
28 if os.IsNotExist(err) {
29 return nil
30 }
31 return err
32 }
33 userSID, err := currentProcessUserSIDString()
34 if err != nil {
35 return err
36 }
37 legacy, other, err := currentUserDenyACECounts(path, userSID)
38 if err != nil {
39 return err
40 }
41 if legacy == 0 {
42 return nil
43 }
44 if other != 0 || legacy != 1 {
45 return fmt.Errorf("refusing to alter non-legacy current-user deny ACL on %q", path)
46 }
47 markers := staleCredentialDenyMarkers(path, canonicalWindowsPath(path))
48 if len(markers) == 0 {
49 return fmt.Errorf("refusing to alter credential deny ACL without a stale sandbox record on %q", path)
50 }
51
52 removeErr := icacls(path, "/remove:d", "*"+userSID, "/C")
53 remainingLegacy, remainingOther, verifyErr := currentUserDenyACECounts(path, userSID)
54 if verifyErr == nil && remainingLegacy == 0 && remainingOther == 0 {
55 sids := residueSIDs()
56 for _, marker := range markers {
57 sweepMarkerFile(marker, sids)
58 }
59 return nil
60 }
61 if removeErr != nil {
62 return fmt.Errorf("remove legacy credential deny ACL: %w", removeErr)
63 }
64 if verifyErr != nil {
65 return fmt.Errorf("verify legacy credential deny ACL removal: %w", verifyErr)
66 }
67 return fmt.Errorf("legacy credential deny ACL remains on %q", path)
68 }
69
70 // staleCredentialDenyMarkers returns the markers of exited runs that recorded
71 // a deny on path. Any marker with a live or unverifiable owner vetoes repair.
72 func staleCredentialDenyMarkers(path, canonicalPath string) []string {
73 entries, err := os.ReadDir(markerDir())
74 if err != nil {
75 return nil
76 }
77 var found []string
78 for _, entry := range entries {
79 pid, ok := markerOwnerPID(entry.Name())
80 if entry.IsDir() || !ok {
81 continue
82 }
83 marker := filepath.Join(markerDir(), entry.Name())
84 for _, residue := range readResidueMarker(marker) {
85 if residue.kind != residueDeny {
86 continue
87 }
88 if !strings.EqualFold(filepath.Clean(residue.path), filepath.Clean(path)) &&
89 !strings.EqualFold(canonicalWindowsPath(residue.path), canonicalPath) {
90 continue
91 }
92 if !processExited(pid) {
93 return nil
94 }
95 found = append(found, marker)
96 break
97 }
98 }
99 return found
100 }
101
102 // canonicalWindowsPath expands 8.3 aliases without opening the protected file.
103 // The original spelling is kept on failure so the exact-path comparison still
104 // works on volumes without long-name expansion.
105 func canonicalWindowsPath(path string) string {
106 input, err := windows.UTF16PtrFromString(filepath.Clean(path))
107 if err != nil {
108 return filepath.Clean(path)
109 }
110 buffer := make([]uint16, 32768)
111 n, err := windows.GetLongPathName(input, &buffer[0], uint32(len(buffer)))
112 if err != nil || n == 0 || n >= uint32(len(buffer)) {
113 return filepath.Clean(path)
114 }
115 return filepath.Clean(windows.UTF16ToString(buffer[:n]))
116 }
117
118 func currentUserDenyACECounts(path, userSID string) (legacy, other int, err error) {
119 sid, err := windows.StringToSid(userSID)
120 if err != nil {
121 return 0, 0, fmt.Errorf("parse current user SID: %w", err)
122 }
123 sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
124 if err != nil {
125 return 0, 0, fmt.Errorf("read credential DACL %q: %w", path, err)
126 }
127 if sd == nil {
128 return 0, 0, nil
129 }
130 acl, _, err := sd.DACL()
131 if errors.Is(err, windows.ERROR_OBJECT_NOT_FOUND) {
132 return 0, 0, nil
133 }
134 if err != nil {
135 return 0, 0, fmt.Errorf("read credential DACL entries %q: %w", path, err)
136 }
137 if acl == nil {
138 return 0, 0, nil
139 }
140 for index := range uint32(acl.AceCount) {
141 var ace *windows.ACCESS_ALLOWED_ACE
142 if err := windows.GetAce(acl, index, &ace); err != nil {
143 return 0, 0, fmt.Errorf("read credential DACL ACE %d for %q: %w", index, path, err)
144 }
145 if ace == nil || ace.Header.AceType == windows.ACCESS_ALLOWED_ACE_TYPE {
146 continue
147 }
148 // SID-wide icacls removal must not erase object or conditional deny
149 // entries whose trustee layout differs from a basic deny ACE.
150 if ace.Header.AceType != windows.ACCESS_DENIED_ACE_TYPE {
151 return 0, 0, fmt.Errorf("refusing to alter unsupported credential ACE type %d on %q", ace.Header.AceType, path)
152 }
153 aceSID := (*windows.SID)(unsafe.Pointer(&ace.SidStart))
154 if !windows.EqualSid(aceSID, sid) {
155 continue
156 }
157 if ace.Header.AceFlags == 0 && ace.Mask == legacyCredentialDenyMask {
158 legacy++
159 } else {
160 other++
161 }
162 }
163 runtime.KeepAlive(sd)
164 return legacy, other, nil
165 }
166
167 // ResetCredentialDACL replaces the DACL of the credential store with a
168 // protected entry that grants only the current user full control. It opens
169 // the file for WRITE_DAC alone and never reads the existing descriptor, so it
170 // works when READ_CONTROL is denied and the caller is not the owner. Callers
171 // reserve it for an explicit save of Reasonix's own credential file after the
172 // provenance-checked repair could not run.
173 func ResetCredentialDACL(path string) error {
174 ptr, err := windows.UTF16PtrFromString(path)
175 if err != nil {
176 return err
177 }
178 // Attributes are readable through the parent directory's list right even
179 // while the file itself denies FILE_READ_ATTRIBUTES.
180 attrs, err := windows.GetFileAttributes(ptr)
181 if err != nil {
182 return fmt.Errorf("inspect credential store %q: %w", path, err)
183 }
184 if attrs&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 {
185 return fmt.Errorf("refusing to reset the ACL of reparse point %q", path)
186 }
187 handle, err := openExact(path, windows.WRITE_DAC)
188 if err != nil {
189 return fmt.Errorf("open credential store for WRITE_DAC: %w", err)
190 }
191 defer windows.CloseHandle(handle)
192 user, err := windows.GetCurrentProcessToken().GetTokenUser()
193 if err != nil {
194 return err
195 }
196 if user == nil || user.User.Sid == nil {
197 return fmt.Errorf("current process token has no user SID")
198 }
199 entry := windows.EXPLICIT_ACCESS{
200 AccessPermissions: windows.STANDARD_RIGHTS_ALL | windows.SPECIFIC_RIGHTS_ALL,
201 AccessMode: windows.GRANT_ACCESS,
202 Inheritance: windows.NO_INHERITANCE,
203 Trustee: windows.TRUSTEE{TrusteeForm: windows.TRUSTEE_IS_SID, TrusteeType: windows.TRUSTEE_IS_USER, TrusteeValue: windows.TrusteeValueFromSID(user.User.Sid)},
204 }
205 acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{entry}, nil)
206 if err != nil {
207 return fmt.Errorf("build credential store DACL: %w", err)
208 }
209 sd, err := windows.NewSecurityDescriptor()
210 if err != nil {
211 return err
212 }
213 if err := sd.SetDACL(acl, true, false); err != nil {
214 return err
215 }
216 // Protected: inherited entries from the profile directory must not bring
217 // back the trustees the reset is meant to replace.
218 if err := sd.SetControl(windows.SE_DACL_PROTECTED, windows.SE_DACL_PROTECTED); err != nil {
219 return err
220 }
221 if err := windows.SetKernelObjectSecurity(handle, windows.DACL_SECURITY_INFORMATION, sd); err != nil {
222 return fmt.Errorf("reset credential store DACL %q: %w", path, err)
223 }
224 return nil
225 }
226
226 lines GO