From ea63c3acae3437151db1bac54ff318a6fcb7761e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 20:44:37 +0000 Subject: [PATCH] feat(manifest): add ed25519 signing and verification Sign() computes signature over canonical YAML (excluding sign field), Verify() checks against public key. Tampered manifests are rejected. Co-Authored-By: Claude Opus 4.6 --- pkg/manifest/sign.go | 43 +++++++++++++++++++++++++++++++++ pkg/manifest/sign_test.go | 51 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 pkg/manifest/sign.go create mode 100644 pkg/manifest/sign_test.go diff --git a/pkg/manifest/sign.go b/pkg/manifest/sign.go new file mode 100644 index 0000000..c8699b9 --- /dev/null +++ b/pkg/manifest/sign.go @@ -0,0 +1,43 @@ +package manifest + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + + "gopkg.in/yaml.v3" +) + +// signable returns the canonical bytes to sign (manifest without sign field). +func signable(m *Manifest) ([]byte, error) { + tmp := *m + tmp.Sign = "" + return yaml.Marshal(&tmp) +} + +// Sign computes the ed25519 signature and stores it in m.Sign (base64). +func Sign(m *Manifest, priv ed25519.PrivateKey) error { + msg, err := signable(m) + if err != nil { + return fmt.Errorf("manifest.Sign: marshal: %w", err) + } + sig := ed25519.Sign(priv, msg) + m.Sign = base64.StdEncoding.EncodeToString(sig) + return nil +} + +// Verify checks the ed25519 signature in m.Sign against the public key. +func Verify(m *Manifest, pub ed25519.PublicKey) (bool, error) { + if m.Sign == "" { + return false, fmt.Errorf("manifest.Verify: no signature present") + } + sig, err := base64.StdEncoding.DecodeString(m.Sign) + if err != nil { + return false, fmt.Errorf("manifest.Verify: decode: %w", err) + } + msg, err := signable(m) + if err != nil { + return false, fmt.Errorf("manifest.Verify: marshal: %w", err) + } + return ed25519.Verify(pub, msg, sig), nil +} diff --git a/pkg/manifest/sign_test.go b/pkg/manifest/sign_test.go new file mode 100644 index 0000000..bee2503 --- /dev/null +++ b/pkg/manifest/sign_test.go @@ -0,0 +1,51 @@ +package manifest + +import ( + "crypto/ed25519" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSignAndVerify_Good(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + m := &Manifest{ + Code: "test-app", + Name: "Test App", + Version: "1.0.0", + Layout: "HLCRF", + Slots: map[string]string{"C": "main"}, + } + + err = Sign(m, priv) + require.NoError(t, err) + assert.NotEmpty(t, m.Sign) + + ok, err := Verify(m, pub) + require.NoError(t, err) + assert.True(t, ok) +} + +func TestVerify_Bad_Tampered(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + m := &Manifest{Code: "test-app", Version: "1.0.0"} + _ = Sign(m, priv) + + m.Code = "evil-app" // tamper + + ok, err := Verify(m, pub) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestVerify_Bad_Unsigned(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(nil) + m := &Manifest{Code: "test-app"} + + ok, err := Verify(m, pub) + assert.Error(t, err) + assert.False(t, ok) +}