Verify plugin signatures

This commit is contained in:
2026-05-07 23:41:08 -04:00
parent eddd694ae9
commit e30d14728b
13 changed files with 610 additions and 144 deletions
+1
View File
@@ -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" }
+4
View File
@@ -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;
@@ -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<PathBuf, Box<dyn Error>> {
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<dyn Error>> {
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,
}
}
@@ -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<PathBuf, Box<dyn Error>> {
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 {
+22 -39
View File
@@ -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<dyn Error>> {
@@ -346,6 +351,19 @@ mod tests {
Ok(())
}
#[test]
fn rejects_package_signature_verification_failure() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<PathBuf, Box<dyn Error>> {
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<PathBuf, Box<dyn Error>> {
@@ -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<Vec<u8>, 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<String, PluginSignatureVerificationError> {
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<SignedEntry>,
) -> 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<String, PluginSignatureVerificationError> {
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<u8>, 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<u8>, name: &str, values: impl Iterator<Item = &'a str>) {
let values = values.collect::<Vec<_>>();
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<const N: usize>(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<u8> {
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,
}