Files
pad/internal/server/claim_codes.go
T
xarmian aec67e202e feat(mcp): workspace.create + workspace.claim actions + claim-code mechanics (TASK-1521) (#582)
Phase B for PLAN-1519. Adds two MCP actions so agents can bring a
workspace into an OAuth connection without re-auth — the agent-first
onboarding story IDEA-1517 §1 set out to fix.

pad_workspace.action: create
- New action on the shared catalog (stdio + cloud both pick it up).
- POSTs to /api/v1/workspaces; handler auto-adds the new workspace to
  the calling OAuth connection's allow-list (added_by='agent-create')
  when the grant carries may_create_workspaces=true. Phase A wired the
  oauth_connection_workspaces table this writes to. PAT / CLI-session
  callers fall through silently (no request_id → no side effect).
- Backed by a new non-interactive `pad workspace create <name>` Cobra
  command for the stdio MCP shell-out path. `pad workspace init`
  remains the guided human flow.

pad_workspace.action: claim
- New POST /api/v1/oauth/claim endpoint redeems a 6-digit stateless
  HMAC code minted from (user_id, workspace_id, 5-min time bucket)
  with a sliding 5–10 minute lifetime. Constant-time compare. Code
  format derived per IDEA-1517 §4.
- Verifies workspace membership before code (privilege-escalation
  guard); uniform 404 envelope so the endpoint can't be used to probe
  existence vs. membership.
- Side effect inserts a row in oauth_connection_workspaces with
  added_by='claim'. Idempotent — re-claiming returns 200 with
  already_added=true.
- 412 connection_not_persisted when the OAuth grant predates Phase C
  (no oauth_connections row); 412 claim_disabled when the deployment
  hasn't wired SetClaimSecret.
- `pad workspace claim <code> --workspace <slug>` Cobra command backs
  the stdio MCP shell-out.

MCP server instructions
- Appended IDEA-1517 §5 paragraph teaching agents the claim flow as
  a peer top-level section. Same string lands universally on every
  MCP handshake response (stdio + cloud both read instructions.md).

Tests
- 10 claim-code unit tests: determinism, zero-pad, length-prefix
  collision guard, current/previous bucket accept, aged-out reject,
  wrong-everything rejects, short-secret fails closed.
- 7 handler tests: 412 when secret disabled, 400/404/401 vocabulary,
  PAT caller note path, 412 connection_not_persisted, idempotent
  insert.
- 5 MCP-catalog tests: actions registered, schema params advertised,
  description mentions both actions, route mappers produce correct
  HTTP shape, routeTable carries the entries.
- Bumped the existing read-only catalog bijection + fixture-input
  fixtures so the new actions resolve cleanly.

Parent: PLAN-1519.
2026-05-18 00:43:46 -04:00

145 lines
6.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"fmt"
"time"
)
// Stateless 6-digit claim codes (PLAN-1519 / TASK-1521 / IDEA-1517 §4).
//
// A claim code lets a user grant an existing OAuth connection access to
// one specific workspace they own/belong to. The user generates the
// code in the web UI (Phase E "Connect project" modal), reads it to
// their agent, and the agent calls `pad_workspace.action: claim` —
// which calls POST /api/v1/oauth/claim with {workspace, code}.
//
// **Why stateless.** Generating + storing + GC'ing per-code rows would
// pollute the schema and require background sweeps. Instead we
// HMAC-derive the code from (user_id, workspace_id, 5-min time bucket)
// using a server-stable secret. Server re-derives on claim and
// constant-time compares. Verifier checks the current AND previous
// bucket so the effective lifetime is 510 minutes (sliding window) —
// long enough for the user to paste the prompt, short enough that a
// snoop with the code has limited replay surface.
//
// **Threat model.** An attacker who knows the secret can mint codes
// for any (user, workspace) pair — same posture as anyone who can
// mint fosite tokens, which uses the same secret material. An attacker
// who DOESN'T know the secret can't fabricate codes; they can only
// observe ones in flight (5-10 minute window) AND must also control an
// OAuth connection belonging to the same user to redeem (the claim
// handler binds redemption to the calling grant's owner — see
// handleOAuthClaim).
//
// **Code format.** Six decimal digits, zero-padded. Truncated from
// HMAC-SHA256(secret, payload) modulo 10^6. Birthday collisions are
// irrelevant — verification re-derives + compares for a specific
// (user, workspace) tuple, not against a global pool — so the small
// digit count just controls how guessable a fresh code is. Without
// rate limiting, brute force is 10^6 attempts on the claim endpoint;
// rate limiting at the /mcp gate (TASK-959) caps that in practice.
// If 6 digits ever proves too narrow, bump to 8 — the same derive +
// modulus logic applies.
const (
// claimCodeDigits is how many decimal digits the code carries.
// 6 is the IDEA-1517 §4 spec. Changing it is a wire-format change
// — the modal text + agent NL parser both reference "6-digit."
claimCodeDigits = 6
// claimCodeModulus is 10^claimCodeDigits, used to truncate the
// HMAC output to digit count.
claimCodeModulus = 1_000_000
// claimBucketSeconds is the granularity of the time component
// going into the HMAC. Locked to 5 minutes per IDEA-1517 §4:
// "Bucket size: hardcoded 5 minutes." Combined with the
// current+previous bucket check below this yields a sliding
// 510 minute effective lifetime.
claimBucketSeconds = 300
)
// DeriveClaimCode produces the 6-digit code for the given (user,
// workspace) at the given wall-clock time. Exported so the Phase E
// "Connect project" modal handler can render the same code the claim
// path will verify.
//
// secret must be at least 16 bytes of cryptographically-strong material
// — production wires the 32-byte encryption key from the deployment
// config. Shorter secrets degrade to "obfuscation" rather than HMAC
// strength; the constructor in SetClaimSecret rejects them.
//
// Returns "000000""999999" zero-padded so the wire format is uniform
// (avoids an agent stripping leading zeros when echoing the code back).
func DeriveClaimCode(secret []byte, userID, workspaceID string, at time.Time) string {
bucket := at.UTC().Unix() / claimBucketSeconds
return deriveClaimCodeForBucket(secret, userID, workspaceID, bucket)
}
// deriveClaimCodeForBucket is the inner derive — split out so
// VerifyClaimCode can derive both the current and previous bucket
// without re-computing the bucket math twice.
func deriveClaimCodeForBucket(secret []byte, userID, workspaceID string, bucket int64) string {
mac := hmac.New(sha256.New, secret)
// Length-prefix each component so two distinct (user, workspace)
// pairs that happen to concatenate to the same byte string (e.g.
// userID="abc" + workspaceID="def" vs. userID="abcdef" +
// workspaceID="") can't collide on the same code. Belt-and-
// suspenders — UUIDs don't realistically alias, but a future
// schema change to numeric IDs could re-introduce the risk.
writeLenPrefixed(mac, []byte(userID))
writeLenPrefixed(mac, []byte(workspaceID))
var bucketBytes [8]byte
binary.BigEndian.PutUint64(bucketBytes[:], uint64(bucket))
mac.Write(bucketBytes[:])
sum := mac.Sum(nil)
// Take the first 8 bytes as an unsigned int, then mod down to
// the digit count. Using the full 32-byte hash would be wasteful
// (we throw away 24 bytes either way); 8 bytes gives plenty of
// entropy before truncation.
n := binary.BigEndian.Uint64(sum[:8]) % claimCodeModulus
return fmt.Sprintf("%0*d", claimCodeDigits, n)
}
// writeLenPrefixed writes a 4-byte big-endian length followed by the
// raw bytes. Cheap collision-resistant separator for HMAC inputs.
func writeLenPrefixed(w interface{ Write([]byte) (int, error) }, b []byte) {
var lenBytes [4]byte
binary.BigEndian.PutUint32(lenBytes[:], uint32(len(b)))
w.Write(lenBytes[:])
w.Write(b)
}
// VerifyClaimCode returns true if code matches the derived code for
// (userID, workspaceID) at the current OR previous time bucket — the
// sliding 510 minute lifetime described in IDEA-1517 §4.
//
// Constant-time compare prevents timing oracles from leaking which of
// the two derivations failed (which would reduce the effective lifetime
// signal to 5 minutes rather than 10).
//
// Empty code, empty userID, or empty workspaceID always return false —
// no need to even derive in those cases.
func VerifyClaimCode(secret []byte, userID, workspaceID, code string, at time.Time) bool {
if code == "" || userID == "" || workspaceID == "" || len(secret) < 16 {
return false
}
if len(code) != claimCodeDigits {
return false
}
bucket := at.UTC().Unix() / claimBucketSeconds
current := deriveClaimCodeForBucket(secret, userID, workspaceID, bucket)
previous := deriveClaimCodeForBucket(secret, userID, workspaceID, bucket-1)
codeBytes := []byte(code)
// ConstantTimeCompare returns 1 on match. OR the two results so
// either matching bucket passes; subtle's compare doesn't panic
// on equal-length operands so length-mismatch isn't a concern
// here (we already gated on claimCodeDigits above).
return subtle.ConstantTimeCompare(codeBytes, []byte(current)) == 1 ||
subtle.ConstantTimeCompare(codeBytes, []byte(previous)) == 1
}