Skip to content

feat: otp-display-grant#84

Draft
lwin-kyaw wants to merge 8 commits into
mainfrom
feat/otp-display-grant
Draft

feat: otp-display-grant#84
lwin-kyaw wants to merge 8 commits into
mainfrom
feat/otp-display-grant

Conversation

@lwin-kyaw

@lwin-kyaw lwin-kyaw commented Jun 23, 2026

Copy link
Copy Markdown

Description

Implements an opt-in strict untrusted connection flow that defers wallet OTP display until the dapp explicitly grants it for the accepted handshake offer. The default untrusted flow is unchanged.

ADDED

  • Protocol (packages/core)
    • SessionRequest.capabilities.otpDisplayGrant
    • HandshakeOfferPayload.otpDisplayGrantRequired
    • OtpDisplayGrant message type ({ type: "otp-display-grant" })
    • Error codes: OTP_DISPLAY_GRANT_REQUIRED, OTP_DISPLAY_GRANT_TIMEOUT
  • Dapp client (packages/dapp-client)
    • DappConnectOptions.requireOtpDisplayGrant — advertises capability on the session request when mode: "untrusted"
    • Strict-path validation: rejects offers missing otpDisplayGrantRequired
    • Sends encrypted otp-display-grant on the handshake channel after accepting the offer
    • Sets wallet public key from the offer before sending the grant (so sendMessage can encrypt to the accepted wallet)
  • Wallet client (packages/wallet-client)
    • When capabilities.otpDisplayGrant is present on the session request: send handshake-offer with otpDisplayGrantRequired: true, wait for otp-display-grant, then emit display_otp
    • Routes inbound otp-display-grant during CONNECTING to otp_display_grant_received
  • Docs
    • docs/otp-display-grant-plan.md — design, compatibility matrix, security notes
    • docs/otp-display-grant-implementation-plan.md — phased implementation checklist
  • Tests
    • Unit tests for strict/legacy paths in both untrusted handlers
    • Client integration tests for capability advertisement and grant routing
    • E2E tests in apps/integration-tests for strict connect, compatibility matrix rows, and display_otp timing

CHANGED

  • Legacy untrusted flow behavior is preserved when requireOtpDisplayGrant is omitted or false.

Issue

In the current untrusted flow, the wallet displays the OTP before the dapp has accepted a specific handshake-offer. In a same-room scenario, an attacker can scan the same QR code, front-run the real wallet with an attacker-controlled offer, and potentially cause the dapp to bind to the attacker's session if the user enters an OTP matching that offer.

Solution

Strict mode is opt-in:

await dappClient.connect({
  mode: "untrusted",
  requireOtpDisplayGrant: true,
});

When enabled:

  1. Wallet generates OTP but does not emit display_otp yet.
  2. Wallet sends handshake-offer with otpDisplayGrantRequired: true on the handshake channel.
  3. Dapp validates the offer, sets the wallet public key from the offer, and sends encrypted otp-display-grant on the handshake channel.
  4. Wallet receives the grant and only then emits display_otp.
  5. User enters OTP; dapp verifies and sends handshake-ack on the session channel.
  6. Both sides persist and finalize.

The grant is encrypted to the accepted offer's wallet public key. A front-running attacker may still cause timeout/DoS, but the legitimate wallet should not display an OTP for an offer the dapp did not accept.

Design note: otp-display-grant is sent on the handshake channel (not the session channel). handshake-ack remains on the session channel and still means "OTP verified; finalize connection."

Compatibility matrix

Pair Result
Old dapp + old wallet Legacy flow (unchanged)
Old dapp + new wallet Legacy flow (unchanged)
New dapp without flag + old wallet Legacy flow (unchanged)
New dapp with requireOtpDisplayGrant: true + old wallet Fails with OTP_DISPLAY_GRANT_REQUIRED
New dapp with requireOtpDisplayGrant: true + new wallet Strict flow

Strict mode does not silently fall back.

Strict flow sequence

Handshake channel: handshake-offer, otp-display-grant
Session channel: handshake-ack, then application traffic

  1. Dapp creates SessionRequest with capabilities.otpDisplayGrant = true.
  2. Wallet scans the request.
  3. Wallet generates OTP and deadline but does not emit display_otp.
  4. Wallet sends handshake-offer with otpDisplayGrantRequired: true on the handshake channel.
  5. Dapp receives the offer.
  6. If strict mode is required and the offer lacks otpDisplayGrantRequired, dapp rejects.
  7. Dapp sets wallet public key from the offer on the pending session.
  8. Dapp sends encrypted otp-display-grant on the handshake channel.
  9. Wallet receives the grant and emits display_otp.
  10. User enters OTP into the dapp.
  11. Dapp verifies OTP, creates the secure session, and sends handshake-ack on the session channel.
  12. Both clients persist and finalize.
sequenceDiagram
	participant U as User
	participant D as Dapp Client
	participant R as Relay
	participant W as Wallet Client

	U->>D: Start untrusted connect
	D->>D: Create SessionRequest<br/>requireOtpDisplayGrant=true
	D-->>U: Show QR code
	U->>W: Scan QR code
	W->>W: Generate OTP<br/>do not display yet
	W->>R: handshake-offer (handshake channel)
	R->>D: handshake-offer
	D->>D: Validate strict offer support
	D->>R: otp-display-grant (handshake channel)
	R->>W: otp-display-grant
	W-->>U: Display OTP
	U->>D: Enter OTP
	D->>D: Verify OTP
	D->>R: handshake-ack (session channel)
	R->>W: handshake-ack
	D->>D: Persist session
	W->>W: Persist session
Loading

Test plan

  • Unit: dapp strict happy path, strict rejection, legacy unchanged, OTP edge cases in strict mode
  • Unit: wallet deferred display_otp, grant timeout, legacy regression, full strict happy path
  • Integration: session request capability when flag set; wallet handleMessage routes otp-display-grant
  • E2E (apps/integration-tests, requires local Centrifugo relay):
    • connectClientsStrict() helper
    • Strict dapp + strict wallet: connect + bidirectional messaging
    • Strict dapp + legacy wallet simulation: OTP_DISPLAY_GRANT_REQUIRED
    • Legacy dapp + strict-capable wallet: legacy flow unchanged
    • display_otp fires after dapp receives handshake offer in strict mode

Run locally:

docker compose -f backend/docker-compose.yml up -d
yarn test:unit
yarn test:integration

Follow-ups (out of scope for this PR)

  • apps/web-demo toggle for requireOtpDisplayGrant
  • Update docs/02-connection-flow.md with strict-flow subsection

Checklist

  • Tests are included if applicable
  • Any added code is fully documented

@lwin-kyaw lwin-kyaw changed the title chore: init otp-display-grant plan feat: otp-display-grant Jul 21, 2026
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.

1 participant