From 19abdebfe3efdd1bc4f4b998afd68fb5f1a51e04 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sat, 31 Oct 2026 23:06:33 -0300 Subject: [PATCH 01/15] docs: record D48, public beta-channel pre-releases before review pass 1 and signing Beta builds may be published as public GitHub pre-releases; the review-pass1 and authenticode gates may be waived for beta only, with SHA256SUMS.sig, the not-yet-reviewed notice and the key fingerprints in the README. Stable keeps every gate. --- docs/design/04-build-install-update.md | 10 +++++++--- docs/design/decisions.md | 1 + docs/requirements.md | 5 +++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/design/04-build-install-update.md b/docs/design/04-build-install-update.md index e6d4c49..6b830fc 100644 --- a/docs/design/04-build-install-update.md +++ b/docs/design/04-build-install-update.md @@ -1568,7 +1568,10 @@ real certificate by adding secrets. this is the strongest reason to obtain a certificate before a public 1.0. - Windows 11 modern context menu: see §10.1. -**Interim rules while binaries are unsigned (TM-R38, T-71, D38).** Users who learn to click through "Windows +**Interim rules while binaries are unsigned (TM-R38, T-71, D38; relaxed for beta by D48).** D48 (2026-11-01) allows +beta-channel builds as **public GitHub pre-releases** of this repository before Authenticode signing and before crypto +review pass 1; the rules below then apply to them as well, except the first one (no public links), and the release +notes repeat the "not yet externally reviewed" notice. Stable keeps every rule. Users who learn to click through "Windows protected your PC" cannot tell a trojaned look-alike from our build. Until the Authenticode certificate exists: - **No public download links.** No GitHub release is published as public, no mirror path is linked from the @@ -1845,6 +1848,7 @@ Shipping code must not use `__DATE__`, `__TIME__`, `__TIMESTAMP__` or `__FILE__` | Distribution | Requires | |---|---| +| **Public beta pre-release** (D48) | Lab pass; `review-pass1` and `authenticode` may be waived; the rules of §10 except "no public links"; `SHA256SUMS` + `SHA256SUMS.sig`; before crypto pass 1, the "not yet externally reviewed" notice in setup and in the release notes | | Named testers (private) | Lab pass; interim rules of §10 while unsigned; before crypto pass 1, the "not yet externally reviewed" notice | | **Public beta** (public links, public beta manifest) | External crypto review **pass 1** (end of M2: all SSH and crypto code, D38) completed and its findings closed; **and** Authenticode signing in place (TM-R38: no public download links for unsigned builds) | | **1.0 and later stable** | Crypto review **pass 2** closed (D38, TST-33); certificate (D29); all P3 tests of 05 §7; independent maintainer rebuild (TM-R39) | @@ -1880,8 +1884,8 @@ approved FacuM 2026-10-28 |---|---|---|---| | `ci-local` | required | required | never | | `lab` | required | required | beta only (named testers and beta VM runs); stable never | -| `review-pass1` | required | required | never | -| `authenticode` | required | required | never (TM-R38) | +| `review-pass1` | required | required | beta only (D48: public pre-releases before pass 1); stable never | +| `authenticode` | required | required | beta only (D48); stable never (TM-R38) | | `review-pass2` | not allowed | required | never | | `p3-tests` | not allowed | required | never | | `rebuild` | not allowed | required | never | diff --git a/docs/design/decisions.md b/docs/design/decisions.md index 2739826..0894e17 100644 --- a/docs/design/decisions.md +++ b/docs/design/decisions.md @@ -52,6 +52,7 @@ Where this file differs from the review's recommendation, this file wins. | D46 | **The repository is public** (`github.com/LibreStatic/ShellDrive`), so GitHub Actions minutes are free; 04 §9.5 "private repository (interim)" no longer applies. Local CI (`tools/ci-local.ps1`) stays the authority for interop and Dokany tests. | Set 2026-10-25 at the user's request. | | D44 | **`mark_of_the_web` defaults to `off`, independent of `security.trust`** (confirmed by the user 2026-10-24). | Tying it to the `untrusted` default (D41) would tag every file on a Dokany drive with Mark-of-the-Web. | | D47 | **OS-tiered UI (06-ui-tiers.md), option B**: one binary per flavour picks Classic (XP), Aero (Vista/7, glass band) or Fluent (8–11: flat on 8–10, Mica on 11 22H2+) at run time; full dark mode on Windows 11 and on 10 from 1809 through the undocumented uxtheme helpers behind an allow-list and a kill switch (`[ui] dark_helpers`, policy `UiDarkHelpers`); the menu bar becomes a "···" overflow on 10/11; Windows 11 tray flyout and Aero wizard in the first slice. | Set 2026-10-31 by the user from the mock-ups. Supersedes 03 §7.4's "no undocumented uxtheme ordinals". | +| D48 | **Public pre-release builds on the beta channel** (2026-11-01, the maintainer's decision): beta builds are published as public GitHub pre-releases of this repository before crypto review pass 1 and before Authenticode signing. This relaxes D38 ("no public betas before pass 1") and the interim rules of 04 §10 / TM-R38 for the beta channel only: the release gates `review-pass1` and `authenticode` may be `waived` for beta (with a note), never for stable. Every beta release carries `SHA256SUMS` and its ed25519 `SHA256SUMS.sig` (release-beta key), the "pre-release, cryptography not yet externally reviewed" notice on the first setup page and in the release notes, and the root and release key fingerprints in `README.md`. Stable and 1.0 keep every gate of 04 §12.3. | D38, TM-R38 (beta only) | ## Resulting build flavours diff --git a/docs/requirements.md b/docs/requirements.md index a6c4502..dffd574 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -11,7 +11,7 @@ Design decisions that refine these requirements are recorded in `docs/design/dec bignum, ML-KEM, SHA-3 and every other primitive are written by us. Papers, RFCs and specs may be read; no code is copied. Assurance comes from Wycheproof and RFC vectors, differential tests against CNG in CI, fuzzing, constant-time timing tests and an external crypto review in two passes: pass 1 at the end of M2 (all SSH and - crypto code), pass 2 before 1.0. No public betas before pass 1 (D38). + crypto code), pass 2 before 1.0. No public betas before pass 1 (D38), except the public beta-channel pre-releases of D48. - Never break Windows signing policy: no test-signing, no unsigned drivers. The only allowed kernel component is an existing, already-signed, open-source driver: **Dokany** (redistributed as published upstream, never forked). - Self-installing. Also runs portable. @@ -148,7 +148,8 @@ Design decisions that refine these requirements are recorded in `docs/design/dec and records rotations and revocations; release keys sign only manifests of their channel. Clients keep an anti-rollback serial per channel, refuse expired manifests (at most 30 days stable, 14 days beta; re-signed every two weeks) and warn when update information is stale. Stable releases use two offline signing sessions (payload - tables, then the manifest over the final EXEs, D42). No public download links before Authenticode signing. + tables, then the manifest over the final EXEs, D42). No public download links before Authenticode signing, except the beta-channel pre-releases of D48 (ed25519-signed + `SHA256SUMS`, key fingerprints in the README, a "not yet externally reviewed" notice). - Code signing via SignPath Foundation, else Certum OSS, obtained before a public 1.0. ## Project From 989c5d9f6feedf1ff0e36e0c2e774d7415fc7fe3 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sat, 31 Oct 2026 23:07:44 -0300 Subject: [PATCH 02/15] release: accept waived review-pass1 and authenticode gates on beta (D48) Stable still refuses both waivers; tests cover the beta record and the two stable refusals. --- tests/release/gates_flow.cmake | 16 ++++++++++++++++ tools/sd-release/sd-release.c | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/release/gates_flow.cmake b/tests/release/gates_flow.cmake index b964f42..5c7942d 100644 --- a/tests/release/gates_flow.cmake +++ b/tests/release/gates_flow.cmake @@ -109,6 +109,22 @@ string(REPLACE "gate ci-local pass 2026-10-26 FacuM" "gate ci-local waived 2026- put(waived_ci_beta.txt "${t}") rel(3 out gates check waived_ci_beta.txt --version 1.0.0 --commit ${C}) +# D48: review-pass1 and authenticode may be waived on beta (public pre-releases), never on stable. +string(REPLACE "gate review-pass1 pass 2026-06-12 FacuM +gate authenticode pass 2026-10-20 FacuM" + "gate review-pass1 waived 2026-06-12 FacuM D48 pre-release +gate authenticode waived 2026-10-20 FacuM D48 unsigned" t "${BETA_OK}") +put(waived_d48_beta.txt "${t}") +rel(0 out gates check waived_d48_beta.txt --version 1.0.0 --commit ${C}) + +string(REPLACE "gate review-pass1 pass 2026-06-12 FacuM" "gate review-pass1 waived 2026-06-12 FacuM D48" t "${STABLE_OK}") +put(waived_pass1_stable.txt "${t}") +rel(3 out gates check waived_pass1_stable.txt --version 1.0.0 --commit ${C}) + +string(REPLACE "gate authenticode pass 2026-10-20 FacuM" "gate authenticode waived 2026-10-20 FacuM D48" t "${STABLE_OK}") +put(waived_authenticode_stable.txt "${t}") +rel(3 out gates check waived_authenticode_stable.txt --version 1.0.0 --commit ${C}) + # A waived lab gate needs its note (malformed, 2). string(REPLACE "gate lab waived 2026-10-27 FacuM two VMs deferred to the next beta" "gate lab waived 2026-10-27 FacuM" t "${BETA_OK}") put(waived_no_note.txt "${t}") diff --git a/tools/sd-release/sd-release.c b/tools/sd-release/sd-release.c index 4e98631..e1a1ccb 100644 --- a/tools/sd-release/sd-release.c +++ b/tools/sd-release/sd-release.c @@ -630,7 +630,7 @@ static int cmd_manifest(int argc, char **argv) #define RG_STATUS_WAIVED 3 /* One row per gate. beta and stable are 1 when the gate is required on that channel and 0 when a record may not carry - it. waive_* is 1 when a waived status is accepted on that channel. */ + it. waive_* is 1 when a waived status is accepted on that channel (review-pass1 and authenticode on beta: D48). */ typedef struct rg_def { const char *name; int beta, stable; @@ -640,8 +640,8 @@ typedef struct rg_def { static const rg_def rg_defs[] = { {"ci-local", 1, 1, 0, 0}, {"lab", 1, 1, 1, 0}, - {"review-pass1", 1, 1, 0, 0}, - {"authenticode", 1, 1, 0, 0}, + {"review-pass1", 1, 1, 1, 0}, + {"authenticode", 1, 1, 1, 0}, {"review-pass2", 0, 1, 0, 0}, {"p3-tests", 0, 1, 0, 0}, {"rebuild", 0, 1, 0, 0} From 837ff99338b7c77fb749765ee111e8ec5010c792 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sat, 31 Oct 2026 23:13:51 -0300 Subject: [PATCH 03/15] installer: pin Dokany 2.3.1.1000 in installer/dokany/dokany.lock Size, SHA-256, signer and MSI UpgradeCodes recorded from the upstream files; dokan2.dll imports no VC++ runtime on x64 or x86, so the MSI form applies. ARM64 has no row yet. --- installer/dokany/dokany.lock | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 installer/dokany/dokany.lock diff --git a/installer/dokany/dokany.lock b/installer/dokany/dokany.lock new file mode 100644 index 0000000..9591e7b --- /dev/null +++ b/installer/dokany/dokany.lock @@ -0,0 +1,30 @@ +# installer/dokany/dokany.lock: the pinned Dokany release (docs/design/04-build-install-update.md section 7.3, D32, +# TM-R23). The package job checks size and SHA-256 of every file below at embed time; setup checks them again, plus +# the Authenticode signer, right before msiexec or the bundle runs. Files are upstream, byte for byte, never modified. +# +# Recorded 2026-11-01 from the files of the upstream release v2.3.1.1000, supplied by the maintainer: +# size and SHA-256: Get-FileHash -Algorithm SHA256 +# signer: Get-AuthenticodeSignature (Status Valid on all three files), full subject: +# CN=LEOSAC, O=LEOSAC, STREET=39 rue Principale, PostalCode=67220, L=Breitenau, S=Bas-Rhin, C=FR, +# SERIALNUMBER=919 690 420 00014, businessCategory=Private Organization +# issuer CN=Certum Extended Validation Code Signing 2021 CA, O=Asseco Data Systems S.A., C=PL +# upgrade-code and product version: the Property table of each MSI (WindowsInstaller.Installer, read-only) +# vcruntime: dumpbin /dependents on dokan2.dll (File key DokanDLL) extracted from each MSI's Dokan.cab; x64 and +# x86 import only KERNEL32, USER32, ADVAPI32 and SHELL32 (no VC++ runtime), so the setup EXEs embed the MSI form. +# dokan2.dll SHA-256: x64 75600aba867acbdfdb85fcd142b524da769bdc611b855a760aeb0c6e2eaae17a, +# x86 516da99d541961260888631c8cad3869a9e4e5021904560aa6534ac7ae549c75 +# ARM64: Dokan_arm64.msi was not part of the supplied files; ARM64 setups offer WebDAV only until a row is added. +# +# Line format: " ", one per line; "file" lines are +# file
+dokany-lock 1 +version 2.3.1.1000 +min-api 2.1.0 +signer-cn LEOSAC +signer-issuer-cn Certum Extended Validation Code Signing 2021 CA +url-upstream https://github.com/dokan-dev/dokany/releases/download/v2.3.1.1000/ +url-mirror - +checked 2026-11-01 +file x64 msi Dokan_x64.msi 9269248 69ff8cb37bfec3a75921c85ffd1c6370b50a9ec4ecef2cf3a009d488dcbf5465 {31A8F445-2AC6-494B-819C-4F4E1B2AECF1} none +file x86 msi Dokan_x86.msi 5128192 c833fd51f67c44a467eeade419b8934033445ee847119f82cbef1fddc84ef618 {B08DE90A-F064-4ADC-99AC-2EC933417CC5} none +file all bundle DokanSetup.exe 19194904 bf602263a594f595b4fdd8c4e822172b103de93f07fd6a51a8ff69569bfd1460 - - From b426cc957b8c7b9f0e35867257548ca774a2c21b Mon Sep 17 00:00:00 2001 From: FacuM Date: Sat, 31 Oct 2026 23:24:03 -0300 Subject: [PATCH 04/15] release: add the offline key ceremony script and guide tools/release/key-ceremony.ps1 generates the two roots and the release-beta key on an offline machine, signs keyset version 1 with both roots, verifies each signature and writes roots.txt, keyset.txt and the README fingerprints block. docs/dev/key-ceremony.md walks the maintainer through it (D48, 04 9.3). --- docs/dev/key-ceremony.md | 110 ++++++++++++++ tools/release/key-ceremony.ps1 | 259 +++++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 docs/dev/key-ceremony.md create mode 100644 tools/release/key-ceremony.ps1 diff --git a/docs/dev/key-ceremony.md b/docs/dev/key-ceremony.md new file mode 100644 index 0000000..f17b97c --- /dev/null +++ b/docs/dev/key-ceremony.md @@ -0,0 +1,110 @@ +# Key ceremony (first keyset) + +How to run the first key ceremony and publish its results. The ceremony creates the two offline root keys +and the `release-beta` release key, and produces the signed `keyset.txt` (format version 1) that future +builds compile in. Background: `docs/design/04-build-install-update.md` §9.3 (key roles, keyset format, +rotation and revocation) and §12.3 (release process; the ceremony is step 11). Per D48, beta builds are +public GitHub pre-releases before crypto review pass 1 and before Authenticode signing, so the first +keyset needs only the beta release key; a `release-stable` key can be added later by a keyset rotation. + +Tool: `tools/release/key-ceremony.ps1` (Windows PowerShell 5.1). It refuses to run on a networked machine, +on the system drive, over existing seeds or existing outputs, and it never prints or copies a seed. + +## What it produces + +Public (bring to the network machine, commit later): + +| File | Content | +|---|---| +| `roots.txt` | comment header, then two `root ` lines | +| `keyset.txt` | keyset version 1: the `release-beta` key (valid 1 year from the ceremony date) and both roots, with one `sig ed25519 ...` line per root (a keyset that sets root keys must be signed by both roots, §9.3) | +| `fingerprints.md` | the "Release keys" section for `README.md`: role, key id and full public key of each key | + +Secret (stays on the removable media named by `-SecretDir`, never on a networked machine): +`root-a.seed`, `root-b.seed`, `release-beta.seed`. A seed file holds 32 bytes as 64 hex digits and is +written in clear text by the tool (`docs/dev/m4-notes.md`, "Signing tool") — that is why the machine and +the media must stay offline. The key id is the first 8 bytes of SHA-256 of the public key, printed by +`sd-updsign pubkey` as 16 hex digits. + +## 1. Prepare the offline machine + +1. Use a dedicated machine that never connects to any network. The script checks every network adapter and + refuses while any reports *Up* (`Get-NetAdapter`; an `ipconfig` scan on Windows 7). Disable virtual + switches too — Hyper-V/WSL adapters report *Up* even without a cable. +2. Build the tools on the networked machine (`build//bin//`), record their SHA-256 + (`Get-FileHash -Algorithm SHA256 sd-updsign.exe, sd-release.exe`), and copy both EXEs to offline media. +3. On the offline machine, verify the copied EXE hashes match what the build machine recorded, and compare + them again with what the script prints at start. + +## 2. Run the script + +Have ready: `-ToolsDir` (the two EXEs), `-SecretDir` (an **empty** folder on removable media), +`-PublicDir` (an empty output folder), and optionally `-Date` (UTC `YYYY-MM-DD`, default: today). + +```text +powershell -NoProfile -ExecutionPolicy Bypass -File key-ceremony.ps1 ^ + -ToolsDir E:\tools -SecretDir E:\keys -PublicDir E:\public [-Date 2026-11-01] +``` + +(The `^` continues the line in cmd.exe; on a PowerShell prompt, put the command on one line.) + +In order, it: checks the environment refusals; prints the tools' SHA-256; generates `root-a`, `root-b` and +`release-beta` (`sd-updsign keygen`), re-deriving each key id from SHA-256 and cross-checking both tools +(`sd-release pubkey`); writes the keyset body (version 1, product `com.librestatic.shelldrive`, the beta +key line and the two root lines); signs it with both roots (`sd-updsign sign-keyset --root ... --root ...`); +verifies both signatures (`sd-release verify --kind keyset`, once per root); writes `roots.txt` and +`fingerprints.md`; prints the SHA-256 of every public file. + +If it aborts: fix the cause, **empty `-SecretDir`**, and start over. A half-generated set of keys must +never survive, and the script refuses to run over existing seeds to enforce that. + +## 3. Secure the secret media + +1. Copy the three `.seed` files to a second set of encrypted media. You now have two copies; store them in + two separate locations (§9.3: the roots live on two separate encrypted media in two locations, offline + only, never in CI, never on a networked machine). +2. Label the media with the three public key ids (public information, no secret on the label). +3. From now on the media is only ever mounted on the offline machine, and only for keyset ceremonies. + +## 4. Publish + +1. Bring **only** `-PublicDir` to the network machine (the seeds never leave the media). +2. Compare the SHA-256 of the three files against what the script printed. +3. Commit `installer/keys/roots.txt` and `installer/keys/keyset.txt`. The release build compiles the two + root public keys and the current keyset into every binary and the setup stub (§9.3); until they exist + every update verification fails closed (`docs/dev/m4-notes.md`). +4. Paste `fingerprints.md` into `README.md` as the **"Release keys"** section (TM-R38: key fingerprints in + the README). + +## 5. The release-beta seed and the later signing sessions + +- Store `release-beta.seed` as the secret of the protected GitHub environment `beta-signing` (required + reviewer: the maintainer). This is the seed's single transfer off the secret media; delete any working + copy after the import and keep only the media copies. +- For beta pre-releases (D48) the release workflow performs the equivalent of the two offline signing + sessions (§12.3 steps 5 and 7, D42) with that key behind the environment, and the maintainer approves the + run: payload tables first (`sd-release sign --kind payload`), then `SHA256SUMS` + `SHA256SUMS.sig` and + the signed manifest (`sd-release sign --kind sums`, `--kind manifest`) once the draft release exists. + There is no manual offline session for beta; `sd-release verify` runs in the workflow against the + release artifacts before anything is published. +- When stable releases start, rotate in a `release-stable` key (below). From then on, steps 5 and 7 for + stable are the maintainer's manual offline sessions on this machine: `sd-release session` lists the + hashes to compare (`SESSION.txt`, D42), `sd-release verify` checks the payloads/manifest against the + rebuilt artifacts, and `sd-release sign --gates --version --commit ` signs + (the gates record is mandatory there, which is why keysets are signed with `sd-updsign sign-keyset` + instead — the ceremony is step 11, outside any release). + +## 6. Rotation, revocation, expiry (§9.3) + +- **Release key rotation:** sign a root keyset version `n+1` that adds the new release key; releases signed + with it name `keyset n+1`; a later keyset drops the old key. Changing release keys needs a signature by + **one** root ("either root"); the roots themselves are untouched. +- **Revocation of a release key** (e.g. a leaked beta key): a root-signed keyset with a + `revoke ` line; clients refuse the key from then on and it cannot sign keysets back. +- **Root change:** a keyset with `root ` lines, signed by **both** current roots (the + parser refuses anything less — `update_verify.c`, "rotation needs both current roots"). Loss of both + roots cannot be recovered remotely: it needs a manual reinstall with new binaries. +- **Expiry:** the beta key from this ceremony is valid until the ceremony date + 1 year (the `key` line's + second date). Rotate it before then with a new root-signed keyset. +- **Freshness re-signing** (TM-R36, every 14 days) uses release keys only; roots are touched only for + keyset changes like these. diff --git a/tools/release/key-ceremony.ps1 b/tools/release/key-ceremony.ps1 new file mode 100644 index 0000000..224d6d4 --- /dev/null +++ b/tools/release/key-ceremony.ps1 @@ -0,0 +1,259 @@ +# key-ceremony.ps1 - the first key ceremony for the ShellDrive update trust (04-build-install-update.md +# section 9.3, section 12.3 step 11, D42, D48). +# +# Generates the two offline root keys and the release-beta key, builds keyset.txt (keyset format version 1, +# listing the beta release key and both roots) signed by BOTH roots, verifies the signatures with sd-release, +# and writes roots.txt and the fingerprints block for README.md. The three private seeds are written only +# into -SecretDir and are never printed or copied elsewhere. +# +# Run this ONLY on the offline signing machine (Windows PowerShell 5.1 or later): +# +# powershell -NoProfile -ExecutionPolicy Bypass -File key-ceremony.ps1 ^ +# -ToolsDir E:\tools -SecretDir E:\keys -PublicDir E:\public [-Date 2026-11-01] +# (^ continues the line in cmd.exe; join the lines when invoking from a PowerShell prompt.) +# +# -ToolsDir folder holding sd-updsign.exe and sd-release.exe copied from a build +# -SecretDir empty folder on removable media; receives root-a.seed, root-b.seed, release-beta.seed +# -PublicDir output folder; receives roots.txt, keyset.txt, fingerprints.md +# -Date ceremony date, UTC, YYYY-MM-DD (default: today) +# +# The script refuses to run when a network adapter is active, when -SecretDir is on the system drive or a +# network path, when -SecretDir already holds *.seed files, or when the public outputs already exist. If it +# aborts mid-way, fix the cause and start again with an empty -SecretDir (a half-generated key set must +# never survive). File formats follow docs/design/04-build-install-update.md section 9.3 and the parser in +# src/sd_update/update_verify.c (strict: no blank lines, sig lines only as the trailing block). + +param( + [Parameter(Mandatory = $true)][string]$ToolsDir, + [Parameter(Mandatory = $true)][string]$SecretDir, + [Parameter(Mandatory = $true)][string]$PublicDir, + [string]$Date = ((Get-Date).ToUniversalTime().ToString('yyyy-MM-dd')) +) + +$ErrorActionPreference = 'Stop' +$culture = [System.Globalization.CultureInfo]::InvariantCulture +$script:WorkDir = $null + +function Fail([string]$Message) { + Write-Host ("key-ceremony: ERROR: " + $Message) -ForegroundColor Red + exit 1 +} + +# Refuse a networked machine. Get-NetAdapter is authoritative on Windows 8+; any adapter reporting Up +# (including virtual switches) counts as active. On Windows 7 the fallback scans ipconfig for routable +# IPv4 addresses (language-neutral: addresses are digits everywhere; loopback, APIPA and subnet masks are +# ignored). A static address on an unplugged adapter can false-positive there; disable the adapter. +function Assert-Offline { + if (Get-Command Get-NetAdapter -ErrorAction SilentlyContinue) { + $up = @(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' }) + if ($up.Count -gt 0) { + $names = (@($up | ForEach-Object { $_.Name })) -join ', ' + Fail ("active network adapter(s): " + $names + " - disconnect every network and disable the adapters") + } + return + } + $text = (ipconfig.exe | Out-String) + $ips = @([regex]::Matches($text, '(\d{1,3}\.){3}\d{1,3}') | ForEach-Object { $_.Value } | Select-Object -Unique) + foreach ($ip in $ips) { + if ($ip -like '127.*' -or $ip -like '169.254.*' -or $ip -like '255.*' -or $ip -eq '0.0.0.0') { continue } + Fail ("ipconfig shows the address " + $ip + " - disconnect every network first") + } +} + +# Generates one key in -SecretDir and returns its public facts (never the seed). Checks the key id three +# ways: sd-updsign prints it, it must equal the first 8 bytes of SHA-256 of the public key (section 9.3), +# and sd-release must report the same public key hex and key id for the same seed. +function New-Key([string]$Name) { + $seed = Join-Path $SecretDir ($Name + '.seed') + $out = @(& $script:SdUpdsign keygen --seed $seed) + if ($LASTEXITCODE -ne 0) { Fail ("sd-updsign keygen failed for " + $Name + " (exit " + $LASTEXITCODE + ")") } + $pub = $null + $kid = $null + foreach ($line in $out) { + if ($line -match '^pubkey ([A-Za-z0-9+/]{43}=)\s*$') { $pub = $Matches[1] } + elseif ($line -match '^key-id ([0-9a-f]{16})\s*$') { $kid = $Matches[1] } + } + if (-not $pub -or -not $kid) { Fail ("unexpected sd-updsign keygen output for " + $Name) } + $sha = [System.Security.Cryptography.SHA256]::Create() + $pubBytes = [Convert]::FromBase64String($pub) + $idHex = ([BitConverter]::ToString($sha.ComputeHash($pubBytes), 0, 8)).Replace('-', '').ToLowerInvariant() + $sha.Dispose() + if ($idHex -ne $kid) { Fail ("key id mismatch for " + $Name + " (tool " + $kid + ", SHA-256 says " + $idHex + ")") } + $rel = @(& $script:SdRelease pubkey --seed $seed) + if ($LASTEXITCODE -ne 0) { Fail ("sd-release pubkey failed for " + $Name + " (exit " + $LASTEXITCODE + ")") } + $relPub = $null + $relKid = $null + foreach ($line in $rel) { + if ($line -match '^pub ([0-9a-f]{64}) kid ([0-9a-f]{16})\s*$') { $relPub = $Matches[1]; $relKid = $Matches[2] } + } + if (-not $relPub) { Fail ("unexpected sd-release pubkey output for " + $Name) } + $pubHex = ([BitConverter]::ToString($pubBytes)).Replace('-', '').ToLowerInvariant() + if ($relPub -ne $pubHex -or $relKid -ne $kid) { Fail ("sd-release disagrees with sd-updsign for " + $Name) } + # sd-release verify reads the public key from a file of 64 hex digits (sd-release.c read_key_hex), + # while sd-updsign prints base64; write the hex form for the verification step below. + $hexPath = Join-Path $script:WorkDir ($Name + '.hexpub') + [System.IO.File]::WriteAllBytes($hexPath, [System.Text.Encoding]::ASCII.GetBytes($pubHex + "`n")) + Write-Host ("key " + $Name + ": key-id " + $kid) + return @{ Seed = $seed; PubB64 = $pub; PubHex = $pubHex; Id = $kid; HexPubPath = $hexPath } +} + +function Assert-Verify([string]$PubPath, [string]$File, [string]$ExpectedId, [string]$What) { + $out = @(& $script:SdRelease verify --kind keyset --pub $PubPath --in $File) + if ($LASTEXITCODE -ne 0) { Fail ("sd-release verify FAILED for " + $What + " (exit " + $LASTEXITCODE + ")") } + $ok = $false + foreach ($line in $out) { + if ($line -match ('^OK keyset key ' + $ExpectedId + '\s*$')) { $ok = $true } + } + if (-not $ok) { Fail ("sd-release verify did not confirm key " + $ExpectedId + " for " + $What) } + Write-Host ("verified " + $What) +} + +# --- preflight checks ------------------------------------------------------- + +Assert-Offline + +try { $ceremonyDate = [datetime]::ParseExact($Date, 'yyyy-MM-dd', $culture) } catch { + Fail "-Date must be a UTC date in YYYY-MM-DD form" +} +$validTo = $ceremonyDate.AddYears(1).ToString('yyyy-MM-dd', $culture) +# The keyset parser accepts exactly YYYY-MM-DDTHH:MM:SSZ (20 characters) for "issued". +$issued = $Date + 'T00:00:00Z' + +$script:SdUpdsign = Join-Path $ToolsDir 'sd-updsign.exe' +$script:SdRelease = Join-Path $ToolsDir 'sd-release.exe' +if (-not (Test-Path $script:SdUpdsign -PathType Leaf)) { Fail ("missing " + $script:SdUpdsign) } +if (-not (Test-Path $script:SdRelease -PathType Leaf)) { Fail ("missing " + $script:SdRelease) } +Write-Host ("tool sd-updsign.exe SHA-256 " + (Get-FileHash $script:SdUpdsign -Algorithm SHA256).Hash) +Write-Host ("tool sd-release.exe SHA-256 " + (Get-FileHash $script:SdRelease -Algorithm SHA256).Hash) + +if ($SecretDir -like '\\*') { Fail "-SecretDir must be a local drive (removable media), not a network path" } +$secretRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($SecretDir)) +$sysRoot = $env:SystemDrive +if (-not $sysRoot.EndsWith('\')) { $sysRoot = $sysRoot + '\' } +if ($secretRoot -ieq $sysRoot) { Fail ("-SecretDir is on the system drive " + $sysRoot + " - use removable media") } +if (-not (Test-Path $SecretDir)) { New-Item -ItemType Directory -Path $SecretDir | Out-Null } +$existingSeeds = @(Get-ChildItem -Path $SecretDir -Filter '*.seed' -File) +if ($existingSeeds.Count -gt 0) { + Fail ("-SecretDir already holds seed file(s): " + (($existingSeeds | ForEach-Object { $_.Name }) -join ', ') + + " - start the ceremony from an empty folder") +} +$volume = Get-CimInstance Win32_LogicalDisk | + Where-Object { $_.DeviceID -ieq $secretRoot.TrimEnd('\') } | + Select-Object -First 1 +if ($volume -and $volume.DriveType -ne 2) { + Write-Warning ("-SecretDir drive " + $volume.DeviceID + " is not removable media (DriveType " + + $volume.DriveType + "); 04 section 9.3 wants the roots on encrypted removable media") +} + +if (-not (Test-Path $PublicDir)) { New-Item -ItemType Directory -Path $PublicDir | Out-Null } +foreach ($name in @('roots.txt', 'keyset.txt', 'fingerprints.md')) { + if (Test-Path (Join-Path $PublicDir $name)) { Fail ("-PublicDir already contains " + $name + " - choose an empty output folder") } +} + +# Scratch space for the keyset body, the hex public keys and the partial keyset copy. Only public +# material ever lands here; it is removed at the end. +$script:WorkDir = Join-Path ([System.IO.Path]::GetTempPath()) ('key-ceremony-' + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $script:WorkDir | Out-Null + +try { + # --- 1. generate the three keys ---------------------------------------- + + Write-Host "generating root-a, root-b and release-beta keys ..." + $rootA = New-Key 'root-a' + $rootB = New-Key 'root-b' + $beta = New-Key 'release-beta' + + # --- 2. build the unsigned keyset body (version 1) ---------------------- + + # Grammar per section 9.3 and update_verify.c: header line, product, version, issued, then records. + # Root lines mark a root change, which requires signatures by both roots - that is exactly what the + # first keyset is. No blank lines; the body must end with LF and carry no sig line. + $bodyLines = @( + 'shelldrive-keyset 1', + 'product com.librestatic.shelldrive', + 'version 1', + ('issued ' + $issued), + ('key ' + $beta.Id + ' beta ' + $beta.PubB64 + ' ' + $Date + ' ' + $validTo), + ('root ' + $rootA.Id + ' ' + $rootA.PubB64), + ('root ' + $rootB.Id + ' ' + $rootB.PubB64) + ) + $bodyPath = Join-Path $script:WorkDir 'keyset.body' + [System.IO.File]::WriteAllBytes($bodyPath, [System.Text.Encoding]::ASCII.GetBytes((($bodyLines -join "`n") + "`n"))) + + # --- 3. sign the keyset with both roots --------------------------------- + + $keysetPath = Join-Path $PublicDir 'keyset.txt' + Write-Host "signing the keyset with root-a and root-b ..." + & $script:SdUpdsign sign-keyset --in $bodyPath --root $rootA.Seed --root $rootB.Seed --out $keysetPath + if ($LASTEXITCODE -ne 0) { Fail ("sd-updsign sign-keyset failed (exit " + $LASTEXITCODE + ")") } + # The signed file is body + sig(root-a) + sig(root-b); the last sig line is root-b's. + + # --- 4. verify both signatures with sd-release --------------------------- + + # Both roots sign the same body (everything before the sig block). sd-release verify checks the LAST sig + # line of a file against everything before it, so each signature is verified on body + that one line. + $keysetBytes = [System.IO.File]::ReadAllBytes($keysetPath) + $sigLineLen = 118 # "sig ed25519 " (12) + key id (16) + ' ' + signature (88) + LF = RL_SIG_LINE_LEN + if ($keysetBytes.Length -le (2 * $sigLineLen)) { Fail "the signed keyset is implausibly short" } + $bodyLen = $keysetBytes.Length - 2 * $sigLineLen + $slices = @(@{ Key = $rootA; Offset = $bodyLen; What = 'root-a signature' }, + @{ Key = $rootB; Offset = $bodyLen + $sigLineLen; What = 'root-b signature' }) + foreach ($s in $slices) { + $slice = New-Object byte[] ($bodyLen + $sigLineLen) + [Array]::Copy($keysetBytes, 0, $slice, 0, $bodyLen) + [Array]::Copy($keysetBytes, $s.Offset, $slice, $bodyLen, $sigLineLen) + $slicePath = Join-Path $script:WorkDir ('keyset.' + $s.Key.Id + '.txt') + [System.IO.File]::WriteAllBytes($slicePath, $slice) + Assert-Verify $s.Key.HexPubPath $slicePath $s.Key.Id $s.What + } + + # --- 5. write roots.txt and the fingerprints block ------------------------ + + $rootsLines = @( + '# ShellDrive update trust roots (docs/design/04-build-install-update.md, section 9.3).', + '# Format: root ; the key id is the first 8 bytes of SHA-256 of the public key.', + ('root ' + $rootA.Id + ' ' + $rootA.PubB64), + ('root ' + $rootB.Id + ' ' + $rootB.PubB64) + ) + [System.IO.File]::WriteAllBytes((Join-Path $PublicDir 'roots.txt'), + [System.Text.Encoding]::ASCII.GetBytes((($rootsLines -join "`n") + "`n"))) + + $fpLines = @( + '## Release keys', + '', + 'Update signatures are checked against the keys below (docs/design/04-build-install-update.md, section 9.3).', + 'The key id is the first 8 bytes of the SHA-256 of the public key, as printed by `sd-updsign pubkey`.', + 'The keyset current at build time is committed at `installer/keys/keyset.txt`, signed by both roots.', + '', + '| Role | Name | Key id | Public key (base64) |', + '|---|---|---|---|', + ('| root | root-a | ' + $rootA.Id + ' | `' + $rootA.PubB64 + '` |'), + ('| root | root-b | ' + $rootB.Id + ' | `' + $rootB.PubB64 + '` |'), + ('| release-beta | release-beta | ' + $beta.Id + ' | `' + $beta.PubB64 + '` |'), + '', + ('Keyset version 1, issued ' + $issued + '; the beta release key is valid ' + $Date + ' through ' + $validTo + '.'), + '' + ) + [System.IO.File]::WriteAllBytes((Join-Path $PublicDir 'fingerprints.md'), + [System.Text.Encoding]::ASCII.GetBytes(($fpLines -join "`n"))) + + # --- 6. summary ---------------------------------------------------------- + + Write-Host '' + Write-Host 'Public files (compare these SHA-256 values against what you commit):' + foreach ($name in @('roots.txt', 'keyset.txt', 'fingerprints.md')) { + $p = Join-Path $PublicDir $name + Write-Host (' SHA-256 ' + (Get-FileHash $p -Algorithm SHA256).Hash + ' ' + $p) + } + Write-Host '' + Write-Host ('keyset version 1 issued ' + $issued + '; beta release key valid ' + $Date + ' through ' + $validTo) + Write-Host ('secrets: three .seed files in ' + $SecretDir + + ' - keep two copies on separate encrypted media, stored apart, never on a networked machine') +} +finally { + if ($script:WorkDir -and (Test-Path $script:WorkDir)) { + Remove-Item $script:WorkDir -Recurse -Force + } +} + +exit 0 From bca43cc3c58e9a8ee0e4458421b135b5afab42d3 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sat, 31 Oct 2026 23:43:33 -0300 Subject: [PATCH 05/15] setup: show the beta pre-release cryptography notice on the welcome page (D48) SD_SETUP_PRERELEASE_NOTICE (default on for the beta channel) adds a visible notice to the welcome page in the six languages; unattended runs log it in English. Tests check the catalogs and when the flow shows it. --- apps/shelldrive-setup/CMakeLists.txt | 15 +++++++++++ apps/shelldrive-setup/setup_core.h | 2 ++ apps/shelldrive-setup/setup_flow.c | 5 ++++ apps/shelldrive-setup/setup_flow.h | 12 +++++++++ apps/shelldrive-setup/setup_install.c | 16 ++++++++++++ apps/shelldrive-setup/setup_main.c | 1 + apps/shelldrive-setup/setup_ui.c | 11 ++++++-- l10n/installer/de.po | 5 ++++ l10n/installer/en.po | 5 ++++ l10n/installer/es.po | 5 ++++ l10n/installer/fr.po | 5 ++++ l10n/installer/it.po | 5 ++++ l10n/installer/pt-BR.po | 5 ++++ tests/setup/CMakeLists.txt | 6 +++++ tests/setup/check_prerelease_notice.cmake | 32 +++++++++++++++++++++++ tests/setup/main.c | 2 ++ tests/setup/test_flow.c | 27 +++++++++++++++++++ 17 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 tests/setup/check_prerelease_notice.cmake diff --git a/apps/shelldrive-setup/CMakeLists.txt b/apps/shelldrive-setup/CMakeLists.txt index a123a14..ac4b43e 100644 --- a/apps/shelldrive-setup/CMakeLists.txt +++ b/apps/shelldrive-setup/CMakeLists.txt @@ -11,6 +11,17 @@ include(SdPayload) +# Pre-release notice on the setup's first page (04 §10, D48): beta-channel setups are public pre-releases whose +# cryptography no external review has covered yet, so their welcome page warns; stable builds say nothing. +# On by default for the beta channel, off otherwise; the cache keeps an explicit -D value over a channel change. +if(SD_SETUP_CHANNEL STREQUAL "beta") + set(_sd_notice_default ON) +else() + set(_sd_notice_default OFF) +endif() +option(SD_SETUP_PRERELEASE_NOTICE "Show the pre-release cryptography notice on the setup's welcome page (04 §10, D48)" + "${_sd_notice_default}") + sd_add_l10n_catalog(installer DOMAINS installer) sd_setup_generate_regtable(SD_SETUP_REGTABLE_C) @@ -47,6 +58,10 @@ add_library(sd_setup_core STATIC target_include_directories(sd_setup_core PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") target_include_directories(sd_setup_core PRIVATE "${_sd_setup_host_dir}") target_link_libraries(sd_setup_core PUBLIC sd_headers sd_l10n sd_l10n_catalog_installer sd_base sd_payload_sig) +if(SD_SETUP_PRERELEASE_NOTICE) + # PUBLIC: the stub's own files (setup_main.c, setup_ui.c) see it through sd_setup_core, like SD_SETUP_BUILD_CHANNEL. + target_compile_definitions(sd_setup_core PUBLIC "SD_SETUP_PRERELEASE_NOTICE=1") +endif() set_target_properties(sd_setup_core PROPERTIES FOLDER "apps") if(SD_FLAVOUR STREQUAL "x86-ia32" OR SD_FLAVOUR STREQUAL "arm64" OR SD_FLAVOUR STREQUAL "arm32") diff --git a/apps/shelldrive-setup/setup_core.h b/apps/shelldrive-setup/setup_core.h index 3056e77..fc28900 100644 --- a/apps/shelldrive-setup/setup_core.h +++ b/apps/shelldrive-setup/setup_core.h @@ -230,6 +230,7 @@ typedef struct sd_setup_install_opts { int autostart; int force; int update; + int notice_shown; /* a page showed the pre-release notice (04 §10, D48); silent runs log it instead */ int skip_shortcut; int skip_hooks; int skip_agents; /* tests only: never signal or wait for running agents */ @@ -244,6 +245,7 @@ typedef struct sd_setup_uninstall_opts { const wchar_t *install_dir; /* NULL = the InstallDir value of the account registration */ const wchar_t *programs_dir; int force; + int notice_shown; /* a page showed the pre-release notice (04 §10, D48); silent runs log it instead */ int skip_shortcut; int skip_hooks; int skip_agents; /* tests only: never signal or wait for running agents */ diff --git a/apps/shelldrive-setup/setup_flow.c b/apps/shelldrive-setup/setup_flow.c index 537c44e..d36cccd 100644 --- a/apps/shelldrive-setup/setup_flow.c +++ b/apps/shelldrive-setup/setup_flow.c @@ -12,6 +12,11 @@ sd_flow_page sd_flow_first(const sd_flow_input *in) return SD_FLOW_WELCOME; } +int sd_flow_welcome_notice(const sd_flow_input *in, int enabled) +{ + return enabled != 0 && !in->quiet && !in->passive && !in->uninstall; +} + /* The page that follows the mode decision: the destination for a portable copy without a folder, the Dokany page for the all-users choice, otherwise the progress page. */ static sd_flow_page after_mode(const sd_flow_input *in, sd_setup_mode mode) diff --git a/apps/shelldrive-setup/setup_flow.h b/apps/shelldrive-setup/setup_flow.h index a6d8426..8368bbd 100644 --- a/apps/shelldrive-setup/setup_flow.h +++ b/apps/shelldrive-setup/setup_flow.h @@ -24,6 +24,13 @@ /* The all-users choice is offered (04 §6.3). It needs the payload MSI at run time (packaging/msi, SD_BUILD_MSI). */ #define SD_FLOW_MACHINE_AVAILABLE 1 +/* The pre-release notice on the welcome page (04 §10, D48): the build turns it on with the + SD_SETUP_PRERELEASE_NOTICE CMake option (on for the beta channel, off otherwise). 0 when undefined, so + stable builds carry no notice. */ +#ifndef SD_SETUP_PRERELEASE_NOTICE +#define SD_SETUP_PRERELEASE_NOTICE 0 +#endif + typedef enum sd_flow_page { SD_FLOW_NONE = 0, /* no more pages: the caller goes on with the work */ SD_FLOW_WELCOME, @@ -53,6 +60,11 @@ typedef struct sd_flow_choice { /* The page a run starts with: SD_FLOW_NONE for /quiet, SD_FLOW_PROGRESS for /passive. */ sd_flow_page sd_flow_first(const sd_flow_input *in); +/* Whether the welcome page shows the pre-release notice (04 §10, D48): only when the build carries it + (enabled is SD_SETUP_PRERELEASE_NOTICE) and a welcome page exists at all, that is an interactive + install. Quiet, passive and removal runs never show it; their log carries the notice instead. */ +int sd_flow_welcome_notice(const sd_flow_input *in, int enabled); + /* The page after cur, given the choices so far. SD_FLOW_NONE after the last page. */ sd_flow_page sd_flow_next(const sd_flow_input *in, const sd_flow_choice *c, sd_flow_page cur); diff --git a/apps/shelldrive-setup/setup_install.c b/apps/shelldrive-setup/setup_install.c index 3c8cdc1..44501cf 100644 --- a/apps/shelldrive-setup/setup_install.c +++ b/apps/shelldrive-setup/setup_install.c @@ -8,6 +8,8 @@ failed extraction never touches the installed files. */ #include "setup_int.h" +#include "setup_flow.h" /* SD_SETUP_PRERELEASE_NOTICE */ + #include #include #include @@ -582,6 +584,13 @@ int sd_setup_install(const sd_setup_install_opts *o, sd_setup_result *r) (int)o->mode, sd_setup_flavour_name(o->flavour) ? sd_setup_flavour_name(o->flavour) : L"none", o->version ? o->version : L"0.0.0", (unsigned)host.native_machine, host.sse2, host.os_major, host.os_minor, host.is_admin); +#if SD_SETUP_PRERELEASE_NOTICE + /* No page showed the pre-release notice (04 §10, D48): a silent run logs it, in English like every log line. */ + if (!o->notice_shown) + sd_setup_log("notice.prerelease", + L"Pre-release: the cryptography in this version has not yet been reviewed externally. " + L"Use it with test servers and test keys."); +#endif if (o->payload == NULL || o->payload->data == NULL || o->flavour == SD_SETUP_FL_NONE) { sd_setup_fail(r, SD_SETUP_EXIT_FATAL, SD_MSG_INSTALLER_ERROR_PAYLOAD_MISSING, 0); @@ -628,6 +637,13 @@ int sd_setup_uninstall(const sd_setup_uninstall_opts *o, sd_setup_result *r) open_log_for(o->log_path); setup_logf("uninstall.start", L"force=%d", o->force); +#if SD_SETUP_PRERELEASE_NOTICE + /* The removal pages carry no pre-release notice (04 §10, D48): the log carries it, in English. */ + if (!o->notice_shown) + sd_setup_log("notice.prerelease", + L"Pre-release: the cryptography in this version has not yet been reviewed externally. " + L"Use it with test servers and test keys."); +#endif if (o->install_dir != NULL && o->install_dir[0] != L'\0') { StringCchCopyW(dir, SD_SETUP_COUNTOF(dir), o->install_dir); diff --git a/apps/shelldrive-setup/setup_main.c b/apps/shelldrive-setup/setup_main.c index a4811f6..6869b72 100644 --- a/apps/shelldrive-setup/setup_main.c +++ b/apps/shelldrive-setup/setup_main.c @@ -538,6 +538,7 @@ static int run_install(HINSTANCE inst, const sd_setup_args *args, const wchar_t io.autostart = args->autostart; io.force = args->force; io.update = args->update; + io.notice_shown = interactive; /* the wizard showed the welcome page with its pre-release notice (04 §10) */ io.log_path = args->log_path[0] != L'\0' ? args->log_path : NULL; io.progress = progress_cb; io.progress_ctx = ui; diff --git a/apps/shelldrive-setup/setup_ui.c b/apps/shelldrive-setup/setup_ui.c index c04cd85..b853d29 100644 --- a/apps/shelldrive-setup/setup_ui.c +++ b/apps/shelldrive-setup/setup_ui.c @@ -52,6 +52,7 @@ struct sd_setup_ui { wchar_t uninstall_dir[MAX_PATH]; sd_setup_dokany_plan dokany_plan; sd_setup_mode default_mode; + int welcome_notice; /* the build carries the pre-release notice and the welcome page shows it */ }; /* Folder dialog and item-list helpers: shell32 and ole32 entry points resolved at run time (04 §2.4). */ @@ -222,9 +223,13 @@ static void set_buttons(sd_setup_ui *ui, int show, int back_on, sd_msg_id next_l static void build_welcome(sd_setup_ui *ui, int back_on) { wchar_t buf[512]; + int x = SD_UI_MARGIN, w = SD_UI_CLIENT_W - 2 * SD_UI_MARGIN; add_heading(ui, SD_MSG_INSTALLER_WELCOME_TITLE); - add_text(ui, text_of(SD_MSG_INSTALLER_WELCOME_TEXT, NULL, buf, SD_SETUP_COUNTOF(buf)), SD_UI_MARGIN, 48, - SD_UI_CLIENT_W - 2 * SD_UI_MARGIN, 80, SD_ID_BODY); + add_text(ui, text_of(SD_MSG_INSTALLER_WELCOME_TEXT, NULL, buf, SD_SETUP_COUNTOF(buf)), x, 48, w, 80, SD_ID_BODY); + /* Beta builds before crypto review pass 1 (04 §10, D48): a visible warning, not a tooltip. */ + if (ui->welcome_notice) + add_text(ui, text_of(SD_MSG_INSTALLER_WELCOME_PRERELEASE, NULL, buf, SD_SETUP_COUNTOF(buf)), x, 150, w, 72, + SD_ID_NOTE); set_buttons(ui, 1, back_on, SD_MSG_INSTALLER_BUTTON_NEXT); } @@ -474,6 +479,7 @@ int sd_setup_ui_run_pages(sd_setup_ui *ui, const sd_flow_input *in, const sd_set StringCchCopyW(ui->portable_dir, SD_SETUP_COUNTOF(ui->portable_dir), d->portable_dir ? d->portable_dir : L""); ui->dokany_plan = d->dokany_plan; ui->default_mode = d->mode; + ui->welcome_notice = sd_flow_welcome_notice(in, SD_SETUP_PRERELEASE_NOTICE); out->mode = (d->mode == SD_SETUP_MODE_AUTO) ? SD_SETUP_MODE_USER : d->mode; StringCchCopyW(out->portable_dir, SD_SETUP_COUNTOF(out->portable_dir), ui->portable_dir); @@ -531,6 +537,7 @@ int sd_setup_ui_test_pages(sd_setup_ui *ui, const sd_flow_input *in, const sd_se StringCchCopyW(ui->portable_dir, SD_SETUP_COUNTOF(ui->portable_dir), d->portable_dir ? d->portable_dir : L""); ui->dokany_plan = d->dokany_plan; ui->default_mode = d->mode; + ui->welcome_notice = sd_flow_welcome_notice(in, SD_SETUP_PRERELEASE_NOTICE); c.mode = (d->mode == SD_SETUP_MODE_AUTO) ? SD_SETUP_MODE_USER : d->mode; ui->failed = 0; diff --git a/l10n/installer/de.po b/l10n/installer/de.po index e3a3c98..8533f78 100644 --- a/l10n/installer/de.po +++ b/l10n/installer/de.po @@ -437,3 +437,8 @@ msgstr "Die Installation für alle Benutzer ist mit dem Windows-Installer-Code { msgctxt "installer.error.payload_signature" msgid "The setup program's release signature could not be verified. Download it again." msgstr "Die Release-Signatur des Installationsprogramms konnte nicht geprüft werden. Lade es erneut herunter." + +#. Visible warning on the welcome page of beta builds: the cryptography is not externally reviewed yet (04 §10, D48). +msgctxt "installer.welcome.prerelease" +msgid "Pre-release: the cryptography in this version has not yet been reviewed externally. Use it with test servers and test keys." +msgstr "Vorabversion: Die Kryptografie dieser Version wurde noch nicht extern überprüft. Verwende sie mit Testservern und Testschlüsseln." diff --git a/l10n/installer/en.po b/l10n/installer/en.po index e7b512f..f0bb0c8 100644 --- a/l10n/installer/en.po +++ b/l10n/installer/en.po @@ -476,3 +476,8 @@ msgstr "" msgctxt "installer.error.payload_signature" msgid "The setup program's release signature could not be verified. Download it again." msgstr "" + +#. Visible warning on the welcome page of beta builds: the cryptography is not externally reviewed yet (04 §10, D48). +msgctxt "installer.welcome.prerelease" +msgid "Pre-release: the cryptography in this version has not yet been reviewed externally. Use it with test servers and test keys." +msgstr "" diff --git a/l10n/installer/es.po b/l10n/installer/es.po index 40d80cd..b688a99 100644 --- a/l10n/installer/es.po +++ b/l10n/installer/es.po @@ -438,3 +438,8 @@ msgstr "La instalación para todos los usuarios falló con el código {code} de msgctxt "installer.error.payload_signature" msgid "The setup program's release signature could not be verified. Download it again." msgstr "No se pudo verificar la firma de publicación del programa de instalación. Descargalo de nuevo." + +#. Visible warning on the welcome page of beta builds: the cryptography is not externally reviewed yet (04 §10, D48). +msgctxt "installer.welcome.prerelease" +msgid "Pre-release: the cryptography in this version has not yet been reviewed externally. Use it with test servers and test keys." +msgstr "Versión preliminar: la criptografía de esta versión todavía no fue revisada externamente. Usala con servidores y claves de prueba." diff --git a/l10n/installer/fr.po b/l10n/installer/fr.po index 9ca1628..2e8e442 100644 --- a/l10n/installer/fr.po +++ b/l10n/installer/fr.po @@ -438,3 +438,8 @@ msgstr "L'installation pour tous les utilisateurs a échoué avec le code {code} msgctxt "installer.error.payload_signature" msgid "The setup program's release signature could not be verified. Download it again." msgstr "La signature de publication du programme d'installation n'a pas pu être vérifiée. Téléchargez-le à nouveau." + +#. Visible warning on the welcome page of beta builds: the cryptography is not externally reviewed yet (04 §10, D48). +msgctxt "installer.welcome.prerelease" +msgid "Pre-release: the cryptography in this version has not yet been reviewed externally. Use it with test servers and test keys." +msgstr "Préversion : la cryptographie de cette version n'a pas encore fait l'objet d'une révision externe. Utilisez-la avec des serveurs et des clés de test." diff --git a/l10n/installer/it.po b/l10n/installer/it.po index aab0770..f2e4ab4 100644 --- a/l10n/installer/it.po +++ b/l10n/installer/it.po @@ -438,3 +438,8 @@ msgstr "L'installazione per tutti gli utenti non è riuscita con il codice {code msgctxt "installer.error.payload_signature" msgid "The setup program's release signature could not be verified. Download it again." msgstr "Impossibile verificare la firma di rilascio del programma di installazione. Scaricalo di nuovo." + +#. Visible warning on the welcome page of beta builds: the cryptography is not externally reviewed yet (04 §10, D48). +msgctxt "installer.welcome.prerelease" +msgid "Pre-release: the cryptography in this version has not yet been reviewed externally. Use it with test servers and test keys." +msgstr "Versione preliminare: la crittografia di questa versione non è ancora stata revisionata esternamente. Usala con server e chiavi di prova." diff --git a/l10n/installer/pt-BR.po b/l10n/installer/pt-BR.po index 3db278a..a57a74c 100644 --- a/l10n/installer/pt-BR.po +++ b/l10n/installer/pt-BR.po @@ -438,3 +438,8 @@ msgstr "A instalação para todos os usuários falhou com o código {code} do Wi msgctxt "installer.error.payload_signature" msgid "The setup program's release signature could not be verified. Download it again." msgstr "Não foi possível verificar a assinatura de lançamento do programa de instalação. Baixe-o novamente." + +#. Visible warning on the welcome page of beta builds: the cryptography is not externally reviewed yet (04 §10, D48). +msgctxt "installer.welcome.prerelease" +msgid "Pre-release: the cryptography in this version has not yet been reviewed externally. Use it with test servers and test keys." +msgstr "Versão prévia: a criptografia desta versão ainda não foi revisada externamente. Use-a com servidores e chaves de teste." diff --git a/tests/setup/CMakeLists.txt b/tests/setup/CMakeLists.txt index 6b0295f..2ffbbc7 100644 --- a/tests/setup/CMakeLists.txt +++ b/tests/setup/CMakeLists.txt @@ -11,3 +11,9 @@ sd_add_unit_test(engine DEPS sd_setup_core sd_crypto) # test_trust.c uses the service's svc_repair.h seam (the same owner rule as tests/unit/sd_svc/test_repair.c). target_include_directories(sd-test-engine PRIVATE "${CMAKE_SOURCE_DIR}/apps/shelldrive-host") + +# The pre-release notice exists in all six catalogs (04 §10, D48); the flow logic is covered by unit.engine. +add_test(NAME setup.l10n.prerelease_notice + COMMAND "${CMAKE_COMMAND}" "-DPO_DIR=${CMAKE_SOURCE_DIR}/l10n/installer" + -P "${CMAKE_CURRENT_SOURCE_DIR}/check_prerelease_notice.cmake") +set_tests_properties(setup.l10n.prerelease_notice PROPERTIES LABELS setup) diff --git a/tests/setup/check_prerelease_notice.cmake b/tests/setup/check_prerelease_notice.cmake new file mode 100644 index 0000000..f7a1ca6 --- /dev/null +++ b/tests/setup/check_prerelease_notice.cmake @@ -0,0 +1,32 @@ +# ctest helper for setup.l10n.prerelease_notice (tests/setup/CMakeLists.txt, 04 §10, D48). +# Expects -DPO_DIR=. The pre-release notice must exist in all six catalogs: the English +# msgid (the source of truth), and a non-empty msgstr in the five translations. +set(_langs "en;es;fr;pt-BR;it;de") +set(_ctx "msgctxt \"installer.welcome.prerelease\"") +foreach(_lang IN LISTS _langs) + set(_po "${PO_DIR}/${_lang}.po") + if(NOT EXISTS "${_po}") + message(FATAL_ERROR "missing catalog: ${_po}") + endif() + file(READ "${_po}" _text) + string(FIND "${_text}" "${_ctx}" _at) + if(_at LESS 0) + message(FATAL_ERROR "${_lang}.po has no installer.welcome.prerelease entry") + endif() + # The entry sits before the license section, so a window past it covers its msgid and msgstr lines. + string(SUBSTRING "${_text}" ${_at} 400 _entry) + string(REGEX MATCH "msgid \"[^\"]+\"" _msgid "${_entry}") + if(NOT _msgid) + message(FATAL_ERROR "${_lang}.po: installer.welcome.prerelease has no msgid") + endif() + string(REGEX MATCH "msgstr \"([^\"]*)\"" _msgstr "${_entry}") + set(_translation "${CMAKE_MATCH_1}") + if(_lang STREQUAL "en") + # en.po keeps msgstr empty; the msgid is the English text. + if(NOT _translation STREQUAL "") + message(FATAL_ERROR "en.po: installer.welcome.prerelease must keep msgstr empty") + endif() + elseif(_translation STREQUAL "") + message(FATAL_ERROR "${_lang}.po: installer.welcome.prerelease has an empty msgstr") + endif() +endforeach() diff --git a/tests/setup/main.c b/tests/setup/main.c index 3f0555d..ceb9d5f 100644 --- a/tests/setup/main.c +++ b/tests/setup/main.c @@ -36,6 +36,7 @@ SD_TEST_DECL(setup_flow_terminates_for_every_input); SD_TEST_DECL(setup_flow_dokany_view_states); SD_TEST_DECL(setup_flow_dokany_plan_by_mode); SD_TEST_DECL(setup_flow_names_and_titles); +SD_TEST_DECL(setup_flow_welcome_notice); SD_TEST_DECL(setup_programdata_trust); SD_TEST_DECL(setup_stage_dir_fresh_names); SD_TEST_DECL(setup_dokany_pe_vcr_state); @@ -121,6 +122,7 @@ static const sd_test_case k_tests[] = { SD_TEST_ENTRY(setup_flow_dokany_view_states), SD_TEST_ENTRY(setup_flow_dokany_plan_by_mode), SD_TEST_ENTRY(setup_flow_names_and_titles), + SD_TEST_ENTRY(setup_flow_welcome_notice), SD_TEST_ENTRY(setup_programdata_trust), SD_TEST_ENTRY(setup_stage_dir_fresh_names), SD_TEST_ENTRY(setup_dokany_pe_vcr_state), diff --git a/tests/setup/test_flow.c b/tests/setup/test_flow.c index 2f6b99b..18587cd 100644 --- a/tests/setup/test_flow.c +++ b/tests/setup/test_flow.c @@ -210,3 +210,30 @@ SD_TEST(setup_flow_names_and_titles) /* The all-users choice is offered (04 §6.3); its install runs the elevated stages with the progress view. */ SD_CHECK_EQ_INT(SD_FLOW_MACHINE_AVAILABLE, 1); } + +SD_TEST(setup_flow_welcome_notice) +{ + sd_flow_input in; + int built_notice = SD_SETUP_PRERELEASE_NOTICE; + + /* The flag is a 0/1 build switch (SD_SETUP_PRERELEASE_NOTICE, on for the beta channel). */ + SD_CHECK(built_notice == 0 || built_notice == 1); + + /* Off in the build (stable): no command line shows it (04 §10, D48). */ + memset(&in, 0, sizeof(in)); + SD_CHECK(!sd_flow_welcome_notice(&in, 0)); + in.quiet = in.passive = in.uninstall = 1; + SD_CHECK(!sd_flow_welcome_notice(&in, 0)); + + /* On (beta): the welcome page shows it; a run without a welcome page logs it instead. */ + memset(&in, 0, sizeof(in)); + SD_CHECK(sd_flow_welcome_notice(&in, 1) == 1); + in.quiet = 1; + SD_CHECK(!sd_flow_welcome_notice(&in, 1)); /* /quiet shows no page at all */ + in.quiet = 0; + in.passive = 1; + SD_CHECK(!sd_flow_welcome_notice(&in, 1)); /* /passive shows the progress only */ + in.passive = 0; + in.uninstall = 1; + SD_CHECK(!sd_flow_welcome_notice(&in, 1)); /* the removal pages carry no notice */ +} From dcf27116301fea1934e73b0d3969c06623d6ce74 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sat, 31 Oct 2026 23:40:38 -0300 Subject: [PATCH 06/15] =?UTF-8?q?docs:=20document=20the=20SD=5FSETUP=5FPRE?= =?UTF-8?q?RELEASE=5FNOTICE=20option=20(D48,=2004=20=C2=A710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One sentence next to the interim rule in 04 §10 (beta setups carry the notice; silent runs log it in English) and a short paragraph in docs/dev/m4-notes.md with the option's default, the compile-in path and the tests. --- docs/design/04-build-install-update.md | 4 +++- docs/dev/m4-notes.md | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/design/04-build-install-update.md b/docs/design/04-build-install-update.md index 6b830fc..9cb9d25 100644 --- a/docs/design/04-build-install-update.md +++ b/docs/design/04-build-install-update.md @@ -1587,7 +1587,9 @@ protected your PC" cannot tell a trojaned look-alike from our build. Until the A - The setup stub verifies its own payload table against the compiled-in keyset and refuses on mismatch (§6.1); existing installs update only through the signed manifest (§9). - Before crypto review pass 1 (end of M2, D38) tester builds show "pre-release, cryptography not yet externally - reviewed" on the first setup page, and tester instructions recommend test servers and test keys. + reviewed" on the first setup page, and tester instructions recommend test servers and test keys. The setup + carries the notice when it is built with the `SD_SETUP_PRERELEASE_NOTICE` CMake option (on by default for the + beta channel, off otherwise); a silent run logs the notice in English instead. - The gates for wider distribution are in §12.3 ("Release gates"). ### 10.1 Sparse MSIX package for the Windows 11 modern context menu (D27) diff --git a/docs/dev/m4-notes.md b/docs/dev/m4-notes.md index 05ead55..40eb103 100644 --- a/docs/dev/m4-notes.md +++ b/docs/dev/m4-notes.md @@ -901,3 +901,18 @@ Open (lab or later): `x64-modern`, `arm64` and `arm32` with the runner's WiX 3.14, runs their `msi.*` checks and uploads the packages. The `x64-modern` and `arm64` jobs now also install `v141_xp` for the x86 shell extension, which adds several minutes to each. + +## Beta pre-release notice on the welcome page (D48, 04 §10) + +`SD_SETUP_PRERELEASE_NOTICE` (new CMake option in `apps/shelldrive-setup/CMakeLists.txt`) defaults to ON when +`SD_SETUP_CHANNEL` is `beta` and OFF otherwise, on a fresh configure; as with every `option()`, a value already +in the CMake cache (including the default computed for another channel) is kept, so pass `-D` explicitly or use +a fresh build dir when switching the channel in place. When on, it compiles `SD_SETUP_PRERELEASE_NOTICE=1` into +`sd_setup_core` (PUBLIC, so the stub's `setup_main.c` and `setup_ui.c` see it too). The welcome page then shows +`installer.welcome.prerelease` (new string, all six catalogs, appended at the end of each `.po`) as a visible +warning above the buttons; the pure switch is `sd_flow_welcome_notice` (setup_flow.c, covered by `unit.engine`), +and `/quiet`, `/passive` and the removal flow log the English text instead (`notice.prerelease` line via the +new `notice_shown` field of the install and uninstall opts). Stable builds never show it. The setup stub is the +only surface: the MSI's strings share the catalog (`msi.*`) but its dialogs are unchanged. Tests: +`setup.l10n.prerelease_notice` (the string exists in all six `.po`, msgstr non-empty outside en) and +`unit.engine`'s `setup_flow_welcome_notice`. From b20420a037606db2274351315dd1f26a7b35414d Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 00:09:44 -0300 Subject: [PATCH 07/15] build: generate the compiled-in keyring from installer/keys (04 9.3) sd-release keyring verifies the signed keyset of installer/keys/keyset.txt against the two root public keys of roots.txt (the keyset's root line encoding; comments and blank lines allowed) and writes the two include files deterministically into the build tree: setup_keyring.inc (the stub's payload keyring) and update_trust.inc (the update trust of shelldrive-host). cmake/SdKeyring.cmake runs it as a build step whenever installer/keys/ exists; without it the placeholders in the source tree are compiled in, so every verification still fails closed. The payload signature library splits: sd_payload_sig_text (pure verifier, linked by sd-release) and sd_payload_sig (the stub's keyring wrapper), so the keyring of the build can depend on the tool that generates it without a dependency cycle. sd-release joins the host tools of the ARM64 and ARM32 nested builds, since it now runs during the build there too. The includes are angle form and the generated directory goes first on the include path: a quoted include would always find the placeholder beside the sources, whatever the -I order (checked with cl). Test: tests/release/keyring_flow.cmake (ctest release.keyring) covers the generated entries, the inclusive unix-second dates, revocation, byte determinism, a keyset signed by an unknown root (exit 3) and malformed roots.txt variants (exit 2). --- CMakeLists.txt | 19 +- apps/shelldrive-host/CMakeLists.txt | 9 + apps/shelldrive-host/update_ipc.c | 11 +- apps/shelldrive-host/update_trust.inc | 6 + apps/shelldrive-setup/CMakeLists.txt | 25 +- apps/shelldrive-setup/setup_payload_sig.c | 39 +-- apps/shelldrive-setup/setup_payload_sig.h | 2 +- .../setup_payload_sig_keyring.c | 36 +++ cmake/SdHostTools.cmake | 5 +- cmake/SdKeyring.cmake | 42 ++++ docs/design/04-build-install-update.md | 7 +- docs/dev/m4-notes.md | 23 +- tests/release/CMakeLists.txt | 9 + tests/release/keyring_flow.cmake | 210 +++++++++++++++++ tests/release/write_lf.ps1 | 11 + tools/sd-release/CMakeLists.txt | 7 +- tools/sd-release/sd-release.c | 222 +++++++++++++++++- 17 files changed, 623 insertions(+), 60 deletions(-) create mode 100644 apps/shelldrive-host/update_trust.inc create mode 100644 apps/shelldrive-setup/setup_payload_sig_keyring.c create mode 100644 cmake/SdKeyring.cmake create mode 100644 tests/release/keyring_flow.cmake create mode 100644 tests/release/write_lf.ps1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 3924a8d..1f60246 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ include(SdVersion) # VERSION file, git commit, generated sd/version.h include(SdModule) # sd_add_module, sd_add_unit_test, sd_add_app helpers include(SdL10n) # sdl10n catalog generation (03 §9) include(SdHostTools) # build-time tools of cross-compiled (ARM64, ARM32) trees (04 §2.5) +include(SdKeyring) # compiled-in release keys of installer/keys/ (04 §9.3) # Group Policy headers and ADMX/ADML outputs (03 §3.5); generated under generated/policy/. set(SD_POLICY_DIR "${SD_GENERATED_DIR}/policy") @@ -33,6 +34,17 @@ if(SD_HOST_TOOLS_ONLY) add_subdirectory(tools/sd-importcheck) add_subdirectory(tools/sd-admxgen) add_subdirectory(tools/sd-payload) + # sd-release now also runs during the build (`sd-release keyring`, cmake/SdKeyring.cmake): it verifies against the + # modules of the update engine and the pure payload table signature verifier (sd_payload_sig_text, without the + # keyring: the keyring library of the stub depends on this very tool). + add_subdirectory(src/sd_base) + add_subdirectory(src/sd_platform) + add_subdirectory(src/sd_crypto) + add_subdirectory(src/sd_update) + add_library(sd_payload_sig_text STATIC "${CMAKE_SOURCE_DIR}/apps/shelldrive-setup/setup_payload_sig.c") + target_include_directories(sd_payload_sig_text PUBLIC "${CMAKE_SOURCE_DIR}/apps/shelldrive-setup") + target_link_libraries(sd_payload_sig_text PUBLIC sd_crypto sd_headers) + add_subdirectory(tools/sd-release) if(SD_HOST_TOOLS_MSI) # The MSI tools of an ARM tree with SD_BUILD_MSI (packaging/msi, tests/msi). add_subdirectory(tools/sd-msigen) @@ -77,7 +89,12 @@ if(NOT SD_ARM64 AND NOT SD_ARM32) add_subdirectory(tools/sd-isacheck) endif() add_subdirectory(tools/sd-updsign) -add_subdirectory(tools/sd-release) +# In a cross-compiled tree sd-release is a host tool (cmake/SdHostTools.cmake): the imported x64 binary runs the +# keyring step, and this tree must not define a second target of that name. sd_keyring_setup needs it below. +if(NOT SD_CROSS_HOST_TOOLS) + add_subdirectory(tools/sd-release) +endif() +sd_keyring_setup() # Test harness (needed by module unit tests). add_subdirectory(tests/harness) diff --git a/apps/shelldrive-host/CMakeLists.txt b/apps/shelldrive-host/CMakeLists.txt index 4297e27..0d188e3 100644 --- a/apps/shelldrive-host/CMakeLists.txt +++ b/apps/shelldrive-host/CMakeLists.txt @@ -90,3 +90,12 @@ sd_add_app(shelldrive-host sd_l10n sd_l10n_catalog_svc ole32 shell32 user32 advapi32 mpr) target_include_directories(shelldrive-host PRIVATE "${SD_SVC_EVENTS_DIR}") add_dependencies(shelldrive-host sd_svc_events) +# Compiled-in update trust (04 §9.3): update_ipc.c angle-includes . This source dir must be on the +# include path for the placeholder; with installer/keys/ the generated directory (cmake/SdKeyring.cmake) goes first, +# so the generated trust is the one compiled in. Test binaries that reuse these sources (tests/apps) keep the +# placeholder: they never verify a real payload. +target_include_directories(shelldrive-host PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") +if(SD_KEYRING_TARGET) + target_include_directories(shelldrive-host BEFORE PRIVATE "${SD_KEYRING_DIR}") + add_dependencies(shelldrive-host ${SD_KEYRING_TARGET}) +endif() diff --git a/apps/shelldrive-host/update_ipc.c b/apps/shelldrive-host/update_ipc.c index e4802a6..bd1edcc 100644 --- a/apps/shelldrive-host/update_ipc.c +++ b/apps/shelldrive-host/update_ipc.c @@ -1,8 +1,9 @@ /* shelldrive-host update methods (see update_ipc.h). Configuration precedence (04 §9.1, 03 §3.5): * policy (sd_policy: HKLM, then HKCU; the update policies of 03 §3.5) > settings.ini [update] of the user * (or of \data for a portable install) > settings.ini [update] of the machine (%ProgramData%) > defaults. - * Defaults: enabled, channel stable, the GitHub bootstrap manifest. Trust: the release roots are compiled in by the - * release build (04 §9.3); this build carries none, so every verification fails closed until they are added. */ + * Defaults: enabled, channel stable, the GitHub bootstrap manifest. Trust (update_trust.inc, 04 §9.3): cmake/SdKeyring.cmake + * generates it with `sd-release keyring` from installer/keys/ when the repository carries those files; the placeholder + * in this directory holds none, so every verification fails closed until then. */ #include #include #include @@ -27,8 +28,10 @@ /* One check or apply at a time, per agent. */ static volatile LONG g_busy; -/* No release roots in this build (see the header comment). */ -static const sd_upd_trust k_trust = { NULL, 0, NULL, 0 }; +/* The update trust: roots array, its count, and the exact keyset.txt bytes (sd_upd_trust, sd_update.h). Generated + into the build tree from installer/keys/ (update_trust.inc), or the placeholder with none (see the header + comment). Angle include: the generated directory, when present, comes first on the include path. */ +#include typedef struct upd_cfg { int enabled; /* 0: checks disabled (reason says why) */ diff --git a/apps/shelldrive-host/update_trust.inc b/apps/shelldrive-host/update_trust.inc new file mode 100644 index 0000000..063d214 --- /dev/null +++ b/apps/shelldrive-host/update_trust.inc @@ -0,0 +1,6 @@ +/* update_trust.inc: the update trust compiled into shelldrive-host (see update_ipc.c). Generated by `sd-release + * keyring` from installer/keys/roots.txt and installer/keys/keyset.txt (04 §9.3) into the build tree when the + * repository carries those files (cmake/SdKeyring.cmake puts the generated directory first on the include path); + * this placeholder is used otherwise. It holds no roots and no keyset, so every update verification fails closed + * until the release pipeline provides them. */ +static const sd_upd_trust k_trust = { NULL, 0, NULL, 0 }; diff --git a/apps/shelldrive-setup/CMakeLists.txt b/apps/shelldrive-setup/CMakeLists.txt index ac4b43e..92e6391 100644 --- a/apps/shelldrive-setup/CMakeLists.txt +++ b/apps/shelldrive-setup/CMakeLists.txt @@ -29,13 +29,26 @@ sd_setup_generate_regtable(SD_SETUP_REGTABLE_C) # The TM-R34 folder check is the service's (svc_repair.c, svc_folder.c): the same source files, not a copy. set(_sd_setup_host_dir "${CMAKE_SOURCE_DIR}/apps/shelldrive-host") -# Payload table signature (TM-R38): pure verifier plus the resource wrapper. Shared with tools/sd-release and its -# tests; sd_crypto only (sd_ed25519_verify and SHA-256 run on XP and in the stub). -add_library(sd_payload_sig STATIC setup_payload_sig.c) -target_include_directories(sd_payload_sig PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") -target_link_libraries(sd_payload_sig PUBLIC sd_crypto sd_headers) -target_compile_definitions(sd_payload_sig PUBLIC "SD_SETUP_BUILD_CHANNEL=\"${SD_SETUP_CHANNEL}\"") +# Payload table signature (TM-R38): the pure verifier (sd_payload_sig_text, also linked by tools/sd-release, whose +# keyring step must not depend back on the keyring library) and the stub's compiled-in keyring wrapper +# (sd_payload_sig: setup_payload_sig_keyring.c, one call site in setup_main.c). sd_crypto only (sd_ed25519_verify and +# SHA-256 run on XP and in the stub). +add_library(sd_payload_sig_text STATIC setup_payload_sig.c) +target_include_directories(sd_payload_sig_text PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(sd_payload_sig_text PUBLIC sd_crypto sd_headers) +target_compile_definitions(sd_payload_sig_text PUBLIC "SD_SETUP_BUILD_CHANNEL=\"${SD_SETUP_CHANNEL}\"") +set_target_properties(sd_payload_sig_text PROPERTIES FOLDER "apps") + +add_library(sd_payload_sig STATIC setup_payload_sig_keyring.c) +target_link_libraries(sd_payload_sig PUBLIC sd_headers sd_payload_sig_text) set_target_properties(sd_payload_sig PROPERTIES FOLDER "apps") +# Compiled-in keyring (04 §9.3): with installer/keys/ the generated directory goes FIRST on the include path, so the +# angle include of in setup_payload_sig_keyring.c picks the generated keyring; without it the +# placeholder beside the sources is used (this source dir stays on the path via sd_payload_sig_text). +if(SD_KEYRING_TARGET) + target_include_directories(sd_payload_sig BEFORE PRIVATE "${SD_KEYRING_DIR}") + add_dependencies(sd_payload_sig ${SD_KEYRING_TARGET}) +endif() add_library(sd_setup_core STATIC setup_args.c diff --git a/apps/shelldrive-setup/setup_payload_sig.c b/apps/shelldrive-setup/setup_payload_sig.c index ee596ab..d7f2272 100644 --- a/apps/shelldrive-setup/setup_payload_sig.c +++ b/apps/shelldrive-setup/setup_payload_sig.c @@ -1,25 +1,16 @@ /* Payload table signature (docs/design/04 §6.1, §9.3; TM-R38, T-71). See setup_payload_sig.h. * - * Fail closed: any malformed input, unknown key, wrong channel, revoked or out-of-date key, or a signature that - * does not verify returns an error, and the stub refuses to unpack. Only sd_crypto (sd_ed25519_verify, - * sd_hash) is used, which the stub may link (no CNG, TM-R33). */ + * Pure part, without the keyring: tools/sd-release links this file, and the keyring of the build is generated by + * that same tool, so the keyring and its resource wrapper live in setup_payload_sig_keyring.c (library + * sd_payload_sig). Fail closed: any malformed input, unknown key, wrong channel, revoked or out-of-date key, or a + * signature that does not verify returns an error, and the stub refuses to unpack. Only sd_crypto + * (sd_ed25519_verify, sd_hash) is used, which the stub may link (no CNG, TM-R33). */ #include #include #include "sd_crypto/sd_crypto.h" #include "setup_payload_sig.h" -#ifndef SD_SETUP_BUILD_CHANNEL -#define SD_SETUP_BUILD_CHANNEL "stable" -#endif - -/* The keyring compiled into the stub: the keys of the build's keyset (installer/keys/keyset.txt, §9.3). The file - in the repository holds only a revoked placeholder, so no payload verifies until the release pipeline - generates it. */ -static const sd_setup_psig_key k_builtin_keys[] = { -#include "setup_keyring.inc" -}; - static const char k_header_magic[] = "shelldrive-payload-table 1\n"; static const char k_header_product[] = "product " SD_SETUP_PSIG_PRODUCT "\n"; static const char k_sig_prefix[] = "sig ed25519 "; @@ -245,23 +236,3 @@ int64_t sd_setup_psig_now(void) /* 100 ns intervals since 1601-01-01 -> unix seconds */ return (int64_t)(u.QuadPart / 10000000ull) - 11644473600ll; } - -sd_setup_psig_res sd_setup_payload_sig_check(HINSTANCE inst, const uint8_t *table, size_t tlen) -{ - HRSRC hr; - HGLOBAL hg; - const uint8_t *p; - DWORD n; - - hr = FindResourceW(inst, L"PAYLOAD_SIG", RT_RCDATA); - hg = hr != NULL ? LoadResource(inst, hr) : NULL; - if (hg == NULL || table == NULL) - return SD_SETUP_PSIG_E_RESOURCE; - p = (const uint8_t *)LockResource(hg); - n = SizeofResource(inst, hr); - if (p == NULL || n == 0) - return SD_SETUP_PSIG_E_RESOURCE; - return sd_setup_psig_verify(table, tlen, p, n, k_builtin_keys, - sizeof(k_builtin_keys) / sizeof(k_builtin_keys[0]), - SD_SETUP_BUILD_CHANNEL, sd_setup_psig_now()); -} diff --git a/apps/shelldrive-setup/setup_payload_sig.h b/apps/shelldrive-setup/setup_payload_sig.h index 81e0404..4a32353 100644 --- a/apps/shelldrive-setup/setup_payload_sig.h +++ b/apps/shelldrive-setup/setup_payload_sig.h @@ -67,7 +67,7 @@ int sd_setup_psig_b64_decode(const char *s, size_t len, uint8_t *out, size_t cap int64_t sd_setup_psig_now(void); /* Resource wrapper for the stub: loads PAYLOAD_SIG from inst, checks the PAYLOAD_TABLE bytes against the keyring - compiled into the stub (setup_payload_sig.c, setup_keyring.inc) for SD_SETUP_BUILD_CHANNEL. 0 = verified. */ + compiled into the stub (setup_payload_sig_keyring.c, setup_keyring.inc) for SD_SETUP_BUILD_CHANNEL. 0 = verified. */ sd_setup_psig_res sd_setup_payload_sig_check(HINSTANCE inst, const uint8_t *table, size_t tlen); #endif /* SD_SETUP_PAYLOAD_SIG_H */ diff --git a/apps/shelldrive-setup/setup_payload_sig_keyring.c b/apps/shelldrive-setup/setup_payload_sig_keyring.c new file mode 100644 index 0000000..5d6cb70 --- /dev/null +++ b/apps/shelldrive-setup/setup_payload_sig_keyring.c @@ -0,0 +1,36 @@ +/* The keyring compiled into the stub and its resource wrapper (docs/design/04 §6.1, §9.3; TM-R38). The pure + * verifier lives in setup_payload_sig.c (library sd_payload_sig_text, shared with tools/sd-release, which must not + * pull this file in: the keyring of the build depends on the tool that generates it). The single call site of + * sd_setup_payload_sig_check is the stub (setup_main.c, run_install). + * + * The repository file setup_keyring.inc holds only a revoked placeholder, so no payload verifies until the release + * build generates it: cmake/SdKeyring.cmake runs `sd-release keyring` and puts the generated directory FIRST on the + * include path of this target, so the angle include below picks the generated file (a quoted include would always + * find the placeholder beside the sources, whichever -I comes first). */ +#include + +#include "setup_payload_sig.h" + +static const sd_setup_psig_key k_builtin_keys[] = { +#include +}; + +sd_setup_psig_res sd_setup_payload_sig_check(HINSTANCE inst, const uint8_t *table, size_t tlen) +{ + HRSRC hr; + HGLOBAL hg; + const uint8_t *p; + DWORD n; + + hr = FindResourceW(inst, L"PAYLOAD_SIG", RT_RCDATA); + hg = hr != NULL ? LoadResource(inst, hr) : NULL; + if (hg == NULL || table == NULL) + return SD_SETUP_PSIG_E_RESOURCE; + p = (const uint8_t *)LockResource(hg); + n = SizeofResource(inst, hr); + if (p == NULL || n == 0) + return SD_SETUP_PSIG_E_RESOURCE; + return sd_setup_psig_verify(table, tlen, p, n, k_builtin_keys, + sizeof(k_builtin_keys) / sizeof(k_builtin_keys[0]), + SD_SETUP_BUILD_CHANNEL, sd_setup_psig_now()); +} diff --git a/cmake/SdHostTools.cmake b/cmake/SdHostTools.cmake index 4c57494..5ed55e6 100644 --- a/cmake/SdHostTools.cmake +++ b/cmake/SdHostTools.cmake @@ -1,6 +1,7 @@ # Build-time tools for cross-compiled trees (ARM64 and ARM32, 04 §1.1, §2.5). # -# The tools that run during the build (sdl10n, sd-importcheck, sd-admxgen, sd-payload) must run on the build +# The tools that run during the build (sdl10n, sd-importcheck, sd-admxgen, sd-payload, and sd-release for the keyring +# step of 04 §9.3, cmake/SdKeyring.cmake) must run on the build # host. An ARM64 or ARM32 tree cannot run them: its executables are ARM and Windows 11 on x64 does not run ARM # binaries. So an ARM64 or ARM32 configure builds them in a nested x64 configure of this same source tree # (SD_HOST_TOOLS_ONLY=ON, see the root CMakeLists.txt) through the ExternalProject target sd_host_tools, and the @@ -14,7 +15,7 @@ # packaging/msi runs, and sd-test-msi, which the msi.* CTests run on the package (tests/msi). So an ARM tree builds its # MSI and checks it on this host, the way an x86 or x64 tree does (04 §8, D31). -set(SD_HOST_TOOLS_NAMES sdl10n sd-importcheck sd-admxgen sd-payload) +set(SD_HOST_TOOLS_NAMES sdl10n sd-importcheck sd-admxgen sd-payload sd-release) set(SD_HOST_TOOLS_MSI_NAMES sd-msigen sd-msiembed sd-test-msi) function(sd_host_tools_setup) diff --git a/cmake/SdKeyring.cmake b/cmake/SdKeyring.cmake new file mode 100644 index 0000000..2b676e8 --- /dev/null +++ b/cmake/SdKeyring.cmake @@ -0,0 +1,42 @@ +# Compiled-in release keys (docs/design/04 §9.3). `sd-release keyring` verifies the signed keyset of +# installer/keys/ against the two root public keys and generates the two include files that every shipping binary +# compiles in: the stub's payload keyring (setup_keyring.inc) and the host's update trust (update_trust.inc). The +# files are generated into the build tree, deterministically (04 §12.2), and the generated directory goes FIRST on +# the include path of the targets that include them, so the generated file wins over the placeholder in the source +# tree (the includes are angle form for exactly that reason; MSVC searches the including file's own directory +# before the include path for quoted includes). +# +# sd_keyring_setup() called once by the root CMakeLists.txt, after the build-time tools exist. It sets: +# SD_KEYRING_DIR directory of the generated include files; empty when installer/keys/ is absent. +# SD_KEYRING_TARGET the custom target that generates them ("sd_keyring"); empty likewise. +# +# Without installer/keys/ nothing changes: the repository placeholders are used, which hold no keys, so no payload +# verifies and every update verification fails closed until the release pipeline provides the key files. + +set(SD_KEYRING_DIR "") +set(SD_KEYRING_TARGET "") + +function(sd_keyring_setup) + set(_roots "${CMAKE_SOURCE_DIR}/installer/keys/roots.txt") + set(_keyset "${CMAKE_SOURCE_DIR}/installer/keys/keyset.txt") + if(NOT EXISTS "${_roots}" OR NOT EXISTS "${_keyset}") + return() + endif() + set(_dir "${SD_GENERATED_DIR}/keyring") + set(_setup_inc "${_dir}/setup_keyring.inc") + set(_trust_inc "${_dir}/update_trust.inc") + sd_tool_depends(sd-release _sd_keyring_tool_dep) + add_custom_command( + OUTPUT "${_setup_inc}" "${_trust_inc}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${_dir}" + COMMAND $ keyring --roots "${_roots}" --keyset "${_keyset}" + --out-setup "${_setup_inc}" --out-trust "${_trust_inc}" + DEPENDS ${_sd_keyring_tool_dep} "${_roots}" "${_keyset}" + COMMENT "sd-release keyring: compiled-in release keys (04 §9.3)" + VERBATIM) + add_custom_target(sd_keyring DEPENDS "${_setup_inc}" "${_trust_inc}") + sd_add_tool_dependency(sd_keyring sd-release) + set_target_properties(sd_keyring PROPERTIES FOLDER "tools") + set(SD_KEYRING_DIR "${_dir}" PARENT_SCOPE) + set(SD_KEYRING_TARGET "sd_keyring" PARENT_SCOPE) +endfunction() diff --git a/docs/design/04-build-install-update.md b/docs/design/04-build-install-update.md index 9cb9d25..01b6aaf 100644 --- a/docs/design/04-build-install-update.md +++ b/docs/design/04-build-install-update.md @@ -1375,7 +1375,12 @@ manifests and payload tables only and cannot change who is trusted. - Key ID = first 8 bytes of SHA-256(public key). Key labels (key file names, keyset comments, CI secret descriptions) are reverse-DNS (D45): `com.librestatic.shelldrive.root-a`, `.root-b`, `.release-stable`, `.release-beta`. The two **root public keys** and the keyset current at build - time (`installer/keys/`) are compiled into every binary and stub. + time (`installer/keys/`) are compiled into every binary and stub: at build time `sd-release keyring + --roots installer/keys/roots.txt --keyset installer/keys/keyset.txt` verifies the keyset against the roots of + `roots.txt` (the two root lines in the keyset's `root ` encoding; `#` comments and blank + lines allowed) and writes the two include files deterministically into the build tree (the stub's + `setup_keyring.inc`, the host's `update_trust.inc`). A build without `installer/keys/` compiles the + placeholders in the source tree, so no payload verifies and every update verification fails closed. - **Keyset format** (`keyset.txt`, same line syntax and size limit as the manifest): ``` diff --git a/docs/dev/m4-notes.md b/docs/dev/m4-notes.md index 40eb103..3adbef5 100644 --- a/docs/dev/m4-notes.md +++ b/docs/dev/m4-notes.md @@ -107,7 +107,8 @@ Content-Length above the manifest size; digest mismatch with nothing kept; setup - **Release roots:** no root public key is compiled into this build. The release build must add the two root keys and the keyset current at build time (`installer/keys/`, 04 §9.3); until then every verification fails closed - (`update_verify_failed`). The `k_trust` table in `update_ipc.c` is the single place to change. + (`update_verify_failed`). The trust is `update_trust.inc` (update_ipc.c includes it): `sd-release keyring` generates + it from `installer/keys/` at build time (cmake/SdKeyring.cmake), and the placeholder in the source tree holds none. - **Bootstrap URLs:** only the GitHub URL is the default. The HTTP mirror (`http://dl./...`) needs the domain (D30, an operational choice); until then, testers set `manifest_url`. The `manifest_alt` slot exists for it. - **Beta:** there is no default beta manifest URL; a beta user sets `manifest_url` and `channel = beta`. @@ -431,7 +432,7 @@ lint). Rows 20 to 22 of the table move as follows. |---|---|---|---| | 20 | Release pipeline | Partial (unchanged) | `release.yml` still stops at `package` and `finalize`; its header says `sd-release` is missing, which is no longer true. The `workflow-lint` job is in `ci.yml` and `nightly.yml`. | | 21 | Two offline signing sessions (D42) | Partial | `sd-release session` writes `SESSION.txt` with the file hashes and the digest of the signed message; `sd-release sign` and `verify` do the signing and the checks. The steps stay manual (no signing in CI). `resign` is missing (see open items). | -| 22 | `PAYLOAD_SIG` and signed payload tables (TM-R38) | Done in the stub; the keyring and the package step are open | `apps/shelldrive-setup/setup_payload_sig.c|h`, one call site in `setup_main.c` (`run_install`, after the table lookup). Tests: `tests/setup/test_payload_sig.c` (14 tests, generated test keys). | +| 22 | `PAYLOAD_SIG` and signed payload tables (TM-R38) | Done in the stub; the keyring generator is done (`sd-release keyring`, test `release.keyring`), the real `installer/keys/` and the package step are open | `apps/shelldrive-setup/setup_payload_sig.c|h`, one call site in `setup_main.c` (`run_install`, after the table lookup). Tests: `tests/setup/test_payload_sig.c` (14 tests, generated test keys). | ### What the stub checks now (04 section 6.1, 9.3; TM-R38) @@ -444,7 +445,9 @@ lint). Rows 20 to 22 of the table move as follows. `SD_SETUP_BUILD_CHANNEL` (default `stable`). - Any failure returns a refusal with `SD_MSG_INSTALLER_ERROR_PAYLOAD_DAMAGED`. Fail closed. - The keyring is `apps/shelldrive-setup/setup_keyring.inc`. The repository holds only a revoked placeholder, so the stub - refuses every payload until the release pipeline generates the file from `installer/keys/keyset.txt`. + refuses every payload until the release build generates the file with `sd-release keyring` from + `installer/keys/keyset.txt` (cmake/SdKeyring.cmake puts the generated directory first on the include path; + `tests/release/keyring_flow.cmake`, ctest `release.keyring`, covers the generator). - `sd_setup_core` now links `sd_payload_sig` (sd_crypto only). `shelldrive-setup-stub.exe` still passes `importcheck.stub.knowndlls` and `isacheck` on `x86-ia32`. @@ -465,7 +468,9 @@ Commands: `hash`, `sums`, `table` (one flavour per setup EXE), `check-table` (ta (unsigned body: `file` lines hashed from a directory; the TM-R36 limits are enforced: stable at most 30 days, beta at most 14 days from `released` to `expires`), `sign` (body + sig line, or the sig line alone with `--sig-only`; a manifest is parsed with `sd_upd_manifest_parse` before it is written), `verify` (checks the sig and the key ID; a manifest is -parsed in full), `pubkey`, `session` (`SESSION.txt` for the offline sessions). No network code; no key in the tree. +parsed in full), `pubkey`, `keyring` (04 §9.3: verifies keyset.txt against installer/keys/roots.txt and writes +`setup_keyring.inc` and `update_trust.inc`, deterministically), `session` (`SESSION.txt` for the offline sessions). No +network code; no key in the tree. `sd-updsign` (earlier, from the update work) overlaps `sign` and `verify`; merge or retire one of them. Test: `tests/release/release_flow.cmake` (ctest `release.flow`, label `release`). It generates two TEST seeds in the script, @@ -482,7 +487,11 @@ manifest with 15 days, and a file name with a separator are all refused. Passing ### Open items after this change -1. `installer/keys/` (root public keys, `keyset.txt`) and the generated `setup_keyring.inc`. Until then no payload verifies. +1. `installer/keys/` (roots.txt with the two root public keys, keyset.txt) is still missing; the key ceremony writes it. + The build-time generator exists: `sd-release keyring` verifies the keyset against the roots and generates + `setup_keyring.inc` and `update_trust.inc` into the build tree (cmake/SdKeyring.cmake wires it into the stub and + shelldrive-host; test `release.keyring`). Until the files exist the placeholders are compiled in and no payload + verifies. 2. `cmake/SdPayload.cmake` and the `package` job: pass `--channel` and `--sig` to `sd-payload`, with the signature made by session 1 of 2 (`sd-release sign --kind payload --sig-only`). 3. No test of the positive path through the stub's resources (`sd_setup_payload_sig_check` with a real `PAYLOAD_SIG`). @@ -782,7 +791,7 @@ another milestone. Evidence paths are relative to the repository root. | Dokany chaining (C31, D32) | **Partial** | `setup_dokany.c`: decision, PE import reader, command lines and run gate, 5 tests. Nothing calls `sd_setup_dokany_run`; no `DOKANY_` payload member, no `installer/dokany/dokany.lock`, no post-install `--selftest` probe. C31 fixes the form: official per-arch MSIs | | `webdav-tune` and the other installer hooks | **Partial → webdav-tune closed here** | `signal-shutdown`, `user-cleanup` and `programdata-secure` implemented; `modern-menu` is a deliberate no-op until the sparse package exists (D27); `webdav-tune` was a silent no-op and is implemented in this change (below) | | Upgrade with running processes (C3, C4) | **Partial** | C3 step 1 done and tested: the agent creates the manual-reset `Global\…\agent.shutdown..` event with the `user, SY, BA` DACL, waits on it (`agent_shutdown_event_stops_agent`), setup signals it and waits 15 s (`setup_host.c`, `setup_install.c`), locked files are renamed aside. Missing: the Restart Manager step 2 and C4's versioned `shellext\\` directory (the fixed path plus rename-aside is used). Lab pending | -| Signed manifest | **Done; blocked on the release roots** | `src/sd_update` (parse, chain, rollback, freshness, tests). `k_trust` in `update_ipc.c` is empty, so every verification fails closed until `installer/keys` exists | +| Signed manifest | **Done; blocked on the release roots** | `src/sd_update` (parse, chain, rollback, freshness, tests). The trust (`update_trust.inc`, generated by `sd-release keyring`, test `release.keyring`) is empty until `installer/keys` exists, so every verification fails closed | | Static HTTP mirror (D30) | **Partial; blocked on the domain** | `manifest_alt` fallback after a network error and the redirect allow list exist; the default URL is the GitHub release URL only. The mirror URL is an operational choice (D30) | | Updater UI and rollback | **Done** | `apps/shelldrive-ui/ui_update.c` (Settings group, consent dialog, tray balloon) over `update.check` / `update.apply`; `update_engine.c` apply with `.rollback`, `--selftest` and byte-for-byte restore, covered by the update tests | | ADMX/ADML for every 03 §3.5 policy | **Done** | 21 policies in `res/policy/policies.txt`; `tools/sd-admxgen` emits the ADMX, six ADML files, `sd_policy_ids.h`/`sd_policy_table.h`; `policy.*`, `unit.admxgen`, `unit.policy`, `l10n.policy_catalogs_complete` | @@ -791,7 +800,7 @@ another milestone. Evidence paths are relative to the repository root. | Service installed and enabled by default per machine (D6) | **Blocked (M5)** | `SD_MSI_WITH_SERVICE=0` until the service binary exists; the `ServiceInstall`/`ServiceControl` authoring is behind the define | | `ENABLELINKEDCONNECTIONS` (D24) | **Done** | MSI property default 0; `AppSearch` ownership rules so an admin's pre-existing value is neither written nor removed; `msi..tables` | | `DEFAULTPROFILES` (C9) | **Done (authored); install lab pending** | `MoveFile` custom table and `C_DefaultProfiles` in `packaging/msi/ShellDrive.wxs` | -| Signed payload tables (TM-R38) | **Partial; blocked on the keys** | Verifier, domain separation, resource check and 14 tests (`tests/setup/test_payload_sig.c`). `setup_keyring.inc` holds a revoked placeholder; `installer/keys`, the generated keyring and the package step's `--sig` input are release work | +| Signed payload tables (TM-R38) | **Partial; blocked on the keys** | Verifier, domain separation, resource check and 14 tests (`tests/setup/test_payload_sig.c`). The keyring generator is done (`sd-release keyring`, cmake/SdKeyring.cmake, test `release.keyring`); `setup_keyring.inc` still holds a revoked placeholder until `installer/keys` exists, and the package step's `--sig` input is release work | | Release pipeline and two offline signing sessions (D42) | **Partial** | `.github/workflows/release.yml` skeleton stops at `package`/`finalize`; `sd-release session/sign/verify/gates` exist with tests; `resign` and `installer/` (keys, `release-gates.txt`, `dokany.lock`) are missing; the workflow lint reports the missing `CODEOWNERS` | | Security-test support TST-29/31/32 | **Partial** | `tools/check-workflows.ps1` (TST-32) runs in `ci.yml` and `nightly.yml`; `tools/sd-msilang --dump-sequences` (TST-29) does not exist; the TST-27/31 lab checks were not run | diff --git a/tests/release/CMakeLists.txt b/tests/release/CMakeLists.txt index 803c4da..ec4847e 100644 --- a/tests/release/CMakeLists.txt +++ b/tests/release/CMakeLists.txt @@ -12,3 +12,12 @@ add_test(NAME release.gates "-DWORK=${CMAKE_BINARY_DIR}/tests/release/gates" -P "${CMAKE_CURRENT_SOURCE_DIR}/gates_flow.cmake") set_tests_properties(release.gates PROPERTIES LABELS release TIMEOUT 300) + +# Compiled-in keyring (04 §9.3): keyring generates the stub keyring and the host trust from TEST keys, refused +# keysets and malformed roots.txt included. +add_test(NAME release.keyring + COMMAND "${CMAKE_COMMAND}" "-DSD_RELEASE=$" + "-DSD_UPDSIGN=$" + "-DWORK=${CMAKE_BINARY_DIR}/tests/release/keyring" + -P "${CMAKE_CURRENT_SOURCE_DIR}/keyring_flow.cmake") +set_tests_properties(release.keyring PROPERTIES LABELS release TIMEOUT 300) diff --git a/tests/release/keyring_flow.cmake b/tests/release/keyring_flow.cmake new file mode 100644 index 0000000..089ee68 --- /dev/null +++ b/tests/release/keyring_flow.cmake @@ -0,0 +1,210 @@ +# Compiled-in keyring (docs/design/04 §9.3): `sd-release keyring` end to end with TEST keys. Run by ctest as +# cmake -DSD_RELEASE= -DSD_UPDSIGN= -DWORK= -P keyring_flow.cmake +# +# Every key here is a TEST key generated by sd-updsign keygen in the scratch dir; nothing touches installer/keys/ +# and nothing uses or stores a release key. The keyset body goes through write_lf.ps1 because file(WRITE) is text +# mode on Windows (CRLF) and the keyset verifier takes the exact bytes and refuses CR. Exit codes checked: 0 ok, +# 2 malformed roots.txt or keyset, 3 a keyset that does not verify. Any failed step stops with FATAL_ERROR. + +if(NOT SD_RELEASE OR NOT SD_UPDSIGN OR NOT WORK) + message(FATAL_ERROR "SD_RELEASE, SD_UPDSIGN and WORK are required") +endif() + +file(REMOVE_RECURSE "${WORK}") +file(MAKE_DIRECTORY "${WORK}") + +# rel( ) — as in release_flow.cmake +function(rel expect out_var) + execute_process(COMMAND "${SD_RELEASE}" ${ARGN} + WORKING_DIRECTORY "${WORK}" + RESULT_VARIABLE rc + OUTPUT_VARIABLE out + ERROR_VARIABLE err) + if(NOT rc EQUAL expect) + message(FATAL_ERROR "sd-release ${ARGN}: exit ${rc}, expected ${expect}\n${out}${err}") + endif() + set(${out_var} "${out}" PARENT_SCOPE) +endfunction() + +# upd( ) +function(upd expect out_var) + execute_process(COMMAND "${SD_UPDSIGN}" ${ARGN} + WORKING_DIRECTORY "${WORK}" + RESULT_VARIABLE rc + OUTPUT_VARIABLE out + ERROR_VARIABLE err) + if(NOT rc EQUAL expect) + message(FATAL_ERROR "sd-updsign ${ARGN}: exit ${rc}, expected ${expect}\n${out}${err}") + endif() + set(${out_var} "${out}" PARENT_SCOPE) +endfunction() + +function(expect_contains text needle) + string(FIND "${text}" "${needle}" pos) + if(pos EQUAL -1) + message(FATAL_ERROR "expected '${needle}' in:\n${text}") + endif() +endfunction() + +# The public key (hex) and key ID of a seed file, from the `sd-release pubkey` output line. +function(pub_of seed_file out_pub out_kid) + rel(0 out pubkey --seed "${seed_file}") + string(REGEX REPLACE "^pub ([0-9a-f]+) kid ([0-9a-f]+).*" "\\1" pub "${out}") + string(REGEX REPLACE "^pub ([0-9a-f]+) kid ([0-9a-f]+).*" "\\2" kid "${out}") + string(LENGTH "${pub}" pn) + string(LENGTH "${kid}" kn) + if(NOT pn EQUAL 64 OR NOT kn EQUAL 16) + message(FATAL_ERROR "pubkey output: ${out}") + endif() + set(${out_pub} "${pub}" PARENT_SCOPE) + set(${out_kid} "${kid}" PARENT_SCOPE) +endfunction() + +# The public key (base64) and key ID of a seed file, from the `sd-updsign keygen` output (and the keygen .pub file). +function(key_of seed_file out_b64 out_kid) + upd(0 out keygen --seed "${seed_file}" --pub "${seed_file}.pub") + string(REPLACE "\n" ";" lines "${out}") + list(GET lines 0 l0) + list(GET lines 1 l1) + string(REPLACE "pubkey " "" b64 "${l0}") + string(REPLACE "key-id " "" kid "${l1}") + string(LENGTH "${b64}" bn) + string(LENGTH "${kid}" kn) + if(NOT bn EQUAL 44 OR NOT kn EQUAL 16) + message(FATAL_ERROR "keygen output: ${out}") + endif() + set(${out_b64} "${b64}" PARENT_SCOPE) + set(${out_kid} "${kid}" PARENT_SCOPE) +endfunction() + +# hex_bytes(<64 hex digits> ): the bytes as "0x.., 0x.., ..." as they appear in the generated include. +function(hex_bytes hex out_var) + string(REGEX REPLACE "(..)" "0x\\1, " t "${hex}") + string(REGEX REPLACE ", $" "" t "${t}") + set(${out_var} "${t}" PARENT_SCOPE) +endfunction() + +# write_lf( ): OUT is IN with CRLF pairs read as LF (see write_lf.ps1). +function(write_lf src dst) + execute_process(COMMAND powershell -NoProfile -ExecutionPolicy Bypass + -File "${CMAKE_CURRENT_LIST_DIR}/write_lf.ps1" -In "${src}" -Out "${dst}" + RESULT_VARIABLE rc) + if(NOT rc EQUAL 0) + message(FATAL_ERROR "write_lf.ps1 ${src}: exit ${rc}") + endif() +endfunction() + +# ---- TEST keys: two roots and one beta release key ---- +key_of("${WORK}/root-a.seed" B64_A KID_A) +key_of("${WORK}/root-b.seed" B64_B KID_B) +key_of("${WORK}/beta.seed" B64_BETA KID_BETA) +pub_of("${WORK}/beta.seed" PUB_BETA KID_BETA_FROM_PUB) +if(NOT KID_BETA STREQUAL KID_BETA_FROM_PUB) + message(FATAL_ERROR "the tools disagree on the beta key ID: ${KID_BETA} vs ${KID_BETA_FROM_PUB}") +endif() + +# roots.txt: comments and blank lines, then exactly two root lines (CRLF pairs are accepted here, unlike the keyset). +file(WRITE "${WORK}/roots.txt" +"# test roots for the keyring test +root ${KID_A} ${B64_A} + +root ${KID_B} ${B64_B} +") + +# ---- a signed keyset with one beta release key ---- +set(BODY "shelldrive-keyset 1 +product com.librestatic.shelldrive +version 1 +issued 2026-10-31T00:00:00Z +key ${KID_BETA} beta ${B64_BETA} 2026-01-01 2027-01-01 +") +file(WRITE "${WORK}/body-crlf.txt" "${BODY}") +write_lf("${WORK}/body-crlf.txt" "${WORK}/keyset-body.txt") +upd(0 updsign_out sign-keyset --in keyset-body.txt --root root-a.seed --out keyset.txt) + +# ---- success: the include files hold the roots, the key bytes and the inclusive dates ---- +rel(0 out keyring --roots roots.txt --keyset keyset.txt --out-setup setup_keyring.inc --out-trust update_trust.inc) +expect_contains("${out}" "keyring roots 2 keys 1") +file(READ "${WORK}/setup_keyring.inc" setup_inc) +file(READ "${WORK}/update_trust.inc" trust_inc) +# 2026-01-01 00:00:00Z = 1767225600, 2027-01-01 23:59:59Z = 1798847999 (sd_upd_date_parse, end of day inclusive). +hex_bytes("${PUB_BETA}" pub_beta_bytes) +expect_contains("${setup_inc}" "{ { ${pub_beta_bytes} }, \"beta\", 1767225600, 1798847999, 0 },") +expect_contains("${trust_inc}" "static const sd_upd_root k_roots[] = {") +expect_contains("${trust_inc}" "static const uint8_t k_keyset[] = {") +expect_contains("${trust_inc}" "static const sd_upd_trust k_trust = { k_roots, (uint32_t)(sizeof(k_roots) / sizeof(k_roots[0])), k_keyset,") +hex_bytes("${KID_A}" kid_a_bytes) +hex_bytes("${KID_B}" kid_b_bytes) +expect_contains("${trust_inc}" "${kid_a_bytes}") +expect_contains("${trust_inc}" "${kid_b_bytes}") +# the exact bytes of keyset.txt: the inc embeds them from "shelldrive-keyset 1" on (first bytes of the ASCII magic). +expect_contains("${trust_inc}" "0x73, 0x68, 0x65, 0x6c, 0x6c, 0x64, 0x72, 0x69, 0x76, 0x65, 0x2d, 0x6b, 0x65, 0x79, 0x73, 0x65") + +# ---- deterministic output (04 §12.2) ---- +rel(0 out keyring --roots roots.txt --keyset keyset.txt --out-setup setup2.inc --out-trust trust2.inc) +file(READ "${WORK}/setup2.inc" setup_inc2) +file(READ "${WORK}/trust2.inc" trust_inc2) +if(NOT setup_inc STREQUAL setup_inc2 OR NOT trust_inc STREQUAL trust_inc2) + message(FATAL_ERROR "sd-release keyring output is not deterministic") +endif() + +# ---- a keyset signed by a root outside roots.txt: refused (3), nothing written ---- +upd(0 stranger_out keygen --seed "${WORK}/stranger.seed" --pub "${WORK}/stranger.pub") +upd(0 updsign_out sign-keyset --in keyset-body.txt --root stranger.seed --out keyset-stranger.txt) +rel(3 out keyring --roots roots.txt --keyset keyset-stranger.txt --out-setup refused.inc --out-trust refused2.inc) +if(EXISTS "${WORK}/refused.inc" OR EXISTS "${WORK}/refused2.inc") + message(FATAL_ERROR "keyring wrote output for a keyset that does not verify") +endif() + +# ---- a tampered keyset (bytes changed behind the signature): refused (3) ---- +file(READ "${WORK}/keyset.txt" keyset_text) +string(REPLACE "version 1" "version 2" tampered "${keyset_text}") +file(WRITE "${WORK}/tampered-crlf.txt" "${tampered}") +write_lf("${WORK}/tampered-crlf.txt" "${WORK}/keyset-tampered.txt") +rel(3 out keyring --roots roots.txt --keyset keyset-tampered.txt --out-setup refused.inc --out-trust refused2.inc) + +# ---- malformed roots.txt: refused (2) ---- +file(WRITE "${WORK}/roots-one.txt" "root ${KID_A} ${B64_A}\n") +rel(2 out keyring --roots roots-one.txt --keyset keyset.txt --out-setup refused.inc --out-trust refused2.inc) +file(WRITE "${WORK}/roots-three.txt" +"root ${KID_A} ${B64_A} +root ${KID_B} ${B64_B} +root ${KID_BETA} ${B64_BETA} +") +rel(2 out keyring --roots roots-three.txt --keyset keyset.txt --out-setup refused.inc --out-trust refused2.inc) +file(WRITE "${WORK}/roots-junk.txt" +"root ${KID_A} ${B64_A} +root ${KID_B} ${B64_B} +not a root line +") +rel(2 out keyring --roots roots-junk.txt --keyset keyset.txt --out-setup refused.inc --out-trust refused2.inc) +string(TOUPPER "${KID_A}" KID_A_UPPER) +file(WRITE "${WORK}/roots-upper.txt" +"root ${KID_A_UPPER} ${B64_A} +root ${KID_B} ${B64_B} +") +rel(2 out keyring --roots roots-upper.txt --keyset keyset.txt --out-setup refused.inc --out-trust refused2.inc) +file(WRITE "${WORK}/roots-wrongid.txt" +"root 1234567890abcdef ${B64_A} +root ${KID_B} ${B64_B} +") +rel(2 out keyring --roots roots-wrongid.txt --keyset keyset.txt --out-setup refused.inc --out-trust refused2.inc) +rel(2 out keyring --roots roots-missing.txt --keyset keyset.txt --out-setup refused.inc --out-trust refused2.inc) +rel(1 out keyring --roots roots.txt --keyset keyset.txt --out-setup refused.inc) + +# ---- a revoked key gets revoked = 1 ---- +set(BODY_R "shelldrive-keyset 1 +product com.librestatic.shelldrive +version 2 +issued 2026-10-31T00:00:00Z +key ${KID_BETA} beta ${B64_BETA} 2026-01-01 2027-01-01 +revoke ${KID_BETA} tm-r37.test +") +file(WRITE "${WORK}/body-revoked-crlf.txt" "${BODY_R}") +write_lf("${WORK}/body-revoked-crlf.txt" "${WORK}/keyset-body-revoked.txt") +upd(0 updsign_out sign-keyset --in keyset-body-revoked.txt --root root-a.seed --out keyset-revoked.txt) +rel(0 out keyring --roots roots.txt --keyset keyset-revoked.txt --out-setup setup-revoked.inc --out-trust trust-revoked.inc) +file(READ "${WORK}/setup-revoked.inc" setup_revoked) +expect_contains("${setup_revoked}" "{ { ${pub_beta_bytes} }, \"beta\", 1767225600, 1798847999, 1 },") + +message(STATUS "keyring flow: all checks passed") diff --git a/tests/release/write_lf.ps1 b/tests/release/write_lf.ps1 new file mode 100644 index 0000000..6d4155e --- /dev/null +++ b/tests/release/write_lf.ps1 @@ -0,0 +1,11 @@ +# Writes OUT as a copy of IN with every CR LF pair replaced by LF (byte exact otherwise). The ctest script helpers +# (keyring_flow.cmake) need it because file(WRITE) is text mode on Windows and turns "\n" into CRLF, while the +# keyset verifier takes the exact file bytes and refuses CR (docs/design/04 §9.3). +param([string]$In, [string]$Out) +$bytes = [IO.File]::ReadAllBytes($In) +$lf = New-Object System.Collections.Generic.List[byte] +for ($i = 0; $i -lt $bytes.Length; $i++) { + if ($bytes[$i] -eq 13 -and $i + 1 -lt $bytes.Length -and $bytes[$i + 1] -eq 10) { continue } + $lf.Add($bytes[$i]) +} +[IO.File]::WriteAllBytes($Out, $lf.ToArray()) diff --git a/tools/sd-release/CMakeLists.txt b/tools/sd-release/CMakeLists.txt index 2eed072..e02804f 100644 --- a/tools/sd-release/CMakeLists.txt +++ b/tools/sd-release/CMakeLists.txt @@ -1,6 +1,7 @@ # sd-release: release signing tool (docs/design/04 §9.2, §9.3, §12.3). Build-time tool, never shipped. It links the -# verifier and signer of sd_crypto, the manifest parser of sd_update and the payload table signature of the setup -# engine (sd_payload_sig). No network code. +# verifier and signer of sd_crypto, the manifest parser of sd_update and the pure payload table signature verifier +# (sd_payload_sig_text; the keyring library of the stub depends on this very tool, so it must not link it back). +# No network code. add_executable(sd-release sd-release.c) -target_link_libraries(sd-release PRIVATE sd_update sd_payload_sig sd_crypto sd_platform sd_base) +target_link_libraries(sd-release PRIVATE sd_update sd_payload_sig_text sd_crypto sd_platform sd_base) set_target_properties(sd-release PROPERTIES FOLDER "tools") diff --git a/tools/sd-release/sd-release.c b/tools/sd-release/sd-release.c index e1a1ccb..652bb29 100644 --- a/tools/sd-release/sd-release.c +++ b/tools/sd-release/sd-release.c @@ -16,6 +16,11 @@ * checks the signature: the last line of FILE, or SIGFILE; * a manifest must be whole (with its sig line) and parses * sd-release pubkey --seed SEED public key (hex) and key ID of a seed file + * sd-release keyring --roots ROOTS --keyset KEYSET --out-setup INC --out-trust INC + * verifies the signed keyset against the two root keys of + * ROOTS and writes the include files compiled into the + * stub (payload keyring) and the host (update trust), + * deterministically (04 §9.3, §12.2) * sd-release session --kind K --out DIR FILE... writes DIR\SESSION.txt: the hashes an offline signing * session compares before it signs (D42) * sd-release gates check FILE --version V --commit C validates the release gates record (04 §12.3): exit 0, @@ -1147,6 +1152,219 @@ static int cmd_pubkey(int argc, char **argv) return RC_OK; } +/* ---- compiled-in keyring (04 §9.3) ---- + * roots.txt: ASCII, '#' comments and blank lines, then exactly two "root " lines, with the + * encodings of the keyset root lines (16 lowercase hex, 44 canonical base64 characters). The keyset is verified + * against those roots (sd_upd_keyset_verify) and the two include files compiled into the shipping binaries are + * written: the stub's setup_keyring.inc and the host's update_trust.inc. The output is byte-for-byte deterministic + * (04 §12.2). Exit codes: 2 for a malformed roots.txt or keyset, 3 for a keyset that does not verify. */ + +#define RK_MAX_BYTES (16u * 1024u) + +static int rk_bad(unsigned line, const char *msg) +{ + if (line == 0) + fprintf(stderr, "sd-release: roots.txt: %s\n", msg); + else + fprintf(stderr, "sd-release: roots.txt line %u: %s\n", line, msg); + return -1; +} + +static int rk_is_lower_hex(const char *s, size_t n) +{ + size_t i; + for (i = 0; i < n; i++) { + if (!((s[i] >= '0' && s[i] <= '9') || (s[i] >= 'a' && s[i] <= 'f'))) + return 0; + } + return 1; +} + +/* One "root " line. 0 on success. */ +static int rk_root_line(const char *line, unsigned lineno, sd_upd_root *r) +{ + const char *sp = strchr(line, ' '), *b64 = sp != NULL ? strchr(sp + 1, ' ') : NULL; + uint8_t want[8]; + if (sp == NULL || b64 == NULL || strchr(b64 + 1, ' ') != NULL || + (size_t)(sp - line) != 4 || memcmp(line, "root", 4) != 0) + return rk_bad(lineno, "a root line is: root "); + if ((size_t)(b64 - sp - 1) != 16 || !rk_is_lower_hex(sp + 1, 16) || from_hex(sp + 1, 8, r->id) != 0) + return rk_bad(lineno, "the key id is 16 lowercase hex digits"); + if (strlen(b64 + 1) != 44 || sd_upd_base64_decode(b64 + 1, 44, r->pub, 32) != 32) + return rk_bad(lineno, "the public key is 44 canonical base64 characters (32 bytes)"); + sd_upd_key_id(r->pub, want); + if (memcmp(want, r->id, 8) != 0) + return rk_bad(lineno, "the key id is not the id of that public key (first 8 bytes of SHA-256)"); + return 0; +} + +/* Parses roots.txt into roots (room for SD_UPD_MAX_ROOTS). 0 on success; -1 with a message. */ +static int rk_parse_roots(const char *path, sd_upd_root roots[SD_UPD_MAX_ROOTS], uint32_t *nroots) +{ + size_t n = 0, i = 0, j; + unsigned lineno = 0, found = 0; + unsigned char *buf = read_file(path, RK_MAX_BYTES, &n); + if (buf == NULL) { + fprintf(stderr, "sd-release: cannot read roots.txt (16 KB max)\n"); + return -1; + } + /* A CRLF pair (a Windows editor) is read as LF; any other CR, a tab or a byte outside printable ASCII is refused. */ + for (i = 0, j = 0; i < n; i++) { + unsigned char b = buf[i]; + if (b == '\r' && i + 1 < n && buf[i + 1] == '\n') + continue; + if (b != '\n' && (b < 0x20u || b > 0x7eu)) { + free(buf); + fprintf(stderr, "sd-release: roots.txt takes printable ASCII, LF and CRLF only\n"); + return -1; + } + buf[j++] = b; + } + n = j; + if (n == 0 || buf[n - 1] != '\n') { + free(buf); + return rk_bad(0, "the file must end with LF"); + } + buf[n] = '\0'; + i = 0; + while (i < n) { + size_t start = i, len; + lineno++; + while (i < n && buf[i] != '\n') + i++; + len = i - start; + i++; + if (len == 0 || buf[start] == '#') + continue; + if (found == 2) { + free(buf); + return rk_bad(lineno, "roots.txt holds exactly two root lines"); + } + buf[start + len] = '\0'; /* the LF of the line, always inside the buffer */ + if (rk_root_line((const char *)buf + start, lineno, &roots[found]) != 0) { + free(buf); + return -1; + } + found++; + } + free(buf); + if (found != 2) + return rk_bad(0, "roots.txt holds exactly two root lines"); + *nroots = found; + return 0; +} + +static void rk_put_bytes(FILE *f, const uint8_t *p, size_t n) +{ + size_t i; + for (i = 0; i < n; i++) + fprintf(f, "%s0x%02x", i != 0 ? ", " : "", p[i]); +} + +static int rk_close(FILE *f) +{ + int bad = ferror(f) != 0; + if (fclose(f) != 0) + bad = 1; + return bad ? -1 : 0; +} + +/* setup_keyring.inc: one sd_setup_psig_key entry per key line of the keyset (see setup_payload_sig.h). */ +static int rk_write_setup_inc(const char *path, const sd_upd_keyset *ks) +{ + FILE *f = fopen(path, "wb"); + uint32_t i; + if (f == NULL) + return -1; + fprintf(f, + "/* setup_keyring.inc: keyring compiled into the stub (see setup_payload_sig.c). Generated by `sd-release keyring` from\n" + " * installer/keys/roots.txt and installer/keys/keyset.txt (04 §9.3): one entry per key line of the keyset, the dates\n" + " * as unix seconds (from 00:00:00Z, to 23:59:59Z, inclusive), keys named by a revoke line flagged. Do not edit.\n" + " * Entry: { { 32 public key bytes }, \"stable\"|\"beta\", valid_from, valid_to, revoked }. */\n"); + for (i = 0; i < ks->nkeys; i++) { + fprintf(f, "{ { "); + rk_put_bytes(f, ks->keys[i].pub, 32); + fprintf(f, " }, \"%s\", %lld, %lld, %d },\n", ks->keys[i].channel, + (long long)ks->keys[i].valid_from, (long long)ks->keys[i].valid_to, ks->keys[i].revoked); + } + return rk_close(f); +} + +/* update_trust.inc: the roots array and the exact keyset bytes (see update_ipc.c and sd_update.h). */ +static int rk_write_trust_inc(const char *path, const sd_upd_root *roots, uint32_t nroots, + const unsigned char *keyset, size_t kn) +{ + FILE *f; + uint32_t i; + size_t k; + if (kn == 0) + return -1; + f = fopen(path, "wb"); + if (f == NULL) + return -1; + fprintf(f, + "/* update_trust.inc: the update trust compiled into shelldrive-host (see update_ipc.c). Generated by `sd-release\n" + " * keyring` from installer/keys/roots.txt and installer/keys/keyset.txt (04 §9.3). Do not edit. k_keyset holds the\n" + " * exact bytes of keyset.txt, its sig lines included (sd_upd_trust in sd_update.h). */\n"); + fprintf(f, "static const sd_upd_root k_roots[] = {\n"); + for (i = 0; i < nroots; i++) { + fprintf(f, " { { "); + rk_put_bytes(f, roots[i].id, 8); + fprintf(f, " }, { "); + rk_put_bytes(f, roots[i].pub, 32); + fprintf(f, " } },\n"); + } + fprintf(f, "};\n"); + fprintf(f, "static const uint8_t k_keyset[] = {\n"); + for (k = 0; k < kn; k += 16) { + size_t b, e = k + 16 < kn ? k + 16 : kn; + fprintf(f, " "); + for (b = k; b < e; b++) + fprintf(f, "%s0x%02x", b != k ? ", " : "", keyset[b]); + fprintf(f, ",\n"); + } + fprintf(f, "};\n"); + fprintf(f, + "static const sd_upd_trust k_trust = { k_roots, (uint32_t)(sizeof(k_roots) / sizeof(k_roots[0])), k_keyset,\n" + " sizeof(k_keyset) };\n"); + return rk_close(f); +} + +/* sd-release keyring --roots ROOTS --keyset KEYSET --out-setup INC --out-trust INC */ +static int cmd_keyring(int argc, char **argv) +{ + const char *roots_p = opt(argc, argv, "--roots"), *keyset_p = opt(argc, argv, "--keyset"); + const char *out_setup = opt(argc, argv, "--out-setup"), *out_trust = opt(argc, argv, "--out-trust"); + sd_upd_root roots[SD_UPD_MAX_ROOTS]; + uint32_t nroots = 0; + unsigned char *keyset; + size_t kn = 0; + sd_upd_keyset ks; + sd_upd_res r; + if (roots_p == NULL || keyset_p == NULL || out_setup == NULL || out_trust == NULL) + return fail(RC_USAGE, "usage: sd-release keyring --roots ROOTS --keyset KEYSET --out-setup INC --out-trust INC"); + if (rk_parse_roots(roots_p, roots, &nroots) != 0) + return RC_IO; + keyset = read_file(keyset_p, SD_UPD_KEYSET_MAX, &kn); + if (keyset == NULL) + return fail(RC_IO, "cannot read the keyset (64 KB max)"); + r = sd_upd_keyset_verify(keyset, kn, roots, nroots, 0, &ks); + if (r != SD_UPD_OK) { + free(keyset); + if (r == SD_UPD_E_KEYSET_SIGNATURE || r == SD_UPD_E_SIGNATURE) + return fail(RC_VERIFY, "the keyset does not verify against the roots of roots.txt"); + fprintf(stderr, "sd-release: the keyset does not parse (%s)\n", sd_upd_res_name(r)); + return RC_IO; + } + if (rk_write_setup_inc(out_setup, &ks) != 0 || rk_write_trust_inc(out_trust, roots, nroots, keyset, kn) != 0) { + free(keyset); + return fail(RC_IO, "cannot write the generated include files"); + } + free(keyset); + printf("keyring roots %u keys %u\n", nroots, ks.nkeys); + return RC_OK; +} + /* SESSION.txt: what an offline signing session compares before it signs (04 §12.3 steps 5 and 7, D42). It lists the SHA-256 of every file; with exactly one body (not sums), it also gives the digest of the signed message, domain || body, which the signing machine prints again after signing. */ @@ -1218,7 +1436,7 @@ static int cmd_session(int argc, char **argv) static void usage(void) { - fprintf(stderr, "usage: sd-release hash|sums|table|check-table|manifest|sign|verify|pubkey|session|gates ... " + fprintf(stderr, "usage: sd-release hash|sums|table|check-table|manifest|sign|verify|pubkey|keyring|session|gates ... " "(see the header of tools/sd-release/sd-release.c)\n"); } @@ -1244,6 +1462,8 @@ int main(int argc, char **argv) return cmd_verify(argc, argv); if (strcmp(argv[1], "pubkey") == 0) return cmd_pubkey(argc, argv); + if (strcmp(argv[1], "keyring") == 0) + return cmd_keyring(argc, argv); if (strcmp(argv[1], "session") == 0) return cmd_session(argc, argv); if (strcmp(argv[1], "gates") == 0) From cfc053755b1199b2fe89bb971f5268075832dae1 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 00:55:28 -0300 Subject: [PATCH 08/15] release: add sd-release dokany verify against dokany.lock (04 7.3, TM-R23) The package job checks size and SHA-256 of every pinned Dokany file at embed time. The strict parser takes printable ASCII with LF or CRLF, the dokany-lock 1 header, known key lines and file lines only, and refuses unknown keys, duplicates and malformed file lines (exit 2); a file that is missing or differs exits 3. --arch checks one arch plus the bundle. Test release.dokany covers the cases with fake files. --- tests/release/CMakeLists.txt | 7 + tests/release/dokany_flow.cmake | 185 +++++++++++++++ tools/sd-release/sd-release.c | 399 +++++++++++++++++++++++++++++++- 3 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 tests/release/dokany_flow.cmake diff --git a/tests/release/CMakeLists.txt b/tests/release/CMakeLists.txt index ec4847e..1f3b4a4 100644 --- a/tests/release/CMakeLists.txt +++ b/tests/release/CMakeLists.txt @@ -21,3 +21,10 @@ add_test(NAME release.keyring "-DWORK=${CMAKE_BINARY_DIR}/tests/release/keyring" -P "${CMAKE_CURRENT_SOURCE_DIR}/keyring_flow.cmake") set_tests_properties(release.keyring PROPERTIES LABELS release TIMEOUT 300) + +# dokany.lock verification (04 §7.3, TM-R23): the strict lock parser and the file checks, with fake upstream files. +add_test(NAME release.dokany + COMMAND "${CMAKE_COMMAND}" "-DSD_RELEASE=$" + "-DWORK=${CMAKE_BINARY_DIR}/tests/release/dokany" + -P "${CMAKE_CURRENT_SOURCE_DIR}/dokany_flow.cmake") +set_tests_properties(release.dokany PROPERTIES LABELS release TIMEOUT 300) diff --git a/tests/release/dokany_flow.cmake b/tests/release/dokany_flow.cmake new file mode 100644 index 0000000..a4f8fbe --- /dev/null +++ b/tests/release/dokany_flow.cmake @@ -0,0 +1,185 @@ +# dokany.lock verification (docs/design/04 §7.3; TM-R23, D32): `sd-release dokany verify` end to end with fake +# upstream files. Run by ctest as +# cmake -DSD_RELEASE= -DWORK= -P dokany_flow.cmake +# +# The fake files are small text files written here; the lock is generated from their real sizes and SHA-256 +# (`sd-release hash`), the way installer/dokany/dokany.lock was recorded. Nothing is downloaded. Exit codes +# checked: 0 ok, 1 usage, 2 malformed lock, 3 a file is missing or its size or SHA-256 differs. Any failed step +# stops the script with FATAL_ERROR. + +if(NOT SD_RELEASE OR NOT WORK) + message(FATAL_ERROR "SD_RELEASE and WORK are required") +endif() + +file(REMOVE_RECURSE "${WORK}") +file(MAKE_DIRECTORY "${WORK}/files") + +# rel( ): as in release_flow.cmake, but the +# variable receives stdout AND stderr — the refusals of `dokany verify` are printed on stderr. +function(rel expect out_var) + execute_process(COMMAND "${SD_RELEASE}" ${ARGN} + WORKING_DIRECTORY "${WORK}" + RESULT_VARIABLE rc + OUTPUT_VARIABLE out + ERROR_VARIABLE err) + if(NOT rc EQUAL expect) + message(FATAL_ERROR "sd-release ${ARGN}: exit ${rc}, expected ${expect}\n${out}${err}") + endif() + set(${out_var} "${out}${err}" PARENT_SCOPE) +endfunction() + +function(expect_contains text needle) + string(FIND "${text}" "${needle}" pos) + if(pos EQUAL -1) + message(FATAL_ERROR "expected '${needle}' in:\n${text}") + endif() +endfunction() + +# hash_of( ) +function(hash_of f out_hash out_size) + rel(0 out hash "files/${f}") + if(NOT out MATCHES "^sha256:([0-9a-f]+) ([0-9]+) ") + message(FATAL_ERROR "hash output: ${out}") + endif() + set(${out_hash} "${CMAKE_MATCH_1}" PARENT_SCOPE) + set(${out_size} "${CMAKE_MATCH_2}" PARENT_SCOPE) +endfunction() + +# ---- the fake upstream files and the lock generated from them ---- +file(WRITE "${WORK}/files/Dokan_x64.msi" "fake x64 msi bytes for the dokany test\n") +file(WRITE "${WORK}/files/Dokan_x86.msi" "fake x86 msi bytes\n") +file(WRITE "${WORK}/files/DokanSetup.exe" "fake bundle bytes\n") +hash_of("Dokan_x64.msi" H64 S64) +hash_of("Dokan_x86.msi" H86 S86) +hash_of("DokanSetup.exe" HB SB) + +set(X64_LINE "file x64 msi Dokan_x64.msi ${S64} ${H64} {31A8F445-2AC6-494B-819C-4F4E1B2AECF1} none") +set(LOCK_TEXT +"# test lock for the dokany test +dokany-lock 1 +version 2.3.1.1000 +min-api 2.1.0 +signer-cn LEOSAC +signer-issuer-cn Certum Extended Validation Code Signing 2021 CA +url-upstream https://github.com/dokan-dev/dokany/releases/download/v2.3.1.1000/ +url-mirror - +checked 2026-11-01 +${X64_LINE} +file x86 msi Dokan_x86.msi ${S86} ${H86} {B08DE90A-F064-4ADC-99AC-2EC933417CC5} none +file all bundle DokanSetup.exe ${SB} ${HB} - - +") +file(WRITE "${WORK}/lock.txt" "${LOCK_TEXT}") + +# ---- ok: every file matches, whole lock or one arch plus the bundle ---- +rel(0 out dokany verify --lock lock.txt --dir files) +expect_contains("${out}" "OK x64 msi Dokan_x64.msi ${S64} sha256:${H64}") +expect_contains("${out}" "OK x86 msi Dokan_x86.msi ${S86} sha256:${H86}") +expect_contains("${out}" "OK all bundle DokanSetup.exe ${SB} sha256:${HB}") +expect_contains("${out}" "dokany ok files 3") +rel(0 out dokany verify --lock lock.txt --dir files --arch x64) +expect_contains("${out}" "dokany ok files 2") + +# ---- one file differs: exit 3, the others still reported ---- +file(APPEND "${WORK}/files/Dokan_x86.msi" "one byte too many") +rel(3 out dokany verify --lock lock.txt --dir files) +expect_contains("${out}" "OK x64 msi Dokan_x64.msi") +expect_contains("${out}" "Dokan_x86.msi") +# --arch x64 only checks the x64 file and the bundle: still ok with a broken x86 file. +rel(0 out dokany verify --lock lock.txt --dir files --arch x64) +expect_contains("${out}" "dokany ok files 2") + +# ---- --arch of an arch with only the bundle pinned: the bundle alone is checked, exit 0 ---- +rel(0 out dokany verify --lock lock.txt --dir files --arch arm64) +expect_contains("${out}" "OK all bundle DokanSetup.exe") +expect_contains("${out}" "dokany ok files 1") + +# ---- an arch the lock pins nowhere (no matching row and no bundle row): exit 3 ---- +file(WRITE "${WORK}/lock-nobundle.txt" +"# no bundle row +dokany-lock 1 +version 2.3.1.1000 +min-api 2.1.0 +checked 2026-11-01 +file x64 msi Dokan_x64.msi ${S64} ${H64} {31A8F445-2AC6-494B-819C-4F4E1B2AECF1} none +") +rel(3 out dokany verify --lock lock-nobundle.txt --dir files --arch arm64) +expect_contains("${out}" "pins no file for arch arm64") + +# ---- a missing file: exit 3 ---- +file(REMOVE "${WORK}/files/Dokan_x86.msi") +rel(3 out dokany verify --lock lock.txt --dir files --arch x86) +expect_contains("${out}" "missing Dokan_x86.msi") + +# ---- a lock whose size or hash differs from the files: exit 3 ---- +file(WRITE "${WORK}/files/Dokan_x86.msi" "fake x86 msi bytes\n") +string(REPLACE "file x64 msi Dokan_x64.msi ${S64}" "file x64 msi Dokan_x64.msi 12345" + lock_bad_size "${LOCK_TEXT}") +file(WRITE "${WORK}/lock-size.txt" "${lock_bad_size}") +rel(3 out dokany verify --lock lock-size.txt --dir files --arch x64) +expect_contains("${out}" "the lock pins 12345") +string(REPLACE "${H64}" "0000000000000000000000000000000000000000000000000000000000000000" + lock_bad_hash "${LOCK_TEXT}") +file(WRITE "${WORK}/lock-hash.txt" "${lock_bad_hash}") +rel(3 out dokany verify --lock lock-hash.txt --dir files --arch x64) +expect_contains("${out}" "sha256 ") + +# ---- malformed locks: exit 2 ---- +# a wrong format version, no value after the key, a key line in the place of the header +foreach(bad_header IN ITEMS "dokany-lock 2" "dokany-lock" "version 2.3.1.1000") + string(REPLACE "dokany-lock 1" "${bad_header}" lock_magic "${LOCK_TEXT}") + file(WRITE "${WORK}/lock-magic.txt" "${lock_magic}") + rel(2 out dokany verify --lock lock-magic.txt --dir files) +endforeach() +# no header line at all +file(WRITE "${WORK}/lock-nomagic.txt" +"# no header +version 2.3.1.1000 +checked 2026-11-01 +file all bundle DokanSetup.exe ${SB} ${HB} - - +") +rel(2 out dokany verify --lock lock-nomagic.txt --dir files) + +# unknown key, duplicate key, invalid version, invalid date +foreach(bad_kv IN ITEMS + "signer-street 39 rue Principale" + "version 2.3.1.1000|version 2.3.1.1000" + "version two" + "checked 2026-13-40") + string(REPLACE "|" "\n" bad_kv "${bad_kv}") + string(REPLACE "min-api 2.1.0" "min-api 2.1.0\n${bad_kv}" lock_key "${LOCK_TEXT}") + file(WRITE "${WORK}/lock-key.txt" "${lock_key}") + rel(2 out dokany verify --lock lock-key.txt --dir files) +endforeach() + +# malformed file lines (the whole x64 line replaced by a broken one) +string(TOUPPER "${H64}" H64_UP) +foreach(bad_line IN ITEMS + "file x64 msi Dokan_x64.msi" + "file x64 msi Dokan_x64.msi ${S64} ${H64} {31A8F445-2AC6-494B-819C-4F4E1B2AECF1}" + "file x64 msi Dokan_x64.msi ${S64} ${H64_UP} {31A8F445-2AC6-494B-819C-4F4E1B2AECF1} none" + "file x64 msi Dokan_x64.msi ${S64} ${H64} 31A8F445-2AC6-494B-819C-4F4E1B2AECF1 none" + "file x64 msi Dokan_x64.msi ${S64} ${H64} {31A8F445-2AC6-494B-819C-4F4E1B2AECF1} maybe" + "file x64 msi ..\\evil ${S64} ${H64} - none" + "file x64 msi Dokan_x64.msi ${S64} ${H64} {31A8F445-2AC6-494B-819C-4F4E1B2AECF1} none extra" + "file x64 msi Dokan_x64.msi ${S64} ${H64} {31A8F445-2AC6-494B-819C-4F4E1B2AECF1} none ") + string(REPLACE "${X64_LINE}" "${bad_line}" lock_key "${LOCK_TEXT}") + file(WRITE "${WORK}/lock-key.txt" "${lock_key}") + rel(2 out dokany verify --lock lock-key.txt --dir files) +endforeach() + +# duplicate file line (same arch and form) +string(REPLACE "file x86 msi Dokan_x86.msi" "file x64 msi Dokan_x64.msi" lock_dup "${LOCK_TEXT}") +file(WRITE "${WORK}/lock-dup.txt" "${lock_dup}") +rel(2 out dokany verify --lock lock-dup.txt --dir files) + +# a tab is not printable per the grammar +string(REPLACE "signer-cn LEOSAC" "signer-cn LEOSAC\t" lock_tab "${LOCK_TEXT}") +file(WRITE "${WORK}/lock-tab.txt" "${lock_tab}") +rel(2 out dokany verify --lock lock-tab.txt --dir files) + +# ---- usage: exit 1 ---- +rel(1 out dokany verify --lock lock.txt) +rel(1 out dokany verify --dir files) +rel(1 out dokany frobnicate) + +message(STATUS "dokany flow: all checks passed") diff --git a/tools/sd-release/sd-release.c b/tools/sd-release/sd-release.c index 652bb29..67cbdaa 100644 --- a/tools/sd-release/sd-release.c +++ b/tools/sd-release/sd-release.c @@ -26,6 +26,12 @@ * sd-release gates check FILE --version V --commit C validates the release gates record (04 §12.3): exit 0, * 2 malformed, 3 refused (gate missing, failed or waived * where not allowed, wrong version or commit, not approved) + * sd-release dokany verify --lock LOCK --dir DIR [--arch A] + * checks the pinned Dokany files in DIR against LOCK + * (installer/dokany/dokany.lock, 04 §7.3, TM-R23): + * exit 0 ok, 2 malformed lock, 3 a file is missing or + * its size or SHA-256 differs; with --arch, only that + * arch's files plus the bundle are checked * * sign takes --gates FILE --version V --commit C for every K and refuses (exit 3) unless gates check passes; a manifest * or payload body must also carry the record's channel, and a manifest the record's version. @@ -941,6 +947,394 @@ static int cmd_gates(int argc, char **argv) return rc; } +/* ---- dokany.lock (docs/design/04 §7.3; TM-R23, D32) ---- + * The package job checks size and SHA-256 of every pinned Dokany file against the lock at embed time, whether the + * file was downloaded fresh or served from a mirror (a poisoned cache entry fails the build). Grammar: printable + * ASCII with LF or CRLF line ends, '#' comments and blank lines allowed; the first content line must be + * "dokany-lock 1"; other lines are "key value..." (value = the rest of the line) or "file" lines + * file + * Unknown keys, duplicate keys, malformed file lines and trailing spaces are refused. Exit codes: 2 malformed, + * 3 a file is missing or its size or SHA-256 differs, 0 ok. */ + +#define DK_MAX_BYTES (64u * 1024u) +#define DK_MAX_FILES 16u + +typedef struct dk_file { + char arch[16], form[16], name[160]; + unsigned long long size; + uint8_t sha256[32]; +} dk_file; + +typedef struct dk_lock { + int have_magic, have_version, have_min_api, have_signer_cn, have_signer_issuer; + int have_url_upstream, have_url_mirror, have_checked; + char version[40], min_api[40], signer_cn[128], signer_issuer[128]; + char url_upstream[512], url_mirror[512], checked[16]; + dk_file files[DK_MAX_FILES]; + unsigned nfiles; +} dk_lock; + +static int dk_bad(unsigned line, const char *msg) +{ + if (line == 0) + fprintf(stderr, "sd-release: dokany.lock: %s\n", msg); + else + fprintf(stderr, "sd-release: dokany.lock line %u: %s\n", line, msg); + return -1; +} + +/* One to five decimal digits: a version part. */ +static int dk_is_digits(const char *s, size_t n) +{ + size_t i; + if (n == 0 || n > 5) + return 0; + for (i = 0; i < n; i++) { + if (s[i] < '0' || s[i] > '9') + return 0; + } + return 1; +} + +/* Decimal digits only, any length (a file size, checked against a 20-digit cap at the call site). */ +static int dk_all_digits(const char *s, size_t n) +{ + size_t i; + if (n == 0) + return 0; + for (i = 0; i < n; i++) { + if (s[i] < '0' || s[i] > '9') + return 0; + } + return 1; +} + +/* 1 to 4 dot-separated parts of 1 to 5 digits (the Dokany release and API versions). */ +static int dk_valid_version(const char *s, size_t n) +{ + size_t i, start = 0, parts = 1; + for (i = 0; i <= n; i++) { + if (i == n || s[i] == '.') { + if (!dk_is_digits(s + start, i - start)) + return 0; + if (i == n) + break; + start = i + 1; + if (++parts > 4) + return 0; + } + } + return 1; +} + +/* YYYY-MM-DD with month 01 to 12 and day 01 to 31 (the "checked" line; the same rule as the gates record). */ +static int dk_valid_date(const char *s, size_t n) +{ + if (n != 10 || s[4] != '-' || s[7] != '-') + return 0; + if (!dk_is_digits(s, 4) || !dk_is_digits(s + 5, 2) || !dk_is_digits(s + 8, 2)) + return 0; + return 1; +} + +/* Lowercase [a-z0-9]{1,15}: an arch (x64, x86, arm64, all) or a form (msi, bundle). */ +static int dk_valid_tag(const char *s, size_t n) +{ + size_t i; + if (n == 0 || n > 15) + return 0; + for (i = 0; i < n; i++) { + char c = s[i]; + if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'))) + return 0; + } + return 1; +} + +/* A plain file name: no separators or wildcards, no leading dot. */ +static int dk_valid_name(const char *s, size_t n) +{ + size_t i; + static const char forbidden[] = "\\/:*?\"<>|"; + if (n == 0 || n >= 160 || s[0] == '.') + return 0; + for (i = 0; i < n; i++) { + if (strchr(forbidden, s[i]) != NULL || (unsigned char)s[i] < 0x21u) + return 0; + } + return 1; +} + +/* 64 lowercase hex digits (the pinned SHA-256; the lock records Get-FileHash output in lower case). */ +static int dk_valid_sha256(const char *s) +{ + size_t i; + if (strlen(s) != 64) + return 0; + for (i = 0; i < 64; i++) { + char c = s[i]; + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'))) + return 0; + } + return 1; +} + +/* "-" or "{8-4-4-4-12 hex}" (an MSI UpgradeCode; hex digits in either case, dashes fixed). */ +static int dk_valid_guid(const char *s, size_t n) +{ + static const size_t dashes[4] = { 8, 13, 18, 23 }; + size_t i, k = 0; + if (n == 1 && s[0] == '-') + return 1; + if (n != 38 || s[0] != '{' || s[37] != '}') + return 0; + for (i = 1; i <= 36; i++) { + char c = s[i]; + if (k < 4 && i - 1 == dashes[k]) { + if (c != '-') + return 0; + k++; + continue; + } + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) + return 0; + } + return k == 4; +} + +/* The value of a "key value..." line: the rest of the line, printable, no trailing space, within cap. */ +static int dk_value(const char *v, size_t n, char *out, size_t cap) +{ + if (n == 0 || n >= cap || v[0] == ' ') + return -1; + memcpy(out, v, n); + out[n] = '\0'; + return 0; +} + +/* One "file" line: the words after the "file" keyword, seven of them. */ +static int dk_file_line(dk_lock *l, const char *rest, size_t n, unsigned lineno) +{ + char word[8][192]; + size_t i = 0, start = 0, w = 0, len; + dk_file *f; + unsigned k; + + while (i <= n) { + if (i == n || rest[i] == ' ') { + len = i - start; + if (len == 0 || len >= sizeof(word[0]) || w >= 8) + return dk_bad(lineno, "a file line is: file " + ""); + memcpy(word[w], rest + start, len); + word[w][len] = '\0'; + w++; + if (i == n) + break; + start = i + 1; + } + i++; + } + if (w != 7) + return dk_bad(lineno, "a file line has seven fields"); + if (l->nfiles >= DK_MAX_FILES) + return dk_bad(lineno, "too many file lines"); + if (!dk_valid_tag(word[0], strlen(word[0]))) + return dk_bad(lineno, "the arch is lowercase letters and digits (x64, x86, arm64, all)"); + if (!dk_valid_tag(word[1], strlen(word[1]))) + return dk_bad(lineno, "the form is lowercase letters and digits (msi, bundle)"); + if (!dk_valid_name(word[2], strlen(word[2]))) + return dk_bad(lineno, "the name is a plain file name without separators or a leading dot"); + if (!dk_all_digits(word[3], strlen(word[3])) || strlen(word[3]) > 20) + return dk_bad(lineno, "the size is a decimal byte count"); + if (!dk_valid_sha256(word[4]) || from_hex(word[4], 32, l->files[l->nfiles].sha256) != 0) + return dk_bad(lineno, "the SHA-256 is 64 lowercase hex digits"); + if (!dk_valid_guid(word[5], strlen(word[5]))) + return dk_bad(lineno, "the upgrade code is a {GUID} or '-'"); + if (strcmp(word[6], "none") != 0 && strcmp(word[6], "required") != 0 && strcmp(word[6], "-") != 0) + return dk_bad(lineno, "vcruntime is none, required or -"); + f = &l->files[l->nfiles]; + strcpy(f->arch, word[0]); + strcpy(f->form, word[1]); + strcpy(f->name, word[2]); + f->size = 0; + for (k = 0; word[3][k] != '\0'; k++) + f->size = f->size * 10 + (unsigned long long)(word[3][k] - '0'); + for (k = 0; k < l->nfiles; k++) { + if (strcmp(l->files[k].arch, f->arch) == 0 && strcmp(l->files[k].form, f->form) == 0) + return dk_bad(lineno, "duplicate file line for that arch and form"); + if (strcmp(l->files[k].name, f->name) == 0) + return dk_bad(lineno, "duplicate file line for that file name"); + } + l->nfiles++; + return 0; +} + +/* Parses a whole lock (the caller has checked the bytes). 0 on success; -1 with a message. */ +static int dk_parse(const char *s, size_t n, dk_lock *l) +{ + size_t i = 0; + unsigned lineno = 0; + char line[1024]; + memset(l, 0, sizeof(*l)); + if (n == 0 || s[n - 1] != '\n') + return dk_bad(0, "the lock must end with LF"); + while (i < n) { + size_t start = i, len, kl, vl; + const char *p; + lineno++; + while (i < n && s[i] != '\n') + i++; + len = i - start; + i++; + if (len == 0 || s[start] == '#') + continue; + if (len >= sizeof(line)) + return dk_bad(lineno, "line too long"); + memcpy(line, s + start, len); + line[len] = '\0'; + if (line[len - 1] == ' ') + return dk_bad(lineno, "trailing space"); + p = strchr(line, ' '); + kl = p != NULL ? (size_t)(p - line) : len; + vl = p != NULL ? len - kl - 1 : 0; + if (!l->have_magic) { + if (kl != 11 || memcmp(line, "dokany-lock", 11) != 0) + return dk_bad(lineno, "the first line must be: dokany-lock 1"); + if (vl != 1 || p[1] != '1') + return dk_bad(lineno, "the first line must be: dokany-lock 1"); + l->have_magic = 1; + continue; + } + if (p == NULL) + return dk_bad(lineno, "a key line is: key value"); + if (kl == 7 && memcmp(line, "version", 7) == 0) { + if (l->have_version++) + return dk_bad(lineno, "duplicate key"); + if (dk_value(p + 1, vl, l->version, sizeof(l->version)) != 0 || !dk_valid_version(p + 1, vl)) + return dk_bad(lineno, "the version is 1 to 4 dot-separated numbers"); + } else if (kl == 7 && memcmp(line, "min-api", 7) == 0) { + if (l->have_min_api++) + return dk_bad(lineno, "duplicate key"); + if (dk_value(p + 1, vl, l->min_api, sizeof(l->min_api)) != 0 || !dk_valid_version(p + 1, vl)) + return dk_bad(lineno, "min-api is 1 to 4 dot-separated numbers"); + } else if (kl == 9 && memcmp(line, "signer-cn", 9) == 0) { + if (l->have_signer_cn++) + return dk_bad(lineno, "duplicate key"); + if (dk_value(p + 1, vl, l->signer_cn, sizeof(l->signer_cn)) != 0) + return dk_bad(lineno, "signer-cn is too long"); + } else if (kl == 16 && memcmp(line, "signer-issuer-cn", 16) == 0) { + if (l->have_signer_issuer++) + return dk_bad(lineno, "duplicate key"); + if (dk_value(p + 1, vl, l->signer_issuer, sizeof(l->signer_issuer)) != 0) + return dk_bad(lineno, "signer-issuer-cn is too long"); + } else if (kl == 12 && memcmp(line, "url-upstream", 12) == 0) { + if (l->have_url_upstream++) + return dk_bad(lineno, "duplicate key"); + if (dk_value(p + 1, vl, l->url_upstream, sizeof(l->url_upstream)) != 0) + return dk_bad(lineno, "url-upstream is too long"); + } else if (kl == 10 && memcmp(line, "url-mirror", 10) == 0) { + if (l->have_url_mirror++) + return dk_bad(lineno, "duplicate key"); + if (dk_value(p + 1, vl, l->url_mirror, sizeof(l->url_mirror)) != 0) + return dk_bad(lineno, "url-mirror is too long"); + } else if (kl == 7 && memcmp(line, "checked", 7) == 0) { + if (l->have_checked++) + return dk_bad(lineno, "duplicate key"); + if (dk_value(p + 1, vl, l->checked, sizeof(l->checked)) != 0 || !dk_valid_date(p + 1, vl)) + return dk_bad(lineno, "checked is a YYYY-MM-DD date"); + } else if (kl == 4 && memcmp(line, "file", 4) == 0) { + if (dk_file_line(l, p + 1, vl, lineno) != 0) + return -1; + } else { + return dk_bad(lineno, "unknown key"); + } + } + if (!l->have_magic) + return dk_bad(0, "the first line must be: dokany-lock 1"); + if (l->nfiles == 0) + return dk_bad(0, "the lock pins no files"); + return 0; +} + +/* sd-release dokany verify --lock FILE --dir DIR [--arch ARCH] */ +static int cmd_dokany(int argc, char **argv) +{ + const char *lock_path = opt(argc, argv, "--lock"), *dir = opt(argc, argv, "--dir"); + const char *arch = opt(argc, argv, "--arch"); + unsigned char *buf; + size_t n = 0, i, j; + unsigned k, selected = 0, failed = 0; + char path[1024], hx[65]; + dk_lock l; + int bad_byte = 0; + if (argc < 3 || strcmp(argv[2], "verify") != 0 || lock_path == NULL || dir == NULL) + return fail(RC_USAGE, "usage: sd-release dokany verify --lock FILE --dir DIR [--arch ARCH]"); + buf = read_file(lock_path, DK_MAX_BYTES, &n); + if (buf == NULL) + return fail(RC_IO, "cannot read dokany.lock (64 KB max)"); + /* A CRLF pair (a Windows editor) is read as LF; any other CR, a tab or a byte outside printable ASCII is refused. */ + for (i = 0, j = 0; i < n; i++) { + unsigned char b = buf[i]; + if (b == '\r' && i + 1 < n && buf[i + 1] == '\n') + continue; + if (b != '\n' && (b < 0x20u || b > 0x7eu)) + bad_byte = 1; + buf[j++] = b; + } + n = j; + buf[n] = '\0'; + if (bad_byte) { + free(buf); + return fail(RC_IO, "dokany.lock takes printable ASCII, LF and CRLF only"); + } + if (dk_parse((const char *)buf, n, &l) != 0) { + free(buf); + return RC_IO; + } + free(buf); + for (k = 0; k < l.nfiles; k++) { + unsigned long long size = 0; + uint8_t d[32]; + char want[65]; + int missing; + if (arch != NULL && strcmp(l.files[k].arch, arch) != 0 && strcmp(l.files[k].arch, "all") != 0) + continue; + selected++; + if (strlen(dir) + strlen(l.files[k].name) + 2 > sizeof(path)) { + fprintf(stderr, "sd-release: dokany: path too long for %s\n", l.files[k].name); + failed++; + continue; + } + snprintf(path, sizeof(path), "%s\\%s", dir, l.files[k].name); + missing = hash_file(path, SD_HASH_SHA256, d, &size) != 0; + if (missing) { + fprintf(stderr, "sd-release: dokany: missing %s (expected in %s)\n", l.files[k].name, dir); + failed++; + } else if (size != l.files[k].size) { + fprintf(stderr, "sd-release: dokany: %s is %llu bytes, the lock pins %llu\n", l.files[k].name, size, + l.files[k].size); + failed++; + } else if (memcmp(d, l.files[k].sha256, 32) != 0) { + to_hex(d, 32, hx); + to_hex(l.files[k].sha256, 32, want); + fprintf(stderr, "sd-release: dokany: %s sha256 %s, the lock pins %s\n", l.files[k].name, hx, want); + failed++; + } else { + to_hex(d, 32, hx); + printf("OK %s %s %s %llu sha256:%s\n", l.files[k].arch, l.files[k].form, l.files[k].name, l.files[k].size, hx); + } + } + if (arch != NULL && selected == 0) { + fprintf(stderr, "sd-release: dokany: the lock pins no file for arch %s\n", arch); + return RC_VERIFY; + } + if (failed > 0) + return RC_VERIFY; + printf("dokany ok files %u\n", selected); + return RC_OK; +} + static int cmd_sign(int argc, char **argv) { const char *kind = opt(argc, argv, "--kind"), *seed = opt(argc, argv, "--seed"); @@ -1436,7 +1830,8 @@ static int cmd_session(int argc, char **argv) static void usage(void) { - fprintf(stderr, "usage: sd-release hash|sums|table|check-table|manifest|sign|verify|pubkey|keyring|session|gates ... " + fprintf(stderr, "usage: sd-release hash|sums|table|check-table|manifest|sign|verify|pubkey|keyring|session|gates" + "|dokany ... " "(see the header of tools/sd-release/sd-release.c)\n"); } @@ -1468,6 +1863,8 @@ int main(int argc, char **argv) return cmd_session(argc, argv); if (strcmp(argv[1], "gates") == 0) return cmd_gates(argc, argv); + if (strcmp(argv[1], "dokany") == 0) + return cmd_dokany(argc, argv); usage(); return RC_USAGE; } From 9f6e1056375a34afcd307e4c4575e78f02f9f1bc Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 00:55:54 -0300 Subject: [PATCH 09/15] build: embed the upstream Dokany installers as DOKANY_X64/X86 (04 7.3) sd-payload gains --dokany-x64/--dokany-x86: the lock-verified upstream packages ride as RT_RCDATA resources (04 6.1), unmodified. SdPayload passes them from the new SD_PAYLOAD_DOKANY_X64/X86 cache variables, so the packaging script can reconfigure with PAYLOAD_SIG and the Dokany resources in one attach step. Without them nothing changes. --- cmake/SdPayload.cmake | 24 +++++++++++++++++---- tools/sd-payload/sd-payload.c | 40 +++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/cmake/SdPayload.cmake b/cmake/SdPayload.cmake index accaccb..26e58ce 100644 --- a/cmake/SdPayload.cmake +++ b/cmake/SdPayload.cmake @@ -14,7 +14,9 @@ # cabinet, and sd-payload embeds the CAB and its hash in shelldrive-setup.exe, so the build clock # would break the reproducible-build check (04 §12.2). Then tools/sd-payload attaches the CAB and the # PAYLOAD_TABLE hash to a copy of the stub as RT_RCDATA, with --channel SD_SETUP_CHANNEL and, when -# SD_PAYLOAD_SIG names a file, the PAYLOAD_SIG resource made offline by tools/sd-release (TM-R38). +# SD_PAYLOAD_SIG names a file, the PAYLOAD_SIG resource made offline by tools/sd-release (TM-R38). When +# SD_PAYLOAD_DOKANY_X64/X86 name the upstream files (checked against installer/dokany/dokany.lock by the +# caller, 04 §7.3), their DOKANY_X64/X86 resources are attached too. # Result: , a custom target shelldrive-setup depends on it. # Channel of the payload table (stable or beta; the stub refuses a table of the other channel, TM-R38). @@ -29,6 +31,11 @@ endif() # One-line signature of the payload table (sd-release sign --kind payload --sig-only). Empty: no PAYLOAD_SIG, and the # stub refuses the payload until one is attached (the release pipeline supplies it). set(SD_PAYLOAD_SIG "" CACHE FILEPATH "Signature file of the payload table (PAYLOAD_SIG); empty: none") +# The upstream Dokany installers embedded as DOKANY_X64 / DOKANY_X86 (04 §6.1, §7.3, TM-R23). The caller has checked +# them against installer/dokany/dokany.lock (sd-release dokany verify) at embed time; sd-payload embeds them unmodified. +# Empty: the resource is not attached (the stub then offers no Dokany install of that architecture). +set(SD_PAYLOAD_DOKANY_X64 "" CACHE FILEPATH "Dokan_x64.msi embedded as DOKANY_X64 (04 §7.3); empty: none") +set(SD_PAYLOAD_DOKANY_X86 "" CACHE FILEPATH "Dokan_x86.msi embedded as DOKANY_X86 (04 §7.3); empty: none") function(sd_setup_generate_regtable out_var) set(_src "${CMAKE_SOURCE_DIR}/packaging/registration.txt") @@ -199,12 +206,21 @@ ${_ddf_members}") add_dependencies(shelldrive-setup-payload-${flavour} shelldrive shelldrive-host ${_ui_targets} ${stub_target} ${_msi_target} ${_x86_target}) set_target_properties(shelldrive-setup-payload-${flavour} PROPERTIES FOLDER "apps") - # The table channel and, when given, the PAYLOAD_SIG file (tools/sd-release sign --kind payload --sig-only). + # The table channel and, when given, the PAYLOAD_SIG file (tools/sd-release sign --kind payload --sig-only) and the + # Dokany installers (DOKANY_X64 / DOKANY_X86, 04 §7.3). set(_sig_args "") set(_sig_deps "") if(NOT SD_PAYLOAD_SIG STREQUAL "") - set(_sig_args --sig "${SD_PAYLOAD_SIG}") - set(_sig_deps "${SD_PAYLOAD_SIG}") + list(APPEND _sig_args --sig "${SD_PAYLOAD_SIG}") + list(APPEND _sig_deps "${SD_PAYLOAD_SIG}") + endif() + if(NOT SD_PAYLOAD_DOKANY_X64 STREQUAL "") + list(APPEND _sig_args --dokany-x64 "${SD_PAYLOAD_DOKANY_X64}") + list(APPEND _sig_deps "${SD_PAYLOAD_DOKANY_X64}") + endif() + if(NOT SD_PAYLOAD_DOKANY_X86 STREQUAL "") + list(APPEND _sig_args --dokany-x86 "${SD_PAYLOAD_DOKANY_X86}") + list(APPEND _sig_deps "${SD_PAYLOAD_DOKANY_X86}") endif() set(_final "${CMAKE_BINARY_DIR}/bin/$/shelldrive-setup.exe") diff --git a/tools/sd-payload/sd-payload.c b/tools/sd-payload/sd-payload.c index e7c7f97..ed63913 100644 --- a/tools/sd-payload/sd-payload.c +++ b/tools/sd-payload/sd-payload.c @@ -2,6 +2,7 @@ * * sd-payload --base --out --flavour --cab * [--channel ] [--sig ] + * [--dokany-x64 ] [--dokany-x86 ] * * Writes the stub to and adds RT_RCDATA resources with UpdateResourceW: * PAYLOAD_ the CAB bytes (the flavour name in upper case, '-' replaced by '_'); @@ -10,6 +11,8 @@ * without it, the legacy single line (the stub refuses it: no signature can match). * PAYLOAD_SIG --sig: the one-line "sig ed25519 ..." signature of the table (TM-R38), made by * tools/sd-release sign --domain payload. + * DOKANY_X64/X86 --dokany-x64/--dokany-x86: the upstream Dokany installer of that architecture, unmodified + * (04 §6.1, §7.3; the lock's size and SHA-256 are checked by the caller, at embed time). * Build-time tool, never shipped; the console and the CRT are fine here, unlike in the stub. */ #define _CRT_SECURE_NO_WARNINGS 1 #include @@ -53,10 +56,12 @@ static unsigned char *read_file(const char *path, size_t *size) int main(int argc, char **argv) { const char *base = NULL, *out = NULL, *flavour = NULL, *cab = NULL, *channel = NULL, *sigpath = NULL; + const char *dok64path = NULL, *dok86path = NULL; wchar_t resname[64]; wchar_t wflav[32]; unsigned char *cab_bytes, *stub_bytes = NULL, *sig_bytes = NULL; - size_t cab_size = 0, stub_size = 0, sig_size = 0; + unsigned char *dok64_bytes = NULL, *dok86_bytes = NULL; + size_t cab_size = 0, stub_size = 0, sig_size = 0, dok64_size = 0, dok86_size = 0; unsigned char sha[32]; char hex[65]; char table[512]; @@ -80,11 +85,16 @@ int main(int argc, char **argv) channel = argv[i + 1]; else if (strcmp(argv[i], "--sig") == 0) sigpath = argv[i + 1]; + else if (strcmp(argv[i], "--dokany-x64") == 0) + dok64path = argv[i + 1]; + else if (strcmp(argv[i], "--dokany-x86") == 0) + dok86path = argv[i + 1]; else return fail("unknown option"); } if (base == NULL || out == NULL || flavour == NULL || cab == NULL) - return fail("usage: sd-payload --base --out --flavour --cab [--channel c] [--sig f]"); + return fail("usage: sd-payload --base --out --flavour --cab " + "[--channel c] [--sig f] [--dokany-x64 f] [--dokany-x86 f]"); if (strlen(flavour) >= sizeof(wflav) / sizeof(wflav[0]) - 1) return fail("flavour name too long"); if (channel != NULL && strcmp(channel, "stable") != 0 && strcmp(channel, "beta") != 0) @@ -98,6 +108,16 @@ int main(int argc, char **argv) if (sig_bytes == NULL || sig_size == 0 || sig_size > 256) return fail("cannot read the signature file"); } + if (dok64path != NULL) { + dok64_bytes = read_file(dok64path, &dok64_size); + if (dok64_bytes == NULL || dok64_size == 0) + return fail("cannot read the Dokany x64 package"); + } + if (dok86path != NULL) { + dok86_bytes = read_file(dok86path, &dok86_size); + if (dok86_bytes == NULL || dok86_size == 0) + return fail("cannot read the Dokany x86 package"); + } stub_bytes = read_file(base, &stub_size); if (stub_bytes == NULL || stub_size == 0) return fail("cannot read the stub"); @@ -147,16 +167,28 @@ int main(int argc, char **argv) if (!UpdateResourceW(upd, RT_RCDATA, resname, 0, cab_bytes, (DWORD)cab_size) || !UpdateResourceW(upd, RT_RCDATA, L"PAYLOAD_TABLE", 0, table, (DWORD)strlen(table)) || (sig_bytes != NULL && - !UpdateResourceW(upd, RT_RCDATA, L"PAYLOAD_SIG", 0, sig_bytes, (DWORD)sig_size))) { + !UpdateResourceW(upd, RT_RCDATA, L"PAYLOAD_SIG", 0, sig_bytes, (DWORD)sig_size)) || + (dok64_bytes != NULL && + !UpdateResourceW(upd, RT_RCDATA, L"DOKANY_X64", 0, dok64_bytes, (DWORD)dok64_size)) || + (dok86_bytes != NULL && + !UpdateResourceW(upd, RT_RCDATA, L"DOKANY_X86", 0, dok86_bytes, (DWORD)dok86_size))) { EndUpdateResourceW(upd, TRUE); return fail("UpdateResource failed"); } if (!EndUpdateResourceW(upd, FALSE)) rc = fail("EndUpdateResource failed"); - if (rc == 0) + if (rc == 0) { printf("sd-payload: %s (%lu bytes, sha256 %s)\n", out, (unsigned long)cab_size, hex); + if (dok64_bytes != NULL) + printf("sd-payload: %s DOKANY_X64 (%lu bytes)\n", out, (unsigned long)dok64_size); + if (dok86_bytes != NULL) + printf("sd-payload: %s DOKANY_X86 (%lu bytes)\n", out, (unsigned long)dok86_size); + } free(cab_bytes); free(stub_bytes); + free(sig_bytes); + free(dok64_bytes); + free(dok86_bytes); return rc; } From 4195309abf4c516f20bc01ab3fac5e1a5a54930f Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 00:55:55 -0300 Subject: [PATCH 10/15] release: beta pre-release packaging with tools/release/package-beta.ps1 (D48) The whole beta packaging, runnable locally and from CI: fresh -DSD_SETUP_CHANNEL=beta build trees (pre-release notice defaults ON), setup EXEs and MSIs of the producible flavours, dokany.lock verification of the upstream files (never a download), payload tables, and with -seed the payload-table signatures, the PAYLOAD_SIG reattach plus the DOKANY_X64/X86 resources, SHA256SUMS + SHA256SUMS.sig, the signed beta manifest and release notes that open with the not-yet-reviewed notice. Without -seed it stops after the unsigned artifacts and payload-tables.txt. What the repository cannot produce (x64 and x86-sse2 setups, MSIs without WiX, ARM64 Dokany) is skipped with a clear message, never faked. --- tools/release/package-beta.ps1 | 493 +++++++++++++++++++++++++++++++++ 1 file changed, 493 insertions(+) create mode 100644 tools/release/package-beta.ps1 diff --git a/tools/release/package-beta.ps1 b/tools/release/package-beta.ps1 new file mode 100644 index 0000000..9f7575b --- /dev/null +++ b/tools/release/package-beta.ps1 @@ -0,0 +1,493 @@ +<# +.SYNOPSIS + Package a beta pre-release of ShellDrive (docs/design/04-build-install-update.md section 12.3 step 10, D48). + +.DESCRIPTION + The whole beta packaging, runnable by the maintainer locally and by the release workflow (both with and without + the release-beta seed). It configures fresh build trees with -DSD_SETUP_CHANNEL=beta (so the pre-release notice + defaults ON, 04 section 10), builds the setup EXEs and per-machine MSIs of the flavours this repository can + produce, verifies the upstream Dokany files against installer/dokany/dokany.lock (sd-release dokany verify, + TM-R23; it never downloads anything), produces the payload tables and, when -Seed is given, signs them and + reattaches the payloads with PAYLOAD_SIG (SD_PAYLOAD_SIG, TM-R38) plus the DOKANY_X64/X86 resources (04 §7.3). + Then SHA256SUMS + SHA256SUMS.sig and the signed beta manifest (TM-R36: at most 14 days), and every signature is + verified again with sd-release verify. Without -Seed it stops after the UNSIGNED artifacts and + payload-tables.txt, so the signing can happen elsewhere (an offline session, a CI signing job). + + What cannot be produced is skipped with a clear message and listed at the end; nothing is faked: + - setup EXEs exist only for the x86-ia32, arm64 and arm32 payloads (the stub targets of those presets). The + x64-modern, x64-xp and x86-sse2 payload CABs and the universal EXE are release-pipeline work that is not in + the repository yet (docs/dev/m4-notes.md, M4 row audit). + - the MSIs need WiX 3.14; without it every per-machine MSI is skipped. + - Dokany rides only in the x86-ia32 setup EXE (DOKANY_X64 + DOKANY_X86): the lock pins no ARM64 installer and + there is no ARM32 one upstream (04 §7.1). + + Exit code: 0 ok, 1 a check or build step failed. + +.PARAMETER Version + The version to release; must match the VERSION file (checked like release.yml does). + +.PARAMETER Commit + The full 40-hex commit the release is built from (the workflow passes github.sha). Bound into the gates record. + +.PARAMETER BuildRoot + Folder of the build trees; fresh "beta-" trees are configured inside it (default: \build). + +.PARAMETER DokanyDir + Folder holding the upstream Dokany files of the lock (Dokan_x64.msi, Dokan_x86.msi, DokanSetup.exe). They are + verified against the lock here, never downloaded. + +.PARAMETER OutDir + Output folder (default: \dist\beta-). Its previous content is removed. + +.PARAMETER Seed + Path to the release-beta seed file (64 hex digits). Optional: without it the script stops after the unsigned + artifacts and payload-tables.txt. + +.PARAMETER Gates + The release gates record (04 §12.3; default installer\keys\release-gates.txt). Required with -Seed: every + sd-release sign refuses to sign unless the record validates for this version and commit. + +.PARAMETER BuildNumber + The build number stamped into the binaries (CI: the run number; local: 0). + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File tools\release\package-beta.ps1 -Version 0.1.0 -Commit -DokanyDir E:\dokany -Seed E:\keys\release-beta.seed +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Version, + [Parameter(Mandatory = $true)][string]$Commit, + [string]$BuildRoot = '', + [Parameter(Mandatory = $true)][string]$DokanyDir, + [string]$OutDir = '', + [string]$Seed = '', + [string]$Gates = '', + [string]$BuildNumber = '0' +) + +$ErrorActionPreference = 'Stop' +$env:MSBUILDDISABLENODEREUSE = '1' +$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$script:Skipped = New-Object System.Collections.ArrayList +$script:Configured = @{} + +function Fail([string]$Message) { + Write-Host ("package-beta: ERROR: " + $Message) -ForegroundColor Red + exit 1 +} + +function Skip([string]$Message) { + [void]$script:Skipped.Add($Message) + Write-Host ("package-beta: SKIPPED: " + $Message) -ForegroundColor Yellow +} + +# Runs a program and fails the script on a non-zero exit code. +function Invoke-Checked([string]$Title, [string]$Exe, [string[]]$ExeArgs) { + Write-Host (">> " + $Title) + & $Exe @ExeArgs + if ($LASTEXITCODE -ne 0) { Fail ("$Title failed (exit $LASTEXITCODE)") } +} + +# Locates cmake.exe: on PATH, else the Visual Studio Build Tools installation (as tools/ci-local.ps1 does). +function Find-CMake { + $cmd = Get-Command cmake.exe -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path $vswhere)) { Fail 'cmake.exe not found on PATH and vswhere.exe not found' } + $vs = & $vswhere -latest -products * -property installationPath + $bin = Join-Path $vs 'Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe' + if (-not (Test-Path $bin)) { Fail ("cmake.exe not found under " + $bin) } + return $bin +} + +# Reads a configure preset from CMakePresets.json: generator, platform (-A), toolset (-T) and the inherited +# cacheVariables, so the fresh trees configure exactly like the presets (that file stays the single source). +function Get-PresetConfig([string]$Name) { + $all = (Get-Content -Raw (Join-Path $repo 'CMakePresets.json') | ConvertFrom-Json).configurePresets + $chain = @() + $cur = $all | Where-Object { $_.name -eq $Name } | Select-Object -First 1 + if (-not $cur) { Fail ("preset not found in CMakePresets.json: " + $Name) } + while ($cur) { + $chain = @($cur) + $chain + if ($cur.inherits) { + $cur = $all | Where-Object { $_.name -eq $cur.inherits } | Select-Object -First 1 + } else { $cur = $null } + } + $cfg = @{ Generator = $null; Platform = $null; Toolset = $null; Vars = @{} } + foreach ($e in $chain) { + if ($e.generator) { $cfg.Generator = $e.generator } + if ($e.architecture -and $e.architecture.value) { $cfg.Platform = $e.architecture.value } + if ($e.toolset -and $e.toolset.value) { $cfg.Toolset = $e.toolset.value } + if ($e.cacheVariables) { + foreach ($p in $e.cacheVariables.PSObject.Properties) { $cfg.Vars[$p.Name] = [string]$p.Value } + } + } + return $cfg +} + +# Writes LF-only text without a BOM (the payload tables, the manifest spec and the notes are read byte for byte). +function Write-LfText([string]$Path, [string]$Text) { + [System.IO.File]::WriteAllText($Path, ($Text -replace "`r`n", "`n"), (New-Object System.Text.UTF8Encoding($false))) +} + +# Configures the fresh beta tree of a preset (a tree configured for another channel is removed first, so the +# SD_SETUP_PRERELEASE_NOTICE default is evaluated for beta, 04 §10). +function New-BetaTree([string]$Tree, [string]$PresetName) { + if ($script:Configured.ContainsKey($Tree)) { return } + $cache = Join-Path $Tree 'CMakeCache.txt' + if (Test-Path $cache) { + $chan = Select-String -Path $cache -Pattern 'SD_SETUP_CHANNEL:STRING=(.+)$' | Select-Object -First 1 + if (-not $chan -or $chan.Matches[0].Groups[1].Value.Trim() -ne 'beta') { + Write-Host ("removing the stale build tree " + $Tree + " (not configured for the beta channel)") + Remove-Item $Tree -Recurse -Force -Confirm:$false + } + } + $cfg = Get-PresetConfig $PresetName + $confArgs = @('-S', $repo, '-B', $Tree, '-G', $cfg.Generator) + if ($cfg.Platform) { $confArgs += @('-A', $cfg.Platform) } + if ($cfg.Toolset) { $confArgs += @('-T', $cfg.Toolset) } + foreach ($k in $cfg.Vars.Keys) { $confArgs += ("-D$k=" + $cfg.Vars[$k]) } + $confArgs += @('-DSD_SETUP_CHANNEL=beta', ("-DSD_BUILD_NUMBER=" + $BuildNumber)) + if ($script:HaveWix) { $confArgs += '-DSD_BUILD_MSI=ON' } + Invoke-Checked ("configure beta-$PresetName") $cmake $confArgs + $script:Configured[$Tree] = $true +} + +# The updater's per-flavour minimum OS (04 §9.2; ARM32 is Windows 10+ best effort, D5). +function Get-MinOs([string]$Flavour) { + switch ($Flavour) { + 'x86-ia32' { '5.1' } + 'x86-sse2' { '5.1' } + 'x64-xp' { '5.2' } + 'x64-modern' { '10.0' } + 'arm64' { '10.0' } + 'arm32' { '10.0' } + default { Fail ("no min-os known for flavour " + $Flavour) } + } +} + +function Invoke-Summary { + Write-Host '' + Write-Host 'Beta packaging finished:' + foreach ($name in $script:ReleaseFiles) { + $p = Get-Item (Join-Path $OutDir $name) + Write-Host (" " + $name + " " + $p.Length + " bytes") + } + foreach ($extra in $script:OutExtras) { + $p = Get-Item (Join-Path $OutDir $extra) + Write-Host (" " + $extra + " " + $p.Length + " bytes") + } + if ($script:Skipped.Count -gt 0) { + Write-Host 'Skipped:' + foreach ($s in $script:Skipped) { Write-Host (" - " + $s) } + } +} + +# ---- preflight ------------------------------------------------------------- + +if ($Version -ne (Get-Content -Raw (Join-Path $repo 'VERSION')).Trim()) { + Fail ("-Version $Version does not match the VERSION file " + (Get-Content -Raw (Join-Path $repo 'VERSION')).Trim()) +} +if ($Commit -notmatch '^[0-9a-f]{40}$') { Fail '-Commit must be the full 40-digit lowercase hex commit SHA' } + +$keysDir = Join-Path $repo 'installer\keys' +$rootsTxt = Join-Path $keysDir 'roots.txt' +$keysetTxt = Join-Path $keysDir 'keyset.txt' +if (-not (Test-Path $rootsTxt) -or -not (Test-Path $keysetTxt)) { + Fail 'installer/keys/roots.txt and keyset.txt are missing: run the key ceremony first (docs/dev/key-ceremony.md); without them the stubs compile no release key and refuse every payload' +} + +if ($Gates -eq '') { $Gates = Join-Path $keysDir 'release-gates.txt' } +if ($Seed -ne '' -and -not (Test-Path $Gates)) { + Fail ("-Gates not found: " + $Gates + " - every sd-release sign needs a validated release gates record (04 section 12.3)") +} + +$dokanyLock = Join-Path $repo 'installer\dokany\dokany.lock' +if (-not (Test-Path $DokanyDir -PathType Container)) { Fail ("-DokanyDir not found: " + $DokanyDir) } + +$script:HaveWix = $false +$wixBin = $env:WIX +if (-not $wixBin) { $wixBin = 'C:\Program Files (x86)\WiX Toolset v3.14' } +if (Test-Path (Join-Path $wixBin 'bin\candle.exe')) { + $script:HaveWix = $true + Write-Host ("WiX: " + $wixBin) +} else { + Skip ("the per-machine MSIs: WiX 3.14 not found at '" + $wixBin + "' (set WIX)") +} + +$cmake = Find-CMake +if ($BuildRoot -eq '') { $BuildRoot = Join-Path $repo 'build' } +if ($OutDir -eq '') { $OutDir = Join-Path $repo ("dist\beta-" + $Version) } +$work = Join-Path $BuildRoot ('beta-work-' + $Version) +New-Item -ItemType Directory -Force $work | Out-Null +New-Item -ItemType Directory -Force (Join-Path $work 'tables') | Out-Null +if (Test-Path $OutDir) { Remove-Item (Join-Path $OutDir '*') -Recurse -Force -Confirm:$false } +New-Item -ItemType Directory -Force $OutDir | Out-Null + +# The flavours: setup EXEs only where the repository builds a setup stub and payload; MSIs for every flavour the +# packaging supports (the x64 pair and the x86 pair share one UpgradeCode, 04 §8.2). +$flavours = @( + @{ Preset = 'x64-modern'; Flavour = 'x64-modern'; Setup = $false; Msi = $true }, + @{ Preset = 'x64-xp'; Flavour = 'x64-xp'; Setup = $false; Msi = $true }, + @{ Preset = 'x86-ia32'; Flavour = 'x86-ia32'; Setup = $true; Msi = $true; Dokany = $true }, + @{ Preset = 'x86-sse2'; Flavour = 'x86-sse2'; Setup = $false; Msi = $true }, + @{ Preset = 'arm64'; Flavour = 'arm64'; Setup = $true; Msi = $true }, + @{ Preset = 'arm32'; Flavour = 'arm32'; Setup = $true; Msi = $true } +) +$setupFlavours = @('x86-ia32', 'arm64', 'arm32') # presets whose apps/shelldrive-setup builds a stub + payload +foreach ($f in $flavours | Where-Object { -not $_.Setup }) { + Skip ("the setup EXE of $($_.Flavour): no payload target for that preset; the universal EXE assembly is release-pipeline work not in the repository yet (m4-notes, M4 row audit)") +} + +$script:ReleaseFiles = New-Object System.Collections.ArrayList +$script:OutExtras = @('SHA256SUMS', 'SHA256SUMS.sig', 'manifest.txt', 'release-notes.md') + +# ---- 1. the tools: sd-release first, from the x64-modern tree ------------------------------ + +$x64modern = Join-Path $BuildRoot 'beta-x64-modern' +New-BetaTree $x64modern 'x64-modern' +Invoke-Checked 'build sd-release' $cmake @('--build', $x64modern, '--config', 'Release', '--target', 'sd-release') +$sdRelease = Join-Path $x64modern 'bin\Release\sd-release.exe' +if (-not (Test-Path $sdRelease)) { Fail ("sd-release.exe not found after the build: " + $sdRelease) } + +# ---- 2. the Dokany files: verified against the lock, never downloaded ---------------------- + +Write-Host 'verifying the Dokany files against installer/dokany/dokany.lock ...' +& $sdRelease dokany verify --lock $dokanyLock --dir $DokanyDir +if ($LASTEXITCODE -ne 0) { + Fail ("the Dokany files in '" + $DokanyDir + "' do not match the lock (exit $LASTEXITCODE); a mismatched file is never embedded (TM-R23)") +} + +# ---- 3. the fresh beta build trees --------------------------------------------------------- + +foreach ($f in $flavours) { + if (-not $f.Setup -and (-not $f.Msi -or -not $script:HaveWix)) { continue } + New-BetaTree (Join-Path $BuildRoot ('beta-' + $f.Preset)) $f.Preset +} + +# ---- 4. round 1: the payloads and their tables, unsigned ----------------------------------- + +$tables = @{} +foreach ($f in $flavours | Where-Object { $_.Setup }) { + $tree = Join-Path $BuildRoot ('beta-' + $f.Preset) + Invoke-Checked ("build shelldrive-setup ($($f.Preset))") $cmake @('--build', $tree, '--config', 'Release', '--target', 'shelldrive-setup') + $cab = Join-Path $tree ("payload\$($f.Flavour)\payload.cab") + if (-not (Test-Path $cab)) { Fail ("payload CAB not found after the build: " + $cab) } + $table = Join-Path $work ("tables\$($f.Flavour).txt") + Invoke-Checked ("payload table ($($f.Flavour))") $sdRelease @('table', '--channel', 'beta', '--flavour', $f.Flavour, '--cab', $cab, '--out', $table) + Invoke-Checked ("check-table ($($f.Flavour))") $sdRelease @('check-table', '--table', $table, '--cab', $cab) + $tables[$f.Flavour] = @{ Table = $table; Cab = $cab; Tree = $tree } +} +foreach ($f in $flavours | Where-Object { $_.Msi -and $script:HaveWix }) { + $tree = Join-Path $BuildRoot ('beta-' + $f.Preset) + Invoke-Checked ("build msi ($($f.Preset))") $cmake @('--build', $tree, '--config', 'Release', '--target', 'msi') +} + +# Collect the unsigned artifacts under their release names. +$unsigned = Join-Path $work 'unsigned' +New-Item -ItemType Directory -Force $unsigned | Out-Null +$msiFiles = @{} +foreach ($f in $flavours) { + $tree = Join-Path $BuildRoot ('beta-' + $f.Preset) + if ($f.Setup) { + $exe = Join-Path $tree 'bin\Release\shelldrive-setup.exe' + if (-not (Test-Path $exe)) { Fail ("shelldrive-setup.exe not found: " + $exe) } + [void]$script:ReleaseFiles.Add("ShellDrive-$Version-setup-$($f.Flavour).exe") + } + if ($f.Msi -and $script:HaveWix) { + $msis = @(Get-ChildItem -Path (Join-Path $tree 'msi\Release') -Filter '*.msi' -File -ErrorAction SilentlyContinue) + if ($msis.Count -eq 0) { Fail ("no MSI built in " + (Join-Path $tree 'msi\Release')) } + $msiFiles[$f.Flavour] = "ShellDrive-$Version-$($f.Flavour).msi" + [void]$script:ReleaseFiles.Add($msiFiles[$f.Flavour]) + } +} +foreach ($name in $script:ReleaseFiles) { + $src = Join-Path $unsigned $name + if ($name -like '*.exe') { + $flavour = $name -replace [regex]::Escape("ShellDrive-$Version-setup-"), '' -replace '\.exe$', '' + $src = Join-Path (Join-Path $BuildRoot ('beta-' + $flavour)) 'bin\Release\shelldrive-setup.exe' + } + Copy-Item $src (Join-Path $unsigned $name) -Force +} +# payload-tables.txt: the table of every setup EXE - what an offline session verifies and signs (D42). +$tablesParts = @($tables.Keys | Sort-Object | ForEach-Object { Get-Content -Raw $tables[$_].Table }) +$tablesTxt = Join-Path $work 'payload-tables.txt' +Write-LfText $tablesTxt ($tablesParts -join '') +Copy-Item $tablesTxt (Join-Path $unsigned 'payload-tables.txt') -Force + +# ---- 5. without the seed: stop here, the signing happens elsewhere ------------------------- + +if ($Seed -eq '') { + foreach ($name in @($script:ReleaseFiles) + @('payload-tables.txt')) { + Copy-Item (Join-Path $unsigned $name) $OutDir -Force + } + Write-Host '' + Write-Host 'UNSIGNED beta packaging finished (no -Seed given):' + foreach ($name in @($script:ReleaseFiles) + @('payload-tables.txt')) { + $p = Get-Item (Join-Path $OutDir $name) + Write-Host (" " + $name + " " + $p.Length + " bytes") + } + Write-Host 'The setups refuse to run (no PAYLOAD_SIG, TM-R38). Sign each table elsewhere:' + Write-Host ' sd-release sign --kind payload --seed --in --out --sig-only --gates --version --commit ' + Write-Host 'and re-run this script with -Seed to attach the signatures and finish the release.' + if ($script:Skipped.Count -gt 0) { + Write-Host 'Skipped:' + foreach ($s in $script:Skipped) { Write-Host (" - " + $s) } + } + exit 0 +} + +# ---- 6. with the seed: gates, the beta key check, the payload signatures ------------------- + +if (-not (Test-Path $Seed)) { Fail ("-Seed not found: " + $Seed) } +$gatesArgs = @('--gates', $Gates, '--version', $Version, '--commit', $Commit) +Invoke-Checked 'gates check' $sdRelease @('gates', 'check', $Gates, '--version', $Version, '--commit', $Commit) + +# The seed must be the beta release key of the keyset compiled into these stubs; anything else would produce +# setups that refuse their own payload. +$pubOut = @(& $sdRelease pubkey --seed $Seed) +if ($LASTEXITCODE -ne 0) { Fail 'cannot read the seed file (64 hex digits)' } +$pubText = ($pubOut -join "`n") +if ($pubText -notmatch 'pub ([0-9a-f]{64}) kid ([0-9a-f]{16})') { Fail ("unexpected pubkey output: " + $pubText) } +$betaPub = $Matches[1]; $betaKid = $Matches[2] +Write-Host ("release-beta key id " + $betaKid) +$pubFile = Join-Path $work 'release-beta.pub' +Write-LfText $pubFile ($betaPub + "`n") +if (-not (Select-String -Path $keysetTxt -Pattern ('^key ' + $betaKid + ' beta ') -Quiet)) { + Fail ("the seed's key $betaKid is not a beta release key of installer/keys/keyset.txt: the stubs would refuse the payload") +} + +$sigs = @{} +foreach ($flavour in $tables.Keys) { + $sig = Join-Path $work ("tables\$flavour.sig") + Invoke-Checked ("sign the payload table ($flavour)") $sdRelease (@('sign', '--kind', 'payload', '--seed', $Seed, '--in', $tables[$flavour].Table, '--out', $sig, '--sig-only') + $gatesArgs) + $sigs[$flavour] = $sig +} + +# ---- 7. round 2: the payloads reattached with PAYLOAD_SIG (and the Dokany resources) ------- + +$dokany64 = Join-Path $DokanyDir 'Dokan_x64.msi' +$dokany86 = Join-Path $DokanyDir 'Dokan_x86.msi' +foreach ($f in $flavours | Where-Object { $_.Setup }) { + $tree = Join-Path $BuildRoot ('beta-' + $f.Preset) + $reconfArgs = @('-S', $repo, '-B', $tree, ("-DSD_PAYLOAD_SIG=" + $sigs[$f.Flavour])) + if ($f.Dokany) { + $reconfArgs += @("-DSD_PAYLOAD_DOKANY_X64=$dokany64", "-DSD_PAYLOAD_DOKANY_X86=$dokany86") + } + Invoke-Checked ("reconfigure with PAYLOAD_SIG ($($f.Preset))") $cmake $reconfArgs + Invoke-Checked ("reattach the payload ($($f.Preset))") $cmake @('--build', $tree, '--config', 'Release', '--target', 'shelldrive-setup') +} + +# ---- 8. the release set, SHA256SUMS, the manifest ------------------------------------------ + +foreach ($name in $script:ReleaseFiles) { + $src = Join-Path $unsigned $name + if ($name -like '*.exe') { + $flavour = $name -replace [regex]::Escape("ShellDrive-$Version-setup-"), '' -replace '\.exe$', '' + $src = Join-Path (Join-Path $BuildRoot ('beta-' + $flavour)) 'bin\Release\shelldrive-setup.exe' + } + Copy-Item $src (Join-Path $OutDir $name) -Force +} +$releasePaths = @($script:ReleaseFiles | ForEach-Object { Join-Path $OutDir $_ }) +$sumsPath = Join-Path $OutDir 'SHA256SUMS' +Invoke-Checked 'SHA256SUMS' $sdRelease (@('sums', '--out', $sumsPath) + $releasePaths) +$sumsSig = Join-Path $OutDir 'SHA256SUMS.sig' +Invoke-Checked 'sign SHA256SUMS' $sdRelease (@('sign', '--kind', 'sums', '--seed', $Seed, '--in', $sumsPath, '--out', $sumsSig, '--sig-only') + $gatesArgs) + +# The update manifest of the beta channel (04 §9.2; TM-R36: beta expires within 14 days). +$keysetVersion = '1' +foreach ($line in (Get-Content $keysetTxt)) { + if ($line -match '^version (\d+)$') { $keysetVersion = $Matches[1] } +} +$now = [DateTimeOffset]::UtcNow +$culture = [System.Globalization.CultureInfo]::InvariantCulture +$released = $now.ToString('yyyy-MM-ddTHH:mm:ss', $culture) + 'Z' +$expires = $now.AddDays(13).ToString('yyyy-MM-ddTHH:mm:ss', $culture) + 'Z' +$serial = $now.ToString('yyyyMMdd', $culture) + '01' +$tag = "v$Version-beta" +$specParts = @( + 'product com.librestatic.shelldrive', + 'channel beta', + "keyset $keysetVersion", + "serial $serial", + "version $Version", + "released $released", + "expires $expires", + "notes https://github.com/LibreStatic/ShellDrive/releases/tag/$tag" +) +$shipped = @() +foreach ($flavour in ($setupFlavours + @($msiFiles.Keys))) { + if ($shipped -contains $flavour) { continue } + $hasFile = ($script:ReleaseFiles -contains "ShellDrive-$Version-setup-$flavour.exe") -or + ($script:ReleaseFiles -contains "ShellDrive-$Version-$flavour.msi") + if ($hasFile) { + $shipped += $flavour + $specParts += "min-os $flavour $(Get-MinOs $flavour)" + } +} +foreach ($name in $script:ReleaseFiles) { + if ($name -like '*.exe') { + $id = $name -replace [regex]::Escape("ShellDrive-$Version-setup-"), 'setup-' -replace '\.exe$', '' + } else { + $id = $name -replace [regex]::Escape("ShellDrive-$Version-"), 'msi-' -replace '\.msi$', '' + } + $specParts += "file $id $name $name" +} +$specParts += "base https://github.com/LibreStatic/ShellDrive/releases/download/$tag/" +$specPath = Join-Path $work 'manifest-spec.txt' +Write-LfText $specPath (($specParts -join "`n") + "`n") +$manifestBody = Join-Path $work 'manifest-body.txt' +Invoke-Checked 'manifest' $sdRelease @('manifest', '--spec', $specPath, '--root', $OutDir, '--out', $manifestBody) +$manifestPath = Join-Path $OutDir 'manifest.txt' +Invoke-Checked 'sign manifest' $sdRelease (@('sign', '--kind', 'manifest', '--seed', $Seed, '--in', $manifestBody, '--out', $manifestPath) + $gatesArgs) + +# ---- 9. verify everything ------------------------------------------------------------------ + +foreach ($flavour in $tables.Keys) { + Invoke-Checked ("verify payload ($flavour)") $sdRelease @('verify', '--kind', 'payload', '--pub', $pubFile, '--in', $tables[$flavour].Table, '--sig', $sigs[$flavour]) +} +Invoke-Checked 'verify SHA256SUMS' $sdRelease @('verify', '--kind', 'sums', '--pub', $pubFile, '--in', $sumsPath, '--sig', $sumsSig) +Invoke-Checked 'verify manifest' $sdRelease @('verify', '--kind', 'manifest', '--pub', $pubFile, '--in', $manifestPath) + +# ---- 10. the release notes ------------------------------------------------------------------ + +$notes = New-Object System.Collections.Generic.List[string] +[void]$notes.Add('Pre-release: the cryptography in this version has not yet been reviewed externally (D38, D48). Binaries are not Authenticode-signed; verify SHA256SUMS against SHA256SUMS.sig and the keys in README.md before running anything.') +[void]$notes.Add('') +[void]$notes.Add('## Verifying this release') +[void]$notes.Add('') +[void]$notes.Add('1. Download SHA256SUMS, SHA256SUMS.sig and the files you want.') +[void]$notes.Add('2. Check every downloaded file against SHA256SUMS (the hash is the first field of its line):') +[void]$notes.Add('') +[void]$notes.Add(" certutil -hashfile ShellDrive-$Version-setup-x86-ia32.exe SHA256") +[void]$notes.Add('') +[void]$notes.Add('3. Check the signatures with a build of the release tools and the release-beta public key:') +[void]$notes.Add('') +[void]$notes.Add(' sd-release verify --kind sums --pub --in SHA256SUMS --sig SHA256SUMS.sig') +[void]$notes.Add(' sd-release verify --kind manifest --pub --in manifest.txt') +[void]$notes.Add('') +[void]$notes.Add('manifest.txt carries the same files with their SHA-256, signed by the release-beta key of keyset') +[void]$notes.Add("version $keysetVersion, released $released, expires $expires (TM-R36).") +[void]$notes.Add('') +[void]$notes.Add('## Files') +[void]$notes.Add('') +foreach ($name in $script:ReleaseFiles) { [void]$notes.Add('- ' + $name) } +[void]$notes.Add('- SHA256SUMS, SHA256SUMS.sig') +[void]$notes.Add('- manifest.txt (signed beta update manifest)') +[void]$notes.Add('') +[void]$notes.Add('## Known limitations of this pre-release') +[void]$notes.Add('') +[void]$notes.Add('- The setups are beta-channel builds: the welcome page carries the pre-release notice and the payload') +[void]$notes.Add(' tables are signed with the release-beta key (channel beta).') +foreach ($s in $script:Skipped) { [void]$notes.Add('- Skipped: ' + $s) } +[void]$notes.Add('- The x86-ia32 setup embeds the upstream Dokan_x64.msi and Dokan_x86.msi (DOKANY_X64/DOKANY_X86),') +[void]$notes.Add(' verified against installer/dokany/dokany.lock. The stub does not chain them yet (nothing calls') +[void]$notes.Add(' sd_setup_dokany_run); until that wiring lands, Dokany is used when already installed and otherwise') +[void]$notes.Add(' the install continues with WebDAV only.') +[void]$notes.Add('- The ARM64 setup embeds no Dokany installer: the lock pins none upstream (04 section 7.1).') +Write-LfText (Join-Path $OutDir 'release-notes.md') (($notes -join "`n") + "`n") + +# ---- summary -------------------------------------------------------------------------------- + +Invoke-Summary +exit 0 From d4e22836a7d46402a40a7360b0ffcb4b3d1ed9a5 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 00:56:14 -0300 Subject: [PATCH 11/15] ci: wire the beta channel into the release workflow (D48, 04 9.3) workflow_dispatch gains the channel input, beta by default. package downloads the pinned Dokany files fresh from the lock's url-upstream (no caches, TM-R39) and runs package-beta.ps1 without the seed; sign-beta holds the release-beta seed in the protected beta-signing environment (secret RELEASE_BETA_SEED, written to a temp file and deleted after the run, never printed) and produces the signed release set; publish-beta is the only contents: write job and creates the v-beta pre-release with the generated notes. Stable keeps every gate and its offline sessions: its package and finalize jobs fail closed as before. --- .github/workflows/release.yml | 205 ++++++++++++++++++++++++++++++---- 1 file changed, 182 insertions(+), 23 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6497997..efd5d9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,16 +1,22 @@ -# Release pipeline skeleton (docs/design/04 §12.3, §11.2; D42, TM-R38, TM-R39). +# Release pipeline (docs/design/04 §12.3, §11.2; D42, D48, TM-R38, TM-R39). # -# Status: SKELETON. The jobs that build and test the release presets are complete, and check-gates validates the release -# gates record (installer/keys/release-gates.txt, 04 §12.3) before the signing sessions. The jobs after package stop with -# an error until the parts they need exist in the repository (installer/dokany/dokany.lock, payload packaging; see -# docs/dev/m4-notes.md). Every such step fails closed, so no draft release can come out of this file yet. +# Two channels, chosen with the workflow_dispatch input `channel`: # -# Secrets: none are referenced. Signing never runs in CI. The two offline signing sessions (D42) are maintainer steps on -# the offline machine; the jobs signing-session-1 and signing-session-2 are approval gates (a protected environment with a -# required reviewer, configured in the repository settings) and print the checklist of 04 §12.3. +# beta (default): public pre-release (D48). build -> package (unsigned artifacts + payload tables) -> +# check-gates -> sign-beta -> publish-beta. The offline signing sessions of the stable flow are replaced by the +# job sign-beta: it runs tools/release/package-beta.ps1 with the release-beta seed in the protected `beta-signing` +# environment (secret RELEASE_BETA_SEED, required reviewer = maintainer; 04 §9.3, docs/dev/release-beta.md). +# review-pass1 and authenticode may be waived for beta in installer/keys/release-gates.txt (D48); every other +# gate of §12.3 still applies, and check-gates validates the record before anything is signed or published. # -# Actions: only first-party actions, pinned by commit SHA (TM-R39, same pins as ci.yml). No actions/cache is used. -# Triggers: workflow_dispatch only. The tag trigger (v*) is added when the signed-tag gate exists (TM-R39). +# stable: keeps every gate of §12.3, and signing never runs in CI. The two offline signing sessions (D42) are +# maintainer steps on the offline machine; the jobs signing-session-1, finalize and signing-session-2 are the +# approval gates and checklists. The package and finalize jobs fail closed for stable until the stable packaging +# exists in the repository (docs/dev/m4-notes.md): no stable draft release can come out of this file yet. +# +# Actions: only first-party actions, pinned by commit SHA (TM-R39, same pins as ci.yml). No caches anywhere in +# this file (TM-R39): the pinned Dokany files are downloaded fresh in every job that needs them and verified +# against installer/dokany/dokany.lock; artifacts only move files between the jobs of this one run. name: release @@ -21,6 +27,11 @@ on: description: "Version to release (must match VERSION, for example 0.1.0)" required: true type: string + channel: + description: "Release channel: beta (public pre-release, D48) or stable" + required: false + default: beta + type: string permissions: contents: read @@ -31,21 +42,25 @@ concurrency: jobs: verify-gates: - name: verify version and release gates + name: verify version, channel and release gates runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} timeout-minutes: 15 env: SD_INPUT_VERSION: ${{ inputs.version }} + SD_INPUT_CHANNEL: ${{ inputs.channel }} steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - name: Check the version input against VERSION + - name: Check the inputs shell: pwsh run: | $ErrorActionPreference = 'Stop' + if ($env:SD_INPUT_CHANNEL -ne 'beta' -and $env:SD_INPUT_CHANNEL -ne 'stable') { + throw "channel must be beta or stable, got '$env:SD_INPUT_CHANNEL'." + } $fileVersion = (Get-Content -Raw VERSION).Trim() if ($fileVersion -ne $env:SD_INPUT_VERSION) { throw "VERSION is '$fileVersion' but the input is '$env:SD_INPUT_VERSION'." @@ -92,7 +107,8 @@ jobs: shell: pwsh run: powershell -NoProfile -ExecutionPolicy Bypass -File tools/check-repro.ps1 -Preset ${{ matrix.preset }} - # Unsigned binaries only: the payload tables are signed in the offline session (D42), never here. + # Unsigned binaries only: the payload tables are signed in the offline session (D42, stable) or in the + # protected beta-signing environment (D48, beta), never in the build jobs. - name: Upload binaries uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: @@ -102,24 +118,59 @@ jobs: build/${{ matrix.preset }}/bin/Release/*.pdb if-no-files-found: error + # 04 §12.3 step 10 / D48 for beta: the Dokany files are downloaded fresh here (never from a cache, TM-R39) and + # package-beta.ps1 verifies them against installer/dokany/dokany.lock before embedding any of them. It then + # produces the unsigned beta artifacts and payload-tables.txt, so the release set exists and is checked before + # the protected environment is involved. Stable keeps failing closed here until its packaging exists. package: - name: package (payload CABs, MSIs, setup EXEs) + name: package (payload CABs, MSIs, setup EXEs, tables) needs: build runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} - timeout-minutes: 60 + timeout-minutes: 120 steps: - name: Check out uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Not implemented yet. Needed: installer/dokany/dokany.lock with the verified Dokany files (TM-R23, D32), the payload - # CAB and PAYLOAD_TABLE step (tools/sd-payload), the MSIs (packaging/msi, SD_BUILD_MSI with WiX 3.14 on this runner), - # sd-msilang transforms and the sd-release manifest tool (tools/sd-release does not exist yet). - - name: Stop until packaging exists + - name: Stable packaging is not implemented yet + if: inputs.channel == 'stable' shell: pwsh run: | - throw 'package is not implemented: payload packaging, the Dokany lock and tools/sd-release are not in the repository yet (docs/dev/m4-notes.md).' + throw 'package is not implemented for the stable channel yet: it needs the offline sessions of 04 section 12.3 (docs/dev/m4-notes.md). The stable path stays fail-closed.' + + - name: Download the pinned Dokany files + if: inputs.channel == 'beta' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $url = (Select-String -Path installer/dokany/dokany.lock -Pattern '^url-upstream (.+)$').Matches[0].Groups[1].Value.Trim() + $files = @(Select-String -Path installer/dokany/dokany.lock -Pattern '^file \S+ \S+ (\S+) ').Matches | + ForEach-Object { $_.Groups[1].Value } + $files = @($files | Select-Object -Unique) + if ($files.Count -eq 0) { throw 'no file lines found in installer/dokany/dokany.lock' } + New-Item -ItemType Directory -Force build/dokany | Out-Null + foreach ($f in $files) { + $u = $url + $f + Write-Host "downloading $u" + Invoke-WebRequest -Uri $u -OutFile (Join-Path 'build/dokany' $f) + } + + - name: Package the unsigned beta artifacts + if: inputs.channel == 'beta' + shell: pwsh + run: | + powershell -NoProfile -ExecutionPolicy Bypass -File tools/release/package-beta.ps1 ` + -Version '${{ inputs.version }}' -Commit '${{ github.sha }}' -BuildNumber '${{ github.run_number }}' ` + -DokanyDir build/dokany -OutDir dist/beta + + - name: Upload the unsigned beta artifacts + if: inputs.channel == 'beta' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: beta-release-unsigned + path: dist/beta/* + if-no-files-found: error # 04 §12.3 release gates: sd-release validates installer/keys/release-gates.txt for this version and commit. The # record is maintainer-edited and CODEOWNERS-protected; the check reads it and uses no secrets. Refusal fails the job. @@ -153,9 +204,115 @@ jobs: & $exe.FullName gates check installer/keys/release-gates.txt --version $env:SD_INPUT_VERSION --commit $env:SD_COMMIT if ($LASTEXITCODE -ne 0) { throw "release gates check failed (exit $LASTEXITCODE): see 04 section 12.3." } + # Beta signing (D48, 04 §9.3): the only step that holds the release-beta seed. The protected beta-signing + # environment (required reviewer = maintainer) must be approved; the seed arrives as the RELEASE_BETA_SEED + # secret, is written to a temp file for the packaging script, and is deleted afterwards. It is never printed. + sign-beta: + name: sign the beta release (release-beta key) + needs: [package, check-gates] + if: inputs.channel == 'beta' + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 120 + environment: beta-signing + steps: + - name: Check out + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Download the pinned Dokany files + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $url = (Select-String -Path installer/dokany/dokany.lock -Pattern '^url-upstream (.+)$').Matches[0].Groups[1].Value.Trim() + $files = @(Select-String -Path installer/dokany/dokany.lock -Pattern '^file \S+ \S+ (\S+) ').Matches | + ForEach-Object { $_.Groups[1].Value } + $files = @($files | Select-Object -Unique) + if ($files.Count -eq 0) { throw 'no file lines found in installer/dokany/dokany.lock' } + New-Item -ItemType Directory -Force build/dokany | Out-Null + foreach ($f in $files) { + $u = $url + $f + Write-Host "downloading $u" + Invoke-WebRequest -Uri $u -OutFile (Join-Path 'build/dokany' $f) + } + + - name: Write the release-beta seed to a temp file + shell: pwsh + env: + SD_RELEASE_BETA_SEED: ${{ secrets.RELEASE_BETA_SEED }} + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:SD_RELEASE_BETA_SEED)) { + throw 'RELEASE_BETA_SEED is not configured in the beta-signing environment.' + } + $seedFile = Join-Path ([IO.Path]::GetTempPath()) ("sd-release-beta-" + [guid]::NewGuid().ToString('N') + ".seed") + [IO.File]::WriteAllText($seedFile, $env:SD_RELEASE_BETA_SEED.Trim() + "`n") + Add-Content -Path $env:GITHUB_ENV -Value "SD_BETA_SEED_FILE=$seedFile" + + - name: Package and sign the beta release + shell: pwsh + run: | + powershell -NoProfile -ExecutionPolicy Bypass -File tools/release/package-beta.ps1 ` + -Version '${{ inputs.version }}' -Commit '${{ github.sha }}' -BuildNumber '${{ github.run_number }}' ` + -DokanyDir build/dokany -OutDir dist/beta -Seed $env:SD_BETA_SEED_FILE + + - name: Delete the seed file + if: always() + shell: pwsh + run: | + if ($env:SD_BETA_SEED_FILE -and (Test-Path $env:SD_BETA_SEED_FILE)) { + Remove-Item -Force $env:SD_BETA_SEED_FILE + Write-Host 'the seed file was deleted' + } + + - name: Upload the signed beta release + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: beta-release-signed + path: dist/beta/* + if-no-files-found: error + + # The only job with contents: write (TM-R39): it fetches the signed set of this run and creates the public + # pre-release with all files and the notes from package-beta.ps1. + publish-beta: + name: publish the beta pre-release + needs: sign-beta + if: inputs.channel == 'beta' + runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} + timeout-minutes: 15 + permissions: + contents: write + actions: read + environment: beta-signing + env: + GH_TOKEN: ${{ github.token }} + steps: + - name: Download the signed beta release of this run + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + gh run download ${{ github.run_id }} -n beta-release-signed -D dist/beta + $files = @(Get-ChildItem dist/beta -File) + if ($files.Count -lt 6) { throw "the signed release set is incomplete ($($files.Count) files)." } + foreach ($must in @('SHA256SUMS', 'SHA256SUMS.sig', 'manifest.txt', 'release-notes.md')) { + if (-not (Test-Path (Join-Path 'dist/beta' $must))) { throw "missing $must in the signed release set" } + } + + - name: Create the public pre-release + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $tag = "v${{ inputs.version }}-beta" + $assets = @(Get-ChildItem dist/beta -File | Where-Object { $_.Name -ne 'release-notes.md' } | + ForEach-Object { $_.FullName }) + gh release create $tag --prerelease --title $tag ` + --notes-file dist/beta/release-notes.md --target ${{ github.sha }} @assets + if ($LASTEXITCODE -ne 0) { throw "gh release create failed (exit $LASTEXITCODE)." } + signing-session-1: name: offline signing session 1 of 2 (maintainer, D42) needs: [package, check-gates] + if: inputs.channel == 'stable' runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} timeout-minutes: 30 environment: release-stable-session-1 @@ -171,21 +328,23 @@ jobs: finalize: name: finalize (draft release, manually approved) needs: signing-session-1 + if: inputs.channel == 'stable' runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} timeout-minutes: 60 - # The only job with write access: it creates the draft release and nothing else (TM-R39). + # The only stable job with write access: it creates the draft release and nothing else (TM-R39). permissions: contents: write environment: release-finalize steps: - - name: Stop until packaging exists + - name: Stop until stable packaging exists shell: pwsh run: | - throw 'finalize is not implemented: PAYLOAD_SIG insertion, SHA256SUMS, manifest.txt and the draft release need the package job first.' + throw 'finalize is not implemented for the stable channel yet: PAYLOAD_SIG insertion, SHA256SUMS, manifest.txt and the draft release need the stable package job first (docs/dev/m4-notes.md).' signing-session-2: name: offline signing session 2 of 2 (maintainer, D42) needs: finalize + if: inputs.channel == 'stable' runs-on: ${{ vars.SD_BUILD_RUNNER || 'windows-2022' }} timeout-minutes: 30 environment: release-stable-session-2 From 82934e9fd6d098b2c906984d7ef72bbdcb746df7 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 00:56:14 -0300 Subject: [PATCH 12/15] docs: publishing a beta pre-release; m4-notes rows 20-22 docs/dev/release-beta.md: the one-time GitHub setup (beta-signing environment, RELEASE_BETA_SEED, installer/keys committed, the gates record), the CI job order and the local flow with tools/release/package-beta.ps1, plus what is skipped and why. key-ceremony.md points there from the release-beta seed section. --- docs/dev/key-ceremony.md | 3 +- docs/dev/m4-notes.md | 11 +++++++ docs/dev/release-beta.md | 64 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 docs/dev/release-beta.md diff --git a/docs/dev/key-ceremony.md b/docs/dev/key-ceremony.md index f17b97c..9806df8 100644 --- a/docs/dev/key-ceremony.md +++ b/docs/dev/key-ceremony.md @@ -80,7 +80,8 @@ never survive, and the script refuses to run over existing seeds to enforce that - Store `release-beta.seed` as the secret of the protected GitHub environment `beta-signing` (required reviewer: the maintainer). This is the seed's single transfer off the secret media; delete any working - copy after the import and keep only the media copies. + copy after the import and keep only the media copies. How the release workflow and + `tools/release/package-beta.ps1` use that seed for a beta pre-release: `docs/dev/release-beta.md`. - For beta pre-releases (D48) the release workflow performs the equivalent of the two offline signing sessions (§12.3 steps 5 and 7, D42) with that key behind the environment, and the maintainer approves the run: payload tables first (`sd-release sign --kind payload`), then `SHA256SUMS` + `SHA256SUMS.sig` and diff --git a/docs/dev/m4-notes.md b/docs/dev/m4-notes.md index 3adbef5..dcf77f5 100644 --- a/docs/dev/m4-notes.md +++ b/docs/dev/m4-notes.md @@ -925,3 +925,14 @@ new `notice_shown` field of the install and uninstall opts). Stable builds never only surface: the MSI's strings share the catalog (`msi.*`) but its dialogs are unchanged. Tests: `setup.l10n.prerelease_notice` (the string exists in all six `.po`, msgstr non-empty outside en) and `unit.engine`'s `setup_flow_welcome_notice`. + +## Beta release packaging (D48, 2026-11-01) + +Update of audit rows 20, 21 and 22 of the M4 row audit above, for the beta channel (public GitHub pre-releases, +D48). The flow and its GitHub prerequisites are documented in `docs/dev/release-beta.md`. + +| Row | Status | Evidence and remaining work | +|---|---|---| +| 20 Release pipeline | Partial → beta path works; stable still fail-closed | `tools/release/package-beta.ps1`: fresh `-DSD_SETUP_CHANNEL=beta` trees (notice defaults ON), payload CABs, MSIs, `sd-release table`/`check-table`, `sums`/`manifest`, every signature re-verified with `sd-release verify`; without `-Seed` it stops after the unsigned artifacts and `payload-tables.txt`. `.github/workflows/release.yml` gained the `channel` input: `package` downloads the pinned Dokany files fresh from the lock's `url-upstream` (no caches, TM-R39) and the script verifies them with the new `sd-release dokany verify` (`installer/dokany/dokany.lock`, test `release.dokany`); `check-gates` unchanged; `sign-beta` (environment `beta-signing`, secret `RELEASE_BETA_SEED`, temp file deleted afterwards) and `publish-beta` (`gh release create v-beta --prerelease`, the only `contents: write` job) automate §12.3 step 10. The stable path keeps the offline-session jobs (D42) and `package`/`finalize` throw for stable. Remaining: the stable packaging, the signed-tag gate and the tag trigger | +| 21 Two offline signing sessions (D42) | Partial → beta replaces them with `sign-beta`; stable keeps the manual sessions | For beta there is no offline session: the payload-table, `SHA256SUMS` and manifest signatures are made by the release-beta key inside the protected `beta-signing` environment (04 §9.3), and `sd-release verify` runs over the whole release set before publication. The stable sessions stay manual (offline machine, `sd-release session`/`sign`) | +| 22 `PAYLOAD_SIG` and signed payload tables (TM-R38) | Done for beta; the real keys are the maintainer's step | `package-beta.ps1` signs each table (`sd-release sign --kind payload --gates ... --version ... --commit ...`) and reattaches the payloads round 2 with `-DSD_PAYLOAD_SIG` (cmake/SdPayload.cmake), so the shipped setups carry `PAYLOAD_TABLE` + `PAYLOAD_SIG`; `SD_PAYLOAD_DOKANY_X64/X86` embed the lock-verified upstream Dokan MSIs as `DOKANY_X64`/`DOKANY_X86` (tools/sd-payload). The beta release still needs the maintainer's key ceremony outputs committed (`installer/keys/`) and the `RELEASE_BETA_SEED` secret configured. Open: the stub does not chain the embedded Dokany packages yet (nothing calls `sd_setup_dokany_run`), and the x64/x86-sse2 payload CABs plus the universal EXE assembly are still release-pipeline work — `package-beta.ps1` reports both as skipped instead of faking them | diff --git a/docs/dev/release-beta.md b/docs/dev/release-beta.md new file mode 100644 index 0000000..ac8d7cd --- /dev/null +++ b/docs/dev/release-beta.md @@ -0,0 +1,64 @@ +# Publishing a beta pre-release + +Beta builds are public GitHub pre-releases of this repository before crypto review pass 1 and before +Authenticode signing (decision D48): the gates `review-pass1` and `authenticode` may be waived in the release +gates record, every other gate of `docs/design/04-build-install-update.md` §12.3 still applies. Every beta +release carries `SHA256SUMS` + `SHA256SUMS.sig` (release-beta key), the "not yet externally reviewed" notice on +the setup's welcome page (`SD_SETUP_PRERELEASE_NOTICE`, on by default for the beta channel) and in the release +notes, and the key fingerprints in `README.md`. The tooling is `tools/release/package-beta.ps1`; the stable +channel keeps the two offline sessions (D42) and is not automated yet (`docs/dev/m4-notes.md`). + +## One-time setup in GitHub + +1. **Environment `beta-signing`** (Settings → Environments): required reviewer = the maintainer. It gates the two + jobs that touch the seed or publish (`sign-beta`, `publish-beta`). +2. **Secret `RELEASE_BETA_SEED`** in that environment: the content of `release-beta.seed` (64 hex digits) from + the key ceremony (`docs/dev/key-ceremony.md` §5 — the seed's single transfer off the secret media). Keep only + the media copies afterwards. +3. **Commit `installer/keys/`** produced by the ceremony: `roots.txt`, `keyset.txt` (they compile into every + binary; without them nothing verifies). Paste `fingerprints.md` into `README.md` as the "Release keys" section. +4. **Commit `installer/keys/release-gates.txt`** for the version in the release PR: channel `beta`, the version, + the commit, every gate of the beta row of the §12.3 table (`ci-local` and `lab` pass; `review-pass1` and + `authenticode` `waived` with a note), `approved` last. `sd-release gates check` refuses anything else. + +## CI flow + +Run the `release` workflow (`workflow_dispatch`), inputs `version` (must match `VERSION`) and `channel: beta` +(the default). The jobs run in this order; nothing is published unless all of them pass: + +| Job | What it does | +|---|---| +| `verify-gates` | the input channel is `beta` or `stable`; `VERSION` matches the input | +| `build` | the six-preset matrix of §11.2 (tests, repro check), unsigned binaries | +| `package` | downloads each pinned Dokany file fresh from the lock's `url-upstream` (never a cache, TM-R39), then `package-beta.ps1` **without** `-Seed`: it verifies the files against `installer/dokany/dokany.lock` (`sd-release dokany verify`, TM-R23), builds fresh `-DSD_SETUP_CHANNEL=beta` trees, and produces the unsigned setup EXEs, the MSIs and `payload-tables.txt` | +| `check-gates` | `sd-release gates check` on the committed record (no secrets) | +| `sign-beta` | in `beta-signing`: writes `RELEASE_BETA_SEED` to a temp file, runs `package-beta.ps1 -Seed` (signs the payload tables, reattaches them as `PAYLOAD_SIG` with the `DOKANY_X64/X86` resources, builds `SHA256SUMS` + `SHA256SUMS.sig` and the signed beta manifest, verifies everything with `sd-release verify`), deletes the seed file | +| `publish-beta` | in `beta-signing`, the only job with `contents: write`: `gh release create v-beta --prerelease --notes-file release-notes.md` with all files | + +## Local flow (the maintainer, same tool) + +```text +powershell -NoProfile -ExecutionPolicy Bypass -File tools\release\package-beta.ps1 ^ + -Version 0.1.0 -Commit <40-hex commit> -DokanyDir ^ + -Seed [-Gates installer\keys\release-gates.txt] [-OutDir dist\beta-0.1.0] +``` + +Without `-Seed` the script stops after the unsigned artifacts and `payload-tables.txt` (the setups refuse to +run: no `PAYLOAD_SIG`, TM-R38), so the signing can happen on the offline machine: + +```text +sd-release sign --kind payload --seed release-beta.seed --in
--out --sig-only ^ + --gates release-gates.txt --version 0.1.0 --commit +``` + +Re-running with `-Seed` attaches those signatures and finishes the release. The script never downloads: point +`-DokanyDir` at the lock-verified upstream files. What a beta release ships today, and what is skipped with a +clear message (nothing is faked): + +| Artifact | State | +|---|---| +| Setup EXEs | `x86-ia32` (carries `DOKANY_X64` + `DOKANY_X86`), `arm64`, `arm32` — the payload targets of those presets | +| Setup EXEs x64 / x86-sse2 | skipped: no payload target in those presets; the universal EXE assembly is release-pipeline work not in the repository yet | +| Per-machine MSIs | all six flavours, with WiX 3.14; without WiX every MSI is skipped | +| Dokany in the stub | embedded but not chained yet: nothing calls `sd_setup_dokany_run`, so a beta install uses Dokany when already installed and otherwise continues with WebDAV only | +| ARM64 Dokany | none: the lock pins no ARM64 installer upstream (§7.1); no ARM32 one exists | From cb711d85ae8b3824f39a5d1e5671b7f4fbdf0e83 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 01:14:09 -0300 Subject: [PATCH 13/15] release: verify the embedded setup EXE resources in package-beta and fix the MSI copy source Extract PAYLOAD_TABLE, PAYLOAD_SIG and the Dokany resources from each final setup EXE, require them to be the signed/verified bytes, and verify the embedded signature. Collect the MSIs from the build tree, and fix the skipped-flavour message. --- tools/release/package-beta.ps1 | 90 +++++++++++++++++++++++++++++++--- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/tools/release/package-beta.ps1 b/tools/release/package-beta.ps1 index 9f7575b..cab8d15 100644 --- a/tools/release/package-beta.ps1 +++ b/tools/release/package-beta.ps1 @@ -8,7 +8,7 @@ defaults ON, 04 section 10), builds the setup EXEs and per-machine MSIs of the flavours this repository can produce, verifies the upstream Dokany files against installer/dokany/dokany.lock (sd-release dokany verify, TM-R23; it never downloads anything), produces the payload tables and, when -Seed is given, signs them and - reattaches the payloads with PAYLOAD_SIG (SD_PAYLOAD_SIG, TM-R38) plus the DOKANY_X64/X86 resources (04 §7.3). + reattaches the payloads with PAYLOAD_SIG (SD_PAYLOAD_SIG, TM-R38) plus the DOKANY_X64/X86 resources (04 section 7.3). Then SHA256SUMS + SHA256SUMS.sig and the signed beta manifest (TM-R36: at most 14 days), and every signature is verified again with sd-release verify. Without -Seed it stops after the UNSIGNED artifacts and payload-tables.txt, so the signing can happen elsewhere (an offline session, a CI signing job). @@ -19,7 +19,7 @@ the repository yet (docs/dev/m4-notes.md, M4 row audit). - the MSIs need WiX 3.14; without it every per-machine MSI is skipped. - Dokany rides only in the x86-ia32 setup EXE (DOKANY_X64 + DOKANY_X86): the lock pins no ARM64 installer and - there is no ARM32 one upstream (04 §7.1). + there is no ARM32 one upstream (04 section 7.1). Exit code: 0 ok, 1 a check or build step failed. @@ -44,7 +44,7 @@ artifacts and payload-tables.txt. .PARAMETER Gates - The release gates record (04 §12.3; default installer\keys\release-gates.txt). Required with -Seed: every + The release gates record (04 section 12.3; default installer\keys\release-gates.txt). Required with -Seed: every sd-release sign refuses to sign unless the record validates for this version and commit. .PARAMETER BuildNumber @@ -131,7 +131,7 @@ function Write-LfText([string]$Path, [string]$Text) { } # Configures the fresh beta tree of a preset (a tree configured for another channel is removed first, so the -# SD_SETUP_PRERELEASE_NOTICE default is evaluated for beta, 04 §10). +# SD_SETUP_PRERELEASE_NOTICE default is evaluated for beta, 04 section 10). function New-BetaTree([string]$Tree, [string]$PresetName) { if ($script:Configured.ContainsKey($Tree)) { return } $cache = Join-Path $Tree 'CMakeCache.txt' @@ -153,7 +153,7 @@ function New-BetaTree([string]$Tree, [string]$PresetName) { $script:Configured[$Tree] = $true } -# The updater's per-flavour minimum OS (04 §9.2; ARM32 is Windows 10+ best effort, D5). +# The updater's per-flavour minimum OS (04 section 9.2; ARM32 is Windows 10+ best effort, D5). function Get-MinOs([string]$Flavour) { switch ($Flavour) { 'x86-ia32' { '5.1' } @@ -166,6 +166,45 @@ function Get-MinOs([string]$Flavour) { } } +# Reads one RT_RCDATA resource of a PE file (as a data file, nothing is executed); $null when it is not there. +function Get-ExeResource([string]$Path, [string]$Name) { + if (-not ('SdBetaRes' -as [type])) { + Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public static class SdBetaRes { + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr LoadLibraryExW(string f, IntPtr h, uint flags); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr FindResourceW(IntPtr m, string name, IntPtr type); + [DllImport("kernel32.dll")] static extern uint SizeofResource(IntPtr m, IntPtr r); + [DllImport("kernel32.dll")] static extern IntPtr LoadResource(IntPtr m, IntPtr r); + [DllImport("kernel32.dll")] static extern IntPtr LockResource(IntPtr r); + [DllImport("kernel32.dll")] static extern bool FreeLibrary(IntPtr m); + public static byte[] Get(string path, string name) { + IntPtr m = LoadLibraryExW(path, IntPtr.Zero, 2); + if (m == IntPtr.Zero) throw new Exception("cannot load " + path); + try { + IntPtr r = FindResourceW(m, name, (IntPtr)10); + if (r == IntPtr.Zero) return null; + uint n = SizeofResource(m, r); + byte[] b = new byte[n]; + Marshal.Copy(LockResource(LoadResource(m, r)), b, 0, (int)n); + return b; + } finally { FreeLibrary(m); } + } +} +"@ + } + return [SdBetaRes]::Get($Path, $Name) +} + +function Test-BytesEqual([byte[]]$A, [byte[]]$B) { + if ($null -eq $A -or $null -eq $B -or $A.Length -ne $B.Length) { return $false } + for ($i = 0; $i -lt $A.Length; $i++) { if ($A[$i] -ne $B[$i]) { return $false } } + return $true +} + function Invoke-Summary { Write-Host '' Write-Host 'Beta packaging finished:' @@ -225,7 +264,7 @@ if (Test-Path $OutDir) { Remove-Item (Join-Path $OutDir '*') -Recurse -Force -Co New-Item -ItemType Directory -Force $OutDir | Out-Null # The flavours: setup EXEs only where the repository builds a setup stub and payload; MSIs for every flavour the -# packaging supports (the x64 pair and the x86 pair share one UpgradeCode, 04 §8.2). +# packaging supports (the x64 pair and the x86 pair share one UpgradeCode, 04 section 8.2). $flavours = @( @{ Preset = 'x64-modern'; Flavour = 'x64-modern'; Setup = $false; Msi = $true }, @{ Preset = 'x64-xp'; Flavour = 'x64-xp'; Setup = $false; Msi = $true }, @@ -236,7 +275,7 @@ $flavours = @( ) $setupFlavours = @('x86-ia32', 'arm64', 'arm32') # presets whose apps/shelldrive-setup builds a stub + payload foreach ($f in $flavours | Where-Object { -not $_.Setup }) { - Skip ("the setup EXE of $($_.Flavour): no payload target for that preset; the universal EXE assembly is release-pipeline work not in the repository yet (m4-notes, M4 row audit)") + Skip ("the setup EXE of $($f.Flavour): no payload target for that preset; the universal EXE assembly is release-pipeline work not in the repository yet (m4-notes, M4 row audit)") } $script:ReleaseFiles = New-Object System.Collections.ArrayList @@ -287,6 +326,7 @@ foreach ($f in $flavours | Where-Object { $_.Msi -and $script:HaveWix }) { $unsigned = Join-Path $work 'unsigned' New-Item -ItemType Directory -Force $unsigned | Out-Null $msiFiles = @{} +$msiSources = @{} foreach ($f in $flavours) { $tree = Join-Path $BuildRoot ('beta-' + $f.Preset) if ($f.Setup) { @@ -298,6 +338,7 @@ foreach ($f in $flavours) { $msis = @(Get-ChildItem -Path (Join-Path $tree 'msi\Release') -Filter '*.msi' -File -ErrorAction SilentlyContinue) if ($msis.Count -eq 0) { Fail ("no MSI built in " + (Join-Path $tree 'msi\Release')) } $msiFiles[$f.Flavour] = "ShellDrive-$Version-$($f.Flavour).msi" + $msiSources[$f.Flavour] = $msis[0].FullName [void]$script:ReleaseFiles.Add($msiFiles[$f.Flavour]) } } @@ -306,6 +347,8 @@ foreach ($name in $script:ReleaseFiles) { if ($name -like '*.exe') { $flavour = $name -replace [regex]::Escape("ShellDrive-$Version-setup-"), '' -replace '\.exe$', '' $src = Join-Path (Join-Path $BuildRoot ('beta-' + $flavour)) 'bin\Release\shelldrive-setup.exe' + } elseif ($name -like '*.msi') { + $src = $msiSources[($name -replace [regex]::Escape("ShellDrive-$Version-"), '' -replace '\.msi$', '')] } Copy-Item $src (Join-Path $unsigned $name) -Force } @@ -394,7 +437,7 @@ Invoke-Checked 'SHA256SUMS' $sdRelease (@('sums', '--out', $sumsPath) + $release $sumsSig = Join-Path $OutDir 'SHA256SUMS.sig' Invoke-Checked 'sign SHA256SUMS' $sdRelease (@('sign', '--kind', 'sums', '--seed', $Seed, '--in', $sumsPath, '--out', $sumsSig, '--sig-only') + $gatesArgs) -# The update manifest of the beta channel (04 §9.2; TM-R36: beta expires within 14 days). +# The update manifest of the beta channel (04 section 9.2; TM-R36: beta expires within 14 days). $keysetVersion = '1' foreach ($line in (Get-Content $keysetTxt)) { if ($line -match '^version (\d+)$') { $keysetVersion = $Matches[1] } @@ -449,6 +492,37 @@ foreach ($flavour in $tables.Keys) { Invoke-Checked 'verify SHA256SUMS' $sdRelease @('verify', '--kind', 'sums', '--pub', $pubFile, '--in', $sumsPath, '--sig', $sumsSig) Invoke-Checked 'verify manifest' $sdRelease @('verify', '--kind', 'manifest', '--pub', $pubFile, '--in', $manifestPath) +# The final setup EXEs themselves: their embedded PAYLOAD_TABLE and PAYLOAD_SIG must be exactly the bytes that were +# signed (PAYLOAD_SIG covers the exact PAYLOAD_TABLE bytes, TM-R38), verify against the release-beta key that is in +# the compiled-in keyring (checked against keyset.txt above), and the embedded Dokany files must be the lock's. +foreach ($flavour in $tables.Keys) { + $exe = Join-Path $OutDir ("ShellDrive-$Version-setup-$flavour.exe") + $resTable = Get-ExeResource $exe 'PAYLOAD_TABLE' + $resSig = Get-ExeResource $exe 'PAYLOAD_SIG' + if (-not (Test-BytesEqual $resTable ([IO.File]::ReadAllBytes($tables[$flavour].Table)))) { + Fail ("PAYLOAD_TABLE inside $exe is not the signed table of $flavour") + } + if (-not (Test-BytesEqual $resSig ([IO.File]::ReadAllBytes($sigs[$flavour])))) { + Fail ("PAYLOAD_SIG inside $exe is not the signature made over the table of $flavour") + } + $xTable = Join-Path $work "embedded-$flavour.table" + $xSig = Join-Path $work "embedded-$flavour.sig" + [IO.File]::WriteAllBytes($xTable, $resTable) + [IO.File]::WriteAllBytes($xSig, $resSig) + Invoke-Checked ("verify the PAYLOAD_SIG embedded in the setup EXE ($flavour)") $sdRelease @('verify', '--kind', 'payload', '--pub', $pubFile, '--in', $xTable, '--sig', $xSig) + $hasD64 = $null -ne (Get-ExeResource $exe 'DOKANY_X64') + $hasD86 = $null -ne (Get-ExeResource $exe 'DOKANY_X86') + $wantDokany = ($flavour -eq 'x86-ia32') + if ($wantDokany) { + if (-not (Test-BytesEqual (Get-ExeResource $exe 'DOKANY_X64') ([IO.File]::ReadAllBytes($dokany64))) -or + -not (Test-BytesEqual (Get-ExeResource $exe 'DOKANY_X86') ([IO.File]::ReadAllBytes($dokany86)))) { + Fail ("the Dokany resources inside $exe are not the verified lock files") + } + } elseif ($hasD64 -or $hasD86) { + Fail ("$exe embeds a Dokany resource it must not carry") + } +} + # ---- 10. the release notes ------------------------------------------------------------------ $notes = New-Object System.Collections.Generic.List[string] From 941b70c64218dc32ef958990abe602d3d4dc9eb6 Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 01:14:09 -0300 Subject: [PATCH 14/15] ci: give gh a repository and drop the signing environment from publish-beta The publish job has no checkout, so gh needs GH_REPO. It no longer binds beta-signing, which keeps RELEASE_BETA_SEED out of the job that holds contents: write. --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efd5d9c..2b62768 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,9 +283,10 @@ jobs: permissions: contents: write actions: read - environment: beta-signing + # No environment here: the seed secret of beta-signing is not exposed to this job (it was approved at sign-beta). env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} steps: - name: Download the signed beta release of this run shell: pwsh From 1fc4df9ddda594512568eaa3268673772d16872f Mon Sep 17 00:00:00 2001 From: FacuM Date: Sun, 1 Nov 2026 01:31:10 -0300 Subject: [PATCH 15/15] release: compare dokany.lock keys through a bounded helper (C6385 under /analyze) --- tools/sd-release/sd-release.c | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tools/sd-release/sd-release.c b/tools/sd-release/sd-release.c index 67cbdaa..869e8e1 100644 --- a/tools/sd-release/sd-release.c +++ b/tools/sd-release/sd-release.c @@ -1170,6 +1170,13 @@ static int dk_file_line(dk_lock *l, const char *rest, size_t n, unsigned lineno) return 0; } +/* 1 when the first kl characters of line (NUL-terminated) are exactly key. */ +static int dk_key_is(const char *line, size_t kl, const char *key) +{ + size_t n = strlen(key); + return kl == n && strncmp(line, key, n) == 0; +} + /* Parses a whole lock (the caller has checked the bytes). 0 on success; -1 with a message. */ static int dk_parse(const char *s, size_t n, dk_lock *l) { @@ -1199,7 +1206,7 @@ static int dk_parse(const char *s, size_t n, dk_lock *l) kl = p != NULL ? (size_t)(p - line) : len; vl = p != NULL ? len - kl - 1 : 0; if (!l->have_magic) { - if (kl != 11 || memcmp(line, "dokany-lock", 11) != 0) + if (!dk_key_is(line, kl, "dokany-lock")) return dk_bad(lineno, "the first line must be: dokany-lock 1"); if (vl != 1 || p[1] != '1') return dk_bad(lineno, "the first line must be: dokany-lock 1"); @@ -1208,42 +1215,42 @@ static int dk_parse(const char *s, size_t n, dk_lock *l) } if (p == NULL) return dk_bad(lineno, "a key line is: key value"); - if (kl == 7 && memcmp(line, "version", 7) == 0) { + if (dk_key_is(line, kl, "version")) { if (l->have_version++) return dk_bad(lineno, "duplicate key"); if (dk_value(p + 1, vl, l->version, sizeof(l->version)) != 0 || !dk_valid_version(p + 1, vl)) return dk_bad(lineno, "the version is 1 to 4 dot-separated numbers"); - } else if (kl == 7 && memcmp(line, "min-api", 7) == 0) { + } else if (dk_key_is(line, kl, "min-api")) { if (l->have_min_api++) return dk_bad(lineno, "duplicate key"); if (dk_value(p + 1, vl, l->min_api, sizeof(l->min_api)) != 0 || !dk_valid_version(p + 1, vl)) return dk_bad(lineno, "min-api is 1 to 4 dot-separated numbers"); - } else if (kl == 9 && memcmp(line, "signer-cn", 9) == 0) { + } else if (dk_key_is(line, kl, "signer-cn")) { if (l->have_signer_cn++) return dk_bad(lineno, "duplicate key"); if (dk_value(p + 1, vl, l->signer_cn, sizeof(l->signer_cn)) != 0) return dk_bad(lineno, "signer-cn is too long"); - } else if (kl == 16 && memcmp(line, "signer-issuer-cn", 16) == 0) { + } else if (dk_key_is(line, kl, "signer-issuer-cn")) { if (l->have_signer_issuer++) return dk_bad(lineno, "duplicate key"); if (dk_value(p + 1, vl, l->signer_issuer, sizeof(l->signer_issuer)) != 0) return dk_bad(lineno, "signer-issuer-cn is too long"); - } else if (kl == 12 && memcmp(line, "url-upstream", 12) == 0) { + } else if (dk_key_is(line, kl, "url-upstream")) { if (l->have_url_upstream++) return dk_bad(lineno, "duplicate key"); if (dk_value(p + 1, vl, l->url_upstream, sizeof(l->url_upstream)) != 0) return dk_bad(lineno, "url-upstream is too long"); - } else if (kl == 10 && memcmp(line, "url-mirror", 10) == 0) { + } else if (dk_key_is(line, kl, "url-mirror")) { if (l->have_url_mirror++) return dk_bad(lineno, "duplicate key"); if (dk_value(p + 1, vl, l->url_mirror, sizeof(l->url_mirror)) != 0) return dk_bad(lineno, "url-mirror is too long"); - } else if (kl == 7 && memcmp(line, "checked", 7) == 0) { + } else if (dk_key_is(line, kl, "checked")) { if (l->have_checked++) return dk_bad(lineno, "duplicate key"); if (dk_value(p + 1, vl, l->checked, sizeof(l->checked)) != 0 || !dk_valid_date(p + 1, vl)) return dk_bad(lineno, "checked is a YYYY-MM-DD date"); - } else if (kl == 4 && memcmp(line, "file", 4) == 0) { + } else if (dk_key_is(line, kl, "file")) { if (dk_file_line(l, p + 1, vl, lineno) != 0) return -1; } else {