diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a9c72e39..9a4af689a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,7 @@ jobs: runs-on: ubuntu-latest outputs: release_id: ${{ steps.release.outputs.id }} + official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }} steps: - uses: actions/checkout@v4 with: { ref: "${{ env.TAG }}" } @@ -36,6 +37,20 @@ jobs: echo "::error::tag $TAG does not match tauri.conf.json version $app" exit 1 fi + # The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed + # `openidea-plugin keygen` writes), built into the app: their packages + # must carry its signature whatever the marketplace index lists. Without + # the secret the app takes the index's word, as development builds do. + - id: plugin-keys + env: + SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }} + run: | + if [ -n "$SIGNING_KEY" ]; then + seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')" + key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p | + openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)" + echo "keys=$key" >> "$GITHUB_OUTPUT" + fi - id: release uses: actions/github-script@v7 with: @@ -58,6 +73,8 @@ jobs: build: needs: create-release name: Build (${{ matrix.name }}) + env: + OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }} strategy: fail-fast: false matrix: diff --git a/Cargo.lock b/Cargo.lock index a9bce3786..76ff7212d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4276,6 +4276,7 @@ dependencies = [ "regex-syntax", "reqwest", "rustix", + "semver", "serde", "serde_json", "specta", diff --git a/crates/ide-extension/src/lib.rs b/crates/ide-extension/src/lib.rs index 880f6e02a..fdf63d950 100644 --- a/crates/ide-extension/src/lib.rs +++ b/crates/ide-extension/src/lib.rs @@ -27,8 +27,8 @@ mod when; pub use capability::{Capability, CapabilityInfo, Risk, host_matches}; pub use index::ContributionIndex; pub use manifest::{ - ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, - check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id, + ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES, + Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id, }; pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode}; pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet}; diff --git a/crates/ide-extension/src/manifest.rs b/crates/ide-extension/src/manifest.rs index 25d19c45d..d079cff2d 100644 --- a/crates/ide-extension/src/manifest.rs +++ b/crates/ide-extension/src/manifest.rs @@ -38,8 +38,8 @@ pub const LEGACY_API: &str = "0.1"; /// Marketplace categories, in the order they are shown. pub const CATEGORIES: &[&str] = &["languages", "themes", "icon-themes", "linters", "vcs", "ai", "frameworks", "tools", "other"]; -/// What a manifest may declare in `categories`. -const MAX_CATEGORIES: usize = 3; +/// Categories a plugin is listed under; later ones are ignored. +pub const MAX_CATEGORIES: usize = 3; #[derive(Clone, Debug, PartialEq)] pub struct Manifest { @@ -300,9 +300,6 @@ impl Manifest { return Err(format!("duplicate activation event {event}")); } } - if self.declared_categories.len() > MAX_CATEGORIES { - return Err(format!("`categories` lists at most {MAX_CATEGORIES}")); - } for c in &self.declared_categories { validate_item_id(c, "category")?; } @@ -342,12 +339,18 @@ impl Manifest { } impl Manifest { - /// Its marketplace categories, in [`CATEGORIES`] order: those declared that - /// this IDE knows; without any, those its contributions imply (themes, - /// icon themes, languages), else `other`. + /// Its marketplace categories, in [`CATEGORIES`] order: the first + /// [`MAX_CATEGORIES`] declared that this IDE knows; without any, those its + /// contributions imply (themes, icon themes, languages), else `other`. pub fn categories(&self) -> Vec { let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect(); - let mut out: Vec = pick(&|c| self.declared_categories.iter().any(|d| d == c)); + let mut known: Vec<&str> = Vec::new(); + for d in &self.declared_categories { + if CATEGORIES.contains(&d.as_str()) && !known.contains(&d.as_str()) && known.len() < MAX_CATEGORIES { + known.push(d); + } + } + let mut out: Vec = pick(&|c| known.contains(&c)); if out.is_empty() { out = pick(&|c| match c { "themes" => self.contributes.contains_key("themes"), @@ -474,7 +477,9 @@ mod tests { let declared_wins = m("categories = [\"tools\"]\n[[contributes.languages]]\nid = \"l\"\n").unwrap(); assert_eq!(declared_wins.categories(), ["tools"]); assert_eq!(m("").unwrap().categories(), ["other"]); - assert!(m("categories = [\"a\", \"b\", \"c\", \"d\"]\n").unwrap_err().contains("at most 3")); + // At most three: the first ones declared. + let many = m("categories = [\"vcs\", \"ai\", \"vcs\", \"tools\", \"themes\"]\n").unwrap(); + assert_eq!(many.categories(), ["vcs", "ai", "tools"]); assert!(m("categories = [\"Bad Name\"]\n").is_err()); } diff --git a/crates/ide-extension/src/package.rs b/crates/ide-extension/src/package.rs index e41c11fb2..f1bc9c040 100644 --- a/crates/ide-extension/src/package.rs +++ b/crates/ide-extension/src/package.rs @@ -14,6 +14,14 @@ //! revoked plugin version is disabled where it is installed. A plugin may be //! listed in several versions, each with the plugin API it needs: the IDE //! offers the newest one it can load. +//! - **Key continuity**: an installed plugin updates silently only to a +//! package signed with the same key, or with a key the installed one +//! endorsed ([`Endorsement`], a publisher rotating keys); any other signer +//! is the user's call. An IDE can pin a publisher to keys it was built with +//! ([`Index::pin_publisher`]): the index then can't replace them. +//! - **Format**: fields are only ever added (IDEs ignore those they don't +//! know); `format` grows only for changes older IDEs can't read, and they +//! still apply such an index's revocations ([`Index::parse`]). use crate::MANIFEST_FILE; use base64::Engine as _; @@ -42,6 +50,24 @@ fn signed_message(id: &str, version: &str, digest: &str) -> Vec { format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes() } +fn endorsement_message(publisher: &str, key: &str) -> Vec { + format!("openidea-key/1\n{publisher}\n{key}").into_bytes() +} + +/// Whether `signature` (base64) is `key`'s (base64) Ed25519 signature of +/// `message`; malformed keys and signatures are errors. +fn verify_ed25519(key: &str, message: &[u8], signature: &str) -> Result { + let key: [u8; 32] = B64 + .decode(key) + .ok() + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| "signature: malformed public key".to_owned())?; + let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?; + let sig: [u8; 64] = + B64.decode(signature).ok().and_then(|b| b.try_into().ok()).ok_or_else(|| "signature: malformed".to_owned())?; + Ok(key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok()) +} + /// `SIGNATURE.json` of a signed package. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -76,6 +102,12 @@ impl PublisherKey { B64.encode(self.0.verifying_key().to_bytes()) } + /// Vouches for `publisher`'s new key `key` (base64), the one that follows this one. + pub fn endorse(&self, publisher: &str, key: &str) -> Endorsement { + let sig = self.0.sign(&endorsement_message(publisher, key)); + Endorsement { key: self.public_base64(), signature: B64.encode(sig.to_bytes()) } + } + pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature { let sig = self.0.sign(&signed_message(id, version, digest)); PackageSignature { @@ -90,19 +122,11 @@ impl PackageSignature { /// Whether the signature is valid for `id`, `version` and `digest` (the /// key's validity is checked against an [`Index`]). pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> { - let key: [u8; 32] = B64 - .decode(&self.key) - .ok() - .and_then(|b| b.try_into().ok()) - .ok_or_else(|| "signature: malformed public key".to_owned())?; - let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?; - let sig: [u8; 64] = B64 - .decode(&self.signature) - .ok() - .and_then(|b| b.try_into().ok()) - .ok_or_else(|| "signature: malformed".to_owned())?; - key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig)) - .map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned()) + if verify_ed25519(&self.key, &signed_message(id, version, digest), &self.signature)? { + Ok(()) + } else { + Err("the signature does not match the package (it was changed after signing)".to_owned()) + } } } @@ -259,7 +283,7 @@ pub struct Publisher { pub keys: Vec, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(rename_all = "camelCase")] pub struct KeyEntry { /// Ed25519 public key, base64. @@ -267,6 +291,27 @@ pub struct KeyEntry { /// Packages signed with it are no longer trusted. #[serde(default)] pub revoked: bool, + /// The publisher's earlier key vouching for this one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endorsement: Option, +} + +impl KeyEntry { + pub fn new(key: String) -> Self { + Self { key, ..Self::default() } + } +} + +/// A publisher's earlier key vouching for a newer one: plugins installed from +/// packages signed with the earlier key update to ones signed with the newer +/// key without asking the user. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct Endorsement { + /// The endorsing key, base64. + pub key: String, + /// Its Ed25519 signature of `openidea-key/1\n\n`, base64. + pub signature: String, } /// A published plugin version. @@ -289,7 +334,7 @@ pub struct IndexEntry { pub capabilities: Vec, /// The plugin API versions it loads in (a semver requirement): its /// manifest's `engines.openidea` and the API its component was built for. - /// Empty: any. + /// Required: an entry without one is offered to no IDE. #[serde(default)] pub engine: String, /// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. @@ -300,7 +345,7 @@ pub struct IndexEntry { impl IndexEntry { /// Whether this IDE can load the version. pub fn compatible(&self) -> bool { - self.engine.is_empty() || crate::engine_compatible(&self.engine, false) + crate::engine_compatible(&self.engine, false) } fn semver(&self) -> Option { @@ -328,12 +373,34 @@ pub struct Revocation { } impl Index { + /// Parses an index. One in a newer format keeps what must reach every IDE — + /// revocations, publisher keys (revoked ones) and `moved` — and lists no + /// plugins ([`Index::is_newer`]): the IDE needs updating to install from it. pub fn parse(text: &str) -> Result { - let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?; - if index.format != INDEX_FORMAT { - return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format)); + let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?; + let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?; + if format == u64::from(INDEX_FORMAT) { + return serde_json::from_value(value).map_err(|e| format!("index: {e}")); } - Ok(index) + if format < u64::from(INDEX_FORMAT) { + return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})")); + } + fn each(value: &serde_json::Value, field: &str) -> Vec { + let items = value.get(field).and_then(serde_json::Value::as_array); + items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect() + } + Ok(Index { + format: u32::try_from(format).unwrap_or(u32::MAX), + publishers: each(&value, "publishers"), + plugins: Vec::new(), + revoked: each(&value, "revoked"), + moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned), + }) + } + + /// Written in a format newer than this IDE reads: it lists no plugins. + pub fn is_newer(&self) -> bool { + self.format > INDEX_FORMAT } /// The version of each plugin to offer, and whether this IDE can load it: @@ -407,6 +474,58 @@ impl Index { Ok(publisher.id.clone()) } + /// Whether `to` is `from`, or a key of `publisher` that `from` endorsed, + /// directly or through keys it endorsed. Endorsements by revoked keys don't + /// count: whoever stole a key could vouch for their own. + pub fn succeeds(&self, publisher: &str, from: &str, to: &str) -> bool { + let keys = self.publishers.iter().find(|p| p.id == publisher).map_or(&[][..], |p| &p.keys[..]); + let mut key = to; + // Each step moves to an earlier key; more steps than keys is a loop. + for _ in 0..=keys.len() { + if key == from { + return true; + } + let Some(e) = keys.iter().find(|k| k.key == key).and_then(|k| k.endorsement.as_ref()) else { + return false; + }; + let endorser_valid = keys.iter().any(|k| k.key == e.key && !k.revoked); + if !endorser_valid || verify_ed25519(&e.key, &endorsement_message(publisher, key), &e.signature) != Ok(true) + { + return false; + } + key = &e.key; + } + false + } + + /// Makes `roots` (base64) the keys `publisher` signs with, whatever this + /// index says: another key it lists stays only when a root endorsed it (a + /// rotation the roots vouched for). Revocations of the keys are kept. + pub fn pin_publisher(&mut self, publisher: &str, name: &str, roots: &[String]) { + if roots.is_empty() { + return; + } + let i = match self.publishers.iter().position(|p| p.id == publisher) { + Some(i) => i, + None => { + self.publishers.push(Publisher { id: publisher.into(), name: name.into(), keys: Vec::new() }); + self.publishers.len() - 1 + } + }; + for root in roots { + if !self.publishers[i].keys.iter().any(|k| &k.key == root) { + self.publishers[i].keys.push(KeyEntry::new(root.clone())); + } + } + let keys = self.publishers[i].keys.clone(); + let trusted = keys + .into_iter() + .filter(|k| roots.iter().any(|r| self.succeeds(publisher, r, &k.key))) + .map(|k| if roots.contains(&k.key) { KeyEntry { endorsement: None, ..k } } else { k }) + .collect(); + self.publishers[i].keys = trusted; + } + /// Replaces what this index lists for every publisher but `keep` with what /// `other` lists: its publishers, their plugins and their revocations /// (`other`'s downloads should already be absolute). `other` may only @@ -430,6 +549,9 @@ impl Index { if !theirs.contains(&e.publisher.as_str()) || !owns(&e.publisher, &e.id) { return Err(format!("{}: not a plugin of publisher {:?}", e.id, e.publisher)); } + if semver::VersionReq::parse(&e.engine).is_err() { + return Err(format!("{} {}: `engine` must be the plugin API it needs", e.id, e.version)); + } } if let Some(r) = other.revoked.iter().find(|r| !owned_by(&r.id)) { return Err(format!("{}: revoked by an index that doesn't publish it", r.id)); @@ -445,6 +567,25 @@ impl Index { } } +/// Whether a package signed `new` (`None`: unsigned) may replace an installed +/// one signed `installed` without asking the user: signed with the same key, +/// or with one the installed key endorsed (per `index`). An unsigned installed +/// copy pins nothing. +pub fn signer_continues( + index: Option<&Index>, + installed: Option<&PackageSignature>, + new: Option<&PackageSignature>, +) -> bool { + match (installed, new) { + (None, _) => true, + (Some(_), None) => false, + (Some(old), Some(new)) => { + old.publisher == new.publisher + && (old.key == new.key || index.is_some_and(|i| i.succeeds(&new.publisher, &old.key, &new.key))) + } + } +} + /// Whether the plugin `id` is in `publisher`'s namespace. fn owns(publisher: &str, id: &str) -> bool { id.strip_prefix(publisher).is_some_and(|rest| rest.starts_with('.')) @@ -472,7 +613,7 @@ mod tests { publishers: vec![Publisher { id: "acme".into(), name: "ACME".into(), - keys: vec![KeyEntry { key: key.public_base64(), revoked: false }], + keys: vec![KeyEntry::new(key.public_base64())], }], plugins: Vec::new(), revoked: vec![Revocation { @@ -529,7 +670,7 @@ mod tests { download: format!("https://example.com/{id}.oiplugin"), digest: "d".into(), capabilities: Vec::new(), - engine: String::new(), + engine: "^1.0".into(), categories: Vec::new(), } } @@ -562,7 +703,8 @@ mod tests { let expected = [ ("acme.a".to_owned(), "1.2.0".to_owned(), true), ("acme.b".to_owned(), "0.2.0".to_owned(), false), - ("acme.c".to_owned(), "1.0.0".to_owned(), true), + // No `engine`: listed, but no IDE installs it. + ("acme.c".to_owned(), "1.0.0".to_owned(), false), ]; assert_eq!(offered(&index), expected); @@ -579,7 +721,7 @@ mod tests { } fn publisher(id: &str) -> Publisher { - Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry { key: format!("{id}-key"), revoked: false }] } + Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry::new(format!("{id}-key"))] } } fn revocation(id: &str) -> Revocation { @@ -632,6 +774,14 @@ mod tests { (with(vec![publisher("alice")], vec![entry("alice.a", "bob")], vec![]), "not a plugin of"), (with(vec![publisher("alice")], vec![entry("alicex.a", "alice")], vec![]), "not a plugin of"), (with(vec![publisher("alice")], vec![], vec![revocation("openidea.a")]), "doesn't publish"), + ( + with( + vec![publisher("alice")], + vec![IndexEntry { engine: String::new(), ..entry("alice.a", "alice") }], + vec![], + ), + "`engine`", + ), ]; for (other, error) in cases { let mut index = base.clone(); @@ -655,7 +805,7 @@ mod tests { let err = unpack(&out, &tmp.path().join("dest")).unwrap_err(); assert!(err.contains("invalid path"), "{err}"); assert!(!tmp.path().join("outside.txt").exists()); - assert!(Index::parse(r#"{"format": 2}"#).is_err()); + assert!(Index::parse(r#"{"format": 2}"#).unwrap().is_newer()); } fn zip_of(path: &Path, entries: &[(&str, &str)]) { @@ -704,4 +854,102 @@ mod tests { assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}"); } } + + #[test] + fn a_newer_index_still_revokes_and_moves() { + let newer = format!( + r#"{{"format": {}, "plugins": [{{"anything": "new"}}], "shape": "changed", + "publishers": [{{"id": "acme", "name": "ACME", "keys": [{{"key": "k1", "revoked": true}}]}}, {{"id": 5}}], + "revoked": [{{"id": "acme.bad", "reason": "malware"}}, {{"oops": 1}}], + "moved": "https://example.com/next.json"}}"#, + INDEX_FORMAT + 1 + ); + let index = Index::parse(&newer).unwrap(); + assert!(index.is_newer()); + assert!(index.plugins.is_empty() && index.offered().is_empty()); + assert_eq!(index.revocation("acme.bad", "2.0.0").unwrap().reason, "malware"); + assert!(index.key_revoked("k1")); + assert_eq!(index.moved.as_deref(), Some("https://example.com/next.json")); + // Fields this IDE doesn't know are ignored in its own format. + let current = format!(r#"{{"format": {INDEX_FORMAT}, "later": true, "plugins": []}}"#); + assert!(!Index::parse(¤t).unwrap().is_newer()); + assert!(Index::parse(r#"{"format": 0}"#).unwrap_err().contains("not supported")); + assert!(Index::parse(r#"{"plugins": []}"#).is_err()); + } + + fn keys<'a>(index: &'a mut Index, publisher: &str) -> &'a mut Vec { + &mut index.publishers.iter_mut().find(|p| p.id == publisher).unwrap().keys + } + + #[test] + fn rotated_keys_continue_through_endorsements() { + let (k1, k2, k3) = + (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32])); + let mut index = index(&k1); + let endorsed = |by: &PublisherKey, key: &PublisherKey| KeyEntry { + endorsement: Some(by.endorse("acme", &key.public_base64())), + ..KeyEntry::new(key.public_base64()) + }; + keys(&mut index, "acme").push(endorsed(&k1, &k2)); + keys(&mut index, "acme").push(endorsed(&k2, &k3)); + let (p1, p2, p3) = (k1.public_base64(), k2.public_base64(), k3.public_base64()); + assert!(index.succeeds("acme", &p1, &p1)); + assert!(index.succeeds("acme", &p1, &p3), "through k2"); + assert!(!index.succeeds("acme", &p3, &p1), "not backwards"); + assert!(!index.succeeds("other", &p1, &p2)); + // An endorsement for another publisher, or by a revoked key, doesn't count. + let mut foreign = index.clone(); + keys(&mut foreign, "acme")[1].endorsement = Some(k1.endorse("evil", &p2)); + assert!(!foreign.succeeds("acme", &p1, &p2)); + let mut stolen = index.clone(); + keys(&mut stolen, "acme")[0].revoked = true; + assert!(!stolen.succeeds("acme", &p1, &p2)); + + let sig = |key: &PublisherKey| key.sign("acme", "acme.tools", "1.0.0", "d"); + assert!(signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&k3)))); + assert!(signer_continues(None, Some(&sig(&k1)), Some(&sig(&k1)))); + assert!(!signer_continues(None, Some(&sig(&k1)), Some(&sig(&k2))), "no index to vouch"); + let stranger = PublisherKey::from_seed([4; 32]); + assert!(!signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&stranger)))); + assert!(!signer_continues(Some(&index), Some(&sig(&k1)), None), "signed, then unsigned"); + assert!(signer_continues(Some(&index), None, Some(&sig(&stranger))), "unsigned pins nothing"); + } + + #[test] + fn pinned_publishers_keep_their_built_in_keys() { + let (root, next, intruder) = + (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([5; 32])); + let mut index = index(&intruder); + index.publishers[0].id = "openidea".into(); + keys(&mut index, "openidea").push(KeyEntry { + endorsement: Some(root.endorse("openidea", &next.public_base64())), + ..KeyEntry::new(next.public_base64()) + }); + let roots = [root.public_base64()]; + let listed = |index: &Index| -> Vec<(String, bool)> { + let mut index = index.clone(); + index.pin_publisher("openidea", "OpenIDEA", &roots); + keys(&mut index, "openidea").iter().map(|k| (k.key.clone(), k.revoked)).collect() + }; + // The intruder's key goes, the successor the root endorsed stays, the root is added. + assert_eq!(listed(&index), [(next.public_base64(), false), (root.public_base64(), false)]); + let mut pinned = index.clone(); + pinned.pin_publisher("openidea", "OpenIDEA", &roots); + let digest = "d"; + let signed = |k: &PublisherKey| k.sign("openidea", "openidea.tools", "1.0.0", digest); + assert!(pinned.verify(&signed(&intruder), "openidea.tools", "1.0.0", digest).is_err()); + assert!(pinned.verify(&signed(&next), "openidea.tools", "1.0.0", digest).is_ok()); + // A revoked root stays revoked, and what it endorsed is no longer trusted. + keys(&mut index, "openidea").push(KeyEntry { revoked: true, ..KeyEntry::new(root.public_base64()) }); + assert_eq!(listed(&index), [(root.public_base64(), true)]); + + // A publisher missing from the index is added with its roots. + let mut empty = Index { format: INDEX_FORMAT, ..Index::default() }; + empty.pin_publisher("openidea", "OpenIDEA", &[root.public_base64()]); + assert_eq!(empty.verify(&signed(&root), "openidea.tools", "1.0.0", digest).unwrap(), "openidea"); + // No roots: the index decides. + let mut open = index.clone(); + open.pin_publisher("openidea", "OpenIDEA", &[]); + assert_eq!(open, index); + } } diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml index 70c843e77..a8473b6b7 100644 --- a/crates/ide-host/Cargo.toml +++ b/crates/ide-host/Cargo.toml @@ -46,6 +46,7 @@ ide-extension.workspace = true ide-plugin.workspace = true ide-tools = { path = "../ide-tools" } ide-docker = { path = "../ide-docker" } +semver = "1" serde.workspace = true serde_json.workspace = true toml.workspace = true diff --git a/crates/ide-host/src/bin/openidea-plugin/main.rs b/crates/ide-host/src/bin/openidea-plugin/main.rs index 107d6149d..c0bee922b 100644 --- a/crates/ide-host/src/bin/openidea-plugin/main.rs +++ b/crates/ide-host/src/bin/openidea-plugin/main.rs @@ -8,8 +8,9 @@ //! openidea-plugin unlink [dir] //! openidea-plugin links # list linked folders //! openidea-plugin keygen # a publisher's Ed25519 key +//! openidea-plugin endorse --key OLD --publisher ID # vouch for a new key //! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # -.oiplugin -//! openidea-plugin index [--publisher ID --name NAME --key F] # marketplace index.json +//! openidea-plugin index [--publisher ID --name NAME --key F [--previous-key F]] # marketplace index.json //! openidea-plugin merge --keep ID # list another index's publishers //! ``` //! @@ -39,13 +40,19 @@ commands: unlink [dir] stop loading a linked folder links list linked folders keygen create a publisher signing key (keep the file secret) + endorse --key F --publisher ID + print an earlier key's endorsement of a new one (JSON), for an + index the publisher doesn't write with `index --previous-key` pack [dir] [--key F --publisher ID] [--out FILE] write -.oiplugin, signed with the key - index [--publisher ID --name NAME --key F] [--out FILE] [--download-base URL] + index [--publisher ID --name NAME --key F [--previous-key F]] [--out FILE] + [--download-base URL] write the marketplace index.json for the packages in dir: with a key, register it and list that publisher's plugins (earlier versions stay for older IDEs); without, list only these packages, - each checked against the index's publishers + each checked against the index's publishers. A new key replacing + an earlier one is endorsed with --previous-key, so installed + plugins update to it without asking merge --keep ID [--out FILE] replace every publisher but ID in index with those of other-index (a path or URL), their plugins and revocations @@ -63,6 +70,7 @@ fn main() -> ExitCode { Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)), Some("links") => list_links(), Some("keygen") => cmd_keygen(&args[1..]), + Some("endorse") => cmd_endorse(&args[1..]), Some("pack") => cmd_pack(&args[1..]), Some("index") => cmd_index(&args[1..]), Some("merge") => cmd_merge(&args[1..]), @@ -368,6 +376,13 @@ fn check(dir: &Path) -> Result<(), String> { for c in manifest.unknown_categories() { println!(" warning: unknown category {c:?} (ignored); use {}", ide_extension::CATEGORIES.join(", ")); } + if manifest.declared_categories.len() > ide_extension::MAX_CATEGORIES { + println!( + " warning: only the first {} known categories are used; {} are declared", + ide_extension::MAX_CATEGORIES, + manifest.declared_categories.len() + ); + } if !manifest.activation.is_empty() { let events: Vec = manifest.activation.iter().map(ToString::to_string).collect(); println!(" activation: {}", events.join(", ")); @@ -500,6 +515,20 @@ fn cmd_keygen(args: &[String]) -> Result<(), String> { Ok(()) } +fn cmd_endorse(args: &[String]) -> Result<(), String> { + let a = Args::parse(args, &[], &["key", "publisher"])?; + let usage = "usage: openidea-plugin endorse --key --publisher ID"; + let ([new], Some(key), Some(publisher)) = (a.positional.as_slice(), a.value("key"), a.value("publisher")) else { + return Err(usage.into()); + }; + if !package::publisher_id_ok(publisher) { + return Err(format!("invalid publisher id {publisher:?}")); + } + let endorsement = read_key(key)?.endorse(publisher, new.trim()); + println!("{}", serde_json::to_string_pretty(&endorsement).map_err(|e| e.to_string())?); + Ok(()) +} + /// A fresh scratch directory, removed when dropped. fn scratch_dir(what: &str) -> Result { tempfile::Builder::new().prefix(&format!("openidea-plugin-{what}-")).tempdir().map_err(|e| e.to_string()) @@ -546,33 +575,25 @@ fn cmd_pack(args: &[String]) -> Result<(), String> { } fn cmd_index(args: &[String]) -> Result<(), String> { - let a = Args::parse(args, &[], &["publisher", "name", "key", "out", "download-base"])?; + let a = Args::parse(args, &[], &["publisher", "name", "key", "previous-key", "out", "download-base"])?; let dir = canonical(&a.dir()?)?; let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json")); let base = a.value("download-base").map(|b| b.trim_end_matches('/')); // Revocations and other publishers of an existing index are kept. let mut index = match std::fs::read_to_string(&out) { - Ok(text) => Index::parse(&text)?, + Ok(text) => current_format(Index::parse(&text)?, &out.to_string_lossy())?, Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() }, }; let publisher = match (a.value("publisher"), a.value("key")) { (Some(publisher), Some(key)) => { let key = read_key(key)?; let name = a.value("name").unwrap_or(publisher); - // Earlier keys of the publisher stay listed (packages they signed remain valid until revoked). - let current = key.public_base64(); - let earlier: Vec = index - .publishers - .iter() - .filter(|p| p.id == publisher) - .flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned()) - .collect(); - index.publishers.retain(|p| p.id != publisher); - let keys: Vec = - std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect(); - index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys }); + let previous = a.value("previous-key").map(read_key).transpose()?; + index.publishers = + register_key(std::mem::take(&mut index.publishers), publisher, name, &key, previous.as_ref())?; Some(publisher) } + (None, None) if a.has("previous-key") => return Err("--previous-key goes with --publisher and --key".into()), (None, None) => None, _ => return Err("--publisher and --key go together".into()), }; @@ -642,6 +663,60 @@ fn cmd_index(args: &[String]) -> Result<(), String> { write_index(&index, &out) } +/// `publishers` with `key` as `publisher`'s current key. Earlier keys stay listed +/// (packages they signed remain valid until revoked); a key new to the +/// publisher is endorsed by `previous`, one of them, so installed plugins +/// signed with it update without asking. +fn register_key( + mut publishers: Vec, + publisher: &str, + name: &str, + key: &PublisherKey, + previous: Option<&PublisherKey>, +) -> Result, String> { + let current = key.public_base64(); + let earlier: Vec = publishers.iter().filter(|p| p.id == publisher).flat_map(|p| p.keys.clone()).collect(); + let entry = match earlier.iter().find(|k| k.key == current) { + Some(listed) => listed.clone(), + None => { + let endorsement = match previous { + Some(prev) => { + let prev_key = prev.public_base64(); + if !earlier.iter().any(|k| k.key == prev_key && !k.revoked) { + return Err(format!("--previous-key is not one of {publisher}'s valid keys in the index")); + } + Some(prev.endorse(publisher, ¤t)) + } + None => { + if earlier.iter().any(|k| !k.revoked) { + println!( + " warning: {publisher}'s new key is not endorsed (--previous-key): users who installed \ + its plugins will be asked to confirm the change when they update" + ); + } + None + } + }; + KeyEntry { endorsement, ..KeyEntry::new(current.clone()) } + } + }; + let keys = std::iter::once(entry).chain(earlier.into_iter().filter(|k| k.key != current)).collect(); + publishers.retain(|p| p.id != publisher); + publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys }); + Ok(publishers) +} + +/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read). +fn current_format(index: Index, file: &str) -> Result { + if index.is_newer() { + return Err(format!( + "{file}: index format {} is newer than this tool writes; update openidea-plugin", + index.format + )); + } + Ok(index) +} + fn cmd_merge(args: &[String]) -> Result<(), String> { let a = Args::parse(args, &[], &["keep", "out"])?; let [base, other] = a.positional.as_slice() else { @@ -649,9 +724,11 @@ fn cmd_merge(args: &[String]) -> Result<(), String> { }; let keep = a.value("keep").ok_or("--keep is required")?; let out = a.value("out").unwrap_or(base); - let mut index = Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?; + let mut index = + current_format(Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?, base)?; let bytes = read_market_url(other, 16 << 20)?; - let mut theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?; + let theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?; + let mut theirs = current_format(theirs, other)?; // Their downloads are relative to their index, not to ours. for e in &mut theirs.plugins { e.download = resolve_market_href(other, &e.download); @@ -673,6 +750,25 @@ fn write_index(index: &Index, out: &Path) -> Result<(), String> { mod tests { use super::*; + #[test] + fn a_new_key_is_endorsed_by_the_previous_one() { + let (k1, k2, k3) = + (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32])); + let first = register_key(Vec::new(), "acme", "ACME", &k1, None).unwrap(); + let rotated = register_key(first.clone(), "acme", "ACME", &k2, Some(&k1)).unwrap(); + let index = Index { format: package::INDEX_FORMAT, publishers: rotated.clone(), ..Index::default() }; + assert!(index.succeeds("acme", &k1.public_base64(), &k2.public_base64())); + assert_eq!(rotated[0].keys.len(), 2, "the earlier key stays listed"); + // Registering the current key again keeps its endorsement. + let again = register_key(rotated.clone(), "acme", "ACME", &k2, None).unwrap(); + assert_eq!(again, rotated); + // Only a key the publisher has can endorse. + assert!(register_key(first.clone(), "acme", "ACME", &k2, Some(&k3)).unwrap_err().contains("--previous-key")); + // Without one the new key is listed unendorsed. + let unendorsed = register_key(first, "acme", "ACME", &k3, None).unwrap(); + assert!(unendorsed[0].keys[0].endorsement.is_none()); + } + #[test] fn names() { assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin"); @@ -776,7 +872,7 @@ mod tests { publishers: vec![package::Publisher { id: "alice".into(), name: "Alice".into(), - keys: vec![KeyEntry { key: keys[1].1.clone(), revoked: false }], + keys: vec![KeyEntry::new(keys[1].1.clone())], }], ..Index::default() }; diff --git a/crates/ide-host/src/plugins/market.rs b/crates/ide-host/src/plugins/market.rs index deab1ebce..061b5df29 100644 --- a/crates/ide-host/src/plugins/market.rs +++ b/crates/ide-host/src/plugins/market.rs @@ -3,11 +3,14 @@ //! plugins, and revocations. The host keeps the last index it read //! (`market-index.json`), checks signatures against it, and disables what it //! revokes — at start-up and on every refresh (the desktop refreshes daily). +//! Release builds pin the official publisher to the keys they were built +//! with, so no index can sign `openidea.*` plugins with another key. -use super::{PackageFile, PluginHost, PluginSource, PluginStatus}; +use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install}; use ide_core::{IdeError, IdeResult}; use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE}; use ide_extension::{MANIFEST_FILE, Manifest}; +use semver::Version; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -15,6 +18,9 @@ use std::path::{Path, PathBuf}; /// the app, next to the signed packages of the official plugins. pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json"; +/// The publisher of the official plugins. +pub const OFFICIAL_PUBLISHER: &str = "openidea"; + /// The last index read, in the plugins directory. pub const INDEX_FILE: &str = "market-index.json"; const DOWNLOADS: &str = ".downloads"; @@ -37,6 +43,30 @@ pub struct MarketPlugin { pub compatible: bool, } +impl MarketPlugin { + /// A version this IDE can load, newer than the one installed. + pub fn is_update(&self) -> bool { + let newer = |installed: &String| match (Version::parse(&self.entry.version), Version::parse(installed)) { + (Ok(offered), Ok(installed)) => offered > installed, + _ => false, + }; + self.compatible && self.installed.as_ref().is_some_and(newer) + } +} + +/// The official publisher's keys this IDE was built with +/// (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`: base64, comma-separated). Development +/// builds have none: the index decides. +pub(super) fn built_in_official_keys() -> Vec { + let keys = option_env!("OPENIDEA_OFFICIAL_PLUGIN_KEYS").unwrap_or_default(); + keys.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_owned).collect() +} + +fn pinned(mut index: Index, official_keys: &[String]) -> Index { + index.pin_publisher(OFFICIAL_PUBLISHER, "OpenIDEA", official_keys); + index +} + /// Reads `url`: `http(s)://…`, `file://…` or a local path. pub fn read_url(url: &str, max: usize) -> Result, String> { if url.starts_with("https://") || url.starts_with("http://") { @@ -108,9 +138,16 @@ pub fn resolve(base: &str, href: &str) -> String { } impl PluginHost { - pub(super) fn load_market_index(dir: &Path) -> Option { + pub(super) fn load_market_index(dir: &Path, official_keys: &[String]) -> Option { let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?; - Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok() + let index = Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()?; + Some(pinned(index, official_keys)) + } + + /// Pins the official publisher to `keys` (base64) instead of the built-in + /// ones, from the next index read. + pub fn set_official_keys(&self, keys: Vec) { + *self.official_keys.write() = keys; } /// The marketplace index URL: the user's, else the default (none: no marketplace). @@ -128,14 +165,16 @@ impl PluginHost { self.save_state(); } - /// Reads the index again, keeps it, and applies its revocations. + /// Reads the index again, keeps it, and applies its revocations. Announces + /// updates of installed plugins it hasn't announced yet. pub fn refresh_market(&self) -> IdeResult> { let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?; let index = read_index(&url)?; if let Ok(json) = serde_json::to_string(&index) { let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), json); } - *self.market.write() = Some(index); + let newer = index.is_newer().then_some(index.format); + *self.market.write() = Some(pinned(index, &self.official_keys.read())); // Trust may have changed: who signed what, what is revoked. let installed: Vec<_> = self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect(); @@ -144,7 +183,41 @@ impl PluginHost { plugin.meta.lock().publisher = publisher; } self.apply_revocations(); - Ok(self.market_plugins()) + if let Some(format) = newer { + return Err(IdeError::unavailable(format!( + "the marketplace needs a newer OpenIDEA (index format {format}); its revocations still apply" + ))); + } + let plugins = self.market_plugins(); + self.announce_updates(&plugins); + Ok(plugins) + } + + /// Tells the windows about updates once per set of versions: a later + /// refresh offering the same ones says nothing. + fn announce_updates(&self, plugins: &[MarketPlugin]) { + let updates: Vec = plugins + .iter() + .filter(|p| p.is_update()) + .map(|p| PluginUpdate { + id: p.entry.id.clone(), + name: p.entry.name.clone(), + version: p.entry.version.clone(), + }) + .collect(); + let key: Vec = updates.iter().map(|u| format!("{}@{}", u.id, u.version)).collect(); + if std::mem::replace(&mut *self.announced_updates.lock(), key.clone()) != key && !updates.is_empty() { + self.emit(None, PluginEvent::UpdatesAvailable { updates }); + } + } + + /// Whether the package in `src` would replace the installed `id`'s copy + /// with one signed by another key than that copy, without that key's + /// endorsement: the user confirms the change of signer. + pub(super) fn signer_changed(&self, id: &str, src: &Path) -> bool { + let Some(current) = install::active_dir(&self.plugins_dir.join(id)) else { return false }; + let market = self.market.read(); + !package::signer_continues(market.as_ref(), read_signature(¤t).as_ref(), read_signature(src).as_ref()) } /// The plugins of the last index read. diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index c32c028c0..0b82a9350 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -37,7 +37,7 @@ pub mod web; pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix}; pub use market::{ - INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, + INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER, read_url as read_market_url, resolve as resolve_market_href, }; pub use points::{COMMANDS, CommandDecl, register_builtin_points}; @@ -245,12 +245,16 @@ pub struct Consent { /// Optional capabilities the user accepted (`net:crates.io`, …). Required /// ones are implied by consenting at all. pub optional: Vec, + /// The user accepted a package signed by another key than the installed + /// copy ([`InstallPreview::signer_changed`]). + #[serde(default)] + pub signer_change: bool, } impl Consent { /// Accept everything the plugin asks for (tests, trusted official plugins). pub fn all() -> Self { - Self { optional: vec!["*".into()] } + Self { optional: vec!["*".into()], signer_change: true } } fn accepts(&self, cap: &Capability) -> bool { @@ -277,6 +281,14 @@ pub struct InstallPreview { pub unsigned: bool, /// The verified publisher. pub publisher: Option, + /// Replaces an installed copy signed with another key, which didn't + /// endorse this package's (or this one is unsigned): the user confirms it + /// ([`Consent::signer_change`]). + pub signer_changed: bool, + /// Something to confirm: a first install, an unsigned package, another + /// signer, or capabilities the installed version wasn't granted or + /// refused. Without, an update installs with the grants it has. + pub needs_consent: bool, } type ProjectPolicy = Arc bool + Send + Sync>; @@ -323,6 +335,10 @@ pub struct PluginHost { pins: Arc>>, /// The last marketplace index read: trust root for signatures, revocations. market: RwLock>, + /// The official publisher's keys the index is pinned to (none: as listed). + official_keys: RwLock>, + /// `id@version` of the updates last announced. + announced_updates: Mutex>, /// The marketplace used while the user has set none (the app's official one). default_market_url: RwLock>, /// Updates not confirmed yet (the new version hasn't started once): what @@ -361,9 +377,12 @@ impl PluginHost { policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned() })); let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone()); - let market = RwLock::new(Self::load_market_index(&plugins_dir)); + let official_keys = market::built_in_official_keys(); + let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys)); let host = Self { market, + official_keys: RwLock::new(official_keys), + announced_updates: Mutex::default(), default_market_url: RwLock::new(None), updates: Mutex::default(), project_policy: RwLock::new(Arc::new(|_| false)), @@ -706,14 +725,27 @@ impl PluginHost { } let publisher = self.verified_publisher(src); let m = &pkg.manifest; + let replaces = self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone()); + // An update shows what the installed version was granted. + let grant = replaces + .as_ref() + .and_then(|_| self.ledger.lock().get(&Subject::Installed(m.id.clone())).cloned()) + .filter(|g| g.confirmed); + let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.has(c)); let mut capabilities: Vec = - m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)).collect(); + m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect(); for extra in &set.capabilities { if !m.declares(extra) { - capabilities.push(CapabilityInfo::new(extra, true, false)); + capabilities.push(CapabilityInfo::new(extra, true, granted(extra))); } } capabilities.sort_by(|a, b| b.risk.cmp(&a.risk).then(a.id.cmp(&b.id))); + let signer_changed = self.signer_changed(&m.id, src); + let answered = |c: &CapabilityInfo| { + c.granted || (c.optional && grant.as_ref().is_some_and(|g| g.declined.contains(&c.id))) + }; + let needs_consent = + grant.is_none() || publisher.is_none() || signer_changed || !capabilities.iter().all(answered); Ok(InstallPreview { id: m.id.clone(), name: m.name.clone(), @@ -724,9 +756,11 @@ impl PluginHost { capabilities, contributions: counts(m), warnings: set.warnings.clone(), - replaces: self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone()), + replaces, unsigned: publisher.is_none(), publisher, + signer_changed, + needs_consent, }) } @@ -749,6 +783,11 @@ impl PluginHost { if let Some(problem) = self.signature_problem(src) { return Err(IdeError::invalid(format!("refusing {id}: {problem}"))); } + if !consent.signer_change && self.signer_changed(&id, src) { + return Err(IdeError::invalid(format!( + "refusing {id}: it is signed by another key than the installed version; confirm the change of signer" + ))); + } let version = pkg.manifest.version.clone(); if let Some(r) = self.market.read().as_ref().and_then(|i| i.revocation(&id, &version)) { return Err(IdeError::invalid(format!("{id} {version} was revoked: {}", r.reason))); diff --git a/crates/ide-host/src/plugins/remote_host.rs b/crates/ide-host/src/plugins/remote_host.rs index 71be9116d..947c0b9b8 100644 --- a/crates/ide-host/src/plugins/remote_host.rs +++ b/crates/ide-host/src/plugins/remote_host.rs @@ -272,7 +272,8 @@ impl RemotePlugins { report.failed.push(format!("{}: invalid plugin id", pkg.id)); continue; } - let consent = Consent { optional: pkg.granted.clone() }; + // The desktop asked the user, change of signer included. + let consent = Consent { optional: pkg.granted.clone(), signer_change: true }; if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) { if let Err(e) = self.follow_grants(&pkg, &consent) { report.failed.push(format!("{}: {e}", pkg.id)); diff --git a/crates/ide-host/src/plugins/types.rs b/crates/ide-host/src/plugins/types.rs index db3b18f9a..fc13800f7 100644 --- a/crates/ide-host/src/plugins/types.rs +++ b/crates/ide-host/src/plugins/types.rs @@ -138,12 +138,24 @@ pub struct PluginsSnapshot { pub ui: Vec, } +/// A newer version of an installed plugin in the marketplace. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct PluginUpdate { + pub id: String, + pub name: String, + pub version: String, +} + /// Backend → UI notifications (Tauri event `ide://plugin`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(tag = "type", rename_all = "camelCase")] pub enum PluginEvent { /// Plugins, their status or their contributions changed: re-fetch `plugin_list`. CommandsChanged, + /// The marketplace offers newer versions of installed plugins (announced + /// once per set of versions). + UpdatesAvailable { updates: Vec }, #[serde(rename_all = "camelCase")] Notification { plugin_id: String, level: NotificationLevel, title: String, body: String }, /// A plugin's status bar item was set (`item`) or removed. diff --git a/crates/ide-host/tests/distribution.rs b/crates/ide-host/tests/distribution.rs index b3718e115..a8d97934a 100644 --- a/crates/ide-host/tests/distribution.rs +++ b/crates/ide-host/tests/distribution.rs @@ -131,22 +131,39 @@ fn installs_from_before_versioning_keep_working_and_move_on_update() { /// `hello` as `acme.hello` at `version`, packed and signed with `key`. fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) { - let src = variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{version}"), |t| { - t.replace("id = \"hello\"", "id = \"acme.hello\"") - .replace("version = \"0.3.0\"", &format!("version = \"{version}\"")) - }); + signed(dir, "acme", version, key, host, |t| t.to_owned()) +} + +/// `hello` as `.hello` at `version`, its manifest changed by +/// `edit`, packed and signed with `key`. +fn signed( + dir: &Path, + publisher: &str, + version: &str, + key: &PublisherKey, + host: &PluginHost, + edit: impl Fn(&str) -> String, +) -> (PathBuf, IndexEntry) { + let id = format!("{publisher}.hello"); + let src = + variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{id}-{version}"), |t| { + edit( + &t.replace("id = \"hello\"", &format!("id = \"{id}\"")) + .replace("version = \"0.3.0\"", &format!("version = \"{version}\"")), + ) + }); let files = host.package_files_of(&src).unwrap(); let digest = package::package_digest(&src, &files).unwrap(); - let sig = key.sign("acme", "acme.hello", version, &digest); - let out = dir.join(format!("acme.hello-{version}.oiplugin")); + let sig = key.sign(publisher, &id, version, &digest); + let out = dir.join(format!("{id}-{version}.oiplugin")); package::pack(&src, &files, &out, Some(&sig)).unwrap(); let entry = IndexEntry { - id: "acme.hello".into(), + id: id.clone(), name: "Hello".into(), version: version.into(), description: String::new(), authors: vec![], - publisher: "acme".into(), + publisher: publisher.into(), download: out.file_name().unwrap().to_string_lossy().into_owned(), digest, capabilities: vec![], @@ -168,7 +185,7 @@ fn write_index( publishers: vec![Publisher { id: "acme".into(), name: "ACME".into(), - keys: vec![KeyEntry { key: key.public_base64(), revoked: key_revoked }], + keys: vec![KeyEntry { revoked: key_revoked, ..KeyEntry::new(key.public_base64()) }], }], plugins, revoked, @@ -313,3 +330,160 @@ fn a_moved_marketplace_is_read_where_it_moved_to() { let err = f.host.refresh_market().unwrap_err(); assert!(err.message.contains("moved more than"), "{}", err.message); } + +/// `dir/index.json` listing `publishers` and `plugins`; its path. +fn index_of(dir: &Path, publishers: Vec, plugins: Vec) -> String { + let index = Index { format: package::INDEX_FORMAT, publishers, plugins, ..Index::default() }; + let path = dir.join("index.json"); + std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap(); + path.to_string_lossy().into_owned() +} + +fn publisher(id: &str, keys: Vec) -> Publisher { + Publisher { id: id.into(), name: id.into(), keys } +} + +fn updates_announced(events: &Events) -> Vec> { + events + .lock() + .iter() + .filter_map(|e| match e { + PluginEvent::UpdatesAvailable { updates } => { + Some(updates.iter().map(|u| format!("{} {}", u.id, u.version)).collect()) + } + _ => None, + }) + .collect() +} + +/// What the install page sends for an update it doesn't need to show: the grants the installed version has. +fn as_granted(preview: &InstallPreview) -> Consent { + let optional = preview.capabilities.iter().filter(|c| c.optional && c.granted).map(|c| c.id.clone()).collect(); + Consent { optional, signer_change: false } +} + +#[test] +fn updates_keep_their_signer_and_need_no_consent_when_nothing_is_new() { + let f = fixture(); + let market = f.work.join("market"); + std::fs::create_dir_all(&market).unwrap(); + let (k1, k2, k3) = + (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32])); + let same = |t: &str| t.to_owned(); + let (_, v1) = signed(&market, "acme", "1.0.0", &k1, &f.host, same); + f.host.set_market_url(Some(index_of( + &market, + vec![publisher("acme", vec![KeyEntry::new(k1.public_base64())])], + vec![v1.clone()], + ))); + f.host.refresh_market().unwrap(); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + let first = f.host.preview_install(&dir).unwrap(); + assert!(first.needs_consent && !first.signer_changed, "a first install is confirmed"); + f.host.install_from_dir(&dir, &Consent::default()).unwrap(); + assert!(updates_announced(&f.events).is_empty()); + + // 1.1.0, same key and needs: announced once, installed with the grants it has. + let (_, v11) = signed(&market, "acme", "1.1.0", &k1, &f.host, same); + let keys1 = vec![KeyEntry::new(k1.public_base64())]; + index_of(&market, vec![publisher("acme", keys1.clone())], vec![v1.clone(), v11.clone()]); + f.host.refresh_market().unwrap(); + f.host.refresh_market().unwrap(); + assert_eq!(updates_announced(&f.events), [["acme.hello 1.1.0"]], "once per set of versions"); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + let preview = f.host.preview_install(&dir).unwrap(); + assert_eq!(preview.replaces.as_deref(), Some("1.0.0")); + assert!(!preview.needs_consent && !preview.signer_changed); + assert!(preview.capabilities.iter().all(|c| c.granted), "shows what the installed version was granted"); + f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap(); + assert!(f.host.market_plugins().iter().all(|p| !p.is_update())); + + // 1.2.0 asks for something new: the user is asked. + let (_, v12) = signed(&market, "acme", "1.2.0", &k1, &f.host, |t| { + t.replace( + "required = [\"editor.read\", \"editor.write\"]", + "required = [\"editor.read\", \"editor.write\"]\noptional = [\"net:example.com\"]", + ) + }); + index_of(&market, vec![publisher("acme", keys1.clone())], vec![v12.clone()]); + f.host.refresh_market().unwrap(); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + let preview = f.host.preview_install(&dir).unwrap(); + assert!(preview.needs_consent && !preview.signer_changed); + f.host.install_from_dir(&dir, &Consent::default()).unwrap(); + + // 1.3.0 signed with a key the installed one didn't endorse: another signer, the user's call. + let (_, v13) = signed(&market, "acme", "1.3.0", &k2, &f.host, same); + let keys12 = vec![KeyEntry::new(k1.public_base64()), KeyEntry::new(k2.public_base64())]; + index_of(&market, vec![publisher("acme", keys12)], vec![v13]); + f.host.refresh_market().unwrap(); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + let preview = f.host.preview_install(&dir).unwrap(); + assert!(preview.signer_changed && preview.needs_consent); + let err = f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap_err(); + assert!(err.message.contains("another key"), "{}", err.message); + assert_eq!(f.host.info_of("acme.hello").unwrap().version, "1.2.0", "nothing changed"); + f.host.install_from_dir(&dir, &Consent { signer_change: true, ..as_granted(&preview) }).unwrap(); + + // 1.4.0 signed with k3, which k2 endorsed: a rotation the installed signer vouched for. + let (_, v14) = signed(&market, "acme", "1.4.0", &k3, &f.host, same); + let keys = vec![ + KeyEntry { endorsement: Some(k2.endorse("acme", &k3.public_base64())), ..KeyEntry::new(k3.public_base64()) }, + KeyEntry::new(k2.public_base64()), + ]; + index_of(&market, vec![publisher("acme", keys)], vec![v14]); + f.host.refresh_market().unwrap(); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + let preview = f.host.preview_install(&dir).unwrap(); + assert!(!preview.signer_changed && !preview.needs_consent); + f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap(); + assert_eq!(f.host.info_of("acme.hello").unwrap().version, "1.4.0"); +} + +#[test] +fn an_index_in_a_newer_format_still_revokes() { + let f = fixture(); + let market = f.work.join("market"); + std::fs::create_dir_all(&market).unwrap(); + let key = PublisherKey::from_seed([3; 32]); + let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host); + f.host.set_market_url(Some(write_index(&market, &key, vec![entry], vec![], false))); + f.host.refresh_market().unwrap(); + let dir = f.host.download_market_plugin("acme.hello").unwrap(); + f.host.install_from_dir(&dir, &Consent::all()).unwrap(); + + let newer = format!( + r#"{{"format": {}, "plugins": "a shape this IDE can't read", + "revoked": [{{"id": "acme.hello", "reason": "steals tokens"}}]}}"#, + package::INDEX_FORMAT + 1 + ); + std::fs::write(market.join("index.json"), newer).unwrap(); + let err = f.host.refresh_market().unwrap_err(); + assert!(err.message.contains("newer OpenIDEA"), "{}", err.message); + let info = f.host.info_of("acme.hello").unwrap(); + assert_eq!((info.status, info.error.as_deref()), (PluginStatus::Failed, Some("revoked: steals tokens"))); + assert!(f.host.market_plugins().is_empty()); +} + +#[test] +fn official_plugins_need_the_key_this_ide_was_built_with() { + let f = fixture(); + let market = f.work.join("market"); + std::fs::create_dir_all(&market).unwrap(); + let (root, intruder) = (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([6; 32])); + f.host.set_official_keys(vec![root.public_base64()]); + // The index lists the intruder's key for the official publisher. + let (_, forged) = signed(&market, OFFICIAL_PUBLISHER, "1.0.0", &intruder, &f.host, |t| t.to_owned()); + let keys = vec![KeyEntry::new(intruder.public_base64())]; + f.host.set_market_url(Some(index_of(&market, vec![publisher(OFFICIAL_PUBLISHER, keys.clone())], vec![forged]))); + f.host.refresh_market().unwrap(); + let err = f.host.download_market_plugin("openidea.hello").unwrap_err(); + assert!(err.message.contains("trusted publisher"), "{}", err.message); + + // Signed with the built-in key it installs, whatever keys the index lists. + let (_, genuine) = signed(&market, OFFICIAL_PUBLISHER, "1.0.1", &root, &f.host, |t| t.to_owned()); + index_of(&market, vec![publisher(OFFICIAL_PUBLISHER, keys)], vec![genuine]); + f.host.refresh_market().unwrap(); + let dir = f.host.download_market_plugin("openidea.hello").unwrap(); + assert_eq!(f.host.preview_install(&dir).unwrap().publisher.as_deref(), Some(OFFICIAL_PUBLISHER)); +} diff --git a/plugins/README.md b/plugins/README.md index a15e401fb..ae53436b1 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -99,7 +99,8 @@ when = "editorFocus" # optional: same expressions as built-in actio `themes`, `icon-themes`, `linters` (linters and formatters), `vcs`, `ai`, `frameworks`, `tools`, `other`. Without them, a plugin contributing themes, icon themes or languages is filed under those, any other under `other`. -Categories an IDE doesn't know are ignored (`check` warns about them). +Categories an IDE doesn't know, and any after the first three it knows, are +ignored (`check` warns about both). All paths are relative to the package directory and may not leave it. A manifest is validated as a whole: if one contribution is invalid, nothing is @@ -746,6 +747,12 @@ granted, and starts the approved program. the version it replaced automatically (you're told why); _Roll Back to …_ in the plugin's details does it by hand. Data (`storage`) is per plugin, not per version. +- **Updates:** when the daily marketplace check finds newer versions of + installed plugins, the IDE says so once, with _Update All_ (also in the gear + menu and as the _Update All Plugins_ action). An update that asks for + nothing new installs with the grants the installed version has; the install + page is shown only for new capabilities, an unsigned package or another + signer (see _Distribution_). - **Linked plugins:** `openidea-plugin link ` (§1). Listed with a _linked_ tag; capabilities are granted per folder. - **Project plugins:** put (or symlink) the package in @@ -767,6 +774,7 @@ openidea-plugin pack plugins/acme-tools --key acme.key --publisher acme openidea-plugin index market/ --publisher acme --name "ACME" --key acme.key openidea-plugin check acme.tools-1.0.0.oiplugin # a package as installing would check it openidea-plugin merge market/index.json https://example.com/index.json --keep acme +openidea-plugin endorse --key acme.key --publisher acme # when rotating keys ``` A **marketplace** is static: an `index.json` and the packages next to it, on @@ -782,15 +790,32 @@ and the publisher's key, then shows the install page. - **Revocation:** the index can revoke plugin versions (or all versions) and publisher keys. The IDE reads the index daily; revoked plugins are disabled with the reason and the user is notified — also after a restart, before - their code could run. A publisher rotates keys by adding the new one; older - keys stay valid until revoked. + their code could run. +- **Key rotation:** a publisher rotates keys by registering the new one with + `index --key new.key --previous-key old.key`: the old key endorses the new + one, and older keys stay valid until revoked (`endorse` prints the + endorsement for an index someone else writes, such as the registry's). An installed plugin updates + silently only to a package signed with the key it was installed with, or + one that key endorsed (directly or through endorsed keys; endorsements by + revoked keys don't count). Another signer — an unendorsed key, or an + unsigned package replacing a signed one — is shown on the install page and + needs the user's confirmation. +- **Official key:** release builds carry the official publisher's public key + (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`, derived from the release's signing key). + `openidea.*` plugins must be signed with it, or with a key it endorsed, + whatever keys an index lists for `openidea`. +- **Format:** fields are only ever added to the index (an IDE ignores those it + doesn't know), and `format` stays `1` unless older IDEs can't read the + change. An IDE reading a newer format still applies its revocations and + `moved`, lists no plugins, and asks the user to update. - **Moving:** an index with `"moved": ""` sends the IDE to the index at that URL (relative or absolute; https stays https), so a marketplace can change hosts while released IDEs keep the address they were built with. **Versions.** Each index entry records the plugin API it needs (`engine`: the manifest's `engines.openidea`, narrowed to the API its component was built -for), and a plugin may be listed in several versions. Settings › Plugins +for; an entry without one is offered to no IDE), and a plugin may be listed in +several versions. Settings › Plugins offers the newest version this IDE can load; when there is none, it shows the plugin as incompatible and says which API it needs. Entries also carry the plugin's categories, which Settings › Plugins › Available filters by. Indexing keeps, of each diff --git a/src/bindings.ts b/src/bindings.ts index 1e7529a68..324f2122b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2239,6 +2239,11 @@ export type Consent = { * ones are implied by consenting at all. */ optional: string[], + /** + * The user accepted a package signed by another key than the installed + * copy ([`InstallPreview::signer_changed`]). + */ + signerChange?: boolean, }; /** Build, Execution, Deployment › Console. */ @@ -4142,7 +4147,7 @@ export type IndexEntry = { /** * The plugin API versions it loads in (a semver requirement): its * manifest's `engines.openidea` and the API its component was built for. - * Empty: any. + * Required: an entry without one is offered to no IDE. */ engine?: string, /** Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know. */ @@ -4304,6 +4309,18 @@ export type InstallPreview = { unsigned: boolean, /** The verified publisher. */ publisher: string | null, + /** + * Replaces an installed copy signed with another key, which didn't + * endorse this package's (or this one is unsigned): the user confirms it + * ([`Consent::signer_change`]). + */ + signerChanged: boolean, + /** + * Something to confirm: a first install, an unsigned package, another + * signer, or capabilities the installed version wasn't granted or + * refused. Without, an update installs with the grants it has. + */ + needsConsent: boolean, }; /** A theme of a plugin installed in a JetBrains IDE. */ @@ -5254,7 +5271,12 @@ export type PluginEditor = { /** Backend → UI notifications (Tauri event `ide://plugin`). */ export type PluginEvent = /** Plugins, their status or their contributions changed: re-fetch `plugin_list`. */ -{ type: "commandsChanged" } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } | +{ type: "commandsChanged" } | +/** + * The marketplace offers newer versions of installed plugins (announced + * once per set of versions). + */ +{ type: "updatesAvailable"; updates: PluginUpdate[] } | { type: "notification"; pluginId: string; level: NotificationLevel; title: string; body: string } | /** A plugin's status bar item was set (`item`) or removed. */ { type: "statusItemChanged"; pluginId: string; id: string; item: StatusItem | null } | /** A plugin began a task: show it with the background tasks. */ @@ -5363,6 +5385,13 @@ export type PluginStatusItem = { item: StatusItem, }; +/** A newer version of an installed plugin in the marketplace. */ +export type PluginUpdate = { + id: string, + name: string, + version: string, +}; + export type PluginsSnapshot = { plugins: PluginInfo[], activeTheme: ActiveTheme | null, diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx index 2a469f519..3f8da6c64 100644 --- a/src/features/plugins/PluginsPage.tsx +++ b/src/features/plugins/PluginsPage.tsx @@ -29,6 +29,7 @@ import { RISK_LABEL, sortByRisk } from "./consent"; import { itemsOf, uiPoint } from "./projection"; import { compareVersions, + isUpdate, grantPlugin, installFromDisk, installFromMarket, @@ -42,6 +43,7 @@ import { setPluginCapability, setPluginEnabled, uninstallPlugin, + updateAllPlugins, usePlugins, } from "./store"; import { useProject } from "@/platform/project"; @@ -537,9 +539,15 @@ interface GearActions { importInstalledIntellij: () => void; } -function gearMenu(plugins: readonly PluginInfo[], actions: GearActions): MenuItem[] { +function gearMenu(plugins: readonly PluginInfo[], updates: number, actions: GearActions): MenuItem[] { const installed = plugins.filter((p) => p.source === "installed"); return [ + { + label: updates > 0 ? tx`Update All Plugins (${updates})` : "Update All Plugins", + disabled: updates === 0, + run: () => void updateAllPlugins(), + }, + { kind: "separator" }, { label: "Install Plugin from Disk…", icon: "package", run: () => actions.install(false) }, { label: "Install Plugin from Package File…", run: () => actions.install(true) }, ...(actions.canImport @@ -671,7 +679,7 @@ function MarketUrlBar() { } function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) { - const update = entry.compatible && entry.installed && compareVersions(entry.version, entry.installed) > 0; + const update = isUpdate(entry); return (