macOS 进程身份不能只靠 Team ID
一套实用的 macOS 进程身份模型,综合 Team ID、签名标识符、指定要求、路径与严格的未签名构建处理方式。

Team ID 不足以判断哪个 macOS 进程可以使用秘密。它标识的是一个 Apple 开发团队,而不是该团队制作的某个可执行文件。如果只按 Team ID 授权,该团队正确签名的所有应用、辅助程序、测试工具和未来二进制文件都可能继承同样的权限。
可靠的授权身份需要把签名主体与签名标识符结合起来,记录一个能跨越正常更新的要求,并针对真正发出请求的进程评估这个身份。可执行文件路径仍应出现在批准卡片和审计记录中,但不应决定授权。未签名和临时签名的开发构建需要一条独立且明显更弱的处理路径,不能靠无声的例外放行。
在秘密边界上,这一区别尤其重要。代码签名能说明谁签署了某个进程,以及签名者为程序声明了什么名称。它不能说明该进程理应获得数据库密码、当前请求是否合理,或者同一开发者的另一个程序是否也应得到相同授权。身份只是授权的一项输入,不能代替授权本身。
Team ID 标识签名者,而不是程序
Team ID 回答了一个有用但宽泛的问题:这段代码使用的 Apple 开发者签名身份属于哪个团队?对于 Apple 颁发的开发者证书,要求语言通过叶证书的组织单位字段公开 Team ID。工具也可能在签名详情中打印 TeamIdentifier 值。
请对准确的可执行文件运行下面的命令,不要只检查外层应用包:
codesign -dvvv /Applications/Example.app/Contents/MacOS/Example 2>&1
输出中有用的部分大致如下:
Executable=/Applications/Example.app/Contents/MacOS/Example
Identifier=dev.example.agent
Authority=Developer ID Application: Example Developer (A1B2C3D4E5)
Authority=Developer ID Certification Authority
Authority=Apple Root CA
TeamIdentifier=A1B2C3D4E5
有效的 Team ID 给出了签名者范围。它排除其他团队签名的二进制文件,因而可阻止无关开发者声明相同签名标识符并通过检查。Apple 的 SigningIdentifier 文档明确指出,多个签名者都能声明同一个签名标识符,所以对非 Apple 代码的安全检查还需要 TeamIdentifier 约束和合适的验证类别。
陷阱来自反方向。一个团队能签署许多互不相关的产品,也能签署一个产品中的许多组件。应用、特权辅助程序、登录项、命令行工具、XPC 服务和内部诊断程序都可能共享同一个 Team ID。遭到入侵的签名服务还可能在该签名者范围内生成新的二进制文件。团队归属无法区分这些程序。
因此,Team ID == 已批准团队 对秘密使用而言范围太大。这相当于因为所有员工的工牌都由同一机构签发,就让整家公司的人获得访问权限。签发者确实重要,但工牌上的名字也重要。
在少数场景中,只使用 Team ID 可能是有意设计。开发工具或许允许用户自己团队签名的任何组件访问可随时丢弃的本地测试资源。这是一项团队信任策略,不是进程身份,批准文字应明确说明这一点。不要把它存进名为 application 的字段,然后在以后忘记它授予了多大范围的权力。
签名标识符区分团队内部的程序
签名标识符补上了 Team ID 缺少的程序维度。codesign 以 Identifier 显示它。对应用包而言,它通常等于包标识符,但 Apple 明确表示两者并非必须相同。签名者选择这个值,命令行可执行文件即使不属于应用包,也可以带有标识符。
把 Team ID 与签名标识符配对,可以得到好得多的最低限度身份:
team_id = A1B2C3D4E5
signing_identifier = dev.example.agent
这对值的含义是“由团队 A1B2C3D4E5 签署、名为 dev.example.agent 的程序”。另一个团队可以复制标识符,却不能满足团队约束。已批准团队制作的另一个程序应该使用不同标识符,因此不应匹配。
上一句中的“应该”值得留意。团队控制自己的标识符,也可以重复使用同一个值。粗心的构建配置可能把主应用的标识符分配给嵌入式辅助程序。恶意或已被入侵的签名者也可能复制批准值。这对字段能缩小权限范围,前提是签名者同时保护好签名凭据与标识符命名空间。
即便如此,这仍是 macOS 上适合更新且稳定的第三方身份常用边界。代码哈希更具体,但每个合法版本都会改变哈希。证书指纹也不适合作为连续性锚点,因为证书会到期和轮换。Team ID 加签名标识符表达了大多数应用真正需要的连续性:接受同一签名者发布的这个程序的新版本,但不接受该签名者的整个产品目录。
检查所有可能建立连接的可执行文件。不要根据容器应用推断辅助程序的标识符。Apple TN3127 用一个应用及其嵌入式命令行工具说明同一问题:两者共享 Team ID,却拥有不同的代码签名标识符,Apple 认为这种区分是最佳做法。如果辅助程序发起请求,边界上就应使用它的身份。
还要逐字节处理标识符。Apple 当前的 SigningIdentifier 文档指出,比较过程不会进行 Unicode 规范化。应把 Code Signing Services 返回的值作为不透明字符串或字节序列存储和比较。转成小写、进行规范化,或根据 CFBundleIdentifier 重建它,都会创造一个规则不同的第二身份系统。
指定要求记录可跨更新的身份
指定要求通常简称 DR,是 macOS 判断当前代码是否与以前见过的代码相同的原生表达式。它把签名标识符与签名主体约束组合起来。与单独查看任一字段相比,它更接近授权真正需要的对象。
Apple TN3127 把 DR 描述为代码告诉他人以后如何再次识别自己的方式。该技术说明使用更新作为实际检验:即便字节已经改变,1.3 版也应满足为 1.2 版记录的身份,同时另一个产品必须失败。持久的秘密授权正需要解决这种张力。
用下面的命令显示 DR:
codesign -d -r- /Applications/Example.app/Contents/MacOS/Example 2>&1
Developer ID 结果通常具有以下形式,证书细节会随签名方式而变化:
Executable=/Applications/Example.app/Contents/MacOS/Example
designated => anchor apple generic and identifier "dev.example.agent" and certificate leaf[subject.OU] = "A1B2C3D4E5"
不要把显示字符串解析成自制字段,然后尝试重新实现 Apple 的评估器。TN3125 警告签名结构会改变,并要求开发者使用 codesign 或 Code Signing Services 进行验证。用 SecCodeCopyDesignatedRequirement 获取要求,用 SecCodeCheckValidity 或当前的进程要求 API 评估代码。让平台编译和比较它自己的要求格式。
这里还有一个细节:代码自己的 DR 是声明,不是授权决定。签名者可以提供显式 DR,在没有嵌入 DR 时,Code Signing Services 也能合成一个。系统仍需决定记录哪个要求,以及它的范围是否适合这次授权。读取 DR 后直接宣布“可信”,就是把身份材料与策略混为一谈。
对普通 Developer ID 软件,在批准时记录已经验证的 DR 是可靠的默认方式。同时把显示出来的 Team ID 和签名标识符存为审计字段,方便人理解决定。后续调用应针对实时进程评估已存要求,不要比较新打印的 DR 文本是否完全相同。要求对象表达行为,等价表达式不必拥有相同格式。
分发方式变化必须有意处理。TN3127 指出,Mac App Store 和 Developer ID 版本的默认 DR 不会自动兼容,而 Xcode 可以用自定义要求连接计划内的分发形式。版本迁移渠道时,不要临时放宽验证器。应把新要求视为身份变化,向用户展示,并要求重新批准,除非你已经设计并测试了明确的兼容要求。
可执行文件路径提供背景,而不是证明
可执行文件路径告诉操作人员 macOS 从哪里找到映像。这是很好的批准背景,却只能提供很弱的连续性证据。文件会移动,应用转置可能改变位置,用户可能保留多个版本,包管理器也会安装到带版本号的路径。反过来,如果攻击者能替换一个获批可写路径上的文件,就能继承只按路径授予的权限。
常见的路径允许列表会在两个方向失败。它会在同一个签名程序被无害移动后拒绝它,也会在文件遭到恶意替换后接受不同字节。增加所有者和权限检查可以降低部分替换风险,但无法把路径名变成密码学身份。
请把路径留给三项工作:
- 向用户显示哪个安装位置发起了请求。
- 记录足够背景,以调查异常调用。
- 在签名身份验证成功后,应用可选的位置限制。
最后一种用途在受管环境中可能合理。你可以同时要求获批 DR,并规定可执行文件必须位于 root 拥有的部署目录。此时,路径限制的是一个已经识别的程序能从哪里运行。它无法弥补缺失或无效的签名。
解析并记录真正属于运行进程的可执行文件。不要相信客户端提供的字符串,例如 argv[0]、工作目录、请求中的应用包路径或环境变量。这些都只是请求者自己的陈述。即使路径已经规范化,如果你先检查文件、之后只根据保存的名字授权进程,仍可能遇到竞态。
平台提供信息时,我会同时把最初观察到的路径和解析后的路径写入审计数据,因为符号链接与启动包装器可以解释许多异常。两个值都不进入持久身份元组。如果组织选择路径限制,应把它放在独立的 location_constraint 字段中,让审查者看出这是一层叠加在身份上的策略。
评估实时调用者,而不是路径名
授权检查必须绑定到正在建立连接的进程。安装时检查文件、保存路径,并在以后信任该位置运行的任何内容,会留下检查时间差。请求到达后再检查路径中的当前文件,也仍可能看到替代文件,而不是已经运行的映像。
macOS Code Signing Services 区分磁盘上的静态代码与运行进程关联的动态代码。Apple 记录了如何使用 SecCodeCopyGuestWithAttributes 获取来宾代码对象,通常按 PID 获取,也记录了如何用 SecCodeCheckValidityWithProcessRequirement 按要求检查运行进程。较新的轻量级要求 API 可以显式表达所需的 TeamIdentifier 和 SigningIdentifier 约束。请使用适合部署目标的受支持 API,不要在生产环境解析 codesign 输出。
可靠的连接流程如下:
- 从 IPC 边界取得内核提供的进程身份,例如已接受连接附带的审计令牌。不要接受请求正文中报告的 PID。
- 把该实时进程解析成动态代码对象,并用平台 API 验证签名。
- 针对同一个代码对象评估已存要求,或者已存签名者与标识符约束。
- 在决定记录中捕获路径、PID、可用时的进程启动身份、签名事实和验证结果。
- 把批准绑定到连接或进程生命期,并在该生命期结束时丢弃批准。
单独的 PID 不是持久句柄,因为内核会重复使用进程 ID。如果验证器读取 PID、等待后再次查找,它可能检查到另一个进程。审计令牌携带的进程身份比客户端提供的整数更多,按连接验证也会缩小窗口。具体 API 取决于边界使用 XPC、Unix 域套接字还是其他 IPC,但规则不变:根据操作系统看到的对端确定主体。
显示身份之前必须先验证。否则批准卡片可能把畸形或无效签名中提取的字段,显示成已经得到 macOS 背书的事实。卡片应区分“有效的 Developer ID 签名”和“发现了标识符文本”。验证失败时,不要在同一次批准中退回路径匹配。
对进程树也要做明确决定。如果签名代理启动 /bin/sh,随后 shell 直接连接,那么对端就是 shell。自动向上寻找祖先进程并借用父进程身份,可能授权被替换的子进程或无关后代。如果架构确实要授权启动器,应把能力绑定到最初通过认证的连接,并通过受控通道传递。不要通过攀爬父 PID 来猜测意图。
这也说明了为何批准应在获批进程退出时过期。保存的身份可以支持未来批准,但会话授权不应脱离进程,永久依附到任何匹配进程。请把“我们认识这个程序”与“我们现在批准这次运行”分开。
未签名和临时签名构建需要独立策略
未签名代码没有指定要求。临时签名代码的 DR 绑定到特定代码版本,因此重建会改变身份。Apple TN3127 指出,macOS 无法可靠地跨版本跟踪这两类代码。秘密代理应保留这一限制,而不是用目录例外掩盖它。
对生产秘密而言,调用者没有有效且可跨更新的签名时应关闭访问。这条规则最清楚,也最容易解释。开发者可以用 Apple Development 身份签署本地构建,或者使用组织自行管理信任和要求的私有签名身份。与永久未签名例外造成的歧义相比,这种不便通常小得多。
本地开发有时需要较弱模式。它应由用户主动启用,明确命名为 开发批准,并限制影响范围。合理的设计会把批准绑定到当前进程生命期和准确代码哈希,醒目显示 未签名 或 临时签名,排除生产秘密集,并在每次重建后重新询问。重复提示不是缺陷,它准确反映可执行文件已经失去 macOS 能证明的连续性。
不要使用下面这些常见替代方案:
- 用户主目录中的路径,因为同一用户能替换其中的文件。
- 从元数据读取的文件名或包标识符,因为未签名代码能声明任何值。
- 父进程签名,因为真正使用权限的是作为对端的子进程。
- 对终端的一揽子批准,因为终端可以启动任意程序。
- 把哈希当作永久身份,因为每次合法重建都需要手工迁移。
哈希可以安全地缩小临时例外。它表示“本次运行使用这些准确字节”,而不是“更新后仍是同一个程序”。应在存储和界面中清楚呈现这个语义差别。
把 Team ID 缺失视为需要分类的状态,不要让空值绕过比较。Apple 签名的平台代码、独立签名代码、临时签名代码和未签名代码并不都适合一个元组。先定义产品接受哪些类别,再逐类测试。类似 if team != expected 但对空值宽松放行的检查已经造成太多授权漏洞,值得拥有专门的单元测试。
存储能解释自身范围的身份记录
持久记录应保存机器可评估的要求、人类可读的签名事实、代码类别和任何单独批准的位置条件。它还应记录授权范围。半年后查看数据库的人必须能判断,批准覆盖一次运行、未来签名版本,还是团队制作的每个程序。
这个示例使用占位值,并以 base64 表示序列化的要求数据。该数据应来自 Code Signing Services,而不是通过编译客户端提供的字符串获得:
{
"schema": 1,
"code_category": "developer_id",
"team_id": "A1B2C3D4E5",
"signing_identifier": "dev.example.agent",
"designated_requirement": "BASE64_PLATFORM_REQUIREMENT",
"approval_scope": "matching_identity_per_session",
"location_constraint": null,
"observed_path": "/Applications/Example.app/Contents/MacOS/Example"
}
匹配算法应该朴素直接。先分类调用者,并验证其实时签名。对签名身份,针对实时进程评估已存要求。确认返回的 Team ID 和签名标识符与审计字段匹配;不匹配说明状态损坏或存在程序错误,不是扩大权限的理由。然后应用位置限制。最后检查授权范围和任何逐秘密批准要求。
伪代码让失败行为更容易审查:
caller = peer_from_kernel(connection)
code = dynamic_code(caller)
result = validate(code)
if result.category not in accepted_categories:
deny("unsupported code category")
identity = evaluate(stored_requirement, code)
if identity != satisfied:
deny("process identity changed")
if facts(code) != stored_audit_facts:
deny("identity record inconsistent")
if location_constraint and not location_allowed(code, location_constraint):
deny("approved program ran from an unapproved location")
authorize_for(connection_lifetime, requested_secret)
请注意算法没有做什么。它不只接受 Team ID,不在检查签名前比较路径,不向上搜索进程树寻找更有利的身份,也不会默默把未签名调用者转成开发授权。每种失败都会给出用户与审计人员能理解的原因。
应把身份迁移规划成一项操作,而不是例外。团队转移、签名标识符变化、分发渠道变化,或从临时签名改为 Developer ID,都可能合理地改变要求。并排展示新旧事实,要求获授权人员批准迁移,并在审计历史中保留两条记录。不要覆盖旧身份,让过去的调用看起来来自新身份。
用对抗性样本测试。用同一团队签署两个标识符不同的可执行文件,只有一个应通过。再用另一个团队签署带有已批准标识符的文件,它应失败。把已批准文件复制到新路径,除非位置条件禁止,否则身份应通过。启动后替换文件,确认验证器评估的是实时对端。重建临时签名目标,并确认临时批准不会延续。
身份不能回答操作是否获准
正确的进程匹配只标识请求主体。它不能证明该主体可以使用所有秘密、访问所有主机,或永久保留权限。即使同一界面同时呈现这些选择,也要把进程身份、会话批准和操作批准当作独立决定。
这种分离能阻止一种常见升级。用户批准一个签名编码代理读取开发令牌,系统保存代理身份,后来的实现却开始把该记录视为访问所有凭据的许可。Team ID、签名标识符或 DR 都没有携带最初的资源边界。身份保持稳定,授权范围却悄悄扩大了。
明确写出授权元组:主体、操作、资源、条件和生命期。对秘密网关而言,可以写成:
subject: requirement R42 satisfied by this live process
action: perform an HTTP request with injected bearer credentials
resource: issue-tracker-development
conditions: vault unlocked and session approved
lifetime: this connection, with per-call approval if the secret requires it
主体字段引用进程身份记录。其他字段来自请求的操作和周围控制。这一结构也能处理同时使用 HTTP 与 SSH 的代理,不必假装识别进程就自动授权两个通道。
权利声明可以为专门策略提供信息,但不应变成自制的信誉分数。权利声明是 macOS 针对特定系统设施解释的签名声明。它的存在不代表程序总体更安全,缺失也不会削弱 Team ID 加标识符的匹配。只有当授权设计为某项具体权利声明赋予精确含义时,才检查它。
公证和 Gatekeeper 回答的也是其他问题。它们的接受结果可以帮助验证代码类别与分发信任,但两者都无法标识开发团队内部的某个程序,也不会授权访问秘密。诊断时运行 spctl -a -vv -t exec 也许能说明 Gatekeeper 是否接受文件,但不能用该结论取代已存要求检查。
撤销也需要同样精确。撤销会话应终止当前连接,但不删除已识别的程序身份。撤销身份应迫使以后匹配的进程再次批准。撤销资源授权应保留其他授权。如果一个名为 trusted 的布尔值控制全部三项,数据模型就无法表达用户真正做出的决定。
这些边界让事件审查少很多猜测。审计人员可以确认某个签名程序曾运行、某人批准了那次运行,并且该运行获得了明确操作的权限。如果缺少三类记录中的任意一种,即便 DR 完美无误,仍无法回答最重要的问题:识别进程究竟允许了什么?
批准界面应说明已经证明的事实
人在中断式批准中无法审阅原始要求数据。应显示根据验证事实得出的简短声明:签名者或团队、签名标识符、签名类别、可执行文件路径,以及批准针对进程生命期还是单次调用。只有设计确实授予宽泛权力时,才把范围最广的事实放在最前面。
不要把友好的应用名称当成主要身份。显示名称来自可变元数据,也经常重复。它们和路径一样,只适合放在已经验证的签名事实之后作为标签。写着 Example Agent 请求访问 的批准无法说明请求者是发布版应用、辅助程序、本地重建版本,还是未签名副本。
Sallyport 会在新代理进程第一次调用时显示批准卡片,并首先列出该进程的代码签名主体,然后把批准绑定到这次运行直到进程退出。它的保险库闸门与逐次调用批准标志仍是独立控制,所以识别进程绝不会自动变成不受限制的秘密访问。
审计事件应保存验证器观察到的事实及其应用的规则。记录验证类别、Team ID、签名标识符、要求引用、路径、进程和会话标识符、决定、拒绝原因与授权范围。以后的审查人员不应依赖旧路径中当前存在的文件,才能理解一次调用。
不要把结果标成 可信进程。验证器证明的陈述更窄:这个实时进程满足某项代码要求,而且用户或策略授权它执行特定操作。当新的秘密类型和代理工具加入时,这种表述可以阻止权限自然膨胀。
Team ID 属于这项陈述,但不能独自承担整项证明。用 Team ID 命名签名者,用签名标识符命名该签名者范围内的程序,用指定要求维持合法版本之间的身份,再用实时进程句柄把证明绑定到请求者。只要缺少任何一项,就应缩小授权或再次询问。秘密边界应该暴露不确定性,而不是把不确定性转换为永久访问。
常见问题
两个不同的 macOS 应用可以有相同 Team ID 吗?
可以。同一 Apple 开发团队签署的每个应用和辅助程序都可能共享 Team ID。请用签名标识符或指定要求区分团队内部的程序。
包标识符与签名标识符相同吗?
通常相同,但 macOS 没有这一硬性要求。签名者选择签名标识符,命令行工具无需应用包也能拥有标识符,所以应从已验证的代码签名中读取它。
应该保存指定要求,还是保存 Team ID 与标识符字段?
保存机器可评估的指定要求,同时把 Team ID 与签名标识符保留为易读审计事实。应使用 Code Signing Services 评估要求,不要解析并比较其打印文本。
指定要求能证明进程安全吗?
不能。它只证明代码满足某个身份表达式。你仍需另行决定该身份能使用哪些秘密和操作、授权持续多久,以及需要什么人工批准。
可执行文件路径可以成为进程授权的一部分吗?
签名身份验证成功后,路径可以作为额外的位置限制。单独使用路径并不安全,因为文件会移动,攻击者也可能替换获批可写位置中的文件。
服务应如何识别 IPC 连接上的进程?
根据审计令牌等内核提供的连接数据确定对端,再验证对应的实时代码对象。绝不要相信客户端请求中提供的 PID、路径或标识符。
签名应用更新后,授权会怎样?
选择正确的指定要求后,合法更新应能满足同一身份。如果签名者、标识符或分发方式发生了要求之外的变化,应进行显式身份迁移或重新批准。
能永久授权未签名的开发构建吗?
可以,但基于路径的永久授权无法提供可靠的代码连续性。更好的做法是把开发批准绑定到进程生命期和准确构建,只允许非生产秘密,并在重建后再次询问。
子进程应该继承已签名父进程的身份吗?
不应自动继承。如果子进程建立连接,它就是直接对端;向上遍历进程树可能把权限借给被替换的后代。若设计需要继承,应通过受控通道传递范围受限的能力。
代码哈希比 Team ID 更适合作为身份吗?
哈希标识准确的代码字节,很适合临时的未签名构建例外。它无法跨越合法更新,而 Team ID 加签名标识符或指定要求可以表达不同版本之间的连续性。