lnbits-legend/lnbits/extensions/watchonly/static/components/serial-signer/serial-signer.js

968 lines
29 KiB
JavaScript

async function serialSigner(path) {
const t = await loadTemplateAsync(path)
Vue.component('serial-signer', {
name: 'serial-signer',
template: t,
props: ['sats-denominated', 'network'],
data: function () {
return {
selectedPort: null,
writableStreamClosed: null,
writer: null,
readableStreamClosed: null,
reader: null,
receivedData: '',
config: {},
decryptionKey: null,
sharedSecret: null, // todo: store in secure local storage
hww: {
password: null,
showPassword: false,
mnemonic: null,
showMnemonic: false,
passphrase: null,
showPassphrase: false,
hasPassphrase: false,
authenticated: false,
showPasswordDialog: false,
showConfigDialog: false,
showWipeDialog: false,
showRestoreDialog: false,
showConfirmationDialog: false,
showSignedPsbt: false,
sendingPsbt: false,
signingPsbt: false,
loginResolve: null,
psbtSentResolve: null,
xpubResolve: null,
seedWordPosition: 1,
seedWord: null,
showSeedWord: false,
showSeedDialog: false,
// config: null,
confirm: {
outputIndex: 0,
showFee: false
}
},
tx: null, // todo: move to hww
showConsole: false
}
},
computed: {
pairedDevices: {
get: function () {
return (
JSON.parse(window.localStorage.getItem('lnbits-paired-devices')) ||
[]
)
},
set: function (devices) {
window.localStorage.setItem(
'lnbits-paired-devices',
JSON.stringify(devices)
)
}
}
},
methods: {
satBtc(val, showUnit = true) {
return satOrBtc(val, showUnit, this.satsDenominated)
},
openSerialPortDialog: async function () {
this.config = {...HWW_DEFAULT_CONFIG}
await this.openSerialPort(this.config)
},
openSerialPort: async function (config = {baudRate: 9600}) {
if (!this.checkSerialPortSupported()) return false
if (this.selectedPort) {
this.$q.notify({
type: 'warning',
message: 'Already connected. Disconnect first!',
timeout: 10000
})
return true
}
try {
this.selectedPort = await navigator.serial.requestPort()
this.selectedPort.addEventListener('connect', event => {
// do nothing
})
this.selectedPort.addEventListener('disconnect', () => {
this.selectedPort = null
this.hww.authenticated = false
this.$q.notify({
type: 'warning',
message: 'Disconnected from Serial Port!',
timeout: 10000
})
})
// Wait for the serial port to open.
await this.selectedPort.open(config)
this.startSerialPortReading()
const textEncoder = new TextEncoderStream()
this.writableStreamClosed = textEncoder.readable.pipeTo(
this.selectedPort.writable
)
this.writer = textEncoder.writable.getWriter()
await this.hwwPing()
return true
} catch (error) {
this.selectedPort = null
this.$q.notify({
type: 'warning',
message: 'Cannot open serial port!',
caption: `${error}`,
timeout: 10000
})
return false
}
},
openSerialPortConfig: async function (deviceId) {
const device = this.getPairedDevice(deviceId)
if (device) {
this.config = device.config
} else {
this.config = {...HWW_DEFAULT_CONFIG}
}
this.hww.showConfigDialog = true
},
closeSerialPort: async function () {
try {
if (this.writer) this.writer.close()
if (this.writableStreamClosed) await this.writableStreamClosed
if (this.reader) this.reader.cancel()
if (this.readableStreamClosed)
await this.readableStreamClosed.catch(() => {
/* Ignore the error */
})
if (this.selectedPort) await this.selectedPort.close()
this.$q.notify({
type: 'positive',
message: 'Serial port disconnected!',
timeout: 5000
})
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Cannot close serial port!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.selectedPort = null
this.hww.authenticated = false
}
},
isConnected: function () {
return !!this.selectedPort
},
isAuthenticated: function () {
return this.hww.authenticated
},
isAuthenticating: function () {
if (this.isAuthenticated()) return false
return new Promise(resolve => {
this.loginResolve = resolve
})
},
isSendingPsbt: async function () {
if (!this.hww.sendingPsbt) return false
return new Promise(resolve => {
this.psbtSentResolve = resolve
})
},
isFetchingXpub: async function () {
return new Promise(resolve => {
this.xpubResolve = resolve
})
},
checkSerialPortSupported: function () {
if (!navigator.serial) {
this.$q.notify({
type: 'warning',
message: 'Serial port communication not supported!',
caption:
'Make sure your browser supports Serial Port and that you are using HTTPS.',
timeout: 10000
})
return false
}
return true
},
startSerialPortReading: async function () {
const port = this.selectedPort
while (port && port.readable) {
const textDecoder = new TextDecoderStream()
this.readableStreamClosed = port.readable.pipeTo(textDecoder.writable)
this.reader = textDecoder.readable.getReader()
const readStringUntil = readFromSerialPort(this.reader)
try {
while (true) {
const {value, done} = await readStringUntil('\n')
if (value) {
this.handleSerialPortResponse(value)
this.updateSerialPortConsole(value)
}
if (done) return
}
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Serial port communication error!',
caption: `${error}`,
timeout: 10000
})
}
}
},
handleSerialPortResponse: async function (value) {
const {command, commandData} = await this.extractCommand(value)
this.logPublicCommandsResponse(command, commandData)
switch (command) {
case COMMAND_PING:
this.handlePingResponse(commandData)
break
case COMMAND_CHECK_PAIRING:
this.handleCheckPairingResponse(commandData)
break
case COMMAND_SIGN_PSBT:
this.handleSignResponse(commandData)
break
case COMMAND_PASSWORD:
this.handleLoginResponse(commandData)
break
case COMMAND_PASSWORD_CLEAR:
this.handleLogoutResponse(commandData)
break
case COMMAND_SEND_PSBT:
this.handleSendPsbtResponse(commandData)
break
case COMMAND_WIPE:
this.handleWipeResponse(commandData)
break
case COMMAND_XPUB:
this.handleXpubResponse(commandData)
break
case COMMAND_SEED:
this.handleShowSeedResponse(commandData)
break
case COMMAND_PAIR:
this.handlePairResponse(commandData)
break
case COMMAND_LOG:
console.log(
` %c${commandData}`,
'background: #222; color: #bada55'
)
break
default:
console.log(` %c${value}`, 'background: #222; color: red')
}
},
logPublicCommandsResponse: function (command, commandData) {
switch (command) {
case COMMAND_SIGN_PSBT:
case COMMAND_PASSWORD:
case COMMAND_PASSWORD_CLEAR:
case COMMAND_SEND_PSBT:
case COMMAND_WIPE:
case COMMAND_XPUB:
case COMMAND_PAIR:
console.log(
` %c${command} ${commandData}`,
'background: #222; color: yellow'
)
}
},
updateSerialPortConsole: function (value) {
this.receivedData += value + '\n'
const textArea = document.getElementById('serial-port-console')
if (textArea) textArea.scrollTop = textArea.scrollHeight
},
hwwPing: async function () {
try {
await this.sendCommandClearText(COMMAND_PING, [window.location.host])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to ping Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
handlePingResponse: function (res = '') {
const [status, deviceId] = res.split(' ')
this.deviceId = deviceId
if (!this.deviceId) {
this.$q.notify({
type: 'warning',
message: 'Missing device ID for Hardware Wallet',
timeout: 10000
})
return
}
const device = this.getPairedDevice(deviceId)
if (device) {
this.sharedSecret = nobleSecp256k1.utils.hexToBytes(
device.sharedSecretHex
)
this.hwwCheckPairing()
} else {
this.hwwPair()
}
},
hwwShowPasswordDialog: async function () {
try {
this.hww.showPasswordDialog = true
await this.sendCommandSecure(COMMAND_PASSWORD)
} catch (error) {
console.log(error)
this.$q.notify({
type: 'warning',
message: 'Failed to connect to Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwShowWipeDialog: async function () {
try {
this.hww.showWipeDialog = true
await this.sendCommandSecure(COMMAND_WIPE)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to connect to Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwShowRestoreDialog: async function () {
try {
this.hww.showRestoreDialog = true
await this.sendCommandSecure(COMMAND_RESTORE)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to connect to Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
closeSeedDialog: function () {
this.hww.seedWord = null
this.hww.showSeedWord = false
},
hwwConfirmNext: async function () {
this.hww.confirm.outputIndex += 1
if (this.hww.confirm.outputIndex >= this.tx.outputs.length) {
this.hww.confirm.showFee = true
}
await this.sendCommandSecure(COMMAND_CONFIRM_NEXT)
},
cancelOperation: async function () {
try {
await this.sendCommandSecure(COMMAND_CANCEL)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to send cancel!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwConfigAndConnect: async function () {
this.hww.showConfigDialog = false
if (this.config.deviceId) {
this.updatePairedDeviceConfig(this.config.deviceId, this.config)
}
await this.openSerialPort(this.config)
return true
},
hwwLogin: async function () {
try {
await this.sendCommandSecure(COMMAND_PASSWORD, [
this.hww.password,
this.hww.passphrase
])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to send password to Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.hww.showPasswordDialog = false
this.hww.password = null
this.hww.passphrase = null
this.hww.showPassword = false
this.hww.showPassphrase = false
}
},
handleLoginResponse: function (res = '') {
this.hww.authenticated = res.trim() === '1'
if (this.loginResolve) {
this.loginResolve(this.hww.authenticated)
}
if (this.hww.authenticated) {
this.$q.notify({
type: 'positive',
message: 'Login successfull!',
timeout: 10000
})
} else {
this.$q.notify({
type: 'warning',
message: 'Wrong password, try again!',
timeout: 10000
})
}
},
hwwLogout: async function () {
try {
await this.sendCommandSecure(COMMAND_PASSWORD_CLEAR)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to logout from Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwShowAddress: async function (path, address) {
try {
await this.sendCommandSecure(COMMAND_ADDRESS, [
this.network,
path,
address
])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to logout from Hardware Wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
handleLogoutResponse: function (res = '') {
const authenticated = !(res.trim() === '1')
if (this.hww.authenticated && !authenticated) {
this.$q.notify({
type: 'positive',
message: 'Logged Out',
timeout: 10000
})
}
this.hww.authenticated = authenticated
},
hwwSendPsbt: async function (psbtBase64, tx) {
try {
this.tx = tx
this.hww.sendingPsbt = true
await this.sendCommandSecure(COMMAND_SEND_PSBT, [
this.network,
psbtBase64
])
this.$q.notify({
type: 'positive',
message: 'Data sent to serial port device!',
timeout: 5000
})
} catch (error) {
this.hww.sendingPsbt = false
this.$q.notify({
type: 'warning',
message: 'Failed to send data to serial port!',
caption: `${error}`,
timeout: 10000
})
}
},
handleSendPsbtResponse: function (res = '') {
try {
const psbtOK = res.trim() === '1'
if (!psbtOK) {
this.$q.notify({
type: 'warning',
message: 'Failed to send PSBT!',
caption: `${res}`,
timeout: 10000
})
return
}
this.hww.confirm.outputIndex = 0
this.hww.showConfirmationDialog = true
this.hww.confirm = {
outputIndex: 0,
showFee: false
}
this.hww.sendingPsbt = false
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to send PSBT!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.psbtSentResolve()
}
},
hwwSignPsbt: async function () {
try {
this.hww.showConfirmationDialog = false
this.hww.signingPsbt = true
await this.sendCommandSecure(COMMAND_SIGN_PSBT)
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to sign PSBT!',
caption: `${error}`,
timeout: 10000
})
}
},
handleSignResponse: function (res = '') {
this.hww.signingPsbt = false
const [count, psbt] = res.trim().split(' ')
if (!psbt || !count || count.trim() === '0') {
this.$q.notify({
type: 'warning',
message: 'No input signed!',
caption: 'Are you using the right seed?',
timeout: 10000
})
return
}
this.updateSignedPsbt(psbt)
this.$q.notify({
type: 'positive',
message: 'Transaction Signed',
message: `Inputs signed: ${count}`,
timeout: 10000
})
},
hwwCheckPairing: async function () {
const iv = window.crypto.getRandomValues(new Uint8Array(16))
const encrypted = await this.encryptMessage(
this.sharedSecret,
iv,
PAIRING_CONTROL_TEXT.length + ' ' + PAIRING_CONTROL_TEXT
)
const encryptedHex = nobleSecp256k1.utils.bytesToHex(encrypted)
const encryptedIvHex = nobleSecp256k1.utils.bytesToHex(iv)
try {
await this.sendCommandClearText(COMMAND_CHECK_PAIRING, [
encryptedHex + encryptedIvHex
])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to check secure connection!',
caption: `${error}`,
timeout: 10000
})
}
},
handleCheckPairingResponse: async function (res = '') {
const [statusCode, encryptedMessage] = res.split(' ')
switch (statusCode) {
case '0':
const controlText = await this.decryptData(encryptedMessage)
if (controlText == PAIRING_CONTROL_TEXT) {
this.$q.notify({
type: 'positive',
message: 'Re-paired with success!',
timeout: 10000
})
} else {
this.$q.notify({
type: 'warning',
message: 'Re-pairing failed!',
caption: 'Remove (forget) device and try again!',
timeout: 10000
})
}
break
default:
// noting to do here yet
break
}
},
hwwPair: async function () {
try {
this.decryptionKey = nobleSecp256k1.utils.randomPrivateKey()
const publicKey = nobleSecp256k1.Point.fromPrivateKey(
this.decryptionKey
)
const publicKeyHex = publicKey.toHex().slice(2)
const args = [publicKeyHex]
if (Number.isInteger(+this.config.buttonOnePin)) {
args.push(this.config.buttonOnePin)
}
if (Number.isInteger(+this.config.buttonTwoPin)) {
args.push(this.config.buttonTwoPin)
}
await this.sendCommandClearText(COMMAND_PAIR, args)
this.$q.notify({
type: 'positive',
message: 'Pairing started!',
timeout: 5000
})
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to pair with device!',
caption: `${error}`,
timeout: 10000
})
}
},
handlePairResponse: async function (res = '') {
const [statusCode, data] = res.trim().split(' ')
let pubKeyHex, errorMessage, captionMessage
switch (statusCode) {
case '0':
pubKeyHex = data
if (!data) errorMessage = 'Failed to exchange DH secret!'
break
case '1':
errorMessage =
'Device pairing only possible in the first 10 seconds after start-up!'
captionMessage = 'Restart and try again'
break
default:
errorMessage = 'Unexpected error code'
break
}
if (errorMessage) {
this.$q.notify({
type: 'warning',
message: errorMessage,
caption: captionMessage || '',
timeout: 10000
})
this.closeSerialPort()
return
}
const hwwPublicKey = nobleSecp256k1.Point.fromHex('04' + pubKeyHex)
this.sharedSecret = nobleSecp256k1
.getSharedSecret(this.decryptionKey, hwwPublicKey)
.slice(1, 33)
const sharedSecretHex = nobleSecp256k1.utils.bytesToHex(
this.sharedSecret
)
const sharedSecredHash = await nobleSecp256k1.utils.sha256(
asciiToUint8Array(sharedSecretHex)
)
const fingerprint = nobleSecp256k1.utils
.bytesToHex(sharedSecredHash)
.substring(0, 5)
.toUpperCase()
LNbits.utils
.confirmDialog('Confirm code from display: ' + fingerprint)
.onOk(() => {
this.addPairedDevice(
this.deviceId,
nobleSecp256k1.utils.bytesToHex(this.sharedSecret),
this.config
)
this.$q.notify({
type: 'positive',
message: 'Paired with device!',
timeout: 5000
})
})
.onCancel(() => {
this.closeSerialPort()
})
},
hwwHelp: async function () {
try {
await this.sendCommandSecure(COMMAND_HELP)
this.$q.notify({
type: 'positive',
message: 'Check display or console for details!',
timeout: 5000
})
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to ask for help!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwWipe: async function () {
try {
this.hww.showWipeDialog = false
await this.sendCommandSecure(COMMAND_WIPE, [this.hww.password])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to ask for help!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.hww.password = null
this.hww.confirmedPassword = null
this.hww.showPassword = false
}
},
handleWipeResponse: function (res = '') {
const wiped = res.trim() === '1'
if (wiped) {
this.$q.notify({
type: 'positive',
message: 'Wallet wiped!',
timeout: 10000
})
} else {
this.$q.notify({
type: 'warning',
message: 'Failed to wipe wallet!',
caption: `${error}`,
timeout: 10000
})
}
},
hwwXpub: async function (path) {
try {
await this.sendCommandSecure(COMMAND_XPUB, [this.network, path])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to fetch XPub!',
caption: `${error}`,
timeout: 10000
})
}
},
handleXpubResponse: function (res = '') {
const args = res.trim().split(' ')
if (args.length < 3 || args[0].trim() !== '1') {
this.$q.notify({
type: 'warning',
message: 'Failed to fetch XPub!',
caption: `${res}`,
timeout: 10000
})
this.xpubResolve({})
return
}
const xpub = args[1].trim()
const fingerprint = args[2].trim()
this.xpubResolve({xpub, fingerprint})
},
hwwShowSeed: async function () {
try {
this.hww.showSeedDialog = true
this.hww.seedWordPosition = 1
await this.sendCommandSecure(COMMAND_SEED, [
this.hww.seedWordPosition
])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to show seed!',
caption: `${error}`,
timeout: 10000
})
}
},
showNextSeedWord: async function () {
this.hww.seedWordPosition++
await this.sendCommandSecure(COMMAND_SEED, [this.hww.seedWordPosition])
},
showPrevSeedWord: async function () {
this.hww.seedWordPosition = Math.max(1, this.hww.seedWordPosition - 1)
await this.sendCommandSecure(COMMAND_SEED, [this.hww.seedWordPosition])
},
handleShowSeedResponse: function (res = '') {
const [pos, word] = res.trim().split(' ')
this.hww.seedWord = `${pos}. ${word}`
},
hwwRestore: async function () {
try {
await this.sendCommandSecure(COMMAND_RESTORE, [
this.hww.password,
this.hww.mnemonic
])
} catch (error) {
this.$q.notify({
type: 'warning',
message: 'Failed to restore from seed!',
caption: `${error}`,
timeout: 10000
})
} finally {
this.hww.showRestoreDialog = false
this.hww.mnemonic = null
this.hww.showMnemonic = false
this.hww.password = null
this.hww.confirmedPassword = null
this.hww.showPassword = false
}
},
updateSignedPsbt: async function (value) {
this.$emit('signed:psbt', value)
},
sendCommandSecure: async function (command, attrs = []) {
const message = [command].concat(attrs).join(' ')
const iv = window.crypto.getRandomValues(new Uint8Array(16))
const encrypted = await this.encryptMessage(
this.sharedSecret,
iv,
message.length + ' ' + message
)
const encryptedHex = nobleSecp256k1.utils.bytesToHex(encrypted)
const encryptedIvHex = nobleSecp256k1.utils.bytesToHex(iv)
await this.writer.write(encryptedHex + encryptedIvHex + '\n')
},
sendCommandClearText: async function (command, attrs = []) {
const message = [command].concat(attrs).join(' ')
await this.writer.write(message + '\n')
},
extractCommand: async function (value) {
const command = value.split(' ')[0]
const commandData = value.substring(command.length).trim()
if (
command === COMMAND_PAIR ||
command === COMMAND_LOG ||
command === COMMAND_PASSWORD_CLEAR ||
command === COMMAND_PING ||
command === COMMAND_CHECK_PAIRING
)
return {command, commandData}
const decryptedValue = await this.decryptData(value)
const decryptedCommand = decryptedValue.split(' ')[0]
const decryptedCommandData = decryptedValue
.substring(decryptedCommand.length)
.trim()
return {
command: decryptedCommand,
commandData: decryptedCommandData
}
},
decryptData: async function (value) {
if (!this.sharedSecret) {
return '/error Secure session not established!'
}
try {
const ivSize = 32
const messageHex = value.substring(0, value.length - ivSize)
const ivHex = value.substring(value.length - ivSize)
const messageBytes = nobleSecp256k1.utils.hexToBytes(messageHex)
const iv = nobleSecp256k1.utils.hexToBytes(ivHex)
const decrypted1 = await this.decryptMessage(
this.sharedSecret,
iv,
messageBytes
)
const data = new TextDecoder().decode(decrypted1)
const [len] = data.split(' ')
const command = data
.substring(len.length + 1, +len + len.length + 1)
.trim()
return command
} catch (error) {
return '/error Failed to decrypt message from device!'
}
},
encryptMessage: async function (key, iv, message) {
while (message.length % 16 !== 0) message += ' '
const encodedMessage = asciiToUint8Array(message)
const aesCbc = new aesjs.ModeOfOperation.cbc(key, iv)
const encryptedBytes = aesCbc.encrypt(encodedMessage)
return encryptedBytes
},
decryptMessage: async function (key, iv, encryptedBytes) {
const aesCbc = new aesjs.ModeOfOperation.cbc(key, iv)
const decryptedBytes = aesCbc.decrypt(encryptedBytes)
return decryptedBytes
},
getPairedDevice: function (deviceId) {
return this.pairedDevices.find(d => d.id === deviceId)
},
removePairedDevice: function (deviceId) {
const devices = this.pairedDevices
const deviceIndex = devices.findIndex(d => d.id === deviceId)
if (deviceIndex !== -1) {
devices.splice(deviceIndex, 1)
}
this.pairedDevices = devices
},
addPairedDevice: function (deviceId, sharedSecretHex, config) {
const devices = this.pairedDevices
config.deviceId = deviceId
devices.unshift({
id: deviceId,
sharedSecretHex: sharedSecretHex,
pairingDate: new Date().toISOString(),
config
})
this.pairedDevices = devices
},
updatePairedDeviceConfig(deviceId, config) {
const device = this.getPairedDevice(deviceId)
if (device) {
this.removePairedDevice(deviceId)
this.addPairedDevice(deviceId, device.sharedSecretHex, config)
}
}
},
created: async function () {}
})
}