From 60f87b55d1dc2de1e8d6c07ce773a87adb10d5f8 Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Tue, 25 Aug 2026 18:22:31 -0700 Subject: [PATCH 1/2] Let Windows hosts find signerd.toml and pick jsign's PIV store. User config still wins; the system fallback is ProgramData on Windows and /etc/signerd elsewhere. store_type reaches jsign so a Windows daemon can skip libykcs11. Co-authored-by: Cursor --- backends/backends_test.go | 28 +++++++++++++++++++ backends/jsign.go | 9 +++++- cmd/signerd/main.go | 47 ++++++++++++++++++++++++++++--- cmd/signerd/main_test.go | 58 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 5 deletions(-) diff --git a/backends/backends_test.go b/backends/backends_test.go index 1831ff3..60d92bc 100644 --- a/backends/backends_test.go +++ b/backends/backends_test.go @@ -319,6 +319,8 @@ func TestJsignSign(t *testing.T) { require.Len(t, runner.calls, 1) assert.Equal(t, "/usr/bin/jsign", runner.calls[0][0]) + assert.Contains(t, runner.calls[0], "--storetype") + assert.Contains(t, runner.calls[0], "YUBIKEY") assert.Contains(t, runner.calls[0], "--alias") assert.Contains(t, runner.calls[0], "--tsmode") assert.Contains(t, runner.calls[0], "RFC3161") @@ -338,6 +340,32 @@ func TestJsignSign(t *testing.T) { assert.True(t, strings.HasPrefix(runner.calls[0][passAt+1], "file:")) } +func TestJsignSignPIVStoreType(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + input := filepath.Join(dir, "app.exe") + output := filepath.Join(dir, "app.exe.signed") + + require.NoError(t, os.WriteFile(input, []byte("MZ unsigned"), 0o600)) + + runner := &fakeRunner{} + backend := backends.NewJsign(&backends.JsignConfig{ + CertFile: "/etc/signerd/chain.pem", + StoreType: "PIV", + Run: runner.run, + }) + assert.Equal(t, "PIV", backend.StoreType()) + + err := backend.Sign(t.Context(), &signer.Request{InputPath: input, OutputPath: output}) + require.NoError(t, err) + + require.Len(t, runner.calls, 1) + assert.Contains(t, runner.calls[0], "--storetype") + assert.Contains(t, runner.calls[0], "PIV") + assert.NotContains(t, runner.calls[0], "YUBIKEY") +} + func TestJsignSignMissingInput(t *testing.T) { t.Parallel() diff --git a/backends/jsign.go b/backends/jsign.go index 5db11a9..62db34b 100644 --- a/backends/jsign.go +++ b/backends/jsign.go @@ -12,7 +12,9 @@ import ( type JsignConfig struct { // Command is the jsign launcher. Defaults to "jsign". Command string - // StoreType defaults to YUBIKEY, jsign's native PC/SC YubiKey store. + // StoreType defaults to YUBIKEY, jsign's native store that locates + // libykcs11. Set PIV to talk to the token over PC/SC without that DLL + // (the usual Windows setup). StoreType string // Alias selects the certificate on the token, for example // "X.509 Certificate for PIV Authentication" for slot 9A. @@ -165,3 +167,8 @@ func (s *Jsign) Health(ctx context.Context) error { func (s *Jsign) HealthCommand() []string { return slices.Clone(s.config.HealthCommand) } + +// StoreType returns the jsign --storetype that Sign will pass. +func (s *Jsign) StoreType() string { + return s.config.StoreType +} diff --git a/cmd/signerd/main.go b/cmd/signerd/main.go index 21a2dba..38a9583 100644 --- a/cmd/signerd/main.go +++ b/cmd/signerd/main.go @@ -18,6 +18,7 @@ import ( "os" "os/signal" "path/filepath" + "runtime" "strings" "syscall" @@ -53,6 +54,9 @@ type Config struct { KeyID string `toml:"key_id" xml:"key_id"` // Alias selects the certificate for the jsign backend. Alias string `toml:"alias" xml:"alias"` + // StoreType is the jsign --storetype. Empty defaults to YUBIKEY. + // Windows hosts that skip libykcs11 should set PIV. + StoreType string `toml:"store_type" xml:"store_type"` // PIN is the token user PIN. Prefer SIGNERD_PIN or pin_file over // writing it in the config file. Never the PUK. PIN string `toml:"pin" xml:"pin"` @@ -103,7 +107,8 @@ func main() { } func run() error { - configFlag := flag.String("config", "", "path to signerd.toml (default: user config dir, then /etc/signerd)") + configFlag := flag.String("config", "", + "path to signerd.toml (default: user config dir, then "+systemConfigHint()+")") versionFlag := flag.Bool("version", false, "print the version and exit") flag.Parse() @@ -206,18 +211,51 @@ func loadConfig(path string) (*Config, error) { return config, nil } +const ( + configFileName = "signerd.toml" + unixSystemDir = "/etc/signerd" +) + +// systemConfigHint is the second half of -config's default-path help. +func systemConfigHint() string { + if runtime.GOOS == "windows" { + return `ProgramData\signerd` + } + + return unixSystemDir +} + // findConfigFile returns the first config file that exists in the default // locations, or "" when none do (environment-only configuration). func findConfigFile() string { - candidates := []string{"/etc/signerd/signerd.toml"} + return firstExistingConfig(defaultConfigCandidates()) +} + +// defaultConfigCandidates is user config first, then the system path: +// %ProgramData%\signerd on Windows, /etc/signerd elsewhere. +func defaultConfigCandidates() []string { + var candidates []string dir, err := os.UserConfigDir() if err == nil { - candidates = append([]string{filepath.Join(dir, "signerd", "signerd.toml")}, candidates...) + candidates = append(candidates, filepath.Join(dir, "signerd", configFileName)) } + if runtime.GOOS == "windows" { + programData := os.Getenv("ProgramData") + if programData != "" { + candidates = append(candidates, filepath.Join(programData, "signerd", configFileName)) + } + + return candidates + } + + return append(candidates, filepath.Join(unixSystemDir, configFileName)) +} + +func firstExistingConfig(candidates []string) string { for _, candidate := range candidates { - _, err = os.Stat(candidate) + _, err := os.Stat(candidate) // Anything but not-exist counts as a hit (permission problems, for // example) so loadConfig surfaces a clear error instead of silently // running on environment-only configuration. @@ -366,6 +404,7 @@ func buildSigner(config *Config) (signer.Signer, error) { //nolint:ireturn // Pi case "jsign": return backends.NewJsign(&backends.JsignConfig{ Command: config.Command, + StoreType: config.StoreType, Alias: config.Alias, CertFile: config.CertFile, PIN: config.PIN, diff --git a/cmd/signerd/main_test.go b/cmd/signerd/main_test.go index 6c79e9f..af89199 100644 --- a/cmd/signerd/main_test.go +++ b/cmd/signerd/main_test.go @@ -4,6 +4,7 @@ import ( "math" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -27,6 +28,23 @@ func TestBuildSignerJsignPassesModule(t *testing.T) { assert.Equal(t, []string{ "pkcs11-tool", "--module", module, "--list-objects", "--type", "cert", }, jsign.HealthCommand()) + assert.Equal(t, "YUBIKEY", jsign.StoreType()) +} + +func TestBuildSignerJsignPassesStoreType(t *testing.T) { + t.Parallel() + + backend, err := buildSigner(&Config{ + Backend: "jsign", + StoreType: "PIV", + HealthCommand: []string{"ykman", "piv", "info"}, + CertFile: filepath.Join(t.TempDir(), "chain.pem"), + }) + require.NoError(t, err) + + jsign, ok := backend.(*backends.Jsign) + require.True(t, ok) + assert.Equal(t, "PIV", jsign.StoreType()) } func TestValidateConfig(t *testing.T) { @@ -134,3 +152,43 @@ func TestBuildSignerFakeRequiresEnv(t *testing.T) { require.NoError(t, err) require.NotNil(t, backend) } + +func TestDefaultConfigCandidates(t *testing.T) { + t.Parallel() + + candidates := defaultConfigCandidates() + require.NotEmpty(t, candidates) + + for _, candidate := range candidates { + assert.Equal(t, configFileName, filepath.Base(candidate)) + } + + if runtime.GOOS == "windows" { + assert.NotContains(t, candidates, filepath.Join(unixSystemDir, configFileName)) + + programData := os.Getenv("ProgramData") + if programData != "" { + assert.Contains(t, candidates, filepath.Join(programData, "signerd", configFileName)) + } + + return + } + + assert.Contains(t, candidates, filepath.Join(unixSystemDir, configFileName)) + assert.Equal(t, unixSystemDir, systemConfigHint()) +} + +func TestFirstExistingConfig(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + missing := filepath.Join(dir, "missing.toml") + present := filepath.Join(dir, "present.toml") + + require.NoError(t, os.WriteFile(present, []byte("listen = \"127.0.0.1:8750\"\n"), 0o600)) + + assert.Empty(t, firstExistingConfig(nil)) + assert.Empty(t, firstExistingConfig([]string{missing})) + assert.Equal(t, present, firstExistingConfig([]string{missing, present})) + assert.Equal(t, present, firstExistingConfig([]string{present, missing})) +} From 2c344e878381dd7ad67a6e74ee16948ed01ae34e Mon Sep 17 00:00:00 2001 From: David Newhall II Date: Tue, 25 Aug 2026 19:29:00 -0700 Subject: [PATCH 2/2] Cover store_type TOML/env decode and the Windows config-path hint. loadConfig is what operators hit; constructing Config in tests was skipping it. Co-authored-by: Cursor --- cmd/signerd/main_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/cmd/signerd/main_test.go b/cmd/signerd/main_test.go index af89199..33112e1 100644 --- a/cmd/signerd/main_test.go +++ b/cmd/signerd/main_test.go @@ -165,6 +165,7 @@ func TestDefaultConfigCandidates(t *testing.T) { if runtime.GOOS == "windows" { assert.NotContains(t, candidates, filepath.Join(unixSystemDir, configFileName)) + assert.Equal(t, `ProgramData\signerd`, systemConfigHint()) programData := os.Getenv("ProgramData") if programData != "" { @@ -178,6 +179,38 @@ func TestDefaultConfigCandidates(t *testing.T) { assert.Equal(t, unixSystemDir, systemConfigHint()) } +func TestLoadConfigStoreType(t *testing.T) { + path := filepath.Join(t.TempDir(), configFileName) + require.NoError(t, os.WriteFile(path, []byte("store_type = \"PKCS11\"\n"), 0o600)) + + t.Run("toml", func(t *testing.T) { + t.Setenv("SIGNERD_STORE_TYPE", "unused") + require.NoError(t, os.Unsetenv("SIGNERD_STORE_TYPE")) + + config, err := loadConfig(path) + require.NoError(t, err) + assert.Equal(t, "PKCS11", config.StoreType) + }) + + t.Run("env", func(t *testing.T) { + empty := filepath.Join(t.TempDir(), configFileName) + require.NoError(t, os.WriteFile(empty, []byte("listen = \"127.0.0.1:8750\"\n"), 0o600)) + t.Setenv("SIGNERD_STORE_TYPE", "PIV") + + config, err := loadConfig(empty) + require.NoError(t, err) + assert.Equal(t, "PIV", config.StoreType) + }) + + t.Run("envOverridesTOML", func(t *testing.T) { + t.Setenv("SIGNERD_STORE_TYPE", "PIV") + + config, err := loadConfig(path) + require.NoError(t, err) + assert.Equal(t, "PIV", config.StoreType) + }) +} + func TestFirstExistingConfig(t *testing.T) { t.Parallel()