Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ async function onPromptSettled() {

let defaultWsURL = 'ws://192.168.1.123:8266'
let defaultWsPass = ''
let defaultBlePass = ''

async function prepareNewPort(type) {
let new_port;
Expand Down Expand Up @@ -446,6 +447,15 @@ async function prepareNewPort(type) {
return
}
new_port = new WebBluetooth()
new_port.onPasswordRequest(async () => {
/* As for WebREPL: a board that dropped the link and came back asks
again, and it says if the remembered password is wrong */
if (deviceState === 'reconnecting' && defaultBlePass) { return defaultBlePass }
const pass = prompt('Bluetooth REPL password:', defaultBlePass)
if (pass == null) { return }
defaultBlePass = pass
return pass
})
} else if (type === 'usb') {
if (iOS) {
toastr.error('WebSerial is not available on iOS')
Expand Down
46 changes: 46 additions & 0 deletions src/transports/bluetooth.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ const CH9143_RX = '0000fff1-0000-1000-8000-00805f9b34fb' // Notify
const CH9143_CTRL = '0000fff3-0000-1000-8000-00805f9b34fb' // Read / Write
const CH9143_TX_LIMIT = 20

/* How long a board gets to answer the empty line that opens a session */
const LOGIN_ASK_MS = 1500

function isFastNusDevice(deviceInfo) {
const id = [
deviceInfo?.machine,
Expand Down Expand Up @@ -190,6 +193,49 @@ export class WebBluetooth extends Transport {

await this.rx.startNotifications()
processStream()

if (this.service.uuid === NUS_SERVICE) {
await this._logIn()
}
}

onPasswordRequest(callback) {
this._passReqCallback = callback
}

/*
* A board serving its REPL with a password (bledev.repl, like WebREPL) takes
* the first line it is sent as the password. So before anything else is sent,
* ask with an empty line: such a board answers "Password:", and any other REPL
* just prints a prompt. A board that is pairing says nothing yet; it shows its
* prompt once the pairing settles.
*/
async _logIn() {
const release = await this.startTransaction()
try {
await this.write('\r')
/* A hard deadline, unlike readUntil's: a running program that keeps
printing must not hold the connection up */
const deadline = Date.now() + LOGIN_ASK_MS
while (!/Password:|>>> /.test(this.receivedData) && Date.now() < deadline) {
await sleep(20)
}
if (!this.receivedData.includes('Password:')) {
return // a REPL without a password, a busy board, or one still pairing
}
this.receivedData = ''
const pass = this._passReqCallback ? await this._passReqCallback() : null
if (!pass) {
throw new Error('Password is required')
}
await this.write(pass + '\r')
const rsp = await this.readUntil(['>>> ', 'Access denied'], 5000)
if (rsp.includes('Access denied')) {
throw new Error('Invalid password')
}
} finally {
release()
}
}

/*
Expand Down
167 changes: 167 additions & 0 deletions test/suites/bluetooth_login.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* SPDX-License-Identifier: MIT
*
* Logging in over Bluetooth: the WebBluetooth transport against a fake board that
* keeps the rules a password-protected Bluetooth REPL keeps (bledev.repl, like
* WebREPL). The first line the board is sent is taken as the password, so
* nothing but an empty line may go before it: a board that sees anything else
* answers "Access denied" and hangs up.
*
* Web Bluetooth is faked at the GATT level, so nothing here touches a board.
*/

import { assert } from 'chai'
import { WebBluetooth } from '../../src/transports/bluetooth.js'

const NUS_SERVICE = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
const NUS_WRITE = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'
const NUS_NOTIFY = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'

const PROMPT = 'Password: '
const BANNER = '\r\nbledev REPL connected\r\n>>> '
const DENIED = '\r\nAccess denied\r\n'

/*
* kind 'password': bledev.repl with a password. 'plain': a REPL with no lock,
* answering every line with a prompt. 'silent': a board that answers nothing,
* as one still pairing does.
*/
class FakeBoard {
constructor(kind, password = 'hunter22') {
this.kind = kind
this.password = password
this.writes = []
this.line = ''
this.authed = kind === 'plain'
this.refused = false
this.connected = false
this.repl = ''
this.listener = null
const board = this
this.notifyChar = {
uuid: NUS_NOTIFY,
addEventListener(_name, fn) { board.listener = fn },
removeEventListener() { board.listener = null },
async startNotifications() {},
}
this.writeChar = {
uuid: NUS_WRITE,
async writeValue(data) { board.feed(new TextDecoder().decode(data)) },
}
this.device = {
name: 'mpy-repl',
addEventListener() {},
removeEventListener() {},
gatt: {
connected: false,
connect: async () => {
this.connected = true
this.device.gatt.connected = true
return {
getPrimaryServices: async () => [{
uuid: NUS_SERVICE,
getCharacteristic: async (uuid) =>
uuid === NUS_NOTIFY ? this.notifyChar : this.writeChar,
}],
}
},
disconnect: async () => {
this.connected = false
this.device.gatt.connected = false
},
},
}
}

say(text) {
const value = new TextEncoder().encode(text)
setTimeout(() => { if (this.listener) this.listener({ target: { value } }) }, 5)
}

feed(text) {
this.writes.push(text)
if (this.kind === 'silent' || this.refused || !this.connected) { return }
if (this.authed) {
this.repl += text
if (text.includes('\r')) { this.say('\r\n>>> ') }
return
}
for (let i = 0; i < text.length; i++) {
const c = text[i]
if (c === '\r' || c === '\n') {
if (!this.line) { this.say(PROMPT); continue }
if (this.line === this.password) {
this.authed = true
this.repl += text.slice(i + 1)
this.say(BANNER)
} else {
this.refused = true
this.say(DENIED)
}
this.line = ''
return
}
this.line += c
}
}
}

async function transportFor(board, password) {
Object.defineProperty(globalThis.navigator, 'bluetooth', {
value: { requestDevice: async () => board.device },
configurable: true,
})
const port = new WebBluetooth()
let asked = 0
port.onPasswordRequest(async () => { asked++; return password })
await port.requestAccess()
return { port, asked: () => asked }
}

describe('Bluetooth / logging in', function () {
after(function () {
delete globalThis.navigator.bluetooth
})

it('asks for the password before sending anything else', async function () {
const board = new FakeBoard('password')
const { port, asked } = await transportFor(board, 'hunter22')
await port.connect()
assert.equal(asked(), 1, 'the password was asked for once')
assert.isTrue(board.authed, 'the board let us in')
assert.deepEqual(board.writes, ['\r', 'hunter22\r'],
'only an empty line went before the password')
await port.write(' \r')
assert.equal(board.repl, ' \r', 'what follows reaches the REPL')
await port.disconnect()
})

it('reports a wrong password', async function () {
const board = new FakeBoard('password')
const { port } = await transportFor(board, 'hunter2')
let error = null
try { await port.connect() } catch (err) { error = err }
assert.match(String(error), /Invalid password/)
assert.isFalse(board.authed)
await port.disconnect()
})

it('does not ask a board with no password', async function () {
const board = new FakeBoard('plain')
const { port, asked } = await transportFor(board, 'hunter22')
await port.connect()
assert.equal(asked(), 0)
assert.deepEqual(board.writes, ['\r'])
await port.disconnect()
})

it('does not hold up a board that says nothing', async function () {
const board = new FakeBoard('silent')
const { port, asked } = await transportFor(board, 'hunter22')
const started = Date.now()
await port.connect()
assert.isBelow(Date.now() - started, 3000, 'connect() gave up waiting')
assert.equal(asked(), 0)
await port.disconnect()
})
})
Loading