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..3de706c95376 --- /dev/null +++ b/doc/manual/rl-next/darwin-macho-signatures.md @@ -0,0 +1,38 @@ +--- +synopsis: "macOS code-signature validity is now checked when store paths are registered" +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)). + +Nix now treats a valid Mach-O signature as a property to preserve when bytes +enter the store, controlled by three settings: + +- `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. 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 new file mode 100644 index 000000000000..4317536dd683 --- /dev/null +++ b/src/libstore-tests/macho-signature.cc @@ -0,0 +1,714 @@ +#include "nix/store/macho-signature.hh" +#include "nix/util/error.hh" +#include "nix/util/hash.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); +} + +/* ------------------------------------------------------------------- + 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, `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); + + { + /* 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-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..68f521098909 100644 --- a/src/libstore/globals.cc +++ b/src/libstore/globals.cc @@ -364,6 +364,98 @@ 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; + +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) { 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..d9c4b4d73df7 100644 --- a/src/libstore/include/nix/store/local-settings.hh +++ b/src/libstore/include/nix/store/local-settings.hh @@ -23,6 +23,37 @@ 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; + +/** + * 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<> @@ -494,6 +525,143 @@ 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. + + 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 — 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 + 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 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/build/derivation-builder-impl.hh b/src/libstore/unix/build/derivation-builder-impl.hh index cdfe9a901791..6c0adb90d0a2 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,49 @@ 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. 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 + * 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); + + /** + * 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 3b02dfc4f172..c406e3cfaa27 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,230 @@ 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; + } + + /* 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, ""); +} + +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); +} + +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; @@ -1278,9 +1503,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? */ @@ -1299,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( @@ -1354,7 +1588,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 +1620,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..a46da9fb9969 --- /dev/null +++ b/src/libstore/unix/include/nix/store/macho-signature.hh @@ -0,0 +1,148 @@ +#pragma once +///@file + +#include "nix/util/types.hh" + +#include +#include +#include +#include + +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. 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 }; + +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. + 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) +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. Shared by the + daemon-side scan and the `nix __fixup-macho` tool so the two + agree. */ +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, +}; + +/** + * 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 + * 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); + +/** + * 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 + * 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/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..5c5fd0bc465c --- /dev/null +++ b/src/libstore/unix/macho-signature.cc @@ -0,0 +1,660 @@ +#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/hash.hh" +#include "nix/util/logging.hh" + +#include +#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, shared + with `hasMachOMagic`.) */ +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; +} + +/** + * 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) +{ + 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("scanForMachOSignatures: %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); +} + +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) + 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/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/src/nix/fixup-macho.cc b/src/nix/fixup-macho.cc new file mode 100644 index 000000000000..21136d8eab91 --- /dev/null +++ b/src/nix/fixup-macho.cc @@ -0,0 +1,177 @@ +#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, 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 + * 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..64de35bc5097 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', @@ -117,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/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..44159caca590 --- /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 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 + +[[ $(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..7a50a5fb7148 --- /dev/null +++ b/tests/functional/macho-signature-check.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash + +# 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 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 +# 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/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, 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`, 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" +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... +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" + +# 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 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" ]] + +# 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$') +[[ -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" + +# 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" + +# 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-cms.nix b/tests/functional/macho-signature-cms.nix new file mode 100644 index 000000000000..1ee2be8935bd --- /dev/null +++ b/tests/functional/macho-signature-cms.nix @@ -0,0 +1,92 @@ +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). 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"; + 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/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-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-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/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 8fdaf4ffe018..a175c45df8fd 100644 --- a/tests/functional/meson.build +++ b/tests/functional/meson.build @@ -127,6 +127,9 @@ suites = [ 'legacy-ssh-store.sh', 'linux-sandbox.sh', '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',