From 471cd1f9035d8a4e483f769f7fe380f6191a3703 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 29 Jan 2026 02:48:54 +0000 Subject: [PATCH] feat(signing): add macOS codesign + notarization Signs binaries with Developer ID and hardened runtime. Notarization submits to Apple and staples ticket. Co-Authored-By: Claude Opus 4.5 --- pkg/build/signing/codesign.go | 102 +++++++++++++++++++++++++++++ pkg/build/signing/codesign_test.go | 34 ++++++++++ 2 files changed, 136 insertions(+) create mode 100644 pkg/build/signing/codesign.go create mode 100644 pkg/build/signing/codesign_test.go diff --git a/pkg/build/signing/codesign.go b/pkg/build/signing/codesign.go new file mode 100644 index 00000000..4b55bb55 --- /dev/null +++ b/pkg/build/signing/codesign.go @@ -0,0 +1,102 @@ +package signing + +import ( + "context" + "fmt" + "os" + "os/exec" + "runtime" +) + +// MacOSSigner signs binaries using macOS codesign. +type MacOSSigner struct { + config MacOSConfig +} + +// Compile-time interface check. +var _ Signer = (*MacOSSigner)(nil) + +// NewMacOSSigner creates a new macOS signer. +func NewMacOSSigner(cfg MacOSConfig) *MacOSSigner { + return &MacOSSigner{config: cfg} +} + +// Name returns "codesign". +func (s *MacOSSigner) Name() string { + return "codesign" +} + +// Available checks if running on macOS with codesign and identity configured. +func (s *MacOSSigner) Available() bool { + if runtime.GOOS != "darwin" { + return false + } + if s.config.Identity == "" { + return false + } + _, err := exec.LookPath("codesign") + return err == nil +} + +// Sign codesigns a binary with hardened runtime. +func (s *MacOSSigner) Sign(ctx context.Context, binary string) error { + if !s.Available() { + return fmt.Errorf("codesign.Sign: codesign not available") + } + + cmd := exec.CommandContext(ctx, "codesign", + "--sign", s.config.Identity, + "--timestamp", + "--options", "runtime", // Hardened runtime for notarization + "--force", + binary, + ) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("codesign.Sign: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// Notarize submits binary to Apple for notarization and staples the ticket. +// This blocks until Apple responds (typically 1-5 minutes). +func (s *MacOSSigner) Notarize(ctx context.Context, binary string) error { + if s.config.AppleID == "" || s.config.TeamID == "" || s.config.AppPassword == "" { + return fmt.Errorf("codesign.Notarize: missing Apple credentials (apple_id, team_id, app_password)") + } + + // Create ZIP for submission + zipPath := binary + ".zip" + zipCmd := exec.CommandContext(ctx, "zip", "-j", zipPath, binary) + if output, err := zipCmd.CombinedOutput(); err != nil { + return fmt.Errorf("codesign.Notarize: failed to create zip: %w\nOutput: %s", err, string(output)) + } + defer os.Remove(zipPath) + + // Submit to Apple and wait + submitCmd := exec.CommandContext(ctx, "xcrun", "notarytool", "submit", + zipPath, + "--apple-id", s.config.AppleID, + "--team-id", s.config.TeamID, + "--password", s.config.AppPassword, + "--wait", + ) + if output, err := submitCmd.CombinedOutput(); err != nil { + return fmt.Errorf("codesign.Notarize: notarization failed: %w\nOutput: %s", err, string(output)) + } + + // Staple the ticket + stapleCmd := exec.CommandContext(ctx, "xcrun", "stapler", "staple", binary) + if output, err := stapleCmd.CombinedOutput(); err != nil { + return fmt.Errorf("codesign.Notarize: failed to staple: %w\nOutput: %s", err, string(output)) + } + + return nil +} + +// ShouldNotarize returns true if notarization is enabled. +func (s *MacOSSigner) ShouldNotarize() bool { + return s.config.Notarize +} diff --git a/pkg/build/signing/codesign_test.go b/pkg/build/signing/codesign_test.go new file mode 100644 index 00000000..522e7b90 --- /dev/null +++ b/pkg/build/signing/codesign_test.go @@ -0,0 +1,34 @@ +package signing + +import ( + "runtime" + "testing" +) + +func TestMacOSSigner_Good_Name(t *testing.T) { + s := NewMacOSSigner(MacOSConfig{Identity: "Developer ID Application: Test"}) + if s.Name() != "codesign" { + t.Errorf("expected name 'codesign', got %q", s.Name()) + } +} + +func TestMacOSSigner_Good_Available(t *testing.T) { + s := NewMacOSSigner(MacOSConfig{Identity: "Developer ID Application: Test"}) + + // Only available on macOS with identity set + if runtime.GOOS == "darwin" { + // May or may not be available depending on Xcode + _ = s.Available() + } else { + if s.Available() { + t.Error("expected Available() to be false on non-macOS") + } + } +} + +func TestMacOSSigner_Bad_NoIdentity(t *testing.T) { + s := NewMacOSSigner(MacOSConfig{}) + if s.Available() { + t.Error("expected Available() to be false when identity is empty") + } +}