Harden audit signatures against boundary forgery

This commit is contained in:
Pulse Autonomous Maintainer 2026-08-12 05:05:11 +01:00
parent a1b5f085b3
commit 1dcb414167
5 changed files with 571 additions and 29 deletions

View file

@ -956,9 +956,19 @@ retain persistent-reader and signature-verification capabilities. Retention
`0` is an explicit keep-forever setting, persisted retention is restored at
startup, the configured cleanup cadence is preserved across the enterprise
configuration seam, and cleanup must tolerate concurrent writers without
exposing partial results. Existing core and Pro signing keys remain valid through upgrade, and
legacy Pro signature encodings remain verifiable while all new writes use the
canonical signed event representation.
exposing partial results. Existing core and Pro signing keys remain valid
through upgrade. Every new global and tenant SQLite row must carry a
self-identifying `v2:` HMAC-SHA256 signature over the domain-separated,
length-prefixed persisted tuple (ID, Unix-second timestamp, event type, user,
IP, path, success, and details). Arbitrary string bytes, including pipes,
empty values, Unicode, and newlines, must retain injective field boundaries.
Verification dispatches by the signature envelope: unknown or malformed
versions fail closed, and a v2 signature is never retried against a historical
representation. Unprefixed 64-hex signatures remain explicitly identifiable
as legacy and may verify against the three previously accepted encodings for
read/export compatibility, but that result proves only the historical MAC and
must not be represented as providing v2 boundary integrity. Startup and reads
must not rewrite or re-sign those historical rows.
That shared token-management boundary now also includes
`frontend-modern/src/utils/apiTokenPresentation.ts`, so API-token load,
generate, and revoke errors stay on one governed customer-facing wording path

View file

@ -6,12 +6,14 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/rs/zerolog/log"
@ -23,6 +25,22 @@ type Signer struct {
key []byte // 32-byte HMAC signing key
}
const (
signatureV2Prefix = "v2:"
canonicalV2Domain = "pulse.audit.event\x00v2\x00"
)
// SignatureVersion identifies the representation authenticated by a signature.
// Legacy signatures remain verifiable for historical compatibility, but their
// delimiter-separated representation does not protect field boundaries.
type SignatureVersion string
const (
SignatureVersionUnknown SignatureVersion = "unknown"
SignatureVersionLegacy SignatureVersion = "legacy"
SignatureVersionV2 SignatureVersion = "v2"
)
// CryptoEncryptor interface for encrypting/decrypting the signing key.
// This matches the methods from internal/crypto.CryptoManager.
type CryptoEncryptor interface {
@ -114,48 +132,131 @@ func loadAuditSigningKey(cryptoMgr CryptoEncryptor, data []byte) ([]byte, bool,
return nil, false, fmt.Errorf("failed to decrypt audit signing key: %w", err)
}
// Sign computes an HMAC-SHA256 signature over the event's canonical form.
// Returns hex-encoded signature, or empty string if signing is disabled.
// Sign computes an HMAC-SHA256 signature over the injective v2 representation.
// The version prefix is persisted with the hex-encoded MAC so verification can
// select exactly one representation without downgrade fallback.
func (s *Signer) Sign(event Event) string {
if s.key == nil {
return ""
}
canonical := s.canonicalForm(event)
mac := hmac.New(sha256.New, s.key)
mac.Write([]byte(canonical))
return hex.EncodeToString(mac.Sum(nil))
return signatureV2Prefix + hex.EncodeToString(s.mac(s.canonicalV2Form(event)))
}
// Verify checks if the event's signature matches its content.
// Returns true if the signature is valid, false if invalid or signing is disabled.
// Verify checks if the event's signature matches its content. A true result for
// SignatureVersionLegacy confirms only a historical delimiter-based MAC; call
// DetectSignatureVersion when the stronger v2 boundary guarantee matters.
// Returns false for invalid, unknown, malformed, or disabled signatures.
func (s *Signer) Verify(event Event) bool {
if s.key == nil || event.Signature == "" {
return false
}
for _, canonical := range []string{
s.canonicalForm(event),
s.legacyUnixCanonicalForm(event),
s.legacyTimeCanonicalForm(event),
} {
expected := s.signCanonical(canonical)
if hmac.Equal([]byte(expected), []byte(event.Signature)) {
return true
switch DetectSignatureVersion(event.Signature) {
case SignatureVersionV2:
provided, err := hex.DecodeString(strings.TrimPrefix(event.Signature, signatureV2Prefix))
if err != nil || len(provided) != sha256.Size {
return false
}
return hmac.Equal(s.mac(s.canonicalV2Form(event)), provided)
case SignatureVersionLegacy:
provided, err := hex.DecodeString(event.Signature)
if err != nil || len(provided) != sha256.Size {
return false
}
// These three unversioned encodings were emitted by historical Pulse
// releases. They are intentionally confined to the legacy dispatch arm:
// a v2-prefixed signature is never retried here.
for _, canonical := range []string{
s.legacyZeroOneCanonicalForm(event),
s.legacyUnixCanonicalForm(event),
s.legacyTimeCanonicalForm(event),
} {
if hmac.Equal(s.mac([]byte(canonical)), provided) {
return true
}
}
return false
default:
return false
}
return false
}
// DetectSignatureVersion classifies only well-formed signature envelopes.
// Unprefixed 64-digit hexadecimal MACs are historical. Any prefix other than
// v2, or any malformed/truncated MAC, is unknown and must fail closed.
func DetectSignatureVersion(signature string) SignatureVersion {
if strings.HasPrefix(signature, signatureV2Prefix) {
if isSHA256Hex(signature[len(signatureV2Prefix):]) {
return SignatureVersionV2
}
return SignatureVersionUnknown
}
if strings.Contains(signature, ":") {
return SignatureVersionUnknown
}
if isSHA256Hex(signature) {
return SignatureVersionLegacy
}
return SignatureVersionUnknown
}
func isSHA256Hex(value string) bool {
if len(value) != hex.EncodedLen(sha256.Size) {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && len(decoded) == sha256.Size
}
func (s *Signer) mac(message []byte) []byte {
mac := hmac.New(sha256.New, s.key)
_, _ = mac.Write(message)
return mac.Sum(nil)
}
// canonicalV2Form is an injective representation of the exact SQLite tuple:
// domain || len(ID) || ID || int64 Unix seconds || len(EventType) || EventType
// || len(User) || User || len(IP) || IP || len(Path) || Path || Success byte
// || len(Details) || Details. Integers and uint64 byte lengths are big-endian;
// strings are their unmodified UTF-8 bytes. SQLite persists timestamps as Unix
// seconds, so sub-second time data is deliberately outside the signed tuple.
func (s *Signer) canonicalV2Form(event Event) []byte {
var canonical bytes.Buffer
canonical.WriteString(canonicalV2Domain)
writeLengthPrefixedString(&canonical, event.ID)
var timestamp [8]byte
binary.BigEndian.PutUint64(timestamp[:], uint64(event.Timestamp.Unix()))
canonical.Write(timestamp[:])
writeLengthPrefixedString(&canonical, event.EventType)
writeLengthPrefixedString(&canonical, event.User)
writeLengthPrefixedString(&canonical, event.IP)
writeLengthPrefixedString(&canonical, event.Path)
if event.Success {
canonical.WriteByte(1)
} else {
canonical.WriteByte(0)
}
writeLengthPrefixedString(&canonical, event.Details)
return canonical.Bytes()
}
func writeLengthPrefixedString(dst *bytes.Buffer, value string) {
var length [8]byte
binary.BigEndian.PutUint64(length[:], uint64(len(value)))
dst.Write(length[:])
dst.WriteString(value)
}
func (s *Signer) signCanonical(canonical string) string {
mac := hmac.New(sha256.New, s.key)
mac.Write([]byte(canonical))
return hex.EncodeToString(mac.Sum(nil))
return hex.EncodeToString(s.mac([]byte(canonical)))
}
// canonicalForm creates a deterministic string representation of an event for signing.
// Format: ID|Timestamp(Unix)|EventType|User|IP|Path|Success(0/1)|Details
func (s *Signer) canonicalForm(event Event) string {
// legacyZeroOneCanonicalForm is the last unversioned representation emitted
// before v2. It is ambiguous when string values contain pipe characters.
func (s *Signer) legacyZeroOneCanonicalForm(event Event) string {
success := "0"
if event.Success {
success = "1"

View file

@ -0,0 +1,231 @@
package audit
import (
"strings"
"testing"
"time"
)
func securityTestSigner(t *testing.T) *Signer {
t.Helper()
signer, err := NewSignerWithKey([]byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatalf("NewSignerWithKey: %v", err)
}
return signer
}
func securityTestEvent() Event {
return Event{
ID: "event-123",
Timestamp: time.Unix(1_725_555_555, 0).UTC(),
EventType: "security",
User: "operator",
IP: "2001:db8::1",
Path: "/api/audit",
Success: true,
Details: "detected",
}
}
func TestSignerV2RejectsEquivalentLegacyBoundaryShifts(t *testing.T) {
signer := securityTestSigner(t)
tests := []struct {
name string
mutate func(original, forged *Event)
}{
{
name: "event type and user",
mutate: func(original, forged *Event) {
original.EventType, original.User = "security", "alert|system"
forged.EventType, forged.User = "security|alert", "system"
},
},
{
name: "user and IP",
mutate: func(original, forged *Event) {
original.User, original.IP = "alice", "admin|127.0.0.1"
forged.User, forged.IP = "alice|admin", "127.0.0.1"
},
},
{
name: "IP and path",
mutate: func(original, forged *Event) {
original.IP, original.Path = "10.0.0.1", "internal|/api/audit"
forged.IP, forged.Path = "10.0.0.1|internal", "/api/audit"
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
original := securityTestEvent()
forged := original
tt.mutate(&original, &forged)
if signer.legacyZeroOneCanonicalForm(original) != signer.legacyZeroOneCanonicalForm(forged) {
t.Fatal("test pair does not reproduce the historical boundary collision")
}
original.Signature = signer.Sign(original)
if original.Signature == signer.Sign(forged) {
t.Fatal("v2 signatures collided for distinct tuples")
}
forged.Signature = original.Signature
if signer.Verify(forged) {
t.Fatal("v2 signature verified after a boundary shift")
}
})
}
}
func TestSignerV2AuthenticatesEveryPersistedField(t *testing.T) {
signer := securityTestSigner(t)
original := securityTestEvent()
original.Signature = signer.Sign(original)
tests := []struct {
name string
mutate func(*Event)
}{
{name: "ID", mutate: func(e *Event) { e.ID += "-tampered" }},
{name: "timestamp", mutate: func(e *Event) { e.Timestamp = e.Timestamp.Add(time.Second) }},
{name: "event type", mutate: func(e *Event) { e.EventType += "-tampered" }},
{name: "user", mutate: func(e *Event) { e.User += "-tampered" }},
{name: "IP", mutate: func(e *Event) { e.IP += "-tampered" }},
{name: "path", mutate: func(e *Event) { e.Path += "-tampered" }},
{name: "success", mutate: func(e *Event) { e.Success = !e.Success }},
{name: "details", mutate: func(e *Event) { e.Details += "-tampered" }},
{name: "field order", mutate: func(e *Event) { e.EventType, e.User = e.User, e.EventType }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tampered := original
tt.mutate(&tampered)
if signer.Verify(tampered) {
t.Fatal("signature verified after tampering")
}
})
}
}
func TestSignerV2PreservesArbitraryStringContent(t *testing.T) {
signer := securityTestSigner(t)
tests := []struct {
name string
value string
}{
{name: "empty", value: ""},
{name: "pipes", value: "a||b|c"},
{name: "unicode", value: "監査ログ 🔐 café"},
{name: "newlines and null", value: "first\nsecond\r\n\x00last"},
{name: "large", value: strings.Repeat("界|\n", 350_000)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
event := securityTestEvent()
event.User = tt.value
event.Details = tt.value
event.Signature = signer.Sign(event)
if DetectSignatureVersion(event.Signature) != SignatureVersionV2 {
t.Fatalf("signature version = %q, want v2", DetectSignatureVersion(event.Signature))
}
if !signer.Verify(event) {
t.Fatal("valid v2 signature did not verify")
}
tampered := event
tampered.Details += "x"
if signer.Verify(tampered) {
t.Fatal("tampered arbitrary content verified")
}
})
}
}
func TestSignerV2CanonicalRepresentationFixture(t *testing.T) {
signer := securityTestSigner(t)
event := Event{
ID: "id|1",
Timestamp: time.Unix(-1, 0).UTC(),
EventType: "監査",
User: "",
IP: "\x00",
Path: "\n",
Success: true,
Details: "done|ok",
}
want := "pulse.audit.event\x00v2\x00" +
"\x00\x00\x00\x00\x00\x00\x00\x04id|1" +
"\xff\xff\xff\xff\xff\xff\xff\xff" +
"\x00\x00\x00\x00\x00\x00\x00\x06監査" +
"\x00\x00\x00\x00\x00\x00\x00\x00" +
"\x00\x00\x00\x00\x00\x00\x00\x01\x00" +
"\x00\x00\x00\x00\x00\x00\x00\x01\n" +
"\x01" +
"\x00\x00\x00\x00\x00\x00\x00\x07done|ok"
if got := string(signer.canonicalV2Form(event)); got != want {
t.Fatalf("canonical v2 bytes changed:\n got %x\nwant %x", got, want)
}
}
func TestSignerVersionDispatchFailsClosedWithoutDowngrade(t *testing.T) {
signer := securityTestSigner(t)
event := securityTestEvent()
v2Signature := signer.Sign(event)
legacySignature := signer.signCanonical(signer.legacyZeroOneCanonicalForm(event))
for _, signature := range []string{
"v2:",
"v2:not-hex",
"v2:" + strings.Repeat("0", 62),
"v2:" + strings.Repeat("0", 66),
"v3:" + strings.TrimPrefix(v2Signature, signatureV2Prefix),
"unknown:" + strings.TrimPrefix(v2Signature, signatureV2Prefix),
strings.TrimPrefix(v2Signature, signatureV2Prefix),
"v2:" + legacySignature,
legacySignature + "00",
} {
t.Run(signature, func(t *testing.T) {
tamperedEnvelope := event
tamperedEnvelope.Signature = signature
if signer.Verify(tamperedEnvelope) {
t.Fatalf("unexpected verification for %q", signature)
}
})
}
if DetectSignatureVersion(v2Signature) != SignatureVersionV2 {
t.Fatal("valid v2 signature was not identified")
}
if DetectSignatureVersion(legacySignature) != SignatureVersionLegacy {
t.Fatal("valid legacy envelope was not identified")
}
if DetectSignatureVersion("v3:"+strings.TrimPrefix(v2Signature, signatureV2Prefix)) != SignatureVersionUnknown {
t.Fatal("unknown version did not fail closed")
}
}
func TestSignerLegacyCompatibilityIsExplicitlyBoundaryAmbiguous(t *testing.T) {
signer := securityTestSigner(t)
original := securityTestEvent()
original.EventType, original.User = "security", "alert|system"
forged := original
forged.EventType, forged.User = "security|alert", "system"
// Historical unversioned records remain readable, but cannot retroactively
// prove which side of a pipe a value belonged to. The legacy envelope makes
// that lower assurance identifiable without rewriting stored history.
original.Signature = signer.signCanonical(signer.legacyZeroOneCanonicalForm(original))
if DetectSignatureVersion(original.Signature) != SignatureVersionLegacy {
t.Fatal("historical signature was not identified as legacy")
}
if !signer.Verify(original) {
t.Fatal("historical signature did not verify")
}
forged.Signature = original.Signature
if !signer.Verify(forged) {
t.Fatal("legacy fixture no longer demonstrates its documented boundary ambiguity")
}
}

View file

@ -4,6 +4,7 @@ import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@ -218,9 +219,9 @@ func TestSignerSign(t *testing.T) {
sig := signer.Sign(event)
// Signature should be hex-encoded (64 characters for SHA256)
if len(sig) != 64 {
t.Errorf("Expected signature length 64, got %d", len(sig))
// The envelope identifies v2 and carries a 64-character SHA-256 MAC.
if len(sig) != len("v2:")+64 || !strings.HasPrefix(sig, "v2:") {
t.Errorf("Expected v2 signature envelope, got %q", sig)
}
// Same event should produce same signature
@ -288,6 +289,36 @@ func TestSignerVerify(t *testing.T) {
}
}
func TestSignerRejectsFieldBoundaryShiftForgery(t *testing.T) {
signer, err := NewSignerWithKey([]byte("0123456789abcdef0123456789abcdef"))
if err != nil {
t.Fatalf("NewSignerWithKey: %v", err)
}
original := Event{
ID: "boundary-shift",
Timestamp: time.Unix(1_725_555_555, 0).UTC(),
EventType: "security",
User: "alert|system",
IP: "127.0.0.1",
Path: "/api/audit",
Success: true,
Details: "detected",
}
forged := original
forged.EventType = "security|alert"
forged.User = "system"
original.Signature = signer.Sign(original)
if original.Signature == signer.Sign(forged) {
t.Fatal("distinct persisted tuples produced the same signature")
}
forged.Signature = original.Signature
if signer.Verify(forged) {
t.Fatal("signature verified after moving a delimiter between fields")
}
}
func TestSignerVerifyAcceptsLegacyProCanonicalForms(t *testing.T) {
signer, err := NewSignerWithKey([]byte("0123456789abcdef0123456789abcdef"))
if err != nil {
@ -304,6 +335,10 @@ func TestSignerVerifyAcceptsLegacyProCanonicalForms(t *testing.T) {
Details: "legacy",
}
event.Signature = signer.signCanonical(signer.legacyZeroOneCanonicalForm(event))
if !signer.Verify(event) {
t.Fatal("last unversioned zero/one signature did not verify")
}
event.Signature = signer.signCanonical(signer.legacyUnixCanonicalForm(event))
if !signer.Verify(event) {
t.Fatal("current Pro Unix/boolean signature did not verify")

View file

@ -0,0 +1,165 @@
package audit
import (
"bytes"
"encoding/csv"
"encoding/json"
"testing"
"time"
)
func TestSQLiteV2AndLegacySignaturesSurviveRestartQueryAndExport(t *testing.T) {
dataDir := t.TempDir()
signingKey := []byte("0123456789abcdef0123456789abcdef")
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{DataDir: dataDir, SigningKey: signingKey})
if err != nil {
t.Fatalf("NewSQLiteLogger: %v", err)
}
v2Event := Event{
ID: "v2-event",
Timestamp: time.Unix(1_725_555_555, 987_654_321).UTC(),
EventType: "security|alert",
User: "監査\noperator",
IP: "2001:db8::1",
Path: "/api/audit|verify",
Success: true,
Details: "line one\nline two|done",
}
if err := logger.Record(v2Event); err != nil {
t.Fatalf("Record v2 event: %v", err)
}
legacyEvent := Event{
ID: "legacy-event",
Timestamp: time.Unix(1_725_555_556, 0).UTC(),
EventType: "startup",
User: "admin",
IP: "127.0.0.1",
Path: "/api/audit",
Success: true,
Details: "historical",
}
legacyEvent.Signature = logger.signer.signCanonical(logger.signer.legacyTimeCanonicalForm(legacyEvent))
if _, err := logger.db.Exec(`
INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
legacyEvent.ID,
legacyEvent.Timestamp.Unix(),
legacyEvent.EventType,
legacyEvent.User,
legacyEvent.IP,
legacyEvent.Path,
1,
legacyEvent.Details,
legacyEvent.Signature,
); err != nil {
t.Fatalf("insert historical fixture: %v", err)
}
if err := logger.Close(); err != nil {
t.Fatalf("close logger: %v", err)
}
restarted, err := NewSQLiteLogger(SQLiteLoggerConfig{DataDir: dataDir, SigningKey: signingKey})
if err != nil {
t.Fatalf("restart SQLite logger: %v", err)
}
defer restarted.Close()
events, total, err := restarted.QueryPage(QueryFilter{Limit: 10})
if err != nil {
t.Fatalf("QueryPage: %v", err)
}
if len(events) != 2 || total != 2 {
t.Fatalf("QueryPage = %d events, total %d; want 2/2", len(events), total)
}
versions := make(map[string]SignatureVersion, len(events))
for _, event := range events {
versions[event.ID] = DetectSignatureVersion(event.Signature)
if !restarted.VerifySignature(event) {
t.Fatalf("signature for %s did not verify after restart", event.ID)
}
}
if versions[v2Event.ID] != SignatureVersionV2 {
t.Fatalf("new SQLite row version = %q, want v2", versions[v2Event.ID])
}
if versions[legacyEvent.ID] != SignatureVersionLegacy {
t.Fatalf("historical SQLite row version = %q, want legacy", versions[legacyEvent.ID])
}
var storedLegacySignature string
if err := restarted.db.QueryRow(`SELECT signature FROM audit_events WHERE id = ?`, legacyEvent.ID).Scan(&storedLegacySignature); err != nil {
t.Fatalf("read historical signature: %v", err)
}
if storedLegacySignature != legacyEvent.Signature {
t.Fatal("historical signature was rewritten")
}
exporter := NewExporter(restarted)
jsonResult, err := exporter.Export(QueryFilter{}, ExportFormatJSON, true)
if err != nil {
t.Fatalf("JSON export: %v", err)
}
var jsonExport struct {
Events []ExportEvent `json:"events"`
}
if err := json.Unmarshal(jsonResult.Data, &jsonExport); err != nil {
t.Fatalf("decode JSON export: %v", err)
}
if len(jsonExport.Events) != 2 {
t.Fatalf("JSON export events = %d, want 2", len(jsonExport.Events))
}
for _, event := range jsonExport.Events {
if event.SignatureValid == nil || !*event.SignatureValid {
t.Fatalf("JSON export signature verdict for %s = %v", event.ID, event.SignatureValid)
}
}
csvResult, err := exporter.Export(QueryFilter{}, ExportFormatCSV, true)
if err != nil {
t.Fatalf("CSV export: %v", err)
}
records, err := csv.NewReader(bytes.NewReader(csvResult.Data)).ReadAll()
if err != nil {
t.Fatalf("decode CSV export: %v", err)
}
if len(records) != 3 {
t.Fatalf("CSV records = %d, want header plus 2 rows", len(records))
}
for _, row := range records[1:] {
if row[len(row)-1] != "true" {
t.Fatalf("CSV signature verdict for %s = %q", row[0], row[len(row)-1])
}
if DetectSignatureVersion(row[len(row)-2]) == SignatureVersionUnknown {
t.Fatalf("CSV signature for %s lost its identifiable envelope", row[0])
}
}
}
func TestTenantSQLiteFactoryWritesV2Signatures(t *testing.T) {
manager := NewTenantLoggerManager(t.TempDir(), &SQLiteLoggerFactory{
CryptoMgr: newMockCryptoManager(),
})
defer manager.Close()
if err := manager.Log("tenant-a", "security|alert", "user|admin", "127.0.0.1", "/api/audit", true, "tenant event"); err != nil {
t.Fatalf("tenant Log: %v", err)
}
events, err := manager.Query("tenant-a", QueryFilter{})
if err != nil {
t.Fatalf("tenant Query: %v", err)
}
if len(events) != 1 {
t.Fatalf("tenant events = %d, want 1", len(events))
}
if DetectSignatureVersion(events[0].Signature) != SignatureVersionV2 {
t.Fatalf("tenant signature version = %q, want v2", DetectSignatureVersion(events[0].Signature))
}
logger, ok := manager.GetLogger("tenant-a").(*SQLiteLogger)
if !ok {
t.Fatalf("tenant logger type = %T, want *SQLiteLogger", manager.GetLogger("tenant-a"))
}
if !logger.VerifySignature(events[0]) {
t.Fatal("tenant v2 signature did not verify")
}
}