364 lines
12 KiB
Go
364 lines
12 KiB
Go
// 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)
|
||
|
|
}
|