Files
kubesolo-os/update/pkg/signing/signing.go
Adolfo Delorenzo 49a37e30e8 feat: add production hardening — Ed25519 signing, Portainer Edge, SSH extension (Phase 4)
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>
2026-02-11 11:26:23 -06:00

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)
}