build(signing): 签名身份按 config 注入,Debug 免证书构建

证书与 Team 不入库(开源仓库):Debug 基线改为 ad-hoc,新 clone 与 CI 无
需任何证书即可 task build / task test;Release 基线保持 Developer ID,发
布产物仍须真签名。本机身份由 gitignore 的 Configs/Signing.local.xcconfig
注入,打包门槛的 Team 期望值同源,缺失时明确失败而非静默跳过。

project.yml 不能再设置 CODE_SIGN_IDENTITY / DEVELOPMENT_TEAM:pbxproj 构
建设置优先于 project xcconfig,写死会静默盖掉基线(实测)。整条注入链条
由 Tests/Architecture/SigningConfigurationTests.swift 守卫。

ad-hoc 产物不带 Hardened Runtime(Xcode 对 ad-hoc 签名不传 -o runtime),
嵌套 helper 重签与之一致;Hardened Runtime 仍由 DevID 路径与打包门槛断言。
This commit is contained in:
Qiu Yuzhou
2026-10-05 21:25:12 +08:00
parent aa9d83149b
commit 9b6d73e91c
8 changed files with 251 additions and 23 deletions
+2
View File
@@ -96,6 +96,8 @@ ShadowsocksX-NG2/DerivedData/
####
# NG2 generated build files (project.yml is the source of truth; run `xcodegen generate`)
ShadowsocksX-NG2/*.xcodeproj
# 本机签名身份(DEVELOPMENT_TEAM),构建期由 Configs/Signing.xcconfig 可选包含
ShadowsocksX-NG2/Configs/Signing.local.xcconfig
ShadowsocksX-NG2/App/Resources/Info.plist
ShadowsocksX-NG2/App/Resources/ShadowsocksX-NG2.entitlements
ShadowsocksX-NG2/Tests/Info.plist
@@ -0,0 +1,17 @@
// Debug 签名基线:无证书即可构建(贡献者与 CI)。
//
// 基线是 ad-hoc(`-`)。arm64 上代码至少要有一层有效签名才会被加载,所以「免证书」
// 不能靠不签名,只能靠 ad-hoc。ad-hoc 产物可以构建、可以跑 app、可以跑全部单测,
// 但过不了 Scripts/packaging-gate.sh —— 那是有证书的发布门槛。
//
// 有证书的人(含维护者)在 Configs/Signing.local.xcconfig 里显式升级,例如:
//
// DEVELOPMENT_TEAM = <10 位 Apple Developer Team ID>
// CODE_SIGN_IDENTITY = Developer ID Application
//
// 该本机文件在 .gitignore 中,不入库;CI 不需要它。
//
// `#include?` 必须留在最后一行:xcconfig 中后出现的赋值优先,本机文件才能盖掉
// 上面的 ad-hoc 基线。
CODE_SIGN_IDENTITY = -
#include? "Signing.local.xcconfig"
@@ -0,0 +1,12 @@
// Release 签名基线:发布产物必须是 Developer ID 真签名(Hardened Runtime、公证与
// Gatekeeper 的前提),因此这里不给免证书路径。
//
// Team 只能来自本机注入的 Configs/Signing.local.xcconfig(gitignore)。缺失时
// DEVELOPMENT_TEAM 为空,Xcode 以「Signing for ... requires selecting either a
// development team or a provisioning profile」使构建明确失败,不会静默降级成
// ad-hoc。
//
// `#include?` 放在 Developer ID 之前:本机文件可以补 Team(另一个键),但不能把
// 发布身份改成 ad-hoc 或开发证书——Release 基线在这里是硬约束,后赋值者胜。
#include? "Signing.local.xcconfig"
CODE_SIGN_IDENTITY = Developer ID Application
+20 -2
View File
@@ -59,8 +59,22 @@ ShadowsocksX-NG 的现代化重写版本。新工程的全部源码与构建配
## 签名与运行形态基线
- bundle id `com.qiuyuzhou.ShadowsocksX-NG2`,与 Legacy(`com.qiuyuzhou.ShadowsocksX-NG`)区分;运行时数据落 `~/Library/Application Support/ShadowsocksX-NG2/`。macOS 15+ / arm64。
- Hardened Runtime + Developer ID 签名(`DEVELOPMENT_TEAM` / 证书见 `project.yml`);不启用 App Sandbox,entitlements 为空 dict。
- 测试 target 用 Apple Development 证书签名:宿主 app 开启 Hardened Runtime 后 library validation 要求被注入的 xctest 包同 Team 签名。
- Hardened Runtime(DevID 发布路径;免证书的 ad-hoc Debug 例外,见下);不启用 App Sandbox,entitlements 为空 dict;签名样式固定 `Manual`。
- **证书与 Team 都不入库**(开源仓库):`project.yml` 不含 `CODE_SIGN_IDENTITY` / `DEVELOPMENT_TEAM`,两个键由 project xcconfig 按 config 在构建期注入:
- `Configs/Signing-Debug.xcconfig`(入库)基线是 **ad-hoc(`-`)**:新 clone 与 CI 不需要任何证书就能 `task build` / `task test`。arm64 要求代码至少有 ad-hoc 签名才会被加载,所以「免证书」不是「不签名」。ad-hoc 产物不带 Hardened Runtime(Xcode 对 ad-hoc 签名不传 `-o runtime`,实测;`Scripts/sign-embedded-helpers.sh` 的 ad-hoc 分支同样不加),Hardened Runtime 由 DevID 发布路径与打包门槛断言。
- `Configs/Signing-Release.xcconfig`(入库)基线是 **Developer ID Application**:发布产物必须真签名,这里不给免证书路径。
- 两者都可选包含 `Configs/Signing.local.xcconfig`(gitignore)。有证书的人(含维护者)用它升级本机身份,例如让 Debug 也走 Developer ID:
```bash
cat > Configs/Signing.local.xcconfig <<'EOF'
DEVELOPMENT_TEAM = YOUR_TEAM_ID
CODE_SIGN_IDENTITY = Developer ID Application
EOF
```
- 该文件缺失时 Debug 构建为 ad-hoc(可跑单测),Release 构建以 Xcode 的「Signing for … requires selecting either a development team or a provisioning profile」明确失败,不静默降级。
- 测试 target(单测与冒烟)身份随同一基线:去宿主化(ADR-0021)后 xctest 包不再注入宿主 app,ad-hoc 也能加载执行(实测),因此测试无需证书。
- 注意 `project.yml` 一旦重新设置这两个键,pbxproj 构建设置会盖掉 xcconfig(`Tests/Architecture/SigningConfigurationTests.swift` 守卫该约束)。
- Release 配置关闭 `CODE_SIGN_INJECT_BASE_ENTITLEMENTS`,保证发布产物 entitlements 只来自项目文件。
## 开发、构建与测试
@@ -86,6 +100,8 @@ Scripts/fetch-external-binaries.sh
task gen:prj
```
无证书也能构建与跑单测:Debug 基线是 ad-hoc,不需要 `Configs/Signing.local.xcconfig`。只有要做 Developer ID 签名(发布,或让本机 Debug 产物过打包门槛)时才写该文件,内容见上节「签名与运行形态基线」。
`Tests/Smoke/` 是真实进程冒烟(经 wrapper 拉起真实 sslocal 验证启动、握手、
ACL 路由与插件监管),不在默认 `task test` 内,由独立 scheme 按需运行。
@@ -98,6 +114,8 @@ Scripts/packaging-gate.sh \
.build/derivedData/Build/Products/Debug/ShadowsocksX-NG2.app
```
打包门槛只接受 Developer ID 签名的产物:Team 期望值取自 `Configs/Signing.local.xcconfig`(可用 `EXPECTED_TEAM_ID=<Team ID>` 显式覆盖),两者都没有时该断言直接失败,不静默跳过。无证书的 ad-hoc Debug 产物会在此明确失败(`gate: hint` 会说明原因),这是预期行为。
## 代码风格工具
- `swift format` 随 Xcode/Swift 工具链提供,无需单独安装(基线:Apple Swift 6.4);SwiftLint 需 `brew install swiftlint`(基线 0.65.0)。工具缺失时,含本目录 Swift 文件的提交会被 pre-commit 钩子拒绝而不是放行。
+27 -5
View File
@@ -12,7 +12,7 @@
# 4. The manifest set exactly covers every Mach-O under Contents/Helpers.
#
# Usage: Scripts/packaging-gate.sh path/to/ShadowsocksX-NG2.app
# EXPECTED_TEAM_ID overrides the pinned Team ID check.
# EXPECTED_TEAM_ID overrides the Team ID read from the local signing config.
set -u
@@ -21,8 +21,20 @@ SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
NG2_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
VENDOR_DIR="$NG2_ROOT/Vendor"
# Mirrors DEVELOPMENT_TEAM in project.yml.
EXPECTED_TEAM_ID=${EXPECTED_TEAM_ID:-S878RH3PA8}
# Expected Team ID is injected, not pinned in the repo (open source). The same
# file the build reads supplies it here: Configs/Signing.local.xcconfig
# (gitignored). An explicit EXPECTED_TEAM_ID wins. Knowing no expected team is a
# hard failure: an unsigned or wrong-team bundle must not pass just because
# there is nothing to compare against.
LOCAL_SIGNING_CONFIG="$NG2_ROOT/Configs/Signing.local.xcconfig"
EXPECTED_TEAM_ID=${EXPECTED_TEAM_ID:-}
if [ -z "$EXPECTED_TEAM_ID" ] && [ -f "$LOCAL_SIGNING_CONFIG" ]; then
EXPECTED_TEAM_ID=$(
sed -n 's/^[[:space:]]*DEVELOPMENT_TEAM[[:space:]]*=[[:space:]]*\([^[:space:]]*\).*/\1/p' \
"$LOCAL_SIGNING_CONFIG" | tail -1
)
fi
APP=${1:-}
[ -n "$APP" ] || {
@@ -81,9 +93,19 @@ app_team=$(dv_value "$app_dv" TeamIdentifier)
app_authority=$(dv_value "$app_dv" Authority)
app_flags=$(dv_value "$app_dv" flags)
[ "$app_team" = "$EXPECTED_TEAM_ID" ] ||
fail "main app TeamIdentifier is $EXPECTED_TEAM_ID (got: ${app_team:-none})"
if [ -z "$EXPECTED_TEAM_ID" ]; then
fail "expected Team ID is known (set EXPECTED_TEAM_ID or write DEVELOPMENT_TEAM in Configs/Signing.local.xcconfig)"
else
[ "$app_team" = "$EXPECTED_TEAM_ID" ] ||
fail "main app TeamIdentifier is $EXPECTED_TEAM_ID (got: ${app_team:-none})"
fi
check_devid_and_runtime "main app" "$app_dv"
case "$app_authority" in
"Developer ID Application:"*) ;;
*)
echo "gate: hint ad-hoc / non-Developer-ID artifacts come from the certificate-less Debug baseline; only a Developer ID signed release artifact can pass"
;;
esac
app_entitlements=$(codesign -d --entitlements - --xml "$APP" 2>/dev/null || true)
for forbidden in com.apple.security.app-sandbox com.apple.security.network.server; do
@@ -2,11 +2,17 @@
# Re-sign every embedded helper binary with the Developer ID identity before
# Xcode signs the app itself (spec #21 D6/D10).
#
# Upstream ships these binaries ad-hoc signed; they must carry our Developer ID
# signature with Hardened Runtime and a stable code-signing identifier so the
# outer app seal, notarization and Gatekeeper cover them. Xcode runs this as a
# post-build script phase and applies the app's own signature afterwards, so
# the outer signature embeds the re-signed nested code.
# Upstream ships these binaries ad-hoc signed; they must carry a signature with
# Hardened Runtime and a stable code-signing identifier so the outer app seal,
# notarization and Gatekeeper cover them. Xcode runs this as a post-build script
# phase and applies the app's own signature afterwards, so the outer signature
# embeds the re-signed nested code.
#
# The identity comes from the build configuration: Developer ID on a release
# build, ad-hoc ("-") on the certificate-less Debug baseline. Ad-hoc mirrors
# Xcode's own ad-hoc signing: no secure timestamp and no Hardened Runtime (Xcode
# omits -o runtime for ad-hoc; hardened runtime with an untrusted identity would
# only get in the way of local debugging). The release path is unchanged.
#
# The identifier scheme is pinned in the manifests: <bundle-id>.<name> for
# sslocal, <bundle-id>.plugin.<name> for SIP003 plugins.
@@ -45,7 +51,11 @@ for manifest in $manifests; do
exit 1
}
codesign --force --sign "$IDENTITY" --timestamp --options runtime \
--identifier "$identifier" "$target"
if [ "$IDENTITY" = "-" ]; then
codesign --force --sign - --identifier "$identifier" "$target"
else
codesign --force --sign "$IDENTITY" --timestamp --options runtime \
--identifier "$identifier" "$target"
fi
echo "sign($binary): $identifier"
done
@@ -0,0 +1,141 @@
import Foundation
import Testing
/// 签名身份注入守卫:证书与 Team 都不入库(开源仓库),由 config 基线 + 本机
/// xcconfig 在构建期决定。
///
/// 这条约束容易被无声破坏:`project.yml` 一旦重新设置 `DEVELOPMENT_TEAM` 或
/// `CODE_SIGN_IDENTITY`,pbxproj 里的构建设置会盖掉 `Configs/Signing-*.xcconfig`
/// (Xcode 构建设置优先级,实测),注入失效却不报错。本套用例把整条链条钉住:
/// 工程定义不碰签名键 → config 基线各自正确且本机文件能(或不能)盖掉它 →
/// 本机文件不入库 → 供应链脚本与发布门槛跟着基线走。
struct SigningConfigurationTests {
private static let ng2Root = TestSourceTree.ng2Root()
private static let repositoryRoot = ng2Root.deletingLastPathComponent()
/// 逐行匹配:`String.CompareOptions` 没有 `.anchorsMatchLines`,整段文本上
/// `^`/`$` 只锚定字符串首尾,因此把行切开再套正则。
private static func matchingLines(_ pattern: String, in text: String) -> [String] {
text.split(separator: "\n", omittingEmptySubsequences: false)
.filter { $0.range(of: pattern, options: .regularExpression) != nil }
.map(String.init)
}
private static func firstLineIndex(matching pattern: String, in text: String) -> Int? {
text.split(separator: "\n", omittingEmptySubsequences: false)
.firstIndex { $0.range(of: pattern, options: .regularExpression) != nil }
}
private static func text(_ relativePath: String) throws -> String {
try String(contentsOf: ng2Root.appendingPathComponent(relativePath), encoding: .utf8)
}
/// 只命中 YAML 键(行首为 `KEY:`),注释行以 `#` 开头,不算设置。
private static func yamlKeyPattern(_ key: String) -> String { #"^\s*\#(key)\s*:"# }
private static let identityAssignment = #"^\s*CODE_SIGN_IDENTITY\s*="#
private static let localInclude = #"^\s*#include\?\s+"Signing\.local\.xcconfig"#
@Test
func projectSpecDoesNotPinSigningKeys() throws {
let spec = try Self.text("project.yml")
for key in ["DEVELOPMENT_TEAM", "CODE_SIGN_IDENTITY"] {
let pinned = Self.matchingLines(Self.yamlKeyPattern(key), in: spec)
#expect(
pinned.isEmpty,
"project.yml 不得设置 \(key)(pbxproj 设置会静默盖掉 Configs/Signing-*.xcconfig,且证书/Team 不应入库):\(pinned)"
)
}
}
@Test
func projectSpecInjectsPerConfigurationBaselines() throws {
let spec = try Self.text("project.yml")
let expected = [
"Debug": "Configs/Signing-Debug.xcconfig",
"Release": "Configs/Signing-Release.xcconfig",
]
for (configuration, path) in expected {
let pattern = #"^\s*\#(configuration):\s*\#(path)\s*$"#
#expect(
!Self.matchingLines(pattern, in: spec).isEmpty,
"project.yml 的 configFiles 必须把 \(configuration) 挂到 \(path)")
}
}
/// Debug 必须无证书可构建:基线 ad-hoc,且本机文件能把它升级成真证书。
@Test
func debugBaselineIsAdHocAndUpgradableByLocalFile() throws {
let config = try Self.text("Configs/Signing-Debug.xcconfig")
#expect(
Self.matchingLines(#"^\s*CODE_SIGN_IDENTITY\s*=\s*-\s*$"#, in: config).count == 1,
"Configs/Signing-Debug.xcconfig 必须恰好有一条 ad-hoc(-)CODE_SIGN_IDENTITY 基线")
let baseline = try #require(
Self.firstLineIndex(matching: Self.identityAssignment, in: config),
"Configs/Signing-Debug.xcconfig 缺少 CODE_SIGN_IDENTITY 基线")
let include = try #require(
Self.firstLineIndex(matching: Self.localInclude, in: config),
"Configs/Signing-Debug.xcconfig 必须包含 Configs/Signing.local.xcconfig")
#expect(
baseline < include,
"Debug 的 #include? 必须在 ad-hoc 基线之后:xcconfig 后赋值者胜,本机文件才能升级身份")
}
/// Release 必须真签名:本机文件只能补 Team,不能把发布身份改成 ad-hoc。
@Test
func releaseBaselineStaysDeveloperID() throws {
let config = try Self.text("Configs/Signing-Release.xcconfig")
let assignments = Self.matchingLines(Self.identityAssignment, in: config)
#expect(
assignments.last?.contains("Developer ID Application") == true,
"Configs/Signing-Release.xcconfig 最后生效的 CODE_SIGN_IDENTITY 必须是 Developer ID Application:\(assignments)"
)
let include = try #require(
Self.firstLineIndex(matching: Self.localInclude, in: config),
"Configs/Signing-Release.xcconfig 必须包含 Configs/Signing.local.xcconfig")
let baseline = try #require(
Self.firstLineIndex(matching: Self.identityAssignment, in: config),
"Configs/Signing-Release.xcconfig 缺少 CODE_SIGN_IDENTITY 基线")
#expect(include < baseline, "Release 的 #include? 必须在 Developer ID 基线之前,发布身份不被本机文件削弱")
}
@Test
func committedBaselinesDoNotPinTeamID() throws {
for path in ["Configs/Signing-Debug.xcconfig", "Configs/Signing-Release.xcconfig"] {
let config = try Self.text(path)
#expect(
Self.matchingLines(#"^\s*DEVELOPMENT_TEAM\s*="#, in: config).isEmpty,
"入库的 \(path) 不得固化 DEVELOPMENT_TEAM")
}
}
@Test
func localSigningConfigIsGitIgnored() throws {
let ignoreList = try String(
contentsOf: Self.repositoryRoot.appendingPathComponent(".gitignore"), encoding: .utf8)
#expect(
ignoreList.contains("ShadowsocksX-NG2/Configs/Signing.local.xcconfig"),
".gitignore 必须忽略 Configs/Signing.local.xcconfig(本机证书/Team 不入库)")
}
/// 嵌套二进制重签必须跟着基线走:ad-hoc 构建没有 secure timestamp。
@Test
func embeddedHelperSigningHandlesAdHocIdentity() throws {
let script = try Self.text("Scripts/sign-embedded-helpers.sh")
#expect(
!Self.matchingLines(#"--sign -"#, in: script).isEmpty,
"sign-embedded-helpers.sh 必须有无证书(ad-hoc)分支,否则 Debug 基线无法封套嵌套二进制")
}
/// 发布门槛的 Team 期望值同样不许写死:只能取自同一个注入文件或显式环境变量。
@Test
func packagingGateDerivesTeamIDFromInjectedConfig() throws {
let gate = try Self.text("Scripts/packaging-gate.sh")
#expect(
gate.contains("Signing.local.xcconfig"),
"packaging-gate.sh 必须从 Configs/Signing.local.xcconfig 读取期望 Team ID")
#expect(
Self.matchingLines(#":-[A-Z0-9]{10}\}"#, in: gate).isEmpty,
"packaging-gate.sh 不得给 Team ID 留 10 位写死的默认值")
}
}
+15 -9
View File
@@ -13,12 +13,22 @@ settings:
# 自动抽取会在 IDE 构建时膨胀文件(2026-10-04 #81 验收时实测)。
LOCALIZED_STRING_SWIFTUI_SUPPORT: NO
CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: S878RH3PA8
ENABLE_HARDENED_RUNTIME: YES
configs:
Release:
# 发布基线:签名 entitlements 必须只来自项目 entitlements 文件(保持最小)。
CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO
# 签名身份注入:证书与 Team 都不入库(开源仓库;写死会让 fork 与贡献者的构建去
# 找维护者的证书)。Debug 基线是 ad-hoc,无证书即可构建并跑单测;Release 基线是
# Developer ID,发布产物必须真签名。两者的本机 Team / 身份都来自 gitignore 的
# Configs/Signing.local.xcconfig,见 Configs/Signing-Debug.xcconfig 与
# Configs/Signing-Release.xcconfig。
# 所有 target 一律不设置 CODE_SIGN_IDENTITY:pbxproj 构建设置优先于 project
# xcconfig,写在 target 上会静默盖掉两个基线(实测)。该约束由
# Tests/Architecture/SigningConfigurationTests.swift 守卫。
configFiles:
Debug: Configs/Signing-Debug.xcconfig
Release: Configs/Signing-Release.xcconfig
targets:
ShadowsocksX-NG2:
type: application
@@ -110,7 +120,6 @@ targets:
# 共享 Products 目录,重名会互相覆盖;app 可执行自身不 import 任何
# 消费方,改名零影响。
PRODUCT_MODULE_NAME: ShadowsocksX_NG2App
CODE_SIGN_IDENTITY: "Developer ID Application"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: "2.0.0"
# 必须 > 旧版 1.x 已发布构建的 CFBundleVersion(始终为 1),保证 build 号严格递增。
@@ -194,7 +203,6 @@ targets:
base:
PRODUCT_NAME: ShadowsocksX-NG2Agent
PRODUCT_BUNDLE_IDENTIFIER: com.qiuyuzhou.ShadowsocksX-NG2.agent
CODE_SIGN_IDENTITY: "Developer ID Application"
ShadowsocksX-NG2SystemProxyHelper:
type: tool
platform: macOS
@@ -213,7 +221,6 @@ targets:
base:
PRODUCT_NAME: ShadowsocksX-NG2SystemProxyHelper
PRODUCT_BUNDLE_IDENTIFIER: com.qiuyuzhou.ShadowsocksX-NG2.systemproxy.helper
CODE_SIGN_IDENTITY: "Developer ID Application"
ShadowsocksX-NG2Tests:
type: bundle.unit-test
platform: macOS
@@ -229,9 +236,9 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.qiuyuzhou.ShadowsocksX-NG2.Tests
# 必须与宿主 app 同 Team 签名:Hardened Runtime 的 library validation 拒绝
# 加载不同 Team ID 的非平台库,ad-hoc("-")签名的 xctest 包无法注入。
CODE_SIGN_IDENTITY: "Apple Development"
# 身份随 config 基线走(Debug ad-hoc / Release DevID,见 configFiles):
# 去宿主化(ADR 0021)后 xctest 包不再注入宿主 app,ad-hoc 可加载执行
# (实测),因此测试不再需要证书,CI 无需签名密钥。
info:
path: Tests/Info.plist
properties: {}
@@ -250,8 +257,7 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.qiuyuzhou.ShadowsocksX-NG2.SmokeTests
# 同 Team 签名理由同 ShadowsocksX-NG2Tests。
CODE_SIGN_IDENTITY: "Apple Development"
# 身份理由同 ShadowsocksX-NG2Tests:随 config 基线,Debug 无证书可跑。
info:
path: Tests/Smoke/Info.plist
properties: {}