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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
59 changes: 56 additions & 3 deletions pkg/logic/common/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
73 changes: 73 additions & 0 deletions pkg/logic/common/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
39 changes: 39 additions & 0 deletions tests/cmd/dfirgen/main.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading