Skip to main content

confium_signatif/
jws.rs

1//! The JWS format profile (SIGNATIF §8, Annex E): JSON Web Signature,
2//! compact serialization, detached content (RFC 7515).
3//!
4//! The reference stack's envelope: the payload is external (the JCS
5//! canonical bytes the co-signatures attest), the signing input is
6//! `ASCII(BASE64URL(header)) "." BASE64URL(payLoad)` with the payload
7//! carried out of band, per RFC 7515 detached-content signing.
8//! Supported algorithms: `EdDSA` (Ed25519) and `ES256` (ECDSA P-256) —
9//! matched to the classical algorithms in the registry.
10
11use crate::error::{SignatifError, SignatifResult};
12
13/// JWS algorithm identifiers.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum JwsAlg {
16    /// Ed25519 (`EdDSA`).
17    Ed25519,
18    /// ECDSA P-256 (`ES256`).
19    Es256,
20}
21
22impl JwsAlg {
23    /// The `alg` header value.
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            JwsAlg::Ed25519 => "EdDSA",
27            JwsAlg::Es256 => "ES256",
28        }
29    }
30}
31
32fn b64url_encode(bytes: &[u8]) -> String {
33    use base64ct::Encoding as _;
34    base64ct::Base64UrlUnpadded::encode_string(bytes)
35}
36
37fn b64url_decode(s: &str) -> SignatifResult<Vec<u8>> {
38    use base64ct::Encoding as _;
39    base64ct::Base64UrlUnpadded::decode_vec(s)
40        .map_err(|e| SignatifError::Encoding(format!("base64url decode: {e}")))
41}
42
43/// Produce the detached JWS signing input:
44/// `BASE64URL(UTF8(ASCII(header))) "." BASE64URL(payload)`, where the
45/// payload is the external content (typically the JCS canonical
46/// bytes).
47///
48/// # Errors
49///
50/// Encoding errors from header serialization or base64url.
51pub fn detached_signing_input(
52    alg: JwsAlg,
53    kid: Option<&str>,
54    external_payload: &[u8],
55) -> SignatifResult<String> {
56    let mut header = serde_json::Map::new();
57    header.insert("alg".into(), serde_json::json!(alg.as_str()));
58    if let Some(k) = kid {
59        header.insert("kid".into(), serde_json::json!(k));
60    }
61    header.insert("b64".into(), serde_json::json!(false));
62    header.insert("crit".into(), serde_json::json!(["b64"]));
63    let header_json = crate::jcs::canonicalize(&serde_json::Value::Object(header))?;
64    Ok(format!(
65        "{}.{}",
66        b64url_encode(header_json.as_bytes()),
67        b64url_encode(external_payload)
68    ))
69}
70
71/// Sign the detached input with Ed25519 and return the compact JWS
72/// (header.sig — the payload segment stays with the content).
73///
74/// # Errors
75///
76/// Encoding errors from the signing input.
77pub fn sign_detached_ed25519(
78    signing_key: &ed25519_dalek::SigningKey,
79    kid: Option<&str>,
80    external_payload: &[u8],
81) -> SignatifResult<String> {
82    use ed25519_dalek::Signer as _;
83    let input = detached_signing_input(JwsAlg::Ed25519, kid, external_payload)?;
84    let sig = signing_key.sign(input.as_bytes());
85    let (_, header_b64) = input.split_once('.').expect("two segments");
86    let _ = header_b64;
87    Ok(format!(
88        "{}.{}",
89        input.split('.').next().expect("header"),
90        b64url_encode(&sig.to_bytes())
91    ))
92}
93
94/// Verify a detached compact JWS against its external payload.
95///
96/// `jws` is `header.signature`; the signing input is reconstructed
97/// from the decoded header and the supplied external payload.
98///
99/// # Errors
100///
101/// Signature or format errors.
102pub fn verify_detached_ed25519(
103    jws: &str,
104    external_payload: &[u8],
105    public_key: &[u8],
106) -> SignatifResult<()> {
107    use ed25519_dalek::Signature;
108    use ed25519_dalek::Verifier;
109    let (header_b64, sig_b64) = jws
110        .split_once('.')
111        .ok_or_else(|| SignatifError::ArtifactFormat("JWS must be header.signature".into()))?;
112    let header_bytes = b64url_decode(header_b64)?;
113    let header: serde_json::Value = serde_json::from_slice(&header_bytes)
114        .map_err(|e| SignatifError::ArtifactFormat(format!("JWS header: {e}")))?;
115    let alg = header
116        .get("alg")
117        .and_then(|v| v.as_str())
118        .ok_or_else(|| SignatifError::ArtifactFormat("JWS header lacks alg".into()))?;
119    if alg != JwsAlg::Ed25519.as_str() {
120        return Err(SignatifError::ArtifactFormat(format!(
121            "unsupported JWS alg {alg}"
122        )));
123    }
124    let kid = header.get("kid").and_then(|v| v.as_str());
125    let input = detached_signing_input(JwsAlg::Ed25519, kid, external_payload)?;
126    let sig_bytes = b64url_decode(sig_b64)?;
127    let vk = ed25519_dalek::VerifyingKey::from_bytes(public_key.try_into().map_err(|_| {
128        SignatifError::BadSignature {
129            context: "Ed25519 public key must be 32 bytes".into(),
130        }
131    })?)
132    .map_err(|e| SignatifError::BadSignature {
133        context: format!("Ed25519 pubkey: {e}"),
134    })?;
135    let sig = Signature::from_slice(&sig_bytes).map_err(|_| SignatifError::BadSignature {
136        context: "Ed25519 signature must be 64 bytes".into(),
137    })?;
138    vk.verify(input.as_bytes(), &sig)
139        .map_err(|_| SignatifError::BadSignature {
140            context: "JWS detached signature".into(),
141        })
142}
143
144/// Sign the detached input with ECDSA P-256 (`ES256`). JWS ES256
145/// signatures are the P1363-style fixed-width 64-byte `r || s`
146/// encoding (RFC 7515 §3.4), not DER.
147///
148/// # Errors
149///
150/// Encoding errors from the signing input.
151pub fn sign_detached_es256(
152    signing_key: &p256::ecdsa::SigningKey,
153    kid: Option<&str>,
154    external_payload: &[u8],
155) -> SignatifResult<String> {
156    use p256::ecdsa::signature::Signer as _;
157    let input = detached_signing_input(JwsAlg::Es256, kid, external_payload)?;
158    let sig: p256::ecdsa::Signature = signing_key.sign(input.as_bytes());
159    let header_b64 = input.split('.').next().expect("header segment").to_string();
160    Ok(format!("{}.{}", header_b64, b64url_encode(&sig.to_bytes())))
161}
162
163/// Verify a detached compact ES256 JWS against its external payload.
164///
165/// # Errors
166///
167/// Signature or format errors.
168pub fn verify_detached_es256(
169    jws: &str,
170    external_payload: &[u8],
171    public_key: &p256::ecdsa::VerifyingKey,
172) -> SignatifResult<()> {
173    use p256::ecdsa::signature::Verifier as _;
174    let (header_b64, sig_b64) = jws
175        .split_once('.')
176        .ok_or_else(|| SignatifError::ArtifactFormat("JWS must be header.signature".into()))?;
177    let header_bytes = b64url_decode(header_b64)?;
178    let header: serde_json::Value = serde_json::from_slice(&header_bytes)
179        .map_err(|e| SignatifError::ArtifactFormat(format!("JWS header: {e}")))?;
180    let alg = header
181        .get("alg")
182        .and_then(|v| v.as_str())
183        .ok_or_else(|| SignatifError::ArtifactFormat("JWS header lacks alg".into()))?;
184    if alg != JwsAlg::Es256.as_str() {
185        return Err(SignatifError::ArtifactFormat(format!(
186            "unsupported JWS alg {alg}"
187        )));
188    }
189    let kid = header.get("kid").and_then(|v| v.as_str());
190    let input = detached_signing_input(JwsAlg::Es256, kid, external_payload)?;
191    let sig_bytes = b64url_decode(sig_b64)?;
192    let sig = p256::ecdsa::Signature::from_slice(&sig_bytes).map_err(|_| {
193        SignatifError::BadSignature {
194            context: "ES256 signature must be 64-byte r||s".into(),
195        }
196    })?;
197    public_key
198        .verify(input.as_bytes(), &sig)
199        .map_err(|_| SignatifError::BadSignature {
200            context: "JWS detached ES256 signature".into(),
201        })
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use rand_core::RngCore;
208
209    fn generate_key() -> ed25519_dalek::SigningKey {
210        let mut seed = [0u8; 32];
211        rand_core::OsRng.fill_bytes(&mut seed);
212        ed25519_dalek::SigningKey::from_bytes(&seed)
213    }
214
215    #[test]
216    fn detached_jws_round_trip() {
217        let sk = generate_key();
218        let payload = crate::jcs::canonicalize(&serde_json::json!({"batch":"LOT-1"}))
219            .unwrap()
220            .into_bytes();
221        let jws = sign_detached_ed25519(&sk, Some("end-cert-7"), &payload).unwrap();
222        // Compact: header.signature
223        assert_eq!(jws.split('.').count(), 2);
224        let pk = sk.verifying_key().as_bytes().to_vec();
225        assert!(verify_detached_ed25519(&jws, &payload, &pk).is_ok());
226
227        // Detached semantics: a different payload breaks the signature.
228        assert!(verify_detached_ed25519(&jws, b"other", &pk).is_err());
229
230        // Tampered signature fails.
231        let mut parts: Vec<&str> = jws.split('.').collect();
232        let mut sig = b64url_decode(parts[1]).unwrap();
233        sig[0] ^= 1;
234        parts[1] = "";
235        let tampered = format!("{}.{}", parts[0], b64url_encode(&sig));
236        assert!(verify_detached_ed25519(&tampered, &payload, &pk).is_err());
237    }
238
239    #[test]
240    fn es256_round_trip() {
241        use p256::elliptic_curve::Generate;
242        let sk = p256::ecdsa::SigningKey::generate();
243        let payload = crate::jcs::canonicalize(&serde_json::json!({"dose":500}))
244            .unwrap()
245            .into_bytes();
246        let jws = sign_detached_es256(&sk, Some("end-p256"), &payload).unwrap();
247        // P1363: 64-byte signature -> 86 base64url chars, no padding.
248        let sig_len = b64url_decode(jws.split('.').nth(1).unwrap()).unwrap().len();
249        assert_eq!(sig_len, 64, "ES256 must be r||s, not DER");
250        assert!(verify_detached_es256(&jws, &payload, sk.verifying_key()).is_ok());
251        assert!(verify_detached_es256(&jws, b"other", sk.verifying_key()).is_err());
252        // Wrong key rejected.
253        let other: p256::ecdsa::SigningKey = p256::ecdsa::SigningKey::generate();
254        assert!(verify_detached_es256(&jws, &payload, other.verifying_key()).is_err());
255    }
256
257    #[test]
258    fn signing_input_is_deterministic() {
259        let a = detached_signing_input(JwsAlg::Ed25519, None, b"payload").unwrap();
260        let b = detached_signing_input(JwsAlg::Ed25519, None, b"payload").unwrap();
261        assert_eq!(a, b);
262        assert!(
263            a.starts_with("eyJhbGciOiJFZERTQSIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19"),
264            "header was {a}"
265        );
266    }
267}