diff --git a/packages/hdwallet-core/src/ethereum.ts b/packages/hdwallet-core/src/ethereum.ts index a54e829a..213b9565 100644 --- a/packages/hdwallet-core/src/ethereum.ts +++ b/packages/hdwallet-core/src/ethereum.ts @@ -58,6 +58,19 @@ export type ETHSignTx = { data: string; /** mainnet: 1, ropsten: 3, kovan: 42 */ chainId: number; + /** + * EVM clear-signing metadata (firmware 7.14+). + * If provided, sent as EthereumTxMetadata BEFORE EthereumSignTx so the + * device OLED can display decoded contract call info instead of raw hex. + */ + txMetadata?: { + /** Cryptographically signed metadata blob from Pioneer descriptor API */ + signedPayload: Uint8Array | string; + /** Verification key slot embedded in the blob */ + keyId?: number; + /** Metadata schema version (default 1) */ + metadataVersion?: number; + }; /** * Device must `ethSupportsNativeShapeShift()` */ diff --git a/packages/hdwallet-keepkey/src/ethereum-metadata.test.ts b/packages/hdwallet-keepkey/src/ethereum-metadata.test.ts new file mode 100644 index 00000000..c8baa46e --- /dev/null +++ b/packages/hdwallet-keepkey/src/ethereum-metadata.test.ts @@ -0,0 +1,151 @@ +/** + * Unit tests for EVM clear-signing protobuf shims. + * + * Validates that EthereumTxMetadata and EthereumMetadataAck + * serialize/deserialize correctly through the same codepath + * the transport uses (deserializeBinaryFromReader). + */ +// Force registration by importing the module +import "./ethereum"; + +import * as jspb from "google-protobuf"; + +// We can't import the classes directly (not exported), so we test +// via the type registry which is the same path the transport uses. +import { messageNameRegistry, messageTypeRegistry } from "./typeRegistry"; + +const MESSAGETYPE_ETHEREUMTXMETADATA = 115; +const MESSAGETYPE_ETHEREUMMETADATAACK = 116; + +describe("EVM clear-signing protobuf shims", () => { + describe("message registration", () => { + it("registers EthereumTxMetadata (115) in type registry", () => { + expect(messageTypeRegistry[MESSAGETYPE_ETHEREUMTXMETADATA]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_ETHEREUMTXMETADATA]).toBe("EthereumTxMetadata"); + }); + + it("registers EthereumMetadataAck (116) in type registry", () => { + expect(messageTypeRegistry[MESSAGETYPE_ETHEREUMMETADATAACK]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_ETHEREUMMETADATAACK]).toBe("EthereumMetadataAck"); + }); + }); + + describe("EthereumTxMetadata round-trip", () => { + it("serializes and deserializes signed_payload + key_id + metadata_version", () => { + const MType = messageTypeRegistry[MESSAGETYPE_ETHEREUMTXMETADATA] as any; + const msg = new MType(); + + // Set fields + const testPayload = new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03]); + msg.setSignedPayload(testPayload); + msg.setKeyId(42); + msg.setMetadataVersion(1); + + // Serialize + const bytes = msg.serializeBinary(); + expect(bytes.length).toBeGreaterThan(0); + + // Deserialize via deserializeBinaryFromReader (transport codepath) + const reader = new jspb.BinaryReader(bytes); + const decoded = new MType(); + MType.deserializeBinaryFromReader(decoded, reader); + + expect(decoded.getKeyId()).toBe(42); + expect(decoded.getMetadataVersion()).toBe(1); + // Payload comes back as Uint8Array + const decodedPayload = decoded.getSignedPayload(); + expect(decodedPayload).toBeInstanceOf(Uint8Array); + expect(Array.from(decodedPayload as Uint8Array)).toEqual(Array.from(testPayload)); + }); + + it("handles empty payload gracefully", () => { + const MType = messageTypeRegistry[MESSAGETYPE_ETHEREUMTXMETADATA] as any; + const msg = new MType(); + msg.setKeyId(0); + msg.setMetadataVersion(1); + // No payload set + + const bytes = msg.serializeBinary(); + const reader = new jspb.BinaryReader(bytes); + const decoded = new MType(); + MType.deserializeBinaryFromReader(decoded, reader); + + expect(decoded.getMetadataVersion()).toBe(1); + expect(decoded.getKeyId()).toBe(0); + }); + }); + + describe("EthereumMetadataAck round-trip", () => { + it("deserializes classification=VERIFIED and display_summary", () => { + const MType = messageTypeRegistry[MESSAGETYPE_ETHEREUMMETADATAACK] as any; + + // Manually build a binary response as firmware would send it + const writer = new jspb.BinaryWriter(); + writer.writeUint32(1, 1); // classification = VERIFIED + writer.writeString(2, "Uniswap V2: swapExactETHForTokens"); + const bytes = writer.getResultBuffer(); + + // Deserialize via deserializeBinaryFromReader (transport codepath) + const reader = new jspb.BinaryReader(bytes); + const msg = new MType(); + MType.deserializeBinaryFromReader(msg, reader); + + expect(msg.getClassification()).toBe(1); + expect(msg.getDisplaySummary()).toBe("Uniswap V2: swapExactETHForTokens"); + }); + + it("deserializes classification=MALFORMED with no summary", () => { + const MType = messageTypeRegistry[MESSAGETYPE_ETHEREUMMETADATAACK] as any; + + const writer = new jspb.BinaryWriter(); + writer.writeUint32(1, 2); // classification = MALFORMED + const bytes = writer.getResultBuffer(); + + const reader = new jspb.BinaryReader(bytes); + const msg = new MType(); + MType.deserializeBinaryFromReader(msg, reader); + + expect(msg.getClassification()).toBe(2); + expect(msg.getDisplaySummary()).toBe(""); + }); + + it("deserializes classification=OPAQUE (default)", () => { + const MType = messageTypeRegistry[MESSAGETYPE_ETHEREUMMETADATAACK] as any; + + // Empty message — all defaults + const bytes = new Uint8Array(0); + const reader = new jspb.BinaryReader(bytes); + const msg = new MType(); + MType.deserializeBinaryFromReader(msg, reader); + + expect(msg.getClassification()).toBe(0); // OPAQUE + expect(msg.getDisplaySummary()).toBe(""); + }); + }); + + describe("toObject", () => { + it("EthereumTxMetadata.toObject returns expected shape", () => { + const MType = messageTypeRegistry[MESSAGETYPE_ETHEREUMTXMETADATA] as any; + const msg = new MType(); + msg.setSignedPayload(new Uint8Array([1, 2, 3])); + msg.setKeyId(7); + msg.setMetadataVersion(1); + + const obj = msg.toObject(); + expect(obj).toHaveProperty("signedPayload"); + expect(obj).toHaveProperty("keyId", 7); + expect(obj).toHaveProperty("metadataVersion", 1); + }); + + it("EthereumMetadataAck.toObject returns expected shape", () => { + const MType = messageTypeRegistry[MESSAGETYPE_ETHEREUMMETADATAACK] as any; + const msg = new MType(); + // Simulate setting fields as firmware would + jspb.Message.setField(msg, 1, 1); + jspb.Message.setField(msg, 2, "test summary"); + + const obj = msg.toObject(); + expect(obj).toEqual({ classification: 1, displaySummary: "test summary" }); + }); + }); +}); diff --git a/packages/hdwallet-keepkey/src/ethereum.ts b/packages/hdwallet-keepkey/src/ethereum.ts index 94ce8bca..051a2fef 100644 --- a/packages/hdwallet-keepkey/src/ethereum.ts +++ b/packages/hdwallet-keepkey/src/ethereum.ts @@ -6,10 +6,199 @@ import * as Types from "@keepkey/device-protocol/lib/types_pb"; import * as core from "@keepkey/hdwallet-core"; import { SignTypedDataVersion, TypedDataUtils } from "@metamask/eth-sig-util"; import * as eip55 from "eip55"; +import * as jspb from "google-protobuf"; import { Transport } from "./transport"; +import { messageNameRegistry, messageTypeRegistry } from "./typeRegistry"; import { toUTF8Array } from "./utils"; +// ── EVM Clear-Signing Message Types (firmware 7.14+) ───────────────── +// Message type IDs from device-protocol-clear-signing/messages.proto +const MESSAGETYPE_ETHEREUMTXMETADATA = 115; +const MESSAGETYPE_ETHEREUMMETADATAACK = 116; + +// ── EVM Metadata Classification (from EthereumMetadataAck) ─────────── +/** Device could not verify the blob (unsigned or unknown key) */ +const _METADATA_OPAQUE = 0; // eslint-disable-line @typescript-eslint/no-unused-vars +/** Device verified the blob signature — OLED will show decoded info */ +const METADATA_VERIFIED = 1; +/** Blob structure is invalid or corrupted */ +const METADATA_MALFORMED = 2; + +/** + * EthereumTxMetadata: sent BEFORE EthereumSignTx to provide signed + * metadata for clear-signing on the device OLED. + * + * Proto definition: + * message EthereumTxMetadata { + * optional bytes signed_payload = 1; + * optional uint32 metadata_version = 2; + * optional uint32 key_id = 3; + * } + */ +class EthereumTxMetadata extends jspb.Message { + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, null, null); + } + + getSignedPayload(): Uint8Array | string { + return jspb.Message.getFieldWithDefault(this, 1, "") as Uint8Array | string; + } + setSignedPayload(value: Uint8Array | string): void { + jspb.Message.setField(this, 1, value); + } + + getMetadataVersion(): number { + return jspb.Message.getFieldWithDefault(this, 2, 1) as number; + } + setMetadataVersion(value: number): void { + jspb.Message.setField(this, 2, value); + } + + getKeyId(): number { + return jspb.Message.getFieldWithDefault(this, 3, 0) as number; + } + setKeyId(value: number): void { + jspb.Message.setField(this, 3, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + EthereumTxMetadata.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + static serializeBinaryToWriter(message: EthereumTxMetadata, writer: jspb.BinaryWriter): void { + const payload = message.getSignedPayload(); + if (payload && (typeof payload === "string" ? payload.length > 0 : payload.length > 0)) { + writer.writeBytes(1, payload); + } + // Always write metadata_version — device needs it to parse the blob correctly + writer.writeUint32(2, message.getMetadataVersion()); + const keyId = message.getKeyId(); + if (keyId !== 0) writer.writeUint32(3, keyId); + } + + static deserializeBinary(bytes: Uint8Array): EthereumTxMetadata { + const reader = new jspb.BinaryReader(bytes); + const msg = new EthereumTxMetadata(); + return EthereumTxMetadata.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: EthereumTxMetadata, reader: jspb.BinaryReader): EthereumTxMetadata { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + switch (reader.getFieldNumber()) { + case 1: + msg.setSignedPayload(reader.readBytes()); + break; + case 2: + msg.setMetadataVersion(reader.readUint32()); + break; + case 3: + msg.setKeyId(reader.readUint32()); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + toObject(): { signedPayload: Uint8Array | string; metadataVersion: number; keyId: number } { + return { + signedPayload: this.getSignedPayload(), + metadataVersion: this.getMetadataVersion(), + keyId: this.getKeyId(), + }; + } + static toObject(_includeInstance: boolean, msg: EthereumTxMetadata) { + return msg.toObject(); + } +} + +/** + * EthereumMetadataAck: device response after receiving EthereumTxMetadata. + * + * Proto definition: + * message EthereumMetadataAck { + * required uint32 classification = 1; // 0=OPAQUE, 1=VERIFIED, 2=MALFORMED + * optional string display_summary = 2; + * } + */ +class EthereumMetadataAck extends jspb.Message { + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, null, null); + } + + /** 0=OPAQUE, 1=VERIFIED, 2=MALFORMED */ + getClassification(): number { + return jspb.Message.getFieldWithDefault(this, 1, 0) as number; + } + + getDisplaySummary(): string { + return jspb.Message.getFieldWithDefault(this, 2, "") as string; + } + + serializeBinary(): Uint8Array { + // MetadataAck is device→host only, but implement properly for completeness + const writer = new jspb.BinaryWriter(); + const c = this.getClassification(); + if (c !== 0) writer.writeUint32(1, c); + const s = this.getDisplaySummary(); + if (s) writer.writeString(2, s); + return writer.getResultBuffer(); + } + + static deserializeBinary(bytes: Uint8Array): EthereumMetadataAck { + const reader = new jspb.BinaryReader(bytes); + const msg = new EthereumMetadataAck(); + return EthereumMetadataAck.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: EthereumMetadataAck, reader: jspb.BinaryReader): EthereumMetadataAck { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + switch (reader.getFieldNumber()) { + case 1: + jspb.Message.setField(msg, 1, reader.readUint32()); + break; + case 2: + jspb.Message.setField(msg, 2, reader.readString()); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + toObject(): { classification: number; displaySummary: string } { + return { classification: this.getClassification(), displaySummary: this.getDisplaySummary() }; + } + static toObject(_includeInstance: boolean, msg: EthereumMetadataAck) { + return msg.toObject(); + } +} + +// ── Register EVM clear-signing messages ────────────────────────────── +function registerEthClearSignMessages() { + const mt = Messages.MessageType as unknown as Record; + mt["MESSAGETYPE_ETHEREUMTXMETADATA"] = MESSAGETYPE_ETHEREUMTXMETADATA; + mt["MESSAGETYPE_ETHEREUMMETADATAACK"] = MESSAGETYPE_ETHEREUMMETADATAACK; + + messageNameRegistry[MESSAGETYPE_ETHEREUMTXMETADATA] = "EthereumTxMetadata"; + messageNameRegistry[MESSAGETYPE_ETHEREUMMETADATAACK] = "EthereumMetadataAck"; + + messageTypeRegistry[MESSAGETYPE_ETHEREUMTXMETADATA] = EthereumTxMetadata as any; + messageTypeRegistry[MESSAGETYPE_ETHEREUMMETADATAACK] = EthereumMetadataAck as any; +} +registerEthClearSignMessages(); + function isHexString(value: string): boolean { return typeof value === "string" && /^0x[0-9a-fA-F]*$/.test(value); } @@ -62,6 +251,53 @@ function stripLeadingZeroes(buf: Uint8Array) { export async function ethSignTx(transport: Transport, msg: core.ETHSignTx): Promise { return transport.lockDuring(async () => { + // ── EVM Clear-Signing: send metadata BEFORE EthereumSignTx ────── + // If txMetadata is present, the firmware can verify the signed blob + // and display decoded contract call info on the OLED instead of raw hex. + if (msg.txMetadata?.signedPayload) { + const meta = new EthereumTxMetadata(); + const payload = msg.txMetadata.signedPayload; + if (typeof payload === "string") { + // Hex string → bytes + meta.setSignedPayload(core.arrayify(payload.startsWith("0x") ? payload : "0x" + payload)); + } else { + meta.setSignedPayload(payload); + } + if (msg.txMetadata.keyId !== undefined) { + meta.setKeyId(msg.txMetadata.keyId); + } + if (msg.txMetadata.metadataVersion !== undefined) { + meta.setMetadataVersion(msg.txMetadata.metadataVersion); + } + + try { + const metaResponse = await transport.call(MESSAGETYPE_ETHEREUMTXMETADATA, meta, { + msgTimeout: core.DEFAULT_TIMEOUT, + omitLock: true, + }); + const ack = metaResponse.proto as EthereumMetadataAck; + const classification = ack.getClassification(); + const classLabel = + classification === METADATA_VERIFIED + ? "VERIFIED" + : classification === METADATA_MALFORMED + ? "MALFORMED" + : "OPAQUE"; + // eslint-disable-next-line no-console -- intentional diagnostics for clear-sign debugging + console.warn( + `[hdwallet] EthereumTxMetadata response: ${classLabel} (${classification})` + + ` summary="${ack.getDisplaySummary()}"` + ); + if (classification === METADATA_MALFORMED) { + console.warn("[hdwallet] Metadata blob is MALFORMED — device will fall back to blind signing"); + } + } catch (e) { + // Metadata send failed — fall through to regular signing (blind mode) + // This is non-fatal: older firmware versions don't support this message. + console.warn("[hdwallet] EthereumTxMetadata not supported or failed, falling back to blind signing:", e); + } + } + const est: Ethereum.EthereumSignTx = new Ethereum.EthereumSignTx(); est.setAddressNList(msg.addressNList); est.setNonce(stripLeadingZeroes(core.arrayify(msg.nonce)));