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>
335 lines
7.3 KiB
Go
335 lines
7.3 KiB
Go
package signing
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func generateTestKeyPair(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
pub, priv, err := GenerateKeyPair()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return pub, priv
|
|
}
|
|
|
|
func TestGenerateKeyPair(t *testing.T) {
|
|
pub, priv, err := GenerateKeyPair()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Public key should be 32 bytes = 64 hex chars
|
|
if len(pub) != 64 {
|
|
t.Errorf("expected 64 hex chars for public key, got %d", len(pub))
|
|
}
|
|
|
|
// Private key should be 64 bytes = 128 hex chars
|
|
if len(priv) != 128 {
|
|
t.Errorf("expected 128 hex chars for private key, got %d", len(priv))
|
|
}
|
|
|
|
// Keys should be valid hex
|
|
if _, err := hex.DecodeString(pub); err != nil {
|
|
t.Errorf("public key is not valid hex: %v", err)
|
|
}
|
|
if _, err := hex.DecodeString(priv); err != nil {
|
|
t.Errorf("private key is not valid hex: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewVerifier(t *testing.T) {
|
|
pub, _ := generateTestKeyPair(t)
|
|
|
|
v, err := NewVerifier(pub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v == nil {
|
|
t.Fatal("verifier should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestNewVerifierInvalid(t *testing.T) {
|
|
// Invalid hex
|
|
_, err := NewVerifier("not-hex")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid hex")
|
|
}
|
|
|
|
// Wrong length
|
|
_, err = NewVerifier("abcd")
|
|
if err == nil {
|
|
t.Fatal("expected error for wrong key length")
|
|
}
|
|
}
|
|
|
|
func TestNewSigner(t *testing.T) {
|
|
_, priv := generateTestKeyPair(t)
|
|
|
|
s, err := NewSigner(priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s == nil {
|
|
t.Fatal("signer should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestSignAndVerifyBytes(t *testing.T) {
|
|
pub, priv := generateTestKeyPair(t)
|
|
|
|
signer, err := NewSigner(priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
verifier, err := NewVerifier(pub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
message := []byte("KubeSolo OS update v1.0.0")
|
|
sig := signer.SignBytes(message)
|
|
|
|
// Verify should succeed
|
|
if err := verifier.VerifyBytes(message, sig); err != nil {
|
|
t.Errorf("verification should succeed: %v", err)
|
|
}
|
|
|
|
// Tampered message should fail
|
|
tampered := []byte("KubeSolo OS update v1.0.1")
|
|
if err := verifier.VerifyBytes(tampered, sig); err == nil {
|
|
t.Error("verification should fail for tampered message")
|
|
}
|
|
|
|
// Tampered signature should fail
|
|
badSig := make([]byte, len(sig))
|
|
copy(badSig, sig)
|
|
badSig[0] ^= 0xff
|
|
if err := verifier.VerifyBytes(message, badSig); err == nil {
|
|
t.Error("verification should fail for tampered signature")
|
|
}
|
|
}
|
|
|
|
func TestSignAndVerifyFile(t *testing.T) {
|
|
pub, priv := generateTestKeyPair(t)
|
|
dir := t.TempDir()
|
|
|
|
signer, err := NewSigner(priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
verifier, err := NewVerifier(pub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create a test file
|
|
filePath := filepath.Join(dir, "test-image.gz")
|
|
content := []byte("fake OS image content for signing test")
|
|
if err := os.WriteFile(filePath, content, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Sign
|
|
sigPath := filePath + ".sig"
|
|
if err := signer.SignFile(filePath, sigPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify signature file was created
|
|
if _, err := os.Stat(sigPath); err != nil {
|
|
t.Fatalf("signature file not created: %v", err)
|
|
}
|
|
|
|
// Verify
|
|
if err := verifier.VerifyFile(filePath, sigPath); err != nil {
|
|
t.Errorf("verification should succeed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyFileTampered(t *testing.T) {
|
|
pub, priv := generateTestKeyPair(t)
|
|
dir := t.TempDir()
|
|
|
|
signer, err := NewSigner(priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
verifier, err := NewVerifier(pub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create and sign a file
|
|
filePath := filepath.Join(dir, "test-image.gz")
|
|
if err := os.WriteFile(filePath, []byte("original content"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sigPath := filePath + ".sig"
|
|
if err := signer.SignFile(filePath, sigPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Tamper with the file
|
|
if err := os.WriteFile(filePath, []byte("tampered content"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verification should fail
|
|
if err := verifier.VerifyFile(filePath, sigPath); err == nil {
|
|
t.Error("verification should fail for tampered file")
|
|
}
|
|
}
|
|
|
|
func TestVerifyFileWrongKey(t *testing.T) {
|
|
_, priv := generateTestKeyPair(t)
|
|
otherPub, _ := generateTestKeyPair(t) // different key pair
|
|
dir := t.TempDir()
|
|
|
|
signer, err := NewSigner(priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
wrongVerifier, err := NewVerifier(otherPub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Create and sign
|
|
filePath := filepath.Join(dir, "test.gz")
|
|
if err := os.WriteFile(filePath, []byte("test content"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
sigPath := filePath + ".sig"
|
|
if err := signer.SignFile(filePath, sigPath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Verify with wrong key should fail
|
|
if err := wrongVerifier.VerifyFile(filePath, sigPath); err == nil {
|
|
t.Error("verification should fail with wrong public key")
|
|
}
|
|
}
|
|
|
|
func TestNewVerifierFromFile(t *testing.T) {
|
|
pub, _ := generateTestKeyPair(t)
|
|
dir := t.TempDir()
|
|
|
|
keyFile := filepath.Join(dir, "pubkey.hex")
|
|
if err := os.WriteFile(keyFile, []byte(pub+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
v, err := NewVerifierFromFile(keyFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if v == nil {
|
|
t.Fatal("verifier should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestNewSignerFromFile(t *testing.T) {
|
|
_, priv := generateTestKeyPair(t)
|
|
dir := t.TempDir()
|
|
|
|
keyFile := filepath.Join(dir, "privkey.hex")
|
|
if err := os.WriteFile(keyFile, []byte(priv+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
s, err := NewSignerFromFile(keyFile)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s == nil {
|
|
t.Fatal("signer should not be nil")
|
|
}
|
|
}
|
|
|
|
func TestSignerPublicKeyHex(t *testing.T) {
|
|
pub, priv := generateTestKeyPair(t)
|
|
|
|
signer, err := NewSigner(priv)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := signer.PublicKeyHex()
|
|
if got != pub {
|
|
t.Errorf("public key mismatch: got %s, want %s", got, pub)
|
|
}
|
|
}
|
|
|
|
func TestDecodeSignature(t *testing.T) {
|
|
// Create a valid signature
|
|
_, priv, _ := ed25519.GenerateKey(nil)
|
|
message := []byte("test")
|
|
rawSig := ed25519.Sign(priv, message)
|
|
hexSig := hex.EncodeToString(rawSig)
|
|
|
|
// Raw signature (64 bytes)
|
|
decoded, err := decodeSignature(rawSig)
|
|
if err != nil {
|
|
t.Fatalf("raw sig decode failed: %v", err)
|
|
}
|
|
if len(decoded) != ed25519.SignatureSize {
|
|
t.Errorf("expected %d bytes, got %d", ed25519.SignatureSize, len(decoded))
|
|
}
|
|
|
|
// Hex-encoded signature
|
|
decoded, err = decodeSignature([]byte(hexSig))
|
|
if err != nil {
|
|
t.Fatalf("hex sig decode failed: %v", err)
|
|
}
|
|
if len(decoded) != ed25519.SignatureSize {
|
|
t.Errorf("expected %d bytes, got %d", ed25519.SignatureSize, len(decoded))
|
|
}
|
|
|
|
// Hex with trailing newline
|
|
decoded, err = decodeSignature([]byte(hexSig + "\n"))
|
|
if err != nil {
|
|
t.Fatalf("hex sig with newline decode failed: %v", err)
|
|
}
|
|
if len(decoded) != ed25519.SignatureSize {
|
|
t.Errorf("expected %d bytes, got %d", ed25519.SignatureSize, len(decoded))
|
|
}
|
|
|
|
// Invalid data
|
|
_, err = decodeSignature([]byte("not valid"))
|
|
if err == nil {
|
|
t.Error("expected error for invalid signature data")
|
|
}
|
|
}
|
|
|
|
func TestTrimWhitespace(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expect string
|
|
}{
|
|
{"hello", "hello"},
|
|
{" hello ", "hello"},
|
|
{"hello\n", "hello"},
|
|
{"\thello\r\n", "hello"},
|
|
{" he llo ", "hello"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
got := trimWhitespace(tt.input)
|
|
if got != tt.expect {
|
|
t.Errorf("trimWhitespace(%q) = %q, want %q", tt.input, got, tt.expect)
|
|
}
|
|
}
|
|
}
|