From a73b5e07c519c9a765aaf492b2a529aa57b8548c Mon Sep 17 00:00:00 2001 From: Corey Butler <770982+coreybutler@users.noreply.github.com> Date: Sun, 25 Oct 2026 13:07:26 -0500 Subject: [PATCH] feat(malwaredb): verify feed head and persist pair Unsigned head.cose was accepted. VerifySign1 against Author Software Inc. and keep head.cose beside malware.db so a later reader can check the live pair. Signed-off-by: Corey Butler <770982+coreybutler@users.noreply.github.com> --- malwaredb/feed_test.go | 29 +++++++++++++ malwaredb/go.mod | 2 +- malwaredb/trust.go | 85 ++++++++++++++++++++++++++++++++++++++ malwaredb/trust_test.go | 91 +++++++++++++++++++++++++++++++++++++++++ malwaredb/update.go | 31 ++++++++++++-- 5 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 malwaredb/trust.go create mode 100644 malwaredb/trust_test.go diff --git a/malwaredb/feed_test.go b/malwaredb/feed_test.go index a8adaa7..ab90baa 100644 --- a/malwaredb/feed_test.go +++ b/malwaredb/feed_test.go @@ -28,7 +28,15 @@ func coseWrap(payload []byte) []byte { return append(encodeUint(6, 18), arr...) } +func useUnsignedFeedHead(t *testing.T) { + t.Helper() + old := verifyFeedHead + verifyFeedHead = ExtractCOSEPayload + t.Cleanup(func() { verifyFeedHead = old }) +} + func TestUpdateFromSnapshot(t *testing.T) { + useUnsignedFeedHead(t) entries := []any{[]any{"left-pad", []any{0}}} snap, err := encodeCBOR(map[int]any{3: entries}) if err != nil { @@ -95,4 +103,25 @@ func TestUpdateFromSnapshot(t *testing.T) { if _, err := os.Stat(filepath.Join(root, "malware.db")); err != nil { t.Fatal(err) } + if _, err := os.Stat(filepath.Join(root, HeadFileName)); err != nil { + t.Fatal(err) + } +} + +func TestUpdateRejectsUnsignedHead(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/v1/head.cose", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(coseWrap([]byte{0xa1, 0x01, 0x01})) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + _, err := Update(UpdateOptions{ + Root: t.TempDir(), + BaseURL: srv.URL, + License: "tmp", + Now: time.Unix(100, 0), + }) + if err == nil { + t.Fatal("unsigned head must fail VerifySign1") + } } diff --git a/malwaredb/go.mod b/malwaredb/go.mod index e005c77..fc11285 100644 --- a/malwaredb/go.mod +++ b/malwaredb/go.mod @@ -21,6 +21,7 @@ replace common/token v1.0.0 => ../token replace common/urlguard v1.0.0 => ../urlguard require ( + common/cose v1.0.0 common/mirrorauth v1.0.0 common/settings v1.0.0 common/token v1.0.0 @@ -28,7 +29,6 @@ require ( ) require ( - common/cose v1.0.0 // indirect common/fs v1.0.0 // indirect common/modulefirewall v1.0.0 // indirect common/preferences v1.0.0 // indirect diff --git a/malwaredb/trust.go b/malwaredb/trust.go new file mode 100644 index 0000000..28c9c36 --- /dev/null +++ b/malwaredb/trust.go @@ -0,0 +1,85 @@ +package malwaredb + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// PairTrusted reports whether root holds a malware.db whose state_hash matches +// a COSE-verified head.cose beside it. +func PairTrusted(root string) error { + raw, err := os.ReadFile(filepath.Join(root, DBFileName)) + if err != nil { + return err + } + db, err := Parse(raw) + if err != nil { + return err + } + headRaw, err := os.ReadFile(filepath.Join(root, HeadFileName)) + if err != nil { + return err + } + payload, err := verifyFeedHead(headRaw) + if err != nil { + return err + } + head, err := ParseHead(payload) + if err != nil { + return err + } + if err := db.CheckState(head.StateHash); err != nil { + return err + } + return nil +} + +// CopySeed copies malware.db and head.cose from srcDir into destRoot when dest +// does not already hold a trusted pair. It refuses to overwrite a trusted live pair. +func CopySeed(srcDir, destRoot string) error { + if err := PairTrusted(destRoot); err == nil { + return nil + } + if err := os.MkdirAll(destRoot, 0o755); err != nil { + return err + } + for _, name := range []string{HeadFileName, DBFileName} { + if err := copyFile(filepath.Join(srcDir, name), filepath.Join(destRoot, name)); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + return PairTrusted(destRoot) +} + +func copyFile(src, dest string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + tmp := dest + ".tmp" + out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + _ = os.Remove(tmp) + return err + } + if err := out.Sync(); err != nil { + out.Close() + _ = os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + _ = os.Remove(tmp) + return err + } + return os.Rename(tmp, dest) +} diff --git a/malwaredb/trust_test.go b/malwaredb/trust_test.go new file mode 100644 index 0000000..7adc54a --- /dev/null +++ b/malwaredb/trust_test.go @@ -0,0 +1,91 @@ +package malwaredb + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTrustedPair(t *testing.T, dir string, state [32]byte) { + t.Helper() + headPayload, err := encodeCBOR(map[int]any{ + 2: 1, + 3: 100, + 4: 100 + 72*3600, + 5: state[:], + 6: []any{1, make([]byte, 32), 1}, + 7: []any{}, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, HeadFileName), coseWrap(headPayload), 0o644); err != nil { + t.Fatal(err) + } + encoded, err := Encode([]Entry{{Name: "left-pad", Kind: KindAll}}, 1, 100, state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, DBFileName), encoded, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestCopySeedSkipsTrustedDest(t *testing.T) { + useUnsignedFeedHead(t) + var state [32]byte + state[0] = 9 + src := t.TempDir() + dest := t.TempDir() + writeTrustedPair(t, src, state) + writeTrustedPair(t, dest, state) + if err := CopySeed(src, dest); err != nil { + t.Fatal(err) + } +} + +func TestCopySeedFillsMissingDest(t *testing.T) { + useUnsignedFeedHead(t) + var state [32]byte + state[0] = 9 + src := t.TempDir() + dest := t.TempDir() + writeTrustedPair(t, src, state) + if err := CopySeed(src, dest); err != nil { + t.Fatal(err) + } + if err := PairTrusted(dest); err != nil { + t.Fatal(err) + } +} + +func TestPairTrustedRejectsStateMismatch(t *testing.T) { + useUnsignedFeedHead(t) + root := t.TempDir() + var state [32]byte + state[0] = 9 + headPayload, err := encodeCBOR(map[int]any{ + 2: 1, + 3: 100, + 4: 100 + 72*3600, + 5: make([]byte, 32), + 6: []any{1, make([]byte, 32), 1}, + 7: []any{}, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, HeadFileName), coseWrap(headPayload), 0o644); err != nil { + t.Fatal(err) + } + encoded, err := Encode([]Entry{{Name: "left-pad", Kind: KindAll}}, 1, 100, state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, DBFileName), encoded, 0o644); err != nil { + t.Fatal(err) + } + if err := PairTrusted(root); err == nil { + t.Fatal("mismatched state_hash must fail") + } +} diff --git a/malwaredb/update.go b/malwaredb/update.go index 24c326d..c182f71 100644 --- a/malwaredb/update.go +++ b/malwaredb/update.go @@ -3,6 +3,7 @@ package malwaredb import ( "bytes" "compress/gzip" + commoncose "common/cose" "fmt" "io" "net/http" @@ -11,7 +12,24 @@ import ( "time" ) -const DefaultFeedBase = "https://cdn.author.io/metadata/nvm-windows/malicious_packages" +const ( + DefaultFeedBase = "https://cdn.author.io/metadata/nvm-windows/malicious_packages" + DBFileName = "malware.db" + HeadFileName = "head.cose" + FeedSignerOrg = "Author Software Inc." +) + +// verifyFeedHead verifies a COSE_Sign1 malware feed head and returns the payload. +// Tests replace this with ExtractCOSEPayload for unsigned fixtures. +var verifyFeedHead = verifyFeedHeadCOSE + +func verifyFeedHeadCOSE(raw []byte) ([]byte, error) { + payload, err := commoncose.VerifySign1(raw, []string{FeedSignerOrg}) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFeed, err) + } + return payload, nil +} type UpdateOptions struct { Root string @@ -40,7 +58,8 @@ func Update(opts UpdateOptions) (UpdateResult, error) { if opts.Now.IsZero() { opts.Now = time.Now() } - dbPath := filepath.Join(opts.Root, "malware.db") + dbPath := filepath.Join(opts.Root, DBFileName) + headPath := filepath.Join(opts.Root, HeadFileName) if opts.Background { if _, err := os.Stat(dbPath); err == nil { time.Sleep(time.Duration(time.Now().UnixNano() % int64(15*time.Minute))) @@ -64,7 +83,7 @@ func Update(opts UpdateOptions) (UpdateResult, error) { if err != nil { return UpdateResult{}, err } - payload, err := ExtractCOSEPayload(headBody) + payload, err := verifyFeedHead(headBody) if err != nil { return UpdateResult{}, err } @@ -74,6 +93,9 @@ func Update(opts UpdateOptions) (UpdateResult, error) { } result := UpdateResult{Seq: head.Seq, PreviousSeq: previousSeq} if previousSeq == head.Seq && previousHash == head.StateHash { + if _, err := os.Stat(headPath); err != nil { + _ = writeDB(headPath, headBody) + } return result, nil } if head.Expires > 0 && opts.Now.Unix() >= int64(head.Expires) && previousSeq > 0 { @@ -129,6 +151,9 @@ func Update(opts UpdateOptions) (UpdateResult, error) { if err != nil { return UpdateResult{}, err } + if err := writeDB(headPath, headBody); err != nil { + return UpdateResult{}, err + } if err := writeDB(dbPath, encoded); err != nil { return UpdateResult{}, err }