Skip to content

Commit e247ff6

Browse files
authored
fix: reject inbound messages on expired sessions (WAPI-1130) (#72)
1 parent 03372f6 commit e247ff6

4 files changed

Lines changed: 86 additions & 10 deletions

File tree

apps/integration-tests/src/end-to-end.integration.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/** biome-ignore-all lint/suspicious/noExplicitAny: test code */
22
/** biome-ignore-all lint/suspicious/noShadowRestrictedNames: test code */
3-
import { type ConnectionMode, type IKeyManager, type IKVStore, type KeyPair, type SessionRequest, SessionStore, WebSocketTransport } from "@metamask/mobile-wallet-protocol-core";
3+
import { type ConnectionMode, ErrorCode, type IKeyManager, type IKVStore, type KeyPair, type SessionRequest, SessionStore, WebSocketTransport } from "@metamask/mobile-wallet-protocol-core";
44
import { DappClient, type OtpRequiredPayload } from "@metamask/mobile-wallet-protocol-dapp-client";
55
import { WalletClient } from "@metamask/mobile-wallet-protocol-wallet-client";
66
import { decrypt, encrypt, PrivateKey, PublicKey } from "eciesjs";
@@ -190,6 +190,34 @@ t.describe("E2E Integration Test", () => {
190190
await t.expect(messageFromWalletPromise).resolves.toEqual(responsePayload);
191191
});
192192

193+
t.test("should reject inbound messages on an expired session", async () => {
194+
await connectClients(dappClient, walletClient, "trusted");
195+
196+
// Verify a message works pre-expiry
197+
const preExpiryPayload = { method: "pre_expiry_check" };
198+
const preExpiryPromise = new Promise((resolve) => walletClient.on("message", resolve));
199+
await dappClient.sendRequest(preExpiryPayload);
200+
await t.expect(preExpiryPromise).resolves.toEqual(preExpiryPayload);
201+
202+
// Force-expire the wallet's session
203+
(walletClient as any).session.expiresAt = Date.now() - 1000;
204+
205+
const errorPromise = new Promise<any>((resolve) => {
206+
walletClient.once("error", resolve);
207+
});
208+
209+
// Send another message from dapp
210+
await dappClient.sendRequest({ method: "post_expiry_check" });
211+
212+
// Wallet should emit SESSION_EXPIRED
213+
const error = await errorPromise;
214+
t.expect(error.code).toBe(ErrorCode.SESSION_EXPIRED);
215+
216+
// Wait briefly, confirm the message was NOT delivered
217+
const walletMessagePromise = new Promise((resolve) => walletClient.once("message", resolve));
218+
await assertPromiseNotResolve(walletMessagePromise, 500, "Wallet should not receive messages on expired session");
219+
});
220+
193221
t.test("should successfully resume a previously established session", async () => {
194222
await connectClients(dappClient, walletClient, "untrusted");
195223
const sessionId = (await dappSessionStore.list())[0].id;

packages/core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818

1919
### Fixed
2020

21+
- Reject inbound messages on expired sessions instead of processing them
2122
- Fix `SessionStore` race conditions and fire-and-forget garbage collection ([#71](https://github.com/MetaMask/mobile-wallet-protocol/pull/71))
2223
- Guard against `NaN` in session expiry timestamps ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70))
2324

packages/core/src/base-client.integration.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as t from "vitest";
77
import WebSocket from "ws";
88
import { BaseClient } from "./base-client";
99
import { ClientState } from "./domain/client-state";
10+
import { ErrorCode } from "./domain/errors";
1011
import type { IKeyManager } from "./domain/key-manager";
1112
import type { KeyPair } from "./domain/key-pair";
1213
import type { IKVStore } from "./domain/kv-store";
@@ -368,6 +369,49 @@ t.describe("BaseClient", () => {
368369
publishSpy.mockRestore();
369370
});
370371

372+
t.test("should reject inbound messages on an expired session", async () => {
373+
const keyManagerA = new KeyManager();
374+
const keyManagerB = new KeyManager();
375+
const keyPairA = keyManagerA.generateKeyPair();
376+
const keyPairB = keyManagerB.generateKeyPair();
377+
378+
const sessionA: Session = {
379+
id: "session-inbound-expiry",
380+
channel,
381+
keyPair: keyPairA,
382+
theirPublicKey: keyPairB.publicKey,
383+
expiresAt: Date.now() + 60000,
384+
};
385+
const sessionB: Session = {
386+
id: "session-inbound-expiry",
387+
channel,
388+
keyPair: keyPairB,
389+
theirPublicKey: keyPairA.publicKey,
390+
expiresAt: Date.now() - 1000, // Already expired
391+
};
392+
393+
clientA.setSession(sessionA);
394+
clientB.setSession(sessionB);
395+
396+
await clientA["transport"].subscribe(channel);
397+
await clientB["transport"].subscribe(channel);
398+
399+
const errorPromise = new Promise<any>((resolve) => {
400+
clientB.once("error", resolve);
401+
});
402+
403+
const messageToSend: ProtocolMessage = { type: "message", payload: { method: "should_be_rejected" } };
404+
await clientA.sendMessage(channel, messageToSend);
405+
406+
const error = await errorPromise;
407+
t.expect(error.code).toBe(ErrorCode.SESSION_EXPIRED);
408+
409+
// Give a small window to ensure no message processing occurs
410+
await new Promise((resolve) => setTimeout(resolve, 200));
411+
t.expect(clientB.receivedMessages).toHaveLength(0);
412+
t.expect(clientB.getSession()).toBeNull();
413+
});
414+
371415
t.test("should reject resume() when client is already connected", async () => {
372416
// 1. Create and store a valid session
373417
const keyManagerA = new KeyManager();

packages/core/src/base-client.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export abstract class BaseClient extends EventEmitter {
4545

4646
this.transport.on("message", async (payload) => {
4747
if (!this.session?.keyPair.privateKey) return;
48+
if (await this.checkSessionExpiry()) {
49+
this.emit("error", new SessionError(ErrorCode.SESSION_EXPIRED, "Session expired"));
50+
return;
51+
}
4852
const message = await this.decryptMessage(payload.data);
4953
if (message) this.handleMessage(message);
5054
});
@@ -140,23 +144,22 @@ export abstract class BaseClient extends EventEmitter {
140144
*/
141145
protected async sendMessage(channel: string, message: ProtocolMessage): Promise<void> {
142146
if (!this.session) throw new SessionError(ErrorCode.SESSION_INVALID_STATE, "Cannot send message: session is not initialized.");
143-
await this.checkSessionExpiry();
147+
if (await this.checkSessionExpiry()) throw new SessionError(ErrorCode.SESSION_EXPIRED, "Session expired");
144148
const plaintext = JSON.stringify(message);
145149
const encrypted = await this.keymanager.encrypt(plaintext, this.session.theirPublicKey);
146150
const ok = await this.transport.publish(channel, encrypted);
147151
if (!ok) throw new TransportError(ErrorCode.TRANSPORT_DISCONNECTED, "Message could not be sent because the transport is disconnected.");
148152
}
149153

150154
/**
151-
* Checks if the current session is expired. If it is, triggers a disconnect.
152-
* @throws {SessionError} if the session is expired.
155+
* Checks if the current session has expired. If so, triggers a disconnect.
156+
*
157+
* @returns true if the session was expired (and cleanup was triggered), false otherwise.
153158
*/
154-
private async checkSessionExpiry(): Promise<void> {
155-
if (!this.session) return;
156-
if (this.session.expiresAt < Date.now()) {
157-
await this.disconnect();
158-
throw new SessionError(ErrorCode.SESSION_EXPIRED, "Session expired");
159-
}
159+
private async checkSessionExpiry(): Promise<boolean> {
160+
if (!this.session || this.session.expiresAt >= Date.now()) return false;
161+
await this.disconnect();
162+
return true;
160163
}
161164

162165
/**

0 commit comments

Comments
 (0)