From 0f762b392c4d24c873c77bfa314e568d55b47def Mon Sep 17 00:00:00 2001 From: Tamas Date: Wed, 25 Feb 2026 09:08:52 +0100 Subject: [PATCH 1/9] fix: validate incoming public keys as compressed secp256k1 (WAPI-1116) Add validateSecp256k1PublicKey utility that checks for exactly 33 bytes and a 0x02/0x03 prefix. Applied at all public key ingestion points: wallet-client session creation, both dapp-client connection handlers, and SessionStore deserialization. Also guards against NaN in session expiry timestamps on both set and get. --- packages/core/CHANGELOG.md | 5 ++ packages/core/src/domain/errors.ts | 1 + packages/core/src/index.ts | 1 + packages/core/src/session-store/index.test.ts | 21 ++++++ packages/core/src/session-store/index.ts | 15 +++-- .../src/utils/validate-public-key.test.ts | 64 +++++++++++++++++++ .../core/src/utils/validate-public-key.ts | 19 ++++++ packages/dapp-client/CHANGELOG.md | 4 ++ .../trusted-connection-handler.test.ts | 2 +- .../handlers/trusted-connection-handler.ts | 6 +- .../untrusted-connection-handler.test.ts | 2 +- .../handlers/untrusted-connection-handler.ts | 6 +- packages/wallet-client/CHANGELOG.md | 4 ++ packages/wallet-client/src/client.ts | 5 +- 14 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/utils/validate-public-key.test.ts create mode 100644 packages/core/src/utils/validate-public-key.ts diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index ae1a5c6..906ac6c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Validate incoming public keys as compressed secp256k1 keys at all ingestion points ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70)) +- Guard against `NaN` in session expiry timestamps ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70)) + ## [0.3.1] ### Fixed diff --git a/packages/core/src/domain/errors.ts b/packages/core/src/domain/errors.ts index d3d2515..a308fbe 100644 --- a/packages/core/src/domain/errors.ts +++ b/packages/core/src/domain/errors.ts @@ -15,6 +15,7 @@ export enum ErrorCode { // Crypto errors DECRYPTION_FAILED = "DECRYPTION_FAILED", + INVALID_KEY = "INVALID_KEY", // Handshake errors REQUEST_EXPIRED = "REQUEST_EXPIRED", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 476f4f0..671533e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,3 +12,4 @@ export type { ISessionStore } from "./domain/session-store"; export type { ITransport } from "./domain/transport"; export { DEFAULT_SESSION_TTL, SessionStore } from "./session-store"; export { WebSocketTransport, type WebSocketTransportOptions } from "./transport/websocket/index"; +export { validateSecp256k1PublicKey } from "./utils/validate-public-key"; diff --git a/packages/core/src/session-store/index.test.ts b/packages/core/src/session-store/index.test.ts index 91df175..094516a 100644 --- a/packages/core/src/session-store/index.test.ts +++ b/packages/core/src/session-store/index.test.ts @@ -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); diff --git a/packages/core/src/session-store/index.ts b/packages/core/src/session-store/index.ts index 67497a6..a7f7014 100644 --- a/packages/core/src/session-store/index.ts +++ b/packages/core/src/session-store/index.ts @@ -2,6 +2,7 @@ import { ErrorCode, SessionError } from "../domain/errors"; import type { IKVStore } from "../domain/kv-store"; import type { Session } from "../domain/session"; import type { ISessionStore } from "../domain/session-store"; +import { validateSecp256k1PublicKey } from "../utils/validate-public-key"; /** * Serializable representation of a Session where Uint8Array keys are converted to base64 strings. @@ -42,8 +43,8 @@ export class SessionStore implements ISessionStore { * @param session - The session to set. */ async set(session: Session): Promise { - // 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"); } @@ -80,14 +81,16 @@ 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 theirPublicKey = new Uint8Array(Buffer.from(data.theirPublicKeyB64, "base64")); + validateSecp256k1PublicKey(theirPublicKey); + const session: Session = { id: data.id, channel: data.channel, @@ -95,7 +98,7 @@ export class SessionStore implements ISessionStore { publicKey: new Uint8Array(Buffer.from(data.keyPair.publicKeyB64, "base64")), privateKey: new Uint8Array(Buffer.from(data.keyPair.privateKeyB64, "base64")), }, - theirPublicKey: new Uint8Array(Buffer.from(data.theirPublicKeyB64, "base64")), + theirPublicKey, expiresAt: data.expiresAt, }; diff --git a/packages/core/src/utils/validate-public-key.test.ts b/packages/core/src/utils/validate-public-key.test.ts new file mode 100644 index 0000000..13261ba --- /dev/null +++ b/packages/core/src/utils/validate-public-key.test.ts @@ -0,0 +1,64 @@ +import * as t from "vitest"; +import { CryptoError, ErrorCode } from "../domain/errors"; +import { validateSecp256k1PublicKey } from "./validate-public-key"; + +t.describe("validateSecp256k1PublicKey", () => { + t.test("should accept a valid compressed key with 0x02 prefix", () => { + const key = new Uint8Array(33); + key[0] = 0x02; + t.expect(() => validateSecp256k1PublicKey(key)).not.toThrow(); + }); + + t.test("should accept a valid compressed key with 0x03 prefix", () => { + const key = new Uint8Array(33); + key[0] = 0x03; + t.expect(() => validateSecp256k1PublicKey(key)).not.toThrow(); + }); + + t.test("should reject a key that is too short", () => { + const key = new Uint8Array(32); + key[0] = 0x02; + try { + validateSecp256k1PublicKey(key); + t.expect.unreachable("should have thrown"); + } catch (e) { + t.expect(e).toBeInstanceOf(CryptoError); + t.expect((e as CryptoError).code).toBe(ErrorCode.INVALID_KEY); + } + }); + + t.test("should reject a key that is too long", () => { + const key = new Uint8Array(65); + key[0] = 0x04; + try { + validateSecp256k1PublicKey(key); + t.expect.unreachable("should have thrown"); + } catch (e) { + t.expect(e).toBeInstanceOf(CryptoError); + t.expect((e as CryptoError).code).toBe(ErrorCode.INVALID_KEY); + } + }); + + t.test("should reject a key with an invalid prefix", () => { + const key = new Uint8Array(33); + key[0] = 0x04; + try { + validateSecp256k1PublicKey(key); + t.expect.unreachable("should have thrown"); + } catch (e) { + t.expect(e).toBeInstanceOf(CryptoError); + t.expect((e as CryptoError).code).toBe(ErrorCode.INVALID_KEY); + } + }); + + t.test("should reject an empty key", () => { + const key = new Uint8Array(0); + try { + validateSecp256k1PublicKey(key); + t.expect.unreachable("should have thrown"); + } catch (e) { + t.expect(e).toBeInstanceOf(CryptoError); + t.expect((e as CryptoError).code).toBe(ErrorCode.INVALID_KEY); + } + }); +}); diff --git a/packages/core/src/utils/validate-public-key.ts b/packages/core/src/utils/validate-public-key.ts new file mode 100644 index 0000000..edc159f --- /dev/null +++ b/packages/core/src/utils/validate-public-key.ts @@ -0,0 +1,19 @@ +import { CryptoError, ErrorCode } from "../domain/errors"; + +const COMPRESSED_KEY_LENGTH = 33; +const VALID_PREFIXES = [0x02, 0x03]; + +/** + * Validates that the given bytes represent a compressed secp256k1 public key. + * A valid compressed key is exactly 33 bytes and starts with 0x02 or 0x03. + * + * @throws {CryptoError} with code INVALID_KEY if the key is malformed + */ +export function validateSecp256k1PublicKey(keyBytes: Uint8Array): void { + if (keyBytes.length !== COMPRESSED_KEY_LENGTH) { + throw new CryptoError(ErrorCode.INVALID_KEY, `Invalid public key length: expected ${COMPRESSED_KEY_LENGTH}, got ${keyBytes.length}`); + } + if (!VALID_PREFIXES.includes(keyBytes[0])) { + throw new CryptoError(ErrorCode.INVALID_KEY, `Invalid public key prefix: expected 0x02 or 0x03, got 0x${keyBytes[0].toString(16).padStart(2, "0")}`); + } +} diff --git a/packages/dapp-client/CHANGELOG.md b/packages/dapp-client/CHANGELOG.md index 38e10d5..06b7386 100644 --- a/packages/dapp-client/CHANGELOG.md +++ b/packages/dapp-client/CHANGELOG.md @@ -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 diff --git a/packages/dapp-client/src/handlers/trusted-connection-handler.test.ts b/packages/dapp-client/src/handlers/trusted-connection-handler.test.ts index ce04d49..f087170 100644 --- a/packages/dapp-client/src/handlers/trusted-connection-handler.test.ts +++ b/packages/dapp-client/src/handlers/trusted-connection-handler.test.ts @@ -56,7 +56,7 @@ t.describe("TrustedConnectionHandler", () => { }; mockOffer = { channelId: "secure-channel", - publicKeyB64: "cHVia2V5", + publicKeyB64: "Aqurq6urq6urq6urq6urq6urq6urq6urq6urq6urq6ur", }; // No OTP for trusted flow }); diff --git a/packages/dapp-client/src/handlers/trusted-connection-handler.ts b/packages/dapp-client/src/handlers/trusted-connection-handler.ts index 1de6392..c0bd656 100644 --- a/packages/dapp-client/src/handlers/trusted-connection-handler.ts +++ b/packages/dapp-client/src/handlers/trusted-connection-handler.ts @@ -1,4 +1,4 @@ -import { ClientState, ErrorCode, type HandshakeOfferPayload, type Session, SessionError, type SessionRequest } from "@metamask/mobile-wallet-protocol-core"; +import { ClientState, ErrorCode, type HandshakeOfferPayload, type Session, SessionError, type SessionRequest, validateSecp256k1PublicKey } from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import { HANDSHAKE_TIMEOUT } from "../client"; import type { IConnectionHandler } from "../domain/connection-handler"; @@ -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); + validateSecp256k1PublicKey(theirPublicKey); return { ...session, channel: `session:${offer.channelId}`, - theirPublicKey: base64ToBytes(offer.publicKeyB64), + theirPublicKey, }; } diff --git a/packages/dapp-client/src/handlers/untrusted-connection-handler.test.ts b/packages/dapp-client/src/handlers/untrusted-connection-handler.test.ts index cf26e60..50b3c14 100644 --- a/packages/dapp-client/src/handlers/untrusted-connection-handler.test.ts +++ b/packages/dapp-client/src/handlers/untrusted-connection-handler.test.ts @@ -57,7 +57,7 @@ t.describe("UntrustedConnectionHandler", () => { }; mockOffer = { channelId: "secure-channel", - publicKeyB64: "cHVia2V5", + publicKeyB64: "Aqurq6urq6urq6urq6urq6urq6urq6urq6urq6urq6ur", otp: "123456", deadline: Date.now() + 1000, }; diff --git a/packages/dapp-client/src/handlers/untrusted-connection-handler.ts b/packages/dapp-client/src/handlers/untrusted-connection-handler.ts index 376fdcf..761eddd 100644 --- a/packages/dapp-client/src/handlers/untrusted-connection-handler.ts +++ b/packages/dapp-client/src/handlers/untrusted-connection-handler.ts @@ -1,4 +1,4 @@ -import { ClientState, ErrorCode, type HandshakeOfferPayload, type Session, SessionError, type SessionRequest } from "@metamask/mobile-wallet-protocol-core"; +import { ClientState, ErrorCode, type HandshakeOfferPayload, type Session, SessionError, type SessionRequest, validateSecp256k1PublicKey } from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import type { OtpRequiredPayload } from "../client"; import type { IConnectionHandler } from "../domain/connection-handler"; @@ -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); + validateSecp256k1PublicKey(theirPublicKey); return { ...session, channel: `session:${offer.channelId}`, - theirPublicKey: base64ToBytes(offer.publicKeyB64), + theirPublicKey, }; } diff --git a/packages/wallet-client/CHANGELOG.md b/packages/wallet-client/CHANGELOG.md index 9b841bb..b4771b3 100644 --- a/packages/wallet-client/CHANGELOG.md +++ b/packages/wallet-client/CHANGELOG.md @@ -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 session creation ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70)) + ## [0.2.2] ### Added diff --git a/packages/wallet-client/src/client.ts b/packages/wallet-client/src/client.ts index 575041c..b86e21f 100644 --- a/packages/wallet-client/src/client.ts +++ b/packages/wallet-client/src/client.ts @@ -10,6 +10,7 @@ import { type Session, SessionError, type SessionRequest, + validateSecp256k1PublicKey, } from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import { v4 as uuid } from "uuid"; @@ -154,11 +155,13 @@ export class WalletClient extends BaseClient { * @returns A new `Session` object */ private _createSession(request: SessionRequest): Session { + const theirPublicKey = base64ToBytes(request.publicKeyB64); + validateSecp256k1PublicKey(theirPublicKey); return { id: request.id, channel: `session:${uuid()}`, // Create a new, unique channel for secure communication keyPair: this.keymanager.generateKeyPair(), - theirPublicKey: base64ToBytes(request.publicKeyB64), + theirPublicKey, expiresAt: Date.now() + DEFAULT_SESSION_TTL, }; } From e5fd7d5ccabf13aaa205c58c6fca40a3749a557a Mon Sep 17 00:00:00 2001 From: Tamas Date: Wed, 25 Feb 2026 09:35:21 +0100 Subject: [PATCH 2/9] fix: format long import lines to pass biome CI check --- .../src/handlers/trusted-connection-handler.ts | 10 +++++++++- .../src/handlers/untrusted-connection-handler.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/dapp-client/src/handlers/trusted-connection-handler.ts b/packages/dapp-client/src/handlers/trusted-connection-handler.ts index c0bd656..3c661d4 100644 --- a/packages/dapp-client/src/handlers/trusted-connection-handler.ts +++ b/packages/dapp-client/src/handlers/trusted-connection-handler.ts @@ -1,4 +1,12 @@ -import { ClientState, ErrorCode, type HandshakeOfferPayload, type Session, SessionError, type SessionRequest, validateSecp256k1PublicKey } from "@metamask/mobile-wallet-protocol-core"; +import { + ClientState, + ErrorCode, + type HandshakeOfferPayload, + type Session, + SessionError, + type SessionRequest, + validateSecp256k1PublicKey, +} from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import { HANDSHAKE_TIMEOUT } from "../client"; import type { IConnectionHandler } from "../domain/connection-handler"; diff --git a/packages/dapp-client/src/handlers/untrusted-connection-handler.ts b/packages/dapp-client/src/handlers/untrusted-connection-handler.ts index 761eddd..5c75c6a 100644 --- a/packages/dapp-client/src/handlers/untrusted-connection-handler.ts +++ b/packages/dapp-client/src/handlers/untrusted-connection-handler.ts @@ -1,4 +1,12 @@ -import { ClientState, ErrorCode, type HandshakeOfferPayload, type Session, SessionError, type SessionRequest, validateSecp256k1PublicKey } from "@metamask/mobile-wallet-protocol-core"; +import { + ClientState, + ErrorCode, + type HandshakeOfferPayload, + type Session, + SessionError, + type SessionRequest, + validateSecp256k1PublicKey, +} from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import type { OtpRequiredPayload } from "../client"; import type { IConnectionHandler } from "../domain/connection-handler"; From 8e219d8893c71293ebf57c2059b70ba8a447fcc3 Mon Sep 17 00:00:00 2001 From: Tamas Date: Wed, 25 Feb 2026 09:43:42 +0100 Subject: [PATCH 3/9] fix: use valid compressed public key in integration test fixtures Tests used Uint8Array(33) filled with zeros (0x00 prefix) which is now rejected by the secp256k1 public key validation added in this PR. Updated to use 0x02 prefix for a valid compressed key format. --- .../core/src/base-client.integration.test.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/core/src/base-client.integration.test.ts b/packages/core/src/base-client.integration.test.ts index e536cee..ced6abe 100644 --- a/packages/core/src/base-client.integration.test.ts +++ b/packages/core/src/base-client.integration.test.ts @@ -170,7 +170,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, }; @@ -198,7 +202,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 }; @@ -220,7 +228,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 }; From 2d16fa101c8a5483a375d19c1177ef852c89e3de Mon Sep 17 00:00:00 2001 From: Tamas Date: Wed, 25 Feb 2026 09:45:36 +0100 Subject: [PATCH 4/9] fix: move _createSession inside try-catch to prevent stuck state If validateSecp256k1PublicKey threw on a malformed peer key, the wallet client state would be stuck at CONNECTING with no cleanup, permanently bricking the instance. Moving _createSession into the try block ensures disconnect() runs on validation failure. --- packages/wallet-client/src/client.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/wallet-client/src/client.ts b/packages/wallet-client/src/client.ts index b86e21f..4672b8a 100644 --- a/packages/wallet-client/src/client.ts +++ b/packages/wallet-client/src/client.ts @@ -83,8 +83,6 @@ export class WalletClient extends BaseClient { const request = options.sessionRequest; if (Date.now() > request.expiresAt) throw new SessionError(ErrorCode.REQUEST_EXPIRED, "Session request expired"); - const session = this._createSession(request); - const self = this; const context: IConnectionHandlerContext = { transport: this.transport, @@ -111,6 +109,7 @@ export class WalletClient extends BaseClient { const handler: IConnectionHandler = request.mode === "trusted" ? new TrustedConnectionHandler(context) : new UntrustedConnectionHandler(context); try { + const session = this._createSession(request); await handler.execute(session, request); } catch (error) { this.emit("error", error); From 1ae93a1be687c3bdeac136ec108c21bc47e6d76c Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 26 Feb 2026 10:15:53 +0100 Subject: [PATCH 5/9] fix: move peer key validation into IKeyManager interface (WAPI-1116) The previous approach leaked secp256k1-specific validation into the core package, breaking its crypto-agnostic architecture. This moves validation behind the IKeyManager interface so each implementation validates keys according to its own crypto system. - Add validatePeerKey() to IKeyManager interface - Delete standalone validateSecp256k1PublicKey utility - Remove validation from SessionStore.get() (validated at ingestion) - Add validation in BaseClient.resume() for persisted sessions - Wire keymanager into dapp-client handler context - Use eciesjs PublicKey.fromHex() for curve-point validation --- .../src/end-to-end.integration.test.ts | 6 +- apps/load-tests/src/client/key-manager.ts | 4 ++ apps/rn-demo/lib/KeyManager.ts | 6 +- apps/web-demo/src/lib/KeyManager.ts | 6 +- .../core/src/base-client.integration.test.ts | 6 +- packages/core/src/base-client.ts | 1 + packages/core/src/domain/key-manager.ts | 1 + packages/core/src/index.ts | 3 +- packages/core/src/session-store/index.ts | 4 -- .../src/utils/validate-public-key.test.ts | 64 ------------------- .../core/src/utils/validate-public-key.ts | 19 ------ .../src/client.integration.test.ts | 6 +- packages/dapp-client/src/client.ts | 1 + .../src/domain/connection-handler-context.ts | 3 +- .../trusted-connection-handler.test.ts | 6 ++ .../handlers/trusted-connection-handler.ts | 3 +- .../untrusted-connection-handler.test.ts | 6 ++ .../handlers/untrusted-connection-handler.ts | 3 +- .../src/client.integration.test.ts | 6 +- packages/wallet-client/src/client.ts | 3 +- 20 files changed, 55 insertions(+), 102 deletions(-) delete mode 100644 packages/core/src/utils/validate-public-key.test.ts delete mode 100644 packages/core/src/utils/validate-public-key.ts diff --git a/apps/integration-tests/src/end-to-end.integration.test.ts b/apps/integration-tests/src/end-to-end.integration.test.ts index b7fd767..33dd482 100644 --- a/apps/integration-tests/src/end-to-end.integration.test.ts +++ b/apps/integration-tests/src/end-to-end.integration.test.ts @@ -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"; @@ -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 { const plaintextBuffer = Buffer.from(plaintext, "utf8"); const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer); diff --git a/apps/load-tests/src/client/key-manager.ts b/apps/load-tests/src/client/key-manager.ts index a0d183d..932e6c1 100644 --- a/apps/load-tests/src/client/key-manager.ts +++ b/apps/load-tests/src/client/key-manager.ts @@ -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 { return Buffer.from(plaintext, "utf8").toString("base64"); } diff --git a/apps/rn-demo/lib/KeyManager.ts b/apps/rn-demo/lib/KeyManager.ts index 3c67649..32b5bad 100644 --- a/apps/rn-demo/lib/KeyManager.ts +++ b/apps/rn-demo/lib/KeyManager.ts @@ -1,5 +1,5 @@ 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 { @@ -7,6 +7,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 { const plaintextBuffer = Buffer.from(plaintext, "utf8"); const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer); diff --git a/apps/web-demo/src/lib/KeyManager.ts b/apps/web-demo/src/lib/KeyManager.ts index 3c67649..32b5bad 100644 --- a/apps/web-demo/src/lib/KeyManager.ts +++ b/apps/web-demo/src/lib/KeyManager.ts @@ -1,5 +1,5 @@ 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 { @@ -7,6 +7,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 { const plaintextBuffer = Buffer.from(plaintext, "utf8"); const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer); diff --git a/packages/core/src/base-client.integration.test.ts b/packages/core/src/base-client.integration.test.ts index ced6abe..ef0273a 100644 --- a/packages/core/src/base-client.integration.test.ts +++ b/packages/core/src/base-client.integration.test.ts @@ -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"; @@ -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 { const plaintextBuffer = Buffer.from(plaintext, "utf8"); const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer); diff --git a/packages/core/src/base-client.ts b/packages/core/src/base-client.ts index 7173fcf..ede243f 100644 --- a/packages/core/src/base-client.ts +++ b/packages/core/src/base-client.ts @@ -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(); diff --git a/packages/core/src/domain/key-manager.ts b/packages/core/src/domain/key-manager.ts index fa0fd90..cfebc7e 100644 --- a/packages/core/src/domain/key-manager.ts +++ b/packages/core/src/domain/key-manager.ts @@ -7,4 +7,5 @@ export interface IKeyManager { generateKeyPair(): KeyPair; encrypt(plaintext: string, theirPublicKey: Uint8Array): Promise; decrypt(encryptedB64: string, myPrivateKey: Uint8Array): Promise; + validatePeerKey(key: Uint8Array): void; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 671533e..238bf5e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,5 +11,4 @@ export type { SessionRequest } from "./domain/session-request"; export type { ISessionStore } from "./domain/session-store"; export type { ITransport } from "./domain/transport"; export { DEFAULT_SESSION_TTL, SessionStore } from "./session-store"; -export { WebSocketTransport, type WebSocketTransportOptions } from "./transport/websocket/index"; -export { validateSecp256k1PublicKey } from "./utils/validate-public-key"; +export { WebSocketTransport, type WebSocketTransportOptions } from "./transport/websocket/index"; \ No newline at end of file diff --git a/packages/core/src/session-store/index.ts b/packages/core/src/session-store/index.ts index a7f7014..0b85922 100644 --- a/packages/core/src/session-store/index.ts +++ b/packages/core/src/session-store/index.ts @@ -2,7 +2,6 @@ import { ErrorCode, SessionError } from "../domain/errors"; import type { IKVStore } from "../domain/kv-store"; import type { Session } from "../domain/session"; import type { ISessionStore } from "../domain/session-store"; -import { validateSecp256k1PublicKey } from "../utils/validate-public-key"; /** * Serializable representation of a Session where Uint8Array keys are converted to base64 strings. @@ -87,10 +86,7 @@ export class SessionStore implements ISessionStore { return null; } - // Deserialize back to Session const theirPublicKey = new Uint8Array(Buffer.from(data.theirPublicKeyB64, "base64")); - validateSecp256k1PublicKey(theirPublicKey); - const session: Session = { id: data.id, channel: data.channel, diff --git a/packages/core/src/utils/validate-public-key.test.ts b/packages/core/src/utils/validate-public-key.test.ts deleted file mode 100644 index 13261ba..0000000 --- a/packages/core/src/utils/validate-public-key.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import * as t from "vitest"; -import { CryptoError, ErrorCode } from "../domain/errors"; -import { validateSecp256k1PublicKey } from "./validate-public-key"; - -t.describe("validateSecp256k1PublicKey", () => { - t.test("should accept a valid compressed key with 0x02 prefix", () => { - const key = new Uint8Array(33); - key[0] = 0x02; - t.expect(() => validateSecp256k1PublicKey(key)).not.toThrow(); - }); - - t.test("should accept a valid compressed key with 0x03 prefix", () => { - const key = new Uint8Array(33); - key[0] = 0x03; - t.expect(() => validateSecp256k1PublicKey(key)).not.toThrow(); - }); - - t.test("should reject a key that is too short", () => { - const key = new Uint8Array(32); - key[0] = 0x02; - try { - validateSecp256k1PublicKey(key); - t.expect.unreachable("should have thrown"); - } catch (e) { - t.expect(e).toBeInstanceOf(CryptoError); - t.expect((e as CryptoError).code).toBe(ErrorCode.INVALID_KEY); - } - }); - - t.test("should reject a key that is too long", () => { - const key = new Uint8Array(65); - key[0] = 0x04; - try { - validateSecp256k1PublicKey(key); - t.expect.unreachable("should have thrown"); - } catch (e) { - t.expect(e).toBeInstanceOf(CryptoError); - t.expect((e as CryptoError).code).toBe(ErrorCode.INVALID_KEY); - } - }); - - t.test("should reject a key with an invalid prefix", () => { - const key = new Uint8Array(33); - key[0] = 0x04; - try { - validateSecp256k1PublicKey(key); - t.expect.unreachable("should have thrown"); - } catch (e) { - t.expect(e).toBeInstanceOf(CryptoError); - t.expect((e as CryptoError).code).toBe(ErrorCode.INVALID_KEY); - } - }); - - t.test("should reject an empty key", () => { - const key = new Uint8Array(0); - try { - validateSecp256k1PublicKey(key); - t.expect.unreachable("should have thrown"); - } catch (e) { - t.expect(e).toBeInstanceOf(CryptoError); - t.expect((e as CryptoError).code).toBe(ErrorCode.INVALID_KEY); - } - }); -}); diff --git a/packages/core/src/utils/validate-public-key.ts b/packages/core/src/utils/validate-public-key.ts deleted file mode 100644 index edc159f..0000000 --- a/packages/core/src/utils/validate-public-key.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CryptoError, ErrorCode } from "../domain/errors"; - -const COMPRESSED_KEY_LENGTH = 33; -const VALID_PREFIXES = [0x02, 0x03]; - -/** - * Validates that the given bytes represent a compressed secp256k1 public key. - * A valid compressed key is exactly 33 bytes and starts with 0x02 or 0x03. - * - * @throws {CryptoError} with code INVALID_KEY if the key is malformed - */ -export function validateSecp256k1PublicKey(keyBytes: Uint8Array): void { - if (keyBytes.length !== COMPRESSED_KEY_LENGTH) { - throw new CryptoError(ErrorCode.INVALID_KEY, `Invalid public key length: expected ${COMPRESSED_KEY_LENGTH}, got ${keyBytes.length}`); - } - if (!VALID_PREFIXES.includes(keyBytes[0])) { - throw new CryptoError(ErrorCode.INVALID_KEY, `Invalid public key prefix: expected 0x02 or 0x03, got 0x${keyBytes[0].toString(16).padStart(2, "0")}`); - } -} diff --git a/packages/dapp-client/src/client.integration.test.ts b/packages/dapp-client/src/client.integration.test.ts index 1277d00..3d064ff 100644 --- a/packages/dapp-client/src/client.integration.test.ts +++ b/packages/dapp-client/src/client.integration.test.ts @@ -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"; @@ -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 { const plaintextBuffer = Buffer.from(plaintext, "utf8"); const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer); diff --git a/packages/dapp-client/src/client.ts b/packages/dapp-client/src/client.ts index b68c21d..d76c084 100644 --- a/packages/dapp-client/src/client.ts +++ b/packages/dapp-client/src/client.ts @@ -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; }, diff --git a/packages/dapp-client/src/domain/connection-handler-context.ts b/packages/dapp-client/src/domain/connection-handler-context.ts index d6545f2..ab83202 100644 --- a/packages/dapp-client/src/domain/connection-handler-context.ts +++ b/packages/dapp-client/src/domain/connection-handler-context.ts @@ -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"; /** @@ -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; diff --git a/packages/dapp-client/src/handlers/trusted-connection-handler.test.ts b/packages/dapp-client/src/handlers/trusted-connection-handler.test.ts index f087170..eaad7fe 100644 --- a/packages/dapp-client/src/handlers/trusted-connection-handler.test.ts +++ b/packages/dapp-client/src/handlers/trusted-connection-handler.test.ts @@ -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(), diff --git a/packages/dapp-client/src/handlers/trusted-connection-handler.ts b/packages/dapp-client/src/handlers/trusted-connection-handler.ts index 3c661d4..06d4294 100644 --- a/packages/dapp-client/src/handlers/trusted-connection-handler.ts +++ b/packages/dapp-client/src/handlers/trusted-connection-handler.ts @@ -5,7 +5,6 @@ import { type Session, SessionError, type SessionRequest, - validateSecp256k1PublicKey, } from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import { HANDSHAKE_TIMEOUT } from "../client"; @@ -91,7 +90,7 @@ export class TrustedConnectionHandler implements IConnectionHandler { */ private _createFinalSession(session: Session, offer: HandshakeOfferPayload): Session { const theirPublicKey = base64ToBytes(offer.publicKeyB64); - validateSecp256k1PublicKey(theirPublicKey); + this.context.keymanager.validatePeerKey(theirPublicKey); return { ...session, channel: `session:${offer.channelId}`, diff --git a/packages/dapp-client/src/handlers/untrusted-connection-handler.test.ts b/packages/dapp-client/src/handlers/untrusted-connection-handler.test.ts index 50b3c14..0126a9b 100644 --- a/packages/dapp-client/src/handlers/untrusted-connection-handler.test.ts +++ b/packages/dapp-client/src/handlers/untrusted-connection-handler.test.ts @@ -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(), diff --git a/packages/dapp-client/src/handlers/untrusted-connection-handler.ts b/packages/dapp-client/src/handlers/untrusted-connection-handler.ts index 5c75c6a..e881867 100644 --- a/packages/dapp-client/src/handlers/untrusted-connection-handler.ts +++ b/packages/dapp-client/src/handlers/untrusted-connection-handler.ts @@ -5,7 +5,6 @@ import { type Session, SessionError, type SessionRequest, - validateSecp256k1PublicKey, } from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import type { OtpRequiredPayload } from "../client"; @@ -124,7 +123,7 @@ export class UntrustedConnectionHandler implements IConnectionHandler { */ private _createFinalSession(session: Session, offer: HandshakeOfferPayload): Session { const theirPublicKey = base64ToBytes(offer.publicKeyB64); - validateSecp256k1PublicKey(theirPublicKey); + this.context.keymanager.validatePeerKey(theirPublicKey); return { ...session, channel: `session:${offer.channelId}`, diff --git a/packages/wallet-client/src/client.integration.test.ts b/packages/wallet-client/src/client.integration.test.ts index e2b7717..08d63b9 100644 --- a/packages/wallet-client/src/client.integration.test.ts +++ b/packages/wallet-client/src/client.integration.test.ts @@ -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"; @@ -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 { const plaintextBuffer = Buffer.from(plaintext, "utf8"); const encryptedBuffer = encrypt(theirPublicKey, plaintextBuffer); diff --git a/packages/wallet-client/src/client.ts b/packages/wallet-client/src/client.ts index 4672b8a..67c1c4b 100644 --- a/packages/wallet-client/src/client.ts +++ b/packages/wallet-client/src/client.ts @@ -10,7 +10,6 @@ import { type Session, SessionError, type SessionRequest, - validateSecp256k1PublicKey, } from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import { v4 as uuid } from "uuid"; @@ -155,7 +154,7 @@ export class WalletClient extends BaseClient { */ private _createSession(request: SessionRequest): Session { const theirPublicKey = base64ToBytes(request.publicKeyB64); - validateSecp256k1PublicKey(theirPublicKey); + this.keymanager.validatePeerKey(theirPublicKey); return { id: request.id, channel: `session:${uuid()}`, // Create a new, unique channel for secure communication From a1ff41119ffb7f6e4b9620e4a6f1897bcf106ca3 Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 26 Feb 2026 10:19:21 +0100 Subject: [PATCH 6/9] chore: fix pre-existing unused variable lint warning --- packages/core/src/transport/websocket/shared-centrifuge.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/transport/websocket/shared-centrifuge.test.ts b/packages/core/src/transport/websocket/shared-centrifuge.test.ts index c1c6aa2..291c665 100644 --- a/packages/core/src/transport/websocket/shared-centrifuge.test.ts +++ b/packages/core/src/transport/websocket/shared-centrifuge.test.ts @@ -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 From d0f04edbfd3be257d0fd4f5d4b198e5912c355d9 Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 26 Feb 2026 10:29:11 +0100 Subject: [PATCH 7/9] tidy --- packages/core/src/session-store/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/session-store/index.ts b/packages/core/src/session-store/index.ts index 0b85922..a50bda6 100644 --- a/packages/core/src/session-store/index.ts +++ b/packages/core/src/session-store/index.ts @@ -86,7 +86,6 @@ export class SessionStore implements ISessionStore { return null; } - const theirPublicKey = new Uint8Array(Buffer.from(data.theirPublicKeyB64, "base64")); const session: Session = { id: data.id, channel: data.channel, @@ -94,7 +93,7 @@ export class SessionStore implements ISessionStore { publicKey: new Uint8Array(Buffer.from(data.keyPair.publicKeyB64, "base64")), privateKey: new Uint8Array(Buffer.from(data.keyPair.privateKeyB64, "base64")), }, - theirPublicKey, + theirPublicKey: new Uint8Array(Buffer.from(data.theirPublicKeyB64, "base64")), expiresAt: data.expiresAt, }; From f2c6c30e42834d37c2ce528e4d0cc2047be166c6 Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 26 Feb 2026 10:39:15 +0100 Subject: [PATCH 8/9] fix: correct changelogs and wallet-client stuck-state bug - Core changelog: categorize validatePeerKey as Changed (breaking interface addition), keep NaN guard under Fixed - Dapp/wallet-client changelogs: recategorize peer key validation entries as Fixed - Wallet-client: reset state to DISCONNECTED when _createSession throws before session is assigned, preventing permanent CONNECTING state --- packages/core/CHANGELOG.md | 5 ++++- packages/wallet-client/CHANGELOG.md | 3 ++- packages/wallet-client/src/client.ts | 6 +++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 906ac6c..516e9d7 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,9 +7,12 @@ 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 -- Validate incoming public keys as compressed secp256k1 keys at all ingestion points ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70)) - Guard against `NaN` in session expiry timestamps ([#70](https://github.com/MetaMask/mobile-wallet-protocol/pull/70)) ## [0.3.1] diff --git a/packages/wallet-client/CHANGELOG.md b/packages/wallet-client/CHANGELOG.md index b4771b3..4d8f5ef 100644 --- a/packages/wallet-client/CHANGELOG.md +++ b/packages/wallet-client/CHANGELOG.md @@ -7,9 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed +### 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] diff --git a/packages/wallet-client/src/client.ts b/packages/wallet-client/src/client.ts index 67c1c4b..1187a26 100644 --- a/packages/wallet-client/src/client.ts +++ b/packages/wallet-client/src/client.ts @@ -112,7 +112,11 @@ export class WalletClient extends BaseClient { await handler.execute(session, request); } catch (error) { this.emit("error", error); - await this.disconnect(); + if (this.session) { + await this.disconnect(); + } else { + this.state = ClientState.DISCONNECTED; + } throw error; } } From 460249c437f80bfc2ab35b61b94551f96c2eb6ab Mon Sep 17 00:00:00 2001 From: Tamas Date: Thu, 26 Feb 2026 10:42:01 +0100 Subject: [PATCH 9/9] style: fix biome formatting for CI --- packages/core/src/index.ts | 2 +- .../src/handlers/trusted-connection-handler.ts | 9 +-------- .../src/handlers/untrusted-connection-handler.ts | 9 +-------- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 238bf5e..476f4f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,4 +11,4 @@ export type { SessionRequest } from "./domain/session-request"; export type { ISessionStore } from "./domain/session-store"; export type { ITransport } from "./domain/transport"; export { DEFAULT_SESSION_TTL, SessionStore } from "./session-store"; -export { WebSocketTransport, type WebSocketTransportOptions } from "./transport/websocket/index"; \ No newline at end of file +export { WebSocketTransport, type WebSocketTransportOptions } from "./transport/websocket/index"; diff --git a/packages/dapp-client/src/handlers/trusted-connection-handler.ts b/packages/dapp-client/src/handlers/trusted-connection-handler.ts index 06d4294..e1a9ac4 100644 --- a/packages/dapp-client/src/handlers/trusted-connection-handler.ts +++ b/packages/dapp-client/src/handlers/trusted-connection-handler.ts @@ -1,11 +1,4 @@ -import { - ClientState, - ErrorCode, - type HandshakeOfferPayload, - type Session, - SessionError, - type SessionRequest, -} from "@metamask/mobile-wallet-protocol-core"; +import { ClientState, ErrorCode, type HandshakeOfferPayload, type Session, SessionError, type SessionRequest } from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import { HANDSHAKE_TIMEOUT } from "../client"; import type { IConnectionHandler } from "../domain/connection-handler"; diff --git a/packages/dapp-client/src/handlers/untrusted-connection-handler.ts b/packages/dapp-client/src/handlers/untrusted-connection-handler.ts index e881867..ea9564d 100644 --- a/packages/dapp-client/src/handlers/untrusted-connection-handler.ts +++ b/packages/dapp-client/src/handlers/untrusted-connection-handler.ts @@ -1,11 +1,4 @@ -import { - ClientState, - ErrorCode, - type HandshakeOfferPayload, - type Session, - SessionError, - type SessionRequest, -} from "@metamask/mobile-wallet-protocol-core"; +import { ClientState, ErrorCode, type HandshakeOfferPayload, type Session, SessionError, type SessionRequest } from "@metamask/mobile-wallet-protocol-core"; import { base64ToBytes } from "@metamask/utils"; import type { OtpRequiredPayload } from "../client"; import type { IConnectionHandler } from "../domain/connection-handler";