mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-20 22:43:31 +00:00
Harden audit signatures against boundary forgery
This commit is contained in:
parent
a1b5f085b3
commit
1dcb414167
5 changed files with 571 additions and 29 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
231
pkg/audit/signer_security_test.go
Normal file
231
pkg/audit/signer_security_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
165
pkg/audit/sqlite_signature_security_test.go
Normal file
165
pkg/audit/sqlite_signature_security_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue