Skip to content

Commit 4b7bca5

Browse files
Merge pull request #67 from keepkey/fix/certified-solana-on-master
fix(solana): preserve certified ClearSign envelopes on master
2 parents 79f0e57 + 0034a80 commit 4b7bca5

5 files changed

Lines changed: 336 additions & 93 deletions

File tree

packages/hdwallet-core/src/solana.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,26 @@ export interface SolanaSignTx {
3030
tokenRecipientOwners?: Array<Uint8Array | string>;
3131
/** One-request opaque-signing authorization; does not mutate AdvancedMode. */
3232
allowBlindSigning?: boolean;
33-
/** Transaction-bound, signer-attested KKSOLSW1 swap descriptor. */
34-
swapMetadata?: {
35-
payload: Uint8Array | string;
33+
/**
34+
* Transaction-bound, signer-attested resolution of the Address Lookup
35+
* Table accounts this exact message references (KKSOLSW1). `accounts` is
36+
* the raw canonical account list: all writable lookup keys, then all
37+
* readonly lookup keys, in lookup-table/index order — max 8. Firmware
38+
* verifies `signature` (64-byte compact secp256k1) over
39+
* SHA256("KeepKeySolanaTxAccounts/1" || message_hash(32) || count(LE32) ||
40+
* account[0..count-1]).
41+
*
42+
* `signerKeyId` is a runtime clear-sign signer slot (0-3) for the
43+
* annotation-only path (Advanced Mode still required), or the certified
44+
* delegate sentinel 0x80 when `certificate` is also set on the request.
45+
*/
46+
lutProof?: {
47+
accounts: Array<Uint8Array | string>;
3648
signature: Uint8Array | string;
3749
signerKeyId: number;
3850
};
3951
/**
40-
* Signer-attested KKSOLSC1 instruction schema. Unlike swapMetadata this is
52+
* Signer-attested KKSOLSC1 instruction schema. Unlike lutProof this is
4153
* NOT bound to one transaction: it describes how to read a program's
4254
* instruction, so a single signature is reused for every transaction to
4355
* that program and the device decodes values from the bytes it signs.
@@ -47,6 +59,14 @@ export interface SolanaSignTx {
4759
signature: Uint8Array | string;
4860
signerKeyId: number;
4961
};
62+
/**
63+
* 139-byte KeepKey root certificate authorizing the delegate that signed
64+
* `schema` and, when present, `lutProof`. Required for the certified path —
65+
* schema.signerKeyId and any present lutProof.signerKeyId MUST be 0x80.
66+
* Self-contained legacy/v0 messages intentionally omit lutProof because all
67+
* instruction accounts are already committed by rawTx.
68+
*/
69+
certificate?: Uint8Array | string;
5070
}
5171

5272
export interface SolanaSignedTx {
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import * as jspb from "google-protobuf";
2+
3+
import { SolanaSignedTx, solanaSignTx } from "./solana";
4+
5+
const SOLANA_SIGN_TX = 752;
6+
const SOLANA_SIGNED_TX = 753;
7+
const PATH = [0x8000002c, 0x800001f5, 0x80000000, 0x80000000];
8+
9+
function makeTransport(inspect: (bytes: Uint8Array) => void) {
10+
return {
11+
debugLink: false,
12+
lockDuring: <T>(fn: () => Promise<T>) => fn(),
13+
call: jest.fn().mockImplementation((messageType: number, msg: jspb.Message) => {
14+
expect(messageType).toBe(SOLANA_SIGN_TX);
15+
inspect((msg as any).serializeBinary());
16+
const response = new SolanaSignedTx();
17+
response.setSignature(new Uint8Array(64).fill(0x42));
18+
return Promise.resolve({
19+
message_enum: SOLANA_SIGNED_TX,
20+
message_type: "SolanaSignedTx",
21+
proto: response,
22+
});
23+
}),
24+
} as any;
25+
}
26+
27+
function decodeFieldNumbers(bytes: Uint8Array): number[] {
28+
const reader = new jspb.BinaryReader(bytes);
29+
const fields: number[] = [];
30+
while (reader.nextField()) {
31+
if (reader.isEndGroup()) break;
32+
fields.push(reader.getFieldNumber());
33+
reader.skipField();
34+
}
35+
return fields;
36+
}
37+
38+
describe("certified Solana wire shapes", () => {
39+
it("encodes schema + certificate without manufacturing LUT fields", async () => {
40+
const schemaPayload = new Uint8Array([0x4b, 0x4b, 0x53, 0x4f, 0x4c]);
41+
const schemaSignature = new Uint8Array(64).fill(0x22);
42+
const certificate = new Uint8Array(139).map((_, i) => i);
43+
const transport = makeTransport((bytes) => {
44+
const fields = decodeFieldNumbers(bytes);
45+
expect(fields).toEqual(expect.arrayContaining([1, 3, 9, 10, 11, 13]));
46+
expect(fields).not.toContain(5);
47+
expect(fields).not.toContain(6);
48+
expect(fields).not.toContain(7);
49+
50+
const reader = new jspb.BinaryReader(bytes);
51+
const decoded: Record<number, Uint8Array | number> = {};
52+
while (reader.nextField()) {
53+
if (reader.isEndGroup()) break;
54+
const field = reader.getFieldNumber();
55+
if (field === 9 || field === 10 || field === 13) decoded[field] = reader.readBytes();
56+
else if (field === 11) decoded[field] = reader.readUint32();
57+
else reader.skipField();
58+
}
59+
expect(decoded[9]).toEqual(schemaPayload);
60+
expect(decoded[10]).toEqual(schemaSignature);
61+
expect(decoded[11]).toBe(0x80);
62+
expect(decoded[13]).toEqual(certificate);
63+
});
64+
65+
await solanaSignTx(transport, {
66+
addressNList: PATH,
67+
rawTx: new Uint8Array([0x80, 0x00]),
68+
schema: {
69+
payload: schemaPayload,
70+
signature: schemaSignature,
71+
signerKeyId: 0x80,
72+
},
73+
certificate,
74+
});
75+
});
76+
77+
it("adds the LUT account/signature/id only for the ALT-backed shape", async () => {
78+
const transport = makeTransport((bytes) => {
79+
const fields = decodeFieldNumbers(bytes);
80+
expect(fields.filter((field) => field === 5)).toHaveLength(2);
81+
expect(fields).toEqual(expect.arrayContaining([6, 7, 9, 10, 11, 13]));
82+
});
83+
84+
await solanaSignTx(transport, {
85+
addressNList: PATH,
86+
rawTx: new Uint8Array([0x80, 0x00]),
87+
lutProof: {
88+
accounts: [new Uint8Array(32).fill(0x11), new Uint8Array(32).fill(0x12)],
89+
signature: new Uint8Array(64).fill(0x21),
90+
signerKeyId: 0x80,
91+
},
92+
schema: {
93+
payload: new Uint8Array([0x4b, 0x4b, 0x53, 0x4f, 0x4c]),
94+
signature: new Uint8Array(64).fill(0x22),
95+
signerKeyId: 0x80,
96+
},
97+
certificate: new Uint8Array(139).fill(0x33),
98+
});
99+
});
100+
101+
it("rejects partial or mixed certified material before transport", async () => {
102+
const call = jest.fn();
103+
const transport = {
104+
debugLink: false,
105+
lockDuring: <T>(fn: () => Promise<T>) => fn(),
106+
call,
107+
} as any;
108+
109+
await expect(
110+
solanaSignTx(transport, {
111+
addressNList: PATH,
112+
rawTx: new Uint8Array([0x80, 0x00]),
113+
certificate: new Uint8Array(139),
114+
})
115+
).rejects.toThrow(/requires schema signerKeyId 0x80/);
116+
117+
await expect(
118+
solanaSignTx(transport, {
119+
addressNList: PATH,
120+
rawTx: new Uint8Array([0x80, 0x00]),
121+
schema: {
122+
payload: new Uint8Array([1]),
123+
signature: new Uint8Array(64),
124+
signerKeyId: 0x80,
125+
},
126+
})
127+
).rejects.toThrow(/requires a certificate/);
128+
129+
expect(call).not.toHaveBeenCalled();
130+
});
131+
});

0 commit comments

Comments
 (0)