Files
executus/tool/hmac.go
steve dc28b63ad8
executus CI / test (push) Successful in 36s
P1 (part 1): move skilltools core -> tool/ (clean, verbatim)
The tool registry core (registry, permission model, Invocation, gated-tool
wrapper, ssrf guard, hmac, encryption, argcoerce, helpers, rootrun,
session_tools, webhook_rate_limit) had zero mort coupling — it imports only
majordomo/llm + x/crypto/hkdf — so it moves verbatim with a package rename
(skilltools -> tool). All same-package tests came along and pass; the SSRF,
gated-wrapper, encryption and output-pattern invariants are re-anchored here.

majordomo re-enters the module graph (now pinned to the latest, incl. the
front-loaded-output fix). model/ + llmmeta + structured follow next.

Docs: CLAUDE.md now requires README/examples to stay in sync with changes in
the same commit; CI skips docs/example-only pushes via paths-ignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:31:47 -04:00

122 lines
4.2 KiB
Go

// Package skilltools — hmac.go: HMAC-SHA256 signature verification
// for the v7 inbound webhook subsystem.
//
// Why a small util in pkg/skilltools (vs inline in skillsui): the
// signature format is part of the skill platform's public contract —
// callers (GitHub, monitoring, Stripe, etc) compute it client-side,
// and other parts of mort may eventually verify the same shape (e.g.
// outbound retry verification). A shared util means we test the
// verifier once and the format stays consistent.
//
// Format:
//
// X-Mort-Signature: sha256=<hex(HMAC-SHA256(secret, body))>
// X-Mort-Timestamp: <unix-seconds>
//
// The timestamp is included so a stolen payload+signature pair can't
// be replayed indefinitely. Default skew window is 5 min via the
// caller-supplied maxSkew. The body is verified verbatim — callers
// must NOT canonicalise (the LLM-supplied JSON shape is usually
// unstable on round-trip).
package tool
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"strconv"
"strings"
"time"
)
// HMAC-related sentinel errors. Callers compare with errors.Is so
// handler code can surface the right HTTP status (401 vs 400).
var (
// ErrHMACBadFormat is returned when the signature header is not
// the expected "sha256=<hex>" form.
ErrHMACBadFormat = errors.New("hmac: bad signature format")
// ErrHMACBadSignature is returned when the computed HMAC does
// not match the supplied signature (constant-time compare).
ErrHMACBadSignature = errors.New("hmac: signature mismatch")
// ErrHMACBadTimestamp is returned when the timestamp header is
// missing, malformed, or outside the maxSkew window.
ErrHMACBadTimestamp = errors.New("hmac: bad or stale timestamp")
// ErrHMACEmptySecret is returned when verification is requested
// but the secret is empty — a programmer error (caller should
// have rejected the request earlier).
ErrHMACEmptySecret = errors.New("hmac: empty secret")
)
// SignBody returns the canonical signature value for the given body
// + secret. Used by the test-payload sender on the management page.
//
// Why exported: the management page's "send test payload" button needs
// to compute the signature server-side before POSTing; reusing the
// same function ensures the verifier and signer stay in lock-step.
func SignBody(body []byte, secret string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
}
// VerifyHMAC checks the signature + timestamp against the body using
// the supplied secret. Returns nil on success or one of the sentinels.
//
// Why hmac.Equal (constant-time): a naive == leaks signature length
// information through timing — VerifyHMAC must be safe against
// length-extension and timing oracle attacks.
//
// Why the timestamp is part of the verification (not the body): the
// signature does NOT cover the timestamp itself (callers may rotate
// timestamps without re-signing). The timestamp is a separate
// freshness check; if you wanted timestamp-bound replay protection
// you'd include it in the signed payload — but that complicates the
// signing API for callers and the per-skill rate limiter is the
// real defence against rapid replay.
func VerifyHMAC(body []byte, signature, timestamp, secret string, maxSkew time.Duration) error {
if secret == "" {
return ErrHMACEmptySecret
}
// Timestamp first — cheap reject before the HMAC compute.
if timestamp != "" {
ts, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
if err != nil {
return ErrHMACBadTimestamp
}
if maxSkew > 0 {
now := time.Now().Unix()
if abs(now-ts) > int64(maxSkew.Seconds()) {
return ErrHMACBadTimestamp
}
}
}
// Signature format: "sha256=<hex>"
const prefix = "sha256="
if !strings.HasPrefix(signature, prefix) {
return ErrHMACBadFormat
}
provided, err := hex.DecodeString(signature[len(prefix):])
if err != nil {
return ErrHMACBadFormat
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := mac.Sum(nil)
if !hmac.Equal(provided, expected) {
return ErrHMACBadSignature
}
return nil
}
// abs returns |x| for int64. Avoids importing math for one call.
func abs(x int64) int64 {
if x < 0 {
return -x
}
return x
}