// 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= // X-Mort-Timestamp: // // 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=" 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=" 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 }