Files
pulse/internal/agentexec/approval_grant.go
T
2026-04-23 23:37:25 +01:00

232 lines
8.9 KiB
Go

package agentexec
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
)
const (
approvalGrantVersion = 1
approvalGrantKeyPrefix = "pulse-agent-exec-approval-grant-v1"
approvalGrantSigPrefix = "hmac-sha256:"
DefaultApprovalGrantTTL = 2 * time.Minute
)
const (
ApprovalGrantRejectionUnknown = "unknown"
ApprovalGrantRejectionMissing = "missing"
ApprovalGrantRejectionKeyUnavailable = "key_unavailable"
ApprovalGrantRejectionVersionMismatch = "version_mismatch"
ApprovalGrantRejectionApprovalIDMismatch = "approval_id_mismatch"
ApprovalGrantRejectionRequestMismatch = "request_mismatch"
ApprovalGrantRejectionAgentMismatch = "agent_mismatch"
ApprovalGrantRejectionCommandHashMismatch = "command_hash_mismatch"
ApprovalGrantRejectionTargetTypeMismatch = "target_type_mismatch"
ApprovalGrantRejectionTargetIDMismatch = "target_id_mismatch"
ApprovalGrantRejectionExpired = "expired"
ApprovalGrantRejectionIssuedAtInvalid = "issued_at_invalid"
ApprovalGrantRejectionNonceMissing = "nonce_missing"
ApprovalGrantRejectionSignatureInvalid = "signature_invalid"
)
// ApprovalGrantVerificationError preserves a stable rejection reason for
// metrics and logs while keeping the human-readable verification error.
type ApprovalGrantVerificationError struct {
Reason string
Err error
}
func (e *ApprovalGrantVerificationError) Error() string {
if e == nil || e.Err == nil {
return "approval grant verification failed"
}
return e.Err.Error()
}
func (e *ApprovalGrantVerificationError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func approvalGrantVerificationError(reason, message string, args ...interface{}) error {
if reason == "" {
reason = ApprovalGrantRejectionUnknown
}
return &ApprovalGrantVerificationError{
Reason: reason,
Err: fmt.Errorf(message, args...),
}
}
// ApprovalGrantVerificationReason returns the stable metric/log label for a
// verification error. Non-verification errors collapse to "unknown".
func ApprovalGrantVerificationReason(err error) string {
if err == nil {
return ""
}
var verificationErr *ApprovalGrantVerificationError
if errors.As(err, &verificationErr) && strings.TrimSpace(verificationErr.Reason) != "" {
return verificationErr.Reason
}
return ApprovalGrantRejectionUnknown
}
// CommandApprovalGrant is a server-issued, token-bound grant that lets an agent
// verify an approval-gated command before executing it.
type CommandApprovalGrant struct {
Version int `json:"version"`
ApprovalID string `json:"approval_id"`
RequestID string `json:"request_id"`
AgentID string `json:"agent_id"`
CommandHash string `json:"command_hash"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id,omitempty"`
IssuedAt time.Time `json:"issued_at"`
ExpiresAt time.Time `json:"expires_at"`
Nonce string `json:"nonce"`
Signature string `json:"signature"`
}
// DeriveApprovalGrantKey returns the in-memory signing key derived from the
// runtime agent token. Callers should keep the raw token out of long-lived state.
func DeriveApprovalGrantKey(token string) []byte {
token = strings.TrimSpace(token)
if token == "" {
return nil
}
sum := sha256.Sum256([]byte(approvalGrantKeyPrefix + "\x00" + token))
return sum[:]
}
// ComputeCommandApprovalHash computes the canonical command+target hash used by
// approval grants and the approval store.
func ComputeCommandApprovalHash(command, targetType, targetID string) string {
h := sha256.New()
h.Write([]byte(command))
h.Write([]byte("|"))
h.Write([]byte(strings.ToLower(strings.TrimSpace(targetType))))
h.Write([]byte("|"))
h.Write([]byte(strings.TrimSpace(targetID)))
return hex.EncodeToString(h.Sum(nil))
}
func NewCommandApprovalGrant(key []byte, agentID string, cmd ExecuteCommandPayload, now time.Time, ttl time.Duration) (*CommandApprovalGrant, error) {
if len(key) == 0 {
return nil, errors.New("approval grant key is unavailable")
}
if strings.TrimSpace(cmd.ApprovalID) == "" {
return nil, errors.New("approval id is required")
}
if ttl <= 0 {
ttl = DefaultApprovalGrantTTL
}
nonce, err := newApprovalGrantNonce()
if err != nil {
return nil, err
}
grant := &CommandApprovalGrant{
Version: approvalGrantVersion,
ApprovalID: strings.TrimSpace(cmd.ApprovalID),
RequestID: strings.TrimSpace(cmd.RequestID),
AgentID: strings.TrimSpace(agentID),
CommandHash: ComputeCommandApprovalHash(cmd.Command, cmd.TargetType, cmd.TargetID),
TargetType: strings.ToLower(strings.TrimSpace(cmd.TargetType)),
TargetID: strings.TrimSpace(cmd.TargetID),
IssuedAt: now.UTC(),
ExpiresAt: now.Add(ttl).UTC(),
Nonce: nonce,
}
grant.Signature = signApprovalGrant(key, grant)
return grant, nil
}
func VerifyCommandApprovalGrant(token string, agentID string, cmd ExecuteCommandPayload, now time.Time) error {
if cmd.ApprovalGrant == nil {
return approvalGrantVerificationError(ApprovalGrantRejectionMissing, "approval grant is required")
}
key := DeriveApprovalGrantKey(token)
return VerifyCommandApprovalGrantWithKey(key, agentID, cmd, now)
}
func VerifyCommandApprovalGrantWithKey(key []byte, agentID string, cmd ExecuteCommandPayload, now time.Time) error {
grant := cmd.ApprovalGrant
if grant == nil {
return approvalGrantVerificationError(ApprovalGrantRejectionMissing, "approval grant is required")
}
if len(key) == 0 {
return approvalGrantVerificationError(ApprovalGrantRejectionKeyUnavailable, "approval grant key is unavailable")
}
if grant.Version != approvalGrantVersion {
return approvalGrantVerificationError(ApprovalGrantRejectionVersionMismatch, "unsupported approval grant version %d", grant.Version)
}
if strings.TrimSpace(grant.ApprovalID) == "" || strings.TrimSpace(grant.ApprovalID) != strings.TrimSpace(cmd.ApprovalID) {
return approvalGrantVerificationError(ApprovalGrantRejectionApprovalIDMismatch, "approval grant id does not match command")
}
if strings.TrimSpace(grant.RequestID) == "" || strings.TrimSpace(grant.RequestID) != strings.TrimSpace(cmd.RequestID) {
return approvalGrantVerificationError(ApprovalGrantRejectionRequestMismatch, "approval grant request does not match command")
}
if strings.TrimSpace(grant.AgentID) == "" || strings.TrimSpace(grant.AgentID) != strings.TrimSpace(agentID) {
return approvalGrantVerificationError(ApprovalGrantRejectionAgentMismatch, "approval grant agent does not match command")
}
if strings.TrimSpace(grant.CommandHash) != ComputeCommandApprovalHash(cmd.Command, cmd.TargetType, cmd.TargetID) {
return approvalGrantVerificationError(ApprovalGrantRejectionCommandHashMismatch, "approval grant command hash does not match command")
}
if strings.ToLower(strings.TrimSpace(grant.TargetType)) != strings.ToLower(strings.TrimSpace(cmd.TargetType)) {
return approvalGrantVerificationError(ApprovalGrantRejectionTargetTypeMismatch, "approval grant target type does not match command")
}
if strings.TrimSpace(grant.TargetID) != strings.TrimSpace(cmd.TargetID) {
return approvalGrantVerificationError(ApprovalGrantRejectionTargetIDMismatch, "approval grant target id does not match command")
}
if grant.ExpiresAt.IsZero() || now.UTC().After(grant.ExpiresAt.UTC()) {
return approvalGrantVerificationError(ApprovalGrantRejectionExpired, "approval grant has expired")
}
if grant.IssuedAt.IsZero() || grant.IssuedAt.UTC().After(now.UTC().Add(30*time.Second)) {
return approvalGrantVerificationError(ApprovalGrantRejectionIssuedAtInvalid, "approval grant issued_at is invalid")
}
if strings.TrimSpace(grant.Nonce) == "" {
return approvalGrantVerificationError(ApprovalGrantRejectionNonceMissing, "approval grant nonce is required")
}
if !hmac.Equal([]byte(strings.TrimSpace(grant.Signature)), []byte(signApprovalGrant(key, grant))) {
return approvalGrantVerificationError(ApprovalGrantRejectionSignatureInvalid, "approval grant signature is invalid")
}
return nil
}
func signApprovalGrant(key []byte, grant *CommandApprovalGrant) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(approvalGrantCanonicalString(grant)))
return approvalGrantSigPrefix + base64.StdEncoding.EncodeToString(mac.Sum(nil))
}
func approvalGrantCanonicalString(grant *CommandApprovalGrant) string {
return strings.Join([]string{
fmt.Sprintf("%d", grant.Version),
strings.TrimSpace(grant.ApprovalID),
strings.TrimSpace(grant.RequestID),
strings.TrimSpace(grant.AgentID),
strings.TrimSpace(grant.CommandHash),
strings.ToLower(strings.TrimSpace(grant.TargetType)),
strings.TrimSpace(grant.TargetID),
grant.IssuedAt.UTC().Format(time.RFC3339Nano),
grant.ExpiresAt.UTC().Format(time.RFC3339Nano),
strings.TrimSpace(grant.Nonce),
}, "\n")
}
func newApprovalGrantNonce() (string, error) {
var raw [16]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", fmt.Errorf("generate approval grant nonce: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
}