Skip to content
NesarfPublic

About

Sign local build artifacts with Authenticode, keep a tamper-evident ledger of what was signed, and mark the formats that have no signature slot. Windows, no SDK required.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

OnlySign

Morphea Amethyst, reading in her study

Sign local build artifacts, and be able to say later what was signed.

Most people who need to sign a binary do not own a code signing certificate, have never made one, and do not have the Windows SDK installed. OnlySign starts from that position: it can make a certificate, find the artifacts in a project, sign them, and keep a record that an edit cannot quietly change.

The two facts this is built on

It needs no SDK. Set-AuthenticodeSignature is part of Windows PowerShell, which is on every Windows, and it signs a PE image and attaches an RFC 3161 timestamp just as well as the SDK's signtool. Verified here, not assumed. That single fact is the difference between a tool for people who already had the toolchain and a tool for everyone.

The engine is separate from the window. The window is the easy half. What is hard -- refusing to sign twice, refusing to leave a signature that expires with the certificate, pinning the signer, and keeping a record that shows when it has been edited -- lives in onlysign/engine/, where it can be tested without anyone clicking. The window, when it arrives, only draws what the engine already decided.

Using it

The window, once it is built:

dist\OnlySign.exe                 the window
dist\OnlySign.exe scan <project>  the command line, from the same binary

From a checkout, without building anything:

python -m onlysign.gui                          # the window
python OnlySign.py                              # the same window, through the packaged entry point
python -m onlysign selftest                     # run the engine against itself: 52 checks
python -m onlysign selftest --keep              # leave the scratch directory behind to look at
python tools/gui_smoke.py                       # drive the window the way a person would: 15 checks

python -m onlysign scan  <project>              # propose artifacts, with the reason for each
python -m onlysign certs                        # what could sign
python -m onlysign certs --create "CN=Your Name" --out .\keys
python -m onlysign sign  <file>... --cert <name> --dry-run
python -m onlysign sign  --repo <project> --cert <name> --ledger .onlysign\signings.ndjson
python -m onlysign check --ledger .onlysign\signings.ndjson
python -m onlysign trust --cert <name> [--install user-root]
python -m onlysign mark  <file>... --identity IDENTITY.local.json

--cert takes a full thumbprint, an unambiguous prefix of one, or part of a subject; an ambiguous answer is an error listing the candidates rather than a guess. Exit codes are meant to be used: 0 when everything asked for happened, 1 when something did not, 2 for a usage error -- so check in a pipeline fails when a signed artifact has stopped being the one that was signed.

There is no dependency and nothing to pip install.

The window, and what it does not do

The window decides nothing. Which artifacts exist, which certificate can sign, whether a file is already signed, whether the signature landed, whether a timestamp was obtained -- every one of those questions was answered before the window was written, by functions the command line calls too. What is in onlysign/gui.py is layout, plus a worker thread so that a scan or a signing run does not freeze it.

Project directory [_______________] [Browse]      the repository
Artifact directory[_______________] [Browse]      given one, the scan takes it at its word
[Scan]          → a list of what was found, each line saying why

Certificate     [ a code signing certificate with a private key, showing expiry ]
Signing           note, where signed copies go, where the record goes, timestamp authorities
Trust           ○ none  ○ user: publishers  ○ user: root  ○ machine: both
Mark              a file, an identity file naming who the mark belongs to, a year
                  [Preview] [Place the mark] [Read the mark back]
                  and, for when there is no file to carry it, a 512-byte sector or a floppy image
[Dry run] [Execute signing] [Check the ledger]

The Mark page decides nothing either, and there is less room for it to: which carrier applies to a file, whether the format is one that should be signed instead, and whether a mark's digest still describes the file are all asked of one function, mark.carrier_for, which is the same one the mark verb calls. That decision used to live in the command line, and moving it here is what keeps a file from being a zip to one door and unmarkable to the other. The one thing the page insists on is the identity: a mark says whose it is, so an empty identity refuses rather than inventing a name.

The signing buttons are switched off while the Mark page is showing, and back on when it is not. That is not decoration -- an action whose result would appear on a page the person is not looking at is an action that gets pressed by mistake. tools/gui_smoke.py walks both pages and checks that the buttons follow.

Signing and installing trust are two groups rather than one list of options on purpose, and that is the point most easily got wrong: a trust store decides what this machine accepts, and it is not a property of the signature. The two that matter are not equal either -- the root store is the trust anchor and decides whether anything verifies, while the publisher store alone changes no verdict anywhere. The window says so on the radio buttons themselves.

Execute signing asks once before it does anything, because a signature cannot be taken back off a file. Dry run never writes.

The window sizes itself to the screen it opens on, positions itself inside that screen, and scrolls its content if the content does not fit. None of that was true of the first version: on a 1536x864 screen the content wanted 1086 pixels, so the trust choices, the buttons and the result were off the bottom edge -- and setting only a size, not a position, left the window 40 pixels below the bottom of the screen as well. The buttons and the result are now pinned outside the scrolling area, because a set-once choice can be scrolled to and the two things a person reaches for after every action cannot be one scroll away from where they are looking.

tools/gui_smoke.py exists so this claim is tested rather than asserted: it opens the real window, fills in the real fields, presses the real buttons, and then reads the ledger the window wrote.

A built executable can be asked what is inside it, which is not the same as having been rebuilt: one build carried a verb its entry point could not reach and looked exactly like a successful one.

OnlySign.exe --tabs     the pages the window offers, and how many were expected
OnlySign.exe --check    builds the window, switches language twice, closes it

--tabs reads the names out of the notebook rather than out of this file, so a page that was written and never added shows up as a shortfall rather than as silence.

Why the executable has to answer for itself rather than a script looking at it. UI Automation was tried, and it cannot read this window's contents at all. The title bar and the menu come through -- 独家签署, then 文件, 设置, 语言, 关于 -- and everything below them is PaneControl of class TkChild with no name and no value: no tab names, no group boxes, no labels, no button text. What makes that a finding rather than a guess is the control: a plain tkinter window built on the spot (ttk.Notebook, two tabs, one label, one button) reads exactly the same way, so it is a property of Tk -- not of packaging, not of this tool, not of the machine's COM configuration. Two consequences, and the second is worth saying plainly: a script cannot verify this window from outside, which is why --tabs exists; and this window cannot be read by a screen reader either. That is a real limit of the tool as it stands, stated here rather than left implicit.

Building the executable

python tools/build.py                                    # dist/OnlySign.exe, the window
python tools/build.py --console --name OnlySign-console   # dist/OnlySign-console.exe, checkable
python tools/build.py --icon path\to.ico                 # a different icon than the one that ships

--console does not change the output name, so the second build has to say what it is called: run without --name it writes OnlySign.exe again and replaces the windowed build with a console one, which is a mistake that looks exactly like success until somebody double-clicks it.

One file, about 13 MB, no runtime to install. --python points at the interpreter that has PyInstaller, which is a dependency of building the tool and not of using it. The work directory goes to the system temp area, because a build leaves a great deal behind that nobody wants in a source tree. The build uses mark/onlysign.ico; --no-icon leaves it off.

The packaged binary has two doors: started with no arguments it opens the window, started with a command (scan, certs, sign, check, trust, mark) it behaves like the command line -- and the list it dispatches on is read from the parser, because a hand-kept copy of it fell one verb behind the first time a verb was added. That is what makes it testable -- an executable that only opens a window is one that cannot be checked from a script. Note that a one-file build starts a bootloader which then starts the real process, so the process id you get from launching it is not the one that owns the window.

Building the installer

python tools/build-msi.py --identity IDENTITY.local.json   # dist/OnlySign-1.0.0.0.msi

An MSI, built with WiX from the executables tools/build.py has already produced. It installs both of them under Program Files, a Start Menu shortcut and a desktop shortcut for the window, the licence and the notice beside them, and it removes all of it cleanly -- verified by installing and uninstalling rather than by reading the authoring. The console build is installed beside the window and not added to the PATH: a signing tool that quietly puts itself into every shell on the machine is a different product.

Three things about it are decisions rather than details.

The publisher in it is a token. installer/onlysign.wxs ships with neutral values and tools/build-msi.py fills them from a local identity file, so the repository says OnlySign / its publisher while a build made with an identity says whose it is. The same rule the executable build and the mark command follow.

The version is read out of the executable, not written down again. WiX wants four numbers and no letters, and an installer that says one version while the thing it installed says another is a support question with no answer, so the build asks OnlySign.exe what version it is. tools/build.py in turn refuses to build when that disagrees with pyproject.toml -- which it did: the binaries said 1.0.0 while the package said 0.1.0. A version written down twice is a version that is wrong once.

The licence page shows the licence that is shipped. The RTF is generated from LICENSE, escaped rather than retyped: a second copy of the terms is a second copy that can drift, and a licence page showing something other than the licence is worse than no licence page.

WiX's UI extension is a separate download and is not committed -- it is a third-party binary. tools/build-msi.py says where it expects to find it and how to get it if it is not there.

The installer is signed by this tool, itself. That is the point of it: the MSI's signature is carried inside the package as a DigitalSignature stream rather than as a detached catalogue file, which is the carrier the engine reads, and Windows reports it as Valid with the timestamp attached. onlysign check afterwards reports it UNCHANGED against the ledger record it wrote.

The face it wears

Six things in mark/ decide how the tool looks, and they are not interchangeable.

file what it is where it is used
onlysign.ico the close-up, seven sizes the executable, the installer, the file icon
onlysign-256.png the same crop, square the window's own icon, title bar and taskbar
chibi-about.png the whole figure the About window, left of the illustration
onlysign.png the crop at 512 the fallback when the others are missing
states.json + eight pose files what the character does the result of every action
portrait-about.png the whole illustration, at the height of the chibi the About window, beside the figure

One file sits outside mark/ for the same reason the others sit inside it. docs/portrait.png is the illustration at page width, no code reads it, and everything in mark/ travels inside the executable -- so a picture that only a web page shows belongs where a web page can reach it and a build does not carry it.

the close-up the whole figure
the close-up the whole figure
what goes on the file what goes in the About box

Four of the nine outcomes, in the order the character answers them:

signed: staff cast

failed: the cauldron

changed: the whisk

gone: asleep

The character is Morphea Amethyst. The art is the publisher's own and lives outside this repository, because source art is large and a repository is for what a build needs; the tiles and docs/portrait.png are the copies that are kept.

The split between the close-up and the chibi is deliberate. A close-up face is what an icon should be -- it survives being sixteen pixels wide -- while a whole figure at sixteen pixels is a smudge with a hat on it, and a close-up in an About box says less than the character does. One is the file; the other is the identity.

The character in the result box answers what happened. Nine outcomes, nine poses, and the rule for choosing a pose is that it matches the expression, not the props: this is what CrystalDiskInfo does, whose good and bad art are two different drawings of one character, one smiling and one grimacing, rather than one drawing recoloured. A picture whose expression matches the outcome tells you something before you read the text; a recoloured icon does not.

outcome pose why
idle holding the book a signer with a ledger, and idle is a state rather than the absence of one
working reading busy, and not yours to interrupt
signed staff cast it worked
failed the cauldron it did not, and something is smoking
unchanged holding the book the ledger agrees with the disk
changed the whisk the one outcome that should look like somebody has been at it
unsigned surprised a signature that was there is not
gone asleep the file the record names is not there to check
refused curious not an error: this format should be signed, not marked

Which pose goes with which outcome is mark/states.json, written by the art tool and read by the window -- not a list of file names in the window, because a window that names files is a window that breaks when one is renamed, and changing what "signed" looks like should be a change to the art.

The window used to have no icon at all. It showed Tk's own feather while the executable carried the right one: two faces for one program, and the wrong one on screen while it ran. install_window_icon sets it from the same PNG the packaging uses. Two checks in tools/gui_smoke.py guard this -- one that the picture is found, one that every outcome in the map reaches a picture -- and both were seen failing, with the files moved aside, before they were kept.

They are generated rather than hand-made:

python tools/prepare-art.py --source "<the art directory>" --out .onlysign-art

which cuts the character sheet into its sixteen poses, writes the standing art, writes the four files above, and writes the two portraits -- portrait.png at page width for docs/, and portrait-about.png at the height of the chibi for the About window. .onlysign-art/ is gitignored; the files the build reads are committed, because an icon that exists on one machine is an icon nobody else can build. So is the page-width portrait, for that reason and one more: a repository that cannot show what the tool looks like has kept its best argument in a folder nobody opens. The About window draws the figure and the illustration together, because a figure says who the window is and an illustration says what she looks like -- two claims, so two pictures rather than one replacing the other.

Two things about that script are worth knowing before changing it.

The sheet is cut where the background is, not at a quarter of the width. Measured: the four columns are separated at x 292..324, 607..642 and 921..959, where an even division would guess 313, 627 and 940 -- twenty pixels out, which is enough to slice a hat and leave it in the next tile. The rows are worse: one of their three separators is a single row of background at y=316, between cells of 310 and 300 rows. Nothing about "a quarter of 1254" finds that; something about "this row has no ink in it" does. A sheet whose separators do not partition it into equal pieces is refused rather than quartered on the assumption that it must be a grid.

Making the white background transparent cannot be lossless, and it is not the default. A flat sheet stores one colour per pixel and no longer stores how much ink made it, so a pixel reading (200, 180, 210) is ambiguous between pale ink at full coverage and dark ink at low coverage. Measured: taking alpha out and putting the result on the colour this window actually paints, (240, 240, 240), moves the mid tones by about 25 of 255 per channel -- a washed-out character beside the same art on white. So the tiles keep their background, and --drop-background is there for a destination that really is white.

Finding the artifacts

A scanner that returns a list of paths is not much use on its own -- the person reading it has no way to judge it. Every candidate here carries the reason it was proposed, and a path that two layers agree about shows both reasons:

app.exe  [a PE image, 3334144 bytes, unsigned]
    E:\project\dist\app.exe
    found: it is in a directory named 'dist'
    found: .github\workflows\release.yml says the output is dist/*.exe

note: installer.iss says the output is 'Output\DemoSetup', and there is nothing signable there

Four layers, each adding candidates rather than replacing them: the directory you named; directories named like an output directory; build files that declare where their output goes (Inno, NSIS, WiX, PyInstaller, electron-builder, Cargo, MSBuild, CI workflows -- including globs, because a workflow says dist/*.exe far more often than it names a file); and the local object library, which already knows paths this machine has seen. A declared output with nothing in it is reported as exactly that, because a thin list is usually a project that has not been built rather than one with no artifacts.

The rules the engine enforces

These are not preferences; each one is the answer to something that went wrong.

  • Never sign a file twice. Authenticode appends a certificate table instead of replacing one, so a second pass would leave the record of that signing describing the previous signature's output -- the one field that says what the build was.
  • A signature with no timestamp is treated as a failure. It stops verifying when the certificate does. Several authorities are tried in order; if none answers, the attempt is undone.
  • The engine undoes what a failure leaves behind. signtool restores the file when the timestamp cannot be reached. Set-AuthenticodeSignature does not: it appends a complete signature and leaves it there. So the engine puts back the length, the PE checksum and the certificate-table entry itself, and then proves it by comparing the file's hash with the hash from before.
  • A verdict and a fact are different questions. Get-AuthenticodeSignature consults the machine's security catalogs, so a Microsoft binary whose own signature was removed still answers "signed by Microsoft" -- with a Microsoft timestamp. Asking it whether our signature landed would get an answer about somebody else's. The engine asks the file instead, and keeps the two answers apart.
  • The signer is pinned by thumbprint, and the key is taken from the certificate store, so no password ever appears on a command line where every process on the machine can read it.
  • A private key is not left open to everyone. A certificate made here has its key file taken out from under inherited permissions, down to the owning account, the administrators and the system. On the machine this was written on, the password file had been locked down and the key beside it had not: it was readable and writable by every authenticated user.

The ledger

Every signing appends one line of JSON to a ledger: the file, its size, its SHA-256 as signed and before signing, the signer, the thumbprint, whether a timestamp was obtained, which authority provided it, and the hash of the previous line.

The format is not this project's invention -- an existing signing toolchain already writes it, and two implementations writing one file have to write the same bytes. What matters about it:

  • it is UTF-8 with no byte order mark, read and written as such, because reading it with the machine's ANSI code page turns a Chinese note into mojibake and can make a whole record unparseable;
  • the prev field makes an edit to the ledger a visible break rather than a silent one;
  • the tip is read and the line written inside one lock, because when those are two operations two signings that overlap name the same parent and the chain reports a fork -- which reads as "someone edited the ledger" on a file nobody touched.

Asking whether it is still true

onlysign check compares the ledger against the disk and keeps the answers apart, because they are not the same kind of news:

verdict what it means
unchanged the bytes on disk are the bytes that were signed
superseded the path holds a later build this ledger also signed -- routine, and named
changed the bytes match no signing on record. This is the one worth reading
signer differs the bytes ARE the ones on record and the signer read for them is not the signer on record. Nothing was modified: the answer came from somewhere other than the file (a catalog is the usual cause, and for an MSI it is what happens whenever the package's own stream cannot be read), or the record names the wrong certificate. It has its own verdict and its own sentence in the summary because reporting it as changed accused somebody of an unrecorded edit on a file whose bytes sat there unaltered
now unsigned the file carries no signature of its own any more
gone the recorded path does not exist -- which is what a signed-in-a-temp-directory record becomes

An earlier version reported the routine case and the alarming one identically, and six alarming lines buried the one that mattered. changed, signer differs and now unsigned make the command exit non-zero, so a pipeline notices when a signed artifact has stopped being the one that was signed.

How this talks to Windows

Two traps cost a session on the machine this was written on, and both are fixed structurally rather than remembered, in onlysign/engine/ps.py:

  • A script that has to be decoded can be misdecoded. Windows PowerShell reads a .ps1 with no byte order mark using the machine's ANSI code page; a script containing non-ASCII text can be misread before it runs. Scripts here are never written to disk: they travel as base64 UTF-16 (-EncodedCommand), so a path with a Chinese character in it is not merely tolerated -- the ambiguity does not exist.
  • A module that cannot be found is not an error you get to see. A PowerShell 7 session puts its own module directories at the front of PSModulePath, so a 5.1 child started from there fails to load its own Microsoft.PowerShell.Security and Set-AuthenticodeSignature simply ceases to exist. The child is handed a clean module path.

Layout

OnlySign.py                  the entry point that gets packaged; one binary, two doors
onlysign/engine/ps.py        the only place that talks to Windows
onlysign/engine/ledger.py    the record, and the lock that keeps its chain honest
onlysign/engine/pe.py        what a file is; what a signature physically is; how to undo one
onlysign/engine/certs.py     what can sign, and how one comes to exist
onlysign/engine/signer.py    putting a signature on, checking the right one landed
onlysign/engine/discover.py  finding the artifacts, and saying how each one was found
onlysign/engine/audit.py     asking the ledger whether it is still true
onlysign/engine/trust.py     the two stores, and the difference between them
onlysign/engine/workflow.py  the order of operations, which is where the rules live
onlysign/engine/bootmark.py  a mark in 512 bytes, for the machines that cannot verify anything
onlysign/mark.py             the mark line, and the carriers the verb puts it in
onlysign/gui.py              the window; layout and a worker thread, nothing else
onlysign/cli.py              the engine with a keyboard instead of a window
onlysign/selftest.py         52 checks of the engine, in a scratch directory
tools/build.py               building the one-file executable
tools/build-msi.py           building the installer, out of the executables that build made
installer/onlysign.wxs       the installer's authoring; the publisher in it is a token
tools/gui_smoke.py           15 checks that drive the window itself
tools/cfb-probe.py           what a compound file's header says, against what the reader returned
tools/boot-check.py          boots the mark sector on real firmware (needs QEMU; not a check)
dist/                        built executables and installer (not committed)

A mark, and where it does not belong

Two verbs, and they are not the same one. Signing is what a platform gives you: a PE certificate table, an MSI stream, a CAB's appended blob. It needs cryptography and something to trust. Marking is the other one: text placed where it will still be later, needing no cryptography at all. onlysign/mark.py carries it, and the line it writes is

ONLYSIGN-MARK v1 | (c) PRIMARY [SECONDARY] | sha256:… | kind:hash-record

Square brackets rather than a dash: a secondary is a connotation of the primary, not a peer of it. kind: says how strong the mark is, because a mark does not make a file trustworthy and this does not pretend otherwise. And a mark goes on before signing, never after — changing a file's bytes is what stops every signature over them from verifying, so a mark added afterwards turns a signed artifact into a broken one.

That boundary is why an MSI is not a candidate. Two things were measured before deciding it, and they point the same way.

The first is that attribution on an MSI is already carried by something stronger. A package's certificate subject is inside what its signature covers, so the O= in it is a claim the signature vouches for — read back from signed packages, and it needs no code.

The second is what the other candidate would cost. The summary information stream is the field Explorer's Details pane shows, and the platform's own API writes it, but it has no spare room (the declared section size equalled the available bytes on every package looked at), and the string it stores goes through the machine's ANSI code page: on a code page 1252 machine (c) stores as A9, while on the 936 machine this was measured on it cannot be encoded at all and came back as 7F, even though \u4e2d round-tripped untouched. Whether a mark survives there is a property of the machine rather than of this tool.

So an MSI is signed, not marked, and the mark module exists for the formats that have nowhere to sign — ZIP today, and whatever else that turns out to be. The MSI equivalent of the PE version resource is the build's business, not the signer's: the build states the attribution and the signer vouches for it, which is the same division of labour that puts company and trademarks into an executable before anything signs it. For an MSI that means the summary information at build time, where the code page is known and the package is still the builder's to write.

It is wired in now, as onlysign mark. The verb marks files that have nowhere to sign, produces a mark sector, and refuses the formats that do have somewhere:

python -m onlysign mark release.zip --identity IDENTITY.local.json --year 2026
python -m onlysign mark --sector mark.bin --identity IDENTITY.local.json
python -m onlysign mark --floppy boot.img --identity IDENTITY.local.json   # for an emulator to boot

The identity is required rather than defaulted, and that is the one rule the command enforces hardest: a mark is a claim of ownership, so it is an input and never a constant. A tool that invents a name to put in a copyright line has done the thing a mark must not do.

Which carrier applies is one decision, in one place. mark.carrier_for(path, signable=...) answers it -- zip, tar, ISO, refused, or no carrier yet -- and both doors ask it. It used to be a chain of ifs in the command line, which was fine while the command line was the only door; the window made it a second implementation waiting to happen, and a file that is a zip to one door and unmarkable to the other is the failure that would not be visible from either. mark.looks_like_tar moved there with it, and the self-test checks that the command line no longer decides for itself.

A digest inside a zip comment cannot describe the file that contains it. The comment is part of the archive, so the value would have to depend on itself, and any number written there would be wrong the moment it was written. So the digest is taken over the archive without any comment: find the end-of-central-directory record, take everything before the comment-length field, append two zero bytes, hash that. Zeroing the length is what makes it one number for the archive whether or not a comment is present, so the same mark checks out against the marked file and against the same file with the comment stripped. The order matters and getting it wrong is silent, so the digest is taken and the comment written inside one function rather than left to the caller -- measured, one byte flipped anywhere in the archive makes the digest stop matching while the mark text is unchanged, which is what a hash in a mark is for. --no-digest writes the mark with kind:visibility instead, for a carrier where there is nothing to hash.

A tar takes a member, not a comment. A tar has no comment field at all, so the mark goes in as one appended member named ONLYSIGN-MARK: tar -tvf lists it and tar -xOf archive ONLYSIGN-MARK prints it, with no knowledge of this tool on the reading side, and nothing already in the archive is touched. Marking twice replaces that member rather than adding a second one, so the archive returns to the bytes it arrived as.

The digest there is taken over the archive's members up to the mark, and the trade-off is worth stating: it is reproducible from the marked file alone -- which is the property that makes it checkable -- and it is not the number sha256sum prints, because the trailing padding and the end-of-archive marker are excluded. Measured while writing it: a two-member tar was 10240 bytes with its members ending at 9216, so the two definitions are genuinely different numbers. A digest a marked archive could never recompute would be the worse of the two.

An ISO takes its Application Use field. An ISO 9660 image has one part the standard sets aside for the application: 512 bytes at offset 883 inside the Primary Volume Descriptor at sector 16. The mark goes there, so nothing the filesystem cares about is touched -- and the descriptor's own CD001 is what recognises the image, not its extension. Verified against a real ISO made with pycdlib: the image still opens, the volume identifier is intact, and the file inside it still reads out afterwards.

Its digest is taken with that field zeroed, which is the zip's idea again: a reader clears the same 512 bytes and hashes, and gets the number the mark records. Hashing the image as it stands would make the value depend on the mark carrying it.

An MSI, a PE and a CAB are refused, with the reason printed. That is not a missing feature: those formats have a slot a signature goes in, so on them the attribution belongs in something the signature covers. Marking one anyway would put a claim nothing vouches for beside a claim something does, and the weaker one is the one a reader is likelier to believe.

One carrier in that family is finished, and it is the one for the oldest machines there are. onlysign/engine/bootmark.py builds a 512-byte boot sector whose own code prints the mark on the BIOS teletype and down COM1, and stops. It is the floor of the whole idea: a boot sector is not a format with a slot for attribution, it is the first sector of a disk, read by firmware before any operating system exists, on machines that cannot verify a signature and in some cases cannot run anything modern at all. It is also visibility only, and says so in its own kind: -- anyone can write those 512 bytes and nothing vouches for them.

What it is worth is bounded by what it is; what it is is verified. The assembly is nasm's, the bytes were disassembled with ndisasm to confirm they are the instructions intended, and the self-test executes the sector on a real x86 emulator -- Unicorn, the CPU core QEMU itself uses -- capturing the teletype characters and the port writes and requiring the whole mark to come out of both before the CPU halts. Firmware is stubbed, because Unicorn has no BIOS: that half is stated rather than glossed. The check compares against the entire line rather than against "something was printed", and that is not pedantry -- advancing the program counter by hand in the interrupt hook stops the sector after exactly one character and looks like success.

Of the two checks, the one that does not need an emulator runs everywhere, and the one that does reports not tested when Unicorn is absent, which is the outcome that mechanism exists for.

The firmware half is confirmed separately, because Unicorn has no BIOS and cannot confirm it. QEMU loaded the image as a floppy, handed the sector control, and the mark came back down the serial line exactly as the sector wrote it -- QEMU 11.1.0, and the identity's own names rather than the test's. That run is tools/boot-check.py, which builds the image and boots it:

python tools/boot-check.py --identity IDENTITY.local.json

It is a script rather than a thirtieth-something check for the same reason terminal-probe.ps1 is: it needs something a normal run does not have, and a suite that quietly skips checks on a machine without QEMU would be worse than one that does not make the claim. QEMU is optional; the suite's coverage does not change with it.

Licence

Apache-2.0. The full text is in LICENSE and is carried unmodified, appendix and all: Apache-2.0 asks for that, and its own appendix says where the holder and the year belong -- a NOTICE file, which is NOTICE. The copyright line is there rather than in the licence, and that is not a stylistic choice.

Why this section exists at all: the repository had no licence for its whole life until this was written, and no licence does not mean "free to use" -- it means all rights reserved. A tool whose entire purpose is to be run on other people's machines was, in the only sense that legally counts, not usable by anyone. Saying nothing is worse than choosing wrongly, because a reader fills the silence with whatever they find convenient.

The bundled typeface in mark/fonts/ is Cinzel, under the SIL Open Font License 1.1, and carries its own terms as mark/fonts/OFL.txt; the Apache License above does not cover it. Everything else -- the tool, the engine, the window, the mark carriers -- is under Apache-2.0, including the explicit patent grant, which is the part of it that matters for something meant to be embedded in somebody else's build.

Status

Built and checked at every level:

what how it is checked
the engine python -m onlysign selftest -- 52 checks, in a scratch directory
the window python tools/gui_smoke.py -- 15 checks that open it and press its buttons
both PowerShell editions the same 52 checks under Windows PowerShell and under PowerShell 7
the executable about 13 MB, one file: lists certificates, answers scan --help, opens its window
reading a container python tools/cfb-probe.py <dir> -- every compound file in it, against its own header

Three of the 38 need a timestamp authority, which is the only part of this toolchain that is not on this machine, so they can report a fourth outcome: not tested. A run that reached everything says 38 check(s) passed, 0 failed; a run that could not says so in those words and names the three. This exists because of what the alternative cost: the first version asserted that the first authority answered, so one flaky answer from timestamp.digicert.com was reported as signing did not succeed, and the check after it -- which needs a signed file to refuse a second signature on -- then reported signing an already signed file was allowed, because the rollback had left it nothing to refuse. Both read as defects in the tool. Neither was. What the tool does when no authority answers is checked with no network at all, by a timestamp failure leaves the file untouched and unsigned.

That flake turned out to be hiding a real defect underneath it, which is why it was worth chasing rather than retrying. An authority that answers without timestamping the signature was treated differently from one that does not answer: the first ended the list and failed the signing, the second passed to the next authority. The list exists so that one authority failing is survivable, so that first behaviour threw away two working authorities because a third misbehaved. It now rolls that attempt back -- safe, because the rollback is byte-exact, and signing on top of the signature just left would append a second one -- and passes to the next. The rule is signer.attempt_outcome, pulled out of the loop so it can be checked directly: the state that matters, an authority that answers without timestamping, could not be produced on demand here, because a refused connection and a refusal in the timestamp protocol both end the attempt before anything is written. It was seen once in 125 runs, which is also why the rule is checked rather than the run.

The last two rows are verified rather than assumed. The packaged build was run and its window opens, because tcl/tk having been bundled correctly is not something a build log tells you. The checks run under both editions because .NET methods and module paths are not the same in both -- the first version of this tool named one edition's paths as constants.

Nothing is known to be missing in what it reads. Both package formats it was thought to be unable to read turned out to be readable, and one of them for a simpler reason than expected:

  • MSI keeps its signature as one stream among many inside a compound file, so that one did need a reader -- onlysign/engine/container.py, checked against packages signed here.
  • CAB does not: measured on a cabinet signed with this tool, cbCabinet ends at byte 132 and the PKCS#7 begins exactly there, appended the way a PE's certificate table is. The .NET call the engine already used had been reading it all along, and a reader written for it was deleted after the measurement showed it was looking in abReserve, which holds something else.

What the compound-file reader got wrong is worth recording, because it was wrong in a way that a passing test suite could not see. Sectors were found at 512 + index * sector_size. Sector 0 begins after the header, so the offset is (index + 1) * sector_size -- and those two expressions are the same arithmetic whenever a sector is 512 bytes. Every file it was tried on was one, including the hand-built fixture in the self-test. On a 4096-byte-sector package the offset landed 3584 bytes early, inside the allocation table rather than at the directory, so the reader followed a table describing something else, walked a chain that was never the directory's, and parsed the sectors it reached as directory entries. A 1.2 MB package returned 64 MB of entries -- bounded by a byte cap, so it did not die, and the real entries were among them, so lookups still mostly worked. Measured afterwards across 931 compound files in the Windows installer cache: 371 of the 382 that carry a signature use 4096-byte sectors, which is to say the embedded signature of nearly every one of them was being missed and the question was being handed to Windows instead -- and a machine's answer comes from its security catalogs, which is the ambiguity the reader exists to remove. Two further changes came out of it: a chain that revisits a sector is now refused rather than walked to a cap, and the directory walk is bounded by the sector count the header states instead of by a byte ceiling. The self-test builds its fixture at both sector sizes, and it was confirmed to fail against the old arithmetic before being kept -- a regression test that has not been seen to fail is a guess.

Reading an MSI's signature also needed Add-Type -AssemblyName System.Security. Windows PowerShell does not resolve SignedCms without it, so on that edition the whole path failed with "type not found" while working under PowerShell 7 -- a difference between the two editions of exactly the kind the both-editions row above exists to catch, and it had not been caught because the failure came back as "this package carries no signature" rather than as an error.

Attribution on an MSI was measured rather than assumed, and the answer is that the signed carrier is already there. A package's certificate subject is inside what its signature covers, so O= in the subject is a claim the signature vouches for -- stronger than any field the installer's own tables hold, and it needs no code. The other candidate is the summary information stream, which is what Explorer's Details pane shows; it does exist (\u0005SummaryInformation) and the platform's own API writes it, but two things were measured about it. It has no spare room -- the declared section size exactly fills the stream on every package looked at -- so writing to it is a container rewrite, not a patch. And the string it stores went through the machine's ANSI code page: on a code page 1252 machine (c) stores as A9, on the 936 machine this was measured on it cannot be encoded at all and came back as 7F, while \u4e2d round-tripped untouched. So whether a (c) survives in that field is a property of the machine, not of this tool, which is why the mark's own ASCII form is the one to place there -- and why placing it belongs before signing, like every other mark.

A terminal cannot be tested by a script, so tools/terminal-probe.ps1 exists for a person to run in each one. What the gate's output depends on is a short list -- whether stdout is a console, what it is encoded as, how wide the window is -- and nothing in the tool branches on which terminal it is in.

About

Sign local build artifacts with Authenticode, keep a tamper-evident ledger of what was signed, and mark the formats that have no signature slot. Windows, no SDK required.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages