dc28b63ad8
executus CI / test (push) Successful in 36s
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>
122 lines
4.2 KiB
Go
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
|
|
}
|