Skip to content
6 changes: 5 additions & 1 deletion apps/integration-tests/src/end-to-end.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { type ConnectionMode, type IKeyManager, type IKVStore, type KeyPair, type SessionRequest, SessionStore, WebSocketTransport } from "@metamask/mobile-wallet-protocol-core";
import { DappClient, type OtpRequiredPayload } from "@metamask/mobile-wallet-protocol-dapp-client";
import { WalletClient } from "@metamask/mobile-wallet-protocol-wallet-client";
import { decrypt, encrypt, PrivateKey } from "eciesjs";
import { decrypt, encrypt, PrivateKey, PublicKey } from "eciesjs";
import { type Proxy, Toxiproxy } from "toxiproxy-node-client";
import * as t from "vitest";
import WebSocket from "ws";
Expand Down Expand Up @@ -32,6 +32,10 @@ export class KeyManager implements IKeyManager {
return { privateKey: new Uint8Array(privateKey.secret), publicKey: privateKey.publicKey.toBytes(true) };
}

validatePeerKey(key: Uint8Array): void {
PublicKey.fromHex(Buffer.from(key).toString("hex"));
}

async encrypt(plaintext: string, theirPublicKey: Uint8Array): Promise<string> {
const plaintextBuffer = Buffer.from(plaintext, "utf8");
const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer);
Expand Down
4 changes: 4 additions & 0 deletions apps/load-tests/src/client/key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export class MockKeyManager implements IKeyManager {
};
}

validatePeerKey(_key: Uint8Array): void {
// No-op: load tests don't use real crypto
}

async encrypt(plaintext: string, _theirPublicKey: Uint8Array): Promise<string> {
return Buffer.from(plaintext, "utf8").toString("base64");
}
Expand Down
6 changes: 5 additions & 1 deletion apps/rn-demo/lib/KeyManager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { IKeyManager, KeyPair } from "@metamask/mobile-wallet-protocol-core";
import { decrypt, encrypt, PrivateKey } from "eciesjs";
import { decrypt, encrypt, PrivateKey, PublicKey } from "eciesjs";

export class KeyManager implements IKeyManager {
generateKeyPair(): KeyPair {
const privateKey = new PrivateKey();
return { privateKey: new Uint8Array(privateKey.secret), publicKey: privateKey.publicKey.toBytes(true) };
}

validatePeerKey(key: Uint8Array): void {
PublicKey.fromHex(Buffer.from(key).toString("hex"));
}

async encrypt(plaintext: string, theirPublicKey: Uint8Array): Promise<string> {
const plaintextBuffer = Buffer.from(plaintext, "utf8");
const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer);
Expand Down
6 changes: 5 additions & 1 deletion apps/web-demo/src/lib/KeyManager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import type { IKeyManager, KeyPair } from "@metamask/mobile-wallet-protocol-core";
import { decrypt, encrypt, PrivateKey } from "eciesjs";
import { decrypt, encrypt, PrivateKey, PublicKey } from "eciesjs";

export class KeyManager implements IKeyManager {
generateKeyPair(): KeyPair {
const privateKey = new PrivateKey();
return { privateKey: new Uint8Array(privateKey.secret), publicKey: privateKey.publicKey.toBytes(true) };
}

validatePeerKey(key: Uint8Array): void {
PublicKey.fromHex(Buffer.from(key).toString("hex"));
}

async encrypt(plaintext: string, theirPublicKey: Uint8Array): Promise<string> {
const plaintextBuffer = Buffer.from(plaintext, "utf8");
const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer);
Expand Down
8 changes: 8 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Add `validatePeerKey` method to `IKeyManager` interface for peer public key validation at handshake and resume time ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70))

### Fixed

- Guard against `NaN` in session expiry timestamps ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70))

## [0.3.1]

### Fixed
Expand Down
24 changes: 20 additions & 4 deletions packages/core/src/base-client.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: test code */
/** biome-ignore-all lint/complexity/useLiteralKeys: test code */

import { decrypt, encrypt, PrivateKey } from "eciesjs";
import { decrypt, encrypt, PrivateKey, PublicKey } from "eciesjs";
import { v4 as uuid } from "uuid";
import * as t from "vitest";
import WebSocket from "ws";
Expand Down Expand Up @@ -43,6 +43,10 @@ export class KeyManager implements IKeyManager {
return { privateKey: new Uint8Array(privateKey.secret), publicKey: privateKey.publicKey.toBytes(true) };
}

validatePeerKey(key: Uint8Array): void {
PublicKey.fromHex(Buffer.from(key).toString("hex"));
}

async encrypt(plaintext: string, theirPublicKey: Uint8Array): Promise<string> {
const plaintextBuffer = Buffer.from(plaintext, "utf8");
const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer);
Expand Down Expand Up @@ -170,7 +174,11 @@ t.describe("BaseClient", () => {
id: "session-to-disconnect",
channel,
keyPair: new KeyManager().generateKeyPair(),
theirPublicKey: new Uint8Array(33),
theirPublicKey: (() => {
const k = new Uint8Array(33);
k[0] = 0x02;
return k;
})(),
expiresAt: Date.now() + 60000,
};

Expand Down Expand Up @@ -198,7 +206,11 @@ t.describe("BaseClient", () => {
id: "expired-session",
channel,
keyPair: new KeyManager().generateKeyPair(),
theirPublicKey: new Uint8Array(33),
theirPublicKey: (() => {
const k = new Uint8Array(33);
k[0] = 0x02;
return k;
})(),
expiresAt: Date.now() - 1000, // Expired 1 second ago
};

Expand All @@ -220,7 +232,11 @@ t.describe("BaseClient", () => {
id: "expired-resume-session",
channel,
keyPair: new KeyManager().generateKeyPair(),
theirPublicKey: new Uint8Array(33),
theirPublicKey: (() => {
const k = new Uint8Array(33);
k[0] = 0x02;
return k;
})(),
expiresAt: Date.now() + 60000, // Valid session
};

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/base-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export abstract class BaseClient extends EventEmitter {
try {
const session = await this.sessionstore.get(sessionId);
if (!session) throw new SessionError(ErrorCode.SESSION_NOT_FOUND, "Session not found or expired");
this.keymanager.validatePeerKey(session.theirPublicKey);

this.session = session;
await this.transport.connect();
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/domain/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export enum ErrorCode {

// Crypto errors
DECRYPTION_FAILED = "DECRYPTION_FAILED",
INVALID_KEY = "INVALID_KEY",

// Handshake errors
REQUEST_EXPIRED = "REQUEST_EXPIRED",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/domain/key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ export interface IKeyManager {
generateKeyPair(): KeyPair;
encrypt(plaintext: string, theirPublicKey: Uint8Array): Promise<string>;
decrypt(encryptedB64: string, myPrivateKey: Uint8Array): Promise<string>;
validatePeerKey(key: Uint8Array): void;
}
21 changes: 21 additions & 0 deletions packages/core/src/session-store/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,27 @@ t.describe("SessionStore", () => {
t.expect(raw).toBeNull();
});

t.test("should reject a session with NaN expiresAt on set", async () => {
const session = createSession("nan-set", Number.NaN);
await t.expect(sessionstore.set(session)).rejects.toThrow("Cannot save expired session");
});

t.test("should treat a session with NaN expiresAt as expired on get", async () => {
const key = "session:nan-get";
const data = {
id: "nan-get",
channel: "test-channel",
keyPair: { publicKeyB64: Buffer.from(new Uint8Array(33).fill(1)).toString("base64"), privateKeyB64: Buffer.from(new Uint8Array(32).fill(1)).toString("base64") },
theirPublicKeyB64: Buffer.from(new Uint8Array(33).fill(2)).toString("base64"),
expiresAt: "not-a-number",
};
await kvstore.set(key, JSON.stringify(data));
await (kvstore as MockKVStore)["store"].set("sessions:master-list", JSON.stringify(["nan-get"]));

const retrieved = await sessionstore.get("nan-get");
t.expect(retrieved).toBeNull();
});

t.test("should garbage collect expired sessions", async () => {
const valid = createSession("valid", Date.now() + 10000);
const expired = createSession("expired", Date.now() - 10000);
Expand Down
10 changes: 4 additions & 6 deletions packages/core/src/session-store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ export class SessionStore implements ISessionStore {
* @param session - The session to set.
*/
async set(session: Session): Promise<void> {
// Check if session is expired
if (session.expiresAt < Date.now()) {
// Check if session is expired (also rejects NaN)
if (Number.isNaN(session.expiresAt) || session.expiresAt < Date.now()) {
throw new SessionError(ErrorCode.SESSION_SAVE_FAILED, "Cannot save expired session");
}

Expand Down Expand Up @@ -80,14 +80,12 @@ export class SessionStore implements ISessionStore {
try {
const data: SerializableSession = JSON.parse(raw);

// Check if session is expired
if (data.expiresAt < Date.now()) {
// Session expired, clean it up
// Check if session is expired (handles NaN, non-number, and past timestamps)
if (typeof data.expiresAt !== "number" || !(data.expiresAt >= Date.now())) {
await this.delete(id);
return null;
}

// Deserialize back to Session
const session: Session = {
id: data.id,
channel: data.channel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ t.describe("SharedCentrifuge Unit Tests", () => {
// Clean up any shared contexts after each test
// @ts-expect-error - accessing private property for cleanup
const contexts = SharedCentrifuge.contexts;
for (const [url, context] of contexts.entries()) {
for (const [_url, context] of contexts.entries()) {
// Clear any pending reconnect promises
context.reconnectPromise = null;
// Remove all event listeners to prevent unhandled errors
Expand Down
4 changes: 4 additions & 0 deletions packages/dapp-client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Validate peer public keys during handshake in both trusted and untrusted connection flows ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70))

## [0.2.2]

### Added
Expand Down
6 changes: 5 additions & 1 deletion packages/dapp-client/src/client.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: test code */
import { type IKeyManager, type IKVStore, type KeyPair, type SessionRequest, SessionStore, WebSocketTransport } from "@metamask/mobile-wallet-protocol-core";
import { decrypt, encrypt, PrivateKey } from "eciesjs";
import { decrypt, encrypt, PrivateKey, PublicKey } from "eciesjs";
import * as t from "vitest";
import WebSocket from "ws";
import { DappClient } from "./client";
Expand All @@ -26,6 +26,10 @@ export class KeyManager implements IKeyManager {
return { privateKey: new Uint8Array(privateKey.secret), publicKey: privateKey.publicKey.toBytes(true) };
}

validatePeerKey(key: Uint8Array): void {
PublicKey.fromHex(Buffer.from(key).toString("hex"));
}

async encrypt(plaintext: string, theirPublicKey: Uint8Array): Promise<string> {
const plaintextBuffer = Buffer.from(plaintext, "utf8");
const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer);
Expand Down
1 change: 1 addition & 0 deletions packages/dapp-client/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ export class DappClient extends BaseClient {
const context: IConnectionHandlerContext = {
transport: this.transport,
sessionstore: this.sessionstore,
keymanager: this.keymanager,
get session() {
return self.session;
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ClientState, HandshakeOfferPayload, ISessionStore, ITransport, ProtocolMessage, Session } from "@metamask/mobile-wallet-protocol-core";
import type { ClientState, HandshakeOfferPayload, IKeyManager, ISessionStore, ITransport, ProtocolMessage, Session } from "@metamask/mobile-wallet-protocol-core";
import type { OtpRequiredPayload } from "../client";

/**
Expand All @@ -14,6 +14,7 @@ export interface IConnectionHandlerContext {
// Core Dependencies
readonly transport: ITransport;
readonly sessionstore: ISessionStore;
readonly keymanager: IKeyManager;

// Events
emit(event: "otp_required", payload: OtpRequiredPayload): void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ function createMockDappHandlerContext(): IConnectionHandlerContext {
list: vi.fn(),
delete: vi.fn(),
},
keymanager: {
generateKeyPair: vi.fn(),
encrypt: vi.fn(),
decrypt: vi.fn(),
validatePeerKey: vi.fn(),
},
emit: vi.fn(),
once: vi.fn(),
off: vi.fn(),
Expand Down Expand Up @@ -56,7 +62,7 @@ t.describe("TrustedConnectionHandler", () => {
};
mockOffer = {
channelId: "secure-channel",
publicKeyB64: "cHVia2V5",
publicKeyB64: "Aqurq6urq6urq6urq6urq6urq6urq6urq6urq6urq6ur",
}; // No OTP for trusted flow
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,12 @@ export class TrustedConnectionHandler implements IConnectionHandler {
* @returns The complete session object ready for use
*/
private _createFinalSession(session: Session, offer: HandshakeOfferPayload): Session {
const theirPublicKey = base64ToBytes(offer.publicKeyB64);
this.context.keymanager.validatePeerKey(theirPublicKey);
return {
...session,
channel: `session:${offer.channelId}`,
theirPublicKey: base64ToBytes(offer.publicKeyB64),
theirPublicKey,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ function createMockDappHandlerContext(): IConnectionHandlerContext {
list: vi.fn(),
delete: vi.fn(),
},
keymanager: {
generateKeyPair: vi.fn(),
encrypt: vi.fn(),
decrypt: vi.fn(),
validatePeerKey: vi.fn(),
},
emit: vi.fn(),
once: vi.fn(),
off: vi.fn(),
Expand Down Expand Up @@ -57,7 +63,7 @@ t.describe("UntrustedConnectionHandler", () => {
};
mockOffer = {
channelId: "secure-channel",
publicKeyB64: "cHVia2V5",
publicKeyB64: "Aqurq6urq6urq6urq6urq6urq6urq6urq6urq6urq6ur",
otp: "123456",
deadline: Date.now() + 1000,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,12 @@ export class UntrustedConnectionHandler implements IConnectionHandler {
* @returns The complete session object ready for use
*/
private _createFinalSession(session: Session, offer: HandshakeOfferPayload): Session {
const theirPublicKey = base64ToBytes(offer.publicKeyB64);
this.context.keymanager.validatePeerKey(theirPublicKey);
return {
...session,
channel: `session:${offer.channelId}`,
theirPublicKey: base64ToBytes(offer.publicKeyB64),
theirPublicKey,
};
}

Expand Down
5 changes: 5 additions & 0 deletions packages/wallet-client/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Validate peer public keys during session creation ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70))
- Fix client stuck in CONNECTING state when session creation fails ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70))

## [0.2.2]

### Added
Expand Down
6 changes: 5 additions & 1 deletion packages/wallet-client/src/client.integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: test code */
import { type IKeyManager, type IKVStore, type KeyPair, type SessionRequest, SessionStore, WebSocketTransport } from "@metamask/mobile-wallet-protocol-core";
import { bytesToBase64 } from "@metamask/utils";
import { decrypt, encrypt, PrivateKey } from "eciesjs";
import { decrypt, encrypt, PrivateKey, PublicKey } from "eciesjs";
import * as t from "vitest";
import WebSocket from "ws";
import { WalletClient } from "./client";
Expand All @@ -27,6 +27,10 @@ export class KeyManager implements IKeyManager {
return { privateKey: new Uint8Array(privateKey.secret), publicKey: privateKey.publicKey.toBytes(true) };
}

validatePeerKey(key: Uint8Array): void {
PublicKey.fromHex(Buffer.from(key).toString("hex"));
}

async encrypt(plaintext: string, theirPublicKey: Uint8Array): Promise<string> {
const plaintextBuffer = Buffer.from(plaintext, "utf8");
const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer);
Expand Down
Loading