This commit is contained in:
Mateusz Gruszczyński
2026-08-23 22:19:53 +02:00
parent 9c9b7b272b
commit d834284695
9 changed files with 199 additions and 64 deletions
+46 -8
View File
@@ -1,7 +1,14 @@
use aes::{Aes128, cipher::{BlockDecrypt, BlockEncrypt, KeyInit, generic_array::GenericArray}};
use aes_gcm::{Aes128Gcm, Nonce, aead::{AeadInPlace, KeyInit as AeadKeyInit}};
use anyhow::{anyhow, bail, Context, Result};
use base64::{engine::general_purpose::STANDARD, Engine};
use base64::{
alphabet,
engine::{
general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD},
DecodePaddingMode,
},
Engine,
};
/// Shared discovery/bind key used by the original AES-128-ECB protocol.
pub const GENERIC_GREE_V1_KEY: &str = "a3K8Bx%2r8Y7#xDh";
@@ -11,6 +18,17 @@ pub const GENERIC_GREE_V2_KEY: &str = "{yxAHAY_Lm6pbC/<";
const GCM_NONCE: [u8; 12] = [0x54, 0x40, 0x78, 0x44, 0x49, 0x67, 0x5a, 0x51, 0x6c, 0x5e, 0x63, 0x13];
const GCM_AAD: &[u8] = b"qualcomm-test";
// Some GREE Wi-Fi modules emit technically non-canonical Base64: padding may
// be omitted and unused trailing bits can be set. Python's base64 decoder,
// used by the established GREE implementations, accepts those packets. Keep
// encoding canonical but use a forgiving decoder for device-originated data.
const GREE_BASE64_DECODE: GeneralPurpose = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_decode_padding_mode(DecodePaddingMode::Indifferent)
.with_decode_allow_trailing_bits(true),
);
pub fn normalize_key(key: &str) -> Result<[u8; 16]> {
let bytes = key.as_bytes();
if bytes.len() == 16 {
@@ -44,7 +62,7 @@ pub fn encrypt_v1(key: &str, plaintext: &[u8]) -> Result<String> {
pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
let key = normalize_key(key)?;
let cipher = Aes128::new_from_slice(&key).map_err(|_| anyhow!("invalid AES key"))?;
let mut data = STANDARD.decode(ciphertext_b64).context("invalid base64 packet")?;
let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid base64 packet")?;
if data.is_empty() || data.len() % 16 != 0 {
bail!("invalid AES-ECB ciphertext length")
}
@@ -52,10 +70,19 @@ pub fn decrypt_v1(key: &str, ciphertext_b64: &str) -> Result<Vec<u8>> {
cipher.decrypt_block(GenericArray::from_mut_slice(block));
}
let pad = *data.last().ok_or_else(|| anyhow!("empty plaintext"))? as usize;
if pad == 0 || pad > 16 || data.len() < pad || data[data.len() - pad..].iter().any(|v| *v as usize != pad) {
bail!("invalid PKCS#7 padding")
let valid_padding = pad > 0
&& pad <= 16
&& data.len() >= pad
&& data[data.len() - pad..].iter().all(|v| *v as usize == pad);
if valid_padding {
data.truncate(data.len() - pad);
} else if let Some(last_json_byte) = data.iter().rposition(|byte| *byte == b'}') {
// Legacy GREE implementations are deliberately tolerant here: some
// modules return non-standard padding but the JSON itself is valid.
data.truncate(last_json_byte + 1);
} else {
bail!("invalid AES-ECB padding and no JSON terminator")
}
data.truncate(data.len() - pad);
Ok(data)
}
@@ -81,13 +108,15 @@ pub fn encrypt_v2(key: &str, plaintext: &[u8]) -> Result<V2Encrypted> {
pub fn decrypt_v2(key: &str, ciphertext_b64: &str, tag_b64: &str) -> Result<Vec<u8>> {
let key = normalize_key(key)?;
let cipher = <Aes128Gcm as AeadKeyInit>::new_from_slice(&key).map_err(|_| anyhow!("invalid AES-GCM key"))?;
let tag_bytes = STANDARD.decode(tag_b64).context("invalid GCM tag")?;
if tag_bytes.len() != 16 { bail!("invalid GCM tag length") }
let mut data = STANDARD.decode(ciphertext_b64).context("invalid GCM ciphertext")?;
let tag_bytes = GREE_BASE64_DECODE.decode(tag_b64).context("invalid GCM tag")?;
if tag_bytes.len() != 16 { bail!("invalid GCM tag length: {} bytes", tag_bytes.len()) }
let mut data = GREE_BASE64_DECODE.decode(ciphertext_b64).context("invalid GCM ciphertext")?;
let nonce = Nonce::from_slice(&GCM_NONCE);
let tag = GenericArray::from_slice(&tag_bytes);
cipher.decrypt_in_place_detached(nonce, GCM_AAD, &mut data, tag)
.map_err(|_| anyhow!("AES-GCM authentication failed"))?;
// A few modules append 0xff filler bytes to decrypted JSON.
data.retain(|byte| *byte != 0xff);
Ok(data)
}
@@ -108,4 +137,13 @@ mod tests {
let encrypted = encrypt_v2(GENERIC_GREE_V2_KEY, value).unwrap();
assert_eq!(decrypt_v2(GENERIC_GREE_V2_KEY, &encrypted.ciphertext, &encrypted.tag).unwrap(), value);
}
#[test]
fn gree_base64_accepts_noncanonical_trailing_bits() {
// 16 zero bytes canonically end with `A==`. `B==` carries the same
// useful two bits but has non-zero unused trailing bits. Python's
// base64.b64decode accepts it and real GREE modules emit this form.
let decoded = GREE_BASE64_DECODE.decode("AAAAAAAAAAAAAAAAAAAAAB==").unwrap();
assert_eq!(decoded, vec![0_u8; 16]);
}
}
+116 -9
View File
@@ -27,11 +27,24 @@ impl GreeClient {
Self { controller_id, interface }
}
async fn udp_socket(&self, broadcast: bool) -> Result<UdpSocket> {
async fn udp_socket(&self, broadcast: bool, target_hint: Option<Ipv4Addr>) -> Result<UdpSocket> {
let socket = if let Some(interface) = self.interface.as_deref() {
let ip = interface_ipv4(interface)?;
UdpSocket::bind(SocketAddrV4::new(ip, 0)).await
.with_context(|| format!("cannot bind GREE UDP socket to {ip} from interface {interface}"))?
} else if let Some(target) = target_hint {
if let Some(config) = local_ipv4_config_for_target(target)? {
tracing::debug!(
target = %target,
interface = %config.interface,
local_ip = %config.ip,
"Automatically selected local interface for GREE UDP"
);
UdpSocket::bind(SocketAddrV4::new(config.ip, 0)).await
.with_context(|| format!("cannot bind GREE UDP socket to {} on {}", config.ip, config.interface))?
} else {
UdpSocket::bind("0.0.0.0:0").await?
}
} else {
UdpSocket::bind("0.0.0.0:0").await?
};
@@ -39,6 +52,19 @@ impl GreeClient {
Ok(socket)
}
fn bind_scan_target(&self, target: SocketAddr) -> Result<SocketAddr> {
let SocketAddr::V4(target_v4) = target else { return Ok(target); };
let broadcast = if let Some(interface) = self.interface.as_deref() {
let (_, broadcast) = interface_ipv4_config(interface)?;
Some(broadcast)
} else {
local_ipv4_config_for_target(*target_v4.ip())?.map(|config| config.broadcast)
};
Ok(broadcast
.map(|ip| SocketAddr::V4(SocketAddrV4::new(ip, target_v4.port())))
.unwrap_or(target))
}
fn discovery_target(&self, configured: &str) -> Result<SocketAddr> {
let value = configured.trim();
if value.eq_ignore_ascii_case("auto") || value.to_ascii_lowercase().starts_with("auto:") {
@@ -57,7 +83,8 @@ impl GreeClient {
/// protocol_filter: 0=auto/both, 1=ECB only, 2=GCM only.
pub async fn discover(&self, broadcast: &str, duration: Duration, protocol_filter: u8, passes: u8) -> Result<Vec<Device>> {
let target = self.discovery_target(broadcast)?;
let socket = self.udp_socket(true).await?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
let socket = self.udp_socket(true, target_hint).await?;
let local = socket.local_addr()?;
let passes = passes.clamp(1, 10);
tracing::info!(
@@ -219,13 +246,30 @@ impl GreeClient {
async fn bind_attempt(&self, device: &Device, version: u8) -> Result<String> {
let target = self.device_target(device)?;
let socket = self.udp_socket(false).await?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
let socket = self.udp_socket(true, target_hint).await?;
// Some Wi-Fi modules only accept bind shortly after a scan. A direct scan
// refreshes that window and works across routed/VLAN deployments too.
socket.send_to(br#"{"t":"scan"}"#, target).await?;
// Binding is time-sensitive on older GREE Wi-Fi modules. Refresh the
// bind window with a subnet broadcast when the target is on a directly
// connected network. A unicast scan remains the fallback for routed
// deployments. Keep the same UDP socket for scan + bind.
let scan_target = self.bind_scan_target(target)?;
tracing::debug!(device=%device.id, target=%target, scan_target=%scan_target, local=%socket.local_addr()?, "Refreshing GREE bind window");
socket.send_to(br#"{"t":"scan"}"#, scan_target).await?;
let mut scan_buf = vec![0_u8; 16 * 1024];
let _ = timeout(Duration::from_millis(900), socket.recv_from(&mut scan_buf)).await;
let scan_deadline = Instant::now() + Duration::from_millis(1500);
while Instant::now() < scan_deadline {
let remaining = scan_deadline.saturating_duration_since(Instant::now());
match timeout(remaining, socket.recv_from(&mut scan_buf)).await {
Ok(Ok((_size, source))) if source.ip() == target.ip() => {
tracing::debug!(device=%device.id, source=%source, "Received scan response immediately before bind");
break;
}
Ok(Ok(_)) => continue,
Ok(Err(err)) => return Err(err.into()),
Err(_) => break,
}
}
let inner = json!({"mac": device.mac, "t": "bind", "uid": 0});
let generic_key = if version == 2 { GENERIC_GREE_V2_KEY } else { GENERIC_GREE_V1_KEY };
@@ -328,7 +372,9 @@ impl GreeClient {
}
async fn request(&self, device: &Device, inner: &Value, key: &str, binding: bool, protocol_version: u8) -> Result<Value> {
let socket = self.udp_socket(false).await?;
let target = self.device_target(device)?;
let target_hint = match target { SocketAddr::V4(addr) => Some(*addr.ip()), SocketAddr::V6(_) => None };
let socket = self.udp_socket(false, target_hint).await?;
self.request_on_socket(device, inner, key, binding, protocol_version, &socket).await
}
@@ -371,7 +417,16 @@ impl GreeClient {
Err(err) => { last_decode_error = Some(anyhow!("invalid GREE JSON response: {err}")); continue; }
};
if let Some(pack) = response.get("pack").and_then(Value::as_object) {
return Ok(Value::Object(pack.clone()));
let decoded = Value::Object(pack.clone());
if binding {
let response_type = decoded.get("t").and_then(Value::as_str).unwrap_or_default();
if !response_type.eq_ignore_ascii_case("bindok") {
tracing::debug!(source=%source, response_type=%response_type, "Ignoring non-bind packet while waiting for GREE bind response");
continue;
}
}
if let Some(err) = decoded.get("err").filter(|v| !v.is_null()) { bail!("GREE device error: {err}") }
return Ok(decoded);
}
let Some(pack) = response.get("pack").and_then(Value::as_str) else { continue; };
let clear = if version == 2 {
@@ -409,6 +464,58 @@ impl GreeClient {
}
}
#[derive(Debug, Clone)]
struct LocalIpv4Config {
interface: String,
ip: Ipv4Addr,
broadcast: Ipv4Addr,
prefix_len: u32,
}
#[cfg(target_os = "linux")]
fn local_ipv4_config_for_target(target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> {
use std::{ffi::CStr, ptr};
unsafe {
let mut addrs: *mut libc::ifaddrs = ptr::null_mut();
if libc::getifaddrs(&mut addrs) != 0 { return Err(std::io::Error::last_os_error()).context("getifaddrs failed"); }
let mut current = addrs;
let mut best: Option<LocalIpv4Config> = None;
while !current.is_null() {
let ifa = &*current;
if !ifa.ifa_name.is_null() && !ifa.ifa_addr.is_null() && !ifa.ifa_netmask.is_null()
&& (*ifa.ifa_addr).sa_family as i32 == libc::AF_INET
{
let interface = CStr::from_ptr(ifa.ifa_name).to_string_lossy().into_owned();
let addr = &*(ifa.ifa_addr as *const libc::sockaddr_in);
let mask_addr = &*(ifa.ifa_netmask as *const libc::sockaddr_in);
let ip = Ipv4Addr::from(addr.sin_addr.s_addr.to_ne_bytes());
let mask = Ipv4Addr::from(mask_addr.sin_addr.s_addr.to_ne_bytes());
let ip_u32 = u32::from(ip);
let mask_u32 = u32::from(mask);
let target_u32 = u32::from(target);
if !ip.is_loopback() && (ip_u32 & mask_u32) == (target_u32 & mask_u32) {
let prefix_len = mask_u32.count_ones();
let candidate = LocalIpv4Config {
interface,
ip,
broadcast: Ipv4Addr::from(ip_u32 | !mask_u32),
prefix_len,
};
if best.as_ref().map(|current| prefix_len > current.prefix_len).unwrap_or(true) {
best = Some(candidate);
}
}
}
current = ifa.ifa_next;
}
libc::freeifaddrs(addrs);
Ok(best)
}
}
#[cfg(not(target_os = "linux"))]
fn local_ipv4_config_for_target(_target: Ipv4Addr) -> Result<Option<LocalIpv4Config>> { Ok(None) }
#[cfg(target_os = "linux")]
fn interface_ipv4_config(interface: &str) -> Result<(Ipv4Addr, Ipv4Addr)> {
use std::{ffi::CStr, ptr};