diff --git a/Cargo.lock b/Cargo.lock index a19876d9..a5737ead 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,6 +779,7 @@ dependencies = [ "itertools 0.12.1", "multibase", "okp4-cognitarium", + "okp4-cognitarium-client", "okp4-rdf", "rio_api", "rio_turtle", diff --git a/contracts/okp4-dataverse/Cargo.toml b/contracts/okp4-dataverse/Cargo.toml index e4e3fcde..46c9a07b 100644 --- a/contracts/okp4-dataverse/Cargo.toml +++ b/contracts/okp4-dataverse/Cargo.toml @@ -38,6 +38,7 @@ cw-utils.workspace = true cw2.workspace = true itertools = "0.12.1" multibase = "0.9.1" +okp4-cognitarium-client.workspace = true okp4-cognitarium.workspace = true okp4-rdf.workspace = true rio_api.workspace = true diff --git a/contracts/okp4-dataverse/src/contract.rs b/contracts/okp4-dataverse/src/contract.rs index 53ba357d..84cfa8da 100644 --- a/contracts/okp4-dataverse/src/contract.rs +++ b/contracts/okp4-dataverse/src/contract.rs @@ -67,14 +67,14 @@ pub fn instantiate( pub fn execute( deps: DepsMut<'_>, _env: Env, - _info: MessageInfo, + info: MessageInfo, msg: ExecuteMsg, ) -> Result { match msg { ExecuteMsg::SubmitClaims { metadata, format: _, - } => execute::submit_claims(deps, metadata), + } => execute::submit_claims(deps, info, metadata), _ => Err(StdError::generic_err("Not implemented").into()), } } @@ -82,19 +82,33 @@ pub fn execute( pub mod execute { use super::*; use crate::credential::vc::VerifiableCredential; + use crate::registrar::credential::DataverseCredential; + use crate::registrar::registry::ClaimRegistrar; use okp4_rdf::dataset::Dataset; use okp4_rdf::serde::NQuadsReader; use std::io::BufReader; - pub fn submit_claims(deps: DepsMut<'_>, data: Binary) -> Result { + pub fn submit_claims( + deps: DepsMut<'_>, + info: MessageInfo, + data: Binary, + ) -> Result { let buf = BufReader::new(data.as_slice()); let mut reader = NQuadsReader::new(buf); let rdf_quads = reader.read_all()?; let vc_dataset = Dataset::from(rdf_quads.as_slice()); let vc = VerifiableCredential::try_from(&vc_dataset)?; - vc.verify(deps)?; + vc.verify(&deps)?; + + let credential = DataverseCredential::try_from((info.sender, &vc))?; + let registrar = ClaimRegistrar::try_new(deps.storage)?; - Ok(Response::default()) + Ok(Response::default() + .add_attribute("action", "submit_claims") + .add_attribute("credential", credential.id) + .add_attribute("subject", credential.subject) + .add_attribute("type", credential.r#type) + .add_message(registrar.submit_claim(&deps, &credential)?)) } } @@ -108,12 +122,19 @@ pub mod query {} #[cfg(test)] mod tests { use super::*; - use crate::msg::{TripleStoreConfig, TripleStoreLimitsInput}; + use crate::msg::{RdfFormat, TripleStoreConfig, TripleStoreLimitsInput}; + use crate::testutil::testutil::read_test_data; use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info}; use cosmwasm_std::{ - Addr, Attribute, ContractResult, HexBinary, SubMsg, SystemError, SystemResult, Uint128, - Uint64, WasmQuery, + from_json, Addr, Attribute, ContractResult, CosmosMsg, HexBinary, SubMsg, SystemError, + SystemResult, Uint128, Uint64, WasmQuery, }; + use okp4_cognitarium::msg::{ + DataFormat, Head, Node, Results, SelectItem, SelectQuery, SelectResponse, + SimpleWhereCondition, TriplePattern, Value, VarOrNode, VarOrNodeOrLiteral, WhereCondition, + IRI, + }; + use std::collections::BTreeMap; #[test] fn proper_instantiate() { @@ -175,4 +196,234 @@ mod tests { } ) } + + #[test] + fn proper_submit_claims() { + let mut deps = mock_dependencies(); + deps.querier.update_wasm(|query| match query { + WasmQuery::Smart { contract_addr, msg } => { + if contract_addr != "my-dataverse-addr" { + return SystemResult::Err(SystemError::NoSuchContract { + addr: contract_addr.to_string(), + }); + } + let query_msg: StdResult = from_json(msg); + assert_eq!( + query_msg, + Ok(okp4_cognitarium::msg::QueryMsg::Select { + query: SelectQuery { + prefixes: vec![], + limit: Some(1u32), + select: vec![SelectItem::Variable("p".to_string())], + r#where: vec![WhereCondition::Simple( + SimpleWhereCondition::TriplePattern(TriplePattern { + subject: VarOrNode::Node(Node::NamedNode(IRI::Full( + "http://example.edu/credentials/3732".to_string(), + ))), + predicate: VarOrNode::Variable("p".to_string()), + object: VarOrNodeOrLiteral::Variable("o".to_string()), + }) + )], + } + }) + ); + + let select_resp = SelectResponse { + results: Results { bindings: vec![] }, + head: Head { vars: vec![] }, + }; + SystemResult::Ok(ContractResult::Ok(to_json_binary(&select_resp).unwrap())) + } + _ => SystemResult::Err(SystemError::Unknown {}), + }); + + DATAVERSE + .save( + deps.as_mut().storage, + &Dataverse { + name: "my-dataverse".to_string(), + triplestore_address: Addr::unchecked("my-dataverse-addr"), + }, + ) + .unwrap(); + + let resp = execute( + deps.as_mut(), + mock_env(), + mock_info("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf", &[]), + ExecuteMsg::SubmitClaims { + metadata: Binary(read_test_data("vc-eddsa-2020-ok.nq")), + format: Some(RdfFormat::NQuads), + }, + ); + + assert!(resp.is_ok()); + let resp = resp.unwrap(); + assert_eq!(resp.messages.len(), 1); + assert_eq!( + resp.attributes, + vec![ + Attribute::new("action", "submit_claims"), + Attribute::new("credential", "http://example.edu/credentials/3732"), + Attribute::new( + "subject", + "did:key:zDnaeUm3QkcyZWZTPttxB711jgqRDhkwvhF485SFw1bDZ9AQw" + ), + Attribute::new( + "type", + "https://example.org/examples#UniversityDegreeCredential" + ), + ] + ); + + let expected_data = " \"okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf\" . + . + . + \"2024-02-16T00:00:00Z\"^^ . + . +_:c0 _:b2 . +_:b2 \"Bachelor of Science and Arts\"^^ . +_:b2 . + _:c0 . + \"2026-02-16T00:00:00Z\"^^ .\n"; + + match resp.messages[0].msg.clone() { + CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr, + msg, + funds, + }) if contract_addr == "my-dataverse-addr".to_string() && funds == vec![] => { + let exec_msg: StdResult = from_json(msg); + assert!(exec_msg.is_ok()); + match exec_msg.unwrap() { + okp4_cognitarium::msg::ExecuteMsg::InsertData { format, data } => { + assert_eq!(format, Some(DataFormat::NTriples)); + assert_eq!(String::from_utf8(data.0).unwrap(), expected_data); + } + _ => assert!(false), + } + } + _ => assert!(false), + } + } + + #[test] + fn submit_nonrdf_claims() { + let resp = execute( + mock_dependencies().as_mut(), + mock_env(), + mock_info("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf", &[]), + ExecuteMsg::SubmitClaims { + metadata: Binary("notrdf".as_bytes().to_vec()), + format: Some(RdfFormat::NQuads), + }, + ); + + assert!(resp.is_err()); + assert!(matches!(resp.err().unwrap(), ContractError::ParseRDF(_))) + } + + #[test] + fn submit_invalid_claims() { + let resp = execute( + mock_dependencies().as_mut(), + mock_env(), + mock_info("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf", &[]), + ExecuteMsg::SubmitClaims { + metadata: Binary(vec![]), + format: Some(RdfFormat::NQuads), + }, + ); + + assert!(resp.is_err()); + assert!(matches!( + resp.err().unwrap(), + ContractError::InvalidCredential(_) + )) + } + + #[test] + fn submit_unverified_claims() { + let resp = execute( + mock_dependencies().as_mut(), + mock_env(), + mock_info("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf", &[]), + ExecuteMsg::SubmitClaims { + metadata: Binary(read_test_data("vc-eddsa-2020-ok-unsecured.nq")), + format: Some(RdfFormat::NQuads), + }, + ); + + assert!(resp.is_err()); + assert!(matches!( + resp.err().unwrap(), + ContractError::CredentialVerification(_) + )) + } + + #[test] + fn submit_unsupported_claims() { + let resp = execute( + mock_dependencies().as_mut(), + mock_env(), + mock_info("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf", &[]), + ExecuteMsg::SubmitClaims { + metadata: Binary(read_test_data("vc-unsupported-1.nq")), + format: Some(RdfFormat::NQuads), + }, + ); + + assert!(resp.is_err()); + assert!(matches!( + resp.err().unwrap(), + ContractError::UnsupportedCredential(_) + )) + } + + #[test] + fn submit_existing_claims() { + let mut deps = mock_dependencies(); + deps.querier.update_wasm(|query| match query { + WasmQuery::Smart { .. } => { + let select_resp = SelectResponse { + results: Results { + bindings: vec![BTreeMap::from([( + "p".to_string(), + Value::BlankNode { + value: "".to_string(), + }, + )])], + }, + head: Head { vars: vec![] }, + }; + SystemResult::Ok(ContractResult::Ok(to_json_binary(&select_resp).unwrap())) + } + _ => SystemResult::Err(SystemError::Unknown {}), + }); + + DATAVERSE + .save( + deps.as_mut().storage, + &Dataverse { + name: "my-dataverse".to_string(), + triplestore_address: Addr::unchecked("my-dataverse-addr"), + }, + ) + .unwrap(); + + let resp = execute( + deps.as_mut(), + mock_env(), + mock_info("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf", &[]), + ExecuteMsg::SubmitClaims { + metadata: Binary(read_test_data("vc-eddsa-2020-ok.nq")), + format: Some(RdfFormat::NQuads), + }, + ); + + assert!(resp.is_err()); + assert!( + matches!(resp.err().unwrap(), ContractError::CredentialAlreadyExists(id) if id == "http://example.edu/credentials/3732") + ); + } } diff --git a/contracts/okp4-dataverse/src/credential/crypto.rs b/contracts/okp4-dataverse/src/credential/crypto.rs index 91647c33..fea90bd7 100644 --- a/contracts/okp4-dataverse/src/credential/crypto.rs +++ b/contracts/okp4-dataverse/src/credential/crypto.rs @@ -39,7 +39,7 @@ impl From<(CanonicalizationAlg, DigestAlg, SignatureAlg)> for CryptoSuite { impl CryptoSuite { pub fn verify_document( &self, - deps: DepsMut<'_>, + deps: &'_ DepsMut<'_>, unsecured_doc: &[Quad<'_>], proof_opts: &[Quad<'_>], proof_value: &[u8], @@ -77,7 +77,7 @@ impl CryptoSuite { fn verify( &self, - deps: DepsMut<'_>, + deps: &'_ DepsMut<'_>, message: &[u8], signature: &[u8], pub_key: &[u8], diff --git a/contracts/okp4-dataverse/src/credential/error.rs b/contracts/okp4-dataverse/src/credential/error.rs index 161ac07e..85b87da3 100644 --- a/contracts/okp4-dataverse/src/credential/error.rs +++ b/contracts/okp4-dataverse/src/credential/error.rs @@ -12,9 +12,6 @@ pub enum InvalidCredentialError { #[error("Missing issuance date")] MissingIssuanceDate, - #[error("Missing proof, at least a supported one")] - MissingProof, - #[error("Invalid proof: {0}")] InvalidProof(#[from] InvalidProofError), diff --git a/contracts/okp4-dataverse/src/credential/mod.rs b/contracts/okp4-dataverse/src/credential/mod.rs index bd34dcf1..7f634366 100644 --- a/contracts/okp4-dataverse/src/credential/mod.rs +++ b/contracts/okp4-dataverse/src/credential/mod.rs @@ -1,5 +1,5 @@ mod crypto; pub mod error; mod proof; -mod rdf_marker; +pub mod rdf_marker; pub mod vc; diff --git a/contracts/okp4-dataverse/src/credential/rdf_marker.rs b/contracts/okp4-dataverse/src/credential/rdf_marker.rs index 8b7e7d17..7e7550d3 100644 --- a/contracts/okp4-dataverse/src/credential/rdf_marker.rs +++ b/contracts/okp4-dataverse/src/credential/rdf_marker.rs @@ -10,9 +10,8 @@ pub const RDF_DATE_TYPE: NamedNode<'_> = NamedNode { iri: "http://www.w3.org/2001/XMLSchema#dateTime", }; -pub const VC_RDF_TYPE: Term<'_> = Term::NamedNode(NamedNode { - iri: "https://www.w3.org/2018/credentials#VerifiableCredential", -}); +pub const IRI_VC_TYPE: &str = "https://www.w3.org/2018/credentials#VerifiableCredential"; +pub const VC_RDF_TYPE: Term<'_> = Term::NamedNode(NamedNode { iri: IRI_VC_TYPE }); pub const VC_RDF_ISSUER: NamedNode<'_> = NamedNode { iri: "https://www.w3.org/2018/credentials#issuer", }; diff --git a/contracts/okp4-dataverse/src/credential/vc.rs b/contracts/okp4-dataverse/src/credential/vc.rs index fbb53ebf..c03e84a2 100644 --- a/contracts/okp4-dataverse/src/credential/vc.rs +++ b/contracts/okp4-dataverse/src/credential/vc.rs @@ -9,21 +9,21 @@ use rio_api::model::{BlankNode, Literal, NamedNode, Subject, Term}; #[derive(Debug, PartialEq)] pub struct VerifiableCredential<'a> { - id: &'a str, - types: Vec<&'a str>, - issuer: &'a str, - issuance_date: &'a str, - expiration_date: Option<&'a str>, - claims: Vec>, - status: Option>, - proof: Vec>, + pub id: &'a str, + pub types: Vec<&'a str>, + pub issuer: &'a str, + pub issuance_date: &'a str, + pub expiration_date: Option<&'a str>, + pub claims: Vec>, + pub status: Option>, + pub proof: Vec>, unsecured_document: Dataset<'a>, } #[derive(Debug, PartialEq)] pub struct Claim<'a> { - id: &'a str, - content: Dataset<'a>, + pub id: &'a str, + pub content: Dataset<'a>, } #[derive(Debug, PartialEq)] @@ -42,10 +42,6 @@ impl<'a> TryFrom<&'a Dataset<'a>> for VerifiableCredential<'a> { let (proofs, proof_graphs): (Vec>, Vec>) = Self::extract_proofs(dataset, id)?.into_iter().unzip(); - if proofs.is_empty() { - return Err(InvalidCredentialError::MissingProof); - } - let mut unsecured_filter: Vec> = proof_graphs .into_iter() .map(|g| (None, None, None, Some(Some(g.into()))).into()) @@ -74,7 +70,7 @@ impl<'a> TryFrom<&'a Dataset<'a>> for VerifiableCredential<'a> { } impl<'a> VerifiableCredential<'a> { - pub fn verify(&self, deps: DepsMut<'_>) -> Result<(), VerificationError> { + pub fn verify(&self, deps: &'_ DepsMut<'_>) -> Result<(), VerificationError> { let proof = self .proof .iter() @@ -99,10 +95,10 @@ impl<'a> VerifiableCredential<'a> { .subjects() .exactly_one() .map_err(|e| match e.size_hint() { - (_, Some(_)) => InvalidCredentialError::MissingIdentifier, - _ => InvalidCredentialError::Malformed( + (_, Some(_)) => InvalidCredentialError::Malformed( "Credential cannot have more than one id".to_string(), ), + _ => InvalidCredentialError::MissingIdentifier, }) .and_then(|s| match s { Subject::NamedNode(n) => Ok(n), @@ -217,12 +213,7 @@ impl<'a> VerifiableCredential<'a> { }) .map_ok(|claim_id| Claim { id: claim_id.iri, - content: Dataset::new( - dataset - .match_pattern(Some(claim_id.into()), None, None, None) - .copied() - .collect(), - ), + content: dataset.sub_graph(claim_id.into()), }) .collect() } @@ -299,6 +290,7 @@ mod test { use super::*; use crate::testutil::testutil; use cosmwasm_std::testing::mock_dependencies; + use rio_api::model::Quad; #[test] fn proper_vc_from_dataset() { @@ -311,22 +303,62 @@ mod test { let vc_res = VerifiableCredential::try_from(&dataset); assert!(vc_res.is_ok()); let vc = vc_res.unwrap(); - assert_eq!(vc.id, "http://example.edu/credentials/58473"); + assert_eq!(vc.id, "http://example.edu/credentials/3732"); assert_eq!( vc.types, - vec!["https://www.w3.org/2018/credentials#VerifiableCredential"] + vec![ + "https://example.org/examples#UniversityDegreeCredential", + "https://www.w3.org/2018/credentials#VerifiableCredential" + ] ); assert_eq!( vc.issuer, "did:key:z6MkpwdnLPAm4apwcrRYQ6fZ3rAcqjLZR4AMk14vimfnozqY" ); - assert_eq!(vc.issuance_date, "2023-05-01T06:09:10Z"); - assert_eq!(vc.expiration_date, None); + assert_eq!(vc.issuance_date, "2024-02-16T00:00:00Z"); + assert_eq!(vc.expiration_date, Some("2026-02-16T00:00:00Z")); assert_eq!( vc.claims, vec![Claim { - id: "did:key:z6MkpwdnLPAm4apwcrRYQ6fZ3rAcqjLZR4AMk14vimfnozqY", - content: Dataset::new(vec![]), + id: "did:key:zDnaeUm3QkcyZWZTPttxB711jgqRDhkwvhF485SFw1bDZ9AQw", + content: Dataset::new(vec![ + Quad { + subject: NamedNode { + iri: "did:key:zDnaeUm3QkcyZWZTPttxB711jgqRDhkwvhF485SFw1bDZ9AQw" + } + .into(), + predicate: NamedNode { + iri: "https://example.org/examples#degree" + }, + object: BlankNode { id: "b2" }.into(), + graph_name: None + }, + Quad { + subject: BlankNode { id: "b2" }.into(), + predicate: NamedNode { + iri: "http://schema.org/name" + }, + object: Literal::Typed { + value: "Bachelor of Science and Arts", + datatype: NamedNode { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#HTML" + } + } + .into(), + graph_name: None + }, + Quad { + subject: BlankNode { id: "b2" }.into(), + predicate: NamedNode { + iri: "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" + }, + object: NamedNode { + iri: "https://example.org/examples#BachelorDegree" + } + .into(), + graph_name: None + } + ]) }] ); assert_eq!(vc.status, None); @@ -347,7 +379,7 @@ mod test { let owned_quads = testutil::read_test_quads(case); let dataset = Dataset::from(owned_quads.as_slice()); let vc = VerifiableCredential::try_from(&dataset).unwrap(); - let verif_res = vc.verify(deps.as_mut()); + let verif_res = vc.verify(&deps.as_mut()); assert!(verif_res.is_ok()); } } diff --git a/contracts/okp4-dataverse/src/error.rs b/contracts/okp4-dataverse/src/error.rs index 1e62f163..f0df3721 100644 --- a/contracts/okp4-dataverse/src/error.rs +++ b/contracts/okp4-dataverse/src/error.rs @@ -17,6 +17,12 @@ pub enum ContractError { #[error("Invalid credential: '{0}'")] InvalidCredential(#[from] InvalidCredentialError), - #[error("Credential verification failed: {0}")] + #[error("Credential verification failed: '{0}'")] CredentialVerification(#[from] VerificationError), + + #[error("Credential not supported: '{0}'")] + UnsupportedCredential(String), + + #[error("Credential already exists: '{0}'")] + CredentialAlreadyExists(String), } diff --git a/contracts/okp4-dataverse/src/lib.rs b/contracts/okp4-dataverse/src/lib.rs index 300fb02f..6ea2e762 100644 --- a/contracts/okp4-dataverse/src/lib.rs +++ b/contracts/okp4-dataverse/src/lib.rs @@ -14,6 +14,7 @@ pub mod contract; mod credential; mod error; pub mod msg; +mod registrar; pub mod state; mod testutil; diff --git a/contracts/okp4-dataverse/src/msg.rs b/contracts/okp4-dataverse/src/msg.rs index 0516c20a..4197f68f 100644 --- a/contracts/okp4-dataverse/src/msg.rs +++ b/contracts/okp4-dataverse/src/msg.rs @@ -26,11 +26,7 @@ pub enum ExecuteMsg { /// /// #### Format /// - /// Claims are injected into the dataverse through Verifiable Presentations (VPs). These presentations effectively amalgamate and - /// showcase multiple credentials, thus providing a cohesive and comprehensive view of the assertions being made. - /// - /// While the data in a VP typically revolves around a common subject, it accommodates an unlimited number of subjects and issuers. - /// This flexibility allows for a broad spectrum of claims to be represented. + /// Claims are injected into the dataverse through Verifiable Credentials (VCs). /// /// Primarily, the claims leverage the OKP4 ontology, which facilitates articulating assertions about widely acknowledged resources /// in the dataverse, including digital services, digital resources, zones, governance, and more. @@ -41,11 +37,22 @@ pub enum ExecuteMsg { /// /// To maintain integrity and coherence in the dataverse, several preconditions are set for the submission of claims: /// - /// 1. **Format Requirement**: Claims must be encapsulated within Verifiable Presentations (VPs). + /// 1. **Format Requirement**: Claims must be encapsulated within Verifiable Credentials (VCs). /// /// 2. **Unique Identifier Mandate**: Each Verifiable Credential within the dataverse must possess a unique identifier. /// /// 3. **Issuer Signature**: Claims must bear the issuer's signature. This signature must be verifiable, ensuring authenticity and credibility. + /// + /// 4. **Content**: The actual implementation supports the submission of a single Verifiable Credential, containing a single claim. + /// + /// #### Supported cryptographic proofs + /// + /// - `Ed25519Signature2020` + /// + /// - `EcdsaSecp256k1Signature2019` + /// + /// - `DataIntegrity` with the following cryptosuites: `eddsa-2022`, `eddsa-rdfc-2022`. + /// SubmitClaims { /// The serialized metadata intended for attachment. /// This metadata should adhere to the format specified in the `format` field. diff --git a/contracts/okp4-dataverse/src/registrar/credential.rs b/contracts/okp4-dataverse/src/registrar/credential.rs new file mode 100644 index 00000000..be95b4ad --- /dev/null +++ b/contracts/okp4-dataverse/src/registrar/credential.rs @@ -0,0 +1,180 @@ +use crate::credential::rdf_marker::IRI_VC_TYPE; +use crate::credential::vc::VerifiableCredential; +use crate::registrar::rdf::VC_CLAIM; +use crate::ContractError; +use cosmwasm_std::Addr; +use itertools::Itertools; +use rio_api::model::{BlankNode, NamedNode, Subject, Term, Triple}; + +#[derive(Debug, PartialEq)] +pub struct DataverseCredential<'a> { + pub submitter_addr: Addr, + pub id: &'a str, + pub issuer: &'a str, + pub r#type: &'a str, + pub valid_from: &'a str, + pub valid_until: Option<&'a str>, + pub subject: &'a str, + pub claim: Vec>, +} + +impl<'a> DataverseCredential<'a> { + fn extract_vc_type(vc: &'a VerifiableCredential<'a>) -> Result<&'a str, ContractError> { + vc.types + .iter() + .filter(|t| *t != &IRI_VC_TYPE) + .exactly_one() + .map_err(|_| { + ContractError::UnsupportedCredential( + "credential is expected to have exactly one type".to_string(), + ) + }) + .map(|t| *t) + } + + fn extract_vc_claim( + vc: &'a VerifiableCredential<'a>, + ) -> Result<(&'a str, Vec>), ContractError> { + //todo: use the canon identifier issuer instead and rename all blank nodes + let claim_node = BlankNode { id: "c0" }; + + let claim = vc.claims.iter().exactly_one().map_err(|_| { + ContractError::UnsupportedCredential( + "credential is expected to contain exactly one claim".to_string(), + ) + })?; + + let mut triples = claim + .content + .iter() + .map(|q| { + let subject = match q.subject { + Subject::NamedNode(n) => { + if n.iri != claim.id { + Err(ContractError::UnsupportedCredential( + "claim hierarchy can be forge only through blank nodes".to_string(), + ))?; + } + Subject::BlankNode(claim_node) + } + _ => q.subject, + }; + Ok(Triple { + subject, + predicate: q.predicate, + object: q.object, + }) + }) + .collect::>, ContractError>>()?; + + triples.push(Triple { + subject: Subject::NamedNode(NamedNode { iri: vc.id }), + predicate: VC_CLAIM, + object: Term::BlankNode(BlankNode { id: "c0" }), + }); + + Ok((claim.id, triples)) + } +} + +impl<'a> TryFrom<(Addr, &'a VerifiableCredential<'a>)> for DataverseCredential<'a> { + type Error = ContractError; + + fn try_from( + (submitter_addr, vc): (Addr, &'a VerifiableCredential<'a>), + ) -> Result { + let (subject, claim) = DataverseCredential::extract_vc_claim(vc)?; + Ok(DataverseCredential { + submitter_addr, + id: vc.id, + issuer: vc.issuer, + r#type: DataverseCredential::extract_vc_type(vc)?, + valid_from: vc.issuance_date, + valid_until: vc.expiration_date, + subject, + claim, + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::testutil::testutil; + use okp4_rdf::dataset::Dataset; + use rio_api::model::Literal; + + #[test] + fn proper_from_verifiable() { + let owned_quads = testutil::read_test_quads("vc-valid.nq"); + let dataset = Dataset::from(owned_quads.as_slice()); + let vc = VerifiableCredential::try_from(&dataset).unwrap(); + let dc_res = DataverseCredential::try_from(( + Addr::unchecked("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf"), + &vc, + )); + + assert!(dc_res.is_ok()); + assert_eq!(dc_res.unwrap(), DataverseCredential { + submitter_addr: Addr::unchecked("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf"), + id: "https://w3id.org/okp4/ontology/vnext/schema/credential/digital-service/description/72cab400-5bd6-4eb4-8605-a5ee8c1a45c9", + issuer: "did:key:zQ3shs7auhJSmVJpiUbQWco6bxxEhSqWnVEPvaBHBRvBKw6Q3", + r#type: "https://w3id.org/okp4/ontology/vnext/schema/credential/digital-service/description/DigitalServiceDescriptionCredential", + valid_from: "2024-01-22T00:00:00", + valid_until: Some("2025-01-22T00:00:00"), + subject: "did:key:zQ3shhb4SvzBRLbBonsvKb3WX6WoDeKWHpsXXXMhAJETqXAfB", + claim: vec![Triple { + subject: BlankNode {id: "c0"}.into(), + predicate: NamedNode {iri: "https://w3id.org/okp4/ontology/vnext/schema/credential/digital-service/description/hasCategory"}, + object: NamedNode {iri: "https://w3id.org/okp4/ontology/vnext/thesaurus/digital-service-category/Storage"}.into(), + },Triple { + subject: BlankNode {id: "c0"}.into(), + predicate: NamedNode {iri: "https://w3id.org/okp4/ontology/vnext/schema/credential/digital-service/description/hasTag"}, + object: Literal::Simple {value: "Cloud"}.into(), + },Triple { + subject: NamedNode {iri: "https://w3id.org/okp4/ontology/vnext/schema/credential/digital-service/description/72cab400-5bd6-4eb4-8605-a5ee8c1a45c9"}.into(), + predicate: NamedNode {iri: "dataverse:credential#claim"}, + object: BlankNode {id: "c0"}.into(), + }], + }) + } + + #[test] + fn unsupported_from_verifiable() { + let cases = vec![ + ( + "vc-unsupported-1.nq", + "credential is expected to have exactly one type", + ), + ( + "vc-unsupported-2.nq", + "credential is expected to have exactly one type", + ), + ( + "vc-unsupported-3.nq", + "credential is expected to contain exactly one claim", + ), + ( + "vc-unsupported-4.nq", + "claim hierarchy can be forge only through blank nodes", + ), + ]; + + for case in cases { + let owned_quads = testutil::read_test_quads(case.0); + let dataset = Dataset::from(owned_quads.as_slice()); + let vc = VerifiableCredential::try_from(&dataset).unwrap(); + let dc_res = DataverseCredential::try_from(( + Addr::unchecked("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf"), + &vc, + )); + + assert!(dc_res.is_err()); + if let ContractError::UnsupportedCredential(msg) = dc_res.err().unwrap() { + assert_eq!(msg, case.1.to_string()); + } else { + assert!(false); + } + } + } +} diff --git a/contracts/okp4-dataverse/src/registrar/mod.rs b/contracts/okp4-dataverse/src/registrar/mod.rs new file mode 100644 index 00000000..767ae316 --- /dev/null +++ b/contracts/okp4-dataverse/src/registrar/mod.rs @@ -0,0 +1,3 @@ +pub mod credential; +mod rdf; +pub mod registry; diff --git a/contracts/okp4-dataverse/src/registrar/rdf.rs b/contracts/okp4-dataverse/src/registrar/rdf.rs new file mode 100644 index 00000000..ebdfb01a --- /dev/null +++ b/contracts/okp4-dataverse/src/registrar/rdf.rs @@ -0,0 +1,145 @@ +use crate::credential::rdf_marker::RDF_DATE_TYPE; +use crate::registrar::credential::DataverseCredential; +use crate::ContractError; +use cosmwasm_std::{Binary, StdError}; +use okp4_rdf::serde::{DataFormat, TripleWriter}; +use rio_api::model::{Literal, NamedNode, Subject, Term, Triple}; + +pub const VC_SUBMITTER_ADDRESS: NamedNode<'_> = NamedNode { + iri: "dataverse:credential#submitterAddress", +}; +pub const VC_TYPE: NamedNode<'_> = NamedNode { + iri: "dataverse:credential#type", +}; +pub const VC_ISSUER: NamedNode<'_> = NamedNode { + iri: "dataverse:credential#issuer", +}; +pub const VC_VALID_FROM: NamedNode<'_> = NamedNode { + iri: "dataverse:credential#validFrom", +}; +pub const VC_VALID_UNTIL: NamedNode<'_> = NamedNode { + iri: "dataverse:credential#validUntil", +}; +pub const VC_SUBJECT: NamedNode<'_> = NamedNode { + iri: "dataverse:credential#subject", +}; +pub const VC_CLAIM: NamedNode<'_> = NamedNode { + iri: "dataverse:credential#claim", +}; + +impl<'a> From<&'a DataverseCredential<'a>> for Vec> { + fn from(credential: &'a DataverseCredential<'a>) -> Self { + let c_subject = Subject::NamedNode(NamedNode { iri: credential.id }); + + let mut triples = vec![ + Triple { + subject: c_subject, + predicate: VC_SUBMITTER_ADDRESS, + object: Term::Literal(Literal::Simple { + value: credential.submitter_addr.as_str(), + }), + }, + Triple { + subject: c_subject, + predicate: VC_ISSUER, + object: Term::NamedNode(NamedNode { + iri: credential.issuer, + }), + }, + Triple { + subject: c_subject, + predicate: VC_TYPE, + object: Term::NamedNode(NamedNode { + iri: credential.r#type, + }), + }, + Triple { + subject: c_subject, + predicate: VC_VALID_FROM, + object: Term::Literal(Literal::Typed { + value: credential.valid_from, + datatype: RDF_DATE_TYPE, + }), + }, + Triple { + subject: c_subject, + predicate: VC_SUBJECT, + object: Term::NamedNode(NamedNode { + iri: credential.subject, + }), + }, + ]; + + triples.extend(credential.claim.as_slice()); + + if let Some(valid_until) = credential.valid_until { + triples.push(Triple { + subject: c_subject, + predicate: VC_VALID_UNTIL, + object: Term::Literal(Literal::Typed { + value: valid_until, + datatype: RDF_DATE_TYPE, + }), + }); + } + + triples + } +} + +pub fn serialize( + credential: &DataverseCredential<'_>, + format: DataFormat, +) -> Result { + let triples: Vec> = credential.into(); + let out: Vec = Vec::default(); + let mut writer = TripleWriter::new(&format, out); + for triple in triples { + writer + .write(&triple) + .map_err(|e| StdError::serialize_err("triple", format!("Error writing triple: {e}")))?; + } + + Ok(Binary::from(writer.finish().map_err(|e| { + StdError::serialize_err("triple", format!("Error writing triple: {e}")) + })?)) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::credential::vc::VerifiableCredential; + use crate::testutil::testutil; + use cosmwasm_std::Addr; + use okp4_rdf::dataset::Dataset; + + #[test] + fn proper_serialization() { + let owned_quads = testutil::read_test_quads("vc-valid.nq"); + let dataset = Dataset::from(owned_quads.as_slice()); + let vc = VerifiableCredential::try_from(&dataset).unwrap(); + let dc = DataverseCredential::try_from(( + Addr::unchecked("okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf"), + &vc, + )) + .unwrap(); + + let expected = " \"okp41072nc6egexqr2v6vpp7yxwm68plvqnkf6xsytf\" . + . + . + \"2024-01-22T00:00:00\"^^ . + . +_:c0 . +_:c0 \"Cloud\" . + _:c0 . + \"2025-01-22T00:00:00\"^^ .\n"; + + let serialization_res = serialize(&dc, DataFormat::NQuads); + assert!(serialization_res.is_ok()); + + assert_eq!( + String::from_utf8(serialization_res.unwrap().0).unwrap(), + expected + ); + } +} diff --git a/contracts/okp4-dataverse/src/registrar/registry.rs b/contracts/okp4-dataverse/src/registrar/registry.rs new file mode 100644 index 00000000..72260733 --- /dev/null +++ b/contracts/okp4-dataverse/src/registrar/registry.rs @@ -0,0 +1,65 @@ +use crate::registrar::credential::DataverseCredential; +use crate::registrar::rdf::serialize; +use crate::state::DATAVERSE; +use crate::ContractError; +use cosmwasm_std::{DepsMut, StdResult, Storage, WasmMsg}; +use okp4_cognitarium::msg::{ + DataFormat, Node, SelectItem, SelectQuery, SimpleWhereCondition, TriplePattern, VarOrNode, + VarOrNodeOrLiteral, WhereCondition, IRI, +}; +use okp4_cognitarium_client::CognitariumClient; + +/// ClaimRegistrar is the entity responsible to manage claims (i.e. submission and revocation) into +/// the Dataverse, ensuring that any pre-condition criteria to an action is met, and any attached +/// logic is properly executed. +pub struct ClaimRegistrar { + triplestore: CognitariumClient, +} + +impl ClaimRegistrar { + const RDF_DATA_FORMAT: DataFormat = DataFormat::NTriples; + + pub fn try_new(storage: &dyn Storage) -> StdResult { + let dataverse = DATAVERSE.load(storage)?; + Ok(Self { + triplestore: CognitariumClient::new(dataverse.triplestore_address), + }) + } + + pub fn submit_claim( + &self, + deps: &DepsMut<'_>, + credential: &DataverseCredential<'_>, + ) -> Result { + let resp = self.triplestore.select( + deps.querier, + SelectQuery { + prefixes: vec![], + limit: Some(1u32), + select: vec![SelectItem::Variable("p".to_string())], + r#where: vec![WhereCondition::Simple(SimpleWhereCondition::TriplePattern( + TriplePattern { + subject: VarOrNode::Node(Node::NamedNode(IRI::Full( + credential.id.to_string(), + ))), + predicate: VarOrNode::Variable("p".to_string()), + object: VarOrNodeOrLiteral::Variable("o".to_string()), + }, + ))], + }, + )?; + + if !resp.results.bindings.is_empty() { + Err(ContractError::CredentialAlreadyExists( + credential.id.to_string(), + ))?; + } + + self.triplestore + .insert_data( + Some(Self::RDF_DATA_FORMAT), + serialize(credential, (&Self::RDF_DATA_FORMAT).into())?, + ) + .map_err(ContractError::from) + } +} diff --git a/contracts/okp4-dataverse/testdata/vc-eddsa-2020-ok-unsecured.nq b/contracts/okp4-dataverse/testdata/vc-eddsa-2020-ok-unsecured.nq index 73caed51..dbe80a33 100644 --- a/contracts/okp4-dataverse/testdata/vc-eddsa-2020-ok-unsecured.nq +++ b/contracts/okp4-dataverse/testdata/vc-eddsa-2020-ok-unsecured.nq @@ -1,4 +1,9 @@ - . - . - "2023-05-01T06:09:10Z"^^ . - . + _:b2 . + . + . + . + "2026-02-16T00:00:00Z"^^ . + "2024-02-16T00:00:00Z"^^ . + . +_:b2 "Bachelor of Science and Arts"^^ . +_:b2 . diff --git a/contracts/okp4-dataverse/testdata/vc-eddsa-2020-ok.nq b/contracts/okp4-dataverse/testdata/vc-eddsa-2020-ok.nq index 451e978b..4c0a3964 100644 --- a/contracts/okp4-dataverse/testdata/vc-eddsa-2020-ok.nq +++ b/contracts/okp4-dataverse/testdata/vc-eddsa-2020-ok.nq @@ -1,10 +1,15 @@ - . - _:b0 . - . - "2023-05-01T06:09:10Z"^^ . - . -_:b1 "2024-02-01T17:46:53.676947Z"^^ _:b0 . + _:b2 . + . + . + _:b0 . + . + "2026-02-16T00:00:00Z"^^ . + "2024-02-16T00:00:00Z"^^ . + . +_:b1 "2024-02-16T17:35:56.668169Z"^^ _:b0 . _:b1 _:b0 . _:b1 _:b0 . -_:b1 "z3WboEDRwsWokH8vQrveVWbg6fQnqhHfhrkGHT9tyG2GYgzQVZ9zFW6eK2ZNcnGhydqXWDwwTsZq29e7cHJkbnVkF"^^ _:b0 . +_:b1 "zUuTPsT5aKs53ciMY6qEj2dqZxK4XnLoZhX26amB9GMCMhfcTmLbtndcW5JS4gUqPkxGxsCmZCKuvkFnDgrGFrWD"^^ _:b0 . _:b1 _:b0 . +_:b2 "Bachelor of Science and Arts"^^ . +_:b2 . diff --git a/contracts/okp4-dataverse/testdata/vc-unsupported-1.nq b/contracts/okp4-dataverse/testdata/vc-unsupported-1.nq new file mode 100644 index 00000000..6b709c96 --- /dev/null +++ b/contracts/okp4-dataverse/testdata/vc-unsupported-1.nq @@ -0,0 +1,14 @@ + _:b2 . + . + _:b0 . + . + "2026-02-16T00:00:00Z"^^ . + "2024-02-16T00:00:00Z"^^ . + . +_:b1 "2024-02-17T13:32:14.814613Z"^^ _:b0 . +_:b1 _:b0 . +_:b1 _:b0 . +_:b1 "z5vGstniEMfyk5riV1UQvFXhXzdcbZ1978JFGdn1H2wTdp6qvxqDuw5xg8M33hjdZTG6zGuCbAhgCqf7R2CkhVRp5"^^ _:b0 . +_:b1 _:b0 . +_:b2 "Bachelor of Science and Arts"^^ . +_:b2 . diff --git a/contracts/okp4-dataverse/testdata/vc-unsupported-2.nq b/contracts/okp4-dataverse/testdata/vc-unsupported-2.nq new file mode 100644 index 00000000..db6e7132 --- /dev/null +++ b/contracts/okp4-dataverse/testdata/vc-unsupported-2.nq @@ -0,0 +1,15 @@ + . + "Cloud" . + . + . + . + . + "2024-01-22T00:00:00"^^ . + "2025-01-22T00:00:00"^^ . + . + _:b0 . +_:b1 "2024-02-01T17:46:53.676947Z"^^ _:b0 . +_:b1 _:b0 . +_:b1 _:b0 . +_:b1 "z3WboEDRwsWokH8vQrveVWbg6fQnqhHfhrkGHT9tyG2GYgzQVZ9zFW6eK2ZNcnGhydqXWDwwTsZq29e7cHJkbnVkF"^^ _:b0 . +_:b1 _:b0 . diff --git a/contracts/okp4-dataverse/testdata/vc-unsupported-3.nq b/contracts/okp4-dataverse/testdata/vc-unsupported-3.nq new file mode 100644 index 00000000..d13c7438 --- /dev/null +++ b/contracts/okp4-dataverse/testdata/vc-unsupported-3.nq @@ -0,0 +1,16 @@ + . + "Cloud" . + "Cloud" . + . + . + . + . + "2024-01-22T00:00:00"^^ . + "2025-01-22T00:00:00"^^ . + . + _:b0 . +_:b1 "2024-02-01T17:46:53.676947Z"^^ _:b0 . +_:b1 _:b0 . +_:b1 _:b0 . +_:b1 "z3WboEDRwsWokH8vQrveVWbg6fQnqhHfhrkGHT9tyG2GYgzQVZ9zFW6eK2ZNcnGhydqXWDwwTsZq29e7cHJkbnVkF"^^ _:b0 . +_:b1 _:b0 . diff --git a/contracts/okp4-dataverse/testdata/vc-unsupported-4.nq b/contracts/okp4-dataverse/testdata/vc-unsupported-4.nq new file mode 100644 index 00000000..a18311fc --- /dev/null +++ b/contracts/okp4-dataverse/testdata/vc-unsupported-4.nq @@ -0,0 +1,16 @@ + . + "Cloud" . + . + "yes I am" . + . + . + . + "2024-01-22T00:00:00"^^ . + "2025-01-22T00:00:00"^^ . + . + _:b0 . +_:b1 "2024-02-01T17:46:53.676947Z"^^ _:b0 . +_:b1 _:b0 . +_:b1 _:b0 . +_:b1 "z3WboEDRwsWokH8vQrveVWbg6fQnqhHfhrkGHT9tyG2GYgzQVZ9zFW6eK2ZNcnGhydqXWDwwTsZq29e7cHJkbnVkF"^^ _:b0 . +_:b1 _:b0 . diff --git a/contracts/okp4-dataverse/testdata/vc-valid.nq b/contracts/okp4-dataverse/testdata/vc-valid.nq new file mode 100644 index 00000000..cbdd23a2 --- /dev/null +++ b/contracts/okp4-dataverse/testdata/vc-valid.nq @@ -0,0 +1,14 @@ + . + "Cloud" . + . + . + . + "2024-01-22T00:00:00"^^ . + "2025-01-22T00:00:00"^^ . + . + _:b0 . +_:b1 "2024-02-01T17:46:53.676947Z"^^ _:b0 . +_:b1 _:b0 . +_:b1 _:b0 . +_:b1 "z3WboEDRwsWokH8vQrveVWbg6fQnqhHfhrkGHT9tyG2GYgzQVZ9zFW6eK2ZNcnGhydqXWDwwTsZq29e7cHJkbnVkF"^^ _:b0 . +_:b1 _:b0 . diff --git a/docs/okp4-dataverse.md b/docs/okp4-dataverse.md index 17f2d896..8dd93cca 100644 --- a/docs/okp4-dataverse.md +++ b/docs/okp4-dataverse.md @@ -68,9 +68,7 @@ The SubmitClaims message is a pivotal component in the dataverse, enabling entit #### Format -Claims are injected into the dataverse through Verifiable Presentations (VPs). These presentations effectively amalgamate and showcase multiple credentials, thus providing a cohesive and comprehensive view of the assertions being made. - -While the data in a VP typically revolves around a common subject, it accommodates an unlimited number of subjects and issuers. This flexibility allows for a broad spectrum of claims to be represented. +Claims are injected into the dataverse through Verifiable Credentials (VCs). Primarily, the claims leverage the OKP4 ontology, which facilitates articulating assertions about widely acknowledged resources in the dataverse, including digital services, digital resources, zones, governance, and more. @@ -80,12 +78,22 @@ Additionally, other schemas may also be employed to supplement and enhance the v To maintain integrity and coherence in the dataverse, several preconditions are set for the submission of claims: -1. **Format Requirement**: Claims must be encapsulated within Verifiable Presentations (VPs). +1. **Format Requirement**: Claims must be encapsulated within Verifiable Credentials (VCs). 2. **Unique Identifier Mandate**: Each Verifiable Credential within the dataverse must possess a unique identifier. 3. **Issuer Signature**: Claims must bear the issuer's signature. This signature must be verifiable, ensuring authenticity and credibility. +4. **Content**: The actual implementation supports the submission of a single Verifiable Credential, containing a single claim. + +#### Supported cryptographic proofs + +- `Ed25519Signature2020` + +- `EcdsaSecp256k1Signature2019` + +- `DataIntegrity` with the following cryptosuites: `eddsa-2022`, `eddsa-rdfc-2022`. + | parameter | description | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `submit_claims` | _(Required.) _ **object**. | @@ -213,5 +221,5 @@ let b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ``` --- -*Rendered by [Fadroma](https://fadroma.tech) ([@fadroma/schema 1.1.0](https://www.npmjs.com/package/@fadroma/schema)) from `okp4-dataverse.json` (`f38b5aafa47fb333`)* +*Rendered by [Fadroma](https://fadroma.tech) ([@fadroma/schema 1.1.0](https://www.npmjs.com/package/@fadroma/schema)) from `okp4-dataverse.json` (`30443a4cdcde9c27`)* ```` diff --git a/packages/okp4-rdf/src/dataset.rs b/packages/okp4-rdf/src/dataset.rs index ef2d38bf..e7d4e414 100644 --- a/packages/okp4-rdf/src/dataset.rs +++ b/packages/okp4-rdf/src/dataset.rs @@ -1,6 +1,7 @@ use crate::owned_model::OwnedQuad; use itertools::Itertools; use rio_api::model::{GraphName, NamedNode, Quad, Subject, Term}; +use std::collections::HashSet; use std::slice::Iter; #[derive(Clone, Debug, PartialEq)] @@ -49,6 +50,39 @@ impl<'a> Dataset<'a> { ) -> QuadPatternFilter<'a, Iter<'a, Quad<'a>>> { self.iter().skip_pattern((s, p, o, g).into()) } + + pub fn sub_graph(&'a self, subject: Subject<'a>) -> Dataset<'a> { + Self::new(Self::sub_graph_from_quads(self.as_ref(), HashSet::new(), subject).0) + } + + fn sub_graph_from_quads( + quads: &'a [Quad<'a>], + mut visited: HashSet>, + subject: Subject<'a>, + ) -> (Vec>, HashSet>) { + let mut sub_graph = vec![]; + for quad in quads + .iter() + .match_pattern((Some(subject), None, None, None).into()) + { + sub_graph.push(*quad); + + let maybe_node: Option> = match quad.object { + Term::NamedNode(n) => Some(n.into()), + Term::BlankNode(n) => Some(n.into()), + _ => None, + }; + + if let Some(s) = maybe_node.filter(|n| !visited.contains(n)) { + visited.insert(subject); + let (new_quads, new_visited) = Self::sub_graph_from_quads(quads, visited, s); + visited = new_visited; + sub_graph.extend(new_quads); + } + } + + (sub_graph, visited) + } } #[derive(Copy, Clone)]