rust/bt-set-coordinator: Implement CoordinatedSet and RSI Implement the CoordinatedSet struct and the Resolvable Set Identifier (RSI) resolution algorithm (sih). This enables the Set Coordinator to identify set members from advertising data during scanning without needing to initiate a BLE connection. Bug: 540068272 Test: cargo test Change-Id: I09cfefab7135dc4225b8e21aa0132c2d6c4da772 Reviewed-on: https://bluetooth-review.googlesource.com/c/bluetooth/+/3360
diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f0a9142..4ed2d16 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml
@@ -19,9 +19,11 @@ bt-csis = { path = "bt-csis" } bt-gatt = { path = "bt-gatt" } bt-pacs = { path = "bt-pacs" } +bt-set-coordinator = { path = "bt-set-coordinator" } ## External dependencies # Intended to match Fuchsia versions. +aes = "0.9.1" assert_matches = "1.5.0" bitfield = "0.19.4" futures = "=0.3.31"
diff --git a/rust/bt-set-coordinator/Cargo.toml b/rust/bt-set-coordinator/Cargo.toml new file mode 100644 index 0000000..ad3d9f8 --- /dev/null +++ b/rust/bt-set-coordinator/Cargo.toml
@@ -0,0 +1,12 @@ +[package] +name = "bt-set-coordinator" +version = "0.0.1" +edition.workspace = true +license.workspace = true + +[dependencies] +bt-csis.workspace = true +bt-common.workspace = true +bt-gatt.workspace = true +thiserror.workspace = true +aes.workspace = true
diff --git a/rust/bt-set-coordinator/src/lib.rs b/rust/bt-set-coordinator/src/lib.rs new file mode 100644 index 0000000..534a25f --- /dev/null +++ b/rust/bt-set-coordinator/src/lib.rs
@@ -0,0 +1,7 @@ +// Copyright 2026 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +pub mod types; + +pub use types::*;
diff --git a/rust/bt-set-coordinator/src/types.rs b/rust/bt-set-coordinator/src/types.rs new file mode 100644 index 0000000..513898a --- /dev/null +++ b/rust/bt-set-coordinator/src/types.rs
@@ -0,0 +1,199 @@ +// Copyright 2026 The Fuchsia Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +use aes::cipher::{BlockCipherEncrypt, KeyInit}; +use bt_csis::types::{SetIdentityResolvingKey, SirkType}; +use bt_gatt::central::{AdvertisingDatum, ScanResult}; + +/// Represents a Coordinated Set managed by the Set Coordinator. +pub struct CoordinatedSet { + sirk: SetIdentityResolvingKey, + // Add other fields as necessary (like size, lock state, etc.) +} + +impl CoordinatedSet { + pub fn new(sirk: SetIdentityResolvingKey) -> Self { + Self { sirk } + } + + /// Checks if a ScanResult belongs to this Coordinated Set by looking for an + /// RSI (in standalone ResolvableSetIdentifier AD Type) and attempting to + /// resolve it with this set's SIRK. + pub fn matches_scan_result(&self, scan_result: &ScanResult) -> bool { + scan_result.advertised.iter().any(|datum| match datum { + AdvertisingDatum::ResolvableSetIdentifier(rsi) => self.resolves_rsi(rsi), + _ => false, + }) + } + + /// Resolves an RSI using the Set Identity Resolving Key (SIRK). + /// + /// RSI format (6 bytes): + /// bytes 0..3: hash + /// bytes 3..6: prand + /// + /// See CSIP v1.1 Section 4.8 & Core Spec Vol 3 Part H Section 1.3. + /// An encrypted SIRK cannot resolve RSIs until decrypted. + pub fn resolves_rsi(&self, rsi: &[u8; 6]) -> bool { + if self.sirk.sirk_type == SirkType::Encrypted { + return false; + } + + // Per CSIP v1.1 Section 4.8, the two most significant bits of prand + // (bits 7 and 6 of rsi[5]) must be 0 and 1 (0x40). + if (rsi[5] & 0xc0) != 0x40 { + return false; + } + + let mut prand = [0u8; 3]; + prand.copy_from_slice(&rsi[3..6]); + + let hash_expected = &rsi[0..3]; + let hash_computed = set_identity_hash(&self.sirk.value, &prand); + + hash_computed == *hash_expected + } +} + +/// Set Identity Hash (SIH) function as defined in CSIS 1.1 Specification, +/// Section 4.7. `sih(k, r) = e(k, r') mod 2^24` +/// where `r' = padding || r`, padding is 104 bits of 0. +/// The LSO of r becomes the LSO of r'. +fn set_identity_hash(key: &[u8; 16], r: &[u8; 3]) -> [u8; 3] { + let mut plaintext = [0u8; 16]; + + // r' = padding || r. LSO of r is LSO of r'. + // Since Bluetooth is little endian, LSO is at index 0. + plaintext[0] = r[0]; + plaintext[1] = r[1]; + plaintext[2] = r[2]; + // The rest of the bytes are already 0 (padding). + + // Bluetooth `e` function uses AES-128 but is LSB-first (little endian). + // The `aes` crate is MSB-first. We need to reverse the input and output. + let mut key_reversed = *key; + key_reversed.reverse(); + plaintext.reverse(); + + let key_block = key_reversed.into(); + let mut block = plaintext.into(); + + let cipher = aes::Aes128::new(&key_block); + cipher.encrypt_block(&mut block); + + let mut output = [0u8; 16]; + output.copy_from_slice(block.as_slice()); + output.reverse(); + + // Output of security function e is truncated to 24 bits by taking the least + // significant 24 bits. LSO is at index 0. + [output[0], output[1], output[2]] +} + +#[cfg(test)] +mod tests { + use super::*; + use bt_common::PeerId; + use bt_csis::types::COORDINATED_SET_IDENTIFICATION_SERVICE_UUID; + use bt_gatt::central::PeerName; + + // Test vector from CSIS Spec v1.1 Appendix A.1 + // Given k (SIRK): 0xcd, 0xcc, 0x72, 0xdd, 0x86, 0x8c, 0xcd, 0xce, 0x22, 0xfd, + // 0xa1, 0x21, 0x09, 0x7d, 0x7d, 0x45 (LSO to MSO) Given r (prand): 0x63, + // 0xf5, 0x69 (LSO first; in MSB-first notation 0x69f563) Result hash: 0xda, + // 0x48, 0x19 (LSO first; in MSB-first notation 0x1948da) + const SAMPLE_KEY: [u8; 16] = [ + 0xcd, 0xcc, 0x72, 0xdd, 0x86, 0x8c, 0xcd, 0xce, 0x22, 0xfd, 0xa1, 0x21, 0x09, 0x7d, 0x7d, + 0x45, + ]; + const SAMPLE_PRAND: [u8; 3] = [0x63, 0xf5, 0x69]; + const SAMPLE_HASH: [u8; 3] = [0xda, 0x48, 0x19]; + + // RSI = hash || prand. In LSO first: bytes 0..3 are hash, bytes 3..6 are prand. + const SAMPLE_RSI: [u8; 6] = [0xda, 0x48, 0x19, 0x63, 0xf5, 0x69]; + + #[test] + fn set_identity_hash_test() { + let hash_computed = set_identity_hash(&SAMPLE_KEY, &SAMPLE_PRAND); + assert_eq!(hash_computed, SAMPLE_HASH); + } + + #[test] + fn resolves_rsi() { + let sirk = SetIdentityResolvingKey { sirk_type: SirkType::Plaintext, value: SAMPLE_KEY }; + let set = CoordinatedSet::new(sirk); + + assert!(set.resolves_rsi(&SAMPLE_RSI)); + + // Encrypted SIRK cannot resolve RSI + let encrypted_sirk = + SetIdentityResolvingKey { sirk_type: SirkType::Encrypted, value: SAMPLE_KEY }; + let encrypted_set = CoordinatedSet::new(encrypted_sirk); + assert!(!encrypted_set.resolves_rsi(&SAMPLE_RSI)); + + // Invalid prand MSBs (bit 7 and bit 6 of rsi[5] != 0x40) + let mut invalid_rsi = SAMPLE_RSI; + invalid_rsi[5] = 0x00; // Bit 6 is 0 instead of 1 + assert!(!set.resolves_rsi(&invalid_rsi)); + } + + #[test] + fn matches_scan_result_rsi() { + let sirk = SetIdentityResolvingKey { sirk_type: SirkType::Plaintext, value: SAMPLE_KEY }; + let set = CoordinatedSet::new(sirk); + + // Matching standalone RSI AD Type (0x2E) + let scan_rsi = ScanResult { + id: PeerId(1), + connectable: true, + name: PeerName::Unknown, + advertised: vec![AdvertisingDatum::ResolvableSetIdentifier(SAMPLE_RSI)], + advertising_sid: None, + periodic_advertising_interval: None, + }; + assert!(set.matches_scan_result(&scan_rsi)); + + // CSIS Service Data AD Type (0x16) is not an RSI and should return false + let scan_service_data = ScanResult { + id: PeerId(2), + connectable: true, + name: PeerName::Unknown, + advertised: vec![AdvertisingDatum::ServiceData( + COORDINATED_SET_IDENTIFICATION_SERVICE_UUID, + vec![0x01, 0x02, 0x03], + )], + advertising_sid: None, + periodic_advertising_interval: None, + }; + assert!(!set.matches_scan_result(&scan_service_data)); + + // Multiple advertising records in a single scan result + let scan_multiple_records = ScanResult { + id: PeerId(3), + connectable: true, + name: PeerName::Unknown, + advertised: vec![ + AdvertisingDatum::ServiceData( + COORDINATED_SET_IDENTIFICATION_SERVICE_UUID, + vec![0x00; 3], + ), + AdvertisingDatum::ResolvableSetIdentifier(SAMPLE_RSI), // Valid RSI match + ], + advertising_sid: None, + periodic_advertising_interval: None, + }; + assert!(set.matches_scan_result(&scan_multiple_records)); + + // Non-matching RSI + let scan_non_matching = ScanResult { + id: PeerId(4), + connectable: true, + name: PeerName::Unknown, + advertised: vec![AdvertisingDatum::ResolvableSetIdentifier([0x40; 6])], + advertising_sid: None, + periodic_advertising_interval: None, + }; + assert!(!set.matches_scan_result(&scan_non_matching)); + } +}