diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 87d9c5db..0cf99f6f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -22,12 +22,23 @@ jobs: - name: Build and Test Did Utils run: | cd did-utils - cargo build --verbose - cargo test --verbose + cargo build + cargo test - - name: Build and Test Sample Pop Server + - name: Build and Test Did Endpoint run: | - cd sample-pop-server - cargo build --verbose - cargo run --bin didgen - cargo test --verbose + cd did-endpoint + cargo build + cargo test + + - name: Build and Test Generic Server + run: | + cd generic-server + cargo build + cargo test + + - name: Build and Mediator Server + run: | + cd mediator-server + cargo build + cargo test diff --git a/did-endpoint/.env b/did-endpoint/.env new file mode 100644 index 00000000..e1888346 --- /dev/null +++ b/did-endpoint/.env @@ -0,0 +1,2 @@ +STORAGE_DIRPATH="target/storage" +SERVER_PUBLIC_DOMAIN="example.com" diff --git a/did-endpoint/Cargo.toml b/did-endpoint/Cargo.toml new file mode 100644 index 00000000..e2294428 --- /dev/null +++ b/did-endpoint/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "did-endpoint" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +axum = { version = "0.6.20" } +chrono = { version = "0.4.26" } +did-utils = { path = "../did-utils"} +dotenv-flow = "0.15.0" +hyper = { version = "0.14.27", features = ["full"] } +multibase = { version = "0.8.0" } # earlier version due to 'did-utils' +serde_json = "1.0.104" +thiserror = "1.0.49" +tokio = { version = "1.30.0", features = ["full"] } +tracing = "0.1.37" +url = { version = "2.4.0" } +uuid = { version = "1.4.1", features = ["v4"] } +zeroize = { version = "1.6.0" } + +# Plugins traits +server-plugin = { path = "../server-plugin" } + +[dev-dependencies] +json-canon = "0.1.3" +tower = { version = "0.4.13", features = ["util"] } diff --git a/did-endpoint/src/didgen.rs b/did-endpoint/src/didgen.rs new file mode 100644 index 00000000..1af0dc76 --- /dev/null +++ b/did-endpoint/src/didgen.rs @@ -0,0 +1,328 @@ +use crate::util::{didweb, KeyStore}; +use did_utils::{ + didcore::{ + AssertionMethod, Authentication, Document, Jwk, KeyAgreement, KeyFormat, Service, + VerificationMethod, + }, + ldmodel::Context, +}; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("KeyGenerationError")] + KeyGenerationError, + #[error("MissingServerPublicDomain")] + MissingServerPublicDomain, + #[error("DidAddressDerivationError")] + DidAddressDerivationError, + #[error("PersistenceError")] + PersistenceError, + #[error("Generic: {0}")] + Generic(String), +} + +/// Generates keys and forward them for DID generation +/// +/// All persistence is handled at `storage_dirpath`. +pub fn didgen(storage_dirpath: &str, server_public_domain: &str) -> Result { + // Create a new store, which is timestamp-aware + let mut store = KeyStore::new(storage_dirpath); + tracing::info!("keystore: {}", store.path()); + + // Generate authentication key + tracing::debug!("generating authentication key"); + let authentication_key = store + .gen_ed25519_jwk() + .map_err(|_| Error::KeyGenerationError)?; + + // Generate assertion key + tracing::debug!("generating assertion key"); + let assertion_key = store + .gen_ed25519_jwk() + .map_err(|_| Error::KeyGenerationError)?; + + // Generate agreement key + tracing::debug!("generating agreement key"); + let agreement_key = store + .gen_x25519_jwk() + .map_err(|_| Error::KeyGenerationError)?; + + // Build DID document + let diddoc = gen_diddoc( + storage_dirpath, + server_public_domain, + authentication_key, + assertion_key, + agreement_key, + )?; + + // Mark successful completion + tracing::debug!("successful completion"); + Ok(diddoc) +} + +/// Builds and persists DID document +fn gen_diddoc( + storage_dirpath: &str, + server_public_domain: &str, + authentication_key: Jwk, + assertion_key: Jwk, + agreement_key: Jwk, +) -> Result { + tracing::info!("building DID document"); + + // Prepare DID address + + let did = didweb::url_to_did_web_id(server_public_domain) + .map_err(|_| Error::DidAddressDerivationError)?; + + // Prepare authentication verification method + + let authentication_method = VerificationMethod { + public_key: Some(KeyFormat::Jwk(authentication_key)), + ..VerificationMethod::new( + did.clone() + "#keys-1", + String::from("JsonWebKey2020"), + did.clone(), + ) + }; + + // Prepare assertion verification method + + let assertion_method = VerificationMethod { + public_key: Some(KeyFormat::Jwk(assertion_key)), + ..VerificationMethod::new( + did.clone() + "#keys-2", + String::from("JsonWebKey2020"), + did.clone(), + ) + }; + + // Prepare key agreement verification method + + let agreement_method = VerificationMethod { + public_key: Some(KeyFormat::Jwk(agreement_key)), + ..VerificationMethod::new( + did.clone() + "#keys-3", + String::from("JsonWebKey2020"), + did.clone(), + ) + }; + + // Prepare service endpoint + + let service = Service::new( + did.clone() + "#pop-domain", + String::from("LinkedDomains"), + format!("{server_public_domain}/.well-known/did/pop.json"), + ); + + // Build document + + let context = Context::SetOfString(vec![ + String::from("https://www.w3.org/ns/did/v1"), + String::from("https://w3id.org/security/suites/jws-2020/v1"), + ]); + + let diddoc = Document { + authentication: Some(vec![Authentication::Reference( + authentication_method.id.clone(), // + )]), + assertion_method: Some(vec![AssertionMethod::Reference( + assertion_method.id.clone(), // + )]), + key_agreement: Some(vec![KeyAgreement::Reference( + agreement_method.id.clone(), // + )]), + verification_method: Some(vec![ + authentication_method, + assertion_method, + agreement_method, + ]), + service: Some(vec![service]), + ..Document::new(context, did) + }; + + // Serialize and persist to file + + let did_json = serde_json::to_string_pretty(&diddoc).unwrap(); + + std::fs::create_dir_all(storage_dirpath).map_err(|_| Error::PersistenceError)?; + std::fs::write(format!("{storage_dirpath}/did.json"), did_json) + .map_err(|_| Error::PersistenceError)?; + + tracing::info!("persisted DID document to disk"); + Ok(diddoc) +} + +/// Validates the integrity of the persisted diddoc +pub fn validate_diddoc(storage_dirpath: &str) -> Result<(), String> { + // Validate that did.json exists + + let didpath = format!("{storage_dirpath}/did.json"); + if !Path::new(&didpath).exists() { + return Err(String::from("Missing did.json")); + }; + + // Validate that keystore exists + + let store = KeyStore::latest(storage_dirpath); + if store.is_none() { + return Err(String::from("Missing keystore")); + } + + // Validate that did.json matches keystore + + let store = store.unwrap(); + + let diddoc: Document = match std::fs::read_to_string(didpath) { + Err(_) => return Err(String::from("Unreadable did.json")), + Ok(content) => { + serde_json::from_str(&content).map_err(|_| String::from("Unparseable did.json"))? + } + }; + + for method in diddoc.verification_method.unwrap_or(vec![]) { + let pubkey = method.public_key.ok_or(String::from("Missing key"))?; + let pubkey = match pubkey { + KeyFormat::Jwk(jwk) => jwk, + _ => return Err(String::from("Unsupported key format")), + }; + + store + .find_keypair(&pubkey) + .ok_or(String::from("Keystore mismatch"))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::util::dotenv_flow_read; + + fn setup() -> (String, String) { + let storage_dirpath = dotenv_flow_read("STORAGE_DIRPATH") + .map(|p| format!("{}/{}", p, uuid::Uuid::new_v4())) + .unwrap(); + + let server_public_domain = dotenv_flow_read("SERVER_PUBLIC_DOMAIN").unwrap(); + + (storage_dirpath, server_public_domain) + } + + fn cleanup(storage_dirpath: &str) { + std::fs::remove_dir_all(storage_dirpath).unwrap(); + } + + // Verifies that the didgen function returns a DID document. + // Does not validate the DID document. + #[test] + fn test_didgen() { + let (storage_dirpath, server_public_domain) = setup(); + + let diddoc = didgen(&storage_dirpath, &server_public_domain).unwrap(); + assert_eq!(diddoc.id, "did:web:example.com"); + + cleanup(&storage_dirpath); + } + + // Produces did doc from keys and validate that corresponding verification methods are present. + #[test] + fn test_gen_diddoc() { + let (storage_dirpath, server_public_domain) = setup(); + + let authentication_key = Jwk { + key_id: None, + key_type: String::from("OKP"), + curve: String::from("Ed25519"), + x: Some(String::from( + "d75a980182b10ab2463c5b1be1b4d97e06ec21ebac8552059996bd962d77f259", + )), + y: None, + d: None, + }; + + let assertion_key = Jwk { + key_id: None, + key_type: String::from("OKP"), + curve: String::from("Ed25519"), + x: Some(String::from( + "d75a980182b10ab2463c5b1be1b4d97e06ec21ebac8552059996bd962d77f259", + )), + y: None, + d: None, + }; + + let agreement_key = Jwk { + key_id: None, + key_type: String::from("OKP"), + curve: String::from("X25519"), + x: Some(String::from( + "d75a980182b10ab2463c5b1be1b4d97e06ec21ebac8552059996bd962d77f259", + )), + y: None, + d: None, + }; + + let diddoc = gen_diddoc( + &storage_dirpath, + &server_public_domain, + authentication_key.clone(), + assertion_key.clone(), + agreement_key.clone(), + ) + .unwrap(); + + // Verify that the DID contains exactly the defined verification methods. + let expected_verification_methods = vec![ + VerificationMethod { + id: "did:web:example.com#keys-1".to_string(), + public_key: Some(KeyFormat::Jwk(authentication_key)), + ..VerificationMethod::new( + "did:web:example.com#keys-1".to_string(), + String::from("JsonWebKey2020"), + "did:web:example.com".to_string(), + ) + }, + VerificationMethod { + id: "did:web:example.com#keys-2".to_string(), + public_key: Some(KeyFormat::Jwk(assertion_key)), + ..VerificationMethod::new( + "did:web:example.com#keys-2".to_string(), + String::from("JsonWebKey2020"), + "did:web:example.com".to_string(), + ) + }, + VerificationMethod { + id: "did:web:example.com#keys-3".to_string(), + public_key: Some(KeyFormat::Jwk(agreement_key)), + ..VerificationMethod::new( + "did:web:example.com#keys-3".to_string(), + String::from("JsonWebKey2020"), + "did:web:example.com".to_string(), + ) + }, + ]; + + let actual_verification_methods = diddoc.verification_method.unwrap(); + + let actual = json_canon::to_string(&actual_verification_methods).unwrap(); + let expected = json_canon::to_string(&expected_verification_methods).unwrap(); + assert_eq!(expected, actual); + + cleanup(&storage_dirpath); + } + + #[test] + fn test_validate_diddoc() { + let (storage_dirpath, server_public_domain) = setup(); + + didgen(&storage_dirpath, &server_public_domain).unwrap(); + assert!(validate_diddoc(&storage_dirpath).is_ok()); + + cleanup(&storage_dirpath); + } +} diff --git a/did-endpoint/src/lib.rs b/did-endpoint/src/lib.rs new file mode 100644 index 00000000..c83b050d --- /dev/null +++ b/did-endpoint/src/lib.rs @@ -0,0 +1,5 @@ +pub mod didgen; +pub mod web; +pub mod plugin; + +mod util; diff --git a/did-endpoint/src/plugin.rs b/did-endpoint/src/plugin.rs new file mode 100644 index 00000000..4c1bc2e1 --- /dev/null +++ b/did-endpoint/src/plugin.rs @@ -0,0 +1,43 @@ +use axum::Router; +use super::{didgen, web}; +use server_plugin::{Plugin, PluginError}; + +#[derive(Default)] +pub struct DidEndpointPlugin; + +impl Plugin for DidEndpointPlugin { + fn name(&self) -> &'static str { + "did_endpoint" + } + + fn mount(&self) -> Result<(), PluginError> { + let storage_dirpath = std::env::var("STORAGE_DIRPATH").map_err(|_| { + tracing::error!("STORAGE_DIRPATH env variable required"); + PluginError::InitError + })?; + + if didgen::validate_diddoc(&storage_dirpath).is_err() { + tracing::debug!("diddoc validation failed, will generate one"); + + let server_public_domain = std::env::var("SERVER_PUBLIC_DOMAIN").map_err(|_| { + tracing::error!("SERVER_PUBLIC_DOMAIN env variable required"); + PluginError::InitError + })?; + + didgen::didgen(&storage_dirpath, &server_public_domain).map_err(|_| { + tracing::error!("failed to generate an initial keystore and its DID document"); + PluginError::InitError + })?; + }; + + Ok(()) + } + + fn unmount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn routes(&self) -> Router { + web::routes() + } +} diff --git a/sample-pop-server/src/util/didweb.rs b/did-endpoint/src/util/didweb.rs similarity index 100% rename from sample-pop-server/src/util/didweb.rs rename to did-endpoint/src/util/didweb.rs diff --git a/sample-pop-server/src/util/keystore.rs b/did-endpoint/src/util/keystore.rs similarity index 61% rename from sample-pop-server/src/util/keystore.rs rename to did-endpoint/src/util/keystore.rs index 3abef953..21987453 100644 --- a/sample-pop-server/src/util/keystore.rs +++ b/did-endpoint/src/util/keystore.rs @@ -1,42 +1,39 @@ +use chrono::Utc; use did_utils::{ - crypto::{ - ed25519::Ed25519KeyPair, - traits::{Generate, KeyMaterial}, - x25519::X25519KeyPair, - }, + crypto::{ed25519::Ed25519KeyPair, traits::Generate, x25519::X25519KeyPair}, didcore::Jwk, }; -use serde_json::{json, Value}; use std::error::Error; - -use crate::KEYSTORE_DIR; +use zeroize::Zeroize; pub struct KeyStore { - path: String, + dirpath: String, + filename: String, keys: Vec, } -struct KeyStoreFactory { - location: String, -} - -impl KeyStoreFactory { - fn create(&self) -> KeyStore { - KeyStore { - path: format!("{}/{}.json", self.location, chrono::Utc::now().timestamp()), +impl KeyStore { + /// Constructs file-based key-value store. + pub fn new(storage_dirpath: &str) -> Self { + Self { + dirpath: format!("{storage_dirpath}/keystore"), + filename: format!("{}.json", Utc::now().timestamp()), keys: vec![], } } - fn latest(&self) -> Option { + /// Returns latest store on disk, if any. + pub fn latest(storage_dirpath: &str) -> Option { + let dirpath = format!("{storage_dirpath}/keystore"); + let msg = "Error parsing keystore directory"; - let file = std::fs::read_dir(&self.location) + let file = std::fs::read_dir(&dirpath) .expect(msg) .map(|x| x.expect(msg).path().to_str().expect(msg).to_string()) .filter(|p| p.ends_with(".json")) .max_by_key(|p| { let p = p - .trim_start_matches(&format!("{}/", self.location)) + .trim_start_matches(&format!("{}/", &dirpath)) .trim_end_matches(".json"); p.parse::().expect(msg) }); @@ -47,39 +44,31 @@ impl KeyStoreFactory { Err(_) => None, Ok(content) => match serde_json::from_str::>(&content) { Err(_) => None, - Ok(keys) => Some(KeyStore { path, keys }), + Ok(keys) => { + let filename = path + .trim_start_matches(&format!("{}/", &dirpath)) + .to_string(); + + Some(KeyStore { + dirpath, + filename, + keys, + }) + } }, }, } } -} - -impl KeyStore { - /// Constructs file-based key-value store. - pub fn new() -> Self { - Self::factory(KEYSTORE_DIR).create() - } - - /// Returns latest store on disk, if any. - pub fn latest() -> Option { - Self::factory(KEYSTORE_DIR).latest() - } - - /// Returns location-aware factory - fn factory(location: &str) -> KeyStoreFactory { - KeyStoreFactory { - location: location.to_string(), - } - } /// Gets path pub fn path(&self) -> String { - self.path.clone() + format!("{}/{}", self.dirpath, self.filename) } /// Persists store on disk fn persist(&self) -> std::io::Result<()> { - std::fs::write(self.path.clone(), serde_json::to_string_pretty(&self.keys)?) + std::fs::create_dir_all(&self.dirpath)?; + std::fs::write(self.path(), serde_json::to_string_pretty(&self.keys)?) } /// Searches keypair given public key @@ -118,9 +107,11 @@ impl KeyStore { } } -impl Default for KeyStore { - fn default() -> Self { - Self::new() +impl Drop for KeyStore { + fn drop(&mut self) { + for jwk in &mut self.keys { + jwk.d.zeroize(); + }; } } @@ -140,21 +131,23 @@ impl ToPublic for Jwk { #[cfg(test)] mod tests { use super::*; - use crate::util::crate_name; - use tempdir::TempDir; + use crate::util::dotenv_flow_read; - impl KeyStore { - fn destroy(self) { - std::fs::remove_file(self.path); - } + fn setup() -> String { + dotenv_flow_read("STORAGE_DIRPATH") + .map(|p| format!("{}/{}", p, uuid::Uuid::new_v4())) + .unwrap() + } + + fn cleanup(storage_dirpath: &str) { + std::fs::remove_dir_all(storage_dirpath).unwrap(); } #[test] fn test_keystore_flow() { - let location = TempDir::new(&crate_name()).unwrap(); - let factory = KeyStore::factory(location.path().to_str().unwrap()); + let storage_dirpath = setup(); - let mut store = factory.create(); + let mut store = KeyStore::new(&storage_dirpath); let jwk = store.gen_ed25519_jwk().unwrap(); assert!(store.find_keypair(&jwk).is_some()); @@ -162,9 +155,9 @@ mod tests { let jwk = store.gen_x25519_jwk().unwrap(); assert!(store.find_keypair(&jwk).is_some()); - let latest = factory.latest(); + let latest = KeyStore::latest(&storage_dirpath); assert!(latest.is_some()); - store.destroy(); + cleanup(&storage_dirpath); } } diff --git a/did-endpoint/src/util/mod.rs b/did-endpoint/src/util/mod.rs new file mode 100644 index 00000000..aaa9f56b --- /dev/null +++ b/did-endpoint/src/util/mod.rs @@ -0,0 +1,12 @@ +pub mod didweb; +pub mod keystore; + +pub use keystore::KeyStore; + +#[cfg(test)] +pub fn dotenv_flow_read(key: &str) -> Option { + dotenv_flow::dotenv_iter().unwrap().find_map(|item| { + let (k, v) = item.unwrap(); + (k == key).then_some(v) + }) +} diff --git a/sample-pop-server/src/web/did.rs b/did-endpoint/src/web.rs similarity index 78% rename from sample-pop-server/src/web/did.rs rename to did-endpoint/src/web.rs index 0dd148ce..2d7d09cd 100644 --- a/sample-pop-server/src/web/did.rs +++ b/did-endpoint/src/web.rs @@ -14,7 +14,7 @@ use multibase::Base; use serde_json::{json, Value}; use std::collections::HashMap; -use crate::{util::KeyStore, DIDDOC_DIR}; +use crate::util::KeyStore; const DEFAULT_CONTEXT_V2: &str = "https://www.w3.org/ns/credentials/v2"; @@ -24,18 +24,28 @@ pub fn routes() -> Router { .route("/.well-known/did/pop.json", get(didpop)) } -pub async fn diddoc() -> Result, StatusCode> { - match tokio::fs::read_to_string(&format!("{DIDDOC_DIR}/did.json")).await { +async fn diddoc() -> Result, StatusCode> { + let storage_dirpath = std::env::var("STORAGE_DIRPATH").map_err(|_| { + tracing::error!("STORAGE_DIRPATH env variable required"); + StatusCode::NOT_FOUND + })?; + + match tokio::fs::read_to_string(&format!("{storage_dirpath}/did.json")).await { Ok(content) => Ok(Json(serde_json::from_str(&content).unwrap())), Err(_) => Err(StatusCode::NOT_FOUND), } } -pub async fn didpop( - Query(params): Query>, -) -> Result, StatusCode> { +async fn didpop(Query(params): Query>) -> Result, StatusCode> { let challenge = params.get("challenge").ok_or(StatusCode::BAD_REQUEST)?; - let keystore = KeyStore::latest().expect("Keystore file probably missing"); + + // Retrieve keystore + + let storage_dirpath = std::env::var("STORAGE_DIRPATH").map_err(|_| { + tracing::error!("STORAGE_DIRPATH env variable required"); + StatusCode::NOT_FOUND + })?; + let keystore = KeyStore::latest(&storage_dirpath).expect("Keystore file probably missing"); // Load DID document and its verification methods @@ -164,7 +174,9 @@ fn inspect_vm_relationship(diddoc: &Document, vm_id: &str) -> Option { #[cfg(test)] mod tests { - use crate::app; + use super::*; + use crate::{didgen, util::dotenv_flow_read}; + use axum::{ body::Body, http::{Request, StatusCode}, @@ -177,10 +189,33 @@ mod tests { use serde_json::json; use tower::util::ServiceExt; + fn setup_ephemeral_diddoc() -> (String, Document) { + let storage_dirpath = dotenv_flow_read("STORAGE_DIRPATH") + .map(|p| format!("{}/{}", p, uuid::Uuid::new_v4())) + .unwrap(); + + let server_public_domain = dotenv_flow_read("SERVER_PUBLIC_DOMAIN").unwrap(); + + // Run didgen logic + let diddoc = didgen::didgen(&storage_dirpath, &server_public_domain).unwrap(); + + // TODO! Find a race-free way to accomodate this. Maybe a test mutex? + std::env::set_var("STORAGE_DIRPATH", &storage_dirpath); + + (storage_dirpath, diddoc) + } + + fn cleanup(storage_dirpath: &str) { + std::env::remove_var("STORAGE_DIRPATH"); + std::fs::remove_dir_all(storage_dirpath).unwrap(); + } + #[tokio::test] async fn verify_didpop() { - let app = app(); + // Generate test-restricted did.json + let (storage_dirpath, expected_diddoc) = setup_ephemeral_diddoc(); + let app = routes(); let response = app .oneshot( Request::builder() @@ -202,6 +237,11 @@ mod tests { let vc = vp.verifiable_credential.get(0).unwrap(); let diddoc = serde_json::from_value(json!(vc.credential_subject)).unwrap(); + assert_eq!( + json_canon::to_string(&diddoc).unwrap(), + json_canon::to_string(&expected_diddoc).unwrap() + ); + let Some(proofs) = &vp.proof else { panic!("Verifiable presentation carries no proof") }; let Proofs::SetOfProofs(proofs) = proofs else { unreachable!() }; for proof in proofs { @@ -215,6 +255,8 @@ mod tests { assert!(verifier.verify(json!(vp)).is_ok()); } + + cleanup(&storage_dirpath); } fn resolve_vm_for_public_key(diddoc: &Document, vm_id: &str) -> Option { diff --git a/sample-pop-server/.env b/generic-server/.env similarity index 84% rename from sample-pop-server/.env rename to generic-server/.env index 29daf860..591c498d 100644 --- a/sample-pop-server/.env +++ b/generic-server/.env @@ -3,3 +3,5 @@ SERVER_PUBLIC_DOMAIN=https://example.com SERVER_LOCAL_PORT=3000 + +STORAGE_DIRPATH="target/storage" diff --git a/generic-server/.gitignore b/generic-server/.gitignore new file mode 100644 index 00000000..11ee7581 --- /dev/null +++ b/generic-server/.gitignore @@ -0,0 +1 @@ +.env.local diff --git a/sample-pop-server/Cargo.toml b/generic-server/Cargo.toml similarity index 59% rename from sample-pop-server/Cargo.toml rename to generic-server/Cargo.toml index c01c0b24..01e8a2bf 100644 --- a/sample-pop-server/Cargo.toml +++ b/generic-server/Cargo.toml @@ -1,27 +1,36 @@ [package] -name = "sample-pop-server" +name = "generic-server" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -default-run = "sample-pop-server" +default-run = "generic-server" [dependencies] async-trait = "0.1.73" axum = { version = "0.6.20" } -chrono = "0.4.26" -did-utils = { path = "../did-utils" } dotenv-flow = "0.15.0" hyper = { version = "0.14.27", features = ["full"] } -multibase = "0.8.0" # earlier version due to 'did-utils' +lazy_static = "1.4.0" serde_json = "1.0.104" tokio = { version = "1.30.0", features = ["full"] } tower-http = { version = "0.4.3", features = ["catch-panic", "trace"] } tracing = "0.1.37" tracing-subscriber = { version = "0.3.17", features = ["json"] } -url = "2.4.0" -uuid = { version = "1.4.1", features = ["v4"] } + +# Plugins traits +server-plugin = { path = "../server-plugin" } + +# optional +chrono = { version = "0.4.26", optional = true } +did-endpoint = { path = "../did-endpoint", optional = true } [dev-dependencies] -tempdir = "0.3.7" tower = { version = "0.4.13", features = ["util"] } + +[features] +default = ["plugin-index", "plugin-did_endpoint"] + +# plugins +plugin-index = ["dep:chrono"] +plugin-did_endpoint = ["dep:did-endpoint"] diff --git a/generic-server/README.md b/generic-server/README.md new file mode 100644 index 00000000..d11c06c4 --- /dev/null +++ b/generic-server/README.md @@ -0,0 +1,2 @@ +# generic-server +This server aggregates features provided by configurable plugins. diff --git a/sample-pop-server/src/lib.rs b/generic-server/src/lib.rs similarity index 51% rename from sample-pop-server/src/lib.rs rename to generic-server/src/lib.rs index 93ab9b98..5cfbd604 100644 --- a/sample-pop-server/src/lib.rs +++ b/generic-server/src/lib.rs @@ -1,18 +1,18 @@ -pub mod didgen; -pub mod model; +pub mod plugin; pub mod util; -pub mod web; + +use plugin::container::PluginContainer; use axum::Router; use tower_http::catch_panic::CatchPanicLayer; use tower_http::trace::TraceLayer; -#[allow(unused)] -pub const DIDDOC_DIR: &str = "storage"; -pub const KEYSTORE_DIR: &str = "storage/keystore"; - pub fn app() -> Router { - web::routes() // + let mut container = PluginContainer::default(); + let _ = container.load(); + + Router::new() // + .merge(container.routes().unwrap_or_default()) .layer(TraceLayer::new_for_http()) .layer(CatchPanicLayer::new()) } diff --git a/sample-pop-server/src/main.rs b/generic-server/src/main.rs similarity index 63% rename from sample-pop-server/src/main.rs rename to generic-server/src/main.rs index c6d4b1d3..33d6a5de 100644 --- a/sample-pop-server/src/main.rs +++ b/generic-server/src/main.rs @@ -1,4 +1,4 @@ -use sample_pop_server::{app, didgen}; +use generic_server::app; use axum::Server; use std::net::SocketAddr; @@ -8,14 +8,9 @@ async fn main() { // Load dotenv-flow variables dotenv_flow::dotenv_flow().ok(); - // Enable tracing + // Enable logging config_tracing(); - // Run `didgen` if necessary - if didgen::validate_diddoc().is_err() { - didgen::didgen().expect("Failed to generate an initial keystore and its DID document."); - }; - // Start server let port = std::env::var("SERVER_LOCAL_PORT").unwrap_or("3000".to_owned()); let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); @@ -32,10 +27,9 @@ fn config_tracing() { let tracing_layer = tracing_subscriber::fmt::layer(); let filter = filter::Targets::new() - .with_target("tower_http::trace::on_response", Level::DEBUG) - .with_target("tower_http::trace::on_request", Level::DEBUG) - .with_target("tower_http::trace::make_span", Level::DEBUG) - .with_default(Level::INFO); + .with_target("hyper::proto", Level::INFO) + .with_target("tower_http::trace", Level::DEBUG) + .with_default(Level::DEBUG); tracing_subscriber::registry() .with(tracing_layer) diff --git a/generic-server/src/plugin/container.rs b/generic-server/src/plugin/container.rs new file mode 100644 index 00000000..caafcb0c --- /dev/null +++ b/generic-server/src/plugin/container.rs @@ -0,0 +1,271 @@ +use std::collections::{HashMap, HashSet}; + +use axum::Router; +use server_plugin::{Plugin, PluginError}; + +use super::PLUGINS; + +#[derive(Debug, PartialEq)] +pub enum PluginContainerError { + DuplicateEntry, + Unloaded, + PluginErrorMap(HashMap), +} + +pub struct PluginContainer<'a> { + loaded: bool, + collected_routes: Vec, + plugins: &'a Vec>, +} + +impl<'a> Default for PluginContainer<'a> { + fn default() -> Self { + Self::new() + } +} + +impl<'a> PluginContainer<'a> { + /// Instantiate an object aware of all statically registered plugins + pub fn new() -> Self { + Self { + loaded: false, + collected_routes: vec![], + plugins: &*PLUGINS, + } + } + + /// Search loaded plugin based on name string + pub fn find_plugin(&self, name: &str) -> Option<&dyn Plugin> { + self.plugins + .iter() + .find_map(|plugin| (name == plugin.name()).then_some(&**plugin)) + } + + /// Load referenced plugins + /// + /// This entails mounting them and merging their routes internally (only + /// upon successful initialization). An error is returned if plugins + /// bearing the same name are found. Also, all plugins failing to be + /// initialized are returned in a map with respectively raised errors. + pub fn load(&mut self) -> Result<(), PluginContainerError> { + tracing::debug!("loading plugin container"); + + // Checking for duplicates + let unique_plugins: HashSet<_> = self.plugins.iter().collect(); + if unique_plugins.len() != self.plugins.len() { + tracing::error!("found duplicate entries in plugin registry"); + return Err(PluginContainerError::DuplicateEntry); + } + + // Reset collection of routes + self.collected_routes.truncate(0); + + // Mount plugins and collect routes on successful status + let errors: HashMap<_, _> = self + .plugins + .iter() + .filter_map(|plugin| match plugin.mount() { + Ok(_) => { + tracing::info!("mounted plugin {}", plugin.name()); + self.collected_routes.push(plugin.routes()); + None + } + Err(err) => { + tracing::error!("error mounting plugin {}", plugin.name()); + Some((plugin.name().to_string(), err)) + } + }) + .collect(); + + // Flag as loaded + self.loaded = true; + + // Return state of completion + if errors.is_empty() { + tracing::debug!("plugin container loaded"); + Ok(()) + } else { + Err(PluginContainerError::PluginErrorMap(errors)) + } + } + + /// Merge collected routes from all plugins successfully initialized. + pub fn routes(&self) -> Result { + if self.loaded { + Ok(self + .collected_routes + .iter() + .fold(Router::new(), |acc, e| acc.merge(e.clone()))) + } else { + Err(PluginContainerError::Unloaded) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::routing::get; + + struct FirstPlugin; + impl Plugin for FirstPlugin { + fn name(&self) -> &'static str { + "first" + } + + fn mount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn unmount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn routes(&self) -> Router { + Router::new().route("/first", get(|| async {})) + } + } + + struct SecondPlugin; + impl Plugin for SecondPlugin { + fn name(&self) -> &'static str { + "second" + } + + fn mount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn unmount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn routes(&self) -> Router { + Router::new().route("/second", get(|| async {})) + } + } + + struct SecondAgainPlugin; + impl Plugin for SecondAgainPlugin { + fn name(&self) -> &'static str { + "second" + } + + fn mount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn unmount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn routes(&self) -> Router { + Router::new().route("/second", get(|| async {})) + } + } + + struct FaultyPlugin; + impl Plugin for FaultyPlugin { + fn name(&self) -> &'static str { + "faulty" + } + + fn mount(&self) -> Result<(), PluginError> { + Err(PluginError::InitError) + } + + fn unmount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn routes(&self) -> Router { + Router::new().route("/faulty", get(|| async {})) + } + } + + #[test] + fn test_loading() { + let mut container = PluginContainer { + loaded: false, + collected_routes: vec![], + plugins: &vec![Box::new(FirstPlugin {}), Box::new(SecondPlugin {})], + }; + + assert!(container.load().is_ok()); + assert!(container.routes().is_ok()); + + assert!(container.find_plugin("first").is_some()); + assert!(container.find_plugin("second").is_some()); + assert!(container.find_plugin("non-existent").is_none()); + + // The actual routes collected are actually hard to test + // given that axum::Router seems not to provide public + // directives to inquire internal state. + // See: https://github.com/tokio-rs/axum/discussions/860 + assert_eq!(container.collected_routes.len(), 2); + } + + #[test] + fn test_double_loading() { + let mut container = PluginContainer { + loaded: false, + collected_routes: vec![], + plugins: &vec![Box::new(FirstPlugin {}), Box::new(SecondPlugin {})], + }; + + assert!(container.load().is_ok()); + assert!(container.load().is_ok()); + + assert_eq!(container.collected_routes.len(), 2); + } + + #[test] + fn test_loading_with_duplicates() { + let mut container = PluginContainer { + loaded: false, + collected_routes: vec![], + plugins: &vec![Box::new(SecondPlugin {}), Box::new(SecondAgainPlugin {})], + }; + + assert_eq!( + container.load().unwrap_err(), + PluginContainerError::DuplicateEntry + ); + } + + #[test] + fn test_loading_with_failing_plugin() { + let mut container = PluginContainer { + loaded: false, + collected_routes: vec![], + plugins: &vec![Box::new(FirstPlugin {}), Box::new(FaultyPlugin {})], + }; + + let err = container.load().unwrap_err(); + + assert_eq!( + err, + PluginContainerError::PluginErrorMap( + [("faulty".to_string(), PluginError::InitError)] + .into_iter() + .collect() + ) + ); + + assert_eq!(container.collected_routes.len(), 1); + } + + #[test] + fn test_route_extraction_without_loading() { + let container = PluginContainer { + loaded: false, + collected_routes: vec![], + plugins: &vec![Box::new(FirstPlugin {}), Box::new(SecondPlugin {})], + }; + + assert_eq!( + container.routes().unwrap_err(), + PluginContainerError::Unloaded + ); + } +} diff --git a/generic-server/src/plugin/index/mod.rs b/generic-server/src/plugin/index/mod.rs new file mode 100644 index 00000000..d225143c --- /dev/null +++ b/generic-server/src/plugin/index/mod.rs @@ -0,0 +1,25 @@ +mod web; + +use axum::Router; +use server_plugin::{Plugin, PluginError}; + +#[derive(Default)] +pub struct IndexPlugin; + +impl Plugin for IndexPlugin { + fn name(&self) -> &'static str { + "index" + } + + fn mount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn unmount(&self) -> Result<(), PluginError> { + Ok(()) + } + + fn routes(&self) -> Router { + web::routes() + } +} diff --git a/sample-pop-server/src/web/index.rs b/generic-server/src/plugin/index/web.rs similarity index 93% rename from sample-pop-server/src/web/index.rs rename to generic-server/src/plugin/index/web.rs index 53d4319d..abcd79fb 100644 --- a/sample-pop-server/src/web/index.rs +++ b/generic-server/src/plugin/index/web.rs @@ -21,7 +21,9 @@ pub async fn index() -> Json { #[cfg(test)] mod tests { - use crate::{app, util::crate_name}; + use super::*; + use crate::util::crate_name; + use axum::{ body::Body, http::{Request, StatusCode}, @@ -31,7 +33,7 @@ mod tests { #[tokio::test] async fn index() { - let app = app(); + let app = routes(); let response = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) diff --git a/generic-server/src/plugin/mod.rs b/generic-server/src/plugin/mod.rs new file mode 100644 index 00000000..90d7c8fc --- /dev/null +++ b/generic-server/src/plugin/mod.rs @@ -0,0 +1,16 @@ +pub mod container; + +use lazy_static::lazy_static; +use server_plugin::Plugin; + +#[cfg(feature = "plugin-index")] +mod index; + +lazy_static! { + pub static ref PLUGINS: Vec> = vec![ + #[cfg(feature = "plugin-index")] + Box::::default(), + #[cfg(feature = "plugin-did_endpoint")] + Box::::default(), + ]; +} diff --git a/sample-pop-server/src/util/mod.rs b/generic-server/src/util/mod.rs similarity index 77% rename from sample-pop-server/src/util/mod.rs rename to generic-server/src/util/mod.rs index 038c50d6..5310108e 100644 --- a/sample-pop-server/src/util/mod.rs +++ b/generic-server/src/util/mod.rs @@ -1,10 +1,5 @@ #![allow(unused)] -pub mod didweb; - -mod keystore; -pub use keystore::KeyStore; - pub fn crate_name() -> String { let current_dir = std::env::current_dir().unwrap(); let basename = current_dir.file_name().unwrap().to_str().unwrap(); diff --git a/run_test.sh b/run_test.sh new file mode 100755 index 00000000..12b0224a --- /dev/null +++ b/run_test.sh @@ -0,0 +1,10 @@ +#!/bin/bash +for dir in ./did-utils/ ./did-endpoint/ ./generic-server/ +do + cd "${dir}" + if [ -f Cargo.toml ]; then + echo "Running tests in: ${dir}" + cargo test + fi + cd .. +done \ No newline at end of file diff --git a/sample-pop-server/.gitignore b/sample-pop-server/.gitignore deleted file mode 100644 index 3a8fe5ed..00000000 --- a/sample-pop-server/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.env.local \ No newline at end of file diff --git a/sample-pop-server/README.md b/sample-pop-server/README.md deleted file mode 100644 index 9849bd5e..00000000 --- a/sample-pop-server/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# sample-pop-server -This project builds a sample did:web-compatible server that can prove -possession of the secrets paired with its DID document. diff --git a/sample-pop-server/src/bin/didgen.rs b/sample-pop-server/src/bin/didgen.rs deleted file mode 100644 index 56811239..00000000 --- a/sample-pop-server/src/bin/didgen.rs +++ /dev/null @@ -1,14 +0,0 @@ -use sample_pop_server::didgen; - -/// Program entry -fn main() -> Result<(), Box> { - // Load dotenv-flow variables - dotenv_flow::dotenv_flow().ok(); - - // Enable tracing - tracing_subscriber::fmt::init(); - - // Run didgen logic - didgen::didgen()?; - Ok(()) -} diff --git a/sample-pop-server/src/didgen.rs b/sample-pop-server/src/didgen.rs deleted file mode 100644 index eaf6a768..00000000 --- a/sample-pop-server/src/didgen.rs +++ /dev/null @@ -1,196 +0,0 @@ -use crate::{ - util::{didweb, KeyStore}, - DIDDOC_DIR, -}; -use did_utils::{ - didcore::{ - AssertionMethod, Authentication, Document, Jwk, KeyAgreement, KeyFormat, Service, - VerificationMethod, - }, - ldmodel::Context, -}; -use std::path::Path; - -#[derive(Debug)] -pub enum Error { - KeyGenerationError, - MissingServerPublicDomain, - DidAddressDerivationError, - PersistenceError, - Unknown(String), -} - -/// Generates keys and forward them for DID generation -pub fn didgen() -> Result { - // Create a new store, which is timestamp-aware - let mut store = KeyStore::new(); - tracing::info!("Keystore: {}", store.path()); - - // Generate authentication key - tracing::info!("Generating authentication key..."); - let authentication_key = store - .gen_ed25519_jwk() - .map_err(|_| Error::KeyGenerationError)?; - - // Generate assertion key - tracing::info!("Generating assertion key..."); - let assertion_key = store - .gen_ed25519_jwk() - .map_err(|_| Error::KeyGenerationError)?; - - // Generate agreement key - tracing::info!("Generating agreement key..."); - let agreement_key = store - .gen_x25519_jwk() - .map_err(|_| Error::KeyGenerationError)?; - - // Build DID document - let diddoc = gen_diddoc(authentication_key, assertion_key, agreement_key)?; - - // Mark successful completion - tracing::info!("Successful completion."); - Ok(diddoc) -} - -/// Builds and persists DID document -fn gen_diddoc( - authentication_key: Jwk, - assertion_key: Jwk, - agreement_key: Jwk, -) -> Result { - tracing::info!("Building DID document..."); - - // Prepare DID address - - let public_domain = std::env::var("SERVER_PUBLIC_DOMAIN") // - .map_err(|_| Error::MissingServerPublicDomain)?; - let did = didweb::url_to_did_web_id(&public_domain) // - .map_err(|_| Error::DidAddressDerivationError)?; - - // Prepare authentication verification method - - let authentication_method = VerificationMethod { - public_key: Some(KeyFormat::Jwk(authentication_key)), - ..VerificationMethod::new( - did.clone() + "#keys-1", - String::from("JsonWebKey2020"), - did.clone(), - ) - }; - - // Prepare assertion verification method - - let assertion_method = VerificationMethod { - public_key: Some(KeyFormat::Jwk(assertion_key)), - ..VerificationMethod::new( - did.clone() + "#keys-2", - String::from("JsonWebKey2020"), - did.clone(), - ) - }; - - // Prepare key agreement verification method - - let agreement_method = VerificationMethod { - public_key: Some(KeyFormat::Jwk(agreement_key)), - ..VerificationMethod::new( - did.clone() + "#keys-3", - String::from("JsonWebKey2020"), - did.clone(), - ) - }; - - // Prepare service endpoint - - let service = Service::new( - did.clone() + "#pop-domain", - String::from("LinkedDomains"), - format!("{public_domain}/.well-known/did/pop.json"), - ); - - // Build document - - let context = Context::SetOfString(vec![ - String::from("https://www.w3.org/ns/did/v1"), - String::from("https://w3id.org/security/suites/jws-2020/v1"), - ]); - - let doc = Document { - authentication: Some(vec![Authentication::Reference( - authentication_method.id.clone(), // - )]), - assertion_method: Some(vec![AssertionMethod::Reference( - assertion_method.id.clone(), // - )]), - key_agreement: Some(vec![KeyAgreement::Reference( - agreement_method.id.clone(), // - )]), - verification_method: Some(vec![ - authentication_method, - assertion_method, - agreement_method, - ]), - service: Some(vec![service]), - ..Document::new(context, did) - }; - - // Serialize and persist to file - - let did_json = serde_json::to_string_pretty(&doc).unwrap(); - - std::fs::write(format!("{DIDDOC_DIR}/did.json"), &did_json) - .map_err(|_| Error::PersistenceError)?; - - tracing::info!("Persisted DID document to file."); - Ok(did_json) -} - -/// Validates the integrity of the persisted diddoc -pub fn validate_diddoc() -> Result<(), String> { - // Validate that did.json exists - - let didpath = format!("{DIDDOC_DIR}/did.json"); - if !Path::new(&didpath).exists() { - return Err(String::from("Missing did.json")); - }; - - // Validate that keystore exists - - let store = KeyStore::latest(); - if store.is_none() { - return Err(String::from("Missing keystore")); - } - - // Validate that did.json matches keystore - - let store = store.unwrap(); - - let diddoc: Document = match std::fs::read_to_string(didpath) { - Err(_) => return Err(String::from("Unreadable did.json")), - Ok(content) => { - serde_json::from_str(&content).map_err(|_| String::from("Unparseable did.json"))? - } - }; - - for method in diddoc.verification_method.unwrap_or(vec![]) { - let pubkey = method.public_key.ok_or(String::from("Missing key"))?; - let pubkey = match pubkey { - KeyFormat::Jwk(jwk) => jwk, - _ => return Err(String::from("Unsupported key format")), - }; - - store - .find_keypair(&pubkey) - .ok_or(String::from("Keystore mismatch"))?; - } - - Ok(()) -} - -impl std::error::Error for Error {} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "{self:?}") - } -} diff --git a/sample-pop-server/src/model/mod.rs b/sample-pop-server/src/model/mod.rs deleted file mode 100644 index 8b137891..00000000 --- a/sample-pop-server/src/model/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/sample-pop-server/src/web/mod.rs b/sample-pop-server/src/web/mod.rs deleted file mode 100644 index 3aa9ddcf..00000000 --- a/sample-pop-server/src/web/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -mod did; -mod index; - -use axum::Router; - -pub fn routes() -> Router { - Router::new() // - .merge(index::routes()) - .merge(did::routes()) -} diff --git a/sample-pop-server/storage/.gitignore b/sample-pop-server/storage/.gitignore deleted file mode 100644 index 2b1de7e7..00000000 --- a/sample-pop-server/storage/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -did.json -keystore/* -!keystore/.gitkeep diff --git a/sample-pop-server/storage/keystore/.gitkeep b/sample-pop-server/storage/keystore/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/server-plugin/Cargo.toml b/server-plugin/Cargo.toml new file mode 100644 index 00000000..69c210fe --- /dev/null +++ b/server-plugin/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "server-plugin" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +axum = { version = "0.6.20" } diff --git a/server-plugin/src/lib.rs b/server-plugin/src/lib.rs new file mode 100644 index 00000000..7c4a334b --- /dev/null +++ b/server-plugin/src/lib.rs @@ -0,0 +1,42 @@ +use std::{ + fmt::Debug, + hash::{Hash, Hasher}, +}; + +use axum::Router; + +#[derive(Debug, PartialEq)] +pub enum PluginError { + InitError, +} + +pub trait Plugin: Sync { + /// Define a unique identifier + fn name(&self) -> &'static str; + + /// Provide initialization actions as needed + fn mount(&self) -> Result<(), PluginError>; + + /// Revert initialization actions as needed + fn unmount(&self) -> Result<(), PluginError>; + + /// Export managed endpoints + fn routes(&self) -> Router; +} + +impl Eq for dyn Plugin {} + +impl PartialEq for dyn Plugin { + fn eq(&self, other: &Self) -> bool { + self.name() == other.name() + } +} + +impl Hash for dyn Plugin { + fn hash(&self, state: &mut H) + where + H: Hasher, + { + self.name().hash(state) + } +}