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}