Skip to content

Commit 6186268

Browse files
amascia-ggclaude
andcommitted
feat(packaging): code-sign the Rust binary inside the macOS wheel
An ad-hoc signature carries no signing identity, so the binary's designated requirement degenerates to its cdhash — which changes on every build. That requirement is what a Keychain item's ACL records when the user clicks "Always Allow", so an unsigned wheel re-prompts for the API token on every upgrade. hatch_build.py signs the fused binary right after lipo, because the wheel is zipped around it and nothing downstream can. The rcodesign invocation moves out of macos_sign_file into scripts/build-os-packages/macos-sign-file so the standalone bundle and the wheel share one set of binary identifiers, and the `ggshield` in both ends up with the same designated requirement. tag.yml asks for signing explicitly; a run that cannot read the secrets fails rather than publishing an unsigned wheel. Without `sign` — PRs, including from forks, and local builds — the wheel is built unsigned and still works. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d28020a commit 6186268

6 files changed

Lines changed: 147 additions & 32 deletions

File tree

.github/workflows/tag.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ jobs:
1717
# `push_to_pypi` below can download them.
1818
build_platform_wheels:
1919
uses: ./.github/workflows/wheels.yml
20+
secrets: inherit
21+
with:
22+
sign: true
2023

2124
push_to_pypi:
2225
needs: [build_release_assets, build_platform_wheels]

.github/workflows/wheels.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,17 @@ on:
1818
# Called by tag.yml, so a release actually publishes these. Same run as
1919
# `build_wheel_sdist`, which is what lets `push_to_pypi` download both.
2020
workflow_call:
21+
inputs:
22+
sign:
23+
description: 'Code-sign the macOS binary (needs secrets, so no PR from a fork can)'
24+
type: boolean
25+
default: false
2126
workflow_dispatch:
27+
inputs:
28+
sign:
29+
description: 'Code-sign the macOS binary (needs secrets, so no PR from a fork can)'
30+
type: boolean
31+
default: false
2232
# Only the packaging inputs: nothing else changes what these wheels contain,
2333
# and six jobs on every push is a lot of runner time for that.
2434
pull_request:
@@ -36,6 +46,11 @@ env:
3646
# standalone bundles; bump both together. Checksums from
3747
# https://static.rust-lang.org/rustup/archive/$RUSTUP_VERSION/$triple/rustup-init.sha256
3848
RUSTUP_VERSION: 1.29.0
49+
# Same rcodesign as build_release_assets.yml installs for the standalone
50+
# bundles; bump both together. The checksum is the aarch64 one -- the macOS
51+
# wheel is built on macos-14 and lipo'd, so there is no Intel runner here.
52+
RCODESIGN_VERSION: 0.27.0
53+
RCODESIGN_SHA256: 163520079cd6ad1427791c792735a6ddfcb8eca0187bbcf0cc0bebfa4a62153d
3954

4055
jobs:
4156
macos:
@@ -49,6 +64,44 @@ jobs:
4964
- uses: dtolnay/rust-toolchain@stable
5065
with:
5166
targets: aarch64-apple-darwin,x86_64-apple-darwin
67+
# Only `sign: true` callers get here, and for them signing is not
68+
# best-effort: an unsigned `ggshield` publishes fine and only shows up
69+
# months later, as every upgrade re-prompting for Keychain access. Fail
70+
# instead of shipping one.
71+
- name: Prepare code signing
72+
if: inputs.sign
73+
env:
74+
MACOS_P12_FILE: ${{ secrets.MACOS_P12_FILE }}
75+
MACOS_P12_PASSWORD: ${{ secrets.MACOS_P12_PASSWORD }}
76+
run: |
77+
set -euo pipefail
78+
if [ -z "$MACOS_P12_FILE" ] ; then
79+
echo "sign: true, but the signing secrets are not readable here" >&2
80+
exit 1
81+
fi
82+
83+
# scripts/download needs `sha256sum`, which macOS does not ship.
84+
brew install coreutils
85+
# Unpacked outside the checkout: `python -m build` runs on it next.
86+
scripts/download \
87+
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F$RCODESIGN_VERSION/apple-codesign-$RCODESIGN_VERSION-aarch64-apple-darwin.tar.gz" \
88+
"$RUNNER_TEMP/rcodesign.tar.gz" \
89+
"$RCODESIGN_SHA256"
90+
tar -C "$RUNNER_TEMP" --strip-components=1 -xzf "$RUNNER_TEMP/rcodesign.tar.gz"
91+
cp "$RUNNER_TEMP/rcodesign" /usr/local/bin
92+
93+
SECRETS_DIR=$RUNNER_TEMP/secrets
94+
mkdir -p "$SECRETS_DIR"
95+
# The p12 is base64-encoded because it is binary.
96+
echo "$MACOS_P12_FILE" | base64 --decode > "$SECRETS_DIR/cert.p12"
97+
echo "$MACOS_P12_PASSWORD" > "$SECRETS_DIR/cert.pwd"
98+
cat >> "$GITHUB_ENV" <<EOF
99+
MACOS_P12_FILE=$SECRETS_DIR/cert.p12
100+
MACOS_P12_PASSWORD_FILE=$SECRETS_DIR/cert.pwd
101+
EOF
102+
103+
# hatch_build.py signs the binary itself, right after lipo: nothing outside
104+
# the build can, because the wheel is zipped around it.
52105
- name: Build wheel
53106
env:
54107
GGSHIELD_BUILD_RUST: '1'
@@ -62,6 +115,11 @@ jobs:
62115
name: wheel-macos-universal2
63116
path: dist/*.whl
64117

118+
# Not Authenticode-signed, unlike the .msi and .zip: nothing on Windows binds a
119+
# stored credential to a binary's signature the way a macOS Keychain ACL does,
120+
# so the only thing signing buys the wheel is SmartScreen reputation on a
121+
# binary users reach through pip rather than a download. Separate concern,
122+
# separate ticket.
65123
windows:
66124
name: wheel windows-amd64
67125
runs-on: windows-latest

doc/dev/os-packages.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ Although PyInstaller supports signing, it did not work at the time we tried it,
7070

7171
For Gatekeeper to accept the app, the executable and all the dynamic libraries must be signed, as well as the .pkg archive itself. Signing the executable and the libraries is done by the `sign` step, whereas signing the .pkg archive is done by the `create_archive` step.
7272

73+
The `rcodesign` invocation lives in its own script, `scripts/build-os-packages/macos-sign-file`, because the macOS wheel signs the same `ggshield` from Python (`hatch_build.py`). Both must produce the same designated requirement, or a Keychain grant given to one artifact does not apply to the other.
74+
7375
[rcodesign]: https://gregoryszorc.com/docs/apple-codesign/
7476

7577
## Windows specific information

hatch_build.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,25 @@ def _verify_elf(binary: Path, plat: str) -> None:
107107
f"which does not match the {plat} wheel tag"
108108
)
109109

110+
def _sign(self, binary: Path) -> None:
111+
"""Code-sign the fused binary, when the build was given a certificate.
112+
113+
On the fused file rather than the slices: one signature covers both, and
114+
cargo leaves the x86_64 slice unsigned altogether.
115+
116+
Unsigned -- local builds and PRs from forks, neither of which can read the
117+
secrets -- the wheel still installs and runs. What it loses is a stable
118+
designated requirement: an ad-hoc signature carries no signing identity,
119+
so the requirement degenerates to the binary's cdhash, which changes on
120+
every build. That requirement is what a Keychain item's ACL records, so
121+
the grant the user gives `ggshield` for the API token stops matching on
122+
the next upgrade.
123+
"""
124+
if not os.environ.get("MACOS_P12_FILE"):
125+
return
126+
script = Path(self.root) / "scripts/build-os-packages/macos-sign-file"
127+
subprocess.run([str(script), str(binary)], check=True)
128+
110129
def _build(self, crate: Path, binary: Path) -> None:
111130
# --locked: build the dependency versions committed in rust/Cargo.lock,
112131
# not whatever resolves today.
@@ -139,6 +158,7 @@ def _build(self, crate: Path, binary: Path) -> None:
139158
subprocess.run(
140159
["lipo", "-create", "-output", str(binary), *slices], check=True
141160
)
161+
self._sign(binary)
142162
else:
143163
subprocess.run(
144164
["cargo", "build", "--release", "--locked", "--bin", "ggshield"],

scripts/build-os-packages/macos-functions.bash

Lines changed: 5 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
MACOS_P12_FILE=${MACOS_P12_FILE:-}
22
MACOS_P12_PASSWORD_FILE=${MACOS_P12_PASSWORD_FILE:-}
3+
# macos-sign-file is a separate process: without this it sees neither.
4+
export MACOS_P12_FILE MACOS_P12_PASSWORD_FILE
35

46
# Path to a file used by rcodesign for notarizing.
57
# Follow the instructions from
@@ -37,40 +39,11 @@ macos_sign() {
3739
done
3840
}
3941

40-
# $1 is the file to sign, $2 an optional entitlements plist.
42+
# $1 is the file to sign, $2 an optional entitlements plist. The invocation lives
43+
# in macos-sign-file, shared with the macOS wheel build.
4144
macos_sign_file() {
4245
check_var MACOS_P12_FILE
43-
44-
local file entitlements
45-
file="$1"
46-
entitlements="${2:-}"
47-
info "- Signing $file${entitlements:+ (with entitlements)}"
48-
49-
local args=(
50-
--p12-file "$MACOS_P12_FILE"
51-
--p12-password-file "$MACOS_P12_PASSWORD_FILE"
52-
--code-signature-flags runtime
53-
--for-notarization
54-
)
55-
if [ -n "$entitlements" ] ; then
56-
args+=(--entitlements-xml-path "$entitlements")
57-
fi
58-
59-
# Left alone, rcodesign keeps the ad-hoc identifier the linker left in the
60-
# Mach-O, which carries a per-build hash: 1.53.0 shipped as
61-
# `ggshield-55554944c60d09a76c903cb78828e61396fa0721`. The identifier is part
62-
# of the designated requirement, and the designated requirement is what a
63-
# Keychain item's ACL records when the user clicks "Always Allow" — so a
64-
# per-build identifier means the grant stops matching on the next release and
65-
# every upgrade re-prompts for the token. Both launchers read the token, so
66-
# both need a stable one; libraries keep their own, they are never the
67-
# process asking for the secret.
68-
case "$(basename "$file")" in
69-
ggshield) args+=(--binary-identifier com.gitguardian.ggshield) ;;
70-
ggshield-py) args+=(--binary-identifier com.gitguardian.ggshield-py) ;;
71-
esac
72-
73-
rcodesign sign "${args[@]}" "$file"
46+
"$SCRIPT_DIR/macos-sign-file" "$@"
7447
}
7548

7649
# Every Mach-O in the bundle, because notarization rejects the .pkg if a single
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
#!/usr/bin/env bash
2+
# Code-sign one Mach-O with the GitGuardian Developer ID certificate.
3+
#
4+
# A script rather than a function in macos-functions.bash because the macOS wheel
5+
# signs from Python (hatch_build.py) and the standalone bundle from bash: the
6+
# `ggshield` in both is the same dispatcher reading the same Keychain item, so
7+
# both must produce the same designated requirement — one invocation, one set of
8+
# identifiers.
9+
#
10+
# $1 is the file to sign, $2 an optional entitlements plist. $MACOS_P12_FILE and
11+
# $MACOS_P12_PASSWORD_FILE must point to the certificate and to its password.
12+
set -euo pipefail
13+
14+
file="${1:?usage: $0 FILE [ENTITLEMENTS]}"
15+
entitlements="${2:-}"
16+
if [ -z "${MACOS_P12_FILE:-}" ] || [ -z "${MACOS_P12_PASSWORD_FILE:-}" ] ; then
17+
echo "$0: MACOS_P12_FILE and MACOS_P12_PASSWORD_FILE must be set" >&2
18+
exit 1
19+
fi
20+
21+
echo "- Signing $file${entitlements:+ (with entitlements)}"
22+
23+
args=(
24+
--p12-file "$MACOS_P12_FILE"
25+
--p12-password-file "$MACOS_P12_PASSWORD_FILE"
26+
--code-signature-flags runtime
27+
--for-notarization
28+
)
29+
if [ -n "$entitlements" ] ; then
30+
args+=(--entitlements-xml-path "$entitlements")
31+
fi
32+
33+
# Left alone, rcodesign keeps the ad-hoc identifier the linker left in the
34+
# Mach-O, which carries a per-build hash: 1.53.0 shipped as
35+
# `ggshield-55554944c60d09a76c903cb78828e61396fa0721`. The identifier is part of
36+
# the designated requirement, and the designated requirement is what a Keychain
37+
# item's ACL records when the user clicks "Always Allow" — so a per-build
38+
# identifier means the grant stops matching on the next release and every upgrade
39+
# re-prompts for the token. Both launchers read the token, so both need a stable
40+
# one; libraries keep their own, they are never the process asking for the secret.
41+
identifier=""
42+
case "$(basename "$file")" in
43+
ggshield) identifier=com.gitguardian.ggshield ;;
44+
ggshield-py) identifier=com.gitguardian.ggshield-py ;;
45+
esac
46+
[ -z "$identifier" ] || args+=(--binary-identifier "$identifier")
47+
48+
rcodesign sign "${args[@]}" "$file"
49+
50+
# A wrong identifier signs, notarizes and installs perfectly happily; it only
51+
# surfaces later as a re-prompt on upgrade. Check the one thing the ACL depends on.
52+
if [ -n "$identifier" ] ; then
53+
# --verbose, or codesign prints only the path. Everything goes to stderr.
54+
signed_as=$(codesign --display --verbose "$file" 2>&1 | sed -n 's/^Identifier=//p')
55+
if [ "$signed_as" != "$identifier" ] ; then
56+
echo "FAIL: $file is signed as '$signed_as', not $identifier" >&2
57+
exit 1
58+
fi
59+
fi

0 commit comments

Comments
 (0)