feat: ajouter la vérification Ed25519 des artefacts de release

This commit is contained in:
thibaud-lclr 2026-04-15 12:20:06 +02:00
parent 0d266cd5cc
commit fbff660bcc
8 changed files with 545 additions and 36 deletions

View file

@ -126,7 +126,10 @@ repository = "org/repo"
base_url = "https://gitea.example.com" base_url = "https://gitea.example.com"
asset_name_template = "{binary}-{os}-{arch}{ext}" asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256" 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_header = "Authorization"
token_prefix = "token" token_prefix = "token"
token_env_names = ["GITEA_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}`). - `asset_name_template` : template de nom d'asset (`{binary}`, `{os}`, `{arch}`, `{ext}`).
- `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`. - `checksum_asset_name` : nom d'asset checksum, avec placeholder optionnel `{asset}`.
- `checksum_required` : si `true`, l'update échoue quand l'asset checksum est absent. - `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_header` : header HTTP à utiliser pour l'authentification.
- `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...). - `token_prefix` : préfixe appliqué devant le token (`Bearer`, `token`, ...).
- `token_env_names` : liste de variables d'environnement candidates pour retrouver le token. - `token_env_names` : liste de variables d'environnement candidates pour retrouver le token.

View file

@ -77,6 +77,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
var releaseBaseURL string var releaseBaseURL string
var releaseRepository string var releaseRepository string
var releaseTokenEnv string var releaseTokenEnv string
var releasePublicKeyEnv string
var overwrite bool var overwrite bool
fs.StringVar(&target, "target", "", "Répertoire cible du nouveau projet (requis)") 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(&releaseBaseURL, "release-base-url", "", "Base URL de la forge release")
fs.StringVar(&releaseRepository, "release-repository", "", "Repository release (org/repo)") 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(&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") fs.BoolVar(&overwrite, "overwrite", false, "Autorise l'écrasement des fichiers existants")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
@ -121,6 +123,7 @@ func runScaffoldInit(args []string, stdout, stderr io.Writer) error {
ReleaseBaseURL: releaseBaseURL, ReleaseBaseURL: releaseBaseURL,
ReleaseRepository: releaseRepository, ReleaseRepository: releaseRepository,
ReleaseTokenEnv: releaseTokenEnv, ReleaseTokenEnv: releaseTokenEnv,
ReleasePublicKeyEnv: releasePublicKeyEnv,
Overwrite: overwrite, Overwrite: overwrite,
}) })
if err != nil { if err != nil {
@ -159,7 +162,7 @@ func printScaffoldHelp(w io.Writer) {
func printScaffoldInitHelp(w io.Writer) { func printScaffoldInitHelp(w io.Writer) {
fmt.Fprintf( fmt.Fprintf(
w, w,
"Usage:\n %s scaffold init --target <dir> [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 <dir> [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, toolName,
) )
} }

View file

@ -25,17 +25,21 @@ type File struct {
} }
type Update struct { type Update struct {
SourceName string `toml:"source_name"` SourceName string `toml:"source_name"`
Driver string `toml:"driver"` Driver string `toml:"driver"`
Repository string `toml:"repository"` Repository string `toml:"repository"`
BaseURL string `toml:"base_url"` BaseURL string `toml:"base_url"`
LatestReleaseURL string `toml:"latest_release_url"` LatestReleaseURL string `toml:"latest_release_url"`
AssetNameTemplate string `toml:"asset_name_template"` AssetNameTemplate string `toml:"asset_name_template"`
ChecksumAssetName string `toml:"checksum_asset_name"` ChecksumAssetName string `toml:"checksum_asset_name"`
ChecksumRequired bool `toml:"checksum_required"` ChecksumRequired bool `toml:"checksum_required"`
TokenHeader string `toml:"token_header"` SignatureAssetName string `toml:"signature_asset_name"`
TokenPrefix string `toml:"token_prefix"` SignatureRequired bool `toml:"signature_required"`
TokenEnvNames []string `toml:"token_env_names"` 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 { type Environment struct {
@ -155,6 +159,9 @@ func (u *Update) normalize() {
u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL) u.LatestReleaseURL = strings.TrimSpace(u.LatestReleaseURL)
u.AssetNameTemplate = strings.TrimSpace(u.AssetNameTemplate) u.AssetNameTemplate = strings.TrimSpace(u.AssetNameTemplate)
u.ChecksumAssetName = strings.TrimSpace(u.ChecksumAssetName) 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.TokenHeader = strings.TrimSpace(u.TokenHeader)
u.TokenPrefix = strings.TrimSpace(u.TokenPrefix) u.TokenPrefix = strings.TrimSpace(u.TokenPrefix)
u.TokenEnvNames = normalizeStringList(u.TokenEnvNames) u.TokenEnvNames = normalizeStringList(u.TokenEnvNames)
@ -181,17 +188,21 @@ func (u Update) ReleaseSource() update.ReleaseSource {
u.normalize() u.normalize()
return update.ReleaseSource{ return update.ReleaseSource{
Name: u.SourceName, Name: u.SourceName,
Driver: u.Driver, Driver: u.Driver,
Repository: u.Repository, Repository: u.Repository,
BaseURL: u.BaseURL, BaseURL: u.BaseURL,
LatestReleaseURL: u.LatestReleaseURL, LatestReleaseURL: u.LatestReleaseURL,
AssetNameTemplate: u.AssetNameTemplate, AssetNameTemplate: u.AssetNameTemplate,
ChecksumAssetName: u.ChecksumAssetName, ChecksumAssetName: u.ChecksumAssetName,
ChecksumRequired: u.ChecksumRequired, ChecksumRequired: u.ChecksumRequired,
TokenHeader: u.TokenHeader, SignatureAssetName: u.SignatureAssetName,
TokenPrefix: u.TokenPrefix, SignatureRequired: u.SignatureRequired,
TokenEnvNames: append([]string(nil), u.TokenEnvNames...), SignaturePublicKey: u.SignaturePublicKey,
SignaturePublicKeyEnvNames: append([]string(nil), u.SignaturePublicKeyEnvNames...),
TokenHeader: u.TokenHeader,
TokenPrefix: u.TokenPrefix,
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
} }
} }

View file

@ -53,6 +53,10 @@ latest_release_url = "https://gitea.example.com/api/releases/latest"
asset_name_template = "{binary}_{os}_{arch}{ext}" asset_name_template = "{binary}_{os}_{arch}{ext}"
checksum_asset_name = "{asset}.sha256" checksum_asset_name = "{asset}.sha256"
checksum_required = true 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_header = " Authorization "
token_prefix = " token " token_prefix = " token "
token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"] token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
@ -92,6 +96,18 @@ token_env_names = [" GITEA_TOKEN ", "", "GITEA_RELEASE_TOKEN"]
if !source.ChecksumRequired { if !source.ChecksumRequired {
t.Fatal("checksum required should be true") 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" { if source.TokenHeader != "Authorization" {
t.Fatalf("token header = %q", source.TokenHeader) t.Fatalf("token header = %q", source.TokenHeader)
} }

View file

@ -31,6 +31,7 @@ type Options struct {
ReleaseBaseURL string ReleaseBaseURL string
ReleaseRepository string ReleaseRepository string
ReleaseTokenEnv string ReleaseTokenEnv string
ReleasePublicKeyEnv string
Overwrite bool Overwrite bool
} }
@ -56,6 +57,7 @@ type normalizedOptions struct {
ReleaseBaseURL string ReleaseBaseURL string
ReleaseRepository string ReleaseRepository string
ReleaseTokenEnv string ReleaseTokenEnv string
ReleasePublicKeyEnv string
Overwrite bool Overwrite bool
} }
@ -231,6 +233,13 @@ func normalizeOptions(options Options) (normalizedOptions, error) {
if releaseTokenEnv == "" { if releaseTokenEnv == "" {
releaseTokenEnv = envPrefix + "_RELEASE_TOKEN" 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{ return normalizedOptions{
TargetDir: resolvedTarget, TargetDir: resolvedTarget,
@ -249,6 +258,7 @@ func normalizeOptions(options Options) (normalizedOptions, error) {
ReleaseBaseURL: releaseBaseURL, ReleaseBaseURL: releaseBaseURL,
ReleaseRepository: releaseRepository, ReleaseRepository: releaseRepository,
ReleaseTokenEnv: releaseTokenEnv, ReleaseTokenEnv: releaseTokenEnv,
ReleasePublicKeyEnv: releasePublicKeyEnv,
Overwrite: options.Overwrite, Overwrite: options.Overwrite,
}, nil }, nil
} }
@ -1182,6 +1192,9 @@ base_url = "{{.ReleaseBaseURL}}"
asset_name_template = "{binary}-{os}-{arch}{ext}" asset_name_template = "{binary}-{os}-{arch}{ext}"
checksum_asset_name = "{asset}.sha256" checksum_asset_name = "{asset}.sha256"
checksum_required = true checksum_required = true
signature_asset_name = "{asset}.sig"
signature_required = false
signature_public_key_env_names = ["{{.ReleasePublicKeyEnv}}"]
token_header = "Authorization" token_header = "Authorization"
token_prefix = "token" token_prefix = "token"
token_env_names = ["{{.ReleaseTokenEnv}}"] token_env_names = ["{{.ReleaseTokenEnv}}"]

View file

@ -89,6 +89,8 @@ func TestGenerateCreatesRecommendedSkeleton(t *testing.T) {
"binary_name = \"my-mcp\"", "binary_name = \"my-mcp\"",
"[update]", "[update]",
"checksum_required = true", "checksum_required = true",
"signature_asset_name = \"{asset}.sig\"",
"signature_required = false",
"[secret_store]", "[secret_store]",
"[environment]", "[environment]",
"[profiles]", "[profiles]",

View file

@ -2,7 +2,9 @@ package update
import ( import (
"context" "context"
"crypto/ed25519"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
@ -52,18 +54,22 @@ type ValidationInput struct {
} }
type ReleaseSource struct { type ReleaseSource struct {
Name string Name string
Driver string Driver string
Repository string Repository string
BaseURL string BaseURL string
LatestReleaseURL string LatestReleaseURL string
AssetNameTemplate string AssetNameTemplate string
ChecksumAssetName string ChecksumAssetName string
ChecksumRequired bool ChecksumRequired bool
Token string SignatureAssetName string
TokenHeader string SignatureRequired bool
TokenPrefix string SignaturePublicKey string
TokenEnvNames []string SignaturePublicKeyEnvNames []string
Token string
TokenHeader string
TokenPrefix string
TokenEnvNames []string
} }
type Auth struct { 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 { if err := VerifyReleaseAssetChecksum(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil {
return err return err
} }
if err := VerifyReleaseAssetSignature(ctx, opts.Client, release, releaseURL, assetName, downloadPath, auth, source); err != nil {
return err
}
if opts.ValidateDownloaded != nil { if opts.ValidateDownloaded != nil {
if err := opts.ValidateDownloaded(ctx, ValidationInput{ if err := opts.ValidateDownloaded(ctx, ValidationInput{
@ -527,6 +536,70 @@ func VerifyReleaseAssetChecksum(
return nil 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 { func ReplaceExecutable(downloadPath, targetPath string) error {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return errors.New("self-update is not supported on windows without a custom ReplaceExecutable hook") 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.LatestReleaseURL = strings.TrimSpace(source.LatestReleaseURL)
source.AssetNameTemplate = strings.TrimSpace(source.AssetNameTemplate) source.AssetNameTemplate = strings.TrimSpace(source.AssetNameTemplate)
source.ChecksumAssetName = strings.TrimSpace(source.ChecksumAssetName) 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.Token = strings.TrimSpace(source.Token)
source.TokenHeader = strings.TrimSpace(source.TokenHeader) source.TokenHeader = strings.TrimSpace(source.TokenHeader)
source.TokenPrefix = strings.TrimSpace(source.TokenPrefix) source.TokenPrefix = strings.TrimSpace(source.TokenPrefix)
@ -557,6 +632,14 @@ func normalizeSource(source ReleaseSource) ReleaseSource {
} }
source.TokenEnvNames = envNames 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 { switch source.Driver {
case "gitea": case "gitea":
if source.Name == "" { if source.Name == "" {
@ -742,6 +825,14 @@ func resolveChecksumAssetName(assetName, configured string) string {
return strings.ReplaceAll(value, "{asset}", assetName) 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) { 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) req, err := http.NewRequestWithContext(ctx, http.MethodGet, assetURL, nil)
if err != 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) 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 { func matchesAssetName(candidate, assetName string) bool {
name := strings.TrimSpace(strings.TrimPrefix(candidate, "*")) name := strings.TrimSpace(strings.TrimPrefix(candidate, "*"))
name = strings.TrimPrefix(name, "./") name = strings.TrimPrefix(name, "./")

View file

@ -3,6 +3,8 @@ package update
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "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) { func TestRunFailsOnChecksumMismatch(t *testing.T) {
assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH) assetName, err := AssetName("graylog-mcp", runtime.GOOS, runtime.GOARCH)
if err != nil { if err != nil {