feat: deliver Sense audits to Bell (T-016)
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
// Package audit authenticates and validates Sense audit relay batches.
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
RelayPath = "/internal/v1/audit-events:batch"
|
||||
MaxBatchSize = 100
|
||||
MaxBodyBytes = 1 << 20
|
||||
HeaderKeyID = "X-YoVision-Key-Id"
|
||||
HeaderTimestamp = "X-YoVision-Timestamp"
|
||||
HeaderNonce = "X-YoVision-Nonce"
|
||||
HeaderSignature = "X-YoVision-Signature"
|
||||
)
|
||||
|
||||
var ErrReplayConflict = errors.New("audit relay replay conflict")
|
||||
|
||||
type Actor struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type ProjectionVersions struct {
|
||||
QuotaSourceVersion *int64 `json:"quota_source_version"`
|
||||
AreaPolicySourceVersion *int64 `json:"area_policy_source_version"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
Actor Actor `json:"actor"`
|
||||
Reason *string `json:"reason"`
|
||||
TraceID *string `json:"trace_id"`
|
||||
AggregateGeneration int64 `json:"aggregate_generation"`
|
||||
ProjectionVersions ProjectionVersions `json:"projection_versions"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
type Envelope struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Event Event `json:"event"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
EventID string `json:"event_id"`
|
||||
Status string `json:"status"`
|
||||
ErrorCode *string `json:"error_code,omitempty"`
|
||||
}
|
||||
|
||||
type BatchResponse struct {
|
||||
Results []Result `json:"results"`
|
||||
}
|
||||
|
||||
type Candidate struct {
|
||||
Envelope Envelope
|
||||
RecordHash [sha256.Size]byte
|
||||
ErrorCode string
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
ProcessAuditBatch(context.Context, string, string, [sha256.Size]byte, []Candidate) ([]Result, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
repository Repository
|
||||
keys map[string][]byte
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewHandler(repository Repository, keys map[string][]byte) (*Handler, error) {
|
||||
if repository == nil || len(keys) == 0 {
|
||||
return nil, errors.New("audit handler dependencies are required")
|
||||
}
|
||||
copyKeys := make(map[string][]byte, len(keys))
|
||||
for id, secret := range keys {
|
||||
if !keyIDPattern.MatchString(id) || len(secret) < 32 {
|
||||
return nil, errors.New("invalid audit handler key")
|
||||
}
|
||||
copyKeys[id] = append([]byte(nil), secret...)
|
||||
}
|
||||
return &Handler{repository: repository, keys: copyKeys, now: time.Now}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != RelayPath {
|
||||
writeError(writer, http.StatusNotFound, "not_found")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(request.Body, MaxBodyBytes+1))
|
||||
if err != nil || len(body) > MaxBodyBytes {
|
||||
writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large")
|
||||
return
|
||||
}
|
||||
keyID := request.Header.Get(HeaderKeyID)
|
||||
timestamp := request.Header.Get(HeaderTimestamp)
|
||||
nonce := request.Header.Get(HeaderNonce)
|
||||
provided := request.Header.Get(HeaderSignature)
|
||||
secret, ok := h.keys[keyID]
|
||||
seconds, timestampErr := strconv.ParseInt(timestamp, 10, 64)
|
||||
nonceBytes, nonceErr := base64.RawURLEncoding.DecodeString(nonce)
|
||||
signatureBytes, signatureErr := base64.RawURLEncoding.DecodeString(provided)
|
||||
if !ok || timestampErr != nil || len(timestamp) < 10 || nonceErr != nil || len(nonceBytes) < 16 || len(nonceBytes) > 48 ||
|
||||
signatureErr != nil || len(signatureBytes) != sha256.Size || absDuration(h.now().UTC().Sub(time.Unix(seconds, 0).UTC())) > 300*time.Second {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
expected := signature(secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body))
|
||||
if !hmac.Equal(signatureBytes, expected) {
|
||||
writeError(writer, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
candidates, err := decodeCandidates(body)
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusBadRequest, "invalid_batch")
|
||||
return
|
||||
}
|
||||
requestHash := sha256.Sum256(body)
|
||||
results, err := h.repository.ProcessAuditBatch(request.Context(), keyID, nonce, requestHash, candidates)
|
||||
if errors.Is(err, ErrReplayConflict) {
|
||||
writeError(writer, http.StatusConflict, "replay_conflict")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, BatchResponse{Results: results})
|
||||
}
|
||||
|
||||
func canonicalString(method, path, timestamp, nonce string, body []byte) string {
|
||||
digest := sha256.Sum256(body)
|
||||
return strings.Join([]string{method, path, timestamp, nonce, hex.EncodeToString(digest[:])}, "\n")
|
||||
}
|
||||
|
||||
func signature(secret []byte, canonical string) []byte {
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
_, _ = mac.Write([]byte(canonical))
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func absDuration(value time.Duration) time.Duration {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type rawBatch struct {
|
||||
Events []json.RawMessage `json:"events"`
|
||||
}
|
||||
|
||||
func decodeCandidates(body []byte) ([]Candidate, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.DisallowUnknownFields()
|
||||
var batch rawBatch
|
||||
if err := decoder.Decode(&batch); err != nil || len(batch.Events) < 1 || len(batch.Events) > MaxBatchSize {
|
||||
return nil, errors.New("invalid audit batch")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid audit batch trailing data")
|
||||
}
|
||||
values := make([]Candidate, len(batch.Events))
|
||||
for index, raw := range batch.Events {
|
||||
values[index].RecordHash = sha256.Sum256(raw)
|
||||
if !hasExactEnvelopeShape(raw) {
|
||||
values[index].ErrorCode = "schema_invalid"
|
||||
continue
|
||||
}
|
||||
itemDecoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
itemDecoder.DisallowUnknownFields()
|
||||
if err := itemDecoder.Decode(&values[index].Envelope); err != nil {
|
||||
values[index].ErrorCode = "schema_invalid"
|
||||
continue
|
||||
}
|
||||
values[index].ErrorCode = validateEnvelope(values[index].Envelope)
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
var (
|
||||
eventIDPattern = regexp.MustCompile(`^audit_[0-9a-f]{32}$`)
|
||||
logicalIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
|
||||
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
)
|
||||
|
||||
func validateEnvelope(value Envelope) string {
|
||||
event := value.Event
|
||||
if (value.SchemaVersion != 1 && value.SchemaVersion != 2) || !eventIDPattern.MatchString(event.EventID) ||
|
||||
!logicalIDPattern.MatchString(event.TenantID) || !logicalIDPattern.MatchString(event.SiteID) || !logicalIDPattern.MatchString(event.DeviceID) ||
|
||||
event.AggregateGeneration < 1 || event.OccurredAt.IsZero() || strings.TrimSpace(event.Actor.ID) == "" || utf8.RuneCountInString(event.Actor.ID) > 200 ||
|
||||
(event.Actor.Type != "user" && event.Actor.Type != "service" && event.Actor.Type != "system") ||
|
||||
(event.Reason != nil && utf8.RuneCountInString(*event.Reason) > 500) || (event.TraceID != nil && utf8.RuneCountInString(*event.TraceID) > 128) ||
|
||||
(event.ProjectionVersions.QuotaSourceVersion != nil && *event.ProjectionVersions.QuotaSourceVersion < 1) ||
|
||||
(event.ProjectionVersions.AreaPolicySourceVersion != nil && *event.ProjectionVersions.AreaPolicySourceVersion < 1) {
|
||||
return "schema_invalid"
|
||||
}
|
||||
if event.EventType != "device.created" && event.EventType != "device.desired_state.accepted" &&
|
||||
(event.EventType != "device.configuration.accepted" || value.SchemaVersion != 2) {
|
||||
return "schema_invalid"
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal(event.Data, &data); err != nil || data == nil {
|
||||
return "schema_invalid"
|
||||
}
|
||||
expected := map[string]string{
|
||||
"device.created": "device_created",
|
||||
"device.desired_state.accepted": "desired_state_accepted",
|
||||
"device.configuration.accepted": "configuration_accepted",
|
||||
}[event.EventType]
|
||||
if data["kind"] != expected || !validateData(event.EventType, data) || containsSensitiveKey(data) {
|
||||
return "payload_invalid"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasExactEnvelopeShape(raw []byte) bool {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil || !exactRawKeys(envelope, "schema_version", "event") {
|
||||
return false
|
||||
}
|
||||
var event map[string]json.RawMessage
|
||||
if err := json.Unmarshal(envelope["event"], &event); err != nil || !exactRawKeys(event,
|
||||
"event_id", "event_type", "tenant_id", "site_id", "device_id", "actor", "reason", "trace_id",
|
||||
"aggregate_generation", "projection_versions", "data", "occurred_at",
|
||||
) {
|
||||
return false
|
||||
}
|
||||
var actor, projections map[string]json.RawMessage
|
||||
return json.Unmarshal(event["actor"], &actor) == nil && exactRawKeys(actor, "type", "id") &&
|
||||
json.Unmarshal(event["projection_versions"], &projections) == nil && exactRawKeys(projections, "quota_source_version", "area_policy_source_version")
|
||||
}
|
||||
|
||||
func exactRawKeys(value map[string]json.RawMessage, expected ...string) bool {
|
||||
if len(value) != len(expected) {
|
||||
return false
|
||||
}
|
||||
for _, key := range expected {
|
||||
if _, exists := value[key]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validateData(eventType string, data map[string]any) bool {
|
||||
switch eventType {
|
||||
case "device.created":
|
||||
if !exactAnyKeys(data, "kind", "area_id", "modality", "capabilities", "desired_state") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
|
||||
return false
|
||||
}
|
||||
if !member(stringValue(data["modality"]), "video", "radar", "contact", "button", "wearable", "other") || !member(stringValue(data["desired_state"]), "disabled", "enabled") {
|
||||
return false
|
||||
}
|
||||
return validStringSet(data["capabilities"], 16, "video_capture", "audio_capture", "spatial_rule", "telemetry")
|
||||
case "device.desired_state.accepted":
|
||||
if !exactAnyKeys(data, "kind", "previous_desired_state", "desired_state", "changed") {
|
||||
return false
|
||||
}
|
||||
_, changed := data["changed"].(bool)
|
||||
return changed && member(stringValue(data["previous_desired_state"]), "disabled", "enabled") && member(stringValue(data["desired_state"]), "disabled", "enabled")
|
||||
case "device.configuration.accepted":
|
||||
if !exactAnyKeys(data, "kind", "changed", "changed_fields", "area_id") || !logicalIDPattern.MatchString(stringValue(data["area_id"])) {
|
||||
return false
|
||||
}
|
||||
_, changed := data["changed"].(bool)
|
||||
return changed && validStringSet(data["changed_fields"], 5, "name", "area_id", "endpoint_ref", "credential_ref", "profile_token")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func exactAnyKeys(value map[string]any, expected ...string) bool {
|
||||
if len(value) != len(expected) {
|
||||
return false
|
||||
}
|
||||
for _, key := range expected {
|
||||
if _, exists := value[key]; !exists {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
result, _ := value.(string)
|
||||
return result
|
||||
}
|
||||
|
||||
func member(value string, allowed ...string) bool {
|
||||
for _, candidate := range allowed {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validStringSet(value any, maximum int, allowed ...string) bool {
|
||||
items, ok := value.([]any)
|
||||
if !ok || len(items) > maximum {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
text, ok := item.(string)
|
||||
if !ok || !member(text, allowed...) || seen[text] {
|
||||
return false
|
||||
}
|
||||
seen[text] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func containsSensitiveKey(value any) bool {
|
||||
forbidden := map[string]bool{"password": true, "stream_uri": true, "mediamtx_config": true}
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
if forbidden[strings.ToLower(key)] || containsSensitiveKey(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if containsSensitiveKey(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func writeError(writer http.ResponseWriter, status int, code string) {
|
||||
writeJSON(writer, status, map[string]string{"error": code})
|
||||
}
|
||||
|
||||
func writeJSON(writer http.ResponseWriter, status int, value any) {
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.Header().Set("Cache-Control", "no-store")
|
||||
writer.WriteHeader(status)
|
||||
_ = json.NewEncoder(writer).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type recordingRepository struct {
|
||||
candidates []Candidate
|
||||
results []Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *recordingRepository) ProcessAuditBatch(_ context.Context, _, _ string, _ [sha256.Size]byte, values []Candidate) ([]Result, error) {
|
||||
r.candidates = values
|
||||
return r.results, r.err
|
||||
}
|
||||
|
||||
func validBody(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
value := map[string]any{"events": []any{map[string]any{
|
||||
"schema_version": 1,
|
||||
"event": map[string]any{
|
||||
"event_id": "audit_00000000000000000000000000000001", "event_type": "device.created",
|
||||
"tenant_id": "tenant", "site_id": "site", "device_id": "camera-1",
|
||||
"actor": map[string]any{"type": "system", "id": "sense"}, "reason": nil, "trace_id": nil,
|
||||
"aggregate_generation": 1,
|
||||
"projection_versions": map[string]any{"quota_source_version": 1, "area_policy_source_version": 1},
|
||||
"data": map[string]any{"kind": "device_created", "area_id": "area", "modality": "video", "capabilities": []any{"video_capture"}, "desired_state": "enabled"},
|
||||
"occurred_at": "2026-08-11T00:00:00Z",
|
||||
},
|
||||
}}}
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func signedRequest(t *testing.T, body, secret []byte, timestamp time.Time, nonce string) *http.Request {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(http.MethodPost, RelayPath, bytes.NewReader(body))
|
||||
stamp := strconv.FormatInt(timestamp.Unix(), 10)
|
||||
request.Header.Set(HeaderKeyID, "sense-a")
|
||||
request.Header.Set(HeaderTimestamp, stamp)
|
||||
request.Header.Set(HeaderNonce, nonce)
|
||||
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, RelayPath, stamp, nonce, body))))
|
||||
return request
|
||||
}
|
||||
|
||||
func TestHandlerAuthenticatesAndReturnsPerItemResults(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{3}, 32)
|
||||
repository := &recordingRepository{results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}}
|
||||
handler, err := NewHandler(repository, map[string][]byte{"sense-a": secret})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 11, 0, 1, 0, 0, time.UTC)
|
||||
handler.now = func() time.Time { return now }
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, signedRequest(t, validBody(t), secret, now, "AAAAAAAAAAAAAAAAAAAAAA"))
|
||||
if response.Code != http.StatusOK || len(repository.candidates) != 1 || repository.candidates[0].ErrorCode != "" {
|
||||
t.Fatalf("valid batch rejected: status=%d candidates=%+v", response.Code, repository.candidates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRejectsStaleOrTamperedRequests(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{4}, 32)
|
||||
repository := &recordingRepository{}
|
||||
handler, _ := NewHandler(repository, map[string][]byte{"sense-a": secret})
|
||||
now := time.Date(2026, 8, 11, 0, 10, 0, 0, time.UTC)
|
||||
handler.now = func() time.Time { return now }
|
||||
for _, request := range []*http.Request{
|
||||
signedRequest(t, validBody(t), secret, now.Add(-301*time.Second), "BBBBBBBBBBBBBBBBBBBBBB"),
|
||||
signedRequest(t, append(validBody(t), ' '), bytes.Repeat([]byte{5}, 32), now, "CCCCCCCCCCCCCCCCCCCCCC"),
|
||||
} {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unsafe request returned %d", response.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCandidatesRejectsSensitiveItemWithoutRejectingBatch(t *testing.T) {
|
||||
body := validBody(t)
|
||||
var value map[string]any
|
||||
_ = json.Unmarshal(body, &value)
|
||||
events := value["events"].([]any)
|
||||
event := events[0].(map[string]any)["event"].(map[string]any)
|
||||
event["data"].(map[string]any)["password"] = "must-not-persist"
|
||||
body, _ = json.Marshal(value)
|
||||
candidates, err := decodeCandidates(body)
|
||||
if err != nil || len(candidates) != 1 || candidates[0].ErrorCode != "payload_invalid" {
|
||||
t.Fatalf("unexpected per-item validation: %+v %v", candidates, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
type keyDocument struct {
|
||||
Version int `json:"version"`
|
||||
Keys []struct {
|
||||
KeyID string `json:"key_id"`
|
||||
Secret string `json:"secret_base64url"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
|
||||
func LoadKeys(path string) (map[string][]byte, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errors.New("read Bell audit key file")
|
||||
}
|
||||
var document keyDocument
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&document); err != nil || document.Version != 1 || len(document.Keys) == 0 {
|
||||
return nil, errors.New("invalid Bell audit key file")
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return nil, errors.New("invalid Bell audit key file")
|
||||
}
|
||||
values := make(map[string][]byte, len(document.Keys))
|
||||
for _, item := range document.Keys {
|
||||
secret, err := base64.RawURLEncoding.DecodeString(item.Secret)
|
||||
if err != nil || !keyIDPattern.MatchString(item.KeyID) || len(secret) < 32 {
|
||||
return nil, errors.New("invalid Bell audit key")
|
||||
}
|
||||
if _, exists := values[item.KeyID]; exists {
|
||||
return nil, errors.New("duplicate Bell audit key ID")
|
||||
}
|
||||
values[item.KeyID] = secret
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
Reference in New Issue
Block a user