Skip to content

Comply with peer design-review outcomes (H3 Consent-Bound Completion, H2 read-only, H1 derivation, M2 drop device grant) - #32

Merged
aterga merged 5 commits into
mainfrom
claude/mcp-spec-compliance-9202ka
Jul 5, 2026
Merged

aterga merged 5 commits into
mainfrom
claude/mcp-spec-compliance-9202ka

Conversation

@aterga

@aterga aterga commented Jul 4, 2026 •

Copy link
Copy Markdown
Collaborator

What & why

Implements the changes required by the IC MCP server design-review outcomes (companion to the "Design & Gotchas" doc, reviewed against II PR dfinity/internet-identity#4086). Three high-severity items needed server code; one was a decision; the rest are factual doc/copy corrections.

Changes

H3 — Close the split-browser authorization-code injection (account takeover, required)

The state in the II connect link is echoed back to the client, so it can't prove the browser finishing /oauth/finish is the one that started /oauth/authorize — nor the one that consented at II. That enables a split-browser session-fixation takeover: an attacker (open DCR + own redirect_uri/PKCE) initiates the flow, phishes the II link to a victim who trusts the origin, and after the victim consents, the attacker completes the flow and redeems the code as the victim.

This went through two review rounds:

  1. An initial initiator cookie was implemented, then an adversarial review (2 lenses + verifier) confirmed it was only a partial mitigation — the attacker is the initiator, so it holds the cookie.
  2. A follow-up design, Consent-Bound Completion, was peer-reviewed (incl. a 13-agent red-team that found zero token-yielding breaks) and revised to rev2 incorporating the feedback. This PR implements rev2.

Implemented (Consent-Bound Completion): /oauth/finish mints a code only when the browser proves it is both:

  1. the initiator — the sid cookie (HttpOnly; Secure; SameSite=Lax, scoped to the instance's …/oauth path) set at /oauth/authorize; and
  2. the consenter — a one-time finish_secret (256-bit CSPRNG) minted at the single-use key request and disclosed only in that response (embedded in finish_url's query);

plus proven registration (a signed mcp_get_accounts = Ok), not a bare completion POST.

Because the single-use key request delivers both the finish_secret and the public_key a victim would register, the party that can register as the victim is exactly the party that gets the secret — and sid pins the finisher to the initiator. So the two proofs co-reside in one browser only in the legitimate same-browser flow. Closes the split-browser injection for all transports incl. loopback.

Load-bearing invariants (design's P1–P3/P5/P6), all enforced + tested:

  • Atomic single-use connect_state consume (compare-and-set): first key request claims the PA (stores H(finish_secret)); any later/racing one 403s (prevents TOCTOU double-consume).
  • finish_secret in the query (not path, so path-only logs don't capture it), stored hashed, constant-time compared; /oauth/finish + retry page send Referrer-Policy: no-referrer (no Referer leak).
  • registered proven by grant_is_live (signed call), never by the unauthenticated completion POST (now a latency hint for expiry/permissions only).
  • Propagation-retry page threads finish_secret across reloads, consumes only on success.

Companion control (out of scope, documented): the same-browser variant (a victim driven to a hosted attacker redirect_uri) needs hosted-redirect allow-listing; loopback/native are safe either way. "H3 fully closed" = Consent-Bound Completion + that allow-list. Also assumes II's frontend issues exactly one key request per connect (a retry fails as "restart", never a takeover — the safe direction).

H2 — Handle read-only sessions (silent failure for the common user, required)

II's consent screen defaults to read-only (opt-out), giving permissions = "queries" per-app delegations the IC rejects for update calls at ingress — making the management surface inert under the default session.

  • Parse the completion POST's permissions: "queries" | "all" (§0) to learn the level at connect; unrecognized/missing ⇒ unknown ⇒ falls through to the ingress-rejection fallback.
  • Gate every management (update) tool with require_write — actionable "reconnect with read-only off" instead of an opaque IC rejection. The genuine query tool (cycles_balance) stays ungated.
  • Reflect a read-only session in get_principal output.

H1 — Stop claiming exact browser-principal parity (silent wrong principal)

  • H1a (code): target_origin normalizes to a bare https://<host> (strips :443, path, query, fragment, trailing slash) before the gateway remap.
  • H1b (copy): tool descriptions, get_info instructions, and README describe derivation as domain-based and give the custom-derivation-origin (/.well-known/ii-alternative-origins) fallback hint.

M2 — Drop the RFC 8628 device grant (decision)

Removed the routes, handlers/structs, AS metadata (grant_types_supported = ["authorization_code"]), and the register intersection.

Doc corrections (low severity)

"synthetic" → "the anchor's current (user-controllable) default at the origin"; refresh tokens noted as a deliberate non-goal; README auth section rewritten.

Testing

  • cargo build clean (pre-existing macro warning only); cargo test — 41/41 (network-dependent discover/skills tests are occasionally flaky offline; all deterministic tests stable).
  • New tests: Consent-Bound Completion — secret helpers (uniqueness/entropy/constant-time), single-use key request (first mints + claims; replay 403s; secret in query not path), completion-POST-does-not-materialize (P3); plus H2 read-only gating incl. unknown-value fallback; H1a target_origin normalization; binding-cookie extraction.
  • Security reasoning cross-checked by two workflows: the original H3 adversarial review (confirmed the cookie-only gap) and a 13-agent red-team of Consent-Bound Completion (zero real breaks; its two flagged items — atomicity and single-use-retry — are handled by P1 as implemented).
  • Rebased onto current main after Add /mcp-prod — a second instance against production Internet Identity (id.ai) #31's squash-merge; the diff is only this change set.

Notes

  • The §-numbered doc corrections target the standalone design doc (a separately-delivered PDF, not in-repo); equivalents are applied to the README and code comments.

🤖 Generated with Claude Code

https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym

claude added 2 commits July 4, 2026 21:46
Implement the required changes from the IC MCP server design-review:

H3 (account takeover): bind the OAuth browser session across
/oauth/authorize -> /oauth/finish with an unguessable HttpOnly; Secure;
SameSite=Lax cookie scoped to the instance's /oauth path. finish now
requires a matching cookie before minting the code, defeating the
session-fixation takeover where an attacker phishes their own II connect
link to a victim who trusts the origin.

H2 (read-only sessions): parse the completion POST's new `permissions`
field ("queries" | "all") to learn the session access level at connect;
gate every management (update) tool with require_write, returning an
actionable "reconnect with read-only off" message instead of an opaque
IC ingress rejection; surface a read-only session in get_principal. The
genuine query tool (cycles_balance) stays ungated. Unknown level falls
back to the ingress-error path.

H1a: normalize target_origin to a bare https://<host> (strip :443, path,
query, fragment, trailing slash) before the gateway remap, so a stray
port/path no longer derives a different principal.

H1b: stop claiming exact browser-principal parity in tool descriptions,
instructions and README; describe derivation as domain-based and add the
custom derivation-origin (ii-alternative-origins) fallback hint.

M2: drop the RFC 8628 device grant entirely — routes, handlers, structs,
AS metadata grant_types_supported, and the register intersection.

Doc corrections: replace "synthetic" with "the anchor's current
(user-controllable) default", note refresh tokens as a deliberate
non-goal, and rewrite the README auth section accordingly.

Add tests for target_origin normalization, read-only gating, and the
binding-cookie extraction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
An adversarial review found the browser-binding cookie does not fully
close the session-fixation takeover it targets: because /oauth/authorize
requires no auth, the attacker can be the flow initiator and thus holds
the cookie. After phishing only the II link to a victim who consents
(registering the session key under the victim's anchor), the attacker
completes /oauth/finish themselves with their own cookie and redeems the
code as the victim. The cookie only stops the zero-click variant (the
victim's browser passively delivering the code); consent happens
cross-origin at II keyed by the shared state, so the server can't tie
"who receives the code" to "who consented".

Keep the cookie as defense-in-depth (it eliminates the zero-click
variant) but correct the overstated "closes the takeover" claims in the
module docs, the AuthzPending::cookie field, the authorize/finish
comments, and the README to describe it accurately as a partial
mitigation, with the residual risk and the note that a complete fix
needs an II-side control identifying the client (not just the origin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements the IC MCP server design-review outcomes by hardening the OAuth/II connect flow, making read-only sessions explicit/actionable, correcting principal derivation behavior/copy, and removing the unused device grant.

Changes:

  • Added /oauth/authorize → /oauth/finish browser-session binding via an HttpOnly/Secure/SameSite=Lax cookie; removed RFC 8628 device grant support and updated AS metadata/routes accordingly.
  • Introduced read-only session detection (permissions) and gated management/update tools with an actionable “reconnect with read-only off” message; surfaced read-only state in get_principal.
  • Normalized target_origin derivation inputs (strip :443, path/query/fragment, etc.) and updated README/tool copy to reflect domain-based derivation and limitations.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/management.rs Adds require_write gating for management/update calls (including canister_status).
src/main.rs Updates tool descriptions and surfaces read-only session status in get_principal.
src/identities.rs Tracks session access level (read_only) from completion permissions; adds gating helper and origin normalization.
src/auth.rs Adds browser-binding cookie check for /oauth/finish; removes device grant; updates connect callback to record permissions.
README.md Updates docs for domain-based derivation, browser-binding cookie (and residual risk), read-only sessions, and removal of device grant.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/identities.rs Resolved
Comment thread src/main.rs Resolved
claude added 2 commits July 4, 2026 22:39
set_permissions treated any non-"queries" value as full access
(Some(false)). For an unrecognized/future permissions value that
wrongly asserts "known writable" instead of leaving the level unknown,
which — once the deferred delegation-probe fallback keys on None — would
suppress the fallback and re-surface opaque ingress errors. Only accept
the known vocabulary ("queries" => read-only, "all" => full); leave the
level None (unknown) otherwise, with a warning log. Add a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
…tion)

Replace the cookie-only partial mitigation with the reviewed-and-approved
Consent-Bound Completion design, which closes the split-browser
authorization-code injection for all transports (including loopback).

/oauth/finish now mints a code only when the requesting browser proves it
is BOTH:
  1. the initiator — the `sid` cookie set at /oauth/authorize; and
  2. the consenter — a one-time `finish_secret` (256-bit CSPRNG) minted at
     the key request and disclosed only in that response (embedded in
     finish_url's query), so only the browser that drove the II handshake
     holds it;
plus proven registration (a signed mcp_get_accounts returning Ok), not a
bare completion POST.

Because the single-use key request delivers both the finish_secret and the
public_key a victim would register, the party that can register as the
victim is exactly the party that gets the secret, and the sid cookie pins
the finisher to the initiator — so the two proofs co-reside in one browser
only in the legitimate same-browser flow.

Load-bearing invariants (per the design's P1–P3, P5, P6):
- The key request is a single ATOMIC compare-and-set on connect_state:
  the first claims the PA (stores H(finish_secret)); any later/racing key
  request 403s. Prevents the TOCTOU double-consume.
- finish_secret rides in the query (not a path segment) so path-only
  access logs don't capture it; /oauth/finish and the propagation-retry
  page send Referrer-Policy: no-referrer so it can't leak via Referer;
  stored hashed (SHA-256), constant-time compared.
- `registered` is proven by grant_is_live (signed mcp_get_accounts), never
  by the unauthenticated completion POST (now a latency hint for
  expiration/permissions only).
- The propagation-retry page threads finish_secret across reloads and
  consumes it only on success.

The same-browser variant (a victim driven to a hosted attacker
redirect_uri) is out of scope and needs hosted-redirect allow-listing;
loopback/native are safe either way. Documented in the module docs + README.

Add tests: secret helpers (uniqueness/entropy/constant-time), single-use
key request (first mints + claims, replay 403s, secret in query not path),
and completion-POST-does-not-materialize (P3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
@aterga aterga changed the title Comply with peer design-review outcomes (H3 session binding, H2 read-only, H1 derivation, M2 drop device grant) Comply with peer design-review outcomes (H3 Consent-Bound Completion, H2 read-only, H1 derivation, M2 drop device grant) Jul 5, 2026
@aterga
aterga requested a review from Copilot July 5, 2026 10:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment thread src/auth.rs Resolved
Comment thread README.md Resolved
Comment thread src/main.rs Resolved
Observability (requested follow-up): count and warn-log key requests that
hit a still-valid but already-consumed connect_state — i.e. a *repeat*
key request. Under strict single-use (P1) these are ~zero; a sustained
rise means II's frontend regressed to re-issuing the key request (which
strict single-use turns into failed connects — a benign-but-breaking
availability regression, never a takeover), while an isolated hit is a
stray replay/probe. Surfaced as `repeat_key_requests` on /version and
warn-logged with the instance name, so a silent regression is caught
without log scraping. Distinguishes the consumed-repeat case from the
ordinary unknown/expired 403 via a KeyClaim enum.

Also addresses Copilot review of the Consent-Bound Completion commit:
- The `sid` cookie's `Secure` attribute is now conditional on an https
  PUBLIC_URL. A `Secure` cookie is dropped by browsers over plain HTTP, so
  the unconditional flag broke the initiator check for local
  `http://localhost` development; production (https) is unchanged.
- log_request's comment now notes `/oauth/finish`'s one-time `?fs=`
  (finish_secret) is kept out of logs by the never-log-query rule (P2).
- README H2 wording corrected: a missing/unrecognized `permissions` value
  leaves the level unknown (not "full access") and falls through to the
  IC ingress-rejection fallback, matching the implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014T9N8USDfNK5yzznPGg7Ym
@aterga
aterga marked this pull request as ready for review July 5, 2026 10:41
Copilot AI review requested due to automatic review settings July 5, 2026 10:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@aterga
aterga merged commit 013089a into main Jul 5, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants