From f1c8cca50f723d8ab0f86b97b69aa474fe3dd337 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 13:10:11 +0400 Subject: [PATCH 1/2] docs(mcp): decide stateless profile context (#364) --- ...8-11-stateless-profile-context-decision.md | 122 +++++++ tests/prototypes/stateless-profile-context.ts | 322 ++++++++++++++++++ ...less-profile-context-docs-contract.test.ts | 28 ++ ...tateless-profile-context-prototype.test.ts | 128 +++++++ 4 files changed, 600 insertions(+) create mode 100644 docs/plans/2026-08-11-stateless-profile-context-decision.md create mode 100644 tests/prototypes/stateless-profile-context.ts create mode 100644 tests/stateless-profile-context-docs-contract.test.ts create mode 100644 tests/stateless-profile-context-prototype.test.ts diff --git a/docs/plans/2026-08-11-stateless-profile-context-decision.md b/docs/plans/2026-08-11-stateless-profile-context-decision.md new file mode 100644 index 00000000..b4e1e515 --- /dev/null +++ b/docs/plans/2026-08-11-stateless-profile-context-decision.md @@ -0,0 +1,122 @@ +# Stateless Profile Context Decision + +Status: Accepted for follow-up implementation; the executable model in `tests/prototypes` is non-shipping research. + +Issues: [#362](https://github.com/mohanagy/miftah/issues/362), [#364](https://github.com/mohanagy/miftah/issues/364), [#376](https://github.com/mohanagy/miftah/issues/376), [#377](https://github.com/mohanagy/miftah/issues/377) + +Specification sources: [MCP 2026-07-28 announcement](https://blog.modelcontextprotocol.io/posts/2026-07-28/), [SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575), and [SEP-2567](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2567) + +## Decision + +Modern stateless Miftah requests will not read or mutate an implicit active profile. An account-sensitive request will carry an explicit, opaque profile-context handle that was minted by Miftah and is visible to the model as request data. Miftah will validate that handle together with verified authentication context on every use before routing, policy, approval, OAuth, audit, or upstream work begins. + +The authenticated binding must include an unforgeable per-chat context claim in addition to issuer, subject, and audience. MCP `clientInfo` identifies client software and is not an authenticated or unique chat identity. An arbitrary HTTP header, request `_meta`, tool argument, or model-generated conversation identifier is also insufficient. + +The selected handle format is a short-lived, authenticated-encrypted token containing: + +- a version and deployment identifier; +- a random context identifier; +- the selected named profile; +- a keyed digest of issuer, subject, audience, and trusted chat context; +- issuance and expiry times. + +Every production instance for one deployment must share the active sealing-key epoch and a bounded revocation backend. Resolution fails closed if required key or revocation state is unavailable. Audit records use a separate keyed correlation derived from the internal identifier; the bearer itself is never logged, exported, diagnosed, or forwarded upstream. + +This keeps the one-connector, named-account experience for hosts that can provide the trusted chat binding. Until a host can do so, modern stateless mode must use an operator-locked/profile-scoped endpoint or require an explicit profile for each call. It must not claim chat-scoped switching. Legacy stdio and session-aware HTTP retain their current connection-bound behavior during the documented compatibility window. + +## Why this is necessary + +MCP 2026-07-28 removes `initialize`, `initialized`, and `Mcp-Session-Id`. Each request carries protocol and client metadata, and any request may land on any instance. The specification recommends explicit application handles rather than transport-hidden state. + +Miftah currently creates one complete runtime per Streamable HTTP session. That runtime owns more than transport state: + +| Current owner | State coupled to the session/runtime | Modern treatment | +| --- | --- | --- | +| `HttpServerHost` | `Mcp-Session-Id`, transport, idle timer, runtime admission and cleanup | Modern requests have no session record or sticky routing. Legacy records remain separate. | +| `ProfileManager` | active profile, selection revision/source, confirmation, runtime lock, and lease | Capture immutable selection from the verified handle for each request. No modern mutable active profile. | +| `MiftahServer` | approval session, profile confirmation proofs, active routing profile, Roots snapshot, subscriptions, catalog invalidation, and restart coordination | Make request-bound state explicit; move streaming/subscription behavior to modern protocol contracts; never infer a profile from prior calls. | +| `ApprovalStore` | pending/approved records and bearer bindings tied to a generated session ID | Bind approval/MRTR state to authenticated request/context IDs and the exact profile handle, operation, and principal. | +| OAuth and identity runtime | provider flows, identity cache, and persisted bindings constructed with the runtime | Keep issuer/principal/profile boundaries explicit; do not put OAuth codes, tokens, or secrets in the handle. | +| Upstream managers | process/session ownership, profile concurrency slots, health, and lifecycle listeners | Resolve the profile first, then acquire a profile-scoped upstream through deployment-safe lifecycle ownership. | +| Routing, policy, and audit | decisions derived from the captured profile and request evidence | Verify the handle before routing and record only safe profile/correlation metadata. | + +Protocol statelessness therefore does not mean Miftah can delete application state. It means that state cannot remain hidden behind a transport session. + +## Rejected alternatives + +### Mutable process or durable default + +Rejected. A switch in one chat would change later calls from another chat or instance. A default is safe only when configured as an operator lock, never as recovery for missing request context. + +### Keying by `clientInfo` + +Rejected. `clientInfo` names client software, can be shared by many chats and users, and is carried by the client. It cannot authenticate or isolate a conversation. + +### Sticky load balancing or an in-memory instance map + +Rejected for modern mode. It recreates protocol sessions operationally, loses state after instance failure, and violates the round-robin deployment goal. + +### Plain profile names or unvalidated routing headers + +Rejected as a substitute for context. Names and headers may inform an explicit request but do not prove the caller, chat, deployment, expiry, or revocation state. `Mcp-Method` and `Mcp-Name` must be checked against the JSON-RPC body and are not account credentials. + +### Principal-only handle binding + +Rejected. Two chats for the same authenticated subject would still be able to consume each other's handles. The binding must include a trusted per-chat claim. If the host cannot supply one, Miftah must expose the compatibility exception rather than weaken isolation. + +### Opaque random identifier with only instance-local storage + +Rejected. It cannot survive round-robin routing or an instance restart. A deployment-wide store is viable, but the selected sealed handle keeps ordinary resolution self-contained while the shared backend is limited to revocation/key-epoch safety. + +## Request and lifecycle contract + +1. The modern profile-selection operation authenticates the request and requires the trusted chat binding. +2. Miftah mints a short-lived handle for one named profile. The handle is a profile selector, not proof that an operation is authorized. +3. Every account-sensitive call carries the exact handle as a model-visible reserved argument or equivalent method parameter. Miftah strips it before forwarding upstream. +4. The receiving instance authenticates first, opens the handle, checks deployment/key epoch, compares the full authenticated binding in constant time, checks profile existence, expiry, and revocation, and only then enters routing and policy evaluation. +5. A switch mints a replacement handle. The prior handle is revoked only after the profile-transition audit commits; an audit failure leaves the old context usable and does not disclose the replacement. +6. Expiry is exact. Renewal requires the current valid handle and the same authenticated binding. Revocation is deployment-wide and bounded by the handle expiry. +7. Missing, malformed, tampered, mismatched, expired, revoked, or unavailable context returns a fixed safe error. No error includes the handle, decrypted payload, identity claims, or private account data. + +Replay of a valid handle within its bound chat is possible by design: it selects a profile but does not authorize or make an operation idempotent. Approvals, MRTR responses, destructive actions, and retried calls must retain their own exact request/operation bindings. + +## Executable prototype evidence + +`tests/stateless-profile-context-prototype.test.ts` exercises a deliberately non-exported model under `tests/prototypes`. The npm `files` allowlist excludes tests, so this code cannot enter the published package. + +The model proves: + +- work and personal chats for the same subject retain independent profile choices while requests alternate between two instances; +- either chat's handle fails under the other chat's authenticated context; +- cross-principal and cross-deployment use fails closed; +- expiry and deployment-wide revocation apply on every instance; +- the encrypted handle contains neither the profile nor subject in plaintext; +- returned results and fixed errors contain only a keyed audit correlation, never the capability bearer. + +The prototype does not prove production key custody, distributed-store availability, real host chat claims, packaged SDK interoperability, or schema integration. Those remain release gates. + +## Implementation follow-ups + +- [#376](https://github.com/mohanagy/miftah/issues/376): establish the verified issuer/subject/audience/chat binding and safe host fallback. +- [#377](https://github.com/mohanagy/miftah/issues/377): implement production sealing, key epochs, revocation, schema threading, request-scoped resolution, and legacy separation. +- [#363](https://github.com/mohanagy/miftah/issues/363): negotiate modern stateless and legacy session-aware protocol eras before selecting either runtime path. +- [#365](https://github.com/mohanagy/miftah/issues/365): validate standard MCP routing headers independently of profile-context resolution and make catalogs deterministic/cacheable. +- [#366](https://github.com/mohanagy/miftah/issues/366): bind MRTR confirmations and cancellation to the exact authenticated context, profile handle, and request. +- [#367](https://github.com/mohanagy/miftah/issues/367): preserve issuer-bound OAuth credentials and keep OAuth secrets outside handles. +- [#368](https://github.com/mohanagy/miftah/issues/368): publish exact host/version/transport evidence and the fallback boundary. + +## Required compatibility and security tests + +- Modern requests alternate instances with the same handle and never depend on `Mcp-Session-Id`. +- Two chat claims for one subject cannot resolve, revoke, renew, approve, or cancel each other's state. +- Issuer, subject, audience, chat, deployment, key epoch, profile, expiry, and revocation mismatches fail before upstream acquisition. +- Missing trusted chat identity does not fall back to `clientInfo`, defaults, prior selections, persisted state, or routing hints. +- Concurrent switch/call and revoke/call races are linearized around the audited transition. +- Tool, resource, and prompt catalogs do not vary with prior request state and remain scoped by current authentication/policy. +- Errors, logs, diagnostics, audit exports, crash output, and upstream arguments contain no handle. +- Existing stdio and supported legacy Streamable HTTP sessions retain their current selection, approval, lock, lease, OAuth, cancellation, subscription, and shutdown behavior. +- Packaged builds pass real modern and legacy client interoperability on Linux, macOS, and Windows before release claims change. + +## Stop rule + +Do not enable modern chat-scoped profile switching if a supported host lacks a trusted per-chat authentication claim, if any instance cannot validate the same key epoch/revocation state, or if cross-chat/adversarial packaged tests are incomplete. Ship the documented profile-scoped/locked fallback and keep the legacy path instead. diff --git a/tests/prototypes/stateless-profile-context.ts b/tests/prototypes/stateless-profile-context.ts new file mode 100644 index 00000000..09d53ded --- /dev/null +++ b/tests/prototypes/stateless-profile-context.ts @@ -0,0 +1,322 @@ +import { + createCipheriv, + createDecipheriv, + createHmac, + randomBytes, + timingSafeEqual +} from "node:crypto"; + +const handlePrefix = "mctx1"; +const maximumHandleLength = 4_096; +const maximumLifetimeMs = 15 * 60_000; +const initializationVectorBytes = 12; +const authenticationTagBytes = 16; +const base64UrlPattern = /^[A-Za-z0-9_-]+$/u; + +export interface AuthenticatedProfileContext { + /** Verified authorization-server issuer, never a client-supplied MCP metadata value. */ + readonly issuer: string; + /** Verified subject for the authenticated human or workload. */ + readonly subject: string; + /** Verified audience for this Miftah deployment. */ + readonly audience: string; + /** Unforgeable per-chat/request-context claim supplied by the authenticating host. */ + readonly contextId: string; +} + +interface ProfileContextPayload { + readonly version: 1; + readonly id: string; + readonly deploymentId: string; + readonly profile: string; + readonly binding: string; + readonly issuedAtMs: number; + readonly expiresAtMs: number; +} + +export type StatelessProfileContextErrorCode = + | "PROFILE_CONTEXT_EXPIRED" + | "PROFILE_CONTEXT_INVALID" + | "PROFILE_CONTEXT_REVOKED"; + +/** Fixed, non-sensitive failure returned by the executable design prototype. */ +export class StatelessProfileContextError extends Error { + constructor(readonly code: StatelessProfileContextErrorCode, message: string) { + super(message); + this.name = "StatelessProfileContextError"; + } +} + +export interface ProfileContextRevocations { + isRevoked(id: string, atMs: number): boolean; + revoke(id: string, expiresAtMs: number): void; +} + +/** Models the deployment-wide revocation backend required for immediate cross-instance revocation. */ +export class InMemoryProfileContextRevocations implements ProfileContextRevocations { + private readonly expirations = new Map(); + + isRevoked(id: string, atMs: number): boolean { + const expiresAtMs = this.expirations.get(id); + if (expiresAtMs === undefined) return false; + if (expiresAtMs <= atMs) { + this.expirations.delete(id); + return false; + } + return true; + } + + revoke(id: string, expiresAtMs: number): void { + this.expirations.set(id, expiresAtMs); + } +} + +export interface StatelessProfileContextPrototypeOptions { + readonly deploymentId: string; + readonly profiles: readonly string[]; + readonly encryptionKey: Buffer; + readonly bindingKey: Buffer; + readonly auditKey: Buffer; + readonly revocations: ProfileContextRevocations; + readonly now?: () => Date; +} + +export interface ResolvedProfileContext { + readonly profile: string; + /** Safe correlation derived from the internal identifier, never from the bearer itself. */ + readonly auditCorrelation: string; + readonly expiresAt: string; +} + +/** + * Non-shipping executable model for an explicit, authenticated, cross-instance profile handle. + * It deliberately has no mutable active-profile state. + */ +export class StatelessProfileContextPrototype { + private readonly deploymentId: string; + private readonly profiles: ReadonlySet; + private readonly encryptionKey: Buffer; + private readonly bindingKey: Buffer; + private readonly auditKey: Buffer; + private readonly revocations: ProfileContextRevocations; + private readonly now: () => Date; + + constructor(options: StatelessProfileContextPrototypeOptions) { + assertBoundedValue(options.deploymentId, "deployment ID"); + if (options.profiles.length === 0 || new Set(options.profiles).size !== options.profiles.length) { + throw new Error("Profiles must be a non-empty unique collection."); + } + for (const profile of options.profiles) assertBoundedValue(profile, "profile"); + assertKey(options.encryptionKey, "Encryption"); + assertKey(options.bindingKey, "Binding"); + assertKey(options.auditKey, "Audit"); + this.deploymentId = options.deploymentId; + this.profiles = new Set(options.profiles); + this.encryptionKey = Buffer.from(options.encryptionKey); + this.bindingKey = Buffer.from(options.bindingKey); + this.auditKey = Buffer.from(options.auditKey); + this.revocations = options.revocations; + this.now = options.now ?? (() => new Date()); + } + + mint(profile: string, authenticated: AuthenticatedProfileContext, lifetimeMs: number): string { + if (!this.profiles.has(profile)) throw invalidContext(); + assertAuthenticatedContext(authenticated); + if (!Number.isInteger(lifetimeMs) || lifetimeMs <= 0 || lifetimeMs > maximumLifetimeMs) { + throw new Error("Profile context lifetime must be a positive integer no greater than 15 minutes."); + } + const issuedAtMs = this.nowMs(); + const payload: ProfileContextPayload = { + version: 1, + id: randomBytes(16).toString("base64url"), + deploymentId: this.deploymentId, + profile, + binding: this.binding(authenticated), + issuedAtMs, + expiresAtMs: issuedAtMs + lifetimeMs + }; + return this.seal(payload); + } + + resolve(handle: string, authenticated: AuthenticatedProfileContext): ResolvedProfileContext { + const payload = this.resolvePayload(handle, authenticated); + return { + profile: payload.profile, + auditCorrelation: this.auditCorrelation(payload.id), + expiresAt: new Date(payload.expiresAtMs).toISOString() + }; + } + + revoke(handle: string, authenticated: AuthenticatedProfileContext): void { + const payload = this.resolvePayload(handle, authenticated); + this.revocations.revoke(payload.id, payload.expiresAtMs); + } + + private resolvePayload(handle: string, authenticated: AuthenticatedProfileContext): ProfileContextPayload { + assertAuthenticatedContext(authenticated); + const payload = this.open(handle); + if ( + payload.deploymentId !== this.deploymentId || + !this.profiles.has(payload.profile) || + !safeEqual(payload.binding, this.binding(authenticated)) + ) { + throw invalidContext(); + } + const nowMs = this.nowMs(); + if (payload.expiresAtMs <= nowMs) { + throw new StatelessProfileContextError("PROFILE_CONTEXT_EXPIRED", "Profile context has expired."); + } + if (this.revocations.isRevoked(payload.id, nowMs)) { + throw new StatelessProfileContextError("PROFILE_CONTEXT_REVOKED", "Profile context has been revoked."); + } + return payload; + } + + private seal(payload: ProfileContextPayload): string { + const initializationVector = randomBytes(initializationVectorBytes); + const cipher = createCipheriv("aes-256-gcm", this.encryptionKey, initializationVector, { + authTagLength: authenticationTagBytes + }); + cipher.setAAD(this.additionalAuthenticatedData()); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]); + const authenticationTag = cipher.getAuthTag(); + return [ + handlePrefix, + initializationVector.toString("base64url"), + ciphertext.toString("base64url"), + authenticationTag.toString("base64url") + ].join("."); + } + + private open(handle: string): ProfileContextPayload { + try { + if (handle.length === 0 || handle.length > maximumHandleLength) throw invalidContext(); + const parts = handle.split("."); + if (parts.length !== 4 || parts[0] !== handlePrefix) throw invalidContext(); + const initializationVector = decodePart(parts[1]!, initializationVectorBytes); + const ciphertext = decodePart(parts[2]!); + const authenticationTag = decodePart(parts[3]!, authenticationTagBytes); + const decipher = createDecipheriv("aes-256-gcm", this.encryptionKey, initializationVector, { + authTagLength: authenticationTagBytes + }); + decipher.setAAD(this.additionalAuthenticatedData()); + decipher.setAuthTag(authenticationTag); + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + return parsePayload(JSON.parse(plaintext) as unknown); + } catch (error) { + if (error instanceof StatelessProfileContextError) throw error; + throw invalidContext(); + } + } + + private binding(authenticated: AuthenticatedProfileContext): string { + return createHmac("sha256", this.bindingKey) + .update(lengthPrefixed([ + authenticated.issuer, + authenticated.subject, + authenticated.audience, + authenticated.contextId + ])) + .digest("base64url"); + } + + private auditCorrelation(id: string): string { + return `mctx_${createHmac("sha256", this.auditKey).update(id).digest("hex").slice(0, 16)}`; + } + + private additionalAuthenticatedData(): Buffer { + return Buffer.from(`miftah-profile-context\u0000${this.deploymentId}\u0000v1`, "utf8"); + } + + private nowMs(): number { + const value = this.now().getTime(); + if (!Number.isFinite(value)) throw new Error("Prototype clock returned an invalid time."); + return value; + } +} + +function parsePayload(value: unknown): ProfileContextPayload { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidContext(); + const payload = value as Record; + const keys = Object.keys(payload).sort(); + const expectedKeys = [ + "binding", + "deploymentId", + "expiresAtMs", + "id", + "issuedAtMs", + "profile", + "version" + ]; + if (keys.length !== expectedKeys.length || keys.some((key, index) => key !== expectedKeys[index])) { + throw invalidContext(); + } + if ( + payload.version !== 1 || + typeof payload.id !== "string" || + typeof payload.deploymentId !== "string" || + typeof payload.profile !== "string" || + typeof payload.binding !== "string" || + typeof payload.issuedAtMs !== "number" || + typeof payload.expiresAtMs !== "number" || + !Number.isSafeInteger(payload.issuedAtMs) || + !Number.isSafeInteger(payload.expiresAtMs) || + payload.expiresAtMs <= payload.issuedAtMs + ) { + throw invalidContext(); + } + return payload as unknown as ProfileContextPayload; +} + +function assertAuthenticatedContext(authenticated: AuthenticatedProfileContext): void { + assertBoundedValue(authenticated.issuer, "authenticated issuer"); + assertBoundedValue(authenticated.subject, "authenticated subject"); + assertBoundedValue(authenticated.audience, "authenticated audience"); + assertBoundedValue(authenticated.contextId, "authenticated context ID"); +} + +function assertBoundedValue(value: string, label: string): void { + if (value.length === 0 || value.length > 1_024 || hasControlCharacter(value)) { + throw new Error(`${label} must be a bounded printable value.`); + } +} + +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined || codePoint <= 0x1f || codePoint === 0x7f) return true; + } + return false; +} + +function assertKey(key: Buffer, label: string): void { + if (key.length !== 32) throw new Error(`${label} key must contain exactly 32 bytes.`); +} + +function decodePart(value: string, exactBytes?: number): Buffer { + if (value.length === 0 || !base64UrlPattern.test(value)) throw invalidContext(); + const decoded = Buffer.from(value, "base64url"); + if (decoded.length === 0 || (exactBytes !== undefined && decoded.length !== exactBytes)) throw invalidContext(); + return decoded; +} + +function safeEqual(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "utf8"); + const rightBuffer = Buffer.from(right, "utf8"); + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); +} + +function lengthPrefixed(values: readonly string[]): Buffer { + const parts: Buffer[] = []; + for (const value of values) { + const encoded = Buffer.from(value, "utf8"); + const length = Buffer.allocUnsafe(4); + length.writeUInt32BE(encoded.length); + parts.push(length, encoded); + } + return Buffer.concat(parts); +} + +function invalidContext(): StatelessProfileContextError { + return new StatelessProfileContextError("PROFILE_CONTEXT_INVALID", "Profile context is invalid."); +} diff --git a/tests/stateless-profile-context-docs-contract.test.ts b/tests/stateless-profile-context-docs-contract.test.ts new file mode 100644 index 00000000..ff9be2f1 --- /dev/null +++ b/tests/stateless-profile-context-docs-contract.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const decision = readFileSync( + new URL("../docs/plans/2026-08-11-stateless-profile-context-decision.md", import.meta.url), + "utf8" +); + +describe("stateless profile context decision", () => { + it("records the fail-closed decision and rejects hidden session substitutes", () => { + expect(decision).toContain("will not read or mutate an implicit active profile"); + expect(decision).toContain("unforgeable per-chat context claim"); + expect(decision).toContain("MCP `clientInfo`"); + expect(decision).toContain("### Mutable process or durable default"); + expect(decision).toContain("### Sticky load balancing or an in-memory instance map"); + expect(decision).toContain("must not claim chat-scoped switching"); + }); + + it("defines expiry, revocation, redaction, UX fallback, and implementation follow-ups", () => { + expect(decision).toContain("Revocation is deployment-wide"); + expect(decision).toContain("No error includes the handle"); + expect(decision).toContain("one-connector, named-account experience"); + expect(decision).toContain("[#376]"); + expect(decision).toContain("[#377]"); + expect(decision).toContain("## Required compatibility and security tests"); + expect(decision).toContain("## Stop rule"); + }); +}); diff --git a/tests/stateless-profile-context-prototype.test.ts b/tests/stateless-profile-context-prototype.test.ts new file mode 100644 index 00000000..0d07128b --- /dev/null +++ b/tests/stateless-profile-context-prototype.test.ts @@ -0,0 +1,128 @@ +import { randomBytes } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + InMemoryProfileContextRevocations, + StatelessProfileContextError, + StatelessProfileContextPrototype, + type AuthenticatedProfileContext +} from "./prototypes/stateless-profile-context.js"; + +const deploymentId = "miftah.example/deployment-a"; +const profiles = ["personal", "work"] as const; + +function authenticatedContext(contextId: string, subject = "user-123"): AuthenticatedProfileContext { + return { + issuer: "https://issuer.example", + subject, + audience: "miftah.example", + contextId + }; +} + +function instances(now: () => Date) { + const encryptionKey = randomBytes(32); + const bindingKey = randomBytes(32); + const auditKey = randomBytes(32); + const revocations = new InMemoryProfileContextRevocations(); + const options = { + deploymentId, + profiles, + encryptionKey, + bindingKey, + auditKey, + revocations, + now + }; + return { + first: new StatelessProfileContextPrototype(options), + second: new StatelessProfileContextPrototype(options), + revocations, + keys: { encryptionKey, bindingKey, auditKey } + }; +} + +describe("stateless profile context prototype", () => { + it("keeps two chats for one principal isolated even when requests alternate instances", () => { + const now = () => new Date("2026-08-11T08:00:00.000Z"); + const { first, second } = instances(now); + const workChat = authenticatedContext("chat-work"); + const personalChat = authenticatedContext("chat-personal"); + const workHandle = first.mint("work", workChat, 60_000); + const personalHandle = second.mint("personal", personalChat, 60_000); + + expect(second.resolve(workHandle, workChat).profile).toBe("work"); + expect(first.resolve(personalHandle, personalChat).profile).toBe("personal"); + expect(first.resolve(workHandle, workChat).profile).toBe("work"); + expect(second.resolve(personalHandle, personalChat).profile).toBe("personal"); + + expect(() => first.resolve(workHandle, personalChat)).toThrowError(StatelessProfileContextError); + expect(() => second.resolve(personalHandle, workChat)).toThrowError(StatelessProfileContextError); + }); + + it("binds every resolution to the authenticated principal and deployment", () => { + const now = () => new Date("2026-08-11T08:00:00.000Z"); + const { first, keys } = instances(now); + const owner = authenticatedContext("chat-work"); + const handle = first.mint("work", owner, 60_000); + + expect(() => first.resolve(handle, authenticatedContext("chat-work", "user-456"))).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" }) + ); + + const otherDeployment = new StatelessProfileContextPrototype({ + deploymentId: "miftah.example/deployment-b", + profiles, + encryptionKey: keys.encryptionKey, + bindingKey: keys.bindingKey, + auditKey: keys.auditKey, + revocations: new InMemoryProfileContextRevocations(), + now + }); + expect(() => otherDeployment.resolve(handle, owner)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" }) + ); + }); + + it("revokes across instances and expires at the exact boundary", () => { + let current = new Date("2026-08-11T08:00:00.000Z"); + const now = () => current; + const { first, second } = instances(now); + const context = authenticatedContext("chat-work"); + const revoked = first.mint("work", context, 60_000); + const expiring = first.mint("work", context, 10_000); + + second.revoke(revoked, context); + expect(() => first.resolve(revoked, context)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_REVOKED" }) + ); + + current = new Date("2026-08-11T08:00:10.000Z"); + expect(() => second.resolve(expiring, context)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_EXPIRED" }) + ); + }); + + it("never returns or reports the capability-bearing handle", () => { + const now = () => new Date("2026-08-11T08:00:00.000Z"); + const { first } = instances(now); + const context = authenticatedContext("chat-work"); + const handle = first.mint("work", context, 60_000); + const resolution = first.resolve(handle, context); + + expect(JSON.stringify(resolution)).not.toContain(handle); + expect(resolution.auditCorrelation).toMatch(/^mctx_[a-f0-9]{16}$/u); + expect(handle).not.toContain("work"); + expect(handle).not.toContain(context.subject); + + let failure: unknown; + try { + first.resolve(`${handle}tampered`, context); + } catch (error) { + failure = error; + } + expect(String(failure)).not.toContain(handle); + expect(failure).toEqual( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID", message: "Profile context is invalid." }) + ); + }); +}); From 3fd43438b9770d31901d8f27963ba63ca80fc313 Mon Sep 17 00:00:00 2001 From: mohammed naji Date: Tue, 11 Aug 2026 13:16:37 +0400 Subject: [PATCH 2/2] test(mcp): harden stateless context prototype --- ...8-11-stateless-profile-context-decision.md | 6 +- tests/prototypes/stateless-profile-context.ts | 54 ++++++++++------ ...less-profile-context-docs-contract.test.ts | 2 + ...tateless-profile-context-prototype.test.ts | 63 +++++++++++++++++++ 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/docs/plans/2026-08-11-stateless-profile-context-decision.md b/docs/plans/2026-08-11-stateless-profile-context-decision.md index b4e1e515..ad501fb3 100644 --- a/docs/plans/2026-08-11-stateless-profile-context-decision.md +++ b/docs/plans/2026-08-11-stateless-profile-context-decision.md @@ -14,7 +14,7 @@ The authenticated binding must include an unforgeable per-chat context claim in The selected handle format is a short-lived, authenticated-encrypted token containing: -- a version and deployment identifier; +- a version, deployment identifier, and authenticated key epoch; - a random context identifier; - the selected named profile; - a keyed digest of issuer, subject, audience, and trusted chat context; @@ -22,6 +22,8 @@ The selected handle format is a short-lived, authenticated-encrypted token conta Every production instance for one deployment must share the active sealing-key epoch and a bounded revocation backend. Resolution fails closed if required key or revocation state is unavailable. Audit records use a separate keyed correlation derived from the internal identifier; the bearer itself is never logged, exported, diagnosed, or forwarded upstream. +The non-secret envelope carries the version and key-epoch identifier so an instance can select the candidate key before opening the authenticated ciphertext. Both values, plus the deployment identifier, are authenticated as additional data. A deployment keyring has exactly one active epoch for minting and may retain explicitly configured previous epochs for resolution only. The overlap lasts no longer than the maximum handle lifetime plus bounded clock skew; after that window the previous key is removed and its remaining handles fail closed. Unknown, disabled, future, or malformed epochs are invalid. Rotation changes the active epoch atomically across instances: new handles use only the new epoch, while unexpired old handles resolve only during the declared overlap. Rollback to an older minting epoch is forbidden. + This keeps the one-connector, named-account experience for hosts that can provide the trusted chat binding. Until a host can do so, modern stateless mode must use an operator-locked/profile-scoped endpoint or require an explicit profile for each call. It must not claim chat-scoped switching. Legacy stdio and session-aware HTTP retain their current connection-bound behavior during the documented compatibility window. ## Why this is necessary @@ -73,7 +75,7 @@ Rejected. It cannot survive round-robin routing or an instance restart. A deploy 1. The modern profile-selection operation authenticates the request and requires the trusted chat binding. 2. Miftah mints a short-lived handle for one named profile. The handle is a profile selector, not proof that an operation is authorized. 3. Every account-sensitive call carries the exact handle as a model-visible reserved argument or equivalent method parameter. Miftah strips it before forwarding upstream. -4. The receiving instance authenticates first, opens the handle, checks deployment/key epoch, compares the full authenticated binding in constant time, checks profile existence, expiry, and revocation, and only then enters routing and policy evaluation. +4. The receiving instance authenticates first, reads the bounded non-secret version/epoch envelope, selects an enabled resolution-only or active key from the deployment keyring, authenticates and opens the ciphertext, checks deployment/key epoch, compares the full authenticated binding in constant time, checks profile existence, expiry, and revocation, and only then enters routing and policy evaluation. 5. A switch mints a replacement handle. The prior handle is revoked only after the profile-transition audit commits; an audit failure leaves the old context usable and does not disclose the replacement. 6. Expiry is exact. Renewal requires the current valid handle and the same authenticated binding. Revocation is deployment-wide and bounded by the handle expiry. 7. Missing, malformed, tampered, mismatched, expired, revoked, or unavailable context returns a fixed safe error. No error includes the handle, decrypted payload, identity claims, or private account data. diff --git a/tests/prototypes/stateless-profile-context.ts b/tests/prototypes/stateless-profile-context.ts index 09d53ded..9fe97949 100644 --- a/tests/prototypes/stateless-profile-context.ts +++ b/tests/prototypes/stateless-profile-context.ts @@ -148,28 +148,36 @@ export class StatelessProfileContextPrototype { } revoke(handle: string, authenticated: AuthenticatedProfileContext): void { - const payload = this.resolvePayload(handle, authenticated); - this.revocations.revoke(payload.id, payload.expiresAtMs); + try { + const payload = this.resolvePayload(handle, authenticated); + this.revocations.revoke(payload.id, payload.expiresAtMs); + } catch (error) { + throw normalizedContextError(error); + } } private resolvePayload(handle: string, authenticated: AuthenticatedProfileContext): ProfileContextPayload { - assertAuthenticatedContext(authenticated); - const payload = this.open(handle); - if ( - payload.deploymentId !== this.deploymentId || - !this.profiles.has(payload.profile) || - !safeEqual(payload.binding, this.binding(authenticated)) - ) { - throw invalidContext(); - } - const nowMs = this.nowMs(); - if (payload.expiresAtMs <= nowMs) { - throw new StatelessProfileContextError("PROFILE_CONTEXT_EXPIRED", "Profile context has expired."); - } - if (this.revocations.isRevoked(payload.id, nowMs)) { - throw new StatelessProfileContextError("PROFILE_CONTEXT_REVOKED", "Profile context has been revoked."); + try { + assertAuthenticatedContext(authenticated); + const payload = this.open(handle); + if ( + payload.deploymentId !== this.deploymentId || + !this.profiles.has(payload.profile) || + !safeEqual(payload.binding, this.binding(authenticated)) + ) { + throw invalidContext(); + } + const nowMs = this.nowMs(); + if (payload.expiresAtMs <= nowMs) { + throw new StatelessProfileContextError("PROFILE_CONTEXT_EXPIRED", "Profile context has expired."); + } + if (this.revocations.isRevoked(payload.id, nowMs)) { + throw new StatelessProfileContextError("PROFILE_CONTEXT_REVOKED", "Profile context has been revoked."); + } + return payload; + } catch (error) { + throw normalizedContextError(error); } - return payload; } private seal(payload: ProfileContextPayload): string { @@ -320,3 +328,13 @@ function lengthPrefixed(values: readonly string[]): Buffer { function invalidContext(): StatelessProfileContextError { return new StatelessProfileContextError("PROFILE_CONTEXT_INVALID", "Profile context is invalid."); } + +function normalizedContextError(error: unknown): StatelessProfileContextError { + if ( + error instanceof StatelessProfileContextError && + (error.code === "PROFILE_CONTEXT_EXPIRED" || error.code === "PROFILE_CONTEXT_REVOKED") + ) { + return error; + } + return invalidContext(); +} diff --git a/tests/stateless-profile-context-docs-contract.test.ts b/tests/stateless-profile-context-docs-contract.test.ts index ff9be2f1..d6bd8904 100644 --- a/tests/stateless-profile-context-docs-contract.test.ts +++ b/tests/stateless-profile-context-docs-contract.test.ts @@ -18,6 +18,8 @@ describe("stateless profile context decision", () => { it("defines expiry, revocation, redaction, UX fallback, and implementation follow-ups", () => { expect(decision).toContain("Revocation is deployment-wide"); + expect(decision).toContain("exactly one active epoch for minting"); + expect(decision).toContain("maximum handle lifetime plus bounded clock skew"); expect(decision).toContain("No error includes the handle"); expect(decision).toContain("one-connector, named-account experience"); expect(decision).toContain("[#376]"); diff --git a/tests/stateless-profile-context-prototype.test.ts b/tests/stateless-profile-context-prototype.test.ts index 0d07128b..de263f07 100644 --- a/tests/stateless-profile-context-prototype.test.ts +++ b/tests/stateless-profile-context-prototype.test.ts @@ -68,6 +68,12 @@ describe("stateless profile context prototype", () => { expect(() => first.resolve(handle, authenticatedContext("chat-work", "user-456"))).toThrowError( expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" }) ); + expect(() => first.resolve(handle, { ...owner, issuer: "https://other-issuer.example" })).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" }) + ); + expect(() => first.resolve(handle, { ...owner, audience: "other-miftah.example" })).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" }) + ); const otherDeployment = new StatelessProfileContextPrototype({ deploymentId: "miftah.example/deployment-b", @@ -102,6 +108,63 @@ describe("stateless profile context prototype", () => { ); }); + it("does not let another chat revoke a valid handle", () => { + const now = () => new Date("2026-08-11T08:00:00.000Z"); + const { first, second } = instances(now); + const owner = authenticatedContext("chat-work"); + const otherChat = authenticatedContext("chat-personal"); + const handle = first.mint("work", owner, 60_000); + + expect(() => second.revoke(handle, otherChat)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID" }) + ); + expect(first.resolve(handle, owner).profile).toBe("work"); + }); + + it("normalizes malformed authentication and revocation backend failures", () => { + const now = () => new Date("2026-08-11T08:00:00.000Z"); + const { first, keys } = instances(now); + const owner = authenticatedContext("chat-work"); + const handle = first.mint("work", owner, 60_000); + + expect(() => first.resolve(handle, undefined as unknown as AuthenticatedProfileContext)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID", message: "Profile context is invalid." }) + ); + + const unavailableReadRevocations = { + isRevoked: () => { + throw new Error("backend details"); + }, + revoke: () => undefined + }; + const unavailableRead = new StatelessProfileContextPrototype({ + deploymentId, + profiles, + ...keys, + revocations: unavailableReadRevocations, + now + }); + expect(() => unavailableRead.resolve(handle, owner)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID", message: "Profile context is invalid." }) + ); + + const unavailableWrite = new StatelessProfileContextPrototype({ + deploymentId, + profiles, + ...keys, + revocations: { + isRevoked: () => false, + revoke: () => { + throw new Error("backend details"); + } + }, + now + }); + expect(() => unavailableWrite.revoke(handle, owner)).toThrowError( + expect.objectContaining({ code: "PROFILE_CONTEXT_INVALID", message: "Profile context is invalid." }) + ); + }); + it("never returns or reports the capability-bearing handle", () => { const now = () => new Date("2026-08-11T08:00:00.000Z"); const { first } = instances(now);