diff --git a/verifycache/script_cache.go b/verifycache/script_cache.go index 51d84dd..e337700 100644 --- a/verifycache/script_cache.go +++ b/verifycache/script_cache.go @@ -28,8 +28,9 @@ func SignVersionScripts(versionDir string) error { } // SignScript force-resigns one delegated .cmd/.bat/.js launcher, bypassing the -// TrustedModules gate. Used after the user accepts a trust prompt so VerifyCache -// can be refreshed before the next launch retry. +// TrustedModules gate. In-process callers (nvm firewall trust module) use this +// directly. The --sign-script CLI flag is gated separately: signed proxy parent +// plus an inherited pipe. Do not treat argv as a trust source. func SignScript(scriptPath string) error { scriptPath = strings.TrimSpace(scriptPath) if scriptPath == "" { diff --git a/verifycache/sign_script_auth.go b/verifycache/sign_script_auth.go new file mode 100644 index 0000000..626b030 --- /dev/null +++ b/verifycache/sign_script_auth.go @@ -0,0 +1,216 @@ +//go:build windows + +package verifycache + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "unsafe" + + "common/settings" + "common/verify" + + "golang.org/x/sys/windows" +) + +const maxSignScriptPathBytes = 32 * 1024 + +var ( + parentPIDFn = func() uint32 { return uint32(os.Getppid()) } + stdinPipeServerPIDFn = stdinPipeServerPID + verifyParentPublisherFn = verifyParentPublisher + + procGetNamedPipeServerProcessId = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetNamedPipeServerProcessId") +) + +func verifyParentPublisher(parent string) error { + return verify.VerifySamePublisherAsExecutable(parent) +} + +func stdinPipeServerPID() (uint32, bool) { + h := windows.Handle(os.Stdin.Fd()) + ft, err := windows.GetFileType(h) + if err != nil || ft != windows.FILE_TYPE_PIPE { + return 0, false + } + var pid uint32 + r1, _, _ := procGetNamedPipeServerProcessId.Call(uintptr(h), uintptr(unsafe.Pointer(&pid))) + if r1 == 0 || pid == 0 { + return 0, false + } + return pid, true +} + +// AuthorizeSignScriptFromParent is the --sign-script gate. The caller must be +// nvm.exe spawned by a signed proxy image, with the path on an anonymous pipe +// whose server PID is that parent. argv is not a trust source. +func AuthorizeSignScriptFromParent() error { + dataRoot, err := dataRootFromSettings() + if err != nil { + return fmt.Errorf("sign-script refused: %w", err) + } + parent := parentImagePathFn() + if !parentIsNvmProxy(parent, dataRoot) { + return fmt.Errorf("sign-script refused: parent is not proxy") + } + if err := verifyParentPublisherFn(parent); err != nil { + return fmt.Errorf("sign-script refused: %w", err) + } + pipePID, ok := stdinPipeServerPIDFn() + if !ok { + return fmt.Errorf("sign-script refused: missing proxy pipe") + } + if pipePID != parentPIDFn() { + return fmt.Errorf("sign-script refused: pipe is not from parent") + } + return nil +} + +// ReadSignScriptPath reads one launcher path from the proxy pipe. Extra +// command-line arguments are ignored by design; this is the only path source. +func ReadSignScriptPath(r io.Reader) (string, error) { + limited := io.LimitReader(r, maxSignScriptPathBytes+1) + raw, err := io.ReadAll(limited) + if err != nil { + return "", fmt.Errorf("sign-script refused: %w", err) + } + if len(raw) > maxSignScriptPathBytes { + return "", fmt.Errorf("sign-script refused: path too long") + } + scriptPath := strings.TrimSpace(string(raw)) + if scriptPath == "" { + return "", fmt.Errorf("sign-script refused: empty path") + } + if strings.ContainsAny(scriptPath, "\x00\n\r") { + return "", fmt.Errorf("sign-script refused: invalid path") + } + return scriptPath, nil +} + +// IsDelegatedLauncherPath is true for .cmd/.bat/.js files NVM proxy would +// launch: under the installs root, Yarn Classic global bin, pnpm home bin, +// or the roaming npm prefix. +func IsDelegatedLauncherPath(scriptPath string) bool { + scriptPath = filepath.Clean(strings.TrimSpace(scriptPath)) + if scriptPath == "" || scriptPath == "." { + return false + } + ext := strings.ToLower(filepath.Ext(scriptPath)) + if ext != ".cmd" && ext != ".bat" && ext != ".js" { + return false + } + installRoot := filepath.Clean(strings.TrimSpace(settings.Global().Root)) + if installRoot != "" && installRoot != "." && pathUnderDir(scriptPath, installRoot) { + return true + } + if dir := yarnClassicGlobalBinDir(); dir != "" && pathUnderDir(scriptPath, dir) { + return true + } + if dir := pnpmGlobalBinDir(); dir != "" && pathUnderDir(scriptPath, dir) { + return true + } + if dir := npmRoamingBinDir(); dir != "" && pathUnderDir(scriptPath, dir) { + return true + } + return false +} + +func parentIsNvmProxy(parent, dataRoot string) bool { + parent = filepath.Clean(strings.TrimSpace(parent)) + dataRoot = filepath.Clean(strings.TrimSpace(dataRoot)) + if parent == "" || dataRoot == "" || dataRoot == "." { + return false + } + proxy := filepath.Join(dataRoot, "proxy.exe") + if strings.EqualFold(parent, proxy) { + return true + } + shim := filepath.Join(dataRoot, ".shim") + if !pathUnderDir(parent, shim) { + return false + } + if strings.EqualFold(filepath.Base(parent), "node.exe") { + return false + } + if !strings.EqualFold(filepath.Ext(parent), ".exe") { + return false + } + return filesHaveSameContents(parent, proxy) +} + +func yarnClassicGlobalBinDir() string { + local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) + if local == "" { + return "" + } + return filepath.Join(local, "Yarn", "bin") +} + +func pnpmGlobalBinDir() string { + home := strings.TrimSpace(os.Getenv("PNPM_HOME")) + if home == "" { + return "" + } + return filepath.Join(home, "bin") +} + +func npmRoamingBinDir() string { + appdata, err := os.UserConfigDir() + if err != nil { + return "" + } + appdata = strings.TrimSpace(appdata) + if appdata == "" { + return "" + } + return filepath.Join(appdata, "npm") +} + +func filesHaveSameContents(leftPath, rightPath string) bool { + left, err := os.Open(leftPath) + if err != nil { + return false + } + defer left.Close() + right, err := os.Open(rightPath) + if err != nil { + return false + } + defer right.Close() + leftInfo, err := left.Stat() + if err != nil { + return false + } + rightInfo, err := right.Stat() + if err != nil { + return false + } + if leftInfo.Size() != rightInfo.Size() { + return false + } + bufL := make([]byte, 8192) + bufR := make([]byte, 8192) + for { + nL, errL := left.Read(bufL) + nR, errR := right.Read(bufR) + if nL != nR || !bytes.Equal(bufL[:nL], bufR[:nR]) { + return false + } + if errL == io.EOF && errR == io.EOF { + return true + } + if errL != nil && errL != io.EOF { + return false + } + if errR != nil && errR != io.EOF { + return false + } + if (errL == io.EOF) != (errR == io.EOF) { + return false + } + } +} diff --git a/verifycache/sign_script_auth_test.go b/verifycache/sign_script_auth_test.go new file mode 100644 index 0000000..8d254dc --- /dev/null +++ b/verifycache/sign_script_auth_test.go @@ -0,0 +1,158 @@ +//go:build windows + +package verifycache + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestParentIsNvmProxy(t *testing.T) { + dataRoot := t.TempDir() + proxy := filepath.Join(dataRoot, "proxy.exe") + shim := filepath.Join(dataRoot, ".shim") + if err := os.MkdirAll(shim, 0o755); err != nil { + t.Fatal(err) + } + payload := []byte("proxy-bytes") + if err := os.WriteFile(proxy, payload, 0o644); err != nil { + t.Fatal(err) + } + vue := filepath.Join(shim, "vue.exe") + if err := os.WriteFile(vue, payload, 0o644); err != nil { + t.Fatal(err) + } + node := filepath.Join(shim, "node.exe") + if err := os.WriteFile(node, []byte("node-shim"), 0o644); err != nil { + t.Fatal(err) + } + evil := filepath.Join(shim, "evil.exe") + if err := os.WriteFile(evil, []byte("not-proxy"), 0o644); err != nil { + t.Fatal(err) + } + + if !parentIsNvmProxy(proxy, dataRoot) { + t.Fatal("canonical proxy.exe must match") + } + if !parentIsNvmProxy(vue, dataRoot) { + t.Fatal(".shim vue.exe with proxy bytes must match") + } + if parentIsNvmProxy(node, dataRoot) { + t.Fatal(".shim node.exe must not match") + } + if parentIsNvmProxy(evil, dataRoot) { + t.Fatal("mismatched .shim exe must not match") + } + if parentIsNvmProxy(`C:\Windows\System32\cmd.exe`, dataRoot) { + t.Fatal("cmd.exe must not match") + } + if parentIsNvmProxy("", dataRoot) { + t.Fatal("empty parent must not match") + } +} + +func TestAuthorizeSignScriptFromParent(t *testing.T) { + dataRoot := setupVerifyCacheTestProfile(t) + proxy := filepath.Join(dataRoot, "proxy.exe") + shim := filepath.Join(dataRoot, ".shim") + if err := os.MkdirAll(shim, 0o755); err != nil { + t.Fatal(err) + } + payload := []byte("proxy-bytes") + if err := os.WriteFile(proxy, payload, 0o644); err != nil { + t.Fatal(err) + } + vue := filepath.Join(shim, "vue.exe") + if err := os.WriteFile(vue, payload, 0o644); err != nil { + t.Fatal(err) + } + + origParent := parentImagePathFn + origPID := parentPIDFn + origPipe := stdinPipeServerPIDFn + origPub := verifyParentPublisherFn + t.Cleanup(func() { + parentImagePathFn = origParent + parentPIDFn = origPID + stdinPipeServerPIDFn = origPipe + verifyParentPublisherFn = origPub + }) + + parentPIDFn = func() uint32 { return 4242 } + stdinPipeServerPIDFn = func() (uint32, bool) { return 4242, true } + verifyParentPublisherFn = func(string) error { return nil } + parentImagePathFn = func() string { return vue } + + if err := AuthorizeSignScriptFromParent(); err != nil { + t.Fatalf("signed proxy parent + matching pipe: %v", err) + } + + parentImagePathFn = func() string { return `C:\Windows\System32\cmd.exe` } + if err := AuthorizeSignScriptFromParent(); err == nil { + t.Fatal("cmd.exe parent must fail") + } + + parentImagePathFn = func() string { return vue } + stdinPipeServerPIDFn = func() (uint32, bool) { return 0, false } + if err := AuthorizeSignScriptFromParent(); err == nil { + t.Fatal("console stdin must fail") + } + + stdinPipeServerPIDFn = func() (uint32, bool) { return 99, true } + if err := AuthorizeSignScriptFromParent(); err == nil { + t.Fatal("pipe from another PID must fail") + } + + stdinPipeServerPIDFn = func() (uint32, bool) { return 4242, true } + verifyParentPublisherFn = func(string) error { return os.ErrPermission } + if err := AuthorizeSignScriptFromParent(); err == nil { + t.Fatal("publisher mismatch must fail") + } +} + +func TestReadSignScriptPath(t *testing.T) { + got, err := ReadSignScriptPath(strings.NewReader(" C:\\nvm\\installs\\v24\\vue.cmd \n")) + if err != nil { + t.Fatal(err) + } + if got != `C:\nvm\installs\v24\vue.cmd` { + t.Fatalf("got %q", got) + } + if _, err := ReadSignScriptPath(strings.NewReader("")); err == nil { + t.Fatal("empty path must fail") + } + if _, err := ReadSignScriptPath(strings.NewReader("a\nb")); err == nil { + t.Fatal("newline in path must fail") + } + if _, err := ReadSignScriptPath(bytes.NewReader(bytes.Repeat([]byte("x"), maxSignScriptPathBytes+1))); err == nil { + t.Fatal("oversize path must fail") + } +} + +func TestIsDelegatedLauncherPath(t *testing.T) { + dataRoot := setupVerifyCacheTestProfile(t) + versionDir := filepath.Join(dataRoot, "installs", "v24.0.0") + ok := filepath.Join(versionDir, "vue.cmd") + nested := filepath.Join(versionDir, "node_modules", "npm", "bin", "npm-cli.js") + if !IsDelegatedLauncherPath(ok) { + t.Fatal("version-dir cmd must pass") + } + if !IsDelegatedLauncherPath(nested) { + t.Fatal("nested js under installs must pass") + } + if IsDelegatedLauncherPath(`C:\Temp\evil.cmd`) { + t.Fatal("temp cmd must fail") + } + if IsDelegatedLauncherPath(filepath.Join(versionDir, "vue.exe")) { + t.Fatal("exe must fail") + } + + t.Setenv("LOCALAPPDATA", filepath.Join(dataRoot, "Local")) + yarn := filepath.Join(dataRoot, "Local", "Yarn", "bin", "vue.cmd") + if !IsDelegatedLauncherPath(yarn) { + t.Fatal("Yarn Classic bin must pass") + } +}