Skip to content

Commit 5ed583d

Browse files
Merge pull request #59 from keepkey/feat/clearsign-studio-ironwood
feat: stage complete protocol client integration
2 parents e6838b2 + db9233f commit 5ed583d

22 files changed

Lines changed: 1242 additions & 121 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { BTCInputScriptType, describeUTXOPath, taprootAccount } from "./bitcoin";
2+
3+
describe("Bitcoin Taproot paths", () => {
4+
it("describes a BIP-86 account as Taproot", () => {
5+
const account = taprootAccount("Bitcoin", 0, 7);
6+
expect(account).toEqual({
7+
coin: "Bitcoin",
8+
scriptType: BTCInputScriptType.SpendTaproot,
9+
addressNList: [0x80000000 + 86, 0x80000000, 0x80000000 + 7],
10+
});
11+
expect(describeUTXOPath(account.addressNList, "Bitcoin", BTCInputScriptType.SpendTaproot)).toMatchObject({
12+
coin: "Bitcoin",
13+
accountIdx: 7,
14+
wholeAccount: true,
15+
isKnown: true,
16+
scriptType: BTCInputScriptType.SpendTaproot,
17+
verbose: "Bitcoin Account #7 (Taproot)",
18+
});
19+
});
20+
21+
it("does not describe BIP-86 with another script type", () => {
22+
const path = [0x80000000 + 86, 0x80000000, 0x80000000];
23+
expect(describeUTXOPath(path, "Bitcoin", BTCInputScriptType.SpendWitness).isKnown).toBe(false);
24+
});
25+
});

packages/hdwallet-core/src/bitcoin.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ type BTCSignTxInputNativeBase = BTCSignTxInputBase & {
7676
};
7777

7878
type BTCSignTxInputNativeSegwitBase = BTCSignTxInputNativeBase & {
79-
scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness;
79+
scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness | BTCInputScriptType.SpendTaproot;
8080
};
8181

8282
type BTCSignTxInputNativeSegwitWithHex = BTCSignTxInputNativeSegwitBase & {
@@ -106,7 +106,11 @@ type BTCSignTxInputKKBase = BTCSignTxInputBase & {
106106
};
107107

108108
type BTCSignTxInputKKSegwit = BTCSignTxInputKKBase & {
109-
scriptType: BTCInputScriptType.SpendWitness | BTCInputScriptType.SpendP2SHWitness | BTCInputScriptType.External;
109+
scriptType:
110+
| BTCInputScriptType.SpendWitness
111+
| BTCInputScriptType.SpendP2SHWitness
112+
| BTCInputScriptType.SpendTaproot
113+
| BTCInputScriptType.External;
110114
hex?: string;
111115
};
112116

@@ -231,6 +235,7 @@ export enum BTCInputScriptType {
231235
External = "external",
232236
SpendWitness = "p2wpkh",
233237
SpendP2SHWitness = "p2sh-p2wpkh",
238+
SpendTaproot = "p2tr",
234239
}
235240

236241
export enum BTCOutputScriptType {
@@ -239,6 +244,7 @@ export enum BTCOutputScriptType {
239244
Bech32 = "bech32",
240245
PayToWitness = "p2wpkh",
241246
PayToP2SHWitness = "p2sh-p2wpkh",
247+
PayToTaproot = "p2tr", // device-derived change only
242248
}
243249

244250
export enum BTCOutputAddressType {
@@ -362,12 +368,16 @@ export function describeUTXOPath(path: BIP32Path, coin: Coin, scriptType: BTCInp
362368

363369
const purpose = path[0] & 0x7fffffff;
364370

365-
if (![44, 49, 84].includes(purpose)) return unknown;
371+
if (![44, 49, 84, 86].includes(purpose)) return unknown;
366372

367373
if (purpose === 44 && scriptType !== BTCInputScriptType.SpendAddress) return unknown;
368374

369375
if (purpose === 49 && scriptType !== BTCInputScriptType.SpendP2SHWitness) return unknown;
370376

377+
if (purpose === 84 && scriptType !== BTCInputScriptType.SpendWitness) return unknown;
378+
379+
if (purpose === 86 && scriptType !== BTCInputScriptType.SpendTaproot) return unknown;
380+
371381
const wholeAccount = path.length === 3;
372382

373383
const script = (
@@ -376,6 +386,7 @@ export function describeUTXOPath(path: BIP32Path, coin: Coin, scriptType: BTCInp
376386
[BTCInputScriptType.SpendP2SHWitness]: [],
377387
[BTCInputScriptType.SpendWitness]: ["Segwit"],
378388
[BTCInputScriptType.Bech32]: ["Segwit Native"],
389+
[BTCInputScriptType.SpendTaproot]: ["Taproot"],
379390
} as Partial<Record<BTCInputScriptType, string[]>>
380391
)[scriptType];
381392

@@ -471,3 +482,11 @@ export function segwitNativeAccount(coin: Coin, slip44: number, accountIdx: numb
471482
addressNList: [0x80000000 + 84, 0x80000000 + slip44, 0x80000000 + accountIdx],
472483
};
473484
}
485+
486+
export function taprootAccount(coin: Coin, slip44: number, accountIdx: number): BTCAccountPath {
487+
return {
488+
coin,
489+
scriptType: BTCInputScriptType.SpendTaproot,
490+
addressNList: [0x80000000 + 86, 0x80000000 + slip44, 0x80000000 + accountIdx],
491+
};
492+
}

packages/hdwallet-core/src/ethereum.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ export interface ETHSignedTx {
103103
s: string;
104104
/** big-endian hex, prefixed with '0x' */
105105
serialized: string;
106+
/** KeepKey-only: keccak256 pre-image the firmware actually signed (32-byte hex).
107+
* Optional — older firmware doesn't populate it. Useful for diagnostics where
108+
* the caller needs to verify which bytes the device hashed. */
109+
deviceSignedHash?: string;
106110
}
107111

108112
export interface ETHSignMessage {

packages/hdwallet-core/src/solana.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,44 @@ export interface SolanaAddress {
99
address: string;
1010
}
1111

12+
export interface SolanaTokenInfo {
13+
/** 32-byte SPL mint, encoded as bytes, hex, base64, or base58. */
14+
mint: Uint8Array | string;
15+
symbol?: string;
16+
decimals?: number;
17+
signature?: Uint8Array | string;
18+
signerKeyId?: number;
19+
}
20+
1221
export interface SolanaSignTx {
1322
addressNList: BIP32Path;
1423
rawTx: Uint8Array | string;
24+
/** Optional token definitions used by firmware display policy. */
25+
tokenInfo?: SolanaTokenInfo[];
26+
/**
27+
* Candidate owners for signed SPL token destinations (for example x402
28+
* payTo). Firmware displays one only after deriving and matching its ATA.
29+
*/
30+
tokenRecipientOwners?: Array<Uint8Array | string>;
31+
/** One-request opaque-signing authorization; does not mutate AdvancedMode. */
32+
allowBlindSigning?: boolean;
33+
/** Transaction-bound, signer-attested KKSOLSW1 swap descriptor. */
34+
swapMetadata?: {
35+
payload: Uint8Array | string;
36+
signature: Uint8Array | string;
37+
signerKeyId: number;
38+
};
39+
/**
40+
* Signer-attested KKSOLSC1 instruction schema. Unlike swapMetadata this is
41+
* NOT bound to one transaction: it describes how to read a program's
42+
* instruction, so a single signature is reused for every transaction to
43+
* that program and the device decodes values from the bytes it signs.
44+
*/
45+
schema?: {
46+
payload: Uint8Array | string;
47+
signature: Uint8Array | string;
48+
signerKeyId: number;
49+
};
1550
}
1651

1752
export interface SolanaSignedTx {

packages/hdwallet-keepkey-nodewebusb/src/adapter.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ export const NodeWebUSBAdapterDelegate = {
1010
return devices.filter((x) => x.vendorId === VENDOR_ID && [WEBUSB_PRODUCT_ID, HID_PRODUCT_ID].includes(x.productId));
1111
},
1212
async getDevice(serialNumber?: string): Promise<Device> {
13+
// Only match the WebUSB PID (0x0002). The TransportDelegate ctor rejects any
14+
// other PID with FirmwareUpdateRequired, so matching legacy 0x0001 here just
15+
// produced a doomed pair attempt + a misleading "Firmware 6.1.0 required"
16+
// before the caller's HID fallback. Old (PID 0x0001) devices now skip WebUSB
17+
// cleanly and pair over HID. (getDevices() still lists 0x0001 for detection.)
1318
const out = await webusb.requestDevice({
14-
filters: [
15-
{ vendorId: VENDOR_ID, productId: WEBUSB_PRODUCT_ID, serialNumber },
16-
{ vendorId: VENDOR_ID, productId: HID_PRODUCT_ID, serialNumber },
17-
],
19+
filters: [{ vendorId: VENDOR_ID, productId: WEBUSB_PRODUCT_ID, serialNumber }],
1820
});
1921
if (out.serialNumber === undefined) throw new Error("expected serial number");
2022
return out as Device;

packages/hdwallet-keepkey/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"dependencies": {
1818
"@ethereumjs/common": "^2.4.0",
1919
"@ethereumjs/tx": "^3.3.0",
20-
"@keepkey/device-protocol": "npm:@bithighlander/device-protocol@7.16.0",
20+
"@keepkey/device-protocol": "https://github.com/keepkey/device-protocol.git#674777f6d4dd16e2b8c4c2df10608976375ee879",
2121
"@keepkey/hdwallet-core": "1.53.16",
2222
"@keepkey/proto-tx-builder": "^0.9.1",
2323
"@shapeshiftoss/bitcoinjs-lib": "5.2.0-shapeshift.2",

packages/hdwallet-keepkey/src/bitcoin.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const supportedCoins = [
2626
];
2727

2828
const segwitCoins = ["Bitcoin", "Testnet", "BitcoinGold", "Litecoin"];
29+
const taprootCoins = ["Bitcoin", "Testnet"];
2930

3031
function legacyAccount(coin: core.Coin, slip44: number, accountIdx: number): core.BTCAccountPath {
3132
return {
@@ -51,6 +52,14 @@ function segwitNativeAccount(coin: core.Coin, slip44: number, accountIdx: number
5152
};
5253
}
5354

55+
function taprootAccount(coin: core.Coin, slip44: number, accountIdx: number): core.BTCAccountPath {
56+
return {
57+
coin,
58+
scriptType: core.BTCInputScriptType.SpendTaproot,
59+
addressNList: [0x80000000 + 86, 0x80000000 + slip44, 0x80000000 + accountIdx],
60+
};
61+
}
62+
5463
function packVarint(n: number): string {
5564
if (n < 253) return n.toString(16).padStart(2, "0");
5665
else if (n < 0xffff) return "FD" + n.toString(16).padStart(4, "0");
@@ -120,6 +129,7 @@ function prepareSignTx(
120129
if (
121130
inputTx.scriptType === core.BTCInputScriptType.SpendP2SHWitness ||
122131
inputTx.scriptType === core.BTCInputScriptType.SpendWitness ||
132+
inputTx.scriptType === core.BTCInputScriptType.SpendTaproot ||
123133
inputTx.scriptType === core.BTCInputScriptType.External
124134
)
125135
return;
@@ -248,6 +258,7 @@ export async function btcSupportsScriptType(coin: core.Coin, scriptType?: core.B
248258
if (!supportedCoins.includes(coin)) return false;
249259
if (!segwitCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendP2SHWitness) return false;
250260
if (!segwitCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendWitness) return false;
261+
if (!taprootCoins.includes(coin) && scriptType === core.BTCInputScriptType.SpendTaproot) return false;
251262
return true;
252263
}
253264

@@ -546,6 +557,7 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array<core.BTC
546557
const bip44 = legacyAccount(msg.coin, slip44, msg.accountIdx);
547558
const bip49 = segwitAccount(msg.coin, slip44, msg.accountIdx);
548559
const bip84 = segwitNativeAccount(msg.coin, slip44, msg.accountIdx);
560+
const bip86 = taprootAccount(msg.coin, slip44, msg.accountIdx);
549561

550562
// For BTC Forks
551563
const btcLegacy = legacyAccount(msg.coin, core.slip44ByCoin("Bitcoin"), msg.accountIdx);
@@ -558,12 +570,12 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array<core.BTC
558570
let paths: Array<core.BTCAccountPath> =
559571
(
560572
{
561-
Bitcoin: [bip44, bip49, bip84],
573+
Bitcoin: [bip44, bip49, bip84, bip86],
562574
Litecoin: [bip44, bip49, bip84],
563575
Dash: [bip44],
564576
DigiByte: [bip44, bip49, bip84],
565577
Dogecoin: [bip44],
566-
Testnet: [bip44, bip49, bip84],
578+
Testnet: [bip44, bip49, bip84, bip86],
567579
BitcoinCash: [bip44, btcLegacy],
568580
BitcoinSV: [bip44, bchLegacy, btcLegacy],
569581
BitcoinGold: [bip44, bip49, bip84, btcLegacy, btcSegwit, btcSegwitNative],
@@ -581,7 +593,7 @@ export function btcGetAccountPaths(msg: core.BTCGetAccountPaths): Array<core.BTC
581593
export function btcIsSameAccount(msg: Array<core.BTCAccountPath>): boolean {
582594
if (msg.length < 1) return false;
583595

584-
if (msg.length > 3) return false;
596+
if (msg.length > 4) return false;
585597

586598
const account0 = msg[0];
587599
if (account0.addressNList.length != 3) return false;
@@ -592,6 +604,7 @@ export function btcIsSameAccount(msg: Array<core.BTCAccountPath>): boolean {
592604
[core.BTCInputScriptType.SpendAddress]: 0x80000000 + 44,
593605
[core.BTCInputScriptType.SpendP2SHWitness]: 0x80000000 + 49,
594606
[core.BTCInputScriptType.SpendWitness]: 0x80000000 + 84,
607+
[core.BTCInputScriptType.SpendTaproot]: 0x80000000 + 86,
595608
} as Partial<Record<core.BTCInputScriptType, number>>;
596609
if (purposeForScriptType[account0.scriptType] !== purpose) return false;
597610

@@ -604,12 +617,13 @@ export function btcIsSameAccount(msg: Array<core.BTCAccountPath>): boolean {
604617
if (idx < 0x80000000) return false;
605618

606619
// Accounts must have the same SLIP44 and Account Idx, but may have differing
607-
// purpose fields (so long as they're BIP44/BIP49/BIP84)
620+
// purpose fields (so long as they're BIP44/BIP49/BIP84/BIP86)
608621
if (
609622
msg.find((path) => {
610623
if (path.addressNList.length != 3) return true;
611624

612-
if (![0x80000000 + 44, 0x80000000 + 49, 0x80000000 + 84].includes(path.addressNList[0])) return true;
625+
if (![0x80000000 + 44, 0x80000000 + 49, 0x80000000 + 84, 0x80000000 + 86].includes(path.addressNList[0]))
626+
return true;
613627

614628
if (purposeForScriptType[path.scriptType] !== path.addressNList[0]) return true;
615629

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Regression coverage for ClearSign attestor message decoding.
3+
*
4+
* Transport.fromMessageBuffer always calls deserializeBinaryFromReader on the
5+
* constructor stored in messageTypeRegistry. Keep the attestor entries on the
6+
* canonical generated classes so public-key and signature responses cannot be
7+
* replaced by incomplete hand-written shims.
8+
*/
9+
import "./clearsign";
10+
11+
import * as jspb from "google-protobuf";
12+
13+
import { messageTypeRegistry } from "./typeRegistry";
14+
15+
const ATTESTOR_TYPES = [
16+
[1700, "ClearsignAttestorGetPublicKey"],
17+
[1701, "ClearsignAttestorPublicKey"],
18+
[1702, "ClearsignAttestorSign"],
19+
[1703, "ClearsignAttestorSignature"],
20+
] as const;
21+
22+
describe("ClearSign attestor protobuf transport", () => {
23+
it.each(ATTESTOR_TYPES)("registers message type %i (%s) with reader decoding", (typeId) => {
24+
const registeredType = messageTypeRegistry[typeId] as any;
25+
expect(registeredType).toBeDefined();
26+
expect(typeof registeredType.deserializeBinaryFromReader).toBe("function");
27+
});
28+
29+
it("decodes the public-key response through the transport registry path", () => {
30+
const publicKey = new Uint8Array(33).fill(0x02);
31+
const writer = new jspb.BinaryWriter();
32+
writer.writeBytes(1, publicKey);
33+
const MType = messageTypeRegistry[1701] as any;
34+
const decoded = MType.deserializeBinaryFromReader(new MType(), new jspb.BinaryReader(writer.getResultBuffer()));
35+
36+
expect(Array.from(decoded.getPublicKey_asU8())).toEqual(Array.from(publicKey));
37+
});
38+
39+
it("decodes the signature response through the transport registry path", () => {
40+
const signature = new Uint8Array(64).fill(0x5a);
41+
const publicKey = new Uint8Array(33).fill(0x03);
42+
const writer = new jspb.BinaryWriter();
43+
writer.writeBytes(1, signature);
44+
writer.writeBytes(2, publicKey);
45+
const MType = messageTypeRegistry[1703] as any;
46+
const decoded = MType.deserializeBinaryFromReader(new MType(), new jspb.BinaryReader(writer.getResultBuffer()));
47+
48+
expect(Array.from(decoded.getSignature_asU8())).toEqual(Array.from(signature));
49+
expect(Array.from(decoded.getPublicKey_asU8())).toEqual(Array.from(publicKey));
50+
});
51+
});

0 commit comments

Comments
 (0)