Skip to content

Sign, silent, update

Exercise sign-silent-update-ci-annex.pdf · PDF

You have seen this window: "Windows protected your PC. Unknown publisher." And you have probably clicked "Run anyway". So how did you know the file was the one its author made, and not one somebody changed on the way? You did not.

To the rest of the world, the .deb from the last page is an anonymous file. Here it gets a name and a seal. It also learns to install itself on 300 PCs with nobody watching, to leave cleanly, and to update itself once it is out there.

What you will learn

  • Say what a digital signature proves and what it does not
  • Tell code signing from the updater signature
  • Sign a package, verify it, and watch one changed byte break the signature
  • Install and uninstall a package unattended, with one line and no questions
  • Define a clean uninstall, and decide what happens to the user's data
  • Inspect and rebuild a package with an external tool, dpkg-deb
  • Read a release pipeline: CI runners, signed packages, latest.json and the updater
  • The trap: why a signed app is not the same as a safe app

The seal

Keep one picture for this page: your package is a sealed parcel.

A wax seal on a parcel says two things: who closed it, because only they own that stamp, and that nobody has opened it since, because a broken seal shows. It says nothing about whether what is inside is any good.

A digital signature is a short piece of data computed from a file and the signer's private key. Anyone with the public key can check that the file is unchanged and who sealed it.

So there are two keys:

Key In the analogy Rule
Private key The stamp It never leaves your hands
Public key A picture of the stamp Give it to everyone. It goes inside your app

Careful: signed does not mean safe

A seal proves the sender and that the parcel was not changed. It says nothing about the contents, and malware can be signed too. Since 2024, not even expensive EV certificates give an app instant trust on Windows SmartScreen. Reputation is earned release by release.

Two seals: do not mix them

Your app can carry two different seals, for two different inspectors:

Code signing Updater signature
Who checks The operating system: SmartScreen on Windows, Gatekeeper on macOS Your own app, before it installs an update
The stamp A certificate issued by a certificate authority, or self-signed for tests only A key pair made with npm run tauri signer generate
In Tauri bundle > windows: certificateThumbprint, digestAlgorithm, timestampUrl plugins > updater > pubkey, and TAURI_SIGNING_PRIVATE_KEY when building
Optional? Yes, but unsigned apps get the warning from the top of this page No. Tauri's docs on signing updates say "This cannot be disabled"

A certificate authority is a company that checks who you are and vouches for your public key. A self-signed certificate is one you vouch for yourself. It proves the file was not changed, but not who you are: anybody can self-sign as "Microsoft".

Signing a real Windows release

Tauri's guide warns that its certificateThumbprint steps only apply to OV certificates acquired before 1 June 2023. The same guide also covers signCommand, which calls an external signing tool, with cloud services such as Azure Artifact Signing as its examples. The idea stays the same: a trusted stamp, applied at build time.

Linux has no SmartScreen, so nobody checks a .deb you download by hand. The habit there is a detached signature, a separate .asc file next to the package, plus a checksum, a short fingerprint of the file. That is what you do next.

Seal it, break it

Create a test key with GPG, the standard signing tool on Linux. This stamp is good for class and worthless for the public, because nobody has a reason to trust it.

gpg --batch --passphrase '' --quick-generate-key "Your Name <you@example.com>" default default never

Seal and check, in the folder of your .deb:

gpg --detach-sign --armor NoteBox_1.0.0_amd64.deb      # writes NoteBox_1.0.0_amd64.deb.asc
gpg --verify NoteBox_1.0.0_amd64.deb.asc NoteBox_1.0.0_amd64.deb
sha256sum NoteBox_1.0.0_amd64.deb > SHA256SUMS && sha256sum -c SHA256SUMS

You should see Good signature from "Your Name <you@example.com>" and NoteBox_1.0.0_amd64.deb: OK. Use your own file name: it carries your app's name and version.

Now break the seal, on a copy:

cp NoteBox_1.0.0_amd64.deb tampered.deb && echo x >> tampered.deb
gpg --verify NoteBox_1.0.0_amd64.deb.asc tampered.deb

BAD signature. You added one letter and a line break to a 3 MB file, and it noticed. The signature is computed from every byte.

The other seal

The Tauri updater has its own key, and its own command:

npm run tauri signer generate -- -w ~/notebox.key
npm run tauri signer sign -- -f ~/notebox.key src-tauri/target/release/bundle/deb/NoteBox_1.0.0_amd64.deb

It asks for a password. In class you can press Enter twice for none. For a real app, set one. It writes notebox.key (private) and notebox.key.pub (public) to your home folder, outside the repository, and a .sig next to the package.

Guard this key

Read the warning the tool prints: lose the private key and updates will not work. And if somebody steals it, they can send "updates" to every installed copy of your app, and those copies will accept them. Never put it in your repository.

On Windows

For a test, create a self-signed certificate in PowerShell:

New-SelfSignedCertificate -Type CodeSigningCert -Subject "CN=NoteBox Classroom" -CertStoreLocation Cert:\CurrentUser\My

Copy its thumbprint to certificateThumbprint in bundle > windows, set digestAlgorithm to sha256, add a timestampUrl, and build. Check the result with Get-AuthenticodeSignature. SmartScreen will still warn the public, because the stamp is yours.

Install without a word

An unattended or silent installation has no window, no question and no click.

It is how an administrator installs your app on every PC of a school overnight. If your package asks even one question, it cannot be deployed that way.

sudo apt install -y ./NoteBox_1.0.0_amd64.deb
dpkg -s note-box | head -3        # Status: install ok installed
which notebox                     # /usr/bin/notebox
notebox &                         # the app opens, with its welcome notes

Three traps hide in those lines:

  • The ./. Without it, apt looks in the repositories for a package named like your file.
  • The package name. It is note-box, not the file name. Tauri writes the product name in kebab-case: lowercase words joined by hyphens.
  • The -y. It answers "yes" to the only question apt asks.

The same idea exists on every system:

Format Unattended install Unattended uninstall
.deb sudo apt install -y ./NoteBox_1.0.0_amd64.deb sudo apt purge -y note-box
.rpm sudo dnf install -y ./NoteBox-1.0.0-1.x86_64.rpm sudo dnf remove -y note-box
NSIS .exe NoteBox_1.0.0_x64-setup.exe /S (/P = passive: progress bar only) "%LOCALAPPDATA%\NoteBox\uninstall.exe" /S
.msi msiexec /i NoteBox_1.0.0_x64_en-US.msi /quiet msiexec /x NoteBox_1.0.0_x64_en-US.msi /quiet

Careful: silent does not mean hidden from the user

It means no questions. It is a tool for administrators, not a way to sneak something onto a computer.

Leave no trace

Add two notes in your installed NoteBox and close it, so there is something to lose. Then list, remove and look:

dpkg -L note-box                  # every file the package installed
sudo apt remove -y note-box
dpkg -s note-box                  # "is not installed"
ls /usr/lib/NoteBox /usr/bin/notebox /usr/share/applications/NoteBox.desktop   # all gone
ls ~/.local/share/com.iessonferrer.notebox                                      # still there

The app is gone, so whose folder is that? It belongs to the user. Their notes live in the WebView's storage, in a folder named after the identifier. Install the app again and the two notes are back. Your folder name will match your own identifier.

A clean uninstall leaves nothing the installer put on the system: no program files, menu entries, shortcuts or system settings.

User data is a separate decision, and each system has its custom:

System What happens to the user's data
Debian, Ubuntu The package never touches home folders. remove keeps system config files and purge deletes them too. Neither deletes the folder in ~/.local/share. Document its path in your installation manual
Windows, NSIS The uninstaller shows a checkbox, "Delete the application data". In silent mode nobody ticks it, so the data stays, unless you add an installerHooks file with a NSIS_HOOK_POSTUNINSTALL macro that removes it

Careful: an uninstall should not delete everything

Think of the user who reinstalls after a problem and finds a year of notes gone. Program files: always. User data: only when the user or the administrator says so. And never during an update, because an update is an uninstall followed by an install.

Open the parcel

Sooner or later tauri build is not enough. There is a company policy to meet, a file to add or a field to fix, and you must know what is in the box. That is when you reach for external tools: packaging programs that do not belong to Tauri.

A .deb is an archive with two parts: control (the label) and data (the files). dpkg-deb is Debian's own tool for them:

dpkg-deb -I NoteBox_1.0.0_amd64.deb     # the label: Package, Version, Depends...
dpkg-deb -c NoteBox_1.0.0_amd64.deb     # the contents, file by file

Look for what you packed: usr/bin/notebox, usr/lib/NoteBox/resources/welcome-notes.json, the icons under usr/share/icons/hicolor/. And Depends: libwebkit2gtk-4.1-0, libgtk-3-0, the things that do not travel.

Tauri leaves its working folder next to the package, so you can repack it yourself and change one line of the label:

cd src-tauri/target/release/bundle/deb
mkdir -p pkg/DEBIAN && cp -r NoteBox_1.0.0_amd64/data/* pkg/
cp NoteBox_1.0.0_amd64/control/control pkg/DEBIAN/
# edit pkg/DEBIAN/control: Maintainer: Your Name <you@example.com>
dpkg-deb --build --root-owner-group pkg notebox-external.deb
dpkg-deb -I notebox-external.deb

That is a valid package, and no line of Tauri built it.

The same split exists inside the Tauri CLI, the command-line tool you have been using. npm run tauri build -- --no-bundle only compiles. npm run tauri bundle -- --bundles deb only packages. On Windows the external tools are makensis, the NSIS compiler, for which Tauri leaves the generated script in target\release\nsis\x64\installer.nsi, and WiX.

The update

NoteBox 1.0.0 is on 500 PCs and has a bug. How do the 500 get 1.0.1? Nobody reinstalls by hand. The app has to ask a server by itself. Here is the whole road:

flowchart LR
    subgraph DEV ["You"]
        A["<b>Tag</b><br>git tag v1.0.1"]
    end
    subgraph ROBOT ["CI: clean machines"]
        B["<b>Runners</b><br>Windows, Ubuntu, macOS"]
        C["<b>Build</b><br>tauri build"]
        D["<b>Seal 1</b><br>code signing"]
        E["<b>Seal 2</b><br>updater .sig"]
    end
    subgraph WEB ["The server"]
        F["<b>Upload</b><br>the packages"]
        G["<b>latest.json</b><br>newest version,<br>URL, signature"]
    end
    subgraph USER ["The user's PC"]
        H["<b>Installed app</b><br>asks, downloads,<br>verifies, installs"]
    end
    A --> B --> C --> D --> E --> F --> G --> H

On which station would a stolen laptop be a disaster? Wherever the private keys are.

It takes three pieces. First, install the plugins. The updater plugin does the updating and the process plugin restarts the app:

npm run tauri add updater
npm run tauri add process

Check that src-tauri/capabilities/default.json lists the permissions updater:default and process:default.

The config. In tauri.conf.json:

"bundle": {
  "createUpdaterArtifacts": true
},
"plugins": {
  "updater": {
    "pubkey": "CONTENT OF notebox.key.pub",
    "endpoints": ["https://example.com/notebox/latest.json"]
  }
}

pubkey is the text of the key, not a path to a file. In production the endpoint must use HTTPS. For the build to sign, the private key must be in the environment variable TAURI_SIGNING_PRIVATE_KEY. A .env file does not work for this, as the docs warn.

The announcement. A small file on your web server, latest.json:

{
  "version": "1.0.1",
  "notes": "Fixes the delete bug",
  "pub_date": "2027-02-19T10:00:00Z",
  "platforms": {
    "linux-x86_64": {
      "signature": "CONTENT OF THE .sig FILE",
      "url": "https://example.com/notebox/NoteBox_1.0.1_amd64.AppImage"
    }
  }
}

Only version and each platform's url and signature are required. The platform keys follow the form OS-ARCH, like windows-x86_64.

The app. Three calls:

import { check } from '@tauri-apps/plugin-updater';
import { relaunch } from '@tauri-apps/plugin-process';

const update = await check();
if (update) {
    await update.downloadAndInstall();
    await relaunch();
}

check() reads latest.json and compares versions. downloadAndInstall() fetches the file and verifies the seal against your public key. relaunch() restarts the app.

What about Linux packages?

Tauri's updater guide covers the AppImage, whose file the updater replaces. That is the version in this example. The plugin's recent versions can also update a .deb or .rpm, but a system package needs administrator rights, so it asks the user for a password. Check the plugin's changelog before relying on it. Otherwise, a .deb is updated by installing the new .deb.

The robot that builds

tauri build only packages for the system it runs on. For three systems you need three machines, clean and identical every time.

Continuous integration (CI) is a service that runs your build commands on fresh machines when something happens in your repository. Each of those machines is a runner.

CI is not only for big teams. It is the way one person can release for three systems without owning three computers. This workflow is based on the example in the Tauri docs, trimmed to one machine per system. Read it for what it does: you are not asked to type it.

# .github/workflows/publish.yml
name: publish

on:
  workflow_dispatch:
  push:
    branches:
      - release

jobs:
  publish-tauri:
    permissions:
      contents: write
    strategy:
      fail-fast: false
      matrix:
        platform: [macos-latest, ubuntu-22.04, windows-latest]
    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
          cache: npm
      - uses: dtolnay/rust-toolchain@stable
      - name: Install Linux libraries
        if: matrix.platform == 'ubuntu-22.04'
        run: |
          sudo apt-get update
          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
      - run: npm install
      - uses: tauri-apps/tauri-action@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
        with:
          tagName: app-v__VERSION__
          releaseName: App v__VERSION__
          releaseBody: See the assets to download this version and install.
          releaseDraft: true
          prerelease: false
In the file Meaning
on: push: branches: - release When it runs. Here, on a push to the release branch. Change it to tags: - 'v*' and it runs when you push a tag like v1.0.1, as in the diagram above
matrix: platform One clean runner per system
apt-get install -y libwebkit2gtk-4.1-dev ... The same libraries you installed once by hand, now typed by a robot
uses: tauri-apps/tauri-action@v1 Runs tauri build, uploads the packages to a GitHub Release, and also generates latest.json for the updater
secrets.GITHUB_TOKEN A secret lives in the service's vault, never in the repository. GitHub issues this token by itself. You add your signing keys to the same vault
permissions: contents: write Lets the job create the release

Why ubuntu-22.04 and not the newest? A Linux package built on an old system runs on newer ones, and not the reverse. A newer base can raise the minimum version of glibc, the system's core C library, and then your app fails on older systems with an error like GLIBC_2.33' not found. So you build on the oldest system you want to support that still has WebKitGTK 4.1.

Release checklist

Before you call a version finished, can you show a proof of each line?

Item Proof
Version The number in tauri.conf.json and the package file name match
Resources The installed app finds its extra files
Branding Icon, wizard images and metadata are in place
Package from the CLI tauri build prints the path
External tool dpkg-deb -I shows the label
Signed gpg --verify says Good signature
Silent install One line, no questions, the app opens
Clean uninstall dpkg -L listed the files, and they are gone. The data folder's path is documented
Update manifest latest.json has the version, URL and signature
CI workflow A push produces the packages on clean machines

Key takeaways

  • A digital signature proves who sealed a file and that it was not changed. It says nothing about the contents.
  • Code signing is checked by the operating system and is optional. The updater signature is checked by your own app and is mandatory. The private key is the secret, and the public key goes inside the app.
  • An unattended install has no questions: apt install -y, /S for NSIS and /quiet for msiexec.
  • A clean uninstall removes what the installer put on the system. User data is a separate decision, and it never goes away during an update.
  • External tools like dpkg-deb let you inspect and rebuild a package outside tauri build.
  • The updater reads latest.json, verifies the seal and installs. CI builds on clean runners, with the keys kept in secrets.

Quick check

1. A package has a valid digital signature. What does that tell you?
  • The program inside contains no malware
  • The operating system has tested the program
  • Who sealed it, and that it has not changed since
  • The program will work on any operating system

A seal proves the sender and the integrity of the file. It says nothing about the contents, and malware can be signed too.

2. Which part of a key pair do you build into your app so it can check updates?
  • The public key
  • The private key
  • Both keys, in separate files
  • Neither key, only the .sig file

The public key goes in plugins > updater > pubkey, inside the app, where anyone can read it. The private key signs the updates and must stay secret.

3. You change a single byte of a signed file and run the verification again. What do you get?
  • A good signature, because the change is so small
  • A warning, and only if the change was in the first bytes
  • A good signature with a note about the new file size
  • A bad signature, because it depends on every byte

A signature is computed from the whole file, so changing any byte breaks it. A broken seal shows, whatever the size of the change.

4. In the command sudo apt install -y ./app_2.0.0_amd64.deb, what does the ./ do?
  • It runs the installation with no window
  • It tells apt to use this file, not a repository package
  • It answers yes to the question apt asks
  • It removes the previous version of the package

Without ./, apt looks in its repositories for a package with that name. The -y is the part that answers yes, which is what makes the line unattended.

5. A user removes a Tauri app with sudo apt purge -y. What happens to the folder named after the app's identifier in ~/.local/share?
  • It is deleted along with the system config files
  • It moves to the system's trash folder
  • It stays, because packages do not touch home folders
  • It is emptied, though the folder itself remains

purge deletes the package's system config files. A home folder belongs to the user, not to the package, so it stays. Document its path in your manual.

6. During an update, why must an uninstaller not delete the user's data?
  • Because deleting data needs administrator rights
  • Because an update is an uninstall followed by an install
  • Because the updater has already backed up the data
  • Because user data is stored in the installer itself

If an update removed user data, each new version would wipe the user's notes. Removing program files is always right, but removing user data only happens when somebody asks for it.

7. A developer commits the private updater key to a public repository by mistake. What is the danger?
  • Users' computers stop accepting any new updates
  • The app's public key stops working on Linux
  • The CI service refuses to build the repository
  • Anyone can sign fake updates that installed apps will accept

The installed apps trust whatever matches their public key. With the private key, an attacker can seal a false "update", and every copy will accept it. That is why keys live in CI secrets, never in the repository.

8. A one-person team needs installers for Windows, Linux and macOS, and only owns a Linux laptop. What is the practical way to do it?
  • Run tauri build on the laptop with the all target
  • Rename the .deb for each of the other systems
  • Use a CI workflow with one runner per system
  • Ship an AppImage, since it runs on every system

tauri build only packages for the system it runs on, and an AppImage only runs on Linux. CI gives you a clean machine per system, as many as your matrix lists.

That is the whole road: from a web app in a browser tab to a signed package that installs itself, leaves cleanly and updates on its own. An app people can actually install is no longer a prototype. Go back to the Unit 8 overview and check every outcome off the list.