From 7be710041123a8d8f66cafa213955e457b9d65a4 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 16:57:24 -0500 Subject: [PATCH 1/5] feat(keepkey): jspb classes + wallet methods for message-signing parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds KeepKey wallet support for the firmware 7.14.1 message-signing features: TRON (TIP-191 + TIP-712): - TronSignMessage / TronMessageSignature jspb shims (1404/1405) - TronVerifyMessage jspb shim (1406) - TronSignTypedHash / TronTypedDataSignature jspb shims (1407/1408) - tronSignMessage / tronVerifyMessage / tronSignTypedHash wrapper functions - Matching KeepKey class methods + core type extensions TON: - TonSignMessage / TonMessageSignature jspb shims (1504/1505) - tonSignMessage wrapper + KeepKey method + core types - Note: firmware fences this behind AdvancedMode policy until TON Connect ton_proof envelope is added — caller will get a Failure response if the policy is disabled Solana off-chain message: - SolanaSignOffchainMessage / SolanaOffchainMessageSignature jspb shims (756/757) - solanaSignOffchainMessage wrapper + KeepKey method + core types - This is the domain-separated path; firmware constructs the '\xff' || 'solana offchain' || version || format || length || msg envelope and Ed25519-signs it. Format 2 (extended UTF-8) is rejected device-side; only formats 0 (ASCII) and 1 (UTF-8 limited, max 1212 bytes) are supported. All wrappers follow the existing transport.lockDuring + transport.call pattern used by tronSignTx / tonSignTx / solanaSignTx. Hand-rolled jspb.Message subclasses are wire-compatible with the firmware's generated nanopb encoding. --- packages/hdwallet-core/src/solana.ts | 18 + packages/hdwallet-core/src/ton.ts | 14 + packages/hdwallet-core/src/tron.ts | 37 ++ packages/hdwallet-keepkey/src/keepkey.ts | 22 + packages/hdwallet-keepkey/src/solana.ts | 323 +++++++++++ packages/hdwallet-keepkey/src/ton.ts | 270 +++++++++ packages/hdwallet-keepkey/src/tron.ts | 686 +++++++++++++++++++++++ 7 files changed, 1370 insertions(+) diff --git a/packages/hdwallet-core/src/solana.ts b/packages/hdwallet-core/src/solana.ts index 81be8373..70fec107 100644 --- a/packages/hdwallet-core/src/solana.ts +++ b/packages/hdwallet-core/src/solana.ts @@ -32,8 +32,26 @@ export interface SolanaWalletInfo extends HDWalletInfo { solanaNextAccountPath(msg: SolanaAccountPath): SolanaAccountPath | undefined; } +// ── Off-chain message signing (domain-separated envelope) ──────────── + +export interface SolanaSignOffchainMessage { + addressNList: BIP32Path; + /** Off-chain message spec version. Only 0 is currently defined. */ + version?: number; + /** 0 = restricted ASCII, 1 = UTF-8 limited (max 1212 bytes). Format 2 is not supported on KeepKey. */ + messageFormat?: number; + message: Uint8Array | string; + showDisplay?: boolean; +} + +export interface SolanaOffchainMessageSignature { + publicKey: Uint8Array | string; + signature: Uint8Array | string; +} + export interface SolanaWallet extends SolanaWalletInfo, HDWallet { readonly _supportsSolana: boolean; solanaGetAddress(msg: SolanaGetAddress): Promise; solanaSignTx(msg: SolanaSignTx): Promise; + solanaSignOffchainMessage(msg: SolanaSignOffchainMessage): Promise; } diff --git a/packages/hdwallet-core/src/ton.ts b/packages/hdwallet-core/src/ton.ts index ed491499..dd1e2650 100644 --- a/packages/hdwallet-core/src/ton.ts +++ b/packages/hdwallet-core/src/ton.ts @@ -44,8 +44,22 @@ export interface TonWalletInfo extends HDWalletInfo { tonNextAccountPath(msg: TonAccountPath): TonAccountPath | undefined; } +// ── Bare Ed25519 message signing (AdvancedMode-gated firmware-side) ── + +export interface TonSignMessage { + addressNList: BIP32Path; + message: Uint8Array | string; + showDisplay?: boolean; +} + +export interface TonMessageSignature { + publicKey: Uint8Array | string; + signature: Uint8Array | string; +} + export interface TonWallet extends TonWalletInfo, HDWallet { readonly _supportsTon: boolean; tonGetAddress(msg: TonGetAddress): Promise; tonSignTx(msg: TonSignTx): Promise; + tonSignMessage(msg: TonSignMessage): Promise; } diff --git a/packages/hdwallet-core/src/tron.ts b/packages/hdwallet-core/src/tron.ts index 3921ebc5..bd3b9850 100644 --- a/packages/hdwallet-core/src/tron.ts +++ b/packages/hdwallet-core/src/tron.ts @@ -34,8 +34,45 @@ export interface TronWalletInfo extends HDWalletInfo { tronNextAccountPath(msg: TronAccountPath): TronAccountPath | undefined; } +// ── TIP-191 personal_sign ───────────────────────────────────────────── + +export interface TronSignMessage { + addressNList: BIP32Path; + message: Uint8Array | string; + showDisplay?: boolean; +} + +export interface TronMessageSignature { + address: string; + signature: Uint8Array | string; +} + +export interface TronVerifyMessage { + address: string; + signature: Uint8Array | string; + message: Uint8Array | string; +} + +// ── TIP-712 typed-data hash mode ────────────────────────────────────── + +export interface TronSignTypedHash { + addressNList: BIP32Path; + /** 32-byte domainSeparator hash (host-precomputed per TIP-712 spec) */ + domainSeparatorHash: Uint8Array | string; + /** 32-byte message hash; omit for primaryType=EIP712Domain */ + messageHash?: Uint8Array | string; +} + +export interface TronTypedDataSignature { + address: string; + signature: Uint8Array | string; +} + export interface TronWallet extends TronWalletInfo, HDWallet { readonly _supportsTron: boolean; tronGetAddress(msg: TronGetAddress): Promise; tronSignTx(msg: TronSignTx): Promise; + tronSignMessage(msg: TronSignMessage): Promise; + tronVerifyMessage(msg: TronVerifyMessage): Promise; + tronSignTypedHash(msg: TronSignTypedHash): Promise; } diff --git a/packages/hdwallet-keepkey/src/keepkey.ts b/packages/hdwallet-keepkey/src/keepkey.ts index ff3aec8f..0073358f 100644 --- a/packages/hdwallet-keepkey/src/keepkey.ts +++ b/packages/hdwallet-keepkey/src/keepkey.ts @@ -1489,6 +1489,12 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW return Solana.solanaSignTx(this.transport, msg); } + public solanaSignOffchainMessage( + msg: core.SolanaSignOffchainMessage, + ): Promise { + return Solana.solanaSignOffchainMessage(this.transport, msg); + } + public solanaSignMessage(msg: { addressNList: number[]; message: Uint8Array | string; @@ -1513,6 +1519,18 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW return Tron.tronSignTx(this.transport, msg); } + public tronSignMessage(msg: core.TronSignMessage): Promise { + return Tron.tronSignMessage(this.transport, msg); + } + + public tronVerifyMessage(msg: core.TronVerifyMessage): Promise { + return Tron.tronVerifyMessage(this.transport, msg); + } + + public tronSignTypedHash(msg: core.TronSignTypedHash): Promise { + return Tron.tronSignTypedHash(this.transport, msg); + } + public tronNextAccountPath(msg: core.TronAccountPath): core.TronAccountPath | undefined { return this.info.tronNextAccountPath(msg); } @@ -1529,6 +1547,10 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW return Ton.tonSignTx(this.transport, msg); } + public tonSignMessage(msg: core.TonSignMessage): Promise { + return Ton.tonSignMessage(this.transport, msg); + } + public tonNextAccountPath(msg: core.TonAccountPath): core.TonAccountPath | undefined { return this.info.tonNextAccountPath(msg); } diff --git a/packages/hdwallet-keepkey/src/solana.ts b/packages/hdwallet-keepkey/src/solana.ts index 623f4321..1084cfcd 100644 --- a/packages/hdwallet-keepkey/src/solana.ts +++ b/packages/hdwallet-keepkey/src/solana.ts @@ -16,6 +16,8 @@ const MESSAGETYPE_SOLANASIGNTX = 752; const MESSAGETYPE_SOLANASIGNEDTX = 753; const MESSAGETYPE_SOLANASIGNMESSAGE = 754; const MESSAGETYPE_SOLANAMESSAGESIGNATURE = 755; +const MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE = 756; +const MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE = 757; // ── Protobuf Shims ────────────────────────────────────────────────── // Hand-rolled jspb.Message subclasses matching messages-solana.proto. @@ -616,6 +618,273 @@ export namespace SolanaMessageSignature { }; } +/** + * SolanaSignOffchainMessage: address_n(1, repeated uint32), coin_name(2, string), + * version(3, uint32), message_format(4, uint32), + * message(5, bytes), show_display(6, bool) + * + * Per the Solana off-chain message spec, firmware signs the envelope: + * "\xff" || "solana offchain" || version:u8 || format:u8 || + * length:u16 LE || message + * + * The 0xFF lead byte is invalid as a Solana transaction prefix, so a signed + * off-chain message can NEVER be replayed as a transaction — this is the + * domain separation that bare SolanaSignMessage lacks. + */ +export class SolanaSignOffchainMessage extends jspb.Message { + static repeatedFields_ = [1]; + + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, SolanaSignOffchainMessage.repeatedFields_, null); + } + + getAddressNList(): number[] { + return Msg.getRepeatedField(this, 1) as number[]; + } + setAddressNList(value: number[]): void { + jspb.Message.setField(this, 1, value || []); + } + addAddressN(value: number): void { + jspb.Message.addToRepeatedField(this, 1, value); + } + + getCoinName(): string | undefined { + return jspb.Message.getFieldWithDefault(this, 2, "Solana") as string; + } + setCoinName(value: string): void { + jspb.Message.setField(this, 2, value); + } + + getVersion(): number | undefined { + const f = jspb.Message.getField(this, 3); + return f == null ? undefined : Number(f); + } + setVersion(value: number): void { + jspb.Message.setField(this, 3, value); + } + + getMessageFormat(): number | undefined { + const f = jspb.Message.getField(this, 4); + return f == null ? undefined : Number(f); + } + setMessageFormat(value: number): void { + jspb.Message.setField(this, 4, value); + } + + getMessage(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 5) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setMessage(value: Uint8Array): void { + jspb.Message.setField(this, 5, value); + } + + getShowDisplay(): boolean | undefined { + const f = jspb.Message.getField(this, 6); + return f == null ? undefined : !!f; + } + setShowDisplay(value: boolean): void { + jspb.Message.setField(this, 6, value ? 1 : 0); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + SolanaSignOffchainMessage.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): SolanaSignOffchainMessage.AsObject { + return { + addressNList: this.getAddressNList(), + coinName: this.getCoinName(), + version: this.getVersion(), + messageFormat: this.getMessageFormat(), + message: this.getMessage(), + showDisplay: this.getShowDisplay(), + }; + } + + static toObject(_includeInstance: boolean, msg: SolanaSignOffchainMessage): SolanaSignOffchainMessage.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): SolanaSignOffchainMessage { + const reader = new jspb.BinaryReader(bytes); + const msg = new SolanaSignOffchainMessage(); + return SolanaSignOffchainMessage.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader( + msg: SolanaSignOffchainMessage, + reader: jspb.BinaryReader, + ): SolanaSignOffchainMessage { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: { + const values = reader.isDelimited() ? reader.readPackedUint32() : [reader.readUint32()]; + for (const v of values) msg.addAddressN(v); + break; + } + case 2: + msg.setCoinName(reader.readString()); + break; + case 3: + msg.setVersion(reader.readUint32()); + break; + case 4: + msg.setMessageFormat(reader.readUint32()); + break; + case 5: + msg.setMessage(reader.readBytes() as Uint8Array); + break; + case 6: + msg.setShowDisplay(reader.readBool()); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: SolanaSignOffchainMessage, writer: jspb.BinaryWriter): void { + const addressN = message.getAddressNList(); + if (addressN.length > 0) { + writer.writeRepeatedUint32(1, addressN); + } + const coinName = jspb.Message.getField(message, 2) as string | null; + if (coinName != null) { + writer.writeString(2, coinName); + } + const version = jspb.Message.getField(message, 3); + if (version != null) { + writer.writeUint32(3, Number(version)); + } + const fmt = jspb.Message.getField(message, 4); + if (fmt != null) { + writer.writeUint32(4, Number(fmt)); + } + const messageBytes = jspb.Message.getField(message, 5) as Uint8Array | null; + if (messageBytes != null) { + writer.writeBytes(5, messageBytes); + } + const showDisplay = jspb.Message.getField(message, 6); + if (showDisplay != null) { + writer.writeBool(6, !!showDisplay); + } + } +} + +export namespace SolanaSignOffchainMessage { + export type AsObject = { + addressNList: number[]; + coinName?: string; + version?: number; + messageFormat?: number; + message?: Uint8Array; + showDisplay?: boolean; + }; +} + +/** + * SolanaOffchainMessageSignature: public_key(1, bytes), signature(2, bytes) + */ +export class SolanaOffchainMessageSignature extends jspb.Message { + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, [], null); + } + + getPublicKey(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 1) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setPublicKey(value: Uint8Array): void { + jspb.Message.setField(this, 1, value); + } + + getSignature(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 2) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setSignature(value: Uint8Array): void { + jspb.Message.setField(this, 2, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + SolanaOffchainMessageSignature.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): SolanaOffchainMessageSignature.AsObject { + return { + publicKey: this.getPublicKey(), + signature: this.getSignature(), + }; + } + + static toObject( + _includeInstance: boolean, + msg: SolanaOffchainMessageSignature, + ): SolanaOffchainMessageSignature.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): SolanaOffchainMessageSignature { + const reader = new jspb.BinaryReader(bytes); + const msg = new SolanaOffchainMessageSignature(); + return SolanaOffchainMessageSignature.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader( + msg: SolanaOffchainMessageSignature, + reader: jspb.BinaryReader, + ): SolanaOffchainMessageSignature { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: + msg.setPublicKey(reader.readBytes() as Uint8Array); + break; + case 2: + msg.setSignature(reader.readBytes() as Uint8Array); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: SolanaOffchainMessageSignature, writer: jspb.BinaryWriter): void { + const pk = jspb.Message.getField(message, 1) as Uint8Array | null; + if (pk != null) { + writer.writeBytes(1, pk); + } + const sig = jspb.Message.getField(message, 2) as Uint8Array | null; + if (sig != null) { + writer.writeBytes(2, sig); + } + } +} + +export namespace SolanaOffchainMessageSignature { + export type AsObject = { + publicKey?: Uint8Array; + signature?: Uint8Array; + }; +} + // ── Runtime Registration ────────────────────────────────────────────── // Inject Solana message types into the KeepKey transport registries. // This allows transport.call() / fromMessageBuffer() to encode/decode them. @@ -629,6 +898,8 @@ function registerSolanaMessages() { mt["MESSAGETYPE_SOLANASIGNEDTX"] = MESSAGETYPE_SOLANASIGNEDTX; mt["MESSAGETYPE_SOLANASIGNMESSAGE"] = MESSAGETYPE_SOLANASIGNMESSAGE; mt["MESSAGETYPE_SOLANAMESSAGESIGNATURE"] = MESSAGETYPE_SOLANAMESSAGESIGNATURE; + mt["MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE"] = MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE; + mt["MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE"] = MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE; // Register name lookup (for readResponse event emitting) messageNameRegistry[MESSAGETYPE_SOLANAGETADDRESS] = "SolanaGetAddress"; @@ -637,6 +908,8 @@ function registerSolanaMessages() { messageNameRegistry[MESSAGETYPE_SOLANASIGNEDTX] = "SolanaSignedTx"; messageNameRegistry[MESSAGETYPE_SOLANASIGNMESSAGE] = "SolanaSignMessage"; messageNameRegistry[MESSAGETYPE_SOLANAMESSAGESIGNATURE] = "SolanaMessageSignature"; + messageNameRegistry[MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE] = "SolanaSignOffchainMessage"; + messageNameRegistry[MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE] = "SolanaOffchainMessageSignature"; // Register protobuf constructors (for fromMessageBuffer deserialization) messageTypeRegistry[MESSAGETYPE_SOLANAGETADDRESS] = SolanaGetAddress as any; @@ -645,6 +918,8 @@ function registerSolanaMessages() { messageTypeRegistry[MESSAGETYPE_SOLANASIGNEDTX] = SolanaSignedTx as any; messageTypeRegistry[MESSAGETYPE_SOLANASIGNMESSAGE] = SolanaSignMessage as any; messageTypeRegistry[MESSAGETYPE_SOLANAMESSAGESIGNATURE] = SolanaMessageSignature as any; + messageTypeRegistry[MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE] = SolanaSignOffchainMessage as any; + messageTypeRegistry[MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE] = SolanaOffchainMessageSignature as any; } // Register on module load @@ -762,3 +1037,51 @@ export async function solanaSignMessage( }; }); } + +/** + * solanaSignOffchainMessage — domain-separated Ed25519 message signing. + * + * Firmware constructs the spec envelope from (version, message_format, + * message) and Ed25519-signs it. NO AdvancedMode gate is needed for this + * primitive — the envelope's 0xFF prefix provides domain separation that + * bare SolanaSignMessage lacks. Format 2 (extended UTF-8) is rejected + * device-side; only formats 0 (ASCII) and 1 (UTF-8 limited, max 1212 bytes) + * are supported. + */ +export async function solanaSignOffchainMessage( + transport: Transport, + msg: core.SolanaSignOffchainMessage, +): Promise { + return transport.lockDuring(async () => { + const signMsg = new SolanaSignOffchainMessage(); + signMsg.setAddressNList(msg.addressNList); + + if (msg.version !== undefined) signMsg.setVersion(msg.version); + if (msg.messageFormat !== undefined) signMsg.setMessageFormat(msg.messageFormat); + + const messageBytes = + msg.message instanceof Uint8Array + ? msg.message + : typeof msg.message === "string" + ? new TextEncoder().encode(msg.message) + : new Uint8Array(msg.message as any); + signMsg.setMessage(messageBytes); + + if (msg.showDisplay !== undefined) signMsg.setShowDisplay(msg.showDisplay); + + const resp = await transport.call(MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE, signMsg, { + msgTimeout: core.LONG_TIMEOUT, + omitLock: true, + }); + + if (resp.message_enum !== MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE) { + throw new Error(`solana: unexpected response ${resp.message_type}`); + } + + const sig = resp.proto as SolanaOffchainMessageSignature; + return { + publicKey: core.mustBeDefined(sig.getPublicKey()), + signature: core.mustBeDefined(sig.getSignature()), + }; + }); +} diff --git a/packages/hdwallet-keepkey/src/ton.ts b/packages/hdwallet-keepkey/src/ton.ts index 3c1a6e80..e74dfcd7 100644 --- a/packages/hdwallet-keepkey/src/ton.ts +++ b/packages/hdwallet-keepkey/src/ton.ts @@ -13,6 +13,8 @@ const MESSAGETYPE_TONGETADDRESS = 1500; const MESSAGETYPE_TONADDRESS = 1501; const MESSAGETYPE_TONSIGNTX = 1502; const MESSAGETYPE_TONSIGNEDTX = 1503; +const MESSAGETYPE_TONSIGNMESSAGE = 1504; +const MESSAGETYPE_TONMESSAGESIGNATURE = 1505; // ── Protobuf Shims ────────────────────────────────────────────────── // Hand-rolled jspb.Message subclasses matching messages-ton.proto. @@ -591,6 +593,227 @@ export namespace TonSignedTx { }; } +/** + * TonSignMessage: address_n(1, repeated uint32), coin_name(2, string), + * message(3, bytes), show_display(4, bool) + * + * Bare Ed25519 over message bytes — no domain separation. Firmware fences + * this behind the AdvancedMode policy until TON Connect's ton_proof + * envelope is added as a separate proto. + */ +export class TonSignMessage extends jspb.Message { + static repeatedFields_ = [1]; + + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, TonSignMessage.repeatedFields_, null); + } + + getAddressNList(): number[] { + return Msg.getRepeatedField(this, 1) as number[]; + } + setAddressNList(value: number[]): void { + jspb.Message.setField(this, 1, value || []); + } + addAddressN(value: number): void { + jspb.Message.addToRepeatedField(this, 1, value); + } + + getCoinName(): string | undefined { + return jspb.Message.getFieldWithDefault(this, 2, "Ton") as string; + } + setCoinName(value: string): void { + jspb.Message.setField(this, 2, value); + } + + getMessage(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 3) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setMessage(value: Uint8Array): void { + jspb.Message.setField(this, 3, value); + } + + getShowDisplay(): boolean | undefined { + const f = jspb.Message.getField(this, 4); + return f == null ? undefined : !!f; + } + setShowDisplay(value: boolean): void { + jspb.Message.setField(this, 4, value ? 1 : 0); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + TonSignMessage.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): TonSignMessage.AsObject { + return { + addressNList: this.getAddressNList(), + coinName: this.getCoinName(), + message: this.getMessage(), + showDisplay: this.getShowDisplay(), + }; + } + + static toObject(_includeInstance: boolean, msg: TonSignMessage): TonSignMessage.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): TonSignMessage { + const reader = new jspb.BinaryReader(bytes); + const msg = new TonSignMessage(); + return TonSignMessage.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: TonSignMessage, reader: jspb.BinaryReader): TonSignMessage { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: { + const values = reader.isDelimited() ? reader.readPackedUint32() : [reader.readUint32()]; + for (const v of values) msg.addAddressN(v); + break; + } + case 2: + msg.setCoinName(reader.readString()); + break; + case 3: + msg.setMessage(reader.readBytes() as Uint8Array); + break; + case 4: + msg.setShowDisplay(reader.readBool()); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: TonSignMessage, writer: jspb.BinaryWriter): void { + const addressN = message.getAddressNList(); + if (addressN.length > 0) { + writer.writeRepeatedUint32(1, addressN); + } + const coinName = jspb.Message.getField(message, 2) as string | null; + if (coinName != null) { + writer.writeString(2, coinName); + } + const messageBytes = jspb.Message.getField(message, 3) as Uint8Array | null; + if (messageBytes != null) { + writer.writeBytes(3, messageBytes); + } + const showDisplay = jspb.Message.getField(message, 4); + if (showDisplay != null) { + writer.writeBool(4, !!showDisplay); + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TonSignMessage { + export type AsObject = { + addressNList: number[]; + coinName?: string; + message?: Uint8Array; + showDisplay?: boolean; + }; +} + +/** + * TonMessageSignature: public_key(1, bytes), signature(2, bytes) + */ +export class TonMessageSignature extends jspb.Message { + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, [], null); + } + + getPublicKey(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 1) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setPublicKey(value: Uint8Array): void { + jspb.Message.setField(this, 1, value); + } + + getSignature(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 2) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setSignature(value: Uint8Array): void { + jspb.Message.setField(this, 2, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + TonMessageSignature.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): TonMessageSignature.AsObject { + return { + publicKey: this.getPublicKey(), + signature: this.getSignature(), + }; + } + + static toObject(_includeInstance: boolean, msg: TonMessageSignature): TonMessageSignature.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): TonMessageSignature { + const reader = new jspb.BinaryReader(bytes); + const msg = new TonMessageSignature(); + return TonMessageSignature.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: TonMessageSignature, reader: jspb.BinaryReader): TonMessageSignature { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: + msg.setPublicKey(reader.readBytes() as Uint8Array); + break; + case 2: + msg.setSignature(reader.readBytes() as Uint8Array); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: TonMessageSignature, writer: jspb.BinaryWriter): void { + const pk = jspb.Message.getField(message, 1) as Uint8Array | null; + if (pk != null) { + writer.writeBytes(1, pk); + } + const sig = jspb.Message.getField(message, 2) as Uint8Array | null; + if (sig != null) { + writer.writeBytes(2, sig); + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TonMessageSignature { + export type AsObject = { + publicKey?: Uint8Array; + signature?: Uint8Array; + }; +} + // ── Runtime Registration ────────────────────────────────────────────── // Inject TON message types into the KeepKey transport registries. @@ -600,16 +823,22 @@ export function registerTonMessages() { mt["MESSAGETYPE_TONADDRESS"] = MESSAGETYPE_TONADDRESS; mt["MESSAGETYPE_TONSIGNTX"] = MESSAGETYPE_TONSIGNTX; mt["MESSAGETYPE_TONSIGNEDTX"] = MESSAGETYPE_TONSIGNEDTX; + mt["MESSAGETYPE_TONSIGNMESSAGE"] = MESSAGETYPE_TONSIGNMESSAGE; + mt["MESSAGETYPE_TONMESSAGESIGNATURE"] = MESSAGETYPE_TONMESSAGESIGNATURE; messageNameRegistry[MESSAGETYPE_TONGETADDRESS] = "TonGetAddress"; messageNameRegistry[MESSAGETYPE_TONADDRESS] = "TonAddress"; messageNameRegistry[MESSAGETYPE_TONSIGNTX] = "TonSignTx"; messageNameRegistry[MESSAGETYPE_TONSIGNEDTX] = "TonSignedTx"; + messageNameRegistry[MESSAGETYPE_TONSIGNMESSAGE] = "TonSignMessage"; + messageNameRegistry[MESSAGETYPE_TONMESSAGESIGNATURE] = "TonMessageSignature"; messageTypeRegistry[MESSAGETYPE_TONGETADDRESS] = TonGetAddress as any; messageTypeRegistry[MESSAGETYPE_TONADDRESS] = TonAddress as any; messageTypeRegistry[MESSAGETYPE_TONSIGNTX] = TonSignTx as any; messageTypeRegistry[MESSAGETYPE_TONSIGNEDTX] = TonSignedTx as any; + messageTypeRegistry[MESSAGETYPE_TONSIGNMESSAGE] = TonSignMessage as any; + messageTypeRegistry[MESSAGETYPE_TONMESSAGESIGNATURE] = TonMessageSignature as any; } // Register on module load @@ -686,3 +915,44 @@ export async function tonSignTx(transport: Transport, msg: core.TonSignTx): Prom }; }); } + +/** + * tonSignMessage — bare Ed25519 over message bytes. + * + * The firmware handler is fenced behind the AdvancedMode policy. With the + * policy disabled (the default) this call returns a Failure response. + */ +export async function tonSignMessage( + transport: Transport, + msg: core.TonSignMessage, +): Promise { + return transport.lockDuring(async () => { + const signMsg = new TonSignMessage(); + signMsg.setAddressNList(msg.addressNList); + + const messageBytes = + msg.message instanceof Uint8Array + ? msg.message + : typeof msg.message === "string" + ? new TextEncoder().encode(msg.message) + : new Uint8Array(msg.message as any); + signMsg.setMessage(messageBytes); + + if (msg.showDisplay !== undefined) signMsg.setShowDisplay(msg.showDisplay); + + const resp = await transport.call(MESSAGETYPE_TONSIGNMESSAGE, signMsg, { + msgTimeout: core.LONG_TIMEOUT, + omitLock: true, + }); + + if (resp.message_enum !== MESSAGETYPE_TONMESSAGESIGNATURE) { + throw new Error(`ton: unexpected response ${resp.message_type}`); + } + + const sig = resp.proto as TonMessageSignature; + return { + publicKey: core.mustBeDefined(sig.getPublicKey()), + signature: core.mustBeDefined(sig.getSignature()), + }; + }); +} diff --git a/packages/hdwallet-keepkey/src/tron.ts b/packages/hdwallet-keepkey/src/tron.ts index 2f7962d9..afb78cd6 100644 --- a/packages/hdwallet-keepkey/src/tron.ts +++ b/packages/hdwallet-keepkey/src/tron.ts @@ -13,6 +13,12 @@ const MESSAGETYPE_TRONGETADDRESS = 1400; const MESSAGETYPE_TRONADDRESS = 1401; const MESSAGETYPE_TRONSIGNTX = 1402; const MESSAGETYPE_TRONSIGNEDTX = 1403; +const MESSAGETYPE_TRONSIGNMESSAGE = 1404; +const MESSAGETYPE_TRONMESSAGESIGNATURE = 1405; +const MESSAGETYPE_TRONVERIFYMESSAGE = 1406; +const MESSAGETYPE_TRONSIGNTYPEDHASH = 1407; +const MESSAGETYPE_TRONTYPEDDATASIGNATURE = 1408; +const MESSAGETYPE_SUCCESS = 2; // ── Protobuf Shims ────────────────────────────────────────────────── // Hand-rolled jspb.Message subclasses matching firmware Tron protobuf. @@ -501,6 +507,553 @@ export namespace TronSignedTx { }; } +/** + * TronSignMessage: address_n(1, repeated uint32), coin_name(2, string), message(3, bytes), show_display(4, bool) + * + * TIP-191 personal_sign request: + * keccak256("\x19TRON Signed Message:\n" + ASCII(len) + message) + * secp256k1_sign(digest) → 65 bytes (r || s || 27+v) + */ +export class TronSignMessage extends jspb.Message { + static repeatedFields_ = [1]; + + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, TronSignMessage.repeatedFields_, null); + } + + getAddressNList(): number[] { + return Msg.getRepeatedField(this, 1) as number[]; + } + setAddressNList(value: number[]): void { + jspb.Message.setField(this, 1, value || []); + } + addAddressN(value: number): void { + jspb.Message.addToRepeatedField(this, 1, value); + } + + getCoinName(): string | undefined { + return jspb.Message.getFieldWithDefault(this, 2, "Tron") as string; + } + setCoinName(value: string): void { + jspb.Message.setField(this, 2, value); + } + + getMessage(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 3) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setMessage(value: Uint8Array): void { + jspb.Message.setField(this, 3, value); + } + + getShowDisplay(): boolean | undefined { + const f = jspb.Message.getField(this, 4); + return f == null ? undefined : !!f; + } + setShowDisplay(value: boolean): void { + jspb.Message.setField(this, 4, value ? 1 : 0); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + TronSignMessage.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): TronSignMessage.AsObject { + return { + addressNList: this.getAddressNList(), + coinName: this.getCoinName(), + message: this.getMessage(), + showDisplay: this.getShowDisplay(), + }; + } + + static toObject(_includeInstance: boolean, msg: TronSignMessage): TronSignMessage.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): TronSignMessage { + const reader = new jspb.BinaryReader(bytes); + const msg = new TronSignMessage(); + return TronSignMessage.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: TronSignMessage, reader: jspb.BinaryReader): TronSignMessage { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: { + const values = reader.isDelimited() ? reader.readPackedUint32() : [reader.readUint32()]; + for (const v of values) msg.addAddressN(v); + break; + } + case 2: + msg.setCoinName(reader.readString()); + break; + case 3: + msg.setMessage(reader.readBytes() as Uint8Array); + break; + case 4: + msg.setShowDisplay(reader.readBool()); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: TronSignMessage, writer: jspb.BinaryWriter): void { + const addressN = message.getAddressNList(); + if (addressN.length > 0) { + writer.writeRepeatedUint32(1, addressN); + } + const coinName = jspb.Message.getField(message, 2) as string | null; + if (coinName != null) { + writer.writeString(2, coinName); + } + const messageBytes = jspb.Message.getField(message, 3) as Uint8Array | null; + if (messageBytes != null) { + writer.writeBytes(3, messageBytes); + } + const showDisplay = jspb.Message.getField(message, 4); + if (showDisplay != null) { + writer.writeBool(4, !!showDisplay); + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TronSignMessage { + export type AsObject = { + addressNList: number[]; + coinName?: string; + message?: Uint8Array; + showDisplay?: boolean; + }; +} + +/** + * TronMessageSignature: address(1, string Base58Check), signature(2, bytes) + */ +export class TronMessageSignature extends jspb.Message { + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, [], null); + } + + getAddress(): string | undefined { + const f = jspb.Message.getField(this, 1); + return f == null ? undefined : (f as string); + } + setAddress(value: string): void { + jspb.Message.setField(this, 1, value); + } + + getSignature(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 2) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setSignature(value: Uint8Array): void { + jspb.Message.setField(this, 2, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + TronMessageSignature.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): TronMessageSignature.AsObject { + return { + address: this.getAddress(), + signature: this.getSignature(), + }; + } + + static toObject(_includeInstance: boolean, msg: TronMessageSignature): TronMessageSignature.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): TronMessageSignature { + const reader = new jspb.BinaryReader(bytes); + const msg = new TronMessageSignature(); + return TronMessageSignature.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: TronMessageSignature, reader: jspb.BinaryReader): TronMessageSignature { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: + msg.setAddress(reader.readString()); + break; + case 2: + msg.setSignature(reader.readBytes() as Uint8Array); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: TronMessageSignature, writer: jspb.BinaryWriter): void { + const address = jspb.Message.getField(message, 1) as string | null; + if (address != null) { + writer.writeString(1, address); + } + const sig = jspb.Message.getField(message, 2) as Uint8Array | null; + if (sig != null) { + writer.writeBytes(2, sig); + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TronMessageSignature { + export type AsObject = { + address?: string; + signature?: Uint8Array; + }; +} + +/** + * TronVerifyMessage: address(1, string), signature(2, bytes), message(3, bytes) + */ +export class TronVerifyMessage extends jspb.Message { + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, [], null); + } + + getAddress(): string | undefined { + const f = jspb.Message.getField(this, 1); + return f == null ? undefined : (f as string); + } + setAddress(value: string): void { + jspb.Message.setField(this, 1, value); + } + + getSignature(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 2) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setSignature(value: Uint8Array): void { + jspb.Message.setField(this, 2, value); + } + + getMessage(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 3) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setMessage(value: Uint8Array): void { + jspb.Message.setField(this, 3, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + TronVerifyMessage.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): TronVerifyMessage.AsObject { + return { + address: this.getAddress(), + signature: this.getSignature(), + message: this.getMessage(), + }; + } + + static toObject(_includeInstance: boolean, msg: TronVerifyMessage): TronVerifyMessage.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): TronVerifyMessage { + const reader = new jspb.BinaryReader(bytes); + const msg = new TronVerifyMessage(); + return TronVerifyMessage.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: TronVerifyMessage, reader: jspb.BinaryReader): TronVerifyMessage { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: + msg.setAddress(reader.readString()); + break; + case 2: + msg.setSignature(reader.readBytes() as Uint8Array); + break; + case 3: + msg.setMessage(reader.readBytes() as Uint8Array); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: TronVerifyMessage, writer: jspb.BinaryWriter): void { + const address = jspb.Message.getField(message, 1) as string | null; + if (address != null) { + writer.writeString(1, address); + } + const sig = jspb.Message.getField(message, 2) as Uint8Array | null; + if (sig != null) { + writer.writeBytes(2, sig); + } + const messageBytes = jspb.Message.getField(message, 3) as Uint8Array | null; + if (messageBytes != null) { + writer.writeBytes(3, messageBytes); + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TronVerifyMessage { + export type AsObject = { + address?: string; + signature?: Uint8Array; + message?: Uint8Array; + }; +} + +/** + * TronSignTypedHash: address_n(1, repeated uint32), coin_name(2, string), + * domain_separator_hash(3, bytes), message_hash(4, bytes) + * + * TIP-712 typed-data hash mode. Host pre-computes the domainSeparator + message + * hashes per the TIP-712 spec; device assembles + * keccak256("\x19\x01" || domain_separator_hash || message_hash) + * and signs with secp256k1 (recoverable, 65 bytes). + */ +export class TronSignTypedHash extends jspb.Message { + static repeatedFields_ = [1]; + + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, TronSignTypedHash.repeatedFields_, null); + } + + getAddressNList(): number[] { + return Msg.getRepeatedField(this, 1) as number[]; + } + setAddressNList(value: number[]): void { + jspb.Message.setField(this, 1, value || []); + } + addAddressN(value: number): void { + jspb.Message.addToRepeatedField(this, 1, value); + } + + getCoinName(): string | undefined { + return jspb.Message.getFieldWithDefault(this, 2, "Tron") as string; + } + setCoinName(value: string): void { + jspb.Message.setField(this, 2, value); + } + + getDomainSeparatorHash(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 3) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setDomainSeparatorHash(value: Uint8Array): void { + jspb.Message.setField(this, 3, value); + } + + getMessageHash(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 4) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setMessageHash(value: Uint8Array): void { + jspb.Message.setField(this, 4, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + TronSignTypedHash.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): TronSignTypedHash.AsObject { + return { + addressNList: this.getAddressNList(), + coinName: this.getCoinName(), + domainSeparatorHash: this.getDomainSeparatorHash(), + messageHash: this.getMessageHash(), + }; + } + + static toObject(_includeInstance: boolean, msg: TronSignTypedHash): TronSignTypedHash.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): TronSignTypedHash { + const reader = new jspb.BinaryReader(bytes); + const msg = new TronSignTypedHash(); + return TronSignTypedHash.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: TronSignTypedHash, reader: jspb.BinaryReader): TronSignTypedHash { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: { + const values = reader.isDelimited() ? reader.readPackedUint32() : [reader.readUint32()]; + for (const v of values) msg.addAddressN(v); + break; + } + case 2: + msg.setCoinName(reader.readString()); + break; + case 3: + msg.setDomainSeparatorHash(reader.readBytes() as Uint8Array); + break; + case 4: + msg.setMessageHash(reader.readBytes() as Uint8Array); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: TronSignTypedHash, writer: jspb.BinaryWriter): void { + const addressN = message.getAddressNList(); + if (addressN.length > 0) { + writer.writeRepeatedUint32(1, addressN); + } + const coinName = jspb.Message.getField(message, 2) as string | null; + if (coinName != null) { + writer.writeString(2, coinName); + } + const dsHash = jspb.Message.getField(message, 3) as Uint8Array | null; + if (dsHash != null) { + writer.writeBytes(3, dsHash); + } + const msgHash = jspb.Message.getField(message, 4) as Uint8Array | null; + if (msgHash != null) { + writer.writeBytes(4, msgHash); + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TronSignTypedHash { + export type AsObject = { + addressNList: number[]; + coinName?: string; + domainSeparatorHash?: Uint8Array; + messageHash?: Uint8Array; + }; +} + +/** + * TronTypedDataSignature: address(1, string), signature(2, bytes) + */ +export class TronTypedDataSignature extends jspb.Message { + constructor(opt_data?: any) { + super(); + jspb.Message.initialize(this, opt_data || [], 0, -1, [], null); + } + + getAddress(): string | undefined { + const f = jspb.Message.getField(this, 1); + return f == null ? undefined : (f as string); + } + setAddress(value: string): void { + jspb.Message.setField(this, 1, value); + } + + getSignature(): Uint8Array | undefined { + const f = jspb.Message.getField(this, 2) as Uint8Array | string | null; + if (f == null) return undefined; + return typeof f === "string" ? Uint8Array.from(Buffer.from(f, "base64")) : f; + } + setSignature(value: Uint8Array): void { + jspb.Message.setField(this, 2, value); + } + + serializeBinary(): Uint8Array { + const writer = new jspb.BinaryWriter(); + TronTypedDataSignature.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); + } + + toObject(_includeInstance?: boolean): TronTypedDataSignature.AsObject { + return { + address: this.getAddress(), + signature: this.getSignature(), + }; + } + + static toObject(_includeInstance: boolean, msg: TronTypedDataSignature): TronTypedDataSignature.AsObject { + return msg.toObject(_includeInstance); + } + + static deserializeBinary(bytes: Uint8Array): TronTypedDataSignature { + const reader = new jspb.BinaryReader(bytes); + const msg = new TronTypedDataSignature(); + return TronTypedDataSignature.deserializeBinaryFromReader(msg, reader); + } + + static deserializeBinaryFromReader(msg: TronTypedDataSignature, reader: jspb.BinaryReader): TronTypedDataSignature { + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + switch (field) { + case 1: + msg.setAddress(reader.readString()); + break; + case 2: + msg.setSignature(reader.readBytes() as Uint8Array); + break; + default: + reader.skipField(); + break; + } + } + return msg; + } + + static serializeBinaryToWriter(message: TronTypedDataSignature, writer: jspb.BinaryWriter): void { + const address = jspb.Message.getField(message, 1) as string | null; + if (address != null) { + writer.writeString(1, address); + } + const sig = jspb.Message.getField(message, 2) as Uint8Array | null; + if (sig != null) { + writer.writeBytes(2, sig); + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace TronTypedDataSignature { + export type AsObject = { + address?: string; + signature?: Uint8Array; + }; +} + // ── Runtime Registration ────────────────────────────────────────────── // Inject Tron message types into the KeepKey transport registries. @@ -510,16 +1063,31 @@ export function registerTronMessages() { mt["MESSAGETYPE_TRONADDRESS"] = MESSAGETYPE_TRONADDRESS; mt["MESSAGETYPE_TRONSIGNTX"] = MESSAGETYPE_TRONSIGNTX; mt["MESSAGETYPE_TRONSIGNEDTX"] = MESSAGETYPE_TRONSIGNEDTX; + mt["MESSAGETYPE_TRONSIGNMESSAGE"] = MESSAGETYPE_TRONSIGNMESSAGE; + mt["MESSAGETYPE_TRONMESSAGESIGNATURE"] = MESSAGETYPE_TRONMESSAGESIGNATURE; + mt["MESSAGETYPE_TRONVERIFYMESSAGE"] = MESSAGETYPE_TRONVERIFYMESSAGE; + mt["MESSAGETYPE_TRONSIGNTYPEDHASH"] = MESSAGETYPE_TRONSIGNTYPEDHASH; + mt["MESSAGETYPE_TRONTYPEDDATASIGNATURE"] = MESSAGETYPE_TRONTYPEDDATASIGNATURE; messageNameRegistry[MESSAGETYPE_TRONGETADDRESS] = "TronGetAddress"; messageNameRegistry[MESSAGETYPE_TRONADDRESS] = "TronAddress"; messageNameRegistry[MESSAGETYPE_TRONSIGNTX] = "TronSignTx"; messageNameRegistry[MESSAGETYPE_TRONSIGNEDTX] = "TronSignedTx"; + messageNameRegistry[MESSAGETYPE_TRONSIGNMESSAGE] = "TronSignMessage"; + messageNameRegistry[MESSAGETYPE_TRONMESSAGESIGNATURE] = "TronMessageSignature"; + messageNameRegistry[MESSAGETYPE_TRONVERIFYMESSAGE] = "TronVerifyMessage"; + messageNameRegistry[MESSAGETYPE_TRONSIGNTYPEDHASH] = "TronSignTypedHash"; + messageNameRegistry[MESSAGETYPE_TRONTYPEDDATASIGNATURE] = "TronTypedDataSignature"; messageTypeRegistry[MESSAGETYPE_TRONGETADDRESS] = TronGetAddress as any; messageTypeRegistry[MESSAGETYPE_TRONADDRESS] = TronAddress as any; messageTypeRegistry[MESSAGETYPE_TRONSIGNTX] = TronSignTx as any; messageTypeRegistry[MESSAGETYPE_TRONSIGNEDTX] = TronSignedTx as any; + messageTypeRegistry[MESSAGETYPE_TRONSIGNMESSAGE] = TronSignMessage as any; + messageTypeRegistry[MESSAGETYPE_TRONMESSAGESIGNATURE] = TronMessageSignature as any; + messageTypeRegistry[MESSAGETYPE_TRONVERIFYMESSAGE] = TronVerifyMessage as any; + messageTypeRegistry[MESSAGETYPE_TRONSIGNTYPEDHASH] = TronSignTypedHash as any; + messageTypeRegistry[MESSAGETYPE_TRONTYPEDDATASIGNATURE] = TronTypedDataSignature as any; } // Register on module load @@ -615,3 +1183,121 @@ export async function tronSignTx(transport: Transport, msg: core.TronSignTx): Pr }; }); } + +// Tron message-signing helpers (TIP-191 personal_sign + TIP-712 typed-hash). The +// firmware path validation requires m/44'/195'/...; that's enforced device-side. + +export async function tronSignMessage( + transport: Transport, + msg: core.TronSignMessage, +): Promise { + return transport.lockDuring(async () => { + const signMsg = new TronSignMessage(); + signMsg.setAddressNList(msg.addressNList); + + const messageBytes = + msg.message instanceof Uint8Array + ? msg.message + : typeof msg.message === "string" + ? new TextEncoder().encode(msg.message) + : new Uint8Array(msg.message as any); + signMsg.setMessage(messageBytes); + + if (msg.showDisplay !== undefined) signMsg.setShowDisplay(msg.showDisplay); + + const resp = await transport.call(MESSAGETYPE_TRONSIGNMESSAGE, signMsg, { + msgTimeout: core.LONG_TIMEOUT, + omitLock: true, + }); + + if (resp.message_enum !== MESSAGETYPE_TRONMESSAGESIGNATURE) { + throw new Error(`tron: unexpected response ${resp.message_type}`); + } + + const sig = resp.proto as TronMessageSignature; + return { + address: core.mustBeDefined(sig.getAddress()), + signature: core.mustBeDefined(sig.getSignature()), + }; + }); +} + +export async function tronVerifyMessage( + transport: Transport, + msg: core.TronVerifyMessage, +): Promise { + return transport.lockDuring(async () => { + const verifyMsg = new TronVerifyMessage(); + verifyMsg.setAddress(msg.address); + + const sigBytes = + msg.signature instanceof Uint8Array + ? msg.signature + : typeof msg.signature === "string" + ? core.fromHexString(msg.signature.replace(/^0x/i, "")) + : new Uint8Array(msg.signature as any); + verifyMsg.setSignature(sigBytes); + + const messageBytes = + msg.message instanceof Uint8Array + ? msg.message + : typeof msg.message === "string" + ? new TextEncoder().encode(msg.message) + : new Uint8Array(msg.message as any); + verifyMsg.setMessage(messageBytes); + + const resp = await transport.call(MESSAGETYPE_TRONVERIFYMESSAGE, verifyMsg, { + msgTimeout: core.LONG_TIMEOUT, + omitLock: true, + }); + + // Firmware returns Success on valid sig, Failure otherwise. + // transport.call already throws on Failure, so reaching here = verified. + return resp.message_enum === MESSAGETYPE_SUCCESS; + }); +} + +export async function tronSignTypedHash( + transport: Transport, + msg: core.TronSignTypedHash, +): Promise { + return transport.lockDuring(async () => { + const signMsg = new TronSignTypedHash(); + signMsg.setAddressNList(msg.addressNList); + + const ds = + msg.domainSeparatorHash instanceof Uint8Array + ? msg.domainSeparatorHash + : core.fromHexString((msg.domainSeparatorHash as string).replace(/^0x/i, "")); + if (ds.length !== 32) { + throw new Error(`tron: domain_separator_hash must be exactly 32 bytes, got ${ds.length}`); + } + signMsg.setDomainSeparatorHash(ds); + + if (msg.messageHash != null) { + const mh = + msg.messageHash instanceof Uint8Array + ? msg.messageHash + : core.fromHexString((msg.messageHash as string).replace(/^0x/i, "")); + if (mh.length !== 32) { + throw new Error(`tron: message_hash must be exactly 32 bytes, got ${mh.length}`); + } + signMsg.setMessageHash(mh); + } + + const resp = await transport.call(MESSAGETYPE_TRONSIGNTYPEDHASH, signMsg, { + msgTimeout: core.LONG_TIMEOUT, + omitLock: true, + }); + + if (resp.message_enum !== MESSAGETYPE_TRONTYPEDDATASIGNATURE) { + throw new Error(`tron: unexpected response ${resp.message_type}`); + } + + const sig = resp.proto as TronTypedDataSignature; + return { + address: core.mustBeDefined(sig.getAddress()), + signature: core.mustBeDefined(sig.getSignature()), + }; + }); +} From 16edf839eed869682baff5e1f00bade960a5093a Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 18:16:30 -0500 Subject: [PATCH 2/5] style: prettier auto-fixes on new message-signing code --- packages/hdwallet-keepkey/src/keepkey-initialize.test.ts | 8 ++------ packages/hdwallet-keepkey/src/keepkey.ts | 8 +++----- packages/hdwallet-keepkey/src/solana.ts | 8 ++++---- packages/hdwallet-keepkey/src/ton.ts | 2 +- packages/hdwallet-keepkey/src/tron.ts | 9 +++------ 5 files changed, 13 insertions(+), 22 deletions(-) diff --git a/packages/hdwallet-keepkey/src/keepkey-initialize.test.ts b/packages/hdwallet-keepkey/src/keepkey-initialize.test.ts index 0f872fb1..cd6183b8 100644 --- a/packages/hdwallet-keepkey/src/keepkey-initialize.test.ts +++ b/packages/hdwallet-keepkey/src/keepkey-initialize.test.ts @@ -47,9 +47,7 @@ describe("KeepKeyHDWallet.initialize() version-field validation", () => { }, }); const wallet = new KeepKeyHDWallet(transport); - await expect(wallet.initialize()).rejects.toThrow( - /KeepKey Initialize returned Features without firmware version/ - ); + await expect(wallet.initialize()).rejects.toThrow(/KeepKey Initialize returned Features without firmware version/); }); it("resolves successfully when all version fields are present", async () => { @@ -82,8 +80,6 @@ describe("KeepKeyHDWallet.initialize() version-field validation", () => { err = e; } expect(err).toBeDefined(); - expect(String(err.message ?? err)).not.toMatch( - /Invalid Version: vundefined\.undefined\.undefined/ - ); + expect(String(err.message ?? err)).not.toMatch(/Invalid Version: vundefined\.undefined\.undefined/); }); }); diff --git a/packages/hdwallet-keepkey/src/keepkey.ts b/packages/hdwallet-keepkey/src/keepkey.ts index 0073358f..4eccef89 100644 --- a/packages/hdwallet-keepkey/src/keepkey.ts +++ b/packages/hdwallet-keepkey/src/keepkey.ts @@ -1200,8 +1200,8 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW ) { throw new Error( `KeepKey Initialize returned Features without firmware version ` + - `(major=${out.majorVersion}, minor=${out.minorVersion}, patch=${out.patchVersion}). ` + - `Device may be in bootloader mode, mid-update, or returned an unexpected message type.` + `(major=${out.majorVersion}, minor=${out.minorVersion}, patch=${out.patchVersion}). ` + + `Device may be in bootloader mode, mid-update, or returned an unexpected message type.` ); } @@ -1489,9 +1489,7 @@ export class KeepKeyHDWallet implements core.HDWallet, core.BTCWallet, core.ETHW return Solana.solanaSignTx(this.transport, msg); } - public solanaSignOffchainMessage( - msg: core.SolanaSignOffchainMessage, - ): Promise { + public solanaSignOffchainMessage(msg: core.SolanaSignOffchainMessage): Promise { return Solana.solanaSignOffchainMessage(this.transport, msg); } diff --git a/packages/hdwallet-keepkey/src/solana.ts b/packages/hdwallet-keepkey/src/solana.ts index 1084cfcd..a65e1015 100644 --- a/packages/hdwallet-keepkey/src/solana.ts +++ b/packages/hdwallet-keepkey/src/solana.ts @@ -718,7 +718,7 @@ export class SolanaSignOffchainMessage extends jspb.Message { static deserializeBinaryFromReader( msg: SolanaSignOffchainMessage, - reader: jspb.BinaryReader, + reader: jspb.BinaryReader ): SolanaSignOffchainMessage { while (reader.nextField()) { if (reader.isEndGroup()) break; @@ -833,7 +833,7 @@ export class SolanaOffchainMessageSignature extends jspb.Message { static toObject( _includeInstance: boolean, - msg: SolanaOffchainMessageSignature, + msg: SolanaOffchainMessageSignature ): SolanaOffchainMessageSignature.AsObject { return msg.toObject(_includeInstance); } @@ -846,7 +846,7 @@ export class SolanaOffchainMessageSignature extends jspb.Message { static deserializeBinaryFromReader( msg: SolanaOffchainMessageSignature, - reader: jspb.BinaryReader, + reader: jspb.BinaryReader ): SolanaOffchainMessageSignature { while (reader.nextField()) { if (reader.isEndGroup()) break; @@ -1050,7 +1050,7 @@ export async function solanaSignMessage( */ export async function solanaSignOffchainMessage( transport: Transport, - msg: core.SolanaSignOffchainMessage, + msg: core.SolanaSignOffchainMessage ): Promise { return transport.lockDuring(async () => { const signMsg = new SolanaSignOffchainMessage(); diff --git a/packages/hdwallet-keepkey/src/ton.ts b/packages/hdwallet-keepkey/src/ton.ts index e74dfcd7..16130661 100644 --- a/packages/hdwallet-keepkey/src/ton.ts +++ b/packages/hdwallet-keepkey/src/ton.ts @@ -924,7 +924,7 @@ export async function tonSignTx(transport: Transport, msg: core.TonSignTx): Prom */ export async function tonSignMessage( transport: Transport, - msg: core.TonSignMessage, + msg: core.TonSignMessage ): Promise { return transport.lockDuring(async () => { const signMsg = new TonSignMessage(); diff --git a/packages/hdwallet-keepkey/src/tron.ts b/packages/hdwallet-keepkey/src/tron.ts index afb78cd6..a4b3c8c2 100644 --- a/packages/hdwallet-keepkey/src/tron.ts +++ b/packages/hdwallet-keepkey/src/tron.ts @@ -1189,7 +1189,7 @@ export async function tronSignTx(transport: Transport, msg: core.TronSignTx): Pr export async function tronSignMessage( transport: Transport, - msg: core.TronSignMessage, + msg: core.TronSignMessage ): Promise { return transport.lockDuring(async () => { const signMsg = new TronSignMessage(); @@ -1222,10 +1222,7 @@ export async function tronSignMessage( }); } -export async function tronVerifyMessage( - transport: Transport, - msg: core.TronVerifyMessage, -): Promise { +export async function tronVerifyMessage(transport: Transport, msg: core.TronVerifyMessage): Promise { return transport.lockDuring(async () => { const verifyMsg = new TronVerifyMessage(); verifyMsg.setAddress(msg.address); @@ -1259,7 +1256,7 @@ export async function tronVerifyMessage( export async function tronSignTypedHash( transport: Transport, - msg: core.TronSignTypedHash, + msg: core.TronSignTypedHash ): Promise { return transport.lockDuring(async () => { const signMsg = new TronSignTypedHash(); From 4c6da835583ebe38beb203705bf45a764ab0bb6c Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 18:21:19 -0500 Subject: [PATCH 3/5] fix(tron): tronVerifyMessage returns false on Failure (review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation relied on 'transport.call() returns Success when verified, throws on Failure', but that broke the Promise contract — callers got a rejected promise on signature mismatch instead of a resolved `false`. Mirror btcVerifyMessage / ethVerifyMessage: catch MESSAGETYPE_FAILURE and return false. ActionCancelled is thrown by transport.call() as a distinct core.ActionCancelled instance (no message_enum), so it bubbles past the catch and continues to throw — keeping cancel flow distinguishable from sig mismatch. Reported in keepkey/hdwallet#38 review. --- packages/hdwallet-keepkey/src/tron.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/hdwallet-keepkey/src/tron.ts b/packages/hdwallet-keepkey/src/tron.ts index a4b3c8c2..d275f062 100644 --- a/packages/hdwallet-keepkey/src/tron.ts +++ b/packages/hdwallet-keepkey/src/tron.ts @@ -1243,14 +1243,27 @@ export async function tronVerifyMessage(transport: Transport, msg: core.TronVeri : new Uint8Array(msg.message as any); verifyMsg.setMessage(messageBytes); - const resp = await transport.call(MESSAGETYPE_TRONVERIFYMESSAGE, verifyMsg, { - msgTimeout: core.LONG_TIMEOUT, - omitLock: true, - }); + // Firmware returns Success on a valid signature and Failure + // (FailureType_Failure_SyntaxError) on signature/address mismatch. + // transport.call() throws the Failure event for the mismatch case, + // so we have to translate that into `false` here. ActionCancelled + // (user pressed Cancel on the confirm dialog) is thrown as a + // distinct core.ActionCancelled instance — that should bubble up + // unchanged. Mirrors btcVerifyMessage / ethVerifyMessage. + let event: core.Event; + try { + event = await transport.call(MESSAGETYPE_TRONVERIFYMESSAGE, verifyMsg, { + msgTimeout: core.LONG_TIMEOUT, + omitLock: true, + }); + } catch (e) { + if (core.isIndexable(e) && e.message_enum === Messages.MessageType.MESSAGETYPE_FAILURE) { + return false; + } + throw e; + } - // Firmware returns Success on valid sig, Failure otherwise. - // transport.call already throws on Failure, so reaching here = verified. - return resp.message_enum === MESSAGETYPE_SUCCESS; + return event.message_enum === MESSAGETYPE_SUCCESS; }); } From 402dda22bdf0e65b7d9f6ab2c1d02b0e61bdac86 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 18:28:21 -0500 Subject: [PATCH 4/5] =?UTF-8?q?ci:=20restore=20unit=20tests=20dropped=20in?= =?UTF-8?q?=20CircleCI=E2=86=92GHA=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migration in 58272ddd ('ci: add GitHub Actions CI, replace CircleCI with placeholder') moved CI from CircleCI to GitHub Actions but only copied over yarn build + yarn lint — yarn test (49 tests across 4 files) and yarn test:integration silently disappeared. The .circleci/config.yml was left as a no-op placeholder, so the green 'ci/circleci: noop' check on every PR has been giving the false impression that tests were running. Add 'yarn test --runInBand --coverage=false' back to the workflow. Integration tests (kkemu sidecar) are a separate follow-up since they need a kkemu image source in CI — currently kktech/kkemu:latest comes from dockerhub and isn't easily reproducible from a feature branch. --- .github/workflows/ci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fdb4ab7..705387cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,3 +31,10 @@ jobs: - name: Lint run: yarn lint + + # Unit tests dropped in commit 58272ddd (CircleCI → GHA migration). + # Restored here so PRs catch regressions in the 49-test jest suite. + # Integration tests (yarn test:integration) still need a kkemu + # sidecar — that's a follow-up, tracked in a separate PR/issue. + - name: Unit tests + run: yarn test --runInBand --coverage=false From 4560d976d33f2ca1028a189cfa7f0c4a16760f8e Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 29 Apr 2026 18:33:14 -0500 Subject: [PATCH 5/5] test: unit coverage for new message-signing methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 34 new tests across 3 files covering all 9 new jspb classes + 5 wrapper functions: tron-message.test.ts (20 tests) - registry: TronSignMessage/MessageSignature/VerifyMessage/ SignTypedHash/TypedDataSignature (1404-1408) all registered - jspb round-trip: serializeBinary → deserializeBinaryFromReader preserves all fields for each class (incl. empty-message + domain-only typed-hash edge cases) - tronSignMessage wrapper: success path + UTF-8 string encoding - tronVerifyMessage wrapper: * Success → true * MESSAGETYPE_FAILURE thrown by transport.call → returns false (regression test for the Promise contract bug caught in PR review) * core.ActionCancelled → bubbles past the catch (cancel is distinguishable from sig mismatch) - tronSignTypedHash wrapper: 32-byte hash length validation + success path ton-message.test.ts (7 tests) - registry: TonSignMessage/MessageSignature (1504/1505) - jspb round-trip - tonSignMessage wrapper: success + ActionCancelled bubbling (firmware AdvancedMode gate path) solana-offchain.test.ts (7 tests) - registry: SolanaSignOffchainMessage/OffchainMessageSignature (756/757) - jspb round-trip incl. 1212-byte boundary (spec ceiling for fmt 0/1) - solanaSignOffchainMessage wrapper: UTF-8 encoding, version + message_format forwarding, success path Total jest run: 49 → 83 (all pass locally with yarn test). --- .../src/solana-offchain.test.ts | 162 +++++++++ .../hdwallet-keepkey/src/ton-message.test.ts | 110 ++++++ .../hdwallet-keepkey/src/tron-message.test.ts | 330 ++++++++++++++++++ 3 files changed, 602 insertions(+) create mode 100644 packages/hdwallet-keepkey/src/solana-offchain.test.ts create mode 100644 packages/hdwallet-keepkey/src/ton-message.test.ts create mode 100644 packages/hdwallet-keepkey/src/tron-message.test.ts diff --git a/packages/hdwallet-keepkey/src/solana-offchain.test.ts b/packages/hdwallet-keepkey/src/solana-offchain.test.ts new file mode 100644 index 00000000..972dec92 --- /dev/null +++ b/packages/hdwallet-keepkey/src/solana-offchain.test.ts @@ -0,0 +1,162 @@ +/** + * Unit tests for Solana off-chain message signing protobuf shims and wrapper. + * + * Off-chain spec envelope (constructed firmware-side): + * "\xff" || "solana offchain" || version:u8 || format:u8 || length:u16 LE || message + */ +import * as jspb from "google-protobuf"; + +import { SolanaOffchainMessageSignature, SolanaSignOffchainMessage, solanaSignOffchainMessage } from "./solana"; +import { messageNameRegistry, messageTypeRegistry } from "./typeRegistry"; + +const MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE = 756; +const MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE = 757; + +const ALLALLALL_SOLANA_PATH = [0x80000000 + 44, 0x80000000 + 501, 0x80000000, 0x80000000]; + +function makeMockTransport(callImpl: jest.Mock) { + return { + debugLink: false, + call: callImpl, + lockDuring: (fn: () => Promise) => fn(), + } as any; +} + +describe("Solana SignOffchainMessage protobuf registration", () => { + it("registers SolanaSignOffchainMessage (756)", () => { + expect(messageTypeRegistry[MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE]).toBe("SolanaSignOffchainMessage"); + }); + it("registers SolanaOffchainMessageSignature (757)", () => { + expect(messageTypeRegistry[MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE]).toBe("SolanaOffchainMessageSignature"); + }); +}); + +describe("Solana SignOffchainMessage jspb round-trip", () => { + it("SolanaSignOffchainMessage: all fields", () => { + const m = new SolanaSignOffchainMessage(); + m.setAddressNList(ALLALLALL_SOLANA_PATH); + m.setVersion(0); + m.setMessageFormat(0); + m.setMessage(new Uint8Array([0x68, 0x69])); // "hi" + m.setShowDisplay(true); + + const bytes = m.serializeBinary(); + const decoded = new SolanaSignOffchainMessage(); + SolanaSignOffchainMessage.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect(decoded.getAddressNList()).toEqual(ALLALLALL_SOLANA_PATH); + expect(decoded.getCoinName()).toBe("Solana"); + expect(decoded.getVersion()).toBe(0); + expect(decoded.getMessageFormat()).toBe(0); + expect(Array.from(decoded.getMessage() as Uint8Array)).toEqual([0x68, 0x69]); + expect(decoded.getShowDisplay()).toBe(true); + }); + + it("SolanaSignOffchainMessage: format=1 UTF-8 limited at boundary (1212 bytes)", () => { + const m = new SolanaSignOffchainMessage(); + m.setAddressNList(ALLALLALL_SOLANA_PATH); + m.setMessageFormat(1); + m.setMessage(new Uint8Array(1212).fill(0x41)); // "A"*1212 + + const bytes = m.serializeBinary(); + const decoded = new SolanaSignOffchainMessage(); + SolanaSignOffchainMessage.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect(decoded.getMessageFormat()).toBe(1); + expect((decoded.getMessage() as Uint8Array).length).toBe(1212); + }); + + it("SolanaOffchainMessageSignature: public_key (32) + signature (64)", () => { + const pk = new Uint8Array(32).fill(0x33); + const sig = new Uint8Array(64).fill(0x55); + const m = new SolanaOffchainMessageSignature(); + m.setPublicKey(pk); + m.setSignature(sig); + + const bytes = m.serializeBinary(); + const decoded = new SolanaOffchainMessageSignature(); + SolanaOffchainMessageSignature.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect((decoded.getPublicKey() as Uint8Array).length).toBe(32); + expect((decoded.getSignature() as Uint8Array).length).toBe(64); + expect((decoded.getPublicKey() as Uint8Array)[0]).toBe(0x33); + expect((decoded.getSignature() as Uint8Array)[0]).toBe(0x55); + }); +}); + +describe("solanaSignOffchainMessage wrapper", () => { + it("encodes string message as UTF-8 bytes", async () => { + const transport = makeMockTransport( + jest.fn().mockImplementation((mtype: number, msg: any) => { + expect(mtype).toBe(MESSAGETYPE_SOLANASIGNOFFCHAINMESSAGE); + // Wrapper should have UTF-8 encoded "hi" + expect(Array.from(msg.getMessage() as Uint8Array)).toEqual([0x68, 0x69]); + + const respProto = new SolanaOffchainMessageSignature(); + respProto.setPublicKey(new Uint8Array(32)); + respProto.setSignature(new Uint8Array(64)); + return Promise.resolve({ + message_enum: MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE, + message_type: "SolanaOffchainMessageSignature", + proto: respProto, + }); + }) + ); + + await solanaSignOffchainMessage(transport, { + addressNList: ALLALLALL_SOLANA_PATH, + message: "hi", + }); + }); + + it("forwards version + messageFormat parameters", async () => { + const transport = makeMockTransport( + jest.fn().mockImplementation((_mtype: number, msg: any) => { + expect(msg.getVersion()).toBe(0); + expect(msg.getMessageFormat()).toBe(1); + const respProto = new SolanaOffchainMessageSignature(); + respProto.setPublicKey(new Uint8Array(32)); + respProto.setSignature(new Uint8Array(64)); + return Promise.resolve({ + message_enum: MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE, + message_type: "SolanaOffchainMessageSignature", + proto: respProto, + }); + }) + ); + + await solanaSignOffchainMessage(transport, { + addressNList: ALLALLALL_SOLANA_PATH, + version: 0, + messageFormat: 1, + message: "test", + }); + }); + + it("returns publicKey + signature on success", async () => { + const transport = makeMockTransport( + jest.fn().mockImplementation(() => { + const respProto = new SolanaOffchainMessageSignature(); + respProto.setPublicKey(new Uint8Array(32).fill(0xa1)); + respProto.setSignature(new Uint8Array(64).fill(0xb2)); + return Promise.resolve({ + message_enum: MESSAGETYPE_SOLANAOFFCHAINMESSAGESIGNATURE, + message_type: "SolanaOffchainMessageSignature", + proto: respProto, + }); + }) + ); + + const result = await solanaSignOffchainMessage(transport, { + addressNList: ALLALLALL_SOLANA_PATH, + message: "verify-binding", + }); + + expect((result.publicKey as Uint8Array).length).toBe(32); + expect((result.publicKey as Uint8Array)[0]).toBe(0xa1); + expect((result.signature as Uint8Array).length).toBe(64); + expect((result.signature as Uint8Array)[0]).toBe(0xb2); + }); +}); diff --git a/packages/hdwallet-keepkey/src/ton-message.test.ts b/packages/hdwallet-keepkey/src/ton-message.test.ts new file mode 100644 index 00000000..86d69cbb --- /dev/null +++ b/packages/hdwallet-keepkey/src/ton-message.test.ts @@ -0,0 +1,110 @@ +/** + * Unit tests for TON SignMessage protobuf shims and wrapper. + */ +import * as core from "@keepkey/hdwallet-core"; +import * as jspb from "google-protobuf"; + +import { TonMessageSignature, TonSignMessage, tonSignMessage } from "./ton"; +import { messageNameRegistry, messageTypeRegistry } from "./typeRegistry"; + +const MESSAGETYPE_TONSIGNMESSAGE = 1504; +const MESSAGETYPE_TONMESSAGESIGNATURE = 1505; + +const ALLALLALL_TON_PATH = [0x80000000 + 44, 0x80000000 + 607, 0x80000000]; + +function makeMockTransport(callImpl: jest.Mock) { + return { + debugLink: false, + call: callImpl, + lockDuring: (fn: () => Promise) => fn(), + } as any; +} + +describe("TON SignMessage protobuf registration", () => { + it("registers TonSignMessage (1504)", () => { + expect(messageTypeRegistry[MESSAGETYPE_TONSIGNMESSAGE]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_TONSIGNMESSAGE]).toBe("TonSignMessage"); + }); + it("registers TonMessageSignature (1505)", () => { + expect(messageTypeRegistry[MESSAGETYPE_TONMESSAGESIGNATURE]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_TONMESSAGESIGNATURE]).toBe("TonMessageSignature"); + }); +}); + +describe("TON SignMessage jspb round-trip", () => { + it("TonSignMessage: address_n + coin_name + message + show_display", () => { + const m = new TonSignMessage(); + m.setAddressNList(ALLALLALL_TON_PATH); + m.setMessage(new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + m.setShowDisplay(false); + + const bytes = m.serializeBinary(); + const decoded = new TonSignMessage(); + TonSignMessage.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect(decoded.getAddressNList()).toEqual(ALLALLALL_TON_PATH); + expect(decoded.getCoinName()).toBe("Ton"); + expect(Array.from(decoded.getMessage() as Uint8Array)).toEqual([0xde, 0xad, 0xbe, 0xef]); + expect(decoded.getShowDisplay()).toBe(false); + }); + + it("TonMessageSignature: public_key (32) + signature (64)", () => { + const pk = new Uint8Array(32).fill(0x42); + const sig = new Uint8Array(64).fill(0x11); + const m = new TonMessageSignature(); + m.setPublicKey(pk); + m.setSignature(sig); + + const bytes = m.serializeBinary(); + const decoded = new TonMessageSignature(); + TonMessageSignature.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect((decoded.getPublicKey() as Uint8Array).length).toBe(32); + expect((decoded.getSignature() as Uint8Array).length).toBe(64); + expect((decoded.getPublicKey() as Uint8Array)[0]).toBe(0x42); + expect((decoded.getSignature() as Uint8Array)[0]).toBe(0x11); + }); +}); + +describe("tonSignMessage wrapper", () => { + it("returns publicKey + signature on success", async () => { + const expectedPk = new Uint8Array(32).fill(0x99); + const expectedSig = new Uint8Array(64).fill(0x77); + + const transport = makeMockTransport( + jest.fn().mockImplementation((mtype: number, _msg: any) => { + expect(mtype).toBe(MESSAGETYPE_TONSIGNMESSAGE); + const respProto = new TonMessageSignature(); + respProto.setPublicKey(expectedPk); + respProto.setSignature(expectedSig); + return Promise.resolve({ + message_enum: MESSAGETYPE_TONMESSAGESIGNATURE, + message_type: "TonMessageSignature", + proto: respProto, + }); + }) + ); + + const result = await tonSignMessage(transport, { + addressNList: ALLALLALL_TON_PATH, + message: "test ton message", + }); + + expect((result.publicKey as Uint8Array).length).toBe(32); + expect((result.signature as Uint8Array).length).toBe(64); + }); + + it("ActionCancelled bubbles up (firmware AdvancedMode gate disabled)", async () => { + // When the AdvancedMode policy is disabled, firmware shows the + // "Blocked" review and returns Failure_ActionCancelled. The transport + // converts that into core.ActionCancelled. Wrapper must not swallow it. + const transport = makeMockTransport(jest.fn().mockRejectedValue(new core.ActionCancelled())); + + await expect( + tonSignMessage(transport, { + addressNList: ALLALLALL_TON_PATH, + message: "blocked", + }) + ).rejects.toBeInstanceOf(core.ActionCancelled); + }); +}); diff --git a/packages/hdwallet-keepkey/src/tron-message.test.ts b/packages/hdwallet-keepkey/src/tron-message.test.ts new file mode 100644 index 00000000..09c52b9d --- /dev/null +++ b/packages/hdwallet-keepkey/src/tron-message.test.ts @@ -0,0 +1,330 @@ +/** + * Unit tests for TRON message-signing protobuf shims and wrapper functions. + * + * Covers: + * - Type registry registration (1404-1408) + * - jspb round-trip (serializeBinary → deserializeBinaryFromReader) + * for TronSignMessage / TronMessageSignature / TronVerifyMessage / + * TronSignTypedHash / TronTypedDataSignature + * - tronSignMessage wrapper: success path + * - tronVerifyMessage wrapper: success, mismatch (Failure→false), + * and ActionCancelled (bubbles past the catch) — this is the + * load-bearing regression for the Promise contract + * - tronSignTypedHash wrapper: 32-byte hash validation + */ +import * as Messages from "@keepkey/device-protocol/lib/messages_pb"; +import * as core from "@keepkey/hdwallet-core"; +import * as jspb from "google-protobuf"; + +import { + TronMessageSignature, + TronSignMessage, + tronSignMessage, + TronSignTypedHash, + tronSignTypedHash, + TronTypedDataSignature, + TronVerifyMessage, + tronVerifyMessage, +} from "./tron"; +import { messageNameRegistry, messageTypeRegistry } from "./typeRegistry"; + +const MESSAGETYPE_TRONSIGNMESSAGE = 1404; +const MESSAGETYPE_TRONMESSAGESIGNATURE = 1405; +const MESSAGETYPE_TRONVERIFYMESSAGE = 1406; +const MESSAGETYPE_TRONSIGNTYPEDHASH = 1407; +const MESSAGETYPE_TRONTYPEDDATASIGNATURE = 1408; + +const ALLALLALL_TRON_PATH = [0x80000000 + 44, 0x80000000 + 195, 0x80000000, 0, 0]; + +function makeMockTransport(callImpl: jest.Mock) { + return { + debugLink: false, + call: callImpl, + lockDuring: (fn: () => Promise) => fn(), + } as any; +} + +describe("TRON message-signing protobuf registration", () => { + it("registers TronSignMessage (1404)", () => { + expect(messageTypeRegistry[MESSAGETYPE_TRONSIGNMESSAGE]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_TRONSIGNMESSAGE]).toBe("TronSignMessage"); + }); + it("registers TronMessageSignature (1405)", () => { + expect(messageTypeRegistry[MESSAGETYPE_TRONMESSAGESIGNATURE]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_TRONMESSAGESIGNATURE]).toBe("TronMessageSignature"); + }); + it("registers TronVerifyMessage (1406)", () => { + expect(messageTypeRegistry[MESSAGETYPE_TRONVERIFYMESSAGE]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_TRONVERIFYMESSAGE]).toBe("TronVerifyMessage"); + }); + it("registers TronSignTypedHash (1407)", () => { + expect(messageTypeRegistry[MESSAGETYPE_TRONSIGNTYPEDHASH]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_TRONSIGNTYPEDHASH]).toBe("TronSignTypedHash"); + }); + it("registers TronTypedDataSignature (1408)", () => { + expect(messageTypeRegistry[MESSAGETYPE_TRONTYPEDDATASIGNATURE]).toBeDefined(); + expect(messageNameRegistry[MESSAGETYPE_TRONTYPEDDATASIGNATURE]).toBe("TronTypedDataSignature"); + }); +}); + +describe("TRON message-signing jspb round-trip", () => { + it("TronSignMessage: address_n + coin_name + message + show_display", () => { + const m = new TronSignMessage(); + m.setAddressNList(ALLALLALL_TRON_PATH); + m.setMessage(new Uint8Array([0x68, 0x69])); // "hi" + m.setShowDisplay(true); + + const bytes = m.serializeBinary(); + const decoded = new TronSignMessage(); + TronSignMessage.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect(decoded.getAddressNList()).toEqual(ALLALLALL_TRON_PATH); + expect(decoded.getCoinName()).toBe("Tron"); + expect(Array.from(decoded.getMessage() as Uint8Array)).toEqual([0x68, 0x69]); + expect(decoded.getShowDisplay()).toBe(true); + }); + + it("TronSignMessage: empty message is preserved (TIP-191 allows zero-length)", () => { + const m = new TronSignMessage(); + m.setAddressNList(ALLALLALL_TRON_PATH); + m.setMessage(new Uint8Array()); + const bytes = m.serializeBinary(); + const decoded = new TronSignMessage(); + TronSignMessage.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + // Empty bytes are wire-equivalent to absent — decoder may yield either + // undefined or an empty Uint8Array. Both shapes are acceptable for an + // empty payload; collapsing to a length lets us assert without an + // `if`-gated `expect` (which jest/no-conditional-expect forbids). + const got = decoded.getMessage(); + const length = got === undefined ? 0 : got.length; + expect(length).toBe(0); + }); + + it("TronMessageSignature: address + signature", () => { + const m = new TronMessageSignature(); + m.setAddress("TKtWbdpEq1zHGvF3pGZmAgdJaaCxXn5fW9"); + const sig = new Uint8Array(65).fill(0xab); + sig[64] = 27; + m.setSignature(sig); + + const bytes = m.serializeBinary(); + const decoded = new TronMessageSignature(); + TronMessageSignature.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect(decoded.getAddress()).toBe("TKtWbdpEq1zHGvF3pGZmAgdJaaCxXn5fW9"); + expect(Array.from(decoded.getSignature() as Uint8Array)).toEqual(Array.from(sig)); + }); + + it("TronVerifyMessage: address + signature + message", () => { + const m = new TronVerifyMessage(); + m.setAddress("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + m.setSignature(new Uint8Array(65).fill(0x11)); + m.setMessage(new Uint8Array([0x77, 0x66])); + + const bytes = m.serializeBinary(); + const decoded = new TronVerifyMessage(); + TronVerifyMessage.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect(decoded.getAddress()).toBe("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"); + expect((decoded.getSignature() as Uint8Array).length).toBe(65); + expect(Array.from(decoded.getMessage() as Uint8Array)).toEqual([0x77, 0x66]); + }); + + it("TronSignTypedHash: domain hash + message hash", () => { + const ds = new Uint8Array(32).fill(0xaa); + const mh = new Uint8Array(32).fill(0xbb); + const m = new TronSignTypedHash(); + m.setAddressNList(ALLALLALL_TRON_PATH); + m.setDomainSeparatorHash(ds); + m.setMessageHash(mh); + + const bytes = m.serializeBinary(); + const decoded = new TronSignTypedHash(); + TronSignTypedHash.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect(decoded.getAddressNList()).toEqual(ALLALLALL_TRON_PATH); + expect(decoded.getCoinName()).toBe("Tron"); + expect((decoded.getDomainSeparatorHash() as Uint8Array).length).toBe(32); + expect((decoded.getDomainSeparatorHash() as Uint8Array)[0]).toBe(0xaa); + expect((decoded.getMessageHash() as Uint8Array)[0]).toBe(0xbb); + }); + + it("TronSignTypedHash: domain-only (primaryType=EIP712Domain)", () => { + const ds = new Uint8Array(32).fill(0xcc); + const m = new TronSignTypedHash(); + m.setAddressNList(ALLALLALL_TRON_PATH); + m.setDomainSeparatorHash(ds); + // intentionally omit message_hash + + const bytes = m.serializeBinary(); + const decoded = new TronSignTypedHash(); + TronSignTypedHash.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect((decoded.getDomainSeparatorHash() as Uint8Array)[0]).toBe(0xcc); + expect(decoded.getMessageHash()).toBeUndefined(); + }); + + it("TronTypedDataSignature: address + signature", () => { + const m = new TronTypedDataSignature(); + m.setAddress("TLPhTnp8YTzDQqYWGV6tF9oTaXSc8E7DLp"); + m.setSignature(new Uint8Array(65).fill(0x22)); + + const bytes = m.serializeBinary(); + const decoded = new TronTypedDataSignature(); + TronTypedDataSignature.deserializeBinaryFromReader(decoded, new jspb.BinaryReader(bytes)); + + expect(decoded.getAddress()).toBe("TLPhTnp8YTzDQqYWGV6tF9oTaXSc8E7DLp"); + expect((decoded.getSignature() as Uint8Array).length).toBe(65); + }); +}); + +describe("tronSignMessage wrapper", () => { + it("returns address + signature on success", async () => { + const expectedAddr = "TKtWbdpEq1zHGvF3pGZmAgdJaaCxXn5fW9"; + const expectedSig = new Uint8Array(65).fill(0xab); + expectedSig[64] = 27; + + const transport = makeMockTransport( + jest.fn().mockImplementation((mtype: number, _msg: any) => { + expect(mtype).toBe(MESSAGETYPE_TRONSIGNMESSAGE); + const respProto = new TronMessageSignature(); + respProto.setAddress(expectedAddr); + respProto.setSignature(expectedSig); + return Promise.resolve({ + message_enum: MESSAGETYPE_TRONMESSAGESIGNATURE, + message_type: "TronMessageSignature", + proto: respProto, + }); + }) + ); + + const result = await tronSignMessage(transport, { + addressNList: ALLALLALL_TRON_PATH, + message: "hello tron", + }); + + expect(result.address).toBe(expectedAddr); + expect(Array.from(result.signature as Uint8Array)).toEqual(Array.from(expectedSig)); + }); + + it("encodes UTF-8 strings as bytes when message is a string", async () => { + const transport = makeMockTransport( + jest.fn().mockImplementation((_mtype: number, msg: any) => { + // Verify the wrapper encoded "hi" → [0x68, 0x69] + expect(Array.from(msg.getMessage() as Uint8Array)).toEqual([0x68, 0x69]); + const respProto = new TronMessageSignature(); + respProto.setAddress("T0"); + respProto.setSignature(new Uint8Array(65)); + return Promise.resolve({ + message_enum: MESSAGETYPE_TRONMESSAGESIGNATURE, + message_type: "TronMessageSignature", + proto: respProto, + }); + }) + ); + + await tronSignMessage(transport, { + addressNList: ALLALLALL_TRON_PATH, + message: "hi", + }); + }); +}); + +describe("tronVerifyMessage wrapper", () => { + it("returns true on Success response", async () => { + const transport = makeMockTransport( + jest.fn().mockImplementation(() => + Promise.resolve({ + message_enum: 2 /* MESSAGETYPE_SUCCESS */, + message_type: "Success", + proto: { getMessage: () => "Message verified" }, + }) + ) + ); + + const ok = await tronVerifyMessage(transport, { + address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + signature: new Uint8Array(65), + message: "any", + }); + expect(ok).toBe(true); + }); + + it("returns false when transport.call() throws Failure (mismatch)", async () => { + // Regression for keepkey/hdwallet#38 review finding: + // Promise contract was broken — caller got a rejected + // promise on signature mismatch instead of resolved `false`. + const failureEvent = { + message_enum: Messages.MessageType.MESSAGETYPE_FAILURE, + message_type: "Failure", + message: { code: 9 /* SyntaxError */, message: "Invalid signature" }, + }; + const transport = makeMockTransport(jest.fn().mockRejectedValue(failureEvent)); + + const ok = await tronVerifyMessage(transport, { + address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + signature: new Uint8Array(65), + message: "any", + }); + expect(ok).toBe(false); + }); + + it("ActionCancelled bubbles past the catch (cancel != mismatch)", async () => { + const transport = makeMockTransport(jest.fn().mockRejectedValue(new core.ActionCancelled())); + + await expect( + tronVerifyMessage(transport, { + address: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + signature: new Uint8Array(65), + message: "any", + }) + ).rejects.toBeInstanceOf(core.ActionCancelled); + }); +}); + +describe("tronSignTypedHash wrapper", () => { + it("rejects domain hash with wrong length", async () => { + const transport = makeMockTransport(jest.fn()); + await expect( + tronSignTypedHash(transport, { + addressNList: ALLALLALL_TRON_PATH, + domainSeparatorHash: new Uint8Array(31), // too short + }) + ).rejects.toThrow(/domain_separator_hash must be exactly 32 bytes/); + }); + + it("rejects message hash with wrong length", async () => { + const transport = makeMockTransport(jest.fn()); + await expect( + tronSignTypedHash(transport, { + addressNList: ALLALLALL_TRON_PATH, + domainSeparatorHash: new Uint8Array(32), + messageHash: new Uint8Array(33), // too long + }) + ).rejects.toThrow(/message_hash must be exactly 32 bytes/); + }); + + it("returns address + signature on success", async () => { + const transport = makeMockTransport( + jest.fn().mockImplementation(() => { + const respProto = new TronTypedDataSignature(); + respProto.setAddress("TLPhTnp8YTzDQqYWGV6tF9oTaXSc8E7DLp"); + respProto.setSignature(new Uint8Array(65).fill(0x22)); + return Promise.resolve({ + message_enum: MESSAGETYPE_TRONTYPEDDATASIGNATURE, + message_type: "TronTypedDataSignature", + proto: respProto, + }); + }) + ); + + const result = await tronSignTypedHash(transport, { + addressNList: ALLALLALL_TRON_PATH, + domainSeparatorHash: new Uint8Array(32).fill(0xaa), + messageHash: new Uint8Array(32).fill(0xbb), + }); + + expect(result.address).toBe("TLPhTnp8YTzDQqYWGV6tF9oTaXSc8E7DLp"); + expect((result.signature as Uint8Array).length).toBe(65); + }); +});