trusted.pub now holds the release signing key, so self-upgrade, agent upgrade and node onboarding refuse a release whose checksums are not signed with it. Onboarding fetched the release on the node and compared it with a checksum from the same server; the control plane now fetches it for the node's architecture with release.Fetch, checks signature and checksum, and hands the bytes over. Release addresses must be https (loopback excepted). LLMGW_RELEASE_KEY overrides the key only in development builds. The CI release job fails instead of publishing an unsigned release while a key is built in. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
80 lines
2.8 KiB
Go
80 lines
2.8 KiB
Go
package release
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
_ "embed"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
|
)
|
|
|
|
// trustedPub is the public key releases are signed with, base64, built into
|
|
// every binary from the repository. Empty — as it is until a key is made —
|
|
// trusts a release on its checksums alone, as every release before this did.
|
|
//
|
|
// Built in rather than fetched, because the point of a signature is that the
|
|
// server the release comes from cannot vouch for it: a machine that already
|
|
// runs llmgw checks the next release against the key the release it runs was
|
|
// built with, so replacing both the binaries and the checksums on the server
|
|
// is no longer enough.
|
|
//
|
|
//go:embed trusted.pub
|
|
var trustedPub string
|
|
|
|
// TrustedKey is the key releases are verified with: the built-in one; empty
|
|
// is none. LLMGW_RELEASE_KEY replaces it in a development build — for testing
|
|
// a release pipeline — and never in a release: a key taken from the
|
|
// environment would let whatever sets the environment of an upgrading
|
|
// process choose what it trusts.
|
|
func TrustedKey() string {
|
|
if !Tag.MatchString(version.Version) {
|
|
if k := strings.TrimSpace(os.Getenv("LLMGW_RELEASE_KEY")); k != "" {
|
|
return k
|
|
}
|
|
}
|
|
return strings.TrimSpace(trustedPub)
|
|
}
|
|
|
|
// SignatureName is the file a release's checksums are signed in.
|
|
const SignatureName = "checksums.txt.sig"
|
|
|
|
// GenerateKey makes a signing key pair, both base64: the private key is the
|
|
// 32-byte seed, which is what goes into the CI secret.
|
|
func GenerateKey() (public, private string, err error) {
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(pub), base64.StdEncoding.EncodeToString(priv.Seed()), nil
|
|
}
|
|
|
|
// Sign signs data with a base64 seed, returning the signature in base64.
|
|
func Sign(privateSeed string, data []byte) (string, error) {
|
|
seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateSeed))
|
|
if err != nil || len(seed) != ed25519.SeedSize {
|
|
return "", errors.New("the signing key is not a base64 ed25519 seed")
|
|
}
|
|
return base64.StdEncoding.EncodeToString(ed25519.Sign(ed25519.NewKeyFromSeed(seed), data)), nil
|
|
}
|
|
|
|
// Verify checks a base64 signature of data against a base64 public key.
|
|
func Verify(public string, data []byte, signature string) error {
|
|
pub, err := base64.StdEncoding.DecodeString(strings.TrimSpace(public))
|
|
if err != nil || len(pub) != ed25519.PublicKeySize {
|
|
return errors.New("the trusted release key is not a base64 ed25519 public key")
|
|
}
|
|
sig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(signature))
|
|
if err != nil {
|
|
return fmt.Errorf("the release signature is not base64: %w", err)
|
|
}
|
|
if !ed25519.Verify(pub, data, sig) {
|
|
return errors.New("the release's checksums are not signed by the trusted key")
|
|
}
|
|
return nil
|
|
}
|