From fbff660bcc8a3a9c34539e0ab6f6b166b1f22de6 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Wed, 15 Apr 2026 12:20:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ajouter=20la=20v=C3=A9rification=20Ed25?= =?UTF-8?q?519=20des=20artefacts=20de=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- cmd/mcp-framework/main.go | 5 +- manifest/manifest.go | 55 +++++---- manifest/manifest_test.go | 16 +++ scaffold/scaffold.go | 13 ++ scaffold/scaffold_test.go | 2 + update/update.go | 234 ++++++++++++++++++++++++++++++++++-- update/update_test.go | 247 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 545 insertions(+), 36 deletions(-) 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 {