feat: Add opt-in support for proxy CA cert installation (#318)

* feat: Add opt-in support for proxy CA cert installation

* fix: Code review fixes

* fix: Code review fixes

* fix: Code review fixes

* fix: Code review fixes

* fix: Code review fixes

* docs: Add limitation for MacOS MDM script
This commit is contained in:
Abhisek Datta
2026-06-03 23:15:02 +05:30
committed by GitHub
parent c3f3920d2e
commit 4f0db15ede
31 changed files with 1806 additions and 77 deletions
+69
View File
@@ -0,0 +1,69 @@
// Package truststore installs and removes PMG's MITM CA certificate in the
// operating-system trust store. Per-OS behavior lives in build-tagged files
// (truststore_darwin.go, truststore_linux.go, truststore_windows.go) mirroring
// the sandbox/platform package; this file holds the OS-agnostic surface.
package truststore
import (
"errors"
"os"
"os/exec"
"runtime"
"github.com/safedep/dry/log"
)
// Scope selects which trust store to operate on.
type Scope int
const (
// ScopeUser is the per-user trust store (no elevation). Unsupported on Linux.
ScopeUser Scope = iota
// ScopeSystem is the machine-wide trust store (PMG elevates the write).
ScopeSystem
)
func (s Scope) String() string {
if s == ScopeSystem {
return "system"
}
return "user"
}
// ErrUserScopeUnsupported is returned by Install/Uninstall when per-user trust
// is not a platform concept (Linux). Callers treat it as informational.
var ErrUserScopeUnsupported = errors.New("user-scope trust store is not supported on this platform")
// commandRunner runs an external trust-store tool. Overridable in tests.
var commandRunner = func(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).CombinedOutput()
}
var euid = os.Geteuid
// runElevated prefixes sudo on Unix (unless already root) so only the privileged
// system-store write is elevated. Windows has no sudo and relies on an elevated
// prompt, so the command runs as-is.
func runElevated(name string, args ...string) ([]byte, error) {
if runtime.GOOS != "windows" && euid() != 0 {
log.Infof("Elevating via sudo to modify the system trust store")
return commandRunner("sudo", append([]string{name}, args...)...)
}
return commandRunner(name, args...)
}
// Install adds certPEM (a PEM-encoded CA certificate) to the OS trust store.
func Install(certPEM []byte, scope Scope) error { return installPlatform(certPEM, scope) }
// Uninstall removes the certificate matched by commonName from the OS trust store.
func Uninstall(commonName string, scope Scope) error { return uninstallPlatform(commonName, scope) }
// Status reports whether the certificate matched by commonName is trusted in the
// user and/or system store. It is best-effort; callers may treat errors as not trusted.
func Status(commonName string) (user bool, system bool, err error) {
return statusPlatform(commonName)
}
// UserScopeSupported reports whether the platform has a per-user trust store
// (false on Linux). Consumers use it to interpret "not trusted" correctly.
func UserScopeSupported() bool { return userScopeSupportedPlatform() }
+35
View File
@@ -0,0 +1,35 @@
//go:build darwin || linux || windows
// +build darwin linux windows
package truststore
import (
"fmt"
"os"
)
// writeTempCert writes certPEM to a temp file and returns its path and a cleanup
// func. The OS trust tools (macOS security, Windows certutil, Linux install)
// take a file path argument, so the cert is staged in a user-owned temp file
// before the elevated store write.
func writeTempCert(certPEM []byte) (string, func(), error) {
noop := func() {}
f, err := os.CreateTemp("", "pmg-ca-*.pem")
if err != nil {
return "", noop, fmt.Errorf("failed to create temp cert file: %w", err)
}
if _, err := f.Write(certPEM); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", noop, fmt.Errorf("failed to write temp cert file: %w", err)
}
if err := f.Close(); err != nil {
_ = os.Remove(f.Name())
return "", noop, fmt.Errorf("failed to close temp cert file: %w", err)
}
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
}
+100
View File
@@ -0,0 +1,100 @@
//go:build darwin
// +build darwin
package truststore
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/safedep/dry/log"
)
const systemKeychainPath = "/Library/Keychains/System.keychain"
func userScopeSupportedPlatform() bool { return true }
func loginKeychainPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to resolve home dir: %w", err)
}
return filepath.Join(home, "Library", "Keychains", "login.keychain-db"), nil
}
func installPlatform(certPEM []byte, scope Scope) error {
path, cleanup, err := writeTempCert(certPEM)
if err != nil {
return err
}
defer cleanup()
var args []string
runner := commandRunner
if scope == ScopeSystem {
args = []string{"add-trusted-cert", "-d", "-r", "trustRoot", "-k", systemKeychainPath, path}
runner = runElevated
} else {
kc, err := loginKeychainPath()
if err != nil {
return err
}
args = []string{"add-trusted-cert", "-r", "trustRoot", "-k", kc, path}
}
if out, err := runner("security", args...); err != nil {
return fmt.Errorf("security add-trusted-cert failed: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func uninstallPlatform(commonName string, scope Scope) error {
// security delete-certificate removes a single match; loop until none remain
// so a --force rotation that left two same-CN certs is fully cleaned.
for i := 0; i < 16; i++ {
args := []string{"delete-certificate", "-c", commonName}
runner := commandRunner
if scope == ScopeSystem {
// -t clears trust settings; the keychain is a positional argument for
// delete-certificate (it has no -k flag, unlike add-trusted-cert).
args = append(args, "-t", systemKeychainPath)
runner = runElevated
}
out, err := runner("security", args...)
if err == nil {
continue
}
// security reports no remaining match with "Unable to delete certificate
// matching ..." (older/other paths use "Could not find"). Either marks the
// terminal "nothing left to delete" case, so the loop ends successfully.
msg := strings.TrimSpace(string(out))
if strings.Contains(msg, "Unable to delete certificate matching") || strings.Contains(msg, "Could not find") {
return nil
}
return fmt.Errorf("security delete-certificate failed: %w: %s", err, msg)
}
log.Warnf("security delete-certificate did not converge after 16 iterations; trust store cleanup may be incomplete")
return nil
}
func statusPlatform(commonName string) (bool, bool, error) {
lkc, err := loginKeychainPath()
if err != nil {
return false, certInKeychain(commonName, systemKeychainPath), nil
}
return certInKeychain(commonName, lkc), certInKeychain(commonName, systemKeychainPath), nil
}
func certInKeychain(commonName, keychain string) bool {
args := []string{"find-certificate", "-c", commonName}
if keychain != "" {
args = append(args, keychain)
}
_, err := commandRunner("security", args...)
return err == nil
}
+89
View File
@@ -0,0 +1,89 @@
//go:build darwin
// +build darwin
package truststore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDarwinInstallUserArgs(t *testing.T) {
var gotName string
var gotArgs []string
orig := commandRunner
commandRunner = func(name string, args ...string) ([]byte, error) {
gotName, gotArgs = name, args
return nil, nil
}
t.Cleanup(func() { commandRunner = orig })
require.NoError(t, Install([]byte("PEM"), ScopeUser))
assert.Equal(t, "security", gotName)
assert.Equal(t, "add-trusted-cert", gotArgs[0])
assert.NotContains(t, gotArgs, "-d") // user scope: no -d
assert.Contains(t, gotArgs, "trustRoot")
}
func TestDarwinInstallSystemArgs(t *testing.T) {
var gotArgs []string
orig := commandRunner
commandRunner = func(_ string, args ...string) ([]byte, error) {
gotArgs = args
return nil, nil
}
t.Cleanup(func() { commandRunner = orig })
require.NoError(t, Install([]byte("PEM"), ScopeSystem))
assert.Contains(t, gotArgs, "-d") // system scope: -d
assert.Contains(t, gotArgs, systemKeychainPath)
}
func TestDarwinUninstallStopsWhenNotFound(t *testing.T) {
calls := 0
orig := commandRunner
commandRunner = func(_ string, _ ...string) ([]byte, error) {
calls++
return []byte(`Unable to delete certificate matching "Test CA"`), assert.AnError
}
t.Cleanup(func() { commandRunner = orig })
require.NoError(t, Uninstall("Test CA", ScopeUser))
assert.Equal(t, 1, calls)
}
func TestDarwinUninstallStopsAfterDeletingMatches(t *testing.T) {
calls := 0
orig := commandRunner
commandRunner = func(_ string, _ ...string) ([]byte, error) {
calls++
if calls == 1 {
return nil, nil // first match deleted
}
return []byte(`Unable to delete certificate matching "Test CA"`), assert.AnError
}
t.Cleanup(func() { commandRunner = orig })
require.NoError(t, Uninstall("Test CA", ScopeUser))
assert.Equal(t, 2, calls) // delete one, then terminal not-found
}
func TestDarwinUninstallSystemTargetsSystemKeychain(t *testing.T) {
var gotArgs []string
orig := commandRunner
commandRunner = func(_ string, args ...string) ([]byte, error) {
gotArgs = args
return []byte("Could not find"), assert.AnError
}
t.Cleanup(func() { commandRunner = orig })
require.NoError(t, Uninstall("Test CA", ScopeSystem))
assert.Contains(t, gotArgs, systemKeychainPath)
assert.Contains(t, gotArgs, "-t")
}
func TestDarwinUserScopeSupported(t *testing.T) {
assert.True(t, UserScopeSupported())
}
+114
View File
@@ -0,0 +1,114 @@
//go:build linux
// +build linux
package truststore
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
func userScopeSupportedPlatform() bool { return false }
type linuxTrustTool struct {
anchorDir string
updateCmd string
anchorName string
}
// lookPath is overridable in tests.
var lookPath = exec.LookPath
// detectTrustTool is overridable in tests so anchorDir points to a temp dir.
var detectTrustTool = detectLinuxTrustTool
func detectLinuxTrustTool() (linuxTrustTool, error) {
if _, err := lookPath("update-ca-certificates"); err == nil {
return linuxTrustTool{
anchorDir: "/usr/local/share/ca-certificates",
updateCmd: "update-ca-certificates",
anchorName: "pmg-proxy-ca.crt",
}, nil
}
if _, err := lookPath("update-ca-trust"); err == nil {
return linuxTrustTool{
anchorDir: "/etc/pki/ca-trust/source/anchors",
updateCmd: "update-ca-trust",
anchorName: "pmg-proxy-ca.crt",
}, nil
}
return linuxTrustTool{}, fmt.Errorf("no supported trust tool (update-ca-certificates / update-ca-trust) found")
}
func installPlatform(certPEM []byte, scope Scope) error {
if scope == ScopeUser {
return ErrUserScopeUnsupported
}
tool, err := detectTrustTool()
if err != nil {
return err
}
dest := filepath.Join(tool.anchorDir, tool.anchorName)
tmp, cleanup, err := writeTempCert(certPEM)
if err != nil {
return err
}
defer cleanup()
// install(1) sets an explicit 0644 mode and creates the anchor dir if missing.
if out, err := runElevated("install", "-m", "0644", "-D", tmp, dest); err != nil {
return fmt.Errorf("failed to install CA anchor to %s: %w: %s", dest, err, strings.TrimSpace(string(out)))
}
if out, err := runElevated(tool.updateCmd); err != nil {
return fmt.Errorf("%s failed: %w: %s", tool.updateCmd, err, strings.TrimSpace(string(out)))
}
return nil
}
func uninstallPlatform(_ string, scope Scope) error {
if scope == ScopeUser {
return ErrUserScopeUnsupported
}
tool, err := detectTrustTool()
if err != nil {
return err
}
dest := filepath.Join(tool.anchorDir, tool.anchorName)
if _, err := os.Stat(dest); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to stat CA anchor %s: %w", dest, err)
}
if out, err := runElevated("rm", "-f", dest); err != nil {
return fmt.Errorf("failed to remove CA anchor %s: %w: %s", dest, err, strings.TrimSpace(string(out)))
}
if out, err := runElevated(tool.updateCmd); err != nil {
return fmt.Errorf("%s failed: %w: %s", tool.updateCmd, err, strings.TrimSpace(string(out)))
}
return nil
}
func statusPlatform(_ string) (bool, bool, error) {
tool, err := detectTrustTool()
if err != nil {
return false, false, nil
}
dest := filepath.Join(tool.anchorDir, tool.anchorName)
if _, err := os.Stat(dest); err == nil {
return false, true, nil // user scope is never trusted on Linux
}
return false, false, nil
}
+85
View File
@@ -0,0 +1,85 @@
//go:build linux
// +build linux
package truststore
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLinuxUserScopeUnsupported(t *testing.T) {
assert.False(t, UserScopeSupported())
assert.ErrorIs(t, Install([]byte("PEM"), ScopeUser), ErrUserScopeUnsupported)
assert.ErrorIs(t, Uninstall("Test CA", ScopeUser), ErrUserScopeUnsupported)
}
func TestLinuxSystemInstallStagesAnchorAndUpdates(t *testing.T) {
dir := t.TempDir()
dest := filepath.Join(dir, "pmg-proxy-ca.crt")
origDetect := detectTrustTool
detectTrustTool = func() (linuxTrustTool, error) {
return linuxTrustTool{anchorDir: dir, updateCmd: "update-ca-certificates", anchorName: "pmg-proxy-ca.crt"}, nil
}
origEUID := euid
euid = func() int { return 0 } // run as root so no sudo prefix is added
var calls [][]string
origRunner := commandRunner
commandRunner = func(name string, args ...string) ([]byte, error) {
calls = append(calls, append([]string{name}, args...))
return nil, nil
}
t.Cleanup(func() { detectTrustTool = origDetect; euid = origEUID; commandRunner = origRunner })
require.NoError(t, Install([]byte("PEM-BYTES"), ScopeSystem))
require.Len(t, calls, 2)
assert.Equal(t, "install", calls[0][0])
assert.Contains(t, calls[0], dest)
assert.Equal(t, "update-ca-certificates", calls[1][0])
}
func TestLinuxSystemInstallElevatesWhenNotRoot(t *testing.T) {
dir := t.TempDir()
origDetect := detectTrustTool
detectTrustTool = func() (linuxTrustTool, error) {
return linuxTrustTool{anchorDir: dir, updateCmd: "update-ca-certificates", anchorName: "pmg-proxy-ca.crt"}, nil
}
origEUID := euid
euid = func() int { return 1000 }
var firstName string
origRunner := commandRunner
commandRunner = func(name string, _ ...string) ([]byte, error) {
if firstName == "" {
firstName = name
}
return nil, nil
}
t.Cleanup(func() { detectTrustTool = origDetect; euid = origEUID; commandRunner = origRunner })
require.NoError(t, Install([]byte("PEM"), ScopeSystem))
assert.Equal(t, "sudo", firstName)
}
func TestLinuxStatusReflectsAnchorFile(t *testing.T) {
dir := t.TempDir()
origDetect := detectTrustTool
detectTrustTool = func() (linuxTrustTool, error) {
return linuxTrustTool{anchorDir: dir, updateCmd: "update-ca-certificates", anchorName: "pmg-proxy-ca.crt"}, nil
}
t.Cleanup(func() { detectTrustTool = origDetect })
_, system, err := Status("Test CA")
require.NoError(t, err)
assert.False(t, system)
require.NoError(t, os.WriteFile(filepath.Join(dir, "pmg-proxy-ca.crt"), []byte("x"), 0o644))
_, system, err = Status("Test CA")
require.NoError(t, err)
assert.True(t, system)
}
+24
View File
@@ -0,0 +1,24 @@
//go:build !darwin && !linux && !windows
// +build !darwin,!linux,!windows
package truststore
import (
"errors"
"github.com/safedep/dry/usefulerror"
"github.com/safedep/pmg/errcodes"
)
func installPlatform(_ []byte, _ Scope) error { return unsupportedPlatformError() }
func uninstallPlatform(_ string, _ Scope) error { return unsupportedPlatformError() }
func statusPlatform(_ string) (bool, bool, error) { return false, false, unsupportedPlatformError() }
func userScopeSupportedPlatform() bool { return false }
func unsupportedPlatformError() error {
return usefulerror.NewUsefulError().
WithCode(errcodes.UnsupportedPlatform).
WithHumanError("trust store operations are not supported on this platform").
WithHelp("Use PMG's default env-var trust injection, or install the CA manually").
Wrap(errors.New("unsupported platform"))
}
+63
View File
@@ -0,0 +1,63 @@
//go:build windows
// +build windows
package truststore
import (
"fmt"
"strings"
)
func userScopeSupportedPlatform() bool { return true }
func installPlatform(certPEM []byte, scope Scope) error {
path, cleanup, err := writeTempCert(certPEM)
if err != nil {
return err
}
defer cleanup()
args := []string{}
if scope == ScopeUser {
args = append(args, "-user")
}
args = append(args, "-addstore", "Root", path)
if out, err := commandRunner("certutil", args...); err != nil {
return fmt.Errorf("certutil -addstore failed: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
func uninstallPlatform(commonName string, scope Scope) error {
args := []string{}
if scope == ScopeUser {
args = append(args, "-user")
}
args = append(args, "-delstore", "Root", commonName)
if out, err := commandRunner("certutil", args...); err != nil {
msg := strings.TrimSpace(string(out))
lower := strings.ToLower(msg)
if strings.Contains(lower, "cannot find") || strings.Contains(msg, "0x80092004") {
return nil
}
return fmt.Errorf("certutil -delstore failed: %w: %s", err, msg)
}
return nil
}
func statusPlatform(commonName string) (bool, bool, error) {
return certInWindowsStore(commonName, true), certInWindowsStore(commonName, false), nil
}
func certInWindowsStore(commonName string, userScope bool) bool {
args := []string{}
if userScope {
args = append(args, "-user")
}
args = append(args, "-store", "Root", commonName)
_, err := commandRunner("certutil", args...)
return err == nil
}
+51
View File
@@ -0,0 +1,51 @@
//go:build windows
// +build windows
package truststore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWindowsInstallUserAddsUserFlag(t *testing.T) {
var gotName string
var gotArgs []string
orig := commandRunner
commandRunner = func(name string, args ...string) ([]byte, error) {
gotName, gotArgs = name, args
return nil, nil
}
t.Cleanup(func() { commandRunner = orig })
require.NoError(t, Install([]byte("PEM"), ScopeUser))
assert.Equal(t, "certutil", gotName)
assert.Equal(t, "-user", gotArgs[0])
assert.Contains(t, gotArgs, "-addstore")
assert.Contains(t, gotArgs, "Root")
}
func TestWindowsInstallSystemHasNoUserFlag(t *testing.T) {
var gotArgs []string
orig := commandRunner
commandRunner = func(_ string, args ...string) ([]byte, error) {
gotArgs = args
return nil, nil
}
t.Cleanup(func() { commandRunner = orig })
require.NoError(t, Install([]byte("PEM"), ScopeSystem))
assert.NotContains(t, gotArgs, "-user")
assert.Equal(t, "-addstore", gotArgs[0])
}
func TestWindowsUninstallNotFoundIsOK(t *testing.T) {
orig := commandRunner
commandRunner = func(_ string, _ ...string) ([]byte, error) {
return []byte("Cannot find the requested object"), assert.AnError
}
t.Cleanup(func() { commandRunner = orig })
require.NoError(t, Uninstall("Test CA", ScopeUser))
}