Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions backends/backends_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()

Expand Down
9 changes: 8 additions & 1 deletion backends/jsign.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
47 changes: 43 additions & 4 deletions cmd/signerd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"

Expand Down Expand Up @@ -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"`
Comment thread
davidnewhall marked this conversation as resolved.
// 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"`
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions cmd/signerd/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"math"
"os"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -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) {
Expand Down Expand Up @@ -134,3 +152,76 @@ 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))
assert.Equal(t, `ProgramData\signerd`, systemConfigHint())

programData := os.Getenv("ProgramData")
if programData != "" {
assert.Contains(t, candidates, filepath.Join(programData, "signerd", configFileName))
}

return
Comment thread
davidnewhall marked this conversation as resolved.
}

assert.Contains(t, candidates, filepath.Join(unixSystemDir, configFileName))
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()

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}))
}