Image signing: - Ed25519 sign/verify package (pure Go stdlib, zero deps) - genkey and sign CLI subcommands for build system - Optional --pubkey flag for verifying updates on apply - Signature URLs in update metadata (latest.json) Portainer Edge Agent: - cloud-init portainer.go module writes K8s manifest - Auto-deploys Edge Agent when portainer.edge-agent.enabled - Full RBAC (ServiceAccount, ClusterRoleBinding, Deployment) - 5 Portainer tests in portainer_test.go Production tooling: - SSH debug extension builder (hack/build-ssh-extension.sh) - Boot performance benchmark (test/benchmark/bench-boot.sh) - Resource usage benchmark (test/benchmark/bench-resources.sh) - Deployment guide (docs/deployment-guide.md) Test results: 50 update agent tests + 22 cloud-init tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
189 lines
5.8 KiB
Go
189 lines
5.8 KiB
Go
// Package signing provides Ed25519 signature verification for update images.
|
|
//
|
|
// KubeSolo OS uses Ed25519 signatures to ensure update integrity and
|
|
// authenticity. The update server signs both the metadata (latest.json)
|
|
// and individual image files. The update agent verifies signatures using
|
|
// a trusted public key embedded at build time or loaded from disk.
|
|
//
|
|
// Signature workflow:
|
|
// 1. Build system signs images with private key (offline)
|
|
// 2. Signatures stored alongside images on update server (.sig files)
|
|
// 3. Update agent downloads signatures and verifies before applying
|
|
//
|
|
// Key format: raw 32-byte Ed25519 public keys, hex-encoded for config files.
|
|
package signing
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
)
|
|
|
|
// Verifier checks Ed25519 signatures on update artifacts.
|
|
type Verifier struct {
|
|
publicKey ed25519.PublicKey
|
|
}
|
|
|
|
// NewVerifier creates a verifier from a hex-encoded Ed25519 public key string.
|
|
func NewVerifier(hexPubKey string) (*Verifier, error) {
|
|
keyBytes, err := hex.DecodeString(hexPubKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decoding public key hex: %w", err)
|
|
}
|
|
if len(keyBytes) != ed25519.PublicKeySize {
|
|
return nil, fmt.Errorf("invalid public key size: got %d bytes, want %d", len(keyBytes), ed25519.PublicKeySize)
|
|
}
|
|
return &Verifier{publicKey: ed25519.PublicKey(keyBytes)}, nil
|
|
}
|
|
|
|
// NewVerifierFromFile reads an Ed25519 public key from a file.
|
|
// The file should contain the hex-encoded public key (64 hex chars).
|
|
func NewVerifierFromFile(path string) (*Verifier, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading public key file: %w", err)
|
|
}
|
|
// Trim whitespace
|
|
hexKey := trimWhitespace(string(data))
|
|
return NewVerifier(hexKey)
|
|
}
|
|
|
|
// VerifyFile checks that a file's signature matches.
|
|
// The signature file should contain the raw Ed25519 signature (64 bytes)
|
|
// or a hex-encoded signature (128 hex chars).
|
|
func (v *Verifier) VerifyFile(filePath, sigPath string) error {
|
|
// Read the file content
|
|
message, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("reading file: %w", err)
|
|
}
|
|
|
|
// Read the signature
|
|
sigData, err := os.ReadFile(sigPath)
|
|
if err != nil {
|
|
return fmt.Errorf("reading signature: %w", err)
|
|
}
|
|
|
|
sig, err := decodeSignature(sigData)
|
|
if err != nil {
|
|
return fmt.Errorf("decoding signature: %w", err)
|
|
}
|
|
|
|
if !ed25519.Verify(v.publicKey, message, sig) {
|
|
return fmt.Errorf("signature verification failed for %s", filePath)
|
|
}
|
|
|
|
slog.Debug("signature verified", "file", filePath)
|
|
return nil
|
|
}
|
|
|
|
// VerifyBytes checks a signature against raw bytes.
|
|
func (v *Verifier) VerifyBytes(message, signature []byte) error {
|
|
if !ed25519.Verify(v.publicKey, message, signature) {
|
|
return fmt.Errorf("signature verification failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Signer creates Ed25519 signatures for update artifacts.
|
|
// Used by the build system, not the update agent on the device.
|
|
type Signer struct {
|
|
privateKey ed25519.PrivateKey
|
|
}
|
|
|
|
// NewSigner creates a signer from a hex-encoded Ed25519 private key.
|
|
func NewSigner(hexPrivKey string) (*Signer, error) {
|
|
keyBytes, err := hex.DecodeString(hexPrivKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decoding private key hex: %w", err)
|
|
}
|
|
if len(keyBytes) != ed25519.PrivateKeySize {
|
|
return nil, fmt.Errorf("invalid private key size: got %d bytes, want %d", len(keyBytes), ed25519.PrivateKeySize)
|
|
}
|
|
return &Signer{privateKey: ed25519.PrivateKey(keyBytes)}, nil
|
|
}
|
|
|
|
// NewSignerFromFile reads an Ed25519 private key from a file.
|
|
func NewSignerFromFile(path string) (*Signer, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading private key file: %w", err)
|
|
}
|
|
hexKey := trimWhitespace(string(data))
|
|
return NewSigner(hexKey)
|
|
}
|
|
|
|
// SignFile creates a signature for a file and writes it to sigPath.
|
|
func (s *Signer) SignFile(filePath, sigPath string) error {
|
|
message, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("reading file: %w", err)
|
|
}
|
|
|
|
sig := ed25519.Sign(s.privateKey, message)
|
|
|
|
// Write hex-encoded signature
|
|
hexSig := hex.EncodeToString(sig)
|
|
if err := os.WriteFile(sigPath, []byte(hexSig+"\n"), 0o644); err != nil {
|
|
return fmt.Errorf("writing signature: %w", err)
|
|
}
|
|
|
|
slog.Debug("signed", "file", filePath, "sig", sigPath)
|
|
return nil
|
|
}
|
|
|
|
// SignBytes creates a signature for raw bytes.
|
|
func (s *Signer) SignBytes(message []byte) []byte {
|
|
return ed25519.Sign(s.privateKey, message)
|
|
}
|
|
|
|
// PublicKeyHex returns the hex-encoded public key corresponding to this signer.
|
|
func (s *Signer) PublicKeyHex() string {
|
|
pubKey := s.privateKey.Public().(ed25519.PublicKey)
|
|
return hex.EncodeToString(pubKey)
|
|
}
|
|
|
|
// GenerateKeyPair creates a new Ed25519 key pair and returns hex-encoded strings.
|
|
func GenerateKeyPair() (publicKeyHex, privateKeyHex string, err error) {
|
|
pub, priv, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("generating key pair: %w", err)
|
|
}
|
|
return hex.EncodeToString(pub), hex.EncodeToString(priv), nil
|
|
}
|
|
|
|
// decodeSignature handles both raw (64 bytes) and hex-encoded signatures.
|
|
func decodeSignature(data []byte) ([]byte, error) {
|
|
// Trim whitespace for hex-encoded sigs
|
|
trimmed := trimWhitespace(string(data))
|
|
|
|
// If exactly 64 bytes, treat as raw signature
|
|
if len(data) == ed25519.SignatureSize {
|
|
return data, nil
|
|
}
|
|
|
|
// Try hex decode
|
|
sig, err := hex.DecodeString(trimmed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid signature format (not raw or hex): %w", err)
|
|
}
|
|
|
|
if len(sig) != ed25519.SignatureSize {
|
|
return nil, fmt.Errorf("invalid signature size: got %d bytes, want %d", len(sig), ed25519.SignatureSize)
|
|
}
|
|
|
|
return sig, nil
|
|
}
|
|
|
|
func trimWhitespace(s string) string {
|
|
result := make([]byte, 0, len(s))
|
|
for _, b := range []byte(s) {
|
|
if b != ' ' && b != '\n' && b != '\r' && b != '\t' {
|
|
result = append(result, b)
|
|
}
|
|
}
|
|
return string(result)
|
|
}
|