Documentation
¶
Overview ¶
Package confirmation implements server-issued HMAC confirmation tokens for high-risk tool calls as specified by docs/adr/0018-risk-class-confirmation-tokens.md (Q1 option B).
The contract: a high-risk tool call (RiskBilling | RiskAdmin | RiskPermissionChange | RiskExternalSideEffect | RiskDestructive) arriving with dry_run:true returns a stateless HMAC token bound to the tool name, the argument fingerprint (with dry_run and confirmation_token excluded), the risk class bitmask, the principal's tenant/subject/session if present, and a server-side expiry. A non-dry-run call to the same tool must include the returned token; the verifier recomputes the binding and rejects mismatches, expirations, and tampering. This gate is independent from the policy gate — policy decides whether the tool is callable at all; this gate decides whether the caller has previewed the effect first.
Layout:
- Config carries the HMAC secret, TTL, and enable flag.
- Signer wraps Mint/Verify; Config.Signer() returns one.
- TokenClaims is the on-wire payload (compact JSON, base64url + "." + base64url(HMAC(...))).
- BuildArgumentFingerprint stripping is in fingerprint.go.
The package is stdlib-only — no new module dependencies.
Index ¶
Constants ¶
const ( // MinTokenTTL is the lower bound on the configured token lifetime. // Anything shorter would make the dry-run-then-execute UX impossibly // race-prone (the agent's round trip plus network latency). MinTokenTTL = time.Minute // MaxTokenTTL is the upper bound. Confirmation tokens are a "you // previewed this recently" signal — letting them live for hours // would erode the value of the dry-run-first flow. MaxTokenTTL = time.Hour )
const DefaultTTL = 10 * time.Minute
DefaultTTL is the token expiry window when Config.TTL is unset.
const EnvVarReplayProtection = "CLOCKIFY_CONFIRMATION_REPLAY_PROTECTION"
EnvVarReplayProtection controls nonce replay tracking. Empty defaults to enabled for hosted profiles and disabled for local/self-hosted profiles.
const EnvVarTokenSecret = "CLOCKIFY_CONFIRMATION_TOKEN_SECRET"
EnvVarTokenSecret is the optional explicit secret. Hex- or base64-encoded; >=32 raw bytes after decode.
const EnvVarTokenTTL = "CLOCKIFY_CONFIRMATION_TOKEN_TTL"
EnvVarTokenTTL is the token lifetime. Parsed by time.ParseDuration; clamped to [1m,1h].
const EnvVarTokensEnabled = "CLOCKIFY_CONFIRMATION_TOKENS"
EnvVarTokensEnabled is the on/off knob for the confirmation gate.
const MinSecretBytes = 32
MinSecretBytes is the minimum raw-byte length of the HMAC secret the signer accepts. 32 bytes (256 bits) is the SHA-256 block-fit minimum; rejecting shorter secrets prevents accidental misuse of a short literal.
const TokenVersion = 1
TokenVersion is the on-wire version byte. Older tokens decoded with a different version are rejected so a future binding-format change can roll forward without ambiguity.
Variables ¶
var ( // ErrTokenMalformed signals a token whose on-wire shape is wrong // (missing "." separator, undecodable base64, undecodable JSON). ErrTokenMalformed = errors.New("confirmation token malformed") // ErrTokenSignatureMismatch signals a token whose HMAC does not // match the configured secret. Includes wrong-secret and // payload-tampering cases. ErrTokenSignatureMismatch = errors.New("confirmation token signature mismatch") // ErrTokenExpired signals a token whose exp is at or before // time.Now (per the Signer's clock). ErrTokenExpired = errors.New("confirmation token expired") // ErrTokenBindingMismatch signals a token whose embedded claims // disagree with the verifier's expected binding (different tool // name, different args fingerprint, different // tenant/subject/session, different risk class). ErrTokenBindingMismatch = errors.New("confirmation token binding mismatch") // ErrTokenVersionUnsupported signals a token whose on-wire // version byte differs from TokenVersion. Rolling forward // requires accepting older versions explicitly; the default is // to reject. ErrTokenVersionUnsupported = errors.New("confirmation token version unsupported") // ErrSecretTooShort signals a Config whose secret has fewer than // MinSecretBytes raw bytes. Returned by Signer construction. ErrSecretTooShort = errors.New("confirmation token secret too short") )
Sentinel errors. Verify returns one of these so callers (the enforcement gate, the audit recorder) can branch on the failure mode without parsing strings.
var ErrHostedSecretRequired = errors.New("CLOCKIFY_CONFIRMATION_TOKEN_SECRET is required for hosted profiles (multi-replica deployments need a shared HMAC secret)")
ErrHostedSecretRequired is returned by ConfigFromEnv when the caller indicates a hosted/multi-replica deployment but CLOCKIFY_CONFIRMATION_TOKEN_SECRET is unset. A process-local random secret cannot survive a cross-replica request, so falling back silently would produce a confirmation_invalid error on every pod hop — worse than declining to boot.
var ErrTokenReplayed = errors.New("confirmation token replayed")
ErrTokenReplayed is returned when a previously accepted confirmation-token nonce is presented again before its expiry.
var FingerprintExcludedKeys = []string{
"dry_run",
"confirmation_token",
}
FingerprintExcludedKeys is the set of argument keys stripped before fingerprinting. dry_run flips the request between preview and execution and would otherwise invalidate every token between mint and verify. confirmation_token is the binding itself — including it in its own fingerprint would be circular.
Functions ¶
func BuildArgumentFingerprint ¶
BuildArgumentFingerprint returns a base64url-encoded SHA-256 hash of the canonical JSON serialization of args, with the FingerprintExcludedKeys removed.
Canonical JSON: encoding/json sorts map keys alphabetically by default, including in nested map[string]any. Slices preserve order (intentional — argument order matters for tool semantics like tag_ids). Numbers, booleans, strings, and nil round-trip deterministically. The args map is assumed to be JSON-decodable (the MCP tools/call dispatch hands us a map[string]any decoded from the request body), so json.Marshal can never fail here in practice; we still return a stable empty-string sentinel on the unlikely error path so the caller's "binding mismatch" path is reached instead of a panic.
func NewRandomSecret ¶
NewRandomSecret generates a cryptographically random secret of MinSecretBytes bytes. Used by the runtime fallback when an operator has not configured CLOCKIFY_CONFIRMATION_TOKEN_SECRET.
Types ¶
type Config ¶
type Config struct {
// Enabled toggles the gate. When false the enforcement layer
// should skip Verify entirely and never call Mint. The package
// itself does not inspect this — it's a contract for callers.
Enabled bool
// Secret is the HMAC-SHA256 key. Required when Enabled. Use
// crypto/rand for production; a short literal is rejected at
// Signer construction.
Secret []byte
// TTL is the lifetime applied to Mint'd tokens. Zero defaults to
// DefaultTTL.
TTL time.Duration
// ReplayProtection enables single-use nonce tracking at the
// enforcement layer. The signer stays stateless; callers wire a
// ReplayStore appropriate for the runtime profile.
ReplayProtection bool
// Ephemeral marks a secret that was generated at startup rather
// than supplied by the operator. The enforcement layer logs a
// warning so operators on hosted multi-replica deployments see
// that cross-replica verification will not survive a pod hop.
Ephemeral bool
}
Config carries the operator-facing knobs. Build a Signer with Config.Signer() to mint/verify tokens; a zero-Config Signer rejects every verification with ErrSecretTooShort so misuse is loud.
type LoadResult ¶
LoadResult bundles the parsed Config with operator-facing diagnostic notes the runtime should surface as startup log lines. Returning notes alongside the Config keeps the package free of logging dependencies; the caller decides whether to log, expose in /metrics, or echo to a doctor command.
func ConfigFromEnv ¶
func ConfigFromEnv(hosted bool) (LoadResult, error)
ConfigFromEnv reads the three CLOCKIFY_CONFIRMATION_TOKEN_* env vars into a Config plus diagnostic notes. The `hosted` flag drives the hosted-secret-required policy: hosted profiles refuse to boot without an explicit shared secret because process-local randomness cannot satisfy the stateless verification contract across replicas.
Errors surface as boot-time config errors so a misconfigured hosted deployment fails closed instead of silently falling back to an ephemeral secret nobody else can verify against.
type MemoryReplayStore ¶
type MemoryReplayStore struct {
// contains filtered or unexported fields
}
MemoryReplayStore is the stdio/local replay backend. It is process-local, bounded by token TTL, and concurrency-safe.
func NewMemoryReplayStore ¶
func NewMemoryReplayStore() *MemoryReplayStore
NewMemoryReplayStore builds an empty in-memory replay cache.
func (*MemoryReplayStore) UseConfirmationNonce ¶
func (s *MemoryReplayStore) UseConfirmationNonce(ctx context.Context, rec ReplayRecord) error
UseConfirmationNonce records rec unless the same nonce binding is already present and unexpired.
type MintInput ¶
type MintInput struct {
Tool string
ArgsHash string
RiskClass uint32
Tenant string
Subject string
Session string
}
MintInput names the contract for a Mint call. Tool, ArgsHash, and RiskClass are required; Tenant/Subject/Session are populated when the call has a principal in context.
type ReplayRecord ¶
type ReplayRecord struct {
Nonce string `json:"nonce"`
Tool string `json:"tool"`
ArgsHash string `json:"args_hash"`
Tenant string `json:"tenant,omitempty"`
Subject string `json:"subject,omitempty"`
Session string `json:"session,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
UsedAt time.Time `json:"used_at"`
}
ReplayRecord is the durable fingerprint stored after token verification. ArgsHash is included for audit/debug correlation; uniqueness is enforced on tenant, subject, session, tool, and nonce.
type ReplayStore ¶
type ReplayStore interface {
UseConfirmationNonce(context.Context, ReplayRecord) error
}
ReplayStore atomically records a nonce on first use and returns ErrTokenReplayed for subsequent uses before expiry.
type Signer ¶
type Signer struct {
// contains filtered or unexported fields
}
Signer mints and verifies confirmation tokens with a fixed secret + TTL. It does not own the Enabled flag — that belongs to Config and is enforced by the call site.
func NewSigner ¶
NewSigner builds a Signer from a Config, returning ErrSecretTooShort when the secret is too short to be safe. Pass a nil time-source to use time.Now; tests inject their own.
func (*Signer) Mint ¶
Mint returns a compact token of the form `b64url(claims).b64url(mac)` bound to the input. ExpiresAt is the wall-clock instant the token becomes invalid; surface it to the client so the client knows when to re-issue the dry-run.
func (*Signer) TTL ¶
TTL returns the configured token lifetime; useful for logging the `confirmation_expires_at` field on a dry-run preview.
func (*Signer) Verify ¶
func (s *Signer) Verify(in VerifyInput) (TokenClaims, error)
Verify checks a token against the expected binding. The contract is "constant-time on signature; field-by-field on binding". Returns the decoded claims when verification succeeds so callers can record the nonce for replay tracking if they wish; today we treat tokens as single-use-implicit via TTL but emit the nonce for diagnostics.
type TokenClaims ¶
type TokenClaims struct {
Version int `json:"v"`
Tool string `json:"tool"`
ArgsHash string `json:"args"`
RiskClass uint32 `json:"risk"`
Tenant string `json:"tenant,omitempty"`
Subject string `json:"subject,omitempty"`
Session string `json:"session,omitempty"`
Expires int64 `json:"exp"`
Nonce string `json:"n"`
}
TokenClaims is the JSON payload signed by Mint and re-derived by Verify. All fields are lowercase + short so the on-wire form is compact.
type VerifyInput ¶
type VerifyInput struct {
Tool string
ArgsHash string
RiskClass uint32
Tenant string
Subject string
Session string
Token string
}
VerifyInput names the contract for a Verify call. The verifier receives the expected binding and the on-wire token; mismatch in any field produces ErrTokenBindingMismatch (so the caller can log a single audit outcome without enumerating which field drifted).