Skip to content

Commit 90ccddf

Browse files
✨ (solana-signer): Add clear sign commands
1 parent eab9abb commit 90ccddf

19 files changed

Lines changed: 1539 additions & 18 deletions

.changeset/public-cases-slide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ledgerhq/device-signer-kit-solana": minor
3+
---
4+
5+
Add solana clear sign commands
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
ApduResponse,
3+
CommandResultFactory,
4+
InvalidStatusWordError,
5+
isSuccessCommandResult,
6+
} from "@ledgerhq/device-management-kit";
7+
8+
import { SolanaAppCommandError } from "./utils/SolanaApplicationErrors";
9+
import {
10+
CLA,
11+
INS,
12+
P1,
13+
P2,
14+
PromptUiDisplayCommand,
15+
} from "./PromptUiDisplayCommand";
16+
17+
describe("PromptUiDisplayCommand", () => {
18+
const command = new PromptUiDisplayCommand();
19+
20+
describe("name", () => {
21+
it("should be 'promptUiDisplay'", () => {
22+
expect(command.name).toBe("promptUiDisplay");
23+
});
24+
});
25+
26+
describe("getApdu", () => {
27+
it("builds an empty-data APDU with CLA/INS/P1/P2 = 0xE0/0x0B/0x00/0x00", () => {
28+
const apdu = command.getApdu();
29+
30+
expect(apdu.cla).toBe(CLA);
31+
expect(apdu.ins).toBe(INS);
32+
expect(apdu.p1).toBe(P1);
33+
expect(apdu.p2).toBe(P2);
34+
expect(apdu.data).toStrictEqual(new Uint8Array());
35+
expect(apdu.getRawApdu()).toStrictEqual(
36+
Uint8Array.from([0xe0, 0x0b, 0x00, 0x00, 0x00]),
37+
);
38+
});
39+
});
40+
41+
describe("parseResponse", () => {
42+
it("returns success on 9000 (user approved)", () => {
43+
const result = command.parseResponse(
44+
new ApduResponse({
45+
statusCode: Uint8Array.from([0x90, 0x00]),
46+
data: new Uint8Array(),
47+
}),
48+
);
49+
50+
expect(result).toStrictEqual(CommandResultFactory({ data: undefined }));
51+
});
52+
53+
it("maps 6985 to a typed Solana app error (user-refused / session incomplete)", () => {
54+
const result = command.parseResponse(
55+
new ApduResponse({
56+
statusCode: Uint8Array.from([0x69, 0x85]),
57+
data: new Uint8Array(),
58+
}),
59+
);
60+
61+
expect(isSuccessCommandResult(result)).toBe(false);
62+
// @ts-expect-error narrowed by isSuccessCommandResult
63+
expect(result.error).toBeInstanceOf(SolanaAppCommandError);
64+
// @ts-expect-error narrowed by isSuccessCommandResult
65+
expect(result.error.errorCode).toBe("6985");
66+
});
67+
68+
it("maps 6A80 to a typed Solana app error (merge engine failure)", () => {
69+
const result = command.parseResponse(
70+
new ApduResponse({
71+
statusCode: Uint8Array.from([0x6a, 0x80]),
72+
data: new Uint8Array(),
73+
}),
74+
);
75+
76+
expect(isSuccessCommandResult(result)).toBe(false);
77+
// @ts-expect-error narrowed by isSuccessCommandResult
78+
expect(result.error).toBeInstanceOf(SolanaAppCommandError);
79+
// @ts-expect-error narrowed by isSuccessCommandResult
80+
expect(result.error.errorCode).toBe("6a80");
81+
});
82+
83+
it("rejects unexpected data on 9000", () => {
84+
const result = command.parseResponse(
85+
new ApduResponse({
86+
statusCode: Uint8Array.from([0x90, 0x00]),
87+
data: Uint8Array.from([0x01]),
88+
}),
89+
);
90+
91+
expect(isSuccessCommandResult(result)).toBe(false);
92+
// @ts-expect-error narrowed by isSuccessCommandResult
93+
expect(result.error).toBeInstanceOf(InvalidStatusWordError);
94+
});
95+
});
96+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
type Apdu,
3+
ApduBuilder,
4+
type ApduResponse,
5+
type Command,
6+
type CommandResult,
7+
CommandResultFactory,
8+
InvalidStatusWordError,
9+
} from "@ledgerhq/device-management-kit";
10+
import { CommandErrorHelper } from "@ledgerhq/signer-utils";
11+
import { Maybe } from "purify-ts";
12+
13+
import {
14+
SOLANA_APP_ERRORS,
15+
SolanaAppCommandErrorFactory,
16+
type SolanaAppErrorCodes,
17+
} from "./utils/SolanaApplicationErrors";
18+
19+
export const CLA = 0xe0;
20+
export const INS = 0x0b;
21+
export const P1 = 0x00;
22+
export const P2 = 0x00;
23+
24+
/**
25+
* Triggers the descriptor-augmented review UI on the device.
26+
*
27+
* The device returns:
28+
* - 9000 on user approval (provisional fingerprint armed for DELAYED)
29+
* - 6985 on user rejection or incomplete session (buffer preserved)
30+
* - 6A80 if the merge engine cannot produce a coherent display
31+
* (session discarded)
32+
*/
33+
export class PromptUiDisplayCommand
34+
implements Command<void, void, SolanaAppErrorCodes>
35+
{
36+
readonly name = "promptUiDisplay";
37+
private readonly errorHelper = new CommandErrorHelper<
38+
void,
39+
SolanaAppErrorCodes
40+
>(SOLANA_APP_ERRORS, SolanaAppCommandErrorFactory);
41+
42+
getApdu(): Apdu {
43+
return new ApduBuilder({ cla: CLA, ins: INS, p1: P1, p2: P2 }).build();
44+
}
45+
46+
parseResponse(
47+
response: ApduResponse,
48+
): CommandResult<void, SolanaAppErrorCodes> {
49+
return Maybe.fromNullable(
50+
this.errorHelper.getError(response),
51+
).orDefaultLazy(() => {
52+
if (response.data.length !== 0) {
53+
return CommandResultFactory({
54+
error: new InvalidStatusWordError("Unexpected data in response"),
55+
});
56+
}
57+
return CommandResultFactory({ data: undefined });
58+
});
59+
}
60+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
ApduResponse,
3+
CommandResultFactory,
4+
InvalidStatusWordError,
5+
isSuccessCommandResult,
6+
} from "@ledgerhq/device-management-kit";
7+
8+
import { P2_EXTEND, P2_MORE } from "./utils/clearSignChunking";
9+
import { SolanaAppCommandError } from "./utils/SolanaApplicationErrors";
10+
import {
11+
CLA,
12+
INS,
13+
P1,
14+
ProvideAltResolutionCommand,
15+
} from "./ProvideAltResolutionCommand";
16+
17+
describe("ProvideAltResolutionCommand", () => {
18+
const payload = new Uint8Array([0x11, 0x22, 0x33]);
19+
20+
describe("name", () => {
21+
it("should be 'provideAltResolution'", () => {
22+
expect(
23+
new ProvideAltResolutionCommand({
24+
payload,
25+
isFirstChunk: true,
26+
hasMore: false,
27+
}).name,
28+
).toBe("provideAltResolution");
29+
});
30+
});
31+
32+
describe("getApdu", () => {
33+
it("builds INS=0x28 / P1=0x00 with chunking flags applied to P2", () => {
34+
const apdu = new ProvideAltResolutionCommand({
35+
payload,
36+
isFirstChunk: true,
37+
hasMore: false,
38+
}).getApdu();
39+
40+
expect(apdu.cla).toBe(CLA);
41+
expect(apdu.ins).toBe(INS);
42+
expect(apdu.p1).toBe(P1);
43+
expect(apdu.p2).toBe(0x00);
44+
expect(apdu.data).toStrictEqual(payload);
45+
});
46+
47+
it("first of many: P2 = P2_MORE", () => {
48+
const apdu = new ProvideAltResolutionCommand({
49+
payload,
50+
isFirstChunk: true,
51+
hasMore: true,
52+
}).getApdu();
53+
expect(apdu.p2).toBe(P2_MORE);
54+
});
55+
56+
it("middle: P2 = P2_MORE | P2_EXTEND", () => {
57+
const apdu = new ProvideAltResolutionCommand({
58+
payload,
59+
isFirstChunk: false,
60+
hasMore: true,
61+
}).getApdu();
62+
expect(apdu.p2).toBe(P2_MORE | P2_EXTEND);
63+
});
64+
65+
it("last: P2 = P2_EXTEND", () => {
66+
const apdu = new ProvideAltResolutionCommand({
67+
payload,
68+
isFirstChunk: false,
69+
hasMore: false,
70+
}).getApdu();
71+
expect(apdu.p2).toBe(P2_EXTEND);
72+
});
73+
74+
it("throws a typed error if a chunk exceeds 255 bytes", () => {
75+
const command = new ProvideAltResolutionCommand({
76+
payload: new Uint8Array(256),
77+
isFirstChunk: true,
78+
hasMore: false,
79+
});
80+
expect(() => command.getApdu()).toThrow(/too large/i);
81+
});
82+
});
83+
84+
describe("parseResponse", () => {
85+
const command = new ProvideAltResolutionCommand({
86+
payload,
87+
isFirstChunk: true,
88+
hasMore: false,
89+
});
90+
91+
it("returns success on 9000", () => {
92+
const result = command.parseResponse(
93+
new ApduResponse({
94+
statusCode: Uint8Array.from([0x90, 0x00]),
95+
data: new Uint8Array(),
96+
}),
97+
);
98+
expect(result).toStrictEqual(CommandResultFactory({ data: undefined }));
99+
});
100+
101+
it("maps non-success status words to a typed Solana error", () => {
102+
const result = command.parseResponse(
103+
new ApduResponse({
104+
statusCode: Uint8Array.from([0x6a, 0x80]),
105+
data: new Uint8Array(),
106+
}),
107+
);
108+
109+
expect(isSuccessCommandResult(result)).toBe(false);
110+
// @ts-expect-error narrowed by isSuccessCommandResult
111+
expect(result.error).toBeInstanceOf(SolanaAppCommandError);
112+
});
113+
114+
it("rejects unexpected response data on 9000", () => {
115+
const result = command.parseResponse(
116+
new ApduResponse({
117+
statusCode: Uint8Array.from([0x90, 0x00]),
118+
data: Uint8Array.from([0x01]),
119+
}),
120+
);
121+
122+
expect(isSuccessCommandResult(result)).toBe(false);
123+
// @ts-expect-error narrowed by isSuccessCommandResult
124+
expect(result.error).toBeInstanceOf(InvalidStatusWordError);
125+
});
126+
});
127+
});
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {
2+
type Apdu,
3+
ApduBuilder,
4+
type ApduResponse,
5+
type Command,
6+
type CommandResult,
7+
CommandResultFactory,
8+
InvalidStatusWordError,
9+
} from "@ledgerhq/device-management-kit";
10+
import { CommandErrorHelper } from "@ledgerhq/signer-utils";
11+
import { Maybe } from "purify-ts";
12+
13+
import { assertChunkSize, buildChunkP2 } from "./utils/clearSignChunking";
14+
import {
15+
SOLANA_APP_ERRORS,
16+
SolanaAppCommandErrorFactory,
17+
type SolanaAppErrorCodes,
18+
} from "./utils/SolanaApplicationErrors";
19+
20+
export const CLA = 0xe0;
21+
export const INS = 0x28;
22+
export const P1 = 0x00;
23+
24+
export type ProvideAltResolutionCommandArgs = {
25+
readonly payload: Uint8Array;
26+
readonly isFirstChunk: boolean;
27+
readonly hasMore: boolean;
28+
};
29+
30+
/**
31+
* Provides one signed `ALT_RESOLUTION` TLV carrying
32+
* `(altAddress, entryIndex, resolvedAddress)`. The TLV is challenge-bound:
33+
* the caller must issue `GET CHALLENGE` immediately before this command so
34+
* the device can verify the binding.
35+
*
36+
* The caller pre-builds the wire payload (2-byte BE length prefix followed
37+
* by the `ALT_RESOLUTION` TLV) and splits it into ≤255-byte chunks.
38+
*/
39+
export class ProvideAltResolutionCommand
40+
implements Command<void, ProvideAltResolutionCommandArgs, SolanaAppErrorCodes>
41+
{
42+
readonly name = "provideAltResolution";
43+
private readonly errorHelper = new CommandErrorHelper<
44+
void,
45+
SolanaAppErrorCodes
46+
>(SOLANA_APP_ERRORS, SolanaAppCommandErrorFactory);
47+
48+
constructor(readonly args: ProvideAltResolutionCommandArgs) {}
49+
50+
getApdu(): Apdu {
51+
assertChunkSize(this.args.payload, INS);
52+
const p2 = buildChunkP2(this.args.isFirstChunk, this.args.hasMore);
53+
54+
return new ApduBuilder({ cla: CLA, ins: INS, p1: P1, p2 })
55+
.addBufferToData(this.args.payload)
56+
.build();
57+
}
58+
59+
parseResponse(
60+
response: ApduResponse,
61+
): CommandResult<void, SolanaAppErrorCodes> {
62+
return Maybe.fromNullable(
63+
this.errorHelper.getError(response),
64+
).orDefaultLazy(() => {
65+
if (response.data.length !== 0) {
66+
return CommandResultFactory({
67+
error: new InvalidStatusWordError("Unexpected data in response"),
68+
});
69+
}
70+
return CommandResultFactory({ data: undefined });
71+
});
72+
}
73+
}

0 commit comments

Comments
 (0)