Add explicit Authenticode workflows and correct CI helper contracts

This commit is contained in:
nefarius committed 2026-10-26 17:55:58 +01:00
1 parent 1026c48324
commit 7aa23f50be
18 files changed
+1952 -185

No files matched your search

+49 -17
View File
@@ -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.
+152
View File
@@ -0,0 +1,152 @@
// Tests extract the unchanged production bodies. Kernel I/O is simulated.
#define WIN32_NO_STATUS
#include <windows.h>
#undef WIN32_NO_STATUS
#include <ntstatus.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
#include <cctype>
#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<UCHAR> Image() {
std::vector<UCHAR> 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<UCHAR>& 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);
}
+394 -14
View File
@@ -6,10 +6,12 @@
#include <ntstatus.h>
#include <bcrypt.h>
#include <wintrust.h>
#include <mssip.h>
#include <cstdio>
#include <cstdlib>
#include <vector>
#include <cstring>
#include <string>
#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<UCHAR> 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, &section, 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<UCHAR>& 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<UCHAR> ExplicitDigest(const std::vector<UCHAR>& 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<UCHAR> 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<UCHAR> SipDigest(const std::vector<UCHAR>& 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<char*>(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<UCHAR> buffer(size);
auto indirect = (SIP_INDIRECT_DATA*)buffer.data();
Check(CryptSIPCreateIndirectData(&info, &size, indirect) != FALSE, "Windows SIP digest");
std::vector<UCHAR> result(indirect->Digest.pbData, indirect->Digest.pbData + indirect->Digest.cbData);
CloseHandle(file);
return result;
}
static std::vector<UCHAR> Der(UCHAR tag, const std::vector<UCHAR>& value)
{
std::vector<UCHAR> 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<UCHAR> Join(std::initializer_list<std::vector<UCHAR>> values)
{
std::vector<UCHAR> out;
for (auto& value : values) out.insert(out.end(), value.begin(), value.end());
return out;
}
static std::vector<UCHAR> SignedContent(UINT32 calg, bool nullParameters = true)
{
std::vector<UCHAR> oid = calg == CALG_SHA1 ? std::vector<UCHAR>{0x2B,0x0E,0x03,0x02,0x1A} :
std::vector<UCHAR>{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<UCHAR>{}}));
auto digest = Der(0x30, Join({algorithm, Der(0x04, std::vector<UCHAR>(
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<UINT32>{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<UINT32>{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<UCHAR> 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<UINT32>{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<UCHAR> 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, &timestamp)), "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, &timestamp, 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(&timestamp);
Check(allocations == 0, "final results freed once");
source = 123;
Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA256,
&directory, &policy, &signing, &timestamp, 0, FALSE, &source) == STATUS_INVALID_PARAMETER && !source,
"algorithm and length mismatch rejected");
Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA384,
&directory, &policy, &signing, &timestamp, 0, FALSE, &source) == STATUS_NOT_SUPPORTED,
"legacy catalog algorithm capability explicit");
observedFileSize = 1040;
Check(DomitoValidateFileLegacyModeEx((HANDLE)1, hash, 20, CALG_SHA1,
&directory, &policy, &signing, &timestamp, 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, &timestamp, 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(&timestamp);
Check(allocations == 0, "failed final policies freed once");
std::printf("PASS: %u digest/legacy checks (Windows SIP, Crypt32, guard pages, failures, ownership).\n", checks);
}
+24
View File
@@ -0,0 +1,24 @@
// Compile with the WDK for supported 64-bit targets. No driver is linked/loaded.
#include <ntifs.h>
#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
+18
View File
@@ -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)
+1 -1
View File
@@ -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)