diff --git a/pkg/manifest/manifest.go b/pkg/manifest/manifest.go index c9f654c..c952afd 100644 --- a/pkg/manifest/manifest.go +++ b/pkg/manifest/manifest.go @@ -8,7 +8,14 @@ // description; this file is the Go embodiment of that node. package manifest -import "encoding/json" +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) // Manifest is the signed declaration of what an app is and what it's allowed // to do. @@ -161,3 +168,71 @@ func (m *Manifest) Marshal() ([]byte, error) { // standard library's behavior (it already sorts map[string]interface{}). return json.Marshal(m) } + +// canonicalJSON returns deterministic JSON bytes for v (sorted keys). +func canonicalJSON(v any) ([]byte, error) { + return json.Marshal(v) +} + +// signingPayload builds the canonical byte-string the Store.Signature +// must sign. The publisher key is included so that a signature cannot +// be reused with a different publisher identity — swapping the +// publisher key invalidates the signature. Once a trust-anchor check +// (hardcoded publisher pubkey match) is added, this guarantees the +// manifest was signed by the known publisher. +// +// Format: publisher || ":" || id || ":" || manifest_version || ":" || binary.sha256 || ":" || grants-sha256-hex +func (m *Manifest) signingPayload() ([]byte, error) { + grantsJSON, err := canonicalJSON(m.Grants) + if err != nil { + return nil, fmt.Errorf("grants marshal: %w", err) + } + grantsHash := sha256.Sum256(grantsJSON) + payload := fmt.Sprintf("%s:%s:%d:%s:%x", + m.Store.Publisher, m.ID, m.ManifestVersion, m.Binary.SHA256, grantsHash) + return []byte(payload), nil +} + +// VerifySignature checks that Store.Signature is a valid ed25519 +// signature over the signing payload, verified against the Store.Publisher +// key embedded in the manifest. This provides cryptographic integrity — +// tampering with any manifest field that feeds the signing payload +// (Publisher, ID, ManifestVersion, Binary.SHA256, Grants) will cause +// verification to fail. +// +// NOTE: This does NOT check that Store.Publisher is a trusted key; +// a trust-anchor check (verifying Store.Publisher against a +// daemon-embedded trusted-publisher pubkey) is the next hardening step. +func (m *Manifest) VerifySignature() error { + pubkeyRaw, ok := strings.CutPrefix(m.Store.Publisher, "ed25519:") + if !ok { + return fmt.Errorf("store.publisher must be \"ed25519:\"") + } + pubkey, err := base64.StdEncoding.DecodeString(pubkeyRaw) + if err != nil { + return fmt.Errorf("store.publisher: invalid base64: %w", err) + } + if len(pubkey) != ed25519.PublicKeySize { + return fmt.Errorf("store.publisher: wrong key length %d, want %d", len(pubkey), ed25519.PublicKeySize) + } + + sigRaw := m.Store.Signature + // Accept optional "ed25519:" prefix on the signature too, for symmetry. + sigRaw = strings.TrimPrefix(sigRaw, "ed25519:") + sig, err := base64.StdEncoding.DecodeString(sigRaw) + if err != nil { + return fmt.Errorf("store.signature: invalid base64: %w", err) + } + if len(sig) != ed25519.SignatureSize { + return fmt.Errorf("store.signature: wrong signature length %d, want %d", len(sig), ed25519.SignatureSize) + } + + payload, err := m.signingPayload() + if err != nil { + return err + } + if !ed25519.Verify(pubkey, payload, sig) { + return fmt.Errorf("store.signature: verification failed — manifest may have been tampered with") + } + return nil +} diff --git a/pkg/manifest/manifest_test.go b/pkg/manifest/manifest_test.go index d6a848f..bac5012 100644 --- a/pkg/manifest/manifest_test.go +++ b/pkg/manifest/manifest_test.go @@ -1,6 +1,11 @@ package manifest import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" "strings" "testing" ) @@ -316,6 +321,75 @@ func mustValid(t *testing.T) *Manifest { return m } +func base64Enc(b []byte) string { return base64.StdEncoding.EncodeToString(b) } + +func signTestManifest(m *Manifest, priv ed25519.PrivateKey) (string, error) { + pub := priv.Public().(ed25519.PublicKey) + // Signing payload: publisher || id || manifest_version || binary.sha256 || grants-hash + grantsJSON, err := canonicalJSON(m.Grants) + if err != nil { + return "", err + } + grantsHash := sha256.Sum256(grantsJSON) + payload := fmt.Sprintf("ed25519:%s:%s:%d:%s:%x", + base64Enc(pub), m.ID, m.ManifestVersion, m.Binary.SHA256, grantsHash) + sig := ed25519.Sign(priv, []byte(payload)) + return base64Enc(sig), nil +} + +func TestVerifySignatureRejectsModifiedManifest(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + m := mustValid(t) + m.Store.Publisher = "ed25519:" + base64Enc(pub) + sig, err := signTestManifest(m, priv) + if err != nil { + t.Fatal(err) + } + m.Store.Signature = sig + + if err := m.VerifySignature(); err != nil { + t.Errorf("valid signature rejected: %v", err) + } + + m.Grants[0].Cap = "fs.delete" + if err := m.VerifySignature(); err == nil { + t.Error("expected error after tampering grants, got nil") + } +} + +func TestVerifySignatureRejectsWrongKey(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + otherPub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + m := mustValid(t) + m.Store.Publisher = "ed25519:" + base64Enc(pub) + sig, err := signTestManifest(m, priv) + if err != nil { + t.Fatal(err) + } + m.Store.Publisher = "ed25519:" + base64Enc(otherPub) + m.Store.Signature = sig + if err := m.VerifySignature(); err == nil { + t.Error("expected error with mismatched publisher key, got nil") + } +} + +func TestVerifySignatureRejectsEmptySignature(t *testing.T) { + m := mustValid(t) + m.Store.Signature = "" + if err := m.VerifySignature(); err == nil { + t.Error("expected error with empty signature, got nil") + } +} + func hasErrorContaining(errs []error, substr string) bool { for _, e := range errs { if strings.Contains(e.Error(), substr) { diff --git a/plugin/appstore/supervisor.go b/plugin/appstore/supervisor.go index c50975a..b71d441 100644 --- a/plugin/appstore/supervisor.go +++ b/plugin/appstore/supervisor.go @@ -240,6 +240,12 @@ func (s *supervisor) scanInstalled() ([]*installedApp, error) { s.logger.Printf("skip %s: invalid manifest: %v", e.Name(), errs[0]) continue } + // Verify the store signature — rejects manifests whose + // Store.Signature doesn't verify against Store.Publisher. + if err := m.VerifySignature(); err != nil { + s.logger.Printf("skip %s: signature verification failed: %v", e.Name(), err) + continue + } // Reject path traversal in manifest.binary.path. Without this // a manifest containing binary.path="../../../bin/sh" (or any // "..") would resolve OUTSIDE the app's install dir, letting diff --git a/plugin/appstore/testhelpers_test.go b/plugin/appstore/testhelpers_test.go index a6066ba..8d01870 100644 --- a/plugin/appstore/testhelpers_test.go +++ b/plugin/appstore/testhelpers_test.go @@ -1,6 +1,12 @@ package appstore import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" "io" "log" "os" @@ -34,17 +40,26 @@ func parseDummyManifest(t *testing.T, id string) *manifest.Manifest { } // writeValidAppDir creates //manifest.json with a manifest -// that passes manifest.Parse + Validate (so scanInstalled accepts it). -// No binary is written — the supervisor will hit verify-fail when it -// tries to spawn, but for tests that only care about discovery / -// registration (rescan, Apps()) that's the desired behavior. +// that passes manifest.Parse + Validate + VerifySignature (so scanInstalled +// accepts it). A fresh ed25519 keypair is generated per call so every +// test app has a self-consistent signature. No binary is written — the +// supervisor will hit verify-fail when it tries to spawn, but for tests +// that only care about discovery / registration (rescan, Apps()) that's +// the desired behavior. func writeValidAppDir(t *testing.T, root, id string) string { t.Helper() dir := filepath.Join(root, id) if err := os.MkdirAll(dir, 0o700); err != nil { t.Fatalf("mkdir %s: %v", dir, err) } - raw := strings.NewReplacer("ID", id).Replace(`{ + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + pubB64 := base64.StdEncoding.EncodeToString(pub) + + template := strings.NewReplacer("ID", id, "PUBKEY", pubB64).Replace(`{ "id": "ID", "manifest_version": 1, "app_version": "0.0.0", @@ -55,11 +70,29 @@ func writeValidAppDir(t *testing.T, root, id string) string { {"cap": "fs.read", "target": "$APP/data.db"} ], "store": { - "publisher": "ed25519:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", - "signature": "sig:placeholder" + "publisher": "ed25519:PUBKEY", + "signature": "" } }`) - if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(raw), 0o644); err != nil { + + // Parse, sign, re-serialize. + m, err := manifest.Parse([]byte(template)) + if err != nil { + t.Fatalf("parse template: %v", err) + } + // Compute the signing payload the same way manifest.VerifySignature expects. + grantsJSON, _ := json.Marshal(m.Grants) + grantsHash := sha256.Sum256(grantsJSON) + payload := fmt.Sprintf("%s:%s:%d:%s:%x", + m.Store.Publisher, m.ID, m.ManifestVersion, m.Binary.SHA256, grantsHash) + sig := ed25519.Sign(priv, []byte(payload)) + m.Store.Signature = base64.StdEncoding.EncodeToString(sig) + + raw, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal signed manifest: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "manifest.json"), raw, 0o644); err != nil { t.Fatalf("write manifest: %v", err) } return dir