Skip to main content

matc/
onboarding.rs

1use anyhow::{bail, Context, Result};
2
3/// Bitfield flags for discovery capabilities returned from QR code.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub struct DiscoveryCapabilities(pub u8);
6
7impl DiscoveryCapabilities {
8    pub fn has_soft_ap(self) -> bool { self.0 & 0x01 != 0 }
9    pub fn has_ble(self) -> bool { self.0 & 0x02 != 0 }
10    pub fn has_on_network(self) -> bool { self.0 & 0x04 != 0 }
11}
12
13#[derive(Debug)]
14pub struct OnboardingInfo {
15    pub discriminator: u16,
16    pub passcode: u32,
17    /// True when decoded from a manual pairing code (only top 4 bits of discriminator are valid).
18    pub is_short_discriminator: bool,
19    /// Present only when decoded from a QR code payload.
20    pub vendor_id: Option<u16>,
21    /// Present only when decoded from a QR code payload.
22    pub product_id: Option<u16>,
23    /// Present only when decoded from a QR code payload.
24    pub discovery_capabilities: Option<DiscoveryCapabilities>,
25}
26
27/// Base38 alphabet used in Matter QR codes (no space; ends with `-` and `.`).
28const BASE38_CHARS: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-.";
29
30fn base38_val(ch: char) -> Result<u32> {
31    BASE38_CHARS
32        .iter()
33        .position(|&c| c == ch as u8)
34        .map(|p| p as u32)
35        .ok_or_else(|| anyhow::anyhow!("invalid Base38 character '{}'", ch))
36}
37
38/// Decode a Base38 string into bytes.
39///
40/// Per the Matter spec, Base38 encodes input bytes in chunks of 3 bytes → 5
41/// chars (little-endian, char 0 carrying the least-significant base-38 digit).
42/// Trailing groups are: 4 chars → 2 bytes, 2 chars → 1 byte. Any other
43/// trailing length is invalid.
44fn base38_decode(s: &str) -> Result<Vec<u8>> {
45    const CHARS_PER_CHUNK: usize = 5;
46    const BYTES_PER_CHUNK: usize = 3;
47
48    let chars: Vec<char> = s.chars().collect();
49    let n = chars.len();
50    let trailing = n % CHARS_PER_CHUNK;
51    let trailing_bytes = match trailing {
52        0 => 0,
53        2 => 1,
54        4 => 2,
55        _ => bail!(
56            "invalid Base38 length {}: trailing chars must be 0, 2, or 4 (mod 5)",
57            n
58        ),
59    };
60
61    let full_chunks = n / CHARS_PER_CHUNK;
62    let mut out = Vec::with_capacity(full_chunks * BYTES_PER_CHUNK + trailing_bytes);
63
64    let decode_group = |chars_slice: &[char]| -> Result<u32> {
65        let mut v: u32 = 0;
66        for c in chars_slice.iter().rev() {
67            v = v * 38 + base38_val(*c)?;
68        }
69        Ok(v)
70    };
71
72    for chunk in 0..full_chunks {
73        let i = chunk * CHARS_PER_CHUNK;
74        let v = decode_group(&chars[i..i + CHARS_PER_CHUNK])?;
75        out.push((v & 0xff) as u8);
76        out.push(((v >> 8) & 0xff) as u8);
77        out.push(((v >> 16) & 0xff) as u8);
78    }
79
80    if trailing > 0 {
81        let i = full_chunks * CHARS_PER_CHUNK;
82        let v = decode_group(&chars[i..i + trailing])?;
83        for b in 0..trailing_bytes {
84            out.push(((v >> (b * 8)) & 0xff) as u8);
85        }
86    }
87
88    Ok(out)
89}
90
91/// Decode a Matter QR code payload (the `MT:...` string, with or without the `MT:` prefix).
92///
93/// The payload is a Base38-encoded 88-bit integer with the following layout (LSB first):
94/// * bits  0- 2 : version (3 bits)
95/// * bits  3-18 : vendor ID (16 bits)
96/// * bits 19-34 : product ID (16 bits)
97/// * bits 35-36 : commissioning flow (2 bits)
98/// * bits 37-44 : discovery capabilities (8 bits)
99/// * bits 45-56 : discriminator (12 bits)
100/// * bits 57-83 : passcode (27 bits)
101/// * bits 84-87 : padding (4 bits, must be zero)
102pub fn decode_qr_payload(qr: &str) -> Result<OnboardingInfo> {
103    let payload = qr.trim().strip_prefix("MT:").unwrap_or(qr.trim());
104    let bytes = base38_decode(payload).context("base38 decode")?;
105    if bytes.len() < 11 {
106        bail!("QR payload too short: {} bytes", bytes.len());
107    }
108
109    // Pack into a 88-bit little-endian integer (11 bytes)
110    let mut bits: u128 = 0;
111    for (i, &b) in bytes.iter().take(11).enumerate() {
112        bits |= (b as u128) << (i * 8);
113    }
114
115    let _version          = (bits & 0x7) as u8;
116    let vendor_id         = ((bits >> 3) & 0xffff) as u16;
117    let product_id        = ((bits >> 19) & 0xffff) as u16;
118    let _custom_flow      = ((bits >> 35) & 0x3) as u8;
119    let disc_caps         = ((bits >> 37) & 0xff) as u8;
120    let discriminator     = ((bits >> 45) & 0xfff) as u16;
121    let passcode          = ((bits >> 57) & 0x7ff_ffff) as u32;
122
123    Ok(OnboardingInfo {
124        discriminator,
125        passcode,
126        is_short_discriminator: false,
127        vendor_id: Some(vendor_id),
128        product_id: Some(product_id),
129        discovery_capabilities: Some(DiscoveryCapabilities(disc_caps)),
130    })
131}
132
133pub fn decode_manual_pairing_code(code: &str) -> Result<OnboardingInfo> {
134    let norm = code.replace("-", "");
135    let first_grp = &norm[0..1];
136    let second_grp = &norm[1..6];
137    let third_grp = &norm[6..10];
138    let first = first_grp.parse::<u32>()?;
139    let second = second_grp.parse::<u32>()?;
140    let third = third_grp.parse::<u32>()?;
141    let passcode = second & 0x3fff | (third << 14);
142    let discriminator = (((first & 3) << 10) | (second >> 6) & 0x300) as u16;
143    Ok(OnboardingInfo {
144        discriminator,
145        passcode,
146        is_short_discriminator: true,
147        vendor_id: None,
148        product_id: None,
149        discovery_capabilities: None,
150    })
151}
152
153static D: [[u8; 10]; 10] = [
154    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
155    [1, 2, 3, 4, 0, 6, 7, 8, 9, 5],
156    [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
157    [3, 4, 0, 1, 2, 8, 9, 5, 6, 7],
158    [4, 0, 1, 2, 3, 9, 5, 6, 7, 8],
159    [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
160    [6, 5, 9, 8, 7, 1, 0, 4, 3, 2],
161    [7, 6, 5, 9, 8, 2, 1, 0, 4, 3],
162    [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
163    [9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
164];
165
166/// The permutation table.
167static P: [[u8; 10]; 8] = [
168    [0, 1, 2, 3, 4, 5, 6, 7, 8, 9],
169    [1, 5, 7, 6, 2, 8, 3, 0, 9, 4],
170    [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
171    [8, 9, 1, 6, 0, 4, 3, 5, 2, 7],
172    [9, 4, 5, 3, 1, 2, 6, 8, 7, 0],
173    [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
174    [2, 7, 9, 3, 8, 0, 6, 4, 1, 5],
175    [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
176];
177
178/// Inverse table for Verhoeff's dihedral group D5.
179static INV: [u8; 10] = [0, 4, 3, 2, 1, 5, 6, 7, 8, 9];
180
181fn verhoeff_checksum(num: &str) -> u8 {
182    let mut c: usize = 0;
183    for (i, ch) in num.chars().rev().enumerate() {
184        let digit = ch.to_digit(10).unwrap() as usize;
185        c = D[c][P[(i + 1) % 8][digit] as usize] as usize;
186    }
187    INV[c]
188}
189
190pub fn encode_manual_pairing_code(info: &OnboardingInfo) -> String {
191    let first = (info.discriminator as u32 >> 10) as u8;
192    let second = ((info.discriminator & 0x300) << 6) as u32 | (info.passcode & 0x3fff);
193    let third = info.passcode >> 14;
194    let digits = format!("{:01}{:05}{:04}", first, second, third);
195    let check = verhoeff_checksum(&digits);
196    let num = format!("{}{:05}{:04}{}", first, second, third, check);
197    // Insert dashes after each 4th digit
198    let mut formatted = String::new();
199    for (i, ch) in num.chars().enumerate() {
200        if i > 0 && i % 4 == 0 {
201            formatted.push('-');
202        }
203        formatted.push(ch);
204    }
205    formatted
206}
207
208#[cfg(test)]
209mod tests {
210    use crate::onboarding::OnboardingInfo;
211
212    use super::decode_manual_pairing_code;
213    use super::encode_manual_pairing_code;
214
215    #[test]
216    pub fn test_1() {
217        let res = decode_manual_pairing_code("2585-103-3238").unwrap();
218        assert_eq!(res.discriminator, 2816);
219        assert_eq!(res.passcode, 54453390);
220        let encoded = encode_manual_pairing_code(&res);
221        assert_eq!(encoded.replace("-", ""), "25851033238");
222    }
223
224    #[test]
225    pub fn test_2() {
226        let res = decode_manual_pairing_code("34970112332").unwrap();
227        assert_eq!(res.discriminator, 3840);
228        assert_eq!(res.passcode, 20202021);
229        let encoded = encode_manual_pairing_code(&res);
230        assert_eq!(encoded.replace("-", ""), "34970112332");
231    }
232    #[test]
233    pub fn test_3() {
234        let oi = OnboardingInfo {
235            discriminator: 3840,
236            passcode: 123456,
237            is_short_discriminator: false,
238            vendor_id: None,
239            product_id: None,
240            discovery_capabilities: None,
241        };
242        let encoded = encode_manual_pairing_code(&oi);
243        println!("Encoded: {}", encoded);
244    }
245
246    /// QR from `chip-lighting-app --passcode 123456 --discriminator 100`
247    /// (default vendor 0xFFF1 / product 0x8001).
248    #[test]
249    pub fn test_qr_decode() {
250        let info = super::decode_qr_payload("MT:-24J04QI14G6Q663000").unwrap();
251        assert_eq!(info.passcode, 123456, "passcode mismatch");
252        assert_eq!(info.discriminator, 100, "discriminator mismatch");
253        assert_eq!(info.vendor_id, Some(0xFFF1));
254        assert_eq!(info.product_id, Some(0x8001));
255        let dc = info.discovery_capabilities.unwrap();
256        assert!(dc.has_on_network());
257    }
258
259    /// Same default vendor/product as `test_qr_decode`, but with
260    /// passcode 123456 and discriminator 4095 (all 12 bits set).
261    #[test]
262    pub fn test_qr_decode2() {
263        let info = super::decode_qr_payload("MT:-24J0SO527LJQ663000").unwrap();
264        assert_eq!(info.passcode, 123456, "passcode mismatch");
265        assert_eq!(info.discriminator, 4095, "discriminator mismatch");
266        assert_eq!(info.vendor_id, Some(0xFFF1));
267        assert_eq!(info.product_id, Some(0x8001));
268        let dc = info.discovery_capabilities.unwrap();
269        assert!(dc.has_on_network());
270    }
271}