Updates¶
Sigil ships a small, signed update engine built into every exe package:
Setup.exe /Update checks a channel manifest you host, and — when a newer
version is available and its signature checks out — downloads and runs it,
performing the same version-aware upgrade described in
Upgrades & downgrades. There is no separate updater binary and
no delta-patch SDK yet (see the ADR on delta-update deferral);
today's engine always fetches the full package.
This guide covers the updates: manifest block, the channel-manifest
contract your hosting must serve, the security model, /Update's exit codes,
and the web installer (pack --payload web) that lets your first-download
Setup.exe stay tiny.
How it works¶
Setup.exe /Updateruns (headless, e.g. from a scheduled task, or headed from inside the installed app). It reads theupdates:block that was stamped into the exe at pack time.- Fetch. It downloads the JSON body at
updates.manifestUrl, and the detached signature atupdates.manifestUrl + ".sig". - Verify, then parse. The detached signature is verified against
updates.signingKeyfirst, over the exact fetched bytes; only then is the JSON parsed and validated against the channel-manifest contract (below). An unsigned or tampered manifest is a hard reject — the JSON parser never even sees it — and a malformed one is rejected too. Nothing is downloaded or run in either case. - Check freshness and replay. Signature proves who minted the document,
never when. So the authenticated manifest is then checked against its own
issuedAt/expiresAtvalidity window (±5 minutes of clock-skew tolerance), against a client-enforced 30-day maximum age measured fromissuedAtregardless of whatexpiresAtsays, and against the highestsequencethis machine has previously accepted for this app. Any refusal stops the run — see Freshness and replay protection. The high-water mark is persisted toupdate-sequence.txtin the per-app state directory (%ProgramData%\Sigil\<AppId>for a machine install,%LocalAppData%\Sigil\<AppId>for a per-user one) the moment the manifest is authenticated and judged fresh — not after the install succeeds, so a failed install cannot reopen the replay window. - Compare versions. The channel manifest's
versionis compared against the installed version using the same dotted-version comparison Upgrades & downgrades uses. Same-or-older → up to date, clean exit. - Download + run. If newer, the full package at
packageUrlis downloaded and verified againstsha256— and, subject toinstaller.require_signed_downloads, Authenticode-checked from inside the window where the verified file handle is still held — then the downloadedSetup.exeis launched to perform the actual upgrade (silently for a headless/Update, or with its own wizard visible for a headed one). This process never re-implements install logic itself — it hands off to the new version's own installer and propagates its exit code.
The updates: manifest block¶
updates:
channel: stable
manifestUrl: https://updates.example.com/stable/channel.json
signingKey: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...=="
deltaTargets: 3
| Field | Required | Notes |
|---|---|---|
channel |
no | Free-form label (default stable). Purely descriptive — it names which channel manifest you're pointing at (e.g. stable vs beta); Sigil does not host or resolve channels for you. |
manifestUrl |
for /Update to work |
HTTPS URL of the channel manifest JSON. Its signature is expected at the same URL with .sig appended. Omit this field entirely to ship an installer with no update capability — /Update then exits cleanly with a distinct "not configured" code (below) rather than failing. |
signingKey |
for /Update to work |
The base64-encoded X.509 SubjectPublicKeyInfo (SPKI, DER) encoding of the ECDSA P-256 public key that signs your channel manifests — i.e. what ECDsa.ExportSubjectPublicKeyInfo() returns, base64-encoded. This is embedded in the packed Setup.exe as the trust anchor; see Security model. |
deltaTargets |
no | How many previous versions a future delta-patch generator would target (0–20, default 3). Not yet consumed by the update runtime — full-package updates ship first; see the delta-update deferral ADR. Safe to set now for forward compatibility; it has no effect today. |
Generating the signing key pair. Any ECDSA P-256 (
nistP256/secp256r1) key pair works — for example, in .NET:Keep the private key off the build machine — it is not a Sigil input at all, only the public key is. See Security model for why this direction of trust matters.using var ecdsa = System.Security.Cryptography.ECDsa.Create( System.Security.Cryptography.ECCurve.NamedCurves.nistP256); var publicKeyBase64 = Convert.ToBase64String(ecdsa.ExportSubjectPublicKeyInfo()); // publicKeyBase64 -> updates.signingKey // Keep the private key (ecdsa.ExportPkcs8PrivateKey()) offline; it signs // every channel manifest you publish and is never embedded anywhere.
The channel manifest contract¶
manifestUrl must serve a small JSON document — this is what /Update
fetches and checks version freshness against:
{
"schemaVersion": 1,
"version": "1.4.0",
"packageUrl": "https://cdn.example.com/releases/MyApp-1.4.0-x64-Setup.exe",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"minFromVersion": "1.0.0",
"issuedAt": "2026-09-08T12:00:00Z",
"expiresAt": "2026-09-15T12:00:00Z",
"sequence": 42
}
| Field | Required | Notes |
|---|---|---|
schemaVersion |
yes | Must be 1. Any other value (including an omitted field, which defaults to 0) is rejected outright — a future breaking schema bump fails loudly on an old installed runtime rather than being silently misread. |
version |
yes | The advertised package's dotted version string, compared against the installed version the same way an upgrade decides freshness (see Upgrades & downgrades). |
packageUrl |
yes | Must start with https://. Mirrors the same insecure-URL stance the http_download install step enforces at pack time (SIG0235), applied here at update runtime instead. |
sha256 |
yes | 64-character hex SHA-256 digest of the file at packageUrl. Checked before the download is trusted; a malformed (wrong-length/non-hex) value is refused up front rather than always mismatching. |
issuedAt |
yes | ISO-8601 timestamp of when this manifest was minted. Must carry an explicit zone — a trailing Z or a numeric offset such as +02:00. A zone-less local timestamp is rejected. |
expiresAt |
yes | ISO-8601 timestamp (same format rules) after which the manifest must not be acted on. Must not be earlier than issuedAt: an empty validity window is refused, not treated as permissive. That particular refusal happens in the freshness check rather than the parse, so it exits 8, not 7. |
sequence |
yes | Non-negative monotonic integer. Increment it on every manifest you publish for this channel; a client refuses any value strictly lower than the highest it has already accepted for this app (an equal value is accepted). |
minFromVersion |
no | The lowest installed version this package can update from. An installed version below the floor is treated as "an update exists, but not for you" (see exit codes) rather than silently skipped or force-installed. Omit it if any older installed version may take this package. |
Breaking change —
issuedAt,expiresAtandsequenceare required. They were added by ADR-011 and every shipped installer enforces them. A channel manifest that omits any of the three is malformed (SIG0320), and/Updateexits7without downloading anything — so a publisher who mints the old five-field document ships an update channel that no installed client will take. They are required rather than optional on purpose: an optional freshness field is defeated by replaying a correctly signed manifest that predates it, which is the exact attack.
Each fetch is exact bytes in, exact bytes verified — the channel manifest is
never re-serialized or canonicalized before its signature is checked, so
whatever bytes your server returns for manifestUrl are the exact bytes the
.sig must cover. All three freshness fields therefore live inside the
signed byte range by construction; there is no sidecar or header a freshness
value could arrive on unsigned.
Freshness and replay protection¶
The signature says who minted the manifest, not when. Without a second gate, an on-path attacker or a compromised CDN can replay yesterday's correctly signed manifest indefinitely — freezing your users on a version with a known vulnerability, or steering them onto an intermediate version that is newer than what they have and still exploitable. So the client applies four rules to every authenticated manifest, in this order:
| Rule | Refusal |
|---|---|
| Not expired | now > expiresAt + 5 min → refused as stale. |
| Not too old | now > issuedAt + 30 days + 5 min → refused, regardless of expiresAt. expiresAt is publisher-chosen; a manifest set to expire in the year 3000 would otherwise opt out of the whole defence. This ceiling is enforced by the client and is not configurable from the manifest. |
| Not future-dated | issuedAt > now + 5 min → refused rather than guessing which clock is wrong. |
| Not replayed | sequence below the machine's persisted high-water mark → refused as a rollback to a superseded manifest. |
The ±5-minute allowance on both ends of the window is clock-skew tolerance: a machine with a wrong clock is far more common than one under an active replay, and five minutes is negligible against an attack that is only interesting over days.
Each of these four refusals exits 8, the hard-security-reject code —
distinct from 7, which is what a malformed manifest (a missing or
unparseable issuedAt, say) returns.
Practical consequences for your publishing process. Pick an expiresAt
that comfortably exceeds your release cadence but stays well inside 30 days;
re-mint and re-sign the manifest before it lapses, even when the advertised
version has not changed, or clients stop seeing updates. Bump sequence on
every publish and never lower it — a machine that has accepted sequence 42 will
refuse 41 forever. (Re-publishing at the same sequence is accepted, so
re-minting an unchanged manifest to extend its window does not force a bump —
but bumping anyway keeps the counter meaningful.)
Security model¶
The channel manifest is signed with ECDSA P-256, verified entirely with
.NET's built-in System.Security.Cryptography.ECDsa — no third-party crypto
library, no native dependency. See
ADR-009 for the full
rationale (in short: the update runtime does not reference Sigil's signing
stack at all, and BCL ECDSA keeps it that way at effectively zero size cost).
- Detached signature. The signature lives at
manifestUrl + ".sig"as a sibling HTTP resource — never inline in the JSON. It is the base64 encoding of a raw IEEE P1363 (r‖s, 64-byte) ECDSA P-256 signature over SHA-256 of the manifest's exact fetched bytes — i.e. exactly whatECDsa.SignData(bytes, HashAlgorithmName.SHA256)produces with .NET's default signature format. No ASN.1/DER signature encoding is involved. - The curve is pinned. A key that imports cleanly but is not P-256 — a
P-384 SPKI, say — is rejected on its key size, not accepted merely because
the import did not throw. And an
updates:block with a missing or emptysigningKeyis a hard reject, not "no key, so no verification needed": an app that ships update capability without a trust anchor cannot be trusted to auto-update. - Trust anchor: the pack-embedded public key.
updates.signingKeyis read from the manifest at pack time and stamped into the producedSetup.exe. The already-installed application — not the update server — is the source of truth for which key is trusted. A hostile or compromised manifest host cannot supply its own key and self-certify; it can only serve content that verifies against the key your users already have installed. - Hard rejection. Every failure mode — a bad signature, a missing or
malformed key, a wrong-curve key, an unsigned manifest, a network failure
fetching the
.sig— is treated as a hard reject: nothing is downloaded, nothing is run, and the failure is distinguished in the exit code and log from an ordinary network/availability failure (below). A tampered channel manifest never silently "wins" by falling back to some looser mode. - Package integrity via
sha256. Independent of the manifest's own signature, the downloaded package itself is verified against the channel manifest'ssha256before it is ever executed — the same download + verify path thehttp_downloadinstall step already uses.
Downloaded-package signature policy¶
sha256 proves the bytes match what the manifest advertised. It does not
prove the publisher signed them — the manifest could have advertised anything.
So, separately, the downloaded Setup.exe can be Authenticode-checked
immediately before it is launched, from inside the window where the verified
file handle is still held (so nothing can swap the file between check and
run). Whether that check is armed is declared by installer.require_signed_downloads:
| Value | Behaviour |
|---|---|
sign_declared (default) |
The check is armed only when this manifest declares a sign: block. This is the historical behaviour, kept as the default so no existing manifest changes meaning — but note it infers "should downloads be verified?" from "did the publisher configure signing for their own output?", which are different questions. When the check is not armed, that is said out loud on the log rather than passing silently. |
always |
Armed regardless of whether a sign: block exists. |
always_verified_revocation |
As always, and additionally refuses a binary whose revocation status could not be established. By default an unreachable CRL/OCSP responder is a warning, because refusing would break installs behind a captive portal, on an air-gapped network, or inside a locked-down enterprise egress. Turn this on when you know your audience is reliably online. |
An unrecognized value is a pack-time diagnostic, SIG0326.
This setting governs update packages and the web-stub payload. It does not
govern prerequisites: a downloaded prerequisite installer is always
signature-checked, and carries its own per-prerequisite allow_unsigned
opt-out — see Prerequisites.
/Update behavior and exit codes¶
Setup.exe /Update runs headlessly by default (no wizard), logging each
stage to the console and, if /LOG was also passed, to the log file — see
/LOG below. Codes 6-9 are dedicated to /Update and are not
used by the install/uninstall path, which has its own set — 0, 1, 2, 3,
4 (files in use), 5 (another setup instance is already running), 64
(usage), 78 (native-runtime bootstrap refused) and 3010 (success, reboot
required). See the setup.exe reference for the
full table.
| Exit code | Meaning |
|---|---|
0 |
Up to date — nothing to do, or the installed version is already the same or newer than the channel manifest advertises. |
6 |
Not update-enabled — the manifest declared no updates.manifestUrl, so there is nothing to check. |
7 |
Check/apply failed — a network failure fetching the channel manifest or its signature, a malformed channel manifest (SIG0320 — including one missing issuedAt, expiresAt or sequence), an implausible sha256, or a failed package download / child spawn. An operational failure: nothing was changed. |
8 |
Hard security reject, kept distinct from 7 so a security event is unambiguous in logs and automation. Covers three distinct causes, so do not read 8 as "tampering" alone: (a) the channel manifest's signature did not verify (SIG0321) — tampered, unsigned, wrong key, or wrong curve; (b) the manifest was authentic but failed the freshness/replay check — expired, older than 30 days, future-dated, an empty validity window, or a sequence lower than one already accepted; (c) the downloaded package was refused by Authenticode under the require_signed_downloads policy. The log line distinguishes them. |
9 |
Not eligible — a newer version exists, but the installed version is below the channel manifest's minFromVersion floor and cannot take this package via this path. |
| (the downloaded installer's own code) | When a newer package is downloaded and run, /Update exits with whatever exit code that child Setup.exe returns (typically 0 on success, 3010 if it reports reboot-required) — /Update propagates it rather than inventing its own "upgrade succeeded" code. |
/Update /silent vs. headed¶
- Headless (the default for
/Updaterun standalone, e.g. from a scheduled task): every stage is reported to the console/log only; the downloaded childSetup.exeis itself launched silently (forwarding only the resolved/allusersor/currentuserscope flag), so the whole chain is unattended end to end. - Headed (driven from inside the installed app, e.g. an in-app "Check
for updates" action): the same check → verify → download decision logic
runs, but progress is reported through a small branded window instead of
the console, and — unlike the headless path — the downloaded child
Setup.exeis launched without/silent, so the user sees the new version's own install wizard take over for the actual upgrade. Both paths share one decision engine; only the reporting sink and the child's silence differ.
/LOG¶
/Update honors the same /LOG[=path] convention as install and uninstall
(bare /LOG resolves to %TEMP%\sigil-<appid>.log; /LOG=path picks an
explicit file) — every stage of the update flow (channel checked, signature
verified, version compared, download started, child launched, final exit
code) is written to it alongside the console.
The web installer¶
By default, sigil pack stamps your full app payload directly into
Setup.exe ("embedded" payload — the original, unchanged behavior). For an
app distributed primarily over the web, a multi-hundred-MB embedded
Setup.exe is a poor first download. --payload web instead produces:
- The normal, full package artifact (
<App>-<version>-<arch>-Setup.exe), exactly as before — this is what gets uploaded to your CDN/host. - A second, tiny stub
Setup.exe(suffixedWebSetup) whose only install action is to download the full package from--package-url(verified against its own just-computedsha256) and run it — the Burn/NSIS-style "web installer" pattern. This is what you hand out as the small, fast first download; the stub's own progress screen is the only UI shown while the real package downloads and takes over.
sigil pack sigil.yaml --payload web \
--package-url https://cdn.example.com/releases/MyApp-1.4.0-x64-Setup.exe
--package-url is required when --payload web is used, and must be a
resolvable HTTPS URL — sigil pack refuses up front (diagnostic SIG0322)
if it is missing, empty, or not https://, since a stub whose download step
can never succeed is worse than not producing one. --payload embedded
(the default) ignores --package-url entirely.
See also¶
- Upgrades & downgrades — the version-comparison and install-directory-preservation logic the downloaded update package goes through once launched.
- ADR-009: ECDSA P-256 channel-manifest signatures
- ADR-010: delta-update deferral