@dfinity/hardware-wallet-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +2 -0
- package/.prettierignore +1 -0
- package/LICENSE +201 -0
- package/README.md +17 -0
- package/build/index.js +394 -0
- package/build/src/ledger/identity.js +216 -0
- package/build/src/ledger/secp256k1.js +74 -0
- package/index.ts +535 -0
- package/package.json +43 -0
- package/src/ledger/identity.ts +255 -0
- package/src/ledger/secp256k1.ts +96 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CallRequest,
|
|
3
|
+
Cbor,
|
|
4
|
+
HttpAgentRequest,
|
|
5
|
+
PublicKey,
|
|
6
|
+
ReadRequest,
|
|
7
|
+
Signature,
|
|
8
|
+
SignIdentity,
|
|
9
|
+
} from "@dfinity/agent";
|
|
10
|
+
import { Principal } from "@dfinity/principal";
|
|
11
|
+
import LedgerApp, { LedgerError, ResponseSign } from "@zondax/ledger-icp";
|
|
12
|
+
import { Secp256k1PublicKey } from "./secp256k1";
|
|
13
|
+
|
|
14
|
+
// @ts-ignore (no types are available)
|
|
15
|
+
import TransportWebHID, { Transport } from "@ledgerhq/hw-transport-webhid";
|
|
16
|
+
import TransportNodeHidNoEvents from "@ledgerhq/hw-transport-node-hid-noevents";
|
|
17
|
+
|
|
18
|
+
// Add polyfill for `window.fetch` for agent-js to work.
|
|
19
|
+
// @ts-ignore (no types are available)
|
|
20
|
+
import fetch from "node-fetch";
|
|
21
|
+
global.fetch = fetch;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert the HttpAgentRequest body into cbor which can be signed by the Ledger Hardware Wallet.
|
|
25
|
+
* @param request - body of the HttpAgentRequest
|
|
26
|
+
*/
|
|
27
|
+
function _prepareCborForLedger(
|
|
28
|
+
request: ReadRequest | CallRequest
|
|
29
|
+
): ArrayBuffer {
|
|
30
|
+
return Cbor.encode({ content: request });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A Hardware Ledger Internet Computer Agent identity.
|
|
35
|
+
*/
|
|
36
|
+
export class LedgerIdentity extends SignIdentity {
|
|
37
|
+
// A flag to signal that the next transaction to be signed will be
|
|
38
|
+
// a "stake neuron" transaction.
|
|
39
|
+
private _neuronStakeFlag = false;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Create a LedgerIdentity using the Web USB transport.
|
|
43
|
+
* @param derivePath The derivation path.
|
|
44
|
+
*/
|
|
45
|
+
public static async create(
|
|
46
|
+
derivePath = `m/44'/223'/0'/0/0`
|
|
47
|
+
): Promise<LedgerIdentity> {
|
|
48
|
+
const [app, transport] = await this._connect();
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const publicKey = await this._fetchPublicKeyFromDevice(app, derivePath);
|
|
52
|
+
return new this(derivePath, publicKey);
|
|
53
|
+
} finally {
|
|
54
|
+
// Always close the transport.
|
|
55
|
+
transport.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private constructor(
|
|
60
|
+
public readonly derivePath: string,
|
|
61
|
+
private readonly _publicKey: Secp256k1PublicKey
|
|
62
|
+
) {
|
|
63
|
+
super();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Connect to a ledger hardware wallet.
|
|
68
|
+
*/
|
|
69
|
+
private static async _connect(): Promise<[LedgerApp, Transport]> {
|
|
70
|
+
async function getTransport() {
|
|
71
|
+
if (await TransportWebHID.isSupported()) {
|
|
72
|
+
// We're in a web browser.
|
|
73
|
+
return TransportWebHID.create();
|
|
74
|
+
} else if (await TransportNodeHidNoEvents.isSupported()) {
|
|
75
|
+
// Maybe we're in a CLI.
|
|
76
|
+
return TransportNodeHidNoEvents.create();
|
|
77
|
+
} else {
|
|
78
|
+
// Unknown environment.
|
|
79
|
+
throw Error();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const transport = await getTransport();
|
|
85
|
+
const app = new LedgerApp(transport);
|
|
86
|
+
return [app, transport];
|
|
87
|
+
} catch (err) {
|
|
88
|
+
// @ts-ignore
|
|
89
|
+
if (err.id && err.id == "NoDeviceFound") {
|
|
90
|
+
throw "No Ledger device found. Is the wallet connected and unlocked?";
|
|
91
|
+
} else if (
|
|
92
|
+
// @ts-ignore
|
|
93
|
+
err.message &&
|
|
94
|
+
// @ts-ignore
|
|
95
|
+
err.message.includes("cannot open device with path")
|
|
96
|
+
) {
|
|
97
|
+
throw "Cannot connect to Ledger device. Please close all other wallet applications (e.g. Ledger Live) and try again.";
|
|
98
|
+
} else {
|
|
99
|
+
// Unsupported browser. Data on browser compatibility is taken from https://caniuse.com/webhid
|
|
100
|
+
throw `Cannot connect to Ledger Wallet. Either you have other wallet applications open (e.g. Ledger Live), or your browser doesn't support WebHID, which is necessary to communicate with your Ledger hardware wallet.\n\nSupported browsers:\n* Chrome (Desktop) v89+\n* Edge v89+\n* Opera v76+\n\nError: ${err}`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private static async _fetchPublicKeyFromDevice(
|
|
106
|
+
app: LedgerApp,
|
|
107
|
+
derivePath: string
|
|
108
|
+
): Promise<Secp256k1PublicKey> {
|
|
109
|
+
const resp = await app.getAddressAndPubKey(derivePath);
|
|
110
|
+
// @ts-ignore
|
|
111
|
+
if (resp.returnCode == 28161) {
|
|
112
|
+
throw "Please open the Internet Computer app on your wallet and try again.";
|
|
113
|
+
} else if (resp.returnCode == LedgerError.TransactionRejected) {
|
|
114
|
+
throw "Ledger Wallet is locked. Unlock it and try again.";
|
|
115
|
+
// @ts-ignore
|
|
116
|
+
} else if (resp.returnCode == 65535) {
|
|
117
|
+
throw "Unable to fetch the public key. Please try again.";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// This type doesn't have the right fields in it, so we have to manually type it.
|
|
121
|
+
const principal = (resp as unknown as { principalText: string })
|
|
122
|
+
.principalText;
|
|
123
|
+
const publicKey = Secp256k1PublicKey.fromRaw(
|
|
124
|
+
new Uint8Array(resp.publicKey)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (
|
|
128
|
+
principal !==
|
|
129
|
+
Principal.selfAuthenticating(new Uint8Array(publicKey.toDer())).toText()
|
|
130
|
+
) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"Principal returned by device does not match public key."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return publicKey;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Required by Ledger.com that the user should be able to press a Button in UI
|
|
141
|
+
* and verify the address/pubkey are the same as on the device screen.
|
|
142
|
+
*/
|
|
143
|
+
public async showAddressAndPubKeyOnDevice(): Promise<void> {
|
|
144
|
+
this._executeWithApp(async (app: LedgerApp) => {
|
|
145
|
+
await app.showAddressAndPubKey(this.derivePath);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @returns The verion of the `Internet Computer' app installed on the Ledger device.
|
|
151
|
+
*/
|
|
152
|
+
public async getVersion(): Promise<Version> {
|
|
153
|
+
return this._executeWithApp(async (app: LedgerApp) => {
|
|
154
|
+
const res = await app.getVersion();
|
|
155
|
+
return {
|
|
156
|
+
major: res.major,
|
|
157
|
+
minor: res.minor,
|
|
158
|
+
patch: res.patch,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
public getPublicKey(): PublicKey {
|
|
164
|
+
return this._publicKey;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
public async sign(blob: ArrayBuffer): Promise<Signature> {
|
|
168
|
+
console.log("About to sign");
|
|
169
|
+
console.log(Buffer.from(blob).toString("hex"));
|
|
170
|
+
return await this._executeWithApp(async (app: LedgerApp) => {
|
|
171
|
+
const resp: ResponseSign = await app.sign(
|
|
172
|
+
this.derivePath,
|
|
173
|
+
Buffer.from(blob),
|
|
174
|
+
this._neuronStakeFlag ? 1 : 0
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Remove the "neuron stake" flag, since we already signed the transaction.
|
|
178
|
+
this._neuronStakeFlag = false;
|
|
179
|
+
|
|
180
|
+
const signatureRS = resp.signatureRS;
|
|
181
|
+
if (!signatureRS) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`A ledger error happened during signature:\n` +
|
|
184
|
+
`Code: ${resp.returnCode}\n` +
|
|
185
|
+
`Message: ${JSON.stringify(resp.errorMessage)}\n`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (signatureRS?.byteLength !== 64) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Signature must be 64 bytes long (is ${signatureRS.length})`
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return bufferToArrayBuffer(signatureRS) as Signature;
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Signals that the upcoming transaction to be signed will be a "stake neuron" transaction.
|
|
201
|
+
*/
|
|
202
|
+
public flagUpcomingStakeNeuron(): void {
|
|
203
|
+
this._neuronStakeFlag = true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public async transformRequest(request: HttpAgentRequest): Promise<unknown> {
|
|
207
|
+
const { body, ...fields } = request;
|
|
208
|
+
const signature = await this.sign(_prepareCborForLedger(body));
|
|
209
|
+
return {
|
|
210
|
+
...fields,
|
|
211
|
+
body: {
|
|
212
|
+
content: body,
|
|
213
|
+
sender_pubkey: this._publicKey.toDer(),
|
|
214
|
+
sender_sig: signature,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async _executeWithApp<T>(
|
|
220
|
+
func: (app: LedgerApp) => Promise<T>
|
|
221
|
+
): Promise<T> {
|
|
222
|
+
const [app, transport] = await LedgerIdentity._connect();
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
// Verify that the public key of the device matches the public key of this identity.
|
|
226
|
+
const devicePublicKey = await LedgerIdentity._fetchPublicKeyFromDevice(
|
|
227
|
+
app,
|
|
228
|
+
this.derivePath
|
|
229
|
+
);
|
|
230
|
+
if (JSON.stringify(devicePublicKey) !== JSON.stringify(this._publicKey)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
"Found unexpected public key. Are you sure you're using the right wallet?"
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Run the provided function.
|
|
237
|
+
return await func(app);
|
|
238
|
+
} finally {
|
|
239
|
+
transport.close();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
interface Version {
|
|
245
|
+
major: number;
|
|
246
|
+
minor: number;
|
|
247
|
+
patch: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
|
|
251
|
+
return buffer.buffer.slice(
|
|
252
|
+
buffer.byteOffset,
|
|
253
|
+
buffer.byteOffset + buffer.byteLength
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { DerEncodedPublicKey, PublicKey } from "@dfinity/agent";
|
|
2
|
+
|
|
3
|
+
function equals(b1: ArrayBuffer, b2: ArrayBuffer): boolean {
|
|
4
|
+
if (b1.byteLength !== b2.byteLength) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const u1 = new Uint8Array(b1);
|
|
9
|
+
const u2 = new Uint8Array(b2);
|
|
10
|
+
for (let i = 0; i < u1.length; i++) {
|
|
11
|
+
if (u1[i] !== u2[i]) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// This implementation is adjusted from the Ed25519PublicKey.
|
|
19
|
+
// The RAW_KEY_LENGTH and DER_PREFIX are modified accordingly
|
|
20
|
+
export class Secp256k1PublicKey implements PublicKey {
|
|
21
|
+
public static fromRaw(rawKey: ArrayBuffer): Secp256k1PublicKey {
|
|
22
|
+
return new Secp256k1PublicKey(rawKey);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public static fromDer(derKey: DerEncodedPublicKey): Secp256k1PublicKey {
|
|
26
|
+
return new Secp256k1PublicKey(this.derDecode(derKey));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The length of secp256k1 public keys is always 65 bytes.
|
|
30
|
+
private static RAW_KEY_LENGTH = 65;
|
|
31
|
+
|
|
32
|
+
// Adding this prefix to a raw public key is sufficient to DER-encode it.
|
|
33
|
+
// prettier-ignore
|
|
34
|
+
private static DER_PREFIX = Uint8Array.from([
|
|
35
|
+
0x30, 0x56, // SEQUENCE
|
|
36
|
+
0x30, 0x10, // SEQUENCE
|
|
37
|
+
0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, // OID ECDSA
|
|
38
|
+
0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x0a, // OID secp256k1
|
|
39
|
+
0x03, 0x42, // BIT STRING
|
|
40
|
+
0x00, // no padding
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
private static derEncode(publicKey: ArrayBuffer): DerEncodedPublicKey {
|
|
44
|
+
if (publicKey.byteLength !== Secp256k1PublicKey.RAW_KEY_LENGTH) {
|
|
45
|
+
const bl = publicKey.byteLength;
|
|
46
|
+
throw new TypeError(
|
|
47
|
+
`secp256k1 public key must be ${Secp256k1PublicKey.RAW_KEY_LENGTH} bytes long (is ${bl})`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const derPublicKey = Uint8Array.from([
|
|
52
|
+
...Secp256k1PublicKey.DER_PREFIX,
|
|
53
|
+
...new Uint8Array(publicKey),
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
return derPublicKey.buffer as DerEncodedPublicKey;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private static derDecode(key: DerEncodedPublicKey): ArrayBuffer {
|
|
60
|
+
const expectedLength =
|
|
61
|
+
Secp256k1PublicKey.DER_PREFIX.length + Secp256k1PublicKey.RAW_KEY_LENGTH;
|
|
62
|
+
if (key.byteLength !== expectedLength) {
|
|
63
|
+
const bl = key.byteLength;
|
|
64
|
+
throw new TypeError(
|
|
65
|
+
`secp256k1 DER-encoded public key must be ${expectedLength} bytes long (is ${bl})`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const rawKey = key.slice(0, Secp256k1PublicKey.DER_PREFIX.length);
|
|
70
|
+
if (!equals(this.derEncode(rawKey), key)) {
|
|
71
|
+
throw new TypeError(
|
|
72
|
+
"secp256k1 DER-encoded public key is invalid. A valid secp256k1 DER-encoded public key " +
|
|
73
|
+
`must have the following prefix: ${Secp256k1PublicKey.DER_PREFIX}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return rawKey;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private readonly rawKey: ArrayBuffer;
|
|
81
|
+
private readonly derKey: DerEncodedPublicKey;
|
|
82
|
+
|
|
83
|
+
// `fromRaw` and `fromDer` should be used for instantiation, not this constructor.
|
|
84
|
+
private constructor(key: ArrayBuffer) {
|
|
85
|
+
this.rawKey = key;
|
|
86
|
+
this.derKey = Secp256k1PublicKey.derEncode(key);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public toDer(): DerEncodedPublicKey {
|
|
90
|
+
return this.derKey;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public toRaw(): ArrayBuffer {
|
|
94
|
+
return this.rawKey;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2020",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"outDir": "./build",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"baseUrl": "./",
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"skipLibCheck": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["index.ts", "./src"]
|
|
15
|
+
}
|