Files
MOPAC/internal/events/verify.go
T
mrcharles 05ec1a4142 events: verify, normalize, append-only store with provider-id dedup
Signature verification (gitea hex HMAC-SHA256 over the raw body via
constant-time hmac.Equal; redmine/discourse constant-time shared-secret
headers) with one generic ErrUnverified so rejects give attackers no
oracle. Tolerant normalization of the known Redmine/Discourse/Gitea
payload variants into one Event record (canonical subject ids, actor,
title, repo, sha256 payload digest) plus the DESIGN action mapping
(dispatch_turn / respond_turn / pipeline_step / ignore). Store: JSONL
under state dir, 0600, dedup keyed on provider event id (delivery
header, payload-digest fallback), index rebuilt at startup so replays
across restarts still dedup; torn tail lines skipped, not fatal.
2026-08-28 21:38:16 -05:00

66 lines
2.2 KiB
Go

package events
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
)
// giteaSignatureHeader carries the hex HMAC-SHA256 of the raw body.
const giteaSignatureHeader = "X-Gitea-Signature"
// Default shared-secret header names (overridable per source in config;
// gitea is always HMAC and ignores the override).
const (
DefaultRedmineSecretHeader = "X-Redmine-Webhook-Secret"
DefaultDiscourseSecretHeader = "X-Discourse-Webhook-Secret"
)
// ErrUnverified marks every verification failure so callers reject with 401
// without distinguishing WHY (no oracle for attackers poking the receiver).
var ErrUnverified = errors.New("webhook not verified")
// VerifyGiteaHMAC checks the X-Gitea-Signature header (hex HMAC-SHA256 of
// the raw request body) against the shared secret. Comparison is
// constant-time; error text never echoes header values.
func VerifyGiteaHMAC(body []byte, headerValue, secret string) error {
if headerValue == "" {
return fmt.Errorf("%w: missing %s header", ErrUnverified, giteaSignatureHeader)
}
got, err := hex.DecodeString(headerValue)
if err != nil {
return fmt.Errorf("%w: %s header is not valid hex", ErrUnverified, giteaSignatureHeader)
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
if !hmac.Equal(got, mac.Sum(nil)) {
return fmt.Errorf("%w: %s does not match body", ErrUnverified, giteaSignatureHeader)
}
return nil
}
// VerifySharedSecret constant-time compares the value of the provider's
// secret header against the configured secret.
func VerifySharedSecret(headerName, headerValue, secret string) error {
if headerValue == "" {
return fmt.Errorf("%w: missing %s header", ErrUnverified, headerName)
}
if subtle.ConstantTimeCompare([]byte(headerValue), []byte(secret)) != 1 {
return fmt.Errorf("%w: %s does not verify", ErrUnverified, headerName)
}
return nil
}
// GiteaSignatureHeader exposes the fixed HMAC header name (config/docs).
func GiteaSignatureHeader() string { return giteaSignatureHeader }
// SignGitea computes the expected HMAC for a body+secret (tests, smoke).
func SignGitea(body []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hex.EncodeToString(mac.Sum(nil))
}