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.jsonand 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,aptlooks 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 questionaptasks.
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:
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,/Sfor NSIS and/quietformsiexec. - 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-deblet you inspect and rebuild a package outsidetauri 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
.sigfile
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
aptto use this file, not a repository package - It answers yes to the question
aptasks - 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 buildon the laptop with thealltarget - Rename the
.debfor 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.