diff --git a/Cargo.lock b/Cargo.lock index ad3b689..04c42a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1816,6 +1816,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", + "digest", "fiat-crypto", "rustc_version", "subtle", @@ -2186,6 +2187,30 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -2218,6 +2243,7 @@ name = "ely_app" version = "0.1.0" dependencies = [ "directories", + "ed25519-dalek", "ely_browser_core", "ely_design_system", "ely_domain", diff --git a/Cargo.toml b/Cargo.toml index 5111ae7..f07de41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ rust-version = "1.95" [workspace.dependencies] directories = "6.0.0" dpi = "0.1" +ed25519-dalek = "2.2.0" gpui = "0.2.2" gpui-component = "0.5.1" gpui-component-assets = "0.5.1" diff --git a/crates/ely_app/Cargo.toml b/crates/ely_app/Cargo.toml index 9593960..1990af8 100644 --- a/crates/ely_app/Cargo.toml +++ b/crates/ely_app/Cargo.toml @@ -7,6 +7,7 @@ rust-version.workspace = true [dependencies] directories.workspace = true +ed25519-dalek.workspace = true ely_browser_core = { path = "../ely_browser_core" } ely_design_system = { path = "../ely_design_system" } ely_domain = { path = "../ely_domain" } diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index 214c801..289ef82 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -2,3 +2,7 @@ pub mod download_checksums; pub mod download_files; pub mod plugin_package_store; pub mod plugin_packages; +pub mod plugin_signatures; + +#[cfg(test)] +mod plugin_package_fixtures; diff --git a/crates/ely_app/src/services/plugin_package_fixtures.rs b/crates/ely_app/src/services/plugin_package_fixtures.rs new file mode 100644 index 0000000..d841a98 --- /dev/null +++ b/crates/ely_app/src/services/plugin_package_fixtures.rs @@ -0,0 +1,93 @@ +use std::{ + error::Error, + fs, + path::{Path, PathBuf}, +}; + +use ed25519_dalek::{Signer, SigningKey}; +use ely_domain::PluginManifest; +use sha2::{Digest, Sha256}; + +use super::plugin_signatures::signing_payload; + +const KEY_ID: &str = "elydora-alpha-plugins"; +const TEST_SIGNING_KEY: [u8; 32] = [9; 32]; + +pub(crate) fn write_signed_package( + root: &Path, + name: &str, + component: &[u8], +) -> Result> { + let package = root.join(format!("{name}.rplug")); + fs::create_dir_all(package.join("signatures"))?; + fs::write(package.join("component.wasm"), component)?; + sign_package_in_place(package.as_path(), name)?; + Ok(package) +} + +pub(crate) fn sign_package_in_place(package: &Path, name: &str) -> Result<(), Box> { + fs::create_dir_all(package.join("signatures"))?; + + let signing_key = SigningKey::from_bytes(&TEST_SIGNING_KEY); + let public_key = hex_bytes(signing_key.verifying_key().to_bytes().as_slice()); + let component = fs::read(package.join("component.wasm"))?; + let checksum = format!("{:x}", Sha256::digest(component.as_slice())); + let placeholder_signature = "0".repeat(128); + + fs::write( + package.join("plugin.toml"), + manifest_toml(name, checksum.as_str(), public_key.as_str(), placeholder_signature.as_str()), + )?; + + let manifest_text = fs::read_to_string(package.join("plugin.toml"))?; + let manifest = PluginManifest::from_toml(manifest_text.as_str())?; + let payload = signing_payload(package, &manifest)?; + let signature = signing_key.sign(payload.as_slice()); + let signature = hex_bytes(signature.to_bytes().as_slice()); + + fs::write(package.join("signatures").join("ed25519.sig"), signature.as_str())?; + fs::write( + package.join("plugin.toml"), + manifest_toml(name, checksum.as_str(), public_key.as_str(), signature.as_str()), + )?; + + Ok(()) +} + +fn manifest_toml(name: &str, checksum: &str, public_key: &str, signature: &str) -> String { + format!( + r#" +id = "com.elydora.{name}" +name = "Verified Plugin" +description = "Exports verified content." +author = "Elydora" +homepage = "https://elydora.com/plugins/{name}" +permissions = ["page:metadata"] +contributes = ["command-bar-command"] +min_ely_build = "0.1.0" +checksum = "{checksum}" + +[signature] +algorithm = "ed25519" +key_id = "{KEY_ID}" +public_key = "{public_key}" +value = "{signature}" +"# + ) +} + +fn hex_bytes(bytes: &[u8]) -> String { + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(hex_char(byte >> 4)); + encoded.push(hex_char(byte & 0x0f)); + } + encoded +} + +fn hex_char(value: u8) -> char { + match value { + 0..=9 => (b'0' + value) as char, + _ => (b'a' + value - 10) as char, + } +} diff --git a/crates/ely_app/src/services/plugin_package_store.rs b/crates/ely_app/src/services/plugin_package_store.rs index 50ebe45..f71930d 100644 --- a/crates/ely_app/src/services/plugin_package_store.rs +++ b/crates/ely_app/src/services/plugin_package_store.rs @@ -261,9 +261,8 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use sha2::{Digest, Sha256}; - use super::{PluginPackageStore, PluginPackageStoreError}; + use crate::services::plugin_package_fixtures::write_signed_package; use crate::services::plugin_packages::PluginPackageReader; #[test] @@ -324,43 +323,7 @@ mod tests { } fn write_package(root: &Path, name: &str, component: &[u8]) -> Result> { - let package = root.join(format!("{name}.rplug")); - fs::create_dir_all(package.join("signatures"))?; - fs::write(package.join("component.wasm"), component)?; - - let checksum = sha256_bytes(component); - let signature = "b".repeat(128); - fs::write(package.join("signatures").join("ed25519.sig"), &signature)?; - fs::write( - package.join("plugin.toml"), - manifest_toml(name, checksum.as_str(), signature.as_str()), - )?; - - Ok(package) - } - - fn manifest_toml(name: &str, checksum: &str, signature: &str) -> String { - format!( - r#" -id = "com.elydora.{name}" -name = "Verified Plugin" -description = "Exports verified content." -author = "Elydora" -homepage = "https://elydora.com/plugins/{name}" -permissions = ["page:metadata"] -contributes = ["command-bar-command"] -min_ely_build = "0.1.0" -checksum = "{checksum}" - -[signature] -algorithm = "ed25519" -value = "{signature}" -"# - ) - } - - fn sha256_bytes(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) + write_signed_package(root, name, component) } struct TempTree { diff --git a/crates/ely_app/src/services/plugin_packages.rs b/crates/ely_app/src/services/plugin_packages.rs index 7ba08e7..43003e2 100644 --- a/crates/ely_app/src/services/plugin_packages.rs +++ b/crates/ely_app/src/services/plugin_packages.rs @@ -9,6 +9,8 @@ use ely_domain::PluginManifest; use sha2::{Digest, Sha256}; use thiserror::Error; +use super::plugin_signatures::{PluginSignatureVerificationError, PluginSignatureVerifier}; + #[derive(Clone, Debug, Eq, PartialEq)] pub struct VerifiedPluginPackage { source_path: PathBuf, @@ -48,6 +50,9 @@ pub enum PluginPackageError { #[error("plugin package signature mismatch for signatures/ed25519.sig")] SignatureMismatch, + #[error(transparent)] + SignatureVerification(#[from] PluginSignatureVerificationError), + #[error(transparent)] Manifest(#[from] ely_domain::DomainError), } @@ -78,6 +83,7 @@ impl PluginPackageReader { if signature.trim().to_ascii_lowercase() != manifest.signature().value() { return Err(PluginPackageError::SignatureMismatch); } + PluginSignatureVerifier::verify(path, &manifest)?; let package_hash = sha256_directory(path)?; Ok(VerifiedPluginPackage::new(path.to_path_buf(), manifest, package_hash)) @@ -304,9 +310,8 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use sha2::{Digest, Sha256}; - use super::PluginPackageReader; + use crate::services::plugin_package_fixtures::{sign_package_in_place, write_signed_package}; #[test] fn reads_verified_directory_package() -> Result<(), Box> { @@ -346,6 +351,19 @@ mod tests { Ok(()) } + #[test] + fn rejects_package_signature_verification_failure() -> Result<(), Box> { + let package = write_package("verification", b"wasm component")?; + fs::write(package.join("README.md"), "unsigned package metadata")?; + + let error = PluginPackageReader::read_directory_package(&package) + .err() + .ok_or_else(|| std::io::Error::other("package read succeeded"))?; + + assert!(matches!(error, super::PluginPackageError::SignatureVerification(_))); + Ok(()) + } + #[test] fn rejects_non_package_directory() -> Result<(), Box> { let package = temp_root()?.join("invalid"); @@ -365,6 +383,7 @@ mod tests { let first_hash = PluginPackageReader::read_directory_package(&package)?.package_hash().to_string(); fs::write(package.join("README.md"), "updated package metadata")?; + sign_package_in_place(package.as_path(), "package-hash")?; let second_hash = PluginPackageReader::read_directory_package(&package)?.package_hash().to_string(); @@ -374,43 +393,7 @@ mod tests { } fn write_package(name: &str, component: &[u8]) -> Result> { - let package = temp_root()?.join(format!("{name}.rplug")); - fs::create_dir_all(package.join("signatures"))?; - fs::write(package.join("component.wasm"), component)?; - - let checksum = sha256_bytes(component); - let signature = "b".repeat(128); - fs::write(package.join("signatures").join("ed25519.sig"), &signature)?; - fs::write( - package.join("plugin.toml"), - manifest_toml(name, checksum.as_str(), signature.as_str()), - )?; - - Ok(package) - } - - fn manifest_toml(name: &str, checksum: &str, signature: &str) -> String { - format!( - r#" -id = "com.elydora.{name}" -name = "Verified Plugin" -description = "Exports verified content." -author = "Elydora" -homepage = "https://elydora.com/plugins/{name}" -permissions = ["page:metadata"] -contributes = ["command-bar-command"] -min_ely_build = "0.1.0" -checksum = "{checksum}" - -[signature] -algorithm = "ed25519" -value = "{signature}" -"# - ) - } - - fn sha256_bytes(bytes: &[u8]) -> String { - format!("{:x}", Sha256::digest(bytes)) + write_signed_package(temp_root()?.as_path(), name, component) } fn temp_root() -> Result> { diff --git a/crates/ely_app/src/services/plugin_signatures.rs b/crates/ely_app/src/services/plugin_signatures.rs new file mode 100644 index 0000000..f793b28 --- /dev/null +++ b/crates/ely_app/src/services/plugin_signatures.rs @@ -0,0 +1,306 @@ +use std::{ + ffi::OsStr, + fs, + io::{self, Read}, + path::{Component, Path, PathBuf}, +}; + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use ely_domain::PluginManifest; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum PluginSignatureVerificationError { + #[error("plugin signature public key is invalid for key {key_id}")] + InvalidPublicKey { key_id: String }, + + #[error("plugin signature value is invalid for key {key_id}")] + InvalidSignature { key_id: String }, + + #[error("plugin signature verification failed for key {key_id}")] + VerificationFailed { key_id: String }, + + #[error("failed to read plugin package entry {entry}: {path}")] + ReadFailed { entry: &'static str, path: PathBuf, source: io::Error }, + + #[error("plugin package entry name must be valid utf-8: {path}")] + InvalidEntryName { path: PathBuf }, + + #[error("plugin package entry cannot be signed through a symlink: {path}")] + SymlinkEntry { path: PathBuf }, + + #[error("plugin package entry type is unsupported: {path}")] + UnsupportedEntry { path: PathBuf }, +} + +pub struct PluginSignatureVerifier; + +impl PluginSignatureVerifier { + pub fn verify( + package_root: &Path, + manifest: &PluginManifest, + ) -> Result<(), PluginSignatureVerificationError> { + let key_id = manifest.signature().key_id().to_string(); + let public_key = + decode_hex_array::<32>(manifest.signature().public_key()).ok_or_else(|| { + PluginSignatureVerificationError::InvalidPublicKey { key_id: key_id.clone() } + })?; + let signature = decode_hex_array::<64>(manifest.signature().value()).ok_or_else(|| { + PluginSignatureVerificationError::InvalidSignature { key_id: key_id.clone() } + })?; + let key = VerifyingKey::from_bytes(&public_key).map_err(|_| { + PluginSignatureVerificationError::InvalidPublicKey { key_id: key_id.clone() } + })?; + let signature = Signature::from_bytes(&signature); + let payload = signing_payload(package_root, manifest)?; + + key.verify(payload.as_slice(), &signature) + .map_err(|_| PluginSignatureVerificationError::VerificationFailed { key_id }) + } +} + +pub(crate) fn signing_payload( + package_root: &Path, + manifest: &PluginManifest, +) -> Result, PluginSignatureVerificationError> { + let mut payload = Vec::new(); + append_field(&mut payload, "format", "ely.rplug.signature.v1"); + append_field(&mut payload, "id", manifest.id().as_str()); + append_field(&mut payload, "name", manifest.name()); + append_field(&mut payload, "description", manifest.description()); + append_field(&mut payload, "author", manifest.author()); + append_field(&mut payload, "homepage", manifest.homepage()); + append_list( + &mut payload, + "permissions", + manifest.permissions().iter().map(|permission| permission.as_str()), + ); + append_list( + &mut payload, + "contributes", + manifest.contributes().iter().map(|contribution| contribution.as_str()), + ); + append_field(&mut payload, "min_ely_build", manifest.min_ely_build().to_string().as_str()); + append_field(&mut payload, "checksum", manifest.checksum()); + append_field(&mut payload, "signature_algorithm", manifest.signature().algorithm().as_str()); + append_field(&mut payload, "signature_key_id", manifest.signature().key_id()); + append_field(&mut payload, "signature_public_key", manifest.signature().public_key()); + append_field(&mut payload, "package_files_sha256", package_files_hash(package_root)?.as_str()); + Ok(payload) +} + +fn package_files_hash(package_root: &Path) -> Result { + let mut entries = Vec::new(); + collect_signed_entries(package_root, Path::new(""), &mut entries)?; + entries.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + + let mut hasher = Sha256::new(); + for entry in entries { + let relative_path = canonical_relative_path(entry.relative_path.as_path())?; + match entry.kind { + SignedEntryKind::Directory => { + hasher.update(b"dir\0"); + hasher.update(relative_path.as_bytes()); + hasher.update(b"\0"); + } + SignedEntryKind::File => { + hasher.update(b"file\0"); + hasher.update(relative_path.as_bytes()); + hasher.update(b"\0"); + hash_file_content(entry.absolute_path.as_path(), &mut hasher)?; + } + } + } + + Ok(format!("{:x}", hasher.finalize())) +} + +fn collect_signed_entries( + root: &Path, + relative_dir: &Path, + entries: &mut Vec, +) -> Result<(), PluginSignatureVerificationError> { + let absolute_dir = root.join(relative_dir); + for entry in fs::read_dir(absolute_dir.as_path()).map_err(|source| { + PluginSignatureVerificationError::ReadFailed { + entry: "package", + path: absolute_dir.clone(), + source, + } + })? { + let entry = entry.map_err(|source| PluginSignatureVerificationError::ReadFailed { + entry: "package", + path: absolute_dir.clone(), + source, + })?; + let absolute_path = entry.path(); + let relative_path = relative_dir.join(entry.file_name()); + if excluded_signature_entry(relative_path.as_path()) { + continue; + } + + let metadata = fs::symlink_metadata(absolute_path.as_path()).map_err(|source| { + PluginSignatureVerificationError::ReadFailed { + entry: "package", + path: absolute_path.clone(), + source, + } + })?; + let file_type = metadata.file_type(); + + if file_type.is_symlink() { + return Err(PluginSignatureVerificationError::SymlinkEntry { path: absolute_path }); + } + if file_type.is_dir() { + entries.push(SignedEntry::new( + relative_path.clone(), + absolute_path, + SignedEntryKind::Directory, + )); + collect_signed_entries(root, relative_path.as_path(), entries)?; + continue; + } + if file_type.is_file() { + entries.push(SignedEntry::new(relative_path, absolute_path, SignedEntryKind::File)); + continue; + } + + return Err(PluginSignatureVerificationError::UnsupportedEntry { path: absolute_path }); + } + + Ok(()) +} + +fn excluded_signature_entry(path: &Path) -> bool { + let mut components = path.components(); + match components.next() { + Some(Component::Normal(value)) if value == OsStr::new("plugin.toml") => true, + Some(Component::Normal(value)) if value == OsStr::new("signatures") => true, + _ => false, + } +} + +fn canonical_relative_path(path: &Path) -> Result { + let mut parts = Vec::new(); + for component in path.components() { + let Component::Normal(value) = component else { + return Err(PluginSignatureVerificationError::InvalidEntryName { + path: path.to_path_buf(), + }); + }; + parts.push(utf8_entry_name(value, path)?); + } + Ok(parts.join("/")) +} + +fn utf8_entry_name<'a>( + value: &'a OsStr, + path: &Path, +) -> Result<&'a str, PluginSignatureVerificationError> { + value.to_str().ok_or_else(|| PluginSignatureVerificationError::InvalidEntryName { + path: path.to_path_buf(), + }) +} + +fn hash_file_content( + path: &Path, + hasher: &mut Sha256, +) -> Result<(), PluginSignatureVerificationError> { + let mut file = + fs::File::open(path).map_err(|source| PluginSignatureVerificationError::ReadFailed { + entry: "package", + path: path.to_path_buf(), + source, + })?; + let metadata = + file.metadata().map_err(|source| PluginSignatureVerificationError::ReadFailed { + entry: "package", + path: path.to_path_buf(), + source, + })?; + hasher.update(metadata.len().to_le_bytes()); + hasher.update(b"\0"); + let mut buffer = [0_u8; 8192]; + + loop { + let read = file.read(&mut buffer).map_err(|source| { + PluginSignatureVerificationError::ReadFailed { + entry: "package", + path: path.to_path_buf(), + source, + } + })?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + + Ok(()) +} + +fn append_field(payload: &mut Vec, name: &str, value: &str) { + payload.extend_from_slice(name.as_bytes()); + payload.extend_from_slice(b"\0"); + payload.extend_from_slice(value.len().to_string().as_bytes()); + payload.extend_from_slice(b"\0"); + payload.extend_from_slice(value.as_bytes()); + payload.extend_from_slice(b"\0"); +} + +fn append_list<'a>(payload: &mut Vec, name: &str, values: impl Iterator) { + let values = values.collect::>(); + payload.extend_from_slice(name.as_bytes()); + payload.extend_from_slice(b"\0"); + payload.extend_from_slice(values.len().to_string().as_bytes()); + payload.extend_from_slice(b"\0"); + for value in values { + payload.extend_from_slice(value.len().to_string().as_bytes()); + payload.extend_from_slice(b"\0"); + payload.extend_from_slice(value.as_bytes()); + payload.extend_from_slice(b"\0"); + } +} + +fn decode_hex_array(value: &str) -> Option<[u8; N]> { + if value.len() != N * 2 { + return None; + } + + let mut output = [0_u8; N]; + for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let high = hex_nibble(chunk[0])?; + let low = hex_nibble(chunk[1])?; + output[index] = (high << 4) | low; + } + Some(output) +} + +fn hex_nibble(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SignedEntry { + relative_path: PathBuf, + absolute_path: PathBuf, + kind: SignedEntryKind, +} + +impl SignedEntry { + fn new(relative_path: PathBuf, absolute_path: PathBuf, kind: SignedEntryKind) -> Self { + Self { relative_path, absolute_path, kind } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum SignedEntryKind { + Directory, + File, +} diff --git a/crates/ely_browser_core/tests/plugins.rs b/crates/ely_browser_core/tests/plugins.rs index 659d26b..189c9c5 100644 --- a/crates/ely_browser_core/tests/plugins.rs +++ b/crates/ely_browser_core/tests/plugins.rs @@ -181,6 +181,8 @@ checksum = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" [signature] algorithm = "ed25519" +key_id = "elydora-alpha-plugins" +public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" value = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" "# ) diff --git a/crates/ely_domain/src/error.rs b/crates/ely_domain/src/error.rs index 5d74c7d..8614305 100644 --- a/crates/ely_domain/src/error.rs +++ b/crates/ely_domain/src/error.rs @@ -56,6 +56,12 @@ pub enum DomainError { #[error("invalid plugin signature algorithm: {value}")] InvalidPluginSignatureAlgorithm { value: String }, + #[error("invalid plugin signature key id: {value}")] + InvalidPluginSignatureKeyId { value: String }, + + #[error("invalid plugin signature public key: {value}")] + InvalidPluginSignaturePublicKey { value: String }, + #[error("invalid plugin signature: {value}")] InvalidPluginSignature { value: String }, } diff --git a/crates/ely_domain/src/plugin.rs b/crates/ely_domain/src/plugin.rs index 37a3388..e58475c 100644 --- a/crates/ely_domain/src/plugin.rs +++ b/crates/ely_domain/src/plugin.rs @@ -6,6 +6,11 @@ use url::Url; use crate::DomainError; +mod signature; + +use signature::RawPluginSignature; +pub use signature::{PluginSignature, PluginSignatureAlgorithm}; + #[derive(Clone, Debug, Eq, PartialEq)] pub struct PluginId(String); @@ -69,17 +74,6 @@ pub enum PluginContributionPoint { ReadingModeExporter, } -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum PluginSignatureAlgorithm { - Ed25519, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PluginSignature { - algorithm: PluginSignatureAlgorithm, - value: String, -} - #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawPluginManifest { @@ -95,13 +89,6 @@ struct RawPluginManifest { signature: RawPluginSignature, } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct RawPluginSignature { - algorithm: String, - value: String, -} - impl PluginId { pub fn parse(value: impl Into) -> Result { let value = value.into(); @@ -329,40 +316,6 @@ impl PluginContributionPoint { } } -impl PluginSignature { - fn from_raw(raw: RawPluginSignature) -> Result { - let algorithm = PluginSignatureAlgorithm::parse(raw.algorithm.as_str())?; - let value = plugin_signature_value(&algorithm, raw.value)?; - Ok(Self { algorithm, value }) - } - - #[must_use] - pub fn algorithm(&self) -> &PluginSignatureAlgorithm { - &self.algorithm - } - - #[must_use] - pub fn value(&self) -> &str { - &self.value - } -} - -impl PluginSignatureAlgorithm { - fn parse(value: &str) -> Result { - match value { - "ed25519" => Ok(Self::Ed25519), - _ => Err(DomainError::InvalidPluginSignatureAlgorithm { value: value.to_string() }), - } - } - - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - Self::Ed25519 => "ed25519", - } - } -} - fn parse_unique_permissions(values: &[String]) -> Result, DomainError> { let mut seen = BTreeSet::new(); let mut permissions = Vec::with_capacity(values.len()); @@ -430,20 +383,6 @@ fn plugin_checksum(value: impl Into) -> Result { Ok(value.to_ascii_lowercase()) } -fn plugin_signature_value( - algorithm: &PluginSignatureAlgorithm, - value: impl Into, -) -> Result { - let value = non_empty_plugin_field("signature", value)?; - let valid = match algorithm { - PluginSignatureAlgorithm::Ed25519 => is_hex_of_len(value.as_str(), 128), - }; - if !valid { - return Err(DomainError::InvalidPluginSignature { value }); - } - Ok(value.to_ascii_lowercase()) -} - fn is_valid_plugin_id(value: &str) -> bool { (3..=128).contains(&value.len()) && value.split('.').all(|segment| { diff --git a/crates/ely_domain/src/plugin/signature.rs b/crates/ely_domain/src/plugin/signature.rs new file mode 100644 index 0000000..31b0574 --- /dev/null +++ b/crates/ely_domain/src/plugin/signature.rs @@ -0,0 +1,105 @@ +use serde::Deserialize; + +use crate::DomainError; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PluginSignatureAlgorithm { + Ed25519, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginSignature { + algorithm: PluginSignatureAlgorithm, + key_id: String, + public_key: String, + value: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawPluginSignature { + pub(super) algorithm: String, + pub(super) key_id: String, + pub(super) public_key: String, + pub(super) value: String, +} + +impl PluginSignature { + pub(super) fn from_raw(raw: RawPluginSignature) -> Result { + let algorithm = PluginSignatureAlgorithm::parse(raw.algorithm.as_str())?; + let key_id = plugin_signature_key_id(raw.key_id)?; + let public_key = plugin_signature_public_key(raw.public_key)?; + let value = plugin_signature_value(&algorithm, raw.value)?; + Ok(Self { algorithm, key_id, public_key, value }) + } + + #[must_use] + pub fn algorithm(&self) -> &PluginSignatureAlgorithm { + &self.algorithm + } + + #[must_use] + pub fn key_id(&self) -> &str { + &self.key_id + } + + #[must_use] + pub fn public_key(&self) -> &str { + &self.public_key + } + + #[must_use] + pub fn value(&self) -> &str { + &self.value + } +} + +impl PluginSignatureAlgorithm { + fn parse(value: &str) -> Result { + match value { + "ed25519" => Ok(Self::Ed25519), + _ => Err(DomainError::InvalidPluginSignatureAlgorithm { value: value.to_string() }), + } + } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Ed25519 => "ed25519", + } + } +} + +fn plugin_signature_key_id(value: impl Into) -> Result { + let value = super::non_empty_plugin_field("signature.key_id", value)?; + let valid = (3..=128).contains(&value.len()) + && value.chars().all(|ch| { + ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '-' | '_') + }); + if !valid { + return Err(DomainError::InvalidPluginSignatureKeyId { value }); + } + Ok(value) +} + +fn plugin_signature_public_key(value: impl Into) -> Result { + let value = super::non_empty_plugin_field("signature.public_key", value)?; + if !super::is_hex_of_len(value.as_str(), 64) { + return Err(DomainError::InvalidPluginSignaturePublicKey { value }); + } + Ok(value.to_ascii_lowercase()) +} + +fn plugin_signature_value( + algorithm: &PluginSignatureAlgorithm, + value: impl Into, +) -> Result { + let value = super::non_empty_plugin_field("signature", value)?; + let valid = match algorithm { + PluginSignatureAlgorithm::Ed25519 => super::is_hex_of_len(value.as_str(), 128), + }; + if !valid { + return Err(DomainError::InvalidPluginSignature { value }); + } + Ok(value.to_ascii_lowercase()) +} diff --git a/crates/ely_domain/tests/plugins.rs b/crates/ely_domain/tests/plugins.rs index 5acd793..a3c358f 100644 --- a/crates/ely_domain/tests/plugins.rs +++ b/crates/ely_domain/tests/plugins.rs @@ -26,6 +26,11 @@ fn parses_signed_plugin_manifest() -> Result<(), Box> { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ); assert_eq!(manifest.signature().algorithm(), &PluginSignatureAlgorithm::Ed25519); + assert_eq!(manifest.signature().key_id(), "elydora-alpha-plugins"); + assert_eq!( + manifest.signature().public_key(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); assert_eq!( manifest.signature().value(), "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" @@ -100,6 +105,36 @@ fn rejects_unsigned_plugin_manifest() -> Result<(), Box> { Ok(()) } +#[test] +fn rejects_invalid_plugin_signature_key_id() -> Result<(), Box> { + let manifest = valid_manifest() + .replace("key_id = \"elydora-alpha-plugins\"", "key_id = \"Elydora Alpha Plugins\""); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!( + error, + DomainError::InvalidPluginSignatureKeyId { value } if value == "Elydora Alpha Plugins" + )); + Ok(()) +} + +#[test] +fn rejects_invalid_plugin_signature_public_key() -> Result<(), Box> { + let manifest = valid_manifest().replace( + "public_key = \"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\"", + "public_key = \"abcd\"", + ); + + let error = parse_error(manifest.as_str())?; + + assert!(matches!( + error, + DomainError::InvalidPluginSignaturePublicKey { value } if value == "abcd" + )); + Ok(()) +} + #[test] fn rejects_mismatched_plugin_checksum() -> Result<(), Box> { let manifest = valid_manifest().replace( @@ -163,6 +198,8 @@ checksum = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" [signature] algorithm = "ed25519" +key_id = "elydora-alpha-plugins" +public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" value = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" "# .to_string()