fdkey 0.1.1

FDKEY verification primitives for MCP servers and HTTP backends — gate AI-agent access behind LLM-only puzzles.
Documentation
# `fdkey` (Rust SDK) — Architecture Reference

> **Purpose.** Cross-language verification primitives — same wire format and same per-session policy semantics as `@fdkey/mcp` (TypeScript) and `fdkey` (Python). The Rust MCP server ecosystem has multiple competing SDKs (`rmcp`, `mcp-server-rs`, `tower-mcp`) and no canonical Anthropic-blessed one, so this crate ships **primitives** rather than wrapping a single framework.
>
> **Last verified against:** `src/` as of 2026-05-09 (initial 0.1.0 release).

---

## § 1 — Top-level

What the crate ships:

- `Verifier` — the entry point. Bundles `JwtVerifier` + `VpsClient`.
- `JwtVerifier` — Ed25519 JWT verify against a cached well-known.
- `VpsClient``POST /v1/challenge`, `POST /v1/submit`. `client_type` is selectable so the same crate can serve MCP integrators (`"mcp"`) or REST integrators (`"rest"`).
- `WellKnownClient``HashMap<kid, DecodingKey>` cached for 1 hour, refreshes on cache-miss for a kid (mid-rotation).
- `guard::{can_call, mark_verified, consume_policy}` — pure per-session policy logic; identical state machine to TS / Python.
- Types: `FdkeyConfig`, `Policy`, `FdkeyContext`, `SessionState`, `VerifiedClaims`, `FdkeyError`.

What the crate does NOT ship: a single MCP-server adapter. The Rust MCP ecosystem is fragmented; integrators plug `Verifier` + `guard` into whichever server framework they use. The wire format, JWT shape, and policy semantics match the TS / Python SDKs exactly — any FDKEY-issued JWT verifies identically across languages.

---

## § 2 — Directory map

```
mcp-integration/sdks/rust/
├─ src/
│  ├─ lib.rs            — Public crate root. Re-exports + `Verifier` constructor
│  │                       (`new` for REST clients, `new_mcp` for MCP).
│  ├─ types.rs          — FdkeyConfig, Policy, SessionState, FdkeyContext,
│  │                       VerifiedClaims, FdkeyError. Plus Difficulty + FailMode enums.
│  ├─ guard.rs          — Pure policy evaluation. Mirrors guard.ts / guard.py
│  │                       byte-for-byte (modulo language).
│  ├─ jwt_verify.rs     — JwtVerifier + extract_bearer header parser.
│  │                       Uses jsonwebtoken's EdDSA support + 30s clock tolerance.
│  ├─ vps_client.rs     — reqwest-based async client. POST /v1/challenge,
│  │                       POST /v1/submit. ChallengeMeta forwarded as the
│  │                       same agent / integrator / tags blocks the TS SDK uses.
│  └─ well_known.rs     — HashMap<kid, DecodingKey> cache, 1h TTL, refresh-on-miss.
├─ tests/
│  └─ integration.rs    — wiremock-backed end-to-end: well-known spoof +
│                           Ed25519 sign-verify round-trip + extract_bearer +
│                           guard semantics for each policy variant.
├─ Cargo.toml           — name `fdkey`, version 0.1.0. Dependencies:
│                           reqwest (rustls-tls only — no native-tls dep),
│                           jsonwebtoken, tokio (sync + time + macros),
│                           serde + serde_json, thiserror.
├─ LICENSE              — MIT.
├─ README.md            — Install + usage examples (HTTP backend, fetch+submit,
│                           per-session policy gating, config reference).
└─ ARCHITECTURE.md      — This file.
```

---

## § 3 — Per-file detail

### `src/lib.rs`

**Purpose.** Crate entry point. Re-exports public API. Defines `Verifier` — the bundle most users want.

**Public exports.**
- `Verifier`, `Verifier::new(config) -> Result<Self>`, `Verifier::new_mcp(config)`.
- Module aliases `jwt::*` and `vps::*` for friendlier re-exports.
- Direct re-exports of all public types from `types.rs`.

**Default VPS URL** is `https://api.fdkey.com` — used when `FdkeyConfig.vps_url` is `None`.

---

### `src/types.rs`

`Policy` is a Rust enum (`OncePerSession`, `EachCall`, `EveryMinutes { minutes: u32 }`) — direct translation of the TS discriminated union. `FdkeyConfig` is `Default`-derived so callers can use `..Default::default()` to avoid spelling out every field. `FdkeyError` is `thiserror`-derived and covers the four failure modes: HTTP, JSON, JWT, and VPS-status-non-2xx.

The `FdkeyContext` struct is what an integrator-side handler reads. **`score: Option<f64>` and `tier: Option<String>` are first-class fields** for forward-compat with future capability scoring (today the value is binary 1.0 / 0.0).

---

### `src/guard.rs`

Pure functions over `&SessionState` / `&mut SessionState`. No I/O. The state machine (verified, fresh_verification_available, verified_at_ms) matches the TS and Python SDKs byte-for-byte. `now_ms()` reads `SystemTime::now()` once per call.

---

### `src/jwt_verify.rs`

`JwtVerifier::verify(token)` — `decode_header` → kid → `WellKnownClient.get_key(kid)` → `jsonwebtoken::decode` with `Validation::new(Algorithm::EdDSA)` and 30s leeway → returns `VerifiedJwt` (struct deserialized from claims). `validate_aud` is **disabled** because the SDK doesn't know the integrator's `vps_users.id` at verify time; the VPS already binds aud to the api_key that requested the challenge (defense in depth).

`extract_bearer(Option<&str>) -> Option<&str>` — case-insensitive parser for `Authorization` header values. Returns `None` on missing, empty, malformed, or non-Bearer values.

---

### `src/vps_client.rs`

`VpsClient` wraps a `reqwest::Client` (10s timeout, rustls-tls) bound to the integrator's API key. `fetch_challenge(meta)` POSTs `/v1/challenge` with the same body shape the TS SDK sends:

```json
{
  "difficulty": "medium",
  "client_type": "mcp" | "rest",
  "agent": { ... },         // optional, only included if populated
  "integrator": { ... },    // optional
  "tags": { ... }           // optional
}
```

`submit_answers(challenge_id, answers)` POSTs `/v1/submit`. Both methods return decoded `ChallengeResponse` / `SubmitResponse` structs.

**Errors.** Non-2xx VPS responses surface as `FdkeyError::VpsHttp { status, body }`. Network failures bubble up as `FdkeyError::Http`. Decode errors as `FdkeyError::Json`.

---

### `src/well_known.rs`

`WellKnownClient` holds an `Arc<Mutex<CacheState>>` where `CacheState` is `HashMap<kid, DecodingKey>` + `Option<Instant>` for TTL tracking. On cache miss, calls `_refresh()` once before returning. The lock is held only across the cache read + the refresh call — concurrent calls coalesce safely.

Ed25519 PEM keys are imported via `DecodingKey::from_ed_pem` (jsonwebtoken 9.3+).

---

## § 4 — Build requirements

### Toolchain

- **Rust 1.75+** (edition 2021, async fn in trait stable).
- **Windows-MSVC builds** require Visual Studio 2019 Build Tools or later (provides `link.exe`). On a fresh Windows machine without these installed, `cargo build` will fail at the link step with "link.exe not found" or — more confusingly — a GNU coreutils `link: extra operand` error if Git for Windows' `link` shadows the missing MSVC linker.
  - Fix: install Visual Studio Build Tools with the "Desktop development with C++" workload, OR switch to the GNU toolchain via `rustup default stable-x86_64-pc-windows-gnu`.
- **Linux / macOS / WSL**: works with the system C toolchain, no extra setup.

### Running tests

```bash
cargo test          # full integration tests (wiremock-backed)
cargo test --lib    # just unit tests
cargo build         # release build → target/release/libfdkey.rlib
cargo publish --dry-run    # validates package shape pre-publish
```

The integration tests in `tests/integration.rs` spin up a wiremock server, generate a one-shot Ed25519 keypair via `ed25519-dalek`, and round-trip a JWT through `Verifier::verify_token`. Tests touch the FDKEY VPS only via the wiremock — no network egress, no real api.fdkey.com calls.

---

## § 5 — Integrator obligations (security-relevant)

The TypeScript and Python sibling SDKs each enforce three protections in code that the Rust crate **does NOT enforce** because it ships primitives only. Integrators are responsible for honoring them. The README says this in user-facing terms; this section captures the rationale and the mitigation patterns for future maintainers.

### Obligation 1 — JWT must NOT reach the agent

The flow is:

```
agent → integrator's MCP server (or HTTP route)
         ↓ (server-to-server, integrator's API key)
       api.fdkey.com /v1/submit  →  { verified: true, jwt }
       JwtVerifier::verify(&jwt) → VerifiedJwt { score, tier, claims }
       integrator persists { verified_at, score, tier } in their session
       agent gets back: { verified: true, score, tier }   ← no JWT
```

If the integrator echoes the JWT to the agent (in a tool result body, an HTTP response, etc.), the agent now holds a bearer token that can be replayed against any other FDKEY-protected service within the JWT's lifetime (~5 min default). The crate cannot enforce this — `verifier.verify_token(&jwt)` is a pure function, and what the integrator does with the JWT after that call is opaque to us.

The TS reference at `mcp-integration/sdks/http/src/index.ts` (search for `processSubmit`) shows the canonical session-mediated implementation. Rust integrators should mirror the same shape.

### Obligation 2 — Stable, non-reusable session keys

A session map keyed on `*const SessionState`, `Box::into_raw` casts, or any other identity that can be reused after a session is dropped is unsafe. After the original session is gone, a new allocation can land on the same address; any state still cached under that pointer-as-key gets resurrected for the wrong tenant.

The Python sibling solves this with `_SessionKeyTracker` — a `WeakKeyDictionary[ServerSession, UUID]` that maps each live session to a fresh UUID and uses a `weakref.finalize` to evict the store entry the moment the session is gc'd. In Rust the borrow checker prevents most aliasing, but raw-pointer-keyed maps are still possible — and still wrong.

Recommended Rust pattern:

```rust
use uuid::Uuid;
use std::collections::HashMap;

struct SessionRegistry {
    sessions: HashMap<Uuid, SessionState>,
    // ... TTL + LRU; see Obligation 3
}

impl SessionRegistry {
    fn key_for_new_session() -> Uuid {
        Uuid::new_v4()
    }
}
```

The Uuid is monotonically unique per process. No reuse, no aliasing.

### Obligation 3 — Bounded session storage

A naive `HashMap<Uuid, SessionState>` grows forever as agents connect and drift away. The TS reference (`mcp-integration/sdks/http/src/session-store.ts`) ships an `InMemorySessionStore` with:

- **TTL** (1-hour idle): when a new session is inserted, sweep the head of the LRU map; if its `lastTouchedAt` is older than `idleTtlMs`, drop it. O(1) per insert.
- **Hard cap** (10 000 entries): when the map is at `maxSize`, force-evict the LRU head before insert. O(1) thanks to the JS Map's preserved-insertion-order; Rust's `LinkedHashMap` (or a `(VecDeque<Uuid>, HashMap<Uuid, _>)` pair) gives the same.

The crate doesn't ship this in Rust because every integrator has a different context (in-process vs Redis-backed vs Durable Objects). But every integrator needs SOMETHING here — an unbounded map IS a leak.

### Why the crate doesn't enforce these in code

Rust's MCP ecosystem is fragmented: `rmcp`, `mcp-server-rs`, `tower-mcp`, hand-rolled bytestream servers — each has a different framework shape, lifecycle model, and concurrency story. Wrapping any one of them in the crate would force the crate to track that framework's release cadence and create a permanent maintenance burden. Shipping primitives keeps the crate small (~500 LOC) and lets integrators bolt FDKEY onto whatever they're already running.

The trade-off is exactly the obligation list above. Future-you reading this in 2027 should understand that `@fdkey/http` is the canonical implementation; this crate provides the verified-correct primitives to build a Rust equivalent.

---

## § 6 — Wire-format compatibility with the other SDKs

**This is the rule:** every byte FDKEY's VPS sees from this crate must match what the TS SDK sends. If a TS SDK release changes the challenge body, the Rust SDK gets the same change in the same release — coordinated via [../typescript/ARCHITECTURE.md § 6 wire format](../typescript/ARCHITECTURE.md#-6--public-api-surface).

Currently aligned:
- `POST /v1/challenge` body: `{ difficulty, client_type, agent?, integrator?, tags? }`.
- `POST /v1/submit` body: `{ challenge_id, answers }`.
- JWT header: `{ alg: "EdDSA", kid: "..." }`.
- JWT payload: `{ sub, aud, iss, iat, nbf, exp, score, threshold, tier, puzzle_summary, ... }`.
- `/.well-known/fdkey.json` shape: `{ issuer, keys: [{ alg, kid, public_key_pem }], jwt_default_lifetime_seconds }`.

If the VPS adds a field, the Rust crate's `VerifiedClaims.extra: HashMap<String, Value>` catches it without a code change.

---

## § 7 — Maintenance protocol

> **Rule:** when you change `src/**` or `Cargo.toml`'s public surface, update this file.

Common changes:
- New field on `VerifiedClaims`? → `types.rs` + the corresponding TS / Python `FdkeyContext` for cross-language parity. README config table.
- New `Policy` variant? → `types.rs` + `guard.rs` (pattern match exhaustive). Same change in TS + Python guards.
- New `client_type` value? → `lib.rs` `Verifier::with_client_type`. Coordinate with `vps/src/routes/v1/challenge.ts:26` — the VPS-side zod enum is the source of truth.
- Bumped version? → `Cargo.toml` only. No code-side version constant in this crate.