src.nth.io/

summaryrefslogtreecommitdiff
path: root/src/pairing.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/pairing.rs')
-rw-r--r--src/pairing.rs129
1 files changed, 129 insertions, 0 deletions
diff --git a/src/pairing.rs b/src/pairing.rs
new file mode 100644
index 0000000..715d7a8
--- /dev/null
+++ b/src/pairing.rs
@@ -0,0 +1,129 @@
+//! Manual pairing code decoding (Matter Core spec 5.1.4.1).
+//!
+//! An 11-digit (no VID/PID) or 21-digit (with VID/PID) decimal code packs a
+//! 4-bit short discriminator and the 27-bit setup passcode, guarded by a
+//! trailing Verhoeff check digit. QR payloads ("MT:...") are not accepted;
+//! the primary ecosystem always shows the numeric code.
+
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum PairingCodeError {
+ #[error(
+ "QR payloads are not supported; enter the numeric pairing code shown by the primary ecosystem"
+ )]
+ QrPayload,
+ #[error("pairing code must be 11 or 21 digits (got {0})")]
+ BadLength(usize),
+ #[error("pairing code check digit is wrong; re-check the digits")]
+ BadCheckDigit,
+ #[error("pairing code decodes to an invalid setup passcode")]
+ BadPasscode,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Onboarding {
+ pub passcode: u32,
+ /// Upper 4 bits of the 12-bit discriminator, as carried in manual codes.
+ pub short_discriminator: u8,
+}
+
+/// Setup passcodes the spec forbids (trivial/sequential values).
+const INVALID_PASSCODES: [u32; 12] = [
+ 0, 11111111, 22222222, 33333333, 44444444, 55555555, 66666666, 77777777, 88888888, 99999999,
+ 12345678, 87654321,
+];
+
+pub fn parse_manual_code(raw: &str) -> Result<Onboarding, PairingCodeError> {
+ let trimmed = raw.trim();
+ if trimmed.starts_with("MT:") {
+ return Err(PairingCodeError::QrPayload);
+ }
+ let digits: Vec<u8> = trimmed
+ .chars()
+ .filter(|c| !c.is_whitespace() && *c != '-')
+ .map(|c| {
+ c.to_digit(10)
+ .map(|d| d as u8)
+ .ok_or(PairingCodeError::BadLength(0))
+ })
+ .collect::<Result<_, _>>()
+ .map_err(|_| PairingCodeError::BadLength(trimmed.len()))?;
+
+ if digits.len() != 11 && digits.len() != 21 {
+ return Err(PairingCodeError::BadLength(digits.len()));
+ }
+
+ let payload: String = digits[..digits.len() - 1]
+ .iter()
+ .map(|d| (b'0' + d) as char)
+ .collect();
+ if verhoeff::calculate(payload.as_str()) != digits[digits.len() - 1] {
+ return Err(PairingCodeError::BadCheckDigit);
+ }
+
+ let d1 = u32::from(digits[0]);
+ let chunk2 = digits[1..6]
+ .iter()
+ .fold(0u32, |acc, &d| acc * 10 + u32::from(d));
+ let chunk3 = digits[6..10]
+ .iter()
+ .fold(0u32, |acc, &d| acc * 10 + u32::from(d));
+
+ // digit 1 = (VID_PID_PRESENT << 2) | (discriminator >> 10)
+ // digits 2-6 = ((discriminator & 0x300) << 6) | (passcode & 0x3FFF)
+ // digits 7-10 = passcode >> 14
+ let short_discriminator = (((d1 & 0x3) << 2) | ((chunk2 >> 14) & 0x3)) as u8;
+ let passcode = (chunk3 << 14) | (chunk2 & 0x3FFF);
+
+ if passcode >= 1 << 27 || INVALID_PASSCODES.contains(&passcode) {
+ return Err(PairingCodeError::BadPasscode);
+ }
+ Ok(Onboarding {
+ passcode,
+ short_discriminator,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn decodes_the_canonical_test_code() {
+ // The Matter SDK test onboarding payload: passcode 20202021,
+ // discriminator 3840 (0xF00) => short discriminator 15, manual code
+ // 34970112332.
+ let onboarding = parse_manual_code("34970112332").unwrap();
+ assert_eq!(
+ onboarding,
+ Onboarding {
+ passcode: 20202021,
+ short_discriminator: 15,
+ }
+ );
+ }
+
+ #[test]
+ fn accepts_hyphenated_input() {
+ assert!(parse_manual_code("3497-011-2332").is_ok());
+ }
+
+ #[test]
+ fn rejects_qr_payloads_with_guidance() {
+ assert_eq!(
+ parse_manual_code("MT:Y.K90SO527JA0648G00"),
+ Err(PairingCodeError::QrPayload)
+ );
+ }
+
+ #[test]
+ fn rejects_wrong_lengths_and_check_digits() {
+ assert!(matches!(
+ parse_manual_code("1234"),
+ Err(PairingCodeError::BadLength(_))
+ ));
+ assert_eq!(
+ parse_manual_code("34970112331"),
+ Err(PairingCodeError::BadCheckDigit)
+ );
+ }
+}