A shepherd for your Appimages.
0

Configure Feed

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

add signature verification

Aly Raffauf (Jun 18, 2026, 10:28 AM EDT) 8d00026d 3b900e5a

+503 -19
+9 -5
go.mod
··· 1 1 module github.com/alyraffauf/appherder 2 2 3 - go 1.24.0 3 + go 1.25.0 4 4 5 5 require ( 6 6 github.com/CalebQ42/squashfs v1.4.1 7 + github.com/ProtonMail/go-crypto v1.4.1 7 8 github.com/alyraffauf/goxdgdesktop v0.1.0 8 9 github.com/spf13/cobra v1.10.2 9 10 ) 10 11 11 12 require ( 13 + github.com/cloudflare/circl v1.6.3 // indirect 12 14 github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 15 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 14 - github.com/klauspost/compress v1.18.0 // indirect 16 + github.com/klauspost/compress v1.18.6 // indirect 15 17 github.com/mikelolasagasti/xz v1.0.1 // indirect 16 - github.com/pierrec/lz4/v4 v4.1.22 // indirect 18 + github.com/pierrec/lz4/v4 v4.1.27 // indirect 17 19 github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e // indirect 18 - github.com/spf13/pflag v1.0.9 // indirect 19 - github.com/ulikunitz/xz v0.5.12 // indirect 20 + github.com/spf13/pflag v1.0.10 // indirect 21 + github.com/ulikunitz/xz v0.5.15 // indirect 22 + golang.org/x/crypto v0.53.0 // indirect 23 + golang.org/x/sys v0.46.0 // indirect 20 24 )
+16 -7
go.sum
··· 1 1 github.com/CalebQ42/squashfs v1.4.1 h1:tBcFMQSRQvWcY50e9r9cv2uVzNf06fcUhly0LeZg8bI= 2 2 github.com/CalebQ42/squashfs v1.4.1/go.mod h1:/As5wg6ScFFaab9SaNFNHyCOsd73Q5IFPOFJCVnwWzQ= 3 + github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= 4 + github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= 3 5 github.com/alyraffauf/goxdgdesktop v0.1.0 h1:n5+3AjF2ntvON6W0nN4O5bHDmcjYuFtKVCwBr6ekqj0= 4 6 github.com/alyraffauf/goxdgdesktop v0.1.0/go.mod h1:K91gqx5usBl0hjhNUxHcBSRaOaU8gaggPxfKpCnTXzQ= 7 + github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= 8 + github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 5 9 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 6 10 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 7 11 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 8 12 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= 9 13 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 10 - github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 11 - github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 14 + github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= 15 + github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= 12 16 github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= 13 17 github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= 14 - github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= 15 - github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 18 + github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= 19 + github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= 16 20 github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs= 17 21 github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M= 18 22 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 19 23 github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 20 24 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 21 - github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 22 25 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 23 - github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= 24 - github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 26 + github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 27 + github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 28 + github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= 29 + github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 25 30 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 31 + golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= 32 + golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 33 + golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= 34 + golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 26 35 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+10
internal/appherder/install.go
··· 37 37 icon := resolveIcon(fsys) 38 38 appName = deriveAppName(desktop, desktopName, appimage) 39 39 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)) 43 + if err != nil { 44 + return "", err 45 + } 46 + 40 47 // No desktop file inside the AppImage: synthesize a terminal launcher so 41 48 // CLI apps still get a menu entry and are tracked by managedApps. 42 49 if desktop == nil { ··· 49 56 // Patch in memory before any filesystem writes so a failure here installs nothing. 50 57 if err := a.patchDesktopFile(desktop, appName, icon != ""); err != nil { 51 58 return "", err 59 + } 60 + if pin != "" { 61 + desktop.Set(desktopEntrySection, desktopSigningKey, pin) 52 62 } 53 63 54 64 // Roll back written files on a later failure rather than leaving a half-installed app.
+181
internal/appherder/signature.go
··· 1 + package appherder 2 + 3 + import ( 4 + "bytes" 5 + "crypto/sha256" 6 + "debug/elf" 7 + "encoding/hex" 8 + "errors" 9 + "fmt" 10 + "io" 11 + "os" 12 + "path/filepath" 13 + "strings" 14 + 15 + "github.com/ProtonMail/go-crypto/openpgp" 16 + "github.com/alyraffauf/goxdgdesktop/desktopfile" 17 + ) 18 + 19 + // appimagetool stores an AppImage's optional OpenPGP signature in two ELF 20 + // sections: .sha256_sig (armored detached signature) and .sig_key (armored 21 + // public key). The signed message is the lowercase hex SHA-256 of the whole 22 + // file with both sections zeroed. 23 + const ( 24 + sigSection = ".sha256_sig" 25 + keySection = ".sig_key" 26 + 27 + // desktopSigningKey pins an app's trusted signing-key fingerprint in its 28 + // managed launcher, recorded on the first signed install (trust on first use). 29 + desktopSigningKey = "X-AppHerder-SigningKey" 30 + ) 31 + 32 + // errUnsigned reports that an AppImage carries no usable embedded signature. 33 + var errUnsigned = errors.New("AppImage is not signed") 34 + 35 + // byteRange is a half-open [start, start+length) span of a file. 36 + type byteRange struct{ start, length int64 } 37 + 38 + // sectionData returns the named ELF section's contents with NUL padding trimmed, 39 + // and the byte range it occupies. ok is false when the section is absent or 40 + // holds no file bytes. 41 + func sectionData(f *elf.File, name string) (data []byte, span byteRange, ok bool, err error) { 42 + section := f.Section(name) 43 + if section == nil || section.Type == elf.SHT_NOBITS { 44 + return nil, byteRange{}, false, nil 45 + } 46 + data, err = section.Data() 47 + if err != nil { 48 + return nil, byteRange{}, false, fmt.Errorf("read %s: %w", name, err) 49 + } 50 + return bytes.TrimRight(data, "\x00"), byteRange{int64(section.Offset), int64(section.Size)}, true, nil 51 + } 52 + 53 + // readSignatureSections returns the .sha256_sig and .sig_key contents and the 54 + // byte ranges they occupy, which the digest zeroes. 55 + func readSignatureSections(file string) (sig, key []byte, zero []byteRange, err error) { 56 + elfFile, err := elf.Open(file) 57 + if err != nil { 58 + return nil, nil, nil, fmt.Errorf("open AppImage %s: %w", file, err) 59 + } 60 + defer elfFile.Close() 61 + 62 + sig, sigSpan, sigOK, err := sectionData(elfFile, sigSection) 63 + if err != nil { 64 + return nil, nil, nil, err 65 + } 66 + key, keySpan, keyOK, err := sectionData(elfFile, keySection) 67 + if err != nil { 68 + return nil, nil, nil, err 69 + } 70 + if sigOK { 71 + zero = append(zero, sigSpan) 72 + } 73 + if keyOK { 74 + zero = append(zero, keySpan) 75 + } 76 + return sig, key, zero, nil 77 + } 78 + 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) { 82 + f, err := os.Open(file) 83 + if err != nil { 84 + return "", fmt.Errorf("open AppImage %s: %w", file, err) 85 + } 86 + defer f.Close() 87 + 88 + hasher := sha256.New() 89 + buf := make([]byte, 64*1024) 90 + var pos int64 91 + for { 92 + n, err := f.Read(buf) 93 + if n > 0 { 94 + chunk := buf[:n] 95 + zeroOverlaps(chunk, pos, zero) 96 + hasher.Write(chunk) 97 + pos += int64(n) 98 + } 99 + if err == io.EOF { 100 + break 101 + } 102 + if err != nil { 103 + return "", fmt.Errorf("read AppImage %s: %w", file, err) 104 + } 105 + } 106 + return hex.EncodeToString(hasher.Sum(nil)), nil 107 + } 108 + 109 + // zeroOverlaps NULs the bytes of chunk (which begins at file offset pos) that 110 + // fall within any range in zero. 111 + func zeroOverlaps(chunk []byte, pos int64, zero []byteRange) { 112 + end := pos + int64(len(chunk)) 113 + for _, r := range zero { 114 + for i := max(pos, r.start); i < min(end, r.start+r.length); i++ { 115 + chunk[i-pos] = 0 116 + } 117 + } 118 + } 119 + 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) { 124 + sig, key, zero, err := readSignatureSections(file) 125 + if err != nil { 126 + return "", err 127 + } 128 + if len(bytes.TrimSpace(sig)) == 0 { 129 + return "", errUnsigned 130 + } 131 + if len(bytes.TrimSpace(key)) == 0 { 132 + return "", errors.New("AppImage is signed but carries no public key") 133 + } 134 + 135 + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(key)) 136 + if err != nil { 137 + return "", fmt.Errorf("read embedded signing key: %w", err) 138 + } 139 + digest, err := signedDigest(file, zero) 140 + if err != nil { 141 + return "", err 142 + } 143 + signer, err := openpgp.CheckArmoredDetachedSignature(keyring, strings.NewReader(digest), bytes.NewReader(sig), nil) 144 + if err != nil { 145 + return "", fmt.Errorf("invalid AppImage signature: %w", err) 146 + } 147 + return fmt.Sprintf("%X", signer.PrimaryKey.Fingerprint), nil 148 + } 149 + 150 + // pinnedSigningKey returns the fingerprint appherder pinned for appName, or "". 151 + func (a App) pinnedSigningKey(appName string) string { 152 + desktop, err := desktopfile.Read(filepath.Join(a.applicationsDir, appName+".desktop")) 153 + if err != nil { 154 + return "" 155 + } 156 + fingerprint, _ := desktop.Get(desktopEntrySection, desktopSigningKey) 157 + return fingerprint 158 + } 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 + }
+284
internal/appherder/signature_test.go
··· 1 + package appherder 2 + 3 + import ( 4 + "bytes" 5 + "crypto/sha256" 6 + "encoding/binary" 7 + "encoding/hex" 8 + "errors" 9 + "os" 10 + "os/exec" 11 + "path/filepath" 12 + "strings" 13 + "testing" 14 + 15 + "github.com/ProtonMail/go-crypto/openpgp" 16 + "github.com/ProtonMail/go-crypto/openpgp/armor" 17 + ) 18 + 19 + // elfLayout is a minimal ELF carrying empty .sha256_sig and .sig_key sections, 20 + // ready to have a signature embedded. 21 + type elfLayout struct { 22 + bytes []byte 23 + sigOff, sigCap int64 24 + keyOff, keyCap int64 25 + textOff int64 // a content byte safe to tamper with 26 + } 27 + 28 + // buildSignableELF lays out a 64-bit little-endian ELF with zero-filled 29 + // signature sections, mirroring what appimagetool reserves before signing. 30 + func buildSignableELF(sigCap, keyCap int) elfLayout { 31 + le := binary.LittleEndian 32 + const hdr, shentsize = 64, 64 33 + textData := bytes.Repeat([]byte{0xAA}, 16) 34 + 35 + names := []string{"", ".text", ".sha256_sig", ".sig_key", ".shstrtab"} 36 + var shstr bytes.Buffer 37 + nameOff := map[string]uint32{} 38 + for _, name := range names { 39 + nameOff[name] = uint32(shstr.Len()) 40 + shstr.WriteString(name) 41 + shstr.WriteByte(0) 42 + } 43 + 44 + off := int64(hdr) 45 + textOff := off 46 + off += int64(len(textData)) 47 + sigOff := off 48 + off += int64(sigCap) 49 + keyOff := off 50 + off += int64(keyCap) 51 + shstrOff := off 52 + off += int64(shstr.Len()) 53 + if rem := off % 8; rem != 0 { 54 + off += 8 - rem 55 + } 56 + shoff := off 57 + 58 + sections := []struct { 59 + name string 60 + typ uint32 61 + off, size int64 62 + }{ 63 + {"", 0, 0, 0}, 64 + {".text", 1, textOff, int64(len(textData))}, // SHT_PROGBITS 65 + {".sha256_sig", 1, sigOff, int64(sigCap)}, // SHT_PROGBITS 66 + {".sig_key", 1, keyOff, int64(keyCap)}, // SHT_PROGBITS 67 + {".shstrtab", 3, shstrOff, int64(shstr.Len())}, // SHT_STRTAB 68 + } 69 + 70 + buf := make([]byte, shoff+int64(len(sections))*shentsize) 71 + copy(buf, []byte{0x7f, 'E', 'L', 'F', 2, 1, 1, 0}) 72 + le.PutUint16(buf[16:], 2) // ET_EXEC 73 + le.PutUint16(buf[18:], 0x3e) // x86-64 74 + le.PutUint32(buf[20:], 1) // version 75 + le.PutUint64(buf[40:], uint64(shoff)) 76 + le.PutUint16(buf[52:], hdr) 77 + le.PutUint16(buf[58:], shentsize) 78 + le.PutUint16(buf[60:], uint16(len(sections))) 79 + le.PutUint16(buf[62:], 4) // shstrndx -> .shstrtab 80 + 81 + copy(buf[textOff:], textData) 82 + copy(buf[shstrOff:], shstr.Bytes()) 83 + for i, s := range sections { 84 + base := shoff + int64(i)*shentsize 85 + le.PutUint32(buf[base:], nameOff[s.name]) 86 + le.PutUint32(buf[base+4:], s.typ) 87 + le.PutUint64(buf[base+24:], uint64(s.off)) 88 + le.PutUint64(buf[base+32:], uint64(s.size)) 89 + } 90 + return elfLayout{buf, sigOff, int64(sigCap), keyOff, int64(keyCap), textOff} 91 + } 92 + 93 + // embed writes sig and key into the reserved sections (zero-padded). 94 + func (l elfLayout) embed(sig, key []byte) []byte { 95 + out := append([]byte(nil), l.bytes...) 96 + copy(out[l.sigOff:l.sigOff+l.sigCap], sig) 97 + copy(out[l.keyOff:l.keyOff+l.keyCap], key) 98 + return out 99 + } 100 + 101 + // signWith signs the layout's signed digest with entity and returns the armored 102 + // detached signature plus the armored public key. 103 + func signWith(t *testing.T, l elfLayout, entity *openpgp.Entity) (sig, key []byte) { 104 + t.Helper() 105 + sum := sha256.Sum256(l.bytes) // sections are still zero-filled 106 + digest := hex.EncodeToString(sum[:]) 107 + 108 + var sigBuf bytes.Buffer 109 + if err := openpgp.ArmoredDetachSign(&sigBuf, entity, strings.NewReader(digest), nil); err != nil { 110 + t.Fatalf("sign: %v", err) 111 + } 112 + var keyBuf bytes.Buffer 113 + w, err := armor.Encode(&keyBuf, openpgp.PublicKeyType, nil) 114 + if err != nil { 115 + t.Fatalf("armor key: %v", err) 116 + } 117 + if err := entity.Serialize(w); err != nil { 118 + t.Fatalf("serialize key: %v", err) 119 + } 120 + w.Close() 121 + return sigBuf.Bytes(), keyBuf.Bytes() 122 + } 123 + 124 + func newTestEntity(t *testing.T) *openpgp.Entity { 125 + t.Helper() 126 + entity, err := openpgp.NewEntity("AppHerder Test", "", "test@appherder.local", nil) 127 + if err != nil { 128 + t.Fatalf("new entity: %v", err) 129 + } 130 + return entity 131 + } 132 + 133 + func writeAppImage(t *testing.T, data []byte) string { 134 + t.Helper() 135 + path := filepath.Join(t.TempDir(), "App.AppImage") 136 + if err := os.WriteFile(path, data, 0o755); err != nil { 137 + t.Fatal(err) 138 + } 139 + return path 140 + } 141 + 142 + func TestVerifyAppImageSignatureValid(t *testing.T) { 143 + entity := newTestEntity(t) 144 + layout := buildSignableELF(2048, 4096) 145 + sig, key := signWith(t, layout, entity) 146 + path := writeAppImage(t, layout.embed(sig, key)) 147 + 148 + got, err := verifyAppImageSignature(path) 149 + if err != nil { 150 + t.Fatalf("verify: %v", err) 151 + } 152 + want := strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint)) 153 + if got != want { 154 + t.Fatalf("fingerprint = %s, want %s", got, want) 155 + } 156 + } 157 + 158 + func TestVerifyAppImageSignatureUnsigned(t *testing.T) { 159 + // Sections present but empty. 160 + path := writeAppImage(t, buildSignableELF(2048, 4096).bytes) 161 + if _, err := verifyAppImageSignature(path); !errors.Is(err, errUnsigned) { 162 + t.Fatalf("err = %v, want errUnsigned", err) 163 + } 164 + } 165 + 166 + func TestVerifyAppImageSignatureTampered(t *testing.T) { 167 + entity := newTestEntity(t) 168 + layout := buildSignableELF(2048, 4096) 169 + sig, key := signWith(t, layout, entity) 170 + data := layout.embed(sig, key) 171 + data[layout.textOff] ^= 0xFF // flip a byte outside the signature sections 172 + 173 + 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) 176 + } 177 + } 178 + 179 + func TestVerifyAppImageSignatureMissingKey(t *testing.T) { 180 + entity := newTestEntity(t) 181 + layout := buildSignableELF(2048, 4096) 182 + sig, _ := signWith(t, layout, entity) 183 + path := writeAppImage(t, layout.embed(sig, nil)) // signature but no key 184 + 185 + if _, err := verifyAppImageSignature(path); err == nil || errors.Is(err, errUnsigned) { 186 + t.Fatalf("err = %v, want missing-key error", err) 187 + } 188 + } 189 + 190 + func TestCheckSignaturePolicy(t *testing.T) { 191 + entity := newTestEntity(t) 192 + layout := buildSignableELF(2048, 4096) 193 + sig, key := signWith(t, layout, entity) 194 + fpr := strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint)) 195 + 196 + signed := writeAppImage(t, layout.embed(sig, key)) 197 + unsigned := writeAppImage(t, buildSignableELF(2048, 4096).bytes) 198 + 199 + t.Run("signed with no pin establishes trust", func(t *testing.T) { 200 + got, err := checkSignature(signed, "") 201 + if err != nil || got != fpr { 202 + t.Fatalf("got (%q, %v), want (%q, nil)", got, err, fpr) 203 + } 204 + }) 205 + t.Run("signed matching pin is accepted", func(t *testing.T) { 206 + got, err := checkSignature(signed, fpr) 207 + if err != nil || got != fpr { 208 + t.Fatalf("got (%q, %v), want (%q, nil)", got, err, fpr) 209 + } 210 + }) 211 + t.Run("signed with different pin is refused", func(t *testing.T) { 212 + if _, err := checkSignature(signed, "DEADBEEF"); err == nil { 213 + t.Fatal("expected refusal on key change") 214 + } 215 + }) 216 + t.Run("unsigned is refused once a key is pinned", func(t *testing.T) { 217 + if _, err := checkSignature(unsigned, fpr); err == nil { 218 + t.Fatal("expected refusal of unsigned update over a pinned key") 219 + } 220 + }) 221 + t.Run("unsigned with no pin stays unpinned", func(t *testing.T) { 222 + got, err := checkSignature(unsigned, "") 223 + if err != nil || got != "" { 224 + t.Fatalf("got (%q, %v), want (\"\", nil)", got, err) 225 + } 226 + }) 227 + } 228 + 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 232 + // unavailable. 233 + func TestVerifyAppImageSignatureGPGInterop(t *testing.T) { 234 + gpg, err := exec.LookPath("gpg") 235 + if err != nil { 236 + t.Skip("gpg not installed") 237 + } 238 + home := t.TempDir() 239 + run := func(input []byte, args ...string) ([]byte, error) { 240 + cmd := exec.Command(gpg, append([]string{"--batch", "--no-tty"}, args...)...) 241 + cmd.Env = append(os.Environ(), "GNUPGHOME="+home) 242 + if input != nil { 243 + cmd.Stdin = bytes.NewReader(input) 244 + } 245 + return cmd.Output() 246 + } 247 + if _, err := run(nil, "--pinentry-mode", "loopback", "--passphrase", "", 248 + "--quick-generate-key", "AppHerder Interop <interop@appherder.local>", "rsa2048", "sign", "0"); err != nil { 249 + t.Skipf("gpg key generation failed: %v", err) 250 + } 251 + 252 + colons, err := run(nil, "--list-keys", "--with-colons") 253 + if err != nil { 254 + t.Fatalf("list keys: %v", err) 255 + } 256 + var fpr string 257 + for line := range strings.SplitSeq(string(colons), "\n") { 258 + if fields := strings.Split(line, ":"); fields[0] == "fpr" { 259 + fpr = fields[9] 260 + break 261 + } 262 + } 263 + 264 + layout := buildSignableELF(2048, 4096) 265 + sum := sha256.Sum256(layout.bytes) 266 + sig, err := run([]byte(hex.EncodeToString(sum[:])), "--pinentry-mode", "loopback", "--passphrase", "", 267 + "--armor", "--detach-sign", "--output", "-") 268 + if err != nil { 269 + t.Fatalf("gpg sign: %v", err) 270 + } 271 + key, err := run(nil, "--armor", "--export") 272 + if err != nil { 273 + t.Fatalf("gpg export: %v", err) 274 + } 275 + 276 + path := writeAppImage(t, layout.embed(sig, key)) 277 + got, err := verifyAppImageSignature(path) 278 + if err != nil { 279 + t.Fatalf("verify gpg-signed AppImage: %v", err) 280 + } 281 + if got != fpr { 282 + t.Fatalf("fingerprint = %s, want %s", got, fpr) 283 + } 284 + }
+3 -7
internal/appherder/source.go
··· 97 97 } 98 98 defer file.Close() 99 99 100 - section := file.Section(".upd_info") 101 - if section == nil { 102 - return "", nil 103 - } 104 - data, err := section.Data() 100 + data, _, _, err := sectionData(file, ".upd_info") 105 101 if err != nil { 106 - return "", fmt.Errorf("read .upd_info from %s: %w", path, err) 102 + return "", err 107 103 } 108 - return strings.TrimRight(string(data), "\x00"), nil 104 + return string(data), nil 109 105 } 110 106 111 107 // SourceForAppImage resolves an update source from the AppImage's embedded