// 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) }