Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ jobs:
id-token: write # sign the provenance
attestations: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.26.x'
check-latest: true
cache: false # this job holds the signing key; no restored cache may run in it

- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: Syncer
Expand All @@ -102,6 +112,26 @@ jobs:
working-directory: dist
run: sha256sum Syncer.exe Syncer-amd64-installer.exe | tee SHA256SUMS.txt

- name: Sign the checksums
# The app installs only an exe whose hash is in a sums file signed with
# this key (see RELEASING.md); a release without it must not be made.
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
if [ -z "$RELEASE_SIGNING_KEY" ]; then
echo "::error::The RELEASE_SIGNING_KEY secret is empty."
exit 1
fi
go run ./tools/release-sign -sign dist/SHA256SUMS.txt -tag "$GITHUB_REF_NAME" -key-env RELEASE_SIGNING_KEY

- name: Check the signature against the key in the app
# Fails when the secret doesn't belong to the public key in pubkey.go
# (or that is still the placeholder): such a release would install nowhere.
# A key-change release is signed with the old key: RELEASING.md says how.
env:
PUBKEY_FILE: ${{ vars.RELEASE_PUBKEY_FILE }}
run: go run ./tools/release-sign -verify dist/SHA256SUMS.txt -tag "$GITHUB_REF_NAME" -pubfile "${PUBKEY_FILE:-internal/update/pubkey.go}"

- uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0
with:
subject-path: |
Expand All @@ -121,3 +151,4 @@ jobs:
dist/Syncer-amd64-installer.exe
dist/Syncer.exe
dist/SHA256SUMS.txt
dist/SHA256SUMS.txt.sig
18 changes: 17 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,32 @@ Installed copies update themselves from the newest *published* GitHub release, s

1. Make sure `main` is green and has what you want to ship.
2. Tag it and push the tag: `git tag v1.2.0 && git push origin v1.2.0`. Use `v1.2.0-rc1` for a release candidate (it is marked as a pre-release and is never offered by the updater).
3. CI (`build`) tests and builds both files, then creates a **draft** release with `Syncer-amd64-installer.exe`, `Syncer.exe` and `SHA256SUMS.txt`, and records build provenance for the two exes.
3. CI (`build`) tests and builds both files, then signs `SHA256SUMS.txt` with the release key, and creates a **draft** release with `Syncer-amd64-installer.exe`, `Syncer.exe`, `SHA256SUMS.txt` and `SHA256SUMS.txt.sig`, and records build provenance for the two exes.
4. Download the draft's assets and check them:
- `sha256sum -c SHA256SUMS.txt` (or `Get-FileHash`) matches the files.
- `go run ./tools/release-sign -verify SHA256SUMS.txt -tag v1.2.0 -pubfile internal/update/pubkey.go` accepts the signature (CI runs the same check on the signature it makes, and fails the release if it doesn't pass). Installed copies only update to a release that passes this.
- `gh attestation verify Syncer.exe --repo ApolloF/syncer` and the same for the installer.
5. Smoke-test on a clean VM or PC: a fresh install from the installer, and an upgrade over the previous release (settings and backups still there, the app restarts into the new version).
6. Edit the release notes if needed, then publish the draft. A release candidate stays a pre-release.
7. Copies of the previous release find the new one at their next check and update themselves. A copy running `v1.2.0-rc1` is offered `v1.2.0` once it is published.

If a published release is bad, mark it as a draft again (or delete it) so the updater stops offering it, then fix forward with a new tag.

## Release signing key

Releases are signed so that a tampered or replaced asset is never installed: the app embeds the public key and installs only a Syncer.exe whose hash is in a validly signed SHA256SUMS.txt. The private key is the base64 of an ed25519 seed, one line.

One-time setup:

1. `go run ./tools/release-sign -keygen -out <path outside the repo>` writes the private key (it refuses to overwrite) and prints only the public key.
2. `Get-Content -Raw <path> | gh secret set RELEASE_SIGNING_KEY --repo ApolloF/Syncer` gives it to CI (PowerShell has no `<`; in bash use `gh secret set RELEASE_SIGNING_KEY < <path>`). Don't use `--body`: the key would land in the process arguments and the shell history. The release job fails when the secret is empty.
3. Paste the printed public key into `releasePublicKey` in `internal/update/pubkey.go` and release that. Until then no update verifies, so none is installed.
4. Keep the private key file somewhere safe and offline, and delete the copy on the machine you generated it on. The file mode 0600 does nothing on Windows, so until it is offline keep it in a folder under your user profile.

Recommended: move the secret into a GitHub environment that only tags `v*` may deploy to (repository settings), so no other branch's workflow can read it.

If the key is lost or leaked: make a new one as above, put its public key in `pubkey.go`, and publish that release signed with the old key, so installed copies accept it and from then on trust the new key. CI checks each signature against the key in `pubkey.go`, which for the bridge release is the new one, so for that one tag keep a copy of the old `pubkey.go` in the repo and set the repository variable `RELEASE_PUBKEY_FILE` to its path; remove the variable afterwards. Switch the `RELEASE_SIGNING_KEY` secret to the new key only after the bridge release is out. A leaked key can't be revoked for copies that haven't updated yet: until they take the bridge release, whoever has the key can sign updates for them. If the old key is lost, nothing signed with a new key will be accepted by installed copies: users must download and install that release by hand.

## Personal build

`wails build -tags personal` makes the maintainer's build (see [CONTRIBUTING.md](CONTRIBUTING.md#builds-public-vs-personal)). It is built locally, with the Google client passed through `-ldflags`, and is never published or attached to a release. CI only builds the public one.
Expand Down
48 changes: 42 additions & 6 deletions internal/app/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"

"github.com/ApolloF/syncer/internal/accounts"
"github.com/ApolloF/syncer/internal/backup"
"github.com/ApolloF/syncer/internal/features"
"github.com/ApolloF/syncer/internal/logx"
"github.com/ApolloF/syncer/internal/meta"
Expand Down Expand Up @@ -127,7 +129,31 @@ func (a *App) InstallUpdate() error {
return a.installUpdate(ctx, false)
}

var errBackupRunning = errors.New("a backup is running; update once it has finished")
// errBusy: Syncer is backing up, or moving saves between accounts, and the
// exe swap must not interrupt that.
var errBusy = errors.New("Syncer is busy with your saves; update once that has finished")

// swapExe puts the downloaded exe in place while no backup runs and no
// account operation is pending or starting.
func swapExe(exe, downloaded string) error {
// A running operation holds the lock for a while; say so at once instead
// of waiting for it.
if _, pending := accounts.PendingOp(); pending {
return errBusy
}
err := accounts.Locked(func() error {
unlock, err := backup.Lock()
if err != nil {
return errBusy
}
defer unlock()
return update.Replace(exe, downloaded)
})
if errors.Is(err, accounts.ErrBusy) {
return errBusy
}
return err
}

// installUpdate replaces Syncer.exe with the newest release's and restarts.
// Installed and portable copies both update this way (the installer does
Expand All @@ -146,9 +172,9 @@ func (a *App) installUpdate(ctx context.Context, auto bool) error {
a.mu.Unlock()
return errors.New("Syncer is already updating")
}
if a.backingUp {
if _, pending := accounts.PendingOp(); a.backingUp || pending {
a.mu.Unlock()
return errBackupRunning
return errBusy
}
a.updating = true
a.mu.Unlock()
Expand All @@ -168,7 +194,11 @@ func (a *App) installUpdate(ctx context.Context, auto bool) error {
if r.Exe == nil {
return errors.New("Syncer " + r.Tag + " can't be installed from here; download it from GitHub")
}
path, err := update.Download(ctx, r.Exe, filepath.Dir(exe))
path, err := update.Download(ctx, r, version, filepath.Dir(exe))
if errors.Is(err, update.ErrNotSigned) {
logx.Printf("Syncer %s was not installed: %v", r.Tag, err)
return update.ErrNotSigned
}
if err != nil {
return fmt.Errorf("download Syncer %s: %w", r.Tag, err)
}
Expand All @@ -180,10 +210,16 @@ func (a *App) installUpdate(ctx context.Context, auto bool) error {
if auto {
return nil // next time
}
return errBackupRunning
return errBusy
}
if err := update.Replace(exe, path); err != nil {
if err := swapExe(exe, path); err != nil {
_ = os.Remove(path)
if errors.Is(err, errBusy) {
if auto {
return nil // next time
}
return err
}
return fmt.Errorf("install Syncer %s: %w", r.Tag, err)
}
logx.Printf("updated to Syncer %s (from %s), restarting", r.Tag, version)
Expand Down
67 changes: 67 additions & 0 deletions internal/app/updateswap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package app

import (
"errors"
"os"
"path/filepath"
"testing"

"github.com/ApolloF/syncer/internal/backup"
"github.com/ApolloF/syncer/internal/features"
"github.com/ApolloF/syncer/internal/paths"
)

func TestSwapExeWaitsForBackupsAndAccountOperations(t *testing.T) {
if features.Personal {
t.Skip("the personal build never swaps the exe")
}
t.Cleanup(paths.SetRootForTest(paths.Roaming, t.TempDir()))
dir := t.TempDir()
exe, next := filepath.Join(dir, "Syncer.exe"), filepath.Join(dir, "Syncer.exe.new")
write := func(p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
write(exe, "old")
write(next, "new")
untouched := func(when string) {
t.Helper()
if b, _ := os.ReadFile(exe); string(b) != "old" {
t.Fatalf("%s: exe = %q, was replaced", when, b)
}
}

unlock, err := backup.Lock()
if err != nil {
t.Fatal(err)
}
if err := swapExe(exe, next); !errors.Is(err, errBusy) {
t.Fatalf("during a backup: %v, want errBusy", err)
}
untouched("during a backup")
unlock()

journal := filepath.Join(paths.AppDir(), "accounts-op.json")
write(journal, `{"kind":"switch"}`)
if err := swapExe(exe, next); !errors.Is(err, errBusy) {
t.Fatalf("during an account operation: %v, want errBusy", err)
}
untouched("during an account operation")
if err := os.Remove(journal); err != nil {
t.Fatal(err)
}

if err := swapExe(exe, next); err != nil {
t.Fatal(err)
}
if b, _ := os.ReadFile(exe); string(b) != "new" {
t.Fatalf("exe = %q after the swap", b)
}
if unlock, err := backup.Lock(); err != nil {
t.Fatalf("the backup lock was kept: %v", err)
} else {
unlock()
}
}
10 changes: 10 additions & 0 deletions internal/update/export_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package update

import "crypto/ed25519"

// SetPublicKeyForTest makes updates verify against key; it returns the undo.
func SetPublicKeyForTest(key ed25519.PublicKey) (restore func()) {
prev := embeddedKey
embeddedKey = key
return func() { embeddedKey = prev }
}
23 changes: 23 additions & 0 deletions internal/update/pubkey.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package update

import (
"crypto/ed25519"
"encoding/base64"
)

// releasePublicKey is the base64 ed25519 public key that releases are signed
// with (printed by `go run ./tools/release-sign -keygen -out <file>`). While
// it is not a valid key, no update verifies, so none is installed. CI checks
// the signature it just made against this very line.
const releasePublicKey = "FFmfut3fCN5x/yt9FOf4XePWu1K8UM/Z4pyCRLqben8="

// embeddedKey is releasePublicKey decoded; nil when that isn't a valid key.
var embeddedKey = decodeKey(releasePublicKey)

func decodeKey(s string) ed25519.PublicKey {
b, err := base64.StdEncoding.DecodeString(s)
if err != nil || len(b) != ed25519.PublicKeySize {
return nil
}
return b
}
Loading