From 5d5aef65f6b0c06855ef8ed543c15b5c4fb17198 Mon Sep 17 00:00:00 2001 From: Brent Date: Mon, 28 Jun 2021 11:46:29 -0400 Subject: [PATCH 01/29] Remove forced lint auto fix --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 90b054e7..29fa1782 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ }, "husky": { "hooks": { - "pre-commit": "lint-staged", "pre-push": "npm run test:unit" } }, From 660805556f2821b88516ff53acbf402b8e9ae5a3 Mon Sep 17 00:00:00 2001 From: Brent Date: Mon, 28 Jun 2021 11:48:11 -0400 Subject: [PATCH 02/29] Ledger device integration --- .gitignore | 3 +- package.json | 7 +- packages/common/package.json | 6 +- packages/common/src/logging.ts | 22 +- packages/common/src/messaging/types.ts | 7 + packages/crypto/package.json | 10 +- packages/crypto/webpack.config.js | 2 +- packages/dapp/package.json | 10 +- packages/dapp/src/fn/router.ts | 7 +- packages/dapp/webpack.config.js | 2 +- packages/extension/package.json | 10 +- .../src/background/messaging/handler.ts | 182 +++++++------ .../background/messaging/internalMethods.ts | 245 ++++++++++++++++-- .../src/background/messaging/task.ts | 90 ++++++- .../extension/src/background/utils/session.ts | 12 + packages/storage/package.json | 6 +- packages/storage/webpack.config.js | 2 +- packages/test-project/package.json | 6 +- packages/ui/package.json | 9 +- .../components/Account/AccountDetails.test.ts | 3 + .../src/components/Account/AddAssetConfirm.ts | 81 +++--- .../components/Account/AssetDetails.test.ts | 3 + .../LedgerDevice/LedgerHardwareConnector.ts | 129 +++++++++ .../LedgerDevice/LedgerHardwareSign.ts | 211 +++++++++++++++ .../LedgerDevice/structure/ledgerActions.ts | 184 +++++++++++++ .../structure/ledgerActionsResponse.ts | 5 + .../LedgerDevice/structure/ledgerTransport.ts | 8 + packages/ui/src/components/SettingsMenu.ts | 2 +- .../SignTransaction/Common/TxTemplate.ts | 2 +- .../TransactionDetail/TxAcfg.test.ts | 3 + .../TransactionDetail/TxAfrz.test.ts | 3 + .../TransactionDetail/TxAxfer.test.ts | 3 + .../TransactionDetail/TxPay.test.ts | 3 + packages/ui/src/index.js | 7 +- packages/ui/src/pages/LinkHardwareAccount.ts | 42 +++ packages/ui/src/pages/Login.test.ts | 6 +- packages/ui/src/pages/Wallet.ts | 5 + packages/ui/webpack.config.js | 38 ++- 38 files changed, 1152 insertions(+), 224 deletions(-) create mode 100644 packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts create mode 100644 packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts create mode 100644 packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts create mode 100644 packages/ui/src/components/LedgerDevice/structure/ledgerActionsResponse.ts create mode 100644 packages/ui/src/components/LedgerDevice/structure/ledgerTransport.ts create mode 100644 packages/ui/src/pages/LinkHardwareAccount.ts diff --git a/.gitignore b/.gitignore index 03f510d4..f3e80fa7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ **/packages/dapp/lib .DS_Store test*.png -**/coverage \ No newline at end of file +**/coverage +**/test-project/results \ No newline at end of file diff --git a/package.json b/package.json index 29fa1782..8687bf2e 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "1.5.2", "author": "https://developer.purestake.io", "description": "Sign Algorand transactions in your browser with PureStake.", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "keywords": [ "Algorand", "PureStake" @@ -10,6 +12,8 @@ "scripts": { "install:extension": "(cd ./packages/common && npm install); (cd ./packages/crypto && npm install); (cd ./packages/storage && npm install); (cd ./packages/ui && npm install); (cd ./packages/extension && npm install); (cd ./packages/dapp && npm install);", "install:test": "(cd ./packages/test-project && npm install);", + "removelocks": "rm -rf ./package-lock.json && find -path \"./packages/*\" -name \"package-lock.json\" -not -path \"*/node_modules/*\" -exec rm -rf {} \\;", + "update": "npm update && find -maxdepth 2 -path \"./packages/*\" -exec npm update {} \\;", "build": "(cd ./packages/common && npm run build); (cd ./packages/crypto && npm run build); (cd ./packages/dapp && npm run build); (cd ./packages/storage && npm run build); (cd ./packages/ui && npm run build); (cd ./packages/extension && npm run build);", "build:ui": "cd ./packages/ui && npm run build && cp -r ./dist/* ../../dist/", "build:extension": "cd ./packages/extension && npm run build && cp -r ./dist/* ../../dist/", @@ -18,7 +22,8 @@ "prebuild": "rm -rf ./dist/*", "postbuild": "npm run copy", "postinstall": "npm run install:extension && npm run install:test", - "coveragetest": "cd ./packages/test-project && npm run coveragetest", + "rebuild": "npm run clean && npm run removelocks && npm install && npm run update && npm run build", + "coveragetest": "(cd ./packages/test-project && npm run coveragetest)", "test": "npm run test:unit && npm run test:e2e", "test:unit": "(cd ./packages/crypto && npm run test) && (cd ./packages/extension && npm run test) && (cd ./packages/ui && npm run test) && (cd ./packages/dapp && npm run test) && (cd ./packages/common && npm run test)", "test:e2e": "(cd ./packages/test-project && npm run test)", diff --git a/packages/common/package.json b/packages/common/package.json index 720d3ab6..7fa27fe7 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -3,11 +3,13 @@ "version": "1.5.2", "author": "https://developer.purestake.io", "description": "Common library functions for AlgoSigner.", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "devDependencies": { "@types/jest": "^26.0.14", - "jest": "^26.4.1", + "jest": "27.0.0", "jest-webextension-mock": "^3.6.1", - "ts-jest": "^26.3.0", + "ts-jest": "^27.0.0", "ts-loader": "^8.0.3", "typescript": "^3.7.5" }, diff --git a/packages/common/src/logging.ts b/packages/common/src/logging.ts index ad8699b8..0be5237a 100644 --- a/packages/common/src/logging.ts +++ b/packages/common/src/logging.ts @@ -1,8 +1,26 @@ +/* eslint-disable no-unused-vars */ /// // Central error handling. /// -export class Logging { - log(error: string): void { +export enum LogLevel { + None = 0, + Normal = 1, + Debug = 2, +} + +class Logging { + // Raise to Debug to show additional messages, or lower to None to ignore all + logThreshold = LogLevel.Normal; + + log(error: string, level?: LogLevel): void { + // Set the default to Normal for backwards compatibility + level = level || LogLevel.Normal; + + // If we area below the current threshold then return + if (this.logThreshold === LogLevel.None || level < this.logThreshold) { + return; + } + // TODO: BC - How should we handle errors? // Should likely use a logging packackage here to send errors to the user or backend logging. try { diff --git a/packages/common/src/messaging/types.ts b/packages/common/src/messaging/types.ts index e757ee83..c4e8ea55 100644 --- a/packages/common/src/messaging/types.ts +++ b/packages/common/src/messaging/types.ts @@ -39,6 +39,13 @@ export enum JsonRpcMethod { SaveNetwork = 'save-network', DeleteNetwork = 'delete-network', GetLedgers = 'get-ledgers', + + // Ledger Device Methods + LedgerSaveAccount = 'ledger-save-account', + LedgerLinkAddress = 'ledger-link-address', + LedgerGetSessionTxn = 'ledger-get-session-txn', + LedgerSendTxnResponse = 'ledger-send-txn-response', + LedgerSignTransaction = 'ledger-sign-transaction', } export type JsonPayload = { diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 29591881..ad07aae5 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -7,15 +7,15 @@ "type": "git", "url": "https://github.com/PureStake/algosigner/packages/crypto.git" }, - "license": "", + "license": "MIT", "devDependencies": { "@types/jest": "^26.0.14", - "jest": "^26.4.2", - "ts-jest": "^26.3.0", + "jest": "27.0.0", + "ts-jest": "^27.0.0", "ts-loader": "^7.0.5", "typescript": "^3.9.7", - "webpack": "^4.44.1", - "webpack-cli": "^3.3.11" + "webpack": "^5.39.1", + "webpack-cli": "^4.7.2" }, "dependencies": {}, "scripts": { diff --git a/packages/crypto/webpack.config.js b/packages/crypto/webpack.config.js index f2e10172..730e59c1 100644 --- a/packages/crypto/webpack.config.js +++ b/packages/crypto/webpack.config.js @@ -21,7 +21,7 @@ module.exports = { //devtool: 'source-map', optimization: { minimize: false, - namedModules: true + moduleIds: "named" }, module: { rules: [ diff --git a/packages/dapp/package.json b/packages/dapp/package.json index b020d479..5b704e3e 100644 --- a/packages/dapp/package.json +++ b/packages/dapp/package.json @@ -2,6 +2,8 @@ "name": "@algosigner/dapp", "version": "1.5.2", "author": "https://developer.purestake.io", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "Sample DAPP for use with AlgoSigner.", "scripts": { "build": "npm run clean && webpack", @@ -9,14 +11,14 @@ "test": "jest" }, "devDependencies": { - "jest": "^26.4.2", + "jest": "27.0.0", "ts-loader": "^7.0.5", "typescript": "^3.9.7", - "webpack": "^4.44.1", - "webpack-cli": "^3.3.12" + "webpack": "^5.39.1", + "webpack-cli": "^4.7.2" }, "dependencies": { "@types/jest": "^26.0.14", - "ts-jest": "^26.3.0" + "ts-jest": "^27.0.0" } } diff --git a/packages/dapp/src/fn/router.ts b/packages/dapp/src/fn/router.ts index 13f28429..392eb0ea 100644 --- a/packages/dapp/src/fn/router.ts +++ b/packages/dapp/src/fn/router.ts @@ -9,12 +9,15 @@ // custom handler for different message types, etc.. import { MessageApi } from '../messaging/api'; import { Task } from './task'; +import { MessageSource } from '@algosigner/common/messaging/types'; +import logging from '@algosigner/common/logging'; export class Router { handler: Function; constructor() { this.handler = this.default; window.addEventListener('message', (event) => { + logging.log(`Router DApp message event: ${JSON.stringify(event)}`, 2); const d = event.data; try { @@ -26,8 +29,8 @@ export class Router { } } else { if (Object.prototype.toString.call(d) === '[object Object]' && 'source' in d) { - if (d.source == 'extension') { - d.source = 'router'; + if (d.source == MessageSource.Extension) { + d.source = MessageSource.Router; d.origin = window.location.origin; this.handler(d); } diff --git a/packages/dapp/webpack.config.js b/packages/dapp/webpack.config.js index 5f218953..2cf7bef5 100644 --- a/packages/dapp/webpack.config.js +++ b/packages/dapp/webpack.config.js @@ -21,7 +21,7 @@ module.exports = { }, optimization: { minimize: false, - namedModules: true, + moduleIds: 'named', }, module: { rules: [ diff --git a/packages/extension/package.json b/packages/extension/package.json index a3a8bd07..cf2c9e01 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -2,6 +2,8 @@ "name": "algosigner-extension", "version": "1.5.2", "author": "https://developer.purestake.io", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "Sign Algorand transactions in your browser with PureStake.", "keywords": [ "Algorand", @@ -9,13 +11,13 @@ ], "devDependencies": { "@types/jest": "^26.0.14", - "jest": "^26.4.2", + "jest": "27.0.0", "jest-webextension-mock": "^3.6.1", - "ts-jest": "^26.3.0", + "ts-jest": "^27.0.0", "ts-loader": "^7.0.5", "typescript": "^3.9.7", - "webpack": "^4.44.1", - "webpack-cli": "^3.3.12" + "webpack": "^5.39.1", + "webpack-cli": "^4.7.2" }, "dependencies": { "algosdk": "1.8.1" diff --git a/packages/extension/src/background/messaging/handler.ts b/packages/extension/src/background/messaging/handler.ts index 6d74fe30..46e02f2d 100644 --- a/packages/extension/src/background/messaging/handler.ts +++ b/packages/extension/src/background/messaging/handler.ts @@ -1,114 +1,112 @@ import { MessageApi } from './api'; import { Task } from './task'; -import { extensionBrowser } from '@algosigner/common/chrome'; -import encryptionWrap from "../encryptionWrap"; +import encryptionWrap from '../encryptionWrap'; import { isFromExtension } from '@algosigner/common/utils'; import { RequestErrors } from '@algosigner/common/types'; import { JsonRpcMethod, MessageSource } from '@algosigner/common/messaging/types'; +import logging from '@algosigner/common/logging'; const auth_methods = [ - JsonRpcMethod.Authorization, - JsonRpcMethod.AuthorizationAllow, - JsonRpcMethod.AuthorizationDeny + JsonRpcMethod.Authorization, + JsonRpcMethod.AuthorizationAllow, + JsonRpcMethod.AuthorizationDeny, ]; class RequestValidation { - public static isAuthorization(method: JsonRpcMethod) { - if(auth_methods.indexOf(method) > -1) - return true; - return false; - } - public static isPublic(method: JsonRpcMethod) { - if(method in Task.methods().public) - return true; - return false; - } + public static isAuthorization(method: JsonRpcMethod) { + if (auth_methods.indexOf(method) > -1) return true; + return false; + } + public static isPublic(method: JsonRpcMethod) { + if (method in Task.methods().public) return true; + return false; + } } export class OnMessageHandler extends RequestValidation { - static events: {[key: string]: any} = {}; + static events: { [key: string]: any } = {}; - static handle(request: any, sender: any, sendResponse: any) { - //console.log('HANDLIG MESSAGE', request, sender); + static handle(request: any, sender: any, sendResponse: any) { + logging.log( + `Handler in background messaging: ${JSON.stringify(request)}, ${JSON.stringify(sender)}`, + 2 + ); - if ('tab' in sender){ - request.originTabID = sender.tab.id; - request.originTitle = sender.tab.title; - if ('favIconUrl' in sender.tab) - request.favIconUrl = sender.tab.favIconUrl; - } - - try { - request.origin = new URL(sender.url).origin; - } catch(e) { - request.error = RequestErrors.NotAuthorized; - MessageApi.send(request); - return; - } + if ('tab' in sender) { + request.originTabID = sender.tab.id; + request.originTitle = sender.tab.title; + if ('favIconUrl' in sender.tab) request.favIconUrl = sender.tab.favIconUrl; + } - return this.processMessage(request, sender, sendResponse); + try { + request.origin = new URL(sender.url).origin; + } catch (e) { + request.error = RequestErrors.NotAuthorized; + MessageApi.send(request); + return; } - static processMessage(request: any, sender: any, sendResponse: any) { - const source : MessageSource = request.source; - const body = request.body; - const method = body.method; - const id = body.id; + return this.processMessage(request, sender, sendResponse); + } - // Check if the message comes from the extension - if (isFromExtension(sender.origin)) { - // Message from extension - switch(source) { - // Message from extension to dapp - case MessageSource.Extension: - if(OnMessageHandler.isAuthorization(method) - && !OnMessageHandler.isPublic(method)) { - // Is a protected authorization message, allowing or denying auth - Task.methods().private[method](request); - } else { - OnMessageHandler.events[id] = sendResponse; - MessageApi.send(request); - // Tell Chrome that this response will be resolved asynchronously. - return true; - } - break; - case MessageSource.UI: - return Task.methods().extension[method](request, sendResponse); - break; - } - } else { - // Reject message if there's no wallet - new encryptionWrap("").checkStorage((exist: boolean) => { - if (!exist) { - request.error = { - message: RequestErrors.NotAuthorized - }; - MessageApi.send(request); - } else { - if (OnMessageHandler.isAuthorization(method) - && OnMessageHandler.isPublic(method)) { - // Is a public authorization message, dapp is asking to connect - Task.methods().public[method](request); - } else { - // Other requests from dapp fall here - if (Task.isAuthorized(request.origin)) { - // If the origin is authorized, build a promise - Task.build(request) - .then((d) => { - MessageApi.send(d); - }) - .catch((d) => { - MessageApi.send(d); - }); - } else { - // Origin is not authorized - request.error = RequestErrors.NotAuthorized; - MessageApi.send(request); - } - } - } - }); + static processMessage(request: any, sender: any, sendResponse: any) { + const source: MessageSource = request.source; + const body = request.body; + const method = body.method; + const id = body.id; + + // Check if the message comes from the extension + if (isFromExtension(sender.origin)) { + // Message from extension + switch (source) { + // Message from extension to dapp + case MessageSource.Extension: + if (OnMessageHandler.isAuthorization(method) && !OnMessageHandler.isPublic(method)) { + // Is a protected authorization message, allowing or denying auth + Task.methods().private[method](request); + } else { + OnMessageHandler.events[id] = sendResponse; + MessageApi.send(request); + // Tell Chrome that this response will be resolved asynchronously. return true; + } + break; + case MessageSource.UI: + return Task.methods().extension[method](request, sendResponse); + break; + } + } else { + // Reject message if there's no wallet + new encryptionWrap('').checkStorage((exist: boolean) => { + if (!exist) { + request.error = { + message: RequestErrors.NotAuthorized, + }; + MessageApi.send(request); + } else { + if (OnMessageHandler.isAuthorization(method) && OnMessageHandler.isPublic(method)) { + // Is a public authorization message, dapp is asking to connect + Task.methods().public[method](request); + } else { + // Other requests from dapp fall here + if (Task.isAuthorized(request.origin)) { + // If the origin is authorized, build a promise + Task.build(request) + .then((d) => { + MessageApi.send(d); + }) + .catch((d) => { + MessageApi.send(d); + }); + } else { + // Origin is not authorized + request.error = RequestErrors.NotAuthorized; + MessageApi.send(request); + } + } } + }); + return true; } -} \ No newline at end of file + } +} diff --git a/packages/extension/src/background/messaging/internalMethods.ts b/packages/extension/src/background/messaging/internalMethods.ts index d4442106..c4c67804 100644 --- a/packages/extension/src/background/messaging/internalMethods.ts +++ b/packages/extension/src/background/messaging/internalMethods.ts @@ -12,11 +12,16 @@ import Session from '../utils/session'; import AssetsDetailsHelper from '../utils/assetsDetailsHelper'; import { initializeCache, getAvailableLedgersExt } from '../utils/helper'; import { ValidationStatus } from '../utils/validator'; -import { getValidatedTxnWrap, getLedgerFromGenesisId } from '../transaction/actions'; +import { + calculateEstimatedFee, + getValidatedTxnWrap, + getLedgerFromGenesisId, +} from '../transaction/actions'; import { BaseValidatedTxnWrap } from '../transaction/baseValidatedTxnWrap'; import { buildTransaction } from '../utils/transactionBuilder'; import { getBaseSupportedLedgers, LedgerTemplate } from '@algosigner/common/types/ledgers'; import { NoAccountMatch } from '../../errors/transactionSign'; +import { extensionBrowser } from '@algosigner/common/chrome'; const session = new Session(); @@ -294,6 +299,165 @@ export class InternalMethods { return true; } + public static [JsonRpcMethod.LedgerLinkAddress](request: any, sendResponse: Function) { + const ledger = request.body.params.ledger; + extensionBrowser.tabs.create( + { + active: true, + url: extensionBrowser.extension.getURL(`/index.html#/${ledger}/ledger-hardware-connector`), + }, + (tab) => { + // Tab object is created here, but extension popover will close. + sendResponse(tab); + } + ); + return true; + } + public static [JsonRpcMethod.LedgerGetSessionTxn](request: any, sendResponse: Function) { + if (session.txnWrap && 'body' in session.txnWrap) { + // The transaction may contain source and JSONRPC info, the body.params will be the transaction validation object + sendResponse(session.txnWrap.body.params); + } else { + sendResponse({ error: 'Transaction not found in session.' }); + } + return true; + } + + public static [JsonRpcMethod.LedgerSendTxnResponse](request: any, sendResponse: Function) { + if (session.txnWrap && 'body' in session.txnWrap) { + const txnBuf = Buffer.from(request.body.params.txn, 'base64'); + const decodedTxn = algosdk.decodeSignedTransaction(txnBuf); + const signedTxnEntries = Object.entries(decodedTxn.txn).sort(); + + // Get the session transaction + const sessTxn = session.txnWrap.body.params.transaction; + + // Set the fee to the estimate we showed on the screen for validation. + sessTxn['fee'] = session.txnWrap.body.params.estimatedFee; + const sessTxnEntries = Object.entries(sessTxn).sort(); + + // Update fields in the signed transaction that are not the same format + for (let i = 0; i < signedTxnEntries.length; i++) { + if (signedTxnEntries[i][0] === 'from') { + signedTxnEntries[i][1] = algosdk.encodeAddress(signedTxnEntries[i][1]['publicKey']); + } else if (signedTxnEntries[i][0] === 'to') { + signedTxnEntries[i][1] = algosdk.encodeAddress(signedTxnEntries[i][1]['publicKey']); + } else if (signedTxnEntries[i][1].constructor === Uint8Array) { + //@ts-ignore + signedTxnEntries[i][1] = Buffer.from(signedTxnEntries[i][1]).toString('base64'); + } + } + + logging.log(`Signed Txn: ${signedTxnEntries}`, 2); + logging.log(`Session Txn: ${sessTxnEntries}`, 2); + + if ( + signedTxnEntries['amount'] === sessTxnEntries['amount'] && + signedTxnEntries['fee'] === sessTxnEntries['fee'] && + signedTxnEntries['genesisID'] === sessTxnEntries['genesisID'] && + signedTxnEntries['firstRound'] === sessTxnEntries['firstRound'] && + signedTxnEntries['lastRound'] === sessTxnEntries['lastRound'] && + signedTxnEntries['type'] === sessTxnEntries['type'] && + signedTxnEntries['to'] === sessTxnEntries['to'] && + signedTxnEntries['from'] === sessTxnEntries['from'] && + signedTxnEntries['closeRemainderTo'] === sessTxnEntries['closeRemainderTo'] + ) { + //Check the txnWrap for a dApp response and return the transaction + if (session.txnWrap.source === 'dapp') { + const message = session.txnWrap; + message.response = { + blob: request.body.params.txn, + }; + sendResponse({ message: message }); + } + // If this is a ui transaction then we need to also submit + else if (session.txnWrap.source === 'ui') { + const txHeaders = { 'Content-Type': 'application/x-binary' }; + const ledger = getLedgerFromGenesisId(decodedTxn.txn.genesisID); + + const algod = this.getAlgod(ledger); + algod + .sendRawTransaction(txnBuf, txHeaders) + .do() + .then((resp: any) => { + sendResponse({ txId: resp.txId }); + }) + .catch((e: any) => { + if (e.message.includes('overspend')) + sendResponse({ + error: "Overspending. Your account doesn't have sufficient funds.", + }); + else sendResponse({ error: e.message }); + }); + } else { + sendResponse({ error: 'Session transaction does not match the signed transaction.' }); + } + + // Clear the cached transaction + session.txnWrap.body.params.transaction = undefined; + } else { + sendResponse({ error: 'Transaction not found in session, unable to validate for send.' }); + } + } + return true; + } + + // Protected because this should only be called from within the ui or dapp sign methods + protected static [JsonRpcMethod.LedgerSignTransaction](request: any, sendResponse: Function) { + // Access store here here to save the transaction wrap to cache before the site picks it up. + // Explicitly using txnWrap on session instead of auth message for two reasons: + // 1) So it lives inside background sandbox containment. + // 2) The extension may close before a proper id on the new tab can allow the data to be saved. + session.txnWrap = request; + + // Transaction wrap will contain response message if from dApp and structure will be different + const ledger = getLedgerFromGenesisId(request.body.params.transaction.genesisID); + extensionBrowser.tabs.create( + { + active: true, + url: extensionBrowser.extension.getURL(`/index.html#/${ledger}/ledger-hardware-sign`), + }, + (tab) => { + // Tab object is created here, but extension popover will close. + sendResponse(tab); + } + ); + } + + public static [JsonRpcMethod.LedgerSaveAccount](request: any, sendResponse: Function) { + const { name, ledger, passphrase } = request.body.params; + // The value returned from the Ledger device is hex. + // This is passed directly to save and needs to be converted. + const address = algosdk.encodeAddress(Buffer.from(request.body.params.hexAddress, 'hex')); + + this._encryptionWrap = new encryptionWrap(passphrase); + this._encryptionWrap.unlock((unlockedValue: any) => { + if ('error' in unlockedValue) { + sendResponse(unlockedValue); + } else { + const newAccount = { + address: address, + name: name, + isHardware: true, + }; + + if (!unlockedValue[ledger]) { + unlockedValue[ledger] = []; + } + + unlockedValue[ledger].push(newAccount); + this._encryptionWrap?.lock(JSON.stringify(unlockedValue), (isSuccessful: any) => { + if (isSuccessful) { + session.wallet = this.safeWallet(unlockedValue); + sendResponse(session.wallet); + } else { + sendResponse({ error: 'Lock failed' }); + } + }); + } + }); + return true; + } public static [JsonRpcMethod.AccountDetails](request: any, sendResponse: Function) { const { ledger, address } = request.body.params; const algod = this.getAlgod(ledger); @@ -520,7 +684,6 @@ export class InternalMethods { } } - var recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); const params = await algod.getTransactionParams().do(); const txn = { ...txnParams, @@ -570,31 +733,61 @@ export class InternalMethods { sendResponse({ error: e }); return; } else { - // We have a transaction which does not contain invalid fields, but may contain fields that are dangerous - // or ones we've flagged as needing to be reviewed. We can use a modified popup to allow the normal flow, but require extra scrutiny. - let signedTxn; - try { - const builtTx = buildTransaction(txn); - signedTxn = { - txID: builtTx.txID().toString(), - blob: builtTx.signTxn(recoveredAccount.sk), - }; - } catch (e) { - sendResponse({ error: e.message }); - return; - } + // We have a transaction which does not contain invalid fields, + // but may still contain fields that are dangerous + // or ones we've flagged as needing to be reviewed. + // Perform a change based on if this is a ledger device account + if (account.isHardware) { + // TODO: Temporary workaround by adding min-fee for estimate calculations since it's not in the sdk get params. + params['min-fee'] = 1000; + calculateEstimatedFee(transactionWrap, params); + + // Pass the transaction wrap we can pass to the + // central sign ledger function for consistency + this[JsonRpcMethod.LedgerSignTransaction]( + { source: 'ui', body: { params: transactionWrap } }, + (response) => { + // We only have to worry about possible errors here so we can ignore the created tab + if ('error' in response) { + sendResponse(response); + } else { + // Respond with a 0 tx id so that the page knows not to try and show it. + sendResponse({ txId: 0 }); + } + } + ); - algod - .sendRawTransaction(signedTxn.blob, txHeaders) - .do() - .then((resp: any) => { - sendResponse({ txId: resp.txId }); - }) - .catch((e: any) => { - if (e.message.includes('overspend')) - sendResponse({ error: "Overspending. Your account doesn't have sufficient funds." }); - else sendResponse({ error: e.message }); - }); + // Return to close connection + return true; + } else { + // We can use a modified popup to allow the normal flow, but require extra scrutiny. + const recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); + let signedTxn; + try { + const builtTx = buildTransaction(txn); + signedTxn = { + txID: builtTx.txID().toString(), + blob: builtTx.signTxn(recoveredAccount.sk), + }; + } catch (e) { + sendResponse({ error: e.message }); + return false; + } + + algod + .sendRawTransaction(signedTxn.blob, txHeaders) + .do() + .then((resp: any) => { + sendResponse({ txId: resp.txId }); + }) + .catch((e: any) => { + if (e.message.includes('overspend')) + sendResponse({ + error: "Overspending. Your account doesn't have sufficient funds.", + }); + else sendResponse({ error: e.message }); + }); + } } }); diff --git a/packages/extension/src/background/messaging/task.ts b/packages/extension/src/background/messaging/task.ts index 8e077152..45e13fbb 100644 --- a/packages/extension/src/background/messaging/task.ts +++ b/packages/extension/src/background/messaging/task.ts @@ -808,6 +808,7 @@ export class Task { const { passphrase, responseOriginTabID } = request.body.params; const auth = Task.requests[responseOriginTabID]; const message = auth.message; + let holdResponse = false; const { from, @@ -847,7 +848,11 @@ export class Task { } } - const recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); + // If the account is not a hardware account we need to get the mnemonic + let recoveredAccount; + if (!account.isHardware) { + recoveredAccount = algosdk.mnemonicToSecretKey(account.mnemonic); + } const txn = { ...message.body.params.transaction }; removeEmptyFields(txn); @@ -895,25 +900,48 @@ export class Task { try { // This step transitions a raw object into a transaction style object const builtTx = buildTransaction(txn); - // We are combining the tx id get and sign into one step/object because of legacy, - // this may not need to be the case any longer. - const signedTxn = { - txID: builtTx.txID().toString(), - blob: builtTx.signTxn(recoveredAccount.sk), - }; - const b64Obj = Buffer.from(signedTxn.blob).toString('base64'); - - message.response = { - txID: signedTxn.txID, - blob: b64Obj, - }; + + if (recoveredAccount) { + // We recovered an account from within the saved extension data and can sign with it + const txblob = builtTx.signTxn(recoveredAccount.sk); + + // We are combining the tx id get and sign into one step/object because of legacy, + // this may not need to be the case any longer. + const signedTxn = { + txID: builtTx.txID().toString(), + blob: txblob, + }; + + const b64Obj = Buffer.from(signedTxn.blob).toString('base64'); + + message.response = { + txID: signedTxn.txID, + blob: b64Obj, + }; + } else if (account.isHardware) { + // The account is hardware based. We need to open the extension in tab to connect. + // We will need to hold the response to dApps + holdResponse = true; + InternalMethods[JsonRpcMethod.LedgerSignTransaction](message, (response) => { + // We only have to worry about possible errors here + if ('error' in response) { + // Cancel the hold response since errors needs to be returned + holdResponse = false; + message.error = response.error; + } + }); + } } catch (e) { message.error = e.message; } // Clean class saved request delete Task.requests[responseOriginTabID]; - MessageApi.send(message); + + // Hardware signing will defer the response + if (!holdResponse) { + MessageApi.send(message); + } }); } catch { // On error we should remove the task @@ -1299,6 +1327,40 @@ export class Task { [JsonRpcMethod.GetLedgers]: (request: any, sendResponse: Function) => { return InternalMethods[JsonRpcMethod.GetLedgers](request, sendResponse); }, + [JsonRpcMethod.LedgerLinkAddress]: (request: any, sendResponse: Function) => { + return InternalMethods[JsonRpcMethod.LedgerLinkAddress](request, sendResponse); + }, + [JsonRpcMethod.LedgerGetSessionTxn]: (request: any, sendResponse: Function) => { + return InternalMethods[JsonRpcMethod.LedgerGetSessionTxn](request, sendResponse); + }, + [JsonRpcMethod.LedgerSendTxnResponse]: (request: any, sendResponse: Function) => { + InternalMethods[JsonRpcMethod.LedgerSendTxnResponse](request, function (response) { + logging.log( + `Task method - LedgerSendTxnResponse - Returning: ${JSON.stringify(response)}`, + 2 + ); + + // Message indicates that this response will go to the DApp + if ('message' in response) { + // Send the response back to the origniating page + MessageApi.send(response.message); + // Also pass back the blob response to the caller + sendResponse(response.message.response); + } else { + // Send repsonse to the calling function + sendResponse(response); + } + }); + return true; + }, + [JsonRpcMethod.LedgerSaveAccount]: (request: any, sendResponse: Function) => { + try { + return InternalMethods[JsonRpcMethod.LedgerSaveAccount](request, sendResponse); + } catch { + sendResponse({ error: 'Account parameters invalid.' }); + return false; + } + }, }, }; } diff --git a/packages/extension/src/background/utils/session.ts b/packages/extension/src/background/utils/session.ts index 995dfe11..80e34b2f 100644 --- a/packages/extension/src/background/utils/session.ts +++ b/packages/extension/src/background/utils/session.ts @@ -2,6 +2,7 @@ export default class Session { private _wallet: any; private _ledger: any; private _availableLedgers: any; + private _txnWrap: any; public set wallet(v: any) { this._wallet = v; @@ -19,6 +20,15 @@ export default class Session { return this._ledger; } + public set txnWrap(w: any) { + this._txnWrap = w; + } + + public get txnWrap(): any { + const w = this._txnWrap; + return w; + } + public set availableLedgers(v: any) { this._availableLedgers = v; } @@ -36,6 +46,7 @@ export default class Session { wallet: this._wallet, ledger: this._ledger, availableLedgers: this._availableLedgers || [], + txnWrap: this._txnWrap, }; } @@ -43,5 +54,6 @@ export default class Session { this._wallet = undefined; this._ledger = undefined; this._availableLedgers = undefined; + this._txnWrap = undefined; } } diff --git a/packages/storage/package.json b/packages/storage/package.json index d0999223..3bd2d174 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -2,12 +2,14 @@ "name": "algosigner-storage", "version": "1.5.2", "author": "https://developer.purestake.io", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "Storage wrapper for saving and retrieving extention information in Algosigner.", "devDependencies": { "ts-loader": "^7.0.5", "typescript": "^3.9.7", - "webpack": "^4.44.1", - "webpack-cli": "^3.3.11" + "webpack": "^5.39.1", + "webpack-cli": "^4.7.2" }, "dependencies": {}, "scripts": { diff --git a/packages/storage/webpack.config.js b/packages/storage/webpack.config.js index cf43272c..95e638c4 100644 --- a/packages/storage/webpack.config.js +++ b/packages/storage/webpack.config.js @@ -24,7 +24,7 @@ module.exports = { //devtool: 'source-map', optimization: { minimize: false, - namedModules: true, + moduleIds: 'named', }, module: { rules: [ diff --git a/packages/test-project/package.json b/packages/test-project/package.json index e7894237..eae79c13 100644 --- a/packages/test-project/package.json +++ b/packages/test-project/package.json @@ -1,10 +1,12 @@ { "name": "algorand-test-project", "version": "1.5.2", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "Repository for tests", "devDependencies": { "algosdk": "1.8.1", - "jest": "^27.0.0", + "jest": "27.0.0", "jest-runner-groups": "^2.0.1", "puppeteer": "^5.5.0", "ts-jest": "^27.0.0" @@ -16,7 +18,7 @@ "app-dapp": "jest --group=app-dapp", "dapp/multisig": "jest --group=dapp/multisig", "dapp": "jest --group=dapp", - "coveragetest": "jest --coverage=true --coverageDirectory ../test-project/coverage --projects ../crypto ../extension ../storage ../common ../dapp --runInBand && echo \"Test info at: ./test-project/coverage/locv-report/index.html\"", + "coveragetest": "jest --coverage=true --coverageDirectory ../test-project/coverage --projects ../crypto ../extension ../storage ../common ../dapp --runInBand && bash -c \"start chrome \"$(realpath ./coverage/lcov-report/index.html\"\")", "test": "jest -i --group=-github --group=-dapp-storage" } } diff --git a/packages/ui/package.json b/packages/ui/package.json index ef09fef9..32e2e945 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,6 +2,8 @@ "name": "algosigner-ui", "version": "1.5.2", "author": "https://developer.purestake.io", + "repository": "https://github.com/PureStake/algosigner", + "license": "MIT", "description": "User interface built for AlgoSigner.", "private": true, "scripts": { @@ -12,6 +14,9 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^5.15.0", + "@ledgerhq/hw-app-algorand": "^5.51.1", + "@ledgerhq/hw-transport-webhid": "^5.46.0", + "algosdk": "1.8.1", "history": "^5.0.0", "htm": "^3.0.4", "mobx": "^5.15.6", @@ -33,12 +38,12 @@ "file-loader": "^6.1.0", "html-webpack-plugin": "^4.4.1", "identity-obj-proxy": "^3.0.0", - "jest": "^26.4.2", + "jest": "27.0.0", "jest-webextension-mock": "^3.6.1", "mini-css-extract-plugin": "^1.6.0", "sass": "^1.26.10", "sass-loader": "^8.0.2", - "ts-jest": "^26.3.0", + "ts-jest": "^27.0.0", "ts-loader": "^7.0.1", "typescript": "^3.9.7", "webpack": "^5.39.1", diff --git a/packages/ui/src/components/Account/AccountDetails.test.ts b/packages/ui/src/components/Account/AccountDetails.test.ts index ce867684..8f0a3b94 100644 --- a/packages/ui/src/components/Account/AccountDetails.test.ts +++ b/packages/ui/src/components/Account/AccountDetails.test.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { shallow } from 'enzyme'; import { html } from 'htm/preact'; import AccountDetails from './AccountDetails'; diff --git a/packages/ui/src/components/Account/AddAssetConfirm.ts b/packages/ui/src/components/Account/AddAssetConfirm.ts index 4ccb1c83..f5dc15cd 100644 --- a/packages/ui/src/components/Account/AddAssetConfirm.ts +++ b/packages/ui/src/components/Account/AddAssetConfirm.ts @@ -1,17 +1,15 @@ -import { FunctionalComponent } from "preact"; +import { FunctionalComponent } from 'preact'; import { html } from 'htm/preact'; -import { useContext, useState } from 'preact/hooks'; +import { useState } from 'preact/hooks'; import { route } from 'preact-router'; import { JsonRpcMethod } from '@algosigner/common/messaging/types'; -import { sendMessage } from 'services/Messaging' -import { StoreContext } from 'services/StoreContext' +import { sendMessage } from 'services/Messaging'; -import Authenticate from 'components/Authenticate' +import Authenticate from 'components/Authenticate'; const AddAssetConfirm: FunctionalComponent = (props: any) => { const { asset, ledger, address, accountsAssetsIDs } = props; - const store:any = useContext(StoreContext); const [askAuth, setAskAuth] = useState(false); const [loading, setLoading] = useState(false); const [authError, setAuthError] = useState(''); @@ -26,21 +24,21 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { passphrase: pwd, address: address, txnParams: { - type: "axfer", + type: 'axfer', assetIndex: asset['asset_id'], from: address, to: address, - amount: 0 - } + amount: 0, + }, }; setLoading(true); setAuthError(''); - sendMessage(JsonRpcMethod.SignSendTransaction, params, function(response) { - if ('error' in response) { + sendMessage(JsonRpcMethod.SignSendTransaction, params, function (response) { + if (response && 'error' in response) { setLoading(false); switch (response.error) { - case "Login Failed": + case 'Login Failed': setAuthError('Wrong passphrase'); break; default: @@ -56,7 +54,10 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { }; return html` - ${ !askAuth && txId.length === 0 && !error && html` + ${!askAuth && + txId.length === 0 && + !error && + html`
Adding Asset @@ -64,17 +65,17 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { ${asset['asset_id']}
- - ${ asset.name && html` + ${asset.name && + html`
Asset name ${asset.name}
`} - ${ asset.name && asset['unit_name'] && html` -
- `} - ${ asset['unit_name'] && asset['unit_name'].length > 0 && html` + ${asset.name && asset['unit_name'] && html`
`} + ${asset['unit_name'] && + asset['unit_name'].length > 0 && + html`
Unit name ${asset['unit_name']} @@ -82,9 +83,13 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { `} @@ -97,32 +102,37 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => { id="addAsset" class="button is-primary is-fullwidth" onClick=${() => setAskAuth(true)} - disabled=${disabled}> + disabled=${disabled} + > ${disabled ? 'You already added this asset' : 'Add asset!'}
`} - - ${txId.length > 0 && html` + ${txId.length > 0 && + html`
Transaction sent!
`} - - ${ error !== undefined && error.length > 0 && html` + ${error !== undefined && + error.length > 0 && + html`

Transaction failed with the following error: @@ -130,14 +140,9 @@ const AddAssetConfirm: FunctionalComponent = (props: any) => {

${error}

`} - - ${askAuth && html` - <${Authenticate} - error=${authError} - loading=${loading} - nextStep=${addAsset} /> - `} - ` + ${askAuth && + html` <${Authenticate} error=${authError} loading=${loading} nextStep=${addAsset} /> `} + `; }; -export default AddAssetConfirm; \ No newline at end of file +export default AddAssetConfirm; diff --git a/packages/ui/src/components/Account/AssetDetails.test.ts b/packages/ui/src/components/Account/AssetDetails.test.ts index f88448d1..8201adde 100644 --- a/packages/ui/src/components/Account/AssetDetails.test.ts +++ b/packages/ui/src/components/Account/AssetDetails.test.ts @@ -1,3 +1,6 @@ +/** + * @jest-environment jsdom + */ import { shallow } from 'enzyme'; import { html } from 'htm/preact'; import AssetDetails from './AssetDetails'; diff --git a/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts b/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts new file mode 100644 index 00000000..d7179247 --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/LedgerHardwareConnector.ts @@ -0,0 +1,129 @@ +import { FunctionalComponent } from 'preact'; +import { html } from 'htm/preact'; +import { useState } from 'preact/hooks'; +import { sendMessage } from 'services/Messaging'; +import { JsonRpcMethod } from '@algosigner/common/messaging/types'; +import Authenticate from 'components/Authenticate'; +import { ledgerActions } from './structure/ledgerActions'; + +const LedgerHardwareConnector: FunctionalComponent = (props: any) => { + const { ledger } = props; + const [name, setName] = useState(''); + const [askAuth, setAskAuth] = useState(false); + const [loading, setLoading] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [authError, setAuthError] = useState(''); + const [error, setError] = useState(''); + + // The save address requires a connection to the ledger device via a web page + // This page acts as the extension opened in a new tab + const saveLedgerAddress = (pwd) => { + setLoading(true); + setAuthError(''); + setError(''); + + // Obtain a leger address from the device + ledgerActions.getAddress().then((response) => { + // If we have an error display as normal, otherwise add the address to the saved profile + if ('error' in response) { + setLoading(false); + setAskAuth(false); + setError(`Error getting address from the Ledger hardware device. ${response['error']}`); + } else { + const params = { + passphrase: pwd, + name: name.trim(), + ledger: ledger, + hexAddress: response.message, + }; + + sendMessage(JsonRpcMethod.LedgerSaveAccount, params, function (response) { + setLoading(false); + setAuthError(''); + setError(''); + if ('error' in response) { + switch (response['error']) { + case 'Login Failed': + setAuthError('Wrong passphrase'); + break; + default: + setAskAuth(false); + setError( + `Error saving address from the Ledger hardware device. ${response['error']}` + ); + break; + } + } else { + setAskAuth(false); + setIsComplete(true); + } + }); + } + }); + }; + + return html` +
+
+

Link ${ledger} account to AlgoSigner

+
+ + ${isComplete && + html` +
+

New account ${name} added for ${ledger}.

+

You may now close this site and relaunch AlgoSigner.

+
+ `} + ${isComplete === false && + html` +
+ setName(e.target.value)} + /> + +

+ Insert the hardware device and verify the Algorand application is open. +

+

+ ${error !== undefined && error.length > 0 && error} +

+
+
+ +
+ `} +
+ + ${askAuth && + html` + + `} + `; +}; + +export default LedgerHardwareConnector; diff --git a/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts b/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts new file mode 100644 index 00000000..331b4cf5 --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/LedgerHardwareSign.ts @@ -0,0 +1,211 @@ +import { FunctionalComponent } from 'preact'; +import { html } from 'htm/preact'; +import { useContext, useEffect, useState } from 'preact/hooks'; +import { sendMessage } from 'services/Messaging'; +import { JsonRpcMethod } from '@algosigner/common/messaging/types'; +import { ledgerActions } from './structure/ledgerActions'; +import { getBaseSupportedLedgers } from '@algosigner/common/types/ledgers'; +import TxAcfg from 'components/SignTransaction/TxAcfg'; +import TxPay from 'components/SignTransaction/TxPay'; +import TxKeyreg from 'components/SignTransaction/TxKeyreg'; +import TxAxfer from 'components/SignTransaction/TxAxfer'; +import TxAfrz from 'components/SignTransaction/TxAfrz'; +import TxAppl from 'components/SignTransaction/TxAppl'; +import { logging } from '@algosigner/common/logging'; +import { StoreContext } from 'services/StoreContext'; + +const LedgerHardwareSign: FunctionalComponent = () => { + const store: any = useContext(StoreContext); + const [loading, setLoading] = useState(false); + const [txn, setTxn] = useState({}); + const [isComplete, setIsComplete] = useState(false); + const [error, setError] = useState(''); + const [account, setAccount] = useState(''); + const [txResponseHeader, setTxResponseHeader] = useState(''); + const [txResponseDetail, setTxResponseDetail] = useState(''); + const [ledger, setLedger] = useState(''); + + useEffect(() => { + if (txn.transaction === undefined && error === '') { + try { + sendMessage(JsonRpcMethod.LedgerGetSessionTxn, {}, function (response) { + if (response.error) { + setError(response.error); + } else { + getBaseSupportedLedgers().forEach((l) => { + if (response.transaction.genesisID === l['genesisId']) { + setLedger(l['name']); + + // Update the ledger dropdown to the signing one + sendMessage(JsonRpcMethod.ChangeLedger, { ledger: l['name'] }, function () { + store.setLedger(l['name']); + }); + } + }); + + // Update account value to the signer + setAccount(response.transaction.from); + + // Set the visible transaction + setTxn(response); + } + }); + } catch (ex) { + setError('Error retrieving transaction from AlgoSigner.'); + logging.log(`${JSON.stringify(ex)}`, 2); + } + } + }); + + const ledgerSignTransaction = () => { + setLoading(true); + setError(''); + ledgerActions.signTransaction(txn).then((lar) => { + if (lar.error) { + setError(lar.error); + setLoading(false); + return; + } + const b64Response = Buffer.from(lar.message, 'base64').toString('base64'); + sendMessage(JsonRpcMethod.LedgerSendTxnResponse, { txn: b64Response }, function (response) { + logging.log(`UI: Ledger response: ${JSON.stringify(response)}`); + if (response && 'error' in response) { + setError(response['error']); + } + + if (response && 'txId' in response) { + setTxResponseHeader('Transaction sent:'); + setTxResponseDetail(response['txId']); + setIsComplete(true); + } else { + console.log(`response: ${JSON.stringify(response)}`); + setTxResponseHeader('Transaction signed. Result sent to origin tab.'); + setTxResponseDetail(JSON.stringify(response)); + setIsComplete(true); + } + + setLoading(true); + }); + }); + }; + + return html` +
+
+
+

Sign Using Ledger Device

+
+ ${isComplete && + html` +
+

${txResponseHeader}

+

${txResponseDetail}

+

You may now close this site and relaunch AlgoSigner.

+
+ `} + ${isComplete === false && + html` +
+
+

Insert the hardware device and verify the Algorand application is open before + continuing. Review data on the device for correctness. +

+
+ ${txn && + txn.transaction && + html` +
+ ${txn.transaction.type === 'pay' && + html` + <${TxPay} + tx=${txn.transaction} + vo=${txn.validityObject} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'keyreg' && + html` + <${TxKeyreg} + tx=${txn.transaction} + vo=${txn.validityObject} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'acfg' && + html` + <${TxAcfg} + tx=${txn.transaction} + vo=${txn.validityObject} + dt=${txn.txDerivedTypeText} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'axfer' && + html` + <${TxAxfer} + tx=${txn.transaction} + vo=${txn.validityObject} + dt=${txn.txDerivedTypeText} + fee=${txn.estimatedFee} + da=${txn.displayAmount} + un=${txn.unitName} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'afrz' && + html` + <${TxAfrz} + tx=${txn.transaction} + vo=${txn.validityObject} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} + ${txn.transaction.type === 'appl' && + html` + <${TxAppl} + tx=${txn.transaction} + vo=${txn.validityObject} + fee=${txn.estimatedFee} + account=${account} + ledger=${ledger} + /> + `} +
+ `} +
+ `} +
+ ${isComplete === false && + html` +
+

${error !== undefined && error.length > 0 && error}

+ +
+ `} +
+ `; +}; + +export default LedgerHardwareSign; diff --git a/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts b/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts new file mode 100644 index 00000000..f4bf3a5c --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/structure/ledgerActions.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import transport from './ledgerTransport'; +const algosdk = require('algosdk'); +const Algorand = require('@ledgerhq/hw-app-algorand'); +import { Transaction } from 'algosdk/src/transaction'; +import LedgerActionResponse from './ledgerActionsResponse'; + +let ledgerTransport: typeof Algorand; +const _PATH = "44'/60'/0'/0/0"; + +const getDevice = async () => { + // Check for the presence of an Algorand Ledger transport and return it if one exists + if (ledgerTransport) { + return ledgerTransport; + } + + // Create a transport + // TODO: Expand beyond hid, like bluetooth + const newTransport = await transport.hid.create(); + + // After obtaining the transport use it to create the Algorand Ledger transport + ledgerTransport = new Algorand.default(newTransport); + return ledgerTransport; +}; + +// Check to see if a Ledger device is available at all +const isAvailable = async (): Promise => { + const ledgerDevice = await getDevice().catch(() => { + // If we have an error then it is not available + return false; + }); + + // If we now have a device, return true + if (ledgerDevice !== undefined) { + return true; + } + return false; +}; + +/// +// Takes an unsigned decoded transaction object and converts strings into Uint8Arrays +// for note, appArgs, approval and close programs. Then returns a transactionBuilder encoded value +/// +function cleanseBuildEncodeUnsignedTransaction(transaction: any): any { + const txn = { ...transaction }; + const errors = new Array(); + Object.keys({ ...transaction }).forEach((key) => { + if (txn[key] === undefined || txn[key] === null) { + delete txn[key]; + } + }); + + // Modify base64 encoded fields + if ('note' in txn && txn.note) { + if (JSON.stringify(txn.note) === '{}') { + // If we got here from converting a blank note Uint8 value to an object we should remove it + txn.note = undefined; + } else { + txn.note = new Uint8Array(Buffer.from(txn.note)); + } + } + + // Application transactions only + if (txn.type == 'appl') { + if ('appApprovalProgram' in txn) { + try { + txn.appApprovalProgram = Uint8Array.from(Buffer.from(txn.appApprovalProgram, 'base64')); + } catch { + errors.push('Error trying to parse appApprovalProgram into a Uint8Array value.'); + } + } + if ('appClearProgram' in txn) { + try { + txn.appClearProgram = Uint8Array.from(Buffer.from(txn.appClearProgram, 'base64')); + } catch { + errors.push('Error trying to parse appClearProgram into a Uint8Array value.'); + } + } + if ('appArgs' in txn) { + try { + const tempArgs = new Array(); + txn.appArgs.forEach((element) => { + tempArgs.push(Uint8Array.from(Buffer.from(element, 'base64'))); + }); + txn.appArgs = tempArgs; + } catch { + errors.push('Error trying to parse appArgs into Uint8Array values.'); + } + } + } + + const builtTxn = new Transaction(txn); + + if ('group' in txn && txn['group']) { + // Remap group field lost from cast + builtTxn.group = Buffer.from(txn['group'], 'base64'); + } + + // Encode the transaction and join any errors for return + const encodedTxn = algosdk.encodeUnsignedTransaction(builtTxn); + return { transaction: encodedTxn, error: errors.join() }; +} + +const getAddress = async (): Promise => { + let lar: LedgerActionResponse = {}; + + // If we haven't connected yet, do it now. This will prompt the tab to ask for device. + if (!ledgerTransport) { + ledgerTransport = await getDevice().catch((e) => { + // If this is a known error from Ledger it will contain a message + lar = + e && 'message' in e + ? { error: e.message } + : { error: 'An unknown error has occured in connecting the Ledger device.' }; + }); + } + + // Now attempt to get the default Algorand address + await ledgerTransport + .getAddress(_PATH) + .then((o: any) => { + lar = { message: o.publicKey }; + }) + .catch((e) => { + // If this is a known error from Ledger it will contain a message + lar = + e && 'message' in e + ? { error: e.message } + : { error: 'An unknown error has occured in connecting the Ledger device.' }; + }); + + return lar; +}; + +const signTransaction = async (txn: any): Promise => { + let lar: LedgerActionResponse = {}; + + // If we haven't connected yet, do it now. This will prompt the tab to ask for device. + if (!ledgerTransport) { + ledgerTransport = await getDevice().catch((e) => { + // If this is a known error from Ledger it will contain a message + lar = + e && 'message' in e + ? { error: e.message } + : { error: 'An unknown error has occured in connecting the Ledger device.' }; + }); + } + + // Sign method accesps a message that is "hex" format, need to convert + // and remove any empty fields before the conversion + const txnResponse = cleanseBuildEncodeUnsignedTransaction(txn.transaction); + const message = Buffer.from(txnResponse.transaction).toString('hex'); + + // Send the hex transaction to the Ledger device for signing + await ledgerTransport + .sign(_PATH, message) + .then((o: any) => { + // The device responds with a signature only. We need to build the typical signed transaction + const txResponse = { + sig: o.signature, + txn: algosdk.decodeObj(txnResponse.transaction), + }; + + // Convert to binary for return + lar = { message: new Uint8Array(algosdk.encodeObj(txResponse)) }; + }) + .catch((e) => { + // If this is a known error from Ledger it will contain a message + lar = + e && 'message' in e + ? { error: e.message } + : { error: 'An unknown error has occured in connecting the Ledger device.' }; + }); + + return lar; +}; + +export const ledgerActions = { + isAvailable, + getAddress, + signTransaction, +}; + +export default ledgerActions; diff --git a/packages/ui/src/components/LedgerDevice/structure/ledgerActionsResponse.ts b/packages/ui/src/components/LedgerDevice/structure/ledgerActionsResponse.ts new file mode 100644 index 00000000..d4443524 --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/structure/ledgerActionsResponse.ts @@ -0,0 +1,5 @@ +// Generalized return type for Ledger actions +export default interface LedgerActionResponse { + message?: any; + error?: string; +} diff --git a/packages/ui/src/components/LedgerDevice/structure/ledgerTransport.ts b/packages/ui/src/components/LedgerDevice/structure/ledgerTransport.ts new file mode 100644 index 00000000..4219e69a --- /dev/null +++ b/packages/ui/src/components/LedgerDevice/structure/ledgerTransport.ts @@ -0,0 +1,8 @@ +// Transport connection methods +import TransportWebHID from '@ledgerhq/hw-transport-webhid'; + +const transport = { + hid: TransportWebHID, +}; + +export default transport; diff --git a/packages/ui/src/components/SettingsMenu.ts b/packages/ui/src/components/SettingsMenu.ts index 5c14e7f6..035c5ef7 100644 --- a/packages/ui/src/components/SettingsMenu.ts +++ b/packages/ui/src/components/SettingsMenu.ts @@ -44,7 +44,7 @@ const SettingsMenu: FunctionalComponent = () => { }; return html` -
+