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.
66 lines
2.2 KiB
Go
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))
|
|
}
|