diff --git a/NOTES.md b/NOTES.md
index cc4d759..85aaae9 100644
--- a/NOTES.md
+++ b/NOTES.md
@@ -1,5 +1,10 @@
# CI APIs
+These historical availability notes are unverified. The analyzed ci.dll
+10.0.26100.8328 x64 still exports the digest-based APIs listed below. Exact
+first-available builds and ABI changes need matching old binaries. See
+[the current contracts and remaining work](docs/mincrypt-contracts.md).
+
## Available starting with 1511
- CiCheckSignedFile
diff --git a/README.md b/README.md
index e0f9449..aa58660 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ Built for and tested on **Windows 10 version 1507 (or newer) x64/ARM64**. 32-Bit
- I do not want to force any consumer of the library to drag C++ paradigms into their project. I do expect the user to utilize a modern compiler though, so the library sources themselves may have some 'C++-ish touches' here and there, for my own convenience ๐
- Compatibility with every Windows 10 version.
- APIs not available on older builds will give you a `STATUS_NOT_IMPLEMENTED` instead of hard-linking and therefore making your driver fail to load ๐ค
- - **Caution:** this claim comes with an asterisk though; due to PatchGuard (or some other security mechanism) I couldn't get run-time dynamic linking for `ci.dll` exports to work, so you need to actively avoid implementing code depending on exports that do not exist on earlier versions of Windows. Once I have a compatibility matrix for all of them I'll update the documentation accordingly.
+ - CI entry points are currently static imports from `ci.lib`; consumers must avoid imports unavailable on their target Windows builds. Export lookup now uses the correct name-ordinal mapping and rejects forwarders, but this does not establish a complete CI availability or runtime ABI matrix. See the remaining-work ledger below.
- No conflicts with WDF or DMF
- The consuming driver *may* (but doesn't have to) utilize Microsoft WDF or DMF in addition without having to fear any incompatibilities ๐ช
- Reliable SAL annotations.
@@ -73,6 +73,10 @@ Copy the provided `Domito.props` file into the directory of your `.vcxproj` file
## Code Integrity API notes
+- [Algorithm-explicit Authenticode hashing and legacy catalog policy](docs/authenticode-digests.md)
+- [Mincrypt contracts, lifetimes, flags and opt-in x64 26100 layouts](docs/mincrypt-contracts.md)
+- [Implemented corrections and remaining VM/binary work](docs/implementation-status.md)
+
- [CiValidateFileObject flag 0x20: evidence, limitations, and kernel test procedure](docs/ci-validate-file-object.md)
## Sources & 3rd party credits
diff --git a/docs/authenticode-digests.md b/docs/authenticode-digests.md
new file mode 100644
index 0000000..1525f32
--- /dev/null
+++ b/docs/authenticode-digests.md
@@ -0,0 +1,142 @@
+# Authenticode digests and legacy verification
+
+Prefer `DomitoCalculatePortableExecutableDigestEx` when computing a PE digest.
+It takes an explicit SHA-1/256/384/512 algorithm, validates its supported PE layout,
+and returns a digest independently of whether the file has an embedded signature.
+This allows SHA-256 catalog lookup for a file without an embedded certificate.
+Hash calculation is separate from signature verification and signer acceptance.
+
+The older `DomitoCalculatePortableExecutableDigest` and
+`DomitoGetPortableExecutableDigestKind` retain their compatibility behavior:
+linear byte hashing and heuristic pattern discovery with a SHA-1 fallback. They
+remain bounded, but should not be used to discover a general signature policy.
+The new API is additive so existing callers do not silently acquire different
+selection or rejection rules.
+
+## Supported PE layout
+
+The explicit API validates DOS/NT/optional-header spans, PE32 and PE32+ magic,
+declared data-directory count, the complete section table, `SizeOfHeaders`, every
+nonempty section's raw interval, and the certificate table. It sorts section
+descriptors by raw file offset before validating their sequence. At most 96
+sections are accepted, keeping stack storage and sorting work bounded.
+
+Nonempty sections must form a contiguous sequence starting at `SizeOfHeaders`.
+Section-header order can differ from raw-file order. Zero-raw-size sections
+contribute no bytes. An overlay between the raw sections and the certificate
+table, or EOF for an unsigned file, is included. The checksum and physical
+certificate-directory slot are excluded. A present certificate table must be
+8-byte aligned and end at EOF; every entry must have a bounded length, version
+`0x200`, type PKCS#7 SignedData, and complete 8-byte alignment storage.
+
+For this subset, sorted section bytes coincide with linear file order, so the
+implementation can stream validated ranges directly to BCrypt without copying
+or allocating a whole-file scratch buffer. Digest allocation is the only pool
+allocation in the hashing path and is released on every failure.
+
+Gaps, overlapping raw sections, certificates in the middle of a file, and absent
+certificate-directory slots are unsupported. Some malformed inputs return a
+format error; ambiguous or excluded layouts return `STATUS_NOT_SUPPORTED`.
+The missing-slot boundary is deliberate: Windows SIP produced a different hash
+than the old linear helper on a fixture with zero declared directories. Do not
+infer Windows parity from the older helper's successful return on that input.
+
+This is a validated subset, not a claim that every PE accepted by every Windows
+SIP is supported. Microsoft's [PE format documentation](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format)
+describes section ordering and exclusions, but unusual overlay behavior requires
+measurements against the relevant Windows implementation. The local tests use
+the actual [CryptSIPCreateIndirectData](https://learn.microsoft.com/en-us/windows/win32/api/mssip/nf-mssip-cryptsipcreateindirectdata)
+provider as a differential oracle, rather than treating a prose summary as the
+complete algorithm. SIP hash agreement does not establish CI trust-policy parity.
+
+## Discovering the first embedded content's digest algorithm
+
+`DomitoGetPortableExecutableDigestKindEx` navigates a bounded DER subset:
+PKCS#7 ContentInfo / SignedData / encapsulated `SpcIndirectDataContent` /
+`DigestInfo.AlgorithmIdentifier`. It reads the algorithm associated with the
+file digest, avoiding unrelated algorithm patterns in certificates, attributes,
+timestamps, or signer information. SHA-1/256/384/512 are recognized. Algorithm
+parameters may be absent or ASN.1 NULL; the digest OCTET STRING must have the
+corresponding length. DER lengths must be bounded, definite, and minimally
+encoded. Up to seven zero bytes of aligned padding after the outer DER are
+accepted when included in `WIN_CERTIFICATE.dwLength`, as observed on Microsoft
+system binaries. Other trailing bytes are rejected.
+
+An absent certificate table returns `STATUS_NOT_FOUND`, an unsupported algorithm
+or certificate type returns `STATUS_NOT_SUPPORTED`, and malformed supported
+structure returns a format error. The output algorithm is zero on failure; no
+default algorithm is silently manufactured.
+
+The caller supplies an array containing the security-directory entry and a
+stable input buffer. The helper inspects the first table entry. It does not
+enumerate nested/multiple signatures, validate the entire ASN.1 document,
+validate signerInfos, verify cryptographic signatures, or establish trust.
+Indefinite-length BER is outside this supported DER subset. Choose explicit
+algorithms when handling catalog-only files or application-defined lookup order.
+
+## Explicit legacy catalog policy
+
+`DomitoValidateFileLegacyModeEx` takes a caller-selected
+`AcceptedCatalogPolicyBits` mask, a `RequireTrustedCatalog` marker gate, and a
+`ValidationSource` output. Source is `DOMITO_CI_SOURCE_EMBEDDED` or
+`DOMITO_CI_SOURCE_CATALOG` on success and zero on failure.
+
+This helper accepts SHA-1 with exactly 20 bytes or SHA-256 with exactly 32 bytes.
+Unsupported algorithms return `STATUS_NOT_SUPPORTED`; mismatched lengths return
+`STATUS_INVALID_PARAMETER`. The underlying observed embedded API supports more
+algorithms than this helper's catalog workflow, as documented in
+[the mincrypt contracts](mincrypt-contracts.md).
+
+Both policy objects must be zero-initialized before first use, or own valid CI
+results. The helper frees previous contents on entry and before each retry and
+clears signing time between attempts. The caller owns both final policy results,
+including any returned on failure, and frees them with `CiFreePolicyInfo`.
+Embedded-to-catalog fallback and catalog reload occur only for
+`STATUS_INVALID_IMAGE_HASH`; resource, I/O, and other policy errors propagate.
+
+Before certificate allocation, the helper references the file object and checks
+the supplied table interval against the current file size. It then uses the
+exact-read `DomitoReadFileAt`, which waits on a private completion event. An EOF
+short read fails before CI sees any uninitialized bytes. The caller must keep
+file identity/bytes stable between hashing and consumption; a size check cannot
+prevent concurrent mutation. Handles must be valid in the current process
+context and satisfy the documented kernel I/O/APC requirements.
+
+The older `DomitoValidateFileLegacyMode` delegates with the existing
+`DOMITO_CI_LEGACY_CATALOG_POLICY_BITS` (`0x2007F`) and a false marker gate. That
+value is a compatibility default with broad categories, not an application trust
+recommendation. The explicit mask uses **any-overlap** catalog filtering.
+
+For a catalog-only file, choose an allowed algorithm sequence as application
+policy (for example SHA-256 followed by SHA-1 when legacy acceptance is intended).
+For each algorithm, call `DomitoCalculatePortableExecutableDigestEx`, then
+`DomitoValidateFileLegacyModeEx` with a zero security directory. Free the digest
+and both policy objects before retrying. Continue to another algorithm only on
+the specifically intended lookup/signature-mismatch result, not on all failures.
+Preserve the actual algorithm and validation source in the application's result.
+This supports an explicit search strategy; it does not reproduce every native
+CI scenario-selection rule or WinVerifyTrust policy.
+
+## Validation performed
+
+On the local Windows 26100 system, host tests compare all four digest algorithms
+against Windows SIP for PE32/PE32+, unsigned/signed fixtures, overlays, reverse
+section-header order, multiple certificate entries, and certificate alignment.
+The installed `ci.dll`, `ntdll.dll`, `kernel32.dll`, and `notepad.exe` form a
+real-file corpus. Embedded digest algorithms are independently extracted with
+Crypt32, and computed file digests are compared with the signed
+`SpcIndirectDataContent` values where an embedded signature exists. This reads
+files and hashes bytes; it does not load or execute them as code.
+
+Guard-page truncations, malformed offsets/lengths, unsupported layouts,
+algorithm selection, injected CNG/allocation failures, retry ownership, exact
+read failures, and explicit catalog controls are also covered. Actual CI calls
+are mocked in those host tests. The kernel translation units and layout
+assertions compile for x64/ARM64 with warnings treated as errors; the opt-in
+26100 runtime-layout observations remain x64-specific.
+
+Still pending are historical/ARM64 CI runtime compatibility, Driver Verifier,
+real catalog-only acceptance across algorithms, nested/multiple-signature
+selection, weak-crypto/revocation/timestamp policy, and extension of supported PE
+layouts. Use the [remaining-work ledger](implementation-status.md) and the
+[flag probe](ci-validate-file-object.md) to plan the VM run.
diff --git a/docs/implementation-status.md b/docs/implementation-status.md
new file mode 100644
index 0000000..b53c372
--- /dev/null
+++ b/docs/implementation-status.md
@@ -0,0 +1,79 @@
+# CI SDK implementation status
+
+This ledger tracks the source audit S01โS18 and the remaining binary research.
+The original CodeIntegrity-RE audit remains a snapshot of commit `7985b23`;
+it should not be read as describing the corrected source unchanged.
+
+## Implemented with host tests or compilation
+
+- **S01โS04:** subtraction-safe PE ranges, no fixed 4 MiB copy, bounded certificate
+ intervals, deterministic failure outputs, and digest cleanup. Compatibility
+ and explicit paths share the bounded hashing core.
+- **S05:** actual certificate read length checked; allocation bounded against
+ file size; per-operation completion waits through the exact-read helper.
+- **S06:** `DomitoReadFileAt` uses an explicit offset and private kernel event;
+ `DomitoReadFile` reads exactly from offset zero. Pending completion cannot
+ outlive its stack state or caller buffer. Short reads return EOF.
+- **S10:** export lookup uses the name ordinal to index the EAT, bounds tables,
+ names and resolved RVAs, and rejects forwarders. A valid loader-mapped module
+ remains a precondition because the API has no externally supplied image size.
+- **S11โS12:** corrected policy pointer typedef and publisher return/ownership docs.
+- **S14:** policy cleanup before embedded/catalog/reload retries, with host tests
+ allocating results even on failures. Real CI failure ownership still needs
+ the VM tests below.
+- **S17:** legacy hash algorithm/length combinations validated and calculation
+ versus verification capabilities documented.
+- **S18:** byte-count SAL, buffer-capacity rules, scalar input annotations, and
+ kernel read context/APC requirements documented.
+
+## Implemented within an explicit support boundary
+
+- **S07:** new bounded DER discovery reads the first outer SPC file-digest
+ AlgorithmIdentifier; new explicit digest API needs no discovery. The old
+ heuristic entry point retains compatibility behavior. Arbitrary BER, nested
+ signatures, and full signature enumeration remain outside this parser.
+- **S08:** unsigned/catalog-only files can be hashed with a caller-chosen
+ SHA-1 or SHA-256 strategy. The policy-explicit legacy helper forwards catalog
+ controls and reports the successful source. Real catalog lookup comparisons
+ across OS versions are pending; no implicit algorithm retry policy is imposed.
+- **S09:** the explicit digest path enforces a contiguous raw-section subset and
+ EOF certificate tables, with Windows SIP and real embedded-digest comparisons.
+ Gaps, overlaps, missing directory slots and nonterminal certificates are
+ rejected. The old linear compatibility entry point is not a universal hasher.
+- **S13/S15/S16:** recovered fields, parameter meanings, policy categories, sizes,
+ name encoding, borrowed lifetimes, and allocator ownership are documented.
+ The opt-in x64 26100 header provides distinct observed layouts and assertions.
+ Default layouts remain compatible; runtime layout identity cannot be inferred
+ from `NTDDI_VERSION` or `ChainInfo->Size` alone.
+
+## Requires the test VM or additional matching binaries
+
+The VM is not available yet. Host mocks and compilation do not close these:
+
+1. Run the paired `CiValidateFileObject` flag `0`/`0x20` probe on 19041/19045
+ with TESTSIGNING off and a loadable signed test driver. Preserve complete
+ statuses, policies and fixture/environment identities (issue #3).
+2. Verify historical and ARM64 ABI, structure evolution and first-available
+ export versions using matching binaries/symbols and runtime tests. Preserve
+ default layouts until that evidence supports changing them.
+3. Test real CI policy ownership on every failure/retry path under Driver
+ Verifier, including allocation failures and initialized outputs.
+4. Compare real embedded/catalog-only SHA-1/SHA-256 results, catalog cache/reload
+ behavior, trust-mask choices, requested signing levels and flag combinations.
+5. Measure signing-time propagation, zero/sentinel values, timestamp expiry,
+ revocation freshness/network policy and weak-crypto policy. Record OS policy,
+ Secure Boot/HVCI/WDAC and exact files, not just a boolean result.
+6. Establish multiple/nested signature-selection behavior with a signed corpus;
+ extend supported PE gaps/overlays only after differential evidence.
+7. Resolve page-hash allocation destruction, normalized-entry/truncation meaning,
+ terminal-entry handling and cross-version layouts before publishing a general
+ page-hash API. `CiFreePolicyInfo` is not an established destructor for it.
+8. Investigate protected-media/initialization and ordinal-only exports separately
+ if they are needed. Recovered export names alone are insufficient contracts.
+
+## Checks
+
+Run the three host runners in `tests/README.md`. Kernel compilation also covers
+the default modern x64/ARM64 structures, the pre-Windows-10 x64 compile variant,
+and 14 assertions in the opt-in observed-build header. Those are build checks,
+not a claim that historical or ARM64 CI runtime ABI has been verified.
diff --git a/docs/mincrypt-contracts.md b/docs/mincrypt-contracts.md
new file mode 100644
index 0000000..c2879c5
--- /dev/null
+++ b/docs/mincrypt-contracts.md
@@ -0,0 +1,211 @@
+# Code Integrity and mincrypt contracts
+
+The default header retains the existing structure layouts and calling shapes.
+Parameter names and ownership comments now distinguish the known controls from
+the verification result. `VerificationStatus` is an `NTSTATUS` with the same
+32-bit storage. The additional `Domito.MinCrypt.26100.h` header is an **opt-in
+description of ci.dll 10.0.26100.8328 x64**. It neither adds imports nor extends
+the default structures on every Windows target.
+
+The evidence is static REA/Ghidra analysis of SHA-256
+`c7d23814f3202c51fb23e3ca3d4328855787383cee5d14c4a986a13ec2aeda0b`, with
+Microsoft ci.pdb GUID `1CAE574B-CF1F-3564-5F74-49567A128629`, age 1 supplying the
+address/name map. The companion CodeIntegrity-RE repository retains the full
+records and readable `evidence/` files. These observations do not establish
+older-Windows or ARM64 ABI compatibility. Compile-target `NTDDI_VERSION` is not
+runtime layout negotiation.
+
+## Results and trust policy
+
+Check the API's returned `NTSTATUS` first. `PolicyInfo.VerificationStatus`,
+`PolicyBits`, signer details, and timestamps supply additional observations.
+Successful chain processing does not imply that the caller's file digest matched
+the signed digest; the comparison can occur afterward. Diagnostic policy data
+may exist on failure. An extracted publisher name is display information and
+does not establish publisher identity or signer trust.
+
+Define the policy the application needs: embedded signature validation,
+membership in an accepted installed catalog, an expected publisher/key, or a
+Windows signing scenario. Preserve which path produced a result. A nonzero
+`PolicyBits` value is not automatically failure, and any one root bit is not a
+complete trust decision. File bytes and object identity must remain tied to the
+bytes the application will subsequently use.
+
+The [file-object flag document](ci-validate-file-object.md) covers `0x20`, hash
+capacity, signing level, and its pending VM experiment. Other observed flag
+effects on this build are:
+
+- `0x1` selects hash scenario 5.
+- Otherwise `0x2` selects scenario 4 for signing levels <=4; higher levels use
+ the level mapping.
+- Otherwise `0x80000000` selects scenario 5 for levels <=4; higher levels use
+ the level mapping. It takes precedence over `0x4`.
+- Otherwise `0x4` selects scenario 1; without these choices the signing level
+ maps to a scenario.
+- `0x00400000` suppresses accepted-root bit `0x8` when the relevant helper mode
+ is enabled; CI can add this flag internally. Bits `0x2` and `0x8` also affect
+ later signing-level/EKU restrictions.
+
+The scenarios resolve through initialized signing-policy state. Static defaults
+are not a guarantee of a running system's algorithm, and the flags should not
+be named as universal fixed SHA selectors. Unknown bits remain unknown. Evidence:
+`CipGetHashAlgorithmForLegacyScenario`, `CipGetAcceptedRootKeys`, and
+`CipMinCryptToSigningLevel` in the research bundle.
+
+## Ownership and initialization
+
+Zero-initialize both `MINCRYPT_POLICY_INFO` output objects before their first
+use. Free both with `CiFreePolicyInfo` after use and before reuse, including
+failure paths. Never call the destructor on arbitrary uninitialized stack data.
+Do not clear a nonzero `Size` while a chain is still owned: the observed cleanup
+helper skips an object whose `Size` is zero.
+
+`ChainInfo` owns one allocation. Public-key blobs, names, encoded certificates,
+EKUs, attributes, and chain elements are borrowed views into it. Do not free
+children separately or retain them after releasing the owner. Copying the policy
+structure with `memcpy` creates two aliases to one allocation, not independent
+ownership. CI's internal deep-copy helper uses the total allocation size and
+rebases interior pointers; Domito does not expose that helper.
+
+The default `CiFreePolicyInfo` declaration retains its legacy `PVOID` return to
+avoid a broad source compatibility change. **Ignore that value**: observed x64
+assembly provides no defined pointer result. The opt-in build-specific function
+pointer type uses `VOID`.
+
+`CatalogName` is a separate `RtlDuplicateUnicodeString` allocation in the observed
+catalog export. Initialize it empty, release a returned buffer with
+`RtlFreeUnicodeString`, and release both policies separately. Publisher-name
+storage is allocated through the caller's callback and must be released with
+that allocator's matching free operation, including an allocated buffer on a
+later conversion failure. It is not owned by either policy object.
+
+Evidence: `MincryptFreePolicyInfo`, `MincryptCopyPolicyInfo`,
+`I_MincryptAddChainInfo`, `CiVerifyHashInCatalog`, and `CiGetCertPublisherName`.
+
+## Digest-based verification inputs
+
+`CiCheckSignedFile` consumes a caller-computed file digest. It does not reopen
+the file or compute its Authenticode hash. On the observed build it accepts
+SHA-1/256/384/512 with exactly 20/32/48/64 bytes respectively. A wrong algorithm,
+length, or digest fails the final comparison.
+
+Its signature input points to a `WIN_CERTIFICATE` header. The observed wrapper
+requires 4-byte pointer alignment, supplied size >=12, revision `0x200`, type 2,
+and `8 < dwLength <= supplied size`. The PKCS#7 payload begins at header+8.
+The wrapper selects the first certificate-table entry. Multiple table entries
+and nested signatures are separate concepts; this is not a complete signature
+enumeration interface.
+
+These exports remain implemented on the analyzed Windows 11 build. The older
+header's โWin7โ8.1 onlyโ comments described a workflow, not verified export
+availability. Exact first-available builds and pre/post-KB3033929 ABI changes
+still require matching historical binaries. Import-library symbol names and
+stdcall stack-byte counts alone do not settle parameter semantics.
+
+## Catalog controls
+
+`ReloadCatalogs` controls catalog-store reload and related context setup.
+`RequireTrustedCatalog` is a nonzero gate requiring an observed catalog extension
+marker: its matching bytes include `PE`, integer `0x10001`, and `TRUSTED`.
+The marker is an additional catalog condition, not proof of a Microsoft signer.
+
+`AcceptedCatalogPolicyBits` accepts a candidate when
+`(RequestedMask & Catalog.PolicyInfo.PolicyBits) != 0`: **any overlap** is enough.
+It neither enables policy flags nor requires every requested bit. The existing
+Domito compatibility value is named `DOMITO_CI_LEGACY_CATALOG_POLICY_BITS`
+(`0x2007F`). It includes test/DMD categories and diagnostic bit `0x20000`, while
+omitting other categories. It is retained as an existing behavior, not promoted
+as a universally safe trust mask. Establish and test the application's accepted
+signer policy before changing it.
+
+Observed file-hash lookup supports SHA-1 and SHA-256; the internal path has no
+SHA-384/512 file-hash case. Embedded-signature algorithm support and catalog
+lookup support are different capabilities. Catalog-only files need an explicit,
+tested algorithm strategy rather than assuming SHA-1 from an absent embedded
+table. Catalog stores, caches, silo/process context, and signing policy affect
+results. The new `DomitoValidateFileLegacyModeEx` forwards a caller-selected mask and
+marker gate and reports embedded/catalog origin. Its compatibility wrapper retains
+the existing mask. See [digest and workflow details](authenticode-digests.md).
+
+Domito's older-Windows helper keeps its system-process attachment until
+historical runtime evidence supports changing it.
+
+Evidence: `I_FindFileOrHeaderHashInLoadedCatalogs` and its assembly
+`ev_c42e7ee76099d55e7cf7cdd3d9f79e99030ba637680365612afb4906694ae3a8`,
+`I_ParseCatalog`, and marker matcher
+`ev_a1d3a5844a2b6be6c16175e307956c9819d0b64a4fe70216f292c12e4ea38d82`.
+
+## Opt-in x64 26100 structure observations
+
+The version header has distinct type names and 14 compile-time layout assertions.
+Use it only after independently establishing the matching runtime build and
+architecture. A large `ChainInfo->Size` alone does not identify the header: it
+includes variable trailing data, so an older allocation could also be large.
+Only after layout identity is established may a size check protect a field read.
+
+- `DOMITO_CI26100_NAME_BLOB`: pointer at +0, byte length at +8, ASN.1 string tag
+ at +10. Names are not necessarily NUL-terminated. Observed conversion handles
+ UTF8String tag `0x0C` and BMPString `0x1E`; `PCHAR` in the default declaration
+ must not be read as a guarantee of ANSI encoding.
+- `DOMITO_CI26100_CHAIN_INFO`: `Size` is total allocation bytes; fixed header is
+ 104 bytes. Root ID at +0x34 is an opaque integer, not policy bits. The 32 bytes
+ at +0x48 are a platform-manifest binary ID from an authenticated attribute,
+ not a file digest or certificate thumbprint. Zero/nonzero does not by itself
+ prove trust. Require the matching layout and `Size >=104` before reading it.
+- `DOMITO_CI26100_POLICY_INFO`: 48 bytes; `NotBefore`/`NotAfter` come from the
+ signer certificate validity fields, not an intersection of the whole chain.
+ Interpret `RevocationTime` only with the relevant result. A zero signing time
+ is not a real timestamp to display as a signing date.
+
+Construction/copy evidence: `ev_d7110cfc711074a4af1874b5c8e701c13bcc3dc58a1596c64dca4ca98966c79e`,
+`ev_858afecf2170484325379b6a8bc307fb72dad2f9a5a765169192c7363a3386af`,
+`ev_f82cbee977715399323bb398781c125a9d19b4ea29acd05063ec55b711cfc670`.
+
+## Partial policy-bit dictionary
+
+Names in the opt-in header are Domito descriptions of observed 26100 branches,
+not official Microsoft constants or a universal trusted-root enumeration.
+
+Root/provenance categories include `0x1` self-issued terminal path, `0x2` selected
+built-in Microsoft roots, `0x4` test roots, `0x8` the built-in root associated with
+embedded picroot handling, `0x10` tolerated partial/unresolved chain, `0x20` DMD,
+`0x40` DMD test, `0x80` another built-in Microsoft root, `0x400` flight root,
+`0x2000` third-party marketplace, `0x4000` explicit supplied certificate/key
+acceptance, and `0x8000` Microsoft Identity Root 2020. `0x800` and `0x1000` are
+additional discriminators on some root entries; their independent meanings
+remain unresolved. These categories do not establish a full trust result.
+
+Observed diagnostic branches set `0x20000` chain failure, `0x40000` invalid
+cryptographic/signature data, `0x80000` unmet EKU, `0x100000` page-hash failure,
+`0x200000` revocation, `0x400000` certificate validity failure (including
+not-yet-valid), `0x08000000` path-length constraints, `0x10000000` generic fallback,
+and `0x20000000` CA/BasicConstraints failure. `0x04000000` marks a no-usable-
+timestamp branch during revoked-certificate recovery, not universal timestamp
+absence. `0x50000` and `0x60000` are observed composites; do not infer a unique
+meaning for every constituent bit. Timestamp-before-revocation processing can
+clear a revocation bit. Internal checks of high bits are not a replacement for
+the API status and application policy.
+
+## Remaining runtime and API coverage
+
+The missing page-hash exports have recovered argument shapes in the research
+report. Their normalized allocation uses a 24-byte x64 header and 24-byte entries
+even for stronger source algorithms. Field meaning, truncation intent, source
+terminal-entry handling, and a supported destructor remain open. `CiFreePolicyInfo`
+is not a demonstrated destructor for that allocation. No universal page-hash SDK
+or static import is added until those ownership and cross-version contracts can
+be tested.
+
+`CiGetPEInformation` is a protected-media dispatcher, not a general PE parser.
+`CiInitialize` is initialization machinery. `CiSetTrustedOriginClaimId` writes a
+CI global; it is not an ordinary per-file trust selector. Ordinal-only exports
+and other initialization interfaces require their own evidence and callers.
+
+Still required: historical/ARM64 layout and parameter verification, real CI
+output lifetimes and retry behavior under failures, timestamp propagation,
+revocation freshness/network policy, weak-crypto selection, multiple/nested
+signatures, catalog-only algorithms, and extension of the bounded digest subset to additional PE layouts.
+The explicit digest helper now has local Windows SIP and real embedded-digest
+comparisons; this does not close those historical or unusual-layout cases. Run the prepared probes in disposable VMs with exact OS,
+CI binary, configuration, and fixture identities. Compilation and host mocks do
+not close those runtime questions.
diff --git a/include/Domito.MinCrypt.26100.h b/include/Domito.MinCrypt.26100.h
new file mode 100644
index 0000000..bf7a123
--- /dev/null
+++ b/include/Domito.MinCrypt.26100.h
@@ -0,0 +1,157 @@
+/* Opt-in observed-build declarations, not a universal Windows CI SDK.
+ * Observed: ci.dll 10.0.26100.8328, x64, SHA-256 documented in docs/mincrypt-contracts.md.
+ * Include after ntddk.h. Uses distinct names and function-pointer types so
+ * including this file does not change the default layouts or add static imports.
+ * Verify the actual CI build/architecture before use; Size alone cannot prove
+ * layout identity. See docs/mincrypt-contracts.md for provenance and limits.
+ * Field/parameter names are descriptive analyst names unless stated otherwise.
+ */
+#pragma once
+
+#if !defined(_M_AMD64) && !defined(__x86_64__)
+#error This research layout is validated only for x64.
+#endif
+
+#pragma pack(push, 8)
+typedef struct _DOMITO_CI26100_BLOB {
+ ULONG Size;
+ UCHAR *Data;
+} DOMITO_CI26100_BLOB;
+
+typedef struct _DOMITO_CI26100_NAME_BLOB {
+ UCHAR *Data; /* Borrowed, length-delimited encoded bytes. */
+ USHORT Size; /* Bytes, not characters. */
+ UCHAR Asn1StringTag; /* Observed: 0x0c UTF8String, 0x1e BMPString. */
+} DOMITO_CI26100_NAME_BLOB;
+
+typedef struct _DOMITO_CI26100_CHAIN_ELEMENT {
+ ULONG HashAlgId;
+ ULONG HashSize;
+ UCHAR Hash[64];
+ DOMITO_CI26100_NAME_BLOB Subject;
+ DOMITO_CI26100_NAME_BLOB Issuer;
+ DOMITO_CI26100_BLOB Certificate;
+} DOMITO_CI26100_CHAIN_ELEMENT;
+
+typedef struct _DOMITO_CI26100_CHAIN_INFO {
+ ULONG Size; /* Entire allocation, including trailing data. */
+ DOMITO_CI26100_BLOB *PublicKeys;
+ ULONG NumberOfPublicKeys;
+ DOMITO_CI26100_BLOB *EKUs;
+ ULONG NumberOfEKUs;
+ DOMITO_CI26100_CHAIN_ELEMENT *ChainElements;
+ ULONG NumberOfChainElements;
+ ULONG RootId; /* Opaque classification, NOT policy bits. */
+ DOMITO_CI26100_BLOB AuthenticodeAttributes;
+ UCHAR PlatformManifestBinaryId[32]; /* NOT a file or certificate hash. */
+} DOMITO_CI26100_CHAIN_INFO;
+
+typedef struct _DOMITO_CI26100_POLICY_INFO {
+ ULONG Size;
+ NTSTATUS VerificationStatus; /* Does not replace the API return status. */
+ ULONG PolicyBits;
+ DOMITO_CI26100_CHAIN_INFO *ChainInfo;
+ LARGE_INTEGER RevocationTime;
+ LARGE_INTEGER NotBefore;
+ LARGE_INTEGER NotAfter;
+} DOMITO_CI26100_POLICY_INFO, *PDOMITO_CI26100_POLICY_INFO;
+#pragma pack(pop)
+
+C_ASSERT(sizeof(DOMITO_CI26100_BLOB) == 16);
+C_ASSERT(sizeof(DOMITO_CI26100_NAME_BLOB) == 16);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_NAME_BLOB, Asn1StringTag) == 10);
+C_ASSERT(sizeof(DOMITO_CI26100_CHAIN_ELEMENT) == 120);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_CHAIN_ELEMENT, Certificate) == 104);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_CHAIN_INFO, RootId) == 52);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_CHAIN_INFO, AuthenticodeAttributes) == 56);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_CHAIN_INFO, PlatformManifestBinaryId) == 72);
+C_ASSERT(sizeof(DOMITO_CI26100_CHAIN_INFO) == 104);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_POLICY_INFO, ChainInfo) == 16);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_POLICY_INFO, RevocationTime) == 24);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_POLICY_INFO, NotBefore) == 32);
+C_ASSERT(FIELD_OFFSET(DOMITO_CI26100_POLICY_INFO, NotAfter) == 40);
+C_ASSERT(sizeof(DOMITO_CI26100_POLICY_INFO) == 48);
+
+/* Zero-initialize policies before first use. Free existing owned chains before
+ * reuse. Never copy these structs as independently owned objects with memcpy.
+ * Size is an output/initialization marker here, not runtime ABI negotiation.
+ */
+typedef VOID (NTAPI *DOMITO_CI26100_FREE_POLICY_INFO)(
+ DOMITO_CI26100_POLICY_INFO *PolicyInfo);
+
+typedef NTSTATUS (NTAPI *DOMITO_CI26100_VALIDATE_FILE_OBJECT)(
+ PFILE_OBJECT FileObject,
+ ULONG ValidationFlags,
+ UCHAR RequestedSigningLevel,
+ DOMITO_CI26100_POLICY_INFO *PolicyInfo,
+ DOMITO_CI26100_POLICY_INFO *TimeStampPolicyInfo,
+ LARGE_INTEGER *SigningTime,
+ UCHAR *Hash, /* Provide 64-byte capacity, even for SHA-1. */
+ ULONG *HashSize, /* In: capacity >=64. Out success: actual size. */
+ ULONG *HashAlgId);
+
+typedef NTSTATUS (NTAPI *DOMITO_CI26100_CHECK_SIGNED_FILE)(
+ PVOID Hash,
+ ULONG HashSize,
+ ULONG HashAlgId,
+ PVOID WinCertificate, /* WIN_CERTIFICATE header, not bCertificate. */
+ ULONG SizeOfSecurityDirectory,
+ DOMITO_CI26100_POLICY_INFO *PolicyInfo,
+ LARGE_INTEGER *SigningTime,
+ DOMITO_CI26100_POLICY_INFO *TimeStampPolicyInfo);
+
+/* The existing BOOLEAN reload parameter ABI was not independently resolved
+ * beyond the full-width use in this build; retain Domito's declaration until
+ * cross-architecture call-site evidence settles its formal type.
+ * Fifth argument nonzero filters on the catalog TRUSTED marker.
+ * Sixth argument requires ANY overlap with catalog PolicyInfo.PolicyBits.
+ */
+typedef NTSTATUS (NTAPI *DOMITO_CI26100_VERIFY_HASH_IN_CATALOG)(
+ PVOID Hash,
+ ULONG HashSize,
+ ULONG HashAlgId,
+ BOOLEAN ReloadCatalogs,
+ ULONG RequireTrustedCatalog,
+ ULONG AcceptedCatalogPolicyBits,
+ DOMITO_CI26100_POLICY_INFO *PolicyInfo,
+ UNICODE_STRING *CatalogName, /* Optional; separate RtlFreeUnicodeString. */
+ LARGE_INTEGER *SigningTime,
+ DOMITO_CI26100_POLICY_INFO *TimeStampPolicyInfo);
+
+typedef PVOID (NTAPI *DOMITO_CI26100_ALLOCATE)(SIZE_T ByteSize);
+typedef NTSTATUS (NTAPI *DOMITO_CI26100_GET_CERT_PUBLISHER_NAME)(
+ DOMITO_CI26100_BLOB *Certificate,
+ DOMITO_CI26100_ALLOCATE AllocateRoutine,
+ UNICODE_STRING *PublisherName); /* Free with callback's allocator pair. */
+
+/* Proposed descriptive diagnostic names. Not official Microsoft names, not
+ * an exhaustive dictionary, and not accepted-input validation flags.
+ * See the report for narrower branch conditions and composite values.
+ */
+#define DOMITO_CI26100_POLICY_SELF_ISSUED_TERMINAL 0x00000001UL
+// Also present on rgbMicrosoftRoot4 together with bit 0x800.
+#define DOMITO_CI26100_POLICY_MICROSOFT_ROOT_0_1_2 0x00000002UL
+#define DOMITO_CI26100_POLICY_TEST_ROOT 0x00000004UL
+#define DOMITO_CI26100_POLICY_MICROSOFT_ROOT_3 0x00000008UL
+#define DOMITO_CI26100_POLICY_DMD_ROOT 0x00000020UL
+#define DOMITO_CI26100_POLICY_DMD_TEST_ROOT 0x00000040UL
+#define DOMITO_CI26100_POLICY_MICROSOFT_ROOT_5 0x00000080UL
+#define DOMITO_CI26100_POLICY_FLIGHT_ROOT 0x00000400UL
+#define DOMITO_CI26100_POLICY_MARKETPLACE_ROOT 0x00002000UL
+#define DOMITO_CI26100_POLICY_SUPPLIED_CERT_OR_KEY 0x00004000UL
+#define DOMITO_CI26100_POLICY_IDENTITY_ROOT_2020 0x00008000UL
+#define DOMITO_CI26100_POLICY_PARTIAL_CHAIN 0x00000010UL
+#define DOMITO_CI26100_POLICY_CHAIN_FAILURE 0x00020000UL
+#define DOMITO_CI26100_POLICY_CRYPTO_FAILURE 0x00040000UL
+#define DOMITO_CI26100_POLICY_EKU_FAILURE 0x00080000UL
+#define DOMITO_CI26100_POLICY_PAGE_HASH_FAILURE 0x00100000UL
+#define DOMITO_CI26100_POLICY_CERT_REVOKED 0x00200000UL
+#define DOMITO_CI26100_POLICY_CERT_VALIDITY_FAILURE 0x00400000UL
+#define DOMITO_CI26100_POLICY_PATH_LENGTH_FAILURE 0x08000000UL
+#define DOMITO_CI26100_POLICY_GENERIC_FAILURE 0x10000000UL
+#define DOMITO_CI26100_POLICY_CA_CONSTRAINT_FAILURE 0x20000000UL
+
+/* Narrow branch observation: no usable timestamp during revoked-cert recovery;
+ * not a universal flag for all signatures without a timestamp.
+ */
+#define DOMITO_CI26100_POLICY_REVOCATION_NO_TIMESTAMP 0x04000000UL
diff --git a/include/Domito.MinCrypt.h b/include/Domito.MinCrypt.h
index da97823..78d54d4 100644
--- a/include/Domito.MinCrypt.h
+++ b/include/Domito.MinCrypt.h
@@ -277,6 +277,8 @@ MINCRYPT_DIGEST_BLOB, * PMINCRYPT_DIGEST_BLOB,
MINCRYPT_DER_BLOB, * PMINCRYPT_DER_BLOB,
MINCRYPT_ATTR_BLOB, * PMINCRYPT_ATTR_BLOB;
+// Encoded, length-delimited name bytes; not necessarily ANSI or NUL-terminated.
+// The observed 26100 x64 string tag occupies padding; see the opt-in version header.
typedef struct _MINCRYPT_NAME_BLOB
{
_Field_size_bytes_(Size) PCHAR Data;
@@ -287,6 +289,8 @@ typedef struct _MINCRYPT_NAME_BLOB
#define MINCRYPT_MAX_HASH_LENGTH (64)
#define MINCRYPT_SHA1_LENGTH (160/8)
#define MINCRYPT_SHA256_LENGTH (256/8)
+#define MINCRYPT_SHA384_LENGTH (384/8)
+#define MINCRYPT_SHA512_LENGTH (512/8)
typedef struct _MINCRYPT_CHAIN_ELEMENT
{
@@ -301,9 +305,11 @@ typedef struct _MINCRYPT_CHAIN_ELEMENT
}MINCRYPT_CHAIN_ELEMENT, * PMINCRYPT_CHAIN_ELEMENT;
+// CI owns one chain allocation; nested pointers borrow that allocation.
+// Do not free child blobs separately or use memcpy to create a second owner.
typedef struct _MINCRYPT_CHAIN_INFO
{
- UINT32 Size;
+ UINT32 Size; // Observed 26100 x64: total allocation bytes, including trailing data.
MINCERT_BLOB* PublicKeys;
UINT32 NumberOfPublicKeys;
@@ -319,7 +325,9 @@ typedef struct _MINCRYPT_CHAIN_INFO
// ASN.1 blob of authenticated attributes - spcSpOpusInfo, contentType, etc.
MINCRYPT_ATTR_BLOB AuthenticodeAttributes;
- // UINT8 Hash[32];
+ // Observed 26100 x64 adds RootId at +0x34 and a 32-byte platform-manifest
+ // binary ID at +0x48. These are exposed only by Domito.MinCrypt.26100.h.
+ // Do not infer their availability solely from NTDDI_VERSION or Size.
#endif // NTDDI_VERSION >= NTDDI_WIN10
@@ -338,7 +346,7 @@ typedef struct _MINCRYPT_CHAIN_INFO
typedef struct _MINCRYPT_POLICY_INFO
{
UINT32 Size;
- UINT32 VerificationStatus;
+ NTSTATUS VerificationStatus; // Check API return status separately.
UINT32 PolicyBits;
MINCRYPT_CHAIN_INFO* ChainInfo;
@@ -347,8 +355,8 @@ typedef struct _MINCRYPT_POLICY_INFO
#if (NTDDI_VERSION >= NTDDI_WIN10)
- LARGE_INTEGER NotBefore; // The certificate is not valid before this time
- LARGE_INTEGER NotAfter; // The certificate is not valid after this time
+ LARGE_INTEGER NotBefore; // Observed: signer certificate validity start.
+ LARGE_INTEGER NotAfter; // Observed: signer certificate validity end.
#endif // NTDDI_VERSION >= NTDDI_WIN10
@@ -358,12 +366,16 @@ typedef struct _MINCRYPT_POLICY_INFO
/**
-* Resets a PolicyInfo struct - frees the dynamically allocated buffer in PolicyInfo (ChainInfo) if not null.
-* Zeros the entire PolicyInfo struct.
+* Release a known initialized policy's owned ChainInfo allocation and reset it.
+* Zero-initialize policy objects before first use. Release both signer and TSA
+* policies after use, including failures, and before reusing output storage.
+* Borrowed child pointers become invalid when the owner is released. Never clear
+* Size while a chain is still owned. Size does not negotiate the runtime layout.
*
-* @param PolicyInfo - the struct to reset.
+* @param PolicyInfo - initialized policy, not arbitrary uninitialized storage.
*
-* @return the struct which was reset.
+* Ignore the return value. The legacy PVOID declaration is retained for source
+* compatibility; observed 26100 x64 code provides no defined pointer return.
*/
_IRQL_requires_max_(PASSIVE_LEVEL)
MINCRYPTAPI
@@ -375,18 +387,23 @@ CiFreePolicyInfo(
/**
-* Win7SP1-Win8.1 only (KB3033929 installed). Use CiValidateFileObject on Win10!
+* Digest-based workflow, also implemented on observed Windows 11 build 26100.
+* Prefer the file-object workflow when its policy fits. Older-build ABI and
+* KB3033929 requirements need a separately verified compatibility matrix.
*
* Given a file digest and signature of a file, verify the signature and provide information regarding
* the certificates that was used for signing (the entire certificate chain)
*
-* @param Hash - buffer containing the digest
+* @param Hash[in] - caller-computed file digest; CI does not recompute the file bytes
*
* @param HashSize - size of the digest, e.g. 0x14(160bit) for SHA1, 0x20(256bit) for SHA256
*
* @param HashAlgId - digest algorithm identifier, e.g. CALG_SHA1(0x8004), CALG_SHA_256(0x800C)
*
-* @param SecurityDirectory - pointer to the start of the security directory
+* @param SecurityDirectory - WIN_CERTIFICATE header, not the PKCS#7 payload.
+* Observed 26100 x64 checks 4-byte pointer alignment, revision 0x200,
+* type 2, supplied size >=12 and 8 < dwLength <= supplied size.
+* This wrapper selects the first entry; it does not enumerate the table.
*
* @param SizeOfSecurityDirectory - size the security directory
*
@@ -396,9 +413,12 @@ CiFreePolicyInfo(
*
* @param TimeStampPolicyInfo[out] - PolicyInfo containing information about the timestamping authority (TSA) certificate chain
*
-* @return STATUS_SUCCESS if the file digest in the signature matches the given digest and the signer cetificate is verified.
+* @return Overall verification NTSTATUS. A successful policy VerificationStatus
+* does not replace this return value: the final file-digest comparison
+* can still fail. Both policy outputs may carry diagnostics on failure.
+* See docs/mincrypt-contracts.md for ownership and policy limits.
* Various error values otherwise, for example:
-* STATUS_INVALID_IMAGE_HASH - the digest does not match the digest in the signature
+* STATUS_INVALID_IMAGE_HASH - digest/signature or selected CI policy rejected
* STATUS_IMAGE_CERT_REVOKED - the certificate used for signing the file is revoked
* STATUS_IMAGE_CERT_EXPIRED - the certificate used for signing the file has expired
*/
@@ -407,10 +427,10 @@ MINCRYPTAPI
NTSTATUS
NTAPI
CiCheckSignedFile(
- _In_ PVOID Hash,
+ _In_reads_bytes_(HashSize) PVOID Hash,
_In_ UINT32 HashSize,
_In_ ALG_ID HashAlgId,
- _In_ PVOID SecurityDirectory,
+ _In_reads_bytes_(SizeOfSecurityDirectory) PVOID SecurityDirectory,
_In_ UINT32 SizeOfSecurityDirectory,
_Out_ MINCRYPT_POLICY_INFO* PolicyInfo,
_Out_ LARGE_INTEGER* SigningTime,
@@ -418,36 +438,51 @@ CiCheckSignedFile(
);
+// Existing Domito compatibility filter, not a universal trusted-root policy.
+// Includes test/DMD categories and a diagnostic bit; select policy deliberately.
+#define DOMITO_CI_LEGACY_CATALOG_POLICY_BITS 0x0002007FUL
+
/**
-* Win7SP1-Win8.1 only (KB3033929 installed). Use CiValidateFileObject on Win10!
+* Digest-based workflow, also implemented on observed Windows 11 build 26100.
+* Prefer the file-object workflow when its policy fits. Older-build ABI and
+* KB3033929 requirements need a separately verified compatibility matrix.
*
-* Checks if the SHA-1 message digest is contained within a verified system catalog
+* Finds a caller-computed file digest in an accepted installed catalog.
+* Observed 26100 x64 file-hash lookup supports SHA-1 and SHA-256.
*
* @note must be attached to the PsInitialSystemProcess first!
*
-* @param Hash - buffer containing the digest
+* @param Hash[in] - caller-computed file digest; CI does not recompute the file bytes
*
* @param HashSize - size of the digest, e.g. 0x14(160bit) for SHA1, 0x20(256bit) for SHA256
*
* @param HashAlgId - digest algorithm identifier, e.g. CALG_SHA1(0x8004), CALG_SHA_256(0x800C)
*
-* @param IsReloadCatalogs - is reload catalogs cache.
+* @param ReloadCatalogs - request catalog-store reload; process/silo and cache state matter.
*
-* @param Always0 - this is for IsReloadCatalogs, Always0 != 0 ? 16 : 24;
+* @param RequireTrustedCatalog - nonzero requires the observed catalog TRUSTED
+* extension marker. This does not itself establish Microsoft signer trust.
*
-* @param Always2007F - unknown, always 0x2007F, maybe a mask.
+* @param AcceptedCatalogPolicyBits - candidate catalog policy-bit filter.
+* Observed 26100 x64 requires ANY overlap with catalog PolicyBits, not
+* every requested bit. This is not a flags-to-enable input.
*
* @param PolicyInfo[out] - PolicyInfo containing information about the signer certificate chain.
*
-* @param CatalogName[out option] - catalog file name.
+* @param CatalogName[out option] - initialize empty; observed 26100 x64 returns
+* a separate duplicated string allocation. Release a returned Buffer
+* with RtlFreeUnicodeString, separately from both policy outputs.
*
* @param SigningTime[out] - when the file was signed (FILETIME format)
*
* @param TimeStampPolicyInfo[out] - PolicyInfo containing information about the timestamping authority (TSA) certificate chain.
*
-* @return STATUS_SUCCESS if the file digest in the signature matches the given digest and the signer cetificate is verified.
+* @return Overall verification NTSTATUS. A successful policy VerificationStatus
+* does not replace this return value. Catalog selection and the
+* selected policy still apply. Outputs may carry diagnostics on failure.
+* See docs/mincrypt-contracts.md for ownership and policy limits.
* Various error values otherwise, for example:
-* STATUS_INVALID_IMAGE_HASH - the digest does not match the digest in the signature
+* STATUS_INVALID_IMAGE_HASH - digest/signature or selected CI policy rejected
* STATUS_IMAGE_CERT_REVOKED - the certificate used for signing the file is revoked
* STATUS_IMAGE_CERT_EXPIRED - the certificate used for signing the file has expired
*/
@@ -456,12 +491,12 @@ MINCRYPTAPI
NTSTATUS
NTAPI
CiVerifyHashInCatalog(
- _In_ PVOID Hash,
+ _In_reads_bytes_(HashSize) PVOID Hash,
_In_ UINT32 HashSize,
_In_ ALG_ID HashAlgId,
- _In_ BOOLEAN IsReloadCatalogs,
- _In_ UINT32 Always0,
- _In_ UINT32 Always2007F,
+ _In_ BOOLEAN ReloadCatalogs,
+ _In_ UINT32 RequireTrustedCatalog,
+ _In_ UINT32 AcceptedCatalogPolicyBits,
_Out_ MINCRYPT_POLICY_INFO* PolicyInfo,
_Out_opt_ UNICODE_STRING* CatalogName,
_Out_ LARGE_INTEGER* SigningTime,
@@ -552,7 +587,7 @@ CiSetTrustedOriginClaimId(
* @return STATUS_SUCCESS when verification succeeds under the selected CI policy.
* This does not by itself establish the application's signer trust policy.
* Various error values otherwise, for example:
-* STATUS_INVALID_IMAGE_HASH - the digest does not match the digest in the signature
+* STATUS_INVALID_IMAGE_HASH - digest/signature or selected CI policy rejected
* STATUS_IMAGE_CERT_REVOKED - the certificate used for signing the file is revoked
* STATUS_IMAGE_CERT_EXPIRED - the certificate used for signing the file has expired
*/
@@ -562,12 +597,12 @@ NTSTATUS
NTAPI
CiValidateFileObject(
_In_ FILE_OBJECT* FileObject,
- _In_opt_ UINT32 ValidationFlags,
- _In_opt_ UINT32 RequestedSigningLevel,
+ _In_ UINT32 ValidationFlags,
+ _In_ UINT32 RequestedSigningLevel,
_Out_ MINCRYPT_POLICY_INFO* PolicyInfo,
_Out_ MINCRYPT_POLICY_INFO* TimeStampPolicyInfo,
_Out_ LARGE_INTEGER* SigningTime,
- _Out_ UINT8* Hash,
+ _Out_writes_bytes_(*HashSize) UINT8* Hash,
_Inout_ UINT32* HashSize,
_Out_ ALG_ID* HashAlgId
);
diff --git a/include/Domito.h b/include/Domito.h
index 4ada98c..b1a820f 100644
--- a/include/Domito.h
+++ b/include/Domito.h
@@ -184,6 +184,43 @@ DomitoGetPortableExecutableDigestKind(
_In_ PIMAGE_DATA_DIRECTORY pImgDataDirectory
);
+// Reads the first PKCS#7 entry's SpcIndirectDataContent DigestInfo algorithm
+// using bounded DER navigation. Does not validate the signature or select among
+// multiple/nested signatures. Absent table => STATUS_NOT_FOUND; unsupported OID
+// or certificate type => STATUS_NOT_SUPPORTED; malformed DER => format error.
+// Unlike the compatibility helper above, it never silently falls back to SHA-1.
+_IRQL_requires_max_(DISPATCH_LEVEL)
+EXTERN_C
+NTSTATUS
+DomitoGetPortableExecutableDigestKindEx(
+ _In_reads_bytes_(PeSize) const UCHAR* PeBytes,
+ _In_ ULONG PeSize,
+ _In_reads_(IMAGE_DIRECTORY_ENTRY_SECURITY + 1) const IMAGE_DATA_DIRECTORY* Directories,
+ _Out_ PUINT32 DigestCalg
+);
+
+// Algorithm-explicit Authenticode digest for the supported layout subset:
+// bounded PE32/PE32+ headers, contiguous nonoverlapping raw sections in file
+// offset order, optional overlay before a complete EOF certificate table.
+// SHA-1/256/384/512 supported. Gaps/overlaps/nonterminal certificates are rejected.
+// No signature trust decision is made. Outputs are cleared on failure; the
+// certificate borrows stable input memory, and only the digest must be freed.
+// See docs/authenticode-digests.md for policy and Windows SIP comparisons.
+_Success_(return == STATUS_SUCCESS)
+_Must_inspect_result_
+_IRQL_requires_max_(PASSIVE_LEVEL)
+EXTERN_C
+NTSTATUS
+DomitoCalculatePortableExecutableDigestEx(
+ _In_reads_bytes_(PeSize) const UCHAR* PeBytes,
+ _In_ ULONG PeSize,
+ _In_ UINT32 HashAlgId,
+ _Out_ PULONG DigestSize,
+ _Outptr_result_bytebuffer_(*DigestSize) PVOID* Digest,
+ _Outptr_result_maybenull_ LPWIN_CERTIFICATE* Certificate,
+ _Out_ PULONG SecuritySize
+);
+
//
// Calculates a PE digest using the existing linear hashing convention.
// Validates accessed header/certificate ranges and clears outputs on failure
@@ -217,8 +254,40 @@ DomitoFreePortableExecutableDigest(
_In_ PVOID pDigestOut
);
+#define DOMITO_CI_SOURCE_EMBEDDED 1UL
+#define DOMITO_CI_SOURCE_CATALOG 2UL
+
+// Policy-explicit legacy helper. SHA-1/256 only; hash size must match algorithm.
+// Policy objects must be zero-initialized or own valid CI results; both are freed
+// before each attempt. Caller owns both final policy outputs, including failure.
+// Embedded validation is tried first; only INVALID_IMAGE_HASH permits catalog
+// fallback/reload. AcceptedCatalogPolicyBits uses ANY-overlap catalog filtering;
+// RequireTrustedCatalog gates the observed marker, not publisher identity.
+// Source is 0 on failure, EMBEDDED/CATALOG on success. Stable file bytes and a
+// handle valid in the caller's current process context are required during I/O.
+_Success_(return == STATUS_SUCCESS)
+_Must_inspect_result_
+_IRQL_requires_max_(PASSIVE_LEVEL)
+EXTERN_C
+NTSTATUS
+DomitoValidateFileLegacyModeEx(
+ _In_ HANDLE FileHandle,
+ _In_reads_bytes_(HashSize) PVOID Hash,
+ _In_ UINT32 HashSize,
+ _In_ ALG_ID HashAlgId,
+ _In_ const IMAGE_DATA_DIRECTORY* SecurityDirectory,
+ _Inout_ MINCRYPT_POLICY_INFO* PolicyInfo,
+ _Out_ LARGE_INTEGER* SigningTime,
+ _Inout_ MINCRYPT_POLICY_INFO* TimeStampPolicyInfo,
+ _In_ UINT32 AcceptedCatalogPolicyBits,
+ _In_ BOOLEAN RequireTrustedCatalog,
+ _Out_ PULONG ValidationSource
+);
+
//
-// Verifies if the Authenticode signature of a give PE file matches the provided (e.g. SHA1) file digest.
+// Compatibility wrapper around DomitoValidateFileLegacyModeEx, with catalog
+// mask DOMITO_CI_LEGACY_CATALOG_POLICY_BITS and RequireTrustedCatalog = FALSE.
+// The same initialization, ownership, hash, handle, and stable-file rules apply.
//
_Success_(return == STATUS_SUCCESS)
_Must_inspect_result_
@@ -258,7 +327,10 @@ DomitoFindModuleBaseAddress(
);
//
-// Finds the address of an exported function by name.
+// Finds an export by name (case-insensitive) in a valid loader-mapped module.
+// ModuleBase must remain mapped; arbitrary file buffers are not supported.
+// Export table spans are checked against SizeOfImage. Forwarded exports return
+// STATUS_NOT_SUPPORTED; the optional output is cleared before lookup.
//
_Success_(return == STATUS_SUCCESS)
_Must_inspect_result_
@@ -289,7 +361,28 @@ DomitoMemorySearchPattern(
);
//
-// Reads from the beginning of a file until the end or the buffer size is reached.
+// Reads exactly BufferSize bytes at a nonnegative absolute file offset.
+// Same completion, buffer, handle, APC, and short-read contract as DomitoReadFile.
+//
+_Success_(return == STATUS_SUCCESS)
+_Must_inspect_result_
+_IRQL_requires_max_(PASSIVE_LEVEL)
+EXTERN_C
+NTSTATUS
+DomitoReadFileAt(
+ _In_ HANDLE FileHandle,
+ _Out_writes_bytes_(BufferSize) PVOID Buffer,
+ _In_ ULONG BufferSize,
+ _In_ const LARGE_INTEGER* ByteOffset
+);
+
+//
+// Reads exactly BufferSize bytes from file offset zero. Short reads return
+// STATUS_END_OF_FILE; Buffer may then contain a partial read. Zero length succeeds.
+// Waits for pending I/O on a private event before returning. Requires PASSIVE_LEVEL
+// with special kernel APCs enabled, a readable handle valid in the current process
+// (or a kernel handle), and a buffer that remains valid for the entire call.
+// For synchronous handles, the explicit read also updates the file position.
//
_Success_(return == STATUS_SUCCESS)
_Must_inspect_result_
diff --git a/src/Domito.CodeIntegrity.cpp b/src/Domito.CodeIntegrity.cpp
index c0c2d02..7e67656 100644
--- a/src/Domito.CodeIntegrity.cpp
+++ b/src/Domito.CodeIntegrity.cpp
@@ -89,11 +89,252 @@ DomitoGetPortableExecutableDigestKind(
return CALG_SHA1;
}
+// Bounded DER navigation for the outer Authenticode content only. This does not
+// validate certificates, signerInfos, nested signatures, or cryptographic trust.
+typedef struct _DOMITO_DER_VIEW
+{
+ const UCHAR* Bytes;
+ ULONG Length;
+} DOMITO_DER_VIEW;
+
+static BOOLEAN DomitoDerTake(DOMITO_DER_VIEW* Input, UCHAR Tag, DOMITO_DER_VIEW* Value)
+{
+ if (Input->Length < 2 || Input->Bytes[0] != Tag)
+ return FALSE;
+ ULONG length = Input->Bytes[1];
+ ULONG header = 2;
+ if (length & 0x80)
+ {
+ const ULONG count = length & 0x7F;
+ if (!count || count > sizeof(ULONG) || count > Input->Length - header ||
+ Input->Bytes[header] == 0)
+ return FALSE; // No indefinite or nonminimal DER lengths.
+ length = 0;
+ for (ULONG i = 0; i < count; ++i)
+ length = (length << 8) | Input->Bytes[header++];
+ if (length < 0x80)
+ return FALSE;
+ }
+ if (length > Input->Length - header)
+ return FALSE;
+ Value->Bytes = Input->Bytes + header;
+ Value->Length = length;
+ Input->Bytes += header + length;
+ Input->Length -= header + length;
+ return TRUE;
+}
+
+static BOOLEAN DomitoDerOid(DOMITO_DER_VIEW* Input, const UCHAR* Oid, ULONG Size)
+{
+ DOMITO_DER_VIEW value;
+ return DomitoDerTake(Input, 0x06, &value) && value.Length == Size &&
+ memcmp(value.Bytes, Oid, Size) == 0;
+}
+
+_IRQL_requires_max_(DISPATCH_LEVEL)
+NTSTATUS
+DomitoGetPortableExecutableDigestKindEx(
+ _In_reads_bytes_(PeSize) const UCHAR* PeBytes,
+ _In_ ULONG PeSize,
+ _In_reads_(IMAGE_DIRECTORY_ENTRY_SECURITY + 1) const IMAGE_DATA_DIRECTORY* Directories,
+ _Out_ PUINT32 DigestCalg)
+{
+ if (!DigestCalg)
+ return STATUS_INVALID_PARAMETER;
+ *DigestCalg = 0;
+ if (!PeBytes || !Directories)
+ return STATUS_INVALID_PARAMETER;
+ const IMAGE_DATA_DIRECTORY security = Directories[IMAGE_DIRECTORY_ENTRY_SECURITY];
+ if (!security.VirtualAddress && !security.Size)
+ return STATUS_NOT_FOUND;
+ if (!security.VirtualAddress || (security.VirtualAddress & 7) ||
+ security.VirtualAddress > PeSize || security.Size > PeSize - security.VirtualAddress ||
+ security.Size < 8)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ ULONG length;
+ USHORT revision, type;
+ const UCHAR* certificate = PeBytes + security.VirtualAddress;
+ memcpy(&length, certificate, 4);
+ memcpy(&revision, certificate + 4, 2);
+ memcpy(&type, certificate + 6, 2);
+ if (length <= 8 || length > security.Size)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ if (revision != 0x0200 || type != WIN_CERT_TYPE_PKCS_SIGNED_DATA)
+ return STATUS_NOT_SUPPORTED;
+
+ const UCHAR signedDataOid[] = {0x2A,0x86,0x48,0x86,0xF7,0x0D,0x01,0x07,0x02};
+ const UCHAR indirectOid[] = {0x2B,0x06,0x01,0x04,0x01,0x82,0x37,0x02,0x01,0x04};
+ const UCHAR sha1Oid[] = {0x2B,0x0E,0x03,0x02,0x1A};
+ const UCHAR sha2Prefix[] = {0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x02};
+ DOMITO_DER_VIEW payload = {certificate + 8, length - 8};
+ DOMITO_DER_VIEW content, wrapped, signedData, ignored, contentInfo, indirect, digest, algorithm, oid;
+ if (!DomitoDerTake(&payload, 0x30, &content) ||
+ !DomitoDerOid(&content, signedDataOid, sizeof(signedDataOid)) ||
+ !DomitoDerTake(&content, 0xA0, &wrapped) || content.Length ||
+ !DomitoDerTake(&wrapped, 0x30, &signedData) || wrapped.Length ||
+ !DomitoDerTake(&signedData, 0x02, &ignored) ||
+ !DomitoDerTake(&signedData, 0x31, &ignored) ||
+ !DomitoDerTake(&signedData, 0x30, &contentInfo) ||
+ !DomitoDerOid(&contentInfo, indirectOid, sizeof(indirectOid)) ||
+ !DomitoDerTake(&contentInfo, 0xA0, &wrapped) || contentInfo.Length ||
+ !DomitoDerTake(&wrapped, 0x30, &indirect) || wrapped.Length ||
+ !DomitoDerTake(&indirect, 0x30, &ignored) ||
+ !DomitoDerTake(&indirect, 0x30, &digest) || indirect.Length ||
+ !DomitoDerTake(&digest, 0x30, &algorithm) ||
+ !DomitoDerTake(&algorithm, 0x06, &oid))
+ return STATUS_INVALID_IMAGE_FORMAT;
+ // Windows-signed entries can include up to seven zero alignment bytes in
+ // dwLength itself. Accept only that bounded, aligned padding after the DER.
+ if (payload.Length > 7 || (payload.Length && (length & 7)))
+ return STATUS_INVALID_IMAGE_FORMAT;
+ for (ULONG i = 0; i < payload.Length; ++i)
+ if (payload.Bytes[i])
+ return STATUS_INVALID_IMAGE_FORMAT;
+ if (algorithm.Length && (!DomitoDerTake(&algorithm, 0x05, &ignored) ||
+ ignored.Length || algorithm.Length))
+ return STATUS_INVALID_IMAGE_FORMAT;
+ UINT32 calg = 0;
+ ULONG expectedLength = 0;
+ if (oid.Length == sizeof(sha1Oid) && !memcmp(oid.Bytes, sha1Oid, sizeof(sha1Oid)))
+ {
+ calg = CALG_SHA1;
+ expectedLength = 20;
+ }
+ else if (oid.Length == sizeof(sha2Prefix) + 1 &&
+ !memcmp(oid.Bytes, sha2Prefix, sizeof(sha2Prefix)))
+ {
+ switch (oid.Bytes[sizeof(sha2Prefix)])
+ {
+ case 1: calg = CALG_SHA256; expectedLength = 32; break;
+ case 2: calg = CALG_SHA384; expectedLength = 48; break;
+ case 3: calg = CALG_SHA512; expectedLength = 64; break;
+ }
+ }
+ if (!calg)
+ return STATUS_NOT_SUPPORTED;
+ if (!DomitoDerTake(&digest, 0x04, &ignored) || digest.Length || ignored.Length != expectedLength)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ *DigestCalg = calg;
+ return STATUS_SUCCESS;
+}
+
+// Conservative section-layout boundary: contiguous raw sections (any header
+// order), optional overlay before an EOF certificate table. Gaps, overlaps, and
+// certificates in the middle are rejected rather than assigned an ambiguous hash.
+static NTSTATUS DomitoValidateAuthenticodeLayout(const UCHAR* Bytes, ULONG Size)
+{
+ if (!Bytes || Size < sizeof(IMAGE_DOS_HEADER))
+ return STATUS_INVALID_IMAGE_FORMAT;
+ IMAGE_DOS_HEADER dos;
+ memcpy(&dos, Bytes, sizeof(dos));
+ if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew < 0)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ const ULONG nt = (ULONG)dos.e_lfanew;
+ const ULONG prefix = sizeof(ULONG) + sizeof(IMAGE_FILE_HEADER);
+ if (nt > Size || prefix > Size - nt)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ ULONG signature;
+ IMAGE_FILE_HEADER file;
+ memcpy(&signature, Bytes + nt, sizeof(signature));
+ memcpy(&file, Bytes + nt + sizeof(signature), sizeof(file));
+ if (signature != IMAGE_NT_SIGNATURE || !file.NumberOfSections || file.NumberOfSections > 96)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ const ULONG optional = nt + prefix;
+ if (file.SizeOfOptionalHeader < 2 || file.SizeOfOptionalHeader > Size - optional)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ USHORT magic;
+ memcpy(&magic, Bytes + optional, sizeof(magic));
+ ULONG directory;
+ if (magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC)
+ directory = FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, DataDirectory);
+ else if (magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC)
+ directory = FIELD_OFFSET(IMAGE_OPTIONAL_HEADER64, DataDirectory);
+ else
+ return STATUS_INVALID_IMAGE_FORMAT;
+ if (file.SizeOfOptionalHeader < directory)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ ULONG count, headers;
+ memcpy(&count, Bytes + optional + directory - 4, sizeof(count));
+ memcpy(&headers, Bytes + optional + FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, SizeOfHeaders), sizeof(headers));
+ if (count > (file.SizeOfOptionalHeader - directory) / sizeof(IMAGE_DATA_DIRECTORY))
+ return STATUS_INVALID_IMAGE_FORMAT;
+ if (count <= IMAGE_DIRECTORY_ENTRY_SECURITY)
+ return STATUS_NOT_SUPPORTED; // SIP exclusion behavior differs for absent slots.
+ const ULONG table = optional + file.SizeOfOptionalHeader;
+ const ULONG tableSize = file.NumberOfSections * sizeof(IMAGE_SECTION_HEADER);
+ if (tableSize > Size - table || headers < table + tableSize || headers > Size)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ struct { ULONG Offset; ULONG Length; } sections[96];
+ ULONG used = 0;
+ for (ULONG i = 0; i < file.NumberOfSections; ++i)
+ {
+ IMAGE_SECTION_HEADER section;
+ memcpy(§ion, Bytes + table + i * sizeof(section), sizeof(section));
+ if (!section.SizeOfRawData)
+ continue;
+ if (section.PointerToRawData < headers || section.PointerToRawData > Size ||
+ section.SizeOfRawData > Size - section.PointerToRawData)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ ULONG at = used;
+ while (at && sections[at - 1].Offset > section.PointerToRawData)
+ {
+ sections[at] = sections[at - 1];
+ --at;
+ }
+ sections[at].Offset = section.PointerToRawData;
+ sections[at].Length = section.SizeOfRawData;
+ ++used;
+ }
+ ULONG end = headers;
+ for (ULONG i = 0; i < used; ++i)
+ {
+ if (sections[i].Offset != end)
+ return STATUS_NOT_SUPPORTED;
+ end += sections[i].Length;
+ }
+ if (count > IMAGE_DIRECTORY_ENTRY_SECURITY)
+ {
+ IMAGE_DATA_DIRECTORY security;
+ memcpy(&security, Bytes + optional + directory +
+ IMAGE_DIRECTORY_ENTRY_SECURITY * sizeof(security), sizeof(security));
+ if (security.VirtualAddress || security.Size)
+ {
+ if (!security.VirtualAddress || !security.Size || (security.VirtualAddress & 7) ||
+ security.VirtualAddress < end || security.VirtualAddress > Size ||
+ security.Size != Size - security.VirtualAddress)
+ return STATUS_NOT_SUPPORTED;
+ // Validate every aligned certificate entry, not just the first.
+ ULONG offset = security.VirtualAddress;
+ while (offset < Size)
+ {
+ if (Size - offset < 8)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ ULONG length;
+ USHORT revision, type;
+ memcpy(&length, Bytes + offset, 4);
+ memcpy(&revision, Bytes + offset + 4, 2);
+ memcpy(&type, Bytes + offset + 6, 2);
+ if (length <= 8 || length > Size - offset)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ if (revision != 0x0200 || type != WIN_CERT_TYPE_PKCS_SIGNED_DATA)
+ return STATUS_NOT_SUPPORTED;
+ const ULONG padding = (8 - (length & 7)) & 7;
+ if (padding > Size - offset - length)
+ return STATUS_INVALID_IMAGE_FORMAT;
+ offset += length + padding;
+ }
+ }
+ }
+ return STATUS_SUCCESS;
+}
+
+
_Success_(return == STATUS_SUCCESS)
_Must_inspect_result_
_IRQL_requires_max_(PASSIVE_LEVEL)
-NTSTATUS
-DomitoCalculatePortableExecutableDigest(
+static NTSTATUS
+DomitoCalculatePortableExecutableDigestInternal(
+ _In_ UINT32 ExplicitCalg,
_In_ PUCHAR pPeBytes,
_In_ ULONG PeSize,
_Out_ PUINT32 pDigestCalgOut,
@@ -231,7 +472,8 @@ DomitoCalculatePortableExecutableDigest(
BCRYPT_HASH_HANDLE hbHash = nullptr;
ULONG hashLength = 0;
ULONG resultLength = 0;
- const UINT32 peDigestKind = DomitoGetPortableExecutableDigestKind(pPeBytes, PeSize, directories);
+ const UINT32 peDigestKind = ExplicitCalg ? ExplicitCalg :
+ DomitoGetPortableExecutableDigestKind(pPeBytes, PeSize, directories);
NTSTATUS status = BCryptOpenAlgorithmProvider(
&hbAlg, DOMITO_CALG_TO_BCRYPT_ALGORITHM(peDigestKind), MS_PRIMITIVE_PROVIDER, 0);
if (!NT_SUCCESS(status))
@@ -315,6 +557,47 @@ cleanup:
}
+_Success_(return == STATUS_SUCCESS)
+_Must_inspect_result_
+_IRQL_requires_max_(PASSIVE_LEVEL)
+NTSTATUS
+DomitoCalculatePortableExecutableDigest(
+ PUCHAR PeBytes, ULONG PeSize, PUINT32 DigestCalg, PULONG DigestSize,
+ PVOID* Digest, LPWIN_CERTIFICATE* Certificate, PULONG SecuritySize)
+{
+ return DomitoCalculatePortableExecutableDigestInternal(0, PeBytes, PeSize,
+ DigestCalg, DigestSize, Digest, Certificate, SecuritySize);
+}
+
+_Success_(return == STATUS_SUCCESS)
+_Must_inspect_result_
+_IRQL_requires_max_(PASSIVE_LEVEL)
+NTSTATUS
+DomitoCalculatePortableExecutableDigestEx(
+ const UCHAR* PeBytes, ULONG PeSize, UINT32 HashAlgId, PULONG DigestSize,
+ PVOID* Digest, LPWIN_CERTIFICATE* Certificate, PULONG SecuritySize)
+{
+ if (!DigestSize || !Digest || !Certificate || !SecuritySize)
+ return STATUS_INVALID_PARAMETER;
+ *DigestSize = 0;
+ *Digest = nullptr;
+ *Certificate = nullptr;
+ *SecuritySize = 0;
+ if (!PeBytes)
+ return STATUS_INVALID_PARAMETER;
+ if (HashAlgId != CALG_SHA1 && HashAlgId != CALG_SHA256 &&
+ HashAlgId != CALG_SHA384 && HashAlgId != CALG_SHA512)
+ return STATUS_NOT_SUPPORTED;
+ NTSTATUS status = DomitoValidateAuthenticodeLayout(PeBytes, PeSize);
+ if (!NT_SUCCESS(status))
+ return status;
+ UINT32 actualCalg = 0;
+ // Contiguous sorted sections make the validated section sequence identical
+ // to linear byte order; overlay precedes an EOF certificate table.
+ return DomitoCalculatePortableExecutableDigestInternal(HashAlgId, (PUCHAR)PeBytes,
+ PeSize, &actualCalg, DigestSize, Digest, Certificate, SecuritySize);
+}
+
_IRQL_requires_max_(DISPATCH_LEVEL)
void
DomitoFreePortableExecutableDigest(
@@ -333,8 +616,8 @@ _Success_(return == STATUS_SUCCESS)
_Must_inspect_result_
_IRQL_requires_max_(PASSIVE_LEVEL)
#pragma code_seg("PAGED")
-NTSTATUS
-DomitoValidateFileLegacyMode(
+static NTSTATUS
+DomitoValidateFileLegacyModeInternal(
_In_ HANDLE FileHandle,
_In_ PVOID Hash,
_In_ UINT32 HashSize,
@@ -342,11 +625,20 @@ DomitoValidateFileLegacyMode(
_In_ const IMAGE_DATA_DIRECTORY * SecurityDirectory,
_Inout_ MINCRYPT_POLICY_INFO * PolicyInfo,
_Out_ LARGE_INTEGER * SigningTime,
- _Inout_ MINCRYPT_POLICY_INFO * TimeStampPolicyInfo
+ _Inout_ MINCRYPT_POLICY_INFO * TimeStampPolicyInfo,
+ _In_ UINT32 AcceptedCatalogPolicyBits,
+ _In_ BOOLEAN RequireTrustedCatalog,
+ _Out_ PULONG ValidationSource
)
{
PAGED_CODE();
+ if (!ValidationSource)
+ return STATUS_INVALID_PARAMETER;
+ *ValidationSource = 0;
+ if (!Hash || !SecurityDirectory || !PolicyInfo || !SigningTime ||
+ !TimeStampPolicyInfo || PolicyInfo == TimeStampPolicyInfo)
+ return STATUS_INVALID_PARAMETER;
NTSTATUS status = STATUS_SUCCESS;
PVOID certDirectory = nullptr;
KAPC_STATE systemContext = {};
@@ -358,15 +650,48 @@ DomitoValidateFileLegacyMode(
CiFreePolicyInfo(PolicyInfo);
CiFreePolicyInfo(TimeStampPolicyInfo);
- if (HashSize != MINCRYPT_SHA1_LENGTH && HashSize != MINCRYPT_SHA256_LENGTH)
+ if (HashAlgId != CALG_SHA1 && HashAlgId != CALG_SHA256)
{
- status = STATUS_INVALID_IMAGE_HASH;
+ status = STATUS_NOT_SUPPORTED;
+ break;
+ }
+ if (HashSize != (UINT32)(HashAlgId == CALG_SHA1 ? MINCRYPT_SHA1_LENGTH : MINCRYPT_SHA256_LENGTH))
+ {
+ status = STATUS_INVALID_PARAMETER;
+ break;
+ }
+ if ((SecurityDirectory->Size != 0) != (SecurityDirectory->VirtualAddress != 0))
+ {
+ status = STATUS_INVALID_IMAGE_FORMAT;
break;
}
if (SecurityDirectory->Size != 0u &&
SecurityDirectory->VirtualAddress != 0u)
{
+ if ((SecurityDirectory->VirtualAddress & 7) || SecurityDirectory->Size < 12)
+ {
+ status = STATUS_INVALID_IMAGE_FORMAT;
+ break;
+ }
+ // Bound the allocation/read against the file before trusting its table.
+ PFILE_OBJECT fileObject = nullptr;
+ status = ObReferenceObjectByHandle(FileHandle, FILE_READ_DATA, *IoFileObjectType,
+ KernelMode, (PVOID*)&fileObject, nullptr);
+ if (!NT_SUCCESS(status))
+ break;
+ LARGE_INTEGER fileSize = {};
+ status = FsRtlGetFileSize(fileObject, &fileSize);
+ ObDereferenceObject(fileObject);
+ if (!NT_SUCCESS(status))
+ break;
+ if (fileSize.QuadPart < 0 ||
+ (ULONGLONG)SecurityDirectory->VirtualAddress > (ULONGLONG)fileSize.QuadPart ||
+ SecurityDirectory->Size > (ULONGLONG)fileSize.QuadPart - SecurityDirectory->VirtualAddress)
+ {
+ status = STATUS_INVALID_IMAGE_FORMAT;
+ break;
+ }
#pragma warning(disable:4996)
certDirectory = ExAllocatePoolWithTag(
PagedPool,
@@ -381,44 +706,10 @@ DomitoValidateFileLegacyMode(
}
LARGE_INTEGER offset = {};
- IO_STATUS_BLOCK ioStatusBlock = {};
-
- offset.LowPart = SecurityDirectory->VirtualAddress;
-
- status = ZwReadFile(
- FileHandle,
- NULL,
- NULL,
- NULL,
- &ioStatusBlock,
- certDirectory,
- SecurityDirectory->Size,
- &offset,
- NULL
- );
-
- if (status == STATUS_PENDING)
- {
- ZwWaitForSingleObject(
- FileHandle,
- FALSE,
- NULL
- );
-
- MemoryBarrier();
- status = ioStatusBlock.Status;
- }
-
+ offset.QuadPart = SecurityDirectory->VirtualAddress;
+ status = DomitoReadFileAt(FileHandle, certDirectory, SecurityDirectory->Size, &offset);
if (!NT_SUCCESS(status))
- {
break;
- }
-
- if (ioStatusBlock.Information != SecurityDirectory->Size)
- {
- status = STATUS_END_OF_FILE;
- break;
- }
KeStackAttachProcess(PsInitialSystemProcess, &systemContext);
{
@@ -437,6 +728,7 @@ DomitoValidateFileLegacyMode(
if (NT_SUCCESS(status))
{
+ *ValidationSource = DOMITO_CI_SOURCE_EMBEDDED;
break;
}
@@ -446,6 +738,9 @@ DomitoValidateFileLegacyMode(
}
}
+ CiFreePolicyInfo(PolicyInfo);
+ CiFreePolicyInfo(TimeStampPolicyInfo);
+ SigningTime->QuadPart = 0;
KeStackAttachProcess(PsInitialSystemProcess, &systemContext);
{
status = CiVerifyHashInCatalog(
@@ -453,8 +748,8 @@ DomitoValidateFileLegacyMode(
HashSize,
HashAlgId,
FALSE,
- 0,
- 0x2007F,
+ RequireTrustedCatalog,
+ AcceptedCatalogPolicyBits,
PolicyInfo,
NULL,
SigningTime,
@@ -463,13 +758,16 @@ DomitoValidateFileLegacyMode(
if (status == STATUS_INVALID_IMAGE_HASH)
{
+ CiFreePolicyInfo(PolicyInfo);
+ CiFreePolicyInfo(TimeStampPolicyInfo);
+ SigningTime->QuadPart = 0;
status = CiVerifyHashInCatalog(
Hash,
HashSize,
HashAlgId,
TRUE,
- 0,
- 0x2007F,
+ RequireTrustedCatalog,
+ AcceptedCatalogPolicyBits,
PolicyInfo,
NULL,
SigningTime,
@@ -478,6 +776,8 @@ DomitoValidateFileLegacyMode(
}
}
KeUnstackDetachProcess(&systemContext);
+ if (NT_SUCCESS(status))
+ *ValidationSource = DOMITO_CI_SOURCE_CATALOG;
} while (FALSE);
@@ -488,4 +788,34 @@ DomitoValidateFileLegacyMode(
return status;
}
+
+_Success_(return == STATUS_SUCCESS)
+_Must_inspect_result_
+_IRQL_requires_max_(PASSIVE_LEVEL)
+NTSTATUS
+DomitoValidateFileLegacyMode(
+ HANDLE FileHandle, PVOID Hash, UINT32 HashSize, ALG_ID HashAlgId,
+ const IMAGE_DATA_DIRECTORY* SecurityDirectory, MINCRYPT_POLICY_INFO* PolicyInfo,
+ LARGE_INTEGER* SigningTime, MINCRYPT_POLICY_INFO* TimeStampPolicyInfo)
+{
+ ULONG source;
+ return DomitoValidateFileLegacyModeInternal(FileHandle, Hash, HashSize, HashAlgId,
+ SecurityDirectory, PolicyInfo, SigningTime, TimeStampPolicyInfo,
+ DOMITO_CI_LEGACY_CATALOG_POLICY_BITS, FALSE, &source);
+}
+
+_Success_(return == STATUS_SUCCESS)
+_Must_inspect_result_
+_IRQL_requires_max_(PASSIVE_LEVEL)
+NTSTATUS
+DomitoValidateFileLegacyModeEx(
+ HANDLE FileHandle, PVOID Hash, UINT32 HashSize, ALG_ID HashAlgId,
+ const IMAGE_DATA_DIRECTORY* SecurityDirectory, MINCRYPT_POLICY_INFO* PolicyInfo,
+ LARGE_INTEGER* SigningTime, MINCRYPT_POLICY_INFO* TimeStampPolicyInfo,
+ UINT32 AcceptedCatalogPolicyBits, BOOLEAN RequireTrustedCatalog, PULONG ValidationSource)
+{
+ return DomitoValidateFileLegacyModeInternal(FileHandle, Hash, HashSize, HashAlgId,
+ SecurityDirectory, PolicyInfo, SigningTime, TimeStampPolicyInfo,
+ AcceptedCatalogPolicyBits, RequireTrustedCatalog, ValidationSource);
+}
#pragma code_seg()
diff --git a/src/Domito.cpp b/src/Domito.cpp
index 41c9dab..1ea20e4 100644
--- a/src/Domito.cpp
+++ b/src/Domito.cpp
@@ -129,6 +129,13 @@ DomitoFindModuleBaseAddress(
return status;
}
+// This helper handles directory RVAs after the loader-mapped header establishes
+// SizeOfImage. ModuleBase is not an arbitrary file buffer (there is no size input).
+static BOOLEAN DomitoImageRvaSpan(ULONG ImageSize, ULONG Rva, ULONG Count, ULONG ElementSize)
+{
+ return Rva <= ImageSize && ElementSize != 0 && Count <= (ImageSize - Rva) / ElementSize;
+}
+
_Success_(return == STATUS_SUCCESS)
_Must_inspect_result_
_IRQL_requires_max_(PASSIVE_LEVEL)
@@ -139,56 +146,113 @@ DomitoFindExportedFunctionAddress(
_Inout_opt_ PVOID * FunctionAddress
)
{
- NTSTATUS status = STATUS_NOT_FOUND;
- ULONG exportSize;
-
- if (G_Common.RtlImageDirectoryEntryToData == NULL)
+ if (FunctionAddress)
+ {
+ *FunctionAddress = nullptr;
+ }
+ if (!ModuleBase || !FunctionName || !FunctionName->Buffer || !FunctionName->Length)
+ {
+ return STATUS_INVALID_PARAMETER;
+ }
+ if (!G_Common.RtlImageDirectoryEntryToData)
{
return STATUS_NOT_IMPLEMENTED;
}
- // Retrieve the export directory information
- const PIMAGE_EXPORT_DIRECTORY exportDirectory = (PIMAGE_EXPORT_DIRECTORY)G_Common.RtlImageDirectoryEntryToData(
- ModuleBase,
- TRUE,
- IMAGE_DIRECTORY_ENTRY_EXPORT,
- &exportSize
- );
-
- if (exportDirectory == NULL)
+ // Initial headers belong to a valid loaded module, as required by this API.
+ const PUCHAR base = (PUCHAR)ModuleBase;
+ IMAGE_DOS_HEADER dos;
+ memcpy(&dos, base, sizeof(dos));
+ if (dos.e_magic != IMAGE_DOS_SIGNATURE || dos.e_lfanew < (LONG)sizeof(dos))
+ {
+ return STATUS_INVALID_IMAGE_FORMAT;
+ }
+ const PUCHAR nt = base + dos.e_lfanew;
+ ULONG signature;
+ memcpy(&signature, nt, sizeof(signature));
+ USHORT magic;
+ memcpy(&magic, nt + sizeof(ULONG) + sizeof(IMAGE_FILE_HEADER), sizeof(magic));
+ if (signature != IMAGE_NT_SIGNATURE ||
+ (magic != IMAGE_NT_OPTIONAL_HDR32_MAGIC && magic != IMAGE_NT_OPTIONAL_HDR64_MAGIC))
+ {
+ return STATUS_INVALID_IMAGE_FORMAT;
+ }
+ // SizeOfImage is at the same offset in PE32 and PE32+ optional headers.
+ ULONG imageSize;
+ memcpy(&imageSize, nt + sizeof(ULONG) + sizeof(IMAGE_FILE_HEADER) +
+ FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, SizeOfImage), sizeof(imageSize));
+ if (!DomitoImageRvaSpan(imageSize, (ULONG)dos.e_lfanew,
+ sizeof(ULONG) + sizeof(IMAGE_FILE_HEADER) +
+ FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, SizeOfImage) + sizeof(imageSize), 1))
{
return STATUS_INVALID_IMAGE_FORMAT;
}
- STRING currentFunctionName;
-
- const PULONG functionAddresses = (PULONG)((ULONG_PTR)ModuleBase + exportDirectory->AddressOfFunctions);
- const PULONG functionNames = (PULONG)((ULONG_PTR)ModuleBase + exportDirectory->AddressOfNames);
- const PUSHORT functionOrdinals = (PUSHORT)((ULONG_PTR)ModuleBase + exportDirectory->AddressOfNameOrdinals);
-
- for (ULONG i = 0; i < exportDirectory->NumberOfNames; i++)
+ ULONG exportSize = 0;
+ const PVOID directory = G_Common.RtlImageDirectoryEntryToData(
+ ModuleBase, TRUE, IMAGE_DIRECTORY_ENTRY_EXPORT, &exportSize);
+ const ULONG_PTR directoryOffset = (ULONG_PTR)directory - (ULONG_PTR)ModuleBase;
+ if (!directory || directoryOffset > imageSize || exportSize < sizeof(IMAGE_EXPORT_DIRECTORY) ||
+ exportSize > imageSize - directoryOffset)
{
- const char* functionName = (const char*)((ULONG_PTR)ModuleBase + functionNames[i]);
- const USHORT functionOrdinal = functionOrdinals[i];
- UNREFERENCED_PARAMETER(functionOrdinal);
-
- const ULONG functionRva = functionAddresses[i];
- const PVOID functionAddress = (PVOID)((ULONG_PTR)ModuleBase + functionRva);
-
- RtlInitAnsiString(¤tFunctionName, functionName);
-
- if (0 == RtlCompareString(FunctionName, ¤tFunctionName, TRUE))
- {
- if (FunctionAddress)
- {
- status = STATUS_SUCCESS;
- *FunctionAddress = functionAddress;
- }
- break;
- }
+ return STATUS_INVALID_IMAGE_FORMAT;
}
-
- return status;
+ IMAGE_EXPORT_DIRECTORY exports;
+ memcpy(&exports, directory, sizeof(exports));
+ if (!DomitoImageRvaSpan(imageSize, exports.AddressOfFunctions, exports.NumberOfFunctions, sizeof(ULONG)) ||
+ !DomitoImageRvaSpan(imageSize, exports.AddressOfNames, exports.NumberOfNames, sizeof(ULONG)) ||
+ !DomitoImageRvaSpan(imageSize, exports.AddressOfNameOrdinals, exports.NumberOfNames, sizeof(USHORT)))
+ {
+ return STATUS_INVALID_IMAGE_FORMAT;
+ }
+ for (ULONG i = 0; i < exports.NumberOfNames; ++i)
+ {
+ ULONG nameRva;
+ USHORT ordinal;
+ memcpy(&nameRva, base + exports.AddressOfNames + i * sizeof(ULONG), sizeof(nameRva));
+ memcpy(&ordinal, base + exports.AddressOfNameOrdinals + i * sizeof(USHORT), sizeof(ordinal));
+ if (nameRva >= imageSize || ordinal >= exports.NumberOfFunctions)
+ {
+ return STATUS_INVALID_IMAGE_FORMAT;
+ }
+ ULONG length = 0;
+ while (length < imageSize - nameRva && base[nameRva + length] != 0)
+ {
+ ++length;
+ }
+ if (length == imageSize - nameRva || length > MAXUSHORT)
+ {
+ return STATUS_INVALID_IMAGE_FORMAT;
+ }
+ ANSI_STRING current;
+ current.Buffer = (PCHAR)(base + nameRva);
+ current.Length = (USHORT)length;
+ current.MaximumLength = (USHORT)length;
+ if (RtlCompareString(FunctionName, ¤t, TRUE) != 0)
+ {
+ continue;
+ }
+ ULONG functionRva;
+ memcpy(&functionRva, base + exports.AddressOfFunctions + ordinal * sizeof(ULONG), sizeof(functionRva));
+ if (!functionRva)
+ {
+ return STATUS_NOT_FOUND; // Empty EAT slot.
+ }
+ if (functionRva >= imageSize)
+ {
+ return STATUS_INVALID_IMAGE_FORMAT;
+ }
+ if (functionRva >= directoryOffset && functionRva - directoryOffset < exportSize)
+ {
+ return STATUS_NOT_SUPPORTED; // Forwarder string, not a callable address.
+ }
+ if (FunctionAddress)
+ {
+ *FunctionAddress = base + functionRva;
+ }
+ return STATUS_SUCCESS;
+ }
+ return STATUS_NOT_FOUND;
}
_Success_(return == STATUS_SUCCESS)
@@ -240,6 +304,68 @@ DomitoMemorySearchPattern(
return STATUS_NOT_FOUND;
}
+_Success_(return == STATUS_SUCCESS)
+_Must_inspect_result_
+_IRQL_requires_max_(PASSIVE_LEVEL)
+NTSTATUS
+DomitoReadFileAt(
+ _In_ HANDLE FileHandle,
+ _Out_ PVOID Buffer,
+ _In_ ULONG BufferSize,
+ _In_ const LARGE_INTEGER* ByteOffset
+)
+{
+ if (!FileHandle || !ByteOffset || ByteOffset->QuadPart < 0 || (!Buffer && BufferSize))
+ {
+ return STATUS_INVALID_PARAMETER;
+ }
+ if (!BufferSize)
+ {
+ return STATUS_SUCCESS;
+ }
+
+ HANDLE eventHandle = nullptr;
+ OBJECT_ATTRIBUTES attributes;
+ InitializeObjectAttributes(&attributes, nullptr, OBJ_KERNEL_HANDLE, nullptr, nullptr);
+ NTSTATUS status = ZwCreateEvent(&eventHandle, EVENT_ALL_ACCESS, &attributes,
+ NotificationEvent, FALSE);
+ if (!NT_SUCCESS(status))
+ {
+ return status;
+ }
+ PVOID eventObject = nullptr;
+ status = ObReferenceObjectByHandle(eventHandle, SYNCHRONIZE, *ExEventObjectType,
+ KernelMode, &eventObject, nullptr);
+ if (!NT_SUCCESS(status))
+ {
+ ZwClose(eventHandle);
+ return status;
+ }
+
+ IO_STATUS_BLOCK ioStatusBlock = {};
+ LARGE_INTEGER offset = *ByteOffset;
+ status = ZwReadFile(FileHandle, eventHandle, nullptr, nullptr, &ioStatusBlock,
+ Buffer, BufferSize, &offset, nullptr);
+ if (status == STATUS_PENDING)
+ {
+ // A private event identifies this request even if the file has other I/O.
+ // KernelMode + nonalertable + no timeout cannot return before completion.
+ KeWaitForSingleObject(eventObject, Executive, KernelMode, FALSE, nullptr);
+ status = ioStatusBlock.Status;
+ }
+ if (NT_SUCCESS(status))
+ {
+ status = ioStatusBlock.Status;
+ if (NT_SUCCESS(status) && ioStatusBlock.Information != BufferSize)
+ {
+ status = STATUS_END_OF_FILE;
+ }
+ }
+ ObDereferenceObject(eventObject);
+ ZwClose(eventHandle);
+ return status;
+}
+
_Success_(return == STATUS_SUCCESS)
_Must_inspect_result_
_IRQL_requires_max_(PASSIVE_LEVEL)
@@ -250,33 +376,8 @@ DomitoReadFile(
_In_ ULONG BufferSize
)
{
- NTSTATUS status = STATUS_SUCCESS;
- IO_STATUS_BLOCK ioStatusBlock;
-
- // Read the file into memory using ZwReadFile
- if (!NT_SUCCESS(status = ZwReadFile(
- FileHandle,
- NULL,
- NULL,
- NULL,
- &ioStatusBlock,
- Buffer,
- BufferSize,
- NULL,
- NULL
- )))
- {
- return status;
- }
-
- // Check if the file was read successfully
- if (!NT_SUCCESS(ioStatusBlock.Status))
- {
- return ioStatusBlock.Status;
- }
-
- // File read successfully
- return status;
+ const LARGE_INTEGER offset = {};
+ return DomitoReadFileAt(FileHandle, Buffer, BufferSize, &offset);
}
_Success_(return == STATUS_SUCCESS)
@@ -306,7 +407,7 @@ DomitoGetProcessImageName(
}
CLIENT_ID cid;
- cid.UniqueProcess = (HANDLE)ProcessId;
+ cid.UniqueProcess = ULongToHandle(ProcessId);
cid.UniqueThread = NULL;
OBJECT_ATTRIBUTES objAttr;
diff --git a/src/Domito.vcxproj b/src/Domito.vcxproj
index 760de2e..83e45d4 100644
--- a/src/Domito.vcxproj
+++ b/src/Domito.vcxproj
@@ -127,6 +127,7 @@
+
diff --git a/src/Domito.vcxproj.filters b/src/Domito.vcxproj.filters
index 1b7ac8a..08dd0ce 100644
--- a/src/Domito.vcxproj.filters
+++ b/src/Domito.vcxproj.filters
@@ -39,6 +39,9 @@
Header Files\Public
+
+ Header Files\Public
+
Header Files\Public
diff --git a/tests/README.md b/tests/README.md
index 9888878..e639c3f 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -1,30 +1,62 @@
-# Code Integrity regression tests
+# Regression tests
From an **x64 Visual Studio developer shell**, with the Windows SDK and Python 3:
```powershell
python tests\run-digest-tests.py
+python tests\run-common-tests.py
+python tests\run-ci-probe-tests.py
```
-The runner extracts the production digest, pattern-search, algorithm-mapping,
-and legacy-validation functions without changing their bodies. The C++ harness
-uses real user-mode BCrypt hashing and test doubles for pool allocation, file
-reads, and CI. No driver is installed or loaded. Build outputs are temporary.
+Build outputs are temporary. No driver is installed or loaded. Test runners
+extract the actual production functions or include the probe body unchanged;
+kernel calls, CI calls, and pool allocation are replaced with test doubles.
+The digest harness uses real BCrypt, Crypt32 and the Windows PE SIP provider.
-Coverage includes PE32/PE32+ digest fixtures, checksum and certificate exclusions,
-a DOS stub larger than 4 MiB, truncated headers next to inaccessible guard pages,
-invalid offsets and lengths, absent security-directory entries, injected hashing
-and allocation failures, deterministic failure outputs, allocation ownership,
-and rejection of successful-but-short certificate reads before invoking CI.
+## Digest and legacy verification tests
-The tests do not validate CI trust policy, asynchronous kernel I/O behavior, or
-general Authenticode hashing of unusual section layouts. The digest implementation
-still uses its existing linear byte-order convention and heuristic signature
-algorithm detection; replacing those requires a separate compatibility corpus.
+Coverage includes PE32/PE32+ files, all four supported hash algorithms, checksum
+and certificate exclusions, overlays, reverse section-header order, multiple
+certificate entries, certificate padding, unsupported section layouts, bounded
+DER algorithm discovery, SHA-1 pattern decoys, truncated ASN.1, explicit catalog
+policy forwarding and cleanup between failed/successful CI attempts.
+
+The test compares digests against `CryptSIPCreateIndirectData` and reads the
+installed system `ci.dll`, `ntdll.dll`, `kernel32.dll`, and `notepad.exe` as a
+real-file corpus. For embedded signatures, Crypt32 independently decodes the SPC
+algorithm and expected file digest. These checks do not verify signature trust.
+
+Guard pages cover truncated headers and invalid ranges in both compatibility
+and strict paths. Fault injection covers allocation/hash failures, deterministic
+outputs, short certificate reads and digest ownership. A DOS stub larger than
+4 MiB exercises the removed scratch-buffer limit. Older entry points retain
+legacy selection/layout behavior; strict Ex APIs enforce their documented subset.
+
+## File and export helper tests
+
+The common harness checks explicit and zero offsets, exact/short/failed reads,
+pending completion, event/object cleanup, and error propagation. Export fixtures
+cover nonidentity name-ordinal mappings, forwarders, empty slots, invalid spans,
+unbounded strings, and deterministic failure outputs. The export API requires a
+valid loader-mapped module; it is not an arbitrary file parser.
## File-object validation flag probe
See [the flag documentation and VM procedure](../docs/ci-validate-file-object.md)
-for `CiValidateFileObject` flag `0x20`. Run `python tests\run-ci-probe-tests.py`
-from the same developer shell to test probe plumbing with mock CI. The kernel
-probe must still be run in a suitable VM to verify actual policy behavior.
+for `CiValidateFileObject` flag `0x20`. The host runner tests probe plumbing with
+mock CI. The kernel probe must still run in a suitable VM to verify actual
+policy behavior.
+
+## Kernel compile checks and remaining validation
+
+`mincrypt-layout-tests.cpp` asserts default modern x64/ARM64 and pre-Windows-10
+compile layouts. On x64 it also includes the opt-in observed 26100 header's 14
+layout assertions. Compile it with the same WDK/kernel architecture flags and
+include paths as the library. This checks structure definitions, not runtime
+compatibility on those systems. `ci-file-object-probe.cpp` is separately compiled
+as kernel code for x64/ARM64; its real CI behavior is still untested.
+
+The host tests do not establish CI trust policy, real asynchronous kernel I/O,
+real policy ownership under Driver Verifier, revocation/timestamp behavior, or
+all Windows-version/layout combinations. See the
+[implementation ledger](../docs/implementation-status.md) for the remaining work.
diff --git a/tests/common-tests.cpp b/tests/common-tests.cpp
new file mode 100644
index 0000000..25c2c64
--- /dev/null
+++ b/tests/common-tests.cpp
@@ -0,0 +1,152 @@
+// Tests extract the unchanged production bodies. Kernel I/O is simulated.
+#define WIN32_NO_STATUS
+#include
+#undef WIN32_NO_STATUS
+#include
+#include
+#include
+#include
+#include
+#include
+#define NT_SUCCESS(s) (((NTSTATUS)(s)) >= 0)
+#define MAXUSHORT 0xffff
+#define Executive 0
+#define KernelMode 0
+#define NotificationEvent 0
+#define OBJ_KERNEL_HANDLE 0x200
+#define InitializeObjectAttributes(a,n,f,r,s) ((a)->Attributes=(f))
+struct OBJECT_ATTRIBUTES { ULONG Attributes; };
+struct ANSI_STRING { USHORT Length, MaximumLength; PCHAR Buffer; };
+typedef ANSI_STRING* PANSI_STRING;
+struct IO_STATUS_BLOCK { NTSTATUS Status; ULONG_PTR Information; };
+static int objectType;
+static void* objectTypeValue = &objectType;
+static void** ExEventObjectType = &objectTypeValue;
+static unsigned checks;
+static void Check(bool value, const char* label) {
+ ++checks;
+ if (!value) { std::fprintf(stderr, "FAIL: %s\n", label); std::exit(1); }
+}
+static LONG RtlCompareString(PANSI_STRING a, PANSI_STRING b, BOOLEAN insensitive) {
+ if (a->Length != b->Length) return 1;
+ for (USHORT i = 0; i < a->Length; ++i) {
+ unsigned char x = (unsigned char)a->Buffer[i], y = (unsigned char)b->Buffer[i];
+ if (insensitive) { x = (unsigned char)std::tolower(x); y = (unsigned char)std::tolower(y); }
+ if (x != y) return 1;
+ }
+ return 0;
+}
+static void* exportPointer;
+static ULONG exportBytes;
+static PVOID Directory(PVOID, BOOLEAN mapped, USHORT index, PULONG bytes) {
+ Check(mapped && index == IMAGE_DIRECTORY_ENTRY_EXPORT, "mapped export lookup");
+ *bytes = exportBytes; return exportPointer;
+}
+struct { PVOID (*RtlImageDirectoryEntryToData)(PVOID, BOOLEAN, USHORT, PULONG); } G_Common = { Directory };
+static int eventToken, fileToken;
+static unsigned created, referenced, closed, dereferenced, reads, waits;
+static int ioMode;
+static bool outstanding;
+static IO_STATUS_BLOCK* pendingIosb;
+static ULONG requested;
+static LONGLONG expectedOffset;
+static NTSTATUS ZwCreateEvent(HANDLE* event, ACCESS_MASK access, OBJECT_ATTRIBUTES* attributes, int type, BOOLEAN signaled) {
+ Check(access == EVENT_ALL_ACCESS && attributes->Attributes == OBJ_KERNEL_HANDLE && type == NotificationEvent && !signaled, "private kernel event");
+ if (ioMode == 4) return STATUS_INSUFFICIENT_RESOURCES;
+ ++created; *event = &eventToken; return STATUS_SUCCESS;
+}
+static NTSTATUS ObReferenceObjectByHandle(HANDLE event, ACCESS_MASK access, void* type, int mode, void** object, void*) {
+ Check(event == &eventToken && access == SYNCHRONIZE && type == *ExEventObjectType && mode == KernelMode, "reference own event");
+ if (ioMode == 5) return STATUS_ACCESS_DENIED;
+ ++referenced; *object = &eventToken; return STATUS_SUCCESS;
+}
+static void ZwClose(HANDLE event) {
+ Check(event == &eventToken && !outstanding, "close only after completion"); ++closed;
+}
+static void ObDereferenceObject(void* object) {
+ Check(object == &eventToken && !outstanding, "dereference only after completion"); ++dereferenced;
+}
+static NTSTATUS ZwReadFile(HANDLE file, HANDLE event, void*, void*, IO_STATUS_BLOCK* iosb, void*, ULONG size, LARGE_INTEGER* offset, void*) {
+ ++reads;
+ Check(file == &fileToken && event == &eventToken && offset && offset->QuadPart == expectedOffset, "file and explicit offset");
+ Check(iosb->Status == 0 && iosb->Information == 0, "initialized IOSB");
+ requested = size;
+ if (ioMode == 6) return STATUS_ACCESS_DENIED;
+ if (ioMode == 1 || ioMode == 2) { outstanding = true; pendingIosb = iosb; return STATUS_PENDING; }
+ iosb->Status = STATUS_SUCCESS; iosb->Information = ioMode == 3 ? size - 1 : size;
+ return STATUS_SUCCESS;
+}
+static NTSTATUS KeWaitForSingleObject(void* object, int reason, int mode, BOOLEAN alertable, LARGE_INTEGER* timeout) {
+ ++waits;
+ Check(object == &eventToken && reason == Executive && mode == KernelMode && !alertable && !timeout && outstanding, "noninterruptible per-request wait");
+ outstanding = false;
+ pendingIosb->Status = ioMode == 2 ? STATUS_END_OF_FILE : STATUS_SUCCESS;
+ pendingIosb->Information = ioMode == 2 ? 0 : requested;
+ return STATUS_SUCCESS;
+}
+#include "common-under-test.inc"
+static std::vector Image() {
+ std::vector image(4096);
+ auto dos = (IMAGE_DOS_HEADER*)image.data(); dos->e_magic = IMAGE_DOS_SIGNATURE; dos->e_lfanew = 128;
+ auto nt = (IMAGE_NT_HEADERS64*)(image.data() + 128); nt->Signature = IMAGE_NT_SIGNATURE;
+ nt->OptionalHeader.Magic = IMAGE_NT_OPTIONAL_HDR64_MAGIC; nt->OptionalHeader.SizeOfImage = (ULONG)image.size();
+ auto exports = (IMAGE_EXPORT_DIRECTORY*)(image.data() + 512);
+ exports->NumberOfFunctions = 3; exports->NumberOfNames = 2;
+ exports->AddressOfFunctions = 801; exports->AddressOfNames = 901; exports->AddressOfNameOrdinals = 1001;
+ const ULONG functions[] = {2000, 2100, 2200}, names[] = {1100, 1120};
+ const USHORT ordinals[] = {2, 0};
+ std::memcpy(image.data()+801, functions, sizeof(functions)); std::memcpy(image.data()+901, names, sizeof(names));
+ std::memcpy(image.data()+1001, ordinals, sizeof(ordinals));
+ std::memcpy(image.data()+1100, "Beta", 5); std::memcpy(image.data()+1120, "Alpha", 6);
+ return image;
+}
+static NTSTATUS Lookup(std::vector& image, const char* name, PVOID* output) {
+ ANSI_STRING string = {(USHORT)std::strlen(name), (USHORT)std::strlen(name), (PCHAR)name};
+ exportPointer = image.data() + 512; exportBytes = 256;
+ return DomitoFindExportedFunctionAddress(image.data(), &string, output);
+}
+int main() {
+ auto image = Image(); PVOID output = nullptr;
+ Check(Lookup(image,"Beta",&output) == STATUS_SUCCESS && output == image.data()+2200, "ordinal-indexed EAT");
+ Check(Lookup(image,"aLpHa",&output) == STATUS_SUCCESS && output == image.data()+2000, "case-insensitive name");
+ Check(Lookup(image,"Alpha",nullptr) == STATUS_SUCCESS, "optional output discovery");
+ Check(Lookup(image,"missing",&output) == STATUS_NOT_FOUND && !output, "notfound clears output");
+ auto exports = (IMAGE_EXPORT_DIRECTORY*)(image.data()+512);
+ const USHORT invalidOrdinal = 3; std::memcpy(image.data()+1001,&invalidOrdinal,2);
+ Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT && !output, "ordinal bounded");
+ image=Image(); const ULONG forwarder=600; std::memcpy(image.data()+801+8,&forwarder,4);
+ Check(Lookup(image,"Beta",&output) == STATUS_NOT_SUPPORTED && !output, "forwarder rejected");
+ image=Image(); const ULONG zero=0; std::memcpy(image.data()+801+8,&zero,4);
+ Check(Lookup(image,"Beta",&output) == STATUS_NOT_FOUND, "empty EAT slot");
+ image=Image(); const ULONG outside=4096; std::memcpy(image.data()+801+8,&outside,4);
+ Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "function RVA bounded");
+ image=Image(); exports=(IMAGE_EXPORT_DIRECTORY*)(image.data()+512); exports->NumberOfNames=0xffffffff;
+ Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "table multiplication bounded");
+ image=Image(); exports=(IMAGE_EXPORT_DIRECTORY*)(image.data()+512); exports->AddressOfFunctions=4090;
+ Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "EAT span bounded");
+ image=Image(); const ULONG lateName=4094; std::memcpy(image.data()+901,&lateName,4); image[4094]='x';image[4095]='x';
+ Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "unterminated name bounded");
+ image=Image(); std::memcpy(image.data()+901,&outside,4);
+ Check(Lookup(image,"Beta",&output) == STATUS_INVALID_IMAGE_FORMAT, "name RVA bounded");
+ image=Image(); ANSI_STRING name={4,4,(PCHAR)"Beta"}; exportPointer=image.data()+512; exportBytes=4096;
+ Check(DomitoFindExportedFunctionAddress(image.data(),&name,&output)==STATUS_INVALID_IMAGE_FORMAT, "export directory span bounded");
+ output=(void*)1; Check(DomitoFindExportedFunctionAddress(nullptr,&name,&output)==STATUS_INVALID_PARAMETER && !output,"invalid arguments clear output");
+ char buffer[32];
+ for (ioMode=0;ioMode<=6;++ioMode) {
+ created=referenced=closed=dereferenced=reads=waits=0; outstanding=false; expectedOffset=123;
+ LARGE_INTEGER offset;offset.QuadPart=expectedOffset;
+ NTSTATUS status=DomitoReadFileAt(&fileToken,buffer,sizeof(buffer),&offset);
+ const NTSTATUS expected[]={STATUS_SUCCESS,STATUS_SUCCESS,STATUS_END_OF_FILE,STATUS_END_OF_FILE,STATUS_INSUFFICIENT_RESOURCES,STATUS_ACCESS_DENIED,STATUS_ACCESS_DENIED};
+ Check(status==expected[ioMode],"read status and exact-byte contract");
+ Check(created==closed && referenced==dereferenced && !outstanding,"event lifetime balanced");
+ Check(waits==((ioMode==1||ioMode==2)?1u:0u),"wait pending only");
+ }
+ ioMode=0; expectedOffset=0; Check(DomitoReadFile(&fileToken,buffer,sizeof(buffer))==STATUS_SUCCESS,"zero-offset wrapper");
+ LARGE_INTEGER offset={}; offset.QuadPart=-1;
+ Check(DomitoReadFileAt(&fileToken,buffer,sizeof(buffer),&offset)==STATUS_INVALID_PARAMETER,"negative offset rejected");
+ Check(DomitoReadFileAt(&fileToken,buffer,sizeof(buffer),nullptr)==STATUS_INVALID_PARAMETER,"null offset rejected");
+ offset.QuadPart=0;
+ Check(DomitoReadFileAt(&fileToken,nullptr,0,&offset)==STATUS_SUCCESS,"zero byte read");
+ Check(DomitoReadFileAt(&fileToken,nullptr,1,&offset)==STATUS_INVALID_PARAMETER,"null buffer rejected");
+ std::printf("PASS: %u common-helper checks (mock kernel I/O)\n", checks);
+}
diff --git a/tests/digest-tests.cpp b/tests/digest-tests.cpp
index 1a85994..227db68 100644
--- a/tests/digest-tests.cpp
+++ b/tests/digest-tests.cpp
@@ -6,10 +6,12 @@
#include
#include
#include
+#include
#include
#include
#include
#include
+#include
#define NT_SUCCESS(s) (((NTSTATUS)(s)) >= 0)
#define ASSERT(x) Check(!!(x), #x)
@@ -19,11 +21,21 @@
#define PAGED_CODE() ((void)0)
#define MINCRYPT_SHA1_LENGTH 20
#define MINCRYPT_SHA256_LENGTH 32
+#define DOMITO_CI_LEGACY_CATALOG_POLICY_BITS 0x0002007FUL
+#define DOMITO_CI_SOURCE_EMBEDDED 1UL
+#define DOMITO_CI_SOURCE_CATALOG 2UL
typedef const UCHAR* PCUCHAR;
static unsigned checks, allocations, ciCalls, hashCalls;
static int failOperation, failHashCall;
static ULONG_PTR bytesRead;
+static bool allocatePolicies;
+static NTSTATUS embeddedResult = STATUS_SUCCESS;
+static NTSTATUS catalogResults[2] = {STATUS_SUCCESS, STATUS_SUCCESS};
+static unsigned catalogCalls;
+static ULONG expectedCatalogMask = DOMITO_CI_LEGACY_CATALOG_POLICY_BITS;
+static BOOLEAN expectedTrustedMarker;
+static LONGLONG observedFileSize = 1056;
static void Check(bool ok, const char* message)
{
++checks;
@@ -61,33 +73,67 @@ static NTSTATUS TestFinishHash(BCRYPT_HASH_HANDLE h, PUCHAR b, ULONG n, ULONG f)
}
// Minimal dependencies for the legacy wrapper; no actual CI code is invoked.
-struct MINCRYPT_POLICY_INFO { ULONG Size; };
+struct MINCRYPT_POLICY_INFO { ULONG Size; PVOID ChainInfo; };
struct KAPC_STATE { ULONG unused; };
struct IO_STATUS_BLOCK { NTSTATUS Status; ULONG_PTR Information; };
static PVOID PsInitialSystemProcess = nullptr;
-static void CiFreePolicyInfo(MINCRYPT_POLICY_INFO* p) { p->Size = 0; }
+static void CiFreePolicyInfo(MINCRYPT_POLICY_INFO* p) {
+ if (p->ChainInfo) ExFreePoolWithTag(p->ChainInfo, DOMITO_POOL_TAG);
+ *p = {};
+}
+static void PopulatePolicies(MINCRYPT_POLICY_INFO* policy, MINCRYPT_POLICY_INFO* timestamp)
+{
+ Check(!policy->ChainInfo && !timestamp->ChainInfo, "previous CI attempt released before reuse");
+ if (allocatePolicies) {
+ policy->Size = timestamp->Size = sizeof(*policy);
+ policy->ChainInfo = ExAllocatePoolWithTag(PagedPool, 1, DOMITO_POOL_TAG);
+ timestamp->ChainInfo = ExAllocatePoolWithTag(PagedPool, 1, DOMITO_POOL_TAG);
+ }
+}
static void KeStackAttachProcess(PVOID, KAPC_STATE*) {}
static void KeUnstackDetachProcess(KAPC_STATE*) {}
-static NTSTATUS ZwReadFile(HANDLE, PVOID, PVOID, PVOID, IO_STATUS_BLOCK* io,
- PVOID buffer, ULONG size, LARGE_INTEGER*, PVOID)
+typedef PVOID PFILE_OBJECT;
+static PVOID fileObjectType;
+static PVOID* IoFileObjectType = &fileObjectType;
+#define KernelMode 0
+static NTSTATUS ObReferenceObjectByHandle(HANDLE, ULONG, PVOID, int, PVOID* object, PVOID)
{
- io->Status = STATUS_SUCCESS;
- io->Information = bytesRead;
+ *object = &fileObjectType;
+ return STATUS_SUCCESS;
+}
+static void ObDereferenceObject(PVOID) {}
+static NTSTATUS FsRtlGetFileSize(PFILE_OBJECT, LARGE_INTEGER* size)
+{
+ size->QuadPart = observedFileSize;
+ return STATUS_SUCCESS;
+}
+static NTSTATUS DomitoReadFileAt(HANDLE, PVOID buffer, ULONG size, const LARGE_INTEGER* offset)
+{
+ Check(offset->QuadPart == 1024, "explicit certificate read offset");
std::memset(buffer, 0, (size_t)min(bytesRead, (ULONG_PTR)size));
- return STATUS_SUCCESS;
+ return bytesRead == size ? STATUS_SUCCESS : STATUS_END_OF_FILE;
}
-static NTSTATUS ZwWaitForSingleObject(HANDLE, BOOLEAN, PVOID) { return STATUS_SUCCESS; }
static NTSTATUS CiCheckSignedFile(PVOID, UINT32, ALG_ID, PVOID, UINT32,
- MINCRYPT_POLICY_INFO*, LARGE_INTEGER*, MINCRYPT_POLICY_INFO*)
+ MINCRYPT_POLICY_INFO* policy, LARGE_INTEGER* time, MINCRYPT_POLICY_INFO* timestamp)
{
++ciCalls;
- return STATUS_SUCCESS;
+ Check(time->QuadPart == 0, "fresh signing time for embedded validation");
+ PopulatePolicies(policy, timestamp);
+ time->QuadPart = 123;
+ return embeddedResult;
}
-static NTSTATUS CiVerifyHashInCatalog(PVOID, UINT32, ALG_ID, BOOLEAN, UINT32, UINT32,
- MINCRYPT_POLICY_INFO*, PVOID, LARGE_INTEGER*, MINCRYPT_POLICY_INFO*)
+static NTSTATUS CiVerifyHashInCatalog(PVOID, UINT32, ALG_ID, BOOLEAN reload,
+ UINT32 trusted, UINT32 mask, MINCRYPT_POLICY_INFO* policy, PVOID,
+ LARGE_INTEGER* time, MINCRYPT_POLICY_INFO* timestamp)
{
++ciCalls;
- return STATUS_SUCCESS;
+ Check(mask == expectedCatalogMask && trusted == expectedTrustedMarker, "explicit catalog policy forwarded");
+ Check(reload == (catalogCalls != 0), "catalog reload sequence");
+ Check(time->QuadPart == 0, "fresh signing time for catalog attempt");
+ PopulatePolicies(policy, timestamp);
+ time->QuadPart = 456;
+ Check(catalogCalls < 2, "bounded catalog retries");
+ return catalogResults[catalogCalls++];
}
#define BCryptCreateHash TestCreateHash
@@ -108,21 +154,32 @@ static std::vector Image(bool pe64, ULONG nt = 128, bool certificate = fa
std::memcpy(b.data() + nt, &signature, sizeof(signature));
IMAGE_FILE_HEADER f = {};
f.NumberOfSections = 1;
+ f.Machine = pe64 ? IMAGE_FILE_MACHINE_AMD64 : IMAGE_FILE_MACHINE_I386;
f.SizeOfOptionalHeader = pe64 ? sizeof(IMAGE_OPTIONAL_HEADER64) : sizeof(IMAGE_OPTIONAL_HEADER32);
std::memcpy(b.data() + nt + 4, &f, sizeof(f));
IMAGE_OPTIONAL_HEADER64 h64 = {};
IMAGE_OPTIONAL_HEADER32 h32 = {};
h64.Magic = IMAGE_NT_OPTIONAL_HDR64_MAGIC; h32.Magic = IMAGE_NT_OPTIONAL_HDR32_MAGIC;
h64.NumberOfRvaAndSizes = h32.NumberOfRvaAndSizes = 16;
+ h64.SectionAlignment = h32.SectionAlignment = 4096;
+ h64.FileAlignment = h32.FileAlignment = 512;
+ h64.SizeOfImage = h32.SizeOfImage = 8192;
+ h64.Subsystem = h32.Subsystem = IMAGE_SUBSYSTEM_WINDOWS_CUI;
h64.SizeOfHeaders = h32.SizeOfHeaders = nt + 384;
if (certificate)
{
h64.DataDirectory[4] = h32.DataDirectory[4] = { nt + 896, 32 };
ULONG length = 32;
std::memcpy(b.data() + nt + 896, &length, sizeof(length));
+ USHORT revision = 0x0200, type = WIN_CERT_TYPE_PKCS_SIGNED_DATA;
+ std::memcpy(b.data() + nt + 900, &revision, 2);
+ std::memcpy(b.data() + nt + 902, &type, 2);
}
std::memcpy(b.data() + nt + 24, pe64 ? (void*)&h64 : (void*)&h32, f.SizeOfOptionalHeader);
IMAGE_SECTION_HEADER section = {};
+ section.VirtualAddress = 4096;
+ section.Misc.VirtualSize = 512;
+ section.Characteristics = IMAGE_SCN_CNT_CODE | IMAGE_SCN_MEM_READ;
section.PointerToRawData = nt + 384; section.SizeOfRawData = 512;
std::memcpy(b.data() + nt + 24 + f.SizeOfOptionalHeader, §ion, sizeof(section));
for (ULONG i = nt + 384; i < nt + 896; ++i) b[i] = (UCHAR)(i * 31);
@@ -187,10 +244,295 @@ static void GuardedInvalid(const std::vector& b, NTSTATUS expected)
UCHAR* input = region + span - b.size();
std::memcpy(input, b.data(), b.size());
Digest(input, (ULONG)b.size(), expected);
+ ULONG strictSize = 0, securitySize = 0;
+ PVOID strictDigest = nullptr;
+ LPWIN_CERTIFICATE certificate = nullptr;
+ Check(!NT_SUCCESS(DomitoCalculatePortableExecutableDigestEx(input, (ULONG)b.size(), CALG_SHA256,
+ &strictSize, &strictDigest, &certificate, &securitySize)), "strict guard-page malformed input rejected");
+ Check(!strictSize && !strictDigest && !certificate && !securitySize, "strict malformed outputs cleared");
VirtualFree(region, 0, MEM_RELEASE);
}
+static std::vector ExplicitDigest(const std::vector& b, UINT32 calg,
+ NTSTATUS expected = STATUS_SUCCESS)
+{
+ ULONG length = 0xdead, certSize = 0xdead;
+ PVOID digest = (PVOID)1;
+ LPWIN_CERTIFICATE cert = (LPWIN_CERTIFICATE)1;
+ NTSTATUS status = DomitoCalculatePortableExecutableDigestEx(b.data(), (ULONG)b.size(),
+ calg, &length, &digest, &cert, &certSize);
+ Check(status == expected, "explicit digest status");
+ std::vector result;
+ if (NT_SUCCESS(status))
+ {
+ Check(length == (calg == CALG_SHA1 ? 20u : calg == CALG_SHA256 ? 32u :
+ calg == CALG_SHA384 ? 48u : 64u), "explicit algorithm digest length");
+ result.assign((UCHAR*)digest, (UCHAR*)digest + length);
+ DomitoFreePortableExecutableDigest(digest);
+ }
+ else Check(!length && !digest && !cert && !certSize, "explicit failure outputs cleared");
+ Check(allocations == 0, "explicit digest allocation cleanup");
+ return result;
+}
+
+static std::vector SipDigest(const std::vector& b, UINT32 calg)
+{
+ WCHAR tempPath[MAX_PATH], tempName[MAX_PATH];
+ Check(GetTempPathW(MAX_PATH, tempPath) != 0, "temp path");
+ Check(GetTempFileNameW(tempPath, L"dci", 0, tempName) != 0, "temp name");
+ HANDLE file = CreateFileW(tempName, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ,
+ nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, nullptr);
+ Check(file != INVALID_HANDLE_VALUE, "SIP fixture file");
+ DWORD written = 0;
+ Check(WriteFile(file, b.data(), (DWORD)b.size(), &written, nullptr) && written == b.size(), "write SIP fixture");
+ GUID subject;
+ Check(CryptSIPRetrieveSubjectGuid(tempName, file, &subject) != FALSE, "PE SIP subject");
+ SIP_SUBJECTINFO info = {};
+ info.cbSize = sizeof(info);
+ info.pgSubjectType = &subject;
+ info.hFile = file;
+ info.pwsFileName = tempName;
+ info.dwEncodingType = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
+ info.DigestAlgorithm.pszObjId = const_cast(calg == CALG_SHA1 ? szOID_OIWSEC_sha1 :
+ calg == CALG_SHA256 ? szOID_NIST_sha256 : calg == CALG_SHA384 ? szOID_NIST_sha384 : szOID_NIST_sha512);
+ DWORD size = 0;
+ Check(CryptSIPCreateIndirectData(&info, &size, nullptr) != FALSE, "SIP size query");
+ std::vector buffer(size);
+ auto indirect = (SIP_INDIRECT_DATA*)buffer.data();
+ Check(CryptSIPCreateIndirectData(&info, &size, indirect) != FALSE, "Windows SIP digest");
+ std::vector result(indirect->Digest.pbData, indirect->Digest.pbData + indirect->Digest.cbData);
+ CloseHandle(file);
+ return result;
+}
+
+static std::vector Der(UCHAR tag, const std::vector& value)
+{
+ std::vector out = {tag};
+ if (value.size() < 128) out.push_back((UCHAR)value.size());
+ else if (value.size() < 256) { out.push_back(0x81); out.push_back((UCHAR)value.size()); }
+ else { out.push_back(0x82); out.push_back((UCHAR)(value.size() >> 8)); out.push_back((UCHAR)value.size()); }
+ out.insert(out.end(), value.begin(), value.end());
+ return out;
+}
+static std::vector Join(std::initializer_list> values)
+{
+ std::vector out;
+ for (auto& value : values) out.insert(out.end(), value.begin(), value.end());
+ return out;
+}
+static std::vector SignedContent(UINT32 calg, bool nullParameters = true)
+{
+ std::vector oid = calg == CALG_SHA1 ? std::vector{0x2B,0x0E,0x03,0x02,0x1A} :
+ std::vector{0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x02,
+ (UCHAR)(calg == CALG_SHA256 ? 1 : calg == CALG_SHA384 ? 2 : 3)};
+ auto algorithm = Der(0x30, Join({Der(0x06, oid), nullParameters ? Der(0x05, {}) : std::vector{}}));
+ auto digest = Der(0x30, Join({algorithm, Der(0x04, std::vector(
+ calg == CALG_SHA1 ? 20 : calg == CALG_SHA256 ? 32 : calg == CALG_SHA384 ? 48 : 64, 0xAA))}));
+ // A complete SHA-1 DigestInfo decoy in the ignored data attribute must not
+ // override the selected SpcIndirectDataContent digest AlgorithmIdentifier.
+ auto data = Der(0x30, Der(0x04, {0x30,0x21,0x30,0x09,0x06,0x05,0x2B,0x0E,0x03,0x02,0x1A,0x05,0,0x04,0x14}));
+ auto indirect = Der(0x30, Join({data, digest}));
+ auto content = Der(0x30, Join({Der(0x06, {0x2B,0x06,0x01,0x04,0x01,0x82,0x37,0x02,0x01,0x04}), Der(0xA0, indirect)}));
+ auto signedData = Der(0x30, Join({Der(0x02, {1}), Der(0x31, algorithm), content, Der(0x31, {})}));
+ return Der(0x30, Join({Der(0x06, {0x2A,0x86,0x48,0x86,0xF7,0x0D,0x01,0x07,0x02}), Der(0xA0, signedData)}));
+}
+static void ExtendedTests()
+{
+ for (bool pe64 : {false, true})
+ for (bool cert : {false, true})
+ for (UINT32 calg : std::initializer_list{CALG_SHA1, CALG_SHA256, CALG_SHA384, CALG_SHA512})
+ {
+ auto b = Image(pe64, 128, cert);
+ Check(ExplicitDigest(b, calg) == SipDigest(b, calg), "Windows SIP differential fixture");
+ if (!cert)
+ {
+ b.insert(b.end(), 48, 0x42); // Non-section overlay.
+ Check(ExplicitDigest(b, calg) == SipDigest(b, calg), "Windows SIP overlay differential");
+ }
+ }
+ for (bool pe64 : {false, true})
+ for (ULONG count : {0UL, 4UL})
+ {
+ auto absent = Image(pe64);
+ Set32(absent, pe64 ? 260 : 244, count);
+ ExplicitDigest(absent, CALG_SHA256, STATUS_NOT_SUPPORTED);
+ }
+ auto b = Image(true);
+ // Two contiguous sections whose headers are in reverse raw-offset order.
+ Set16(b, 134, 2);
+ IMAGE_SECTION_HEADER first = {}, second = {};
+ first.PointerToRawData = 768; first.SizeOfRawData = 256;
+ second.PointerToRawData = 512; second.SizeOfRawData = 256;
+ std::memcpy(b.data() + 392, &first, sizeof(first));
+ std::memcpy(b.data() + 432, &second, sizeof(second));
+ Check(ExplicitDigest(b, CALG_SHA256) == SipDigest(b, CALG_SHA256), "section header order differential");
+ Set32(b, 392 + 20, 800);
+ ExplicitDigest(b, CALG_SHA256, STATUS_INVALID_IMAGE_FORMAT); // extends beyond EOF
+ Set32(b, 392 + 20, 640);
+ ExplicitDigest(b, CALG_SHA256, STATUS_NOT_SUPPORTED); // overlap
+ b = Image(true);
+ Set32(b, 392 + 20, 528); Set32(b, 392 + 16, 496);
+ ExplicitDigest(b, CALG_SHA256, STATUS_NOT_SUPPORTED); // gap
+ b = Image(true, 128, true);
+ b.insert(b.end(), 8, 0);
+ ExplicitDigest(b, CALG_SHA256, STATUS_NOT_SUPPORTED); // nonterminal certificate
+ b = Image(true);
+ Set32(b, 392 + 20, 0xFFFFFFFF);
+ ExplicitDigest(b, CALG_SHA256, STATUS_INVALID_IMAGE_FORMAT);
+ ExplicitDigest(Image(true), 0x1234, STATUS_NOT_SUPPORTED);
+ b = Image(true, 128, true);
+ b.resize(1088);
+ Set32(b, 300, 64);
+ Set32(b, 1056, 32); Set16(b, 1060, 0x0200); Set16(b, 1062, 2);
+ Check(ExplicitDigest(b, CALG_SHA256) == SipDigest(b, CALG_SHA256), "multiple certificate entries excluded");
+ Set32(b, 1056, 0);
+ ExplicitDigest(b, CALG_SHA256, STATUS_INVALID_IMAGE_FORMAT);
+ b = Image(true, 128, true);
+ Set32(b, 1024, 31); // Last entry's alignment padding is outside dwLength.
+ Check(ExplicitDigest(b, CALG_SHA256) == SipDigest(b, CALG_SHA256), "certificate alignment padding");
+ for (int failure : {1, 2, 3, 4})
+ {
+ failOperation = failure;
+ failHashCall = 1;
+ hashCalls = 0;
+ ExplicitDigest(Image(true), CALG_SHA256,
+ failure == 4 ? STATUS_INSUFFICIENT_RESOURCES : STATUS_UNSUCCESSFUL);
+ }
+ failOperation = 0;
+ for (UINT32 calg : std::initializer_list{CALG_SHA1, CALG_SHA256, CALG_SHA384, CALG_SHA512})
+ {
+ auto content = SignedContent(calg);
+ auto signedImage = Image(true);
+ ULONG certLength = 8 + (ULONG)content.size();
+ ULONG tableSize = (certLength + 7) & ~7u;
+ signedImage.resize(1024 + tableSize);
+ Set32(signedImage, 296, 1024); Set32(signedImage, 300, tableSize);
+ Set32(signedImage, 1024, certLength);
+ Set16(signedImage, 1028, 0x0200); Set16(signedImage, 1030, 2);
+ std::memcpy(signedImage.data() + 1032, content.data(), content.size());
+ IMAGE_DATA_DIRECTORY directories[5] = {};
+ directories[4] = {1024, tableSize};
+ UINT32 actual = 0;
+ Check(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), (ULONG)signedImage.size(),
+ directories, &actual) == STATUS_SUCCESS && actual == calg, "bounded SPC digest algorithm, decoy ignored");
+ for (ULONG cut = 0; cut < content.size(); ++cut)
+ {
+ Set32(signedImage, 1024, cut + 8);
+ actual = 0xDEAD;
+ Check(!NT_SUCCESS(DomitoGetPortableExecutableDigestKindEx(signedImage.data(),
+ (ULONG)signedImage.size(), directories, &actual)) && actual == 0, "truncated DER rejected");
+ }
+ Set32(signedImage, 1024, tableSize);
+ Check(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), (ULONG)signedImage.size(),
+ directories, &actual) == STATUS_SUCCESS && actual == calg, "bounded zero padding inside dwLength");
+ if (tableSize > certLength)
+ {
+ signedImage.back() = 0x7F;
+ Check(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), (ULONG)signedImage.size(),
+ directories, &actual) == STATUS_INVALID_IMAGE_FORMAT && actual == 0, "nonzero DER trailing bytes rejected");
+ signedImage.back() = 0;
+ }
+ Set32(signedImage, 1024, certLength);
+ signedImage[1033] = 0x80; // Indefinite length forbidden in this DER subset.
+ Check(DomitoGetPortableExecutableDigestKindEx(signedImage.data(), (ULONG)signedImage.size(),
+ directories, &actual) == STATUS_INVALID_IMAGE_FORMAT, "indefinite DER rejected");
+ }
+ for (bool nullParameters : {false, true})
+ {
+ auto content = SignedContent(CALG_SHA256, nullParameters);
+ auto pe = Image(true);
+ const ULONG length = 8 + (ULONG)content.size();
+ const ULONG tableSize = (length + 7) & ~7u;
+ pe.resize(1024 + tableSize);
+ Set32(pe, 1024, length); Set16(pe, 1028, 0x0200); Set16(pe, 1030, 2);
+ std::memcpy(pe.data() + 1032, content.data(), content.size());
+ IMAGE_DATA_DIRECTORY dirs[5] = {};
+ dirs[4] = {1024, tableSize};
+ UINT32 algorithm = 0;
+ Check(DomitoGetPortableExecutableDigestKindEx(pe.data(), (ULONG)pe.size(), dirs, &algorithm) == STATUS_SUCCESS &&
+ algorithm == CALG_SHA256, "AlgorithmIdentifier NULL optional");
+ // Change both copies of the SHA-256 OID to an unsupported family member.
+ const UCHAR oid[] = {0x60,0x86,0x48,0x01,0x65,0x03,0x04,0x02,0x01};
+ for (size_t i = 1032; i + sizeof(oid) <= pe.size(); ++i)
+ if (!std::memcmp(pe.data() + i, oid, sizeof(oid))) pe[i + sizeof(oid) - 1] = 0x7F;
+ Check(DomitoGetPortableExecutableDigestKindEx(pe.data(), (ULONG)pe.size(), dirs, &algorithm) == STATUS_NOT_SUPPORTED &&
+ !algorithm, "unsupported SPC algorithm does not default to SHA1");
+ }
+ IMAGE_DATA_DIRECTORY dirs[5] = {};
+ UINT32 actual = 123;
+ Check(DomitoGetPortableExecutableDigestKindEx(b.data(), (ULONG)b.size(), dirs, &actual) == STATUS_NOT_FOUND &&
+ actual == 0, "absent DER does not silently select SHA1");
+}
+
+static void SystemImageTests()
+{
+ WCHAR system[MAX_PATH];
+ Check(GetSystemDirectoryW(system, MAX_PATH) != 0, "system directory");
+ for (const WCHAR* name : {L"ci.dll", L"ntdll.dll", L"kernel32.dll", L"notepad.exe"})
+ {
+ std::wstring path = std::wstring(system) + L"\\" + name;
+ HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE,
+ nullptr, OPEN_EXISTING, 0, nullptr);
+ Check(file != INVALID_HANDLE_VALUE, "system PE corpus file");
+ LARGE_INTEGER size;
+ Check(GetFileSizeEx(file, &size) && size.QuadPart > 0 && size.QuadPart < 32 * 1024 * 1024, "bounded system PE size");
+ std::vector b((size_t)size.QuadPart);
+ DWORD read;
+ Check(ReadFile(file, b.data(), (DWORD)b.size(), &read, nullptr) && read == b.size(), "read system PE fixture");
+ CloseHandle(file);
+ for (UINT32 calg : std::initializer_list{CALG_SHA1, CALG_SHA256, CALG_SHA384, CALG_SHA512})
+ Check(ExplicitDigest(b, calg) == SipDigest(b, calg), "real system PE Windows SIP differential");
+ IMAGE_DOS_HEADER dos;
+ std::memcpy(&dos, b.data(), sizeof(dos));
+ USHORT magic;
+ const ULONG optional = (ULONG)dos.e_lfanew + 24;
+ std::memcpy(&magic, b.data() + optional, 2);
+ const ULONG directory = magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC ?
+ FIELD_OFFSET(IMAGE_OPTIONAL_HEADER64, DataDirectory) : FIELD_OFFSET(IMAGE_OPTIONAL_HEADER32, DataDirectory);
+ IMAGE_DATA_DIRECTORY dirs[5];
+ std::memcpy(dirs, b.data() + optional + directory, sizeof(dirs));
+ if (dirs[4].Size)
+ {
+ UINT32 actual;
+ Check(NT_SUCCESS(DomitoGetPortableExecutableDigestKindEx(b.data(), (ULONG)b.size(), dirs, &actual)),
+ "real embedded SPC digest algorithm");
+ // Independent Windows ASN.1 decoder: extract signed content with
+ // CryptQueryObject/CryptMsg, then decode SPC_INDIRECT_DATA_OBJID.
+ ULONG certLength;
+ std::memcpy(&certLength, b.data() + dirs[4].VirtualAddress, 4);
+ CRYPT_DATA_BLOB blob = {certLength - 8, b.data() + dirs[4].VirtualAddress + 8};
+ HCRYPTMSG message = nullptr;
+ Check(CryptQueryObject(CERT_QUERY_OBJECT_BLOB, &blob, CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED,
+ CERT_QUERY_FORMAT_FLAG_BINARY, 0, nullptr, nullptr, nullptr, nullptr, &message, nullptr) != FALSE,
+ "Windows decode PKCS7");
+ DWORD contentSize = 0;
+ Check(CryptMsgGetParam(message, CMSG_CONTENT_PARAM, 0, nullptr, &contentSize) != FALSE,
+ "Windows SPC content size");
+ std::vector content(contentSize);
+ Check(CryptMsgGetParam(message, CMSG_CONTENT_PARAM, 0, content.data(), &contentSize) != FALSE,
+ "Windows SPC content");
+ SIP_INDIRECT_DATA* indirect = nullptr;
+ DWORD decodedSize = 0;
+ Check(CryptDecodeObjectEx(X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, SPC_INDIRECT_DATA_OBJID,
+ content.data(), contentSize, CRYPT_DECODE_ALLOC_FLAG, nullptr, &indirect, &decodedSize) != FALSE,
+ "Windows decode indirect data");
+ const char* oid = indirect->DigestAlgorithm.pszObjId;
+ const UINT32 expected = !std::strcmp(oid, szOID_OIWSEC_sha1) ? CALG_SHA1 :
+ !std::strcmp(oid, szOID_NIST_sha256) ? CALG_SHA256 :
+ !std::strcmp(oid, szOID_NIST_sha384) ? CALG_SHA384 : CALG_SHA512;
+ Check(actual == expected, "SPC digest algorithm matches Windows ASN1 decoder");
+ auto digest = ExplicitDigest(b, actual);
+ Check(digest.size() == indirect->Digest.cbData &&
+ !std::memcmp(digest.data(), indirect->Digest.pbData, digest.size()), "computed digest matches real embedded SPC digest");
+ LocalFree(indirect);
+ CryptMsgClose(message);
+ }
+ }
+}
+
int main()
{
+ SystemImageTests();
+ ExtendedTests();
for (bool pe64 : {false, true})
{
for (bool certificate : {false, true})
@@ -255,5 +597,43 @@ int main()
Check(NT_SUCCESS(DomitoValidateFileLegacyMode(nullptr, hash, 20, CALG_SHA1, &directory,
&policy, &signing, ×tamp)), "complete certificate read accepted");
Check(ciCalls == 1 && allocations == 0, "complete read reaches CI once");
- std::printf("PASS: %u checks (guarded malformed inputs, PE32/PE32+, CNG digests, failure cleanup, short reads).\n", checks);
+ // Failure paths must release previous attempts before embedded/catalog/reload reuse.
+ allocatePolicies = true;
+ embeddedResult = STATUS_INVALID_IMAGE_HASH;
+ catalogResults[0] = STATUS_INVALID_IMAGE_HASH;
+ catalogResults[1] = STATUS_SUCCESS;
+ catalogCalls = 0;
+ expectedCatalogMask = 0x8000;
+ expectedTrustedMarker = TRUE;
+ ULONG source = 0xDEAD;
+ Check(NT_SUCCESS(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA1,
+ &directory, &policy, &signing, ×tamp, expectedCatalogMask, TRUE, &source)), "explicit catalog fallback");
+ Check(source == DOMITO_CI_SOURCE_CATALOG && catalogCalls == 2 && allocations == 2,
+ "final policy ownership and catalog source");
+ CiFreePolicyInfo(&policy); CiFreePolicyInfo(×tamp);
+ Check(allocations == 0, "final results freed once");
+ source = 123;
+ Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA256,
+ &directory, &policy, &signing, ×tamp, 0, FALSE, &source) == STATUS_INVALID_PARAMETER && !source,
+ "algorithm and length mismatch rejected");
+ Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA384,
+ &directory, &policy, &signing, ×tamp, 0, FALSE, &source) == STATUS_NOT_SUPPORTED,
+ "legacy catalog algorithm capability explicit");
+ observedFileSize = 1040;
+ Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA1,
+ &directory, &policy, &signing, ×tamp, 0, FALSE, &source) == STATUS_INVALID_IMAGE_FORMAT,
+ "certificate file range checked before allocation");
+ Check(allocations == 0, "invalid file range never allocated");
+ observedFileSize = 1056;
+ expectedCatalogMask = 0x8000;
+ expectedTrustedMarker = TRUE;
+ catalogCalls = 0;
+ catalogResults[1] = STATUS_INVALID_IMAGE_HASH;
+ Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA1,
+ &directory, &policy, &signing, ×tamp, expectedCatalogMask, TRUE, &source) == STATUS_INVALID_IMAGE_HASH,
+ "final catalog failure retained");
+ Check(!source && allocations == 2, "final failed-call policies remain caller-owned");
+ CiFreePolicyInfo(&policy); CiFreePolicyInfo(×tamp);
+ Check(allocations == 0, "failed final policies freed once");
+ std::printf("PASS: %u digest/legacy checks (Windows SIP, Crypt32, guard pages, failures, ownership).\n", checks);
}
diff --git a/tests/mincrypt-layout-tests.cpp b/tests/mincrypt-layout-tests.cpp
new file mode 100644
index 0000000..d01539b
--- /dev/null
+++ b/tests/mincrypt-layout-tests.cpp
@@ -0,0 +1,24 @@
+// Compile with the WDK for supported 64-bit targets. No driver is linked/loaded.
+#include
+#include "Domito.MinCrypt.h"
+
+#if NTDDI_VERSION >= NTDDI_WIN10
+C_ASSERT(sizeof(MINCRYPT_POLICY_INFO) == 48);
+C_ASSERT(FIELD_OFFSET(MINCRYPT_POLICY_INFO, VerificationStatus) == 4);
+C_ASSERT(FIELD_OFFSET(MINCRYPT_POLICY_INFO, ChainInfo) == 16);
+C_ASSERT(FIELD_OFFSET(MINCRYPT_POLICY_INFO, NotBefore) == 32);
+C_ASSERT(sizeof(MINCRYPT_CHAIN_INFO) == 72);
+C_ASSERT(FIELD_OFFSET(MINCRYPT_CHAIN_INFO, ChainElements) == 40);
+C_ASSERT(FIELD_OFFSET(MINCRYPT_CHAIN_INFO, AuthenticodeAttributes) == 56);
+#else
+C_ASSERT(sizeof(MINCRYPT_POLICY_INFO) == 32);
+C_ASSERT(sizeof(MINCRYPT_CHAIN_INFO) == 40);
+#endif
+C_ASSERT(sizeof(MINCRYPT_NAME_BLOB) == 16);
+C_ASSERT(sizeof(MINCRYPT_CHAIN_ELEMENT) == 120);
+C_ASSERT(FIELD_OFFSET(MINCRYPT_CHAIN_ELEMENT, Certificate) == 104);
+
+#if defined(_M_AMD64)
+// These opt-in declarations establish observations only for the named x64 build.
+#include "Domito.MinCrypt.26100.h"
+#endif
diff --git a/tests/run-common-tests.py b/tests/run-common-tests.py
new file mode 100644
index 0000000..3867665
--- /dev/null
+++ b/tests/run-common-tests.py
@@ -0,0 +1,18 @@
+"""Run production export and file-read helpers with deterministic user-mode doubles."""
+from pathlib import Path
+import subprocess
+import tempfile
+root = Path(__file__).resolve().parents[1]
+source = (root / 'src/Domito.cpp').read_text(encoding='utf-8-sig')
+export_start = source.index('// This helper handles directory RVAs')
+export_end = source.index('_Success_', source.index('DomitoFindExportedFunctionAddress(') + 1)
+read_start = source.rfind('_Success_', 0, source.index('DomitoReadFileAt('))
+read_end = source.index('_Success_', source.index('DomitoReadFile(') + 1)
+with tempfile.TemporaryDirectory(prefix='domito-common-tests-') as temp:
+ build = Path(temp)
+ (build / 'common-under-test.inc').write_text(source[export_start:export_end] + '\n' + source[read_start:read_end], encoding='utf-8')
+ exe = build / 'common-tests.exe'
+ subprocess.run(['cl', '/nologo', '/EHsc', '/W4', '/WX', '/std:c++17', '/I' + str(build),
+ str(root / 'tests/common-tests.cpp'), '/Fe' + str(exe),
+ '/Fo' + str(build / 'common-tests.obj')], cwd=build, check=True)
+ subprocess.run([str(exe)], cwd=build, check=True)
diff --git a/tests/run-digest-tests.py b/tests/run-digest-tests.py
index 0a4eac9..6656d4f 100644
--- a/tests/run-digest-tests.py
+++ b/tests/run-digest-tests.py
@@ -30,5 +30,5 @@ with tempfile.TemporaryDirectory(prefix='domito-digest-tests-') as temp:
test_source = Path(__file__).with_name('digest-tests.cpp')
subprocess.run(['cl', '/nologo', '/EHsc', '/W4', '/WX', '/std:c++17',
'/I' + str(build), str(test_source), '/Fe' + str(build/'digest-tests.exe'),
- '/Fo' + str(build/'digest-tests.obj'), '/link', 'bcrypt.lib'], cwd=build, check=True)
+ '/Fo' + str(build/'digest-tests.obj'), '/link', 'bcrypt.lib', 'crypt32.lib'], cwd=build, check=True)
subprocess.run([str(build/'digest-tests.exe')], cwd=build, check=True)