Skip to content

Commit

Permalink
feat: check cacao resources against stream information (#502)
Browse files Browse the repository at this point in the history
* feat: add verifier method to check cacao access properties

* chore: write a couple cacao access tests

* fix: use transparent tuple struct to hide cacao time representation

* refactor: use TryFrom for better CapabilityTime ergonomics

* refactor: adjust verify cacao resources parameters

matches better with js-ceramic, only requires knowing your own stream which should be required and fits better with potential changes to use dimensions
  • Loading branch information
dav1do authored Aug 26, 2024
1 parent 681e666 commit cc263d4
Show file tree
Hide file tree
Showing 2 changed files with 167 additions and 36 deletions.
79 changes: 49 additions & 30 deletions event/src/unvalidated/signed/cacao.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,43 @@ pub struct Header {
pub r#type: HeaderType,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(transparent)]
/// A wrapper around the a time value to hide the internal representation (which is currently a string).
/// Use `From<chrono::DateTime<chrono::Utc>>` to construct if needed.
pub struct CapabilityTime(String);

impl From<chrono::DateTime<chrono::Utc>> for CapabilityTime {
fn from(time: chrono::DateTime<chrono::Utc>) -> Self {
Self(time.to_rfc3339_opts(chrono::SecondsFormat::AutoSi, true))
}
}

impl TryFrom<&CapabilityTime> for chrono::DateTime<chrono::Utc> {
type Error = anyhow::Error;

fn try_from(input: &CapabilityTime) -> Result<Self, Self::Error> {
if let Ok(val) =
chrono::DateTime::parse_from_rfc3339(&input.0).map(|dt| dt.with_timezone(&chrono::Utc))
{
Ok(val)
} else if let Ok(unix_timestamp) = input.0.parse::<i64>() {
let naive = chrono::DateTime::from_timestamp(unix_timestamp, 0)
.ok_or_else(|| anyhow::anyhow!("failed to parse as unix timestamp: {}", input.0))?;
Ok(naive)
} else {
anyhow::bail!(format!("failed to parse timestamp: {}", input.0))
}
}
}

impl CapabilityTime {
/// Returns a string representation of the time
pub fn as_str(&self) -> &str {
&self.0
}
}

/// Payload for a CACAO
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Payload {
Expand All @@ -52,14 +89,14 @@ pub struct Payload {
/// value we receive without modifying precision and changning the CID.
/// The new CAIP proposes using ints but most of our cacaos still use ISO 8601 values.
#[serde(rename = "exp", skip_serializing_if = "Option::is_none")]
pub expiration: Option<String>,
pub expiration: Option<CapabilityTime>,

/// Issued at time.
/// Not using a chrono::DateTime because we need to round trip the exact
/// value we receive without modifying precision and changning the CID.
/// The new CAIP proposes using ints but most of our cacaos still use ISO 8601 values.
#[serde(rename = "iat")]
pub issued_at: String,
pub issued_at: CapabilityTime,

/// Issuer for payload. For capability will be DID in URI format
#[serde(rename = "iss")]
Expand All @@ -70,7 +107,7 @@ pub struct Payload {
/// value we receive without modifying precision and changning the CID.
/// The new CAIP proposes using ints but most of our cacaos still use ISO 8601 values.
#[serde(rename = "nbf", skip_serializing_if = "Option::is_none")]
pub not_before: Option<String>,
pub not_before: Option<CapabilityTime>,

/// Nonce of payload
pub nonce: String,
Expand All @@ -94,45 +131,27 @@ pub struct Payload {
impl Payload {
/// Parse the iat field as a chrono DateTime
pub fn issued_at(&self) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
let ts = Self::parse_timestamp(&self.issued_at)
.map_err(|e| anyhow::anyhow!("invalid issued_at format: {}", e))?;
Ok(ts.to_utc())
(&self.issued_at)
.try_into()
.map_err(|e| anyhow::anyhow!("invalid issued_at format: {}", e))
}

/// Parse the nbf field as a chrono DateTime
pub fn not_before(&self) -> anyhow::Result<Option<chrono::DateTime<chrono::Utc>>> {
let ts = self
.not_before
self.not_before
.as_ref()
.map(|nbf| Self::parse_timestamp(nbf))
.map(|nbf| nbf.try_into())
.transpose()
.map_err(|e| anyhow::anyhow!("invalid not_before format: {}", e))?;
Ok(ts.map(|ts| ts.to_utc()))
.map_err(|e| anyhow::anyhow!("invalid not_before format: {}", e))
}

/// Parse the exp field as a chrono DateTime
pub fn expiration(&self) -> anyhow::Result<Option<chrono::DateTime<chrono::Utc>>> {
let ts = self
.expiration
self.expiration
.as_ref()
.map(|exp| Self::parse_timestamp(exp))
.map(|exp| exp.try_into())
.transpose()
.map_err(|e| anyhow::anyhow!("invalid expiration format: {}", e))?;
Ok(ts.map(|ts| ts.to_utc()))
}

fn parse_timestamp(input: &str) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
if let Ok(val) =
chrono::DateTime::parse_from_rfc3339(input).map(|dt| dt.with_timezone(&chrono::Utc))
{
Ok(val)
} else if let Ok(unix_timestamp) = input.parse::<i64>() {
let naive = chrono::DateTime::from_timestamp(unix_timestamp, 0)
.ok_or_else(|| anyhow::anyhow!("failed to parse unix timestamp"))?;
Ok(naive)
} else {
anyhow::bail!(format!("failed to parse timestamp: {input}"))
}
.map_err(|e| anyhow::anyhow!("invalid expiration format: {}", e))
}
}

Expand Down
124 changes: 118 additions & 6 deletions validation/src/verifier/cacao_verifier.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use anyhow::{bail, Context, Result};
use anyhow::{anyhow, bail, Context, Result};
use base64::Engine as _;
use ceramic_core::Jwk;
use ceramic_core::{Cid, Jwk, StreamId};
use ceramic_event::unvalidated::signed::cacao::{
Capability, HeaderType, SignatureType, SortedMetadata,
};
Expand All @@ -19,6 +19,13 @@ pub trait Verifier {
async fn verify_signature(&self, opts: &VerifyCacaoOpts) -> Result<()>;
/// Verify the time checks for the CACAO using the `VerifyOpts`
fn verify_time_checks(&self, opts: &VerifyCacaoOpts) -> Result<()>;
/// Verify this CACAO grants access to the requested resources
fn verify_access(
&self,
stream_id: &StreamId,
payload_cid: Option<Cid>,
model: Option<&StreamId>,
) -> Result<()>;
}

#[async_trait::async_trait]
Expand All @@ -32,14 +39,14 @@ impl Verifier for Capability {
HeaderType::EIP4361 => PkhEthereum::verify(self),
HeaderType::CAIP122 => match self.signature.r#type {
SignatureType::EIP191 => PkhEthereum::verify(self),
SignatureType::EIP1271 => bail!("EIP1271 signature validation is unimplmented"),
SignatureType::EIP1271 => bail!("EIP1271 signature validation is unimplemented"),
SignatureType::SolanaED25519 => PkhSolana::verify(self),
SignatureType::TezosED25519 => bail!("Tezos signature validation is unimplmented"),
SignatureType::TezosED25519 => bail!("Tezos signature validation is unimplemented"),
SignatureType::StacksSECP256K1 => {
bail!("Stacks signature validation is unimplmented")
bail!("Stacks signature validation is unimplemented")
}
SignatureType::WebAuthNP256 => {
bail!("WebAuthN signature validation is unimplmented")
bail!("WebAuthN signature validation is unimplemented")
}
SignatureType::JWS => {
let meta = if let Some(meta) = &self.signature.metadata {
Expand Down Expand Up @@ -101,4 +108,109 @@ impl Verifier for Capability {

Ok(())
}

fn verify_access(
&self,
stream_id: &StreamId,
payload_cid: Option<Cid>,
model: Option<&StreamId>,
) -> Result<()> {
let resources = self
.payload
.resources
.as_ref()
.ok_or_else(|| anyhow!("capability is missing resources"))?;

if resources.contains(&"ceramic://*".to_owned())
|| resources.contains(&format!("ceramic://{stream_id}"))
|| payload_cid.map_or(false, |payload_cid| {
resources.contains(&format!("ceramic://{stream_id}?payload={payload_cid}"))
})
|| model.map_or(false, |model| {
resources.contains(&format!("ceramic://*?model=${model}"))
})
{
Ok(())
} else {
bail!("capability does not have appropriate permissions to update this stream");
}
}
}

#[cfg(test)]
mod test {
use std::str::FromStr;

use test_log::test;

use super::*;

const CACAO_STAR_RESOURCES: &str = r#"{"h":{"t":"eip4361"},"p":{"aud":"did:key:z6Mkr4a3Z3FFaJF8YqWnWnVHqd5eDjZN9bDST6wVZC1hJ81P","domain":"test","exp":"2024-06-19T20:04:42.464Z","iat":"2024-06-12T20:04:42.464Z","iss":"did:pkh:eip155:1:0x3794d4f077c08d925ff8ff820006b7353299b200","nonce":"wPiCOcpkll","resources":["ceramic://*"],"statement":"Give this application access to some of your data on Ceramic","version":"1"},"s":{"s":"0xb266999263446ddb9bf588825e9ac08b545e655f6077e8d8579a8d6639c1167c56f7dae7ac70f7faed8c141af9e124a7eb4f77423a572b36144ada8ef2206cda1c","t":"eip191"}}"#;
const CACAO_STREAM_RESOURCES: &str = r#"{"h":{"t":"eip4361"},"p":{"aud":"did:key:z6Mkr4a3Z3FFaJF8YqWnWnVHqd5eDjZN9bDST6wVZC1hJ81P","domain":"test","exp":"2024-06-19T20:04:42.464Z","iat":"2024-06-12T20:04:42.464Z","iss":"did:pkh:eip155:1:0x3794d4f077c08d925ff8ff820006b7353299b200","nonce":"wPiCOcpkll","resources":["ceramic://k2t6wz4ylx0qs435j9oi1s6469uekyk6qkxfcb21ikm5ag2g1cook14ole90aw"],"statement":"Give this application access to some of your data on Ceramic","version":"1"},"s":{"s":"0xb266999263446ddb9bf588825e9ac08b545e655f6077e8d8579a8d6639c1167c56f7dae7ac70f7faed8c141af9e124a7eb4f77423a572b36144ada8ef2206cda1c","t":"eip191"}}"#;
const CACAO_NO_RESOURCES: &str = r#"{"h":{"t":"eip4361"},"p":{"aud":"did:key:z6Mkr4a3Z3FFaJF8YqWnWnVHqd5eDjZN9bDST6wVZC1hJ81P","domain":"test","exp":"2024-06-19T20:04:42.464Z","iat":"2024-06-12T20:04:42.464Z","iss":"did:pkh:eip155:1:0x3794d4f077c08d925ff8ff820006b7353299b200","nonce":"wPiCOcpkll","resources":[],"statement":"Give this application access to some of your data on Ceramic","version":"1"},"s":{"s":"0xb266999263446ddb9bf588825e9ac08b545e655f6077e8d8579a8d6639c1167c56f7dae7ac70f7faed8c141af9e124a7eb4f77423a572b36144ada8ef2206cda1c","t":"eip191"}}"#;

#[test]
fn valid_cacao_star_resources() {
let model =
StreamId::from_str("kjzl6hvfrbw6c90uwoyz8j519gxma787qbsfjtrarkr1huq1g1s224k7hopvsyg") // cspell:disable-line
.unwrap();
let stream =
StreamId::from_str("k2t6wz4ylx0qs435j9oi1s6469uekyk6qkxfcb21ikm5ag2g1cook14ole90aw") // cspell:disable-line
.unwrap();
let cid =
Cid::from_str("baejbeicqtpe5si4qvbffs2s7vtbk5ccbsfg6owmpidfj3zeluqz4hlnz6m").unwrap(); // cspell:disable-line
let cacao = serde_json::from_str::<Capability>(CACAO_STAR_RESOURCES).unwrap();
cacao
.verify_access(&stream, Some(cid), Some(&model))
.unwrap();
cacao.verify_access(&model, None, None).unwrap(); // wrong stream okay with *
}

#[test]
fn valid_cacao_stream_resources() {
let model =
StreamId::from_str("kjzl6hvfrbw6c90uwoyz8j519gxma787qbsfjtrarkr1huq1g1s224k7hopvsyg") // cspell:disable-line
.unwrap();
let stream =
StreamId::from_str("k2t6wz4ylx0qs435j9oi1s6469uekyk6qkxfcb21ikm5ag2g1cook14ole90aw") // cspell:disable-line
.unwrap();
let cid =
Cid::from_str("baejbeicqtpe5si4qvbffs2s7vtbk5ccbsfg6owmpidfj3zeluqz4hlnz6m").unwrap(); // cspell:disable-line
let cacao = serde_json::from_str::<Capability>(CACAO_STREAM_RESOURCES).unwrap();
cacao
.verify_access(&stream, Some(cid), Some(&model))
.unwrap();
}

#[test]
fn invalid_cacao_resources() {
let model =
StreamId::from_str("kjzl6hvfrbw6c90uwoyz8j519gxma787qbsfjtrarkr1huq1g1s224k7hopvsyg") // cspell:disable-line
.unwrap();
let cid =
Cid::from_str("baejbeicqtpe5si4qvbffs2s7vtbk5ccbsfg6owmpidfj3zeluqz4hlnz6m").unwrap(); // cspell:disable-line
let cacao = serde_json::from_str::<Capability>(CACAO_STREAM_RESOURCES).unwrap();
if cacao.verify_access(&model, Some(cid), Some(&model)).is_ok() {
panic!("should not have had access to stream")
}
}

#[test]
fn cacao_without_resources() {
let model =
StreamId::from_str("kjzl6hvfrbw6c90uwoyz8j519gxma787qbsfjtrarkr1huq1g1s224k7hopvsyg") // cspell:disable-line
.unwrap();
let stream =
StreamId::from_str("k2t6wz4ylx0qs435j9oi1s6469uekyk6qkxfcb21ikm5ag2g1cook14ole90aw") // cspell:disable-line
.unwrap();
let cid =
Cid::from_str("baejbeicqtpe5si4qvbffs2s7vtbk5ccbsfg6owmpidfj3zeluqz4hlnz6m").unwrap(); // cspell:disable-line
let cacao = serde_json::from_str::<Capability>(CACAO_NO_RESOURCES).unwrap();
if cacao
.verify_access(&stream, Some(cid), Some(&model))
.is_ok()
{
panic!("should not have had access to stream")
}
}
}

0 comments on commit cc263d4

Please sign in to comment.