Skip to main content

identity_kit/jose/
compact.rs

1//! Compact JWS serialization (RFC 7515 §7.1): decoding without verification, and encoding with
2//! a [`Signer`].
3
4use core::fmt;
5
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use super::{Jwk, JwsAlgorithm, JwsError, JwsVerifier, SignError, VerificationError, base64url};
11
12/// The JOSE header of a JWS (RFC 7515 §4).
13///
14/// The registered parameters this crate acts on are fields; everything else, `x5c` and `b64`
15/// included, lives in `additional` in insertion order. `jwk` is the public key the signer claims
16/// to have used; trusting it is the verifier's decision, never the crate's.
17///
18/// # Examples
19///
20/// ```
21/// use identity_kit::jose::{JwsAlgorithm, JwsHeader};
22///
23/// let mut header = JwsHeader::new(JwsAlgorithm::ES256).with_kid("did:example:123#key-1");
24/// header.typ = Some("JWT".to_owned());
25/// assert_eq!(
26///     serde_json::to_string(&header)?,
27///     r#"{"alg":"ES256","typ":"JWT","kid":"did:example:123#key-1"}"#
28/// );
29/// # Ok::<(), serde_json::Error>(())
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[non_exhaustive]
33pub struct JwsHeader {
34    /// The signature algorithm (`alg`).
35    pub alg: JwsAlgorithm,
36    /// The media type of the whole JWS (`typ`), such as `JWT` or `vc+jwt`.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub typ: Option<String>,
39    /// The media type of the payload (`cty`).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub cty: Option<String>,
42    /// The key id (`kid`); for a DID controller, usually a DID URL with a fragment.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub kid: Option<String>,
45    /// The public key the signature was made with (`jwk`).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub jwk: Option<Jwk>,
48    /// Extensions a recipient must understand (`crit`).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub crit: Option<Vec<String>>,
51    /// Every other header parameter, in insertion order.
52    #[serde(flatten)]
53    pub additional: IndexMap<String, Value>,
54}
55
56impl JwsHeader {
57    /// A header with only `alg` set.
58    pub fn new(alg: JwsAlgorithm) -> Self {
59        Self {
60            alg,
61            typ: None,
62            cty: None,
63            kid: None,
64            jwk: None,
65            crit: None,
66            additional: IndexMap::new(),
67        }
68    }
69
70    /// This header with its `kid` set.
71    #[must_use]
72    pub fn with_kid(mut self, kid: impl Into<String>) -> Self {
73        self.kid = Some(kid.into());
74        self
75    }
76
77    /// This header with its `typ` set.
78    #[must_use]
79    pub fn with_typ(mut self, typ: impl Into<String>) -> Self {
80        self.typ = Some(typ.into());
81        self
82    }
83}
84
85/// A decoded compact JWS: `BASE64URL(header) || '.' || BASE64URL(payload) || '.' ||
86/// BASE64URL(signature)`.
87///
88/// Decoding parses the header and the parts and nothing more: [`CompactJws::verify`] (or any
89/// [`JwsVerifier`]) checks the signature once the caller has resolved the key, which usually
90/// means dereferencing [`kid`](Self::kid) through a DID resolver.
91///
92/// # Examples
93///
94/// ```
95/// use identity_kit::jose::{CompactJws, JwsAlgorithm};
96///
97/// // RFC 8037 Appendix A.4.
98/// let token = "eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc.\
99///              hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg";
100/// let jws = CompactJws::decode(token)?;
101/// assert_eq!(jws.alg(), JwsAlgorithm::EdDSA);
102/// assert_eq!(jws.kid(), None);
103/// assert_eq!(jws.payload(), b"Example of Ed25519 signing");
104/// assert_eq!(
105///     jws.signing_input(),
106///     b"eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc"
107/// );
108/// assert_eq!(jws.signature().len(), 64);
109/// # Ok::<(), identity_kit::jose::JwsError>(())
110/// ```
111#[derive(Debug, Clone)]
112pub struct CompactJws {
113    token: String,
114    /// Byte offset of the `.` between payload and signature; the signing input ends there.
115    signing_input_end: usize,
116    header: JwsHeader,
117    payload: Vec<u8>,
118    signature: Vec<u8>,
119}
120
121impl CompactJws {
122    /// Parses a compact JWS without verifying it.
123    ///
124    /// # Errors
125    ///
126    /// A [`JwsError`] when the input is not three base64url parts, the header is not a JSON
127    /// object with a supported `alg`, or the header has `crit` or `b64: false`.
128    pub fn decode(token: &str) -> Result<Self, JwsError> {
129        let mut parts = token.split('.');
130        let (Some(header_b64), Some(payload_b64), Some(signature_b64), None) =
131            (parts.next(), parts.next(), parts.next(), parts.next())
132        else {
133            return Err(JwsError::InvalidFormat {
134                parts: token.split('.').count(),
135            });
136        };
137        let header_json = base64url::decode(header_b64)
138            .map_err(|_| JwsError::InvalidBase64Url { part: "header" })?;
139        let header = parse_header(&header_json)?;
140        let payload = base64url::decode(payload_b64)
141            .map_err(|_| JwsError::InvalidBase64Url { part: "payload" })?;
142        let signature = base64url::decode(signature_b64)
143            .map_err(|_| JwsError::InvalidBase64Url { part: "signature" })?;
144        Ok(Self {
145            token: token.to_owned(),
146            signing_input_end: header_b64.len() + 1 + payload_b64.len(),
147            header,
148            payload,
149            signature,
150        })
151    }
152
153    /// The protected header.
154    pub fn header(&self) -> &JwsHeader {
155        &self.header
156    }
157
158    /// The header `alg`.
159    pub fn alg(&self) -> JwsAlgorithm {
160        self.header.alg
161    }
162
163    /// The header `kid`, if any.
164    pub fn kid(&self) -> Option<&str> {
165        self.header.kid.as_deref()
166    }
167
168    /// The JWS signing input: `BASE64URL(header) || '.' || BASE64URL(payload)` as ASCII.
169    pub fn signing_input(&self) -> &[u8] {
170        &self.token.as_bytes()[..self.signing_input_end]
171    }
172
173    /// The decoded payload.
174    pub fn payload(&self) -> &[u8] {
175        &self.payload
176    }
177
178    /// The decoded signature.
179    pub fn signature(&self) -> &[u8] {
180        &self.signature
181    }
182
183    /// The compact serialization as it was decoded.
184    pub fn as_str(&self) -> &str {
185        &self.token
186    }
187
188    /// Verifies the signature with `verifier` under `key` and the header `alg`.
189    ///
190    /// # Errors
191    ///
192    /// Whatever the verifier reports; see [`JwsVerifier::verify`].
193    pub fn verify<V: JwsVerifier + ?Sized>(
194        &self,
195        verifier: &V,
196        key: &Jwk,
197    ) -> Result<(), VerificationError> {
198        verifier.verify(self.alg(), self.signing_input(), &self.signature, key)
199    }
200}
201
202impl fmt::Display for CompactJws {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        f.write_str(&self.token)
205    }
206}
207
208impl AsRef<str> for CompactJws {
209    fn as_ref(&self) -> &str {
210        &self.token
211    }
212}
213
214/// Parses the header JSON, naming an unsupported `alg` and rejecting `crit` and `b64: false`.
215fn parse_header(json: &[u8]) -> Result<JwsHeader, JwsError> {
216    let value: Value =
217        serde_json::from_slice(json).map_err(|e| JwsError::InvalidHeader(e.to_string()))?;
218    let Some(object) = value.as_object() else {
219        return Err(JwsError::InvalidHeader("not a JSON object".to_owned()));
220    };
221    match object.get("alg") {
222        Some(Value::String(alg)) if alg.parse::<JwsAlgorithm>().is_ok() => {}
223        Some(Value::String(alg)) => return Err(JwsError::UnsupportedAlgorithm(alg.clone())),
224        Some(other) => return Err(JwsError::UnsupportedAlgorithm(other.to_string())),
225        None => return Err(JwsError::InvalidHeader("missing `alg`".to_owned())),
226    }
227    if object
228        .get("b64")
229        .is_some_and(|b64| b64 != &Value::Bool(true))
230    {
231        return Err(JwsError::UnencodedPayload);
232    }
233    let header: JwsHeader =
234        serde_json::from_value(value).map_err(|e| JwsError::InvalidHeader(e.to_string()))?;
235    if let Some(crit) = &header.crit {
236        return Err(JwsError::UnsupportedCritical(crit.clone()));
237    }
238    Ok(header)
239}
240
241/// Produces JWS signatures.
242///
243/// A signer binds one key and one algorithm. Private keys live behind it, in a key store, an HSM
244/// or a cloud service, which is why signing is `async`. The signature is returned in the JWS form
245/// of the algorithm: raw `r || s` for ECDSA, never DER.
246#[async_trait::async_trait]
247pub trait Signer: Send + Sync {
248    /// The algorithm every signature of this signer uses.
249    fn alg(&self) -> JwsAlgorithm;
250
251    /// The key id to put in a header, if the signer has one.
252    fn kid(&self) -> Option<&str>;
253
254    /// Signs `signing_input`.
255    ///
256    /// # Errors
257    ///
258    /// A [`SignError`] wrapping whatever the backend reports.
259    async fn sign(&self, signing_input: &[u8]) -> Result<Vec<u8>, SignError>;
260}
261
262/// Encodes `payload` under `header` as a compact JWS signed by `signer`.
263///
264/// The header is serialized as given: `alg` must equal the signer's algorithm, and `kid` is
265/// whatever the caller set (a DID URL, usually), not necessarily [`Signer::kid`].
266///
267/// # Errors
268///
269/// [`JwsError::AlgorithmMismatch`] when `header.alg` differs from the signer's algorithm, and
270/// [`JwsError::Signer`] when signing fails.
271///
272/// # Examples
273///
274/// ```
275/// use identity_kit::jose::{CompactJws, JwsAlgorithm, JwsHeader, SignError, Signer, encode_compact};
276///
277/// struct Rejecting;
278///
279/// #[async_trait::async_trait]
280/// impl Signer for Rejecting {
281///     fn alg(&self) -> JwsAlgorithm { JwsAlgorithm::ES256 }
282///     fn kid(&self) -> Option<&str> { None }
283///     async fn sign(&self, _: &[u8]) -> Result<Vec<u8>, SignError> {
284///         Err(SignError::msg("key store is offline"))
285///     }
286/// }
287///
288/// # tokio::runtime::Builder::new_current_thread().build()?.block_on(async {
289/// let header = JwsHeader::new(JwsAlgorithm::ES256);
290/// let err = encode_compact(&header, b"{}", &Rejecting).await.unwrap_err();
291/// assert_eq!(err.to_string(), "signing failed: key store is offline");
292/// # Ok::<(), Box<dyn std::error::Error>>(())
293/// # })?;
294/// # Ok::<(), Box<dyn std::error::Error>>(())
295/// ```
296pub async fn encode_compact(
297    header: &JwsHeader,
298    payload: &[u8],
299    signer: &dyn Signer,
300) -> Result<String, JwsError> {
301    if header.alg != signer.alg() {
302        return Err(JwsError::AlgorithmMismatch {
303            header: header.alg,
304            signer: signer.alg(),
305        });
306    }
307    // Serializing a header cannot fail: every field is a string, a list, a `Jwk` or a `Value`.
308    let header_json =
309        serde_json::to_vec(header).map_err(|e| JwsError::InvalidHeader(e.to_string()))?;
310    let mut token = base64url::encode(&header_json);
311    token.push('.');
312    token.push_str(&base64url::encode(payload));
313    let signature = signer
314        .sign(token.as_bytes())
315        .await
316        .map_err(JwsError::Signer)?;
317    token.push('.');
318    token.push_str(&base64url::encode(&signature));
319    Ok(token)
320}