use serde_json::Value;
pub use trust_tasks_rs::specs::task_consent::decision::v0_1 as decision;
pub use trust_tasks_rs::specs::task_consent::request::v0_1 as request;
pub const REQUEST_TYPE: &str = <request::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const DECISION_TYPE: &str = <decision::Payload as trust_tasks_rs::Payload>::TYPE_URI;
pub const MATCH_CODE_LEN: usize = 6;
const MULTIHASH_SHA2_256_32: [u8; 2] = [0x12, 0x20];
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum TaskConsentError {
#[error("payloadDigest is not a multibase sha2-256 multihash: {0}")]
Digest(String),
#[error("no task-consent request found in the input")]
NoRequest,
#[error("not a valid task-consent/request/0.1 document: {0}")]
Malformed(String),
#[error("the request's proof does not verify")]
ProofInvalid,
#[error("the request is signed by {signer}, not by its issuer {issuer}")]
SignerNotIssuer {
signer: String,
issuer: String,
},
#[error("the request was issued by {actual}, not by {expected}")]
WrongIssuer {
expected: String,
actual: String,
},
#[error("the request is addressed to {actual}, not to this approver ({expected})")]
WrongRecipient {
expected: String,
actual: String,
},
#[error("the request expired at {0}")]
Expired(chrono::DateTime<chrono::Utc>),
}
pub fn match_code(payload_digest: &str) -> Result<String, TaskConsentError> {
let (_base, bytes) =
multibase::decode(payload_digest).map_err(|e| TaskConsentError::Digest(e.to_string()))?;
let digest = bytes
.strip_prefix(&MULTIHASH_SHA2_256_32)
.filter(|d| d.len() == 32)
.ok_or_else(|| TaskConsentError::Digest("not a 32-byte sha2-256 multihash".into()))?;
Ok(crate::hex::lower(&digest[..MATCH_CODE_LEN.div_ceil(2)])[..MATCH_CODE_LEN].to_string())
}
pub fn extract_requests(input: &Value) -> Vec<Value> {
fn is_request(v: &Value) -> bool {
v.get("type").and_then(Value::as_str) == Some(REQUEST_TYPE)
}
match input {
Value::Array(items) => items.iter().filter(|v| is_request(v)).cloned().collect(),
v if is_request(v) => vec![v.clone()],
Value::Object(map) => map
.get("consentRequests")
.or_else(|| map.get("details").and_then(|d| d.get("consentRequests")))
.map(extract_requests)
.unwrap_or_default(),
_ => Vec::new(),
}
}
#[derive(Debug, Clone)]
pub struct ConsentRequest {
raw: Value,
}
impl ConsentRequest {
pub fn new(raw: Value) -> Self {
Self { raw }
}
pub fn recipient(&self) -> Option<&str> {
self.raw.get("recipient").and_then(Value::as_str)
}
}
#[cfg(feature = "client")]
impl ConsentRequest {
pub async fn verify(
self,
expected_issuer: &str,
approver: &str,
resolver: &crate::trust_task_proof::TrustTaskVmResolver,
now: chrono::DateTime<chrono::Utc>,
) -> Result<VerifiedConsentRequest, TaskConsentError> {
use trust_tasks_rs::validate::ValidatedPayload;
let doc: trust_tasks_rs::TrustTask<Value> = serde_json::from_value(self.raw.clone())
.map_err(|e| TaskConsentError::Malformed(e.to_string()))?;
if doc.type_uri.to_string() != REQUEST_TYPE {
return Err(TaskConsentError::Malformed(format!(
"document type is {}",
doc.type_uri
)));
}
let signer = crate::trust_task_proof::verify_trust_task_proof_with(&doc, resolver)
.await
.map_err(|_| TaskConsentError::ProofInvalid)?;
let issuer = doc.issuer.clone().unwrap_or_default();
if signer != issuer {
return Err(TaskConsentError::SignerNotIssuer { signer, issuer });
}
if issuer != expected_issuer {
return Err(TaskConsentError::WrongIssuer {
expected: expected_issuer.to_string(),
actual: issuer,
});
}
let recipient = doc.recipient.clone().unwrap_or_default();
if recipient != approver {
return Err(TaskConsentError::WrongRecipient {
expected: approver.to_string(),
actual: recipient,
});
}
request::Payload::validate_value(&doc.payload)
.map_err(|e| TaskConsentError::Malformed(e.to_string()))?;
let payload: request::Payload = serde_json::from_value(doc.payload.clone())
.map_err(|e| TaskConsentError::Malformed(e.to_string()))?;
if payload.expires_at <= now {
return Err(TaskConsentError::Expired(payload.expires_at));
}
let match_code = match_code(&payload.payload_digest)?;
Ok(VerifiedConsentRequest {
issuer,
payload,
match_code,
})
}
}
#[derive(Debug, Clone)]
pub struct VerifiedConsentRequest {
issuer: String,
payload: request::Payload,
match_code: String,
}
impl VerifiedConsentRequest {
pub fn issuer(&self) -> &str {
&self.issuer
}
pub fn payload(&self) -> &request::Payload {
&self.payload
}
pub fn match_code(&self) -> &str {
&self.match_code
}
pub fn decision(
&self,
approve: bool,
reason: Option<&str>,
) -> Result<decision::Payload, TaskConsentError> {
let malformed = |e: &dyn std::fmt::Display| TaskConsentError::Malformed(e.to_string());
decision::Payload::builder()
.challenge(
decision::PayloadChallenge::try_from(self.payload.challenge.to_string())
.map_err(|e| malformed(&e))?,
)
.decision(if approve {
decision::Decision::Approve
} else {
decision::Decision::Deny
})
.payload_digest(
decision::DigestMultibase::try_from(self.payload.payload_digest.to_string())
.map_err(|e| malformed(&e))?,
)
.reason(
reason
.map(|r| decision::PayloadReason::try_from(r.to_string()))
.transpose()
.map_err(|e| malformed(&e))?,
)
.try_into()
.map_err(|e: decision::error::ConversionError| malformed(&e))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn digest_of(bytes: [u8; 32]) -> String {
let mut mh = MULTIHASH_SHA2_256_32.to_vec();
mh.extend_from_slice(&bytes);
multibase::encode(multibase::Base::Base58Btc, mh)
}
#[test]
fn match_code_reads_the_digest_bytes_not_the_encoding() {
let mut bytes = [0u8; 32];
bytes[..3].copy_from_slice(&[0xab, 0xcd, 0xef]);
let digest = digest_of(bytes);
assert!(digest.starts_with("zQm"), "{digest}");
assert_eq!(match_code(&digest).unwrap(), "abcdef");
}
#[test]
fn match_code_refuses_a_digest_that_is_not_sha2_256() {
assert!(match_code("not-multibase!").is_err());
let short = multibase::encode(multibase::Base::Base58Btc, [0x12, 0x20, 1, 2, 3]);
assert!(matches!(
match_code(&short),
Err(TaskConsentError::Digest(_))
));
}
#[test]
fn extract_requests_accepts_every_relayed_shape() {
let req = serde_json::json!({ "type": REQUEST_TYPE, "payload": {} });
let other = serde_json::json!({ "type": DECISION_TYPE });
assert_eq!(extract_requests(&req).len(), 1);
assert_eq!(extract_requests(&serde_json::json!([req, other])).len(), 1);
assert_eq!(
extract_requests(&serde_json::json!({ "consentRequests": [req] })).len(),
1
);
assert_eq!(
extract_requests(&serde_json::json!({ "details": { "consentRequests": [req] } })).len(),
1
);
assert!(extract_requests(&other).is_empty());
}
}