Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions apps/integration-tests/src/end-to-end.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,31 @@ async function connectClients(dappClient: DappClient, walletClient: WalletClient
await Promise.all([dappConnectPromise, walletConnectPromise]);
}

// Helper for strict untrusted flow (dapp advertises otpDisplayGrant capability).
async function connectClientsStrict(dappClient: DappClient, walletClient: WalletClient) {
const sessionRequestPromise = new Promise<SessionRequest>((resolve) => {
dappClient.on("session_request", resolve);
});
const dappConnectPromise = dappClient.connect({ mode: "untrusted", requireOtpDisplayGrant: true });

const sessionRequest = await sessionRequestPromise;
t.expect(sessionRequest.capabilities?.otpDisplayGrant).toBe(true);

const otpPromise = new Promise<string>((resolve) => {
walletClient.once("display_otp", (otp) => resolve(otp));
});

const otpRequiredPromise = new Promise<OtpRequiredPayload>((resolve) => {
dappClient.once("otp_required", resolve);
});

const walletConnectPromise = walletClient.connect({ sessionRequest });
const [otp, otpPayload] = await Promise.all([otpPromise, otpRequiredPromise]);
await otpPayload.submit(otp);

await Promise.all([dappConnectPromise, walletConnectPromise]);
}

// Helper to assert that a promise does NOT resolve within a given time
async function assertPromiseNotResolve(promise: Promise<unknown>, timeout: number, message: string) {
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(message)), timeout));
Expand Down Expand Up @@ -241,6 +266,113 @@ t.describe("E2E Integration Test", () => {
await resumedDappClient.sendRequest(testPayload);
await t.expect(messagePromise).resolves.toEqual(testPayload);
});

t.describe("otp-display-grant strict flow", () => {
t.test("should complete strict untrusted connection and allow bidirectional messaging", async () => {
await connectClientsStrict(dappClient, walletClient);

t.expect((dappClient as any).state).toBe("CONNECTED");
t.expect((walletClient as any).state).toBe("CONNECTED");

const requestPayload = { method: "eth_accounts_strict" };
const messageFromDappPromise = new Promise((resolve) => walletClient.on("message", resolve));
await dappClient.sendRequest(requestPayload);
await t.expect(messageFromDappPromise).resolves.toEqual(requestPayload);

const responsePayload = { result: ["0x789..."] };
const messageFromWalletPromise = new Promise((resolve) => dappClient.on("message", resolve));
await walletClient.sendResponse(responsePayload);
await t.expect(messageFromWalletPromise).resolves.toEqual(responsePayload);
});

t.test("should reject strict dapp when wallet simulates legacy offer (no otpDisplayGrantRequired)", async () => {
const sessionRequestPromise = new Promise<SessionRequest>((resolve) => {
dappClient.on("session_request", resolve);
});
const dappConnectPromise = dappClient.connect({ mode: "untrusted", requireOtpDisplayGrant: true });
const sessionRequest = await sessionRequestPromise;

// Simulate an older wallet that ignores capabilities.otpDisplayGrant on the session request.
const legacyWalletRequest = { ...sessionRequest, capabilities: undefined };
const walletConnectPromise = walletClient.connect({ sessionRequest: legacyWalletRequest });
void walletConnectPromise.catch(() => {});

const displayOtpPromise = new Promise<void>((resolve) => {
walletClient.once("display_otp", () => resolve());
});

await t.expect(dappConnectPromise).rejects.toMatchObject({ code: ErrorCode.OTP_DISPLAY_GRANT_REQUIRED });
await t.expect(displayOtpPromise).resolves.toBeUndefined();
await assertPromiseNotResolve(walletConnectPromise, 1000, "Wallet should not complete strict-incompatible connection");
});

t.test("should keep legacy untrusted flow when dapp does not require otp display grant", async () => {
let displayOtpFired = false;
let displayOtpBeforeOffer = false;

const sessionRequestPromise = new Promise<SessionRequest>((resolve) => {
dappClient.on("session_request", resolve);
});
const dappConnectPromise = dappClient.connect({ mode: "untrusted" });
const sessionRequest = await sessionRequestPromise;
t.expect(sessionRequest.capabilities?.otpDisplayGrant).toBeUndefined();

(dappClient as any).on("handshake_offer_received", () => {
displayOtpBeforeOffer = displayOtpFired;
});
walletClient.on("display_otp", () => {
displayOtpFired = true;
});

const walletConnectPromise = walletClient.connect({ sessionRequest });

const otpPromise = new Promise<string>((resolve) => {
walletClient.on("display_otp", (otp) => resolve(otp));
});
const otp = await otpPromise;
const otpPayload = await new Promise<OtpRequiredPayload>((resolve) => {
dappClient.on("otp_required", resolve);
});
await otpPayload.submit(otp);
await Promise.all([dappConnectPromise, walletConnectPromise]);

t.expect(displayOtpFired).toBe(true);
t.expect(displayOtpBeforeOffer).toBe(true);
});

t.test("should defer wallet display_otp until after dapp receives handshake offer in strict mode", async () => {
let dappReceivedOffer = false;
let displayOtpFired = false;

const sessionRequestPromise = new Promise<SessionRequest>((resolve) => {
dappClient.on("session_request", resolve);
});
const dappConnectPromise = dappClient.connect({ mode: "untrusted", requireOtpDisplayGrant: true });
const sessionRequest = await sessionRequestPromise;

(dappClient as any).on("handshake_offer_received", () => {
dappReceivedOffer = true;
});
const otpPromise = new Promise<string>((resolve) => {
walletClient.once("display_otp", (otp) => {
t.expect(dappReceivedOffer, "display_otp must not fire before dapp receives handshake offer").toBe(true);
displayOtpFired = true;
resolve(otp);
});
});

const otpRequiredPromise = new Promise<OtpRequiredPayload>((resolve) => {
dappClient.once("otp_required", resolve);
});

const walletConnectPromise = walletClient.connect({ sessionRequest });
const [otp, otpPayload] = await Promise.all([otpPromise, otpRequiredPromise]);
t.expect(displayOtpFired).toBe(true);

await otpPayload.submit(otp);
await Promise.all([dappConnectPromise, walletConnectPromise]);
});
});
});

t.describe("E2E Integration Test via Proxy", () => {
Expand Down
23 changes: 20 additions & 3 deletions apps/web-demo/src/components/UntrustedDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type PendingRequest = {
export default function UntrustedDemo() {
// UI State
const [showWalletClient, setShowWalletClient] = useState(true);
const [requireOtpDisplayGrant, setRequireOtpDisplayGrant] = useState(false);

// DApp State
const [dappClient, setDappClient] = useState<DappClient | null>(null);
Expand Down Expand Up @@ -123,7 +124,7 @@ export default function UntrustedDemo() {
addDappLog("sent", `Queuing initial payload: ${JSON.stringify(initialPayload, null, 2)}`);

// Start new connection, which will trigger 'session-request' and start a new timer
dappClient.connect({ initialPayload }).catch((error) => {
dappClient.connect({ mode: "untrusted", initialPayload, requireOtpDisplayGrant }).catch((error) => {
console.error("New QR code generation failed:", error);
addDappLog("system", `New QR code generation failed: ${error.message}`);
});
Expand Down Expand Up @@ -268,14 +269,14 @@ export default function UntrustedDemo() {

try {
setDappStatus("Connecting...");
addDappLog("system", "Starting connection process with initial payload...");
addDappLog("system", `Starting ${requireOtpDisplayGrant ? "strict" : "legacy"} untrusted connection process with initial payload...`);

// Define the initial message to be sent
const initialPayload = "Hello from Untrusted Demo!";
addDappLog("sent", `Queuing initial payload: ${JSON.stringify(initialPayload, null, 2)}`);

// This will trigger the session-request event and generate QR code
dappClient.connect({ initialPayload }).catch((error) => {
dappClient.connect({ mode: "untrusted", initialPayload, requireOtpDisplayGrant }).catch((error) => {
addDappLog("system", `Connection failed: ${error.message}`);
setDappStatus("Connection failed");
});
Expand Down Expand Up @@ -587,6 +588,22 @@ export default function UntrustedDemo() {
<h4 className="font-semibold mb-4 text-gray-900 dark:text-white">Connection</h4>

<div className="space-y-4">
<label className="flex items-start gap-3 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={requireOtpDisplayGrant}
onChange={(event) => setRequireOtpDisplayGrant(event.target.checked)}
disabled={dappConnected || !!qrCodeData}
className="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 disabled:cursor-not-allowed disabled:opacity-50"
/>
<span>
<span className="block font-medium">Require OTP display grant</span>
<span className="block text-xs text-gray-500 dark:text-gray-400">
When enabled, the wallet waits for the dapp to accept its handshake offer before displaying the OTP.
</span>
</span>
</label>

<div className="flex gap-3">
{!dappConnected ? (
<button
Expand Down
136 changes: 136 additions & 0 deletions docs/otp-display-grant-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# OTP Display Grant Plan

## Problem

In the current untrusted connection flow, the wallet generates and displays the OTP before the dapp has accepted any specific handshake offer.

A same-room attacker can scan the dapp QR code, front-run their own `handshake-offer`, and, if they learn or control the OTP the user enters, cause the dapp to establish a session with the attacker's public key and channel.

The risk is not front-running alone. Front-running becomes a session hijack when the attacker can also cause the user to enter the OTP that matches the attacker-controlled offer. In a same-room scenario, exposing the OTP too early makes that realistic.

## Goal

Add an optional strict untrusted flow where the wallet client does not display the OTP until the dapp client explicitly grants OTP display for the accepted handshake offer.

This turns attacker front-running into timeout or denial of service:

1. Attacker sends the first offer.
2. Dapp accepts the attacker offer and sends `otp-display-grant` on the handshake channel, encrypted to the attacker's offer public key.
3. Real MetaMask Mobile never receives a grant it can decrypt.
4. Real MetaMask Mobile never displays the OTP.
5. User has no legitimate OTP to enter.
6. Connection fails instead of binding to the attacker.

## Compatibility Model

Add this as an opt-in strict mode.

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

The default remains unchanged.

```ts
await dappClient.connect({ mode: "untrusted" });
```

Compatibility matrix:

| Pair | Result |
| --- | --- |
| Old dapp + old wallet | Current flow works |
| Old dapp + new wallet | Current flow works |
| New dapp without `requireOtpDisplayGrant` + old wallet | Current flow works |
| New dapp with `requireOtpDisplayGrant: true` + old wallet | Fails cleanly |
| New dapp with `requireOtpDisplayGrant: true` + new wallet | New safer flow |

Strict mode must not silently fall back. Otherwise an attacker can downgrade the flow by omitting the new flag in their own offer.

## Protocol Additions

Extend `SessionRequest`:

```ts
type SessionRequest = {
// existing fields...
capabilities?: {
otpDisplayGrant?: true;
};
};
```

Extend `HandshakeOfferPayload`:

```ts
type HandshakeOfferPayload = {
// existing fields...
otpDisplayGrantRequired?: true;
};
```

Add a protocol message:

```ts
type OtpDisplayGrant = {
type: "otp-display-grant";
};
```

Do not reuse `handshake-ack`. `handshake-ack` should continue to mean "OTP verified; finalize connection."

## New Strict Flow

Handshake channel messages: `handshake-offer`, `otp-display-grant`
Session channel messages (after OTP): `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 `requireOtpDisplayGrant` is true and the offer lacks `otpDisplayGrantRequired`, dapp rejects.
7. Dapp sets the wallet public key from the offer on the pending session (for encryption only).
8. Dapp sends encrypted `otp-display-grant` on the **handshake channel**, encrypted to the accepted offer's wallet public key.
9. Wallet receives the grant and only then emits `display_otp`.
10. User enters OTP into the dapp.
11. Dapp verifies OTP.
12. Dapp creates the secure session from the offer and sends `handshake-ack` on the session channel.
13. Both clients persist and finalize the session.

```mermaid
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
```

## Security Note

This does not prove wallet identity cryptographically. It prevents the practical same-room OTP-copy hijack by ensuring OTP is only displayed by the wallet whose offer the dapp accepted.

The grant is encrypted to the accepted offer's wallet public key. Other wallets subscribed to the same handshake channel cannot decrypt it.

A front-running attacker can still cause denial of service, but should not be able to make official MetaMask Mobile reveal an OTP for a session the dapp did not accept.
2 changes: 2 additions & 0 deletions packages/core/src/domain/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export enum ErrorCode {
OTP_INCORRECT = "OTP_INCORRECT",
OTP_MAX_ATTEMPTS_REACHED = "OTP_MAX_ATTEMPTS_REACHED",
OTP_ENTRY_TIMEOUT = "OTP_ENTRY_TIMEOUT",
OTP_DISPLAY_GRANT_REQUIRED = "OTP_DISPLAY_GRANT_REQUIRED",
OTP_DISPLAY_GRANT_TIMEOUT = "OTP_DISPLAY_GRANT_TIMEOUT",

// Generic fallback
UNKNOWN = "UNKNOWN",
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/domain/protocol-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ export type HandshakeOfferPayload = {
channelId: string;
otp?: string;
deadline?: number;
/** When true, the wallet requires an `otp-display-grant` before displaying the OTP. */
otpDisplayGrantRequired?: true;
};

export type HandshakeOffer = {
Expand All @@ -14,13 +16,17 @@ export type HandshakeAck = {
type: "handshake-ack";
};

export type OtpDisplayGrant = {
type: "otp-display-grant";
};

export type Message = {
type: "message";
payload: unknown;
};

/**
* A protocol message is a message that is sent between the dapp and the wallet.
* It can be a handshake offer, a handshake ack, or a message.
* It can be a handshake offer, a handshake ack, an OTP display grant, or a message.
*/
export type ProtocolMessage = HandshakeOffer | HandshakeAck | Message;
export type ProtocolMessage = HandshakeOffer | HandshakeAck | OtpDisplayGrant | Message;
8 changes: 8 additions & 0 deletions packages/core/src/domain/session-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,12 @@ export type SessionRequest = {
* on mobile deep linking.
*/
initialMessage?: Message;
/**
* Optional capabilities advertised by the dApp in the session request.
* Used for opt-in protocol extensions during the untrusted connection flow.
*/
capabilities?: {
/** When true, the wallet may defer OTP display until the dApp sends an `otp-display-grant`. */
otpDisplayGrant?: true;
};
};
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ export { CryptoError, ErrorCode, ProtocolError, SessionError, TransportError } f
export type { IKeyManager } from "./domain/key-manager";
export type { KeyPair } from "./domain/key-pair";
export type { IKVStore } from "./domain/kv-store";
export type { HandshakeAck, HandshakeOffer, HandshakeOfferPayload, Message, ProtocolMessage } from "./domain/protocol-message";
export type { HandshakeAck, HandshakeOffer, HandshakeOfferPayload, Message, OtpDisplayGrant, ProtocolMessage } from "./domain/protocol-message";
export type { Session } from "./domain/session";
export type { SessionRequest } from "./domain/session-request";
export type { ISessionStore } from "./domain/session-store";
Expand Down
Loading
Loading