diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6497997..2b62768 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,116 @@ 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 + # 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 + 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 +329,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 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 a123a14..92e6391 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) @@ -18,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 @@ -47,6 +71,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_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/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/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/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/docs/design/04-build-install-update.md b/docs/design/04-build-install-update.md index e6d4c49..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): ``` @@ -1568,7 +1573,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 @@ -1584,7 +1592,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) @@ -1845,6 +1855,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 +1891,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/dev/key-ceremony.md b/docs/dev/key-ceremony.md new file mode 100644 index 0000000..9806df8 --- /dev/null +++ b/docs/dev/key-ceremony.md @@ -0,0 +1,111 @@ +# 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. 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 + 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/docs/dev/m4-notes.md b/docs/dev/m4-notes.md index 05ead55..dcf77f5 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 | @@ -901,3 +910,29 @@ 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`. + +## 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 | 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 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 - - 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/release/CMakeLists.txt b/tests/release/CMakeLists.txt index 803c4da..1f3b4a4 100644 --- a/tests/release/CMakeLists.txt +++ b/tests/release/CMakeLists.txt @@ -12,3 +12,19 @@ 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) + +# 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/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/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/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 */ +} 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 diff --git a/tools/release/package-beta.ps1 b/tools/release/package-beta.ps1 new file mode 100644 index 0000000..cab8d15 --- /dev/null +++ b/tools/release/package-beta.ps1 @@ -0,0 +1,567 @@ +<# +.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 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). + + 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 section 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 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 + 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 section 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 section 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) } + } +} + +# 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:' + 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 section 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 $($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 +$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 = @{} +$msiSources = @{} +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" + $msiSources[$f.Flavour] = $msis[0].FullName + [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' + } elseif ($name -like '*.msi') { + $src = $msiSources[($name -replace [regex]::Escape("ShellDrive-$Version-"), '' -replace '\.msi$', '')] + } + 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 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] } +} +$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) + +# 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] +[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 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; } 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 4e98631..869e8e1 100644 --- a/tools/sd-release/sd-release.c +++ b/tools/sd-release/sd-release.c @@ -16,11 +16,22 @@ * 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, * 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. @@ -630,7 +641,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 +651,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} @@ -936,6 +947,401 @@ 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; +} + +/* 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) +{ + 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 (!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"); + l->have_magic = 1; + continue; + } + if (p == NULL) + return dk_bad(lineno, "a key line is: key value"); + 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 (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 (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 (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 (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 (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 (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 (dk_key_is(line, kl, "file")) { + 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"); @@ -1147,6 +1553,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 +1837,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|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"); } @@ -1244,10 +1864,14 @@ 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) return cmd_gates(argc, argv); + if (strcmp(argv[1], "dokany") == 0) + return cmd_dokany(argc, argv); usage(); return RC_USAGE; }