diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 24b0911ab..9dcda6751 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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 diff --git a/pkg/audit/signer.go b/pkg/audit/signer.go index 70ae4b75d..34bcce4fd 100644 --- a/pkg/audit/signer.go +++ b/pkg/audit/signer.go @@ -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" diff --git a/pkg/audit/signer_security_test.go b/pkg/audit/signer_security_test.go new file mode 100644 index 000000000..fb6578ba8 --- /dev/null +++ b/pkg/audit/signer_security_test.go @@ -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") + } +} diff --git a/pkg/audit/signer_test.go b/pkg/audit/signer_test.go index d9a662fda..89f7177f6 100644 --- a/pkg/audit/signer_test.go +++ b/pkg/audit/signer_test.go @@ -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") diff --git a/pkg/audit/sqlite_signature_security_test.go b/pkg/audit/sqlite_signature_security_test.go new file mode 100644 index 000000000..77bc69274 --- /dev/null +++ b/pkg/audit/sqlite_signature_security_test.go @@ -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") + } +}