diff --git a/packages/hdwallet-core/src/solana.ts b/packages/hdwallet-core/src/solana.ts index e57db2ec..6ca7efd0 100644 --- a/packages/hdwallet-core/src/solana.ts +++ b/packages/hdwallet-core/src/solana.ts @@ -30,14 +30,26 @@ export interface SolanaSignTx { tokenRecipientOwners?: Array; /** One-request opaque-signing authorization; does not mutate AdvancedMode. */ allowBlindSigning?: boolean; - /** Transaction-bound, signer-attested KKSOLSW1 swap descriptor. */ - swapMetadata?: { - payload: Uint8Array | string; + /** + * Transaction-bound, signer-attested resolution of the Address Lookup + * Table accounts this exact message references (KKSOLSW1). `accounts` is + * the raw canonical account list: all writable lookup keys, then all + * readonly lookup keys, in lookup-table/index order — max 8. Firmware + * verifies `signature` (64-byte compact secp256k1) over + * SHA256("KeepKeySolanaTxAccounts/1" || message_hash(32) || count(LE32) || + * account[0..count-1]). + * + * `signerKeyId` is a runtime clear-sign signer slot (0-3) for the + * annotation-only path (Advanced Mode still required), or the certified + * delegate sentinel 0x80 when `certificate` is also set on the request. + */ + lutProof?: { + accounts: Array; signature: Uint8Array | string; signerKeyId: number; }; /** - * Signer-attested KKSOLSC1 instruction schema. Unlike swapMetadata this is + * Signer-attested KKSOLSC1 instruction schema. Unlike lutProof this is * NOT bound to one transaction: it describes how to read a program's * instruction, so a single signature is reused for every transaction to * that program and the device decodes values from the bytes it signs. @@ -47,6 +59,14 @@ export interface SolanaSignTx { signature: Uint8Array | string; signerKeyId: number; }; + /** + * 139-byte KeepKey root certificate authorizing the delegate that signed + * `schema` and, when present, `lutProof`. Required for the certified path — + * schema.signerKeyId and any present lutProof.signerKeyId MUST be 0x80. + * Self-contained legacy/v0 messages intentionally omit lutProof because all + * instruction accounts are already committed by rawTx. + */ + certificate?: Uint8Array | string; } export interface SolanaSignedTx { diff --git a/packages/hdwallet-keepkey/src/solana-certified-wire.test.ts b/packages/hdwallet-keepkey/src/solana-certified-wire.test.ts new file mode 100644 index 00000000..d4caefbf --- /dev/null +++ b/packages/hdwallet-keepkey/src/solana-certified-wire.test.ts @@ -0,0 +1,131 @@ +import * as jspb from "google-protobuf"; + +import { SolanaSignedTx, solanaSignTx } from "./solana"; + +const SOLANA_SIGN_TX = 752; +const SOLANA_SIGNED_TX = 753; +const PATH = [0x8000002c, 0x800001f5, 0x80000000, 0x80000000]; + +function makeTransport(inspect: (bytes: Uint8Array) => void) { + return { + debugLink: false, + lockDuring: (fn: () => Promise) => fn(), + call: jest.fn().mockImplementation((messageType: number, msg: jspb.Message) => { + expect(messageType).toBe(SOLANA_SIGN_TX); + inspect((msg as any).serializeBinary()); + const response = new SolanaSignedTx(); + response.setSignature(new Uint8Array(64).fill(0x42)); + return Promise.resolve({ + message_enum: SOLANA_SIGNED_TX, + message_type: "SolanaSignedTx", + proto: response, + }); + }), + } as any; +} + +function decodeFieldNumbers(bytes: Uint8Array): number[] { + const reader = new jspb.BinaryReader(bytes); + const fields: number[] = []; + while (reader.nextField()) { + if (reader.isEndGroup()) break; + fields.push(reader.getFieldNumber()); + reader.skipField(); + } + return fields; +} + +describe("certified Solana wire shapes", () => { + it("encodes schema + certificate without manufacturing LUT fields", async () => { + const schemaPayload = new Uint8Array([0x4b, 0x4b, 0x53, 0x4f, 0x4c]); + const schemaSignature = new Uint8Array(64).fill(0x22); + const certificate = new Uint8Array(139).map((_, i) => i); + const transport = makeTransport((bytes) => { + const fields = decodeFieldNumbers(bytes); + expect(fields).toEqual(expect.arrayContaining([1, 3, 9, 10, 11, 13])); + expect(fields).not.toContain(5); + expect(fields).not.toContain(6); + expect(fields).not.toContain(7); + + const reader = new jspb.BinaryReader(bytes); + const decoded: Record = {}; + while (reader.nextField()) { + if (reader.isEndGroup()) break; + const field = reader.getFieldNumber(); + if (field === 9 || field === 10 || field === 13) decoded[field] = reader.readBytes(); + else if (field === 11) decoded[field] = reader.readUint32(); + else reader.skipField(); + } + expect(decoded[9]).toEqual(schemaPayload); + expect(decoded[10]).toEqual(schemaSignature); + expect(decoded[11]).toBe(0x80); + expect(decoded[13]).toEqual(certificate); + }); + + await solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + schema: { + payload: schemaPayload, + signature: schemaSignature, + signerKeyId: 0x80, + }, + certificate, + }); + }); + + it("adds the LUT account/signature/id only for the ALT-backed shape", async () => { + const transport = makeTransport((bytes) => { + const fields = decodeFieldNumbers(bytes); + expect(fields.filter((field) => field === 5)).toHaveLength(2); + expect(fields).toEqual(expect.arrayContaining([6, 7, 9, 10, 11, 13])); + }); + + await solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + lutProof: { + accounts: [new Uint8Array(32).fill(0x11), new Uint8Array(32).fill(0x12)], + signature: new Uint8Array(64).fill(0x21), + signerKeyId: 0x80, + }, + schema: { + payload: new Uint8Array([0x4b, 0x4b, 0x53, 0x4f, 0x4c]), + signature: new Uint8Array(64).fill(0x22), + signerKeyId: 0x80, + }, + certificate: new Uint8Array(139).fill(0x33), + }); + }); + + it("rejects partial or mixed certified material before transport", async () => { + const call = jest.fn(); + const transport = { + debugLink: false, + lockDuring: (fn: () => Promise) => fn(), + call, + } as any; + + await expect( + solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + certificate: new Uint8Array(139), + }) + ).rejects.toThrow(/requires schema signerKeyId 0x80/); + + await expect( + solanaSignTx(transport, { + addressNList: PATH, + rawTx: new Uint8Array([0x80, 0x00]), + schema: { + payload: new Uint8Array([1]), + signature: new Uint8Array(64), + signerKeyId: 0x80, + }, + }) + ).rejects.toThrow(/requires a certificate/); + + expect(call).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/hdwallet-keepkey/src/solana.ts b/packages/hdwallet-keepkey/src/solana.ts index 017199f2..fa97639d 100644 --- a/packages/hdwallet-keepkey/src/solana.ts +++ b/packages/hdwallet-keepkey/src/solana.ts @@ -78,6 +78,96 @@ function concatBytes(...chunks: Uint8Array[]): Uint8Array { return out; } +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.length !== right.length) return false; + let diff = 0; + for (let i = 0; i < left.length; i++) diff |= left[i] ^ right[i]; + return diff === 0; +} + +type CertifiedWireEnvelope = { + schemaPayload: Uint8Array; + schemaSignature: Uint8Array; + schemaSignerKeyId: number; + certificate: Uint8Array; + lutAccounts: Uint8Array[]; + lutSignature?: Uint8Array; + lutSignerKeyId?: number; +}; + +/** + * Decode the final serialized request and require byte-for-byte agreement with + * the certified material supplied by the caller. This checks the actual bytes + * Transport.call() will frame, so a jspb shim, bundler, or field-number + * regression fails on the host instead of becoming a misleading device-side + * certificate error. + */ +function assertCertifiedWireEnvelope(wire: Uint8Array, expected: CertifiedWireEnvelope): void { + const reader = new jspb.BinaryReader(wire); + const lutAccounts: Uint8Array[] = []; + let schemaPayload: Uint8Array | undefined; + let schemaSignature: Uint8Array | undefined; + let schemaSignerKeyId: number | undefined; + let certificate: Uint8Array | undefined; + let lutSignature: Uint8Array | undefined; + let lutSignerKeyId: number | undefined; + + while (reader.nextField()) { + if (reader.isEndGroup()) break; + switch (reader.getFieldNumber()) { + case 5: + lutAccounts.push(reader.readBytes()); + break; + case 6: + if (lutSignature !== undefined) throw new Error("duplicate Solana LUT signature field"); + lutSignature = reader.readBytes(); + break; + case 7: + if (lutSignerKeyId !== undefined) throw new Error("duplicate Solana LUT signer field"); + lutSignerKeyId = reader.readUint32(); + break; + case 9: + if (schemaPayload !== undefined) throw new Error("duplicate Solana schema payload field"); + schemaPayload = reader.readBytes(); + break; + case 10: + if (schemaSignature !== undefined) throw new Error("duplicate Solana schema signature field"); + schemaSignature = reader.readBytes(); + break; + case 11: + if (schemaSignerKeyId !== undefined) throw new Error("duplicate Solana schema signer field"); + schemaSignerKeyId = reader.readUint32(); + break; + case 13: + if (certificate !== undefined) throw new Error("duplicate Solana certificate field"); + certificate = reader.readBytes(); + break; + default: + reader.skipField(); + break; + } + } + + const sameAccounts = + lutAccounts.length === expected.lutAccounts.length && + lutAccounts.every((account, i) => bytesEqual(account, expected.lutAccounts[i])); + if ( + !schemaPayload || + !bytesEqual(schemaPayload, expected.schemaPayload) || + !schemaSignature || + !bytesEqual(schemaSignature, expected.schemaSignature) || + schemaSignerKeyId !== expected.schemaSignerKeyId || + !certificate || + !bytesEqual(certificate, expected.certificate) || + !sameAccounts || + (lutSignature === undefined) !== (expected.lutSignature === undefined) || + (lutSignature !== undefined && !bytesEqual(lutSignature, expected.lutSignature!)) || + lutSignerKeyId !== expected.lutSignerKeyId + ) { + throw new Error("serialized certified Solana ClearSign envelope does not match its source material"); + } +} + function encodeSolanaTokenInfo(info: core.SolanaTokenInfo): Uint8Array { const fields: Uint8Array[] = [encodeLengthDelimited(1, toSolanaPubkey(info.mint, "token mint"))]; if (info.symbol !== undefined) { @@ -355,34 +445,10 @@ export class SolanaSignTx extends jspb.Message { jspb.Message.setField(this, 3, value); } - getSwapMetadataPayload(): Uint8Array | string { - return jspb.Message.getFieldWithDefault(this, 5, "") as Uint8Array | string; - } - getSwapMetadataPayload_asU8(): Uint8Array { - const val = this.getSwapMetadataPayload(); - return val instanceof Uint8Array ? val : jspb.Message.bytesAsU8(val as string); - } - setSwapMetadataPayload(value: Uint8Array | string): void { - jspb.Message.setField(this, 5, value); - } - - getSwapMetadataSignature(): Uint8Array | string { - return jspb.Message.getFieldWithDefault(this, 6, "") as Uint8Array | string; - } - getSwapMetadataSignature_asU8(): Uint8Array { - const val = this.getSwapMetadataSignature(); - return val instanceof Uint8Array ? val : jspb.Message.bytesAsU8(val as string); - } - setSwapMetadataSignature(value: Uint8Array | string): void { - jspb.Message.setField(this, 6, value); - } - - getSwapMetadataSignerKeyId(): number { - return jspb.Message.getFieldWithDefault(this, 7, 0) as number; - } - setSwapMetadataSignerKeyId(value: number): void { - jspb.Message.setField(this, 7, value); - } + // Fields 5 (lut_account, repeated), 6 (lut_signature), 7 (lut_signer_key_id), + // and 13 (clearsign_certificate) are carried via the wire-append shim below + // (see solanaSignTx), matching the schema/tokenRecipientOwner fields — + // field 5 is a repeated raw account list, not a single opaque blob. getAllowOpaque(): boolean { return jspb.Message.getFieldWithDefault(this, 8, false) as boolean; @@ -402,9 +468,6 @@ export class SolanaSignTx extends jspb.Message { addressNList: this.getAddressNList(), coinName: this.getCoinName(), rawTx: this.getRawTx(), - swapMetadataPayload: this.getSwapMetadataPayload(), - swapMetadataSignature: this.getSwapMetadataSignature(), - swapMetadataSignerKeyId: this.getSwapMetadataSignerKeyId(), allowOpaque: this.getAllowOpaque(), }; } @@ -435,15 +498,6 @@ export class SolanaSignTx extends jspb.Message { case 3: msg.setRawTx(reader.readBytes()); break; - case 5: - msg.setSwapMetadataPayload(reader.readBytes()); - break; - case 6: - msg.setSwapMetadataSignature(reader.readBytes()); - break; - case 7: - msg.setSwapMetadataSignerKeyId(reader.readUint32()); - break; case 8: msg.setAllowOpaque(reader.readBool()); break; @@ -468,18 +522,6 @@ export class SolanaSignTx extends jspb.Message { if (rawTx.length > 0) { writer.writeBytes(3, rawTx); } - const metadataPayload = message.getSwapMetadataPayload_asU8(); - if (metadataPayload.length > 0) { - writer.writeBytes(5, metadataPayload); - } - const metadataSignature = message.getSwapMetadataSignature_asU8(); - if (metadataSignature.length > 0) { - writer.writeBytes(6, metadataSignature); - } - const metadataSignerKeyId = jspb.Message.getField(message, 7) as number | null; - if (metadataSignerKeyId != null) { - writer.writeUint32(7, metadataSignerKeyId); - } const allowOpaque = jspb.Message.getField(message, 8) as boolean | null; if (allowOpaque != null) { writer.writeBool(8, allowOpaque); @@ -492,9 +534,6 @@ export namespace SolanaSignTx { addressNList: number[]; coinName?: string; rawTx: Uint8Array | string; - swapMetadataPayload?: Uint8Array | string; - swapMetadataSignature?: Uint8Array | string; - swapMetadataSignerKeyId?: number; allowOpaque?: boolean; }; } @@ -1158,19 +1197,6 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) rawBytes = new Uint8Array(msg.rawTx as any); } signTx.setRawTx(rawBytes); - if (msg.swapMetadata) { - const payload = - msg.swapMetadata.payload instanceof Uint8Array - ? msg.swapMetadata.payload - : Uint8Array.from(Buffer.from(msg.swapMetadata.payload, "base64")); - const signature = - msg.swapMetadata.signature instanceof Uint8Array - ? msg.swapMetadata.signature - : Uint8Array.from(Buffer.from(msg.swapMetadata.signature, "base64")); - signTx.setSwapMetadataPayload(payload); - signTx.setSwapMetadataSignature(signature); - signTx.setSwapMetadataSignerKeyId(msg.swapMetadata.signerKeyId); - } if (msg.allowBlindSigning === true) { signTx.setAllowOpaque(true); } @@ -1182,23 +1208,74 @@ export async function solanaSignTx(transport: Transport, msg: core.SolanaSignTx) * same canonical field numbers emitted by device-protocol. */ const extraFields: Uint8Array[] = []; + const certifiedWire: Partial & { lutAccounts: Uint8Array[] } = { + lutAccounts: [], + }; + const certified = msg.certificate !== undefined; + if (certified) { + if (!msg.schema || msg.schema.signerKeyId !== 0x80) { + throw new Error("certified Solana request requires schema signerKeyId 0x80"); + } + if (msg.lutProof && msg.lutProof.signerKeyId !== 0x80) { + throw new Error("certified Solana LUT proof requires signerKeyId 0x80"); + } + } else if (msg.schema?.signerKeyId === 0x80 || msg.lutProof?.signerKeyId === 0x80) { + throw new Error("Solana delegate signerKeyId 0x80 requires a certificate"); + } for (const tokenInfo of msg.tokenInfo || []) { extraFields.push(encodeLengthDelimited(4, encodeSolanaTokenInfo(tokenInfo))); } + if (msg.lutProof) { + if (msg.lutProof.accounts.length < 1 || msg.lutProof.accounts.length > 8) { + throw new Error("lutProof.accounts must contain 1-8 accounts"); + } + for (const account of msg.lutProof.accounts) { + const accountBytes = toSolanaPubkey(account, "lut account"); + certifiedWire.lutAccounts.push(accountBytes); + extraFields.push(encodeLengthDelimited(5, accountBytes)); + } + const lutSignature = toBytes(msg.lutProof.signature); + if (lutSignature.length !== 64) { + throw new Error(`lutProof.signature must be exactly 64 bytes, got ${lutSignature.length}`); + } + extraFields.push(encodeLengthDelimited(6, lutSignature), encodeVarintField(7, msg.lutProof.signerKeyId)); + certifiedWire.lutSignature = lutSignature; + certifiedWire.lutSignerKeyId = msg.lutProof.signerKeyId; + } if (msg.schema) { const payload = toBytes(msg.schema.payload); const signature = toBytes(msg.schema.signature); + if (payload.length === 0) { + throw new Error("schema.payload must not be empty"); + } + if (signature.length !== 64) { + throw new Error(`schema.signature must be exactly 64 bytes, got ${signature.length}`); + } extraFields.push( encodeLengthDelimited(9, payload), encodeLengthDelimited(10, signature), encodeVarintField(11, msg.schema.signerKeyId) ); + certifiedWire.schemaPayload = payload; + certifiedWire.schemaSignature = signature; + certifiedWire.schemaSignerKeyId = msg.schema.signerKeyId; } for (const owner of msg.tokenRecipientOwners || []) { extraFields.push(encodeLengthDelimited(12, toSolanaPubkey(owner, "token recipient owner"))); } + if (msg.certificate) { + const certificate = toBytes(msg.certificate); + if (certificate.length !== 139) { + throw new Error(`certificate must be exactly 139 bytes, got ${certificate.length}`); + } + extraFields.push(encodeLengthDelimited(13, certificate)); + certifiedWire.certificate = certificate; + } const outbound: jspb.Message = extraFields.length > 0 ? withAppendedFields(signTx, concatBytes(...extraFields)) : signTx; + if (certified) { + assertCertifiedWireEnvelope(outbound.serializeBinary(), certifiedWire as CertifiedWireEnvelope); + } const resp = await transport.call(MESSAGETYPE_SOLANASIGNTX, outbound, { msgTimeout: core.LONG_TIMEOUT, diff --git a/packages/hdwallet-keepkey/src/typeRegistry.test.ts b/packages/hdwallet-keepkey/src/typeRegistry.test.ts new file mode 100644 index 00000000..297f00ce --- /dev/null +++ b/packages/hdwallet-keepkey/src/typeRegistry.test.ts @@ -0,0 +1,16 @@ +import * as jspb from "google-protobuf"; + +import { messageTypeRegistry } from "./typeRegistry"; + +describe("protobuf type registry", () => { + it("contains only message constructors, never generated enum maps", () => { + // Retired protocol MessageType values intentionally have no constructor; + // every populated entry must still be a real protobuf message class. + const constructors = Object.values(messageTypeRegistry).filter(Boolean); + expect(constructors.length).toBeGreaterThan(0); + for (const constructor of constructors) { + expect(typeof constructor).toBe("function"); + expect(constructor.prototype).toBeInstanceOf(jspb.Message); + } + }); +}); diff --git a/packages/hdwallet-keepkey/src/typeRegistry.ts b/packages/hdwallet-keepkey/src/typeRegistry.ts index 99b83070..46fb3ce7 100644 --- a/packages/hdwallet-keepkey/src/typeRegistry.ts +++ b/packages/hdwallet-keepkey/src/typeRegistry.ts @@ -17,30 +17,29 @@ import * as core from "@keepkey/hdwallet-core"; import * as jspb from "google-protobuf"; import * as Eip712 from "./eip712Wire"; -function omit(obj: Record, ...keys: string[]): Record { - const result = { ...obj }; - for (const key of keys) delete result[key]; - return result; +function messageEntries(obj: Record): Array<[string, core.Constructor]> { + return Object.entries(obj).filter( + (entry): entry is [string, core.Constructor] => + typeof entry[1] === "function" && entry[1].prototype instanceof jspb.Message + ); } -// Conflict between typedef and actual js export - const AllMessages = ([] as Array<[string, core.Constructor]>) - .concat(Object.entries(omit(Messages, "MessageType", "MessageTypeMap"))) - .concat(Object.entries(BinanceMessages)) - .concat(Object.entries(CosmosMessages)) - .concat(Object.entries(EthereumMessages)) - .concat(Object.entries(OsmosisMessages)) - .concat(Object.entries(RippleMessages)) - .concat(Object.entries(NanoMessages)) - .concat(Object.entries(omit(EosMessages, "EosPublicKeyKind", "EosPublicKeyKindMap"))) - .concat(Object.entries(SolanaMessages)) - .concat(Object.entries(TendermintMessages)) - .concat(Object.entries(ThorchainMessages)) - .concat(Object.entries(TonMessages)) - .concat(Object.entries(TronMessages)) - .concat(Object.entries(MayachainMessages)) - .concat(Object.entries(omit(ZcashMessages, "ZcashShieldedPool", "ZcashShieldedPoolMap"))); + .concat(messageEntries(Messages)) + .concat(messageEntries(BinanceMessages)) + .concat(messageEntries(CosmosMessages)) + .concat(messageEntries(EthereumMessages)) + .concat(messageEntries(OsmosisMessages)) + .concat(messageEntries(RippleMessages)) + .concat(messageEntries(NanoMessages)) + .concat(messageEntries(EosMessages)) + .concat(messageEntries(SolanaMessages)) + .concat(messageEntries(TendermintMessages)) + .concat(messageEntries(ThorchainMessages)) + .concat(messageEntries(TonMessages)) + .concat(messageEntries(TronMessages)) + .concat(messageEntries(MayachainMessages)) + .concat(messageEntries(ZcashMessages)); const upperCasedMessageClasses = AllMessages.reduce((registry, entry: [string, core.Constructor]) => { registry[entry[0].toUpperCase()] = entry[1];