From 41b5a0eded37cccfb9cda26acf7f31b9510a1d2b Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:46:48 -0400 Subject: [PATCH 1/3] libstore: Refuse store path hash rewrites that break Mach-O code signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When registering build outputs, the daemon sometimes rewrites store path hashes inside the output's files: when an output being built was already present in the store at build start, its scratch path is a synthesised fallback path that must be substituted with the final one after the builder exits, and content-addressed outputs need their self-references rewritten once the final hash is known. If the rewritten bytes sit inside a Mach-O binary carrying LC_CODE_SIGNATURE, the substitution invalidates the signature's page hashes, and the macOS kernel kills the binary with SIGKILL at first page-in. The corruption was silent: the build succeeds and the registered output is broken. This is the mechanism behind the recently reported darwin startup failures of fish (nixpkgs issue 507531) and one of the mechanisms behind Nix issue 6065. It has also reached cache.nixos.org — directly evidenced for ffmpeg, whose cached build log references fallback paths, and consistent with the stale page contents of several cached Haskell outputs. Before applying a rewrite, scan the output for regular files that both carry a Mach-O code signature and contain one of the hashes about to be substituted, and fail the build with an error naming the affected files and the already-present store paths whose deletion allows a clean rebuild. CMS-signed files (Developer ID) are called out separately since no re-signing without the original identity can ever fix those. The check also fires under --check, replacing the spurious "may not be deterministic" failure previously reported for signed binaries. The new macho-signature-rewrite-check setting controls the behaviour: refuse (default), warn (diagnose but register the broken output — the previous behaviour plus a diagnostic), ignore (previous behaviour). Detection is purely content-based, so cross-builds of darwin binaries on Linux are covered too; the Mach-O constants are vendored rather than taken from Apple headers for the same reason. Refusing by default is a deliberate behaviour change with a visible blast radius: --check / --rebuild of any signed darwin binary that was previously reported as spuriously non-deterministic now fails with this error instead, and content-addressed cold builds of self-referential signed Mach-O files fail loudly rather than registering silently broken outputs. This adds a Mach-O / code-signature parser to the daemon, over bytes produced by untrusted builders. It is deliberately detection-only: read-only, no writes, every read bounds-checked against the buffer, walk lengths capped (fat_arch and SuperBlob counts, file size — files over the limit are refused as unverifiable, not waved through), fat slices validated by offset and size, and unit-tested against malformed inputs. Repair — which needs substantially more parsing — is out of scope here and belongs outside the daemon's privileged context. Refusing at the rewrite is a finer-grained sibling of bailing out at build start when only some outputs could be substituted: the coarse form is simpler and catches non-signature rewrite damage too, but cannot cover content-addressed cold builds, where nothing is present at build start and the damaging rewrite is unconditional. --- doc/manual/rl-next/darwin-macho-signatures.md | 25 ++ src/libstore-tests/macho-signature.cc | 291 ++++++++++++++ src/libstore-tests/meson.build | 6 + src/libstore/globals.cc | 44 +++ .../include/nix/store/local-settings.hh | 54 +++ .../unix/build/derivation-builder-impl.hh | 28 ++ src/libstore/unix/build/derivation-builder.cc | 116 +++++- .../unix/include/nix/store/macho-signature.hh | 73 ++++ .../unix/include/nix/store/meson.build | 1 + src/libstore/unix/macho-signature.cc | 361 ++++++++++++++++++ src/libstore/unix/meson.build | 1 + tests/functional/ca/macho-signature-check.nix | 29 ++ tests/functional/ca/macho-signature-check.sh | 29 ++ tests/functional/ca/meson.build | 1 + tests/functional/macho-signature-check.nix | 38 ++ tests/functional/macho-signature-check.sh | 88 +++++ tests/functional/macho-signature-cms.nix | 91 +++++ tests/functional/meson.build | 1 + 18 files changed, 1274 insertions(+), 3 deletions(-) create mode 100644 doc/manual/rl-next/darwin-macho-signatures.md create mode 100644 src/libstore-tests/macho-signature.cc create mode 100644 src/libstore/unix/include/nix/store/macho-signature.hh create mode 100644 src/libstore/unix/macho-signature.cc create mode 100644 tests/functional/ca/macho-signature-check.nix create mode 100644 tests/functional/ca/macho-signature-check.sh create mode 100644 tests/functional/macho-signature-check.nix create mode 100644 tests/functional/macho-signature-check.sh create mode 100644 tests/functional/macho-signature-cms.nix diff --git a/doc/manual/rl-next/darwin-macho-signatures.md b/doc/manual/rl-next/darwin-macho-signatures.md new file mode 100644 index 000000000000..69f09b4205f7 --- /dev/null +++ b/doc/manual/rl-next/darwin-macho-signatures.md @@ -0,0 +1,25 @@ +--- +synopsis: "Store path hash rewrites that would break a Mach-O code signature are now refused" +issues: [6065] +prs: [15638] +--- + +On macOS, rewriting store path hashes inside a signed Mach-O binary — which +Nix does when registering an output that references a path already present in +the store, and for self-references in content-addressed builds — invalidates +the signature's page hashes. The kernel then kills the binary the first time +it is run. Previously this corruption was silent; the build "succeeded" and +the registered binary was broken (see the [`fish` +issue](https://github.com/NixOS/nixpkgs/issues/507531)). + +The new `macho-signature-rewrite-check` setting (`refuse` by default) makes +the build fail instead, with an error naming the affected files and the +already-present store paths whose deletion allows a clean rebuild. This also +replaces the spurious "may not be deterministic" failure previously reported +for signed binaries under `--check`, and makes content-addressed cold builds +of self-referential signed binaries fail loudly rather than register silently +broken outputs. `warn` and `ignore` restore the previous behaviour with and +without a diagnostic. + +Detection is content-based, so cross-builds of macOS binaries on other +platforms are covered too. diff --git a/src/libstore-tests/macho-signature.cc b/src/libstore-tests/macho-signature.cc new file mode 100644 index 000000000000..775797f6136e --- /dev/null +++ b/src/libstore-tests/macho-signature.cc @@ -0,0 +1,291 @@ +#include "nix/store/macho-signature.hh" + +#include + +#include +#include +#include +#include + +namespace nix { + +/* The detector is content-based and endian-explicit, so these tests + run on every platform — that is the point: Linux cross-builds of + darwin binaries must be covered too. The byte vectors are built by + hand from the on-disk layouts in ``, + `` and xnu's `bsd/sys/codesign.h`. */ + +namespace { + +void putLE32(std::string & s, size_t off, uint32_t v) +{ + s[off + 0] = char(v & 0xff); + s[off + 1] = char((v >> 8) & 0xff); + s[off + 2] = char((v >> 16) & 0xff); + s[off + 3] = char((v >> 24) & 0xff); +} + +void putBE32(std::string & s, size_t off, uint32_t v) +{ + s[off + 0] = char((v >> 24) & 0xff); + s[off + 1] = char((v >> 16) & 0xff); + s[off + 2] = char((v >> 8) & 0xff); + s[off + 3] = char(v & 0xff); +} + +void putBE64(std::string & s, size_t off, uint64_t v) +{ + putBE32(s, off, uint32_t(v >> 32)); + putBE32(s, off + 4, uint32_t(v & 0xffffffff)); +} + +constexpr uint32_t MH_MAGIC_64_ = 0xfeedfacf; +constexpr uint32_t MH_MAGIC_ = 0xfeedface; +constexpr uint32_t FAT_MAGIC_ = 0xcafebabe; +constexpr uint32_t FAT_MAGIC_64_ = 0xcafebabf; +constexpr uint32_t LC_CODE_SIGNATURE_ = 0x1d; +constexpr uint32_t CSMAGIC_EMBEDDED_SIGNATURE_ = 0xfade0cc0; +constexpr uint32_t CSMAGIC_CODEDIRECTORY_ = 0xfade0c02; +constexpr uint32_t CSMAGIC_BLOBWRAPPER_ = 0xfade0b01; +constexpr uint32_t CSSLOT_CODEDIRECTORY_ = 0; +constexpr uint32_t CSSLOT_SIGNATURESLOT_ = 0x10000; + +/** + * A minimal signed 64-bit slice: mach_header_64, one + * LC_CODE_SIGNATURE load command, and a SuperBlob containing a + * CodeDirectory plus (optionally) a CMS blob wrapper of `cmsLen` + * payload bytes (0 = no signature slot, 8 = the empty ad-hoc + * wrapper, >8 = Developer-ID-style). + */ +std::string makeSignedSlice64(size_t cmsLen) +{ + constexpr size_t headerSize = 32; + constexpr size_t lcSize = 16; // linkedit_data_command + bool haveCms = cmsLen > 0; + uint32_t nBlobs = haveCms ? 2 : 1; + size_t sbHeader = 12 + nBlobs * 8; + size_t cdLen = 44; // CodeDirectory header only, no slots + size_t cmsBlob = haveCms ? cmsLen : 0; + size_t sigSize = sbHeader + cdLen + cmsBlob; + size_t sigOff = headerSize + lcSize; + + std::string s(sigOff + sigSize, '\0'); + + /* mach_header_64 */ + putLE32(s, 0, MH_MAGIC_64_); + putLE32(s, 16, 1); // ncmds + putLE32(s, 20, lcSize); // sizeofcmds + + /* LC_CODE_SIGNATURE */ + putLE32(s, headerSize + 0, LC_CODE_SIGNATURE_); + putLE32(s, headerSize + 4, lcSize); + putLE32(s, headerSize + 8, uint32_t(sigOff)); + putLE32(s, headerSize + 12, uint32_t(sigSize)); + + /* SuperBlob */ + putBE32(s, sigOff + 0, CSMAGIC_EMBEDDED_SIGNATURE_); + putBE32(s, sigOff + 4, uint32_t(sigSize)); + putBE32(s, sigOff + 8, nBlobs); + putBE32(s, sigOff + 12, CSSLOT_CODEDIRECTORY_); + putBE32(s, sigOff + 16, uint32_t(sbHeader)); + if (haveCms) { + putBE32(s, sigOff + 20, CSSLOT_SIGNATURESLOT_); + putBE32(s, sigOff + 24, uint32_t(sbHeader + cdLen)); + } + + /* CodeDirectory (header only) */ + size_t cdOff = sigOff + sbHeader; + putBE32(s, cdOff + 0, CSMAGIC_CODEDIRECTORY_); + putBE32(s, cdOff + 4, uint32_t(cdLen)); + + /* CMS blob wrapper */ + if (haveCms) { + size_t cmsOff = cdOff + cdLen; + putBE32(s, cmsOff + 0, CSMAGIC_BLOBWRAPPER_); + putBE32(s, cmsOff + 4, uint32_t(cmsLen)); + } + + return s; +} + +/** An unsigned 64-bit slice with a single non-signature load command. */ +std::string makeUnsignedSlice64() +{ + constexpr size_t headerSize = 32; + constexpr size_t lcSize = 16; + std::string s(headerSize + lcSize, '\0'); + putLE32(s, 0, MH_MAGIC_64_); + putLE32(s, 16, 1); + putLE32(s, 20, lcSize); + putLE32(s, headerSize + 0, 0x32); // LC_SOURCE_VERSION + putLE32(s, headerSize + 4, lcSize); + return s; +} + +/** Wrap slices into a fat container (32- or 64-bit fat headers). */ +std::string makeFat(const std::vector & slices, bool fat64) +{ + size_t archSize = fat64 ? 32 : 20; + size_t tableEnd = 8 + slices.size() * archSize; + /* Align each slice to 16 bytes for tidiness (not required by the + detector). */ + std::vector offsets; + size_t cur = (tableEnd + 15) & ~size_t(15); + for (auto & sl : slices) { + offsets.push_back(cur); + cur += (sl.size() + 15) & ~size_t(15); + } + std::string s(cur, '\0'); + putBE32(s, 0, fat64 ? FAT_MAGIC_64_ : FAT_MAGIC_); + putBE32(s, 4, uint32_t(slices.size())); + for (size_t i = 0; i < slices.size(); i++) { + size_t archOff = 8 + i * archSize; + if (fat64) { + putBE64(s, archOff + 8, offsets[i]); + putBE64(s, archOff + 16, slices[i].size()); + } else { + putBE32(s, archOff + 8, uint32_t(offsets[i])); + putBE32(s, archOff + 12, uint32_t(slices[i].size())); + } + s.replace(offsets[i], slices[i].size(), slices[i]); + } + return s; +} + +} // namespace + +TEST(detectMachOSignature, emptyAndTiny) +{ + EXPECT_EQ(detectMachOSignature(""), MachOSignatureKind::None); + EXPECT_EQ(detectMachOSignature("not a mach-o file at all"), MachOSignatureKind::None); +} + +TEST(detectMachOSignature, unsignedThin) +{ + EXPECT_EQ(detectMachOSignature(makeUnsignedSlice64()), MachOSignatureKind::None); +} + +TEST(detectMachOSignature, adHocThin) +{ + /* No signature slot at all — the linker-signed shape. */ + EXPECT_EQ(detectMachOSignature(makeSignedSlice64(0)), MachOSignatureKind::AdHoc); + /* Empty 8-byte blob wrapper — the `codesign -s -` shape. */ + EXPECT_EQ(detectMachOSignature(makeSignedSlice64(8)), MachOSignatureKind::AdHoc); +} + +TEST(detectMachOSignature, cmsThin) +{ + EXPECT_EQ(detectMachOSignature(makeSignedSlice64(100)), MachOSignatureKind::Cms); +} + +TEST(detectMachOSignature, thin32BitHeader) +{ + /* 32-bit mach_header (28 bytes) followed by LC_CODE_SIGNATURE + whose SuperBlob is out of bounds — still detected as signed + (fail towards detection). */ + std::string s(28 + 16, '\0'); + putLE32(s, 0, MH_MAGIC_); + putLE32(s, 16, 1); + putLE32(s, 20, 16); + putLE32(s, 28 + 0, LC_CODE_SIGNATURE_); + putLE32(s, 28 + 4, 16); + putLE32(s, 28 + 8, 0xffff0000); // dataoff far beyond EOF + putLE32(s, 28 + 12, 64); + EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::AdHoc); +} + +TEST(detectMachOSignature, fat32Container) +{ + auto fat = makeFat({makeUnsignedSlice64(), makeSignedSlice64(0)}, false); + EXPECT_EQ(detectMachOSignature(fat), MachOSignatureKind::AdHoc); +} + +TEST(detectMachOSignature, fat64Container) +{ + auto fat = makeFat({makeSignedSlice64(100), makeUnsignedSlice64()}, true); + EXPECT_EQ(detectMachOSignature(fat), MachOSignatureKind::Cms); +} + +TEST(detectMachOSignature, fatStrongestKindWins) +{ + auto fat = makeFat({makeSignedSlice64(0), makeSignedSlice64(100)}, false); + EXPECT_EQ(detectMachOSignature(fat), MachOSignatureKind::Cms); +} + +TEST(detectMachOSignature, javaClassFile) +{ + /* Java class files share the fat magic; the version field reads + as nfat_arch. A small one fails the arch-array bounds check. */ + std::string s(32, '\0'); + putBE32(s, 0, 0xcafebabe); + putBE32(s, 4, 65); // "nfat_arch" = major version 65 (Java 21) + EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::None); + + /* A large one has room for the phantom arch table, but its + garbage "slices" carry no Mach-O magic. */ + std::string big(8192, '\x5a'); + putBE32(big, 0, 0xcafebabe); + putBE32(big, 4, 65); + EXPECT_EQ(detectMachOSignature(big), MachOSignatureKind::None); +} + +TEST(detectMachOSignature, absurdNFatArch) +{ + /* nfat_arch beyond any real universal binary is rejected by the + bound. */ + std::string s(1 << 20, '\0'); + putBE32(s, 0, FAT_MAGIC_); + putBE32(s, 4, 100000); + EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::None); +} + +TEST(detectMachOSignature, fatWithZeroArches) +{ + std::string s(32, '\0'); + putBE32(s, 0, FAT_MAGIC_); + putBE32(s, 4, 0); + EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::None); +} + +TEST(detectMachOSignature, truncatedLoadCommands) +{ + /* sizeofcmds runs past EOF. */ + std::string s(32, '\0'); + putLE32(s, 0, MH_MAGIC_64_); + putLE32(s, 16, 4); + putLE32(s, 20, 0xffffff); + EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::None); +} + +TEST(detectMachOSignature, zeroSizeLoadCommand) +{ + /* cmdsize = 0 must not loop forever. */ + auto s = makeUnsignedSlice64(); + putLE32(s, 32 + 4, 0); + EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::None); +} + +TEST(detectMachOSignature, fatSliceOffsetsOutOfBounds) +{ + /* Slice offsets pointing outside the file are skipped. */ + std::string s(8 + 20, '\0'); + putBE32(s, 0, FAT_MAGIC_); + putBE32(s, 4, 1); + putBE32(s, 8 + 8, 0x7fffffff); // offset beyond EOF + putBE32(s, 8 + 12, 64); + EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::None); +} + +TEST(detectMachOSignature, fat64HugeOffsetNoOverflow) +{ + /* A fat64 slice offset near UINT64_MAX must not wrap into + bounds. */ + std::string s(8 + 32, '\0'); + putBE32(s, 0, FAT_MAGIC_64_); + putBE32(s, 4, 1); + putBE64(s, 8 + 8, ~uint64_t(0) - 8); + putBE64(s, 8 + 16, 1024); + EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::None); +} + +} // namespace nix diff --git a/src/libstore-tests/meson.build b/src/libstore-tests/meson.build index a7353f1ec7dd..30e0c24083b4 100644 --- a/src/libstore-tests/meson.build +++ b/src/libstore-tests/meson.build @@ -95,6 +95,12 @@ sources = files( 'write-derivation.cc', ) +if host_machine.system() != 'windows' + sources += files( + 'macho-signature.cc', + ) +endif + include_dirs = [ include_directories('.') ] diff --git a/src/libstore/globals.cc b/src/libstore/globals.cc index 5e62408847bf..cbaac5e44429 100644 --- a/src/libstore/globals.cc +++ b/src/libstore/globals.cc @@ -364,6 +364,50 @@ void BaseSetting::convertToArg(Args & args, const std::string & cat }); } +NLOHMANN_JSON_SERIALIZE_ENUM( + MachOSignatureCheck, + { + {MachOSignatureCheck::Ignore, "ignore"}, + {MachOSignatureCheck::Warn, "warn"}, + {MachOSignatureCheck::Refuse, "refuse"}, + }); + +template<> +MachOSignatureCheck BaseSetting::parse(const std::string & str) const +{ + if (str == "ignore") + return MachOSignatureCheck::Ignore; + else if (str == "warn") + return MachOSignatureCheck::Warn; + else if (str == "refuse") + return MachOSignatureCheck::Refuse; + else + throw UsageError("option '%s' has invalid value '%s', expected 'ignore', 'warn', or 'refuse'", name, str); +} + +template<> +struct BaseSetting::trait +{ + static constexpr bool appendable = false; +}; + +template<> +std::string BaseSetting::to_string() const +{ + switch (value) { + case MachOSignatureCheck::Ignore: + return "ignore"; + case MachOSignatureCheck::Warn: + return "warn"; + case MachOSignatureCheck::Refuse: + return "refuse"; + } + unreachable(); +} + +template class BaseSetting; + + void to_json(nlohmann::json & j, const ChrootPath & cp) { j = nlohmann::json{{"source", cp.source.string()}, {"optional", cp.optional}}; diff --git a/src/libstore/include/nix/store/local-settings.hh b/src/libstore/include/nix/store/local-settings.hh index 8e4833591cfa..6bb91d1809fa 100644 --- a/src/libstore/include/nix/store/local-settings.hh +++ b/src/libstore/include/nix/store/local-settings.hh @@ -23,6 +23,21 @@ SandboxMode BaseSetting::parse(const std::string & str) const; template<> std::string BaseSetting::to_string() const; +/** + * What to do when a store path hash rewrite would invalidate a + * Mach-O code signature. See `macho-signature-rewrite-check`. + */ +enum struct MachOSignatureCheck { + Ignore, + Warn, + Refuse, +}; + +template<> +MachOSignatureCheck BaseSetting::parse(const std::string & str) const; +template<> +std::string BaseSetting::to_string() const; + template<> PathsInChroot BaseSetting::parse(const std::string & str) const; template<> @@ -494,6 +509,45 @@ public: "Whether to log Darwin sandbox access violations to the system log."}; #endif + Setting machOSignatureRewriteCheck{ + this, + MachOSignatureCheck::Refuse, + "macho-signature-rewrite-check", + R"( + What to do when registering a build output would require + rewriting store path hashes inside a Mach-O file that carries + a code signature (`LC_CODE_SIGNATURE`). The rewrite changes + bytes that the signature's page hashes cover, so the + resulting binary is killed by the macOS kernel when it is + first executed (see + [nixpkgs#507531](https://github.com/NixOS/nixpkgs/issues/507531)). + + The rewrite happens when an output being built already exists + in the store at build start — for example after a partial + substitution, a `nix-store --delete` of one output of a + multi-output derivation, or `--check` — and the freshly built + output embeds that path. Content-addressed builds hit it on + every cold build of a self-referential signed binary. + + - `refuse` (default): fail the build with an error naming the + affected files and the already-present store paths whose + deletion allows a clean rebuild. Note that this makes + `--check` / `--rebuild` of a signed self-referential binary + fail with this error (previously reported as a spurious + non-determinism failure), and makes content-addressed cold + builds of such binaries fail rather than register a + silently broken output. + + - `warn`: print a warning for each affected file, then + register the output anyway (with its now-invalid + signature). + + - `ignore`: rewrite silently, without checking. + + The check is content-based, so it also fires when + cross-building darwin binaries on other platforms. + )"}; + Setting runDiffHook{ this, false, diff --git a/src/libstore/unix/build/derivation-builder-impl.hh b/src/libstore/unix/build/derivation-builder-impl.hh index cdfe9a901791..d7588505d4c1 100644 --- a/src/libstore/unix/build/derivation-builder-impl.hh +++ b/src/libstore/unix/build/derivation-builder-impl.hh @@ -2,6 +2,7 @@ #include "nix/store/aws-creds.hh" #include "nix/store/build/derivation-builder.hh" +#include "nix/store/macho-signature.hh" #include "nix/store/globals.hh" #include "nix/store/local-store.hh" #include "nix/store/user-lock.hh" @@ -366,6 +367,33 @@ private: */ SingleDrvOutputs registerOutputs(); + /** + * Enforce the `macho-signature-rewrite-check` setting: before + * `rewriteOutput` applies `rewrites` to the output at + * `actualPath`, refuse (or warn) if the rewrite would modify + * bytes covered by a Mach-O code signature. + * + * @param caSelfRef Whether this is the self-reference rewrite of + * a content-addressed output (never repairable: no consistent + * page hash exists, and no rebuild can avoid the rewrite). + */ + void checkRewritesDontBreakMachOSignatures( + const std::filesystem::path & actualPath, + const std::string & outputName, + const StringMap & rewrites, + bool caSelfRef); + + /** + * Refuse the output at `actualPath`: delete it and throw the + * `macho-signature-rewrite-check` build error. + */ + [[noreturn]] void throwMachOSignatureRefusal( + const std::vector & hits, + const std::filesystem::path & actualPath, + const std::string & outputName, + bool caSelfRef, + std::string_view extraNote); + protected: /** diff --git a/src/libstore/unix/build/derivation-builder.cc b/src/libstore/unix/build/derivation-builder.cc index 3b02dfc4f172..d0a243448132 100644 --- a/src/libstore/unix/build/derivation-builder.cc +++ b/src/libstore/unix/build/derivation-builder.cc @@ -52,6 +52,7 @@ #include "build/derivation-check.hh" #include "derivation-builder-impl.hh" +#include "nix/store/macho-signature.hh" #ifdef __linux__ # include "chroot-linux-derivation-builder.hh" @@ -1066,6 +1067,113 @@ void DerivationBuilderImpl::execBuilder(const Strings & args, const Strings & en execve(drv.builder.c_str(), stringsToCharPtrs(args).data(), stringsToCharPtrs(envStrs).data()); } +void DerivationBuilderImpl::checkRewritesDontBreakMachOSignatures( + const std::filesystem::path & actualPath, + const std::string & outputName, + const StringMap & rewrites, + bool caSelfRef) +{ + auto mode = localSettings.machOSignatureRewriteCheck.get(); + if (mode == MachOSignatureCheck::Ignore) + return; + + StringSet hashParts; + for (auto & [from, _to] : rewrites) + hashParts.insert(from); + + auto hits = scanForMachOSignatureRewrites(actualPath, hashParts); + if (hits.empty()) + return; + + if (mode == MachOSignatureCheck::Warn) { + for (auto & hit : hits) + warn( + "rewriting store path hashes inside %s (output '%s' of '%s') invalidates its macOS code signature; " + "the binary will be killed by the kernel when it is first executed", + PathFmt(hit.path), + outputName, + store.printStorePath(drvPath)); + return; + } + + throwMachOSignatureRefusal(hits, actualPath, outputName, caSelfRef, ""); +} + +void DerivationBuilderImpl::throwMachOSignatureRefusal( + const std::vector & hits, + const std::filesystem::path & actualPath, + const std::string & outputName, + bool caSelfRef, + std::string_view extraNote) +{ + std::string files; + bool anyCms = false, anyUnchecked = false; + for (auto & hit : hits) { + std::string_view annotation = hit.kind == MachOSignatureKind::Cms ? " (CMS-signed)" + : hit.kind == MachOSignatureKind::Unchecked + ? " (too large to inspect; assumed signed)" + : ""; + files += fmt("\n %s%s", PathFmt(hit.path), annotation); + anyCms = anyCms || hit.kind == MachOSignatureKind::Cms; + anyUnchecked = anyUnchecked || hit.kind == MachOSignatureKind::Unchecked; + } + + std::string remediation{extraNote}; + if (caSelfRef) + /* The self-reference rewrite of a content-addressed output: + the final hash is only known after the build, so no rebuild + can avoid the rewrite. */ + remediation += + "\nThe output is content-addressed and the signed file embeds its own output path, " + "so no rebuild can currently preserve the signature."; + else { + Strings presentPaths; + for (auto & [name, status] : initialOutputs) + if (status.known && status.known->isPresent()) + presentPaths.push_back(store.printStorePath(status.known->path)); + if (!presentPaths.empty()) + remediation += + fmt("\nThe rewrite is needed because the following outputs were already present in the store " + "when the build started:\n %s\n" + "Delete them (`nix-store --delete `) and build again to get correctly signed binaries.", + concatStringsSep("\n ", presentPaths)); + else + /* Nothing was present at build start, so the rewrite maps + a content-addressed sibling output built in this run: + its final hash was likewise unknown while the file was + being signed, and no rebuild avoids the rewrite. */ + remediation += + "\nThe signed file embeds the path of a content-addressed output whose final hash is " + "only known after the build, so no rebuild can currently preserve the signature."; + } + + if (anyCms) + remediation += "\nFiles marked CMS-signed cannot be re-signed without the original signing identity."; + if (anyUnchecked) + remediation += + fmt("\nFiles larger than %d MiB are not parsed; if such a file is certainly unsigned, " + "set `macho-signature-rewrite-check = warn` to proceed.", + 512); + + /* Delete the rejected output before failing. A floating CA + output sits at a fallback scratch path that — unlike a known + output path — is not deleted at the start of the next build, + so leaving it in place would make a retry's builder fail with + a permission error instead of reproducing this error. */ + deletePath(actualPath); + + throw BuildError( + BuildResult::Failure::OutputRejected, + "refusing to rewrite store path hashes inside signed Mach-O file(s) of output '%s' of '%s':%s\n" + "The rewrite would modify bytes covered by the signature's page hashes, and macOS kills such " + "binaries when they are first executed.%s\n" + "Set `macho-signature-rewrite-check = warn` to allow registering the broken output anyway.", + outputName, + store.printStorePath(drvPath), + files, + remediation); +} + SingleDrvOutputs DerivationBuilderImpl::registerOutputs() { std::map infos; @@ -1278,9 +1386,11 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() continue; auto references = *referencesOpt; - auto rewriteOutput = [&](const StringMap & rewrites) { + auto rewriteOutput = [&](const StringMap & rewrites, bool caSelfRef = false) { /* Apply hash rewriting if necessary. */ if (!rewrites.empty()) { + checkRewritesDontBreakMachOSignatures(actualPath, outputName, rewrites, caSelfRef); + debug("rewriting hashes in %1%; cross fingers", PathFmt(actualPath)); /* FIXME: Is this actually streaming? */ @@ -1354,7 +1464,7 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() "since recursive hashing is not enabled (one of outputHashMode={flat,text} is true)", PathFmt(actualPath)); } - rewriteOutput(outputRewrites); + rewriteOutput(outputRewrites, false); /* FIXME optimize and deduplicate with addToStore */ std::string oldHashPart{scratchPath->hashPart()}; auto got = [&] { @@ -1386,7 +1496,7 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() // (note that this doesn't invalidate the ca hash we calculated // above because it's computed *modulo the self-references*, so // it already takes this rewrite into account). - rewriteOutput(StringMap{{oldHashPart, std::string(newInfo0.path.hashPart())}}); + rewriteOutput(StringMap{{oldHashPart, std::string(newInfo0.path.hashPart())}}, true); } { diff --git a/src/libstore/unix/include/nix/store/macho-signature.hh b/src/libstore/unix/include/nix/store/macho-signature.hh new file mode 100644 index 000000000000..9203f0800501 --- /dev/null +++ b/src/libstore/unix/include/nix/store/macho-signature.hh @@ -0,0 +1,73 @@ +#pragma once +///@file + +#include "nix/util/types.hh" + +#include +#include +#include +#include + +namespace nix { + +/* Mach-O container magics. Thin headers are host-endian (little on + every supported platform); fat headers are big-endian on disk. */ +constexpr uint32_t machMagic32 = 0xfeedface; // MH_MAGIC +constexpr uint32_t machMagic64 = 0xfeedfacf; // MH_MAGIC_64 +constexpr uint32_t fatMagic32 = 0xcafebabe; // FAT_MAGIC (big-endian on disk) +constexpr uint32_t fatMagic64 = 0xcafebabf; // FAT_MAGIC_64 + +/* Largest file the parser will read into memory. No darwin build + output carries a Mach-O binary this big in practice; a larger + file is reported unparsed rather than inspected. */ +constexpr size_t maxMachOFileSize = 512 * 1024 * 1024; + +/** + * Kind of code signature a Mach-O file carries. + */ +enum struct MachOSignatureKind { + /* No `LC_CODE_SIGNATURE` load command in any slice. */ + None, + /* A signature without an embedded CMS blob: `ld`'s linker-signed + ad-hoc signature, or `codesign --sign -`. Deterministically + regenerable from the file contents alone. */ + AdHoc, + /* A signature carrying a non-empty CMS (PKCS#7) blob — signed + with a certificate (Developer ID, App Store). Cannot be + regenerated without the original signing identity. */ + Cms, + /* Mach-O magic, but the file could not be inspected (too large). + Only produced by `scanForMachOSignatureRewrites`, which treats + such files as potentially signed — fail closed. */ + Unchecked, +}; + +/** + * Detect whether `contents` is a Mach-O file (thin, fat32 or fat64) + * carrying a code signature, and of which kind. For fat containers + * the strongest kind across slices is returned. + * + * Purely content-based — works the same on every platform, so + * cross-builds of darwin binaries are covered too. Malformed or + * non-Mach-O contents yield `None`; a present but unparseable + * signature blob yields `AdHoc` (erring towards detection). + */ +MachOSignatureKind detectMachOSignature(std::string_view contents); + +struct MachOSignatureRewriteHit +{ + std::filesystem::path path; + MachOSignatureKind kind; +}; + +/** + * Scan `root` (a regular file, or a directory walked recursively; + * symlinks are never followed) for regular files that both carry a + * Mach-O code signature and contain at least one of `hashParts` — + * i.e. files whose upcoming store path hash rewrite would invalidate + * their signature. + */ +std::vector +scanForMachOSignatureRewrites(const std::filesystem::path & root, const StringSet & hashParts); + +} // namespace nix diff --git a/src/libstore/unix/include/nix/store/meson.build b/src/libstore/unix/include/nix/store/meson.build index bdc4b2f20236..fd022163adbc 100644 --- a/src/libstore/unix/include/nix/store/meson.build +++ b/src/libstore/unix/include/nix/store/meson.build @@ -3,5 +3,6 @@ include_dirs += include_directories('../..') headers += files( 'build/child.hh', 'build/hook-instance.hh', + 'macho-signature.hh', 'user-lock.hh', ) diff --git a/src/libstore/unix/macho-signature.cc b/src/libstore/unix/macho-signature.cc new file mode 100644 index 000000000000..4dbbfb1b3649 --- /dev/null +++ b/src/libstore/unix/macho-signature.cc @@ -0,0 +1,361 @@ +#include "nix/store/macho-signature.hh" + +#include "nix/store/references.hh" +#include "nix/util/base-nix-32.hh" +#include "nix/util/file-descriptor.hh" +#include "nix/util/file-system.hh" +#include "nix/util/fmt.hh" +#include "nix/util/logging.hh" + +#include +#include +#include + +namespace nix { + +namespace { + +/* Mach-O and code-signing constants, mirrored from Apple's + ``, `` and the xnu source + (`bsd/sys/codesign.h`). Vendored rather than included so that the + detection works identically when cross-building darwin binaries on + other platforms. (The container magics live in + the header.) */ +constexpr uint32_t lcCodeSignature = 0x1d; // LC_CODE_SIGNATURE + +constexpr uint32_t csMagicEmbeddedSignature = 0xfade0cc0; // CSMAGIC_EMBEDDED_SIGNATURE +constexpr uint32_t csMagicCodeDirectory = 0xfade0c02; // CSMAGIC_CODEDIRECTORY +constexpr uint32_t csMagicBlobWrapper = 0xfade0b01; // CSMAGIC_BLOBWRAPPER +constexpr uint32_t csSlotSignature = 0x10000; // CSSLOT_SIGNATURESLOT + +constexpr uint8_t csHashTypeSha1 = 1; +constexpr uint8_t csHashTypeSha256 = 2; +constexpr uint8_t csHashSizeSha1 = 20; +constexpr uint8_t csHashSizeSha256 = 32; + +/* Upper bound on the CodeDirectory page-size exponent. Apple emits + 12 (4 KiB) for linker-signed binaries and 14 (16 KiB) for + `codesign(1)`. */ +constexpr uint8_t maxPageSizeLog2 = 16; + +/* Header sizes. `mach_header` is 28 bytes, `mach_header_64` is 32 + (one trailing `reserved` field). `fat_header` is 8; `fat_arch` is + 20 and `fat_arch_64` 32. */ +constexpr size_t machHeaderSize32 = 28; +constexpr size_t machHeaderSize64 = 32; +constexpr size_t fatHeaderSize = 8; +constexpr size_t fatArchSize32 = 20; +constexpr size_t fatArchSize64 = 32; +constexpr size_t loadCommandSize = 8; // cmd + cmdsize +constexpr size_t linkeditDataCommandSize = 16; // cmd + cmdsize + dataoff + datasize + +/* Bound on a fat container's `nfat_arch`, to keep the walk over + untrusted bytes finite. Real universal binaries carry a handful of + slices, but xnu accepts more, so the bound is generous. Non-Mach-O + files sharing the fat magic (Java `.class` files — their version + field reads as `nfat_arch`) are rejected by the per-slice + validation below: their "slices" don't carry Mach-O magic. */ +constexpr uint32_t maxNFatArch = 128; + +/* Bound on a SuperBlob's `count` field, to keep the blob-index walk + over untrusted bytes finite. */ +constexpr uint32_t maxSuperBlobCount = 16; + +uint32_t rdLE32(const uint8_t * p) +{ + uint32_t v; + std::memcpy(&v, p, sizeof(v)); + return v; // all supported hosts are little-endian; same as Mach-O headers on disk +} + +uint32_t rdBE32(const uint8_t * p) +{ + return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | uint32_t(p[3]); +} + +uint64_t rdBE64(const uint8_t * p) +{ + return (uint64_t(rdBE32(p)) << 32) | uint64_t(rdBE32(p + 4)); +} + +/** + * Classify the signature (if any) of the Mach-O slice starting at + * `sliceBase`. Returns `None` for a malformed or unsigned slice. + */ +MachOSignatureKind detectSlice(std::string_view contents, size_t sliceBase) +{ + const auto * bytes = reinterpret_cast(contents.data()); + size_t size = contents.size(); + + if (sliceBase + machHeaderSize32 > size) + return MachOSignatureKind::None; + + uint32_t magic = rdLE32(bytes + sliceBase); + size_t headerSize; + if (magic == machMagic64) + headerSize = machHeaderSize64; + else if (magic == machMagic32) + headerSize = machHeaderSize32; + else + return MachOSignatureKind::None; + + if (sliceBase + headerSize > size) + return MachOSignatureKind::None; + + /* `ncmds` at offset 16, `sizeofcmds` at 20 — same for 32/64-bit. */ + uint32_t ncmds = rdLE32(bytes + sliceBase + 16); + uint32_t sizeofcmds = rdLE32(bytes + sliceBase + 20); + if (sliceBase + headerSize + sizeofcmds > size) + return MachOSignatureKind::None; + + size_t lcOff = sliceBase + headerSize; + size_t lcEnd = lcOff + sizeofcmds; + uint32_t sigOff = 0, sigSize = 0; + bool found = false; + for (uint32_t i = 0; i < ncmds && !found; i++) { + if (lcOff + loadCommandSize > lcEnd) + return MachOSignatureKind::None; + uint32_t cmd = rdLE32(bytes + lcOff); + uint32_t cmdsize = rdLE32(bytes + lcOff + 4); + if (cmdsize < loadCommandSize || lcOff + cmdsize > lcEnd) + return MachOSignatureKind::None; + if (cmd == lcCodeSignature) { + if (cmdsize < linkeditDataCommandSize) + return MachOSignatureKind::None; + sigOff = rdLE32(bytes + lcOff + 8); + sigSize = rdLE32(bytes + lcOff + 12); + found = true; + } + lcOff += cmdsize; + } + if (!found) + return MachOSignatureKind::None; + + /* The signature is present. Peek into the SuperBlob to tell an + ad-hoc signature from a CMS-signed one; if the blob doesn't + parse, report `AdHoc` — the rewrite would still invalidate it. */ + constexpr size_t superBlobHeaderSize = 12; // magic + length + count + constexpr size_t blobIndexSize = 8; // type + offset + size_t sbAbs = sliceBase + sigOff; + if (sigSize < superBlobHeaderSize || sbAbs + sigSize > size || sbAbs + sigSize < sbAbs) + return MachOSignatureKind::AdHoc; + if (rdBE32(bytes + sbAbs) != csMagicEmbeddedSignature) + return MachOSignatureKind::AdHoc; + + uint32_t sbCount = rdBE32(bytes + sbAbs + 8); + if (sbCount > maxSuperBlobCount) + return MachOSignatureKind::AdHoc; + + for (uint32_t bi = 0; bi < sbCount; bi++) { + size_t entryOff = sbAbs + superBlobHeaderSize + size_t(bi) * blobIndexSize; + if (entryOff + blobIndexSize > sbAbs + sigSize) + break; + if (rdBE32(bytes + entryOff) != csSlotSignature) + continue; + uint32_t blobRel = rdBE32(bytes + entryOff + 4); + size_t blobAbs = sbAbs + blobRel; + if (blobRel > sigSize || blobAbs + 8 > sbAbs + sigSize) + continue; + if (rdBE32(bytes + blobAbs) != csMagicBlobWrapper) + continue; + /* An empty 8-byte wrapper is what ad-hoc `codesign(1)` leaves + in place; anything larger is a PKCS#7 chain. */ + if (rdBE32(bytes + blobAbs + 4) > 8) + return MachOSignatureKind::Cms; + } + + return MachOSignatureKind::AdHoc; +} + +/** + * Whether `contents` contains any of `hashParts` as a substring. + * `hashParts` members are 32-char nix-base-32 strings; candidate + * positions are found the same way `RefScanSink` does. + */ +bool containsAnyHash(std::string_view contents, const StringSet & hashParts) +{ + constexpr size_t refLength = 32; // StorePath::HashLen + for (size_t i = 0; i + refLength <= contents.size();) { + bool candidate = true; + for (size_t j = refLength; j-- > 0;) { + if (!BaseNix32::lookupReverse(contents[i + j])) { + i += j + 1; + candidate = false; + break; + } + } + if (!candidate) + continue; + if (hashParts.contains(std::string{contents.substr(i, refLength)})) + return true; + ++i; + } + return false; +} + +/* A fat slice is valid if it fits strictly between the fat_arch + array and EOF, and has non-zero size. Subtraction form so the + addition can't wrap on fat64's u64 offset/size fields. */ +bool validSliceBounds(size_t sliceOff, size_t sliceSize, size_t archArrayEnd, size_t fileSize) +{ + if (sliceSize == 0) + return false; + if (sliceOff < archArrayEnd) + return false; + if (sliceOff >= fileSize) + return false; + if (sliceSize > fileSize - sliceOff) + return false; + return true; +} + +} // namespace + +MachOSignatureKind detectMachOSignature(std::string_view contents) +{ + if (contents.size() < machHeaderSize32) + return MachOSignatureKind::None; + + const auto * bytes = reinterpret_cast(contents.data()); + uint32_t magicLE = rdLE32(bytes); + uint32_t magicBE = rdBE32(bytes); + + /* Byte-swapped magics (MH_CIGAM etc.) are deliberately not + handled: they only occur in PowerPC-era big-endian binaries, + which no supported macOS can execute. */ + if (magicLE == machMagic32 || magicLE == machMagic64) + return detectSlice(contents, 0); + + if (magicBE != fatMagic32 && magicBE != fatMagic64) + return MachOSignatureKind::None; + + const bool is64 = magicBE == fatMagic64; + const size_t archSize = is64 ? fatArchSize64 : fatArchSize32; + + uint32_t nfat = rdBE32(bytes + 4); + if (nfat == 0 || nfat > maxNFatArch) + return MachOSignatureKind::None; + + size_t archArrayEnd = fatHeaderSize + size_t(nfat) * archSize; + if (archArrayEnd > contents.size()) + return MachOSignatureKind::None; + + auto result = MachOSignatureKind::None; + for (uint32_t i = 0; i < nfat; i++) { + /* offset at byte 8 of each entry (u32 in fat_arch, u64 in + fat_arch_64), size at byte 12 / 16. The bounds rule must be + the same one the repair walker uses: a slice the detector + reports but the repair tool would skip could pass a check + it was never given. */ + size_t archOff = fatHeaderSize + size_t(i) * archSize; + size_t sliceOff = is64 ? rdBE64(bytes + archOff + 8) : rdBE32(bytes + archOff + 8); + size_t sliceSize = is64 ? rdBE64(bytes + archOff + 16) : rdBE32(bytes + archOff + 12); + if (!validSliceBounds(sliceOff, sliceSize, archArrayEnd, contents.size())) + continue; + auto kind = detectSlice(contents, sliceOff); + if (kind > result) + result = kind; + } + return result; +} + +/** + * Walk `root` (regular file, or directory recursively; symlinks + * skipped), collecting signed Mach-O files. With a non-null + * `hashParts`, only files containing one of the hashes are reported. + */ +static std::vector scanImpl(const std::filesystem::path & root, const StringSet * hashParts) +{ + std::vector hits; + + auto scanFile = [&](const std::filesystem::path & path) { + std::error_code ec; + auto sz = std::filesystem::file_size(path, ec); + if (ec || sz < machHeaderSize32) + return; + + /* Peek the magic before loading the file — most files in a + build output are not Mach-O. `readOffset` uses `pread`, + which leaves the descriptor's offset at 0 for the full + `readFile(fd)` below; a plain `read` here would shift + every subsequent parse by four bytes. */ + AutoCloseFD fd = openFileReadonly(path); + if (!fd) + return; + std::array peek; + if (readOffset(fd.get(), 0, peek) != peek.size()) + return; + const auto * peekBytes = reinterpret_cast(peek.data()); + uint32_t magicLE = rdLE32(peekBytes); + uint32_t magicBE = rdBE32(peekBytes); + if (magicLE != machMagic32 && magicLE != machMagic64 && magicBE != fatMagic32 && magicBE != fatMagic64) + return; + + /* A Mach-O file too large to inspect could still carry a + signature — report it as a hit rather than silently + letting it through. But when we know which hashes the + rewrite would substitute, stream-scan for them first, so + an oversized binary that doesn't contain any of them (and + is therefore untouched by the rewrite) doesn't cause a + spurious refusal. */ + if (sz > maxMachOFileSize) { + if (hashParts) { + RefScanSink refScan{StringSet{*hashParts}}; + drainFD(fd.get(), refScan); + if (refScan.getResult().empty()) + return; + } + hits.push_back({path, MachOSignatureKind::Unchecked}); + return; + } + + auto contents = readFile(fd.get()); + auto kind = detectMachOSignature(contents); + if (kind == MachOSignatureKind::None) + return; + if (hashParts && !containsAnyHash(contents, *hashParts)) + return; + hits.push_back({path, kind}); + }; + + std::error_code ec; + auto st = std::filesystem::symlink_status(root, ec); + if (ec || std::filesystem::is_symlink(st)) + return hits; + + if (std::filesystem::is_regular_file(st)) { + scanFile(root); + return hits; + } + + if (!std::filesystem::is_directory(st)) + return hits; + + auto it = std::filesystem::recursive_directory_iterator( + root, std::filesystem::directory_options::skip_permission_denied, ec); + auto end = std::filesystem::recursive_directory_iterator(); + for (; it != end; it.increment(ec)) { + if (ec) { + debug("scanForMachOSignatureRewrites: %s: directory iteration error: %s", PathFmt(root), ec.message()); + ec.clear(); + continue; + } + std::error_code sec; + auto est = it->symlink_status(sec); + if (sec || std::filesystem::is_symlink(est)) + continue; + if (std::filesystem::is_regular_file(est)) + scanFile(it->path()); + } + return hits; +} + +std::vector +scanForMachOSignatureRewrites(const std::filesystem::path & root, const StringSet & hashParts) +{ + if (hashParts.empty()) + return {}; + return scanImpl(root, &hashParts); +} + +} // namespace nix diff --git a/src/libstore/unix/meson.build b/src/libstore/unix/meson.build index b7a4f566a593..7ecb42e5112a 100644 --- a/src/libstore/unix/meson.build +++ b/src/libstore/unix/meson.build @@ -4,6 +4,7 @@ sources += files( 'build/derivation-builder.cc', 'build/external-derivation-builder.cc', 'build/hook-instance.cc', + 'macho-signature.cc', 'pathlocks.cc', 'user-lock.cc', ) diff --git a/tests/functional/ca/macho-signature-check.nix b/tests/functional/ca/macho-signature-check.nix new file mode 100644 index 000000000000..4ed8024678ec --- /dev/null +++ b/tests/functional/ca/macho-signature-check.nix @@ -0,0 +1,29 @@ +with import ./config.nix; + +# Content-addressed derivation whose signed binary embeds its own +# output path. On a CA build the final (content-addressed) hash is +# only known after the build, so the daemon must rewrite the +# self-reference on every cold build — invalidating the signature's +# page hashes. This is the trigger `macho-signature-rewrite-check` +# refuses without any store state at all. + +mkDerivation { + __contentAddressed = true; + outputHashMode = "recursive"; + outputHashAlgo = "sha256"; + name = "macho-signature-check-ca"; + selfPlaceholder = "${builtins.placeholder "out"}/bin/hello-self"; + buildCommand = '' + cat > self.c <<'EOF' + #include + int main(void) { + printf("self=%s\n", SELF_PATH); + return 0; + } + EOF + /usr/bin/cc -O0 -Wl,-adhoc_codesign -DSELF_PATH="\"$selfPlaceholder\"" -o hello-self self.c + + mkdir -p "$out/bin" + cp hello-self "$out/bin/hello-self" + ''; +} diff --git a/tests/functional/ca/macho-signature-check.sh b/tests/functional/ca/macho-signature-check.sh new file mode 100644 index 000000000000..33a3954f24be --- /dev/null +++ b/tests/functional/ca/macho-signature-check.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# The content-addressed trigger of `macho-signature-rewrite-check`: +# a CA output's final hash is only known after the build, so a signed +# binary embedding its own output path must be rewritten on every +# cold build — no store state needed. The guard refuses, with the +# CA-specific remediation (no rebuild can preserve the signature): +# the hashed pages contain the output's own path, which is a function +# of those pages, so no consistent repaired value exists +# (NixOS/nix#6065). + +source common.sh + +[[ $(uname) == Darwin ]] || skipTest "Mach-O binaries can only be built on darwin" +[[ -x /usr/bin/cc ]] || skipTest "Need /usr/bin/cc to build the test fixture" + +clearStore + +drv=$(nix-instantiate ./macho-signature-check.nix) + +# Cold CA build: refused, with the self-reference remediation. +expectStderr 1 nix-store --realise "$drv" >"$TEST_ROOT/ca-refuse.stderr" +grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/ca-refuse.stderr" +grepQuiet "content-addressed" "$TEST_ROOT/ca-refuse.stderr" +grepQuiet "no rebuild can currently preserve the signature" "$TEST_ROOT/ca-refuse.stderr" + +# warn: registers the (broken) output. +nix-store --realise "$drv" --option macho-signature-rewrite-check warn 2>&1 | + grepQuiet "invalidates its macOS code signature" diff --git a/tests/functional/ca/meson.build b/tests/functional/ca/meson.build index c60db18534a1..dc09f2067694 100644 --- a/tests/functional/ca/meson.build +++ b/tests/functional/ca/meson.build @@ -14,6 +14,7 @@ suites += { 'gc.sh', 'import-from-derivation.sh', 'issue-13247.sh', + 'macho-signature-check.sh', 'multiple-outputs.sh', 'new-build-cmd.sh', 'nix-copy.sh', diff --git a/tests/functional/macho-signature-check.nix b/tests/functional/macho-signature-check.nix new file mode 100644 index 000000000000..c6826a216365 --- /dev/null +++ b/tests/functional/macho-signature-check.nix @@ -0,0 +1,38 @@ +with import ./config.nix; + +# Multi-output input-addressed derivation whose binaries embed a +# placeholder for the `doc` output. When `doc` is already present in +# the store at build start, the daemon rewrites the placeholder bytes +# inside both binaries after the builder exits — invalidating the +# signed one's page hashes, which is exactly what +# `macho-signature-rewrite-check` guards against. The unsigned twin +# is the negative control: same embed, same rewrite, no signature, +# so the guard must leave it alone. + +mkDerivation { + name = "macho-signature-check"; + outputs = [ + "out" + "doc" + ]; + docPlaceholder = "${builtins.placeholder "doc"}/share/doc/hello"; + buildCommand = '' + cat > main.c <<'EOF' + #include + int main(void) { + printf("doc=%s\n", DOC_PATH); + return 0; + } + EOF + # `-adhoc_codesign` makes `ld` sign unconditionally; Apple's + # linker only signs by default when targeting arm64. + /usr/bin/cc -O0 -Wl,-adhoc_codesign -DDOC_PATH="\"$docPlaceholder\"" -o hello main.c + # Unsigned twin: same placeholder embed, no code signature. + /usr/bin/cc -O0 -Wl,-no_adhoc_codesign -DDOC_PATH="\"$docPlaceholder\"" -o hello-unsigned main.c + + mkdir -p "$out/bin" "$doc/share/doc" + cp hello "$out/bin/hello" + cp hello-unsigned "$out/bin/hello-unsigned" + echo "hello docs" > "$doc/share/doc/hello" + ''; +} diff --git a/tests/functional/macho-signature-check.sh b/tests/functional/macho-signature-check.sh new file mode 100644 index 000000000000..9090a4a7b680 --- /dev/null +++ b/tests/functional/macho-signature-check.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash + +# Tests for the `macho-signature-rewrite-check` setting. +# +# `RewritingSink` substitutes scratch-path hashes with final hashes +# inside build outputs after the builder has exited. When the +# substituted bytes sit inside a Mach-O file carrying +# `LC_CODE_SIGNATURE`, the signature's page hashes no longer match +# and the macOS kernel kills the binary at first page-in +# (nixpkgs#507531, NixOS/nix#6065). The check turns that silent +# corruption into a build error. +# +# The rewrite fires when an output being built is already present in +# the store at build start (its scratch path becomes a synthesised +# fallback path). We drive it two ways: deleting one output of a +# multi-output derivation and rebuilding (the partial-substitution +# shape), and `--check` (all outputs present). + +source common.sh + +TODO_NixOS # Requires clearing the store + +[[ $(uname) == Darwin ]] || skipTest "Mach-O binaries can only be built on darwin" +[[ -x /usr/bin/cc ]] || skipTest "Need /usr/bin/cc to build the test fixture" +[[ -x /usr/bin/python3 ]] || skipTest "Need /usr/bin/python3 to synthesize the CMS fixture" + +clearStore + +drv=$(nix-instantiate ./macho-signature-check.nix) + +# Cold build: no outputs present, no rewrite, no error. +nix-store --realise "$drv" +out=$(nix-store --query --outputs "$drv" | grep -v -- '-doc$') +[[ -x "$out/bin/hello" ]] + +if [[ -n "${NIX_TESTS_CA_BY_DEFAULT:-}" ]]; then + skipTest "the fixture's rewrite trigger below is the input-addressed partial-rebuild shape" +fi + +# Delete `out`, keep `doc`, rebuild. The doc output's presence forces +# hash rewriting in the freshly built `out`, whose signed binary +# embeds the doc path → refused by default. +nix-store --delete "$out" +expectStderr 1 nix-store --realise "$drv" >"$TEST_ROOT/refuse.stderr" +grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/refuse.stderr" + +# The error names the already-present output whose deletion allows a +# clean rebuild... +docPath=$(nix-store --query --outputs "$drv" | grep -- '-doc$') +grepQuiet "$docPath" "$TEST_ROOT/refuse.stderr" + +# ...and the signed binary, but NOT its unsigned twin, which embeds +# the same placeholder and undergoes the same rewrite harmlessly. +grepQuiet 'bin/hello"' "$TEST_ROOT/refuse.stderr" +grepQuietInverse "hello-unsigned" "$TEST_ROOT/refuse.stderr" + +# warn: build succeeds (registering the broken binary), diagnostic on +# stderr; both binaries are registered. +nix-store --realise "$drv" --option macho-signature-rewrite-check warn 2>&1 | + grepQuiet "invalidates its macOS code signature" +[[ -x "$out/bin/hello" ]] +[[ -x "$out/bin/hello-unsigned" ]] + +# With all outputs present and valid, the guard also fires under +# --check: every output's scratch path is a fallback, so the rewrite +# would invalidate the signature the same way. Previously this +# surfaced as a spurious "may not be deterministic" failure. +expectStderr 1 nix-store --realise "$drv" --check | + grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" + +# ignore: previous behaviour, silent. +nix-store --delete "$out" +nix-store --realise "$drv" --option macho-signature-rewrite-check ignore 2>&1 | + grepQuietInverse "code signature" +[[ -x "$out/bin/hello" ]] + +# CMS-signed (Developer-ID-shaped) binary: refused with a distinct +# annotation and explanation, since no re-signing without the +# original identity can ever produce a valid signature. +cmsDrv=$(nix-instantiate ./macho-signature-cms.nix) +nix-store --realise "$cmsDrv" +cmsOut=$(nix-store --query --outputs "$cmsDrv" | grep -v -- '-doc$') +[[ -x "$cmsOut/bin/hello-cms" ]] +nix-store --delete "$cmsOut" +expectStderr 1 nix-store --realise "$cmsDrv" >"$TEST_ROOT/cms-refuse.stderr" +grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/cms-refuse.stderr" +grepQuiet "(CMS-signed)" "$TEST_ROOT/cms-refuse.stderr" +grepQuiet "cannot be re-signed without the original signing identity" "$TEST_ROOT/cms-refuse.stderr" diff --git a/tests/functional/macho-signature-cms.nix b/tests/functional/macho-signature-cms.nix new file mode 100644 index 000000000000..6e3f988c3844 --- /dev/null +++ b/tests/functional/macho-signature-cms.nix @@ -0,0 +1,91 @@ +with import ./config.nix; + +# Like macho-signature-check.nix, but the binary's SuperBlob carries a +# synthetic non-empty CMS blob wrapper (the shape of a Developer-ID +# signature). Recomputing such a file's page hashes would invalidate +# the (would-be) PKCS#7 chain, so the rewrite guard annotates it +# distinctly: only the original signing identity could fix it. + +mkDerivation { + name = "macho-signature-cms"; + outputs = [ + "out" + "doc" + ]; + docPlaceholder = "${builtins.placeholder "doc"}/share/doc/hello"; + buildCommand = '' + # Padded source widens the CodeDirectory (more code pages → more + # hash slots) so the default ad-hoc signature has enough slack to + # accept a small synthetic CMS blob in place. + cat > main.c <<'EOF' + #include + static const volatile char padding[131072] = { 0 }; + int main(void) { + printf("doc=%s\n", DOC_PATH); + (void) padding; + return 0; + } + EOF + /usr/bin/cc -O0 -Wl,-adhoc_codesign -DDOC_PATH="\"$docPlaceholder\"" -o hello-cms main.c + + # Inject a non-empty CSMAGIC_BLOBWRAPPER under CSSLOT_SIGNATURESLOT + # into the SuperBlob, rebuilding the blob index contiguously and + # growing the __LINKEDIT segment to fit. + /usr/bin/python3 - hello-cms <<'PY' + import struct, sys + path = sys.argv[1] + b = bytearray(open(path, "rb").read()) + LC_SEGMENT_64 = 0x19 + LC_CODE_SIGNATURE = 0x1d + def be32(o): return (b[o] << 24) | (b[o+1] << 16) | (b[o+2] << 8) | b[o+3] + def le32(o): return struct.unpack("II", 0xfade0b01, 12) + bytes(4))) + entries_sz = len(entries) * 8 + cursor = 12 + entries_sz + new_entries, new_blobs = [], bytearray() + for typ, blob in entries: + new_entries.append((typ, cursor)); new_blobs += blob; cursor += len(blob) + new_sb_length = cursor + new_sig_sz = (new_sb_length + 15) & ~15 + if new_sig_sz > sig_sz: + growth = new_sig_sz - sig_sz + b.extend(bytes(growth)) + struct.pack_into("III", region, 0, 0xfade0cc0, new_sb_length, len(entries)) + for i, (typ, rel) in enumerate(new_entries): + struct.pack_into(">II", region, 12 + i * 8, typ, rel) + region[12 + entries_sz : 12 + entries_sz + len(new_blobs)] = new_blobs + b[sig_off : sig_off + sig_sz] = region + open(path, "wb").write(b) + PY + + mkdir -p "$out/bin" "$doc/share/doc" + cp hello-cms "$out/bin/hello-cms" + echo "hello docs" > "$doc/share/doc/hello" + ''; +} diff --git a/tests/functional/meson.build b/tests/functional/meson.build index 8fdaf4ffe018..a92722baf758 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -127,6 +127,7 @@ suites = [ 'legacy-ssh-store.sh', 'linux-sandbox.sh', 'logging.sh', + 'macho-signature-check.sh', 'make-content-addressed.sh', 'misc.sh', 'multiple-outputs-substitute-failure.sh', From 2ac9a816c0051e7049edf2274a0bf93d1c34d5f4 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:50:14 -0400 Subject: [PATCH 2/3] libstore: Repair broken Mach-O signatures via a privilege-dropped hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detection alone (the previous commit) leaves the user of a multi-output rebuild with an error and a manual deletion step, and --check of a signed self-referential binary with no way to complete. This adds the repair: a new macho-signature-repair-hook setting, defaulting to the Nix-shipped tool `nix __fixup-macho` (registered like __build-remote), which recomputes exactly the stale signature page hashes in place. The repair is deterministic: only hash slots whose stored value disagrees with the page contents are rewritten, and every other byte — the linker-signed flag, the original page size, the identifier — is preserved, so the same input bytes always yield the same output bytes. That property is what --check and content-addressing require, and what re-signing with codesign(1) cannot provide (it switches page size and clears linker-signed). Both SHA-256 and SHA-1 CodeDirectories are recomputed when present, since the kernel validates every one at page-in. The daemon does not run the repair itself. At the detect point it chowns the affected files to the build user and execs the hook with that user's privileges (the diff-hook pattern) and a minimal environment; the complex parse of untrusted bytes thus happens outside the daemon's own context. A nonzero exit from the hook fails closed to the detection error. Setting macho-signature-repair-hook to an empty string disables repair entirely, restoring plain detect-and-refuse. The hook's exit status says it ran, not that the signatures are now valid: the tool skips what it cannot process (an unsupported CodeDirectory hash type, a malformed header), and a custom hook may do less than it claims. So after the repair the daemon re-invokes the hook with --check (same privileges) and registers the output only if every signature verifies — the hook contract, documented in the setting, is that --check modifies nothing and exits 0 when all signatures are valid, 2 when any is stale or cannot be verified. In check mode the tool counts a signature it cannot verify as a failure for the same reason: exit 0 promises all signatures are valid, and "could not parse" is not "valid". Not repairable, and still refused: CMS/Developer-ID signatures (the certificate chain commits to the directory hash; only the original identity can re-sign), files too large to have been inspected, and the self-reference rewrite of a content-addressed output — there the hashed pages contain the output's own path, which is itself a function of those pages, so no consistent signature value exists (issue 6065). The repair scope is thus exactly the damage the daemon's own rewrite causes; breakage introduced by build tools before registerOutputs is out of scope and stays visible. The hook runs between the rewrite and the metadata canonicalisation, so the NAR hash always covers the repaired bytes and ownership and permissions are restored over the hook's intermediate state. For fixed-output and impure derivations, whose outputs sit in a daemon-private 0700 temporary directory at this point, the directory is chowned along with the files; it is transient and deleted after registration. The functional tests assert the full matrix: default hook repairs (codesign --verify passes, output runs and prints the rewritten path), empty hook refuses, failing hook fails closed, a hook without --check support fails closed, a repairable-looking file whose signature the tool cannot process (unsupported hash type) is refused after the re-check rather than registered broken, --check completes with only the genuine LC_UUID nondeterminism, and a direct dual-oracle exercise of the tool (corrupt a signed byte, codesign rejects, repair, codesign accepts, byte content intact). --- doc/manual/rl-next/darwin-macho-signatures.md | 8 + src/libstore-tests/macho-signature.cc | 422 ++++++++++++++++++ .../include/nix/store/local-settings.hh | 45 ++ .../unix/build/derivation-builder-impl.hh | 18 +- src/libstore/unix/build/derivation-builder.cc | 124 +++++ .../unix/include/nix/store/macho-signature.hh | 72 ++- src/libstore/unix/macho-signature.cc | 298 ++++++++++++- src/nix/fixup-macho.cc | 176 ++++++++ src/nix/main.cc | 13 + src/nix/meson.build | 1 + tests/functional/ca/macho-signature-check.sh | 8 +- tests/functional/macho-signature-check.sh | 112 ++++- tests/functional/macho-signature-cms.nix | 7 +- tests/functional/macho-signature-impure.nix | 30 ++ tests/functional/macho-signature-impure.sh | 36 ++ .../macho-signature-unsupported.nix | 60 +++ tests/functional/meson.build | 1 + 17 files changed, 1401 insertions(+), 30 deletions(-) create mode 100644 src/nix/fixup-macho.cc create mode 100644 tests/functional/macho-signature-impure.nix create mode 100644 tests/functional/macho-signature-impure.sh create mode 100644 tests/functional/macho-signature-unsupported.nix diff --git a/doc/manual/rl-next/darwin-macho-signatures.md b/doc/manual/rl-next/darwin-macho-signatures.md index 69f09b4205f7..55cbd4fe5196 100644 --- a/doc/manual/rl-next/darwin-macho-signatures.md +++ b/doc/manual/rl-next/darwin-macho-signatures.md @@ -21,5 +21,13 @@ of self-referential signed binaries fail loudly rather than register silently broken outputs. `warn` and `ignore` restore the previous behaviour with and without a diagnostic. +Most builds succeed transparently rather than failing: the new +`macho-signature-repair-hook` (an internal tool by default, run with the +privileges of a build user) deterministically repairs the stale page hashes +in place, touching no other byte, and the output is registered only once the +repaired signatures verify. Files signed with a certificate (Developer ID) +and self-referential content-addressed outputs cannot be repaired and are +still refused. + Detection is content-based, so cross-builds of macOS binaries on other platforms are covered too. diff --git a/src/libstore-tests/macho-signature.cc b/src/libstore-tests/macho-signature.cc index 775797f6136e..b9c54bb96109 100644 --- a/src/libstore-tests/macho-signature.cc +++ b/src/libstore-tests/macho-signature.cc @@ -1,4 +1,6 @@ #include "nix/store/macho-signature.hh" +#include "nix/util/error.hh" +#include "nix/util/hash.hh" #include @@ -288,4 +290,424 @@ TEST(detectMachOSignature, fat64HugeOffsetNoOverflow) EXPECT_EQ(detectMachOSignature(s), MachOSignatureKind::None); } +/* ------------------------------------------------------------------- + Repair engine (`fixupMachOSignature`) — the same fixtures, but with + real hash slots whose recompute we can verify against hashString. + ------------------------------------------------------------------- */ + +namespace { + +constexpr uint8_t CS_HASHTYPE_SHA1_ = 1; +constexpr uint8_t CS_HASHTYPE_SHA256_ = 2; + +struct CdSpec +{ + uint8_t hashType; + uint8_t hashSize; +}; + +/** + * A signed slice whose CodeDirectories carry real page-hash slots. + * Layout: mach_header_64, LC_CODE_SIGNATURE, `pages` pages of + * repeated 'A' content padding up to the signature, then a SuperBlob + * with one CodeDirectory per `cds` entry (plus optionally a CMS + * wrapper of `cmsLen` bytes). Page size 4096 (pageSizeLog2 = 12); + * codeLimit = sigOff (everything before the signature is hashed, + * like real binaries). Slots are initialized to ZERO — i.e. stale — + * so a repair must rewrite every one. + */ +std::string makeRepairableSlice(const std::vector & cds, size_t pages, size_t cmsLen = 0) +{ + constexpr size_t headerSize = 32; + constexpr size_t lcSize = 16; + constexpr size_t pageSize = 4096; + + size_t sigOff = pages * pageSize; // codeLimit; header+lc live inside page 0 + uint32_t nCodeSlots = uint32_t(pages); + + bool haveCms = cmsLen > 0; + uint32_t nBlobs = uint32_t(cds.size()) + (haveCms ? 1 : 0); + size_t sbHeader = 12 + nBlobs * 8; + + std::vector cdLens; + size_t cdsTotal = 0; + for (auto & cd : cds) { + size_t len = 44 + size_t(nCodeSlots) * cd.hashSize; + cdLens.push_back(len); + cdsTotal += len; + } + size_t sigSize = sbHeader + cdsTotal + (haveCms ? cmsLen : 0); + + std::string s(sigOff + sigSize, 'A'); + + /* mach_header_64 */ + putLE32(s, 0, MH_MAGIC_64_); + putLE32(s, 16, 1); + putLE32(s, 20, lcSize); + + /* LC_CODE_SIGNATURE */ + putLE32(s, headerSize + 0, LC_CODE_SIGNATURE_); + putLE32(s, headerSize + 4, lcSize); + putLE32(s, headerSize + 8, uint32_t(sigOff)); + putLE32(s, headerSize + 12, uint32_t(sigSize)); + + /* SuperBlob */ + putBE32(s, sigOff + 0, CSMAGIC_EMBEDDED_SIGNATURE_); + putBE32(s, sigOff + 4, uint32_t(sigSize)); + putBE32(s, sigOff + 8, nBlobs); + size_t cursor = sbHeader; + for (size_t i = 0; i < cds.size(); i++) { + putBE32(s, sigOff + 12 + i * 8, CSSLOT_CODEDIRECTORY_ + uint32_t(i ? 0x1000 + i : 0)); // alternate slots + putBE32(s, sigOff + 16 + i * 8, uint32_t(cursor)); + cursor += cdLens[i]; + } + if (haveCms) { + putBE32(s, sigOff + 12 + cds.size() * 8, CSSLOT_SIGNATURESLOT_); + putBE32(s, sigOff + 16 + cds.size() * 8, uint32_t(cursor)); + } + + /* CodeDirectories: zeroed (stale) hash slots */ + cursor = sbHeader; + for (size_t i = 0; i < cds.size(); i++) { + size_t cdOff = sigOff + cursor; + putBE32(s, cdOff + 0, CSMAGIC_CODEDIRECTORY_); + putBE32(s, cdOff + 4, uint32_t(cdLens[i])); + putBE32(s, cdOff + 16, 44); // hashOffset + putBE32(s, cdOff + 24, 0); // nSpecialSlots + putBE32(s, cdOff + 28, nCodeSlots); // nCodeSlots + putBE32(s, cdOff + 32, uint32_t(sigOff)); // codeLimit + s[cdOff + 36] = char(cds[i].hashSize); // hashSize + s[cdOff + 37] = char(cds[i].hashType); // hashType + s[cdOff + 39] = 12; // pageSizeLog2 + for (size_t j = 0; j < size_t(nCodeSlots) * cds[i].hashSize; j++) + s[cdOff + 44 + j] = '\0'; + cursor += cdLens[i]; + } + + if (haveCms) { + size_t cmsOff = sigOff + cursor; + putBE32(s, cmsOff + 0, CSMAGIC_BLOBWRAPPER_); + putBE32(s, cmsOff + 4, uint32_t(cmsLen)); + } + + return s; +} + +/** Verify every hash slot of every CD in `s` against a recompute. */ +void expectAllSlotsValid(const std::string & s, const std::vector & cds, size_t pages) +{ + constexpr size_t pageSize = 4096; + size_t sigOff = pages * pageSize; + size_t sbHeader = 12 + (cds.size() + 0) * 8; + /* Recompute sbHeader accounting for a possible CMS entry: read + the actual blob count from the SuperBlob instead. */ + uint32_t nBlobs = (uint32_t(uint8_t(s[sigOff + 8])) << 24) | (uint32_t(uint8_t(s[sigOff + 9])) << 16) + | (uint32_t(uint8_t(s[sigOff + 10])) << 8) | uint32_t(uint8_t(s[sigOff + 11])); + sbHeader = 12 + nBlobs * 8; + + size_t cursor = sbHeader; + for (auto & cd : cds) { + size_t cdOff = sigOff + cursor; + for (size_t i = 0; i < pages; i++) { + std::string_view page(s.data() + i * pageSize, pageSize); + Hash h = hashString(cd.hashType == CS_HASHTYPE_SHA256_ ? HashAlgorithm::SHA256 : HashAlgorithm::SHA1, page); + EXPECT_EQ(std::memcmp(s.data() + cdOff + 44 + i * cd.hashSize, h.hash, cd.hashSize), 0) + << "stale slot: cd hashType=" << int(cd.hashType) << " page " << i; + } + cursor += 44 + pages * cd.hashSize; + } +} + +} // namespace + +TEST(fixupMachOSignature, repairsStaleSha256Slots) +{ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto s = makeRepairableSlice(cds, 3); + EXPECT_TRUE(fixupMachOSignature(s, "test", false)); + expectAllSlotsValid(s, cds, 3); + /* Idempotent: a second run finds nothing stale. */ + EXPECT_FALSE(fixupMachOSignature(s, "test", false)); + /* And check mode agrees. */ + EXPECT_FALSE(fixupMachOSignature(s, "test", true)); +} + +TEST(fixupMachOSignature, repairsDualSha1Sha256CodeDirectories) +{ + /* Pre-2016 binaries carry SHA-1 + SHA-256 alternates; the kernel + validates every one, so both must be recomputed. */ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}, {CS_HASHTYPE_SHA1_, 20}}; + auto s = makeRepairableSlice(cds, 2); + EXPECT_TRUE(fixupMachOSignature(s, "test", false)); + expectAllSlotsValid(s, cds, 2); + EXPECT_FALSE(fixupMachOSignature(s, "test", true)); +} + +TEST(fixupMachOSignature, checkModeReportsWithoutModifying) +{ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto s = makeRepairableSlice(cds, 2); + auto before = s; + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); + EXPECT_EQ(s, before); +} + +TEST(fixupMachOSignature, repairPreservesNonSlotBytes) +{ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto s = makeRepairableSlice(cds, 2); + auto before = s; + EXPECT_TRUE(fixupMachOSignature(s, "test", false)); + /* Only the hash-slot region may differ. */ + constexpr size_t pageSize = 4096; + size_t sigOff = 2 * pageSize; + size_t sbHeader = 12 + 1 * 8; + size_t slotsBegin = sigOff + sbHeader + 44; + size_t slotsEnd = slotsBegin + 2 * 32; + EXPECT_EQ(s.substr(0, slotsBegin), before.substr(0, slotsBegin)); + EXPECT_EQ(s.substr(slotsEnd), before.substr(slotsEnd)); +} + +TEST(fixupMachOSignature, throwsOnCmsRepair) +{ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto s = makeRepairableSlice(cds, 2, /*cmsLen=*/100); + EXPECT_THROW(fixupMachOSignature(s, "test", false), Error); + /* ...but check mode verifies it like any other slice. */ + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); +} + +TEST(fixupMachOSignature, emptyCmsWrapperIsRepairable) +{ + /* The empty 8-byte wrapper ad-hoc `codesign` leaves in place is + not a real CMS signature. */ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto s = makeRepairableSlice(cds, 2, /*cmsLen=*/8); + EXPECT_TRUE(fixupMachOSignature(s, "test", false)); + expectAllSlotsValid(s, cds, 2); +} + +TEST(fixupMachOSignature, lastPageClampedToCodeLimit) +{ + /* codeLimit truncated into the middle of the last page: the last + slot hashes only up to codeLimit, matching xnu. */ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto s = makeRepairableSlice(cds, 2); + constexpr size_t pageSize = 4096; + size_t sigOff = 2 * pageSize; + size_t sbHeader = 12 + 1 * 8; + size_t cdOff = sigOff + sbHeader; + uint32_t truncatedLimit = uint32_t(sigOff - 100); + putBE32(s, cdOff + 32, truncatedLimit); + EXPECT_TRUE(fixupMachOSignature(s, "test", false)); + + Hash h0 = hashString(HashAlgorithm::SHA256, std::string_view(s.data(), pageSize)); + EXPECT_EQ(std::memcmp(s.data() + cdOff + 44, h0.hash, 32), 0); + Hash h1 = hashString(HashAlgorithm::SHA256, std::string_view(s.data() + pageSize, truncatedLimit - pageSize)); + EXPECT_EQ(std::memcmp(s.data() + cdOff + 44 + 32, h1.hash, 32), 0); +} + +TEST(fixupMachOSignature, repairsFatContainer) +{ + /* The fat-dispatch path repairs each signed slice independently. */ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto slice = makeRepairableSlice(cds, 2); + auto fat = makeFat({slice, slice}, false); + EXPECT_TRUE(fixupMachOSignature(fat, "test", false)); + EXPECT_FALSE(fixupMachOSignature(fat, "test", true)); + auto fat64 = makeFat({slice}, true); + EXPECT_TRUE(fixupMachOSignature(fat64, "test", false)); + EXPECT_FALSE(fixupMachOSignature(fat64, "test", true)); +} + +TEST(fixupMachOSignature, nSpecialSlotsOverflowGuard) +{ + /* When nSpecialSlots * hashSize exceeds hashOffset — the special + (negative-index) slots would overrun the code-slot region — the + CodeDirectory is skipped rather than misparsed. The slice's + stale slots are then left untouched. */ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto s = makeRepairableSlice(cds, 2); + constexpr size_t pageSize = 4096; + size_t cdOff = 2 * pageSize + (12 + 8); + /* hashOffset is 44; 2 * 32 = 64 > 44 -> guard fires, CD skipped. */ + putBE32(s, cdOff + 24, 2); // nSpecialSlots + auto before = s; + EXPECT_FALSE(fixupMachOSignature(s, "test", false)); // skipped, nothing written + EXPECT_EQ(s, before); +} + +TEST(fixupMachOSignature, unsupportedHashTypeSkipped) +{ + /* A CodeDirectory with an unknown hashType is left alone (warned), + not crashed or misrepaired. */ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto s = makeRepairableSlice(cds, 2); + constexpr size_t pageSize = 4096; + size_t cdOff = 2 * pageSize + (12 + 8); + s[cdOff + 37] = char(99); // hashType = bogus + EXPECT_FALSE(fixupMachOSignature(s, "test", false)); +} + +TEST(fixupMachOSignature, checkModeFailsOnUnverifiableSignature) +{ + /* A signature the engine cannot verify must not pass the check: + the callers that fail closed on the check's word (the build + door's post-repair re-check) would otherwise accept a binary + whose signature was never actually verified. */ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + constexpr size_t pageSize = 4096; + size_t cdOff = 2 * pageSize + (12 + 8); + + { + /* Unsupported hash type (SHA-384-shaped). */ + auto s = makeRepairableSlice(cds, 2); + s[cdOff + 37] = char(3); + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); + } + { + /* Unsupported page-size exponent. */ + auto s = makeRepairableSlice(cds, 2); + s[cdOff + 39] = char(20); + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); + } + { + /* CodeDirectory length out of bounds. */ + auto s = makeRepairableSlice(cds, 2); + putBE32(s, cdOff + 4, 0xffffffff); + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); + } + { + /* A signature declared by the load command whose SuperBlob + doesn't parse at all. */ + auto s = makeRepairableSlice(cds, 2); + putBE32(s, 2 * pageSize, 0xdeadbeef); + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); + } + { + /* Blob-index offset pointing outside the signature region. */ + auto s = makeRepairableSlice(cds, 2); + putBE32(s, 2 * pageSize + 16, 0xffffff00); + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); + } + { + /* hashSize inconsistent with the declared hash type. */ + auto s = makeRepairableSlice(cds, 2); + s[cdOff + 36] = char(48); + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); + } + { + /* A SuperBlob that parses but contains no CodeDirectory: + a signature nothing can verify. The detector reports such + a file as a hit, so the check must not call it valid. */ + auto s = makeRepairableSlice(cds, 2); + putBE32(s, cdOff, 0xfade0b01); // CD blob magic -> blob wrapper + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); + } +} + +TEST(fixupMachOSignature, fatSliceBoundsMatchDetection) +{ + /* The detector and the repair walker must agree on which fat + slices exist: a slice one walks and the other skips could pass + a check it was never given. A zero-size arch entry is invalid + to both. */ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + auto slice = makeRepairableSlice(cds, 2); + auto fat = makeFat({slice}, false); + putBE32(fat, 8 + 12, 0); // fat_arch.size = 0 + EXPECT_EQ(detectMachOSignature(fat), MachOSignatureKind::None); + EXPECT_FALSE(fixupMachOSignature(fat, "test", true)); +} + +TEST(fixupMachOSignature, repairSkippingUnsupportedCdStillFailsCheck) +{ + /* The OPEN-1 shape end to end at the engine level: one supported + CodeDirectory and one with an unsupported hash type. The repair + fixes the supported one and returns true (it modified slots), + but the follow-up check must still fail — the unsupported CD + was skipped, not verified, and its slots may be stale. */ + std::vector dual{{CS_HASHTYPE_SHA256_, 32}, {CS_HASHTYPE_SHA1_, 20}}; + auto s = makeRepairableSlice(dual, 2); + constexpr size_t pageSize = 4096; + size_t sha1CdOff = 2 * pageSize + (12 + 2 * 8) + (44 + 2 * 32); + s[sha1CdOff + 37] = char(3); // hashType = SHA-384 (unsupported) + + EXPECT_TRUE(fixupMachOSignature(s, "test", false)); + EXPECT_TRUE(fixupMachOSignature(s, "test", true)); +} + +TEST(fixupMachOSignature, nonMachOUntouched) +{ + std::string s(64, 'x'); + auto before = s; + EXPECT_FALSE(fixupMachOSignature(s, "test", false)); + EXPECT_EQ(s, before); +} + +/* Both entry points parse bytes produced by untrusted builders and + substituters, so memory safety on malformed input is a correctness + property. Feed seeded mutations and truncations of valid signed, + fat, and bare-header fixtures through detection and repair; the + invariant is that they never read out of bounds, which the + sanitizer build turns into a failure. A fixed LCG keeps the corpus + reproducible. */ +TEST(machOSignatureFuzz, mutationsNeverCrash) +{ + std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; + const std::string base = makeRepairableSlice(cds, 4, /*cmsLen=*/16); + /* Also fuzz a fat container and a bare header. */ + std::vector seeds{ + base, + makeFat({base, makeUnsignedSlice64()}, false), + makeFat({base}, true), + makeSignedSlice64(100), + std::string(40, '\0'), + }; + + uint64_t rng = 0x9e3779b97f4a7c15ull; + auto next = [&] { + rng = rng * 6364136223846793005ull + 1442695040888963407ull; + return uint32_t(rng >> 33); + }; + + for (const auto & seed : seeds) { + for (int iter = 0; iter < 4000; iter++) { + std::string s = seed; + if (!s.empty()) { + /* 1–4 byte pokes. */ + int pokes = 1 + int(next() % 4); + for (int k = 0; k < pokes; k++) + s[next() % s.size()] = char(next() & 0xff); + /* Occasionally truncate. */ + if (next() % 4 == 0) + s.resize(next() % (s.size() + 1)); + } + + /* Detection must never throw or read OOB. */ + (void) detectMachOSignature(s); + + /* Check mode is read-only and must never throw. */ + std::string check = s; + (void) fixupMachOSignature(check, "fuzz", true); + EXPECT_EQ(check, s); // check mode never mutates + + /* Repair may legitimately throw on a CMS wrapper but must + not crash. No idempotence assertion here: a poke can + corrupt the CodeDirectory header (hashOffset, + nCodeSlots) so a second pass hashes a different, + overlapping slot region — a malformed CD need not be + idempotent. Idempotence on well-formed input is covered + above. */ + std::string repair = s; + try { + (void) fixupMachOSignature(repair, "fuzz", false); + (void) fixupMachOSignature(repair, "fuzz", true); + } catch (const Error &) { + /* CMS or other unrepairable — fine, just no crash. */ + } + } + } +} + } // namespace nix diff --git a/src/libstore/include/nix/store/local-settings.hh b/src/libstore/include/nix/store/local-settings.hh index 6bb91d1809fa..d5a5dbd50aa4 100644 --- a/src/libstore/include/nix/store/local-settings.hh +++ b/src/libstore/include/nix/store/local-settings.hh @@ -546,6 +546,51 @@ public: The check is content-based, so it also fires when cross-building darwin binaries on other platforms. + + When the check fires in `refuse` mode and the damage is + repairable, the [`macho-signature-repair-hook`](#conf-macho-signature-repair-hook) + is given a chance to repair the file before the build is + failed. + )"}; + + Setting machOSignatureRepairHook{ + this, + {"nix", "__fixup-macho"}, + "macho-signature-repair-hook", + R"( + The program (with arguments) that is executed when a store + path hash rewrite has modified a file carrying a Mach-O code + signature, to repair the signature. It is invoked with the + affected files appended as additional arguments, running as + the build user (like the diff hook), and must exit `0` with + every file repaired; otherwise the build fails with the + [`macho-signature-rewrite-check`](#conf-macho-signature-rewrite-check) + error. + + The program must also implement a check mode: invoked as + ` --check ...` it must modify nothing and + exit `0` if every file's signature is valid, `2` if any + signature is stale or cannot be verified. Nix runs this + mode after each repair; an output is only registered once + the check passes. + + The default is the internal Nix tool that recomputes the + stale page hashes in place, modifying only the stale hash + slots and no other byte — deterministic, unlike a + `codesign` re-sign, which rewrites the whole signature and + would make every repair a new source of `--check` and + content-addressing divergence. + + The hook runs with a minimal environment; a custom program + should be given as an absolute path. + + Set it to an empty string to disable repair; the check then + refuses outright. + + Files whose damage is not repairable — CMS/Developer-ID + signatures, and self-referential content-addressed outputs, + where no consistent page hash exists — are never passed to + the hook; those fail the build regardless. )"}; Setting runDiffHook{ diff --git a/src/libstore/unix/build/derivation-builder-impl.hh b/src/libstore/unix/build/derivation-builder-impl.hh index d7588505d4c1..6c0adb90d0a2 100644 --- a/src/libstore/unix/build/derivation-builder-impl.hh +++ b/src/libstore/unix/build/derivation-builder-impl.hh @@ -371,7 +371,9 @@ private: * Enforce the `macho-signature-rewrite-check` setting: before * `rewriteOutput` applies `rewrites` to the output at * `actualPath`, refuse (or warn) if the rewrite would modify - * bytes covered by a Mach-O code signature. + * bytes covered by a Mach-O code signature. Repairable damage is + * recorded in `pendingMachOSignatureRepairs` for the + * `macho-signature-repair-hook` instead of refusing. * * @param caSelfRef Whether this is the self-reference rewrite of * a content-addressed output (never repairable: no consistent @@ -394,6 +396,20 @@ private: bool caSelfRef, std::string_view extraNote); + /** + * Files recorded by `checkRewritesDontBreakMachOSignatures` for + * repair; consumed by `runPostRewriteHook` after the rewrite has + * been applied. + */ + std::vector pendingMachOSignatureRepairs; + + /** + * Run the `macho-signature-repair-hook` (as the build user, like the diff + * hook) on `pendingMachOSignatureRepairs`. A failing hook + * refuses the output — fail closed. + */ + void runPostRewriteHook(const std::filesystem::path & actualPath, const std::string & outputName); + protected: /** diff --git a/src/libstore/unix/build/derivation-builder.cc b/src/libstore/unix/build/derivation-builder.cc index d0a243448132..c406e3cfaa27 100644 --- a/src/libstore/unix/build/derivation-builder.cc +++ b/src/libstore/unix/build/derivation-builder.cc @@ -1096,6 +1096,28 @@ void DerivationBuilderImpl::checkRewritesDontBreakMachOSignatures( return; } + /* Under `refuse`, damage that is repairable is handed to the + signature repair hook after the rewrite; only irreparable damage + fails the build here. Not repairable: CMS signatures (the + page hashes could be recomputed, but the signer's certificate + chain commits to them — only the original identity can + re-sign), files too large to have been parsed, and the + self-reference rewrite of a content-addressed output (the + hashed pages contain the output's own path, which is itself a + function of those pages — no consistent value exists; see + issue 6065). */ + bool repairable = !caSelfRef && !localSettings.machOSignatureRepairHook.get().empty(); + if (repairable) + for (auto & hit : hits) + if (hit.kind != MachOSignatureKind::AdHoc) { + repairable = false; + break; + } + if (repairable) { + pendingMachOSignatureRepairs = hits; + return; + } + throwMachOSignatureRefusal(hits, actualPath, outputName, caSelfRef, ""); } @@ -1174,6 +1196,101 @@ void DerivationBuilderImpl::throwMachOSignatureRefusal( remediation); } +void DerivationBuilderImpl::runPostRewriteHook(const std::filesystem::path & actualPath, const std::string & outputName) +{ + if (pendingMachOSignatureRepairs.empty()) + return; + auto hits = std::move(pendingMachOSignatureRepairs); + pendingMachOSignatureRepairs.clear(); + + auto hook = localSettings.machOSignatureRepairHook.get(); + assert(!hook.empty()); + + auto makeOptions = [&](bool check) { + RunOptions options{ + .program = hook.front(), + .lookupPath = true, + .chdir = "/", + /* The hook gets a minimal environment, not the daemon's. */ + .environment = OsStringMap{}, + }; + for (auto i = std::next(hook.begin()); i != hook.end(); ++i) + options.args.push_back(string_to_os_string(*i)); + if (check) + options.args.push_back(OS_STR("--check")); + for (auto & hit : hits) + options.args.push_back(hit.path.native()); + if (buildUser) { + options.uid = buildUser->getUID(); + options.gid = buildUser->getGID(); + } + return options; + }; + + for (auto & hit : hits) { + /* The rewrite left the file daemon-owned and read-only; the + hook runs as the build user (privilege-dropped, like the + diff hook) and rewrites it in place. Add owner-write only — + the executable bit must survive, since the canonicalisation + that runs right after derives the final 0444/0555 from it. */ + chownToBuilder(hit.path); + std::filesystem::permissions(hit.path, std::filesystem::perms::owner_write, std::filesystem::perm_options::add); + } + if (buildUser) { + /* A fixed-output or impure output has been moved into a + 0700 daemon-owned temporary directory at this point; the + build user could not traverse it. The directory is + transient (deleted after registration) and the sandbox has + already been torn down, so handing it to the build user is + the same trust step the file chown above takes. */ + auto parent = actualPath.parent_path(); + if (parent != std::filesystem::path(store.config->realStoreDir.get())) + chownToBuilder(parent); + } + + try { + auto res = runProgram(makeOptions(false)); + if (!statusOk(res.first)) + throw ExecError(res.first, "signature repair hook %s", statusToString(res.first)); + if (res.second != "") + printError(chomp(res.second)); + } catch (Error & e) { + logError(e.info()); + throwMachOSignatureRefusal( + hits, actualPath, outputName, false, "\nThe signature repair hook failed to repair the file(s)."); + } + + /* The hook's exit status says it ran, not that the signatures are + now valid: the default tool skips what it cannot process (an + unsupported hash type, a malformed CodeDirectory), and a custom + hook may do less than it claims. Registering the output on the + hook's word alone would admit exactly the broken binary this + check exists to stop, so re-verify — same hook, same + privileges, `--check`. */ + int checkStatus = runProgram(makeOptions(true)).first; + switch (classifyMachOCheck(checkStatus)) { + case MachOCheckOutcome::Valid: + for (auto & hit : hits) + debug("signature repair hook repaired %s", PathFmt(hit.path)); + return; + case MachOCheckOutcome::Stale: + throwMachOSignatureRefusal( + hits, + actualPath, + outputName, + false, + "\nThe signature repair hook exited successfully but left signatures that are still " + "invalid or that it cannot process (such as an unsupported hash type)."); + case MachOCheckOutcome::Error: + throwMachOSignatureRefusal( + hits, + actualPath, + outputName, + false, + fmt("\nRe-checking the repaired file(s) failed: signature repair hook %s", statusToString(checkStatus))); + } +} + SingleDrvOutputs DerivationBuilderImpl::registerOutputs() { std::map infos; @@ -1409,6 +1526,13 @@ SingleDrvOutputs DerivationBuilderImpl::registerOutputs() deletePath(actualPath); movePath(tmpPath, actualPath); + /* Repair any signed Mach-O files the rewrite just + damaged. Runs before the canonicalisation below so + the NAR hash covers the repaired bytes and the + canonicalisation restores ownership and + permissions over the hook's intermediate state. */ + runPostRewriteHook(actualPath, outputName); + /* FIXME: set proper permissions in restorePath() so we don't have to do another traversal. */ canonicalisePathMetaData( diff --git a/src/libstore/unix/include/nix/store/macho-signature.hh b/src/libstore/unix/include/nix/store/macho-signature.hh index 9203f0800501..0b93629fc693 100644 --- a/src/libstore/unix/include/nix/store/macho-signature.hh +++ b/src/libstore/unix/include/nix/store/macho-signature.hh @@ -10,8 +10,22 @@ namespace nix { +/** + * Classify the wait-status of a `nix __fixup-macho --check` child: + * exit 0 = every signature is valid, exit 2 = at least one file has + * stale page hashes or a signature that could not be verified, + * anything else = the check itself failed. Every caller interprets + * it through this one definition, keeping them in lockstep with the + * tool's exit contract. + */ +enum struct MachOCheckOutcome { Valid, Stale, Error }; + +MachOCheckOutcome classifyMachOCheck(int waitStatus); + /* Mach-O container magics. Thin headers are host-endian (little on - every supported platform); fat headers are big-endian on disk. */ + every supported platform); fat headers are big-endian on disk. + Defined here so the cheap `hasMachOMagic` peek and the full parser + in macho-signature.cc share one source of truth. */ constexpr uint32_t machMagic32 = 0xfeedface; // MH_MAGIC constexpr uint32_t machMagic64 = 0xfeedfacf; // MH_MAGIC_64 constexpr uint32_t fatMagic32 = 0xcafebabe; // FAT_MAGIC (big-endian on disk) @@ -19,7 +33,9 @@ constexpr uint32_t fatMagic64 = 0xcafebabf; // FAT_MAGIC_64 /* Largest file the parser will read into memory. No darwin build output carries a Mach-O binary this big in practice; a larger - file is reported unparsed rather than inspected. */ + file is reported unparsed rather than inspected. Shared by the + daemon-side scan and the `nix __fixup-macho` tool so the two + agree. */ constexpr size_t maxMachOFileSize = 512 * 1024 * 1024; /** @@ -42,6 +58,20 @@ enum struct MachOSignatureKind { Unchecked, }; +/** + * Whether the first four bytes at `p` identify a possible Mach-O + * (thin, either width, or a fat container). Used to peek files + * cheaply before reading them whole. `p` must point to at least + * four readable bytes; callers peek a fixed 4-byte buffer. + */ +inline bool hasMachOMagic(const unsigned char * p) +{ + uint32_t le; + __builtin_memcpy(&le, p, 4); + uint32_t be = (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) | (uint32_t(p[2]) << 8) | uint32_t(p[3]); + return le == machMagic32 || le == machMagic64 || be == fatMagic32 || be == fatMagic64; +} + /** * Detect whether `contents` is a Mach-O file (thin, fat32 or fat64) * carrying a code signature, and of which kind. For fat containers @@ -70,4 +100,42 @@ struct MachOSignatureRewriteHit std::vector scanForMachOSignatureRewrites(const std::filesystem::path & root, const StringSet & hashParts); +/** + * Recompute the code-signature page hashes of the Mach-O file + * `contents` in place — or, with `checkOnly`, just report whether + * any stored hash disagrees with the page contents. Returns true iff + * at least one hash slot was (or would be) rewritten; in check mode, + * a signature that is present but cannot be verified (unsupported + * hash type, malformed CodeDirectory) also returns true, since the + * callers that fail closed on this result must not take "could not + * parse" for "valid". Handles thin, fat32 and fat64 containers; both + * SHA-256 and SHA-1 CodeDirectories are recomputed when present, + * since the kernel validates every one at page-in. Only stale hash + * slots are modified; every other byte is preserved, so the repair + * is deterministic: the same input bytes always yield the same + * output bytes. + * + * Throws when asked to *repair* (not check) a slice carrying a + * non-empty CMS signature — the signer's certificate chain commits + * to the CodeDirectory, so a recompute would produce a + * differently-broken binary while hiding the reason. + * + * This function does no I/O and does not drop privileges; it is the + * engine of `nix __fixup-macho`, which the daemon execs as the build + * user wherever untrusted bytes must be repaired. It lives here so + * that the detection and repair share one parser (and one test + * suite); code placement does not change the execution surface — the + * daemon itself only ever calls the read-only detection entry points + * above. + * + * DO NOT call this from daemon code that runs as root: it parses (and + * for repair, writes) bytes produced by untrusted builders and + * substituters, and its whole point is to run only in the exec'd, + * privilege-dropped `nix __fixup-macho` child. The daemon's contract + * is detect (read-only, in-process) → exec the child to repair. + * + * `path` is used in diagnostics only. + */ +bool fixupMachOSignature(std::string & contents, const std::filesystem::path & path, bool checkOnly); + } // namespace nix diff --git a/src/libstore/unix/macho-signature.cc b/src/libstore/unix/macho-signature.cc index 4dbbfb1b3649..32ea3fffb193 100644 --- a/src/libstore/unix/macho-signature.cc +++ b/src/libstore/unix/macho-signature.cc @@ -5,12 +5,15 @@ #include "nix/util/file-descriptor.hh" #include "nix/util/file-system.hh" #include "nix/util/fmt.hh" +#include "nix/util/hash.hh" #include "nix/util/logging.hh" #include #include #include +#include + namespace nix { namespace { @@ -19,8 +22,8 @@ namespace { ``, `` and the xnu source (`bsd/sys/codesign.h`). Vendored rather than included so that the detection works identically when cross-building darwin binaries on - other platforms. (The container magics live in - the header.) */ + other platforms. (The container magics live in the header, shared + with `hasMachOMagic`.) */ constexpr uint32_t lcCodeSignature = 0x1d; // LC_CODE_SIGNATURE constexpr uint32_t csMagicEmbeddedSignature = 0xfade0cc0; // CSMAGIC_EMBEDDED_SIGNATURE @@ -209,6 +212,237 @@ bool validSliceBounds(size_t sliceOff, size_t sliceSize, size_t archArrayEnd, si return true; } +/** + * Recompute stale page-hash slots of one Mach-O slice in place — or, + * with `checkOnly`, just report whether any slot is stale. Returns + * true iff at least one slot was (or would be) rewritten. Throws on + * repairing (not checking) a slice with a non-empty CMS signature. + */ +bool fixupSlice(std::string & data, size_t sliceBase, const std::filesystem::path & path, bool checkOnly) +{ + auto * bytes = reinterpret_cast(data.data()); + + if (sliceBase + machHeaderSize32 > data.size()) + return false; + + uint32_t magic = rdLE32(bytes + sliceBase); + size_t headerSize; + if (magic == machMagic64) + headerSize = machHeaderSize64; + else if (magic == machMagic32) + headerSize = machHeaderSize32; + else + return false; + + if (sliceBase + headerSize > data.size()) + return false; + + uint32_t ncmds = rdLE32(bytes + sliceBase + 16); + uint32_t sizeofcmds = rdLE32(bytes + sliceBase + 20); + if (sliceBase + headerSize + sizeofcmds > data.size()) + return false; + + size_t lcOff = sliceBase + headerSize; + size_t lcEnd = lcOff + sizeofcmds; + bool found = false; + uint32_t sigOff = 0, sigSize = 0; + for (uint32_t i = 0; i < ncmds && !found; i++) { + if (lcOff + loadCommandSize > lcEnd) + return false; + uint32_t cmd = rdLE32(bytes + lcOff); + uint32_t cmdsize = rdLE32(bytes + lcOff + 4); + if (cmdsize < loadCommandSize || lcOff + cmdsize > lcEnd) + return false; + if (cmd == lcCodeSignature) { + if (cmdsize < linkeditDataCommandSize) + return false; + sigOff = rdLE32(bytes + lcOff + 8); + sigSize = rdLE32(bytes + lcOff + 12); + found = true; + } + lcOff += cmdsize; + } + if (!found) + return false; + + constexpr size_t superBlobHeaderSize = 12; // magic + length + count + constexpr size_t blobIndexSize = 8; // type + offset + size_t sbAbs = sliceBase + sigOff; + + /* A signature is present from here on. In check mode, anything + that prevents verifying it counts as suspect: reporting such a + slice as valid would let a broken (or unparseable) signature + pass the doors that fail closed on this function's word. In + repair mode the unverifiable parts are skipped with a warning + and the check afterwards reports them. */ + if (sigSize < superBlobHeaderSize || sbAbs + sigSize > data.size() || sbAbs + sigSize < sbAbs) + return checkOnly; + if (rdBE32(bytes + sbAbs) != csMagicEmbeddedSignature) + return checkOnly; + + uint32_t sbCount = rdBE32(bytes + sbAbs + 8); + if (sbCount > maxSuperBlobCount) { + warn("fixup-macho: %s: implausible SuperBlob count %d, skipping", PathFmt(path), int(sbCount)); + return checkOnly; + } + + /* Pre-scan for a non-empty CMS signature blob. In check mode a + CMS slice is verified like any other — stale hashes under a + CMS signature are still stale. */ + if (!checkOnly) + for (uint32_t bi = 0; bi < sbCount; bi++) { + size_t entryOff = sbAbs + superBlobHeaderSize + size_t(bi) * blobIndexSize; + if (entryOff + blobIndexSize > sbAbs + sigSize) + break; + if (rdBE32(bytes + entryOff) != csSlotSignature) + continue; + uint32_t blobRel = rdBE32(bytes + entryOff + 4); + size_t blobAbs = sbAbs + blobRel; + if (blobRel > sigSize || blobAbs + 8 > sbAbs + sigSize) + continue; + if (rdBE32(bytes + blobAbs) != csMagicBlobWrapper) + continue; + if (rdBE32(bytes + blobAbs + 4) > 8) + throw Error( + "%s carries a CMS signature (Developer ID); its page hashes cannot be repaired " + "without invalidating the signer's certificate chain", + PathFmt(path)); + } + + bool modified = false; + + /* Anything from here on that prevents verifying a CodeDirectory + marks the slice unverifiable. Check mode reports that as a + failure (see above); repair mode still skips, and the check + that follows every repair reports what was left unhandled. */ + bool unverifiable = false; + bool anyCodeDirectory = false; + + /* Process every CodeDirectory: pre-2016 binaries carry SHA-1 + + SHA-256 alternates in one SuperBlob, and the kernel validates + every one at page-in, so fixing only one leaves the binary + broken. */ + for (uint32_t bi = 0; bi < sbCount; bi++) { + size_t entryOff = sbAbs + superBlobHeaderSize + size_t(bi) * blobIndexSize; + if (entryOff + blobIndexSize > sbAbs + sigSize) { + unverifiable = true; + break; + } + uint32_t blobRel = rdBE32(bytes + entryOff + 4); + size_t blobAbs = sbAbs + blobRel; + if (blobRel > sigSize || blobAbs + 8 > sbAbs + sigSize) { + unverifiable = true; + continue; + } + + if (rdBE32(bytes + blobAbs) != csMagicCodeDirectory) + continue; + anyCodeDirectory = true; + + /* CS_CodeDirectory header through `pageSizeLog2` is 44 bytes; + newer versions append more fields, but we never read past + byte 40. */ + constexpr size_t cdHeaderSize = 44; + if (blobAbs + cdHeaderSize > sbAbs + sigSize) { + unverifiable = true; + continue; + } + + uint32_t cdLength = rdBE32(bytes + blobAbs + 4); + if (cdLength < cdHeaderSize || cdLength > sigSize || blobAbs + cdLength > sbAbs + sigSize) { + warn("fixup-macho: %s: CodeDirectory length out of bounds, skipping", PathFmt(path)); + unverifiable = true; + continue; + } + + uint32_t hashOffset = rdBE32(bytes + blobAbs + 16); + uint32_t nSpecialSlots = rdBE32(bytes + blobAbs + 24); + uint32_t nCodeSlots = rdBE32(bytes + blobAbs + 28); + uint32_t codeLimit = rdBE32(bytes + blobAbs + 32); + uint8_t hashSize = bytes[blobAbs + 36]; + uint8_t hashType = bytes[blobAbs + 37]; + uint8_t pageSizeLog2 = bytes[blobAbs + 39]; + + HashAlgorithm hashAlgo; + uint8_t expectedHashSize; + if (hashType == csHashTypeSha256) { + hashAlgo = HashAlgorithm::SHA256; + expectedHashSize = csHashSizeSha256; + } else if (hashType == csHashTypeSha1) { + hashAlgo = HashAlgorithm::SHA1; + expectedHashSize = csHashSizeSha1; + } else { + warn("fixup-macho: %s: CodeDirectory hashType=%d not supported, skipping", PathFmt(path), int(hashType)); + unverifiable = true; + continue; + } + if (hashSize != expectedHashSize) { + warn( + "fixup-macho: %s: CodeDirectory hashType=%d has hashSize=%d, skipping", + PathFmt(path), + int(hashType), + int(hashSize)); + unverifiable = true; + continue; + } + if (pageSizeLog2 == 0 || pageSizeLog2 > maxPageSizeLog2) { + warn("fixup-macho: %s: unsupported pageSizeLog2=%d, skipping", PathFmt(path), int(pageSizeLog2)); + unverifiable = true; + continue; + } + + /* Special slots hash other blobs at negative indices from + `hashOffset`, so they must fit below the code-slot region. */ + if (size_t(nSpecialSlots) * hashSize > hashOffset) { + warn( + "fixup-macho: %s: nSpecialSlots=%d overflows hashOffset=%d, skipping", + PathFmt(path), + nSpecialSlots, + hashOffset); + unverifiable = true; + continue; + } + + size_t pageSize = size_t(1) << pageSizeLog2; + + size_t slotsAbs = blobAbs + hashOffset; + size_t slotsEnd = slotsAbs + size_t(nCodeSlots) * hashSize; + if (hashOffset > cdLength || slotsEnd > blobAbs + cdLength || slotsEnd > data.size()) { + warn("fixup-macho: %s: CodeDirectory hash slots out of bounds, skipping", PathFmt(path)); + unverifiable = true; + continue; + } + + for (uint32_t i = 0; i < nCodeSlots; i++) { + /* size_t-promoted before the `+1` to avoid uint32 wrap. */ + size_t pageStart = sliceBase + size_t(i) * pageSize; + size_t pageEndUnclamped = sliceBase + (size_t(i) + 1) * pageSize; + size_t pageEndLimit = sliceBase + size_t(codeLimit); + size_t pageEnd = std::min(pageEndUnclamped, pageEndLimit); + if (pageEnd > data.size() || pageEnd < pageStart) { + warn("fixup-macho: %s: page %d out of bounds, skipping", PathFmt(path), int(i)); + unverifiable = true; + continue; + } + std::string_view sv(data.data() + pageStart, pageEnd - pageStart); + Hash h = hashString(hashAlgo, sv); + uint8_t * slot = bytes + slotsAbs + size_t(i) * hashSize; + if (std::memcmp(slot, h.hash, hashSize) != 0) { + if (!checkOnly) + std::memcpy(slot, h.hash, hashSize); + modified = true; + } + } + } + + /* A SuperBlob that parses but contains no CodeDirectory at all + declares a signature there is no way to verify. */ + if (!anyCodeDirectory) + unverifiable = true; + + return modified || (checkOnly && unverifiable); +} + } // namespace MachOSignatureKind detectMachOSignature(std::string_view contents) @@ -358,4 +592,64 @@ scanForMachOSignatureRewrites(const std::filesystem::path & root, const StringSe return scanImpl(root, &hashParts); } +bool fixupMachOSignature(std::string & contents, const std::filesystem::path & path, bool checkOnly) +{ + if (contents.size() < machHeaderSize32) + return false; + + const auto * bytes = reinterpret_cast(contents.data()); + uint32_t magicLE = rdLE32(bytes); + uint32_t magicBE = rdBE32(bytes); + + if (magicLE == machMagic32 || magicLE == machMagic64) + return fixupSlice(contents, 0, path, checkOnly); + + if (magicBE != fatMagic32 && magicBE != fatMagic64) + return false; + + const bool is64 = magicBE == fatMagic64; + const size_t archSize = is64 ? fatArchSize64 : fatArchSize32; + + const uint32_t nfat = rdBE32(bytes + 4); + if (nfat == 0 || nfat > maxNFatArch) + return false; + + const size_t archArrayEnd = fatHeaderSize + size_t(nfat) * archSize; + if (archArrayEnd > contents.size()) + return false; + + bool modified = false; + + /* fat_arch entry (BE): offset at byte 8 (u32 / u64), size at + byte 12 (u32) or 16 (u64). */ + for (uint32_t i = 0; i < nfat; i++) { + const size_t archOff = fatHeaderSize + size_t(i) * archSize; + const auto [sliceOff, sliceSize] = [&]() -> std::pair { + if (is64) + return {rdBE64(bytes + archOff + 8), rdBE64(bytes + archOff + 16)}; + return {rdBE32(bytes + archOff + 8), rdBE32(bytes + archOff + 12)}; + }(); + + if (!validSliceBounds(sliceOff, sliceSize, archArrayEnd, contents.size())) + continue; + + if (fixupSlice(contents, sliceOff, path, checkOnly)) + modified = true; + } + + return modified; +} + +MachOCheckOutcome classifyMachOCheck(int waitStatus) +{ + if (WIFEXITED(waitStatus)) { + int code = WEXITSTATUS(waitStatus); + if (code == 0) + return MachOCheckOutcome::Valid; + if (code == 2) + return MachOCheckOutcome::Stale; + } + return MachOCheckOutcome::Error; +} + } // namespace nix diff --git a/src/nix/fixup-macho.cc b/src/nix/fixup-macho.cc new file mode 100644 index 000000000000..8c2478ddbcb7 --- /dev/null +++ b/src/nix/fixup-macho.cc @@ -0,0 +1,176 @@ +#include "nix/cmd/legacy.hh" +#include "nix/store/macho-signature.hh" +#include "nix/util/error.hh" +#include "nix/util/exit.hh" +#include "nix/util/file-descriptor.hh" +#include "nix/util/file-system.hh" +#include "nix/util/logging.hh" + +#include +#include +#include +#include +#include + +/** + * `nix __fixup-macho [--check] ...` — the default payload of + * the `macho-signature-repair-hook` setting. + * + * Recomputes Mach-O `LC_CODE_SIGNATURE` page hashes for any pages + * whose stored hash disagrees with the on-disk contents — the state a + * store path hash rewrite leaves a signed binary in. Only the stale + * hash slots are rewritten; every other byte, including the + * `linker-signed` flag and the original page size, is preserved, so + * the same input bytes always yield the same output bytes + * (deterministic, as `--check` and content-addressing require — + * a `codesign` re-sign cannot provide that). + * + * With `--check`, nothing is written; the exit status is 2 when at + * least one file has stale page hashes or a signature that cannot be + * verified, 0 when all signatures are valid. + * + * The daemon execs this as the build user wherever untrusted bytes + * must be parsed for repair (the `diff-hook` privilege pattern); + * this process is the privilege boundary. The parsing and hashing + * engine is `fixupMachOSignature` in libstore, shared with the + * daemon's read-only detector so both are covered by one test suite. + */ + +namespace nix { + +namespace { + +/* Smallest file that could be a thin Mach-O (sizeof(mach_header)). + The upper bound `maxMachOFileSize` is shared with the daemon-side + scan via the header. */ +constexpr size_t minFileSize = 28; + +/** + * Process one regular file. Returns 1 if it was (or, in check mode, + * would be) modified. + */ +size_t fixupFile(const std::filesystem::path & path, bool checkOnly) +{ + std::error_code ec; + auto st = std::filesystem::symlink_status(path, ec); + if (ec || !std::filesystem::is_regular_file(st)) + return 0; + + auto sz = std::filesystem::file_size(path, ec); + if (ec || sz < minFileSize) + return 0; + + /* Peek the magic before loading the file — most files in a store + path are not Mach-O. `readOffset` uses `pread`, leaving the + descriptor's offset at 0 for the full `readFile(fd)` below. */ + AutoCloseFD fd = openFileReadonly(path); + if (!fd) + return 0; + std::array peek; + if (readOffset(fd.get(), 0, peek) != peek.size()) + return 0; + if (!hasMachOMagic(reinterpret_cast(peek.data()))) + return 0; + + /* The size gate applies only to Mach-O files (checked after the + magic peek, so an ordinary large file — libtorch weights, a + dataset — is ignored, not treated as an error). A Mach-O this + large is left as-is with a warning rather than a throw, which + would abort a whole-path run over its other files. In check + mode it counts as a failure: the file may carry a signature + nothing has looked at, and exit 0 promises all signatures are + valid — the same fail-closed reading the daemon-side scan + gives such files (`Unchecked`). */ + if (sz > maxMachOFileSize) { + warn("%s is too large to inspect (limit %d MiB); skipping", PathFmt(path), 512); + return checkOnly ? 1 : 0; + } + + std::string data = readFile(fd.get()); + + if (!fixupMachOSignature(data, path, checkOnly)) + return 0; + if (checkOnly) + return 1; + + /* Length-preserving in-place write. The daemon canonicalises + permissions right after the hook, so 0600 is a safe transient. */ + writeFile(path, std::string_view{data}, 0600); + return 1; +} + +/** + * Process a path: a regular file directly, a directory recursively + * (symlinks never followed). + */ +size_t fixupPath(const std::filesystem::path & path, bool checkOnly) +{ + std::error_code ec; + auto st = std::filesystem::symlink_status(path, ec); + if (ec || std::filesystem::is_symlink(st)) + return 0; + + if (std::filesystem::is_regular_file(st)) + return fixupFile(path, checkOnly); + + if (!std::filesystem::is_directory(st)) + return 0; + + size_t count = 0; + auto it = std::filesystem::recursive_directory_iterator( + path, std::filesystem::directory_options::skip_permission_denied, ec); + auto end = std::filesystem::recursive_directory_iterator(); + for (; it != end; it.increment(ec)) { + if (ec) { + ec.clear(); + continue; + } + std::error_code sec; + auto est = it->symlink_status(sec); + if (sec || std::filesystem::is_symlink(est)) + continue; + if (std::filesystem::is_regular_file(est)) + count += fixupFile(it->path(), checkOnly); + } + return count; +} + +int main_fixup_macho(int argc, char ** argv) +{ + bool checkOnly = false; + int argi = 1; + if (argi < argc && std::string_view(argv[argi]) == "--check") { + checkOnly = true; + argi++; + } + if (argi >= argc) + throw UsageError("usage: nix __fixup-macho [--check] ..."); + + size_t fixed = 0; + for (int i = argi; i < argc; i++) + fixed += fixupPath(std::filesystem::path(argv[i]), checkOnly); + + /* This tool runs as the check/repair child of every door, so a + clean result stays quiet — anything printed here lands in the + daemon log of every verified substitution. */ + if (checkOnly) { + /* Exit 0 = all signatures valid; 2 = at least one file has + stale page hashes or a signature that cannot be verified. + (1 is the generic error exit.) */ + if (fixed) { + printError("fixup-macho: %d file(s) with stale or unverifiable signatures", fixed); + throw Exit(2); + } + return 0; + } + + if (fixed) + printError("fixup-macho: rewrote %d file(s)", fixed); + return 0; +} + +} // namespace + +static RegisterLegacyCommand r_fixup_macho("fixup-macho", main_fixup_macho); + +} // namespace nix diff --git a/src/nix/main.cc b/src/nix/main.cc index 963bc07ce274..bfac29bacac6 100644 --- a/src/nix/main.cc +++ b/src/nix/main.cc @@ -393,6 +393,13 @@ void mainWrapped(int argc, char ** argv) "__build-remote", }); + /* Same self-invocation for the default signature repair hook. */ + settings.getLocalSettings().machOSignatureRepairHook.setDefault( + Strings{ + getNixBin({}).string(), + "__fixup-macho", + }); + initNix(); initGC(); flakeSettings.configureEvalSettings(evalSettings); @@ -416,6 +423,12 @@ void mainWrapped(int argc, char ** argv) argc--; } + if (argc > 1 && std::string_view(argv[1]) == "__fixup-macho") { + programName = "fixup-macho"; + argv++; + argc--; + } + { if (auto legacy = get(RegisterLegacyCommand::commands(), programName)) return (*legacy)(argc, argv); diff --git a/src/nix/meson.build b/src/nix/meson.build index 346f7e4a5c85..8a729899035b 100644 --- a/src/nix/meson.build +++ b/src/nix/meson.build @@ -94,6 +94,7 @@ nix_sources = [ config_priv_h ] + files( 'edit.cc', 'env.cc', 'eval.cc', + 'fixup-macho.cc', 'flake-prefetch-inputs.cc', 'flake.cc', 'formatter.cc', diff --git a/tests/functional/ca/macho-signature-check.sh b/tests/functional/ca/macho-signature-check.sh index 33a3954f24be..44159caca590 100644 --- a/tests/functional/ca/macho-signature-check.sh +++ b/tests/functional/ca/macho-signature-check.sh @@ -4,10 +4,10 @@ # a CA output's final hash is only known after the build, so a signed # binary embedding its own output path must be rewritten on every # cold build — no store state needed. The guard refuses, with the -# CA-specific remediation (no rebuild can preserve the signature): -# the hashed pages contain the output's own path, which is a function -# of those pages, so no consistent repaired value exists -# (NixOS/nix#6065). +# CA-specific remediation (no rebuild can preserve the signature). +# The signature repair hook never runs here: the hashed pages contain the +# output's own path, which is a function of those pages, so no +# consistent repaired value exists (NixOS/nix#6065). source common.sh diff --git a/tests/functional/macho-signature-check.sh b/tests/functional/macho-signature-check.sh index 9090a4a7b680..96d8fda16e94 100644 --- a/tests/functional/macho-signature-check.sh +++ b/tests/functional/macho-signature-check.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash -# Tests for the `macho-signature-rewrite-check` setting. +# Tests for the `macho-signature-rewrite-check` setting and the +# `macho-signature-repair-hook` repair. # # `RewritingSink` substitutes scratch-path hashes with final hashes # inside build outputs after the builder has exited. When the # substituted bytes sit inside a Mach-O file carrying # `LC_CODE_SIGNATURE`, the signature's page hashes no longer match # and the macOS kernel kills the binary at first page-in -# (nixpkgs#507531, NixOS/nix#6065). The check turns that silent -# corruption into a build error. +# (nixpkgs#507531, NixOS/nix#6065). The check detects this; by +# default the signature repair hook then repairs the stale page hashes +# in place, and with the hook disabled the build fails instead. # # The rewrite fires when an output being built is already present in # the store at build start (its scratch path becomes a synthesised @@ -22,31 +24,40 @@ TODO_NixOS # Requires clearing the store [[ $(uname) == Darwin ]] || skipTest "Mach-O binaries can only be built on darwin" [[ -x /usr/bin/cc ]] || skipTest "Need /usr/bin/cc to build the test fixture" +[[ -x /usr/bin/codesign ]] || skipTest "Need /usr/bin/codesign to validate signatures" [[ -x /usr/bin/python3 ]] || skipTest "Need /usr/bin/python3 to synthesize the CMS fixture" clearStore drv=$(nix-instantiate ./macho-signature-check.nix) -# Cold build: no outputs present, no rewrite, no error. +# Cold build: no outputs present, no rewrite, nothing to detect. nix-store --realise "$drv" out=$(nix-store --query --outputs "$drv" | grep -v -- '-doc$') [[ -x "$out/bin/hello" ]] +docPath=$(nix-store --query --outputs "$drv" | grep -- '-doc$') if [[ -n "${NIX_TESTS_CA_BY_DEFAULT:-}" ]]; then skipTest "the fixture's rewrite trigger below is the input-addressed partial-rebuild shape" fi # Delete `out`, keep `doc`, rebuild. The doc output's presence forces -# hash rewriting in the freshly built `out`, whose signed binary -# embeds the doc path → refused by default. +# hash rewriting in the freshly built `out`, invalidating the signed +# binary's page hashes. The default signature repair hook repairs them: +# the build succeeds and the binary carries a valid signature. nix-store --delete "$out" -expectStderr 1 nix-store --realise "$drv" >"$TEST_ROOT/refuse.stderr" +nix-store --realise "$drv" +[[ -x "$out/bin/hello" ]] +/usr/bin/codesign --verify "$out/bin/hello" +"$out/bin/hello" | grepQuiet "^doc=$docPath" + +# Same rebuild with the hook disabled: detect-and-refuse. +nix-store --delete "$out" +expectStderr 1 nix-store --realise "$drv" --option macho-signature-repair-hook "" >"$TEST_ROOT/refuse.stderr" grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/refuse.stderr" # The error names the already-present output whose deletion allows a # clean rebuild... -docPath=$(nix-store --query --outputs "$drv" | grep -- '-doc$') grepQuiet "$docPath" "$TEST_ROOT/refuse.stderr" # ...and the signed binary, but NOT its unsigned twin, which embeds @@ -54,29 +65,80 @@ grepQuiet "$docPath" "$TEST_ROOT/refuse.stderr" grepQuiet 'bin/hello"' "$TEST_ROOT/refuse.stderr" grepQuietInverse "hello-unsigned" "$TEST_ROOT/refuse.stderr" -# warn: build succeeds (registering the broken binary), diagnostic on -# stderr; both binaries are registered. +# A failing hook fails closed to the same refusal. +expectStderr 1 nix-store --realise "$drv" --option macho-signature-repair-hook "$coreutils/false" >"$TEST_ROOT/hookfail.stderr" +grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/hookfail.stderr" +grepQuiet "The signature repair hook failed to repair" "$TEST_ROOT/hookfail.stderr" + +# A custom hook must implement the --check contract: after any repair +# the hook is re-invoked with --check, and only a clean result lets +# the build proceed. A hook that repairs correctly but errors on +# --check is a refusal, not a registration on the repair's word. +cat > "$TEST_ROOT/no-check-hook.sh" <"$TEST_ROOT/nocheck.stderr" +grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/nocheck.stderr" +grepQuiet "Re-checking the repaired file(s) failed" "$TEST_ROOT/nocheck.stderr" + +# warn: build succeeds registering the broken binary, diagnostic on +# stderr; no repair is attempted. nix-store --realise "$drv" --option macho-signature-rewrite-check warn 2>&1 | grepQuiet "invalidates its macOS code signature" [[ -x "$out/bin/hello" ]] [[ -x "$out/bin/hello-unsigned" ]] -# With all outputs present and valid, the guard also fires under -# --check: every output's scratch path is a fallback, so the rewrite -# would invalidate the signature the same way. Previously this -# surfaced as a spurious "may not be deterministic" failure. -expectStderr 1 nix-store --realise "$drv" --check | +# With all outputs present and valid, the rewrite also fires under +# --check. With the hook disabled that is a refusal (previously a +# spurious "may not be deterministic" failure)... +expectStderr 1 nix-store --realise "$drv" --check --option macho-signature-repair-hook "" | grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" +# ...and with the default hook the repair succeeds, leaving only the +# genuine LC_UUID link nondeterminism for the determinism comparison +# to report. +expect 104 nix-store --realise "$drv" --check + # ignore: previous behaviour, silent. nix-store --delete "$out" nix-store --realise "$drv" --option macho-signature-rewrite-check ignore 2>&1 | grepQuietInverse "code signature" [[ -x "$out/bin/hello" ]] -# CMS-signed (Developer-ID-shaped) binary: refused with a distinct -# annotation and explanation, since no re-signing without the -# original identity can ever produce a valid signature. +# Direct repair-tool exercise, dual-oracle: corrupt one byte of a +# string the signature covers, confirm codesign rejects it, repair, +# confirm codesign accepts it and the byte survived (the repair +# touches only hash slots, never content). +cp "$out/bin/hello" "$TEST_ROOT/corrupted" +chmod +w "$TEST_ROOT/corrupted" +/usr/bin/python3 - "$TEST_ROOT/corrupted" <<'EOF' +import sys +path = sys.argv[1] +data = bytearray(open(path, "rb").read()) +off = data.find(b"doc=") +assert off > 0 +data[off] = ord("D") +open(path, "wb").write(data) +EOF +expect 1 /usr/bin/codesign --verify "$TEST_ROOT/corrupted" + +# --check contract: exit 2 = stale hashes present, repairs nothing. +expect 2 nix __fixup-macho --check "$TEST_ROOT/corrupted" +expect 1 /usr/bin/codesign --verify "$TEST_ROOT/corrupted" + +nix __fixup-macho "$TEST_ROOT/corrupted" 2>&1 | grepQuiet "rewrote 1 file(s)" +/usr/bin/codesign --verify "$TEST_ROOT/corrupted" +"$TEST_ROOT/corrupted" | grepQuiet "^Doc=" + +# --check contract: exit 0 = all signatures valid. +nix __fixup-macho --check "$TEST_ROOT/corrupted" + +# CMS-signed (Developer-ID-shaped) binary: never repaired. The +# rewrite guard refuses even with the default repair hook enabled, +# annotating the file, and the error explains why. cmsDrv=$(nix-instantiate ./macho-signature-cms.nix) nix-store --realise "$cmsDrv" cmsOut=$(nix-store --query --outputs "$cmsDrv" | grep -v -- '-doc$') @@ -86,3 +148,17 @@ expectStderr 1 nix-store --realise "$cmsDrv" >"$TEST_ROOT/cms-refuse.stderr" grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/cms-refuse.stderr" grepQuiet "(CMS-signed)" "$TEST_ROOT/cms-refuse.stderr" grepQuiet "cannot be re-signed without the original signing identity" "$TEST_ROOT/cms-refuse.stderr" + +# A signature the repair tool cannot process (unsupported hash type): +# detection classifies the file repairable, the hook runs and exits 0 +# having skipped it, and only the re-check catches that the signature +# was never verified. The build must be refused, not registered — +# trusting the hook's exit status here is how a still-broken binary +# would slip into the store under the default `refuse`. +unsupDrv=$(nix-instantiate ./macho-signature-unsupported.nix) +nix-store --realise "$unsupDrv" +unsupOut=$(nix-store --query --outputs "$unsupDrv" | grep -v -- '-doc$') +nix-store --delete "$unsupOut" +expectStderr 1 nix-store --realise "$unsupDrv" >"$TEST_ROOT/unsupported.stderr" +grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/unsupported.stderr" +grepQuiet "exited successfully but left signatures" "$TEST_ROOT/unsupported.stderr" diff --git a/tests/functional/macho-signature-cms.nix b/tests/functional/macho-signature-cms.nix index 6e3f988c3844..1ee2be8935bd 100644 --- a/tests/functional/macho-signature-cms.nix +++ b/tests/functional/macho-signature-cms.nix @@ -2,9 +2,10 @@ with import ./config.nix; # Like macho-signature-check.nix, but the binary's SuperBlob carries a # synthetic non-empty CMS blob wrapper (the shape of a Developer-ID -# signature). Recomputing such a file's page hashes would invalidate -# the (would-be) PKCS#7 chain, so the rewrite guard annotates it -# distinctly: only the original signing identity could fix it. +# signature). Repair must never touch such a file — recomputing the +# page hashes would invalidate the (would-be) PKCS#7 chain — so the +# rewrite guard refuses even with the signature repair hook enabled, and +# the at-rest sweep skips the path. mkDerivation { name = "macho-signature-cms"; diff --git a/tests/functional/macho-signature-impure.nix b/tests/functional/macho-signature-impure.nix new file mode 100644 index 000000000000..b13469806ad6 --- /dev/null +++ b/tests/functional/macho-signature-impure.nix @@ -0,0 +1,30 @@ +with import ./config.nix; + +# Multi-output impure derivation whose `dev` output contains a signed +# binary embedding the `out` output's path. Impure outputs are moved +# into a daemon-private temporary directory before registration; the +# cross-output reference forces a hash rewrite there, so the repair +# hook must operate inside that directory (the chown-the-parent +# branch of the hook invocation). + +mkDerivation { + name = "macho-signature-impure"; + __impure = true; + outputs = [ + "out" + "dev" + ]; + buildCommand = '' + mkdir -p "$out" "$dev/bin" + echo data > "$out/data" + + cat > main.c < + int main(void) { + printf("out=%s\n", "$out/data"); + return 0; + } + EOF + /usr/bin/cc -O0 -Wl,-adhoc_codesign -o "$dev/bin/hello" main.c + ''; +} diff --git a/tests/functional/macho-signature-impure.sh b/tests/functional/macho-signature-impure.sh new file mode 100644 index 000000000000..e26b1efda23e --- /dev/null +++ b/tests/functional/macho-signature-impure.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +# The impure-output shape of the signature repair: impure outputs +# are relocated into a daemon-private 0700 temporary directory before +# registration, so when a signed binary there needs its cross-output +# reference rewritten, the hook (running as the build user) must be +# given access to that directory. Exercises the chown-the-parent +# branch of the hook invocation. + +source common.sh + +TODO_NixOS # Requires clearing the store + +[[ $(uname) == Darwin ]] || skipTest "Mach-O binaries can only be built on darwin" +[[ -x /usr/bin/cc ]] || skipTest "Need /usr/bin/cc to build the test fixture" +[[ -x /usr/bin/codesign ]] || skipTest "Need /usr/bin/codesign to validate signatures" + +enableFeatures "ca-derivations impure-derivations" +restartDaemon + +clearStore + +# The default hook repairs the signature inside the temp dir; the +# registered binary runs and carries a valid signature. +json=$(nix build -L --no-link --json --file ./macho-signature-impure.nix) +dev=$(echo "$json" | jq -r '.[0].outputs.dev') +out=$(echo "$json" | jq -r '.[0].outputs.out') +[[ -x "$dev/bin/hello" ]] +/usr/bin/codesign --verify "$dev/bin/hello" +"$dev/bin/hello" | grepQuiet "^out=$out/data" + +# With the hook disabled, the same build is refused. +clearStore +expectStderr 1 nix build -L --no-link --file ./macho-signature-impure.nix \ + --option macho-signature-repair-hook "" | + grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" diff --git a/tests/functional/macho-signature-unsupported.nix b/tests/functional/macho-signature-unsupported.nix new file mode 100644 index 000000000000..17f79b0413d3 --- /dev/null +++ b/tests/functional/macho-signature-unsupported.nix @@ -0,0 +1,60 @@ +with import ./config.nix; + +# Like macho-signature-check.nix, but the binary's CodeDirectory is +# patched to declare an unsupported hash type (SHA-384-shaped). The +# rewrite guard's detection still classifies the file as repairable — +# it only looks for the signature, not at the CodeDirectory — but the +# repair tool skips what it cannot process and exits successfully. +# Only the re-check after the repair catches that nothing was +# verified; without it the build would register the broken binary. + +mkDerivation { + name = "macho-signature-unsupported"; + outputs = [ + "out" + "doc" + ]; + docPlaceholder = "${builtins.placeholder "doc"}/share/doc/hello"; + buildCommand = '' + cat > main.c <<'EOF' + #include + int main(void) { + printf("doc=%s\n", DOC_PATH); + return 0; + } + EOF + /usr/bin/cc -O0 -Wl,-adhoc_codesign -DDOC_PATH="\"$docPlaceholder\"" -o hello main.c + + # Overwrite every CodeDirectory's hashType with 3 (SHA-384, which + # the repair tool does not support). + /usr/bin/python3 - hello <<'PY' + import struct, sys + path = sys.argv[1] + b = bytearray(open(path, "rb").read()) + LC_CODE_SIGNATURE = 0x1d + def be32(o): return (b[o] << 24) | (b[o+1] << 16) | (b[o+2] << 8) | b[o+3] + def le32(o): return struct.unpack(" 0 + open(path, "wb").write(b) + PY + + mkdir -p "$out/bin" "$doc/share/doc" + cp hello "$out/bin/hello" + echo "hello docs" > "$doc/share/doc/hello" + ''; +} diff --git a/tests/functional/meson.build b/tests/functional/meson.build index a92722baf758..635ac443136c 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -128,6 +128,7 @@ suites = [ 'linux-sandbox.sh', 'logging.sh', 'macho-signature-check.sh', + 'macho-signature-impure.sh', 'make-content-addressed.sh', 'misc.sh', 'multiple-outputs-substitute-failure.sh', From d2bbf5ed186cff99f738d402c33f2d1c60ad4511 Mon Sep 17 00:00:00 2001 From: ak2k <19240940+ak2k@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:51:21 -0400 Subject: [PATCH 3/3] libstore: Verify and repair Mach-O signatures at substitution time and at rest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build-door check (the previous commits) covers damage the daemon itself causes when registering outputs. But broken signed binaries mostly reach users through substitution: the artifact was already broken where it was built — by the producing daemon, a build tool (bun, electron-builder, install_name_tool), or a broken upstream release — and the substituting machine registers it verbatim. This completes the check at the two remaining doors. Substitution: the new macho-signature-verify setting (default ignore) checks substituted paths in LocalStore::addToStore between restorePath and registerValidPath. A cheap in-daemon scan finds signed Mach-O files; the page-hash verification itself runs in a child process (the repair hook with --check) with the privileges of a build user acquired from the same pool as builds, falling back to the daemon's own uid in single-user mode. Modes: warn names the path and turns mystery SIGKILLs into a download-time diagnostic; refuse fails the substitution (falling back to a local build where possible); repair recomputes the stale hashes before registration, after which the path's NAR hash no longer matches the substituter's advertised one, so its signatures are dropped and it is registered unsigned. Content-addressed paths and CMS-signed files are never repaired and fall back to warn. A path is never accepted on evidence that doesn't exist: a Mach-O file too large to parse is refused under refuse (warned otherwise) rather than passed on the check child's silence, and after a repair the path only counts as repaired if a re-check comes back valid — either way the recorded NAR hash describes the bytes actually on disk, which a partial repair may have changed. Under refuse the path is never modified at all, unlike the build door, whose refuse mode repairs its own rewrite damage first; an operator who wants neither must also empty macho-signature-repair-hook (both settings document this). At rest: the new 'nix store fixup-macho' command repairs broken signatures in paths already registered. It never modifies files in place — with auto-optimise-store, a file may be hard-linked into other store paths, and an in-place write would corrupt every path sharing the inode. Instead the contents are copied, repaired in the copy, verified, swapped in, and the path's NAR hash updated in the database via the new LocalStore::replaceStorePath (which no existing primitive provides: verifyStore only fills in missing hashes and repairPath restores the original contents). The swap window is the same as repairPath's. Content-addressed paths are skipped; a copy whose signatures still fail the post-repair check is discarded, not swapped in. darwin gotcha encoded in replaceStorePath: renaming a read-only directory fails with EACCES on APFS, so owner-write is temporarily restored around the renames; timestamps and permissions are re-canonicalised after the swap. The functional tests manufacture a genuinely broken cached artifact (rewrite under warn with repair disabled, published to a file:// cache) and assert all four modes at the substitution door, the at-rest sweep including dry-run, idempotence, and batch resilience (a CMS path early in the batch does not prevent later repairs), the partial-repair outcome (one supported and one unsupported CodeDirectory: NAR hash updated, signatures dropped, path reported unrepaired, database consistent), and the unverifiable cases — an oversized Mach-O refused at the door and failed by the tool's own --check. --- doc/manual/rl-next/darwin-macho-signatures.md | 39 +-- src/libstore-tests/macho-signature.cc | 5 +- src/libstore/globals.cc | 48 ++++ .../include/nix/store/local-settings.hh | 73 ++++- src/libstore/include/nix/store/local-store.hh | 13 + src/libstore/local-store.cc | 252 +++++++++++++++++- .../unix/include/nix/store/macho-signature.hh | 13 +- src/libstore/unix/macho-signature.cc | 7 +- src/nix/fixup-macho.cc | 3 +- src/nix/meson.build | 1 + src/nix/store-fixup-macho.cc | 217 +++++++++++++++ src/nix/store-fixup-macho.md | 63 +++++ tests/functional/macho-signature-check.sh | 26 ++ .../functional/macho-signature-oversized.nix | 18 ++ tests/functional/macho-signature-partial.nix | 97 +++++++ tests/functional/macho-signature-verify.sh | 196 ++++++++++++++ tests/functional/meson.build | 1 + 17 files changed, 1045 insertions(+), 27 deletions(-) create mode 100644 src/nix/store-fixup-macho.cc create mode 100644 src/nix/store-fixup-macho.md create mode 100644 tests/functional/macho-signature-oversized.nix create mode 100644 tests/functional/macho-signature-partial.nix create mode 100644 tests/functional/macho-signature-verify.sh diff --git a/doc/manual/rl-next/darwin-macho-signatures.md b/doc/manual/rl-next/darwin-macho-signatures.md index 55cbd4fe5196..3de706c95376 100644 --- a/doc/manual/rl-next/darwin-macho-signatures.md +++ b/doc/manual/rl-next/darwin-macho-signatures.md @@ -1,5 +1,5 @@ --- -synopsis: "Store path hash rewrites that would break a Mach-O code signature are now refused" +synopsis: "macOS code-signature validity is now checked when store paths are registered" issues: [6065] prs: [15638] --- @@ -12,22 +12,27 @@ it is run. Previously this corruption was silent; the build "succeeded" and the registered binary was broken (see the [`fish` issue](https://github.com/NixOS/nixpkgs/issues/507531)). -The new `macho-signature-rewrite-check` setting (`refuse` by default) makes -the build fail instead, with an error naming the affected files and the -already-present store paths whose deletion allows a clean rebuild. This also -replaces the spurious "may not be deterministic" failure previously reported -for signed binaries under `--check`, and makes content-addressed cold builds -of self-referential signed binaries fail loudly rather than register silently -broken outputs. `warn` and `ignore` restore the previous behaviour with and -without a diagnostic. +Nix now treats a valid Mach-O signature as a property to preserve when bytes +enter the store, controlled by three settings: -Most builds succeed transparently rather than failing: the new -`macho-signature-repair-hook` (an internal tool by default, run with the -privileges of a build user) deterministically repairs the stale page hashes -in place, touching no other byte, and the output is registered only once the -repaired signatures verify. Files signed with a certificate (Developer ID) -and self-referential content-addressed outputs cannot be repaired and are -still refused. +- `macho-signature-rewrite-check` (`refuse` by default) checks build outputs + before the rewrite. Under `refuse`, the build fails with an error naming + the affected files; the new `macho-signature-repair-hook` (an internal tool by + default, run with the privileges of a build user) then deterministically + repairs the stale page hashes in place, touching no other byte, and the + output is registered only once the repaired signatures verify — so most + builds succeed transparently. `warn` and `ignore` are also available. + +- `macho-signature-verify` (`ignore` by default) checks paths obtained from + a substituter — where binaries broken elsewhere actually reach users — and + can `warn`, `refuse`, or `repair` them. A signature that cannot be + verified (a file too large to parse, an unsupported hash type) is treated + as invalid rather than waved through. + +- `nix store fixup-macho` repairs broken signatures in paths already in the + store. Detection is content-based, so cross-builds of macOS binaries on other -platforms are covered too. +platforms are covered too. Files signed with a certificate (Developer ID) +and self-referential content-addressed outputs cannot be repaired and are +reported rather than silently altered. diff --git a/src/libstore-tests/macho-signature.cc b/src/libstore-tests/macho-signature.cc index b9c54bb96109..4317536dd683 100644 --- a/src/libstore-tests/macho-signature.cc +++ b/src/libstore-tests/macho-signature.cc @@ -553,8 +553,9 @@ TEST(fixupMachOSignature, checkModeFailsOnUnverifiableSignature) { /* A signature the engine cannot verify must not pass the check: the callers that fail closed on the check's word (the build - door's post-repair re-check) would otherwise accept a binary - whose signature was never actually verified. */ + door's post-repair re-check, `macho-signature-verify`, the + at-rest sweep) would otherwise accept a binary whose signature + was never actually verified. */ std::vector cds{{CS_HASHTYPE_SHA256_, 32}}; constexpr size_t pageSize = 4096; size_t cdOff = 2 * pageSize + (12 + 8); diff --git a/src/libstore/globals.cc b/src/libstore/globals.cc index cbaac5e44429..68f521098909 100644 --- a/src/libstore/globals.cc +++ b/src/libstore/globals.cc @@ -407,6 +407,54 @@ std::string BaseSetting::to_string() const template class BaseSetting; +NLOHMANN_JSON_SERIALIZE_ENUM( + MachOSignatureVerify, + { + {MachOSignatureVerify::Ignore, "ignore"}, + {MachOSignatureVerify::Warn, "warn"}, + {MachOSignatureVerify::Refuse, "refuse"}, + {MachOSignatureVerify::Repair, "repair"}, + }); + +template<> +MachOSignatureVerify BaseSetting::parse(const std::string & str) const +{ + if (str == "ignore") + return MachOSignatureVerify::Ignore; + else if (str == "warn") + return MachOSignatureVerify::Warn; + else if (str == "refuse") + return MachOSignatureVerify::Refuse; + else if (str == "repair") + return MachOSignatureVerify::Repair; + else + throw UsageError( + "option '%s' has invalid value '%s', expected 'ignore', 'warn', 'refuse', or 'repair'", name, str); +} + +template<> +struct BaseSetting::trait +{ + static constexpr bool appendable = false; +}; + +template<> +std::string BaseSetting::to_string() const +{ + switch (value) { + case MachOSignatureVerify::Ignore: + return "ignore"; + case MachOSignatureVerify::Warn: + return "warn"; + case MachOSignatureVerify::Refuse: + return "refuse"; + case MachOSignatureVerify::Repair: + return "repair"; + } + unreachable(); +} + +template class BaseSetting; void to_json(nlohmann::json & j, const ChrootPath & cp) { diff --git a/src/libstore/include/nix/store/local-settings.hh b/src/libstore/include/nix/store/local-settings.hh index d5a5dbd50aa4..d9c4b4d73df7 100644 --- a/src/libstore/include/nix/store/local-settings.hh +++ b/src/libstore/include/nix/store/local-settings.hh @@ -38,6 +38,22 @@ MachOSignatureCheck BaseSetting::parse(const std::string & template<> std::string BaseSetting::to_string() const; +/** + * What to do when a substituted path contains a Mach-O file with an + * invalid code signature. See `macho-signature-verify`. + */ +enum struct MachOSignatureVerify { + Ignore, + Warn, + Refuse, + Repair, +}; + +template<> +MachOSignatureVerify BaseSetting::parse(const std::string & str) const; +template<> +std::string BaseSetting::to_string() const; + template<> PathsInChroot BaseSetting::parse(const std::string & str) const; template<> @@ -571,8 +587,9 @@ public: ` --check ...` it must modify nothing and exit `0` if every file's signature is valid, `2` if any signature is stale or cannot be verified. Nix runs this - mode after each repair; an output is only registered once - the check passes. + mode after each repair — an output is only registered once + the check passes — and to verify paths under + [`macho-signature-verify`](#conf-macho-signature-verify). The default is the internal Nix tool that recomputes the stale page hashes in place, modifying only the stale hash @@ -593,6 +610,58 @@ public: the hook; those fail the build regardless. )"}; + Setting machOSignatureVerify{ + this, + MachOSignatureVerify::Ignore, + "macho-signature-verify", + R"( + Whether to check, when adding a path to the store from a + substituter or another store, that the code signatures of + the Mach-O files it contains match their contents. A binary + with stale signature page hashes is killed by the macOS + kernel when it is first executed, so this turns "mystery + SIGKILLs" from broken cached binaries — whatever broke them: + the producing daemon, a build tool, or a broken upstream + artifact — into a diagnostic at download time. + + - `ignore` (default): no checking. + + - `warn`: print a warning naming the path and the affected + files, but add the path anyway. + + - `refuse`: fail the substitution. Nix falls back to + building the path locally if possible. A signature that + cannot be verified — a file too large to parse, or a + CodeDirectory whose hash type Nix does not support — is + treated the same as an invalid one (fail closed). + + - `repair`: recompute the stale page hashes before the path + is registered, by running the + [`macho-signature-repair-hook`](#conf-macho-signature-repair-hook) with the + privileges of a build user. The path's NAR hash then + differs from the substituter's advertised one, so its + signatures no longer apply: the repaired path is + registered unsigned, and a store that + [requires signatures](#conf-require-sigs) will not accept + it from this one. Content-addressed paths and + CMS/Developer-ID-signed files are never repaired; those + fall back to `warn`. + + The check itself is performed by the + [`macho-signature-repair-hook`](#conf-macho-signature-repair-hook) tool; if that + setting is empty, paths are added without checking whatever + this is set to. + + Note the asymmetry with + [`macho-signature-rewrite-check`](#conf-macho-signature-rewrite-check): + under `refuse` this setting never modifies a path — the + substitution simply fails — while the build-time check under + its `refuse` first lets the `macho-signature-repair-hook` repair the + output and only refuses what the hook cannot fix. A store + that must never register a Nix-modified signed binary needs + `macho-signature-repair-hook` emptied as well. + )"}; + Setting runDiffHook{ this, false, diff --git a/src/libstore/include/nix/store/local-store.hh b/src/libstore/include/nix/store/local-store.hh index 0851795ff3d2..14bf52c0e4b4 100644 --- a/src/libstore/include/nix/store/local-store.hh +++ b/src/libstore/include/nix/store/local-store.hh @@ -295,6 +295,19 @@ public: void addToStore(const ValidPathInfo & info, Source & source, RepairFlag repair, CheckSigsFlag checkSigs) override; + /** + * Replace the contents of the valid store path `info.path` with + * the file system object at `source` (which is moved), updating + * the database row with `info`'s new NAR hash, size and + * signatures. Used by `nix store fixup-macho`, which must not + * modify a path's files in place because `auto-optimise-store` + * may hard-link them into other paths. + * + * The swap window (old path moved away, new one moved in) is the + * same as `repairPath`'s. + */ + void replaceStorePath(const StorePath & path, const std::filesystem::path & source, const ValidPathInfo & info); + StorePath addToStoreFromDump( Source & dump, std::string_view name, diff --git a/src/libstore/local-store.cc b/src/libstore/local-store.cc index 5a1c161c0739..09a2f93c2063 100644 --- a/src/libstore/local-store.cc +++ b/src/libstore/local-store.cc @@ -17,6 +17,12 @@ #include "nix/store/keys.hh" #include "nix/util/users.hh" #include "nix/store/store-registration.hh" +#include "nix/util/processes.hh" + +#ifndef _WIN32 +# include "nix/store/macho-signature.hh" +# include "nix/store/user-lock.hh" +#endif #include #include @@ -1043,6 +1049,242 @@ bool LocalStore::realisationIsUntrusted(const Realisation & realisation) return config->requireSigs && !realisation.checkSignatures(realisation.id, getPublicKeys()); } +#ifndef _WIN32 +/** + * Enforce the `macho-signature-verify` setting on a path just + * restored at `realPath` but not yet registered: check the code + * signatures of the Mach-O files it contains, and depending on the + * setting warn, refuse, or repair (updating `info`'s NAR hash and + * dropping its now-inapplicable signatures). + * + * The daemon itself performs only the bounded, read-only detection + * parse (finding signed Mach-O files); the page-hash verification + * and the repair run in a child process with the privileges of a + * build user (`nix __fixup-macho`, the macho-signature-repair-hook payload). + */ +static void verifyMachOSignatures( + LocalStore & store, + const LocalSettings & localSettings, + const std::filesystem::path & realPath, + ValidPathInfo & info) +{ + auto mode = localSettings.machOSignatureVerify.get(); + if (mode == MachOSignatureVerify::Ignore) + return; + + /* Cheap in-daemon pre-filter: only paths containing signed + Mach-O files need the child process at all. */ + auto hits = scanForMachOSignatures(realPath); + if (hits.empty()) + return; + + /* The check runs privilege-dropped through the same tool as the + repair, so an empty `macho-signature-repair-hook` leaves nothing to verify + with; the path is added unchecked regardless of mode. */ + auto hook = localSettings.machOSignatureRepairHook.get(); + if (hook.empty()) { + warn( + "adding '%s' without verifying its Mach-O code signatures: `macho-signature-repair-hook` is empty", + store.printStorePath(info.path)); + return; + } + + /* A Mach-O file too large to parse is reported `Unchecked` by the + scan, and the check child skips it — its exit status says + nothing about such a file. Treat the path as unverifiable + rather than valid: refuse under `refuse`, warn otherwise. */ + bool anyUnchecked = false; + for (auto & hit : hits) + anyUnchecked = anyUnchecked || hit.kind == MachOSignatureKind::Unchecked; + if (anyUnchecked) { + if (mode == MachOSignatureVerify::Refuse) + throw Error( + "refusing to add '%s' to the store: it contains Mach-O file(s) too large to have their " + "code signatures verified (set `macho-signature-verify` to `warn` or `ignore` to " + "accept such paths)", + store.printStorePath(info.path)); + warn( + "'%s' contains Mach-O file(s) too large to have their code signatures verified", + store.printStorePath(info.path)); + } + + /* Both `--check` and the repair run privilege-dropped. Use a + build user from the same pool as builds; if none is configured + (single-user mode), run at the daemon's own (unprivileged) + uid. If the pool is exhausted, fail the substitution with a + retryable error rather than silently running the hook with the + daemon's privileges — the privilege drop is the point. */ + std::unique_ptr userLock; + if (useBuildUsers(localSettings)) { + userLock = acquireUserLock(settings.nixStateDir, localSettings, 1, false); + if (!userLock) + throw Error( + "cannot verify Mach-O code signatures of '%s': all build users are currently in use " + "(retry, or increase the size of `build-users-group`)", + store.printStorePath(info.path)); + } + + auto runHook = [&](bool check) { + RunOptions options{ + .program = hook.front(), + .lookupPath = true, + .chdir = "/", + .environment = OsStringMap{}, + }; + for (auto i = std::next(hook.begin()); i != hook.end(); ++i) + options.args.push_back(string_to_os_string(*i)); + if (check) + options.args.push_back(OS_STR("--check")); + options.args.push_back(realPath.native()); + if (userLock) { + options.uid = userLock->getUID(); + options.gid = userLock->getGID(); + } + return runProgram(std::move(options)).first; + }; + + int checkStatus = runHook(true); + switch (classifyMachOCheck(checkStatus)) { + case MachOCheckOutcome::Valid: + return; + case MachOCheckOutcome::Stale: + break; + case MachOCheckOutcome::Error: + throw Error( + "Mach-O signature verification of '%s' failed: %s", + store.printStorePath(info.path), + statusToString(checkStatus)); + } + + bool repairable = mode == MachOSignatureVerify::Repair + /* A content-addressed path cannot be repaired: its + `ca` field would no longer match the contents. */ + && !info.ca; + if (repairable) + for (auto & hit : hits) + if (hit.kind != MachOSignatureKind::AdHoc) { + repairable = false; + break; + } + + if (mode == MachOSignatureVerify::Refuse) + throw Error( + "refusing to add '%s' to the store: it contains Mach-O file(s) with invalid code signatures, " + "which macOS kills at first execution (set `macho-signature-verify` to `warn`, `repair`, or " + "`ignore` to accept such paths)", + store.printStorePath(info.path)); + + if (!repairable) { + warn( + "'%s' contains Mach-O file(s) with invalid code signatures; " + "the affected binaries will be killed by the macOS kernel when they are first executed", + store.printStorePath(info.path)); + return; + } + + /* The child writes the files, so they must be owned by the hook + user; the caller canonicalises ownership back immediately + after. The substituter's signatures are then dropped: they + cover the pre-repair content, which the recomputed NAR hash no + longer matches. */ + debug("repairing Mach-O code signatures of '%s'", store.printStorePath(info.path)); + if (userLock) + for (auto & hit : hits) { + chown(hit.path, userLock->getUID(), userLock->getGID()); + std::filesystem::permissions( + hit.path, std::filesystem::perms::owner_write, std::filesystem::perm_options::add); + } + + int repairStatus = runHook(false); + if (!statusOk(repairStatus)) + throw Error( + "failed to repair Mach-O code signatures of '%s': %s", + store.printStorePath(info.path), + statusToString(repairStatus)); + + /* The hook's exit status says it ran, not that the signatures + are now valid — a custom hook may do less than it claims, so + re-check before calling the path repaired. Either way the + recorded NAR hash must describe the bytes actually on disk, + which the repair may have changed even when it could not fix + everything. */ + bool repaired = classifyMachOCheck(runHook(true)) == MachOCheckOutcome::Valid; + + auto narHashAndSize = hashPath( + {makeFSSourceAccessor(realPath), CanonPath::root}, FileSerialisationMethod::NixArchive, HashAlgorithm::SHA256); + if (narHashAndSize.hash != info.narHash) { + info.narHash = narHashAndSize.hash; + info.narSize = narHashAndSize.numBytesDigested; + /* The substituter's signatures signed the old contents. */ + info.sigs.clear(); + } + + if (repaired) + warn( + "repaired invalid Mach-O code signature(s) in '%s' (registered unsigned; NAR hash updated)", + store.printStorePath(info.path)); + else + warn( + "'%s' contains Mach-O file(s) with invalid code signatures that the " + "`macho-signature-repair-hook` did not repair; the affected binaries will be killed " + "by the macOS kernel when they are first executed", + store.printStorePath(info.path)); +} +#endif + +void LocalStore::replaceStorePath( + const StorePath & path, const std::filesystem::path & source, const ValidPathInfo & info) +{ + assert(info.path == path); + + auto realPath = toRealPath(path); + + PathLocks outputLock({realPath}); + + canonicalisePathMetaData(source, {NIX_WHEN_SUPPORT_ACLS(config->getLocalSettings().ignoredAcls)}); + + { + /* Swap the repaired contents in, the same rename window + `repairPath` uses. Plain `rename`, not `moveFile`: the + latter swallows non-EXDEV errors, and a silent failure here + would leave the path unrepaired while the database records + the repaired hash. */ + auto oldPath = + std::filesystem::path(realPath).replace_filename(std::filesystem::path(realPath).filename() += ".old"); + deletePath(oldPath); + /* On darwin, renaming a read-only directory fails with + EACCES, so temporarily restore owner-write on both (the + old path is deleted right after; the new one is re-locked + below). */ + auto addOwnerWrite = [](const std::filesystem::path & p) { + if (std::filesystem::is_directory(p)) + std::filesystem::permissions( + p, std::filesystem::perms::owner_write, std::filesystem::perm_options::add); + }; + addOwnerWrite(realPath); + addOwnerWrite(source); + std::filesystem::rename(realPath, oldPath); + try { + std::filesystem::rename(source, realPath); + } catch (...) { + std::filesystem::rename(oldPath, realPath); + throw; + } + deletePath(oldPath); + canonicaliseTimestampAndPermissions(realPath); + } + + optimisePath(realPath, NoRepair); + + if (config->getLocalSettings().fsyncStorePaths) { + recursiveSync(realPath); + syncParent(realPath); + } + + registerValidPath(info); + invalidatePathInfoCacheFor(path); +} + void LocalStore::addToStore(const ValidPathInfo & info, Source & source, RepairFlag repair, CheckSigsFlag checkSigs) { if (checkSigs && pathInfoIsUntrusted(info)) @@ -1129,6 +1371,14 @@ void LocalStore::addToStore(const ValidPathInfo & info, Source & source, RepairF autoGC(); + /* Possibly-modified copy: signature repair updates + the NAR hash and drops the signatures. */ + ValidPathInfo infoToRegister{info}; + +#ifndef _WIN32 + verifyMachOSignatures(*this, config->getLocalSettings(), realPath, infoToRegister); +#endif + canonicalisePathMetaData(realPath, {NIX_WHEN_SUPPORT_ACLS(config->getLocalSettings().ignoredAcls)}); optimisePath(realPath, repair); // FIXME: combine with hashPath() @@ -1138,7 +1388,7 @@ void LocalStore::addToStore(const ValidPathInfo & info, Source & source, RepairF syncParent(realPath); } - registerValidPath(info); + registerValidPath(infoToRegister); } else // We may have a negative cache entry for this path, so get rid of it. invalidatePathInfoCacheFor(info.path); diff --git a/src/libstore/unix/include/nix/store/macho-signature.hh b/src/libstore/unix/include/nix/store/macho-signature.hh index 0b93629fc693..a46da9fb9969 100644 --- a/src/libstore/unix/include/nix/store/macho-signature.hh +++ b/src/libstore/unix/include/nix/store/macho-signature.hh @@ -14,9 +14,9 @@ namespace nix { * Classify the wait-status of a `nix __fixup-macho --check` child: * exit 0 = every signature is valid, exit 2 = at least one file has * stale page hashes or a signature that could not be verified, - * anything else = the check itself failed. Every caller interprets - * it through this one definition, keeping them in lockstep with the - * tool's exit contract. + * anything else = the check itself failed. The build door, the + * substitution door and the at-rest sweep all interpret it this way; + * one definition keeps them in lockstep with the tool's exit contract. */ enum struct MachOCheckOutcome { Valid, Stale, Error }; @@ -100,6 +100,13 @@ struct MachOSignatureRewriteHit std::vector scanForMachOSignatureRewrites(const std::filesystem::path & root, const StringSet & hashParts); +/** + * Like `scanForMachOSignatureRewrites`, but reports every signed + * Mach-O file regardless of contents. Used by the substitution-time + * check, where any of a path's signatures may be stale. + */ +std::vector scanForMachOSignatures(const std::filesystem::path & root); + /** * Recompute the code-signature page hashes of the Mach-O file * `contents` in place — or, with `checkOnly`, just report whether diff --git a/src/libstore/unix/macho-signature.cc b/src/libstore/unix/macho-signature.cc index 32ea3fffb193..5c5fd0bc465c 100644 --- a/src/libstore/unix/macho-signature.cc +++ b/src/libstore/unix/macho-signature.cc @@ -570,7 +570,7 @@ static std::vector scanImpl(const std::filesystem::pat auto end = std::filesystem::recursive_directory_iterator(); for (; it != end; it.increment(ec)) { if (ec) { - debug("scanForMachOSignatureRewrites: %s: directory iteration error: %s", PathFmt(root), ec.message()); + debug("scanForMachOSignatures: %s: directory iteration error: %s", PathFmt(root), ec.message()); ec.clear(); continue; } @@ -592,6 +592,11 @@ scanForMachOSignatureRewrites(const std::filesystem::path & root, const StringSe return scanImpl(root, &hashParts); } +std::vector scanForMachOSignatures(const std::filesystem::path & root) +{ + return scanImpl(root, nullptr); +} + bool fixupMachOSignature(std::string & contents, const std::filesystem::path & path, bool checkOnly) { if (contents.size() < machHeaderSize32) diff --git a/src/nix/fixup-macho.cc b/src/nix/fixup-macho.cc index 8c2478ddbcb7..21136d8eab91 100644 --- a/src/nix/fixup-macho.cc +++ b/src/nix/fixup-macho.cc @@ -14,7 +14,8 @@ /** * `nix __fixup-macho [--check] ...` — the default payload of - * the `macho-signature-repair-hook` setting. + * the `macho-signature-repair-hook` setting, and the verification/repair child + * of `macho-signature-verify` and `nix store fixup-macho`. * * Recomputes Mach-O `LC_CODE_SIGNATURE` page hashes for any pages * whose stored hash disagrees with the on-disk contents — the state a diff --git a/src/nix/meson.build b/src/nix/meson.build index 8a729899035b..64de35bc5097 100644 --- a/src/nix/meson.build +++ b/src/nix/meson.build @@ -118,6 +118,7 @@ nix_sources = [ config_priv_h ] + files( 'sigs.cc', 'store-copy-log.cc', 'store-delete.cc', + 'store-fixup-macho.cc', 'store-gc.cc', 'store-info.cc', 'store-repair.cc', diff --git a/src/nix/store-fixup-macho.cc b/src/nix/store-fixup-macho.cc new file mode 100644 index 000000000000..5bea2454c606 --- /dev/null +++ b/src/nix/store-fixup-macho.cc @@ -0,0 +1,217 @@ +#include "nix/cmd/command.hh" +#include "nix/store/local-store.hh" +#include "nix/store/store-cast.hh" +#include "nix/util/archive.hh" +#include "nix/util/file-content-address.hh" +#include "nix/util/file-system.hh" +#include "nix/util/fs-sink.hh" +#include "nix/util/processes.hh" +#include "nix/util/source-accessor.hh" + +#ifndef _WIN32 +# include "nix/store/macho-signature.hh" +#endif + +#include "self-exe.hh" + +namespace nix { + +struct CmdStoreFixupMachO : StorePathsCommand +{ + bool dryRun = false; + + CmdStoreFixupMachO() + { + addFlag({ + .longName = "dry-run", + .description = "Only report paths that would be repaired; do not modify anything.", + .handler = {&dryRun, true}, + }); + } + + std::string description() override + { + return "repair invalid Mach-O code signatures in store paths"; + } + + std::string doc() override + { + return +#include "store-fixup-macho.md" + ; + } + + void run(ref store, StorePaths && storePaths) override; +}; + +#ifdef _WIN32 + +void CmdStoreFixupMachO::run(ref store, StorePaths && storePaths) +{ + throw UsageError("'nix store fixup-macho' is not supported on this platform"); +} + +#else + +void CmdStoreFixupMachO::run(ref store, StorePaths && storePaths) +{ + auto & localStore = require(*store); + + auto fixupTool = getNixBin({}).string(); + + size_t repaired = 0, stale = 0, failed = 0; + + /* One unrepairable path (a CMS-signed binary, a permission + failure) must not abort the rest of the sweep. */ + auto processPath = [&](const StorePath & path) { + auto info = localStore.queryPathInfo(path); + auto realPath = localStore.toRealPath(path); + + /* A content-addressed path cannot be repaired: its `ca` field + would no longer match the contents. */ + if (info->ca) { + debug("skipping content-addressed path '%s'", localStore.printStorePath(path)); + return; + } + + /* Find signed Mach-O files, then verify their page hashes via + the fixup tool. A path carrying a CMS signature is skipped + whole with a warning: only the original signing identity + can regenerate it. */ + auto hits = scanForMachOSignatures(realPath); + if (hits.empty()) + return; + for (auto & hit : hits) + if (hit.kind != MachOSignatureKind::AdHoc) { + warn( + "not repairing '%s': %s %s", + localStore.printStorePath(path), + PathFmt(hit.path), + hit.kind == MachOSignatureKind::Cms + ? "carries a CMS signature (Developer ID), which only the original signing identity can regenerate" + : "is too large to inspect"); + return; + } + + { + auto status = runProgram( + RunOptions{ + .program = fixupTool, + .args = {OS_STR("__fixup-macho"), OS_STR("--check"), realPath.native()}, + }) + .first; + switch (classifyMachOCheck(status)) { + case MachOCheckOutcome::Valid: + /* Nothing to do. A NAR-hash mismatch against valid + signatures is deliberately not "healed" by + re-registering the on-disk content: valid signatures + don't distinguish this tool's own interrupted swap + from unrelated corruption, so re-registering would + hide that corruption from `nix store verify`. Such a + path is recovered with `nix store verify --repair`. */ + return; + case MachOCheckOutcome::Stale: + break; + case MachOCheckOutcome::Error: + throw Error( + "verifying Mach-O signatures of '%s': %s", localStore.printStorePath(path), statusToString(status)); + } + } + + stale++; + if (dryRun) { + printInfo("would repair '%s'", localStore.printStorePath(path)); + return; + } + + /* Never repair in place: `auto-optimise-store` hard-links + identical files across store paths, so an in-place write + would corrupt every sharing path. Copy the whole path, + repair the copy, swap it in, and update the database. */ + auto tempDir = createTempDir(); + AutoDelete delTempDir(tempDir); + auto tempPath = std::filesystem::path(tempDir) / "x"; + + { + auto accessor = makeFSSourceAccessor(realPath); + RestoreSink sink{false}; + sink.dstPath = tempPath; + copyRecursive(*accessor, CanonPath::root, sink, CanonPath::root); + } + + { + auto status = runProgram( + RunOptions{ + .program = fixupTool, + .args = {OS_STR("__fixup-macho"), tempPath.native()}, + }) + .first; + if (!statusOk(status)) + throw Error( + "repairing Mach-O signatures of '%s': %s", localStore.printStorePath(path), statusToString(status)); + } + + /* The tool skips what it cannot process (an unsupported hash + type, a malformed CodeDirectory), exiting successfully. + Swapping in a copy that still fails the check would report + the path repaired when it is not — verify before swapping. */ + { + auto status = runProgram( + RunOptions{ + .program = fixupTool, + .args = {OS_STR("__fixup-macho"), OS_STR("--check"), tempPath.native()}, + }) + .first; + if (classifyMachOCheck(status) != MachOCheckOutcome::Valid) { + warn( + "not repairing '%s': its signatures are still invalid after repair " + "(a signature the repair tool cannot process, such as an unsupported hash type)", + localStore.printStorePath(path)); + return; + } + } + + auto narHashAndSize = hashPath( + {makeFSSourceAccessor(tempPath), CanonPath::root}, + FileSerialisationMethod::NixArchive, + HashAlgorithm::SHA256); + + ValidPathInfo newInfo{*info}; + newInfo.narHash = narHashAndSize.hash; + newInfo.narSize = narHashAndSize.numBytesDigested; + /* The signatures signed the old contents. */ + newInfo.sigs.clear(); + + localStore.replaceStorePath(path, tempPath, newInfo); + + printInfo("repaired '%s'", localStore.printStorePath(path)); + repaired++; + }; + + for (auto & path : storePaths) { + try { + processPath(path); + } catch (Error & e) { + failed++; + logError(e.info()); + } catch (std::exception & e) { + /* A filesystem or allocation error on one path must not + abort the rest of the sweep either. */ + failed++; + printError("error processing '%s': %s", localStore.printStorePath(path), e.what()); + } + } + + if (dryRun) + printInfo("%d path(s) with invalid Mach-O signatures", stale); + else + printInfo("repaired %d path(s)", repaired); + if (failed) + throw Error("failed to process %d path(s)", failed); +} + +#endif + +static auto rStoreFixupMachO = registerCommand2({"store", "fixup-macho"}); + +} // namespace nix diff --git a/src/nix/store-fixup-macho.md b/src/nix/store-fixup-macho.md new file mode 100644 index 000000000000..21c98b82c56e --- /dev/null +++ b/src/nix/store-fixup-macho.md @@ -0,0 +1,63 @@ +R""( + +# Examples + +* Check which store paths carry Mach-O files with invalid code + signatures: + + ```console + # nix store fixup-macho --dry-run --all + would repair '/nix/store/hzi7bnyfj6ic73b3rnjjjhq9mgx1v9nh-fish-4.2.1' + 1 path(s) with invalid Mach-O signatures + ``` + +* Repair one path: + + ```console + # nix store fixup-macho /nix/store/hzi7bnyfj6ic73b3rnjjjhq9mgx1v9nh-fish-4.2.1 + repaired '/nix/store/hzi7bnyfj6ic73b3rnjjjhq9mgx1v9nh-fish-4.2.1' + ``` + +# Description + +This command finds Mach-O files whose code-signature page hashes do +not match their contents inside the given store paths, and repairs +them by recomputing the stale hashes in place. Such binaries are +killed by the macOS kernel when they are first executed; they can end +up in a store via substitution of an artifact that was already broken +where it was built (see the [`macho-signature-verify`](@docroot@/command-ref/conf-file.md#conf-macho-signature-verify) +setting for catching that at substitution time), or via builds +performed by older Nix versions. + +The repair never modifies a path's files in place: with +`auto-optimise-store`, files may be hard-linked into other store +paths, and an in-place write would corrupt every path sharing the +inode. Instead the path's contents are copied, repaired, and swapped +in, and the path's NAR hash is updated in the Nix database. Since the +new hash differs from what any substituter advertised, the path's +signatures no longer apply and are dropped — the repaired path is +registered unsigned. + +Content-addressed store paths are skipped: repairing one would change +its contents away from its own content address. Files signed with a +certificate (Developer ID) are also not repairable, since only the +original signing identity can produce a valid signature. A path is +skipped whole if any of its Mach-O files carries such a signature, so +an ad-hoc-signed file (or the ad-hoc-signed slice of a mixed +universal binary) sharing a path with a certificate-signed one is +left unrepaired — the same conservative choice the build-time and +substitution-time checks make. + +> **Warning** +> +> As with `nix store repair`, there is a small window during which +> the old path is moved out of the way and replaced. If the command +> is interrupted in that window, the path may be left missing, or — +> if interrupted after the swap but before the database update — left +> with repaired contents whose recorded NAR hash no longer matches. +> Both states are detected by `nix store verify` and recovered with +> `nix store verify --repair`, which re-obtains the path from a +> substituter or by rebuilding rather than trusting the on-disk +> bytes. + +)"" diff --git a/tests/functional/macho-signature-check.sh b/tests/functional/macho-signature-check.sh index 96d8fda16e94..7a50a5fb7148 100644 --- a/tests/functional/macho-signature-check.sh +++ b/tests/functional/macho-signature-check.sh @@ -162,3 +162,29 @@ nix-store --delete "$unsupOut" expectStderr 1 nix-store --realise "$unsupDrv" >"$TEST_ROOT/unsupported.stderr" grepQuiet "refusing to rewrite store path hashes inside signed Mach-O file" "$TEST_ROOT/unsupported.stderr" grepQuiet "exited successfully but left signatures" "$TEST_ROOT/unsupported.stderr" + +# Register the broken CMS binary anyway (warn mode), then confirm the +# at-rest sweep skips it with a warning instead of failing the batch. +nix-store --realise "$cmsDrv" --option macho-signature-rewrite-check warn 2>&1 | + grepQuiet "invalidates its macOS code signature" +nix store fixup-macho "$cmsOut" 2>&1 >"$TEST_ROOT/cms-sweep.out" | tee "$TEST_ROOT/cms-sweep.stderr" >/dev/null +grepQuiet "not repairing" "$TEST_ROOT/cms-sweep.stderr" +grepQuiet "CMS signature" "$TEST_ROOT/cms-sweep.stderr" + +# Batch resilience: an unrepairable path earlier in the batch must not +# prevent a repairable path later in the same invocation. Build a +# genuinely broken (ad-hoc) path alongside the CMS one and sweep both +# in one call (CMS listed first). The CMS path is skipped with a +# warning; the broken path is still repaired and verifies. +brokenDrv=$(nix-instantiate ./macho-signature-check.nix) +nix-store --realise "$brokenDrv" >/dev/null +brokenOut=$(nix-store --query --outputs "$brokenDrv" | grep -v -- '-doc$') +nix-store --delete "$brokenOut" +nix-store --realise "$brokenDrv" --option macho-signature-rewrite-check warn --option macho-signature-repair-hook "" 2>&1 | + grepQuiet "invalidates its macOS code signature" +expect 1 /usr/bin/codesign --verify "$brokenOut/bin/hello" +# nix logging (printInfo/warn) goes to stderr; capture both streams. +nix store fixup-macho "$cmsOut" "$brokenOut" >"$TEST_ROOT/batch.log" 2>&1 +grepQuiet "not repairing" "$TEST_ROOT/batch.log" # cms skipped +grepQuiet "repaired '" "$TEST_ROOT/batch.log" # broken repaired despite cms earlier +/usr/bin/codesign --verify "$brokenOut/bin/hello" diff --git a/tests/functional/macho-signature-oversized.nix b/tests/functional/macho-signature-oversized.nix new file mode 100644 index 000000000000..22d7e4fd2d4c --- /dev/null +++ b/tests/functional/macho-signature-oversized.nix @@ -0,0 +1,18 @@ +with import ./config.nix; + +# A single-output derivation containing a file that carries Mach-O +# magic but exceeds the parser's file-size limit (512 MiB). Such a +# file is reported `Unchecked` by the daemon-side scan and skipped by +# the check child — it can never be verified, only refused or waved +# through with a warning. + +mkDerivation { + name = "macho-signature-oversized"; + buildCommand = '' + mkdir -p "$out" + # 512 MiB + 1 byte, starting with MH_MAGIC_64. Sparse where the + # filesystem allows; NAR serialisation stores the zeros anyway. + printf '\xcf\xfa\xed\xfe' > "$out/big" + dd if=/dev/zero of="$out/big" bs=1 count=1 seek=536870912 conv=notrunc 2>/dev/null + ''; +} diff --git a/tests/functional/macho-signature-partial.nix b/tests/functional/macho-signature-partial.nix new file mode 100644 index 000000000000..b2f9e0d34743 --- /dev/null +++ b/tests/functional/macho-signature-partial.nix @@ -0,0 +1,97 @@ +with import ./config.nix; + +# Like macho-signature-check.nix, but the binary's SuperBlob carries a +# second, alternate CodeDirectory whose hash type the repair tool does +# not support (SHA-384-shaped), alongside the normal SHA-256 one. A +# repair can then fix the supported CodeDirectory — changing bytes — +# while the unsupported one stays unverifiable, exercising the +# partially-repaired outcome at the verification doors. + +mkDerivation { + name = "macho-signature-partial"; + outputs = [ + "out" + "doc" + ]; + docPlaceholder = "${builtins.placeholder "doc"}/share/doc/hello"; + buildCommand = '' + # Padded source widens the CodeDirectory so the signature region + # has room for the appended alternate. + cat > main.c <<'EOF' + #include + static const volatile char padding[131072] = { 0 }; + int main(void) { + printf("doc=%s\n", DOC_PATH); + (void) padding; + return 0; + } + EOF + /usr/bin/cc -O0 -Wl,-adhoc_codesign -DDOC_PATH="\"$docPlaceholder\"" -o hello-partial main.c + + # Append an alternate CodeDirectory — a copy of the primary with + # hashType overwritten to 3 (SHA-384, unsupported) — rebuilding + # the SuperBlob index contiguously and growing __LINKEDIT to fit. + /usr/bin/python3 - hello-partial <<'PY' + import struct, sys + path = sys.argv[1] + b = bytearray(open(path, "rb").read()) + LC_SEGMENT_64 = 0x19 + LC_CODE_SIGNATURE = 0x1d + def be32(o): return (b[o] << 24) | (b[o+1] << 16) | (b[o+2] << 8) | b[o+3] + def le32(o): return struct.unpack(" sig_sz: + growth = new_sig_sz - sig_sz + b.extend(bytes(growth)) + struct.pack_into("III", region, 0, 0xfade0cc0, new_sb_length, len(entries)) + for i, (typ, rel) in enumerate(new_entries): + struct.pack_into(">II", region, 12 + i * 8, typ, rel) + region[12 + entries_sz : 12 + entries_sz + len(new_blobs)] = new_blobs + b[sig_off : sig_off + sig_sz] = region + open(path, "wb").write(b) + PY + + mkdir -p "$out/bin" "$doc/share/doc" + cp hello-partial "$out/bin/hello-partial" + echo "hello docs" > "$doc/share/doc/hello" + ''; +} diff --git a/tests/functional/macho-signature-verify.sh b/tests/functional/macho-signature-verify.sh new file mode 100644 index 000000000000..c540c72ca5ff --- /dev/null +++ b/tests/functional/macho-signature-verify.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash + +# Tests for `macho-signature-verify` (the substitution-time check) +# and `nix store fixup-macho` (the at-rest repair). +# +# A binary whose signature page hashes are stale can enter a store +# via substitution — broken where it was built, by whatever broke it +# (the producing daemon, a build tool, an upstream artifact). The +# substitution check catches it at the door; the at-rest command +# repairs what is already inside. + +source common.sh + +TODO_NixOS # Requires clearing the store + +[[ $(uname) == Darwin ]] || skipTest "Mach-O binaries can only be built on darwin" +[[ -x /usr/bin/cc ]] || skipTest "Need /usr/bin/cc to build the test fixture" +[[ -x /usr/bin/codesign ]] || skipTest "Need /usr/bin/codesign to validate signatures" +[[ -x /usr/bin/python3 ]] || skipTest "Need /usr/bin/python3 to synthesize the unsupported-hash fixture" + +if [[ -n "${NIX_TESTS_CA_BY_DEFAULT:-}" ]]; then + skipTest "the broken-binary fixture relies on the input-addressed rewrite shape" +fi + +clearStore + +cacheDir="$TEST_ROOT/binary-cache" + +drv=$(nix-instantiate ./macho-signature-check.nix) + +# Manufacture a BROKEN cached artifact: build cold, delete `out`, +# rebuild with the rewrite under `warn` and repair disabled — the +# registered binary then carries stale page hashes, exactly like the +# real-world broken cache entries. Then publish it. +nix-store --realise "$drv" +out=$(nix-store --query --outputs "$drv" | grep -v -- '-doc$') +docPath=$(nix-store --query --outputs "$drv" | grep -- '-doc$') +nix-store --delete "$out" +nix-store --realise "$drv" \ + --option macho-signature-rewrite-check warn \ + --option macho-signature-repair-hook "" 2>&1 | + grepQuiet "invalidates its macOS code signature" +expect 1 /usr/bin/codesign --verify "$out/bin/hello" + +nix copy --to "file://$cacheDir" "$out" --no-check-sigs + +# Substitute it back under each mode. `ignore` (default): broken +# binary comes through silently, as before. +clearStore +nix-store --realise "$docPath" --option substituters "file://$cacheDir" --no-require-sigs +nix-store --realise "$out" --option substituters "file://$cacheDir" --no-require-sigs 2>&1 | + grepQuietInverse "code signature" +expect 1 /usr/bin/codesign --verify "$out/bin/hello" + +# warn: named diagnosis at download time. +clearStore +nix-store --realise "$out" --option substituters "file://$cacheDir" --no-require-sigs \ + --option macho-signature-verify warn 2>&1 | + grepQuiet "invalid code signatures" +[[ -x "$out/bin/hello" ]] + +# refuse: the substitution fails. Realising the bare path cannot fall +# back to a build and fails outright... +clearStore +expectStderr 1 nix-store --realise "$out" --option substituters "file://$cacheDir" --no-require-sigs \ + --option macho-signature-verify refuse | + grepQuiet "refusing to add" +# ...but realising the derivation with --fallback builds locally +# instead (cold build, no rewrite → valid signature). +drv=$(nix-instantiate ./macho-signature-check.nix) # clearStore deleted it +nix-store --realise "$drv" --fallback --option substituters "file://$cacheDir" --no-require-sigs \ + --option macho-signature-verify refuse 2>"$TEST_ROOT/refuse.stderr" +/usr/bin/codesign --verify "$out/bin/hello" + +# repair: the path is fixed before registration and registered +# unsigned with an updated NAR hash. +clearStore +nix-store --realise "$out" --option substituters "file://$cacheDir" --no-require-sigs \ + --option macho-signature-verify repair 2>&1 | + grepQuiet "repaired invalid Mach-O code signature" +/usr/bin/codesign --verify "$out/bin/hello" +"$out/bin/hello" | grepQuiet "^doc=$docPath" +# The recorded NAR hash matches the repaired contents. +nix store verify --no-trust "$out" +# The substituter's signatures signed the old contents; the repaired +# path must be registered unsigned. +nix path-info --json --json-format 2 "$out" | jq -e '.info.[].signatures // [] | length == 0' >/dev/null + +# At-rest sweep: substitute the broken path (ignore mode), then +# repair it in place in the store. +clearStore +nix-store --realise "$out" --option substituters "file://$cacheDir" --no-require-sigs +expect 1 /usr/bin/codesign --verify "$out/bin/hello" + +nix store fixup-macho --dry-run "$out" 2>&1 | grepQuiet "would repair" +expect 1 /usr/bin/codesign --verify "$out/bin/hello" + +nix store fixup-macho "$out" 2>&1 | grepQuiet "repaired" +/usr/bin/codesign --verify "$out/bin/hello" +"$out/bin/hello" | grepQuiet "^doc=$docPath" +nix store verify --no-trust "$out" + +# Idempotent: a second sweep finds nothing. +nix store fixup-macho --dry-run "$out" 2>&1 | grepQuiet "0 path(s)" + +# A signature the repair tool cannot process (unsupported hash type): +# `repair` runs the hook, which skips the file and exits 0. The +# re-check catches that nothing was verified, so the path is added +# with a warning — not reported repaired, and the path info is only +# rewritten to match bytes the repair actually changed (here: none). +clearStore +unsupDrv=$(nix-instantiate ./macho-signature-unsupported.nix) +nix-store --realise "$unsupDrv" +unsupOut=$(nix-store --query --outputs "$unsupDrv" | grep -v -- '-doc$') +nix copy --to "file://$cacheDir" "$unsupOut" --no-check-sigs + +clearStore +nix-store --realise "$unsupOut" --option substituters "file://$cacheDir" --no-require-sigs \ + --option macho-signature-verify repair 2>&1 | + tee "$TEST_ROOT/unsup-repair.stderr" >/dev/null +grepQuiet "did not repair" "$TEST_ROOT/unsup-repair.stderr" +grepQuietInverse "repaired invalid Mach-O code signature" "$TEST_ROOT/unsup-repair.stderr" +# The path was added unmodified, so the substituted NAR still verifies. +nix store verify --no-trust "$unsupOut" + +# ...and under `refuse` an unverifiable signature is refused, the +# same as a stale one. +clearStore +expectStderr 1 nix-store --realise "$unsupOut" \ + --option substituters "file://$cacheDir" --no-require-sigs \ + --option macho-signature-verify refuse | + grepQuiet "refusing to add" + +# The at-rest sweep on the same path: the repair tool runs but cannot +# process the signature, and the re-check on the repaired copy fails — +# the copy must not be swapped in, and the path stays intact. +clearStore +nix-store --realise "$unsupOut" --option substituters "file://$cacheDir" --no-require-sigs +nix store fixup-macho "$unsupOut" 2>&1 | grepQuiet "still invalid after repair" +nix store verify --no-trust "$unsupOut" + +# Partial repair: a binary carrying a normal SHA-256 CodeDirectory +# plus an unsupported-hash alternate. The repair fixes the supported +# one — changing bytes — but the re-check still fails on the alternate, +# so the path is registered warned-not-repaired with its recorded NAR +# hash matching the partially-repaired bytes actually on disk. +clearStore +partialDrv=$(nix-instantiate ./macho-signature-partial.nix) +nix-store --realise "$partialDrv" +partialOut=$(nix-store --query --outputs "$partialDrv" | grep -v -- '-doc$') +nix-store --delete "$partialOut" +nix-store --realise "$partialDrv" \ + --option macho-signature-rewrite-check warn \ + --option macho-signature-repair-hook "" 2>&1 | + grepQuiet "invalidates its macOS code signature" +nix copy --to "file://$cacheDir" "$partialOut" --no-check-sigs + +clearStore +nix-store --realise "$partialOut" --option substituters "file://$cacheDir" --no-require-sigs \ + --option macho-signature-verify repair 2>&1 | + tee "$TEST_ROOT/partial-repair.stderr" >/dev/null +grepQuiet "did not repair" "$TEST_ROOT/partial-repair.stderr" +grepQuietInverse "repaired invalid Mach-O code signature" "$TEST_ROOT/partial-repair.stderr" +# The repair changed bytes, so the recorded NAR hash must describe the +# on-disk state (updated), and the substituter's signatures must be +# gone — the info describes bytes the substituter never signed. +nix store verify --no-trust "$partialOut" +nix path-info --json --json-format 2 "$partialOut" | jq -e '.info.[].signatures // [] | length == 0' >/dev/null + +# A Mach-O file too large to parse cannot be verified: the check +# child skips it, so its exit status says nothing about the file. +# Under `refuse` the path must be refused as unverifiable — trusting +# the child's exit 0 here would accept a possibly-broken binary the +# check never looked at. (Sparse file: only the magic is real.) +clearStore +bigDrv=$(nix-instantiate ./macho-signature-oversized.nix) +nix-store --realise "$bigDrv" +bigOut=$(nix-store --query --outputs "$bigDrv") +nix copy --to "file://$cacheDir?compression=none" "$bigOut" --no-check-sigs + +clearStore +expectStderr 1 nix-store --realise "$bigOut" \ + --option substituters "file://$cacheDir?compression=none" --no-require-sigs \ + --option macho-signature-verify refuse | + grepQuiet "too large to have their code signatures verified" + +# warn: accepted, with the unverifiable file called out. +nix-store --realise "$bigOut" \ + --option substituters "file://$cacheDir?compression=none" --no-require-sigs \ + --option macho-signature-verify warn 2>&1 | + grepQuiet "too large to have their code signatures verified" +[[ -e "$bigOut/big" ]] + +# The tool's own --check contract agrees: an uninspectable Mach-O is +# a check failure, not a pass. +expect 2 nix __fixup-macho --check "$bigOut" diff --git a/tests/functional/meson.build b/tests/functional/meson.build index 635ac443136c..a175c45df8fd 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -129,6 +129,7 @@ suites = [ 'logging.sh', 'macho-signature-check.sh', 'macho-signature-impure.sh', + 'macho-signature-verify.sh', 'make-content-addressed.sh', 'misc.sh', 'multiple-outputs-substitute-failure.sh',