diff --git a/Makefile b/Makefile index b1b0371..4330f16 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ build-kd: go build -ldflags="$(LDFLAGS)" -o kd$(EXE) ./cmd/kd build-static-rust-bridge: - go build -buildmode=c-archive -o libkeibidrop.a ./rustbridge + go build -ldflags="$(LDFLAGS)" -buildmode=c-archive -o libkeibidrop.a ./rustbridge build-rust: protoc build-static-rust-bridge cd rust && cargo build --release @@ -44,7 +44,7 @@ CROSS_RUST_TARGET := $(CROSS_RUST_TARGET_$(CROSS_ARCH)) cross-macos: @echo "Cross-compiling for macOS $(CROSS_ARCH)..." # Go static lib for Rust FFI - CGO_ENABLED=1 GOARCH=$(CROSS_ARCH) go build -buildmode=c-archive -o libkeibidrop.a ./rustbridge + CGO_ENABLED=1 GOARCH=$(CROSS_ARCH) go build -ldflags="$(LDFLAGS)" -buildmode=c-archive -o libkeibidrop.a ./rustbridge # Rust UI rustup target add $(CROSS_RUST_TARGET) 2>/dev/null || true cd rust && cargo build --release --target $(CROSS_RUST_TARGET) diff --git a/pkg/logic/common/tokens.go b/pkg/logic/common/tokens.go index 9d4c1a8..5168fba 100644 --- a/pkg/logic/common/tokens.go +++ b/pkg/logic/common/tokens.go @@ -22,6 +22,7 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" + "errors" "fmt" "io" "log/slog" @@ -224,6 +225,19 @@ func (w *TokenWallet) markRevealed(c *walletChain, revealed int, dead bool) { _ = w.saveLocked() // best-effort; the ledger stays authoritative } +// remove drops a chain the ledger refused at paste time. +func (w *TokenWallet) remove(c *walletChain) { + w.mu.Lock() + defer w.mu.Unlock() + for i, have := range w.chains { + if have == c { + w.chains = append(w.chains[:i], w.chains[i+1:]...) + _ = w.saveLocked() + return + } + } +} + // TokenChainSummary is what CLIs and UIs display. type TokenChainSummary struct { Code string `json:"code"` @@ -294,14 +308,40 @@ func (kd *KeibiDrop) Wallet() *TokenWallet { return kd.wallet } -// TokensAdd pastes a code into the wallet and returns its size in GB. +// TokensAdd pastes a code into the wallet and returns its spendable size in +// GB. The ledger is asked once so a spent or never-minted code is refused at +// paste time instead of at first spend. No ledger answer keeps the add: the +// wallet is a cache and pasting offline must keep working. func (kd *KeibiDrop) TokensAdd(code string) (float64, error) { c, err := kd.Wallet().Add(code) if err != nil { return 0, err } + gb := float64(c.Units) * float64(TokenUnitBytes) / float64(1<<30) + var resp struct { + UnitsRemaining int `json:"units_remaining"` + State string `json:"state"` + } + lerr := kd.postRelayJSON("anchor/balance", map[string]string{ + "anchor": base64.RawURLEncoding.EncodeToString(c.anchor[:]), + }, &resp) + var he *relayHTTPError + switch { + case lerr == nil && resp.UnitsRemaining <= 0: + kd.Wallet().markRevealed(c, c.Units, resp.State == "spent") + return 0, fmt.Errorf("this code is already spent") + case lerr == nil: + kd.Wallet().markRevealed(c, c.Units-resp.UnitsRemaining, false) + gb = float64(resp.UnitsRemaining) * float64(TokenUnitBytes) / float64(1<<30) + case errors.As(lerr, &he) && he.status == http.StatusNotFound && strings.Contains(he.body, "unknown anchor"): + // The ledger answered: it has never seen this chain. A route miss + // on an old or foreign relay says "Not Found" instead and lands in + // the default case. + kd.Wallet().remove(c) + return 0, fmt.Errorf("this code is not on the relay ledger") + } kd.noteCreditLevel() - return float64(c.Units) * float64(TokenUnitBytes) / float64(1<<30), nil + return gb, nil } // exhaustEvent picks the honest message for a dry chain: with another funded @@ -714,6 +754,18 @@ func (p *payConn) Close() error { return p.Conn.Close() } +// relayHTTPError is a non-2xx relay answer. The body head rides along so a +// caller can tell an authoritative refusal from a plain route miss. +type relayHTTPError struct { + sub string + status int + body string +} + +func (e *relayHTTPError) Error() string { + return fmt.Sprintf("relay %s: status %d", e.sub, e.status) +} + // postRelayJSON posts a JSON body to a public relay endpoint and decodes the // answer. Non-2xx answers surface as errors carrying the status code. func (kd *KeibiDrop) postRelayJSON(sub string, payload any, out any) error { @@ -739,7 +791,8 @@ func (kd *KeibiDrop) postRelayJSON(sub string, payload any, out any) error { } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { - return fmt.Errorf("relay %s: status %d", sub, resp.StatusCode) + head, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) + return &relayHTTPError{sub: sub, status: resp.StatusCode, body: string(head)} } if out == nil { return nil diff --git a/pkg/logic/common/tokens_test.go b/pkg/logic/common/tokens_test.go index 46a4291..4e78e9a 100644 --- a/pkg/logic/common/tokens_test.go +++ b/pkg/logic/common/tokens_test.go @@ -428,3 +428,76 @@ func TestExhaustEventHonesty(t *testing.T) { t.Fatalf("funded wallet must promise the next pack: %s", e) } } + +func balanceServer(t *testing.T, status int, body map[string]any) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/anchor/balance" { + t.Errorf("unexpected relay call %s", r.URL.Path) + http.NotFound(w, r) + return + } + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestTokensAddRefusesSpentCode(t *testing.T) { + srv := balanceServer(t, http.StatusOK, map[string]any{"units_remaining": 0, "state": "spent"}) + kd := newTokenTestKD(t, srv.URL) + if _, err := kd.TokensAdd(encodeTokenCode(testSeed(t), 25600)); err == nil || !strings.Contains(err.Error(), "already spent") { + t.Fatalf("want already-spent refusal, got %v", err) + } + // The chain stays as a dead record, same as a balance refresh would + // leave it: the code was real once and the wallet history says so. + s := kd.TokensSummaries() + if len(s) != 1 || !s[0].Dead || s[0].UnitsLeft != 0 { + t.Fatalf("want one dead drained chain, got %+v", s) + } +} + +func TestTokensAddAdoptsLedgerRemaining(t *testing.T) { + srv := balanceServer(t, http.StatusOK, map[string]any{"units_remaining": 12800, "state": "active"}) + kd := newTokenTestKD(t, srv.URL) + gb, err := kd.TokensAdd(encodeTokenCode(testSeed(t), 25600)) + if err != nil { + t.Fatal(err) + } + if gb != 125 { + t.Fatalf("want the ledger's 125 GB, not the nominal size, got %v", gb) + } + s := kd.TokensSummaries() + if len(s) != 1 || s[0].UnitsLeft != 12800 || s[0].Dead { + t.Fatalf("want a live half-used chain, got %+v", s) + } +} + +func TestTokensAddRefusesUnknownAnchor(t *testing.T) { + srv := balanceServer(t, http.StatusNotFound, map[string]any{"message": "unknown anchor"}) + kd := newTokenTestKD(t, srv.URL) + if _, err := kd.TokensAdd(encodeTokenCode(testSeed(t), 25600)); err == nil || !strings.Contains(err.Error(), "not on the relay ledger") { + t.Fatalf("want unknown-anchor refusal, got %v", err) + } + if s := kd.TokensSummaries(); len(s) != 0 { + t.Fatalf("a never-minted chain must not stay in the wallet: %+v", s) + } +} + +func TestTokensAddOfflineStaysOptimistic(t *testing.T) { + // No relay configured at all. + kd := newTokenTestKD(t, "") + gb, err := kd.TokensAdd(encodeTokenCode(testSeed(t), 25600)) + if err != nil || gb != 250 { + t.Fatalf("offline add must keep working: gb=%v err=%v", gb, err) + } + // An old relay without the ledger route answers a plain 404. + srv := httptest.NewServer(http.NotFoundHandler()) + t.Cleanup(srv.Close) + kd2 := newTokenTestKD(t, srv.URL) + gb, err = kd2.TokensAdd(encodeTokenCode(testSeed(t), 25600)) + if err != nil || gb != 250 { + t.Fatalf("route-miss add must keep working: gb=%v err=%v", gb, err) + } +} diff --git a/tests/cmd/dfirgen/main.go b/tests/cmd/dfirgen/main.go new file mode 100644 index 0000000..d8c26e9 --- /dev/null +++ b/tests/cmd/dfirgen/main.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 KeibiSoft S.R.L. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// dfirgen writes a synthetic Windows triage tree for DFIR-shaped testing +// and demos: event logs, registry hives, prefetch, IIS logs, PowerShell +// history. Deterministic for a given -mb and -seed. +// +// go run ./tests/cmd/dfirgen -dir /path/to/share/host1 -mb 2048 +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/KeibiSoft/KeibiDrop/tests/dfirgen" +) + +func main() { + dir := flag.String("dir", "", "target directory (created if missing)") + mb := flag.Int("mb", 2048, "total size budget in MB") + seed := flag.Int64("seed", 1, "RNG seed; same seed and mb = same tree") + flag.Parse() + if *dir == "" { + fmt.Fprintln(os.Stderr, "need -dir") + os.Exit(2) + } + sum, err := dfirgen.Generate(*dir, *mb, *seed) + if err != nil { + fmt.Fprintln(os.Stderr, "generate:", err) + os.Exit(1) + } + fmt.Printf("wrote %d files, %d MB under %s\n", sum.Files, sum.Bytes>>20, *dir) + fmt.Printf("grep target: %q in %d text files\n", dfirgen.IOCMarker, sum.IOCFiles) + fmt.Printf("triage pull set: %d artifacts (winevt, hives, prefetch, PS history)\n", len(sum.Extract)) +} diff --git a/tests/dfirgen/dfirgen.go b/tests/dfirgen/dfirgen.go new file mode 100644 index 0000000..5b4e32d --- /dev/null +++ b/tests/dfirgen/dfirgen.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 KeibiSoft S.R.L. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Package dfirgen writes a deterministic synthetic Windows triage tree: +// event logs, registry hives, prefetch files, IIS logs and PowerShell +// history. Sizes scale with a total budget, content comes from a seeded +// RNG so runs are reproducible, and a known subset of text files carries +// the IOCMarker string so a grep pass has an exact expected hit count. +// Test data only; no file here is a real forensic artifact. +package dfirgen + +import ( + "fmt" + "math/rand" + "os" + "path/filepath" + "strings" +) + +// IOCMarker is the string a triage grep hunts for. Clearly synthetic on +// purpose: the tests need an exact count, not realism. +const IOCMarker = "KD-DFIR-IOC" + +// Summary reports what Generate wrote. +type Summary struct { + Files int + Bytes int64 + IOCFiles int + // Extract lists the KAPE-style pull set as root-relative paths: + // event logs, registry hives, prefetch, PowerShell history. + Extract []string +} + +type writer struct { + root string + rng *rand.Rand + sum *Summary +} + +func (w *writer) binary(rel string, magic string, size int) error { + if size < 64 { + size = 64 + } + path := filepath.Join(w.root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + f, err := os.Create(path) // #nosec G304 -- test fixture path + if err != nil { + return err + } + defer f.Close() + if _, err := f.WriteString(magic); err != nil { + return err + } + remaining := size - len(magic) + buf := make([]byte, 1<<20) + for remaining > 0 { + n := len(buf) + if remaining < n { + n = remaining + } + w.rng.Read(buf[:n]) + if _, err := f.Write(buf[:n]); err != nil { + return err + } + remaining -= n + } + w.sum.Files++ + w.sum.Bytes += int64(size) + return nil +} + +func (w *writer) text(rel string, size int, withIOC bool) error { + var b strings.Builder + b.WriteString("#Software: Microsoft Internet Information Services 10.0\n") + b.WriteString("#Fields: date time s-sitename cs-method cs-uri-stem sc-status\n") + line := 0 + for b.Len() < size { + line++ + fmt.Fprintf(&b, "2026-07-%02d %02d:%02d:%02d W3SVC1 GET /page%04d.aspx %d\n", + 1+w.rng.Intn(28), w.rng.Intn(24), w.rng.Intn(60), w.rng.Intn(60), + w.rng.Intn(10000), 200+w.rng.Intn(4)*100) + if withIOC && line == 40 { + fmt.Fprintf(&b, "2026-07-15 03:14:00 W3SVC1 GET /%s-%04d.aspx 200\n", + IOCMarker, w.rng.Intn(10000)) + } + } + path := filepath.Join(w.root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { + return err + } + w.sum.Files++ + w.sum.Bytes += int64(b.Len()) + if withIOC { + w.sum.IOCFiles++ + } + return nil +} + +func clamp(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// vary returns a size around avg (0.5x to 1.5x), never below 64. +func vary(rng *rand.Rand, avg int) int { + if avg < 128 { + avg = 128 + } + v := avg/2 + rng.Intn(avg) + if v < 64 { + v = 64 + } + return v +} + +// Generate writes the tree under root and returns what it wrote. The same +// (totalMB, seed) pair always produces the same tree. +func Generate(root string, totalMB int, seed int64) (*Summary, error) { + if totalMB < 8 { + return nil, fmt.Errorf("totalMB %d too small, need at least 8", totalMB) + } + total := int64(totalMB) << 20 + //nolint:gosec // G404: test data must be reproducible; crypto/rand takes no seed + w := &writer{root: root, rng: rand.New(rand.NewSource(seed)), sum: &Summary{}} + + // Windows event logs: 45% of the budget across a scaled file count. + evtxDir := "Windows/System32/winevt/Logs" + named := []string{"Security", "System", "Application", + "Microsoft-Windows-PowerShell%4Operational", + "Microsoft-Windows-Sysmon%4Operational"} + nEvtx := clamp(totalMB/3, len(named), 300) + evtxAvg := int(total * 45 / 100 / int64(nEvtx)) + for i := 0; i < nEvtx; i++ { + name := fmt.Sprintf("Channel%03d.evtx", i) + if i < len(named) { + name = named[i] + ".evtx" + } + rel := filepath.Join(evtxDir, name) + if err := w.binary(rel, "ElfFile\x00", vary(w.rng, evtxAvg)); err != nil { + return nil, err + } + w.sum.Extract = append(w.sum.Extract, rel) + } + + // Registry hives: 30%, split across the machine and user hives. + hiveBudget := total * 30 / 100 + hives := []struct { + rel string + share int64 // percent of hiveBudget + }{ + {"Windows/System32/config/SOFTWARE", 45}, + {"Windows/System32/config/SYSTEM", 38}, + {"Windows/System32/config/SAM", 2}, + {"Users/analyst/NTUSER.DAT", 10}, + {"Users/analyst/AppData/Local/Microsoft/Windows/UsrClass.dat", 5}, + } + for _, h := range hives { + if err := w.binary(h.rel, "regf", int(hiveBudget*h.share/100)); err != nil { + return nil, err + } + w.sum.Extract = append(w.sum.Extract, h.rel) + } + + // Prefetch: 5%, many small files, real .pf files stay under ~1 MB. + nPf := clamp(totalMB*6, 100, 2000) + pfAvg := int(total * 5 / 100 / int64(nPf)) + if pfAvg > 1<<20 { + pfAvg = 1 << 20 + } + for i := 0; i < nPf; i++ { + rel := filepath.Join("Windows/Prefetch", + fmt.Sprintf("APP%03d-%08X.pf", i, w.rng.Uint32())) + if err := w.binary(rel, "MAM\x04", vary(w.rng, pfAvg)); err != nil { + return nil, err + } + w.sum.Extract = append(w.sum.Extract, rel) + } + + // IIS logs: 15%, text, every 5th file carries the IOC marker. + nLog := clamp(totalMB, 10, 500) + logAvg := int(total * 15 / 100 / int64(nLog)) + for i := 0; i < nLog; i++ { + rel := filepath.Join("inetpub/logs/LogFiles/W3SVC1", + fmt.Sprintf("u_ex2607%02d_%03d.log", 1+i%28, i)) + if err := w.text(rel, vary(w.rng, logAvg), i%5 == 0); err != nil { + return nil, err + } + } + + // PowerShell history: small, text, always carries the marker. + histRel := "Users/analyst/AppData/Roaming/Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt" + if err := w.text(histRel, 4096, true); err != nil { + return nil, err + } + w.sum.Extract = append(w.sum.Extract, histRel) + + return w.sum, nil +} diff --git a/tests/integration_fuse_dfir_test.go b/tests/integration_fuse_dfir_test.go new file mode 100644 index 0000000..aed94c1 --- /dev/null +++ b/tests/integration_fuse_dfir_test.go @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 KeibiSoft S.R.L. + +//go:build !windows + +// DFIR remote-triage workload over the mount: an analyst machine (Alice, +// FUSE) triages a compromised-host tree that lives on the victim machine +// (Bob) without pulling the whole tree first. Walk, signature pass over +// the head of every file, IOC grep across text artifacts, then a +// selective KAPE-style extract with hash verification. This is the +// cold-on-demand sparse-read path under a many-files tree, the same shape +// the git cold-read work chases, with byte-exactness asserted at the end. +// Uses cp -R inside the share, so Unix-only; the Windows pass is the +// manual device run. + +package tests + +import ( + "bytes" + "crypto/sha256" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/KeibiSoft/KeibiDrop/tests/dfirgen" + "github.com/stretchr/testify/require" +) + +func TestFUSEDFIRTriage(t *testing.T) { + p := connectFUSEPeers(t, false) + req := require.New(t) + + // Deterministic synthetic triage tree, staged outside the share. The + // staging copy is the ground truth for every hash check below. + staging := filepath.Join(t.TempDir(), "host1") + sum, err := dfirgen.Generate(staging, 48, 1) + req.NoError(err) + t.Logf("generated %d files, %d MB, %d IOC files, %d extract artifacts", + sum.Files, sum.Bytes>>20, sum.IOCFiles, len(sum.Extract)) + + // Ship the tree into Bob's share the same way the git tests do: one + // exec inside the save dir, so the watcher sees it like real writes. + resp := p.bob.send(t, "exec . cp -R "+staging+" host1", 180*time.Second) + req.Truef(strings.HasPrefix(resp, "EXEC:0:"), "cp -R into share: %s", resp) + p.bob.send(t, "write_file host1/COMPLETE done", 10*time.Second) + + mountTree := filepath.Join(p.aliceMount, "host1") + req.Eventually(func() bool { + _, err := os.Stat(filepath.Join(mountTree, "COMPLETE")) + return err == nil + }, 180*time.Second, 500*time.Millisecond, "tree never finished syncing") + + // Phase 1: walk. The ReadDir and Lookup storm of a triage tool. + start := time.Now() + var files []string + req.NoError(filepath.WalkDir(mountTree, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && d.Name() != "COMPLETE" { + files = append(files, path) + } + return nil + })) + t.Logf("walk: %d files in %v", len(files), time.Since(start)) + req.Equal(sum.Files, len(files), "mount must show exactly the generated tree") + + // Phase 2: signature pass. First bytes of EVERY file, cold, on demand. + start = time.Now() + head := make([]byte, 8) + for _, f := range files { + fh, err := os.Open(f) // #nosec G304 -- walking our own mount + req.NoError(err) + _, err = io.ReadFull(fh, head) + req.NoError(fh.Close()) + req.NoErrorf(err, "head read %s", f) + } + t.Logf("signature pass: %d heads in %v", len(files), time.Since(start)) + + // Phase 3: IOC grep across the text artifacts only. + start = time.Now() + hits := 0 + for _, f := range files { + if !strings.HasSuffix(f, ".log") && !strings.HasSuffix(f, ".txt") { + continue + } + data, err := os.ReadFile(f) // #nosec G304 -- walking our own mount + req.NoError(err) + if bytes.Contains(data, []byte(dfirgen.IOCMarker)) { + hits++ + } + } + t.Logf("ioc grep: %d hits in %v", hits, time.Since(start)) + req.Equal(sum.IOCFiles, hits, "grep over the mount must find every planted marker") + + // Phase 4: selective extract of the KAPE-style set, hash-verified + // against the staging truth. Chain of custody is the product claim. + start = time.Now() + outDir := t.TempDir() + var extracted int64 + for _, rel := range sum.Extract { + data, err := os.ReadFile(filepath.Join(mountTree, rel)) // #nosec G304 + req.NoErrorf(err, "extract %s", rel) + orig, err := os.ReadFile(filepath.Join(staging, rel)) // #nosec G304 + req.NoError(err) + req.Equalf(sha256.Sum256(orig), sha256.Sum256(data), "hash mismatch on %s", rel) + dst := filepath.Join(outDir, rel) + req.NoError(os.MkdirAll(filepath.Dir(dst), 0755)) + req.NoError(os.WriteFile(dst, data, 0644)) + extracted += int64(len(data)) + } + t.Logf("extract+verify: %d artifacts, %d MB in %v", + len(sum.Extract), extracted>>20, time.Since(start)) +}