···3737 icon := resolveIcon(fsys)
3838 appName = deriveAppName(desktop, desktopName, appimage)
39394040+ // Enforce the signature trust policy before any filesystem writes, so a
4141+ // refused AppImage installs nothing.
4242+ pin, err := checkSignature(appimage, a.pinnedSigningKey(appName))
4343+ if err != nil {
4444+ return "", err
4545+ }
4646+4047 // No desktop file inside the AppImage: synthesize a terminal launcher so
4148 // CLI apps still get a menu entry and are tracked by managedApps.
4249 if desktop == nil {
···4956 // Patch in memory before any filesystem writes so a failure here installs nothing.
5057 if err := a.patchDesktopFile(desktop, appName, icon != ""); err != nil {
5158 return "", err
5959+ }
6060+ if pin != "" {
6161+ desktop.Set(desktopEntrySection, desktopSigningKey, pin)
5262 }
53635464 // Roll back written files on a later failure rather than leaving a half-installed app.
+181
internal/appherder/signature.go
···11+package appherder
22+33+import (
44+ "bytes"
55+ "crypto/sha256"
66+ "debug/elf"
77+ "encoding/hex"
88+ "errors"
99+ "fmt"
1010+ "io"
1111+ "os"
1212+ "path/filepath"
1313+ "strings"
1414+1515+ "github.com/ProtonMail/go-crypto/openpgp"
1616+ "github.com/alyraffauf/goxdgdesktop/desktopfile"
1717+)
1818+1919+// appimagetool stores an AppImage's optional OpenPGP signature in two ELF
2020+// sections: .sha256_sig (armored detached signature) and .sig_key (armored
2121+// public key). The signed message is the lowercase hex SHA-256 of the whole
2222+// file with both sections zeroed.
2323+const (
2424+ sigSection = ".sha256_sig"
2525+ keySection = ".sig_key"
2626+2727+ // desktopSigningKey pins an app's trusted signing-key fingerprint in its
2828+ // managed launcher, recorded on the first signed install (trust on first use).
2929+ desktopSigningKey = "X-AppHerder-SigningKey"
3030+)
3131+3232+// errUnsigned reports that an AppImage carries no usable embedded signature.
3333+var errUnsigned = errors.New("AppImage is not signed")
3434+3535+// byteRange is a half-open [start, start+length) span of a file.
3636+type byteRange struct{ start, length int64 }
3737+3838+// sectionData returns the named ELF section's contents with NUL padding trimmed,
3939+// and the byte range it occupies. ok is false when the section is absent or
4040+// holds no file bytes.
4141+func sectionData(f *elf.File, name string) (data []byte, span byteRange, ok bool, err error) {
4242+ section := f.Section(name)
4343+ if section == nil || section.Type == elf.SHT_NOBITS {
4444+ return nil, byteRange{}, false, nil
4545+ }
4646+ data, err = section.Data()
4747+ if err != nil {
4848+ return nil, byteRange{}, false, fmt.Errorf("read %s: %w", name, err)
4949+ }
5050+ return bytes.TrimRight(data, "\x00"), byteRange{int64(section.Offset), int64(section.Size)}, true, nil
5151+}
5252+5353+// readSignatureSections returns the .sha256_sig and .sig_key contents and the
5454+// byte ranges they occupy, which the digest zeroes.
5555+func readSignatureSections(file string) (sig, key []byte, zero []byteRange, err error) {
5656+ elfFile, err := elf.Open(file)
5757+ if err != nil {
5858+ return nil, nil, nil, fmt.Errorf("open AppImage %s: %w", file, err)
5959+ }
6060+ defer elfFile.Close()
6161+6262+ sig, sigSpan, sigOK, err := sectionData(elfFile, sigSection)
6363+ if err != nil {
6464+ return nil, nil, nil, err
6565+ }
6666+ key, keySpan, keyOK, err := sectionData(elfFile, keySection)
6767+ if err != nil {
6868+ return nil, nil, nil, err
6969+ }
7070+ if sigOK {
7171+ zero = append(zero, sigSpan)
7272+ }
7373+ if keyOK {
7474+ zero = append(zero, keySpan)
7575+ }
7676+ return sig, key, zero, nil
7777+}
7878+7979+// signedDigest returns the lowercase hex SHA-256 that appimagetool signs: the
8080+// file hashed with the bytes in zero replaced by NULs.
8181+func signedDigest(file string, zero []byteRange) (string, error) {
8282+ f, err := os.Open(file)
8383+ if err != nil {
8484+ return "", fmt.Errorf("open AppImage %s: %w", file, err)
8585+ }
8686+ defer f.Close()
8787+8888+ hasher := sha256.New()
8989+ buf := make([]byte, 64*1024)
9090+ var pos int64
9191+ for {
9292+ n, err := f.Read(buf)
9393+ if n > 0 {
9494+ chunk := buf[:n]
9595+ zeroOverlaps(chunk, pos, zero)
9696+ hasher.Write(chunk)
9797+ pos += int64(n)
9898+ }
9999+ if err == io.EOF {
100100+ break
101101+ }
102102+ if err != nil {
103103+ return "", fmt.Errorf("read AppImage %s: %w", file, err)
104104+ }
105105+ }
106106+ return hex.EncodeToString(hasher.Sum(nil)), nil
107107+}
108108+109109+// zeroOverlaps NULs the bytes of chunk (which begins at file offset pos) that
110110+// fall within any range in zero.
111111+func zeroOverlaps(chunk []byte, pos int64, zero []byteRange) {
112112+ end := pos + int64(len(chunk))
113113+ for _, r := range zero {
114114+ for i := max(pos, r.start); i < min(end, r.start+r.length); i++ {
115115+ chunk[i-pos] = 0
116116+ }
117117+ }
118118+}
119119+120120+// verifyAppImageSignature verifies an AppImage's embedded signature and returns
121121+// the signer's fingerprint (uppercase hex). It returns errUnsigned when no
122122+// signature is present, or an error when one is present but invalid.
123123+func verifyAppImageSignature(file string) (fingerprint string, err error) {
124124+ sig, key, zero, err := readSignatureSections(file)
125125+ if err != nil {
126126+ return "", err
127127+ }
128128+ if len(bytes.TrimSpace(sig)) == 0 {
129129+ return "", errUnsigned
130130+ }
131131+ if len(bytes.TrimSpace(key)) == 0 {
132132+ return "", errors.New("AppImage is signed but carries no public key")
133133+ }
134134+135135+ keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(key))
136136+ if err != nil {
137137+ return "", fmt.Errorf("read embedded signing key: %w", err)
138138+ }
139139+ digest, err := signedDigest(file, zero)
140140+ if err != nil {
141141+ return "", err
142142+ }
143143+ signer, err := openpgp.CheckArmoredDetachedSignature(keyring, strings.NewReader(digest), bytes.NewReader(sig), nil)
144144+ if err != nil {
145145+ return "", fmt.Errorf("invalid AppImage signature: %w", err)
146146+ }
147147+ return fmt.Sprintf("%X", signer.PrimaryKey.Fingerprint), nil
148148+}
149149+150150+// pinnedSigningKey returns the fingerprint appherder pinned for appName, or "".
151151+func (a App) pinnedSigningKey(appName string) string {
152152+ desktop, err := desktopfile.Read(filepath.Join(a.applicationsDir, appName+".desktop"))
153153+ if err != nil {
154154+ return ""
155155+ }
156156+ fingerprint, _ := desktop.Get(desktopEntrySection, desktopSigningKey)
157157+ return fingerprint
158158+}
159159+160160+// checkSignature applies the trust policy to an incoming AppImage given the
161161+// fingerprint pinned for the app ("" if none), returning the fingerprint to keep
162162+// going forward. An app with no pin accepts anything and pins the first valid
163163+// signature (trust on first use). Once pinned, every update must be signed by
164164+// the pinned key: an unsigned, invalid, or differently-keyed AppImage is
165165+// refused. Changing trust is a deliberate act — uninstall, then reinstall.
166166+func checkSignature(file, pinned string) (fingerprint string, err error) {
167167+ fpr, err := verifyAppImageSignature(file)
168168+ switch {
169169+ case errors.Is(err, errUnsigned):
170170+ if pinned != "" {
171171+ return "", fmt.Errorf("refusing unsigned AppImage: a signing key is pinned (%s); uninstall and reinstall to trust a different build", pinned)
172172+ }
173173+ return "", nil
174174+ case err != nil:
175175+ return "", err
176176+ }
177177+ if pinned != "" && !strings.EqualFold(pinned, fpr) {
178178+ return "", fmt.Errorf("refusing AppImage: signing key changed (pinned %s, got %s); uninstall and reinstall to trust the new key", pinned, fpr)
179179+ }
180180+ return fpr, nil
181181+}
+284
internal/appherder/signature_test.go
···11+package appherder
22+33+import (
44+ "bytes"
55+ "crypto/sha256"
66+ "encoding/binary"
77+ "encoding/hex"
88+ "errors"
99+ "os"
1010+ "os/exec"
1111+ "path/filepath"
1212+ "strings"
1313+ "testing"
1414+1515+ "github.com/ProtonMail/go-crypto/openpgp"
1616+ "github.com/ProtonMail/go-crypto/openpgp/armor"
1717+)
1818+1919+// elfLayout is a minimal ELF carrying empty .sha256_sig and .sig_key sections,
2020+// ready to have a signature embedded.
2121+type elfLayout struct {
2222+ bytes []byte
2323+ sigOff, sigCap int64
2424+ keyOff, keyCap int64
2525+ textOff int64 // a content byte safe to tamper with
2626+}
2727+2828+// buildSignableELF lays out a 64-bit little-endian ELF with zero-filled
2929+// signature sections, mirroring what appimagetool reserves before signing.
3030+func buildSignableELF(sigCap, keyCap int) elfLayout {
3131+ le := binary.LittleEndian
3232+ const hdr, shentsize = 64, 64
3333+ textData := bytes.Repeat([]byte{0xAA}, 16)
3434+3535+ names := []string{"", ".text", ".sha256_sig", ".sig_key", ".shstrtab"}
3636+ var shstr bytes.Buffer
3737+ nameOff := map[string]uint32{}
3838+ for _, name := range names {
3939+ nameOff[name] = uint32(shstr.Len())
4040+ shstr.WriteString(name)
4141+ shstr.WriteByte(0)
4242+ }
4343+4444+ off := int64(hdr)
4545+ textOff := off
4646+ off += int64(len(textData))
4747+ sigOff := off
4848+ off += int64(sigCap)
4949+ keyOff := off
5050+ off += int64(keyCap)
5151+ shstrOff := off
5252+ off += int64(shstr.Len())
5353+ if rem := off % 8; rem != 0 {
5454+ off += 8 - rem
5555+ }
5656+ shoff := off
5757+5858+ sections := []struct {
5959+ name string
6060+ typ uint32
6161+ off, size int64
6262+ }{
6363+ {"", 0, 0, 0},
6464+ {".text", 1, textOff, int64(len(textData))}, // SHT_PROGBITS
6565+ {".sha256_sig", 1, sigOff, int64(sigCap)}, // SHT_PROGBITS
6666+ {".sig_key", 1, keyOff, int64(keyCap)}, // SHT_PROGBITS
6767+ {".shstrtab", 3, shstrOff, int64(shstr.Len())}, // SHT_STRTAB
6868+ }
6969+7070+ buf := make([]byte, shoff+int64(len(sections))*shentsize)
7171+ copy(buf, []byte{0x7f, 'E', 'L', 'F', 2, 1, 1, 0})
7272+ le.PutUint16(buf[16:], 2) // ET_EXEC
7373+ le.PutUint16(buf[18:], 0x3e) // x86-64
7474+ le.PutUint32(buf[20:], 1) // version
7575+ le.PutUint64(buf[40:], uint64(shoff))
7676+ le.PutUint16(buf[52:], hdr)
7777+ le.PutUint16(buf[58:], shentsize)
7878+ le.PutUint16(buf[60:], uint16(len(sections)))
7979+ le.PutUint16(buf[62:], 4) // shstrndx -> .shstrtab
8080+8181+ copy(buf[textOff:], textData)
8282+ copy(buf[shstrOff:], shstr.Bytes())
8383+ for i, s := range sections {
8484+ base := shoff + int64(i)*shentsize
8585+ le.PutUint32(buf[base:], nameOff[s.name])
8686+ le.PutUint32(buf[base+4:], s.typ)
8787+ le.PutUint64(buf[base+24:], uint64(s.off))
8888+ le.PutUint64(buf[base+32:], uint64(s.size))
8989+ }
9090+ return elfLayout{buf, sigOff, int64(sigCap), keyOff, int64(keyCap), textOff}
9191+}
9292+9393+// embed writes sig and key into the reserved sections (zero-padded).
9494+func (l elfLayout) embed(sig, key []byte) []byte {
9595+ out := append([]byte(nil), l.bytes...)
9696+ copy(out[l.sigOff:l.sigOff+l.sigCap], sig)
9797+ copy(out[l.keyOff:l.keyOff+l.keyCap], key)
9898+ return out
9999+}
100100+101101+// signWith signs the layout's signed digest with entity and returns the armored
102102+// detached signature plus the armored public key.
103103+func signWith(t *testing.T, l elfLayout, entity *openpgp.Entity) (sig, key []byte) {
104104+ t.Helper()
105105+ sum := sha256.Sum256(l.bytes) // sections are still zero-filled
106106+ digest := hex.EncodeToString(sum[:])
107107+108108+ var sigBuf bytes.Buffer
109109+ if err := openpgp.ArmoredDetachSign(&sigBuf, entity, strings.NewReader(digest), nil); err != nil {
110110+ t.Fatalf("sign: %v", err)
111111+ }
112112+ var keyBuf bytes.Buffer
113113+ w, err := armor.Encode(&keyBuf, openpgp.PublicKeyType, nil)
114114+ if err != nil {
115115+ t.Fatalf("armor key: %v", err)
116116+ }
117117+ if err := entity.Serialize(w); err != nil {
118118+ t.Fatalf("serialize key: %v", err)
119119+ }
120120+ w.Close()
121121+ return sigBuf.Bytes(), keyBuf.Bytes()
122122+}
123123+124124+func newTestEntity(t *testing.T) *openpgp.Entity {
125125+ t.Helper()
126126+ entity, err := openpgp.NewEntity("AppHerder Test", "", "test@appherder.local", nil)
127127+ if err != nil {
128128+ t.Fatalf("new entity: %v", err)
129129+ }
130130+ return entity
131131+}
132132+133133+func writeAppImage(t *testing.T, data []byte) string {
134134+ t.Helper()
135135+ path := filepath.Join(t.TempDir(), "App.AppImage")
136136+ if err := os.WriteFile(path, data, 0o755); err != nil {
137137+ t.Fatal(err)
138138+ }
139139+ return path
140140+}
141141+142142+func TestVerifyAppImageSignatureValid(t *testing.T) {
143143+ entity := newTestEntity(t)
144144+ layout := buildSignableELF(2048, 4096)
145145+ sig, key := signWith(t, layout, entity)
146146+ path := writeAppImage(t, layout.embed(sig, key))
147147+148148+ got, err := verifyAppImageSignature(path)
149149+ if err != nil {
150150+ t.Fatalf("verify: %v", err)
151151+ }
152152+ want := strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint))
153153+ if got != want {
154154+ t.Fatalf("fingerprint = %s, want %s", got, want)
155155+ }
156156+}
157157+158158+func TestVerifyAppImageSignatureUnsigned(t *testing.T) {
159159+ // Sections present but empty.
160160+ path := writeAppImage(t, buildSignableELF(2048, 4096).bytes)
161161+ if _, err := verifyAppImageSignature(path); !errors.Is(err, errUnsigned) {
162162+ t.Fatalf("err = %v, want errUnsigned", err)
163163+ }
164164+}
165165+166166+func TestVerifyAppImageSignatureTampered(t *testing.T) {
167167+ entity := newTestEntity(t)
168168+ layout := buildSignableELF(2048, 4096)
169169+ sig, key := signWith(t, layout, entity)
170170+ data := layout.embed(sig, key)
171171+ data[layout.textOff] ^= 0xFF // flip a byte outside the signature sections
172172+173173+ path := writeAppImage(t, data)
174174+ if _, err := verifyAppImageSignature(path); err == nil || errors.Is(err, errUnsigned) {
175175+ t.Fatalf("err = %v, want invalid-signature error", err)
176176+ }
177177+}
178178+179179+func TestVerifyAppImageSignatureMissingKey(t *testing.T) {
180180+ entity := newTestEntity(t)
181181+ layout := buildSignableELF(2048, 4096)
182182+ sig, _ := signWith(t, layout, entity)
183183+ path := writeAppImage(t, layout.embed(sig, nil)) // signature but no key
184184+185185+ if _, err := verifyAppImageSignature(path); err == nil || errors.Is(err, errUnsigned) {
186186+ t.Fatalf("err = %v, want missing-key error", err)
187187+ }
188188+}
189189+190190+func TestCheckSignaturePolicy(t *testing.T) {
191191+ entity := newTestEntity(t)
192192+ layout := buildSignableELF(2048, 4096)
193193+ sig, key := signWith(t, layout, entity)
194194+ fpr := strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint))
195195+196196+ signed := writeAppImage(t, layout.embed(sig, key))
197197+ unsigned := writeAppImage(t, buildSignableELF(2048, 4096).bytes)
198198+199199+ t.Run("signed with no pin establishes trust", func(t *testing.T) {
200200+ got, err := checkSignature(signed, "")
201201+ if err != nil || got != fpr {
202202+ t.Fatalf("got (%q, %v), want (%q, nil)", got, err, fpr)
203203+ }
204204+ })
205205+ t.Run("signed matching pin is accepted", func(t *testing.T) {
206206+ got, err := checkSignature(signed, fpr)
207207+ if err != nil || got != fpr {
208208+ t.Fatalf("got (%q, %v), want (%q, nil)", got, err, fpr)
209209+ }
210210+ })
211211+ t.Run("signed with different pin is refused", func(t *testing.T) {
212212+ if _, err := checkSignature(signed, "DEADBEEF"); err == nil {
213213+ t.Fatal("expected refusal on key change")
214214+ }
215215+ })
216216+ t.Run("unsigned is refused once a key is pinned", func(t *testing.T) {
217217+ if _, err := checkSignature(unsigned, fpr); err == nil {
218218+ t.Fatal("expected refusal of unsigned update over a pinned key")
219219+ }
220220+ })
221221+ t.Run("unsigned with no pin stays unpinned", func(t *testing.T) {
222222+ got, err := checkSignature(unsigned, "")
223223+ if err != nil || got != "" {
224224+ t.Fatalf("got (%q, %v), want (\"\", nil)", got, err)
225225+ }
226226+ })
227227+}
228228+229229+// TestVerifyAppImageSignatureGPGInterop proves appherder verifies signatures
230230+// produced the same way appimagetool produces them (gpg/gpgme: an armored
231231+// detached signature over the lowercase hex digest). It is skipped where gpg is
232232+// unavailable.
233233+func TestVerifyAppImageSignatureGPGInterop(t *testing.T) {
234234+ gpg, err := exec.LookPath("gpg")
235235+ if err != nil {
236236+ t.Skip("gpg not installed")
237237+ }
238238+ home := t.TempDir()
239239+ run := func(input []byte, args ...string) ([]byte, error) {
240240+ cmd := exec.Command(gpg, append([]string{"--batch", "--no-tty"}, args...)...)
241241+ cmd.Env = append(os.Environ(), "GNUPGHOME="+home)
242242+ if input != nil {
243243+ cmd.Stdin = bytes.NewReader(input)
244244+ }
245245+ return cmd.Output()
246246+ }
247247+ if _, err := run(nil, "--pinentry-mode", "loopback", "--passphrase", "",
248248+ "--quick-generate-key", "AppHerder Interop <interop@appherder.local>", "rsa2048", "sign", "0"); err != nil {
249249+ t.Skipf("gpg key generation failed: %v", err)
250250+ }
251251+252252+ colons, err := run(nil, "--list-keys", "--with-colons")
253253+ if err != nil {
254254+ t.Fatalf("list keys: %v", err)
255255+ }
256256+ var fpr string
257257+ for line := range strings.SplitSeq(string(colons), "\n") {
258258+ if fields := strings.Split(line, ":"); fields[0] == "fpr" {
259259+ fpr = fields[9]
260260+ break
261261+ }
262262+ }
263263+264264+ layout := buildSignableELF(2048, 4096)
265265+ sum := sha256.Sum256(layout.bytes)
266266+ sig, err := run([]byte(hex.EncodeToString(sum[:])), "--pinentry-mode", "loopback", "--passphrase", "",
267267+ "--armor", "--detach-sign", "--output", "-")
268268+ if err != nil {
269269+ t.Fatalf("gpg sign: %v", err)
270270+ }
271271+ key, err := run(nil, "--armor", "--export")
272272+ if err != nil {
273273+ t.Fatalf("gpg export: %v", err)
274274+ }
275275+276276+ path := writeAppImage(t, layout.embed(sig, key))
277277+ got, err := verifyAppImageSignature(path)
278278+ if err != nil {
279279+ t.Fatalf("verify gpg-signed AppImage: %v", err)
280280+ }
281281+ if got != fpr {
282282+ t.Fatalf("fingerprint = %s, want %s", got, fpr)
283283+ }
284284+}