Files
eculocate/ecu-esp32/src/wibble.rs
Daniel Barlow 218b7cdb27 ble: encrypt wifi password
BLE "just works" pairing dos not protect against active MITM,
so we need to handle this ourselves. Use nacl/libsodium
crypto_box to encrypt and authenticate the wifi password

this also adds a step to the interaction between client and device:
after writing the password, we now write a "true" to the "connecting"
characteristic to kick off the actual conection attempt.
2026-02-17 12:37:59 +00:00

272 lines
9.1 KiB
Rust

use embassy_time::{Duration,Timer};
use trouble_host::prelude::*;
use esp_radio::ble::controller::BleConnector;
use esp_radio::Controller as RadioController;
use log::{warn,info};
use embassy_futures::join::join;
use embassy_futures::select::select;
use crypto_box::{
aead::Aead,
SalsaBox, PublicKey, SecretKey, Nonce
};
use alloc::vec::Vec;
use alloc::string::String;
use crate::ota::Flasher;
/// Max number of connections
const CONNECTIONS_MAX: usize = 1;
/// Max number of L2CAP channels.
// XXX why not 0?
const L2CAP_CHANNELS_MAX: usize = 2; // Signal + att
// GATT Server definition
#[gatt_server]
struct Server {
wifi_provisioning_service: WifiProvisioningService,
}
#[gatt_service(uuid = "76be25c2-0000-44fc-a97c-bcb56c69c926")]
struct WifiProvisioningService {
#[characteristic(uuid = "76be25c2-0801-44fc-a97c-bcb56c69c926", read, notify, value = false)]
connecting: bool,
#[characteristic(uuid = "76be25c2-0802-44fc-a97c-bcb56c69c926", read, notify, value = 0)]
max_network_index: u8,
#[characteristic(uuid = "76be25c2-0803-44fc-a97c-bcb56c69c926", read, write, value = 0)]
current_network_index: u8,
// for arrays > 32 we must supply a default value : see
// https://github.com/embassy-rs/trouble/issues/320
#[characteristic(uuid = "76be25c2-0804-44fc-a97c-bcb56c69c926",
value = [0; 40],
read, notify)]
current_network: [u8; 40],
#[characteristic(uuid = "76be25c2-0805-44fc-a97c-bcb56c69c926",
value = [0; (24 + 16 + 64 + 1)],
write)]
secret: [u8; 24 + 16 + 64 + 1]
}
pub async fn run_ble(radio_init: &RadioController<'static>,
peripheral: esp_hal::peripherals::BT<'_>,
networks: Vec<[u8; 40]>,
mut flasher: Flasher<'_>) {
let connector = BleConnector::new(&radio_init, peripheral, Default::default()).expect("connector");
let controller: ExternalController<_, 20> = ExternalController::new(connector);
// Using a fixed "random" address can be useful for testing. In real scenarios, one would
// use e.g. the MAC 6 byte array as the address (how to get that varies by the platform).
let address: Address = Address::random([0xff, 0x8f, 0x1a, 0x05, 0xe4, 0xff]);
info!("Our address = {:?}", address);
let mut resources: HostResources<DefaultPacketPool, CONNECTIONS_MAX, L2CAP_CHANNELS_MAX> = HostResources::new();
let stack = trouble_host::new(controller, &mut resources).set_random_address(address);
let Host {
mut peripheral, runner, ..
} = stack.build();
info!("Starting advertising and GATT service");
let server = Server::new_with_config(GapConfig::Peripheral(PeripheralConfig {
name: "eculocate",
appearance: &appearance::power_device::GENERIC_POWER_DEVICE,
}))
.unwrap();
let _ = join(
ble_task(runner),
async {
loop {
match advertise("Eculocate", &mut peripheral, &server).await {
Ok(conn) => {
// set up tasks when the connection is established to a central, so they don't run when no one is connected.
let a = gatt_events_task(&server, &conn, &networks, &mut flasher);
let b = custom_task(&server, &conn, &stack);
// run until any task ends (usually because the connection has been closed),
// then return to advertising state.
select(a, b).await;
}
Err(e) => {
panic!("[adv] error: {:?}", e);
}
}
}
})
.await;
}
/// This is a background task that is required to run forever alongside any other BLE tasks.
///
async fn ble_task<C: Controller, P: PacketPool>(mut runner: Runner<'_, C, P>) {
loop {
if let Err(e) = runner.run().await {
panic!("[ble_task] error: {:?}", e);
}
}
}
const CLIENT_PUBLIC_KEY_BYTES: &[u8; 32] = include_bytes!("../../keys/client.pub");
const SECRET_KEY_BYTES : &[u8; 32] = include_bytes!("../../keys/device.key");
#[derive(Debug)]
enum DecryptError {
Crypto(crypto_box::aead::Error),
Encoding(alloc::str::Utf8Error)
}
impl From<crypto_box::aead::Error> for DecryptError {
fn from(e: crypto_box::aead::Error) -> Self {
Self::Crypto(e)
}
}
impl From<alloc::str::Utf8Error> for DecryptError {
fn from(e: alloc::str::Utf8Error) -> Self { Self::Encoding(e) }
}
fn decrypt_secret(ciphertext : &[u8]) -> Result<String,DecryptError> {
let client_public_key = PublicKey::from(*CLIENT_PUBLIC_KEY_BYTES);
let secret_key= SecretKey::from(*SECRET_KEY_BYTES);
let len : usize = ciphertext[0].into();
let ciphertext = &ciphertext[1..len+1];
let (nonce, encrypted) = ciphertext.split_at(24);
let nonce = Nonce::from_slice(nonce);
let sbox = SalsaBox::new(&client_public_key, &secret_key);
let plain_bytes = sbox.decrypt(nonce, encrypted)?;
Ok(String::from(core::str::from_utf8(&plain_bytes[..])?))
}
/// Stream Events until the connection closes.
///
/// This function will handle the GATT events and process them.
/// This is how we interact with read and write requests.
async fn gatt_events_task<P: PacketPool>(server: &Server<'_>,
conn: &GattConnection<'_, '_, P>,
networks: &Vec<[u8; 40]>,
flasher: &mut Flasher<'_>) -> Result<(), Error> {
let wps = &server.wifi_provisioning_service;
let max_index : u8 = (networks.len() & 0xff).try_into().unwrap();
server.set(&wps.max_network_index, &max_index);
let current_index = wps.current_network_index;
let reason = loop {
match conn.next().await {
GattConnectionEvent::Disconnected { reason } => break reason,
GattConnectionEvent::Gatt { event } => {
match &event {
GattEvent::Write(event) => {
info!("[gatt] write handle {:?} {:?}", event.handle(),
event.data());
if event.handle() == current_index.handle {
let i = (event.data()[0]) as usize;
info!("[gatt] switch current index: {:?}", i);
if i == 0 {
server.set(&wps.current_network, &[0u8;40]);
} else if i <= networks.len() {
server.set(&wps.current_network,
&(networks[i - 1]));
} else {
warn!("index {:?} out of range", i);
}
} else if event.handle() == wps.connecting.handle {
let network = server.get(&wps.current_network).unwrap();
let password = decrypt_secret(&server.get(&wps.secret).unwrap());
match password {
Ok(plaintext) => {
write_wifi_config(
flasher, &network[8..], &plaintext[..]
).await;
},
Err(e) => {
warn!("decdrpt error: {:?}", e);
}
}
}
},
_ => {}
};
// This step is also performed at drop(), but writing
// it explicitly is necessary in order to ensure reply
// is sent.
match event.accept() {
Ok(reply) => reply.send().await,
Err(e) => warn!("[gatt] error sending response: {:?}", e),
};
}
_ => {} // ignore other Gatt Connection Events
}
};
info!("[gatt] disconnected: {:?}", reason);
Ok(())
}
async fn write_wifi_config(flasher: &mut Flasher<'_>, ssid: &[u8], password: &str) -> Result<(),esp_nvs::error::Error> {
let n_s = core::str::from_utf8(&ssid).unwrap();
let n0 =
if let Some(i) = n_s.find("\0") { i } else { n_s.len() };
flasher.set_var("ssid", Some(&n_s[..n0]))?;
flasher.set_var("secret", Some(password))?;
info!("[gatt] deep breath then reboot");
Timer::after(Duration::from_millis(2000)).await;
esp_hal::system::software_reset();
}
/// Create an advertiser to use to connect to a BLE Central, and wait for it to connect.
async fn advertise<'values, 'server, C: Controller>(
name: &'values str,
peripheral: &mut Peripheral<'values, C, DefaultPacketPool>,
server: &'server Server<'values>,
) -> Result<GattConnection<'values, 'server, DefaultPacketPool>, BleHostError<C::Error>> {
let mut advertiser_data = [0; 31];
let len = AdStructure::encode_slice(
&[
AdStructure::Flags(LE_GENERAL_DISCOVERABLE | BR_EDR_NOT_SUPPORTED),
AdStructure::ServiceUuids16(&[[0x0f, 0x18]]),
AdStructure::CompleteLocalName(name.as_bytes()),
],
&mut advertiser_data[..],
)?;
let advertiser = peripheral
.advertise(
&Default::default(),
Advertisement::ConnectableScannableUndirected {
adv_data: &advertiser_data[..len],
scan_data: &[],
},
)
.await?;
info!("[adv] advertising");
let conn = advertiser.accept().await?.with_attribute_server(server)?;
info!("[adv] connection established");
Ok(conn)
}
async fn custom_task<C: Controller, P: PacketPool>(
_server: &Server<'_>,
conn: &GattConnection<'_, '_, P>,
stack: &Stack<'_, C, P>,
) {
loop {
// read RSSI (Received Signal Strength Indicator) of the connection.
if let Ok(rssi) = conn.raw().rssi(stack).await {
info!("[custom_task] RSSI: {:?}", rssi);
} else {
info!("[custom_task] error getting RSSI");
break;
};
Timer::after_secs(2).await;
}
}