lexe/unstable/
provision.rs

1//! Provision-related helpers and utilities.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    sync::LazyLock,
6};
7
8use anyhow::Context;
9use lexe_api::def::AppNodeProvisionApi;
10use lexe_common::{
11    api::{provision::NodeProvisionRequest, version::NodeEnclave},
12    constants,
13    releases::Release,
14};
15use lexe_enclave::enclave;
16use lexe_node_client::client::NodeClient;
17use lexe_tokio::task::LxTask;
18use serde::Deserialize;
19use tracing::{debug, info, info_span, warn};
20
21use crate::{config::WalletEnv, types::auth::RootSeed};
22
23/// The contents of `public/releases.json`.
24pub static RELEASES_JSON: &str = include_str!("../../data/releases.json");
25
26/// The measurements of the three latest trusted node releases.
27/// This is the set of measurements that we want to provision.
28/// There is no need to provision anything older than this.
29pub static LATEST_TRUSTED_MEASUREMENTS: LazyLock<
30    BTreeSet<enclave::Measurement>,
31> = LazyLock::new(|| {
32    trusted_node_releases()
33        .values()
34        .rev()
35        .take(constants::RELEASE_WINDOW_SIZE)
36        .map(|release| release.measurement)
37        .collect()
38});
39
40/// Models the structure of a releases.json file.
41#[derive(Deserialize)]
42pub struct ReleasesJson(
43    pub BTreeMap<String, BTreeMap<semver::Version, Release>>,
44);
45
46/// The set of trusted node releases (populated from releases.json).
47///
48/// The user trusts these releases simply by installing the open-source app or
49/// SDK library which has these values hard-coded. This prevents Lexe from
50/// pushing out unilateral node updates without the user's consent.
51pub fn trusted_node_releases() -> BTreeMap<semver::Version, Release> {
52    releases_json().0.remove("node").unwrap_or_default()
53}
54
55/// Parses [`RELEASES_JSON`] into a [`ReleasesJson`].
56pub fn releases_json() -> ReleasesJson {
57    serde_json::from_str(RELEASES_JSON).expect("Invalid releases.json")
58}
59
60// TODO(max): Questionable whether it's actually OK to spawn tokio tasks here.
61// Does it complicate app FFI for downstream devs? Python SDK?
62// Maybe we should just provision everything inline, especially once we
63// implement server-side calculation of enclaves_to_provision, as
64// `LexeWallet`s without persistence won't need to always try to provision
65// everything returned by `current_enclaves()`.
66
67/// Helper to provision to the given enclaves.
68///
69/// - `allow_gvfs_access`: See [`NodeProvisionRequest::allow_gvfs_access`].
70/// - `google_auth_code`: See [`NodeProvisionRequest::google_auth_code`].
71/// - `maybe_encrypted_seed`: See [`NodeProvisionRequest::encrypted_seed`].
72pub(crate) async fn provision_all(
73    node_client: NodeClient,
74    mut enclaves_to_provision: BTreeSet<NodeEnclave>,
75    root_seed: RootSeed,
76    wallet_env: WalletEnv,
77    google_auth_code: Option<String>,
78    allow_gvfs_access: bool,
79    encrypted_seed: Option<Vec<u8>>,
80) -> anyhow::Result<()> {
81    debug!("Starting provisioning: {enclaves_to_provision:?}");
82
83    // Make sure the latest trusted version is provisioned before we return,
84    // so that when we request a node run, Lexe runs the latest version.
85    let latest = match enclaves_to_provision.pop_last() {
86        Some(enclave) => enclave,
87        None => {
88            debug!("No enclaves to provision");
89            return Ok(());
90        }
91    };
92
93    // Provision the latest trusted enclave inline
94    let root_seed_clone = clone_root_seed(&root_seed);
95    provision_one(
96        &node_client,
97        latest,
98        root_seed_clone,
99        wallet_env,
100        google_auth_code.clone(),
101        allow_gvfs_access,
102        encrypted_seed.clone(),
103    )
104    .await?;
105
106    // Early return if no work left to do
107    if enclaves_to_provision.is_empty() {
108        return Ok(());
109    }
110
111    // Provision remaining versions asynchronously so that we don't block
112    // app startup.
113
114    // TODO(max): In the future we may want to drive the secondary
115    // provisioning in function calls instead of background tasks. Some sage
116    // advice from wizard Philip:
117    //
118    // """
119    // I've found that structuring everything as function calls driven by
120    // the flutter frontend to the app-rs library ends up being the
121    // best approach in the end.
122    //
123    // - The flutter frontend owns the page and app lifecycle, best understands
124    //   what calls and services are relevant, and trying to keep that in sync
125    //   with Rust is cumbersome.
126    // - It's much easier to mock out RPC-style fn calls for design work.
127    // - Reporting errors to the user is also easy, since the error gets bubbled
128    //   up to the frontend to display.
129    // - If a background task has an error, there's no clear way to report to
130    //   the user, so you just log and things are silently broken.
131    // """
132    const SPAN_NAME: &str = "(secondary-provision)";
133    let task =
134        LxTask::spawn_with_span(SPAN_NAME, info_span!(SPAN_NAME), async move {
135            // NOTE: We provision enclaves serially because each provision
136            // updates the approved versions list, and we don't currently
137            // have a locking mechanism.
138            for node_enclave in enclaves_to_provision {
139                let root_seed_clone = clone_root_seed(&root_seed);
140                let provision_result = provision_one(
141                    &node_client,
142                    node_enclave.clone(),
143                    root_seed_clone,
144                    wallet_env,
145                    google_auth_code.clone(),
146                    allow_gvfs_access,
147                    encrypted_seed.clone(),
148                )
149                .await;
150
151                if let Err(e) = provision_result {
152                    warn!(
153                        version = %node_enclave.version,
154                        measurement = %node_enclave.measurement,
155                        machine_id = %node_enclave.machine_id,
156                        "Secondary provision failed: {e:#}"
157                    );
158                }
159            }
160
161            debug!("Secondary provisioning complete");
162        });
163
164    // TODO(max): Ideally, we could await on this ephemeral task somewhere
165    // for structured concurrency. But not sure if it even matters, as the
166    // mobile OS will often just kill the app.
167    task.detach();
168
169    Ok(())
170}
171
172/// Provisions a single enclave.
173async fn provision_one(
174    node_client: &NodeClient,
175    enclave: NodeEnclave,
176    root_seed: RootSeed,
177    wallet_env: WalletEnv,
178    google_auth_code: Option<String>,
179    allow_gvfs_access: bool,
180    // TODO(max): We could have cheaper cloning by using Bytes here
181    encrypted_seed: Option<Vec<u8>>,
182) -> anyhow::Result<()> {
183    let provision_req = NodeProvisionRequest {
184        root_seed: root_seed.into_unstable(),
185        deploy_env: wallet_env.deploy_env,
186        network: wallet_env.network,
187        google_auth_code,
188        allow_gvfs_access,
189        encrypted_seed,
190    };
191    node_client
192        .provision(enclave.measurement, provision_req)
193        .await
194        .context("Failed to provision node")?;
195
196    info!(
197        version = %enclave.version,
198        measurement = %enclave.measurement,
199        machine_id = %enclave.machine_id,
200        "Provision success:"
201    );
202
203    Ok(())
204}
205
206/// Clone a RootSeed reference into a new RootSeed instance.
207// TODO(phlip9): we should get rid of this helper eventually. We could
208// use something like a `Cow<'a, &RootSeed>` in `NodeProvisionRequest`. Ofc
209// we still have the seed serialized in a heap-allocated json blob when we
210// make the request, which is much harder for us to zeroize...
211pub fn clone_root_seed(root_seed_ref: &RootSeed) -> RootSeed {
212    RootSeed::from_bytes(root_seed_ref.as_bytes())
213        .expect("RootSeed always contains 32 bytes")
214}
215
216#[cfg(test)]
217mod test {
218    use super::*;
219
220    /// Test that [`LATEST_TRUSTED_MEASUREMENTS`] doesn't panic and contains an
221    /// entry. Implicitly tests [`trusted_releases`] and [`releases_json`].
222    #[test]
223    fn test_trusted_measurements() {
224        assert!(!LATEST_TRUSTED_MEASUREMENTS.is_empty());
225    }
226}