Files
dingtalk-workspace-cli/internal/keychain/keychain_windows.go
修雨 c75ed45c70 fix(keychain): add StorageDir for Windows to unblock v1.0.33 release (#394)
* docs(chat): 资源下载分流改为按 host 判定,补 media 直链可裸 curl(整合 #376)

main 现状的「资源链接形态分流」段补三类增量,supersede #376:
- 判定依据从「链接形态」改为「链接 host,不由扩展名」
- media(down.dingtalk.com/media)点明为公开 CDN,download-media 命令之外裸 curl 也可直下
- 补 .unknown 用 file 判真实类型、图片 AI 描述说明
- 保留 download-media 官方命令路径,删去 #376 误判「无 mediaId 下载命令」一句

* docs(chat): 修正 media 下载分流——host 非唯一、首选 download-media、签名链勿裸 curl

修复 PR 原版对 media 链接的过头断言(评审发现):
- 点明图片 mediaId 链接 host 不唯一:down.dingtalk.com/media(公开)或 *.trans.dingtalk.com 签名+过期链
- 下载主路改为首选 download-media(取最新 URL,不受过期影响);裸 curl 仅限公开直链
- 删去「无需鉴权 / curl 均 HTTP 200」的普适断言(签名链过期后 curl 403)
- 保留 .unknown 用 file 判类型、AI 图片描述、钉盘/alidocs 分流

* fix(keychain): add StorageDir for Windows to unblock cross-platform release

portable_store.go (#357) references keychain.StorageDir on all platforms,
but only keychain_darwin.go / keychain_linux.go defined it — the Windows
backend (DPAPI + registry) lacked StorageDir, so GoReleaser's
windows/amd64 + windows/arm64 cross-compile failed with
'undefined: keychain.StorageDir' and broke the v1.0.33 release.

Add StorageDir to keychain_windows.go: DWS_KEYCHAIN_DIR override →
%LocalAppData%\<service> default, mirroring the linux/darwin logic.
Verified: go build passes for windows/darwin/linux x amd64/arm64.
2026-06-02 23:37:06 +08:00

207 lines
6.1 KiB
Go

// 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 (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// ---------------------------------------------------------------------------
// 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 (used by tests for isolation),
// the storage root is taken from that env var instead; otherwise it defaults
// to %LocalAppData%\<service>.
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 {
return regRootPath + `\` + safeRegistryComponent(service)
}
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 := registryGet(service, account)
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) {
keyPath := registryPathForService(service)
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
if err != nil {
return "", false
}
defer k.Close()
b64, _, err := k.GetStringValue(valueNameForAccount(account))
if err != nil || b64 == "" {
return "", false
}
blob, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", false
}
entropy := dpapiEntropy(service, account)
plain, err := dpapiUnprotect(blob, entropy)
if err != nil {
return "", false
}
return string(plain), true
}
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 := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
if err != nil {
return nil
}
defer k.Close()
_ = k.DeleteValue(valueNameForAccount(account))
return nil
}