Skip to main content

parley_core/
signing.rs

1//! Parley HTTP request signing. Spec v0.4 §2 (Authentication).
2//!
3//! Replaces the v0.1 in-body Envelope construct. Authentication metadata
4//! moves to a `Parley-Signature` HTTP header. Signatures are Ed25519 over
5//! a deterministic 8-line canonical string that pins the HTTP method,
6//! path, query, ts, nonce, agent, network, and body hash.
7
8use std::fmt;
9
10use base64::engine::general_purpose::URL_SAFE_NO_PAD;
11use base64::Engine as _;
12use ed25519_dalek::{Signature, Verifier as _, VerifyingKey};
13use libcrux_ml_dsa::ml_dsa_65::{
14    self, MLDSA65Signature, MLDSA65SigningKey, MLDSA65VerificationKey,
15};
16use rand::RngCore as _;
17use sha2::{Digest as _, Sha256};
18
19use crate::ids::{AgentPubkey, NetworkId, Nonce};
20
21/// HTTP header name carrying the Parley signature.
22pub const SIGNATURE_HEADER: &str = "Parley-Signature";
23
24/// Signature scheme version. Bump on incompatible changes to the
25/// canonical string or header grammar.
26///
27/// v2 (post-quantum): the header additionally carries a `mldsa_sig`
28/// field — an ML-DSA-65 signature over the same canonical string.
29/// Registered agents MUST supply it; the server verifies both the
30/// Ed25519 and the ML-DSA signature (hybrid).
31pub const SIGNATURE_VERSION: u32 = 2;
32
33/// ML-DSA-65 (FIPS 204) public/verification key length, in bytes.
34pub const ML_DSA_PUBKEY_BYTES: usize = 1952;
35
36/// ML-DSA-65 (FIPS 204) signature length, in bytes.
37pub const ML_DSA_SIG_BYTES: usize = 3309;
38
39/// Domain-separation context for ML-DSA auth signatures (FIPS 204 ctx).
40const ML_DSA_CONTEXT: &[u8] = b"parley-auth-v2";
41
42/// SHA-256 of the empty byte sequence, base64url-no-pad. Used for the
43/// body-hash field of requests with no body. 43 chars.
44pub const EMPTY_BODY_SHA256: &str = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
45
46/// Compute SHA-256 of a body and base64url-no-pad encode it.
47#[must_use]
48pub fn body_sha256_b64url(body: &[u8]) -> String {
49    let mut hasher = Sha256::new();
50    hasher.update(body);
51    let digest = hasher.finalize();
52    URL_SAFE_NO_PAD.encode(digest)
53}
54
55/// Build the canonical string-to-sign per spec §2.2.
56///
57/// Eight LF-joined lines (no trailing newline):
58/// ```text
59/// METHOD\npath\nquery\nts\nnonce\nagent\nnetwork\nbody-sha256-b64url
60/// ```
61#[must_use]
62#[allow(clippy::too_many_arguments)]
63pub fn canonical_string(
64    method: &str,
65    path: &str,
66    canonical_query: &str,
67    ts: i64,
68    nonce: &Nonce,
69    agent: &AgentPubkey,
70    network: &NetworkId,
71    body_sha256_b64url: &str,
72) -> String {
73    let method_upper = method.to_ascii_uppercase();
74    format!(
75        "{method_upper}\n{path}\n{canonical_query}\n{ts}\n{nonce}\n{agent}\n{network}\n{body_sha256_b64url}"
76    )
77}
78
79/// Canonicalize a raw query string per spec §2.2:
80/// parse, sort by key then value, percent-encode each pair, rejoin with `&`.
81///
82/// Returns the empty string for an empty input.
83#[must_use]
84pub fn canonical_query_string(raw: &str) -> String {
85    if raw.is_empty() {
86        return String::new();
87    }
88    let mut pairs: Vec<(String, String)> = raw
89        .split('&')
90        .filter(|s| !s.is_empty())
91        .map(|p| match p.split_once('=') {
92            Some((k, v)) => (
93                percent_decode(k).unwrap_or_else(|_| k.to_owned()),
94                percent_decode(v).unwrap_or_else(|_| v.to_owned()),
95            ),
96            None => (
97                percent_decode(p).unwrap_or_else(|_| p.to_owned()),
98                String::new(),
99            ),
100        })
101        .collect();
102    pairs.sort();
103    pairs
104        .into_iter()
105        .map(|(k, v)| format!("{}={}", percent_encode(&k), percent_encode(&v)))
106        .collect::<Vec<_>>()
107        .join("&")
108}
109
110fn percent_encode(s: &str) -> String {
111    let mut out = String::with_capacity(s.len());
112    for &b in s.as_bytes() {
113        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
114            out.push(b as char);
115        } else {
116            out.push_str(&format!("%{b:02X}"));
117        }
118    }
119    out
120}
121
122fn percent_decode(s: &str) -> Result<String, ()> {
123    let bytes = s.as_bytes();
124    let mut out = Vec::with_capacity(bytes.len());
125    let mut i = 0;
126    while i < bytes.len() {
127        if bytes[i] == b'%' {
128            if i + 2 >= bytes.len() {
129                return Err(());
130            }
131            let hi = hex_val(bytes[i + 1])?;
132            let lo = hex_val(bytes[i + 2])?;
133            out.push((hi << 4) | lo);
134            i += 3;
135        } else {
136            out.push(bytes[i]);
137            i += 1;
138        }
139    }
140    String::from_utf8(out).map_err(|_| ())
141}
142
143fn hex_val(b: u8) -> Result<u8, ()> {
144    match b {
145        b'0'..=b'9' => Ok(b - b'0'),
146        b'a'..=b'f' => Ok(b - b'a' + 10),
147        b'A'..=b'F' => Ok(b - b'A' + 10),
148        _ => Err(()),
149    }
150}
151
152/// Build the `Parley-Signature` header value (v2, hybrid).
153///
154/// `sig_bytes` is the Ed25519 signature; `mldsa_sig` is the ML-DSA-65
155/// signature over the same canonical string. Both are emitted; the
156/// ML-DSA field is large (~4.4 KB base64), which is the expected cost
157/// of post-quantum auth.
158#[must_use]
159pub fn build_header_value(
160    agent: &AgentPubkey,
161    ts: i64,
162    nonce: &Nonce,
163    network: &NetworkId,
164    sig_bytes: &[u8; 64],
165    mldsa_sig: &[u8],
166) -> String {
167    format!(
168        "v={v}, agent={agent}, ts={ts}, nonce={nonce}, network={network}, sig={sig}, mldsa_sig={mldsa}",
169        v = SIGNATURE_VERSION,
170        sig = URL_SAFE_NO_PAD.encode(sig_bytes),
171        mldsa = URL_SAFE_NO_PAD.encode(mldsa_sig),
172    )
173}
174
175/// Parsed `Parley-Signature` header.
176#[derive(Debug, Clone)]
177pub struct ParsedSignature {
178    pub v: u32,
179    pub agent: AgentPubkey,
180    pub ts: i64,
181    pub nonce: Nonce,
182    pub network: NetworkId,
183    pub sig: [u8; 64],
184    /// ML-DSA-65 signature bytes, if the `mldsa_sig` field is present.
185    /// Optional at parse time so unregistered agents (e.g. mid-register)
186    /// and v1 callers still parse; the middleware enforces presence for
187    /// registered agents.
188    pub mldsa_sig: Option<Vec<u8>>,
189}
190
191#[derive(Debug, thiserror::Error)]
192pub enum SignatureParseError {
193    #[error("missing field: {0}")]
194    MissingField(&'static str),
195    #[error("malformed pair: {0}")]
196    MalformedPair(String),
197    #[error("invalid value for {field}: {reason}")]
198    InvalidValue { field: &'static str, reason: String },
199    #[error("duplicate field: {0}")]
200    DuplicateField(&'static str),
201}
202
203/// Parse a `Parley-Signature` header value per spec §2.1.
204pub fn parse_header_value(raw: &str) -> Result<ParsedSignature, SignatureParseError> {
205    let mut v: Option<u32> = None;
206    let mut agent: Option<AgentPubkey> = None;
207    let mut ts: Option<i64> = None;
208    let mut nonce: Option<Nonce> = None;
209    let mut network: Option<NetworkId> = None;
210    let mut sig: Option<[u8; 64]> = None;
211    let mut mldsa_sig: Option<Vec<u8>> = None;
212
213    for raw_pair in raw.split(',') {
214        let pair = raw_pair.trim();
215        if pair.is_empty() {
216            continue;
217        }
218        let (key, value) = pair
219            .split_once('=')
220            .ok_or_else(|| SignatureParseError::MalformedPair(pair.to_owned()))?;
221        let value = value.trim();
222        match key.trim() {
223            "v" => {
224                if v.is_some() {
225                    return Err(SignatureParseError::DuplicateField("v"));
226                }
227                v = Some(value.parse().map_err(|e: std::num::ParseIntError| {
228                    SignatureParseError::InvalidValue {
229                        field: "v",
230                        reason: e.to_string(),
231                    }
232                })?);
233            }
234            "agent" => {
235                if agent.is_some() {
236                    return Err(SignatureParseError::DuplicateField("agent"));
237                }
238                agent = Some(value.parse().map_err(|e: crate::CoreError| {
239                    SignatureParseError::InvalidValue {
240                        field: "agent",
241                        reason: e.to_string(),
242                    }
243                })?);
244            }
245            "ts" => {
246                if ts.is_some() {
247                    return Err(SignatureParseError::DuplicateField("ts"));
248                }
249                ts = Some(value.parse().map_err(|e: std::num::ParseIntError| {
250                    SignatureParseError::InvalidValue {
251                        field: "ts",
252                        reason: e.to_string(),
253                    }
254                })?);
255            }
256            "nonce" => {
257                if nonce.is_some() {
258                    return Err(SignatureParseError::DuplicateField("nonce"));
259                }
260                nonce = Some(value.parse().map_err(|e: crate::CoreError| {
261                    SignatureParseError::InvalidValue {
262                        field: "nonce",
263                        reason: e.to_string(),
264                    }
265                })?);
266            }
267            "network" => {
268                if network.is_some() {
269                    return Err(SignatureParseError::DuplicateField("network"));
270                }
271                network = Some(value.parse().map_err(|e: crate::CoreError| {
272                    SignatureParseError::InvalidValue {
273                        field: "network",
274                        reason: e.to_string(),
275                    }
276                })?);
277            }
278            "sig" => {
279                if sig.is_some() {
280                    return Err(SignatureParseError::DuplicateField("sig"));
281                }
282                let decoded = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
283                    SignatureParseError::InvalidValue {
284                        field: "sig",
285                        reason: e.to_string(),
286                    }
287                })?;
288                let arr: [u8; 64] =
289                    decoded
290                        .try_into()
291                        .map_err(|d: Vec<u8>| SignatureParseError::InvalidValue {
292                            field: "sig",
293                            reason: format!("expected 64 bytes, got {}", d.len()),
294                        })?;
295                sig = Some(arr);
296            }
297            "mldsa_sig" => {
298                if mldsa_sig.is_some() {
299                    return Err(SignatureParseError::DuplicateField("mldsa_sig"));
300                }
301                let decoded = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
302                    SignatureParseError::InvalidValue {
303                        field: "mldsa_sig",
304                        reason: e.to_string(),
305                    }
306                })?;
307                mldsa_sig = Some(decoded);
308            }
309            other => {
310                // Unknown keys are tolerated for forward extensibility but
311                // not stored. The signature still binds the canonical
312                // string, so unknown keys can't change request meaning.
313                let _ = other;
314            }
315        }
316    }
317
318    Ok(ParsedSignature {
319        v: v.ok_or(SignatureParseError::MissingField("v"))?,
320        agent: agent.ok_or(SignatureParseError::MissingField("agent"))?,
321        ts: ts.ok_or(SignatureParseError::MissingField("ts"))?,
322        nonce: nonce.ok_or(SignatureParseError::MissingField("nonce"))?,
323        network: network.ok_or(SignatureParseError::MissingField("network"))?,
324        sig: sig.ok_or(SignatureParseError::MissingField("sig"))?,
325        mldsa_sig,
326    })
327}
328
329/// Verify an Ed25519 signature against a canonical string.
330pub fn verify_signature(
331    agent: &AgentPubkey,
332    canonical: &str,
333    sig: &[u8; 64],
334) -> Result<(), SignatureVerifyError> {
335    let key = VerifyingKey::from_bytes(agent.as_bytes())
336        .map_err(|e| SignatureVerifyError::BadKey(e.to_string()))?;
337    let signature = Signature::from_bytes(sig);
338    key.verify(canonical.as_bytes(), &signature)
339        .map_err(|_| SignatureVerifyError::BadSignature)
340}
341
342#[derive(Debug, thiserror::Error)]
343pub enum SignatureVerifyError {
344    #[error("agent pubkey is not a valid Ed25519 verifying key: {0}")]
345    BadKey(String),
346    #[error("signature does not verify")]
347    BadSignature,
348}
349
350/// Sign a canonical string with an ML-DSA-65 key. Returns raw signature
351/// bytes ([`ML_DSA_SIG_BYTES`] long). Uses hedged (randomized) signing.
352pub fn ml_dsa_sign(
353    signing_key: &MLDSA65SigningKey,
354    canonical: &str,
355) -> Result<Vec<u8>, MlDsaError> {
356    let mut randomness = [0u8; 32];
357    rand::thread_rng().fill_bytes(&mut randomness);
358    let sig = ml_dsa_65::sign(
359        signing_key,
360        canonical.as_bytes(),
361        ML_DSA_CONTEXT,
362        randomness,
363    )
364    .map_err(|_| MlDsaError::Sign)?;
365    Ok(sig.as_slice().to_vec())
366}
367
368/// Verify an ML-DSA-65 signature over `canonical` against raw verification
369/// key bytes. Both `pubkey_bytes` and `sig_bytes` must be exactly the
370/// fixed FIPS 204 lengths or this returns [`MlDsaError`].
371pub fn ml_dsa_verify(
372    pubkey_bytes: &[u8],
373    canonical: &str,
374    sig_bytes: &[u8],
375) -> Result<(), MlDsaError> {
376    let pk: [u8; ML_DSA_PUBKEY_BYTES] = pubkey_bytes.try_into().map_err(|_| MlDsaError::BadKey)?;
377    let sig: [u8; ML_DSA_SIG_BYTES] = sig_bytes.try_into().map_err(|_| MlDsaError::BadSignature)?;
378    let vk = MLDSA65VerificationKey::new(pk);
379    let signature = MLDSA65Signature::new(sig);
380    ml_dsa_65::verify(&vk, canonical.as_bytes(), ML_DSA_CONTEXT, &signature)
381        .map_err(|_| MlDsaError::BadSignature)
382}
383
384#[derive(Debug, thiserror::Error)]
385pub enum MlDsaError {
386    #[error("ML-DSA signing failed")]
387    Sign,
388    #[error("ML-DSA verification key is malformed (wrong length)")]
389    BadKey,
390    #[error("ML-DSA signature does not verify or is malformed")]
391    BadSignature,
392}
393
394impl fmt::Display for ParsedSignature {
395    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
396        write!(
397            f,
398            "v={}, agent={}, ts={}, nonce={}, network={}",
399            self.v, self.agent, self.ts, self.nonce, self.network
400        )
401    }
402}
403
404#[cfg(test)]
405#[allow(clippy::unwrap_used, clippy::expect_used)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn empty_body_sha_constant_matches_computed() {
411        assert_eq!(body_sha256_b64url(b""), EMPTY_BODY_SHA256);
412    }
413
414    #[test]
415    fn canonical_query_sorts_and_encodes() {
416        assert_eq!(canonical_query_string(""), "");
417        assert_eq!(canonical_query_string("b=2&a=1"), "a=1&b=2");
418        assert_eq!(canonical_query_string("k=hello world"), "k=hello%20world");
419        assert_eq!(canonical_query_string("k="), "k=");
420    }
421
422    #[test]
423    fn canonical_string_format_is_eight_lines() {
424        let agent: AgentPubkey = "u9PqJ4gK2mZ8t6nVxR3hB1cW7yE5dF0aQ4sT2lN6oU8"
425            .parse()
426            .unwrap();
427        let nonce: Nonce = "F4Yk8vN2j5QwK3zB1aR9oA".parse().unwrap();
428        let network: NetworkId = "parley-mainnet".parse().unwrap();
429        let s = canonical_string(
430            "GET",
431            "/v1/blobs/abc",
432            "",
433            1715299200,
434            &nonce,
435            &agent,
436            &network,
437            EMPTY_BODY_SHA256,
438        );
439        assert_eq!(s.lines().count(), 8);
440        assert!(s.starts_with("GET\n/v1/blobs/abc\n\n1715299200\n"));
441    }
442
443    #[test]
444    fn header_roundtrips() {
445        let agent: AgentPubkey = "u9PqJ4gK2mZ8t6nVxR3hB1cW7yE5dF0aQ4sT2lN6oU8"
446            .parse()
447            .unwrap();
448        let nonce: Nonce = "F4Yk8vN2j5QwK3zB1aR9oA".parse().unwrap();
449        let network: NetworkId = "parley-mainnet".parse().unwrap();
450        let sig = [7u8; 64];
451        let mldsa = vec![3u8; ML_DSA_SIG_BYTES];
452        let header = build_header_value(&agent, 1715299200, &nonce, &network, &sig, &mldsa);
453        let parsed = parse_header_value(&header).unwrap();
454        assert_eq!(parsed.v, SIGNATURE_VERSION);
455        assert_eq!(parsed.agent, agent);
456        assert_eq!(parsed.ts, 1715299200);
457        assert_eq!(parsed.nonce, nonce);
458        assert_eq!(parsed.network, network);
459        assert_eq!(parsed.sig, sig);
460        assert_eq!(parsed.mldsa_sig.as_deref(), Some(mldsa.as_slice()));
461    }
462
463    #[test]
464    fn header_tolerates_no_space_after_comma() {
465        let agent: AgentPubkey = "u9PqJ4gK2mZ8t6nVxR3hB1cW7yE5dF0aQ4sT2lN6oU8"
466            .parse()
467            .unwrap();
468        let nonce: Nonce = "F4Yk8vN2j5QwK3zB1aR9oA".parse().unwrap();
469        let network: NetworkId = "parley-mainnet".parse().unwrap();
470        let sig = [7u8; 64];
471        let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
472        let header =
473            format!("v=1,agent={agent},ts=1,nonce={nonce},network={network},sig={sig_b64}");
474        let parsed = parse_header_value(&header).unwrap();
475        assert_eq!(parsed.v, 1);
476    }
477
478    #[test]
479    fn sign_then_verify_roundtrip() {
480        use ed25519_dalek::{Signer as _, SigningKey};
481        let signing = SigningKey::from_bytes(&[42u8; 32]);
482        let agent = AgentPubkey::from_bytes(*signing.verifying_key().as_bytes());
483        let canonical = "GET\n/healthz\n\n0\n_\n_\n_\n_";
484        let sig = signing.sign(canonical.as_bytes()).to_bytes();
485        verify_signature(&agent, canonical, &sig).unwrap();
486        let mut bad = sig;
487        bad[0] ^= 1;
488        assert!(verify_signature(&agent, canonical, &bad).is_err());
489    }
490
491    #[test]
492    fn ml_dsa_sign_verify_roundtrip() {
493        use crate::keys::derive_auth_mldsa;
494        let kp = derive_auth_mldsa(&[42u8; crate::keys::SEED_BYTES]);
495        let pk = kp.verification_key.as_slice();
496        let canonical = "GET\n/healthz\n\n0\n_\n_\n_\n_";
497
498        let sig = ml_dsa_sign(&kp.signing_key, canonical).unwrap();
499        assert_eq!(sig.len(), ML_DSA_SIG_BYTES);
500        ml_dsa_verify(pk, canonical, &sig).unwrap();
501
502        // Tampered message rejected.
503        assert!(ml_dsa_verify(pk, "GET\n/other\n\n0\n_\n_\n_\n_", &sig).is_err());
504        // Tampered signature rejected.
505        let mut bad = sig.clone();
506        bad[0] ^= 1;
507        assert!(ml_dsa_verify(pk, canonical, &bad).is_err());
508        // Wrong-length key/sig rejected, not panicked.
509        assert!(ml_dsa_verify(&pk[..10], canonical, &sig).is_err());
510        assert!(ml_dsa_verify(pk, canonical, &sig[..10]).is_err());
511    }
512}