diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fc513c2..3f94c42 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,21 @@ # publishes everything together. Nothing is published if any step fails. # # The release text is the backup part written by release-assets.ps1, -# followed by .github/release-notes/.md (what's new). +# followed by .github/release-notes/.md (what's new) and a line +# that links the code signing policy. +# +# Dry run: tick "dry run" when starting it by hand (any branch). Everything +# is collected, tested, built and checked, CryptBridge.exe stays on the run +# page as a workflow artifact, nothing is signed, tagged or published. Pull +# requests that change this workflow, the build scripts or .signpath/ get a +# dry run by themselves. +# +# Code signing (SignPath Foundation) is prepared but off until the +# repository has the variable SIGNPATH_ORGANIZATION_ID and the secret +# SIGNPATH_API_TOKEN; see .signpath/README.md. Without them CryptBridge.exe +# is released unsigned, exactly as before. Only CryptBridge.exe is ever +# signed: the rclone and WinFsp copies stay exactly as their projects +# published them. name: Release @@ -19,17 +33,34 @@ on: push: tags: ['v*.*.*'] workflow_dispatch: + inputs: + dry_run: + description: 'Dry run: build and check only, sign and publish nothing' + type: boolean + default: false + pull_request: + paths: + - '.github/workflows/release.yml' + - '.signpath/**' + - 'windows/build.ps1' + - 'windows/release-assets.ps1' permissions: contents: write + actions: read + +env: + DRY_RUN: ${{ github.event_name == 'pull_request' || inputs.dry_run == true }} concurrency: - group: release + # Dry runs never wait for (or hold up) a real release. + group: ${{ (github.event_name == 'pull_request' || inputs.dry_run == true) && format('release-dry-run-{0}', github.ref) || 'release' }} jobs: release: runs-on: windows-latest - timeout-minutes: 60 + # Includes up to one hour of waiting for the signing approval. + timeout-minutes: 120 defaults: run: shell: pwsh @@ -42,16 +73,22 @@ jobs: run: | $version = (Get-Content VERSION -Raw).Trim() $tag = "v$version" + $dryRun = $env:DRY_RUN -eq 'true' if ($env:GITHUB_REF_TYPE -eq 'tag') { if ($tag -ne $env:GITHUB_REF_NAME) { throw "tag $env:GITHUB_REF_NAME does not match VERSION $version" } - } elseif ($env:GITHUB_REF_NAME -ne 'main') { + } elseif ($env:GITHUB_REF_NAME -ne 'main' -and -not $dryRun) { throw "a release is made from main only, not from $env:GITHUB_REF_NAME" } - gh release view $tag 2>$null | Out-Null - if ($LASTEXITCODE -eq 0) { throw "release $tag exists already: raise VERSION first" } + if (-not $dryRun) { + gh release view $tag 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { throw "release $tag exists already: raise VERSION first" } + } $global:LASTEXITCODE = 0 $notes = ".github/release-notes/$version.md" - if (-not (Test-Path $notes)) { throw "$notes is missing" } + if (-not (Test-Path $notes)) { + if (-not $dryRun) { throw "$notes is missing" } + Write-Host "::warning::$notes is missing (fine for a dry run)" + } "VERSION=$version" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 "TAG=$tag" | Out-File $env:GITHUB_ENV -Append -Encoding utf8 @@ -74,14 +111,94 @@ jobs: - name: Test and build CryptBridge.exe run: ./windows/build.ps1 + # SignPath only signs an exe whose file properties name CryptBridge + # and the version of the release (build.ps1 writes both from VERSION). + - name: Check the file properties + run: ./.signpath/check-files.ps1 -ProductName CryptBridge -ProductVersion $env:VERSION -Path windows/build/bin/CryptBridge.exe + + # --- code signing ------------------------------------------------------ + - name: 'code signing: on or off?' + id: signing + env: + ORGANIZATION_ID: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + HAS_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN != '' }} + run: | + $hasOrganization = -not [string]::IsNullOrWhiteSpace($env:ORGANIZATION_ID) + $hasToken = $env:HAS_TOKEN -eq 'true' + if ($env:DRY_RUN -eq 'true') { + $on = $false + Write-Host 'Dry run: nothing is signed.' + } elseif ($hasOrganization -ne $hasToken) { + throw 'Code signing is half set up: SIGNPATH_ORGANIZATION_ID and SIGNPATH_API_TOKEN must both be set, or neither (see .signpath/README.md).' + } else { + $on = $hasOrganization -and $hasToken + Write-Host ($(if ($on) { 'Code signing is on.' } else { 'Code signing is off: this release is unsigned.' })) + } + "enabled=$($on.ToString().ToLower())" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: 'code signing: collect the exe' + if: steps.signing.outputs.enabled == 'true' + run: | + New-Item -ItemType Directory sign/unsigned -Force | Out-Null + Copy-Item windows/build/bin/CryptBridge.exe sign/unsigned/ + + - name: 'code signing: upload the exe' + id: unsigned + if: steps.signing.outputs.enabled == 'true' + uses: actions/upload-artifact@v7 + with: + name: unsigned + path: sign/unsigned + if-no-files-found: error + + - name: 'code signing: sign (approve the request in SignPath)' + if: steps.signing.outputs.enabled == 'true' + uses: signpath/github-action-submit-signing-request@v3 + with: + api-token: ${{ secrets.SIGNPATH_API_TOKEN }} + organization-id: ${{ vars.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ vars.SIGNPATH_PROJECT_SLUG || 'CryptBridge' }} + signing-policy-slug: ${{ vars.SIGNPATH_SIGNING_POLICY_SLUG || 'release-signing' }} + artifact-configuration-slug: ${{ vars.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} + github-artifact-id: ${{ steps.unsigned.outputs.artifact-id }} + wait-for-completion: true + wait-for-completion-timeout-in-seconds: 3600 + output-artifact-directory: sign/signed + parameters: | + version: "${{ env.VERSION }}" + + - name: 'code signing: take over the signed exe' + if: steps.signing.outputs.enabled == 'true' + run: | + Copy-Item sign/signed/CryptBridge.exe windows/build/bin/CryptBridge.exe -Force + ./.signpath/check-files.ps1 -ProductName CryptBridge -ProductVersion $env:VERSION -Path windows/build/bin/CryptBridge.exe -Signed + + - name: 'dry run: keep the exe on the run page' + if: env.DRY_RUN == 'true' + uses: actions/upload-artifact@v7 + with: + name: release-dry-run + path: windows/build/bin/CryptBridge.exe + retention-days: 7 + - name: Publish the release + if: env.DRY_RUN != 'true' env: GH_TOKEN: ${{ github.token }} + SIGNED: ${{ steps.signing.outputs.enabled }} run: | $release = 'windows/build/bin/release' $notes = Join-Path $env:RUNNER_TEMP 'release-notes.md' + # The release page is the download page, so it links the code + # signing policy and says whether CryptBridge.exe is signed. + $policy = "https://github.com/$env:GITHUB_REPOSITORY/blob/main/CODE_SIGNING.md" + if ($env:SIGNED -eq 'true') { + $footer = "CryptBridge.exe is signed. Free code signing provided by [SignPath.io](https://about.signpath.io), certificate by [SignPath Foundation](https://signpath.org). The rclone and WinFsp copies are unchanged originals. [Code signing policy]($policy)" + } else { + $footer = "CryptBridge.exe is not code signed, so Windows SmartScreen may warn. [Code signing policy]($policy)" + } $text = [IO.File]::ReadAllText("$release/release-notes.md") + "`n" + - [IO.File]::ReadAllText(".github/release-notes/$env:VERSION.md") + [IO.File]::ReadAllText(".github/release-notes/$env:VERSION.md").TrimEnd() + "`n`n---`n`n$footer`n" [IO.File]::WriteAllText($notes, $text, [Text.UTF8Encoding]::new($false)) $files = @('windows/build/bin/CryptBridge.exe', 'linux/setup.sh') diff --git a/.signpath/README.md b/.signpath/README.md new file mode 100644 index 0000000..6bc608a --- /dev/null +++ b/.signpath/README.md @@ -0,0 +1,65 @@ +# Code signing with SignPath: prepared, off until switched on + +CryptBridge has applied to the [SignPath Foundation](https://signpath.org) for free +code signing of its Windows files. Everything for it is prepared here and in +[`release.yml`](../.github/workflows/release.yml), but **it stays off** until it is +switched on below. Until then every release is built and published exactly as before, +unsigned, and its release text says so. + +| File | What it is | +| ---- | ---------- | +| [`../CODE_SIGNING.md`](../CODE_SIGNING.md) | The public code signing policy (required by SignPath Foundation): roles, what is signed, privacy | +| [`artifact-configuration.xml`](artifact-configuration.xml) | The SignPath artifact configuration (the project's default): signs CryptBridge.exe only, never the rclone or WinFsp copies | +| [`check-files.ps1`](check-files.ps1) | Run by every release build: checks product name and version of every file to be signed (SignPath enforces both), and with `-Signed` that the signatures are valid | + +## How the release workflow decides + +| Repository settings | What a release does | +| ------------------- | ------------------- | +| Neither `SIGNPATH_ORGANIZATION_ID` nor `SIGNPATH_API_TOKEN` set | Builds and publishes unsigned, as before | +| Both set | Uploads the files, sends a signing request to SignPath, **waits up to one hour for the approval**, downloads the signed files, checks their signatures and publishes them | +| Only one of them set | Stops with an error, so a half finished setup never ships unsigned files by surprise | + +**Dry run:** *Actions > Release > Run workflow* with "dry run" ticked builds and checks +everything, keeps the files on the run page as a workflow artifact, never signs and +publishes nothing. It also runs by itself for every pull request that changes the +release workflow or this folder. + +## Switching it on (after SignPath Foundation said yes) + +1. **Two-factor authentication** on GitHub and on SignPath for everybody in the team + (a condition of SignPath Foundation). +2. **In SignPath** (the Foundation sets up most of it with you): + - the project, slug `CryptBridge` (another slug: set the variable `SIGNPATH_PROJECT_SLUG`), + - the trusted build system *GitHub.com* linked to the project, and the SignPath + GitHub App installed on this repository (SignPath shows the link), + - the artifact configuration from [`artifact-configuration.xml`](artifact-configuration.xml), set as the project's default, + - the signing policy `release-signing` with manual approval by the approver + (another slug: set `SIGNPATH_SIGNING_POLICY_SLUG`), + - a CI user with the *Submitter* role on that policy, and an API token for it. +3. **On GitHub:** *Settings > Secrets and variables > Actions* + - secret `SIGNPATH_API_TOKEN`: the API token of the CI user, + - variable `SIGNPATH_ORGANIZATION_ID`: the organization ID from SignPath. +4. **Recommended:** a ruleset that protects `main` and the `v*` tags from force pushes + and deletion (*Settings > Rules > Rulesets*). SignPath can require this in its + policies. +5. **Update the texts:** the status line at the top of + [`CODE_SIGNING.md`](../CODE_SIGNING.md) and the SmartScreen note in the README. +6. **Release as usual.** Approve the signing request in SignPath within one hour (the + link is in the run log and in the e-mail from SignPath), then the release is published + with signed files. + +| Variable / secret | Required | Default | +| ----------------- | -------- | ------- | +| `SIGNPATH_API_TOKEN` (secret) | yes | | +| `SIGNPATH_ORGANIZATION_ID` | yes | | +| `SIGNPATH_PROJECT_SLUG` | no | `CryptBridge` | +| `SIGNPATH_SIGNING_POLICY_SLUG` | no | `release-signing` | +| `SIGNPATH_ARTIFACT_CONFIGURATION_SLUG` | no | the project's default artifact configuration | + +## Taking it out again + +Never setting the secret and the variable is enough: nothing is signed then. To remove +it completely: delete this folder and `CODE_SIGNING.md`, remove the steps marked +`code signing` in `release.yml` (the file check step uses `check-files.ps1` from this +folder) and the links to `CODE_SIGNING.md` in the README. diff --git a/.signpath/artifact-configuration.xml b/.signpath/artifact-configuration.xml new file mode 100644 index 0000000..8748349 --- /dev/null +++ b/.signpath/artifact-configuration.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + diff --git a/.signpath/check-files.ps1 b/.signpath/check-files.ps1 new file mode 100644 index 0000000..94243e9 --- /dev/null +++ b/.signpath/check-files.ps1 @@ -0,0 +1,61 @@ +# CryptBridge (https://github.com/burnersen/CryptBridge) +# Copyright (C) 2026 burnersen +# SPDX-License-Identifier: GPL-3.0-only +# +# Checks the Windows files a release is about to sign or publish: +# +# - every file exists, +# - its file properties carry exactly the product name and product version +# that the SignPath artifact configuration enforces (product-name, +# product-version). SignPath refuses a signing request whose files do not +# match, so this check runs on every release build, signed or not: a +# mismatch shows up long before the first signed release. +# - with -Signed: every file carries a valid Authenticode signature. +# +# Usage (from the release workflow): +# ./.signpath/check-files.ps1 -ProductName X -ProductVersion 1.2.3 -Path a.exe, b.exe [-Signed] + +param( + [Parameter(Mandatory)] [string[]] $Path, + [Parameter(Mandatory)] [string] $ProductName, + [Parameter(Mandatory)] [string] $ProductVersion, + [switch] $Signed +) + +$ErrorActionPreference = 'Stop' +$failed = 0 + +foreach ($file in $Path) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { + Write-Host "::error::$file is missing" + $failed++ + continue + } + $info = (Get-Item -LiteralPath $file).VersionInfo + $name = "$($info.ProductName)".Trim() + $version = "$($info.ProductVersion)".Trim() + if ($name -ne $ProductName) { + Write-Host "::error::$file has product name '$name', expected '$ProductName'" + $failed++ + } + if ($version -ne $ProductVersion) { + Write-Host "::error::$file has product version '$version', expected '$ProductVersion'" + $failed++ + } + $line = "{0}: {1} {2}" -f $file, $name, $version + if ($Signed) { + $signature = Get-AuthenticodeSignature -LiteralPath $file + if ($signature.Status -ne 'Valid') { + Write-Host "::error::$file is not validly signed ($($signature.Status): $($signature.StatusMessage))" + $failed++ + } else { + $line += ", signed by $($signature.SignerCertificate.Subject)" + } + } + Write-Host $line +} + +if ($failed -gt 0) { + Write-Host "::error::$failed problem(s) with the release files" + exit 1 +} diff --git a/CODE_SIGNING.md b/CODE_SIGNING.md new file mode 100644 index 0000000..ecf97e9 --- /dev/null +++ b/CODE_SIGNING.md @@ -0,0 +1,104 @@ +# Code signing policy + +Free code signing provided by [SignPath.io](https://about.signpath.io), +certificate by [SignPath Foundation](https://signpath.org). + +> **Status:** CryptBridge has applied to the SignPath Foundation. Until the +> signing is active, `CryptBridge.exe` is not signed. Every release says in +> its text whether its exe is signed. + +## What is signed + +Only `CryptBridge.exe`, built from the source code in this repository by the +[release workflow](.github/workflows/release.yml) on GitHub-hosted runners. +Nothing that was built elsewhere is signed with this certificate. Every +signing request is approved by hand, release by release. + +Never signed by this project: the copies of the **rclone** ZIP files and of +the **WinFsp** installer that each release keeps as a backup. They are the +unchanged originals of their projects, checked against their known SHA-256 +([`windows/release-assets.ps1`](windows/release-assets.ps1)). `setup.sh` +(Linux server) is a shell script and is not signed either. + +The file properties of the signed exe name the product `CryptBridge` and the +version of the release; SignPath refuses to sign a file that does not match. + +## Team roles + +| Role | Who | +| ---- | --- | +| Committers and reviewers | [burnersen](https://github.com/burnersen) | +| Approvers | [burnersen](https://github.com/burnersen) | + +Changes from anybody else (pull requests) are reviewed by a committer before +they are merged. All team members use multi-factor authentication for GitHub +and for SignPath. + +## Privacy policy + +This program will not transfer any information to other networked systems +unless specifically requested by the user or the person installing or +operating it. + +What CryptBridge does on the network, and only for that: + +- **Your cloud accounts:** CryptBridge connects only to the cloud services + you add yourself (pCloud, Google Drive, OneDrive, Dropbox, MEGA), with your + own sign-in. Files are **encrypted on your PC before** they are uploaded; + the service only ever gets encrypted data. For the storage overview it asks + the service how much space is used. +- **rclone download:** only if the tested rclone is missing (first start, or + after a CryptBridge update that needs another rclone), CryptBridge downloads + it from rclone's own servers (downloads.rclone.org, GitHub), or from the + backup copy in this project's releases, and checks it against a checksum + built into CryptBridge. +- **LAN access** (off unless you turn it on): shares the connected drives + with devices in your home network over WebDAV, protected by user name and + password. See [LAN access](docs/lan-access.md). + +Like any network request, these reveal your IP address to the server asked. +Your vault passwords and keys are never sent anywhere: they leave your PC +only if you pass them on yourself (the emergency sheet, or the encrypted +transfer block you copy to your own Linux server). There is no telemetry, no +update check, no account with this project and no advertising. + +## Changes to your system + +CryptBridge tells you before it changes anything outside its own folders, +and only does so when you ask for it: + +- **Start with Windows** (Settings tab): an entry in your user's + `Run` key, removed again when you turn it off. +- **LAN access:** one Windows firewall rule `CryptBridge LAN access (WebDAV)`, + added with your permission (Windows asks) and removed when you turn both + accesses off. +- **WinFsp** is installed by you, from its official site. + +## Installing and uninstalling + +There is no installer: CryptBridge is one exe. To uninstall: + +1. Let all uploads finish, then turn off *start with Windows* and the LAN + access (this removes the firewall rule) on the Settings tab. +2. *Quit* CryptBridge in the tray menu and delete `CryptBridge.exe`. +3. Delete the folders `%APPDATA%\CryptBridge` (settings) and + `%LOCALAPPDATA%\CryptBridge` (logs, cache and the downloaded rclone). +4. If no other program needs it, uninstall WinFsp in *Settings > Apps*. + +Your encrypted files stay in your cloud. Keep the emergency sheet: without +the password and the salt nobody can open them again. The Linux server is +removed with `sudo cryptbridge uninstall`, see +[Linux server](docs/linux-server.md). + +## Verifying a download + +- **Signature:** right-click `CryptBridge.exe` > *Properties* > *Digital + Signatures*. A signed release shows *SignPath Foundation*. +- **Checksums:** every release run lists the SHA-256 of each file in its log. + +## Reporting a problem + +If you think a signed file of this project does something it should not, +please open an [issue](https://github.com/burnersen/CryptBridge/issues). +Signed files that break these rules can also be reported to the +[SignPath Foundation](https://signpath.org). diff --git a/README.md b/README.md index e5d590d..e39e48b 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ you. ![Ubuntu / Debian](https://img.shields.io/badge/Linux-Ubuntu%20%7C%20Debian-E95420) [![Ko-fi](https://img.shields.io/badge/Ko--fi-Support%20me-FF5E5B?logo=kofi&logoColor=white)](https://ko-fi.com/burnersen) -**[⬇️ Download for Windows](https://github.com/burnersen/CryptBridge/releases/latest)** · **[🚀 Quick start](#-quick-start)** · **[🌐 Website](https://tech.burnersen.de/en/tools/cryptbridge-en/)** · **[☕ Buy me a coffee](https://ko-fi.com/burnersen)** +**[⬇️ Download for Windows](https://github.com/burnersen/CryptBridge/releases/latest)** · **[🚀 Quick start](#-quick-start)** · **[🌐 Website](https://tech.burnersen.de/en/tools/cryptbridge-en/)** · **[🔏 Code signing policy](CODE_SIGNING.md)** · **[☕ Buy me a coffee](https://ko-fi.com/burnersen)** 🇩🇪 Auf Deutsch: [tech.burnersen.de/tools/cryptbridge](https://tech.burnersen.de/tools/cryptbridge/) @@ -186,6 +186,8 @@ No copy and paste at hand? Save the block as a file and read it from there; the hiccup. All details, including the small check file `.cryptbridge-vault` in every vault: [Security](docs/security.md). +What CryptBridge sends where (only to the cloud services you add, and the rclone download), and what it +changes on your system: the privacy policy in the [code signing policy](CODE_SIGNING.md#privacy-policy). --- @@ -234,6 +236,19 @@ Log files: Windows `%LOCALAPPDATA%\CryptBridge\logs\rclone.log` · server The [release notes](https://github.com/burnersen/CryptBridge/releases) say what changed in each version. +> **Windows SmartScreen** may warn because `CryptBridge.exe` is not code signed yet. Choose *More info* > +> *Run anyway*. Every release is built by GitHub Actions from this repository, and free code signing +> through the [SignPath Foundation](https://signpath.org) is applied for: see the +> [code signing policy](CODE_SIGNING.md). + +## Uninstalling + +- **Windows:** let all uploads finish, turn off *start with Windows* and the LAN access on the Settings + tab, *Quit* CryptBridge in the tray menu and delete `CryptBridge.exe`. Then delete the folders + `%APPDATA%\CryptBridge` and `%LOCALAPPDATA%\CryptBridge`, and uninstall WinFsp in *Settings > Apps* if + nothing else needs it. Your encrypted files stay in your cloud; keep the emergency sheet. +- **Linux server:** `sudo cryptbridge uninstall` (keeps your files in the cloud). + --- ## License diff --git a/docs/technical.md b/docs/technical.md index d780ffd..f8e92e6 100644 --- a/docs/technical.md +++ b/docs/technical.md @@ -61,6 +61,10 @@ to `VERSION`), or start it on `main` under *Actions > Release > Run workflow*, w (`windows/release-assets.ps1`), runs the tests with exactly the pinned rclone, builds `CryptBridge.exe` (`windows/build.ps1`) and publishes the release. Its text is the backup part followed by `.github/release-notes/.md`, which has to be in the tagged commit. If any step fails, nothing -is published. +is published. Ticking *dry run* (any branch) runs all of it without signing, tagging or publishing. + +Code signing through the SignPath Foundation is prepared in the same workflow and stays off until it is +set up: [`.signpath/README.md`](../.signpath/README.md). Only `CryptBridge.exe` is ever signed; the +policy is [CODE_SIGNING.md](../CODE_SIGNING.md). What the Linux setup installs and changes is listed in [Linux server](linux-server.md#what-the-setup-does).