Skip to main content

app_facade_api/
host_provision.rs

1//! Native-Client provisioning of embedded Hosts over BLE. See
2//! `contract/host-provisioning-v1.md`. Discovery is shared with pods
3//! (`/local/pod/discovery/*`, candidates of kind `host`).
4use serde::{Deserialize, Serialize};
5
6use crate::pod::{CommissionError, CommissionErrorCode, PodCandidate, WifiNetwork};
7
8pub const PROVISION_START_TARGET: &str = "/local/host/provision/start";
9pub const PROVISION_CODE_TARGET: &str = "/local/host/provision/code";
10pub const PROVISION_WIFI_TARGET: &str = "/local/host/provision/wifi";
11pub const PROVISION_WIFI_SCAN_TARGET: &str = "/local/host/provision/wifi/scan";
12pub const PROVISION_STATUS_TARGET: &str = "/local/host/provision/status";
13
14pub const PROVISION_CHANGED_EVENT: &str = "/local/host/provision/changed";
15
16pub const LOCAL_TARGETS: &[&str] = &[
17    PROVISION_START_TARGET,
18    PROVISION_CODE_TARGET,
19    PROVISION_WIFI_TARGET,
20    PROVISION_WIFI_SCAN_TARGET,
21    PROVISION_STATUS_TARGET,
22];
23pub const EVENT_TARGETS: &[&str] = &[PROVISION_CHANGED_EVENT];
24
25/// App info capability in the Host's `proto-ver` response.
26pub const APP_CAPABILITY: &str = "host";
27pub use crate::pod::{APP_INFO_LABEL, APP_INFO_LOCKED, APP_INFO_LOCKED_FOR_MS};
28/// Largest request and encrypted response value; scan results are paged to fit.
29pub const MAX_ATTRIBUTE_BYTES: usize = 512;
30/// The Host answers `prov-scan` start within this long.
31pub const SCAN_TIMEOUT_MS: i64 = 12_000;
32pub const HOST_INFO_ENDPOINT: &str = "host-info";
33pub const HOST_ENDPOINT_UUIDS: &[(&str, u16)] = &[(HOST_INFO_ENDPOINT, 0xFF54)];
34/// The Host keeps BLE open this long after a successful join without `finish`.
35pub const FINISH_GRACE_MS: i64 = 30_000;
36/// Handshakes are refused this long after each code rotation caused by failed
37/// handshakes, doubling per consecutive rotation up to `CODE_LOCKOUT_MAX_MS`.
38pub const CODE_LOCKOUT_MS: i64 = 30_000;
39pub const CODE_LOCKOUT_MAX_MS: i64 = 600_000;
40/// Unpadded base64url of 32 random bytes.
41pub const CLAIM_TOKEN_LEN: usize = 43;
42
43pub fn is_claim_token(token: &str) -> bool {
44    token.len() == CLAIM_TOKEN_LEN
45        && token
46            .bytes()
47            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
48}
49
50/// Requests reuse the pod request shapes: start takes `candidateId`, code takes
51/// `sessionId` and `code`, Wi-Fi takes `sessionId`, `ssid` and `password`, the
52/// rest take `sessionId` (status takes `{}`).
53pub use crate::pod::{
54    CommissionCodeRequest as ProvisionCodeRequest,
55    CommissionSessionRequest as ProvisionSessionRequest,
56    CommissionStartRequest as ProvisionStartRequest, CommissionWifiRequest as ProvisionWifiRequest,
57};
58
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "snake_case")]
61pub enum ProvisionState {
62    Connecting,
63    AwaitingCode,
64    Securing,
65    ReadingInfo,
66    AwaitingWifi,
67    JoiningWifi,
68    Completed,
69    Failed,
70    TimedOut,
71}
72
73impl ProvisionState {
74    pub fn is_terminal(self) -> bool {
75        matches!(self, Self::Completed | Self::Failed | Self::TimedOut)
76    }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80#[serde(rename_all = "camelCase", deny_unknown_fields)]
81pub struct HostDeviceInfo {
82    pub host_id: String,
83    pub name: String,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub product_id: Option<String>,
86    pub firmware_version: String,
87    pub fingerprint: String,
88    pub wifi_configured: bool,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct ProvisionSession {
94    pub session_id: String,
95    pub revision: u64,
96    pub state: ProvisionState,
97    pub candidate: PodCandidate,
98    pub expires_at_ms: i64,
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub host: Option<HostDeviceInfo>,
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub networks: Vec<WifiNetwork>,
103    /// IPv4 LAN addresses reported by the Host once it joined; may be empty.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub addresses: Vec<String>,
106    /// One-time token for the first claim of this Host, from `finish`.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub claim_token: Option<String>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub error: Option<CommissionError>,
111}
112
113impl ProvisionSession {
114    pub fn validate(&self) -> Result<(), &'static str> {
115        match (self.state, self.error.as_ref().map(|error| error.code)) {
116            (ProvisionState::Failed | ProvisionState::TimedOut, None) => {
117                return Err("failed and timed_out sessions carry an error");
118            }
119            (ProvisionState::Failed | ProvisionState::TimedOut, Some(_))
120            | (_, None)
121            | (ProvisionState::AwaitingCode, Some(CommissionErrorCode::CodeRejected))
122            | (
123                ProvisionState::AwaitingWifi,
124                Some(
125                    CommissionErrorCode::WifiAuthFailed
126                    | CommissionErrorCode::WifiNotFound
127                    | CommissionErrorCode::WifiFailed,
128                ),
129            ) => {}
130            _ => {
131                return Err(
132                    "only failed, timed_out and retryable code or Wi-Fi states carry an error",
133                );
134            }
135        }
136        if let Some(code) = self.error.as_ref().map(|error| error.code) {
137            if matches!(
138                code,
139                CommissionErrorCode::IssueFailed
140                    | CommissionErrorCode::DeliveryFailed
141                    | CommissionErrorCode::ActivationFailed
142            ) {
143                return Err("credential error codes do not apply to Host provisioning");
144            }
145        }
146        if self.state == ProvisionState::Completed && self.host.is_none() {
147            return Err("completed sessions carry the Host information");
148        }
149        match self.claim_token.as_deref() {
150            Some(_) if self.state != ProvisionState::Completed => {
151                return Err("only completed sessions carry a claim token");
152            }
153            Some(token) if !is_claim_token(token) => {
154                return Err("claim tokens are 43 base64url characters");
155            }
156            _ => {}
157        }
158        Ok(())
159    }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163#[serde(rename_all = "camelCase", deny_unknown_fields)]
164pub struct ProvisionStatus {
165    pub session: Option<ProvisionSession>,
166}
167
168/// `host-info` `{"op":"finish"}` response. `claimToken` is present after a
169/// successful join.
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(rename_all = "camelCase")]
172pub struct HostFinishResponse {
173    pub ok: bool,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub claim_token: Option<String>,
176}