Skip to content

Extend identity confirmation for unsigned slices#953

Merged
mlw merged 1 commit into
mainfrom
mlw/unsigned-binary-verify
May 15, 2026
Merged

Extend identity confirmation for unsigned slices#953
mlw merged 1 commit into
mainfrom
mlw/unsigned-binary-verify

Conversation

@mlw
Copy link
Copy Markdown
Contributor

@mlw mlw commented May 14, 2026

Adds a new identity-confirmation mode to the VerifyingHasher facade for Mach-O slices that do not carry an embedded code signature. Confirmation is via a (dev, ino, size, mtime) stat tuple captured at AUTH EXEC and compared against fstat(fd) on the same fd inside the facade.

Public surface

Expected becomes a flat struct: a required StatTuple stat and an optional Signed sub-struct. When signed_check is engaged, Run() verifies the slice via cdhash / signing_id / team_id (existing behavior). When signed_check is nullopt, Run() requires the slice to have no embedded signature and confirms identity via the stat tuple, compared across all four fields including nanosecond mtime. The new Status::kMatchUnsigned reports this success outcome.

Type Change
Expected::Signed Nested in Expected and wrapped in optional<Signed> signed_check
StatTuple New: dev, ino, size, mtime (nanosecond precision)

Behavior

The facade no longer calls fstat() on the path that consumes signed_check; the file-reader size hint comes from exp.stat.size, which callers supply from the ES event. On the path that does not consume signed_check, the facade fstats once at entry and returns kError with sha256 nullopt if the stat tuple does not match — no file read in that case.

kMatchUnsigned requires both: the fstat-derived stat tuple matches Expected.stat across all four fields, AND Core reports kNoSignature for the slice. The two requirements are independent and both load-bearing.

Testing

Adds an unsigned thin arm64 fixture (testdata/hw_unsigned) and 11 new facade tests covering the happy path, per-field stat-mismatch (5 fields including nanosecond mtime), cross-kind anomalies in both directions, kNoSignature-gating against non-Mach-O input, fstat-failure handling, and baseline preservation of the empty-signed_check behavior. Existing tests migrated to the new Expected shape. The OneOffs exerciser gains a -u flag and the smoke test gains a case exercising it end-to-end against a codesign-stripped binary.

@mlw mlw added this to the 2026.4 milestone May 14, 2026
@github-actions github-actions Bot added lang/objc++ PRs modifying files in ObjC++ comp/common size/l Size: large labels May 14, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f783f253-f03c-4018-bcf5-e0401097125d

📥 Commits

Reviewing files that changed from the base of the PR and between 2f8068d and 921e236.

📒 Files selected for processing (8)
  • Source/common/verifyinghasher/BUILD
  • Source/common/verifyinghasher/VerifyingHasher.h
  • Source/common/verifyinghasher/VerifyingHasher.mm
  • Source/common/verifyinghasher/VerifyingHasherTest.mm
  • Source/common/verifyinghasher/testdata/hw_unsigned
  • Testing/OneOffs/BUILD
  • Testing/OneOffs/VerifyingHasher.mm
  • Testing/OneOffs/verifying_hasher_smoke.sh
🚧 Files skipped from review as they are similar to previous changes (5)
  • Testing/OneOffs/verifying_hasher_smoke.sh
  • Source/common/verifyinghasher/VerifyingHasher.h
  • Source/common/verifyinghasher/VerifyingHasher.mm
  • Testing/OneOffs/VerifyingHasher.mm
  • Source/common/verifyinghasher/VerifyingHasherTest.mm

📝 Walkthrough

Walkthrough

VerifyingHasher facade now supports unsigned Mach-O binaries by splitting verification into signed and unsigned paths. The Expected contract captures filesystem identity (stat tuple) and optionally signed expectations. Unsigned verification validates the stat tuple before reading; signed verification uses the signed_check path. Tests, fixture, BUILD, and CLI were updated.

Changes

Unsigned Verification Support

Layer / File(s) Summary
Public contract: filesystem identity and signed/unsigned split
Source/common/verifyinghasher/VerifyingHasher.h, Source/common/verifyinghasher/BUILD
VerifyingHasher::Expected now contains StatTuple (dev/ino/size/mtime) and optional Signed wrapper for cdhash/signing_id/team_id. Adds <sys/types.h> and <ctime> includes. New hw_unsigned_fixture target and test wiring.
Run() unsigned and signed verification paths
Source/common/verifyinghasher/VerifyingHasher.mm
Unsigned path validates stat via fstat early, returns kError on mismatch before reading. Signed path skips facade stat check, uses exp.stat.size for reader. Post-processing split by signedness: unsigned requires kNoSignature for kMatchUnsigned; signed compares cdhash from signed_check fields.
Test fixture and helper methods for unsigned binaries
Source/common/verifyinghasher/VerifyingHasherTest.mm
kHwUnsignedSha256 constant and helpers: unsignedFixturePath, openHwUnsignedFd, actualStatForFd:, hexLowerOfSha256: for unsigned fixture access and stat computation.
Update existing signed-binary tests to new Expected structure
Source/common/verifyinghasher/VerifyingHasherTest.mm
All existing tests (cdhash match, drift, error paths, skip-page-hash) migrated to populate .stat and nest signed expectations under .signed_check = VerifyingHasher::Expected::Signed{...}. Added testUnsignedPathFstatFailureReturnsError for facade bailout validation.
New unsigned-binary verification test cases
Source/common/verifyinghasher/VerifyingHasherTest.mm
Comprehensive unsigned coverage: full stat matching → kMatchUnsigned, individual stat field mismatches (dev, ino, size, mtime.tv_sec, mtime.tv_nsec) → kError with unset sha256, cross-kind mismatches (signed expected vs unsigned file, unsigned vs signed, unsigned vs non-Mach-O, empty signed expectation vs unsigned) validate error and sha256 engagement.
CLI tool unsigned mode and end-to-end testing
Testing/OneOffs/VerifyingHasher.mm, Testing/OneOffs/BUILD, Testing/OneOffs/verifying_hasher_smoke.sh
New -u flag invokes VerifyingHasher::Run with stat-populated unsigned Expected, prints status/digest, exits 0 only on kMatchUnsigned. BUILD dependency added. Smoke test Case 6: strips /usr/bin/yes, validates unsigned mode status/mode/sha256.

Sequence Diagram

sequenceDiagram
  participant App
  participant Run as VerifyingHasher::Run
  participant Stat as fstat
  participant Core as VerifyingHasherCore
  
  App->>Run: Run(fd, exp)
  
  alt unsigned (exp.signed_check unset)
    Run->>Stat: validate (dev, ino, size, mtime)
    alt stat match
      Run->>Core: create reader (size=exp.stat.size), Run()
      Core-->>Run: core_status, CDHash
      alt core_status == kNoSignature
        Run-->>App: kMatchUnsigned + sha256
      else
        Run-->>App: kError
      end
    else stat mismatch
      Run-->>App: kError (no sha256)
    end
  else signed (exp.signed_check set)
    Run->>Core: create reader (size=exp.stat.size), Run()
    Core-->>Run: core_status, CDHash
    alt cdhash matches signed_check
      Run-->>App: kMatch + sha256
    else
      Run-->>App: kError
    end
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • northpolesec/santa#942: Introduced the FD-based VerifyingHasher facade and public Expected contract that this PR restructures to support unsigned binaries.
  • northpolesec/santa#951: Introduced the RunOptions::skip_page_hash flag whose interaction with unsigned verification is clarified in this PR's contract documentation.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Extend identity confirmation for unsigned slices' accurately captures the main feature added: identity confirmation for unsigned Mach-O slices via stat tuple verification.
Description check ✅ Passed The description is directly related to the changeset, providing detailed context about the new unsigned identity-confirmation mode, public surface changes, behavior expectations, and testing additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mlw/unsigned-binary-verify

Comment @coderabbitai help to get the list of available commands and usage tips.

…ty confirmation

Adds a new identity-confirmation mode to the VerifyingHasher facade for
Mach-O slices that do not carry an embedded code signature.
Confirmation is via a (dev, ino, size, mtime) stat tuple captured at
AUTH EXEC and compared against fstat(fd) on the same fd inside the
facade.

### Public surface

Expected becomes a flat struct: a required StatTuple stat and an
optional Signed sub-struct. When signed_check is engaged, Run() verifies
the slice via cdhash / signing_id / team_id (existing behavior). When
signed_check is nullopt, Run() requires the slice to have no embedded
signature and confirms identity via the stat tuple, compared across all
four fields including nanosecond mtime. The new Status::kMatchUnsigned
reports this success outcome.

| Type | Change |
| --- | --- |
| `Expected::Signed` | Nested in `Expected` and wrapped in `optional<Signed> signed_check` |
| `Expected::stat` | New required `StatTuple` |
| `StatTuple` | New: `dev`, `ino`, `size`, `mtime` (nanosecond precision) |
| `Status::kMatchUnsigned` | New variant for stat-confirmed unsigned success |

### Behavior

The facade no longer calls fstat() on the path that consumes
signed_check; the file-reader size hint comes from exp.stat.size, which
callers supply from the ES event. On the path that does not consume
signed_check, the facade fstats once at entry and returns kError with
sha256 nullopt if the stat tuple does not match — no file read in that
case.

kMatchUnsigned requires both: the fstat-derived stat tuple matches
Expected.stat across all four fields, AND Core reports kNoSignature for
the slice. The two requirements are independent and both load-bearing.

### Testing

Adds an unsigned thin arm64 fixture (testdata/hw_unsigned) and 11 new
facade tests covering the happy path, per-field stat-mismatch (5 fields
including nanosecond mtime), cross-kind anomalies in both directions,
kNoSignature-gating against non-Mach-O input, fstat-failure handling,
and baseline preservation of the empty-signed_check behavior. Existing
tests migrated to the new Expected shape. The OneOffs exerciser gains
a -u flag and the smoke test gains a case exercising it end-to-end
against a codesign-stripped binary.
@mlw mlw force-pushed the mlw/unsigned-binary-verify branch from 2f8068d to 921e236 Compare May 14, 2026 02:38
@mlw mlw marked this pull request as ready for review May 14, 2026 02:51
@mlw mlw requested a review from a team as a code owner May 14, 2026 02:51
@mlw mlw merged commit 62174e9 into main May 15, 2026
8 checks passed
@mlw mlw deleted the mlw/unsigned-binary-verify branch May 15, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/common lang/objc++ PRs modifying files in ObjC++ size/l Size: large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants