@radixdlt/hardware-ledger
Version:
Ledger Nano hardware wallet connection
428 lines (385 loc) ⢠12 kB
text/typescript
import { from, Observable, of, Subject, Subscription, throwError } from 'rxjs'
import {
ECPointOnCurve,
ECPointOnCurveT,
HDPathRadixT,
PublicKey,
PublicKeyT,
Signature,
SignatureT,
} from '@radixdlt/crypto'
import { map, mergeMap, take, tap } from 'rxjs/operators'
import {
msgFromError,
readBuffer,
toObservableFromResult,
} from '@radixdlt/util'
import {
GetPublicKeyInput,
HardwareSigningKeyT,
HardwareWalletT,
HardwareWalletWithoutSK,
KeyExchangeInput,
path000H,
SemVerT,
SignHashInput,
SemVer,
signingKeyWithHardWareWallet,
SignTransactionInput,
SignTXOutput,
} from '@radixdlt/hardware-wallet'
import { RadixAPDU } from './apdu'
import { LedgerNanoT } from './_types'
import { LedgerNano } from './ledgerNano'
import { BasicLedgerTransport } from './device-connection'
import { log, BufferReader } from '@radixdlt/util'
import { Transaction } from '@radixdlt/tx-parser'
import { InstructionT } from '@radixdlt/tx-parser'
import { err, Result } from 'neverthrow'
const hardwareError = (message: string) =>
new Error(
JSON.stringify({
type: 'HARDWARE',
message,
}),
)
const truncate = (str: string, n: number): string =>
str.length > n ? str.slice(0, n) : str
const withLedgerNano = (ledgerNano: LedgerNanoT): HardwareWalletT => {
const getPublicKey = (input: GetPublicKeyInput): Observable<PublicKeyT> =>
ledgerNano
.sendAPDUToDevice(
RadixAPDU.getPublicKey({
path: input.path ?? path000H,
display: input.display ?? false, // passing 'false' is convenient for testing,
verifyAddressOnly: input.verifyAddressOnly ?? false,
}),
)
.pipe(
mergeMap(
(buf): Observable<PublicKeyT> => {
if (!Buffer.isBuffer(buf)) {
buf = Buffer.from(buf) // Convert Uint8Array to Buffer for Electron renderer compatibility š©
}
// Response `buf`: pub_key_len (1) || pub_key (var) || chain_code_len (1) || chain_code (var)
const readNextBuffer = readBuffer(buf)
const publicKeyLengthResult = readNextBuffer(1)
if (publicKeyLengthResult.isErr()) {
const errMsg = `Failed to parse length of public key from response buffer: ${msgFromError(
publicKeyLengthResult.error,
)}`
log.error(errMsg)
return throwError(() => hardwareError(errMsg))
}
const publicKeyLength = publicKeyLengthResult.value.readUIntBE(
0,
1,
)
const publicKeyBytesResult = readNextBuffer(
publicKeyLength,
)
if (publicKeyBytesResult.isErr()) {
const errMsg = `Failed to parse public key bytes from response buffer: ${msgFromError(
publicKeyBytesResult.error,
)}`
log.error(errMsg)
return throwError(() => hardwareError(errMsg))
}
const publicKeyBytes = publicKeyBytesResult.value
// We ignore remaining bytes, being: `chain_code_len (1) || chain_code (var)`
return toObservableFromResult(
PublicKey.fromBuffer(publicKeyBytes),
)
},
),
)
const getVersion = (): Observable<SemVerT> =>
ledgerNano
.sendAPDUToDevice(RadixAPDU.getVersion())
.pipe(
mergeMap(buf => toObservableFromResult(SemVer.fromBuffer(buf))),
)
const parseSignatureFromLedger = (
buf: Buffer,
): Result<{ signature: SignatureT; remainingBytes: Buffer }, Error> => {
// Response `buf`: pub_key_len (1) || pub_key (var) || chain_code_len (1) || chain_code (var)
const bufferReader = BufferReader.create(buf)
const signatureDERlengthResult = bufferReader.readNextBuffer(1)
if (signatureDERlengthResult.isErr()) {
const errMsg = `Failed to parse length of signature from response buffer: ${msgFromError(
signatureDERlengthResult.error,
)}`
log.error(errMsg)
return err(hardwareError(errMsg))
}
const signatureDERlength = signatureDERlengthResult.value.readUIntBE(
0,
1,
)
const signatureDERBytesResult = bufferReader.readNextBuffer(
signatureDERlength,
)
if (signatureDERBytesResult.isErr()) {
const errMsg = `Failed to parse Signature DER bytes from response buffer: ${msgFromError(
signatureDERBytesResult.error,
)}`
log.error(errMsg)
return err(hardwareError(errMsg))
}
const signatureDERBytes = signatureDERBytesResult.value
// We ignore remaining bytes, being: `Signature.V (1)`
return Signature.fromDER(signatureDERBytes).map(signature => ({
signature,
remainingBytes: bufferReader.remainingBytes(),
}))
}
const doSignHash = (input: SignHashInput): Observable<SignatureT> =>
ledgerNano
.sendAPDUToDevice(
RadixAPDU.doSignHash({
path: input.path ?? path000H,
hashToSign: input.hashToSign,
}),
)
.pipe(
mergeMap(
(buf: Buffer): Observable<SignatureT> =>
toObservableFromResult(
parseSignatureFromLedger(buf).map(r => r.signature),
),
),
)
const doKeyExchange = (
input: KeyExchangeInput,
): Observable<ECPointOnCurveT> =>
ledgerNano
.sendAPDUToDevice(
RadixAPDU.doKeyExchange(
input.path ?? path000H,
input.publicKeyOfOtherParty,
input.display,
),
)
.pipe(
mergeMap(
(buf: Buffer): Observable<ECPointOnCurveT> => {
// Response `buf`: sharedkeyPointLen (1) || sharedKeyPoint (var)
const readNextBuffer = readBuffer(buf)
const sharedKeyPointLengthResult = readNextBuffer(1)
if (sharedKeyPointLengthResult.isErr()) {
const errMsg = `Failed to parse length of shared key point from response buffer: ${msgFromError(
sharedKeyPointLengthResult.error,
)}`
log.error(errMsg)
return throwError(() => hardwareError(errMsg))
}
const sharedKeyPointLength = sharedKeyPointLengthResult.value.readUIntBE(
0,
1,
)
const sharedKeyPointBytesResult = readNextBuffer(
sharedKeyPointLength,
)
if (sharedKeyPointBytesResult.isErr()) {
const errMsg = `Failed to parse shared key point bytes from response buffer: ${msgFromError(
sharedKeyPointBytesResult.error,
)}`
log.error(errMsg)
return throwError(() => hardwareError(errMsg))
}
const sharedKeyPointBytes =
sharedKeyPointBytesResult.value
return toObservableFromResult(
ECPointOnCurve.fromBuffer(sharedKeyPointBytes),
)
},
),
)
const doSignTransaction = (
input: SignTransactionInput,
): Observable<SignTXOutput> => {
const displayInstructionContentsOnLedgerDevice = true
const displayTXSummaryOnLedgerDevice = true
const subs = new Subscription()
const transactionRes = Transaction.fromBuffer(
Buffer.from(input.tx.blob, 'hex'),
)
if (transactionRes.isErr()) {
const errMsg = `Failed to parse tx, underlying error: ${msgFromError(
transactionRes.error,
)}`
log.error(errMsg)
return throwError(() => hardwareError(errMsg))
}
const transaction = transactionRes.value
const instructions = transaction.instructions
const numberOfInstructions = instructions.length
const sendInstructionSubject = new Subject<InstructionT>()
const resultBufferFromLedgerSubject = new Subject<Buffer>()
const outputSubject = new Subject<SignTXOutput>()
const maxBytesPerExchange = 255
const nextInstructionToSend = (): InstructionT => {
const instructionToSend: InstructionT = instructions.shift()! // "pop first"
log.debug(
`
š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦
Sending instruction #${
numberOfInstructions - instructions.length
}/#${numberOfInstructions}. (length: #${
instructionToSend.toBuffer().length
} bytes).
Raw string representation: "
${instructionToSend.toString()}
"
Human readable string representation: "
${
instructionToSend.toHumanReadableString !== undefined
? instructionToSend.toHumanReadableString()
: 'no human readable representation available.'
}
"
Bytes: "
${instructionToSend.toBuffer().toString('hex')}
"
š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦ š¦
`,
)
return instructionToSend
}
const sendInstruction = (): void => {
sendInstructionSubject.next(nextInstructionToSend())
}
const moreInstructionsToSend = (): boolean => instructions.length > 0
subs.add(
ledgerNano
.sendAPDUToDevice(
RadixAPDU.signTX.initialSetup({
path: input.path ?? path000H,
txByteCount: input.tx.blob.length / 2, // 2 hex chars per byte
numberOfInstructions,
nonNativeTokenRriHRP: input.nonXrdHRP
? truncate(input.nonXrdHRP, 11)
: undefined,
}),
)
.subscribe({
next: _irrelevantBuf => {
sendInstruction()
},
error: error => {
sendInstructionSubject.error(error)
},
}),
)
subs.add(
sendInstructionSubject
.pipe(
mergeMap(nextInstruction => {
const instructionBytes = nextInstruction.toBuffer()
if (instructionBytes.length > maxBytesPerExchange) {
const errMsg = `Failed to send instruction, it is longer than max allowed payload size of ${maxBytesPerExchange}, specifically #${instructionBytes.length} bytes.`
return throwError(() => hardwareError(errMsg))
}
return of(instructionBytes)
}),
mergeMap(
(instructionBytes): Observable<Buffer> => {
return ledgerNano.sendAPDUToDevice(
RadixAPDU.signTX.singleInstruction({
instructionBytes,
isLastInstruction: !moreInstructionsToSend(),
displayInstructionContentsOnLedgerDevice,
displayTXSummaryOnLedgerDevice,
}),
)
},
),
tap({
next: (responseFromLedger: Buffer) => {
if (!moreInstructionsToSend()) {
resultBufferFromLedgerSubject.next(
responseFromLedger,
)
} else {
sendInstruction()
}
},
}),
)
.subscribe({
error: (error: unknown) => {
const errMsg = `Failed to sign tx with Ledger, underlying error while streaming tx bytes: '${msgFromError(
error,
)}'`
log.error(errMsg)
outputSubject.error(hardwareError(errMsg))
},
}),
)
subs.add(
resultBufferFromLedgerSubject.subscribe({
next: (bytes: Buffer) => {
const parsedResult = parseSignatureFromLedger(bytes)
if (!parsedResult.isOk()) {
const errMsg = `Failed to parse signature from response from Ledger, underlying error: '${msgFromError(
parsedResult.error,
)}'`
log.error(errMsg)
outputSubject.error(hardwareError(errMsg))
return
}
const signature: SignatureT = parsedResult.value.signature
const remainingBytes = parsedResult.value.remainingBytes
const signatureV = remainingBytes.readUInt8(0)
console.log(`Signature V: ${signatureV}`)
const hash = remainingBytes.slice(1)
if (hash.length !== 32) {
const errMsg = `Expected hash to have 32 bytes length`
log.error(errMsg)
outputSubject.error({
type: 'HARDWARE',
error: new Error(errMsg),
})
return
}
console.log(
`Ledger app produced hash: ${hash.toString('hex')}`,
)
outputSubject.next({
signature,
signatureV,
hashCalculatedByLedger: hash,
})
},
}),
)
return outputSubject.asObservable().pipe(take(1))
}
const hwWithoutSK: HardwareWalletWithoutSK = {
getPublicKey,
getVersion,
doSignHash,
doKeyExchange,
doSignTransaction,
}
return {
...hwWithoutSK,
makeSigningKey: (
path: HDPathRadixT,
verificationPrompt?: boolean,
): Observable<HardwareSigningKeyT> =>
signingKeyWithHardWareWallet(hwWithoutSK, path, verificationPrompt),
}
}
const create = (
transport: BasicLedgerTransport,
): Observable<HardwareWalletT> => {
const ledgerNano$ = from(LedgerNano.connect(transport))
return ledgerNano$.pipe(
map((ledger: LedgerNanoT) => withLedgerNano(ledger)),
)
}
export const HardwareWalletLedger = {
create,
from: withLedgerNano,
}