ssi/
identity.rs

1// Self-sovereign identity
2//
3// SPDX-License-Identifier: Apache-2.0
4//
5// Written in 2024 by
6//     Dr Maxim Orlovsky <orlovsky@lnp-bp.org>
7//
8// Copyright (C) 2024 LNP/BP Standards Association. All rights reserved.
9//
10// Licensed under the Apache License, Version 2.0 (the "License");
11// you may not use this file except in compliance with the License.
12// You may obtain a copy of the License at
13//
14//     http://www.apache.org/licenses/LICENSE-2.0
15//
16// Unless required by applicable law or agreed to in writing, software
17// distributed under the License is distributed on an "AS IS" BASIS,
18// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19// See the License for the specific language governing permissions and
20// limitations under the License.
21
22use std::collections::BTreeSet;
23use std::fmt::{self, Display, Formatter};
24use std::str::{FromStr, Utf8Error};
25
26use baid64::Baid64ParseError;
27use chrono::{DateTime, Utc};
28use fluent_uri::Uri;
29use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, CONTROLS};
30use sha2::{Digest, Sha256};
31
32use crate::{InvalidSig, SsiPub, SsiSecret, SsiSig};
33
34#[derive(Clone, Eq, PartialEq, Debug, Display, Error, From)]
35#[display(doc_comments)]
36pub enum UidParseError {
37    #[from]
38    /// non-UTF-8 UID - {0}
39    Utf8(Utf8Error),
40    /// UID '{0}' without identity part
41    NoId(String),
42    /// UID '{0}' without identity schema
43    NoSchema(String),
44}
45
46#[derive(Clone, Ord, PartialOrd, Eq, PartialEq, Hash, Debug, Display)]
47#[display("{name} <{schema}:{id}>", alt = "{name} {schema}:{id}")]
48pub struct Uid {
49    pub name: String,
50    pub schema: String,
51    pub id: String,
52}
53
54impl Uid {
55    pub fn from_url_str(s: &str) -> Result<Self, UidParseError> {
56        let s = percent_decode_str(s).decode_utf8()?.replace('+', " ");
57        Self::parse_str(&s)
58    }
59
60    fn parse_str(s: &str) -> Result<Self, UidParseError> {
61        let (name, rest) = s
62            .rsplit_once(' ')
63            .ok_or_else(|| UidParseError::NoId(s.to_string()))?;
64        let (schema, id) = rest
65            .split_once(':')
66            .ok_or_else(|| UidParseError::NoSchema(rest.to_owned()))?;
67        Ok(Self {
68            name: name.to_owned(),
69            schema: schema.to_owned(),
70            id: id.to_owned(),
71        })
72    }
73}
74
75impl FromStr for Uid {
76    type Err = UidParseError;
77
78    fn from_str(s: &str) -> Result<Self, Self::Err> { Self::parse_str(&s.replace(['<', '>'], "")) }
79}
80
81#[derive(Clone, Eq, PartialEq, Hash, Debug)]
82pub struct Ssi {
83    pub pk: SsiPub,
84    pub uids: BTreeSet<Uid>,
85    pub expiry: Option<DateTime<Utc>>,
86    pub sig: SsiSig,
87}
88
89impl Ssi {
90    pub fn new(uids: BTreeSet<Uid>, expiry: Option<DateTime<Utc>>, secret: &SsiSecret) -> Self {
91        let mut me = Self {
92            pk: secret.to_public(),
93            uids,
94            expiry,
95            sig: SsiSig([0u8; 64]),
96        };
97        me.sig = secret.sign(me.to_message());
98        me
99    }
100
101    fn to_message(&self) -> [u8; 32] {
102        let s = self.to_string();
103        let (mut s, _) = s.rsplit_once("sig=").expect("no signature");
104        s = s.trim_end_matches(&['&', '?']);
105        let msg = Sha256::digest(s);
106        Sha256::digest(msg).into()
107    }
108
109    pub fn check_integrity(&self) -> Result<(), InvalidSig> {
110        self.pk.verify(self.to_message(), self.sig)
111    }
112}
113
114#[derive(Debug, Display, Error, From)]
115#[display(doc_comments)]
116pub enum SsiParseError {
117    #[from]
118    #[display(inner)]
119    InvalidUri(fluent_uri::ParseError),
120    /// SSI must be a valid URI containing schema part.
121    NoUriScheme,
122    /// SSI must start with 'ssi:' prefix (URI scheme).
123    InvalidScheme(String),
124    /// the SSI must be signed
125    Unsigned,
126    /// SSI contains invalid attribute '{0}'.
127    InvalidQueryParam(String),
128    /// SSI contains unknown attribute '{0}'.
129    UnknownParam(String),
130    /// SSI contains multiple expiration dates.
131    RepeatedExpiry,
132    /// SSI contains multiple signatures.
133    RepeatedSig,
134
135    #[from]
136    /// SSI contains {0}
137    InvalidUid(UidParseError),
138
139    #[from]
140    /// SSI contains signature not matching the provided data - {0}
141    WrongSig(InvalidSig),
142
143    #[from]
144    /// SSI contains non-parsable expiration date - {0}
145    WrongExpiry(chrono::ParseError),
146
147    /// SSI contains non-parsable public key - {0}
148    InvalidPub(Baid64ParseError),
149    /// SSI contains non-parsable signature - {0}
150    InvalidSig(Baid64ParseError),
151}
152
153impl FromStr for Ssi {
154    type Err = SsiParseError;
155
156    fn from_str(s: &str) -> Result<Self, Self::Err> {
157        let uri = Uri::parse(s)?;
158
159        let scheme = uri.scheme().ok_or(SsiParseError::NoUriScheme)?;
160        if scheme.as_str() != "ssi" {
161            return Err(SsiParseError::InvalidScheme(scheme.to_string()));
162        }
163
164        let pk = uri.path().as_str();
165        let pk = SsiPub::from_str(pk).map_err(SsiParseError::InvalidPub)?;
166
167        let query = uri.query().ok_or(SsiParseError::Unsigned)?.as_str();
168
169        let mut expiry = None;
170        let mut sig = None;
171        let mut uids = bset![];
172        for p in query.split('&') {
173            let (k, v) = p
174                .split_once('=')
175                .ok_or_else(|| SsiParseError::InvalidQueryParam(p.to_owned()))?;
176            match k {
177                "expiry" if expiry.is_none() => {
178                    expiry = Some(DateTime::parse_from_str(v, "%Y-%m-%d")?.to_utc())
179                }
180                "expiry" => return Err(SsiParseError::RepeatedExpiry),
181                "uid" => {
182                    uids.insert(Uid::from_url_str(v)?);
183                }
184                "sig" if sig.is_none() => {
185                    sig = Some(SsiSig::from_str(v).map_err(SsiParseError::InvalidSig)?)
186                }
187                "sig" => return Err(SsiParseError::RepeatedSig),
188                other => return Err(SsiParseError::UnknownParam(other.to_owned())),
189            }
190        }
191
192        let Some(sig) = sig else {
193            return Err(SsiParseError::Unsigned);
194        };
195        let ssi = Self {
196            pk,
197            uids,
198            expiry,
199            sig,
200        };
201        ssi.check_integrity()?;
202
203        Ok(ssi)
204    }
205}
206
207impl Display for Ssi {
208    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
209        const SET: &AsciiSet = &CONTROLS.add(b'?').add(b'&').add(b'+').add(b'=');
210
211        write!(f, "{}?", self.pk)?;
212
213        for uid in &self.uids {
214            let uid = uid.to_string().replace(['<', '>'], "");
215            write!(f, "uid={}&", utf8_percent_encode(&uid, SET).to_string().replace(' ', "+"),)?;
216        }
217
218        if let Some(expiry) = self.expiry {
219            write!(f, "expiry={}&", expiry.format("%Y-%m-%d"))?;
220        }
221
222        write!(f, "sig={}", self.sig)?;
223
224        Ok(())
225    }
226}