package auditrelay import ( "bytes" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "strconv" "strings" "time" ) const ( HeaderKeyID = "X-YoVision-Key-Id" HeaderTimestamp = "X-YoVision-Timestamp" HeaderNonce = "X-YoVision-Nonce" HeaderSignature = "X-YoVision-Signature" RelayPath = "/internal/v1/audit-events:batch" ) type Client struct { endpoint *url.URL keyID string secret []byte httpClient *http.Client now func() time.Time nonce func() (string, error) } func NewClient(rawURL, keyID string, secret []byte, client *http.Client) (*Client, error) { endpoint, err := ValidateEndpoint(rawURL) if err != nil { return nil, err } if keyID == "" || len(secret) < 32 { return nil, errors.New("audit relay key ID and 32-byte secret are required") } if client == nil { client = &http.Client{Timeout: 10 * time.Second} } return &Client{endpoint: endpoint, keyID: keyID, secret: append([]byte(nil), secret...), httpClient: client, now: time.Now, nonce: randomNonce}, nil } func ValidateEndpoint(rawURL string) (*url.URL, error) { parsed, err := url.Parse(rawURL) if err != nil || parsed.Host == "" || parsed.Path != RelayPath || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil { return nil, errors.New("invalid Bell audit relay URL") } host := parsed.Hostname() ip := net.ParseIP(host) loopback := strings.EqualFold(host, "localhost") || (ip != nil && ip.IsLoopback()) if parsed.Scheme != "https" && !(parsed.Scheme == "http" && loopback) { return nil, errors.New("Bell audit relay URL requires HTTPS outside loopback") } return parsed, 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) string { mac := hmac.New(sha256.New, secret) _, _ = mac.Write([]byte(canonical)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } func randomNonce() (string, error) { value := make([]byte, 16) if _, err := rand.Read(value); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(value), nil } func (c *Client) Send(ctx context.Context, events []Envelope) ([]Result, error) { if len(events) < 1 || len(events) > MaxBatchSize { return nil, errors.New("audit relay batch must contain 1 to 100 events") } body, err := json.Marshal(BatchRequest{Events: events}) if err != nil || len(body) > MaxBodyBytes { return nil, errors.New("encode audit relay batch") } requestContext, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() timestamp := strconv.FormatInt(c.now().UTC().Unix(), 10) nonce, err := c.nonce() if err != nil { return nil, errors.New("generate audit relay nonce") } request, err := http.NewRequestWithContext(requestContext, http.MethodPost, c.endpoint.String(), bytes.NewReader(body)) if err != nil { return nil, errors.New("create audit relay request") } request.Header.Set("Content-Type", "application/json") request.Header.Set(HeaderKeyID, c.keyID) request.Header.Set(HeaderTimestamp, timestamp) request.Header.Set(HeaderNonce, nonce) request.Header.Set(HeaderSignature, Signature(c.secret, CanonicalString(http.MethodPost, RelayPath, timestamp, nonce, body))) response, err := c.httpClient.Do(request) if err != nil { return nil, errors.New("send audit relay request") } defer response.Body.Close() if response.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) return nil, fmt.Errorf("Bell audit relay returned HTTP %d", response.StatusCode) } decoder := json.NewDecoder(io.LimitReader(response.Body, MaxBodyBytes+1)) decoder.DisallowUnknownFields() var decoded BatchResponse if err := decoder.Decode(&decoded); err != nil { return nil, errors.New("decode audit relay response") } var trailing any if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { return nil, errors.New("audit relay response contains trailing data") } return decoded.Results, nil }