P1 (part 1): move skilltools core -> tool/ (clean, verbatim)
executus CI / test (push) Successful in 36s
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>
This commit is contained in:
+121
@@ -0,0 +1,121 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user