diff --git a/Cargo.lock b/Cargo.lock index 383624ec8..db24b4cb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3926,6 +3926,7 @@ dependencies = [ "serde", "serde_bytes", "serde_ipld_dagcbor", + "serde_json", "strum", "strum_macros", "tiny-bip39", diff --git a/Cargo.toml b/Cargo.toml index 6907cf739..086012e85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,8 @@ void = { version = "1" } wnfs-namefilter = { version = "0.1.20" } strum = { version = "0.24" } strum_macros = { version = "0.24" } - +serde = { version = "^1" } +serde_json = { version = "^1" } [profile.release] opt-level = 'z' diff --git a/rust/noosphere-api/Cargo.toml b/rust/noosphere-api/Cargo.toml index a79701e0d..bca5df8a9 100644 --- a/rust/noosphere-api/Cargo.toml +++ b/rust/noosphere-api/Cargo.toml @@ -23,7 +23,7 @@ anyhow = "^1" thiserror = { workspace = true } cid = { workspace = true } url = "^2" -serde = "^1" +serde = { workspace = true } serde_urlencoded = "~0.7" tracing = { workspace = true } noosphere-core = { version = "0.11.0", path = "../noosphere-core" } diff --git a/rust/noosphere-cli/Cargo.toml b/rust/noosphere-cli/Cargo.toml index d994334f5..e55a58ebe 100644 --- a/rust/noosphere-cli/Cargo.toml +++ b/rust/noosphere-cli/Cargo.toml @@ -61,8 +61,8 @@ ucan-key-support = { workspace = true } cid = { workspace = true } subtext = "0.3.2" -serde = "^1" -serde_json = "^1" +serde = { workspace = true } +serde_json = { workspace = true } libipld-core = { workspace = true } libipld-cbor = { workspace = true } diff --git a/rust/noosphere-collections/Cargo.toml b/rust/noosphere-collections/Cargo.toml index baeedcd7b..647707f46 100644 --- a/rust/noosphere-collections/Cargo.toml +++ b/rust/noosphere-collections/Cargo.toml @@ -21,7 +21,7 @@ anyhow = "^1" sha2 = "0.10" cid = { workspace = true } forest_hash_utils = "0.1.0" -serde = "^1" +serde = { workspace = true } serde_bytes = "0.11" serde_ipld_dagcbor = "0.2" byteorder = "^1.4" @@ -48,4 +48,4 @@ tokio = { version = "^1", features = ["full"] } wasm-bindgen-test = "0.3" [features] -identity = [] \ No newline at end of file +identity = [] diff --git a/rust/noosphere-core/Cargo.toml b/rust/noosphere-core/Cargo.toml index b770bcbe8..995711cf5 100644 --- a/rust/noosphere-core/Cargo.toml +++ b/rust/noosphere-core/Cargo.toml @@ -33,7 +33,7 @@ anyhow = "^1" fastcdc = "3" futures = "~0.3" fvm_ipld_amt = "~0.5" -serde = "^1" +serde = { workspace = true } byteorder = "^1.4" base64 = "0.21" ed25519-zebra = "^3" @@ -56,6 +56,7 @@ ucan-key-support = { workspace = true } [dev-dependencies] wasm-bindgen-test = "~0.3" serde_bytes = "~0.11" +serde_json = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio = { version = "^1", features = ["full"] } diff --git a/rust/noosphere-core/src/data/address.rs b/rust/noosphere-core/src/data/address.rs index 268718142..ca917f495 100644 --- a/rust/noosphere-core/src/data/address.rs +++ b/rust/noosphere-core/src/data/address.rs @@ -1,10 +1,17 @@ +use crate::authority::{SphereAction, SphereReference, SPHERE_SEMANTICS, SUPPORTED_KEYS}; use anyhow::Result; use cid::Cid; use libipld_cbor::DagCborCodec; use noosphere_storage::BlockStore; use serde::{Deserialize, Serialize}; -use std::{convert::TryFrom, fmt::Display, ops::Deref}; -use ucan::{store::UcanJwtStore, Ucan}; +use std::{convert::TryFrom, fmt::Display, ops::Deref, str::FromStr}; +use ucan::{ + capability::{Capability, Resource, With}, + chain::ProofChain, + crypto::did::DidParser, + store::UcanJwtStore, + Ucan, +}; use super::{Did, IdentitiesIpld, Jwt, Link}; @@ -56,7 +63,9 @@ impl IdentityIpld { } /// A [LinkRecord] is a newtype that represents a JWT that ought to contain a -/// [Cid] reference to a sphere +/// [Cid] reference to a sphere. +/// Static methods are provided to operate on the decoded token ([Ucan]) +/// more performantly. #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize, Hash)] #[serde(from = "Jwt", into = "Jwt")] #[repr(transparent)] @@ -65,13 +74,88 @@ pub struct LinkRecord(Jwt); impl LinkRecord { /// Parse the wrapped [Jwt] as a [Ucan] and looks for the referenced pointer /// to some data in IPFS (via a [Cid] in the `fct` field). - pub async fn dereference(&self) -> Option { - let token = &self.0; - let ucan = match Ucan::try_from(token.to_string()) { - Ok(ucan) => ucan, - _ => return None, + pub fn dereference(&self) -> Option { + match Ucan::try_from(self) { + Ok(ucan) => LinkRecord::link(&ucan), + _ => None, + } + } + + /// Validates the [Ucan] token as a [LinkRecord], ensuring that + /// the sphere's owner authorized the publishing of a new + /// content address. Returns an `Err` if validation fails. + pub async fn validate( + token: &Ucan, + store: &S, + opt_did_parser: Option<&mut DidParser>, + ) -> Result<()> { + if LinkRecord::link(token).is_none() { + return Err(anyhow::anyhow!("LinkRecord missing link.")); + } + + let mut fallback_did_parser = if opt_did_parser.is_none() { + Some(DidParser::new(SUPPORTED_KEYS)) + } else { + None }; - let facts = ucan.facts(); + + let did_parser: &mut DidParser = if let Some(provided_parser) = opt_did_parser { + provided_parser + } else { + fallback_did_parser.as_mut().unwrap() + }; + + let identity = LinkRecord::identity(token); + + // We're interested in the validity of the proof at the time + // of publishing. + let now_time = if let Some(nbf) = token.not_before() { + nbf.to_owned() + } else { + token.expires_at() - 1 + }; + + let proof = + ProofChain::from_ucan(token.to_owned(), Some(now_time), did_parser, store).await?; + + { + let desired_capability = LinkRecord::generate_capability(identity); + let mut has_capability = false; + for capability_info in proof.reduce_capabilities(&SPHERE_SEMANTICS) { + let capability = capability_info.capability; + if capability_info.originators.contains(identity) + && capability.enables(&desired_capability) + { + has_capability = true; + break; + } + } + if !has_capability { + return Err(anyhow::anyhow!("LinkRecord is not authorized.")); + } + } + + token + .check_signature(did_parser) + .await + .map(|_| ()) + .map_err(|_| anyhow::anyhow!("LinkRecord has invalid signature.")) + } + + /// Returns true if the [Ucan] token is currently publishable + /// within the bounds of its expiry/not before time. + pub fn has_publishable_timeframe(token: &Ucan) -> bool { + !token.is_expired(None) && !token.is_too_early() + } + + /// The DID key of the sphere that this record maps. + pub fn identity(token: &Ucan) -> &str { + token.audience() + } + + /// The sphere revision address ([Cid]) that the sphere's identity maps to. + pub fn link(token: &Ucan) -> Option { + let facts = token.facts(); for fact in facts { match fact.as_object() { @@ -99,11 +183,37 @@ impl LinkRecord { } } } - warn!("No facts contained a link!"); - None } + + /// Generates a [Capability] struct representing permissions in a [LinkRecord]. + /// + /// ``` + /// use noosphere_core::{data::LinkRecord, authority::{SphereAction, SphereReference}}; + /// use ucan::capability::{Capability, Resource, With}; + /// + /// let identity = "did:key:z6MkoE19WHXJzpLqkxbGP7uXdJX38sWZNUWwyjcuCmjhPpUP"; + /// let expected_capability = Capability { + /// with: With::Resource { + /// kind: Resource::Scoped(SphereReference { + /// did: identity.to_owned(), + /// }), + /// }, + /// can: SphereAction::Publish, + /// }; + /// assert_eq!(LinkRecord::generate_capability(&identity), expected_capability); + /// ``` + pub fn generate_capability(identity: &str) -> Capability { + Capability { + with: With::Resource { + kind: Resource::Scoped(SphereReference { + did: identity.to_owned(), + }), + }, + can: SphereAction::Publish, + } + } } impl Deref for LinkRecord { @@ -131,3 +241,303 @@ impl From for Jwt { value.0 } } + +impl TryFrom for LinkRecord { + type Error = anyhow::Error; + fn try_from(value: Ucan) -> Result { + LinkRecord::try_from(&value) + } +} + +impl TryFrom for Ucan { + type Error = anyhow::Error; + fn try_from(value: LinkRecord) -> Result { + Ucan::try_from(&value) + } +} + +impl TryFrom<&Ucan> for LinkRecord { + type Error = anyhow::Error; + fn try_from(value: &Ucan) -> Result { + Ok(LinkRecord(Jwt(value.encode()?))) + } +} + +impl TryFrom<&LinkRecord> for Ucan { + type Error = anyhow::Error; + fn try_from(value: &LinkRecord) -> Result { + Ucan::from_str(&value.0) + } +} + +impl TryFrom> for LinkRecord { + type Error = anyhow::Error; + + fn try_from(value: Vec) -> Result { + Ok(LinkRecord(Jwt(String::from_utf8(value)?))) + } +} + +impl TryFrom for Vec { + type Error = anyhow::Error; + fn try_from(value: LinkRecord) -> Result { + Ok(Vec::try_from(value.0.to_string())?) + } +} + +impl<'a> From<&'a str> for LinkRecord { + fn from(value: &str) -> Self { + LinkRecord(Jwt(value.to_owned())) + } +} + +#[cfg(test)] +#[cfg(test)] +mod test { + use super::*; + use crate::{authority::generate_ed25519_key, data::Did, view::SPHERE_LIFETIME}; + use noosphere_storage::{MemoryStorage, SphereDb}; + use serde_json::json; + use ucan::{ + builder::UcanBuilder, crypto::did::DidParser, crypto::KeyMaterial, store::UcanJwtStore, + }; + + pub async fn from_issuer( + issuer: &K, + sphere_id: &Did, + link: &Cid, + proofs: Option<&Vec>, + ) -> Result { + let capability = LinkRecord::generate_capability(sphere_id); + let fact = json!({ "link": link.to_string() }); + + let mut builder = UcanBuilder::default() + .issued_by(issuer) + .for_audience(sphere_id) + .claiming_capability(&capability) + .with_fact(fact); + + if let Some(proofs) = proofs { + let mut earliest_expiry: u64 = u64::MAX; + for token in proofs { + earliest_expiry = *token.expires_at().min(&earliest_expiry); + builder = builder.witnessed_by(token); + } + builder = builder.with_expiration(earliest_expiry); + } else { + builder = builder.with_lifetime(SPHERE_LIFETIME); + } + + builder.build()?.sign().await + } + + async fn expect_failure(message: &str, store: &SphereDb, ucan: Ucan) { + assert!( + LinkRecord::validate(&ucan, store, None).await.is_err(), + "{}", + message + ); + } + + #[tokio::test] + async fn test_self_signed_link_record() -> Result<(), anyhow::Error> { + let sphere_key = generate_ed25519_key(); + let sphere_identity = Did::from(sphere_key.get_did().await?); + let link = "bafyr4iagi6t6khdrtbhmyjpjgvdlwv6pzylxhuhstxhkdp52rju7er325i"; + let cid_link: Cid = link.parse()?; + let store = SphereDb::new(&MemoryStorage::default()).await.unwrap(); + + let record = from_issuer(&sphere_key, &sphere_identity, &cid_link, None).await?; + + assert_eq!(&Did::from(LinkRecord::identity(&record)), &sphere_identity); + assert_eq!(LinkRecord::link(&record), Some(cid_link)); + LinkRecord::validate(&record, &store, None).await?; + Ok(()) + } + + #[tokio::test] + async fn test_delegated_link_record() -> Result<(), anyhow::Error> { + let owner_key = generate_ed25519_key(); + let owner_identity = Did::from(owner_key.get_did().await?); + let sphere_key = generate_ed25519_key(); + let sphere_identity = Did::from(sphere_key.get_did().await?); + let mut did_parser = DidParser::new(SUPPORTED_KEYS); + let link = "bafyr4iagi6t6khdrtbhmyjpjgvdlwv6pzylxhuhstxhkdp52rju7er325i"; + let cid_link: Cid = link.parse()?; + let mut store = SphereDb::new(&MemoryStorage::default()).await.unwrap(); + + // First verify that `owner` cannot publish for `sphere` + // without delegation. + let record = from_issuer(&owner_key, &sphere_identity, &cid_link, None).await?; + + assert_eq!(LinkRecord::identity(&record), &sphere_identity); + assert_eq!(LinkRecord::link(&record), Some(cid_link.clone())); + if LinkRecord::validate(&record, &store, Some(&mut did_parser)) + .await + .is_ok() + { + panic!("Owner should not have authorization to publish record") + } + + // Delegate `sphere_key`'s publishing authority to `owner_key` + let delegate_ucan = UcanBuilder::default() + .issued_by(&sphere_key) + .for_audience(&owner_identity) + .with_lifetime(SPHERE_LIFETIME) + .claiming_capability(&LinkRecord::generate_capability(&sphere_identity)) + .build()? + .sign() + .await?; + let _ = store.write_token(&delegate_ucan.encode()?).await?; + + // Attempt `owner` publishing `sphere` with the proper authorization. + let proofs = vec![delegate_ucan.clone()]; + let record = from_issuer(&owner_key, &sphere_identity, &cid_link, Some(&proofs)).await?; + + assert_eq!(LinkRecord::identity(&record), &sphere_identity); + assert_eq!(LinkRecord::link(&record), Some(cid_link.clone())); + assert!(LinkRecord::has_publishable_timeframe(&record)); + LinkRecord::validate(&record, &store, Some(&mut did_parser)).await?; + + // Now test a similar record that has an expired capability. + // It must still be valid. + let expired = UcanBuilder::default() + .issued_by(&owner_key) + .for_audience(&sphere_identity) + .claiming_capability(&LinkRecord::generate_capability(&sphere_identity)) + .with_fact(json!({ "link": &cid_link.to_string() })) + .witnessed_by(&delegate_ucan) + .with_expiration(ucan::time::now() - 1234) + .build()? + .sign() + .await?; + assert_eq!(LinkRecord::identity(&expired), &sphere_identity); + assert_eq!(LinkRecord::link(&expired), Some(cid_link)); + assert!(LinkRecord::has_publishable_timeframe(&expired) == false); + LinkRecord::validate(&record, &store, Some(&mut did_parser)).await?; + Ok(()) + } + + #[tokio::test] + async fn test_link_record_failures() -> Result<(), anyhow::Error> { + let sphere_key = generate_ed25519_key(); + let sphere_identity = Did::from(sphere_key.get_did().await?); + let cid_address = "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72"; + let store = SphereDb::new(&MemoryStorage::default()).await.unwrap(); + + expect_failure( + "fails when expect `fact` is missing", + &store, + UcanBuilder::default() + .issued_by(&sphere_key) + .for_audience(&sphere_identity) + .with_lifetime(1000) + .claiming_capability(&LinkRecord::generate_capability(&sphere_identity)) + .with_fact(json!({ "invalid_fact": cid_address })) + .build()? + .sign() + .await?, + ) + .await; + + let capability = + LinkRecord::generate_capability(&Did(generate_ed25519_key().get_did().await?)); + expect_failure( + "fails when capability resource does not match sphere identity", + &store, + UcanBuilder::default() + .issued_by(&sphere_key) + .for_audience(&sphere_identity) + .with_lifetime(1000) + .claiming_capability(&capability) + .with_fact(json!({ "link": cid_address.clone() })) + .build()? + .sign() + .await?, + ) + .await; + + let non_auth_key = generate_ed25519_key(); + expect_failure( + "fails when a non-authorized key signs the record", + &store, + UcanBuilder::default() + .issued_by(&non_auth_key) + .for_audience(&sphere_identity) + .with_lifetime(1000) + .claiming_capability(&LinkRecord::generate_capability(&sphere_identity)) + .with_fact(json!({ "link": cid_address.clone() })) + .build()? + .sign() + .await?, + ) + .await; + + Ok(()) + } + + #[tokio::test] + async fn test_link_record_convert() -> Result<(), anyhow::Error> { + let sphere_key = generate_ed25519_key(); + let identity = Did::from(sphere_key.get_did().await?); + let capability = LinkRecord::generate_capability(&identity); + let cid_address = "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72"; + let link = Cid::from_str(cid_address)?; + let maybe_link = Some(link.clone()); + let fact = json!({ "link": cid_address }); + + let ucan = UcanBuilder::default() + .issued_by(&sphere_key) + .for_audience(&identity) + .with_lifetime(1000) + .claiming_capability(&capability) + .with_fact(fact) + .build()? + .sign() + .await?; + + let encoded = ucan.encode()?; + let base = LinkRecord(Jwt(encoded.clone())); + let bytes = Vec::from(encoded.clone()); + + let record: Ucan = LinkRecord::try_from(bytes.clone())?.try_into()?; + assert_eq!( + LinkRecord::identity(&record), + identity, + "try_from::>()" + ); + assert_eq!( + LinkRecord::link(&record), + maybe_link, + "try_from::>()" + ); + + let rec_bytes: Vec = LinkRecord::try_from(bytes.clone())?.try_into()?; + assert_eq!(rec_bytes, bytes, "try_into::>()"); + + let link_record = LinkRecord::try_from(ucan.clone())?; + let record = Ucan::try_from(link_record.clone())?; + assert_eq!( + LinkRecord::identity(&record), + identity, + "try_from::()" + ); + assert_eq!(LinkRecord::link(&record), maybe_link, "try_from::()"); + assert_eq!( + link_record, + LinkRecord::try_from(&ucan)?, + "try_from::<&Ucan>()" + ); + + // LinkRecord::serialize + // LinkRecord::deserialize + let serialized = serde_json::to_string(&base)?; + assert_eq!(format!("\"{}\"", encoded), serialized, "serialize()"); + let link_record: LinkRecord = serde_json::from_str(&serialized)?; + let record: Ucan = link_record.try_into()?; + assert_eq!(LinkRecord::identity(&record), identity, "deserialize()"); + assert_eq!(LinkRecord::link(&record), maybe_link, "deserialize()"); + + Ok(()) + } +} diff --git a/rust/noosphere-gateway/Cargo.toml b/rust/noosphere-gateway/Cargo.toml index d259eab71..5c78440d1 100644 --- a/rust/noosphere-gateway/Cargo.toml +++ b/rust/noosphere-gateway/Cargo.toml @@ -61,8 +61,8 @@ ucan-key-support = { workspace = true } cid = { workspace = true } subtext = "0.3.2" -serde = "^1" -serde_json = "^1" +serde = { workspace = true } +serde_json = { workspace = true } libipld-core = { workspace = true } libipld-cbor = { workspace = true } diff --git a/rust/noosphere-gateway/src/worker/name_system.rs b/rust/noosphere-gateway/src/worker/name_system.rs index 0f1cbcf1a..72f381d3c 100644 --- a/rust/noosphere-gateway/src/worker/name_system.rs +++ b/rust/noosphere-gateway/src/worker/name_system.rs @@ -5,7 +5,7 @@ use cid::Cid; use noosphere_core::data::ContentType; use noosphere_core::data::{Did, IdentityIpld, Jwt, LinkRecord, MapOperation}; use noosphere_ipfs::{IpfsStore, KuboClient}; -use noosphere_ns::{server::HttpClient as NameSystemHttpClient, NameResolver, NsRecord}; +use noosphere_ns::{server::HttpClient as NameSystemHttpClient, NameResolver}; use noosphere_sphere::{ HasMutableSphereContext, SphereCursor, SpherePetnameRead, SpherePetnameWrite, }; @@ -16,7 +16,6 @@ use std::fmt::Display; use std::future::Future; use std::{ collections::{BTreeMap, BTreeSet, VecDeque}, - str::FromStr, string::ToString, sync::Arc, time::Duration, @@ -32,6 +31,7 @@ use tokio::{ }; use tokio_stream::{Stream, StreamExt}; use ucan::crypto::KeyMaterial; +use ucan::Ucan; use url::Url; const PERIODIC_PUBLISH_INTERVAL_SECONDS: u64 = 5 * 60; @@ -243,9 +243,10 @@ where warn!("Could not set counterpart record on sphere: {error}"); } // TODO(#257) - let link_record = NsRecord::from_str(&record)?; + let link_record = LinkRecord::from(record); let publishable = if temporary_validate_expiry { - link_record.has_publishable_timeframe() + let ucan = Ucan::try_from(&link_record)?; + LinkRecord::has_publishable_timeframe(&ucan) } else { true }; @@ -379,26 +380,33 @@ where while let Some((name, identity)) = stream.try_next().await? { let last_known_record = identity.link_record(&db).await; - let next_record = - match fetch_record(client.clone(), name.clone(), identity.did.clone()).await? { - Some(record) => { - // TODO(#257) - if false { - if let Err(error) = record.validate(&ipfs_store, None).await { - error!("Failed record validation: {}", error); - continue; - } + let next_record = match fetch_record(client.clone(), name.clone(), identity.did.clone()) + .await? + { + Some(record) => { + // TODO(#257) + if false { + match Ucan::try_from(&record) { + Ok(ucan) => match LinkRecord::validate(&ucan, &ipfs_store, None).await { + Ok(_) => {} + Err(error) => { + error!("Failed record validation: {}", error); + continue; + } + }, + _ => continue, } - - // TODO(#258): Verify that the new value is the most recent value - Some(LinkRecord::from(Jwt(record.try_to_string()?))) } - None => { - // TODO(#259): Expire recorded value if we don't get an updated - // record after some designated TTL - continue; - } - }; + + // TODO(#258): Verify that the new value is the most recent value + Some(record) + } + None => { + // TODO(#259): Expire recorded value if we don't get an updated + // record after some designated TTL + continue; + } + }; match &next_record { // TODO(#260): What if the resolved value is None? @@ -425,7 +433,7 @@ async fn fetch_record( client: Arc, name: String, identity: Did, -) -> Result> { +) -> Result> { debug!("Resolving record '{}' ({})...", name, identity); Ok(match client.resolve(&identity).await { Ok(Some(record)) => { @@ -518,11 +526,9 @@ where #[cfg(test)] mod tests { - use noosphere_ns::{ - helpers::KeyValueNameResolver, - utils::{generate_capability, generate_fact}, - }; + use noosphere_ns::helpers::KeyValueNameResolver; use noosphere_sphere::helpers::{simulated_sphere_context, SimulationAccess}; + use serde_json::json; use ucan::builder::UcanBuilder; use super::*; @@ -537,11 +543,11 @@ mod tests { Jwt(UcanBuilder::default() .issued_by(&context.author().key) .for_audience(identity) - .claiming_capability(&generate_capability(identity)) + .claiming_capability(&LinkRecord::generate_capability(identity)) .with_lifetime(1000) - .with_fact(generate_fact( - "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72", - )) + .with_fact( + json!({ "link": "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72" }), + ) .build() .unwrap() .sign() @@ -557,11 +563,11 @@ mod tests { Jwt(UcanBuilder::default() .issued_by(&context.author().key) .for_audience(identity) - .claiming_capability(&generate_capability(identity)) + .claiming_capability(&LinkRecord::generate_capability(identity)) .with_expiration(ucan::time::now() - 1000) - .with_fact(generate_fact( - "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72", - )) + .with_fact( + json!({ "link": "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72" }), + ) .build() .unwrap() .sign() diff --git a/rust/noosphere-ipfs/Cargo.toml b/rust/noosphere-ipfs/Cargo.toml index ad1780771..ef399001f 100644 --- a/rust/noosphere-ipfs/Cargo.toml +++ b/rust/noosphere-ipfs/Cargo.toml @@ -34,8 +34,8 @@ libipld-core = { workspace = true } libipld-cbor = { workspace = true } cid = { workspace = true } reqwest = { version = "~0.11", default-features = false, features = ["json", "rustls-tls", "stream"] } -serde = "^1" -serde_json = "^1" +serde = { workspace = true } +serde_json = { workspace = true } tokio = { version = "^1", features = ["io-util"] } tracing = { workspace = true } url = { version = "^2", features = [ "serde" ] } diff --git a/rust/noosphere-ns/Cargo.toml b/rust/noosphere-ns/Cargo.toml index 7b5fd33ec..66ceeb53d 100644 --- a/rust/noosphere-ns/Cargo.toml +++ b/rust/noosphere-ns/Cargo.toml @@ -29,8 +29,8 @@ tracing = { workspace = true } thiserror = { workspace = true } lazy_static = "^1" cid = { workspace = true } -serde = "^1" -serde_json = "^1" +serde = { workspace = true } +serde_json = { workspace = true } futures = "^0.3.26" async-trait = "~0.1" ucan = { workspace = true } diff --git a/rust/noosphere-ns/src/bin/orb-ns/cli/cli_implementation.rs b/rust/noosphere-ns/src/bin/orb-ns/cli/cli_implementation.rs index b3ffe4eff..7825151e7 100644 --- a/rust/noosphere-ns/src/bin/orb-ns/cli/cli_implementation.rs +++ b/rust/noosphere-ns/src/bin/orb-ns/cli/cli_implementation.rs @@ -4,8 +4,8 @@ use crate::cli::address::{ deserialize_multiaddr, deserialize_socket_addr, deserialize_url, parse_cli_address, }; use clap::{Parser, Subcommand}; -use noosphere_core::data::Did; -use noosphere_ns::{DhtConfig, Multiaddr, NsRecord}; +use noosphere_core::data::{Did, LinkRecord}; +use noosphere_ns::{DhtConfig, Multiaddr}; use serde::Deserialize; use std::net::SocketAddr; use std::path::PathBuf; @@ -94,7 +94,7 @@ pub enum CLIRecords { api_url: Url, }, Put { - record: NsRecord, + record: LinkRecord, #[arg(short, long, value_parser = parse_cli_address::)] api_url: Url, }, diff --git a/rust/noosphere-ns/src/bin/orb-ns/cli/mod.rs b/rust/noosphere-ns/src/bin/orb-ns/cli/mod.rs index c2b2faa37..2dacf1d90 100644 --- a/rust/noosphere-ns/src/bin/orb-ns/cli/mod.rs +++ b/rust/noosphere-ns/src/bin/orb-ns/cli/mod.rs @@ -12,13 +12,17 @@ mod test { use anyhow::Result; use cid::Cid; use noosphere::key::{InsecureKeyStorage, KeyStorage}; - use noosphere_core::data::Did; - use noosphere_ns::{Multiaddr, NsRecord, PeerId}; + use noosphere_core::data::{Did, LinkRecord}; + use noosphere_core::view::SPHERE_LIFETIME; + use noosphere_ns::{Multiaddr, PeerId}; use serde::Deserialize; + use serde_json::json; use tempdir::TempDir; use tokio; use tokio::sync::oneshot; + use ucan::builder::UcanBuilder; use ucan::crypto::KeyMaterial; + use ucan::Ucan; use url::Url; #[derive(Debug, Deserialize)] @@ -118,7 +122,16 @@ mod test { let link = "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72"; let cid_link: Cid = link.parse()?; - let record = NsRecord::from_issuer(&key_b, &id_b, &cid_link, None).await?; + let ucan = UcanBuilder::default() + .issued_by(&key_b) + .for_audience(&id_b) + .claiming_capability(&LinkRecord::generate_capability(&id_b)) + .with_fact(json!({ "link": cid_link.to_string() })) + .with_lifetime(SPHERE_LIFETIME) + .build()? + .sign() + .await?; + let record = LinkRecord::try_from(ucan)?; // Push record from node B (for node B) assert!(process_command( @@ -144,9 +157,10 @@ mod test { .await .unwrap(); let value = res.value().unwrap(); - let fetched_record = serde_json::from_str::(value).unwrap(); - assert_eq!(fetched_record.link().unwrap(), &cid_link); - assert_eq!(fetched_record.identity(), &id_b); + let fetched_record = serde_json::from_str::(value).unwrap(); + let ucan = Ucan::try_from(fetched_record)?; + assert_eq!(LinkRecord::link(&ucan).unwrap(), cid_link); + assert_eq!(LinkRecord::identity(&ucan), &id_b); Ok(()) } diff --git a/rust/noosphere-ns/src/dht_client.rs b/rust/noosphere-ns/src/dht_client.rs index 966a1761e..16c1839ba 100644 --- a/rust/noosphere-ns/src/dht_client.rs +++ b/rust/noosphere-ns/src/dht_client.rs @@ -1,12 +1,11 @@ use crate::{ dht::{NetworkInfo, Peer}, - records::NsRecord, PeerId, }; use anyhow::Result; use async_trait::async_trait; use libp2p::Multiaddr; -use noosphere_core::data::Did; +use noosphere_core::data::{Did, LinkRecord}; #[cfg(doc)] use crate::server::HttpClient; @@ -45,12 +44,12 @@ pub trait DhtClient: Send + Sync { /* Record APIs */ - /// Propagates the corresponding managed sphere's [NsRecord] on nearby peers + /// Propagates the corresponding managed sphere's [LinkRecord] on nearby peers /// in the DHT network. - async fn put_record(&self, record: NsRecord, quorum: usize) -> Result<()>; + async fn put_record(&self, record: LinkRecord, quorum: usize) -> Result<()>; - /// Returns an [NsRecord] for the provided identity if found. - async fn get_record(&self, identity: &Did) -> Result>; + /// Returns an [LinkRecord] for the provided identity if found. + async fn get_record(&self, identity: &Did) -> Result>; /* Operator APIs */ @@ -97,11 +96,15 @@ pub mod test { use crate::{utils::wait_for_peers, NameSystemBuilder}; use cid::Cid; use libp2p::multiaddr::Protocol; - use noosphere_core::{authority::generate_ed25519_key, data::Did, tracing::initialize_tracing}; + use noosphere_core::{ + authority::generate_ed25519_key, data::Did, tracing::initialize_tracing, + view::SPHERE_LIFETIME, + }; use noosphere_storage::{MemoryStorage, SphereDb}; + use serde_json::json; use std::sync::Arc; use tokio::sync::Mutex; - use ucan::crypto::KeyMaterial; + use ucan::{builder::UcanBuilder, crypto::KeyMaterial, Ucan}; pub async fn test_network_info(client: Arc>) -> Result<()> { initialize_tracing(None); @@ -165,20 +168,31 @@ pub mod test { client.listen("/ip4/127.0.0.1/tcp/0".parse()?).await?; let sphere_key = generate_ed25519_key(); - let sphere_id = Did::from(sphere_key.get_did().await?); + let sphere_identity = Did::from(sphere_key.get_did().await?); let link: Cid = "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72" .parse() .unwrap(); - let record = NsRecord::from_issuer(&sphere_key, &sphere_id, &link, None).await?; + let ucan = UcanBuilder::default() + .issued_by(&sphere_key) + .for_audience(&sphere_identity) + .claiming_capability(&LinkRecord::generate_capability(&sphere_identity)) + .with_fact(json!({ "link": link.to_string() })) + .with_lifetime(SPHERE_LIFETIME) + .build()? + .sign() + .await?; + let record = LinkRecord::try_from(ucan)?; + client.put_record(record, 1).await?; let retrieved = client - .get_record(&sphere_id) + .get_record(&sphere_identity) .await? .expect("should be some"); + let ucan = Ucan::try_from(retrieved)?; - assert_eq!(retrieved.identity(), &sphere_id); - assert_eq!(retrieved.link(), Some(&link)); + assert_eq!(LinkRecord::identity(&ucan), sphere_identity); + assert_eq!(LinkRecord::link(&ucan), Some(link.clone())); Ok(()) } } diff --git a/rust/noosphere-ns/src/helpers.rs b/rust/noosphere-ns/src/helpers.rs index d7e4eeb0e..39e76a1d6 100644 --- a/rust/noosphere-ns/src/helpers.rs +++ b/rust/noosphere-ns/src/helpers.rs @@ -1,11 +1,14 @@ -use crate::{DhtClient, DhtConfig, NameResolver, NameSystem, NsRecord}; +use crate::{DhtClient, DhtConfig, NameResolver, NameSystem}; use anyhow::Result; use async_trait::async_trait; use libp2p::Multiaddr; -use noosphere_core::{authority::generate_ed25519_key, data::Did}; +use noosphere_core::{ + authority::generate_ed25519_key, + data::{Did, LinkRecord}, +}; use std::collections::HashMap; use tokio::sync::Mutex; -use ucan::store::UcanJwtStore; +use ucan::{store::UcanJwtStore, Ucan}; /// An in-process network of [NameSystem] nodes for testing. pub struct NameSystemNetwork { @@ -68,7 +71,7 @@ impl NameSystemNetwork { } pub struct KeyValueNameResolver { - store: Mutex>, + store: Mutex>, } impl KeyValueNameResolver { @@ -87,14 +90,15 @@ impl Default for KeyValueNameResolver { #[async_trait] impl NameResolver for KeyValueNameResolver { - async fn publish(&self, record: NsRecord) -> Result<()> { + async fn publish(&self, record: LinkRecord) -> Result<()> { let mut store = self.store.lock().await; - let did_id = Did(record.identity().into()); + let ucan = Ucan::try_from(&record)?; + let did_id = Did(LinkRecord::identity(&ucan).into()); store.insert(did_id, record); Ok(()) } - async fn resolve(&self, identity: &Did) -> Result> { + async fn resolve(&self, identity: &Did) -> Result> { let store = self.store.lock().await; Ok(store.get(identity).map(|record| record.to_owned())) } diff --git a/rust/noosphere-ns/src/lib.rs b/rust/noosphere-ns/src/lib.rs index 11067a598..86e863f10 100644 --- a/rust/noosphere-ns/src/lib.rs +++ b/rust/noosphere-ns/src/lib.rs @@ -12,8 +12,8 @@ mod dht_client; pub mod helpers; mod name_resolver; mod name_system; -mod records; pub mod utils; +mod validator; //#[cfg(feature = "api_server")] pub mod server; @@ -24,4 +24,3 @@ pub use dht_client::DhtClient; pub use libp2p::{multiaddr::Multiaddr, PeerId}; pub use name_resolver::NameResolver; pub use name_system::{NameSystem, NameSystemKeyMaterial, BOOTSTRAP_PEERS}; -pub use records::NsRecord; diff --git a/rust/noosphere-ns/src/name_resolver.rs b/rust/noosphere-ns/src/name_resolver.rs index d5d1dd682..59b9f46b2 100644 --- a/rust/noosphere-ns/src/name_resolver.rs +++ b/rust/noosphere-ns/src/name_resolver.rs @@ -1,23 +1,23 @@ -use crate::{DhtClient, NsRecord}; +use crate::DhtClient; use anyhow::Result; use async_trait::async_trait; -use noosphere_core::data::Did; +use noosphere_core::data::{Did, LinkRecord}; #[async_trait] pub trait NameResolver: Send + Sync { /// Publishes a record to the name system. - async fn publish(&self, record: NsRecord) -> Result<()>; + async fn publish(&self, record: LinkRecord) -> Result<()>; /// Retrieves a record from the name system. - async fn resolve(&self, identity: &Did) -> Result>; + async fn resolve(&self, identity: &Did) -> Result>; } #[async_trait] impl NameResolver for T { - async fn publish(&self, record: NsRecord) -> Result<()> { + async fn publish(&self, record: LinkRecord) -> Result<()> { self.put_record(record, 0).await } - async fn resolve(&self, identity: &Did) -> Result> { + async fn resolve(&self, identity: &Did) -> Result> { self.get_record(identity).await } } @@ -42,21 +42,35 @@ macro_rules! name_resolver_tests { pub mod test { use super::*; use cid::Cid; - use noosphere_core::{authority::generate_ed25519_key, data::Did, tracing::initialize_tracing}; - use ucan::crypto::KeyMaterial; + use noosphere_core::{ + authority::generate_ed25519_key, data::Did, tracing::initialize_tracing, + view::SPHERE_LIFETIME, + }; + use serde_json::json; + use ucan::{builder::UcanBuilder, crypto::KeyMaterial, Ucan}; pub async fn test_name_resolver_simple(resolver: N) -> Result<()> { initialize_tracing(None); let sphere_key = generate_ed25519_key(); - let sphere_id = Did::from(sphere_key.get_did().await?); + let sphere_identity = Did::from(sphere_key.get_did().await?); let link: Cid = "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72" .parse() .unwrap(); - let record = NsRecord::from_issuer(&sphere_key, &sphere_id, &link, None).await?; + let ucan = UcanBuilder::default() + .issued_by(&sphere_key) + .for_audience(&sphere_identity) + .claiming_capability(&LinkRecord::generate_capability(&sphere_identity)) + .with_fact(json!({ "link": link.to_string() })) + .with_lifetime(SPHERE_LIFETIME) + .build()? + .sign() + .await?; + let record = LinkRecord::try_from(ucan)?; resolver.publish(record).await?; - let resolved = resolver.resolve(&sphere_id).await?.unwrap(); - assert_eq!(resolved.link().unwrap(), &link); + let resolved = resolver.resolve(&sphere_identity).await?.unwrap(); + let resolved_ucan: Ucan = resolved.try_into()?; + assert_eq!(LinkRecord::link(&resolved_ucan).unwrap(), link); Ok(()) } } diff --git a/rust/noosphere-ns/src/name_system.rs b/rust/noosphere-ns/src/name_system.rs index b1149fb6f..ffff41a5b 100644 --- a/rust/noosphere-ns/src/name_system.rs +++ b/rust/noosphere-ns/src/name_system.rs @@ -1,14 +1,14 @@ use crate::{ dht::{DhtConfig, DhtError, DhtNode, DhtRecord, NetworkInfo, Peer}, - records::{NsRecord, RecordValidator}, utils::make_p2p_address, + validator::RecordValidator, DhtClient, PeerId, }; use anyhow::{anyhow, Result}; use async_trait::async_trait; use libp2p::{identity::Keypair, Multiaddr}; -use noosphere_core::data::Did; -use ucan::{crypto::KeyMaterial, store::UcanJwtStore}; +use noosphere_core::data::{Did, LinkRecord}; +use ucan::{crypto::KeyMaterial, store::UcanJwtStore, Ucan}; use ucan_key_support::ed25519::Ed25519KeyMaterial; #[cfg(doc)] @@ -130,8 +130,9 @@ impl DhtClient for NameSystem { } } - async fn put_record(&self, record: NsRecord, quorum: usize) -> Result<()> { - let identity = Did::from(record.identity()); + async fn put_record(&self, record: LinkRecord, quorum: usize) -> Result<()> { + let token = Ucan::try_from(&record)?; + let identity = Did::from(LinkRecord::identity(&token)); let record_bytes: Vec = record.try_into()?; match self .dht @@ -143,10 +144,10 @@ impl DhtClient for NameSystem { } } - async fn get_record(&self, identity: &Did) -> Result> { + async fn get_record(&self, identity: &Did) -> Result> { match self.dht.get_record(identity.as_bytes()).await { Ok(DhtRecord { key: _, value }) => match value { - Some(value) => Ok(Some(NsRecord::try_from(value)?)), + Some(value) => Ok(Some(LinkRecord::try_from(value)?)), None => Ok(None), }, Err(e) => Err(anyhow!(e.to_string())), diff --git a/rust/noosphere-ns/src/records.rs b/rust/noosphere-ns/src/records.rs deleted file mode 100644 index f11e733fa..000000000 --- a/rust/noosphere-ns/src/records.rs +++ /dev/null @@ -1,648 +0,0 @@ -use crate::{ - dht::Validator, - utils::{generate_capability, generate_fact}, -}; -use async_trait::async_trait; -use cid::Cid; -use noosphere_core::{ - authority::{SPHERE_SEMANTICS, SUPPORTED_KEYS}, - data::Did, - view::SPHERE_LIFETIME, -}; -use serde::{ - de::{self, Deserialize, Deserializer}, - ser::{self, Serialize, Serializer}, -}; -use serde_json::Value; -use std::{ - convert::TryFrom, - fmt::{Debug, Display}, - str, - str::FromStr, -}; -use ucan::{ - builder::UcanBuilder, - crypto::KeyMaterial, - store::{UcanJwtStore, UcanStore}, -}; -use ucan::{chain::ProofChain, crypto::did::DidParser, Ucan}; - -/// An [NsRecord] is the internal representation of a mapping from a -/// sphere's identity (DID key) to a sphere's revision as a -/// content address ([Cid]). The record wraps a [Ucan] token, -/// providing de/serialization for transmitting in the NS network, -/// and validates data ensuring the sphere's owner authorized the publishing -/// of a new content address. -/// -/// When transmitting through the distributed NS network, the record is -/// represented as the base64 encoded UCAN token. -/// -/// # Ucan Semantics -/// -/// An [NsRecord] is a small interface over a [Ucan] token, -/// with the following semantics: -/// -/// ```json -/// { -/// // The identity (DID) of the Principal that signed the token -/// "iss": "did:key:z6MkoE19WHXJzpLqkxbGP7uXdJX38sWZNUWwyjcuCmjhPpUP", -/// // The identity (DID) of the sphere this record maps. -/// "aud": "did:key:z6MkkVfktAC5rVNRmmTjkKPapT3bAyVkYH8ZVCF1UBNUfazp", -/// // Attenuation must contain a capability with a resource "sphere:{AUD}" -/// // and action "sphere/publish". -/// "att": [{ -/// "with": "sphere:did:key:z6MkkVfktAC5rVNRmmTjkKPapT3bAyVkYH8ZVCF1UBNUfazp", -/// "can": "sphere/publish" -/// }], -/// // Additional UCAN proofs needed to validate. -/// "prf": [], -/// // Facts contain a single entry with an "link" field containing -/// // the content address of a sphere revision (CID) associated with -/// // the sphere this record maps to. -/// "fct": [{ -/// "link": "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72" -/// }] -/// } -/// ``` -#[derive(Clone)] -pub struct NsRecord { - /// The wrapped UCAN token describing this record. - pub(crate) token: Ucan, - /// The resolved sphere revision this record maps to. - pub(crate) link: Option, -} - -impl NsRecord { - /// Creates a new [NsRecord]. - pub fn new(token: Ucan) -> Self { - // Cache the revision address if "fct" contains an entry matching - // the following object without any authority validation: - // `{ "link": "{VALID_CID}" }` - let mut link = None; - for ref fact in token.facts() { - if let Value::Object(map) = fact { - if let Some(Value::String(addr)) = map.get(&String::from("link")) { - if let Ok(cid) = Cid::from_str(addr) { - link = Some(cid); - break; - } - } - } - } - - Self { token, link } - } - - /// Creates and signs a new NsRecord from an issuer key. - /// - /// ``` - /// use noosphere_ns::NsRecord; - /// use noosphere_core::{data::Did, authority::generate_ed25519_key}; - /// use noosphere_storage::{SphereDb, MemoryStorage}; - /// use ucan_key_support::ed25519::Ed25519KeyMaterial; - /// use ucan::crypto::KeyMaterial; - /// use cid::Cid; - /// use tokio; - /// - /// #[tokio::main] - /// async fn main() { - /// let sphere_key = generate_ed25519_key(); - /// let sphere_id = Did::from(sphere_key.get_did().await.unwrap()); - /// let store = SphereDb::new(&MemoryStorage::default()).await.unwrap(); - /// let link: Cid = "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72".parse().unwrap(); - /// let record = NsRecord::from_issuer(&sphere_key, &sphere_id, &link, None).await.unwrap(); - /// } - /// ``` - pub async fn from_issuer( - issuer: &K, - sphere_id: &Did, - link: &Cid, - proofs: Option<&Vec>, - ) -> Result { - let capability = generate_capability(sphere_id); - let fact = generate_fact(&link.to_string()); - - let mut builder = UcanBuilder::default() - .issued_by(issuer) - .for_audience(sphere_id) - .claiming_capability(&capability) - .with_fact(fact); - - if let Some(proofs) = proofs { - let mut earliest_expiry: u64 = u64::MAX; - for token in proofs { - earliest_expiry = *token.expires_at().min(&earliest_expiry); - builder = builder.witnessed_by(token); - } - builder = builder.with_expiration(earliest_expiry); - } else { - builder = builder.with_lifetime(SPHERE_LIFETIME); - } - - Ok(builder.build()?.sign().await?.into()) - } - - /// Validates the underlying [Ucan] token, ensuring that - /// the sphere's owner authorized the publishing of a new - /// content address. Returns an `Err` if validation fails. - #[instrument(skip(store, opt_did_parser), level = "trace")] - pub async fn validate( - &self, - store: &S, - opt_did_parser: Option<&mut DidParser>, - ) -> Result<(), NsRecordError> { - if self.link.is_none() { - return Err(NsRecordError::MissingLink); - } - - let mut fallback_did_parser = if opt_did_parser.is_none() { - Some(DidParser::new(SUPPORTED_KEYS)) - } else { - None - }; - - let did_parser: &mut DidParser = if let Some(provided_parser) = opt_did_parser { - provided_parser - } else { - fallback_did_parser.as_mut().unwrap() - }; - - let identity = self.identity(); - - // We're interested in the validity of the proof at the time - // of publishing. - let now_time = if let Some(nbf) = self.token.not_before() { - nbf.to_owned() - } else { - self.token.expires_at() - 1 - }; - - let proof = - ProofChain::from_ucan(self.token.clone(), Some(now_time), did_parser, store).await?; - - { - let desired_capability = generate_capability(identity); - let mut has_capability = false; - for capability_info in proof.reduce_capabilities(&SPHERE_SEMANTICS) { - let capability = capability_info.capability; - if capability_info.originators.contains(identity) - && capability.enables(&desired_capability) - { - has_capability = true; - break; - } - } - if !has_capability { - return Err(NsRecordError::Unauthorized); - } - } - - self.token - .check_signature(did_parser) - .await - .map_err(|_| NsRecordError::InvalidSignature)?; - Ok(()) - } - - /// Returns true if the [Ucan] token is currently publishable - /// within the bounds of its expiry/not before time. - pub fn has_publishable_timeframe(&self) -> bool { - !self.token.is_expired(None) && !self.token.is_too_early() - } - - /// The DID key of the sphere that this record maps. - pub fn identity(&self) -> &str { - self.token.audience() - } - - /// The sphere revision address ([Cid]) that the sphere's identity maps to. - pub fn link(&self) -> Option<&Cid> { - self.link.as_ref() - } - - /// Encodes the underlying Ucan token back into a JWT string. - pub fn try_to_string(&self) -> Result { - self.token.encode() - } -} - -impl Debug for NsRecord { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let link = self.link.map(|cid| cid.to_string()); - write!( - f, - "NsRecord {{ \"sphere\": \"{}\", \"link\": \"{:?}\" }}", - self.token.audience(), - link - ) - } -} - -impl Display for NsRecord { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let link = self.link.map(|cid| cid.to_string()); - write!( - f, - "NsRecord {{\n \"sphere\": \"{}\",\n \"link\": \"{:?}\"\n}}", - self.token.audience(), - link - ) - } -} - -impl Serialize for NsRecord { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let encoded = self.try_to_string().map_err(ser::Error::custom)?; - serializer.serialize_str(&encoded) - } -} - -impl<'de> Deserialize<'de> for NsRecord { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let s = String::deserialize(deserializer)?; - let record = NsRecord::try_from(s).map_err(de::Error::custom)?; - Ok(record) - } -} - -impl From for NsRecord { - fn from(ucan: Ucan) -> Self { - Self::new(ucan) - } -} - -impl From for Ucan { - fn from(record: NsRecord) -> Self { - record.token - } -} - -/// Deserialize an encoded UCAN token byte vec into a [NsRecord]. -impl TryFrom> for NsRecord { - type Error = anyhow::Error; - - fn try_from(bytes: Vec) -> Result { - NsRecord::try_from(&bytes[..]) - } -} - -/// Serialize a [NsRecord] into an encoded UCAN token byte vec. -impl TryFrom for Vec { - type Error = anyhow::Error; - - fn try_from(record: NsRecord) -> Result { - Vec::try_from(&record) - } -} - -/// Serialize a [NsRecord] reference into an encoded UCAN token byte vec. -impl TryFrom<&NsRecord> for Vec { - type Error = anyhow::Error; - - fn try_from(record: &NsRecord) -> Result, Self::Error> { - Ok(Vec::from(record.token.encode()?)) - } -} - -/// Deserialize an encoded UCAN token byte vec reference into a [NsRecord]. -impl TryFrom<&[u8]> for NsRecord { - type Error = anyhow::Error; - - fn try_from(bytes: &[u8]) -> Result { - NsRecord::try_from(str::from_utf8(bytes)?) - } -} - -/// Deserialize an encoded UCAN token string reference into a [NsRecord]. -impl<'a> TryFrom<&'a str> for NsRecord { - type Error = anyhow::Error; - - fn try_from(ucan_token: &str) -> Result { - NsRecord::from_str(ucan_token) - } -} - -/// Deserialize an encoded UCAN token string into a [NsRecord]. -impl TryFrom for NsRecord { - type Error = anyhow::Error; - - fn try_from(ucan_token: String) -> Result { - NsRecord::from_str(ucan_token.as_str()) - } -} - -/// Serialize an NsRecord into a JWT-encoded string. -impl TryFrom for String { - type Error = anyhow::Error; - - fn try_from(record: NsRecord) -> Result { - record.try_to_string() - } -} - -impl FromStr for NsRecord { - type Err = anyhow::Error; - - fn from_str(ucan_token: &str) -> Result { - // Wait for next release of `ucan` which includes traits and - // removes `try_from_token_string`: - // https://github.com/ucan-wg/rs-ucan/commit/75e9afdb9da60c3d5d8c65b6704e412f0ef8189b - Ok(NsRecord::new(Ucan::from_str(ucan_token)?)) - } -} - -#[derive(thiserror::Error, Debug)] -pub enum NsRecordError { - #[error("Token is expired.")] - Expired, - #[error("Token is unauthorized to publish a record for the sphere.")] - Unauthorized, - #[error("Token does not contain a \"fact\" entry with sphere revision.")] - MissingLink, - #[error("Token was not signed by stated issuer.")] - InvalidSignature, - #[error("{0}")] - Other(anyhow::Error), -} - -impl From for NsRecordError { - fn from(error: anyhow::Error) -> Self { - NsRecordError::Other(error) - } -} - -pub(crate) struct RecordValidator { - store: S, - did_parser: DidParser, -} - -impl RecordValidator -where - S: UcanStore, -{ - pub fn new(store: S) -> Self { - RecordValidator { - store, - did_parser: DidParser::new(SUPPORTED_KEYS), - } - } -} - -#[async_trait] -impl Validator for RecordValidator -where - S: UcanStore, -{ - async fn validate(&mut self, record_value: &[u8]) -> bool { - if let Ok(record) = NsRecord::try_from(record_value) { - if let Err(error) = record - .validate(&self.store, Some(&mut self.did_parser)) - .await - { - warn!("Validation error: {}", error); - return false; - } else { - return true; - } - } - return false; - } -} - -#[cfg(test)] -mod test { - use super::*; - use noosphere_core::{ - authority::{generate_ed25519_key, SUPPORTED_KEYS}, - data::Did, - }; - use noosphere_storage::{MemoryStorage, SphereDb}; - use serde_json::json; - use std::str::FromStr; - - use ucan::{ - builder::UcanBuilder, crypto::did::DidParser, crypto::KeyMaterial, store::UcanJwtStore, - }; - - async fn expect_failure(message: &str, store: &SphereDb, ucan: Ucan) { - assert!( - NsRecord::new(ucan).validate(store, None).await.is_err(), - "{}", - message - ); - } - - #[tokio::test] - async fn test_nsrecord_self_signed() -> Result<(), anyhow::Error> { - let sphere_key = generate_ed25519_key(); - let sphere_identity = Did::from(sphere_key.get_did().await?); - let link = "bafyr4iagi6t6khdrtbhmyjpjgvdlwv6pzylxhuhstxhkdp52rju7er325i"; - let cid_link: Cid = link.parse()?; - let store = SphereDb::new(&MemoryStorage::default()).await.unwrap(); - - let record = NsRecord::from_issuer(&sphere_key, &sphere_identity, &cid_link, None).await?; - - assert_eq!(&Did::from(record.identity()), &sphere_identity); - assert_eq!(record.link(), Some(&cid_link)); - record.validate(&store, None).await?; - Ok(()) - } - - #[tokio::test] - async fn test_nsrecord_delegated() -> Result<(), anyhow::Error> { - let owner_key = generate_ed25519_key(); - let owner_identity = Did::from(owner_key.get_did().await?); - let sphere_key = generate_ed25519_key(); - let sphere_identity = Did::from(sphere_key.get_did().await?); - let mut did_parser = DidParser::new(SUPPORTED_KEYS); - let link = "bafyr4iagi6t6khdrtbhmyjpjgvdlwv6pzylxhuhstxhkdp52rju7er325i"; - let cid_link: Cid = link.parse()?; - let mut store = SphereDb::new(&MemoryStorage::default()).await.unwrap(); - - // First verify that `owner` cannot publish for `sphere` - // without delegation. - let record = NsRecord::from_issuer(&owner_key, &sphere_identity, &cid_link, None).await?; - - assert_eq!(record.identity(), &sphere_identity); - assert_eq!(record.link(), Some(&cid_link)); - if record.validate(&store, Some(&mut did_parser)).await.is_ok() { - panic!("Owner should not have authorization to publish record") - } - - // Delegate `sphere_key`'s publishing authority to `owner_key` - let delegate_capability = generate_capability(&sphere_identity); - let delegate_ucan = UcanBuilder::default() - .issued_by(&sphere_key) - .for_audience(&owner_identity) - .with_lifetime(SPHERE_LIFETIME) - .claiming_capability(&delegate_capability) - .build()? - .sign() - .await?; - let out_cid = store.write_token(&delegate_ucan.encode()?).await?; - - // Attempt `owner` publishing `sphere` with the proper authorization. - let proofs = vec![delegate_ucan.clone()]; - let record = - NsRecord::from_issuer(&owner_key, &sphere_identity, &cid_link, Some(&proofs)).await?; - - assert_eq!(record.identity(), &sphere_identity); - assert_eq!(record.link(), Some(&cid_link)); - assert!(record.has_publishable_timeframe()); - record.validate(&store, Some(&mut did_parser)).await?; - - // Now test a similar record that has an expired capability. - // It must still be valid. - let expired: NsRecord = UcanBuilder::default() - .issued_by(&owner_key) - .for_audience(&sphere_identity) - .claiming_capability(&generate_capability(&sphere_identity)) - .with_fact(generate_fact(&cid_link.to_string())) - .witnessed_by(&delegate_ucan) - .with_expiration(ucan::time::now() - 1234) - .build()? - .sign() - .await? - .into(); - assert_eq!(expired.identity(), &sphere_identity); - assert_eq!(expired.link(), Some(&cid_link)); - assert!(expired.has_publishable_timeframe() == false); - expired.validate(&store, Some(&mut did_parser)).await?; - Ok(()) - } - - #[tokio::test] - async fn test_nsrecord_failures() -> Result<(), anyhow::Error> { - let sphere_key = generate_ed25519_key(); - let sphere_identity = Did::from(sphere_key.get_did().await?); - let cid_address = "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72"; - let store = SphereDb::new(&MemoryStorage::default()).await.unwrap(); - - let sphere_capability = generate_capability(&sphere_identity); - expect_failure( - "fails when expect `fact` is missing", - &store, - UcanBuilder::default() - .issued_by(&sphere_key) - .for_audience(&sphere_identity) - .with_lifetime(1000) - .claiming_capability(&sphere_capability) - .with_fact(json!({ "invalid_fact": cid_address })) - .build()? - .sign() - .await?, - ) - .await; - - let capability = generate_capability(&Did(generate_ed25519_key().get_did().await?)); - expect_failure( - "fails when capability resource does not match sphere identity", - &store, - UcanBuilder::default() - .issued_by(&sphere_key) - .for_audience(&sphere_identity) - .with_lifetime(1000) - .claiming_capability(&capability) - .with_fact(generate_fact(cid_address)) - .build()? - .sign() - .await?, - ) - .await; - - let non_auth_key = generate_ed25519_key(); - expect_failure( - "fails when a non-authorized key signs the record", - &store, - UcanBuilder::default() - .issued_by(&non_auth_key) - .for_audience(&sphere_identity) - .with_lifetime(1000) - .claiming_capability(&sphere_capability) - .with_fact(generate_fact(cid_address)) - .build()? - .sign() - .await?, - ) - .await; - - Ok(()) - } - - #[tokio::test] - async fn test_nsrecord_convert() -> Result<(), anyhow::Error> { - let sphere_key = generate_ed25519_key(); - let sphere_identity = Did::from(sphere_key.get_did().await?); - let capability = generate_capability(&sphere_identity); - let cid_address = "bafy2bzacec4p5h37mjk2n6qi6zukwyzkruebvwdzqpdxzutu4sgoiuhqwne72"; - let fact = generate_fact(cid_address); - - let ucan = UcanBuilder::default() - .issued_by(&sphere_key) - .for_audience(&sphere_identity) - .with_lifetime(1000) - .claiming_capability(&capability) - .with_fact(fact) - .build()? - .sign() - .await?; - - let base = NsRecord::new(ucan.clone()); - let encoded = ucan.encode()?; - let bytes = Vec::from(encoded.clone()); - - // NsRecord::serialize - // NsRecord::deserialize - let serialized = serde_json::to_string(&base)?; - assert_eq!(format!("\"{}\"", encoded), serialized, "serialize()"); - let record: NsRecord = serde_json::from_str(&serialized)?; - assert_eq!(base.identity(), record.identity(), "deserialize()"); - assert_eq!(base.link(), record.link(), "deserialize()"); - - // NsRecord::try_from::>() - let record = NsRecord::try_from(bytes.clone())?; - assert_eq!(base.identity(), record.identity(), "try_from::>()"); - assert_eq!(base.link(), record.link(), "try_from::>()"); - - // NsRecord::try_into::>() - let rec_bytes: Vec = base.clone().try_into()?; - assert_eq!(bytes, rec_bytes, "try_into::>()"); - - // NsRecord::try_from::<&[u8]>() - let record = NsRecord::try_from(&bytes[..])?; - assert_eq!(base.identity(), record.identity(), "try_from::<&[u8]>()"); - assert_eq!(base.link(), record.link(), "try_from::<&[u8]>()"); - - // &NsRecord::try_into::>() - let rec_bytes: Vec = (&base).try_into()?; - assert_eq!(bytes, rec_bytes, "&NsRecord::try_into::>()"); - - // NsRecord::from::() - let record = NsRecord::from(ucan); - assert_eq!(base.identity(), record.identity(), "from::()"); - assert_eq!(base.link(), record.link(), "from::()"); - - // NsRecord::try_from::<&str>() - let record = NsRecord::try_from(encoded.as_str())?; - assert_eq!(base.identity(), record.identity(), "try_from::<&str>()"); - assert_eq!(base.link(), record.link(), "try_from::<&str>()"); - - // NsRecord::try_from::() - let record = NsRecord::try_from(encoded.clone())?; - assert_eq!(base.identity(), record.identity(), "try_from::()"); - assert_eq!(base.link(), record.link(), "try_from::()"); - - // NsRecord::from_str() - let record = NsRecord::from_str(encoded.as_str())?; - assert_eq!(base.identity(), record.identity(), "from_str()"); - assert_eq!(base.link(), record.link(), "from_str()"); - - Ok(()) - } -} diff --git a/rust/noosphere-ns/src/server/client.rs b/rust/noosphere-ns/src/server/client.rs index 33b880f61..4cc703f34 100644 --- a/rust/noosphere-ns/src/server/client.rs +++ b/rust/noosphere-ns/src/server/client.rs @@ -1,8 +1,8 @@ use crate::server::routes::Route; -use crate::{dht_client::DhtClient, Multiaddr, NetworkInfo, NsRecord, Peer, PeerId}; +use crate::{dht_client::DhtClient, Multiaddr, NetworkInfo, Peer, PeerId}; use anyhow::{anyhow, Result}; use async_trait::async_trait; -use noosphere_core::data::Did; +use noosphere_core::data::{Did, LinkRecord}; use reqwest::Body; use url::Url; @@ -95,7 +95,7 @@ impl DhtClient for HttpClient { Ok(self.client.get(url).send().await?.json().await?) } - async fn get_record(&self, identity: &Did) -> Result> { + async fn get_record(&self, identity: &Did) -> Result> { let mut url = self.api_base.clone(); let path = Route::GetRecord .to_string() @@ -104,7 +104,7 @@ impl DhtClient for HttpClient { Ok(self.client.get(url).send().await?.json().await?) } - async fn put_record(&self, record: NsRecord, quorum: usize) -> Result<()> { + async fn put_record(&self, record: LinkRecord, quorum: usize) -> Result<()> { let mut url = self.api_base.clone(); url.set_path(&Route::PostRecord.to_string()); url.set_query(Some(&format!("quorum={quorum}"))); diff --git a/rust/noosphere-ns/src/server/handlers.rs b/rust/noosphere-ns/src/server/handlers.rs index ca016eeb3..a3feb807c 100644 --- a/rust/noosphere-ns/src/server/handlers.rs +++ b/rust/noosphere-ns/src/server/handlers.rs @@ -1,4 +1,4 @@ -use crate::{DhtClient, Multiaddr, NameSystem, NetworkInfo, NsRecord, Peer, PeerId}; +use crate::{DhtClient, Multiaddr, NameSystem, NetworkInfo, Peer, PeerId}; use anyhow::Result; use axum::response::{IntoResponse, Response}; use axum::{ @@ -6,7 +6,7 @@ use axum::{ http::StatusCode, Extension, Json, }; -use noosphere_core::data::Did; +use noosphere_core::data::{Did, LinkRecord}; use serde::Deserialize; use std::sync::Arc; @@ -92,7 +92,7 @@ pub async fn get_address( pub async fn get_record( Extension(name_system): Extension>, Path(did): Path, -) -> JsonResponse> { +) -> JsonResponse> { let record = name_system .get_record(&did) .await @@ -107,7 +107,7 @@ pub struct PostRecordQuery { pub async fn post_record( Extension(name_system): Extension>, - Json(record): Json, + Json(record): Json, Query(query): Query, ) -> JsonResponse<()> { name_system diff --git a/rust/noosphere-ns/src/utils.rs b/rust/noosphere-ns/src/utils.rs index aa1bd2d6e..bedf65970 100644 --- a/rust/noosphere-ns/src/utils.rs +++ b/rust/noosphere-ns/src/utils.rs @@ -4,59 +4,7 @@ use libp2p::{ multiaddr::{Multiaddr, Protocol}, PeerId, }; -use noosphere_core::authority::{SphereAction, SphereReference}; -use serde_json; use tokio::time; -use ucan::capability::{Capability, Resource, With}; - -#[cfg(doc)] -use cid::Cid; - -/// Generates a [Capability] struct representing permission to -/// publish a sphere. -/// -/// ``` -/// use noosphere_ns::utils::generate_capability; -/// use noosphere_core::{authority::{SphereAction, SphereReference}}; -/// use ucan::capability::{Capability, Resource, With}; -/// -/// let identity = "did:key:z6MkoE19WHXJzpLqkxbGP7uXdJX38sWZNUWwyjcuCmjhPpUP"; -/// let expected_capability = Capability { -/// with: With::Resource { -/// kind: Resource::Scoped(SphereReference { -/// did: identity.to_owned(), -/// }), -/// }, -/// can: SphereAction::Publish, -/// }; -/// assert_eq!(generate_capability(&identity), expected_capability); -/// ``` -pub fn generate_capability(identity: &str) -> Capability { - Capability { - with: With::Resource { - kind: Resource::Scoped(SphereReference { - did: identity.to_owned(), - }), - }, - can: SphereAction::Publish, - } -} - -/// Generates a UCAN `"fct"` struct for the NS network, representing -/// the resolved sphere's revision as a [Cid]. -/// -/// ``` -/// use noosphere_ns::utils::generate_fact; -/// use noosphere_storage::derive_cid; -/// use libipld_cbor::DagCborCodec; -/// use serde_json::json; -/// -/// let address = "bafy2bzaced25m65oooyocdin7uyehm7u6eak3iauyxbxxvoos6atwe7vvmv46"; -/// assert_eq!(generate_fact(address), json!({ "link": address })); -/// ``` -pub fn generate_fact(address: &str) -> serde_json::Value { - serde_json::json!({ "link": address }) -} /// A utility for [NameSystemClient] in tests. /// Async function returns once there are at least diff --git a/rust/noosphere-ns/src/validator.rs b/rust/noosphere-ns/src/validator.rs new file mode 100644 index 000000000..7bdfbc433 --- /dev/null +++ b/rust/noosphere-ns/src/validator.rs @@ -0,0 +1,40 @@ +use crate::dht::Validator; +use async_trait::async_trait; +use noosphere_core::{authority::SUPPORTED_KEYS, data::LinkRecord}; +use ucan::{crypto::did::DidParser, store::UcanStore, Ucan}; + +/// Implements [Validator] for the DHT. +pub(crate) struct RecordValidator { + store: S, + did_parser: DidParser, +} + +impl RecordValidator +where + S: UcanStore, +{ + pub fn new(store: S) -> Self { + RecordValidator { + store, + did_parser: DidParser::new(SUPPORTED_KEYS), + } + } +} + +#[async_trait] +impl Validator for RecordValidator +where + S: UcanStore, +{ + async fn validate(&mut self, record_value: &[u8]) -> bool { + match LinkRecord::try_from(record_value.to_owned()) { + Ok(record) => match Ucan::try_from(record) { + Ok(token) => LinkRecord::validate(&token, &self.store, Some(&mut self.did_parser)) + .await + .is_ok(), + _ => false, + }, + _ => false, + } + } +} diff --git a/rust/noosphere-ns/tests/ns_test.rs b/rust/noosphere-ns/tests/ns_test.rs index 198052306..2ddb3b7c0 100644 --- a/rust/noosphere-ns/tests/ns_test.rs +++ b/rust/noosphere-ns/tests/ns_test.rs @@ -4,16 +4,14 @@ use anyhow::Result; use cid::Cid; use noosphere_core::{ - authority::generate_ed25519_key, data::Did, tracing::initialize_tracing, view::SPHERE_LIFETIME, -}; -use noosphere_ns::{ - helpers::NameSystemNetwork, - utils::{generate_capability, generate_fact}, - DhtClient, + authority::generate_ed25519_key, data::Did, data::LinkRecord, tracing::initialize_tracing, + view::SPHERE_LIFETIME, }; +use noosphere_ns::{helpers::NameSystemNetwork, DhtClient}; use noosphere_storage::{derive_cid, MemoryStorage, SphereDb}; use libipld_cbor::DagCborCodec; +use serde_json::json; use ucan::{builder::UcanBuilder, crypto::KeyMaterial, store::UcanJwtStore, time::now, Ucan}; use ucan_key_support::ed25519::Ed25519KeyMaterial; @@ -35,7 +33,7 @@ impl PseudoSphere { let sphere_id = Did(sphere_key.get_did().await?); // Delegate `sphere_key`'s publishing authority to `owner_key` - let delegate_capability = generate_capability(&sphere_id); + let delegate_capability = LinkRecord::generate_capability(&sphere_id); let delegation = UcanBuilder::default() .issued_by(&sphere_key) .for_audience(&owner_id) @@ -57,8 +55,8 @@ impl PseudoSphere { UcanBuilder::default() .issued_by(&self.owner_key) .for_audience(&self.sphere_id) - .claiming_capability(&generate_capability(&self.sphere_id)) - .with_fact(generate_fact(&cid.to_string())) + .claiming_capability(&LinkRecord::generate_capability(&self.sphere_id)) + .with_fact(json!({ "link": &cid.to_string() })) .witnessed_by(&self.delegation) } @@ -94,7 +92,7 @@ async fn test_name_system_peer_propagation() -> Result<()> { .build()? .sign() .await? - .into(), + .try_into()?, 1, ) .await?; @@ -110,9 +108,9 @@ async fn test_name_system_peer_propagation() -> Result<()> { ns_2.get_record(&sphere_1.sphere_id) .await? .expect("to be some") - .link() + .dereference() .unwrap(), - &sphere_1_cid_1, + sphere_1_cid_1, "first record found" ); @@ -124,7 +122,7 @@ async fn test_name_system_peer_propagation() -> Result<()> { .build()? .sign() .await? - .into(), + .try_into()?, 1, ) .await?; @@ -133,9 +131,9 @@ async fn test_name_system_peer_propagation() -> Result<()> { ns_2.get_record(&sphere_1.sphere_id) .await? .expect("to be some") - .link() + .dereference() .unwrap(), - &sphere_1_cid_2, + sphere_1_cid_2, "latest record is found from network" ); @@ -147,7 +145,7 @@ async fn test_name_system_peer_propagation() -> Result<()> { .build()? .sign() .await? - .into(), + .try_into()?, 1, ) .await?; @@ -158,9 +156,9 @@ async fn test_name_system_peer_propagation() -> Result<()> { ns_1.get_record(&sphere_2.sphere_id) .await? .expect("to be some") - .link() + .dereference() .unwrap(), - &sphere_2_cid_1, + sphere_2_cid_1, "non-cached record found for sphere_2" ); @@ -187,7 +185,7 @@ async fn test_name_system_validation() -> Result<()> { .build()? .sign() .await? - .into(), + .try_into()?, 1 ) .await diff --git a/rust/noosphere-sphere/Cargo.toml b/rust/noosphere-sphere/Cargo.toml index 5db72afe5..cf7897a23 100644 --- a/rust/noosphere-sphere/Cargo.toml +++ b/rust/noosphere-sphere/Cargo.toml @@ -39,8 +39,8 @@ futures-util = "0.3.27" libipld-core = { workspace = true } libipld-cbor = { workspace = true } bytes = "^1" -serde_json = "^1" -serde = "1" +serde_json = { workspace = true } +serde = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/rust/noosphere-sphere/src/context.rs b/rust/noosphere-sphere/src/context.rs index 10914f26f..7ded31d09 100644 --- a/rust/noosphere-sphere/src/context.rs +++ b/rust/noosphere-sphere/src/context.rs @@ -147,7 +147,7 @@ where debug!("Petname assigned to {:?}", identity); let resolved_version = match identity.link_record(self.db()).await { - Some(link_record) => link_record.dereference().await, + Some(link_record) => link_record.dereference(), None => None, }; diff --git a/rust/noosphere-sphere/src/petname/read.rs b/rust/noosphere-sphere/src/petname/read.rs index 3c9239c71..3f37e6c34 100644 --- a/rust/noosphere-sphere/src/petname/read.rs +++ b/rust/noosphere-sphere/src/petname/read.rs @@ -130,7 +130,7 @@ where .await; match link_record { - Some(link_record) => link_record.dereference().await, + Some(link_record) => link_record.dereference(), None => None, } } diff --git a/rust/noosphere-storage/Cargo.toml b/rust/noosphere-storage/Cargo.toml index f90f63f1d..15c446439 100644 --- a/rust/noosphere-storage/Cargo.toml +++ b/rust/noosphere-storage/Cargo.toml @@ -30,7 +30,7 @@ tracing = "~0.1" ucan = { workspace = true } libipld-core = { workspace = true } libipld-cbor = { workspace = true } -serde = "^1" +serde = { workspace = true } base64 = "=0.13.0" url = { version = "^2" }