// Copyright 2026 Alibaba Group // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //go:build windows package keychain import ( "crypto/sha256" "encoding/base64" "errors" "fmt" "os" "path/filepath" "regexp" "strings" "unsafe" "golang.org/x/sys/windows" "golang.org/x/sys/windows/registry" ) var registryDeleteValue = func(key registry.Key, name string) error { return key.DeleteValue(name) } var registryOpenDeleteKey = func(path string, access uint32) (registry.Key, error) { return registry.OpenKey(registry.CURRENT_USER, path, access) } var registryOpenReadKey = registry.OpenKey // --------------------------------------------------------------------------- // Windows backend: DPAPI + HKCU registry // --------------------------------------------------------------------------- const regRootPath = `Software\DwsCli\keychain` // StorageDir returns the storage directory for a given service name on Windows. // The Windows keychain backend keeps secrets in DPAPI-protected HKCU registry // values rather than on disk, so this path is used only by the portable // auth-bundle export/import (internal/auth) to colocate config. When the // DWS_KEYCHAIN_DIR environment variable is set, the storage root is taken from // that env var; otherwise it defaults to %LocalAppData%\. func StorageDir(service string) string { if override := os.Getenv(StorageDirEnv); override != "" { return filepath.Join(override, service) } if local := os.Getenv("LocalAppData"); local != "" { return filepath.Join(local, service) } home, err := os.UserHomeDir() if err != nil || home == "" { fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err) return filepath.Join(".dws", "keychain", service) } return filepath.Join(home, "AppData", "Local", service) } func registryPathForService(service string) string { path := regRootPath + `\` + safeRegistryComponent(service) namespace := strings.TrimSpace(os.Getenv(TestNamespaceEnv)) if namespace == "" { return path } // Windows stores credentials in HKCU instead of DWS_KEYCHAIN_DIR. Tests set // an explicit process namespace so concurrent package binaries cannot // delete each other's credentials. Hash it to avoid leaking temp paths or // introducing registry separators. namespace = filepath.Clean(namespace) if absolute, err := filepath.Abs(namespace); err == nil { namespace = absolute } sum := sha256.Sum256([]byte(strings.ToLower(namespace))) return fmt.Sprintf(`%s\test-%x`, path, sum[:16]) } var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`) func safeRegistryComponent(s string) string { // Registry key path uses '\\' separators; avoid accidental nesting and odd chars. s = strings.ReplaceAll(s, "\\", "_") return safeRegRe.ReplaceAllString(s, "_") } func valueNameForAccount(account string) string { // Avoid any special characters; keep deterministic. return base64.RawURLEncoding.EncodeToString([]byte(account)) } func dpapiEntropy(service, account string) *windows.DataBlob { // Bind ciphertext to (service, account) to reduce swap/replay risks. data := []byte(service + "\x00" + account) if len(data) == 0 { return nil } return &windows.DataBlob{Size: uint32(len(data)), Data: &data[0]} } func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) { var in windows.DataBlob if len(plaintext) > 0 { in = windows.DataBlob{Size: uint32(len(plaintext)), Data: &plaintext[0]} } var out windows.DataBlob err := windows.CryptProtectData(&in, nil, entropy, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) if err != nil { return nil, err } defer freeDataBlob(&out) if out.Data == nil || out.Size == 0 { return []byte{}, nil } buf := unsafe.Slice(out.Data, int(out.Size)) res := make([]byte, len(buf)) copy(res, buf) return res, nil } func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error) { var in windows.DataBlob if len(ciphertext) > 0 { in = windows.DataBlob{Size: uint32(len(ciphertext)), Data: &ciphertext[0]} } var out windows.DataBlob err := windows.CryptUnprotectData(&in, nil, entropy, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out) if err != nil { return nil, err } defer freeDataBlob(&out) if out.Data == nil || out.Size == 0 { return []byte{}, nil } buf := unsafe.Slice(out.Data, int(out.Size)) res := make([]byte, len(buf)) copy(res, buf) return res, nil } func freeDataBlob(b *windows.DataBlob) { if b == nil || b.Data == nil { return } // Per DPAPI contract, output buffers must be freed with LocalFree. _, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(b.Data))) b.Data = nil b.Size = 0 } func platformGet(service, account string) (string, error) { v, ok, err := registryGet(service, account) if err != nil { return "", err } if !ok { return "", nil // Not found is not an error } return v, nil } func platformSet(service, account, data string) error { entropy := dpapiEntropy(service, account) protected, err := dpapiProtect([]byte(data), entropy) if err != nil { return fmt.Errorf("dpapi protect failed: %w", err) } return registrySet(service, account, protected) } func platformRemove(service, account string) error { return registryRemove(service, account) } func registryGet(service, account string) (string, bool, error) { keyPath := registryPathForService(service) k, err := registryOpenReadKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE) if err != nil { if errors.Is(err, registry.ErrNotExist) { return "", false, nil } return "", false, fmt.Errorf("registry open for read failed: %w", err) } defer k.Close() return registryGetFromKey(k, service, account) } func registryGetFromKey(k registry.Key, service, account string) (string, bool, error) { b64, _, err := k.GetStringValue(valueNameForAccount(account)) if err != nil { if errors.Is(err, registry.ErrNotExist) { return "", false, nil } return "", false, fmt.Errorf("registry read account %q failed: %w", account, err) } if b64 == "" { return "", false, fmt.Errorf("registry account %q contains empty DPAPI ciphertext", account) } blob, err := base64.StdEncoding.DecodeString(b64) if err != nil { return "", false, fmt.Errorf("decode registry account %q DPAPI ciphertext: %w", account, err) } entropy := dpapiEntropy(service, account) plain, err := dpapiUnprotect(blob, entropy) if err != nil { return "", false, fmt.Errorf("dpapi unprotect registry account %q failed: %w", account, err) } return string(plain), true, nil } func registrySet(service, account string, protected []byte) error { keyPath := registryPathForService(service) k, _, err := registry.CreateKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE) if err != nil { return fmt.Errorf("registry create/open failed: %w", err) } defer k.Close() b64 := base64.StdEncoding.EncodeToString(protected) if err := k.SetStringValue(valueNameForAccount(account), b64); err != nil { return fmt.Errorf("registry set failed: %w", err) } return nil } func registryRemove(service, account string) error { keyPath := registryPathForService(service) k, err := registryOpenDeleteKey(keyPath, registry.SET_VALUE) if err != nil { if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { return nil } return fmt.Errorf("registry open for delete failed: %w", err) } defer k.Close() return deleteRegistryValue(k, valueNameForAccount(account)) } func deleteRegistryValue(key registry.Key, name string) error { if err := registryDeleteValue(key, name); err != nil { if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { return nil } return fmt.Errorf("registry delete failed: %w", err) } return nil } func registryRemoveAuthTokenEntries(service string) error { keyPath := registryPathForService(service) k, err := registryOpenDeleteKey(keyPath, registry.QUERY_VALUE|registry.SET_VALUE) if err != nil { if errors.Is(err, windows.ERROR_FILE_NOT_FOUND) { return nil } return fmt.Errorf("registry open for auth token cleanup failed: %w", err) } defer k.Close() names, err := k.ReadValueNames(-1) if err != nil { return fmt.Errorf("registry list values failed: %w", err) } for _, name := range names { accountBytes, decodeErr := base64.RawURLEncoding.DecodeString(name) if decodeErr != nil { continue } account := string(accountBytes) if account != AccountToken && !strings.HasPrefix(account, AccountToken+":") { continue } if err := k.DeleteValue(name); err != nil { return fmt.Errorf("registry delete auth token value failed: %w", err) } } return nil }