Sprint 22: Code Signature (Ad-Hoc)
Prerequisites
Sprints 10, 14 — segment layout and __LINKEDIT finalized.
Goals
Emit a valid ad-hoc LC_CODE_SIGNATURE. On macOS 11+, arm64 binaries without a signature are killed by the kernel at exec time; without this sprint every afs-ld output requires manual codesign -s - to run. This is existence-blocking, not optional.
Deliverables
1. SuperBlob structure
LC_CODE_SIGNATURE points to a code-signing blob in __LINKEDIT:
struct CS_SuperBlob {
u32 magic; // CSMAGIC_EMBEDDED_SIGNATURE = 0xfade0cc0
u32 length; // total blob size including this header
u32 count; // number of index entries
// then count × CS_BlobIndex { u32 type; u32 offset; }
// then each blob inlined at its offset
}
For ad-hoc: two inner blobs — the CodeDirectory and an empty Requirements set. Entitlements absent.
2. CodeDirectory
struct CS_CodeDirectory {
u32 magic; // CSMAGIC_CODEDIRECTORY = 0xfade0c02
u32 length;
u32 version; // 0x20400 (modern)
u32 flags; // CS_ADHOC = 0x2
u32 hashOffset; // from this struct's start to the main hash array
u32 identOffset; // to the null-terminated identifier
u32 nSpecialSlots; // 2 (info plist + requirements); 0 when absent
u32 nCodeSlots; // pages × 1
u32 codeLimit; // file offset of end-of-signed-data
u8 hashSize; // 32 for SHA-256
u8 hashType; // CS_HASHTYPE_SHA256 = 2
u8 platform; // 0 for no platform binary
u8 pageSize; // log2(page) = 12 for 4 KiB
u32 spare2;
u32 scatterOffset; // 0
u32 teamOffset; // 0
u32 spare3;
u64 codeLimit64; // 0 unless codeLimit > 4 GiB
u64 execSegBase;
u64 execSegLimit;
u64 execSegFlags; // CS_EXECSEG_MAIN_BINARY = 0x1 for executables
}
After the struct:
identifier\0— we use the install-name for dylibs, the output binary basename for executables.- Special slots (filled with zeroes for ad-hoc):
nSpecialSlots× 32 bytes of zero before the main slots. - Main slots: one SHA-256 per 4 KiB page of signed data, followed in file order.
3. Signed data range
Signing covers file bytes [0, codeLimit). codeLimit is set to LC_CODE_SIGNATURE.dataoff (the start of the signature blob itself). The signature never signs itself.
4. Page hashing
- Page size 4 KiB (not 16 KiB — code-signing pageSize is independent of VM page size).
- SHA-256 over each 4 KiB chunk; the final chunk is hashed over whatever bytes remain (not padded).
- Hashes concatenated at
hashOffset.
5. Requirements blob
struct CS_RequirementsBlob {
u32 magic; // CSMAGIC_REQUIREMENTS = 0xfade0c01
u32 length; // 12
u32 count; // 0
}
Minimum legal empty requirements.
6. SHA-256 implementation
Hand-rolled. Standard 64-round SHA-256 from FIPS 180-4. ~200 LoC in Rust. Unit-tested against known vectors (empty string, "abc", "a"×1M, NIST test vectors).
7. Layout recomputation
The signature blob size depends on codeLimit, which depends on its own file offset. Two-pass approach:
- Compute layout excluding signature; know exactly the signature's start offset.
- Compute signature size = SuperBlob header + indices + CodeDirectory header + ident + special slots + (ceil(codeLimit / 4096) × 32 bytes hash) + Requirements blob.
- Reserve that many bytes at the signature offset.
- Write all other data.
- Hash pages and write signature in place.
8. Platform binary opt-out
Ad-hoc signatures from third-party tools are not platform binaries; platform = 0, flags = CS_ADHOC.
9. Validation
After writing, validate with codesign -v <binary>. Expected: zero output, exit 0.
Testing Strategy
- Sign hello-world, then
./hello(no manualcodesignstep). Expect "Hello, World!". codesign -dv <binary>reportsSignature=adhoc.- SHA-256 unit tests against NIST vectors.
- Mutate a single byte in the binary post-sign, re-run, expect kernel kill ("Killed: 9") — proves the signature is real and the kernel is checking it.
- Dylib ad-hoc sign:
dlopenoflibfoo.dylibfrom Sprint 18.5 still works.
Definition of Done
./helloruns directly (nocodesign -s -needed).codesign -vclean on every afs-ld output.- Dylib loading via
dlopenworks on Sprint 18.5 fixtures. - SHA-256 passes NIST test vectors.
- Tampering detected by the kernel (confidence check).
View source
| 1 | # Sprint 22: Code Signature (Ad-Hoc) |
| 2 | |
| 3 | ## Prerequisites |
| 4 | Sprints 10, 14 — segment layout and `__LINKEDIT` finalized. |
| 5 | |
| 6 | ## Goals |
| 7 | Emit a valid ad-hoc `LC_CODE_SIGNATURE`. On macOS 11+, arm64 binaries without a signature are killed by the kernel at exec time; without this sprint every afs-ld output requires manual `codesign -s -` to run. This is existence-blocking, not optional. |
| 8 | |
| 9 | ## Deliverables |
| 10 | |
| 11 | ### 1. SuperBlob structure |
| 12 | `LC_CODE_SIGNATURE` points to a code-signing blob in `__LINKEDIT`: |
| 13 | |
| 14 | ``` |
| 15 | struct CS_SuperBlob { |
| 16 | u32 magic; // CSMAGIC_EMBEDDED_SIGNATURE = 0xfade0cc0 |
| 17 | u32 length; // total blob size including this header |
| 18 | u32 count; // number of index entries |
| 19 | // then count × CS_BlobIndex { u32 type; u32 offset; } |
| 20 | // then each blob inlined at its offset |
| 21 | } |
| 22 | ``` |
| 23 | |
| 24 | For ad-hoc: two inner blobs — the CodeDirectory and an empty Requirements set. Entitlements absent. |
| 25 | |
| 26 | ### 2. CodeDirectory |
| 27 | ``` |
| 28 | struct CS_CodeDirectory { |
| 29 | u32 magic; // CSMAGIC_CODEDIRECTORY = 0xfade0c02 |
| 30 | u32 length; |
| 31 | u32 version; // 0x20400 (modern) |
| 32 | u32 flags; // CS_ADHOC = 0x2 |
| 33 | u32 hashOffset; // from this struct's start to the main hash array |
| 34 | u32 identOffset; // to the null-terminated identifier |
| 35 | u32 nSpecialSlots; // 2 (info plist + requirements); 0 when absent |
| 36 | u32 nCodeSlots; // pages × 1 |
| 37 | u32 codeLimit; // file offset of end-of-signed-data |
| 38 | u8 hashSize; // 32 for SHA-256 |
| 39 | u8 hashType; // CS_HASHTYPE_SHA256 = 2 |
| 40 | u8 platform; // 0 for no platform binary |
| 41 | u8 pageSize; // log2(page) = 12 for 4 KiB |
| 42 | u32 spare2; |
| 43 | u32 scatterOffset; // 0 |
| 44 | u32 teamOffset; // 0 |
| 45 | u32 spare3; |
| 46 | u64 codeLimit64; // 0 unless codeLimit > 4 GiB |
| 47 | u64 execSegBase; |
| 48 | u64 execSegLimit; |
| 49 | u64 execSegFlags; // CS_EXECSEG_MAIN_BINARY = 0x1 for executables |
| 50 | } |
| 51 | ``` |
| 52 | |
| 53 | After the struct: |
| 54 | - `identifier\0` — we use the install-name for dylibs, the output binary basename for executables. |
| 55 | - Special slots (filled with zeroes for ad-hoc): `nSpecialSlots` × 32 bytes of zero before the main slots. |
| 56 | - Main slots: one SHA-256 per 4 KiB page of signed data, followed in file order. |
| 57 | |
| 58 | ### 3. Signed data range |
| 59 | Signing covers file bytes `[0, codeLimit)`. `codeLimit` is set to `LC_CODE_SIGNATURE.dataoff` (the start of the signature blob itself). The signature never signs itself. |
| 60 | |
| 61 | ### 4. Page hashing |
| 62 | - Page size 4 KiB (not 16 KiB — code-signing pageSize is independent of VM page size). |
| 63 | - SHA-256 over each 4 KiB chunk; the final chunk is hashed over whatever bytes remain (not padded). |
| 64 | - Hashes concatenated at `hashOffset`. |
| 65 | |
| 66 | ### 5. Requirements blob |
| 67 | ``` |
| 68 | struct CS_RequirementsBlob { |
| 69 | u32 magic; // CSMAGIC_REQUIREMENTS = 0xfade0c01 |
| 70 | u32 length; // 12 |
| 71 | u32 count; // 0 |
| 72 | } |
| 73 | ``` |
| 74 | |
| 75 | Minimum legal empty requirements. |
| 76 | |
| 77 | ### 6. SHA-256 implementation |
| 78 | Hand-rolled. Standard 64-round SHA-256 from FIPS 180-4. ~200 LoC in Rust. Unit-tested against known vectors (empty string, "abc", "a"×1M, NIST test vectors). |
| 79 | |
| 80 | ### 7. Layout recomputation |
| 81 | The signature blob size depends on `codeLimit`, which depends on its own file offset. Two-pass approach: |
| 82 | |
| 83 | 1. Compute layout excluding signature; know exactly the signature's start offset. |
| 84 | 2. Compute signature size = SuperBlob header + indices + CodeDirectory header + ident + special slots + (ceil(codeLimit / 4096) × 32 bytes hash) + Requirements blob. |
| 85 | 3. Reserve that many bytes at the signature offset. |
| 86 | 4. Write all other data. |
| 87 | 5. Hash pages and write signature in place. |
| 88 | |
| 89 | ### 8. Platform binary opt-out |
| 90 | Ad-hoc signatures from third-party tools are not platform binaries; `platform = 0`, `flags = CS_ADHOC`. |
| 91 | |
| 92 | ### 9. Validation |
| 93 | After writing, validate with `codesign -v <binary>`. Expected: zero output, exit 0. |
| 94 | |
| 95 | ## Testing Strategy |
| 96 | - Sign hello-world, then `./hello` (no manual `codesign` step). Expect "Hello, World!". |
| 97 | - `codesign -dv <binary>` reports `Signature=adhoc`. |
| 98 | - SHA-256 unit tests against NIST vectors. |
| 99 | - Mutate a single byte in the binary post-sign, re-run, expect kernel kill ("Killed: 9") — proves the signature is real and the kernel is checking it. |
| 100 | - Dylib ad-hoc sign: `dlopen` of `libfoo.dylib` from Sprint 18.5 still works. |
| 101 | |
| 102 | ## Definition of Done |
| 103 | - `./hello` runs directly (no `codesign -s -` needed). |
| 104 | - `codesign -v` clean on every afs-ld output. |
| 105 | - Dylib loading via `dlopen` works on Sprint 18.5 fixtures. |
| 106 | - SHA-256 passes NIST test vectors. |
| 107 | - Tampering detected by the kernel (confidence check). |