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
30 changes: 27 additions & 3 deletions manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package skillinject

import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -124,6 +125,7 @@ type fetcher struct {
httpClient *http.Client
manifestURL string
repoBase string
publicKey ed25519.PublicKey // nil = skip verification (backward compat)
}

func newFetcher(cfg Config) *fetcher {
Expand All @@ -142,7 +144,7 @@ func newFetcher(cfg Config) *fetcher {
if !strings.HasSuffix(rb, "/") {
rb += "/"
}
return &fetcher{httpClient: c, manifestURL: mu, repoBase: rb}
return &fetcher{httpClient: c, manifestURL: mu, repoBase: rb, publicKey: cfg.ManifestPublicKey}
}

func (f *fetcher) get(ctx context.Context, url string) ([]byte, error) {
Expand All @@ -165,7 +167,7 @@ func (f *fetcher) get(ctx context.Context, url string) ([]byte, error) {

// fetchManifest grabs and parses the manifest from the configured URL.
func (f *fetcher) fetchManifest(ctx context.Context) (*Manifest, error) {
body, err := f.get(ctx, f.manifestURL)
body, err := f.getOrVerify(ctx, f.manifestURL)
if err != nil {
return nil, fmt.Errorf("fetch manifest: %w", err)
}
Expand All @@ -190,7 +192,29 @@ func (f *fetcher) fetchManifest(ctx context.Context) (*Manifest, error) {
func (f *fetcher) fetchRepoFile(ctx context.Context, relPath string) ([]byte, error) {
relPath = strings.TrimPrefix(relPath, "/")
url := f.repoBase + relPath
return f.get(ctx, url)
return f.getOrVerify(ctx, url)
}

// getOrVerify returns the body at url. When f.publicKey is set, it also
// fetches <url>.sig and verifies the detached Ed25519 signature before
// returning. Without a public key, behavior matches get() exactly
// (backward compatible).
func (f *fetcher) getOrVerify(ctx context.Context, url string) ([]byte, error) {
body, err := f.get(ctx, url)
if err != nil {
return nil, err
}
if f.publicKey == nil {
return body, nil
}
sig, err := f.get(ctx, url+".sig")
if err != nil {
return nil, fmt.Errorf("fetch signature %s.sig: %w", url, err)
}
if !ed25519.Verify(f.publicKey, body, sig) {
return nil, fmt.Errorf("ed25519 signature verification failed for %s", url)
}
return body, nil
}

// expandHome resolves "~/" in a manifest path against the user's home dir.
Expand Down
6 changes: 6 additions & 0 deletions skillinject.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ package skillinject

import (
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
Expand Down Expand Up @@ -51,6 +52,11 @@ type Config struct {
RepoBaseURL string
// HTTPClient overrides the HTTP client used for fetching.
HTTPClient *http.Client
// ManifestPublicKey, when set, enables Ed25519 detached-signature
// verification on manifest + all fetched repo files. The daemon
// fetches <url>.sig alongside each resource and verifies before
// accepting. Nil (default) preserves the pre-verification behavior.
ManifestPublicKey ed25519.PublicKey
}

// Run blocks running scan/reconcile ticks until ctx is cancelled. The
Expand Down
95 changes: 95 additions & 0 deletions zz_extra_branches_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ package skillinject

import (
"context"
"crypto/ed25519"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -330,6 +331,100 @@ func TestReconcilePluginAllowList_IdenticalNoop(t *testing.T) {
}
}

// getOrVerify: with a valid public key, fetches file + .sig and verifies.
func TestFetchManifest_WithValidSignature(t *testing.T) {
t.Parallel()
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("generate key: %v", err)
}
body := []byte(`{"version": 1, "entrypoint": "x", "tools": [{"name":"x"}]}`)
sig := ed25519.Sign(priv, body)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".sig") {
_, _ = w.Write(sig)
} else {
_, _ = w.Write(body)
}
}))
defer srv.Close()

f := newFetcher(Config{
ManifestURL: srv.URL + "/m.json",
ManifestPublicKey: pub,
})
m, err := f.fetchManifest(context.Background())
if err != nil {
t.Fatalf("expected success with valid signature; got %v", err)
}
if m.Version != 1 {
t.Errorf("version = %d; want 1", m.Version)
}
}

// getOrVerify: wrong public key → signature verification fails.
func TestFetchManifest_WrongPublicKeyFails(t *testing.T) {
t.Parallel()
_, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("generate key: %v", err)
}
wrongPub, _, _ := ed25519.GenerateKey(nil)

body := []byte(`{"version": 1, "entrypoint": "x", "tools": [{"name":"x"}]}`)
sig := ed25519.Sign(priv, body)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".sig") {
_, _ = w.Write(sig)
} else {
_, _ = w.Write(body)
}
}))
defer srv.Close()

f := newFetcher(Config{
ManifestURL: srv.URL + "/m.json",
ManifestPublicKey: wrongPub,
})
_, err = f.fetchManifest(context.Background())
if err == nil {
t.Fatal("expected signature verification failure")
}
if !strings.Contains(err.Error(), "signature verification failed") {
t.Errorf("unexpected error: %v", err)
}
}

// getOrVerify: missing .sig file returns a wrapped fetch error.
func TestFetchManifest_MissingSigFileErrors(t *testing.T) {
t.Parallel()
pub, _, _ := ed25519.GenerateKey(nil)
body := []byte(`{"version": 1, "entrypoint": "x", "tools": [{"name":"x"}]}`)

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, ".sig") {
http.NotFound(w, r)
return
}
_, _ = w.Write(body)
}))
defer srv.Close()

f := newFetcher(Config{
ManifestURL: srv.URL + "/m.json",
ManifestPublicKey: pub,
})
_, err := f.fetchManifest(context.Background())
if err == nil {
t.Fatal("expected error when .sig is missing")
}
if !strings.Contains(err.Error(), "fetch signature") {
t.Errorf("unexpected error: %v", err)
}
}

// reconcilePluginAllowList: merge failure surfaces as Outcome.Action=Error
// with Err populated. Trigger by writing malformed JSON into the config
// at a state Drift will mis-classify as needing rewrite (note:
Expand Down
Loading