Skip to content

Commit 709f94f

Browse files
committed
Add pure-Ruby ad-hoc signing
- remove the `/usr/bin/codesign` dependency from `MachO.codesign!` so Homebrew can repair bottle signatures on Apple Silicon using Ruby - parse and build XNU-compatible `SuperBlob` and `CodeDirectory` data so replacement signatures can retain security-relevant metadata - add or replace `LC_CODE_SIGNATURE`, resize `__LINKEDIT` and rebuild fat offsets so every page hash describes the final on-disk layout - use SHA-256 with legacy SHA-1 agility only for old deployment targets because XNU accepts the open `ld64` ad-hoc format without an identity - preserve non-linker requirements, entitlements, flags and runtime metadata while deliberately replacing linker-generated metadata - reject malformed `CodeDirectory` hash arrays before exposing offsets and validate before in-place writes to retain hard links and leave failed inputs unchanged - cover thin, fat, byte-order, metadata and macOS verification paths with regression tests, and document the compatibility boundaries
1 parent ce5759a commit 709f94f

7 files changed

Lines changed: 1007 additions & 12 deletions

File tree

README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,49 @@ lc_vers = file[:LC_VERSION_MIN_MACOSX].first
4545
puts lc_vers.version_string # => "10.10.0"
4646
```
4747

48+
### Ad-hoc code signing
49+
50+
Changing a Mach-O load command invalidates any existing code signature. This is
51+
especially important when Homebrew pours bottles on Apple Silicon, where native
52+
code must remain signed after its paths are rewritten. `MachO.codesign!` creates
53+
the required ad-hoc signature in Ruby instead of invoking `/usr/bin/codesign`:
54+
55+
```ruby
56+
MachO.codesign!("/path/to/my/binary")
57+
```
58+
59+
The implementation follows the public structures used by
60+
[XNU](https://github.com/apple-oss-distributions/xnu/blob/main/osfmk/kern/cs_blobs.h)
61+
and [ld64](https://github.com/apple-oss-distributions/ld64). For each thin
62+
Mach-O slice it adds or replaces `LC_CODE_SIGNATURE`, resizes `__LINKEDIT` then
63+
hashes the final pre-signature bytes in 4 KiB pages. It emits a SHA-256
64+
CodeDirectory, adding a SHA-1 alternate only when the declared deployment target
65+
requires legacy hash agility. Fat binaries are signed one slice at a time then
66+
laid out again with updated architecture offsets and sizes.
67+
68+
When replacing a non-linker signature, the signer preserves its requirements,
69+
entitlements, flags, runtime version and executable-segment flags. Linker
70+
signatures use fresh ad-hoc metadata, matching Apple's replacement behaviour.
71+
Code-signing blobs use their mandated big-endian representation independently
72+
of the Mach-O byte order, while the load command retains the slice byte order.
73+
74+
The complete signature is built and validated before the file is written. This
75+
leaves the on-disk file unchanged on validation errors, while the final in-place
76+
write preserves its inode, mode and hard links. Adding a missing load command
77+
requires 16 bytes of existing header padding; ruby-macho raises
78+
`MachO::CodeSigningError` rather than moving segments when that space is absent.
79+
Only ad-hoc signing is provided: certificate identities, Developer ID signing,
80+
notarisation and policy assessment remain outside ruby-macho's scope. See
81+
[issue #262](https://github.com/Homebrew/ruby-macho/issues/262) for the original
82+
design discussion.
83+
4884
### What works?
4985

5086
* Reading data from x86/x86_64/arm64/PPC Mach-O files (other architectures are unsupported, but may work)
5187
* Changing the IDs of Mach-O and Fat dylibs
5288
* Changing install names in Mach-O and Fat files
5389
* Adding, deleting, and modifying rpaths.
90+
* Parsing embedded code signatures and applying ad-hoc signatures in pure Ruby.
5491

5592
### What needs to be done?
5693

@@ -73,6 +110,8 @@ overcommit --install
73110
* Constants were taken from Apple, Inc's
74111
[`loader.h` in `cctools/include/mach-o`](https://opensource.apple.com/source/cctools/cctools-973.0.1/include/mach-o/loader.h.auto.html).
75112
(Apple Public Source License 2.0).
113+
* Code-signing constants and structures follow Apple, Inc's
114+
[`cs_blobs.h` in XNU](https://github.com/apple-oss-distributions/xnu/blob/main/osfmk/kern/cs_blobs.h).
76115
* Binary files used for testing were taken from The LLVM Project. ([Apache License v2.0 with LLVM Exceptions](test/bin/llvm/LICENSE.txt)).
77116

78117
### License

lib/macho.rb

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# frozen_string_literal: true
22

3-
require "open3"
4-
53
require_relative "macho/utils"
64
require_relative "macho/structure"
75
require_relative "macho/view"
86
require_relative "macho/headers"
7+
require_relative "macho/code_signing"
98
require_relative "macho/load_commands"
109
require_relative "macho/sections"
1110
require_relative "macho/macho_file"
@@ -42,20 +41,20 @@ def self.open(filename)
4241
file
4342
end
4443

45-
# Signs the dylib using an ad-hoc identity.
46-
# Necessary after making any changes to a dylib, since otherwise
47-
# changing a signed file invalidates its signature.
44+
# Signs a thin or fat Mach-O using an ad-hoc identity.
45+
# Necessary after changing signed Mach-O data because the signature covers
46+
# the header, load commands and all bytes preceding the signature.
4847
# @param filename [String] the file being opened
4948
# @return [void]
50-
# @raise [ModificationError] if the operation fails
49+
# @raise [CodeSigningError] if the operation fails
5150
def self.codesign!(filename)
52-
raise ArgumentError, "codesign binary is not available on Linux" if RUBY_PLATFORM !~ /darwin/
5351
raise ArgumentError, "#{filename}: no such file" unless File.file?(filename)
5452

55-
_, _, status = Open3.capture3("codesign", "--sign", "-", "--force",
56-
"--preserve-metadata=entitlements,requirements,flags,runtime",
57-
filename)
58-
59-
raise CodeSigningError, "#{filename}: signing failed!" unless status.success?
53+
file = MachO.open(filename)
54+
file.codesign!
55+
file.write!
56+
nil
57+
rescue MachOError => e
58+
raise CodeSigningError, "#{filename}: signing failed: #{e.message}"
6059
end
6160
end

0 commit comments

Comments
 (0)