Skip to content
Lesson 10.8Game Files, Mods, and Artifact Trust

File Hashes and Digital Signatures

Separate byte integrity from publisher identity through hashes, signatures, certificates, and Windows trust checks.

Lesson 10.8
Read or listenPrint chapter or book
About 37 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 10.8Key ideas, reference and questions

A one-page summary of “File Hashes and Digital Signatures” you can keep beside your notes. Plain Markdown.

Lesson 10.8 — File Hashes and Digital Signatures: cheatsheet

  • Remember: Interpret each API’s documented return contract; for this trust check, success is exactly zero.
  • Also useful: The signing key makes the approval mark; the matching public key checks it. Checking does not require handing everyone the signing secret.

Key terms

  • checksum: The last four bytes are a checksum: a number computed from the other bytes and stored beside them, so the loader can detect damage.
  • digital signature: A digital signature is a number computed from a file's hash with a secret key, which anyone can check with a matching key that is not secret.
  • public key: The public key is given to everyone; for Authenticode it travels inside the publisher's certificate.
  • PCWSTR: PCWSTR is only a pointer.

Review

  • Q: What can an Authenticode signature tell you that a standalone SHA-256 hash cannot? A: Whether bytes match a publisher's signature under a Windows trust policy
  • Q: A publisher signs a game patch, and Windows checks the signature on your machine. Which key does each side use? A: The publisher signs with its private key; Windows checks with the public key in the certificate
Download cheatsheet (.md)

The book’s invented GSAV format makes the byte-integrity question concrete. Its offline character Nia has level 3, 250 gold, and this complete 19-byte save:

47 53 41 56 01 00 FA 00 00 00 03 03 4E 69 61 4A 03 00 00

The last four bytes are a checksum: a number computed from the other bytes and stored beside them, so the loader can detect damage. This format uses the simplest checksum there is. It adds up every byte from offset 0 to the byte just before the checksum and stores the total as a little-endian u32. For Nia’s save, bytes 0 to 14 add up like this:

magic 47 53 41 56 71 + 83 + 65 + 86 = 305 total 305
version 01 00 1 + 0 = 1 total 306
gold FA 00 00 00 250 + 0 + 0 + 0 = 250 total 556
level 03 3 = 3 total 559
length 03 3 = 3 total 562
name 4E 69 61 78 + 105 + 97 = 280 total 842

The stored checksum is the same number:

0x0F4A
0x1003
0x1100
0x1200
× 1
× 256
× 65,536
× 16,777,216
0x4A is 4 × 16 + 10 = 74, and 74 + 3 × 256 = 842
The stored checksum, lowest byte first: 842, which is 0x34A because 842 = 3 × 256 + 74.

The loader repeats that sum every time it opens the file:

no
yes
no
yes
no
yes
no
yes
Read the whole file
First four bytes
are GSAV?
Refuse the file
Version is 1?
File size equals
12 + name length + 4?
Add up every byte
before the checksum
Sum equals the
stored checksum?
Decode the fields and load

Validate bytes before interpreting a save. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

encode the 15-byte GSAV payload
sum the payload bytes
store the checksum, or corrupt it
check header, version, length and checksum
interpret the accepted fields
report why the record was accepted or refused

Result: accepted Nia, level 3, gold 250. A recomputable checksum is not evidence of an authorized writer.

Final values: gold = 250; corrupt = no; payload_length = 15; computed_sum = 842; stored_sum = 842; total_length = 19; accepted = true; name = Nia; level = 3; decoded_gold = 250.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    gold = 250; corrupt = no.

  2. Step 2, line 2. The writer sums the first fifteen bytes.

    gold = 250; corrupt = no; payload_length = 15; computed_sum = 842.

  3. Step 3, line 3. The record contains four checksum bytes after its payload.

    gold = 250; corrupt = no; payload_length = 15; computed_sum = 842; stored_sum = 842; total_length = 19.

  4. Step 4, line 4. The fixture’s header and size are valid; compare both checksum values.

    gold = 250; corrupt = no; payload_length = 15; computed_sum = 842; stored_sum = 842; total_length = 19; accepted = true.

  5. Step 5, line 5. Only now interpret the fields.

    gold = 250; corrupt = no; payload_length = 15; computed_sum = 842; stored_sum = 842; total_length = 19; accepted = true; name = Nia; level = 3; decoded_gold = 250.

  6. Step 6, line 6. Result: accepted Nia, level 3, gold 250. A recomputable checksum is not evidence of an authorized writer.

    gold = 250; corrupt = no; payload_length = 15; computed_sum = 842; stored_sum = 842; total_length = 19; accepted = true; name = Nia; level = 3; decoded_gold = 250.

Watch it happenBytes enter the checksum; checked fields become a record

The exact 19-byte GSAV file is checked for its header, version and length. Copies of the first 15 byte values enter an accumulator and sum to 842. The four stored checksum bytes also decode to 842. The accepted record contains Nia, level 3, and gold 250.

Read the steps
  1. Read the unchanged sample: 19 bytes. Offsets locate fields inside the file.
  2. Bytes 47 53 41 56 spell GSAV. Bytes 01 00 decode as little-endian version 1, so this loader recognizes the layout.
  3. Offset 11 holds length 3. Twelve fixed bytes plus three name bytes plus four checksum bytes exactly fill the 19-byte file.
  4. Copy each byte value into the adder. The file stays unchanged. Sum bytes 0 through 14, stopping before the stored checksum.
  5. The computed sum is 842. Stored bytes 4A 03 00 00 decode as 74 + 3 × 256 = 842. The totals match.
  6. Only after those checks does the loader accept the record: FA 00 00 00 gives 250 gold, offset 10 gives level 3, and 4E 69 61 spells Nia.

The adder consumes copies of byte values. A checksum mismatch refuses the file before the game receives a record.

Now type gold over as 9,999 and change nothing else. 9,999 is 39 × 256 + 15, and 39 = 2 × 16 + 7 = 0x27 while 15 = 0x0F, so 9,999 is 0x270F and its little-endian bytes are 0F 27 00 00.

Predict first

The last four bytes still say 4A 03 00 00, which is 842. What does the loader do with the edited file?

Answer: It refuses the save as corrupt. The old gold bytes FA 00 00 00 added 250 to the total; the new 0F 27 00 00 add 15 + 39 = 54. The total becomes 842 − 250 + 54 = 646 while the stored checksum still says 842, so the loader refuses the file.

Edit Nia’s gold and run the loader’s checkThe loader adds bytes 0x00 to 0x0E and compares the total with the stored checksum. Change the gold and decide what happens to the checksum.

The gold bytes, the total, the verdict

Gold at offsets 0x06 to 0x09

9,999 = 39 × 256 + 15
0F× 1
27× 256
00× 65,536
00× 16,777,216
What these bytes add to the total
15 + 39 + 0 + 0 = 54Nia’s 250 gold added 250

The loader’s check

Total of bytes 0x00 to 0x0E
592 + 54 = 646the other fields add 842 − 250 = 592
Stored checksum: 842, left alone
4A
03
00
00
Verdict
refuses: 646 ≠ 842

The gold bytes now add 54 instead of 250, so the loader computes 646 while the file still says 842.

The fix is to do what the game does when it saves: recompute the checksum. For 1,250 gold, the gold bytes E2 04 00 00 add 226 + 4 = 230 instead of 250, so the total is 842 − 250 + 230 = 822. That is 3 × 256 + 54, and 54 = 3 × 16 + 6 = 0x36, so the checksum is 0x336, stored as 36 03 00 00: exactly what save B contained. The complete edited file is:

47 53 41 56 01 00 E2 04 00 00 03 03 4E 69 61 36 03 00 00

A plain sum has a blind spot: adding numbers in a different order gives the same total, so swapping two bytes goes unnoticed. The 64,000 example above is that swap: 00 FA 00 00 still adds up to 250, so the file loads. That is why real formats more often use a CRC-32, a 32-bit checksum designed to change when bytes are reordered, or a hash. The editing procedure stays the same: work out which bytes are covered, which algorithm runs, where the result is stored, and in which byte order, as listed in Lesson 10.2. If the check uses a secret key, as a MAC does, or a digital signature, it cannot be recomputed without that key; Lesson 10.10 explains why.

A hash and a signature answer different questions

Section titled “A hash and a signature answer different questions”

The manifest in Lesson 10.7 records the bytes before and after a mod installation. Its hash detects an unexpected later change. To decide whether a downloaded game or mod came from the publisher you expected, we need a different question: who approved these bytes?

A SHA-256 hash is a fingerprint of exact bytes. Change one byte and the fingerprint should change dramatically. A saved baseline can answer:

“Are these bytes identical to the bytes I recorded earlier?”

It cannot answer who created the original file. A hash does not create trust; it only moves trust from wherever you obtained the hash onto the bytes now on your disk.

Changing one bit of the input changes about half of the 256 bits of a SHA-256 fingerprint, so it shows that bytes changed but not where or how much. The interactive version needs JavaScript; the lesson text around it explains the same idea.

That distinction has practical teeth. If you download mod.zip and mod.zip.sha256 from the same page, and someone is able to change that page, they can change both — and the two will agree perfectly:

matches
compromised download page
mod.zip
(attacker's bytes)
mod.zip.sha256
(attacker's hash of those bytes)
you hash mod.zip
and compare
verification succeeds —
but proves nothing:
the attacker wrote both files

Verification reports success and has proven nothing. A hash is only worth checking when it reached you by a route the attacker does not control: a signed release, a genuinely separate channel, or a value you recorded yourself from a copy you already had reason to trust.

If an attacker changes both mod.zip and the hash published next to it, the two agree and the check proves nothing. A hash you recorded yourself, or a publisher’s signature, would expose the swap. The interactive version needs JavaScript; the lesson text around it explains the same idea.

An Authenticode signature connects the file’s digest to a signing certificate and asks Windows to apply a trust policy. It can help answer:

“Does this file still match a signature made by a publisher whose certificate chain Windows trusts under this policy?”

Neither answer proves that software has no bugs or that you personally intended to install this version.

A digital signature is a number computed from a file’s hash with a secret key, which anyone can check with a matching key that is not secret. It fixes the compromised-page problem above: an attacker who controls the page can replace the file and the hash, but cannot produce a signature that checks out, because they do not have the secret.

That needs public-key cryptography, where keys are made in pairs. The private key never leaves the publisher. The public key is given to everyone; for Authenticode it travels inside the publisher’s certificate. The pair has one property that makes everything work: a signature made with the private key is accepted by the matching public key and by no other, and knowing the public key does not let anyone work out the private one.

Your machine, every check
The publisher, once
SHA-256 hash,
computed again
game.exe as downloaded
public key from
the certificate
signature and certificate
does the signature
match this hash?
SHA-256 hash
game.exe
private key turns
the hash into a signature

Walk the two ways it can fail. Change one byte of the file and your machine computes a different hash, so the signature no longer matches it. Replace the signature with one the attacker made using their own key pair, and it only checks out against the attacker’s public key, whose certificate Windows does not trust.

A comparison often used for public keys is an open padlock: you hand out as many as you like and keep the only key, so anyone can lock a box for you and only you can open it. That picture describes encrypting a message to someone. A signature runs the other way: only the private-key holder can make one, and anyone can check it. Nothing is hidden, either; a signed file stays perfectly readable. The signature only proves which key approved these exact bytes.

Why can nobody forge one? Real keys are numbers hundreds of digits long, chosen so that checking a signature takes a fraction of a millisecond while making one without the private key would take longer than the age of the universe. The same kind of gap shows up with small numbers. Multiplying 7 × 17 = 119 is quick. Given only 119, finding the two numbers means trying divisors: 2 fails, 3 fails, 5 fails, and 7 gives 119 ÷ 7 = 17. Checking a proposed answer is one division. RSA, a common signature scheme, builds its keys on this gap between multiplying and factoring, with numbers so large that the trying never finishes.

The same machinery sits behind the padlock icon in a web browser, driver signing in Lesson 12.7, and a console’s secure boot in Lesson 13.6. Its limits are the ones this lesson keeps returning to: a signature says who approved the bytes, not that the bytes are safe, and if a publisher’s private key is stolen, the thief can sign anything until the certificate is revoked.

Many open-source game builds are unsigned, self-built, or distributed in archives without Authenticode. Wesnoth, AssaultCube, or Urban Terror files can legitimately report “no embedded signature,” depending on the exact build and distributor.

Treat the result as one fact:

Result Reasonable next question
Trusted signature Does the publisher and file version match the source I expected?
No embedded signature Does the official project publish hashes or reproducible release artifacts?
Explicitly distrusted Why did Windows policy reject this publisher/signature?
Other failure Was the file damaged, unsupported, expired, or checked under a limited classroom policy?

WinVerifyTrust returns a status code, not a Result

Section titled “WinVerifyTrust returns a status code, not a Result”

The Win32 function returns a LONG. Microsoft’s return-value contract says only exact zero means the requested trust action succeeded. Do not use HRESULT-style “success if nonnegative” logic.

The original helper treats every nonnegative status as trusted, confusing this result with status types whose nonnegative values indicate success. WinVerifyTrust has a different contract: only exactly zero reports success.

Before: accept every nonnegative trust status
fn explain_trust(status: i32) {
if status >= 0 { println!("trusted"); } // Incorrect for WinVerifyTrust.
else { println!("trust check failed with status {status:#010x}"); }
}

Use the function’s own success contract, and keep every unknown nonzero status visible as a failure. A successful offline check still has the policy limits described below.

Improved code

fn explain_trust(status: i32) {
if status == 0 { println!("trusted"); }
else { println!("trust check failed with status {status:#010x}"); }
}

Show the diff

fn explain_trust(status: i32) {
if status >= 0 { println!("trusted"); } // Incorrect for WinVerifyTrust.
if status == 0 { println!("trusted"); }
else { println!("trust check failed with status {status:#010x}"); }
}

Takeaway: do not reuse another API’s success test just because both results are integers.

Known nonzero values help us print plain-English explanations. Unknown statuses remain failures and are printed in hexadecimal for investigation.

Trust verification has state and policy choices

Section titled “Trust verification has state and policy choices”

WINTRUST_FILE_INFO points to the file path. WINTRUST_DATA describes how the trust provider should work:

  • WTD_UI_NONE prevents pop-up dialogs;
  • WTD_CHOICE_FILE selects the file-info union member;
  • WTD_STATEACTION_VERIFY begins verification and may create provider state;
  • WTD_STATEACTION_CLOSE releases that state afterward;
  • WTD_DISABLE_MD2_MD4 refuses two obsolete digest algorithms;
  • WTD_REVOKE_NONE skips revocation checking in this offline classroom run;
  • WTD_CACHE_ONLY_URL_RETRIEVAL avoids network retrieval in this reproducible lab.

Those last two choices have a tradeoff: the result does not include a fresh revocation check. A release pipeline with network access should define and test a stricter revocation policy instead of treating this offline classroom result as the final word.

run calls these in order; WinVerifyTrust itself is called twice, once to verify and once to release the state the first call created:

sha256(path)
stream the file, hash it
verify_authenticode(path)
WinVerifyTrust
dwStateAction = VERIFY
status: 0 means trusted
WinVerifyTrust again
dwStateAction = CLOSE
meaning(status) explains
the VERIFY call's result
Complete lab source: signature_check.rs

signature_check.rs

windows-labs/src/bin/ · Rust, 141 lines

Show the codeHide the code
#[cfg(windows)]
mod app {
    use std::{
        ffi::OsStr,
        fs::File,
        io::Read,
        mem::size_of,
        os::windows::ffi::OsStrExt,
        path::{Path, PathBuf},
    };

    use anyhow::Context;
    use sha2::{Digest, Sha256};
    use windows::{
        Win32::{
            Foundation::{
                HANDLE, HWND, TRUST_E_EXPLICIT_DISTRUST, TRUST_E_NOSIGNATURE,
                TRUST_E_PROVIDER_UNKNOWN, TRUST_E_SUBJECT_FORM_UNKNOWN,
                TRUST_E_SUBJECT_NOT_TRUSTED,
            },
            Security::WinTrust::{
                WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0,
                WINTRUST_FILE_INFO, WTD_CACHE_ONLY_URL_RETRIEVAL, WTD_CHOICE_FILE,
                WTD_DISABLE_MD2_MD4, WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE,
                WTD_STATEACTION_VERIFY, WTD_UI_NONE, WTD_UICONTEXT_EXECUTE, WinVerifyTrust,
            },
        },
        core::PCWSTR,
    };

    fn sha256(path: &Path) -> anyhow::Result<String> {
        let mut file = File::open(path)?;
        let mut hasher = Sha256::new();
        let mut buffer = [0_u8; 64 * 1_024];
        loop {
            let count = file.read(&mut buffer)?;
            if count == 0 {
                break;
            }
            hasher.update(&buffer[..count]);
        }
        Ok(format!("{:x}", hasher.finalize()))
    }

    fn verify_authenticode(path: &Path) -> i32 {
        let wide_path: Vec<u16> = OsStr::new(path)
            .encode_wide()
            .chain(std::iter::once(0))
            .collect();
        let mut file_info = WINTRUST_FILE_INFO {
            cbStruct: size_of::<WINTRUST_FILE_INFO>() as u32,
            pcwszFilePath: PCWSTR(wide_path.as_ptr()),
            hFile: HANDLE::default(),
            pgKnownSubject: std::ptr::null_mut(),
        };
        let mut trust_data = WINTRUST_DATA {
            cbStruct: size_of::<WINTRUST_DATA>() as u32,
            dwUIChoice: WTD_UI_NONE,
            fdwRevocationChecks: WTD_REVOKE_NONE,
            dwUnionChoice: WTD_CHOICE_FILE,
            Anonymous: WINTRUST_DATA_0 {
                pFile: &mut file_info,
            },
            dwStateAction: WTD_STATEACTION_VERIFY,
            dwProvFlags: WTD_CACHE_ONLY_URL_RETRIEVAL | WTD_DISABLE_MD2_MD4,
            dwUIContext: WTD_UICONTEXT_EXECUTE,
            ..Default::default()
        };
        let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2;

        // SAFETY: both structures contain valid sizes and pointers that remain
        // alive through the call. UI is disabled and the file path is terminated.
        let status = unsafe {
            WinVerifyTrust(
                HWND::default(),
                &mut action,
                (&mut trust_data as *mut WINTRUST_DATA).cast(),
            )
        };

        trust_data.dwStateAction = WTD_STATEACTION_CLOSE;
        // SAFETY: closes provider state created by the verification call. The
        // same live structures and action identifier are supplied once more.
        let _ = unsafe {
            WinVerifyTrust(
                HWND::default(),
                &mut action,
                (&mut trust_data as *mut WINTRUST_DATA).cast(),
            )
        };
        status
    }

    fn meaning(status: i32) -> &'static str {
        if status == 0 {
            "trusted Authenticode signature"
        } else if status == TRUST_E_NOSIGNATURE.0 {
            "no embedded signature was found"
        } else if status == TRUST_E_EXPLICIT_DISTRUST.0 {
            "the publisher or signature is explicitly distrusted"
        } else if status == TRUST_E_SUBJECT_NOT_TRUSTED.0 {
            "the trust policy rejected the file"
        } else if status == TRUST_E_PROVIDER_UNKNOWN.0 {
            "Windows has no matching trust provider"
        } else if status == TRUST_E_SUBJECT_FORM_UNKNOWN.0 {
            "the provider does not understand this file form"
        } else {
            "verification failed with another trust-provider status"
        }
    }

    pub fn run() -> anyhow::Result<()> {
        let path = std::env::args_os()
            .nth(1)
            .map(PathBuf::from)
            .context("usage: signature_check <game.exe-or-dll>")?;
        anyhow::ensure!(path.is_file(), "{} is not a file", path.display());

        let hash = sha256(&path).with_context(|| format!("could not hash {}", path.display()))?;
        let status = verify_authenticode(&path);
        println!("File:   {}", path.display());
        println!("SHA-256: {hash}");
        println!(
            "Trust:  {} (status {:#010x})",
            meaning(status),
            status as u32
        );
        println!("The cache-only check did not modify the file.");
        Ok(())
    }
}

#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    app::run()
}

#[cfg(not(windows))]
fn main() {
    eprintln!("This Authenticode lab must run on Windows.");
}
#[cfg(windows)]
mod app {
use std::{
ffi::OsStr,
fs::File,
io::Read,
mem::size_of,
os::windows::ffi::OsStrExt,
path::{Path, PathBuf},
};
use anyhow::Context;
use sha2::{Digest, Sha256};
use windows::{
Win32::{
Foundation::{
HANDLE, HWND, TRUST_E_EXPLICIT_DISTRUST, TRUST_E_NOSIGNATURE,
TRUST_E_PROVIDER_UNKNOWN, TRUST_E_SUBJECT_FORM_UNKNOWN,
TRUST_E_SUBJECT_NOT_TRUSTED,
},
Security::WinTrust::{
WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA,
WINTRUST_DATA_0, WINTRUST_FILE_INFO,
WTD_CACHE_ONLY_URL_RETRIEVAL, WTD_CHOICE_FILE,
WTD_DISABLE_MD2_MD4, WTD_REVOKE_NONE,
WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY,
WTD_UI_NONE, WTD_UICONTEXT_EXECUTE, WinVerifyTrust,
},
},
core::PCWSTR,
};
fn sha256(path: &Path) -> anyhow::Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0_u8; 64 * 1_024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(format!("{:x}", hasher.finalize()))
}
fn verify_authenticode(path: &Path) -> i32 {
let wide_path: Vec<u16> = OsStr::new(path)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let mut file_info = WINTRUST_FILE_INFO {
cbStruct: size_of::<WINTRUST_FILE_INFO>() as u32,
pcwszFilePath: PCWSTR(wide_path.as_ptr()),
hFile: HANDLE::default(),
pgKnownSubject: std::ptr::null_mut(),
};
let mut trust_data = WINTRUST_DATA {
cbStruct: size_of::<WINTRUST_DATA>() as u32,
dwUIChoice: WTD_UI_NONE,
fdwRevocationChecks: WTD_REVOKE_NONE,
dwUnionChoice: WTD_CHOICE_FILE,
Anonymous: WINTRUST_DATA_0 { pFile: &mut file_info },
dwStateAction: WTD_STATEACTION_VERIFY,
dwProvFlags: WTD_CACHE_ONLY_URL_RETRIEVAL | WTD_DISABLE_MD2_MD4,
dwUIContext: WTD_UICONTEXT_EXECUTE,
..Default::default()
};
let mut action = WINTRUST_ACTION_GENERIC_VERIFY_V2;
// SAFETY: both structures contain valid sizes and pointers that remain
// alive through the call. UI is disabled and the file path is terminated.
let status = unsafe {
WinVerifyTrust(
HWND::default(),
&mut action,
(&mut trust_data as *mut WINTRUST_DATA).cast(),
)
};
trust_data.dwStateAction = WTD_STATEACTION_CLOSE;
// SAFETY: closes provider state created by the verification call. The
// same live structures and action identifier are supplied once more.
let _ = unsafe {
WinVerifyTrust(
HWND::default(),
&mut action,
(&mut trust_data as *mut WINTRUST_DATA).cast(),
)
};
status
}
fn meaning(status: i32) -> &'static str {
if status == 0 {
"trusted Authenticode signature"
} else if status == TRUST_E_NOSIGNATURE.0 {
"no embedded signature was found"
} else if status == TRUST_E_EXPLICIT_DISTRUST.0 {
"the publisher or signature is explicitly distrusted"
} else if status == TRUST_E_SUBJECT_NOT_TRUSTED.0 {
"the trust policy rejected the file"
} else if status == TRUST_E_PROVIDER_UNKNOWN.0 {
"Windows has no matching trust provider"
} else if status == TRUST_E_SUBJECT_FORM_UNKNOWN.0 {
"the provider does not understand this file form"
} else {
"verification failed with another trust-provider status"
}
}
pub fn run() -> anyhow::Result<()> {
let path = std::env::args_os().nth(1).map(PathBuf::from)
.context("usage: signature_check <game.exe-or-dll>")?;
anyhow::ensure!(path.is_file(), "{} is not a file", path.display());
let hash = sha256(&path)
.with_context(|| format!("could not hash {}", path.display()))?;
let status = verify_authenticode(&path);
println!("File: {}", path.display());
println!("SHA-256: {hash}");
println!("Trust: {} (status {:#010x})", meaning(status), status as u32);
println!("The cache-only check did not modify the file.");
Ok(())
}
}
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
app::run()
}
#[cfg(not(windows))]
fn main() {
eprintln!("This Authenticode lab must run on Windows.");
}

PCWSTR is only a pointer. It does not own the UTF-16 vector. wide_path, file_info, and trust_data all remain local variables until both WinVerifyTrust calls finish, so every pointer stays valid.

This is why the unsafe comment names lifetimes instead of saying only “Windows API call.” A useful safety comment explains the exact facts the compiler cannot verify.

In the Windows VM, build the checker from windows-labs and give it one EXE path at a time. Record each reported hash beside the exact file and build; the signature result applies only to the file you supplied.

Terminal window
cd windows-labs
cargo build --release --target i686-pc-windows-msvc --bin signature_check
.\target\i686-pc-windows-msvc\release\signature_check.exe `
"C:\Games\Wesnoth\wesnoth.exe"
.\target\i686-pc-windows-msvc\release\signature_check.exe `
"C:\Games\AssaultCube\bin_win32\ac_client.exe"
.\target\i686-pc-windows-msvc\release\signature_check.exe `
"C:\Games\UrbanTerror\Quake3-UrT.exe"

A SHA-256 value belongs to one exact file, so it means something only next to the game version and download source it came from. Re-run the tool after installing a mod or update: every file that changed gets a new hash, which is expected for the files the mod or update replaced and unexplained for any other.

Some Windows components are signed through catalogs rather than an embedded signature. This lab checks the embedded signature on the selected game EXE. If you expand it to inventory every DLL loaded beside the game, add explicit catalog-signature handling instead of reporting those files as simply unsigned.

Combine this lesson with module_inventory: inventory tells you what actually loaded, the hash identifies exact bytes, and Authenticode adds one publisher-trust signal.

The buildable source is signature_check.rs.

References: WinVerifyTrust, WINTRUST_DATA, WINTRUST_FILE_INFO, and Authenticode time stamping.

Knowledge check

Ask the right trust question

What can an Authenticode signature tell you that a standalone SHA-256 hash cannot?

Finished Lesson 10.8?

Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.

Chapter 10: 10 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.