From 72948e8e2ec800e4ada4d6175e0b35db69d6ef4e Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:06:14 -0700 Subject: [PATCH 01/21] feat(audit): add signed Go audit ledger --- backend/backend/auditledger/ledger.go | 298 ++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 backend/backend/auditledger/ledger.go diff --git a/backend/backend/auditledger/ledger.go b/backend/backend/auditledger/ledger.go new file mode 100644 index 0000000..0f9ef99 --- /dev/null +++ b/backend/backend/auditledger/ledger.go @@ -0,0 +1,298 @@ +// 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") + } + 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) + } + if len(raw) > 0 && raw[len(raw)-1] != '\n' { + cut := bytes.LastIndexByte(raw, '\n') + 1 + discarded := 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] + _ = discarded // recorded below after the chain is verified + } + 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 at sequence %d: %s", check.HeadSequence, check.Reason) } + if len(events) > 0 { + l.seq = events[len(events)-1]["seq"].(json.Number).Int64() + l.headHash = events[len(events)-1]["hash"].(string) + } + 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_WRONLY, 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 len(raw) > 0 && len(raw) > 0 && raw[len(raw)-1] == '\n' { + // No recovery event is necessary when no torn tail was found. + } + if err := l.writeAnchor(); err != nil { _ = f.Close(); return nil, fmt.Errorf("write initial signed audit head: %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 +} From 2b3b62c9b31d861e1994a0011b3d0729d0f8e3af Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:06:24 -0700 Subject: [PATCH 02/21] test(audit): cover Go ledger integrity and concurrency --- backend/backend/auditledger/ledger_test.go | 73 ++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 backend/backend/auditledger/ledger_test.go diff --git a/backend/backend/auditledger/ledger_test.go b/backend/backend/auditledger/ledger_test.go new file mode 100644 index 0000000..8ff2c86 --- /dev/null +++ b/backend/backend/auditledger/ledger_test.go @@ -0,0 +1,73 @@ +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 Date: Fri, 16 Oct 2026 02:06:40 -0700 Subject: [PATCH 03/21] feat(gateway): require and initialize audit ledger in production --- backend/backend/apps/api-gateway/main.go | 26 +++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/backend/backend/apps/api-gateway/main.go b/backend/backend/apps/api-gateway/main.go index 9344802..3d46631 100644 --- a/backend/backend/apps/api-gateway/main.go +++ b/backend/backend/apps/api-gateway/main.go @@ -149,6 +149,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 +227,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 +281,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") From b0b2e008872c32fad2bfdb63af7b6919f9097fa0 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:06:48 -0700 Subject: [PATCH 04/21] feat(gateway): audit authenticated API requests --- .../apps/api-gateway/audit_middleware.go | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 backend/backend/apps/api-gateway/audit_middleware.go 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) + } + } +} From 328296321ae999fba30556f9e1a9dd8318a309f9 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:07:00 -0700 Subject: [PATCH 05/21] fix(gateway): import audit ledger package --- backend/backend/apps/api-gateway/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/backend/apps/api-gateway/main.go b/backend/backend/apps/api-gateway/main.go index 3d46631..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" ) From 2195242c11c4c9543d39485285e7d6161378d3ed Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:07:02 -0700 Subject: [PATCH 06/21] fix(audit): match cross-language Unicode canonicalization From ddc395d785f40eebb65d986537bb7b4f70044602 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:07:10 -0700 Subject: [PATCH 07/21] fix(audit): normalize Unicode separators for canonical JSON --- backend/backend/auditledger/ledger.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/backend/auditledger/ledger.go b/backend/backend/auditledger/ledger.go index 0f9ef99..c3ba079 100644 --- a/backend/backend/auditledger/ledger.go +++ b/backend/backend/auditledger/ledger.go @@ -206,8 +206,8 @@ func canonicalString(s string) ([]byte,error) { 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")) + raw=bytes.ReplaceAll(raw,[]byte(`\u2028`),[]byte("\u2028")) + raw=bytes.ReplaceAll(raw,[]byte(`\u2029`),[]byte("\u2029")) return append([]byte(nil),raw...),nil } From 056d3e7e534daf972b7aac56350571cc66b0fc3d Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:07:20 -0700 Subject: [PATCH 08/21] test(gateway): verify audit middleware avoids sensitive request data --- .../apps/api-gateway/audit_middleware_test.go | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 backend/backend/apps/api-gateway/audit_middleware_test.go 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") } +} From d2dc43190dd33b6ba4d396e4edb030ed22d43620 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:07:39 -0700 Subject: [PATCH 09/21] docs(audit): add gateway ledger operations runbook --- docs/GATEWAY_AUDIT_LEDGER.md | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/GATEWAY_AUDIT_LEDGER.md diff --git a/docs/GATEWAY_AUDIT_LEDGER.md b/docs/GATEWAY_AUDIT_LEDGER.md new file mode 100644 index 0000000..46a91a9 --- /dev/null +++ b/docs/GATEWAY_AUDIT_LEDGER.md @@ -0,0 +1,47 @@ +# Go gateway audit ledger + +The API gateway can write authenticated-route audit events using the v2 event format and Ed25519-signed head described in LEDGER_SPEC.md. The ledger is append-only at the application layer; each event is canonicalized, SHA-256 hash-chained, flushed, and fsync'd before the handler is invoked. The signed head is atomically replaced and its parent directory synced after each append. + +## Required production configuration + +Set both environment variables: + +- AUDIT_LEDGER_DIR: persistent, private directory for ledger.ndjson and anchors/head.json. +- AUDIT_LEDGER_PRIVATE_KEY: filesystem path to an Ed25519 PKCS#8 PEM private key. Keep this key outside the repository and outside the ledger directory. + +Generate a key in a controlled environment: + +~~~sh +openssl genpkey -algorithm ED25519 -out prexus-audit-ed25519.pem +chmod 600 prexus-audit-ed25519.pem +~~~ + +Provision the private key through the deployment's secret-file mechanism and mount the ledger directory on durable storage. Do not commit the key, put it in ordinary environment-variable values, or copy it into an exported audit bundle. The gateway refuses to start in ENV=production if either setting is missing or the key cannot be parsed. + +## Event behavior + +- Middleware is applied after authentication on the protected API route group. +- It writes http.request.started before the route handler runs; if that append fails, the request is rejected with HTTP 503 before the handler executes. +- After the handler, it writes http.request.completed with status and duration. It does not record request bodies, query strings, bearer tokens, or provider payloads. +- If completion recording fails after the response has already been produced, the start event remains durable and the error is logged. The ledger then refuses subsequent appends, causing later protected requests to fail closed. +- Health, registration, and login routes are not covered by this middleware in the current iteration. + +## Operational constraints and limits + +- This implementation uses a local file and an in-process mutex. Run **one gateway writer per ledger directory**. Do not share the same ledger directory between independent replicas or network filesystems without a distributed locking and append protocol. +- Use durable mounted storage; an ephemeral container filesystem is not a durable audit archive. +- Back up/export anchors/head.json and the public key to independently controlled media on a regular schedule. Verify an exported copy offline using the verifier described in LEDGER_SPEC.md. +- A signing key stored on the same host can be compromised with that host. This is tamper evidence, not hardware-backed key custody, remote attestation, or a compliance certification. +- The gateway ledger is not yet transactionally coupled to PostgreSQL business mutations. A successful audit event records the request lifecycle, not proof that every downstream side effect committed atomically. + +## Local validation + +From the repository's Go module directory: + +~~~sh +cd backend/backend +go test ./auditledger ./apps/api-gateway +go test -race ./auditledger ./apps/api-gateway +~~~ + +The CI workflow additionally runs the repository's existing Go vet, race, PostgreSQL integration, and vulnerability checks. From 99b97471062aa0c6cf7d103dfb32cc3e2e1d6cfb Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:07:48 -0700 Subject: [PATCH 10/21] docs: update gateway audit ledger status --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 From 33e2c17e07f0021e81f3ccbf5de74351d2823c67 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:08:43 -0700 Subject: [PATCH 11/21] fix(audit): validate stored sequence before restoring head --- backend/backend/auditledger/ledger.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/backend/auditledger/ledger.go b/backend/backend/auditledger/ledger.go index c3ba079..4fea766 100644 --- a/backend/backend/auditledger/ledger.go +++ b/backend/backend/auditledger/ledger.go @@ -106,7 +106,11 @@ func Open(dir, privateKeyPath string) (*Ledger, error) { check := VerifyEvents(events) if !check.Valid { return nil, fmt.Errorf("audit ledger integrity failure at sequence %d: %s", check.HeadSequence, check.Reason) } if len(events) > 0 { - l.seq = events[len(events)-1]["seq"].(json.Number).Int64() + seq, ok := integer(events[len(events)-1]["seq"]) + if !ok { + return nil, errors.New("audit ledger has an invalid final sequence") + } + l.seq = seq l.headHash = events[len(events)-1]["hash"].(string) } if err := l.verifyAnchor(len(events), events); err != nil { return nil, err } From ebc5355e5e74a81e9f94aabbe2f672c84e58c0d1 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:09:09 -0700 Subject: [PATCH 12/21] fix(audit): fail closed on unsafe keys and record torn-tail recovery --- backend/backend/auditledger/ledger.go | 65 ++++++++++++++++++++------- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/backend/backend/auditledger/ledger.go b/backend/backend/auditledger/ledger.go index 4fea766..a8932a4 100644 --- a/backend/backend/auditledger/ledger.go +++ b/backend/backend/auditledger/ledger.go @@ -53,6 +53,13 @@ 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) @@ -90,38 +97,66 @@ func Open(dir, privateKeyPath string) (*Ledger, error) { 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 - discarded := len(raw) - cut + 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) } + 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] - _ = discarded // recorded below after the chain is verified } events, err := decodeEvents(raw) - if err != nil { return nil, fmt.Errorf("decode audit ledger: %w", err) } + 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 at sequence %d: %s", check.HeadSequence, check.Reason) } + 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") } - l.seq = seq - l.headHash = events[len(events)-1]["hash"].(string) + 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 } - 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_WRONLY, 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) } + 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 len(raw) > 0 && len(raw) > 0 && raw[len(raw)-1] == '\n' { - // No recovery event is necessary when no torn tail was found. + 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) + } } - if err := l.writeAnchor(); err != nil { _ = f.Close(); return nil, fmt.Errorf("write initial signed audit head: %w", err) } return l, nil } From adfeb74c8aa83ce84b05615cadde7a93afde57cb Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:09:16 -0700 Subject: [PATCH 13/21] test(audit): cover torn-tail recovery and signing-key permissions --- backend/backend/auditledger/ledger_test.go | 28 ++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/backend/backend/auditledger/ledger_test.go b/backend/backend/auditledger/ledger_test.go index 8ff2c86..30acbf7 100644 --- a/backend/backend/auditledger/ledger_test.go +++ b/backend/backend/auditledger/ledger_test.go @@ -70,4 +70,32 @@ func TestTamperedEventFailsClosed(t *testing.T) { func TestMissingSigningKeyFails(t *testing.T) { if _,err:=Open(filepath.Join(t.TempDir(),"ledger"),filepath.Join(t.TempDir(),"missing.pem"));err==nil{t.Fatal("expected missing signing key to fail")} +func TestTornTailRecoveryIsRecorded(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", "test.write", "resource", map[string]any{"status": 200}); err != nil { t.Fatal(err) } + if err := l.Close(); err != nil { t.Fatal(err) } + f, err := os.OpenFile(filepath.Join(dir, "ledger.ndjson"), os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { t.Fatal(err) } + if _, err := f.Write([]byte("{\\"torn\\":")); err != nil { t.Fatal(err) } + if err := f.Close(); err != nil { t.Fatal(err) } + l, err = Open(dir, key) + if err != nil { t.Fatal(err) } + defer l.Close() + result := l.Verify() + if !result.Valid || result.HeadSequence != 2 { t.Fatalf("recovery not recorded: %+v", result) } + raw, err := os.ReadFile(filepath.Join(dir, "ledger.ndjson")) + if err != nil { t.Fatal(err) } + if !strings.Contains(string(raw), "ledger.recovered") { t.Fatal("expected ledger.recovered event") } +} + +func TestInsecureSigningKeyPermissionsFail(t *testing.T) { + key := writeTestKey(t) + if err := os.Chmod(key, 0644); err != nil { t.Fatal(err) } + if _, err := Open(filepath.Join(t.TempDir(), "ledger"), key); err == nil { + t.Fatal("expected broad signing-key permissions to fail") + } +} + } From 80eccb51be087781d32db877825324d3eea48d39 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:09:54 -0700 Subject: [PATCH 14/21] security(audit): enforce private signing-key permissions --- backend/backend/auditledger/ledger.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/backend/auditledger/ledger.go b/backend/backend/auditledger/ledger.go index a8932a4..5b68e8b 100644 --- a/backend/backend/auditledger/ledger.go +++ b/backend/backend/auditledger/ledger.go @@ -60,6 +60,13 @@ func Open(dir, privateKeyPath string) (*Ledger, error) { if keyInfo.Mode().Perm()&0077 != 0 { return nil, errors.New("audit signing key permissions are too broad; require owner-only access (0600)") } + 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) From e3b510b8f74fde2e0820972f860be228e146198a Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:10:38 -0700 Subject: [PATCH 15/21] deploy(audit): provision durable gateway ledger storage --- render.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/render.yaml b/render.yaml index 1a99f7c..296abdf 100644 --- a/render.yaml +++ b/render.yaml @@ -29,6 +29,10 @@ services: startCommand: ./app plan: starter # always-on (free instances sleep → first request times out) healthCheckPath: /health # liveness only; /ready also checks the database + disk: + name: gateway-audit-ledger + mountPath: /var/lib/prexus-audit + sizeGB: 1 envVars: - fromGroup: prexus-shared-secrets - key: ENV @@ -39,6 +43,10 @@ services: fromDatabase: { name: prexus-db, property: connectionString } - key: DATA_ENGINE_URL # Render private-network host:port (the gateway adds http://) fromService: { type: pserv, name: meteorium-engine, property: hostport } + - key: AUDIT_LEDGER_DIR + value: /var/lib/prexus-audit + - key: AUDIT_LEDGER_PRIVATE_KEY # Add this secret file in the Render dashboard before deploying + value: /etc/secrets/prexus-audit-ed25519.pem - key: ALLOWED_ORIGINS value: https://prexus.in,https://www.prexus.in - key: TRUSTED_PROXIES # all traffic arrives via Render's proxy; the container is not directly reachable From 461e8bf28c4d6a37bca39af91d161910f3d7d930 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:10:42 -0700 Subject: [PATCH 16/21] docs(audit): clarify Render signing-key provisioning --- docs/GATEWAY_AUDIT_LEDGER.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/GATEWAY_AUDIT_LEDGER.md b/docs/GATEWAY_AUDIT_LEDGER.md index 46a91a9..195fdf9 100644 --- a/docs/GATEWAY_AUDIT_LEDGER.md +++ b/docs/GATEWAY_AUDIT_LEDGER.md @@ -16,7 +16,7 @@ openssl genpkey -algorithm ED25519 -out prexus-audit-ed25519.pem chmod 600 prexus-audit-ed25519.pem ~~~ -Provision the private key through the deployment's secret-file mechanism and mount the ledger directory on durable storage. Do not commit the key, put it in ordinary environment-variable values, or copy it into an exported audit bundle. The gateway refuses to start in ENV=production if either setting is missing or the key cannot be parsed. +Provision the private key through the deployment's secret-file mechanism and mount the ledger directory on durable storage. For the Render blueprint, add a Secret File named `prexus-audit-ed25519.pem` at `/etc/secrets/prexus-audit-ed25519.pem` before deploying; `render.yaml` provisions the durable ledger disk and points the gateway at that path, but deliberately does not generate or commit the private key. Do not commit the key, put it in ordinary environment-variable values, or copy it into an exported audit bundle. The gateway refuses to start in ENV=production if either setting is missing or the key cannot be parsed. ## Event behavior From 196445304261aef19df55428512c748e84a66801 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:11:12 -0700 Subject: [PATCH 17/21] fix(test): close signing-key test before recovery cases --- backend/backend/auditledger/ledger_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/backend/auditledger/ledger_test.go b/backend/backend/auditledger/ledger_test.go index 30acbf7..1b1374a 100644 --- a/backend/backend/auditledger/ledger_test.go +++ b/backend/backend/auditledger/ledger_test.go @@ -70,6 +70,8 @@ func TestTamperedEventFailsClosed(t *testing.T) { func TestMissingSigningKeyFails(t *testing.T) { if _,err:=Open(filepath.Join(t.TempDir(),"ledger"),filepath.Join(t.TempDir(),"missing.pem"));err==nil{t.Fatal("expected missing signing key to fail")} +} + func TestTornTailRecoveryIsRecorded(t *testing.T) { dir, key := filepath.Join(t.TempDir(), "ledger"), writeTestKey(t) l, err := Open(dir, key) @@ -98,4 +100,3 @@ func TestInsecureSigningKeyPermissionsFail(t *testing.T) { } } -} From 8a4a7a9f7590ac45a67d4b8e76226002eed3108d Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:11:28 -0700 Subject: [PATCH 18/21] fix(test): correct torn-tail JSON fixture literal --- backend/backend/auditledger/ledger_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/backend/auditledger/ledger_test.go b/backend/backend/auditledger/ledger_test.go index 1b1374a..6344451 100644 --- a/backend/backend/auditledger/ledger_test.go +++ b/backend/backend/auditledger/ledger_test.go @@ -80,7 +80,7 @@ func TestTornTailRecoveryIsRecorded(t *testing.T) { if err := l.Close(); err != nil { t.Fatal(err) } f, err := os.OpenFile(filepath.Join(dir, "ledger.ndjson"), os.O_APPEND|os.O_WRONLY, 0600) if err != nil { t.Fatal(err) } - if _, err := f.Write([]byte("{\\"torn\\":")); err != nil { t.Fatal(err) } + if _, err := f.Write([]byte("{\"torn\":")); err != nil { t.Fatal(err) } if err := f.Close(); err != nil { t.Fatal(err) } l, err = Open(dir, key) if err != nil { t.Fatal(err) } From 88a6954714c2b40a650bea7c8a6b1dedd0463926 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:15:53 -0700 Subject: [PATCH 19/21] fix(auditledger): remove duplicate key permission check --- backend/backend/auditledger/ledger.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/backend/backend/auditledger/ledger.go b/backend/backend/auditledger/ledger.go index 5b68e8b..a8932a4 100644 --- a/backend/backend/auditledger/ledger.go +++ b/backend/backend/auditledger/ledger.go @@ -60,13 +60,6 @@ func Open(dir, privateKeyPath string) (*Ledger, error) { if keyInfo.Mode().Perm()&0077 != 0 { return nil, errors.New("audit signing key permissions are too broad; require owner-only access (0600)") } - 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) From 2a2dfaf8994916b9bcc6c1bf27e242eac205da06 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:20:05 -0700 Subject: [PATCH 20/21] fix(auditledger): open ledger read-write for verification --- backend/backend/auditledger/ledger.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/backend/auditledger/ledger.go b/backend/backend/auditledger/ledger.go index a8932a4..ca150f7 100644 --- a/backend/backend/auditledger/ledger.go +++ b/backend/backend/auditledger/ledger.go @@ -138,7 +138,7 @@ func Open(dir, privateKeyPath string) (*Ledger, error) { 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_WRONLY, 0600) + 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) } From 16ec5887841ce8a5e40e91fe287084f23ec7df06 Mon Sep 17 00:00:00 2001 From: Devmani Tripathi <265634822+devXyi@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:20:11 -0700 Subject: [PATCH 21/21] test(gateway): configure required audit ledger settings --- backend/backend/apps/api-gateway/main_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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) }