siws_recap/
capability.rs

1use crate::RESOURCE_PREFIX;
2use cid::Cid;
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_with::{serde_as, DeserializeAs, SerializeAs};
7
8use iri_string::types::UriString;
9// use siwe::Message;
10use siws::message::SiwsMessage;
11
12use ucan_capabilities_object::{
13    Ability, AbilityNameRef, AbilityNamespaceRef, Capabilities, CapsInner, ConvertError,
14    ConvertResult, NotaBeneCollection,
15};
16
17/// Representation of a set of delegated Capabilities.
18#[serde_as]
19#[derive(Clone, Debug, Serialize, Deserialize)]
20pub struct Capability<NB> {
21    /// The actions that are allowed for the given target within this namespace.
22    #[serde(rename = "att")]
23    attenuations: Capabilities<NB>,
24
25    /// Cids of parent delegations which these capabilities are attenuated from
26    #[serde(rename = "prf")]
27    #[serde_as(as = "Vec<B58Cid>")]
28    proof: Vec<Cid>,
29}
30
31impl<NB> Capability<NB> {
32    /// Create a new empty Capability.
33    pub fn new() -> Self {
34        Self {
35            attenuations: Capabilities::new(),
36            proof: Default::default(),
37        }
38    }
39
40    /// Check if a particular action is allowed for the specified target, or is allowed globally.
41    pub fn can<T, A>(
42        &self,
43        target: T,
44        action: A,
45    ) -> ConvertResult<Option<&NotaBeneCollection<NB>>, UriString, Ability, T, A>
46    where
47        T: TryInto<UriString>,
48        A: TryInto<Ability>,
49    {
50        self.attenuations.can(target, action)
51    }
52
53    /// Check if a particular action is allowed for the specified target, or is allowed globally, without type conversion.
54    pub fn can_do(&self, target: &UriString, action: &Ability) -> Option<&NotaBeneCollection<NB>> {
55        self.attenuations.can_do(target, action)
56    }
57
58    /// Merge this Capabilities set with another
59    pub fn merge<NB1, NB2>(self, other: Capability<NB1>) -> Capability<NB2>
60    where
61        NB2: From<NB> + From<NB1>,
62    {
63        let (caps, mut proofs) = self.into_inner();
64        for proof in &other.proof {
65            if proofs.contains(proof) {
66                continue;
67            }
68            proofs.push(*proof);
69        }
70
71        Capability {
72            attenuations: caps.merge(other.attenuations),
73            proof: proofs,
74        }
75    }
76
77    /// Add an allowed action for the given target, with a set of note-benes
78    pub fn with_action(
79        &mut self,
80        target: UriString,
81        action: Ability,
82        nb: impl IntoIterator<Item = BTreeMap<String, NB>>,
83    ) -> &mut Self {
84        self.attenuations.with_action(target, action, nb);
85        self
86    }
87
88    /// Add an allowed action for the given target, with a set of note-benes.
89    ///
90    /// This method automatically converts the provided args into the correct types for convenience.
91    pub fn with_action_convert<T, A>(
92        &mut self,
93        target: T,
94        action: A,
95        nb: impl IntoIterator<Item = BTreeMap<String, NB>>,
96    ) -> Result<&mut Self, ConvertError<T::Error, A::Error>>
97    where
98        T: TryInto<UriString>,
99        A: TryInto<Ability>,
100    {
101        self.attenuations.with_action_convert(target, action, nb)?;
102        Ok(self)
103    }
104
105    /// Add a set of allowed action for the given target, with associated note-benes
106    pub fn with_actions(
107        &mut self,
108        target: UriString,
109        abilities: impl IntoIterator<Item = (Ability, impl IntoIterator<Item = BTreeMap<String, NB>>)>,
110    ) -> &mut Self {
111        self.attenuations.with_actions(target, abilities);
112        self
113    }
114
115    /// Add a set of allowed action for the given target, with associated note-benes.
116    ///
117    /// This method automatically converts the provided args into the correct types for convenience.
118    pub fn with_actions_convert<T, A, N>(
119        &mut self,
120        target: T,
121        abilities: impl IntoIterator<Item = (A, N)>,
122    ) -> Result<&mut Self, ConvertError<T::Error, A::Error>>
123    where
124        T: TryInto<UriString>,
125        A: TryInto<Ability>,
126        N: IntoIterator<Item = BTreeMap<String, NB>>,
127    {
128        self.attenuations.with_actions_convert(target, abilities)?;
129        Ok(self)
130    }
131
132    /// Read the set of abilities granted in this capabilities set
133    pub fn abilities(&self) -> &CapsInner<NB> {
134        self.attenuations.abilities()
135    }
136
137    /// Read the set of abilities granted for a given target in this capabilities set
138    pub fn abilities_for<T>(
139        &self,
140        target: T,
141    ) -> Result<Option<&BTreeMap<Ability, NotaBeneCollection<NB>>>, T::Error>
142    where
143        T: TryInto<UriString>,
144    {
145        self.attenuations.abilities_for(target)
146    }
147
148    /// Read the set of proofs which support the granted capabilities
149    pub fn proof(&self) -> &[Cid] {
150        &self.proof
151    }
152
153    /// Add a supporting proof CID
154    pub fn with_proof(mut self, proof: &Cid) -> Self {
155        if self.proof.contains(proof) {
156            return self;
157        }
158        self.proof.push(*proof);
159        self
160    }
161
162    /// Add a set of supporting proofs
163    pub fn with_proofs<'l>(mut self, proofs: impl IntoIterator<Item = &'l Cid>) -> Self {
164        for proof in proofs {
165            if self.proof.contains(proof) {
166                continue;
167            }
168            self.proof.push(*proof);
169        }
170        self
171    }
172
173    fn to_line_groups(
174        &self,
175    ) -> impl Iterator<Item = (&UriString, AbilityNamespaceRef, Vec<AbilityNameRef>)> {
176        self.attenuations
177            .abilities()
178            .iter()
179            .flat_map(|(resource, abilities)| {
180                // group abilities by namespace
181                abilities
182                    .iter()
183                    .fold(
184                        BTreeMap::<AbilityNamespaceRef, Vec<AbilityNameRef>>::new(),
185                        |mut map, (ability, _)| {
186                            map.entry(ability.namespace())
187                                .or_default()
188                                .push(ability.name());
189                            map
190                        },
191                    )
192                    .into_iter()
193                    .map(move |(namespace, names)| (resource, namespace, names))
194            })
195    }
196
197    fn to_statement_lines(&self) -> impl Iterator<Item = String> + '_ {
198        self.to_line_groups().map(|(resource, namespace, names)| {
199            format!(
200                "'{}': {} for '{}'.",
201                namespace,
202                names
203                    .iter()
204                    .map(|an| format!("'{an}'"))
205                    .collect::<Vec<String>>()
206                    .join(", "),
207                resource
208            )
209        })
210    }
211
212    pub fn into_inner(self) -> (Capabilities<NB>, Vec<Cid>) {
213        (self.attenuations, self.proof)
214    }
215    /// Generate a ReCap statement from capabilities and URI (delegee).
216    pub fn to_statement(&self) -> String {
217        [
218            "I further authorize the stated URI to perform the following actions on my behalf:"
219                .to_string(),
220            self.to_statement_lines()
221                .enumerate()
222                .map(|(n, line)| format!(" ({}) {line}", n + 1))
223                .collect(),
224        ]
225        .concat()
226    }
227}
228
229impl<NB> Capability<NB>
230where
231    NB: Serialize,
232{
233    fn encode(&self) -> Result<String, EncodingError> {
234        serde_jcs::to_vec(self)
235            .map_err(EncodingError::Ser)
236            .map(|bytes| base64::encode_config(bytes, base64::URL_SAFE_NO_PAD))
237    }
238
239    /// Apply this capabilities set to a SIWE message by writing to it's statement and resource list
240    pub fn build_message(&self, mut message: SiwsMessage) -> Result<SiwsMessage, EncodingError> {
241        if self.attenuations.abilities().is_empty() {
242            return Ok(message);
243        }
244        let statement = self.to_statement();
245        let encoded: UriString = self.try_into()?;
246        message.resources.push(encoded);
247        let m = message.statement.unwrap_or_default();
248        message.statement = Some(if m.is_empty() {
249            statement
250        } else {
251            format!("{m} {statement}")
252        });
253        Ok(message)
254    }
255}
256
257impl<NB> Capability<NB>
258where
259    NB: for<'a> Deserialize<'a>,
260{
261    /// Extract the encoded capabilities from a SIWE message and ensures the correctness of the statement.
262    pub fn extract_and_verify(message: &SiwsMessage) -> Result<Option<Self>, VerificationError> {
263        if let Some(c) = Self::extract(message)? {
264            let expected = c.to_statement();
265            match &message.statement {
266                Some(s) if s.ends_with(&expected) => Ok(Some(c)),
267                _ => Err(VerificationError::IncorrectStatement(expected)),
268            }
269        } else {
270            // no caps
271            Ok(None)
272        }
273    }
274
275    fn extract(message: &SiwsMessage) -> Result<Option<Self>, DecodingError> {
276        message
277            .resources
278            .iter()
279            .last()
280            .filter(|u| u.as_str().starts_with(RESOURCE_PREFIX))
281            .map(Self::try_from)
282            .transpose()
283    }
284
285    fn decode(encoded: &str) -> Result<Self, DecodingError> {
286        base64::decode_config(encoded, base64::URL_SAFE_NO_PAD)
287            .map_err(DecodingError::Base64Decode)
288            .and_then(|bytes| serde_json::from_slice(&bytes).map_err(DecodingError::De))
289    }
290}
291
292impl<NB> Default for Capability<NB> {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298impl<NB> TryFrom<&UriString> for Capability<NB>
299where
300    NB: for<'a> Deserialize<'a>,
301{
302    type Error = DecodingError;
303    fn try_from(uri: &UriString) -> Result<Self, Self::Error> {
304        uri.as_str()
305            .strip_prefix(RESOURCE_PREFIX)
306            .ok_or_else(|| DecodingError::InvalidResourcePrefix(uri.to_string()))
307            .and_then(Capability::decode)
308    }
309}
310
311impl<NB> TryFrom<&Capability<NB>> for UriString
312where
313    NB: Serialize,
314{
315    type Error = EncodingError;
316    fn try_from(cap: &Capability<NB>) -> Result<Self, Self::Error> {
317        cap.encode()
318            .map(|encoded| format!("{RESOURCE_PREFIX}{encoded}"))
319            .and_then(|s| s.parse().map_err(EncodingError::UriParse))
320    }
321}
322
323#[derive(thiserror::Error, Debug)]
324pub enum DecodingError {
325    #[error(
326        "invalid resource prefix (expected prefix: {}, found: {0})",
327        RESOURCE_PREFIX
328    )]
329    InvalidResourcePrefix(String),
330    #[error("failed to decode base64 capability resource: {0}")]
331    Base64Decode(#[from] base64::DecodeError),
332    #[error("failed to deserialize capability from json: {0}")]
333    De(#[from] serde_json::Error),
334}
335
336#[derive(thiserror::Error, Debug)]
337pub enum EncodingError {
338    #[error("unable to parse capability as a URI: {0}")]
339    UriParse(#[from] iri_string::validate::Error),
340    #[error("failed to serialize capability to json: {0}")]
341    Ser(#[from] serde_json::Error),
342}
343
344#[derive(thiserror::Error, Debug)]
345pub enum VerificationError {
346    #[error("error decoding capabilities: {0}")]
347    Decoding(#[from] DecodingError),
348    #[error("incorrect statement in siwe message, expected to end with: {0}")]
349    IncorrectStatement(String),
350}
351
352struct B58Cid;
353
354impl SerializeAs<Cid> for B58Cid {
355    fn serialize_as<S>(source: &Cid, serializer: S) -> Result<S::Ok, S::Error>
356    where
357        S: serde::Serializer,
358    {
359        serializer.serialize_str(
360            &source
361                .to_string_of_base(cid::multibase::Base::Base58Btc)
362                .map_err(serde::ser::Error::custom)?,
363        )
364    }
365}
366
367impl<'de> DeserializeAs<'de, Cid> for B58Cid {
368    fn deserialize_as<D>(deserializer: D) -> Result<Cid, D::Error>
369    where
370        D: serde::Deserializer<'de>,
371    {
372        use std::str::FromStr;
373        let s = String::deserialize(deserializer)?;
374        if !s.starts_with('z') {
375            return Err(serde::de::Error::custom("non-base58btc encoded Cid"));
376        };
377        Cid::from_str(&s).map_err(serde::de::Error::custom)
378    }
379}
380
381#[cfg(test)]
382mod test {
383    use super::*;
384
385    const JSON_CAP: &str = include_str!("../tests/serialized_cap.json");
386
387    #[test]
388    fn deser() {
389        let cap: Capability<serde_json::Value> = serde_json::from_str(JSON_CAP).unwrap();
390        let reser = serde_jcs::to_string(&cap).unwrap();
391        assert_eq!(JSON_CAP.trim(), reser);
392    }
393}