Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 76 additions & 1 deletion pkg/manifest/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:<base64>\"")
}
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
}
74 changes: 74 additions & 0 deletions pkg/manifest/manifest_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
package manifest

import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"testing"
)
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions plugin/appstore/supervisor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 41 additions & 8 deletions plugin/appstore/testhelpers_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package appstore

import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"os"
Expand Down Expand Up @@ -34,17 +40,26 @@ func parseDummyManifest(t *testing.T, id string) *manifest.Manifest {
}

// writeValidAppDir creates <root>/<id>/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",
Expand All @@ -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
Expand Down
Loading