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
+1
View File
@@ -43,3 +43,4 @@ go test ./config/ -v -count=1 # Run specific package tests
- When soft failure is acceptable, log with `log.Warnf` from `github.com/safedep/dry/log`
- Do not use `_ = someFunc()` to discard errors silently
- For CLI/user-facing errors, prefer `usefulerror` with a specific code and actionable help so `ui.ErrorExit` does not classify expected failures as `Unknown`
- Check the error from `fmt.Fprintf`/`fmt.Fprintln`/`fmt.Fprint` (the `errcheck` linter flags these). Return it up the stack: `if _, err := fmt.Fprintf(out, ...); err != nil { return err }`
+7 -1
View File
@@ -41,4 +41,10 @@ clean:
$(RM_RF)
test:
go test ./...
$(GO) test ./...
fmt:
$(GO) fmt ./...
lint:
golangci-lint run
+13
View File
@@ -105,6 +105,18 @@ Validate your installation and verify protection is working:
pmg setup doctor
```
> **Optional:** PMG inspects HTTPS traffic with an on-the-fly CA that it injects into package
> managers per run. To persist a single CA across runs and trust it in your OS trust store
> (needed for tools that ignore CA environment variables, such as Go on macOS and Windows),
> install it once:
>
> ```bash
> pmg setup cert install # user scope, no sudo
> pmg setup cert status # check trust state and expiry
> ```
>
> See [Certificate Authority](docs/cert.md) for scopes, rotation, and removal.
### 3. Use
See PMG blocking threats.
@@ -248,6 +260,7 @@ PMG builds are reproducible and signed.
- [Trusted Packages Configuration](docs/trusted-packages.md)
- [Dependency Cooldown](docs/dependency-cooldown.md)
- [Proxy Mode Architecture](docs/proxy-mode.md)
- [Certificate Authority](docs/cert.md)
- [Sandboxing](docs/sandbox.md)
## Support
+326
View File
@@ -0,0 +1,326 @@
package setup
import (
"errors"
"fmt"
"io"
"os"
"strconv"
"time"
"github.com/safedep/dry/log"
"github.com/safedep/dry/usefulerror"
"github.com/safedep/pmg/config"
"github.com/safedep/pmg/errcodes"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/truststore"
"github.com/spf13/cobra"
)
// trustStore is the testability seam; defaultTrustStore delegates to the package.
type trustStore interface {
Install(certPEM []byte, scope truststore.Scope) error
Uninstall(commonName string, scope truststore.Scope) error
Status(commonName string) (user, system bool, err error)
UserScopeSupported() bool
}
type defaultTrustStore struct{}
func (defaultTrustStore) Install(p []byte, s truststore.Scope) error { return truststore.Install(p, s) }
func (defaultTrustStore) Uninstall(cn string, s truststore.Scope) error {
return truststore.Uninstall(cn, s)
}
func (defaultTrustStore) Status(cn string) (bool, bool, error) { return truststore.Status(cn) }
func (defaultTrustStore) UserScopeSupported() bool { return truststore.UserScopeSupported() }
type certCommandError struct{ usefulerror.UsefulError }
func (e *certCommandError) ExitCode() int { return 1 }
func newCertCommandError(code, msg, help string, cause error) *certCommandError {
return &certCommandError{
UsefulError: usefulerror.NewUsefulError().
WithCode(code).
WithHumanError(msg).
WithHelp(help).
Wrap(cause),
}
}
// NewCertCommand returns the `pmg setup cert` command tree.
func NewCertCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cert",
Short: "Manage PMG's MITM CA certificate and OS trust",
Long: "Generate, persist, and trust PMG's MITM CA so package managers and " +
"native tools (including Go on macOS/Windows) trust HTTPS interception.",
RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() },
}
cmd.AddCommand(newCertInstallCommand())
cmd.AddCommand(newCertUninstallCommand())
cmd.AddCommand(newCertStatusCommand())
return cmd
}
func scopeFromFlag(system bool) truststore.Scope {
if system {
return truststore.ScopeSystem
}
return truststore.ScopeUser
}
func newCertInstallCommand() *cobra.Command {
var system, force bool
cmd := &cobra.Command{
Use: "install",
Short: "Generate, persist, and trust PMG's MITM CA",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if err := errIfRunningUnderSudo(); err != nil {
return err
}
return runCertInstall(config.Get().ConfigDir(), scopeFromFlag(system), force, defaultTrustStore{}, os.Stdout)
},
}
cmd.Flags().BoolVar(&system, "system", false, "Install into the system (all-users) trust store (PMG prompts for elevation; on Windows run from an elevated prompt)")
cmd.Flags().BoolVar(&force, "force", false, "Regenerate and re-trust the CA even if one already exists")
return cmd
}
func newCertUninstallCommand() *cobra.Command {
var system, purge bool
cmd := &cobra.Command{
Use: "uninstall",
Short: "Remove PMG's MITM CA from the OS trust store",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if err := errIfRunningUnderSudo(); err != nil {
return err
}
return runCertUninstall(config.Get().ConfigDir(), scopeFromFlag(system), purge, defaultTrustStore{}, os.Stdout)
},
}
cmd.Flags().BoolVar(&system, "system", false, "Remove from the system (all-users) trust store (PMG prompts for elevation; on Windows run from an elevated prompt)")
cmd.Flags().BoolVar(&purge, "purge", false, "Also delete the on-disk CA keypair")
return cmd
}
func newCertStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show PMG MITM CA presence, trust scope, and expiry",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
if err := errIfRunningUnderSudo(); err != nil {
return err
}
return runCertStatus(config.Get().ConfigDir(), defaultTrustStore{}, os.Stdout)
},
}
}
func runCertInstall(dir string, scope truststore.Scope, force bool, store trustStore, out io.Writer) error {
caCert, loadErr := certmanager.LoadCA(dir)
exists := loadErr == nil
// A failed load with files still on disk means a corrupt or partial CA (e.g.
// the cert is present/trusted but ca-key.pem is missing or unparseable).
// Treat it like a rotation so the old trusted root is cleaned up rather than
// left behind alongside a freshly generated one. (A missing key surfaces as
// os.ErrNotExist from LoadCA, so checking on-disk remnants is what tells a
// partial state apart from a truly fresh install.)
diskState, inspectErr := certmanager.InspectCA(dir)
if inspectErr != nil {
log.Debugf("inspecting on-disk CA during install: %v", inspectErr)
}
corrupted := loadErr != nil && (diskState.KeyPresent || diskState.CertPresent)
expired := exists && caCert.IsExpired(time.Hour)
rotate := force || expired || corrupted
if corrupted {
log.Debugf("replacing unreadable persisted CA: %v", loadErr)
}
if exists && !rotate {
user, system, _ := store.Status(certmanager.CACommonName)
if (scope == truststore.ScopeUser && user) || (scope == truststore.ScopeSystem && system) {
if _, err := fmt.Fprintf(out, "%s CA already installed and trusted (%s scope)\n", ui.Colors.Green("✓"), scope.String()); err != nil {
return err
}
return nil
}
}
if !exists || rotate {
if rotate && (exists || corrupted) {
msg := "Rotating existing CA"
if corrupted {
msg = "Persisted CA is incomplete or unreadable; replacing it"
}
if _, err := fmt.Fprintf(out, "%s %s\n", ui.Colors.Dim(""), msg); err != nil {
return err
}
if err := store.Uninstall(certmanager.CACommonName, scope); err != nil && !errors.Is(err, truststore.ErrUserScopeUnsupported) {
return newCertCommandError(errcodes.CertTrustStore, "failed to remove old CA before rotation", trustHelp(scope), err)
}
// Best-effort cleanup of the opposite scope so a scope change during
// rotation leaves no stale same-CN cert behind. Non-fatal: the other
// scope may need privileges we do not hold.
if err := store.Uninstall(certmanager.CACommonName, otherScope(scope)); err != nil && !errors.Is(err, truststore.ErrUserScopeUnsupported) {
log.Debugf("best-effort cleanup of %s-scope CA during rotation failed: %v", otherScope(scope), err)
}
}
generated, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
if err != nil {
return newCertCommandError(errcodes.CertGeneration, "failed to generate CA certificate",
"Check available entropy and try again", err)
}
if err := certmanager.SaveCA(dir, generated); err != nil {
return newCertCommandError(errcodes.CertPersistence, "failed to persist CA keypair",
fmt.Sprintf("Check write permissions for %s", dir), err)
}
caCert = generated
if _, err := fmt.Fprintf(out, "%s CA keypair written to %s\n", ui.Colors.Green("✓"), certmanager.CACertPath(dir)); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(out, "%s Reusing existing CA keypair at %s\n", ui.Colors.Dim(""), certmanager.CACertPath(dir)); err != nil {
return err
}
}
if _, err := fmt.Fprintf(out, "%s Installing an OS-trusted MITM CA (%s scope). This lets PMG inspect HTTPS package traffic.\n",
ui.Colors.Yellow("⚠"), scope.String()); err != nil {
return err
}
if err := store.Install(caCert.Certificate, scope); err != nil {
if errors.Is(err, truststore.ErrUserScopeUnsupported) {
// Linux has no per-user trust store; treat as a friendly no-op.
if _, err := fmt.Fprintf(out, "%s %s\n", ui.Colors.Dim(""),
"This platform has no per-user trust store. The CA keypair is persisted. "+
"Re-run with --system for machine-wide trust (PMG prompts for elevation)."); err != nil {
return err
}
return nil
}
return newCertCommandError(errcodes.CertTrustStore, "failed to install CA into trust store", trustHelp(scope), err)
}
if _, err := fmt.Fprintf(out, "%s CA installed and trusted (%s scope)\n", ui.Colors.Green("✓"), scope.String()); err != nil {
return err
}
return nil
}
func runCertUninstall(dir string, scope truststore.Scope, purge bool, store trustStore, out io.Writer) error {
switch err := store.Uninstall(certmanager.CACommonName, scope); {
case errors.Is(err, truststore.ErrUserScopeUnsupported):
if _, e := fmt.Fprintf(out, "%s This platform has no per-user trust store; nothing to remove. Use --system for machine-wide.\n", ui.Colors.Dim("")); e != nil {
return e
}
case err != nil:
return newCertCommandError(errcodes.CertTrustStore, "failed to remove CA from trust store", trustHelp(scope), err)
default:
if _, e := fmt.Fprintf(out, "%s CA removed from %s trust store\n", ui.Colors.Green("✓"), scope.String()); e != nil {
return e
}
}
if purge {
removed := false
for _, p := range []string{certmanager.CACertPath(dir), certmanager.CAKeyPath(dir)} {
if err := os.Remove(p); err != nil {
if os.IsNotExist(err) {
continue
}
return newCertCommandError(errcodes.CertPersistence, "failed to delete CA file",
"Check filesystem permissions", err)
}
removed = true
}
if removed {
if _, err := fmt.Fprintf(out, "%s CA keypair deleted from disk\n", ui.Colors.Green("✓")); err != nil {
return err
}
}
}
return nil
}
func runCertStatus(dir string, store trustStore, out io.Writer) error {
st, err := certmanager.InspectCA(dir)
if err != nil {
return newCertCommandError(errcodes.CertPersistence, "failed to inspect CA",
"The CA file may be corrupt; re-run `pmg setup cert install`", err)
}
user, system, _ := store.Status(certmanager.CACommonName)
st.UserTrusted, st.SystemTrusted = user, system
entries := map[string]string{
"Key Present": strconv.FormatBool(st.KeyPresent),
"Cert Present": strconv.FormatBool(st.CertPresent),
"Trusted (user)": strconv.FormatBool(st.UserTrusted),
"Trusted (system)": strconv.FormatBool(st.SystemTrusted),
}
if st.CertPresent {
entries["Expires"] = st.NotAfter.Format(time.RFC3339)
entries["Fingerprint"] = st.Fingerprint
}
ui.PrintInfoSection("PMG CA Certificate", entries)
drift, reason := st.Drift()
switch {
case drift:
if _, err := fmt.Fprintf(out, "\n%s %s\n", ui.Colors.Red("drift:"), reason); err != nil {
return err
}
case st.ExpiringSoon:
if _, err := fmt.Fprintf(out, "\n%s CA expires within 30 days (%s). Run `pmg setup cert install --force` to rotate.\n",
ui.Colors.Yellow("⚠"), st.NotAfter.Format("2006-01-02")); err != nil {
return err
}
case st.KeyPresent && st.CertPresent && !st.Trusted():
// "not trusted" is expected on Linux (Go honors SSL_CERT_FILE) but a
// real problem where a per-user store exists (macOS/Windows).
if store.UserScopeSupported() {
if _, err := fmt.Fprintf(out, "\n%s CA on disk but not trusted in the OS store. Run `pmg setup cert install`.\n", ui.Colors.Yellow("⚠")); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(out, "\n%s CA on disk; not in OS store. Expected on Linux (Go honors SSL_CERT_FILE); use --system for store trust.\n", ui.Colors.Dim("")); err != nil {
return err
}
}
}
return nil
}
func otherScope(s truststore.Scope) truststore.Scope {
if s == truststore.ScopeSystem {
return truststore.ScopeUser
}
return truststore.ScopeSystem
}
var geteuid = os.Geteuid
func errIfRunningUnderSudo() error {
if geteuid() == 0 && os.Getenv("SUDO_USER") != "" {
return newCertCommandError(errcodes.PermissionDenied,
"run `pmg setup cert` as your normal user, not with sudo",
"PMG generates a per-user CA keypair and elevates only the system trust step. Re-run without sudo (use --system for machine-wide trust).",
errors.New("invoked under sudo"))
}
return nil
}
func trustHelp(scope truststore.Scope) string {
if scope == truststore.ScopeSystem {
return "Approve the elevation prompt when asked (macOS/Linux), or run from an elevated prompt (Windows)"
}
return "Approve the keychain prompt if shown; on Linux use --system (no per-user store)"
}
+150
View File
@@ -0,0 +1,150 @@
package setup
import (
"bytes"
"os"
"testing"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/truststore"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type fakeStore struct {
installed bool
installErr error
uninstalled bool
user, system bool
userSupported bool
}
func (f *fakeStore) Install(_ []byte, _ truststore.Scope) error {
if f.installErr != nil {
return f.installErr
}
f.installed = true
return nil
}
func (f *fakeStore) Uninstall(_ string, _ truststore.Scope) error { f.uninstalled = true; return nil }
func (f *fakeStore) Status(_ string) (bool, bool, error) { return f.user, f.system, nil }
func (f *fakeStore) UserScopeSupported() bool { return f.userSupported }
func TestCertInstallGeneratesSavesAndInstalls(t *testing.T) {
dir := t.TempDir()
store := &fakeStore{userSupported: true}
var out bytes.Buffer
require.NoError(t, runCertInstall(dir, truststore.ScopeUser, false, store, &out))
assert.True(t, store.installed)
_, err := certmanager.LoadCA(dir)
require.NoError(t, err) // keypair persisted
}
func TestCertInstallIdempotentWhenAlreadyTrusted(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
store := &fakeStore{user: true, userSupported: true}
var out bytes.Buffer
require.NoError(t, runCertInstall(dir, truststore.ScopeUser, false, store, &out))
assert.False(t, store.installed) // already trusted → no re-install
assert.Contains(t, out.String(), "already installed")
}
func TestCertInstallLinuxUserNoopIsFriendly(t *testing.T) {
dir := t.TempDir()
store := &fakeStore{installErr: truststore.ErrUserScopeUnsupported, userSupported: false}
var out bytes.Buffer
require.NoError(t, runCertInstall(dir, truststore.ScopeUser, false, store, &out))
assert.Contains(t, out.String(), "--system")
}
func TestCertInstallForceRotates(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
store := &fakeStore{userSupported: true}
var out bytes.Buffer
require.NoError(t, runCertInstall(dir, truststore.ScopeUser, true, store, &out))
assert.True(t, store.uninstalled) // rotation uninstalls old first
assert.True(t, store.installed)
}
func TestErrIfRunningUnderSudo(t *testing.T) {
orig := geteuid
t.Cleanup(func() { geteuid = orig })
// root + SUDO_USER set → refused (sudo from a normal user).
geteuid = func() int { return 0 }
t.Setenv("SUDO_USER", "alice")
assert.Error(t, errIfRunningUnderSudo())
// root without SUDO_USER → genuine root, allowed.
t.Setenv("SUDO_USER", "")
assert.NoError(t, errIfRunningUnderSudo())
// non-root → allowed even if SUDO_USER somehow set.
geteuid = func() int { return 1000 }
t.Setenv("SUDO_USER", "alice")
assert.NoError(t, errIfRunningUnderSudo())
}
func TestCertInstallReplacesCorruptedCA(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
// Simulate a partial/corrupt state: cert still on disk (and possibly trusted)
// but the private key is gone. Install must clean up the old root, not stack a
// second one alongside a freshly generated keypair.
require.NoError(t, os.Remove(certmanager.CAKeyPath(dir)))
store := &fakeStore{userSupported: true}
var out bytes.Buffer
require.NoError(t, runCertInstall(dir, truststore.ScopeUser, false, store, &out))
assert.True(t, store.uninstalled, "old trusted root should be cleaned up")
assert.True(t, store.installed)
_, err = certmanager.LoadCA(dir)
require.NoError(t, err) // a complete keypair is regenerated
}
func TestCertUninstallPurgeDeletesFiles(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
store := &fakeStore{}
var out bytes.Buffer
require.NoError(t, runCertUninstall(dir, truststore.ScopeUser, true, store, &out))
assert.True(t, store.uninstalled)
_, err = certmanager.LoadCA(dir)
assert.Error(t, err) // files gone
assert.NoFileExists(t, certmanager.CAKeyPath(dir))
}
func TestCertStatusReportsDrift(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
// Simulate drift: remove the key, keep the cert.
require.NoError(t, os.Remove(certmanager.CAKeyPath(dir)))
store := &fakeStore{}
var out bytes.Buffer
require.NoError(t, runCertStatus(dir, store, &out))
assert.Contains(t, out.String(), "drift")
}
+47
View File
@@ -12,7 +12,9 @@ import (
"github.com/safedep/pmg/internal/shim"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/internal/version"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/sandbox/platform"
"github.com/safedep/pmg/truststore"
"github.com/spf13/cobra"
)
@@ -28,6 +30,7 @@ const (
checkSandbox = "sandbox"
checkProtectionNpm = "protection-npm"
checkProtectionPip = "protection-pip"
checkCA = "ca-cert"
)
func NewDoctorCommand() *cobra.Command {
@@ -260,6 +263,14 @@ func runCoreChecks(cfg *config.RuntimeConfig) []doctor.CheckResult {
}
},
},
{
Name: checkCA,
Category: "Security",
Run: func() doctor.CheckResult {
user, system, _ := truststore.Status(certmanager.CACommonName)
return evaluateCACheck(cfg.ConfigDir(), user, system, truststore.UserScopeSupported())
},
},
}
return doctor.RunChecks(checks)
}
@@ -317,6 +328,7 @@ var checkDisplayNames = map[string]string{
checkSandbox: "Sandbox",
checkProtectionNpm: "npm protection",
checkProtectionPip: "pip protection",
checkCA: "MITM CA",
}
var checkFixes = map[string]string{
@@ -331,6 +343,41 @@ var checkFixes = map[string]string{
checkEventLogging: "Set skip_event_logging: false in config",
checkProtectionNpm: "pmg setup install",
checkProtectionPip: "pmg setup install",
checkCA: "pmg setup cert install",
}
// evaluateCACheck is the testable core of the CA doctor check. Trust booleans
// and userScopeSupported are injected so the check is hermetic; the live check
// fills them from truststore.
func evaluateCACheck(dir string, userTrusted, systemTrusted, userScopeSupported bool) doctor.CheckResult {
st, err := certmanager.InspectCA(dir)
if err != nil {
return doctor.CheckResult{Status: doctor.StatusFail, Message: "CA files present but unreadable"}
}
if !st.KeyPresent && !st.CertPresent {
return doctor.CheckResult{Status: doctor.StatusWarn, Message: "MITM CA not installed (optional; run pmg setup cert install)"}
}
st.UserTrusted, st.SystemTrusted = userTrusted, systemTrusted
if drift, reason := st.Drift(); drift {
return doctor.CheckResult{Status: doctor.StatusFail, Message: reason}
}
if st.Trusted() {
if st.ExpiringSoon {
return doctor.CheckResult{Status: doctor.StatusWarn, Message: "CA trusted but expiring within 30 days"}
}
return doctor.CheckResult{Status: doctor.StatusPass, Message: "MITM CA installed and trusted"}
}
// On disk but not trusted in any store.
if !userScopeSupported {
// Linux: env-var injection (SSL_CERT_FILE) already covers Go; store trust optional.
return doctor.CheckResult{Status: doctor.StatusWarn, Message: "CA on disk; not in OS store (Linux uses SSL_CERT_FILE; --system for store trust)"}
}
return doctor.CheckResult{Status: doctor.StatusFail, Message: "CA on disk but not trusted; run pmg setup cert install"}
}
func printResults(results []doctor.CheckResult) {
+45
View File
@@ -0,0 +1,45 @@
package setup
import (
"testing"
"github.com/safedep/pmg/internal/doctor"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCACheckAbsentIsWarn(t *testing.T) {
res := evaluateCACheck(t.TempDir(), false, false, true)
assert.Equal(t, doctor.StatusWarn, res.Status)
}
func TestCACheckTrustedIsPass(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
res := evaluateCACheck(dir, true, false, true)
assert.Equal(t, doctor.StatusPass, res.Status)
}
func TestCACheckOnDiskNotTrustedMacWinIsFail(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
res := evaluateCACheck(dir, false, false, true) // userScopeSupported=true (mac/win)
assert.Equal(t, doctor.StatusFail, res.Status)
}
func TestCACheckOnDiskNotTrustedLinuxIsWarn(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
res := evaluateCACheck(dir, false, false, false) // userScopeSupported=false (linux)
assert.Equal(t, doctor.StatusWarn, res.Status)
}
+22
View File
@@ -15,7 +15,9 @@ import (
"github.com/safedep/pmg/internal/audit"
"github.com/safedep/pmg/internal/ui"
"github.com/safedep/pmg/internal/version"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/sandbox/platform"
"github.com/safedep/pmg/truststore"
"github.com/spf13/cobra"
)
@@ -143,6 +145,26 @@ func executeSetupInfo() error {
ui.PrintInfoSection("Sandbox", sandboxEntries)
// Certificate Authority section
caStatus, _ := certmanager.InspectCA(cfg.ConfigDir())
caUser, caSystem, _ := truststore.Status(certmanager.CACommonName)
caStatus.UserTrusted, caStatus.SystemTrusted = caUser, caSystem
caEntries := make(map[string]string)
caEntries["Installed"] = strconv.FormatBool(caStatus.KeyPresent && caStatus.CertPresent)
caScope := "none"
if caStatus.SystemTrusted {
caScope = "system"
} else if caStatus.UserTrusted {
caScope = "user"
}
caEntries["Trust Scope"] = caScope
if caStatus.CertPresent {
caEntries["Expires"] = caStatus.NotAfter.Format("2006-01-02")
caEntries["Fingerprint"] = caStatus.Fingerprint
}
ui.PrintInfoSection("Certificate Authority", caEntries)
if cfg.Config.Cloud.Enabled {
cloudEntries := make(map[string]string)
cloudEntries["Enabled"] = "true"
+1
View File
@@ -28,6 +28,7 @@ func NewSetupCommand() *cobra.Command {
setupCmd.AddCommand(NewRemoveCommand())
setupCmd.AddCommand(NewInfoCommand())
setupCmd.AddCommand(NewDoctorCommand())
setupCmd.AddCommand(NewCertCommand())
return setupCmd
}
+100
View File
@@ -0,0 +1,100 @@
# Certificate Authority
PMG inspects package downloads through a transparent HTTPS proxy. To read encrypted traffic
it acts as a man in the middle (MITM), signing a short lived certificate for each registry
host with its own Certificate Authority (CA). Package managers must trust that CA for the
interception to work.
## Default behavior: ephemeral CA via environment variables
By default PMG generates a CA for each run and injects it into the package manager process
through environment variables such as `NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`,
`REQUESTS_CA_BUNDLE`, and `PIP_CERT`. The CA lives only for the duration of that run and is
discarded afterwards.
This requires no setup and covers the common cases: npm, pip, and Go on Linux.
## When you need a persistent CA trusted by the OS
Environment variable injection does not reach tools that consult only the operating system
trust store. The most important case is Go on macOS and Windows. Go's TLS stack on those
platforms ignores `SSL_CERT_FILE` and validates against the platform verifier
(Security.framework on macOS, CryptoAPI on Windows). On Linux, Go honors `SSL_CERT_FILE`, so
the default behavior already covers it.
For those cases you can install a single persistent PMG CA into the OS trust store. This also
avoids regenerating a CA on every run.
```bash
# User scope (no sudo). macOS login keychain / Windows CurrentUser\Root.
pmg setup cert install
# System scope (all users). Run as your normal user, NOT with sudo.
# PMG prompts for sudo only for the trust store write (macOS / Linux).
# Windows: run from an elevated prompt.
pmg setup cert install --system
# Inspect presence, trust scope, expiry, and drift.
pmg setup cert status
# Rotate (regenerate and trust again).
pmg setup cert install --force
# Remove from the trust store. --purge also deletes the keypair on disk.
pmg setup cert uninstall [--system] [--purge]
```
## Trust scopes
`pmg setup cert install` installs at user scope by default, which needs no elevation. Pass
`--system` to install for all users. Always run the command as your normal user, not under
sudo. PMG generates a keypair owned by you and elevates only the trust store write, prompting
for sudo on macOS and Linux. On Windows, run it from an elevated prompt. Running the whole
command under sudo is refused, because the keypair would be persisted in root's config
directory where the unprivileged proxy never looks.
| Platform | User scope (default) | System scope (`--system`) |
| --- | --- | --- |
| macOS | login keychain | System keychain (sudo) |
| Windows | `CurrentUser\Root` | `LocalMachine\Root` (admin) |
| Linux | not available, see below | `/usr/local/share/ca-certificates` or `/etc/pki/ca-trust` (sudo) |
Linux has no user trust store. Running `pmg setup cert install` without `--system` on Linux
persists the keypair and relies on `SSL_CERT_FILE` injection, which already covers Go on
Linux. Use `--system` to trust the CA for all users.
## Storage and security
The CA keypair is stored under PMG's config directory as `ca-cert.pem` (`0644`) and
`ca-key.pem` (`0600`). The private key never leaves disk. Only the public certificate is
installed into the OS trust store, since the trust store cannot hold or return a private key.
The keypair always belongs to you. The unprivileged proxy must read the private key it signs
with, so the command runs as your normal user and writes to your config directory. `--system`
only widens where the public certificate is trusted, not where the key lives.
A persistent CA that the OS trusts is sensitive. Anyone who can read `ca-key.pem` can
intercept your TLS traffic for tools that trust the CA. PMG mitigates this with restrictive
file permissions and a clean uninstall path. Reading the key requires local filesystem
access, at which point the host is already compromised.
## Rotation and expiry
The root CA is valid for 10 years. Leaf certificates, one per host, stay short lived (one day)
and are never installed anywhere. A long lived root is standard practice because rotating an
installed, trusted root is disruptive. The root is protected by guarding its key rather than by
frequent rotation.
Rotation is manual. Run `pmg setup cert install --force` to regenerate and trust the CA again.
Both `pmg setup cert status` and `pmg setup doctor` warn when the root is within 30 days of
expiry.
## Drift and diagnostics
`pmg setup cert status` reports whether the keypair is present, which scope trusts it, the
fingerprint, and the expiry. It also flags drift, for example a certificate on disk without its
private key, or a CA that is on disk but not trusted in the OS store.
`pmg setup doctor` includes a CA health check. On macOS and Windows a CA on disk that is not
trusted is reported as a failure, with `pmg setup cert install` as the fix. On Linux it is a
warning, since `SSL_CERT_FILE` injection already covers Go.
+5
View File
@@ -73,3 +73,8 @@ model consists of the following layers:
2. Policy as Code (Planned CEL policy based guardrails to prevent known bad practices)
3. Sandbox for enforcing least privilege and defense in depth protection
## Certificate Authority
PMG uses a MITM CA to inspect HTTPS package downloads. See [Certificate Authority](cert.md)
for how it works and how to optionally persist and OS-trust the CA.
+6
View File
@@ -18,6 +18,12 @@ const (
PackageAuthorNotFound = "PackageAuthorNotFound"
GitHubRateLimitExceeded = "GitHubRateLimitExceeded"
// Certificate trust store error codes.
CertGeneration = "CertGeneration"
CertPersistence = "CertPersistence"
CertTrustStore = "CertTrustStore"
UnsupportedPlatform = "UnsupportedPlatform"
// Unknown mirrors the default code that dry/usefulerror returns for errors
// created without an explicit code, so unset and explicitly-unknown errors
// classify identically (e.g. the bug-report hint in ui.ErrorExit).
+52 -9
View File
@@ -2,6 +2,7 @@ package flows
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@@ -18,6 +19,7 @@ import (
"github.com/safedep/pmg/proxy"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/safedep/pmg/proxy/interceptors"
"github.com/safedep/pmg/truststore"
)
type proxyFlow struct {
@@ -302,30 +304,71 @@ func handleExecutionResultError(err error) error {
return fmt.Errorf("failed to execute command: %w", err)
}
// setupCACertificate generates CA for MITM and writes proxy bundle for child package managers.
// setupCACertificate prefers the persisted CA (created by `pmg setup cert install`)
// so the proxy signs leaves with the same CA that is in the OS trust store. When no
// persisted CA exists it falls back to an ephemeral per-run CA, preserving original
// behavior. The temp file always carries the pure CA merged with the system bundle so
// env-var trust injection works on every platform.
func (f *proxyFlow) setupCACertificate() (*certmanager.Certificate, string, error) {
log.Debugf("Generating CA certificate for proxy MITM")
dir := config.Get().ConfigDir()
// Generate CA certificate
caConfig := certmanager.DefaultCertManagerConfig()
caCert, err := certmanager.GenerateCAWithSystemCA(caConfig)
caCert, persisted := loadPersistedCA(dir)
if persisted {
log.Debugf("Using persisted CA certificate from %s", dir)
warnIfCANotTrusted()
} else {
log.Debugf("Generating ephemeral CA certificate for proxy MITM")
generated, err := certmanager.GenerateCA(certmanager.DefaultCertManagerConfig())
if err != nil {
return nil, "", fmt.Errorf("failed to generate CA certificate: %w", err)
}
caCert = generated
}
mergedPEM := certmanager.MergeWithSystemCA(caCert.Certificate)
// Write CA certificate to temporary file for package managers to trust
tempDir := os.TempDir()
caCertPath := filepath.Join(tempDir, fmt.Sprintf("pmg-ca-cert-%d.pem", os.Getpid()))
if err := os.WriteFile(caCertPath, caCert.Certificate, 0o600); err != nil {
if err := os.WriteFile(caCertPath, mergedPEM, 0o600); err != nil {
return nil, "", fmt.Errorf("failed to write CA certificate to %s: %w", caCertPath, err)
}
log.Debugf("CA certificate written to %s", caCertPath)
return caCert, caCertPath, nil
}
// loadPersistedCA returns the on-disk CA when present and not expired.
func loadPersistedCA(dir string) (*certmanager.Certificate, bool) {
caCert, err := certmanager.LoadCA(dir)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.Warnf("Failed to load persisted CA, using ephemeral: %v", err)
}
return nil, false
}
if caCert.IsExpired(time.Hour) {
log.Warnf("Persisted CA is expired; using ephemeral. Re-run `pmg setup cert install`")
return nil, false
}
return caCert, true
}
// warnIfCANotTrusted logs a hint when the persisted CA is not in any OS store,
// which matters for native tools (e.g. Go on macOS/Windows) that ignore the
// injected env vars. Best-effort; never blocks the run.
func warnIfCANotTrusted() {
user, system, err := truststore.Status(certmanager.CACommonName)
if err != nil {
log.Debugf("Could not determine CA trust status: %v", err)
return
}
if !user && !system {
log.Warnf("Persisted CA is not trusted in the OS store; native tools may reject TLS. Run `pmg setup cert install`.")
}
}
// createCertificateManager creates a certificate manager with the given CA certificate
func (f *proxyFlow) createCertificateManager(caCert *certmanager.Certificate) (certmanager.CertificateManager, error) {
caConfig := certmanager.DefaultCertManagerConfig()
+43
View File
@@ -0,0 +1,43 @@
package flows
import (
"os"
"testing"
"github.com/safedep/pmg/proxy/certmanager"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLoadPersistedCAAbsent(t *testing.T) {
_, ok := loadPersistedCA(t.TempDir())
assert.False(t, ok)
}
func TestLoadPersistedCAPresent(t *testing.T) {
dir := t.TempDir()
ca, err := certmanager.GenerateCA(certmanager.PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, certmanager.SaveCA(dir, ca))
loaded, ok := loadPersistedCA(dir)
require.True(t, ok)
assert.Equal(t, ca.Certificate, loaded.Certificate)
}
// TestSetupCACertificateWritesMergedTempFile verifies that setupCACertificate
// creates a temp file containing the pure CA as a prefix of the merged bundle.
// config.Get() is safe to call without setup — it is initialised via package init.
func TestSetupCACertificateWritesMergedTempFile(t *testing.T) {
f := &proxyFlow{}
caCert, path, err := f.setupCACertificate()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(path) })
require.NotNil(t, caCert)
assert.FileExists(t, path)
merged, err := os.ReadFile(path)
require.NoError(t, err)
assert.Equal(t, caCert.Certificate, merged[:len(caCert.Certificate)])
}
+1 -1
View File
@@ -35,7 +35,7 @@ func TestGenerateCA(t *testing.T) {
assert.True(t, ca.X509Cert.IsCA, "Certificate should be marked as CA")
assert.Equal(t, "PMG Proxy CA", ca.X509Cert.Subject.CommonName, "Common name should be PMG Proxy CA")
assert.Equal(t, "SafeDep PMG Proxy CA", ca.X509Cert.Subject.CommonName, "Common name should be SafeDep PMG Proxy CA")
assert.Greater(t, ca.X509Cert.NotAfter.Sub(ca.X509Cert.NotBefore).Hours(),
float64(config.CAValidityDays*24-1), "CA certificate validity period should be greater than the configured validity days")
+61 -64
View File
@@ -26,6 +26,11 @@ const (
goosWindows = "windows"
)
// CACommonName is the subject CN of the PMG CA. It is the single source of truth
// for the CA's identity: GenerateCA stamps it onto the certificate and the
// truststore package matches on it to find/remove the cert in OS trust stores.
const CACommonName = "SafeDep PMG Proxy CA"
// certManager implements the CertificateManager interface
type certManager struct {
ca *Certificate
@@ -207,7 +212,7 @@ func GenerateCA(config CertManagerConfig) (*Certificate, error) {
template := &x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: "PMG Proxy CA",
CommonName: CACommonName,
Organization: []string{"SafeDep PMG"},
},
NotBefore: notBefore,
@@ -292,76 +297,68 @@ func ParseTLSCertificate(cert *Certificate) (tls.Certificate, error) {
return tlsCert, nil
}
// GenerateCAWithSystemCA generates a self-signed certificate and appends system CA bundle
// content to the certificate bytes. If the system bundle is unavailable or too large to merge,
// it falls back to the PMG CA so proxy startup remains functional.
// MergeWithSystemCA appends the system CA bundle to certPEM so env-var trust
// injection (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, ...) covers both the PMG CA and
// the real world. It is best-effort: any problem locating, sizing, or reading the
// system bundle results in certPEM being returned unchanged, so proxy startup is
// never blocked by an optional enhancement.
func MergeWithSystemCA(certPEM []byte) []byte {
systemBundlePath := firstReadablePath(systemCABundleCandidates()...)
if systemBundlePath == "" {
log.Warnf("Skipping system CA bundle merge: No system CA bundle file found")
return certPEM
}
info, err := os.Stat(systemBundlePath)
if err != nil {
log.Errorf("Skipping system CA bundle merge: failed to stat %s: %v", systemBundlePath, err)
return certPEM
}
if info.Size() > maxSystemCABundleBytes {
log.Errorf("Skipping system CA bundle merge: %s is too large (%d bytes > %d bytes)",
systemBundlePath, info.Size(), maxSystemCABundleBytes)
return certPEM
}
systemBundle, err := os.ReadFile(systemBundlePath)
if err != nil {
log.Errorf("Skipping system CA bundle merge: failed to read %s: %v", systemBundlePath, err)
return certPEM
}
const extra = int64(2)
totalCap := int64(len(certPEM)) + int64(len(systemBundle)) + extra
if totalCap > maxSystemCABundleBytes {
log.Errorf("Skipping system CA bundle merge: merged CA would be too large (%d bytes > %d bytes)",
totalCap, maxSystemCABundleBytes)
return certPEM
}
merged := make([]byte, 0, int(totalCap))
merged = append(merged, certPEM...)
if len(merged) > 0 && merged[len(merged)-1] != '\n' {
merged = append(merged, '\n')
}
merged = append(merged, systemBundle...)
if len(merged) > 0 && merged[len(merged)-1] != '\n' {
merged = append(merged, '\n')
}
return merged
}
// GenerateCAWithSystemCA generates a self-signed CA and merges the system CA
// bundle into the certificate bytes (for env-var trust injection). Signing
// always uses the pure CA via X509Cert/PrivKey.
func GenerateCAWithSystemCA(config CertManagerConfig) (*Certificate, error) {
caCert, err := GenerateCA(config)
if err != nil {
return nil, fmt.Errorf("failed to generate CA: %w", err)
}
caCertPEM := caCert.Certificate
systemBundlePath := firstReadablePath(systemCABundleCandidates()...)
// No system CA found. Continue using only PMG cert.
if systemBundlePath == "" {
log.Warnf("Skipping system CA bundle merge: No system CA bundle file found")
return caCert, nil
}
info, err := os.Stat(systemBundlePath)
if err != nil {
return nil, fmt.Errorf("failed to stat system CA bundle %s: %w", systemBundlePath, err)
}
// We make sure there is a boundary on the size of CA bundle loaded
// from the system. Beyond that, we just skip it and return PMG cert.
if info.Size() > maxSystemCABundleBytes {
log.Errorf(
"Skipping system CA bundle merge: %s is too large (%d bytes > %d bytes)",
systemBundlePath,
info.Size(),
maxSystemCABundleBytes,
)
return caCert, nil
}
systemBundle, err := os.ReadFile(systemBundlePath)
if err != nil {
return nil, fmt.Errorf("failed to read system CA bundle %s: %w", systemBundlePath, err)
}
caLen := int64(len(caCertPEM))
sysLen := int64(len(systemBundle))
const extra = int64(2)
totalCap := caLen + sysLen + extra
if totalCap > maxSystemCABundleBytes {
log.Errorf(
"Skipping system CA bundle merge: merged CA would be too large (%d bytes > %d bytes)",
totalCap,
maxSystemCABundleBytes,
)
return caCert, nil
}
merged := make([]byte, 0, int(totalCap))
merged = append(merged, caCertPEM...)
if len(merged) > 0 && merged[len(merged)-1] != '\n' {
merged = append(merged, '\n')
}
merged = append(merged, systemBundle...)
if len(merged) > 0 && merged[len(merged)-1] != '\n' {
merged = append(merged, '\n')
}
return &Certificate{
Certificate: merged,
Certificate: MergeWithSystemCA(caCert.Certificate),
PrivateKey: caCert.PrivateKey,
X509Cert: caCert.X509Cert,
PrivKey: caCert.PrivKey,
+28
View File
@@ -0,0 +1,28 @@
package certmanager
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeWithSystemCAContainsInput(t *testing.T) {
ca, err := GenerateCA(DefaultCertManagerConfig())
require.NoError(t, err)
merged := MergeWithSystemCA(ca.Certificate)
// The input cert must always be a prefix of the merged output, regardless
// of whether a system bundle was found.
assert.True(t, len(merged) >= len(ca.Certificate))
assert.Equal(t, ca.Certificate, merged[:len(ca.Certificate)])
}
func TestGenerateCAWithSystemCAStillWorks(t *testing.T) {
ca, err := GenerateCAWithSystemCA(DefaultCertManagerConfig())
require.NoError(t, err)
require.NotNil(t, ca.X509Cert)
require.NotNil(t, ca.PrivKey)
assert.NotEmpty(t, ca.Certificate)
}
+73
View File
@@ -0,0 +1,73 @@
package certmanager
import (
"fmt"
"os"
"path/filepath"
)
const (
caCertFileName = "ca-cert.pem"
caKeyFileName = "ca-key.pem"
)
func CACertPath(dir string) string { return filepath.Join(dir, caCertFileName) }
func CAKeyPath(dir string) string { return filepath.Join(dir, caKeyFileName) }
// PersistentCACertManagerConfig returns the config for the on-disk,
// system-trusted CA. The root is long-lived (10 years) because rotating an
// installed, trusted root is expensive; leaf certs remain short (1 day).
func PersistentCACertManagerConfig() CertManagerConfig {
c := DefaultCertManagerConfig()
c.CAValidityDays = 3650
return c
}
// SaveCA writes the CA certificate (0644) and private key (0600) to dir.
// Only the pure PMG CA is persisted — not the system-bundle-merged PEM.
func SaveCA(dir string, ca *Certificate) error {
if ca == nil {
return fmt.Errorf("ca certificate is nil")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create config dir %s: %w", dir, err)
}
if err := os.WriteFile(CACertPath(dir), ca.Certificate, 0o644); err != nil {
return fmt.Errorf("failed to write CA certificate: %w", err)
}
// Remove any pre-existing key first so the new file is created fresh with
// 0600. os.WriteFile preserves an existing file's mode, which could otherwise
// leave a group/world-readable private key behind on a --force re-install.
if err := os.Remove(CAKeyPath(dir)); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to reset CA private key file: %w", err)
}
if err := os.WriteFile(CAKeyPath(dir), ca.PrivateKey, 0o600); err != nil {
return fmt.Errorf("failed to write CA private key: %w", err)
}
return nil
}
// LoadCA reads and parses the persisted CA from dir. When a file is missing
// the returned error wraps os.ErrNotExist so callers can use errors.Is.
func LoadCA(dir string) (*Certificate, error) {
certPEM, err := os.ReadFile(CACertPath(dir))
if err != nil {
return nil, fmt.Errorf("failed to read CA certificate: %w", err)
}
keyPEM, err := os.ReadFile(CAKeyPath(dir))
if err != nil {
return nil, fmt.Errorf("failed to read CA private key: %w", err)
}
parsed, err := parseCertificate(&Certificate{Certificate: certPEM, PrivateKey: keyPEM})
if err != nil {
return nil, fmt.Errorf("failed to parse persisted CA: %w", err)
}
return parsed, nil
}
+48
View File
@@ -0,0 +1,48 @@
package certmanager
import (
"errors"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSaveAndLoadCARoundTrip(t *testing.T) {
dir := t.TempDir()
ca, err := GenerateCA(DefaultCertManagerConfig())
require.NoError(t, err)
require.NoError(t, SaveCA(dir, ca))
if runtime.GOOS != "windows" {
keyInfo, err := os.Stat(CAKeyPath(dir))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o600), keyInfo.Mode().Perm())
certInfo, err := os.Stat(CACertPath(dir))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0o644), certInfo.Mode().Perm())
}
loaded, err := LoadCA(dir)
require.NoError(t, err)
assert.Equal(t, ca.Certificate, loaded.Certificate)
assert.Equal(t, ca.PrivateKey, loaded.PrivateKey)
require.NotNil(t, loaded.X509Cert)
require.NotNil(t, loaded.PrivKey)
}
func TestLoadCAMissingIsNotExist(t *testing.T) {
_, err := LoadCA(filepath.Join(t.TempDir(), "nope"))
require.Error(t, err)
assert.True(t, errors.Is(err, os.ErrNotExist))
}
func TestPersistentConfigIsLongLived(t *testing.T) {
assert.Equal(t, 3650, PersistentCACertManagerConfig().CAValidityDays)
}
+84
View File
@@ -0,0 +1,84 @@
package certmanager
import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"os"
"time"
)
// ExpiryWarnWindow is how close to NotAfter a CA must be before status/doctor
// surface an expiry warning.
const ExpiryWarnWindow = 30 * 24 * time.Hour
// CAStatus is the reusable status model shared by `cert status`, `setup doctor`,
// and `setup info`. Disk-side fields are filled by InspectCA; trust fields are
// filled by the caller from the truststore package (kept here so certmanager
// stays free of any OS/trust-store import).
type CAStatus struct {
KeyPresent bool
CertPresent bool
NotAfter time.Time
Expired bool
ExpiringSoon bool
Fingerprint string
UserTrusted bool
SystemTrusted bool
}
func (s CAStatus) Trusted() bool { return s.UserTrusted || s.SystemTrusted }
// Drift reports on-disk integrity / expiry problems independent of OS trust
// policy. Trust-store presence is intentionally NOT evaluated here: "not in a
// store" is normal on Linux (Go honors SSL_CERT_FILE), so consumers interpret
// trust state per platform.
func (s CAStatus) Drift() (bool, string) {
if s.CertPresent && !s.KeyPresent {
return true, "CA certificate on disk but private key missing"
}
if s.KeyPresent && !s.CertPresent {
return true, "CA private key on disk but certificate missing"
}
if s.KeyPresent && s.Expired {
return true, "CA expired; re-run `pmg setup cert install`"
}
return false, ""
}
// InspectCA gathers disk-side CA facts from dir. Absence of the files is not an
// error; the returned CAStatus simply reports them as not present.
func InspectCA(dir string) (CAStatus, error) {
var st CAStatus
if _, err := os.Stat(CAKeyPath(dir)); err == nil {
st.KeyPresent = true
}
certPEM, err := os.ReadFile(CACertPath(dir))
if err != nil {
return st, nil
}
st.CertPresent = true
block, _ := pem.Decode(certPEM)
if block == nil {
return st, fmt.Errorf("failed to decode CA certificate PEM")
}
x509Cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return st, fmt.Errorf("failed to parse CA certificate: %w", err)
}
st.NotAfter = x509Cert.NotAfter
st.Expired = time.Now().After(x509Cert.NotAfter)
st.ExpiringSoon = !st.Expired && time.Until(x509Cert.NotAfter) < ExpiryWarnWindow
sum := sha256.Sum256(x509Cert.Raw)
st.Fingerprint = hex.EncodeToString(sum[:])
return st, nil
}
+60
View File
@@ -0,0 +1,60 @@
package certmanager
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestInspectCAAbsent(t *testing.T) {
st, err := InspectCA(t.TempDir())
require.NoError(t, err)
assert.False(t, st.KeyPresent)
assert.False(t, st.CertPresent)
}
func TestInspectCAPresent(t *testing.T) {
dir := t.TempDir()
ca, err := GenerateCA(PersistentCACertManagerConfig())
require.NoError(t, err)
require.NoError(t, SaveCA(dir, ca))
st, err := InspectCA(dir)
require.NoError(t, err)
assert.True(t, st.KeyPresent)
assert.True(t, st.CertPresent)
assert.False(t, st.Expired)
assert.NotEmpty(t, st.Fingerprint)
assert.WithinDuration(t, ca.X509Cert.NotAfter, st.NotAfter, time.Second)
}
func TestCAStatusDrift(t *testing.T) {
cases := []struct {
name string
status CAStatus
wantDrift bool
}{
{"healthy", CAStatus{KeyPresent: true, CertPresent: true}, false},
{"cert without key", CAStatus{CertPresent: true}, true},
{"key without cert", CAStatus{KeyPresent: true}, true},
{"expired", CAStatus{KeyPresent: true, CertPresent: true, Expired: true}, true},
{"expiring soon is not drift", CAStatus{KeyPresent: true, CertPresent: true, ExpiringSoon: true}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
drift, reason := tc.status.Drift()
assert.Equal(t, tc.wantDrift, drift)
if tc.wantDrift {
assert.NotEmpty(t, reason)
}
})
}
}
func TestCAStatusTrusted(t *testing.T) {
assert.True(t, CAStatus{UserTrusted: true}.Trusted())
assert.True(t, CAStatus{SystemTrusted: true}.Trusted())
assert.False(t, CAStatus{}.Trusted())
}
+1
View File
@@ -93,6 +93,7 @@ SAFEDEP_API_KEY="$4" SAFEDEP_TENANT_ID="$5" ./pmg_setup_install_macos.sh
## Limitations
- Installing PMG's MITM CA into the trust store (`pmg setup cert install`) is not supported via MDM on macOS. Adding a trusted root to the login keychain requires interactive authorization from the user's GUI session, which an MDM deployment cannot supply. Have each user run `pmg setup cert install` in their own session when they need it (only needed for tools that ignore the proxy's CA environment variables, such as Go on macOS).
- The scripts can't read or write the Keychain for a user who isn't logged in, since no session exists to reach. They report and skip those users; configure them in their session when they log in. After an uninstall, their credentials clear on next login.
- Machine-scope steps under a non-root invocation need `sudo`. Without passwordless sudo in a non-interactive context, they fail with an error instead of hanging.
- macOS only. The scripts exit on other platforms.
+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))
}