From 4f0db15eded7ecdcae5f43db25bafb1b6abe4d71 Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Wed, 3 Jun 2026 23:15:02 +0530 Subject: [PATCH] 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 --- AGENTS.md | 1 + Makefile | 8 +- README.md | 13 + cmd/setup/cert.go | 326 ++++++++++++++++++++++ cmd/setup/cert_test.go | 150 ++++++++++ cmd/setup/doctor.go | 47 ++++ cmd/setup/doctor_ca_test.go | 45 +++ cmd/setup/info.go | 22 ++ cmd/setup/setup.go | 1 + docs/cert.md | 100 +++++++ docs/trust.md | 5 + errcodes/codes.go | 6 + internal/flows/proxy_flow.go | 65 ++++- internal/flows/proxy_flow_cert_test.go | 43 +++ proxy/certmanager/certmanager_test.go | 2 +- proxy/certmanager/generator.go | 125 ++++----- proxy/certmanager/generator_merge_test.go | 28 ++ proxy/certmanager/persist.go | 73 +++++ proxy/certmanager/persist_test.go | 48 ++++ proxy/certmanager/status.go | 84 ++++++ proxy/certmanager/status_test.go | 60 ++++ scripts/mdm/README.md | 1 + truststore/truststore.go | 69 +++++ truststore/truststore_certfile.go | 35 +++ truststore/truststore_darwin.go | 100 +++++++ truststore/truststore_darwin_test.go | 89 ++++++ truststore/truststore_linux.go | 114 ++++++++ truststore/truststore_linux_test.go | 85 ++++++ truststore/truststore_unsupported.go | 24 ++ truststore/truststore_windows.go | 63 +++++ truststore/truststore_windows_test.go | 51 ++++ 31 files changed, 1806 insertions(+), 77 deletions(-) create mode 100644 cmd/setup/cert.go create mode 100644 cmd/setup/cert_test.go create mode 100644 cmd/setup/doctor_ca_test.go create mode 100644 docs/cert.md create mode 100644 internal/flows/proxy_flow_cert_test.go create mode 100644 proxy/certmanager/generator_merge_test.go create mode 100644 proxy/certmanager/persist.go create mode 100644 proxy/certmanager/persist_test.go create mode 100644 proxy/certmanager/status.go create mode 100644 proxy/certmanager/status_test.go create mode 100644 truststore/truststore.go create mode 100644 truststore/truststore_certfile.go create mode 100644 truststore/truststore_darwin.go create mode 100644 truststore/truststore_darwin_test.go create mode 100644 truststore/truststore_linux.go create mode 100644 truststore/truststore_linux_test.go create mode 100644 truststore/truststore_unsupported.go create mode 100644 truststore/truststore_windows.go create mode 100644 truststore/truststore_windows_test.go diff --git a/AGENTS.md b/AGENTS.md index 7a048f3..a48fcbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 }` diff --git a/Makefile b/Makefile index 5325674..fe04f41 100644 --- a/Makefile +++ b/Makefile @@ -41,4 +41,10 @@ clean: $(RM_RF) test: - go test ./... + $(GO) test ./... + +fmt: + $(GO) fmt ./... + +lint: + golangci-lint run diff --git a/README.md b/README.md index b22630b..84b5a1a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/setup/cert.go b/cmd/setup/cert.go new file mode 100644 index 0000000..0ac0968 --- /dev/null +++ b/cmd/setup/cert.go @@ -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)" +} diff --git a/cmd/setup/cert_test.go b/cmd/setup/cert_test.go new file mode 100644 index 0000000..94d82bc --- /dev/null +++ b/cmd/setup/cert_test.go @@ -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") +} diff --git a/cmd/setup/doctor.go b/cmd/setup/doctor.go index e65107e..e115440 100644 --- a/cmd/setup/doctor.go +++ b/cmd/setup/doctor.go @@ -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) { diff --git a/cmd/setup/doctor_ca_test.go b/cmd/setup/doctor_ca_test.go new file mode 100644 index 0000000..55afb52 --- /dev/null +++ b/cmd/setup/doctor_ca_test.go @@ -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) +} diff --git a/cmd/setup/info.go b/cmd/setup/info.go index 6d1f1aa..25bc171 100644 --- a/cmd/setup/info.go +++ b/cmd/setup/info.go @@ -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" diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 6191976..f57c47a 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -28,6 +28,7 @@ func NewSetupCommand() *cobra.Command { setupCmd.AddCommand(NewRemoveCommand()) setupCmd.AddCommand(NewInfoCommand()) setupCmd.AddCommand(NewDoctorCommand()) + setupCmd.AddCommand(NewCertCommand()) return setupCmd } diff --git a/docs/cert.md b/docs/cert.md new file mode 100644 index 0000000..e51a2d4 --- /dev/null +++ b/docs/cert.md @@ -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. diff --git a/docs/trust.md b/docs/trust.md index 8adda0e..720dc7c 100644 --- a/docs/trust.md +++ b/docs/trust.md @@ -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. + diff --git a/errcodes/codes.go b/errcodes/codes.go index 73e44c0..fb93e09 100644 --- a/errcodes/codes.go +++ b/errcodes/codes.go @@ -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). diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index ad56376..91a4932 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -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) - if err != nil { - return nil, "", fmt.Errorf("failed to generate CA certificate: %w", err) + 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 } - // Write CA certificate to temporary file for package managers to trust + mergedPEM := certmanager.MergeWithSystemCA(caCert.Certificate) + 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() diff --git a/internal/flows/proxy_flow_cert_test.go b/internal/flows/proxy_flow_cert_test.go new file mode 100644 index 0000000..e92a260 --- /dev/null +++ b/internal/flows/proxy_flow_cert_test.go @@ -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)]) +} diff --git a/proxy/certmanager/certmanager_test.go b/proxy/certmanager/certmanager_test.go index ac64947..fcd9665 100644 --- a/proxy/certmanager/certmanager_test.go +++ b/proxy/certmanager/certmanager_test.go @@ -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") diff --git a/proxy/certmanager/generator.go b/proxy/certmanager/generator.go index 2dbf382..87f1e67 100644 --- a/proxy/certmanager/generator.go +++ b/proxy/certmanager/generator.go @@ -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, diff --git a/proxy/certmanager/generator_merge_test.go b/proxy/certmanager/generator_merge_test.go new file mode 100644 index 0000000..bb02b77 --- /dev/null +++ b/proxy/certmanager/generator_merge_test.go @@ -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) +} diff --git a/proxy/certmanager/persist.go b/proxy/certmanager/persist.go new file mode 100644 index 0000000..39c11a0 --- /dev/null +++ b/proxy/certmanager/persist.go @@ -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 +} diff --git a/proxy/certmanager/persist_test.go b/proxy/certmanager/persist_test.go new file mode 100644 index 0000000..8e03fd6 --- /dev/null +++ b/proxy/certmanager/persist_test.go @@ -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) +} diff --git a/proxy/certmanager/status.go b/proxy/certmanager/status.go new file mode 100644 index 0000000..e8bb100 --- /dev/null +++ b/proxy/certmanager/status.go @@ -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 +} diff --git a/proxy/certmanager/status_test.go b/proxy/certmanager/status_test.go new file mode 100644 index 0000000..a52b693 --- /dev/null +++ b/proxy/certmanager/status_test.go @@ -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()) +} diff --git a/scripts/mdm/README.md b/scripts/mdm/README.md index 5e8c772..5a788c1 100644 --- a/scripts/mdm/README.md +++ b/scripts/mdm/README.md @@ -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. diff --git a/truststore/truststore.go b/truststore/truststore.go new file mode 100644 index 0000000..f4a0795 --- /dev/null +++ b/truststore/truststore.go @@ -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() } diff --git a/truststore/truststore_certfile.go b/truststore/truststore_certfile.go new file mode 100644 index 0000000..962e2ab --- /dev/null +++ b/truststore/truststore_certfile.go @@ -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 +} diff --git a/truststore/truststore_darwin.go b/truststore/truststore_darwin.go new file mode 100644 index 0000000..291c2bb --- /dev/null +++ b/truststore/truststore_darwin.go @@ -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 +} diff --git a/truststore/truststore_darwin_test.go b/truststore/truststore_darwin_test.go new file mode 100644 index 0000000..889484c --- /dev/null +++ b/truststore/truststore_darwin_test.go @@ -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()) +} diff --git a/truststore/truststore_linux.go b/truststore/truststore_linux.go new file mode 100644 index 0000000..95503a0 --- /dev/null +++ b/truststore/truststore_linux.go @@ -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 +} diff --git a/truststore/truststore_linux_test.go b/truststore/truststore_linux_test.go new file mode 100644 index 0000000..c5f28d3 --- /dev/null +++ b/truststore/truststore_linux_test.go @@ -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) +} diff --git a/truststore/truststore_unsupported.go b/truststore/truststore_unsupported.go new file mode 100644 index 0000000..578a00a --- /dev/null +++ b/truststore/truststore_unsupported.go @@ -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")) +} diff --git a/truststore/truststore_windows.go b/truststore/truststore_windows.go new file mode 100644 index 0000000..091d815 --- /dev/null +++ b/truststore/truststore_windows.go @@ -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 +} diff --git a/truststore/truststore_windows_test.go b/truststore/truststore_windows_test.go new file mode 100644 index 0000000..a842905 --- /dev/null +++ b/truststore/truststore_windows_test.go @@ -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)) +}