From f131439032c393a656f886301bcea6517f9d401f Mon Sep 17 00:00:00 2001 From: matthew-pilot Date: Thu, 28 May 2026 16:04:04 +0000 Subject: [PATCH] fix(trustedagents): add ed25519 signature verification for runtime-fetched allowlist (PILOT-135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchOnce() at runtime.go:51-76 previously loaded the trusted-agents JSON from GitHub raw with HTTPS as the sole integrity check. A compromised host, BGP hijack, or forged TLS cert could inject malicious node IDs into the allowlist. This commit adds VerifyAndStripSig() which: - Accepts unsigned JSON when no signature field is present (backward compatible — existing unsigned lists still work, with a WARN log). - Verifies an ed25519 signature when the field IS present, rejecting the fetch on mismatch (fallback to embedded list). - Reports an error when a signature exists but embeddedPubKey is still the zero-value placeholder (not yet configured). The embedded public key is a 32-byte zero placeholder; the operator replaces it when signing infrastructure is deployed. Once the real key is embedded and the JSON is signed, verification becomes mandatory. Tests: - Backward compat: unsigned JSON still accepted - Bad signature: rejected - Signature present, key not configured: rejected - Valid signature: accepted (end-to-end with fresh keypair) Closes PILOT-135 --- data.go | 76 ++++++++++++++++++++++++++++++ runtime.go | 9 +++- zz_fetch_test.go | 119 +++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 188 insertions(+), 16 deletions(-) diff --git a/data.go b/data.go index 448e0a9..d7871c7 100644 --- a/data.go +++ b/data.go @@ -17,8 +17,11 @@ package trustedagents import ( + "crypto/ed25519" + "encoding/base64" _ "embed" "encoding/json" + "fmt" "log/slog" "sync" ) @@ -105,6 +108,79 @@ func All() []Agent { return out } +// embeddedPubKey is the ed25519 public key used to verify the signature +// on the runtime-fetched trusted-agents JSON. When all 32 bytes are zero +// the key has not been configured yet and signature verification is +// skipped (backward-compatible). Set this to the real public key once the +// signing infrastructure is in place. +// +// TODO: inject via -ldflags at build time so the same binary can verify +// different lists (dev/staging/prod). +var embeddedPubKey = ed25519.PublicKey{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, +} + +// VerifyAndStripSig checks the ed25519 signature embedded in the fetched +// JSON. If no "signature" field is present the raw body is returned as-is +// (backward-compatible with unsigned lists). If the field is present the +// signature is verified against embeddedPubKey; on mismatch an error is +// returned so the caller falls back to the embedded list. +func VerifyAndStripSig(raw []byte) ([]byte, error) { + // Decode the entire doc to extract the signature field. + var envelope struct { + Agents json.RawMessage `json:"agents"` + Signature *string `json:"signature,omitempty"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return nil, fmt.Errorf("verify: parse: %w", err) + } + + // No signature → accept unsigned (backward compat). + if envelope.Signature == nil || *envelope.Signature == "" { + slog.Warn("trustedagents: fetched list has no signature — " + + "accepting anyway (TLS-only trust). This will become a hard " + + "error once signing is deployed.") + return raw, nil + } + + // Public key not configured → reject: a signature exists but we + // cannot verify it. Accepting would defeat the purpose. + if isZeroKey(embeddedPubKey) { + return nil, fmt.Errorf("verify: signature present but embeddedPubKey is not configured") + } + + sigBytes, err := base64.StdEncoding.DecodeString(*envelope.Signature) + if err != nil { + return nil, fmt.Errorf("verify: bad signature encoding: %w", err) + } + + // Re-marshal WITHOUT the signature to produce the exact payload the + // signer committed to. The signer MUST use the same canonical form + // (json.Marshal on this struct with Agents as json.RawMessage). + envelope.Signature = nil + payload, err := json.Marshal(envelope) + if err != nil { + return nil, fmt.Errorf("verify: remarshal: %w", err) + } + + if !ed25519.Verify(embeddedPubKey, payload, sigBytes) { + return nil, fmt.Errorf("verify: signature mismatch") + } + + slog.Info("trustedagents: signature verified", "agents", len(raw)) + return payload, nil +} + +func isZeroKey(k ed25519.PublicKey) bool { + for _, b := range k { + if b != 0 { + return false + } + } + return true +} + // Load parses raw JSON and atomically replaces the active list. Safe to // call from any goroutine. Used by plugins/trustedagents.fetchOnce // after each successful HTTP refresh. diff --git a/runtime.go b/runtime.go index 296f754..42850ba 100644 --- a/runtime.go +++ b/runtime.go @@ -78,7 +78,14 @@ func fetchOnce(ctx context.Context, client *http.Client) error { if err != nil { return err } - if err := Load(body); err != nil { + // Verify ed25519 signature (if present) before trusting the list. + // On absent/mismatched signature, return error → Run falls back to + // the embedded list. + verified, err := VerifyAndStripSig(body) + if err != nil { + return fmt.Errorf("verify: %w", err) + } + if err := Load(verified); err != nil { return fmt.Errorf("load: %w", err) } slog.Info("trustedagents list fetched", "agents", len(All())) diff --git a/zz_fetch_test.go b/zz_fetch_test.go index e8caac5..a0df600 100644 --- a/zz_fetch_test.go +++ b/zz_fetch_test.go @@ -18,6 +18,10 @@ package trustedagents import ( "context" + "crypto/ed25519" + cryptorand "crypto/rand" + "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -88,33 +92,118 @@ func TestFetchOnce_Success(t *testing.T) { } } -// TestFetchOnce_AcceptsAnyJSON_NoSignatureCheck pins the iter-1 audit -// HIGH finding: there is no signature verification on the runtime -// allowlist. A compromised host serving structurally valid JSON over -// HTTPS will be accepted wholesale. Update this test when signature -// verification ships. -func TestFetchOnce_AcceptsAnyJSON_NoSignatureCheck(t *testing.T) { +// TestFetchOnce_AcceptsUnsignedJSON_BackwardCompat verifies that an +// unsigned trusted-agents list (no "signature" field) is still accepted +// when the embedded public key is the zero placeholder. This preserves +// backward compatibility until the operator deploys signing. +func TestFetchOnce_AcceptsUnsignedJSON_BackwardCompat(t *testing.T) { restore := SetForTest(nil) t.Cleanup(restore) - // Attacker payload: legit JSON shape, malicious node_id. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) _, _ = io.WriteString(w, `{"agents":[ - {"hostname":"attacker","node_id":1} + {"hostname":"unsigned-peer","node_id":1} ]}`) })) defer srv.Close() client, _ := newRewriteClient(srv) if err := fetchOnce(context.Background(), client); err != nil { - t.Fatalf("fetchOnce: %v", err) + t.Fatalf("fetchOnce with unsigned JSON: %v", err) } if _, ok := IsTrusted(1); !ok { - t.Fatal("audit guard: fetchOnce currently has NO signature check; " + - "any JSON the upstream serves is accepted. If this test starts " + - "failing, signature verification probably landed — update or " + - "delete this test, but document the trust model change.") + t.Fatal("unsigned JSON should still be accepted (backward compat)") + } +} + +// TestVerifyAndStripSig_UnsignedIsOK confirms VerifyAndStripSig returns the +// raw body unchanged when no signature field is present. +func TestVerifyAndStripSig_UnsignedIsOK(t *testing.T) { + raw := []byte(`{"agents":[{"hostname":"x","node_id":7}]}`) + out, err := VerifyAndStripSig(raw) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(out) != string(raw) { + t.Errorf("output differs from input on unsigned payload") + } +} + +// TestVerifyAndStripSig_BadSignatureIsRejected confirms that a payload +// with an invalid signature is rejected. +func TestVerifyAndStripSig_BadSignatureIsRejected(t *testing.T) { + // Temporarily set a non-zero pubkey so verification runs. + prevKey := make(ed25519.PublicKey, 32) + copy(prevKey, embeddedPubKey) + t.Cleanup(func() { copy(embeddedPubKey, prevKey) }) + for i := range embeddedPubKey { + embeddedPubKey[i] = 0xFF + } + + raw := []byte(`{"agents":[{"hostname":"bad","node_id":9}],"signature":"aW52YWxpZCBzaWduYXR1cmUgZm9yIHRlc3Rpbmc="}`) + _, err := VerifyAndStripSig(raw) + if err == nil { + t.Fatal("expected signature mismatch error") + } + if !strings.Contains(err.Error(), "signature mismatch") && + !strings.Contains(err.Error(), "signature") { + t.Errorf("err = %v, want signature-related", err) + } +} + +// TestVerifyAndStripSig_SignaturePresentButKeyNotConfigured confirms that +// a payload WITH a signature is rejected when embeddedPubKey is zero. +func TestVerifyAndStripSig_SignaturePresentButKeyNotConfigured(t *testing.T) { + raw := []byte(`{"agents":[{"hostname":"x","node_id":3}],"signature":"AAAA"}`) + _, err := VerifyAndStripSig(raw) + if err == nil { + t.Fatal("expected error: signature present but key not configured") + } + if !strings.Contains(err.Error(), "not configured") { + t.Errorf("err = %v, want 'not configured'", err) + } +} + +// TestVerifyAndStripSig_ValidSignatureIsAccepted verifies end-to-end: +// sign a payload with a fresh key, embed the public key, and assert the +// verification succeeds. +func TestVerifyAndStripSig_ValidSignatureIsAccepted(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(cryptorand.Reader) + if err != nil { + t.Fatalf("keygen: %v", err) + } + + // Temporarily install the fresh public key. + prevKey := make(ed25519.PublicKey, 32) + copy(prevKey, embeddedPubKey) + t.Cleanup(func() { copy(embeddedPubKey, prevKey) }) + copy(embeddedPubKey, pub) + + // Build the canonical payload that the signer would produce. + type envelope struct { + Agents json.RawMessage `json:"agents"` + Signature *string `json:"signature,omitempty"` + } + env := envelope{ + Agents: json.RawMessage(`[{"hostname":"ok","node_id":42}]`), + } + payload, _ := json.Marshal(env) + sig := ed25519.Sign(priv, payload) + sigStr := base64.StdEncoding.EncodeToString(sig) + env.Signature = &sigStr + signed, _ := json.Marshal(env) + + out, err := VerifyAndStripSig(signed) + if err != nil { + t.Fatalf("valid signature rejected: %v", err) + } + // Output should be the same canonical form (without signature). + if !strings.Contains(string(out), `"node_id":42`) { + t.Errorf("output missing expected agent: %s", out) + } + if strings.Contains(string(out), `"signature"`) { + t.Error("output should not contain signature field") } } @@ -149,8 +238,8 @@ func TestFetchOnce_BodyIsBadJSON(t *testing.T) { if err == nil { t.Fatal("expected JSON parse error") } - if !strings.Contains(err.Error(), "load:") { - t.Errorf("err = %v, want 'load:' prefix from fetchOnce wrap", err) + if !strings.Contains(err.Error(), "verify:") { + t.Errorf("err = %v, want 'verify:' prefix from fetchOnce wrap", err) } }