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