feat: ajouter la vérification Ed25519 des artefacts de release
This commit is contained in:
parent
0d266cd5cc
commit
fbff660bcc
8 changed files with 545 additions and 36 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ type Update struct {
|
||||||
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"`
|
||||||
|
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"`
|
TokenHeader string `toml:"token_header"`
|
||||||
TokenPrefix string `toml:"token_prefix"`
|
TokenPrefix string `toml:"token_prefix"`
|
||||||
TokenEnvNames []string `toml:"token_env_names"`
|
TokenEnvNames []string `toml:"token_env_names"`
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -189,6 +196,10 @@ func (u Update) ReleaseSource() update.ReleaseSource {
|
||||||
AssetNameTemplate: u.AssetNameTemplate,
|
AssetNameTemplate: u.AssetNameTemplate,
|
||||||
ChecksumAssetName: u.ChecksumAssetName,
|
ChecksumAssetName: u.ChecksumAssetName,
|
||||||
ChecksumRequired: u.ChecksumRequired,
|
ChecksumRequired: u.ChecksumRequired,
|
||||||
|
SignatureAssetName: u.SignatureAssetName,
|
||||||
|
SignatureRequired: u.SignatureRequired,
|
||||||
|
SignaturePublicKey: u.SignaturePublicKey,
|
||||||
|
SignaturePublicKeyEnvNames: append([]string(nil), u.SignaturePublicKeyEnvNames...),
|
||||||
TokenHeader: u.TokenHeader,
|
TokenHeader: u.TokenHeader,
|
||||||
TokenPrefix: u.TokenPrefix,
|
TokenPrefix: u.TokenPrefix,
|
||||||
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
|
TokenEnvNames: append([]string(nil), u.TokenEnvNames...),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}}"]
|
||||||
|
|
|
||||||
|
|
@ -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]",
|
||||||
|
|
|
||||||
210
update/update.go
210
update/update.go
|
|
@ -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"
|
||||||
|
|
@ -60,6 +62,10 @@ type ReleaseSource struct {
|
||||||
AssetNameTemplate string
|
AssetNameTemplate string
|
||||||
ChecksumAssetName string
|
ChecksumAssetName string
|
||||||
ChecksumRequired bool
|
ChecksumRequired bool
|
||||||
|
SignatureAssetName string
|
||||||
|
SignatureRequired bool
|
||||||
|
SignaturePublicKey string
|
||||||
|
SignaturePublicKeyEnvNames []string
|
||||||
Token string
|
Token string
|
||||||
TokenHeader string
|
TokenHeader string
|
||||||
TokenPrefix string
|
TokenPrefix string
|
||||||
|
|
@ -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, "./")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue