diff --git a/README.md b/README.md
index dad269e..663088e 100644
--- a/README.md
+++ b/README.md
@@ -126,7 +126,10 @@ repository = "org/repo"
base_url = "https://gitea.example.com"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
-checksum_required = false
+checksum_required = true
+signature_asset_name = "{asset}.sig"
+signature_required = false
+signature_public_key_env_names = ["MY_MCP_RELEASE_ED25519_PUBLIC_KEY"]
token_header = "Authorization"
token_prefix = "token"
token_env_names = ["GITEA_TOKEN"]
@@ -159,6 +162,10 @@ Champs supportés :
- `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`).
- `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`.
- `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent.
+- `signature_asset_name` : nom d'asset signature Ed25519 (détachée), avec placeholder optionnel `{asset}`.
+- `signature_required` : si `true`, l'update échoue si la signature ou la clé publique manquent, ou si la signature est invalide.
+- `signature_public_key` : clé publique Ed25519 (hex ou base64) utilisée pour vérifier la signature.
+- `signature_public_key_env_names` : variables d'environnement candidates contenant la clé publique Ed25519.
- `token_header` : header HTTP à utiliser pour l'authentification.
- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...).
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.
diff --git a/cmd/mcp-framework/main.go b/cmd/mcp-framework/main.go
index d6f98b9..a4a263f 100644
--- a/cmd/mcp-framework/main.go
+++ b/cmd/mcp-framework/main.go
@@ -77,6 +77,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
var releaseBaseURL string
var releaseRepository string
var releaseTokenEnv string
+ var releasePublicKeyEnv string
var overwrite bool
fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)")
@@ -92,6 +93,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
fs.StringVar(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release")
fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)")
fs.StringVar(&releaseTokenEnv, "release-token-env", "", "Nom de variable d'environnement pour le token release")
+ fs.StringVar(&releasePublicKeyEnv, "release-pubkey-env", "", "Nom de variable d'environnement pour la cle publique Ed25519 de signature")
fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants")
if err := fs.Parse(args); err != nil {
@@ -121,6 +123,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
ReleaseBaseURL: releaseBaseURL,
ReleaseRepository: releaseRepository,
ReleaseTokenEnv: releaseTokenEnv,
+ ReleasePublicKeyEnv: releasePublicKeyEnv,
Overwrite: overwrite,
})
if err != nil {
@@ -159,7 +162,7 @@ func printScaffoldHelp(w io.Writer) {
func printScaffoldInitHelp(w io.Writer) {
fmt.Fprintf(
w,
- "Usage:\n %s scaffold init --target
[flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --overwrite Écraser les fichiers existants\n",
+ "Usage:\n %s scaffold init --target [flags]\n\nFlags:\n --target Répertoire cible (requis)\n --module Module Go (ex: example.com/my-mcp)\n --binary Nom du binaire\n --description Description bootstrap\n --docs-url URL de documentation\n --default-profile Profil par défaut\n --profiles CSV des profils connus\n --known-env CSV des variables d'environnement connues\n --secret-store-policy auto|keyring-any|kwallet-only|env-only\n --release-driver gitea|gitlab|github\n --release-base-url URL de base de la forge\n --release-repository Dépôt release (org/repo)\n --release-token-env Variable token release\n --release-pubkey-env Variable cle publique Ed25519 release\n --overwrite Écraser les fichiers existants\n",
toolName,
)
}
diff --git a/manifest/manifest.go b/manifest/manifest.go
index b3c9414..544e59a 100644
--- a/manifest/manifest.go
+++ b/manifest/manifest.go
@@ -25,17 +25,21 @@ type File struct {
}
type Update struct {
- SourceName string `toml:"source_name"`
- Driver string `toml:"driver"`
- Repository string `toml:"repository"`
- BaseURL string `toml:"base_url"`
- LatestReleaseURL string `toml:"latest_release_url"`
- AssetNameTemplate string `toml:"asset_name_template"`
- ChecksumAssetName string `toml:"checksum_asset_name"`
- ChecksumRequired bool `toml:"checksum_required"`
- TokenHeader string `toml:"token_header"`
- TokenPrefix string `toml:"token_prefix"`
- TokenEnvNames []string `toml:"token_env_names"`
+ SourceName string `toml:"source_name"`
+ Driver string `toml:"driver"`
+ Repository string `toml:"repository"`
+ BaseURL string `toml:"base_url"`
+ LatestReleaseURL string `toml:"latest_release_url"`
+ AssetNameTemplate string `toml:"asset_name_template"`
+ ChecksumAssetName string `toml:"checksum_asset_name"`
+ ChecksumRequired bool `toml:"checksum_required"`
+ SignatureAssetName string `toml:"signature_asset_name"`
+ SignatureRequired bool `toml:"signature_required"`
+ SignaturePublicKey string `toml:"signature_public_key"`
+ SignaturePublicKeyEnvNames []string `toml:"signature_public_key_env_names"`
+ TokenHeader string `toml:"token_header"`
+ TokenPrefix string `toml:"token_prefix"`
+ TokenEnvNames []string `toml:"token_env_names"`
}
type Environment struct {
@@ -155,6 +159,9 @@ func (u *Update) normalize() {
u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL)
u.AssetNameTemplate = strings.TrimSpace(u.AssetNameTemplate)
u.ChecksumAssetName = strings.TrimSpace(u.ChecksumAssetName)
+ u.SignatureAssetName = strings.TrimSpace(u.SignatureAssetName)
+ u.SignaturePublicKey = strings.TrimSpace(u.SignaturePublicKey)
+ u.SignaturePublicKeyEnvNames = normalizeStringList(u.SignaturePublicKeyEnvNames)
u.TokenHeader = strings.TrimSpace(u.TokenHeader)
u.TokenPrefix = strings.TrimSpace(u.TokenPrefix)
u.TokenEnvNames = normalizeStringList(u.TokenEnvNames)
@@ -181,17 +188,21 @@ func (u Update) ReleaseSource() update.ReleaseSource {
u.normalize()
return update.ReleaseSource{
- Name: u.SourceName,
- Driver: u.Driver,
- Repository: u.Repository,
- BaseURL: u.BaseURL,
- LatestReleaseURL: u.LatestReleaseURL,
- AssetNameTemplate: u.AssetNameTemplate,
- ChecksumAssetName: u.ChecksumAssetName,
- ChecksumRequired: u.ChecksumRequired,
- TokenHeader: u.TokenHeader,
- TokenPrefix: u.TokenPrefix,
- TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
+ Name: u.SourceName,
+ Driver: u.Driver,
+ Repository: u.Repository,
+ BaseURL: u.BaseURL,
+ LatestReleaseURL: u.LatestReleaseURL,
+ AssetNameTemplate: u.AssetNameTemplate,
+ ChecksumAssetName: u.ChecksumAssetName,
+ ChecksumRequired: u.ChecksumRequired,
+ SignatureAssetName: u.SignatureAssetName,
+ SignatureRequired: u.SignatureRequired,
+ SignaturePublicKey: u.SignaturePublicKey,
+ SignaturePublicKeyEnvNames: append([]string(nil), u.SignaturePublicKeyEnvNames...),
+ TokenHeader: u.TokenHeader,
+ TokenPrefix: u.TokenPrefix,
+ TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
}
}
diff --git a/manifest/manifest_test.go b/manifest/manifest_test.go
index 83ea7d5..5d6a739 100644
--- a/manifest/manifest_test.go
+++ b/manifest/manifest_test.go
@@ -53,6 +53,10 @@ latest_release_url = "https://gitea.example.com/api/releases/latest"
asset_name_template = "{binary}_{os}_{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
+signature_asset_name = "{asset}.sig"
+signature_required = true
+signature_public_key = " 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef "
+signature_public_key_env_names = [" MCP_PUBKEY ", "", "MCP_RELEASE_PUBKEY"]
token_header = " Authorization "
token_prefix = " token "
token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
@@ -92,6 +96,18 @@ token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
if !source.ChecksumRequired {
t.Fatal("checksum required should be true")
}
+ if source.SignatureAssetName != "{asset}.sig" {
+ t.Fatalf("signature asset name = %q", source.SignatureAssetName)
+ }
+ if !source.SignatureRequired {
+ t.Fatal("signature required should be true")
+ }
+ if source.SignaturePublicKey == "" {
+ t.Fatal("signature public key should be set")
+ }
+ if len(source.SignaturePublicKeyEnvNames) != 2 {
+ t.Fatalf("signature env names = %v", source.SignaturePublicKeyEnvNames)
+ }
if source.TokenHeader != "Authorization" {
t.Fatalf("token header = %q", source.TokenHeader)
}
diff --git a/scaffold/scaffold.go b/scaffold/scaffold.go
index c91ac45..81e9fe7 100644
--- a/scaffold/scaffold.go
+++ b/scaffold/scaffold.go
@@ -31,6 +31,7 @@ type Options struct {
ReleaseBaseURL string
ReleaseRepository string
ReleaseTokenEnv string
+ ReleasePublicKeyEnv string
Overwrite bool
}
@@ -56,6 +57,7 @@ type normalizedOptions struct {
ReleaseBaseURL string
ReleaseRepository string
ReleaseTokenEnv string
+ ReleasePublicKeyEnv string
Overwrite bool
}
@@ -231,6 +233,13 @@ func normalizeOptions(options Options) (normalizedOptions, error) {
if releaseTokenEnv == "" {
releaseTokenEnv = envPrefix + "_RELEASE_TOKEN"
}
+ releasePublicKeyEnv := strings.TrimSpace(options.ReleasePublicKeyEnv)
+ if releasePublicKeyEnv == "" {
+ releasePublicKeyEnv = envPrefix + "_RELEASE_ED25519_PUBLIC_KEY"
+ }
+ if !slices.Contains(knownEnvironmentVariables, releasePublicKeyEnv) {
+ knownEnvironmentVariables = append(knownEnvironmentVariables, releasePublicKeyEnv)
+ }
return normalizedOptions{
TargetDir: resolvedTarget,
@@ -249,6 +258,7 @@ func normalizeOptions(options Options) (normalizedOptions, error) {
ReleaseBaseURL: releaseBaseURL,
ReleaseRepository: releaseRepository,
ReleaseTokenEnv: releaseTokenEnv,
+ ReleasePublicKeyEnv: releasePublicKeyEnv,
Overwrite: options.Overwrite,
}, nil
}
@@ -1182,6 +1192,9 @@ base_url = "{{.ReleaseBaseURL}}"
asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256"
checksum_required = true
+signature_asset_name = "{asset}.sig"
+signature_required = false
+signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"]
token_header = "Authorization"
token_prefix = "token"
token_env_names = ["{{.ReleaseTokenEnv}}"]
diff --git a/scaffold/scaffold_test.go b/scaffold/scaffold_test.go
index bf72730..be726db 100644
--- a/scaffold/scaffold_test.go
+++ b/scaffold/scaffold_test.go
@@ -89,6 +89,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
"binary_name = \"my-mcp\"",
"[update]",
"checksum_required = true",
+ "signature_asset_name = \"{asset}.sig\"",
+ "signature_required = false",
"[secret_store]",
"[environment]",
"[profiles]",
diff --git a/update/update.go b/update/update.go
index 6c5dbfb..21aa789 100644
--- a/update/update.go
+++ b/update/update.go
@@ -2,7 +2,9 @@ package update
import (
"context"
+ "crypto/ed25519"
"crypto/sha256"
+ "encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -52,18 +54,22 @@ type ValidationInput struct {
}
type ReleaseSource struct {
- Name string
- Driver string
- Repository string
- BaseURL string
- LatestReleaseURL string
- AssetNameTemplate string
- ChecksumAssetName string
- ChecksumRequired bool
- Token string
- TokenHeader string
- TokenPrefix string
- TokenEnvNames []string
+ Name string
+ Driver string
+ Repository string
+ BaseURL string
+ LatestReleaseURL string
+ AssetNameTemplate string
+ ChecksumAssetName string
+ ChecksumRequired bool
+ SignatureAssetName string
+ SignatureRequired bool
+ SignaturePublicKey string
+ SignaturePublicKeyEnvNames []string
+ Token string
+ TokenHeader string
+ TokenPrefix string
+ TokenEnvNames []string
}
type Auth struct {
@@ -176,6 +182,9 @@ func Run(ctx context.Context, opts Options) error {
if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil {
return err
}
+ if err := VerifyReleaseAssetSignature(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil {
+ return err
+ }
if opts.ValidateDownloaded != nil {
if err := opts.ValidateDownloaded(ctx, ValidationInput{
@@ -527,6 +536,70 @@ func VerifyReleaseAssetChecksum(
return nil
}
+func VerifyReleaseAssetSignature(
+ ctx context.Context,
+ client *http.Client,
+ release Release,
+ releaseURL string,
+ assetName string,
+ artifactPath string,
+ auth Auth,
+ source ReleaseSource,
+) error {
+ source = normalizeSource(source)
+
+ publicKey, hasPublicKey, err := resolveEd25519PublicKey(source.SignaturePublicKey, source.SignaturePublicKeyEnvNames)
+ if err != nil {
+ return fmt.Errorf("signature verification: %w", err)
+ }
+ if !hasPublicKey {
+ if source.SignatureRequired {
+ if len(source.SignaturePublicKeyEnvNames) > 0 {
+ return fmt.Errorf(
+ "signature verification: no Ed25519 public key configured (set %s)",
+ strings.Join(source.SignaturePublicKeyEnvNames, " or "),
+ )
+ }
+ return errors.New("signature verification: no Ed25519 public key configured")
+ }
+ return nil
+ }
+
+ signatureAssetName := resolveSignatureAssetName(assetName, source.SignatureAssetName)
+ signatureURL, err := release.AssetURL(signatureAssetName, releaseURL)
+ if err != nil {
+ if source.SignatureRequired {
+ return fmt.Errorf("signature verification: %w", err)
+ }
+ return nil
+ }
+
+ signatureBody, err := downloadAssetBytes(ctx, client, signatureURL, auth, source)
+ if err != nil {
+ return fmt.Errorf("signature verification: %w", err)
+ }
+
+ signature, err := parseEd25519Signature(string(signatureBody), assetName)
+ if err != nil {
+ return fmt.Errorf("signature verification: %w", err)
+ }
+
+ digestHex, err := fileSHA256(artifactPath)
+ if err != nil {
+ return fmt.Errorf("signature verification: %w", err)
+ }
+ digest, err := hex.DecodeString(digestHex)
+ if err != nil {
+ return fmt.Errorf("signature verification: decode local artifact digest: %w", err)
+ }
+
+ if !ed25519.Verify(publicKey, digest, signature) {
+ return fmt.Errorf("signature mismatch for asset %q", assetName)
+ }
+
+ return nil
+}
+
func ReplaceExecutable(downloadPath, targetPath string) error {
if runtime.GOOS == "windows" {
return errors.New("self-update is not supported on windows without a custom ReplaceExecutable hook")
@@ -545,6 +618,8 @@ func normalizeSource(source ReleaseSource) ReleaseSource {
source.LatestReleaseURL = strings.TrimSpace(source.LatestReleaseURL)
source.AssetNameTemplate = strings.TrimSpace(source.AssetNameTemplate)
source.ChecksumAssetName = strings.TrimSpace(source.ChecksumAssetName)
+ source.SignatureAssetName = strings.TrimSpace(source.SignatureAssetName)
+ source.SignaturePublicKey = strings.TrimSpace(source.SignaturePublicKey)
source.Token = strings.TrimSpace(source.Token)
source.TokenHeader = strings.TrimSpace(source.TokenHeader)
source.TokenPrefix = strings.TrimSpace(source.TokenPrefix)
@@ -557,6 +632,14 @@ func normalizeSource(source ReleaseSource) ReleaseSource {
}
source.TokenEnvNames = envNames
+ publicKeyEnvNames := source.SignaturePublicKeyEnvNames[:0]
+ for _, envName := range source.SignaturePublicKeyEnvNames {
+ if trimmed := strings.TrimSpace(envName); trimmed != "" {
+ publicKeyEnvNames = append(publicKeyEnvNames, trimmed)
+ }
+ }
+ source.SignaturePublicKeyEnvNames = publicKeyEnvNames
+
switch source.Driver {
case "gitea":
if source.Name == "" {
@@ -742,6 +825,14 @@ func resolveChecksumAssetName(assetName, configured string) string {
return strings.ReplaceAll(value, "{asset}", assetName)
}
+func resolveSignatureAssetName(assetName, configured string) string {
+ value := strings.TrimSpace(configured)
+ if value == "" {
+ return assetName + ".sig"
+ }
+ return strings.ReplaceAll(value, "{asset}", assetName)
+}
+
func downloadAssetBytes(ctx context.Context, client *http.Client, assetURL string, auth Auth, source ReleaseSource) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil)
if err != nil {
@@ -835,6 +926,125 @@ func parseChecksum(content, assetName string) (string, error) {
return "", fmt.Errorf("checksum file does not contain a sha256 for asset %q", assetName)
}
+func parseEd25519Signature(content, assetName string) ([]byte, error) {
+ lines := strings.Split(content, "\n")
+ var fallbackSingle []byte
+
+ for _, raw := range lines {
+ line := strings.TrimSpace(raw)
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+
+ fields := strings.Fields(line)
+ if len(fields) > 0 {
+ if signature, ok := parseEd25519SignatureToken(fields[0]); ok {
+ if len(fields) == 1 {
+ if fallbackSingle == nil {
+ fallbackSingle = signature
+ }
+ continue
+ }
+
+ name := strings.TrimSpace(strings.TrimPrefix(fields[1], "*"))
+ if matchesAssetName(name, assetName) {
+ return signature, nil
+ }
+ }
+ }
+
+ colonIndex := strings.Index(line, ":")
+ if colonIndex > 0 && colonIndex < len(line)-1 {
+ left := strings.TrimSpace(line[:colonIndex])
+ right := strings.TrimSpace(line[colonIndex+1:])
+
+ if signature, ok := parseEd25519SignatureToken(left); ok && matchesAssetName(right, assetName) {
+ return signature, nil
+ }
+ if signature, ok := parseEd25519SignatureToken(right); ok && matchesAssetName(left, assetName) {
+ return signature, nil
+ }
+ }
+ }
+
+ if fallbackSingle != nil {
+ return fallbackSingle, nil
+ }
+
+ return nil, fmt.Errorf("signature file does not contain a valid Ed25519 signature for asset %q", assetName)
+}
+
+func parseEd25519SignatureToken(value string) ([]byte, bool) {
+ decoded, err := decodeBinaryValue(value, ed25519.SignatureSize)
+ if err != nil {
+ return nil, false
+ }
+ return decoded, true
+}
+
+func resolveEd25519PublicKey(explicit string, envNames []string) (ed25519.PublicKey, bool, error) {
+ key := strings.TrimSpace(explicit)
+ if key != "" {
+ publicKey, err := parseEd25519PublicKey(key)
+ if err != nil {
+ return nil, false, fmt.Errorf("parse ed25519 public key: %w", err)
+ }
+ return publicKey, true, nil
+ }
+
+ for _, envName := range envNames {
+ if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
+ publicKey, err := parseEd25519PublicKey(value)
+ if err != nil {
+ return nil, false, fmt.Errorf("parse ed25519 public key from %s: %w", envName, err)
+ }
+ return publicKey, true, nil
+ }
+ }
+
+ return nil, false, nil
+}
+
+func parseEd25519PublicKey(value string) (ed25519.PublicKey, error) {
+ decoded, err := decodeBinaryValue(value, ed25519.PublicKeySize)
+ if err != nil {
+ return nil, err
+ }
+ return ed25519.PublicKey(decoded), nil
+}
+
+func decodeBinaryValue(value string, expectedLength int) ([]byte, error) {
+ trimmed := strings.TrimSpace(value)
+ if trimmed == "" {
+ return nil, errors.New("value must not be empty")
+ }
+
+ decoders := []func(string) ([]byte, error){
+ hex.DecodeString,
+ base64.StdEncoding.DecodeString,
+ base64.RawStdEncoding.DecodeString,
+ base64.URLEncoding.DecodeString,
+ base64.RawURLEncoding.DecodeString,
+ }
+
+ lengthMismatch := false
+ for _, decode := range decoders {
+ decoded, err := decode(trimmed)
+ if err != nil {
+ continue
+ }
+ if len(decoded) == expectedLength {
+ return decoded, nil
+ }
+ lengthMismatch = true
+ }
+
+ if lengthMismatch {
+ return nil, fmt.Errorf("decoded value has invalid length (expected %d bytes)", expectedLength)
+ }
+ return nil, errors.New("value must be hex or base64 encoded")
+}
+
func matchesAssetName(candidate, assetName string) bool {
name := strings.TrimSpace(strings.TrimPrefix(candidate, "*"))
name = strings.TrimPrefix(name, "./")
diff --git a/update/update_test.go b/update/update_test.go
index 9c3f98d..dcc0c81 100644
--- a/update/update_test.go
+++ b/update/update_test.go
@@ -3,6 +3,8 @@ package update
import (
"bytes"
"context"
+ "crypto/ed25519"
+ "crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -674,6 +676,251 @@ func TestRunVerifiesChecksumWhenSidecarAvailable(t *testing.T) {
}
}
+func TestRunVerifiesEd25519SignatureWhenConfigured(t *testing.T) {
+ assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH)
+ if err != nil {
+ t.Skipf("unsupported test platform: %v", err)
+ }
+
+ publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+
+ const newBinary = "new-binary"
+ digest := sha256.Sum256([]byte(newBinary))
+ signature := ed25519.Sign(privateKey, digest[:])
+ signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n"
+
+ client := &http.Client{
+ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
+ switch r.URL.String() {
+ case "https://releases.example.com/latest":
+ release := Release{TagName: "v1.2.3"}
+ release.Assets.Links = []ReleaseLink{
+ {Name: assetName, URL: "https://releases.example.com/artifact"},
+ {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"},
+ }
+ payload, err := json.Marshal(release)
+ if err != nil {
+ t.Fatalf("Marshal release: %v", err)
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(bytes.NewReader(payload)),
+ }, nil
+ case "https://releases.example.com/artifact":
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(newBinary)),
+ }, nil
+ case "https://releases.example.com/artifact.sig":
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(signatureBody)),
+ }, nil
+ default:
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("not found")),
+ }, nil
+ }
+ }),
+ }
+
+ t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey))
+
+ tempDir := t.TempDir()
+ target := filepath.Join(tempDir, "graylog-mcp")
+ if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil {
+ t.Fatalf("WriteFile target: %v", err)
+ }
+
+ err = Run(context.Background(), Options{
+ Client: client,
+ CurrentVersion: "v1.2.2",
+ ExecutablePath: target,
+ LatestReleaseURL: "https://releases.example.com/latest",
+ BinaryName: "graylog-mcp",
+ ReleaseSource: ReleaseSource{
+ SignatureRequired: true,
+ SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"},
+ },
+ ReplaceExecutable: func(downloadPath, targetPath string) error {
+ data, readErr := os.ReadFile(downloadPath)
+ if readErr != nil {
+ return readErr
+ }
+ return os.WriteFile(targetPath, data, 0o755)
+ },
+ })
+ if err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+}
+
+func TestRunFailsOnEd25519SignatureMismatch(t *testing.T) {
+ assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH)
+ if err != nil {
+ t.Skipf("unsupported test platform: %v", err)
+ }
+
+ publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatalf("GenerateKey: %v", err)
+ }
+
+ signature := ed25519.Sign(privateKey, []byte("wrong-digest"))
+ signatureBody := hex.EncodeToString(signature) + " " + assetName + "\n"
+
+ client := &http.Client{
+ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
+ switch r.URL.String() {
+ case "https://releases.example.com/latest":
+ release := Release{TagName: "v1.2.3"}
+ release.Assets.Links = []ReleaseLink{
+ {Name: assetName, URL: "https://releases.example.com/artifact"},
+ {Name: assetName + ".sig", URL: "https://releases.example.com/artifact.sig"},
+ }
+ payload, err := json.Marshal(release)
+ if err != nil {
+ t.Fatalf("Marshal release: %v", err)
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(bytes.NewReader(payload)),
+ }, nil
+ case "https://releases.example.com/artifact":
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("new-binary")),
+ }, nil
+ case "https://releases.example.com/artifact.sig":
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(signatureBody)),
+ }, nil
+ default:
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("not found")),
+ }, nil
+ }
+ }),
+ }
+
+ t.Setenv("RELEASE_PUBKEY", hex.EncodeToString(publicKey))
+
+ tempDir := t.TempDir()
+ target := filepath.Join(tempDir, "graylog-mcp")
+ if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil {
+ t.Fatalf("WriteFile target: %v", err)
+ }
+
+ err = Run(context.Background(), Options{
+ Client: client,
+ CurrentVersion: "v1.2.2",
+ ExecutablePath: target,
+ LatestReleaseURL: "https://releases.example.com/latest",
+ BinaryName: "graylog-mcp",
+ ReleaseSource: ReleaseSource{
+ SignatureRequired: true,
+ SignaturePublicKeyEnvNames: []string{"RELEASE_PUBKEY"},
+ },
+ ReplaceExecutable: func(downloadPath, targetPath string) error {
+ data, readErr := os.ReadFile(downloadPath)
+ if readErr != nil {
+ return readErr
+ }
+ return os.WriteFile(targetPath, data, 0o755)
+ },
+ })
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "signature mismatch") {
+ t.Fatalf("error = %v", err)
+ }
+}
+
+func TestRunFailsWhenSignatureRequiredAndPublicKeyMissing(t *testing.T) {
+ assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH)
+ if err != nil {
+ t.Skipf("unsupported test platform: %v", err)
+ }
+
+ client := &http.Client{
+ Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
+ switch r.URL.String() {
+ case "https://releases.example.com/latest":
+ release := Release{TagName: "v1.2.3"}
+ release.Assets.Links = []ReleaseLink{
+ {Name: assetName, URL: "https://releases.example.com/artifact"},
+ }
+ payload, err := json.Marshal(release)
+ if err != nil {
+ t.Fatalf("Marshal release: %v", err)
+ }
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(bytes.NewReader(payload)),
+ }, nil
+ case "https://releases.example.com/artifact":
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("new-binary")),
+ }, nil
+ default:
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("not found")),
+ }, nil
+ }
+ }),
+ }
+
+ tempDir := t.TempDir()
+ target := filepath.Join(tempDir, "graylog-mcp")
+ if err := os.WriteFile(target, []byte("old-binary"), 0o755); err != nil {
+ t.Fatalf("WriteFile target: %v", err)
+ }
+
+ err = Run(context.Background(), Options{
+ Client: client,
+ CurrentVersion: "v1.2.2",
+ ExecutablePath: target,
+ LatestReleaseURL: "https://releases.example.com/latest",
+ BinaryName: "graylog-mcp",
+ ReleaseSource: ReleaseSource{
+ SignatureRequired: true,
+ },
+ ReplaceExecutable: func(downloadPath, targetPath string) error {
+ data, readErr := os.ReadFile(downloadPath)
+ if readErr != nil {
+ return readErr
+ }
+ return os.WriteFile(targetPath, data, 0o755)
+ },
+ })
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(strings.ToLower(err.Error()), "public key") {
+ t.Fatalf("error = %v", err)
+ }
+}
+
func TestRunFailsOnChecksumMismatch(t *testing.T) {
assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH)
if err != nil {