// Package ingress authenticates Brain event candidates and delegates their // atomic persistence to Bell's repository. package ingress import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "io" "net/http" "regexp" "strconv" "strings" "time" "yovision/bell/internal/event" ) const ( Path = "/internal/v1/event-candidates" MaxBodyBytes = 1 << 20 HeaderKeyID = "X-YoVision-Key-Id" HeaderTimestamp = "X-YoVision-Timestamp" HeaderNonce = "X-YoVision-Nonce" HeaderSignature = "X-YoVision-Signature" ) var ( ErrReplayConflict = errors.New("event ingress replay conflict") ErrSourceConflict = errors.New("event ingress source event conflict") ErrIdentityDenied = errors.New("event ingress identity denied") keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) producerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) ) type Result struct { SchemaVersion int `json:"schema_version"` ProducerID string `json:"producer_id"` SourceEventID string `json:"source_event_id"` EventID string `json:"event_id"` Status string `json:"status"` HTTPStatus int `json:"-"` } type Repository interface { Replay( context.Context, string, string, [sha256.Size]byte, string, string, [sha256.Size]byte, ) (Result, bool, error) ProcessEvent( context.Context, string, string, [sha256.Size]byte, string, [sha256.Size]byte, event.Event, ) (Result, error) } type Handler struct { repository Repository keys map[string]Key factory *event.Factory now func() time.Time } func NewHandler(repository Repository, keys map[string]Key, factory *event.Factory) (*Handler, error) { if repository == nil || factory == nil || len(keys) == 0 { return nil, errors.New("event ingress handler dependencies are required") } copyKeys := make(map[string]Key, len(keys)) for id, key := range keys { if !keyIDPattern.MatchString(id) || !producerIDPattern.MatchString(key.ProducerID) || len(key.Secret) < 32 { return nil, errors.New("invalid event ingress handler key") } copyKeys[id] = Key{ProducerID: key.ProducerID, Secret: append([]byte(nil), key.Secret...)} } return &Handler{repository: repository, keys: copyKeys, factory: factory, now: time.Now}, nil } type envelope struct { SchemaVersion int `json:"schema_version"` ProducerID string `json:"producer_id"` Candidate json.RawMessage `json:"candidate"` } func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodPost || request.URL.Path != Path { 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) key, 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(key.Secret, canonicalString(request.Method, request.URL.EscapedPath(), timestamp, nonce, body)) if !hmac.Equal(signatureBytes, expected) { writeError(writer, http.StatusUnauthorized, "unauthorized", "") return } var value envelope decoder := json.NewDecoder(bytes.NewReader(body)) decoder.DisallowUnknownFields() if err := decoder.Decode(&value); err != nil || value.SchemaVersion != 1 || !producerIDPattern.MatchString(value.ProducerID) || len(value.Candidate) == 0 { writeError(writer, http.StatusBadRequest, "invalid_envelope", "") return } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { writeError(writer, http.StatusBadRequest, "invalid_envelope", "") return } if value.ProducerID != key.ProducerID { writeError(writer, http.StatusUnauthorized, "unauthorized", "") return } canonicalCandidate, err := canonicalJSON(value.Candidate) if err != nil { writeError(writer, http.StatusBadRequest, "invalid_envelope", "") return } sourceEventID, err := candidateSourceEventID(canonicalCandidate) if err != nil { writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", "schema_invalid") return } requestHash := sha256.Sum256(body) candidateHash := sha256.Sum256(canonicalCandidate) replay, found, err := h.repository.Replay( request.Context(), keyID, nonce, requestHash, value.ProducerID, sourceEventID, candidateHash, ) switch { case errors.Is(err, ErrReplayConflict): writeError(writer, http.StatusConflict, "replay_conflict", "") return case errors.Is(err, ErrSourceConflict): writeError(writer, http.StatusConflict, "source_event_conflict", "") return case err != nil: writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "") return case found: writeJSON(writer, replay.HTTPStatus, replay) return } created, err := h.factory.Create(request.Context(), canonicalCandidate) if err != nil { handleFactoryError(writer, err) return } result, err := h.repository.ProcessEvent(request.Context(), keyID, nonce, requestHash, value.ProducerID, candidateHash, created) switch { case errors.Is(err, ErrReplayConflict): writeError(writer, http.StatusConflict, "replay_conflict", "") case errors.Is(err, ErrSourceConflict): writeError(writer, http.StatusConflict, "source_event_conflict", "") case errors.Is(err, ErrIdentityDenied): writeError(writer, http.StatusForbidden, "identity_denied", "") case err != nil: writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "") default: writeJSON(writer, result.HTTPStatus, result) } } func handleFactoryError(writer http.ResponseWriter, err error) { var validation *event.ValidationError if !errors.As(err, &validation) { writeError(writer, http.StatusServiceUnavailable, "temporarily_unavailable", "") return } switch validation.Code { case event.CodePayloadTooLarge: writeError(writer, http.StatusRequestEntityTooLarge, "payload_too_large", "") case event.CodePrivacyDenied: writeError(writer, http.StatusForbidden, "privacy_denied", "") case event.CodePrivacyUnavailable: writeError(writer, http.StatusServiceUnavailable, "privacy_unavailable", "") default: writeError(writer, http.StatusUnprocessableEntity, "candidate_invalid", string(validation.Code)) } } func canonicalJSON(raw []byte) ([]byte, error) { decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() var value any if err := decoder.Decode(&value); err != nil || value == nil { return nil, errors.New("invalid JSON") } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { return nil, errors.New("multiple JSON values") } return json.Marshal(value) } var sourceEventIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`) func candidateSourceEventID(raw []byte) (string, error) { var object map[string]json.RawMessage if err := json.Unmarshal(raw, &object); err != nil { return "", errors.New("candidate is not an object") } var value string if err := json.Unmarshal(object["source_event_id"], &value); err != nil || !sourceEventIDPattern.MatchString(value) { return "", errors.New("candidate source event ID is invalid") } return value, nil } 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 } func writeError(writer http.ResponseWriter, status int, code, detail string) { value := map[string]string{"error": code} if detail != "" { value["detail_code"] = detail } writeJSON(writer, status, value) } func writeJSON(writer http.ResponseWriter, status int, value any) { writer.Header().Set("Content-Type", "application/json") writer.Header().Set("Cache-Control", "no-store") writer.Header().Set("X-Content-Type-Options", "nosniff") writer.WriteHeader(status) _ = json.NewEncoder(writer).Encode(value) }