diff --git a/README.md b/README.md index 828747c..6638d64 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ Errors: `400` validation · `401` missing/invalid token · `403` permission · ` | Authorisation | RBAC (`admin`/`user`/`viewer` → permissions). **ABAC** (clearance, compartments, TLP, purpose) lives in `packages/prexus_core` and is enforced by Raksha | | Abuse control | per-IP rate limit (5 req/s, burst 10), strict `/apply` limiter, per-user daily AI quota, body-size caps | | Service-to-service | engine is a private service and requires `ENGINE_SECRET` on every route; it refuses to boot without it | -| Audit | tamper-evident ledger (SHA-256 chain + Ed25519 signed head) in the air-gap control plane and Raksha; the Go gateway still logs to stdout only | +| Audit | tamper-evident ledger (SHA-256 chain + Ed25519 signed head) in air-gap, Raksha, and the Go gateway (production requires durable ledger storage and a private signing key) | | Supply chain | CodeQL (4 languages), `govulncheck`, `pip-audit`, `cargo audit`, gitleaks, SLSA provenance for release artifacts | Not yet implemented: token revocation / refresh tokens, per-organisation tenancy in the gateway, HSM-backed key management, @@ -467,7 +467,7 @@ flowchart LR style F fill:#102A43,stroke:#55D6FF,color:#EAFBFF ``` -This is a **tested development implementation, not an air-gap certification or a claim that every deployment is physically disconnected**. The runbook still needs deployment-specific verification. SSO/PKI integration, high availability, hardware security module (HSM)-backed key management, and full gateway-to-ledger integration remain open; the Go gateway's audit output is still stdout-based. See [air-gap overview](./airgap/README.md), [runbook](./docs/AIRGAP_RUNBOOK.md), [ledger specification](./docs/LEDGER_SPEC.md), and [audit/fix status](./docs/AUDIT_FIXES.md). +This is a **tested development implementation, not an air-gap certification or a claim that every deployment is physically disconnected**. The runbook still needs deployment-specific verification. SSO/PKI integration, high availability, and hardware security module (HSM)-backed key management remain open. The Go gateway now has signed file-ledger middleware, but its ledger is not transactionally coupled to PostgreSQL mutations and requires deployment-specific durable storage and key provisioning. See [air-gap overview](./airgap/README.md), [runbook](./docs/AIRGAP_RUNBOOK.md), [gateway ledger runbook](./docs/GATEWAY_AUDIT_LEDGER.md), [ledger specification](./docs/LEDGER_SPEC.md), and [audit/fix status](./docs/AUDIT_FIXES.md). ## 🧪 Test & Verification Snapshot diff --git a/backend/backend/apps/api-gateway/audit_middleware.go b/backend/backend/apps/api-gateway/audit_middleware.go new file mode 100644 index 0000000..9098bcd --- /dev/null +++ b/backend/backend/apps/api-gateway/audit_middleware.go @@ -0,0 +1,61 @@ +package main + +import ( + "log" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/devXyi/prexus-intelligence/backend/auditledger" +) + +// gatewayAuditLedger is initialized once at startup and closed during graceful shutdown. +// The ledger serializes appends and fails closed after any persistence/integrity error. +var gatewayAuditLedger *auditledger.Ledger + +// AuditMiddleware records an intent before the protected handler runs and records +// the outcome afterward. It deliberately excludes bodies, query strings, credentials, +// and provider payloads from audit metadata. +func AuditMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + ledger := gatewayAuditLedger + if ledger == nil { + if os.Getenv("ENV") == "production" { + c.AbortWithStatusJSON(503, gin.H{"error": "audit ledger unavailable"}) + return + } + c.Next() + return + } + + actor := c.GetString("user_id") + if actor == "" { + actor = "unknown" + } + resource := c.Request.URL.Path + method := c.Request.Method + requestID := c.GetString("request_id") + started := time.Now() + _, err := ledger.Append(actor, "http.request.started", resource, map[string]any{ + "method": method, "request_id": requestID, + }) + if err != nil { + log.Printf("audit ledger refused request: action=start resource=%s error=%v", resource, err) + c.AbortWithStatusJSON(503, gin.H{"error": "audit ledger unavailable; request not executed"}) + return + } + + c.Next() + + _, err = ledger.Append(actor, "http.request.completed", resource, map[string]any{ + "method": method, "request_id": requestID, + "status": c.Writer.Status(), "duration_ms": time.Since(started).Milliseconds(), + }) + if err != nil { + // The start event remains durable. The ledger poisons itself after a failed + // append, so subsequent requests are blocked before their handlers execute. + log.Printf("audit ledger failed to persist request outcome: action=complete resource=%s status=%d error=%v", + resource, c.Writer.Status(), err) + } + } +} diff --git a/backend/backend/apps/api-gateway/audit_middleware_test.go b/backend/backend/apps/api-gateway/audit_middleware_test.go new file mode 100644 index 0000000..1bde922 --- /dev/null +++ b/backend/backend/apps/api-gateway/audit_middleware_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/devXyi/prexus-intelligence/backend/auditledger" +) + +func TestAuditMiddlewareRecordsRequestWithoutQuerySecrets(t *testing.T) { + oldLedger, oldEnv := gatewayAuditLedger, os.Getenv("ENV") + t.Cleanup(func() { + if gatewayAuditLedger != nil && gatewayAuditLedger != oldLedger { _ = gatewayAuditLedger.Close() } + gatewayAuditLedger = oldLedger + _ = os.Setenv("ENV", oldEnv) + }) + _ = os.Setenv("ENV", "test") + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { t.Fatal(err) } + der, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { t.Fatal(err) } + dir := t.TempDir() + keyPath := filepath.Join(dir, "ledger-key.pem") + if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type:"PRIVATE KEY",Bytes:der}), 0600); err != nil { t.Fatal(err) } + ledger, err := auditledger.Open(filepath.Join(dir,"ledger"), keyPath) + if err != nil { t.Fatal(err) } + gatewayAuditLedger = ledger + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(RequestID(), func(c *gin.Context) { c.Set("user_id","operator-test"); c.Next() }, AuditMiddleware()) + r.GET("/risk/asset", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/risk/asset?token=do-not-log", nil) + r.ServeHTTP(rec, req) + if rec.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent) } + + result := ledger.Verify() + if !result.Valid || result.HeadSequence != 2 { t.Fatalf("audit chain invalid: %+v", result) } + raw, err := os.ReadFile(filepath.Join(dir,"ledger","ledger.ndjson")) + if err != nil { t.Fatal(err) } + text := string(raw) + if strings.Contains(text,"do-not-log") || strings.Contains(text,"token") { t.Fatal("query secrets must not be written to audit metadata") } + if !strings.Contains(text,"http.request.started") || !strings.Contains(text,"http.request.completed") { t.Fatal("expected both start and completion events") } +} diff --git a/backend/backend/apps/api-gateway/main.go b/backend/backend/apps/api-gateway/main.go index 9344802..8506803 100644 --- a/backend/backend/apps/api-gateway/main.go +++ b/backend/backend/apps/api-gateway/main.go @@ -23,6 +23,7 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/joho/godotenv" + "github.com/devXyi/prexus-intelligence/backend/auditledger" "golang.org/x/time/rate" ) @@ -149,6 +150,11 @@ func validateConfig(env string) (fatal, warn []string) { fatal = append(fatal, k+" is still the placeholder from .env.example") } } + for _, k := range []string{"AUDIT_LEDGER_DIR", "AUDIT_LEDGER_PRIVATE_KEY"} { + if strings.TrimSpace(os.Getenv(k)) == "" { + fatal = append(fatal, k+" is required in production for tamper-evident gateway auditing") + } + } } if s := os.Getenv("JWT_SECRET"); s == "" { fatal = append(fatal, "JWT_SECRET is not set") @@ -222,7 +228,7 @@ func buildRouter() *gin.Engine { quota := AIQuotaMiddleware(newQuotaStore(aiDailyQuota())) - auth := r.Group("/", AuthMiddleware()) + auth := r.Group("/", AuthMiddleware(), AuditMiddleware()) { auth.GET("/assets", RequirePermission("assets:read"), handleGetAssets) auth.POST("/assets", RequirePermission("assets:create"), handleCreateAsset) @@ -276,6 +282,25 @@ func main() { log.Fatal("refusing to start: invalid configuration") } + auditDir, auditKey := strings.TrimSpace(os.Getenv("AUDIT_LEDGER_DIR")), strings.TrimSpace(os.Getenv("AUDIT_LEDGER_PRIVATE_KEY")) + if auditDir != "" || auditKey != "" { + ledger, err := auditledger.Open(auditDir, auditKey) + if err != nil { + log.Fatalf("audit ledger initialization failed: %v", err) + } + gatewayAuditLedger = ledger + defer func() { + if err := ledger.Close(); err != nil { + log.Printf("audit ledger close failed: %v", err) + } + }() + log.Printf("✓ Tamper-evident gateway audit ledger enabled") + } else if env == "production" { + log.Fatal("AUDIT_LEDGER_DIR and AUDIT_LEDGER_PRIVATE_KEY are required in production") + } else { + log.Printf("⚠ audit ledger disabled in non-production environment") + } + dataEngineURL := getDataEngineURL() if dataEngineURL == "" { log.Fatal("DATA_ENGINE_URL is not set — cannot start") diff --git a/backend/backend/apps/api-gateway/main_test.go b/backend/backend/apps/api-gateway/main_test.go index 4e7cdea..ac42a71 100644 --- a/backend/backend/apps/api-gateway/main_test.go +++ b/backend/backend/apps/api-gateway/main_test.go @@ -10,7 +10,7 @@ import ( ) func TestValidateConfig_ProductionFailsClosed(t *testing.T) { - for _, k := range []string{"JWT_SECRET", "DATABASE_URL", "DB_HOST", "ENGINE_SECRET", "DATA_ENGINE_URL", "ALLOWED_ORIGINS", "TRUSTED_PROXIES"} { + for _, k := range []string{"JWT_SECRET", "DATABASE_URL", "DB_HOST", "ENGINE_SECRET", "DATA_ENGINE_URL", "ALLOWED_ORIGINS", "TRUSTED_PROXIES", "AUDIT_LEDGER_DIR", "AUDIT_LEDGER_PRIVATE_KEY"} { t.Setenv(k, "") } fatal, _ := validateConfig("production") @@ -25,6 +25,8 @@ func TestValidateConfig_ProductionFailsClosed(t *testing.T) { t.Setenv("ENGINE_SECRET", "e") t.Setenv("DATA_ENGINE_URL", "http://engine.internal") t.Setenv("ALLOWED_ORIGINS", "https://prexus.in") + t.Setenv("AUDIT_LEDGER_DIR", filepath.Join(t.TempDir(), "audit-ledger")) + t.Setenv("AUDIT_LEDGER_PRIVATE_KEY", filepath.Join(t.TempDir(), "audit-key.pem")) if fatal, _ := validateConfig("production"); len(fatal) != 0 { t.Errorf("complete config rejected: %v", fatal) } diff --git a/backend/backend/auditledger/ledger.go b/backend/backend/auditledger/ledger.go new file mode 100644 index 0000000..ca150f7 --- /dev/null +++ b/backend/backend/auditledger/ledger.go @@ -0,0 +1,337 @@ +// Package auditledger implements the Prexus v2 append-only audit ledger. +// Its canonical event and signed-head formats follow docs/LEDGER_SPEC.md. +package auditledger + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const genesis = "GENESIS" +const maxSafeInteger int64 = 1<<53 - 1 + +type Ledger struct { + mu sync.Mutex + dir string + file *os.File + private ed25519.PrivateKey + public ed25519.PublicKey + publicPEM string + keyID string + seq int64 + headHash string + closed bool + broken error +} + +type VerifyResult struct { + Valid bool `json:"valid"` + CheckedEvents int `json:"checked_events"` + HeadHash string `json:"head_hash,omitempty"` + HeadSequence int64 `json:"head_sequence,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func Open(dir, privateKeyPath string) (*Ledger, error) { + if strings.TrimSpace(dir) == "" || strings.TrimSpace(privateKeyPath) == "" { + return nil, errors.New("audit ledger directory and private key path are required") + } + keyInfo, err := os.Stat(privateKeyPath) + if err != nil { + return nil, fmt.Errorf("stat audit signing key: %w", err) + } + if keyInfo.Mode().Perm()&0077 != 0 { + return nil, errors.New("audit signing key permissions are too broad; require owner-only access (0600)") + } + keyBytes, err := os.ReadFile(privateKeyPath) + if err != nil { + return nil, fmt.Errorf("read audit signing key: %w", err) + } + block, _ := pem.Decode(keyBytes) + if block == nil { + return nil, errors.New("audit signing key is not PEM encoded") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("parse audit signing key: %w", err) + } + privateKey, ok := parsed.(ed25519.PrivateKey) + if !ok { + return nil, errors.New("audit signing key must be Ed25519 PKCS#8") + } + publicKey := privateKey.Public().(ed25519.PublicKey) + pubDER, err := x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return nil, fmt.Errorf("encode audit public key: %w", err) + } + keyHash := sha256.Sum256(pubDER) + pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}) + + if err := os.MkdirAll(filepath.Join(dir, "anchors"), 0700); err != nil { + return nil, fmt.Errorf("create audit ledger directory: %w", err) + } + if err := os.Chmod(dir, 0700); err != nil { + return nil, fmt.Errorf("secure audit ledger directory: %w", err) + } + l := &Ledger{dir: dir, private: privateKey, public: publicKey, publicPEM: string(pubPEM), + keyID: hex.EncodeToString(keyHash[:])[:16], seq: 0, headHash: genesis} + path := filepath.Join(dir, "ledger.ndjson") + raw, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read audit ledger: %w", err) + } + discardedBytes := 0 + if len(raw) > 0 && raw[len(raw)-1] != '\n' { + cut := bytes.LastIndexByte(raw, '\n') + 1 + discardedBytes = len(raw) - cut + f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0600) + if openErr != nil { + return nil, fmt.Errorf("open ledger for torn-tail recovery: %w", openErr) + } + if truncErr := f.Truncate(int64(cut)); truncErr != nil { + _ = f.Close() + return nil, fmt.Errorf("truncate torn ledger tail: %w", truncErr) + } + if syncErr := f.Sync(); syncErr != nil { + _ = f.Close() + return nil, fmt.Errorf("sync recovered ledger: %w", syncErr) + } + _ = f.Close() + raw = raw[:cut] + } + events, err := decodeEvents(raw) + if err != nil { + return nil, fmt.Errorf("decode audit ledger: %w", err) + } + check := VerifyEvents(events) + if !check.Valid { + return nil, fmt.Errorf("audit ledger integrity failure: %s", check.Reason) + } + if len(events) > 0 { + seq, ok := integer(events[len(events)-1]["seq"]) + if !ok { + return nil, errors.New("audit ledger has an invalid final sequence") + } + hash, ok := events[len(events)-1]["hash"].(string) + if !ok { + return nil, errors.New("audit ledger has an invalid final hash") + } + l.seq, l.headHash = seq, hash + } + if err := l.verifyAnchor(len(events), events); err != nil { + return nil, err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_RDWR, 0600) + if err != nil { + return nil, fmt.Errorf("open audit ledger for append: %w", err) + } + if err := f.Chmod(0600); err != nil { + _ = f.Close() + return nil, fmt.Errorf("secure audit ledger file: %w", err) + } + l.file = f + if err := l.writeAnchor(); err != nil { + _ = f.Close() + return nil, fmt.Errorf("write initial signed audit head: %w", err) + } + if discardedBytes > 0 { + if _, err := l.Append("system", "ledger.recovered", "ledger", map[string]any{"discarded_bytes": discardedBytes}); err != nil { + _ = f.Close() + return nil, fmt.Errorf("record torn-tail recovery: %w", err) + } + } + return l, nil +} + +func decodeEvents(raw []byte) ([]map[string]any, error) { + if len(raw) == 0 { return nil, nil } + lines := bytes.Split(raw, []byte("\n")) + events := make([]map[string]any, 0, len(lines)) + for i, line := range lines { + if len(line) == 0 { continue } + dec := json.NewDecoder(bytes.NewReader(line)) + dec.UseNumber() + var ev map[string]any + if err := dec.Decode(&ev); err != nil { return nil, fmt.Errorf("line %d: %w", i+1, err) } + events = append(events, ev) + } + return events, nil +} + +func VerifyEvents(events []map[string]any) VerifyResult { + prev := genesis + for i, ev := range events { + seq, ok := integer(ev["seq"]) + if ev["v"] != json.Number("2") && ev["v"] != int64(2) && ev["v"] != int(2) { + return VerifyResult{Valid:false, CheckedEvents:len(events), Reason:"unsupported event version"} + } + if !ok || seq != int64(i+1) { + return VerifyResult{Valid:false, CheckedEvents:len(events), Reason:"sequence gap or reorder"} + } + if ev["previous_hash"] != prev { + return VerifyResult{Valid:false, CheckedEvents:len(events), Reason:"previous_hash mismatch (event removed or reordered)"} + } + got, ok := ev["hash"].(string) + if !ok { return VerifyResult{Valid:false, CheckedEvents:len(events), Reason:"event hash missing"} } + unsigned := make(map[string]any, len(ev)-1) + for k, v := range ev { if k != "hash" { unsigned[k] = v } } + canon, err := Canonical(unsigned) + if err != nil { return VerifyResult{Valid:false, CheckedEvents:len(events), Reason:"event cannot be canonicalised: "+err.Error()} } + sum := sha256.Sum256(canon) + want := hex.EncodeToString(sum[:]) + if got != want { return VerifyResult{Valid:false, CheckedEvents:len(events), Reason:"event content does not match its hash (modified)"} } + prev = got + } + return VerifyResult{Valid:true, CheckedEvents:len(events), HeadHash:prev, HeadSequence:int64(len(events))} +} + +func integer(v any) (int64, bool) { + switch n := v.(type) { + case int: return int64(n), true + case int64: return n, true + case json.Number: + i, err := strconv.ParseInt(string(n), 10, 64); return i, err == nil + default: return 0, false + } +} + +// Canonical emits the cross-language canonical JSON specified in docs/LEDGER_SPEC.md. +// Floating-point values and non-ASCII object keys are rejected by design. +func Canonical(v any) ([]byte, error) { + var out bytes.Buffer + var walk func(any) error + walk = func(value any) error { + switch x := value.(type) { + case nil: out.WriteString("null") + case bool: if x { out.WriteString("true") } else { out.WriteString("false") } + case string: + b, err := canonicalString(x); if err != nil { return err }; out.Write(b) + case int: if int64(x) > maxSafeInteger || int64(x) < -maxSafeInteger { return errors.New("integer outside the safe range") }; out.WriteString(strconv.Itoa(x)) + case int64: if x > maxSafeInteger || x < -maxSafeInteger { return errors.New("integer outside the safe range") }; out.WriteString(strconv.FormatInt(x,10)) + case json.Number: + i, err := strconv.ParseInt(string(x),10,64); if err != nil || i > maxSafeInteger || i < -maxSafeInteger { return errors.New("canonical JSON requires safe integers only") }; out.WriteString(strconv.FormatInt(i,10)) + case []any: + out.WriteByte('['); for i, item := range x { if i>0 { out.WriteByte(',') }; if err:=walk(item); err!=nil{return err} }; out.WriteByte(']') + case map[string]any: + keys:=make([]string,0,len(x)); for k:=range x { for _, r:=range k { if r<0x20 || r>0x7e { return errors.New("canonical JSON keys must be ASCII strings") } }; keys=append(keys,k) }; sort.Strings(keys) + out.WriteByte('{'); for i,k:=range keys { if i>0 {out.WriteByte(',')}; kb,err:=canonicalString(k); if err!=nil{return err}; out.Write(kb); out.WriteByte(':'); if err:=walk(x[k]);err!=nil{return err} }; out.WriteByte('}') + default: return fmt.Errorf("cannot canonicalise %T", value) + } + return nil + } + if err:=walk(v);err!=nil{return nil,err} + return out.Bytes(),nil +} + +func canonicalString(s string) ([]byte,error) { + var b bytes.Buffer + enc:=json.NewEncoder(&b); enc.SetEscapeHTML(false) + if err:=enc.Encode(s);err!=nil{return nil,err} + raw:=bytes.TrimSuffix(b.Bytes(),[]byte("\n")) + raw=bytes.ReplaceAll(raw,[]byte(`\u2028`),[]byte("\u2028")) + raw=bytes.ReplaceAll(raw,[]byte(`\u2029`),[]byte("\u2029")) + return append([]byte(nil),raw...),nil +} + +func (l *Ledger) verifyAnchor(eventCount int, events []map[string]any) error { + path:=filepath.Join(l.dir,"anchors","head.json") + raw,err:=os.ReadFile(path) + if errors.Is(err,os.ErrNotExist) { + if eventCount>0 { return errors.New("audit ledger has events but signed anchor is missing") } + return nil + } + if err!=nil{return fmt.Errorf("read signed audit anchor: %w",err)} + var h map[string]any + dec:=json.NewDecoder(bytes.NewReader(raw)); dec.UseNumber() + if err:=dec.Decode(&h);err!=nil{return fmt.Errorf("decode signed audit anchor: %w",err)} + seq,ok:=integer(h["sequence"]); if !ok || seq<0{return errors.New("signed audit anchor has invalid sequence")} + headHash,ok:=h["head_hash"].(string); if !ok{return errors.New("signed audit anchor has invalid head hash")} + signedAt,_:=h["signed_at"].(string); keyID,_:=h["key_id"].(string) + if h["alg"]!="Ed25519" || keyID!=l.keyID{return errors.New("signed audit anchor uses an untrusted algorithm or key")} + message,err:=Canonical(map[string]any{"sequence":seq,"head_hash":headHash,"signed_at":signedAt,"key_id":keyID}) + if err!=nil{return err} + sigText,_:=h["signature"].(string); sig,err:=base64.StdEncoding.DecodeString(sigText) + if err!=nil || !ed25519.Verify(l.public,message,sig){return errors.New("signed audit anchor signature is invalid")} + if seq>int64(eventCount){return fmt.Errorf("ledger has %d events but signed anchor attests %d (truncated or replaced)",eventCount,seq)} + if seq>0 && events[seq-1]["hash"]!=headHash{return errors.New("ledger diverges from signed anchor (history rewritten)")} + return nil +} + +func (l *Ledger) Append(actor, action, resource string, metadata map[string]any) (map[string]any,error) { + l.mu.Lock(); defer l.mu.Unlock() + if l.closed{return nil,errors.New("audit ledger is closed")} + if l.broken!=nil{return nil,fmt.Errorf("audit ledger unavailable after write failure: %w",l.broken)} + if actor=="" || action=="" || resource=="" {return nil,errors.New("audit actor, action, and resource are required")} + if metadata==nil{metadata=map[string]any{}} + now:=time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + id,err:=uuid4();if err!=nil{return nil,err} + ev:=map[string]any{"v":int(2),"seq":l.seq+1,"id":id,"at":now,"actor":actor,"action":action,"resource":resource,"metadata":metadata,"previous_hash":l.headHash} + canon,err:=Canonical(ev);if err!=nil{return nil,fmt.Errorf("canonicalise audit event: %w",err)} + sum:=sha256.Sum256(canon);ev["hash"]=hex.EncodeToString(sum[:]) + line,err:=Canonical(ev);if err!=nil{return nil,err};line=append(line,'\n') + if _,err=l.file.Write(line);err!=nil{l.broken=err;return nil,fmt.Errorf("append audit event: %w",err)} + if err=l.file.Sync();err!=nil{l.broken=err;return nil,fmt.Errorf("sync audit event: %w",err)} + l.seq++;l.headHash=ev["hash"].(string) + if err=l.writeAnchor();err!=nil{l.broken=err;return nil,fmt.Errorf("persist signed audit head: %w",err)} + return ev,nil +} + +func (l *Ledger) writeAnchor() error { + h:=map[string]any{"sequence":l.seq,"head_hash":l.headHash,"signed_at":time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),"key_id":l.keyID} + message,err:=Canonical(h);if err!=nil{return err} + h["alg"]="Ed25519";h["signature"]=base64.StdEncoding.EncodeToString(ed25519.Sign(l.private,message));h["public_key"]=l.publicPEM + raw,err:=json.MarshalIndent(h,""," ");if err!=nil{return err};raw=append(raw,'\n') + path:=filepath.Join(l.dir,"anchors","head.json") + tmp,err:=os.CreateTemp(filepath.Dir(path),".head-*.tmp");if err!=nil{return err} + tmpName:=tmp.Name();defer os.Remove(tmpName) + if err=tmp.Chmod(0600);err==nil{_,err=tmp.Write(raw)} + if err==nil{err=tmp.Sync()} + closeErr:=tmp.Close();if err==nil{err=closeErr};if err!=nil{return err} + if err=os.Rename(tmpName,path);err!=nil{return err} + d,err:=os.Open(filepath.Dir(path));if err!=nil{return err};defer d.Close() + return d.Sync() +} + +func (l *Ledger) Verify() VerifyResult { + l.mu.Lock();defer l.mu.Unlock() + if l.closed{return VerifyResult{Valid:false,Reason:"audit ledger is closed"}} + if l.broken!=nil{return VerifyResult{Valid:false,Reason:l.broken.Error()}} + if _,err:=l.file.Seek(0,io.SeekStart);err!=nil{return VerifyResult{Valid:false,Reason:err.Error()}} + raw,err:=io.ReadAll(l.file);if err!=nil{return VerifyResult{Valid:false,Reason:err.Error()}} + if _,err:=l.file.Seek(0,io.SeekEnd);err!=nil{return VerifyResult{Valid:false,Reason:err.Error()}} + events,err:=decodeEvents(raw);if err!=nil{return VerifyResult{Valid:false,Reason:err.Error()}} + result:=VerifyEvents(events) + if result.Valid && (result.HeadHash!=l.headHash || result.HeadSequence!=l.seq){return VerifyResult{Valid:false,CheckedEvents:len(events),Reason:"on-disk ledger diverges from running process"}} + return result +} + +func (l *Ledger) Close() error { + l.mu.Lock();defer l.mu.Unlock() + if l.closed{return nil};l.closed=true + if l.file!=nil{return l.file.Close()};return nil +} + +func uuid4() (string,error) { + var b [16]byte + if _,err:=rand.Read(b[:]);err!=nil{return "",err} + b[6]=(b[6]&0x0f)|0x40;b[8]=(b[8]&0x3f)|0x80 + s:=hex.EncodeToString(b[:]) + return s[:8]+"-"+s[8:12]+"-"+s[12:16]+"-"+s[16:20]+"-"+s[20:],nil +} diff --git a/backend/backend/auditledger/ledger_test.go b/backend/backend/auditledger/ledger_test.go new file mode 100644 index 0000000..6344451 --- /dev/null +++ b/backend/backend/auditledger/ledger_test.go @@ -0,0 +1,102 @@ +package auditledger + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func writeTestKey(t *testing.T) string { + t.Helper() + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { t.Fatal(err) } + der, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { t.Fatal(err) } + path := filepath.Join(t.TempDir(), "ledger-key.pem") + if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type:"PRIVATE KEY",Bytes:der}), 0600); err != nil { t.Fatal(err) } + return path +} + +func TestCanonicalRejectsFloatsAndNonASCIIKeys(t *testing.T) { + if _, err := Canonical(map[string]any{"risk": 0.25}); err == nil { t.Fatal("expected float to be rejected") } + if _, err := Canonical(map[string]any{"réponse": "x"}); err == nil { t.Fatal("expected non-ASCII key to be rejected") } + got, err := Canonical(map[string]any{"z": "é", "a": 2}) + if err != nil { t.Fatal(err) } + if string(got) != "{\"a\":2,\"z\":\"é\"}" { t.Fatalf("unexpected canonical JSON: %s", got) } +} + +func TestAppendReopenAndVerify(t *testing.T) { + dir, key := filepath.Join(t.TempDir(),"ledger"), writeTestKey(t) + l, err := Open(dir,key); if err != nil { t.Fatal(err) } + if _, err := l.Append("operator-1","risk.evaluate","asset:AST-1",map[string]any{"method":"POST","status":200}); err != nil { t.Fatal(err) } + if _, err := l.Append("operator-1","risk.complete","asset:AST-1",map[string]any{"duration_ms":12}); err != nil { t.Fatal(err) } + if result:=l.Verify(); !result.Valid || result.HeadSequence!=2 { t.Fatalf("unexpected verification: %+v",result) } + if err:=l.Close(); err!=nil { t.Fatal(err) } + l, err = Open(dir,key); if err != nil { t.Fatal(err) } + defer l.Close() + if result:=l.Verify(); !result.Valid || result.HeadSequence!=2 { t.Fatalf("verification after reopen failed: %+v",result) } +} + +func TestConcurrentAppendsAreSerialized(t *testing.T) { + dir,key:=filepath.Join(t.TempDir(),"ledger"),writeTestKey(t) + l,err:=Open(dir,key);if err!=nil{t.Fatal(err)} + defer l.Close() + const n=40 + var wg sync.WaitGroup + errs:=make(chan error,n) + for i:=0;i