···1010 "github.com/alyraffauf/goxdgdesktop/desktopfile"
1111)
12121313-func (a App) Install(appimage string) (appName string, err error) {
1313+func (a App) Install(appimage string) (string, error) {
1414+ return a.install(appimage, expectedChecksum{})
1515+}
1616+1717+func (a App) install(appimage string, want expectedChecksum) (appName string, err error) {
1418 appimage, err = filepath.Abs(appimage)
1519 if err != nil {
1620 return "", fmt.Errorf("resolve AppImage path %q: %w", appimage, err)
···3741 icon := resolveIcon(fsys)
3842 appName = deriveAppName(desktop, desktopName, appimage)
39434040- // Enforce the signature trust policy before any filesystem writes, so a
4141- // refused AppImage installs nothing.
4242- pin, err := checkSignature(appimage, a.pinnedSigningKey(appName))
4444+ // Verify integrity and signature before any filesystem writes, so a refused
4545+ // AppImage installs nothing.
4646+ pin, err := verifyAppImage(appimage, a.pinnedSigningKey(appName), want)
4347 if err != nil {
4448 return "", err
4549 }
···103107 return "", err
104108 }
105109 defer os.Remove(tmpName)
106106- return a.Install(tmpName)
110110+ return a.install(tmpName, expectedChecksum{})
107111}
108112109113// deriveAppName picks the canonical install name, matching GearLever so the
+72-49
internal/appherder/signature.go
···77 "encoding/hex"
88 "errors"
99 "fmt"
1010+ "hash"
1011 "io"
1112 "os"
1213 "path/filepath"
···2930 desktopSigningKey = "X-AppHerder-SigningKey"
3031)
31323232-// errUnsigned reports that an AppImage carries no usable embedded signature.
3333-var errUnsigned = errors.New("AppImage is not signed")
3434-3533// byteRange is a half-open [start, start+length) span of a file.
3634type byteRange struct{ start, length int64 }
37353636+// expectedChecksum is a source-advertised hash to verify a download against. The
3737+// zero value means the source provided none.
3838+type expectedChecksum struct {
3939+ hex string
4040+ hasher hash.Hash
4141+}
4242+4343+func (c expectedChecksum) set() bool { return c.hex != "" }
4444+4545+// matches reports whether the bytes fed to c.hasher hash to the advertised value.
4646+func (c expectedChecksum) matches() bool {
4747+ return strings.EqualFold(hex.EncodeToString(c.hasher.Sum(nil)), c.hex)
4848+}
4949+3850// sectionData returns the named ELF section's contents with NUL padding trimmed,
3951// and the byte range it occupies. ok is false when the section is absent or
4052// holds no file bytes.
···5163}
52645365// readSignatureSections returns the .sha256_sig and .sig_key contents and the
5454-// byte ranges they occupy, which the digest zeroes.
6666+// byte ranges they occupy, which the signing digest zeroes.
5567func readSignatureSections(file string) (sig, key []byte, zero []byteRange, err error) {
5668 elfFile, err := elf.Open(file)
5769 if err != nil {
···7688 return sig, key, zero, nil
7789}
78907979-// 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) {
9191+// hashFile reads file once, writing each chunk to every non-nil hasher: raw
9292+// receives the unmodified bytes (for a checksum), signed receives them with the
9393+// zero ranges NUL'd (for appimagetool's signing digest).
9494+func hashFile(file string, zero []byteRange, raw, signed hash.Hash) error {
8295 f, err := os.Open(file)
8396 if err != nil {
8484- return "", fmt.Errorf("open AppImage %s: %w", file, err)
9797+ return fmt.Errorf("open AppImage %s: %w", file, err)
8598 }
8699 defer f.Close()
871008888- hasher := sha256.New()
89101 buf := make([]byte, 64*1024)
90102 var pos int64
91103 for {
92104 n, err := f.Read(buf)
93105 if n > 0 {
94106 chunk := buf[:n]
9595- zeroOverlaps(chunk, pos, zero)
9696- hasher.Write(chunk)
107107+ if raw != nil {
108108+ raw.Write(chunk)
109109+ }
110110+ if signed != nil {
111111+ zeroOverlaps(chunk, pos, zero)
112112+ signed.Write(chunk)
113113+ }
97114 pos += int64(n)
98115 }
99116 if err == io.EOF {
100100- break
117117+ return nil
101118 }
102119 if err != nil {
103103- return "", fmt.Errorf("read AppImage %s: %w", file, err)
120120+ return fmt.Errorf("read AppImage %s: %w", file, err)
104121 }
105122 }
106106- return hex.EncodeToString(hasher.Sum(nil)), nil
107123}
108124109125// zeroOverlaps NULs the bytes of chunk (which begins at file offset pos) that
···117133 }
118134}
119135120120-// 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) {
136136+// verifyAppImage checks a freshly obtained AppImage before install, in a single
137137+// read pass: download integrity against want (the source's advertised checksum,
138138+// when present) and authenticity against the key pinned for the app. It returns
139139+// the fingerprint to record going forward.
140140+//
141141+// Trust policy: with no pin, the first valid signature is pinned (trust on first
142142+// use). Once pinned, every update must be signed by that key; an unsigned,
143143+// invalid, or re-keyed AppImage is refused, and changing trust means uninstall
144144+// then reinstall.
145145+func verifyAppImage(file, pinned string, want expectedChecksum) (fingerprint string, err error) {
124146 sig, key, zero, err := readSignatureSections(file)
125147 if err != nil {
126148 return "", err
127149 }
128128- if len(bytes.TrimSpace(sig)) == 0 {
129129- return "", errUnsigned
150150+ signed := len(bytes.TrimSpace(sig)) > 0
151151+152152+ // Single read pass: hash only what we check. rawHash takes the unmodified
153153+ // bytes for the checksum, signHash the section-zeroed bytes for the digest.
154154+ var rawHash, signHash hash.Hash
155155+ if want.set() {
156156+ rawHash = want.hasher
157157+ }
158158+ if signed {
159159+ signHash = sha256.New()
160160+ }
161161+ if rawHash != nil || signHash != nil {
162162+ if err := hashFile(file, zero, rawHash, signHash); err != nil {
163163+ return "", err
164164+ }
165165+ }
166166+167167+ if want.set() && !want.matches() {
168168+ return "", errors.New("downloaded AppImage failed checksum verification")
169169+ }
170170+171171+ if !signed {
172172+ if pinned != "" {
173173+ return "", fmt.Errorf("refusing unsigned AppImage: a signing key is pinned (%s); uninstall and reinstall to trust a different build", pinned)
174174+ }
175175+ return "", nil
130176 }
131177 if len(bytes.TrimSpace(key)) == 0 {
132178 return "", errors.New("AppImage is signed but carries no public key")
133179 }
134134-135180 keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(key))
136181 if err != nil {
137182 return "", fmt.Errorf("read embedded signing key: %w", err)
138183 }
139139- digest, err := signedDigest(file, zero)
140140- if err != nil {
141141- return "", err
142142- }
184184+ digest := hex.EncodeToString(signHash.Sum(nil))
143185 signer, err := openpgp.CheckArmoredDetachedSignature(keyring, strings.NewReader(digest), bytes.NewReader(sig), nil)
144186 if err != nil {
145187 return "", fmt.Errorf("invalid AppImage signature: %w", err)
146188 }
147147- return fmt.Sprintf("%X", signer.PrimaryKey.Fingerprint), nil
189189+ fingerprint = fmt.Sprintf("%X", signer.PrimaryKey.Fingerprint)
190190+ if pinned != "" && !strings.EqualFold(pinned, fingerprint) {
191191+ return "", fmt.Errorf("refusing AppImage: signing key changed (pinned %s, got %s); uninstall and reinstall to trust the new key", pinned, fingerprint)
192192+ }
193193+ return fingerprint, nil
148194}
149195150196// pinnedSigningKey returns the fingerprint appherder pinned for appName, or "".
···156202 fingerprint, _ := desktop.Get(desktopEntrySection, desktopSigningKey)
157203 return fingerprint
158204}
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-}
···6565 return false, nil
6666}
67676868-// verifyDownload checks a freshly downloaded file against the release's
6969-// checksum. With no checksum there is nothing to verify and it returns nil.
7070-func (r Release) verifyDownload(file string) error {
6868+// expectedChecksum returns the source's advertised hash for verifying a
6969+// download, or the zero value when the source provides none.
7070+func (r Release) expectedChecksum() expectedChecksum {
7171 want, hasher, ok := r.checksum()
7272 if !ok {
7373- return nil
7373+ return expectedChecksum{}
7474 }
7575- sum, err := fileSum(file, hasher)
7676- if err != nil {
7777- return err
7878- }
7979- if !strings.EqualFold(hex.EncodeToString(sum), want) {
8080- return fmt.Errorf("downloaded AppImage failed checksum verification")
8181- }
8282- return nil
7575+ return expectedChecksum{hex: want, hasher: hasher}
8376}
84778578// Source resolves the latest available build of an installed app.