mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
aec67e202e
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.
145 lines
6.5 KiB
Go
145 lines
6.5 KiB
Go
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 5–10 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
|
||
// 5–10 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 5–10 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
|
||
}
|