From 0024a1c7059bcd7173104c3dad756c5a22541847 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 17:46:50 +0800 Subject: [PATCH] feat(plugins): keep a plugin's signer across updates and announce updates - Index format: fields are only added; an IDE reading a newer `format` still applies its revocations and `moved`, lists no plugins and asks to be updated, instead of failing to read the index. - Key continuity: an installed plugin updates silently only to a package signed with the same key, or a key it endorsed (`KeyEntry.endorsement`, `index --previous-key`, `openidea-plugin endorse`). Another signer, or an unsigned package replacing a signed one, is shown on the install page and needs the user's confirmation (`Consent.signerChange`). - Official key: release builds carry the official publisher's public key (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`, derived from PLUGIN_SIGNING_KEY); `openidea.*` plugins must be signed with it or a key it endorsed, whatever the index lists. - Updates: the daily marketplace check announces new versions once, with Update All (also in the gear menu and as an action). An update that asks for nothing new installs with the grants the installed version has. - Index entries need `engine`; `categories` beyond the first three are ignored instead of rejecting the manifest. --- .github/workflows/release.yml | 17 + Cargo.lock | 1 + crates/ide-extension/src/lib.rs | 4 +- crates/ide-extension/src/manifest.rs | 25 +- crates/ide-extension/src/package.rs | 298 ++++++++++++++++-- crates/ide-host/Cargo.toml | 1 + .../ide-host/src/bin/openidea-plugin/main.rs | 136 ++++++-- crates/ide-host/src/plugins/market.rs | 85 ++++- crates/ide-host/src/plugins/mod.rs | 51 ++- crates/ide-host/src/plugins/remote_host.rs | 3 +- crates/ide-host/src/plugins/types.rs | 12 + crates/ide-host/tests/distribution.rs | 192 ++++++++++- plugins/README.md | 33 +- src/bindings.ts | 33 +- src/features/plugins/PluginsPage.tsx | 16 +- .../plugins/__tests__/intellijImport.test.tsx | 2 + .../plugins/__tests__/plugins.test.tsx | 131 +++++++- src/features/plugins/backend.ts | 2 + src/features/plugins/bridge.ts | 22 +- src/features/plugins/consent.tsx | 10 +- src/features/plugins/index.ts | 12 +- src/features/plugins/store.ts | 76 +++-- src/platform/locales/text/zh.json | 10 +- 23 files changed, 1058 insertions(+), 114 deletions(-) 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 (