mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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:
@@ -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 }`
|
||||
|
||||
@@ -41,4 +41,10 @@ clean:
|
||||
$(RM_RF)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
$(GO) test ./...
|
||||
|
||||
fmt:
|
||||
$(GO) fmt ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -28,6 +28,7 @@ func NewSetupCommand() *cobra.Command {
|
||||
setupCmd.AddCommand(NewRemoveCommand())
|
||||
setupCmd.AddCommand(NewInfoCommand())
|
||||
setupCmd.AddCommand(NewDoctorCommand())
|
||||
setupCmd.AddCommand(NewCertCommand())
|
||||
|
||||
return setupCmd
|
||||
}
|
||||
|
||||
+100
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)])
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user