use std::{
collections::{HashMap, HashSet},
ffi::CString,
sync::Mutex,
thread,
time::Duration,
};
use crate::profiles::CommandSet;
pub trait DeviceIo: Send + Sync + 'static {
fn send(&self, path: &str, message: &[u8], checksum_type: i32) -> Result<(), String>;
fn read(&self, path: &str) -> Result<Vec<u8>, String>;
fn read_event(&self, timeout_ms: i32) -> Result<Option<Vec<u8>>, String>;
fn clean(&self, path: &str) -> Result<(), String>;
}
pub struct DeniedDeviceIo;
impl DeviceIo for DeniedDeviceIo {
fn send(&self, _path: &str, _message: &[u8], _checksum_type: i32) -> Result<(), String> {
Err("hardware transport is disabled".into())
}
fn read(&self, _path: &str) -> Result<Vec<u8>, String> {
Err("hardware transport is disabled".into())
}
fn read_event(&self, _timeout_ms: i32) -> Result<Option<Vec<u8>>, String> {
Ok(None)
}
fn clean(&self, _path: &str) -> Result<(), String> {
Ok(())
}
}
pub struct ScopedHidTransport {
command_sets: HashMap<String, CommandSet>,
policy: AccessPolicy,
state: Mutex<HidState>,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum AccessPolicy {
#[default]
Scoped,
Settings,
}
struct HidState {
api: hidapi::HidApi,
devices: HashMap<String, hidapi::HidDevice>,
pending_reads: HashSet<String>,
}
impl ScopedHidTransport {
pub fn new(paths: impl IntoIterator<Item = String>) -> Result<Self, hidapi::HidError> {
Self::with_policy(paths, AccessPolicy::Scoped)
}
pub fn with_policy(
paths: impl IntoIterator<Item = String>,
policy: AccessPolicy,
) -> Result<Self, hidapi::HidError> {
Self::with_devices(
paths.into_iter().map(|path| (path, CommandSet::Nj98CpV4)),
policy,
)
}
pub fn with_devices(
devices: impl IntoIterator<Item = (String, CommandSet)>,
policy: AccessPolicy,
) -> Result<Self, hidapi::HidError> {
Ok(Self {
command_sets: devices.into_iter().collect(),
policy,
state: Mutex::new(HidState {
api: hidapi::HidApi::new()?,
devices: HashMap::new(),
pending_reads: HashSet::new(),
}),
})
}
}
impl DeviceIo for ScopedHidTransport {
fn send(&self, path: &str, message: &[u8], checksum_type: i32) -> Result<(), String> {
validate_request(
path,
message,
checksum_type,
&self.command_sets,
self.policy,
)?;
let report = prepare_report(message, checksum_type)?;
let mut state = self
.state
.lock()
.map_err(|_| "HID transport lock is poisoned".to_string())?;
if !state.devices.contains_key(path) {
let c_path = CString::new(path).map_err(|_| "device path contains NUL".to_string())?;
let device = state
.api
.open_path(&c_path)
.map_err(|error| error.to_string())?;
state.devices.insert(path.into(), device);
}
let result = state
.devices
.get(path)
.expect("opened device must be cached")
.send_feature_report(&report)
.map_err(|error| error.to_string());
if let Err(error) = result {
state.devices.remove(path);
state.pending_reads.remove(path);
return Err(error);
}
state.pending_reads.insert(path.into());
Ok(())
}
fn read(&self, path: &str) -> Result<Vec<u8>, String> {
if !self.command_sets.contains_key(path) {
return Err("device path is not an enumerated supported vendor interface".into());
}
let mut state = self
.state
.lock()
.map_err(|_| "HID transport lock is poisoned".to_string())?;
if !state.pending_reads.remove(path) {
return Err("read requires a preceding allow-listed request".into());
}
thread::sleep(Duration::from_millis(10));
let mut response = [0_u8; 65];
let result = state
.devices
.get(path)
.ok_or_else(|| "device is not open".to_string())?
.get_feature_report(&mut response)
.map_err(|error| error.to_string());
let read = match result {
Ok(read) => read,
Err(error) => {
state.devices.remove(path);
return Err(error);
}
};
if read < 2 {
return Err("HID feature response is empty".into());
}
Ok(response[1..read].to_vec())
}
fn read_event(&self, timeout_ms: i32) -> Result<Option<Vec<u8>>, String> {
let mut state = self
.state
.lock()
.map_err(|_| "HID transport lock is poisoned".to_string())?;
for path in self.command_sets.keys() {
if !state.devices.contains_key(path) {
let c_path = CString::new(path.as_str())
.map_err(|_| "device path contains NUL".to_string())?;
let device = state
.api
.open_path(&c_path)
.map_err(|error| error.to_string())?;
state.devices.insert(path.clone(), device);
}
let mut report = [0_u8; 65];
let result = state
.devices
.get(path)
.expect("opened device must be cached")
.read_timeout(&mut report, timeout_ms)
.map_err(|error| error.to_string());
let read = match result {
Ok(read) => read,
Err(_) => {
state.devices.remove(path);
state.pending_reads.remove(path);
continue;
}
};
if read > 0 {
return Ok(Some(report[..read].to_vec()));
}
}
Ok(None)
}
fn clean(&self, path: &str) -> Result<(), String> {
if !self.command_sets.contains_key(path) {
return Err("device path is not an enumerated supported vendor interface".into());
}
let mut state = self
.state
.lock()
.map_err(|_| "HID transport lock is poisoned".to_string())?;
state.devices.remove(path);
state.pending_reads.remove(path);
Ok(())
}
}
fn validate_request(
path: &str,
message: &[u8],
checksum_type: i32,
command_sets: &HashMap<String, CommandSet>,
policy: AccessPolicy,
) -> Result<(), String> {
let command_set = command_sets
.get(path)
.ok_or_else(|| "device path is not an enumerated supported vendor interface".to_string())?;
if !matches!(checksum_type, 0..=2) {
return Err("unknown checksum type".into());
}
if message.is_empty() || message.len() > 64 {
return Err("HID reports must contain between 1 and 64 bytes".into());
}
match command_set {
CommandSet::Nj98CpV4 => validate_nj98_cp_v4_request(message, checksum_type, policy),
}
}
fn validate_nj98_cp_v4_request(
message: &[u8],
checksum_type: i32,
policy: AccessPolicy,
) -> Result<(), String> {
let scoped = match message[0] {
0x84 | 0x87 | 0x89 | 0x8f | 0x91 | 0xad => message[1..].iter().all(|byte| *byte == 0),
0x8a => message[2] == 0xff && message[5..].iter().all(|byte| *byte == 0),
0x90 => message[3] == 0xff && message[5..].iter().all(|byte| *byte == 0),
0x28 => valid_clock_request(message),
_ => false,
};
let allowed = match policy {
AccessPolicy::Scoped => checksum_type == 0 && scoped,
AccessPolicy::Settings => {
scoped
|| is_settings_command(message[0])
|| valid_magnetic_helper_request(message)
|| valid_magnetic_profile_write(message)
}
};
if !allowed {
return Err("HID command is not in the scoped allow-list".into());
}
Ok(())
}
fn valid_magnetic_profile_write(message: &[u8]) -> bool {
if message.len() < 8 || message[0] != 0x65 || message[2] != 0x01 {
return false;
}
let valid_page = match message[1] {
0x00 | 0x01 => message[3] <= 4,
0x07 => message[3] <= 2,
_ => false,
};
valid_page
&& message[4] == u8::from(message[1] == 0x01 && message[3] == 4)
&& message[5..8].iter().all(|byte| *byte == 0)
}
fn valid_magnetic_helper_request(message: &[u8]) -> bool {
message.len() >= 4
&& message[0] == 0xe5
&& message[4..].iter().all(|byte| *byte == 0)
&& matches!(
(message[1], message[2], message[3]),
(0x00, 0x01, 0x00..=0x03)
| (0x01, 0x01, 0x00..=0x03)
| (0x06, 0x01, 0x00..=0x03)
| (0x07, 0x01, 0x00..=0x01)
| (0xfc, 0x01, 0x00..=0x01)
)
}
fn is_settings_command(command: u8) -> bool {
matches!(
command,
0x00..=0x2c | 0x80..=0xad | 0xd0..=0xd4 | 0xe0..=0xe1
)
}
fn valid_clock_request(message: &[u8]) -> bool {
let year = u16::from_be_bytes([message[8], message[9]]);
message[1..8].iter().all(|byte| *byte == 0)
&& (2020..=2100).contains(&year)
&& (1..=12).contains(&message[10])
&& (1..=31).contains(&message[11])
&& message[12] <= 23
&& message[13] <= 59
&& message[14] <= 59
&& message[15..].iter().all(|byte| *byte == 0)
}
fn prepare_report(message: &[u8], checksum_type: i32) -> Result<Vec<u8>, String> {
if message.is_empty() || message.len() > 64 {
return Err("HID reports must contain between 1 and 64 bytes".into());
}
let mut report = Vec::with_capacity(65);
report.push(0);
report.extend_from_slice(message);
report.resize(65, 0);
match checksum_type {
0 => {
let sum = report[1..8]
.iter()
.fold(0_u8, |sum, byte| sum.wrapping_add(*byte));
report[8] = 0xff_u8.wrapping_sub(sum);
}
1 => {
let sum = report[1..9]
.iter()
.fold(0_u8, |sum, byte| sum.wrapping_add(*byte));
report[9] = 0xff_u8.wrapping_sub(sum);
}
2 => {}
_ => return Err("unknown checksum type".into()),
}
Ok(report)
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{AccessPolicy, CommandSet, prepare_report, validate_request};
fn paths() -> HashMap<String, CommandSet> {
HashMap::from([("/dev/hidraw14".into(), CommandSet::Nj98CpV4)])
}
fn validate_scoped_request(
path: &str,
message: &[u8],
checksum_type: i32,
paths: &HashMap<String, CommandSet>,
) -> Result<(), String> {
validate_request(path, message, checksum_type, paths, AccessPolicy::Scoped)
}
#[test]
fn confirmed_version_requests_are_allowed() {
for command in [0x84, 0x87, 0x89, 0x8f, 0x91, 0xad] {
let mut message = [0_u8; 64];
message[0] = command;
assert!(validate_scoped_request("/dev/hidraw14", &message, 0, &paths()).is_ok());
}
}
#[test]
fn confirmed_matrix_read_is_allowed() {
for (page, bank) in [(0, 0), (1, 0), (5, 0), (0, 1)] {
let mut message = [0_u8; 64];
message[..5].copy_from_slice(&[0x8a, 0x00, 0xff, page, bank]);
assert!(validate_scoped_request("/dev/hidraw14", &message, 0, &paths()).is_ok());
}
for section in [1, 2, 0xff] {
let mut message = [0_u8; 64];
message[..3].copy_from_slice(&[0x8a, section, 0xff]);
assert!(validate_scoped_request("/dev/hidraw14", &message, 0, &paths()).is_ok());
}
}
#[test]
fn mutation_and_unrelated_paths_are_rejected() {
let mut message = [0_u8; 64];
message[0] = 0x8f;
message[4] = 1;
assert!(validate_scoped_request("/dev/hidraw14", &message, 0, &paths()).is_err());
message[4] = 0;
assert!(validate_scoped_request("/dev/hidraw9", &message, 0, &paths()).is_err());
}
#[test]
fn confirmed_fn_read_is_allowed() {
for (profile, page, bank) in [(0, 0, 0), (0, 0, 1), (4, 9, 2)] {
let mut message = [0_u8; 64];
message[..5].copy_from_slice(&[0x90, profile, page, 0xff, bank]);
assert!(validate_scoped_request("/dev/hidraw14", &message, 0, &paths()).is_ok());
}
}
#[test]
fn valid_display_clock_update_is_allowed() {
let mut message = [0_u8; 64];
message[0] = 0x28;
message[8..15].copy_from_slice(&[0x07, 0xea, 0x07, 0x1d, 0x0c, 0x0d, 0x13]);
assert!(validate_scoped_request("/dev/hidraw14", &message, 0, &paths()).is_ok());
}
#[test]
fn settings_policy_allows_configuration_and_display_writes() {
for command in [0x05, 0x07, 0x09, 0x0b, 0x20, 0x25, 0x29, 0x2c] {
let mut message = [0_u8; 64];
message[0] = command;
assert!(
validate_request(
"/dev/hidraw14",
&message,
0,
&paths(),
AccessPolicy::Settings
)
.is_ok()
);
}
}
#[test]
fn settings_policy_allows_captured_magnetic_helper_pages() {
for request in [
[0xe5, 0x06, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0x06, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0x07, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0x07, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0x01, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0xfc, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00],
[0xe5, 0xfc, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00],
] {
assert!(
validate_request(
"/dev/hidraw14",
&request,
0,
&paths(),
AccessPolicy::Settings
)
.is_ok()
);
}
}
#[test]
fn settings_policy_allows_captured_magnetic_profile_pages() {
for profile in [0x00, 0x01] {
for page in 0..=4 {
let length = if page == 4 { 40 } else { 64 };
let mut request = vec![0_u8; length];
request[..5].copy_from_slice(&[
0x65,
profile,
0x01,
page,
u8::from(profile == 0x01 && page == 4),
]);
request[8..].fill(0x12);
assert!(
validate_request(
"/dev/hidraw14",
&request,
0,
&paths(),
AccessPolicy::Settings
)
.is_ok()
);
}
}
for (page, length) in [(0, 64), (1, 64), (2, 24)] {
let mut request = vec![0_u8; length];
request[..4].copy_from_slice(&[0x65, 0x07, 0x01, page]);
assert!(
validate_request(
"/dev/hidraw14",
&request,
0,
&paths(),
AccessPolicy::Settings
)
.is_ok()
);
}
}
#[test]
fn settings_policy_rejects_bootloader_and_ota_commands() {
for command in [0x30, 0x31, 0x7f, 0xba] {
let mut message = [0_u8; 64];
message[0] = command;
assert!(
validate_request(
"/dev/hidraw14",
&message,
0,
&paths(),
AccessPolicy::Settings
)
.is_err()
);
}
}
#[test]
fn bit7_checksum_matches_captured_protocol() {
for (prefix, checksum) in [
(&[0x8f][..], 0x70),
(&[0xad][..], 0x52),
(&[0x8a, 0x00, 0xff][..], 0x76),
] {
let mut message = [0_u8; 64];
message[..prefix.len()].copy_from_slice(prefix);
let report = prepare_report(&message, 0).unwrap();
assert_eq!(report.len(), 65);
assert_eq!(report[0], 0);
assert_eq!(report[8], checksum);
}
}
#[test]
fn none_checksum_preserves_the_payload() {
let mut message = [0_u8; 64];
message[..9].copy_from_slice(&[0x25, 1, 2, 3, 4, 5, 6, 7, 8]);
let report = prepare_report(&message, 2).unwrap();
assert_eq!(&report[1..], &message);
}
#[test]
fn bit8_checksum_matches_captured_light_commands() {
for (prefix, checksum) in [
(&[0x07, 0x01, 0x04, 0x04, 0x07, 0xec, 0x00, 0x00][..], 0xfc),
(&[0x07, 0x0d, 0x04, 0x04, 0x00, 0x00, 0xc8, 0xc8][..], 0x53),
] {
let mut message = [0_u8; 64];
message[..prefix.len()].copy_from_slice(prefix);
let report = prepare_report(&message, 1).unwrap();
assert_eq!(report[9], checksum);
}
}
}