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