--- /dev/null
+//! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@codecow.com>
+//! SPDX-License-Identifier: GPL-3.0-or-later
+
+import { APDU_CODES, LedgerResponse, LedgerStatus } from '.'
+import { bytes, dec } from '../convert'
+import { queue } from './queue'
+
+interface LedgerVersionResponse extends LedgerResponse {
+ name: string | null,
+ version: string | null
+}
+
+/**
+ * Check if the Nano app is currently open and set device status accordingly.
+ *
+ * @returns Device status as follows:
+ * - UNSUPPORTED: Platform does not support any Ledger transport protocols
+ * - DISCONNECTED: Failed to communicate properly with the app
+ * - BUSY: Nano app is not currently open
+ * - LOCKED: Nano app is open but the device locked after a timeout
+ * - CONNECTED: Nano app is open and listening
+ */
+export async function connect (): Promise<LedgerStatus> {
+ try {
+ const v = await version()
+ if (v.status === 'LOCKED_DEVICE') {
+ this.#setStatus('LOCKED')
+ } else if (v.status !== 'OK') {
+ this.#setStatus('DISCONNECTED')
+ } else if (v.name === 'Nano') {
+ const { status } = await this.account()
+ if (status === 'OK') {
+ this.#setStatus('CONNECTED')
+ } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {
+ this.#setStatus('LOCKED')
+ } else {
+ this.#setStatus('DISCONNECTED')
+ }
+ } else {
+ this.#setStatus('BUSY')
+ }
+ } catch (err) {
+ console.error('Ledger.#connect()', err)
+ this.#setStatus('DISCONNECTED')
+ }
+ return this.#status
+}
+
+/**
+ * Get the version of the current process. If a specific app is running, get
+ * the app version. Otherwise, get the Ledger BOLOS version instead.
+ *
+ * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information
+ *
+ * @returns Status, process name, and version
+ */
+async function version (): Promise<LedgerVersionResponse> {
+ return queue(async () => {
+ try {
+ const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
+ const response = await transport
+ .send(0xb0, APDU_CODES.version, APDU_CODES.paramUnused, APDU_CODES.paramUnused)
+ .catch((err: any) => dec.toBytes(err.statusCode))
+ .finally(async () => await transport.close()) as Uint8Array
+
+ const statusCode = bytes.toDec(response.slice(-2)) as number
+ const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
+ if (status !== 'OK') {
+ return { status, name: null, version: null }
+ }
+
+ const nameLength = response[1]
+ const name = response.slice(2, 2 + nameLength).toString()
+ const versionLength = response[2 + nameLength]
+ const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()
+
+ return { status, name, version }
+ } catch (err: any) {
+ console.error('Ledger.#version()', err)
+ return { status: err.message, name: null, version: null }
+ }
+ })
+}
import { Wallet } from '../wallet'
import { queue } from './queue'
-type LedgerStatus = 'UNSUPPORTED' | 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
+export type LedgerStatus = 'UNSUPPORTED' | 'DISCONNECTED' | 'BUSY' | 'LOCKED' | 'CONNECTED'
-interface LedgerResponse {
+export interface LedgerResponse {
status: string
}
-interface LedgerVersionResponse extends LedgerResponse {
- name: string | null,
- version: string | null
-}
-
interface LedgerAccountResponse extends LedgerResponse {
publicKey: string | null,
address: string | null
hash?: string
}
+export const APDU_CODES: Record<string, number> = Object.freeze({
+ class: 0xa1,
+ bip32DerivationLevel: 0x03,
+ version: 0x01,
+ account: 0x02,
+ cacheBlock: 0x03,
+ signBlock: 0x04,
+ signNonce: 0x05,
+ paramUnused: 0x00
+})
+
/**
* Ledger hardware wallet created by communicating with a Ledger device via ADPU
* calls. This wallet does not feature any seed nor mnemonic phrase as all
static #openTimeout: 3000 = 3000
static #status: LedgerStatus = 'DISCONNECTED'
static #transport: typeof TransportHID | typeof TransportBLE | typeof TransportUSB
- static #ADPU_CODES: Record<string, number> = Object.freeze({
- class: 0xa1,
- bip32DerivationLevel: 0x03,
- version: 0x01,
- account: 0x02,
- cacheBlock: 0x03,
- signBlock: 0x04,
- signNonce: 0x05,
- paramUnused: 0x00
- })
static #DERIVATION_PATH: Uint8Array = new Uint8Array([
- this.#ADPU_CODES.bip32DerivationLevel,
+ APDU_CODES.bip32DerivationLevel,
...dec.toBytes(BIP44_PURPOSE + HARDENED_OFFSET, 4),
...dec.toBytes(BIP44_COIN_NANO + HARDENED_OFFSET, 4)
])
const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
const response = await transport
- .send(this.#ADPU_CODES.class, this.#ADPU_CODES.account, show ? 1 : 0, this.#ADPU_CODES.paramUnused, data as Buffer)
+ .send(APDU_CODES.class, APDU_CODES.account, show ? 1 : 0, APDU_CODES.paramUnused, data as Buffer)
.catch((err: any) => dec.toBytes(err.statusCode))
.finally(async () => await transport.close()) as Uint8Array
}
}
try {
- return this.#connect()
+ return connect()
} catch (err) {
throw new Error('Ledger.connect()', { cause: err })
} finally {
const representative = hex.toBytes(block.representative.publicKey, 32)
const balance = hex.toBytes(block.balance.toString(16), 16)
const signature = hex.toBytes(block.signature, 64)
- const data = new Uint8Array([this.#ADPU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])
+ const data = new Uint8Array([APDU_CODES.bip32DerivationLevel, ...purpose, ...coin, ...account, ...previous, ...link, ...representative, ...balance, ...signature])
const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
const response = await transport
- .send(this.#ADPU_CODES.class, this.#ADPU_CODES.cacheBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+ .send(APDU_CODES.class, APDU_CODES.cacheBlock, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
.then((res: Buffer) => bytes.toDec(res))
.catch((err: any) => err.statusCode)
.finally(async () => await transport.close()) as number
return queue(async () => {
const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
const response = await transport
- .send(0xb0, 0xa7, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
+ .send(0xb0, 0xa7, APDU_CODES.paramUnused, APDU_CODES.paramUnused)
.then((res: Buffer) => bytes.toDec(res))
.catch((err: any) => err.statusCode)
.finally(async () => await transport.close()) as number
})
}
- /**
- * Check if the Nano app is currently open and set device status accordingly.
- *
- * @returns Device status as follows:
- * - UNSUPPORTED: Platform does not support any Ledger transport protocols
- * - DISCONNECTED: Failed to communicate properly with the app
- * - BUSY: Nano app is not currently open
- * - LOCKED: Nano app is open but the device locked after a timeout
- * - CONNECTED: Nano app is open and listening
- */
- static async #connect (): Promise<LedgerStatus> {
- try {
- const version = await this.#version()
- if (version.status === 'LOCKED_DEVICE') {
- this.#setStatus('LOCKED')
- } else if (version.status !== 'OK') {
- this.#setStatus('DISCONNECTED')
- } else if (version.name === 'Nano') {
- const { status } = await this.account()
- if (status === 'OK') {
- this.#setStatus('CONNECTED')
- } else if (status === 'SECURITY_STATUS_NOT_SATISFIED') {
- this.#setStatus('LOCKED')
- } else {
- this.#setStatus('DISCONNECTED')
- }
- } else {
- this.#setStatus('BUSY')
- }
- } catch (err) {
- console.error('Ledger.#connect()', err)
- this.#setStatus('DISCONNECTED')
- }
- return this.#status
- }
-
/**
* Open the Nano app by launching a user flow.
*
const name = new TextEncoder().encode('Nano')
const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
const response = await transport
- .send(0xe0, 0xd8, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, name as Buffer)
+ .send(0xe0, 0xd8, APDU_CODES.paramUnused, APDU_CODES.paramUnused, name as Buffer)
.then((res: Buffer) => bytes.toDec(res))
.catch((err: any) => err.statusCode)
.finally(async () => await transport.close()) as number
const isUsbPaired = (await navigator.usb?.getDevices?.() ?? [])
.some(device => device.vendorId === this.ledgerVendorId)
if (this.#transport === TransportHID && isHidPaired) {
- await this.#connect()
+ await connect()
} else if (this.#transport === TransportUSB && isUsbPaired) {
- await this.#connect()
+ await connect()
} else {
this.#setStatus('DISCONNECTED')
}
const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
const response = await transport
- .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signBlock, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+ .send(APDU_CODES.class, APDU_CODES.signBlock, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
.catch((err: any) => dec.toBytes(err.statusCode))
.finally(async () => await transport.close()) as Uint8Array
const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
const response = await transport
- .send(this.#ADPU_CODES.class, this.#ADPU_CODES.signNonce, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused, data as Buffer)
+ .send(APDU_CODES.class, APDU_CODES.signNonce, APDU_CODES.paramUnused, APDU_CODES.paramUnused, data as Buffer)
.catch((err: any) => dec.toBytes(err.statusCode))
.finally(async () => await transport.close()) as Uint8Array
}
})
}
-
- /**
- * Get the version of the current process. If a specific app is running, get
- * the app version. Otherwise, get the Ledger BOLOS version instead.
- *
- * https://developers.ledger.com/docs/connectivity/ledgerJS/open-close-info-on-apps#get-information
- *
- * @returns Status, process name, and version
- */
- static async #version (): Promise<LedgerVersionResponse> {
- return queue(async () => {
- try {
- const transport = await this.#transport.create(this.#openTimeout, this.#listenTimeout)
- const response = await transport
- .send(0xb0, this.#ADPU_CODES.version, this.#ADPU_CODES.paramUnused, this.#ADPU_CODES.paramUnused)
- .catch((err: any) => dec.toBytes(err.statusCode))
- .finally(async () => await transport.close()) as Uint8Array
-
- const statusCode = bytes.toDec(response.slice(-2)) as number
- const status = this.#STATUS_CODES[statusCode] ?? 'UNKNOWN_ERROR'
- if (status !== 'OK') {
- return { status, name: null, version: null }
- }
-
- const nameLength = response[1]
- const name = response.slice(2, 2 + nameLength).toString()
- const versionLength = response[2 + nameLength]
- const version = response.slice(2 + nameLength + 1, 2 + nameLength + 1 + versionLength).toString()
-
- return { status, name, version }
- } catch (err: any) {
- console.error('Ledger.#version()', err)
- return { status: err.message, name: null, version: null }
- }
- })
- }
}