A shepherd for your Appimages.
0

Configure Feed

Select the types of activity you want to include in your feed.

only read files once for hash and sig checks

Aly Raffauf (Jun 18, 2026, 10:46 AM EDT) 32af0698 8d00026d

+154 -104
+9 -5
internal/appherder/install.go
··· 10 10 "github.com/alyraffauf/goxdgdesktop/desktopfile" 11 11 ) 12 12 13 - func (a App) Install(appimage string) (appName string, err error) { 13 + func (a App) Install(appimage string) (string, error) { 14 + return a.install(appimage, expectedChecksum{}) 15 + } 16 + 17 + func (a App) install(appimage string, want expectedChecksum) (appName string, err error) { 14 18 appimage, err = filepath.Abs(appimage) 15 19 if err != nil { 16 20 return "", fmt.Errorf("resolve AppImage path %q: %w", appimage, err) ··· 37 41 icon := resolveIcon(fsys) 38 42 appName = deriveAppName(desktop, desktopName, appimage) 39 43 40 - // Enforce the signature trust policy before any filesystem writes, so a 41 - // refused AppImage installs nothing. 42 - pin, err := checkSignature(appimage, a.pinnedSigningKey(appName)) 44 + // Verify integrity and signature before any filesystem writes, so a refused 45 + // AppImage installs nothing. 46 + pin, err := verifyAppImage(appimage, a.pinnedSigningKey(appName), want) 43 47 if err != nil { 44 48 return "", err 45 49 } ··· 103 107 return "", err 104 108 } 105 109 defer os.Remove(tmpName) 106 - return a.Install(tmpName) 110 + return a.install(tmpName, expectedChecksum{}) 107 111 } 108 112 109 113 // deriveAppName picks the canonical install name, matching GearLever so the
+72 -49
internal/appherder/signature.go
··· 7 7 "encoding/hex" 8 8 "errors" 9 9 "fmt" 10 + "hash" 10 11 "io" 11 12 "os" 12 13 "path/filepath" ··· 29 30 desktopSigningKey = "X-AppHerder-SigningKey" 30 31 ) 31 32 32 - // errUnsigned reports that an AppImage carries no usable embedded signature. 33 - var errUnsigned = errors.New("AppImage is not signed") 34 - 35 33 // byteRange is a half-open [start, start+length) span of a file. 36 34 type byteRange struct{ start, length int64 } 37 35 36 + // expectedChecksum is a source-advertised hash to verify a download against. The 37 + // zero value means the source provided none. 38 + type expectedChecksum struct { 39 + hex string 40 + hasher hash.Hash 41 + } 42 + 43 + func (c expectedChecksum) set() bool { return c.hex != "" } 44 + 45 + // matches reports whether the bytes fed to c.hasher hash to the advertised value. 46 + func (c expectedChecksum) matches() bool { 47 + return strings.EqualFold(hex.EncodeToString(c.hasher.Sum(nil)), c.hex) 48 + } 49 + 38 50 // sectionData returns the named ELF section's contents with NUL padding trimmed, 39 51 // and the byte range it occupies. ok is false when the section is absent or 40 52 // holds no file bytes. ··· 51 63 } 52 64 53 65 // readSignatureSections returns the .sha256_sig and .sig_key contents and the 54 - // byte ranges they occupy, which the digest zeroes. 66 + // byte ranges they occupy, which the signing digest zeroes. 55 67 func readSignatureSections(file string) (sig, key []byte, zero []byteRange, err error) { 56 68 elfFile, err := elf.Open(file) 57 69 if err != nil { ··· 76 88 return sig, key, zero, nil 77 89 } 78 90 79 - // signedDigest returns the lowercase hex SHA-256 that appimagetool signs: the 80 - // file hashed with the bytes in zero replaced by NULs. 81 - func signedDigest(file string, zero []byteRange) (string, error) { 91 + // hashFile reads file once, writing each chunk to every non-nil hasher: raw 92 + // receives the unmodified bytes (for a checksum), signed receives them with the 93 + // zero ranges NUL'd (for appimagetool's signing digest). 94 + func hashFile(file string, zero []byteRange, raw, signed hash.Hash) error { 82 95 f, err := os.Open(file) 83 96 if err != nil { 84 - return "", fmt.Errorf("open AppImage %s: %w", file, err) 97 + return fmt.Errorf("open AppImage %s: %w", file, err) 85 98 } 86 99 defer f.Close() 87 100 88 - hasher := sha256.New() 89 101 buf := make([]byte, 64*1024) 90 102 var pos int64 91 103 for { 92 104 n, err := f.Read(buf) 93 105 if n > 0 { 94 106 chunk := buf[:n] 95 - zeroOverlaps(chunk, pos, zero) 96 - hasher.Write(chunk) 107 + if raw != nil { 108 + raw.Write(chunk) 109 + } 110 + if signed != nil { 111 + zeroOverlaps(chunk, pos, zero) 112 + signed.Write(chunk) 113 + } 97 114 pos += int64(n) 98 115 } 99 116 if err == io.EOF { 100 - break 117 + return nil 101 118 } 102 119 if err != nil { 103 - return "", fmt.Errorf("read AppImage %s: %w", file, err) 120 + return fmt.Errorf("read AppImage %s: %w", file, err) 104 121 } 105 122 } 106 - return hex.EncodeToString(hasher.Sum(nil)), nil 107 123 } 108 124 109 125 // zeroOverlaps NULs the bytes of chunk (which begins at file offset pos) that ··· 117 133 } 118 134 } 119 135 120 - // verifyAppImageSignature verifies an AppImage's embedded signature and returns 121 - // the signer's fingerprint (uppercase hex). It returns errUnsigned when no 122 - // signature is present, or an error when one is present but invalid. 123 - func verifyAppImageSignature(file string) (fingerprint string, err error) { 136 + // verifyAppImage checks a freshly obtained AppImage before install, in a single 137 + // read pass: download integrity against want (the source's advertised checksum, 138 + // when present) and authenticity against the key pinned for the app. It returns 139 + // the fingerprint to record going forward. 140 + // 141 + // Trust policy: with no pin, the first valid signature is pinned (trust on first 142 + // use). Once pinned, every update must be signed by that key; an unsigned, 143 + // invalid, or re-keyed AppImage is refused, and changing trust means uninstall 144 + // then reinstall. 145 + func verifyAppImage(file, pinned string, want expectedChecksum) (fingerprint string, err error) { 124 146 sig, key, zero, err := readSignatureSections(file) 125 147 if err != nil { 126 148 return "", err 127 149 } 128 - if len(bytes.TrimSpace(sig)) == 0 { 129 - return "", errUnsigned 150 + signed := len(bytes.TrimSpace(sig)) > 0 151 + 152 + // Single read pass: hash only what we check. rawHash takes the unmodified 153 + // bytes for the checksum, signHash the section-zeroed bytes for the digest. 154 + var rawHash, signHash hash.Hash 155 + if want.set() { 156 + rawHash = want.hasher 157 + } 158 + if signed { 159 + signHash = sha256.New() 160 + } 161 + if rawHash != nil || signHash != nil { 162 + if err := hashFile(file, zero, rawHash, signHash); err != nil { 163 + return "", err 164 + } 165 + } 166 + 167 + if want.set() && !want.matches() { 168 + return "", errors.New("downloaded AppImage failed checksum verification") 169 + } 170 + 171 + if !signed { 172 + if pinned != "" { 173 + return "", fmt.Errorf("refusing unsigned AppImage: a signing key is pinned (%s); uninstall and reinstall to trust a different build", pinned) 174 + } 175 + return "", nil 130 176 } 131 177 if len(bytes.TrimSpace(key)) == 0 { 132 178 return "", errors.New("AppImage is signed but carries no public key") 133 179 } 134 - 135 180 keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(key)) 136 181 if err != nil { 137 182 return "", fmt.Errorf("read embedded signing key: %w", err) 138 183 } 139 - digest, err := signedDigest(file, zero) 140 - if err != nil { 141 - return "", err 142 - } 184 + digest := hex.EncodeToString(signHash.Sum(nil)) 143 185 signer, err := openpgp.CheckArmoredDetachedSignature(keyring, strings.NewReader(digest), bytes.NewReader(sig), nil) 144 186 if err != nil { 145 187 return "", fmt.Errorf("invalid AppImage signature: %w", err) 146 188 } 147 - return fmt.Sprintf("%X", signer.PrimaryKey.Fingerprint), nil 189 + fingerprint = fmt.Sprintf("%X", signer.PrimaryKey.Fingerprint) 190 + if pinned != "" && !strings.EqualFold(pinned, fingerprint) { 191 + return "", fmt.Errorf("refusing AppImage: signing key changed (pinned %s, got %s); uninstall and reinstall to trust the new key", pinned, fingerprint) 192 + } 193 + return fingerprint, nil 148 194 } 149 195 150 196 // pinnedSigningKey returns the fingerprint appherder pinned for appName, or "". ··· 156 202 fingerprint, _ := desktop.Get(desktopEntrySection, desktopSigningKey) 157 203 return fingerprint 158 204 } 159 - 160 - // checkSignature applies the trust policy to an incoming AppImage given the 161 - // fingerprint pinned for the app ("" if none), returning the fingerprint to keep 162 - // going forward. An app with no pin accepts anything and pins the first valid 163 - // signature (trust on first use). Once pinned, every update must be signed by 164 - // the pinned key: an unsigned, invalid, or differently-keyed AppImage is 165 - // refused. Changing trust is a deliberate act — uninstall, then reinstall. 166 - func checkSignature(file, pinned string) (fingerprint string, err error) { 167 - fpr, err := verifyAppImageSignature(file) 168 - switch { 169 - case errors.Is(err, errUnsigned): 170 - if pinned != "" { 171 - return "", fmt.Errorf("refusing unsigned AppImage: a signing key is pinned (%s); uninstall and reinstall to trust a different build", pinned) 172 - } 173 - return "", nil 174 - case err != nil: 175 - return "", err 176 - } 177 - if pinned != "" && !strings.EqualFold(pinned, fpr) { 178 - return "", fmt.Errorf("refusing AppImage: signing key changed (pinned %s, got %s); uninstall and reinstall to trust the new key", pinned, fpr) 179 - } 180 - return fpr, nil 181 - }
+67 -27
internal/appherder/signature_test.go
··· 5 5 "crypto/sha256" 6 6 "encoding/binary" 7 7 "encoding/hex" 8 - "errors" 9 8 "os" 10 9 "os/exec" 11 10 "path/filepath" ··· 139 138 return path 140 139 } 141 140 142 - func TestVerifyAppImageSignatureValid(t *testing.T) { 141 + // noChecksum is the zero value used when a test does not exercise checksums. 142 + var noChecksum = expectedChecksum{} 143 + 144 + // checksumOf returns an expectedChecksum that matches data. 145 + func checksumOf(data []byte) expectedChecksum { 146 + sum := sha256.Sum256(data) 147 + return expectedChecksum{hex: hex.EncodeToString(sum[:]), hasher: sha256.New()} 148 + } 149 + 150 + func TestVerifyAppImageValid(t *testing.T) { 143 151 entity := newTestEntity(t) 144 152 layout := buildSignableELF(2048, 4096) 145 153 sig, key := signWith(t, layout, entity) 146 154 path := writeAppImage(t, layout.embed(sig, key)) 147 155 148 - got, err := verifyAppImageSignature(path) 156 + got, err := verifyAppImage(path, "", noChecksum) 149 157 if err != nil { 150 158 t.Fatalf("verify: %v", err) 151 159 } ··· 155 163 } 156 164 } 157 165 158 - func TestVerifyAppImageSignatureUnsigned(t *testing.T) { 159 - // Sections present but empty. 166 + func TestVerifyAppImageUnsigned(t *testing.T) { 160 167 path := writeAppImage(t, buildSignableELF(2048, 4096).bytes) 161 - if _, err := verifyAppImageSignature(path); !errors.Is(err, errUnsigned) { 162 - t.Fatalf("err = %v, want errUnsigned", err) 168 + if got, err := verifyAppImage(path, "", noChecksum); err != nil || got != "" { 169 + t.Fatalf("got (%q, %v), want (\"\", nil)", got, err) 163 170 } 164 171 } 165 172 166 - func TestVerifyAppImageSignatureTampered(t *testing.T) { 173 + func TestVerifyAppImageTampered(t *testing.T) { 167 174 entity := newTestEntity(t) 168 175 layout := buildSignableELF(2048, 4096) 169 176 sig, key := signWith(t, layout, entity) ··· 171 178 data[layout.textOff] ^= 0xFF // flip a byte outside the signature sections 172 179 173 180 path := writeAppImage(t, data) 174 - if _, err := verifyAppImageSignature(path); err == nil || errors.Is(err, errUnsigned) { 175 - t.Fatalf("err = %v, want invalid-signature error", err) 181 + if _, err := verifyAppImage(path, "", noChecksum); err == nil { 182 + t.Fatal("expected invalid-signature error") 176 183 } 177 184 } 178 185 179 - func TestVerifyAppImageSignatureMissingKey(t *testing.T) { 186 + func TestVerifyAppImageMissingKey(t *testing.T) { 180 187 entity := newTestEntity(t) 181 188 layout := buildSignableELF(2048, 4096) 182 189 sig, _ := signWith(t, layout, entity) 183 190 path := writeAppImage(t, layout.embed(sig, nil)) // signature but no key 184 191 185 - if _, err := verifyAppImageSignature(path); err == nil || errors.Is(err, errUnsigned) { 186 - t.Fatalf("err = %v, want missing-key error", err) 192 + if _, err := verifyAppImage(path, "", noChecksum); err == nil { 193 + t.Fatal("expected missing-key error") 187 194 } 188 195 } 189 196 190 - func TestCheckSignaturePolicy(t *testing.T) { 197 + func TestVerifyAppImagePolicy(t *testing.T) { 191 198 entity := newTestEntity(t) 192 199 layout := buildSignableELF(2048, 4096) 193 200 sig, key := signWith(t, layout, entity) ··· 197 204 unsigned := writeAppImage(t, buildSignableELF(2048, 4096).bytes) 198 205 199 206 t.Run("signed with no pin establishes trust", func(t *testing.T) { 200 - got, err := checkSignature(signed, "") 201 - if err != nil || got != fpr { 207 + if got, err := verifyAppImage(signed, "", noChecksum); err != nil || got != fpr { 202 208 t.Fatalf("got (%q, %v), want (%q, nil)", got, err, fpr) 203 209 } 204 210 }) 205 211 t.Run("signed matching pin is accepted", func(t *testing.T) { 206 - got, err := checkSignature(signed, fpr) 207 - if err != nil || got != fpr { 212 + if got, err := verifyAppImage(signed, fpr, noChecksum); err != nil || got != fpr { 208 213 t.Fatalf("got (%q, %v), want (%q, nil)", got, err, fpr) 209 214 } 210 215 }) 211 216 t.Run("signed with different pin is refused", func(t *testing.T) { 212 - if _, err := checkSignature(signed, "DEADBEEF"); err == nil { 217 + if _, err := verifyAppImage(signed, "DEADBEEF", noChecksum); err == nil { 213 218 t.Fatal("expected refusal on key change") 214 219 } 215 220 }) 216 221 t.Run("unsigned is refused once a key is pinned", func(t *testing.T) { 217 - if _, err := checkSignature(unsigned, fpr); err == nil { 222 + if _, err := verifyAppImage(unsigned, fpr, noChecksum); err == nil { 218 223 t.Fatal("expected refusal of unsigned update over a pinned key") 219 224 } 220 225 }) 221 226 t.Run("unsigned with no pin stays unpinned", func(t *testing.T) { 222 - got, err := checkSignature(unsigned, "") 223 - if err != nil || got != "" { 227 + if got, err := verifyAppImage(unsigned, "", noChecksum); err != nil || got != "" { 224 228 t.Fatalf("got (%q, %v), want (\"\", nil)", got, err) 225 229 } 226 230 }) 227 231 } 228 232 229 - // TestVerifyAppImageSignatureGPGInterop proves appherder verifies signatures 230 - // produced the same way appimagetool produces them (gpg/gpgme: an armored 231 - // detached signature over the lowercase hex digest). It is skipped where gpg is 233 + func TestVerifyAppImageChecksum(t *testing.T) { 234 + entity := newTestEntity(t) 235 + layout := buildSignableELF(2048, 4096) 236 + sig, key := signWith(t, layout, entity) 237 + signedData := layout.embed(sig, key) 238 + unsignedData := buildSignableELF(2048, 4096).bytes 239 + 240 + wrong := func() expectedChecksum { 241 + return expectedChecksum{hex: hex.EncodeToString(make([]byte, 32)), hasher: sha256.New()} 242 + } 243 + 244 + cases := []struct { 245 + name string 246 + data []byte 247 + want expectedChecksum 248 + ok bool 249 + }{ 250 + {"signed matching", signedData, checksumOf(signedData), true}, 251 + {"signed mismatched", signedData, wrong(), false}, 252 + {"unsigned matching", unsignedData, checksumOf(unsignedData), true}, 253 + {"unsigned mismatched", unsignedData, wrong(), false}, 254 + } 255 + for _, tc := range cases { 256 + t.Run(tc.name, func(t *testing.T) { 257 + path := writeAppImage(t, tc.data) 258 + _, err := verifyAppImage(path, "", tc.want) 259 + if tc.ok && err != nil { 260 + t.Fatalf("verify: %v", err) 261 + } 262 + if !tc.ok && err == nil { 263 + t.Fatal("expected checksum failure") 264 + } 265 + }) 266 + } 267 + } 268 + 269 + // TestVerifyAppImageGPGInterop proves appherder verifies signatures produced 270 + // the same way appimagetool produces them (gpg/gpgme: an armored detached 271 + // signature over the lowercase hex digest). It is skipped where gpg is 232 272 // unavailable. 233 - func TestVerifyAppImageSignatureGPGInterop(t *testing.T) { 273 + func TestVerifyAppImageGPGInterop(t *testing.T) { 234 274 gpg, err := exec.LookPath("gpg") 235 275 if err != nil { 236 276 t.Skip("gpg not installed") ··· 274 314 } 275 315 276 316 path := writeAppImage(t, layout.embed(sig, key)) 277 - got, err := verifyAppImageSignature(path) 317 + got, err := verifyAppImage(path, "", noChecksum) 278 318 if err != nil { 279 319 t.Fatalf("verify gpg-signed AppImage: %v", err) 280 320 }
+5 -12
internal/appherder/source.go
··· 65 65 return false, nil 66 66 } 67 67 68 - // verifyDownload checks a freshly downloaded file against the release's 69 - // checksum. With no checksum there is nothing to verify and it returns nil. 70 - func (r Release) verifyDownload(file string) error { 68 + // expectedChecksum returns the source's advertised hash for verifying a 69 + // download, or the zero value when the source provides none. 70 + func (r Release) expectedChecksum() expectedChecksum { 71 71 want, hasher, ok := r.checksum() 72 72 if !ok { 73 - return nil 73 + return expectedChecksum{} 74 74 } 75 - sum, err := fileSum(file, hasher) 76 - if err != nil { 77 - return err 78 - } 79 - if !strings.EqualFold(hex.EncodeToString(sum), want) { 80 - return fmt.Errorf("downloaded AppImage failed checksum verification") 81 - } 82 - return nil 75 + return expectedChecksum{hex: want, hasher: hasher} 83 76 } 84 77 85 78 // Source resolves the latest available build of an installed app.
-6
internal/appherder/source_test.go
··· 21 21 if ok, err := rel.localMatches(file); err != nil || !ok { 22 22 t.Fatalf("localMatches = %v, %v; want true", ok, err) 23 23 } 24 - if err := rel.verifyDownload(file); err != nil { 25 - t.Fatalf("verifyDownload = %v; want nil", err) 26 - } 27 24 28 25 stale := Release{SHA1: hex.EncodeToString(make([]byte, 20))} 29 26 if ok, _ := stale.localMatches(file); ok { 30 27 t.Fatal("localMatches = true for mismatched sha1") 31 - } 32 - if err := stale.verifyDownload(file); err == nil { 33 - t.Fatal("verifyDownload = nil for mismatched sha1") 34 28 } 35 29 } 36 30
+1 -5
internal/appherder/upgrade.go
··· 116 116 } 117 117 defer os.Remove(tmpName) 118 118 119 - if err := rel.verifyDownload(tmpName); err != nil { 120 - return err 121 - } 122 - 123 - _, err = a.Install(tmpName) 119 + _, err = a.install(tmpName, rel.expectedChecksum()) 124 120 return err 125 121 } 126 122