Stop macOS agent updates from spawning BTM entries
Unsigned darwin release tarballs replaced the Developer ID binary on each auto-update, so Background Task Management treated every release as a new identity and showed the generic executable icon. Sign those archives with a stable identifier, refuse signature downgrades, and attribute daemon/tray jobs to Probo Agent.app via AssociatedBundleIdentifiers. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
8
.github/workflows/release-probo-agent.yaml
vendored
8
.github/workflows/release-probo-agent.yaml
vendored
@@ -126,6 +126,7 @@ jobs:
|
|||||||
LDFLAGS="-s -w -X 'main.version=${VERSION}'"
|
LDFLAGS="-s -w -X 'main.version=${VERSION}'"
|
||||||
|
|
||||||
mkdir -p dist archives staging
|
mkdir -p dist archives staging
|
||||||
|
export COPYFILE_DISABLE=1
|
||||||
|
|
||||||
GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" \
|
GOOS=darwin GOARCH=arm64 go build -ldflags "${LDFLAGS}" \
|
||||||
-gcflags="-e" -o dist/probo-agent_arm64 ./cmd/probo-agent
|
-gcflags="-e" -o dist/probo-agent_arm64 ./cmd/probo-agent
|
||||||
@@ -147,6 +148,13 @@ jobs:
|
|||||||
if [ -f cmd/probo-agent/CHANGELOG.md ]; then
|
if [ -f cmd/probo-agent/CHANGELOG.md ]; then
|
||||||
cp cmd/probo-agent/CHANGELOG.md "staging/${AGENT_DIR}/"
|
cp cmd/probo-agent/CHANGELOG.md "staging/${AGENT_DIR}/"
|
||||||
fi
|
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}"
|
tar -czf "archives/${AGENT_DIR}.tar.gz" -C staging "${AGENT_DIR}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -151,12 +151,17 @@ fi
|
|||||||
|
|
||||||
codesign_runtime() {
|
codesign_runtime() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
codesign \
|
local identifier="${2:-}"
|
||||||
--force \
|
local -a args=(
|
||||||
--options runtime \
|
--force
|
||||||
--timestamp \
|
--options runtime
|
||||||
--sign "${CODESIGN_IDENTITY}" \
|
--timestamp
|
||||||
"${path}"
|
--sign "${CODESIGN_IDENTITY}"
|
||||||
|
)
|
||||||
|
if [ -n "${identifier}" ]; then
|
||||||
|
args+=(--identifier "${identifier}")
|
||||||
|
fi
|
||||||
|
codesign "${args[@]}" "${path}"
|
||||||
codesign --verify --verbose=2 "${path}"
|
codesign --verify --verbose=2 "${path}"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,7 +396,7 @@ RESOURCES="${STAGE}/Resources"
|
|||||||
mkdir -p "${PAYLOAD}/usr/local/bin" "${SCRIPTS}" "${RESOURCES}"
|
mkdir -p "${PAYLOAD}/usr/local/bin" "${SCRIPTS}" "${RESOURCES}"
|
||||||
|
|
||||||
install -m 0755 "${BINARY}" "${PAYLOAD}/usr/local/bin/probo-agent"
|
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"
|
mkdir -p "${PAYLOAD}/Applications"
|
||||||
build_probo_agent_app "${PAYLOAD}/Applications"
|
build_probo_agent_app "${PAYLOAD}/Applications"
|
||||||
|
|||||||
@@ -24,5 +24,9 @@
|
|||||||
<string>root</string>
|
<string>root</string>
|
||||||
<key>GroupName</key>
|
<key>GroupName</key>
|
||||||
<string>wheel</string>
|
<string>wheel</string>
|
||||||
|
<key>AssociatedBundleIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.probo.agent.url-handler</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
_ "embed"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -39,35 +40,14 @@ const (
|
|||||||
helperBinaryPath = "/Library/PrivilegedHelperTools/" + helperLabel
|
helperBinaryPath = "/Library/PrivilegedHelperTools/" + helperLabel
|
||||||
)
|
)
|
||||||
|
|
||||||
const launchdPlistTmpl = `<?xml version="1.0" encoding="UTF-8"?>
|
var (
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
//go:embed launchd.plist.tmpl
|
||||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
launchdPlistTmpl string
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
launchdPlist = template.Must(
|
||||||
<key>Label</key>
|
template.New("plist").Funcs(template.FuncMap{"xml": xmlEscape}).Parse(launchdPlistTmpl),
|
||||||
<string>{{xml .Label}}</string>
|
)
|
||||||
<key>ProgramArguments</key>
|
)
|
||||||
<array>
|
|
||||||
<string>{{xml .ExePath}}</string>
|
|
||||||
<string>run</string>
|
|
||||||
<string>--dir</string>
|
|
||||||
<string>{{xml .Dir}}</string>
|
|
||||||
</array>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>/var/log/probo-agent.log</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>/var/log/probo-agent.log</string>
|
|
||||||
<key>UserName</key>
|
|
||||||
<string>root</string>
|
|
||||||
<key>GroupName</key>
|
|
||||||
<string>wheel</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
`
|
|
||||||
|
|
||||||
func xmlEscape(v string) (string, error) {
|
func xmlEscape(v string) (string, error) {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -126,11 +106,6 @@ func Install(cfg Config) error {
|
|||||||
cfg.Label = DefaultLabel
|
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 {
|
if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil {
|
||||||
return fmt.Errorf("cannot ensure launch daemons directory: %w", err)
|
return fmt.Errorf("cannot ensure launch daemons directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -142,7 +117,7 @@ func Install(cfg Config) error {
|
|||||||
|
|
||||||
defer func() { _ = f.Close() }()
|
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)
|
return fmt.Errorf("cannot render plist: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,9 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>KeepAlive</key>
|
<key>KeepAlive</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>AssociatedBundleIdentifiers</key>
|
||||||
|
<array>
|
||||||
|
<string>com.probo.agent.url-handler</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
93
pkg/deviceagent/update/install_signature_darwin.go
Normal file
93
pkg/deviceagent/update/install_signature_darwin.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
106
pkg/deviceagent/update/install_signature_identity.go
Normal file
106
pkg/deviceagent/update/install_signature_identity.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
171
pkg/deviceagent/update/install_signature_identity_test.go
Normal file
171
pkg/deviceagent/update/install_signature_identity_test.go
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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"))
|
||||||
|
}
|
||||||
38
pkg/deviceagent/update/install_signature_other.go
Normal file
38
pkg/deviceagent/update/install_signature_other.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -325,6 +325,10 @@ func (u *Updater) Apply(ctx context.Context, rel *Release) error {
|
|||||||
return fmt.Errorf("cannot extract archive: %w", err)
|
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 {
|
if err := replaceBinary(u.ExePath, extractedBinary); err != nil {
|
||||||
return fmt.Errorf("cannot replace agent binary: %w", err)
|
return fmt.Errorf("cannot replace agent binary: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user