diff --git a/go.mod b/go.mod index 163ef0c..f332873 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,24 @@ module github.com/alyraffauf/appherder -go 1.24.0 +go 1.25.0 require ( github.com/CalebQ42/squashfs v1.4.1 + github.com/ProtonMail/go-crypto v1.4.1 github.com/alyraffauf/goxdgdesktop v0.1.0 github.com/spf13/cobra v1.10.2 ) require ( + github.com/cloudflare/circl v1.6.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect - github.com/pierrec/lz4/v4 v4.1.22 // indirect + github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e // indirect - github.com/spf13/pflag v1.0.9 // indirect - github.com/ulikunitz/xz v0.5.12 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index 6213000..1b13c3c 100644 --- a/go.sum +++ b/go.sum @@ -1,26 +1,35 @@ github.com/CalebQ42/squashfs v1.4.1 h1:tBcFMQSRQvWcY50e9r9cv2uVzNf06fcUhly0LeZg8bI= github.com/CalebQ42/squashfs v1.4.1/go.mod h1:/As5wg6ScFFaab9SaNFNHyCOsd73Q5IFPOFJCVnwWzQ= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/alyraffauf/goxdgdesktop v0.1.0 h1:n5+3AjF2ntvON6W0nN4O5bHDmcjYuFtKVCwBr6ekqj0= github.com/alyraffauf/goxdgdesktop v0.1.0/go.mod h1:K91gqx5usBl0hjhNUxHcBSRaOaU8gaggPxfKpCnTXzQ= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= -github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= -github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= +github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs= github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= -github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/appherder/install.go b/internal/appherder/install.go index d10687d..7a9ed19 100644 --- a/internal/appherder/install.go +++ b/internal/appherder/install.go @@ -37,6 +37,13 @@ func (a App) Install(appimage string) (appName string, err error) { icon := resolveIcon(fsys) appName = deriveAppName(desktop, desktopName, appimage) + // Enforce the signature trust policy before any filesystem writes, so a + // refused AppImage installs nothing. + pin, err := checkSignature(appimage, a.pinnedSigningKey(appName)) + if err != nil { + return "", err + } + // No desktop file inside the AppImage: synthesize a terminal launcher so // CLI apps still get a menu entry and are tracked by managedApps. if desktop == nil { @@ -50,6 +57,9 @@ func (a App) Install(appimage string) (appName string, err error) { if err := a.patchDesktopFile(desktop, appName, icon != ""); err != nil { return "", err } + if pin != "" { + desktop.Set(desktopEntrySection, desktopSigningKey, pin) + } // Roll back written files on a later failure rather than leaving a half-installed app. var installed []string diff --git a/internal/appherder/signature.go b/internal/appherder/signature.go new file mode 100644 index 0000000..deba0b2 --- /dev/null +++ b/internal/appherder/signature.go @@ -0,0 +1,181 @@ +package appherder + +import ( + "bytes" + "crypto/sha256" + "debug/elf" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/alyraffauf/goxdgdesktop/desktopfile" +) + +// appimagetool stores an AppImage's optional OpenPGP signature in two ELF +// sections: .sha256_sig (armored detached signature) and .sig_key (armored +// public key). The signed message is the lowercase hex SHA-256 of the whole +// file with both sections zeroed. +const ( + sigSection = ".sha256_sig" + keySection = ".sig_key" + + // desktopSigningKey pins an app's trusted signing-key fingerprint in its + // managed launcher, recorded on the first signed install (trust on first use). + desktopSigningKey = "X-AppHerder-SigningKey" +) + +// errUnsigned reports that an AppImage carries no usable embedded signature. +var errUnsigned = errors.New("AppImage is not signed") + +// byteRange is a half-open [start, start+length) span of a file. +type byteRange struct{ start, length int64 } + +// sectionData returns the named ELF section's contents with NUL padding trimmed, +// and the byte range it occupies. ok is false when the section is absent or +// holds no file bytes. +func sectionData(f *elf.File, name string) (data []byte, span byteRange, ok bool, err error) { + section := f.Section(name) + if section == nil || section.Type == elf.SHT_NOBITS { + return nil, byteRange{}, false, nil + } + data, err = section.Data() + if err != nil { + return nil, byteRange{}, false, fmt.Errorf("read %s: %w", name, err) + } + return bytes.TrimRight(data, "\x00"), byteRange{int64(section.Offset), int64(section.Size)}, true, nil +} + +// readSignatureSections returns the .sha256_sig and .sig_key contents and the +// byte ranges they occupy, which the digest zeroes. +func readSignatureSections(file string) (sig, key []byte, zero []byteRange, err error) { + elfFile, err := elf.Open(file) + if err != nil { + return nil, nil, nil, fmt.Errorf("open AppImage %s: %w", file, err) + } + defer elfFile.Close() + + sig, sigSpan, sigOK, err := sectionData(elfFile, sigSection) + if err != nil { + return nil, nil, nil, err + } + key, keySpan, keyOK, err := sectionData(elfFile, keySection) + if err != nil { + return nil, nil, nil, err + } + if sigOK { + zero = append(zero, sigSpan) + } + if keyOK { + zero = append(zero, keySpan) + } + return sig, key, zero, nil +} + +// signedDigest returns the lowercase hex SHA-256 that appimagetool signs: the +// file hashed with the bytes in zero replaced by NULs. +func signedDigest(file string, zero []byteRange) (string, error) { + f, err := os.Open(file) + if err != nil { + return "", fmt.Errorf("open AppImage %s: %w", file, err) + } + defer f.Close() + + hasher := sha256.New() + buf := make([]byte, 64*1024) + var pos int64 + for { + n, err := f.Read(buf) + if n > 0 { + chunk := buf[:n] + zeroOverlaps(chunk, pos, zero) + hasher.Write(chunk) + pos += int64(n) + } + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("read AppImage %s: %w", file, err) + } + } + return hex.EncodeToString(hasher.Sum(nil)), nil +} + +// zeroOverlaps NULs the bytes of chunk (which begins at file offset pos) that +// fall within any range in zero. +func zeroOverlaps(chunk []byte, pos int64, zero []byteRange) { + end := pos + int64(len(chunk)) + for _, r := range zero { + for i := max(pos, r.start); i < min(end, r.start+r.length); i++ { + chunk[i-pos] = 0 + } + } +} + +// verifyAppImageSignature verifies an AppImage's embedded signature and returns +// the signer's fingerprint (uppercase hex). It returns errUnsigned when no +// signature is present, or an error when one is present but invalid. +func verifyAppImageSignature(file string) (fingerprint string, err error) { + sig, key, zero, err := readSignatureSections(file) + if err != nil { + return "", err + } + if len(bytes.TrimSpace(sig)) == 0 { + return "", errUnsigned + } + if len(bytes.TrimSpace(key)) == 0 { + return "", errors.New("AppImage is signed but carries no public key") + } + + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(key)) + if err != nil { + return "", fmt.Errorf("read embedded signing key: %w", err) + } + digest, err := signedDigest(file, zero) + if err != nil { + return "", err + } + signer, err := openpgp.CheckArmoredDetachedSignature(keyring, strings.NewReader(digest), bytes.NewReader(sig), nil) + if err != nil { + return "", fmt.Errorf("invalid AppImage signature: %w", err) + } + return fmt.Sprintf("%X", signer.PrimaryKey.Fingerprint), nil +} + +// pinnedSigningKey returns the fingerprint appherder pinned for appName, or "". +func (a App) pinnedSigningKey(appName string) string { + desktop, err := desktopfile.Read(filepath.Join(a.applicationsDir, appName+".desktop")) + if err != nil { + return "" + } + fingerprint, _ := desktop.Get(desktopEntrySection, desktopSigningKey) + return fingerprint +} + +// checkSignature applies the trust policy to an incoming AppImage given the +// fingerprint pinned for the app ("" if none), returning the fingerprint to keep +// going forward. An app with no pin accepts anything and pins the first valid +// signature (trust on first use). Once pinned, every update must be signed by +// the pinned key: an unsigned, invalid, or differently-keyed AppImage is +// refused. Changing trust is a deliberate act — uninstall, then reinstall. +func checkSignature(file, pinned string) (fingerprint string, err error) { + fpr, err := verifyAppImageSignature(file) + switch { + case errors.Is(err, errUnsigned): + if pinned != "" { + return "", fmt.Errorf("refusing unsigned AppImage: a signing key is pinned (%s); uninstall and reinstall to trust a different build", pinned) + } + return "", nil + case err != nil: + return "", err + } + if pinned != "" && !strings.EqualFold(pinned, fpr) { + return "", fmt.Errorf("refusing AppImage: signing key changed (pinned %s, got %s); uninstall and reinstall to trust the new key", pinned, fpr) + } + return fpr, nil +} diff --git a/internal/appherder/signature_test.go b/internal/appherder/signature_test.go new file mode 100644 index 0000000..92cb8d1 --- /dev/null +++ b/internal/appherder/signature_test.go @@ -0,0 +1,284 @@ +package appherder + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +// elfLayout is a minimal ELF carrying empty .sha256_sig and .sig_key sections, +// ready to have a signature embedded. +type elfLayout struct { + bytes []byte + sigOff, sigCap int64 + keyOff, keyCap int64 + textOff int64 // a content byte safe to tamper with +} + +// buildSignableELF lays out a 64-bit little-endian ELF with zero-filled +// signature sections, mirroring what appimagetool reserves before signing. +func buildSignableELF(sigCap, keyCap int) elfLayout { + le := binary.LittleEndian + const hdr, shentsize = 64, 64 + textData := bytes.Repeat([]byte{0xAA}, 16) + + names := []string{"", ".text", ".sha256_sig", ".sig_key", ".shstrtab"} + var shstr bytes.Buffer + nameOff := map[string]uint32{} + for _, name := range names { + nameOff[name] = uint32(shstr.Len()) + shstr.WriteString(name) + shstr.WriteByte(0) + } + + off := int64(hdr) + textOff := off + off += int64(len(textData)) + sigOff := off + off += int64(sigCap) + keyOff := off + off += int64(keyCap) + shstrOff := off + off += int64(shstr.Len()) + if rem := off % 8; rem != 0 { + off += 8 - rem + } + shoff := off + + sections := []struct { + name string + typ uint32 + off, size int64 + }{ + {"", 0, 0, 0}, + {".text", 1, textOff, int64(len(textData))}, // SHT_PROGBITS + {".sha256_sig", 1, sigOff, int64(sigCap)}, // SHT_PROGBITS + {".sig_key", 1, keyOff, int64(keyCap)}, // SHT_PROGBITS + {".shstrtab", 3, shstrOff, int64(shstr.Len())}, // SHT_STRTAB + } + + buf := make([]byte, shoff+int64(len(sections))*shentsize) + copy(buf, []byte{0x7f, 'E', 'L', 'F', 2, 1, 1, 0}) + le.PutUint16(buf[16:], 2) // ET_EXEC + le.PutUint16(buf[18:], 0x3e) // x86-64 + le.PutUint32(buf[20:], 1) // version + le.PutUint64(buf[40:], uint64(shoff)) + le.PutUint16(buf[52:], hdr) + le.PutUint16(buf[58:], shentsize) + le.PutUint16(buf[60:], uint16(len(sections))) + le.PutUint16(buf[62:], 4) // shstrndx -> .shstrtab + + copy(buf[textOff:], textData) + copy(buf[shstrOff:], shstr.Bytes()) + for i, s := range sections { + base := shoff + int64(i)*shentsize + le.PutUint32(buf[base:], nameOff[s.name]) + le.PutUint32(buf[base+4:], s.typ) + le.PutUint64(buf[base+24:], uint64(s.off)) + le.PutUint64(buf[base+32:], uint64(s.size)) + } + return elfLayout{buf, sigOff, int64(sigCap), keyOff, int64(keyCap), textOff} +} + +// embed writes sig and key into the reserved sections (zero-padded). +func (l elfLayout) embed(sig, key []byte) []byte { + out := append([]byte(nil), l.bytes...) + copy(out[l.sigOff:l.sigOff+l.sigCap], sig) + copy(out[l.keyOff:l.keyOff+l.keyCap], key) + return out +} + +// signWith signs the layout's signed digest with entity and returns the armored +// detached signature plus the armored public key. +func signWith(t *testing.T, l elfLayout, entity *openpgp.Entity) (sig, key []byte) { + t.Helper() + sum := sha256.Sum256(l.bytes) // sections are still zero-filled + digest := hex.EncodeToString(sum[:]) + + var sigBuf bytes.Buffer + if err := openpgp.ArmoredDetachSign(&sigBuf, entity, strings.NewReader(digest), nil); err != nil { + t.Fatalf("sign: %v", err) + } + var keyBuf bytes.Buffer + w, err := armor.Encode(&keyBuf, openpgp.PublicKeyType, nil) + if err != nil { + t.Fatalf("armor key: %v", err) + } + if err := entity.Serialize(w); err != nil { + t.Fatalf("serialize key: %v", err) + } + w.Close() + return sigBuf.Bytes(), keyBuf.Bytes() +} + +func newTestEntity(t *testing.T) *openpgp.Entity { + t.Helper() + entity, err := openpgp.NewEntity("AppHerder Test", "", "test@appherder.local", nil) + if err != nil { + t.Fatalf("new entity: %v", err) + } + return entity +} + +func writeAppImage(t *testing.T, data []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "App.AppImage") + if err := os.WriteFile(path, data, 0o755); err != nil { + t.Fatal(err) + } + return path +} + +func TestVerifyAppImageSignatureValid(t *testing.T) { + entity := newTestEntity(t) + layout := buildSignableELF(2048, 4096) + sig, key := signWith(t, layout, entity) + path := writeAppImage(t, layout.embed(sig, key)) + + got, err := verifyAppImageSignature(path) + if err != nil { + t.Fatalf("verify: %v", err) + } + want := strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint)) + if got != want { + t.Fatalf("fingerprint = %s, want %s", got, want) + } +} + +func TestVerifyAppImageSignatureUnsigned(t *testing.T) { + // Sections present but empty. + path := writeAppImage(t, buildSignableELF(2048, 4096).bytes) + if _, err := verifyAppImageSignature(path); !errors.Is(err, errUnsigned) { + t.Fatalf("err = %v, want errUnsigned", err) + } +} + +func TestVerifyAppImageSignatureTampered(t *testing.T) { + entity := newTestEntity(t) + layout := buildSignableELF(2048, 4096) + sig, key := signWith(t, layout, entity) + data := layout.embed(sig, key) + data[layout.textOff] ^= 0xFF // flip a byte outside the signature sections + + path := writeAppImage(t, data) + if _, err := verifyAppImageSignature(path); err == nil || errors.Is(err, errUnsigned) { + t.Fatalf("err = %v, want invalid-signature error", err) + } +} + +func TestVerifyAppImageSignatureMissingKey(t *testing.T) { + entity := newTestEntity(t) + layout := buildSignableELF(2048, 4096) + sig, _ := signWith(t, layout, entity) + path := writeAppImage(t, layout.embed(sig, nil)) // signature but no key + + if _, err := verifyAppImageSignature(path); err == nil || errors.Is(err, errUnsigned) { + t.Fatalf("err = %v, want missing-key error", err) + } +} + +func TestCheckSignaturePolicy(t *testing.T) { + entity := newTestEntity(t) + layout := buildSignableELF(2048, 4096) + sig, key := signWith(t, layout, entity) + fpr := strings.ToUpper(hex.EncodeToString(entity.PrimaryKey.Fingerprint)) + + signed := writeAppImage(t, layout.embed(sig, key)) + unsigned := writeAppImage(t, buildSignableELF(2048, 4096).bytes) + + t.Run("signed with no pin establishes trust", func(t *testing.T) { + got, err := checkSignature(signed, "") + if err != nil || got != fpr { + t.Fatalf("got (%q, %v), want (%q, nil)", got, err, fpr) + } + }) + t.Run("signed matching pin is accepted", func(t *testing.T) { + got, err := checkSignature(signed, fpr) + if err != nil || got != fpr { + t.Fatalf("got (%q, %v), want (%q, nil)", got, err, fpr) + } + }) + t.Run("signed with different pin is refused", func(t *testing.T) { + if _, err := checkSignature(signed, "DEADBEEF"); err == nil { + t.Fatal("expected refusal on key change") + } + }) + t.Run("unsigned is refused once a key is pinned", func(t *testing.T) { + if _, err := checkSignature(unsigned, fpr); err == nil { + t.Fatal("expected refusal of unsigned update over a pinned key") + } + }) + t.Run("unsigned with no pin stays unpinned", func(t *testing.T) { + got, err := checkSignature(unsigned, "") + if err != nil || got != "" { + t.Fatalf("got (%q, %v), want (\"\", nil)", got, err) + } + }) +} + +// TestVerifyAppImageSignatureGPGInterop proves appherder verifies signatures +// produced the same way appimagetool produces them (gpg/gpgme: an armored +// detached signature over the lowercase hex digest). It is skipped where gpg is +// unavailable. +func TestVerifyAppImageSignatureGPGInterop(t *testing.T) { + gpg, err := exec.LookPath("gpg") + if err != nil { + t.Skip("gpg not installed") + } + home := t.TempDir() + run := func(input []byte, args ...string) ([]byte, error) { + cmd := exec.Command(gpg, append([]string{"--batch", "--no-tty"}, args...)...) + cmd.Env = append(os.Environ(), "GNUPGHOME="+home) + if input != nil { + cmd.Stdin = bytes.NewReader(input) + } + return cmd.Output() + } + if _, err := run(nil, "--pinentry-mode", "loopback", "--passphrase", "", + "--quick-generate-key", "AppHerder Interop ", "rsa2048", "sign", "0"); err != nil { + t.Skipf("gpg key generation failed: %v", err) + } + + colons, err := run(nil, "--list-keys", "--with-colons") + if err != nil { + t.Fatalf("list keys: %v", err) + } + var fpr string + for line := range strings.SplitSeq(string(colons), "\n") { + if fields := strings.Split(line, ":"); fields[0] == "fpr" { + fpr = fields[9] + break + } + } + + layout := buildSignableELF(2048, 4096) + sum := sha256.Sum256(layout.bytes) + sig, err := run([]byte(hex.EncodeToString(sum[:])), "--pinentry-mode", "loopback", "--passphrase", "", + "--armor", "--detach-sign", "--output", "-") + if err != nil { + t.Fatalf("gpg sign: %v", err) + } + key, err := run(nil, "--armor", "--export") + if err != nil { + t.Fatalf("gpg export: %v", err) + } + + path := writeAppImage(t, layout.embed(sig, key)) + got, err := verifyAppImageSignature(path) + if err != nil { + t.Fatalf("verify gpg-signed AppImage: %v", err) + } + if got != fpr { + t.Fatalf("fingerprint = %s, want %s", got, fpr) + } +} diff --git a/internal/appherder/source.go b/internal/appherder/source.go index f86980a..c3bb744 100644 --- a/internal/appherder/source.go +++ b/internal/appherder/source.go @@ -97,15 +97,11 @@ func ReadUpdateInfo(path string) (string, error) { } defer file.Close() - section := file.Section(".upd_info") - if section == nil { - return "", nil - } - data, err := section.Data() + data, _, _, err := sectionData(file, ".upd_info") if err != nil { - return "", fmt.Errorf("read .upd_info from %s: %w", path, err) + return "", err } - return strings.TrimRight(string(data), "\x00"), nil + return string(data), nil } // SourceForAppImage resolves an update source from the AppImage's embedded