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