diff --git a/.github/workflows/release-probo-agent.yaml b/.github/workflows/release-probo-agent.yaml index 6bb82715c..4ea60d01f 100644 --- a/.github/workflows/release-probo-agent.yaml +++ b/.github/workflows/release-probo-agent.yaml @@ -126,6 +126,7 @@ jobs: LDFLAGS="-s -w -X 'main.version=${VERSION}'" mkdir -p dist archives staging + export COPYFILE_DISABLE=1 GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" \ -gcflags="-e" -o dist/probo-agent_arm64 ./cmd/probo-agent @@ -147,6 +148,13 @@ jobs: if [ -f cmd/probo-agent/CHANGELOG.md ]; then cp cmd/probo-agent/CHANGELOG.md "staging/${AGENT_DIR}/" fi + # Match the PKG payload signing identity so auto-update does + # not replace a Developer ID binary with an ad-hoc one (which + # multiplies macOS Background Activity entries). + codesign --force --options runtime --timestamp \ + --identifier com.probo.agent \ + --sign "${CODESIGN_IDENTITY}" "staging/${AGENT_DIR}/probo-agent" + codesign --verify --verbose=2 "staging/${AGENT_DIR}/probo-agent" tar -czf "archives/${AGENT_DIR}.tar.gz" -C staging "${AGENT_DIR}" done diff --git a/cmd/probo-agent/installer/macos/build.sh b/cmd/probo-agent/installer/macos/build.sh index a36c7a281..51d5906cb 100755 --- a/cmd/probo-agent/installer/macos/build.sh +++ b/cmd/probo-agent/installer/macos/build.sh @@ -151,12 +151,17 @@ fi codesign_runtime() { local path="$1" - codesign \ - --force \ - --options runtime \ - --timestamp \ - --sign "${CODESIGN_IDENTITY}" \ - "${path}" + local identifier="${2:-}" + local -a args=( + --force + --options runtime + --timestamp + --sign "${CODESIGN_IDENTITY}" + ) + if [ -n "${identifier}" ]; then + args+=(--identifier "${identifier}") + fi + codesign "${args[@]}" "${path}" codesign --verify --verbose=2 "${path}" } @@ -391,7 +396,7 @@ RESOURCES="${STAGE}/Resources" mkdir -p "${PAYLOAD}/usr/local/bin" "${SCRIPTS}" "${RESOURCES}" install -m 0755 "${BINARY}" "${PAYLOAD}/usr/local/bin/probo-agent" -codesign_runtime "${PAYLOAD}/usr/local/bin/probo-agent" +codesign_runtime "${PAYLOAD}/usr/local/bin/probo-agent" "com.probo.agent" mkdir -p "${PAYLOAD}/Applications" build_probo_agent_app "${PAYLOAD}/Applications" diff --git a/pkg/deviceagent/service/launchd.plist.tmpl b/pkg/deviceagent/service/launchd.plist.tmpl index f2b5449d2..005c6f67a 100644 --- a/pkg/deviceagent/service/launchd.plist.tmpl +++ b/pkg/deviceagent/service/launchd.plist.tmpl @@ -24,5 +24,9 @@ root GroupName wheel + AssociatedBundleIdentifiers + + com.probo.agent.url-handler + diff --git a/pkg/deviceagent/service/service_darwin.go b/pkg/deviceagent/service/service_darwin.go index b4294e548..9d1ad5381 100644 --- a/pkg/deviceagent/service/service_darwin.go +++ b/pkg/deviceagent/service/service_darwin.go @@ -21,6 +21,7 @@ package service import ( + _ "embed" "encoding/xml" "errors" "fmt" @@ -39,35 +40,14 @@ const ( helperBinaryPath = "/Library/PrivilegedHelperTools/" + helperLabel ) -const launchdPlistTmpl = ` - - - - Label - {{xml .Label}} - ProgramArguments - - {{xml .ExePath}} - run - --dir - {{xml .Dir}} - - RunAtLoad - - KeepAlive - - StandardOutPath - /var/log/probo-agent.log - StandardErrorPath - /var/log/probo-agent.log - UserName - root - GroupName - wheel - - -` +var ( + //go:embed launchd.plist.tmpl + launchdPlistTmpl string + + launchdPlist = template.Must( + template.New("plist").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchdPlistTmpl), + ) +) func xmlEscape(v string) (string, error) { var sb strings.Builder @@ -126,11 +106,6 @@ func Install(cfg Config) error { cfg.Label = DefaultLabel } - tmpl, err := template.New("plist").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchdPlistTmpl) - if err != nil { - return fmt.Errorf("cannot parse plist template: %w", err) - } - if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { return fmt.Errorf("cannot ensure launch daemons directory: %w", err) } @@ -142,7 +117,7 @@ func Install(cfg Config) error { defer func() { _ = f.Close() }() - if err := tmpl.Execute(f, cfg); err != nil { + if err := launchdPlist.Execute(f, cfg); err != nil { return fmt.Errorf("cannot render plist: %w", err) } diff --git a/pkg/deviceagent/tray/launchagent.plist.tmpl b/pkg/deviceagent/tray/launchagent.plist.tmpl index 17f290f35..0aa703b8d 100644 --- a/pkg/deviceagent/tray/launchagent.plist.tmpl +++ b/pkg/deviceagent/tray/launchagent.plist.tmpl @@ -16,5 +16,9 @@ KeepAlive + AssociatedBundleIdentifiers + + com.probo.agent.url-handler + diff --git a/pkg/deviceagent/update/install_signature_darwin.go b/pkg/deviceagent/update/install_signature_darwin.go new file mode 100644 index 000000000..da45ad9f7 --- /dev/null +++ b/pkg/deviceagent/update/install_signature_darwin.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build darwin + +package update + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "go.gearno.de/kit/log" +) + +// ensureSignatureCompatible refuses an update that would replace a +// Developer ID-signed agent with a binary that lacks the same Apple +// Team ID and code-signing Identifier. A mismatch on either field +// creates a fresh Background Task Management identity. +// +// Machines already running an unsigned (ad-hoc) binary are allowed to +// upgrade so they can recover onto a signed release. +func ensureSignatureCompatible( + ctx context.Context, + logger *log.Logger, + currentPath string, + candidatePath string, +) error { + current, err := readCodeSigningIdentity(currentPath) + if err != nil { + return fmt.Errorf("cannot read current binary code signature: %w", err) + } + + candidate, err := readCodeSigningIdentity(candidatePath) + if err != nil { + return fmt.Errorf("cannot read candidate binary code signature: %w", err) + } + + if current.Team == "" { + logger.InfoCtx( + ctx, + "current agent binary has no Apple Team ID; allowing update", + log.String("candidate_team_id", candidate.Team), + log.String("candidate_identifier", candidate.Identifier), + ) + + return nil + } + + return codeSigningIdentitiesCompatible(current, candidate) +} + +func readCodeSigningIdentity(path string) (codeSigningIdentity, error) { + cmd := exec.Command("/usr/bin/codesign", "-d", "--verbose=4", path) + out, err := cmd.CombinedOutput() + output := string(out) + + identity := parseCodeSigningIdentity(output) + if identity.Team != "" { + return identity, nil + } + + // Unsigned and ad-hoc binaries exit non-zero and report no Team ID. + // Treat those as empty rather than failing the update path. + if err != nil && !isUnsignedCodesignOutput(output) { + return codeSigningIdentity{}, fmt.Errorf( + "codesign -d %s: %w: %s", + path, + err, + strings.TrimSpace(output), + ) + } + + return identity, nil +} diff --git a/pkg/deviceagent/update/install_signature_identity.go b/pkg/deviceagent/update/install_signature_identity.go new file mode 100644 index 000000000..8e5db1f5a --- /dev/null +++ b/pkg/deviceagent/update/install_signature_identity.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package update + +import ( + "fmt" + "strings" +) + +// codeSigningIdentity is the Apple code-signing identity fields that +// Background Task Management uses to key a background item. +type codeSigningIdentity struct { + Team string + Identifier string +} + +// codeSigningIdentitiesCompatible refuses an update that would change +// the BTM identity of a Developer ID-signed agent. Both Team ID and +// signing Identifier must match. Machines already running an unsigned +// (ad-hoc) binary are allowed to upgrade onto a signed release. +func codeSigningIdentitiesCompatible( + current codeSigningIdentity, + candidate codeSigningIdentity, +) error { + if current.Team == "" { + return nil + } + + if candidate.Team == "" { + return fmt.Errorf( + "candidate binary has no Apple Team ID (current Team ID %s); refusing signature downgrade", + current.Team, + ) + } + + if candidate.Team != current.Team { + return fmt.Errorf( + "candidate Team ID %s does not match current Team ID %s", + candidate.Team, + current.Team, + ) + } + + if candidate.Identifier != current.Identifier { + return fmt.Errorf( + "candidate Identifier %q does not match current Identifier %q", + candidate.Identifier, + current.Identifier, + ) + } + + return nil +} + +func parseCodeSigningIdentity(codesignOutput string) codeSigningIdentity { + return codeSigningIdentity{ + Team: parseCodesignField(codesignOutput, "TeamIdentifier="), + Identifier: parseCodesignField(codesignOutput, "Identifier="), + } +} + +func parseCodesignField(codesignOutput string, prefix string) string { + for line := range strings.SplitSeq(codesignOutput, "\n") { + line = strings.TrimSpace(line) + + after, ok := strings.CutPrefix(line, prefix) + if !ok { + continue + } + + after = strings.TrimSpace(after) + if after == "" || after == "not set" { + return "" + } + + return after + } + + return "" +} + +func isUnsignedCodesignOutput(output string) bool { + lower := strings.ToLower(output) + + return strings.Contains(lower, "code object is not signed") || + strings.Contains(lower, "not signed at all") || + strings.Contains(lower, "teamidentifier=not set") +} diff --git a/pkg/deviceagent/update/install_signature_identity_test.go b/pkg/deviceagent/update/install_signature_identity_test.go new file mode 100644 index 000000000..4fd00ae51 --- /dev/null +++ b/pkg/deviceagent/update/install_signature_identity_test.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package update + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCodeSigningIdentity(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + out string + want codeSigningIdentity + }{ + { + name: "developer id signed binary", + out: "" + + "Executable=/usr/local/bin/probo-agent\n" + + "Identifier=com.probo.agent\n" + + "Format=Mach-O thin (arm64)\n" + + "TeamIdentifier=ABCD123456\n", + want: codeSigningIdentity{ + Team: "ABCD123456", + Identifier: "com.probo.agent", + }, + }, + { + name: "team identifier not set", + out: "" + + "Identifier=probo-agent\n" + + "Signature=adhoc\n" + + "TeamIdentifier=not set\n", + want: codeSigningIdentity{ + Team: "", + Identifier: "probo-agent", + }, + }, + { + name: "unsigned output has empty fields", + out: "code object is not signed at all\n", + want: codeSigningIdentity{}, + }, + { + name: "empty identifier value", + out: "" + + "Identifier=\n" + + "TeamIdentifier=ABCD123456\n", + want: codeSigningIdentity{ + Team: "ABCD123456", + Identifier: "", + }, + }, + } + + for _, tc := range cases { + t.Run( + tc.name, + func(t *testing.T) { + t.Parallel() + + got := parseCodeSigningIdentity(tc.out) + assert.Equal(t, tc.want, got) + }, + ) + } +} + +func TestCodeSigningIdentitiesCompatible(t *testing.T) { + t.Parallel() + + stable := codeSigningIdentity{ + Team: "ABCD123456", + Identifier: "com.probo.agent", + } + + cases := []struct { + name string + current codeSigningIdentity + candidate codeSigningIdentity + wantErr string + }{ + { + name: "allows unsigned to signed upgrade", + current: codeSigningIdentity{}, + candidate: codeSigningIdentity{ + Team: "ABCD123456", + Identifier: "com.probo.agent", + }, + }, + { + name: "allows matching team and identifier", + current: stable, + candidate: stable, + }, + { + name: "refuses signed to unsigned downgrade", + current: stable, + candidate: codeSigningIdentity{ + Identifier: "com.probo.agent", + }, + wantErr: "refusing signature downgrade", + }, + { + name: "refuses team mismatch", + current: stable, + candidate: codeSigningIdentity{ + Team: "OTHERTEAM1", + Identifier: "com.probo.agent", + }, + wantErr: "does not match current Team ID", + }, + { + name: "refuses same team with different identifier", + current: stable, + candidate: codeSigningIdentity{ + Team: "ABCD123456", + Identifier: "com.probo.agent.other", + }, + wantErr: "does not match current Identifier", + }, + } + + for _, tc := range cases { + t.Run( + tc.name, + func(t *testing.T) { + t.Parallel() + + err := codeSigningIdentitiesCompatible(tc.current, tc.candidate) + if tc.wantErr == "" { + assert.NoError(t, err) + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }, + ) + } +} + +func TestIsUnsignedCodesignOutput(t *testing.T) { + t.Parallel() + + assert.True(t, isUnsignedCodesignOutput("code object is not signed at all")) + assert.True(t, isUnsignedCodesignOutput("TeamIdentifier=not set\n")) + assert.False(t, isUnsignedCodesignOutput("TeamIdentifier=ABCD123456\n")) +} diff --git a/pkg/deviceagent/update/install_signature_other.go b/pkg/deviceagent/update/install_signature_other.go new file mode 100644 index 000000000..faebbd968 --- /dev/null +++ b/pkg/deviceagent/update/install_signature_other.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +//go:build !darwin + +package update + +import ( + "context" + + "go.gearno.de/kit/log" +) + +func ensureSignatureCompatible( + _ context.Context, + _ *log.Logger, + _ string, + _ string, +) error { + return nil +} diff --git a/pkg/deviceagent/update/update.go b/pkg/deviceagent/update/update.go index e0cb2ba2d..6c73de14e 100644 --- a/pkg/deviceagent/update/update.go +++ b/pkg/deviceagent/update/update.go @@ -325,6 +325,10 @@ func (u *Updater) Apply(ctx context.Context, rel *Release) error { return fmt.Errorf("cannot extract archive: %w", err) } + if err := ensureSignatureCompatible(ctx, u.Logger, u.ExePath, extractedBinary); err != nil { + return fmt.Errorf("cannot verify code signature compatibility: %w", err) + } + if err := replaceBinary(u.ExePath, extractedBinary); err != nil { return fmt.Errorf("cannot replace agent binary: %w", err) }