host: store module: install links, allow-list, signed multi-app catalog, staging and installs
Moved from wayBetter for X (distribution.rs + desktop_store.rs) so every wayBetter app gets the Store; the app keeps its Tauri command wrappers and a `StoreConfig`. - store::catalog (pure): `StoreConfig` (app id in the shared catalog, product, user agent, link scheme, store URL, public keys, allowed hosts), `AllowedHost` (raw-files-only rule for a code host; DEFAULT_HOSTS = waybetter.fyi, cdn1.crt.fyi, git.crt.fyi raw), install-link parsing, download allow-list, theme validation, catalog format 2: every item names its `apps`; the whole catalog is verified (Ed25519 over the exact bytes with any listed key, so keys rotate; strict schema; ids/files unique per app) and each app gets only its own items (another app's item is shape-checked but not held to this app's hosts); per-item sha256/size, install records, versions. - store::install: `Store` (pending links, staged installs with TTL/bound, MIN_REVIEW before a commit, commit writes exactly the staged bytes after re-validation via PluginStore::install, plugin remove, records file, verified catalog + offline copy verified again when read back, StoreView). Plugin start/stop scripts run through a function the app passes. - store::net (`net` feature): no-redirect, no-cookie reqwest fetch with caps; stage_link, stage_item, catalog(refresh). - Tests: links, per-app allow-lists and schemes, themes, signatures incl. tampering and key rotation, multi-app filtering, bad items, staging/commit/remove/records/offline copy on disk, fetch refuses anything off the allow-list. test:rust and check:tauri build with `net`.
This commit is contained in:
parent
e510ab8d59
commit
ffab9eda2f
8 changed files with 1950 additions and 7 deletions
114
crates/waybetter-host/src/store/net.rs
Normal file
114
crates/waybetter-host/src/store/net.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
//! Store downloads (`net` feature): allow-listed https only, no redirects, no cookies, size caps.
|
||||
|
||||
use super::catalog::{self as dist, StoreConfig, MAX_CATALOG_BYTES, MAX_SIGNATURE_BYTES};
|
||||
use super::install::{InstallPreview, Store, StoreItemRef, StoreView};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
fn client(config: &StoreConfig) -> Result<&'static reqwest::Client, String> {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
if let Some(client) = CLIENT.get() {
|
||||
return Ok(client);
|
||||
}
|
||||
if rustls::crypto::CryptoProvider::get_default().is_none() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
}
|
||||
// No cookie store is configured: requests carry no cookies and keep none.
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(config.user_agent)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.map_err(|e| format!("NETWORK: {e}"))?;
|
||||
Ok(CLIENT.get_or_init(|| client))
|
||||
}
|
||||
|
||||
/// Downloads an allow-listed URL (re-checked here), no redirects or cookies, at most `max` bytes.
|
||||
pub async fn fetch(config: &StoreConfig, url: &str, max: usize) -> Result<Vec<u8>, String> {
|
||||
let url = Url::parse(url).map_err(|_| "VALIDATION: invalid URL")?;
|
||||
if !dist::allowed_download_url(config.allowed_hosts, &url) {
|
||||
return Err("VALIDATION: this address is not allowed".into());
|
||||
}
|
||||
let mut response = client(config)?.get(url).send().await.map_err(|e| format!("NETWORK: {e}"))?;
|
||||
let status = response.status().as_u16();
|
||||
if status != 200 {
|
||||
return Err(format!("NETWORK: the server answered {status}"));
|
||||
}
|
||||
if response.content_length().is_some_and(|len| len as usize > max) {
|
||||
return Err("VALIDATION: the file is larger than allowed".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| format!("NETWORK: {e}"))? {
|
||||
if bytes.len() + chunk.len() > max {
|
||||
return Err("VALIDATION: the file is larger than allowed".into());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Downloads what an install link points at and stages it for the confirmation screen.
|
||||
pub async fn stage_link(&self, kind: &str, url: &str) -> Result<InstallPreview, String> {
|
||||
let (link, file) = self.check_link(kind, url)?;
|
||||
let bytes = fetch(self.config(), &link.url, link.kind.max_bytes()).await?;
|
||||
self.stage_link_download(&link, file, bytes)
|
||||
}
|
||||
|
||||
/// Downloads a store item, checks it against the signed catalog (size + sha256) and stages it.
|
||||
pub async fn stage_item(&self, item: &StoreItemRef) -> Result<InstallPreview, String> {
|
||||
let entry = self.catalog_item(item)?;
|
||||
let bytes = fetch(self.config(), &entry.url, entry.kind.max_bytes()).await?;
|
||||
self.stage_store_download(entry, bytes)
|
||||
}
|
||||
|
||||
/// The signed catalog (fetched at most every [`super::CATALOG_TTL`] unless `refresh`), with
|
||||
/// what is installed. Offline, the last verified copy is shown with the error.
|
||||
pub async fn catalog(&self, refresh: bool) -> StoreView {
|
||||
if !refresh && self.catalog_is_fresh() {
|
||||
return self.view(None);
|
||||
}
|
||||
let error = match self.fetch_catalog().await {
|
||||
Ok(()) => None,
|
||||
Err(error) => {
|
||||
self.use_cached_catalog();
|
||||
Some(error)
|
||||
}
|
||||
};
|
||||
self.view(error)
|
||||
}
|
||||
|
||||
async fn fetch_catalog(&self) -> Result<(), String> {
|
||||
let base = self.config().store_base;
|
||||
let body = fetch(self.config(), &format!("{base}catalog.json"), MAX_CATALOG_BYTES).await?;
|
||||
let signature = fetch(self.config(), &format!("{base}catalog.json.sig"), MAX_SIGNATURE_BYTES).await?;
|
||||
let signature = String::from_utf8(signature).map_err(|_| "STORE: invalid signature")?;
|
||||
self.accept_catalog(&body, &signature)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::store::catalog::Kind;
|
||||
|
||||
#[test]
|
||||
fn nothing_off_the_allow_list_is_requested() {
|
||||
let config = StoreConfig {
|
||||
app_id: "x",
|
||||
product: "t",
|
||||
user_agent: "t/0",
|
||||
scheme: "waybetter",
|
||||
store_base: dist::DEFAULT_STORE_BASE,
|
||||
public_keys: &[],
|
||||
allowed_hosts: dist::DEFAULT_HOSTS,
|
||||
};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();
|
||||
for url in ["http://waybetter.fyi/a.json", "https://evil.example/a.json", "https://waybetter.fyi:444/a.json", "not a url"] {
|
||||
let error = runtime.block_on(fetch(&config, url, 10)).unwrap_err();
|
||||
assert!(error.starts_with("VALIDATION:"), "{url}: {error}");
|
||||
}
|
||||
assert_eq!(Kind::Theme.max_bytes(), dist::MAX_THEME_BYTES);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue