From bff749737688b5e98ccb5e792af3aee70eeab6f0 Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:23:53 -0500 Subject: [PATCH] Bluetooth: log in to a REPL with a password before sending anything A password-protected Bluetooth REPL takes the first line it's sent as the password, so the probe Workbench sent on connect counted as a wrong one and the board answered Access denied on every reconnect. Connecting now asks with an empty line, and if the board answers Password:, prompts for it and sends it first. Fixes #22 --- src/app.js | 10 ++ src/transports/bluetooth.js | 46 +++++++++ test/suites/bluetooth_login.js | 167 +++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 test/suites/bluetooth_login.js diff --git a/src/app.js b/src/app.js index 3a7a01f9..673d3d00 100644 --- a/src/app.js +++ b/src/app.js @@ -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; @@ -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') diff --git a/src/transports/bluetooth.js b/src/transports/bluetooth.js index 5ad42607..b436e7f6 100644 --- a/src/transports/bluetooth.js +++ b/src/transports/bluetooth.js @@ -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, @@ -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() + } } /* diff --git a/test/suites/bluetooth_login.js b/test/suites/bluetooth_login.js new file mode 100644 index 00000000..6510a9aa --- /dev/null +++ b/test/suites/bluetooth_login.js @@ -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() + }) +})