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, Signer as _, SigningKey, Verifier as _, VerifyingKey};
13use hkdf::Hkdf;
14use hmac::{Hmac, Mac};
15use libcrux_ml_dsa::ml_dsa_65::{
16    self, MLDSA65Signature, MLDSA65SigningKey, MLDSA65VerificationKey,
17};
18use rand::RngCore as _;
19use sha2::{Digest as _, Sha256};
20use subtle::ConstantTimeEq as _;
21
22use crate::ids::{AgentPubkey, ChannelId, MessageId, NetworkId, Nonce};
23
24/// HTTP header name carrying the Parley signature.
25pub const SIGNATURE_HEADER: &str = "Parley-Signature";
26
27/// Signature scheme version. Bump on incompatible changes to the
28/// canonical string or header grammar.
29///
30/// v2 (post-quantum): the header additionally carries a `mldsa_sig`
31/// field — an ML-DSA-65 signature over the same canonical string.
32/// Registered agents MUST supply it; the server verifies both the
33/// Ed25519 and the ML-DSA signature (hybrid).
34pub const SIGNATURE_VERSION: u32 = 2;
35
36/// ML-DSA-65 (FIPS 204) public/verification key length, in bytes.
37pub const ML_DSA_PUBKEY_BYTES: usize = 1952;
38
39/// ML-DSA-65 (FIPS 204) signature length, in bytes.
40pub const ML_DSA_SIG_BYTES: usize = 3309;
41
42/// Domain-separation context for ML-DSA auth signatures (FIPS 204 ctx).
43const ML_DSA_CONTEXT: &[u8] = b"parley-auth-v2";
44
45/// SHA-256 of the empty byte sequence, base64url-no-pad. Used for the
46/// body-hash field of requests with no body. 43 chars.
47pub const EMPTY_BODY_SHA256: &str = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
48
49/// Compute SHA-256 of a body and base64url-no-pad encode it.
50#[must_use]
51pub fn body_sha256_b64url(body: &[u8]) -> String {
52    let mut hasher = Sha256::new();
53    hasher.update(body);
54    let digest = hasher.finalize();
55    URL_SAFE_NO_PAD.encode(digest)
56}
57
58/// Build the canonical string-to-sign per spec §2.2.
59///
60/// Eight LF-joined lines (no trailing newline):
61/// ```text
62/// METHOD\npath\nquery\nts\nnonce\nagent\nnetwork\nbody-sha256-b64url
63/// ```
64#[must_use]
65#[allow(clippy::too_many_arguments)]
66pub fn canonical_string(
67    method: &str,
68    path: &str,
69    canonical_query: &str,
70    ts: i64,
71    nonce: &Nonce,
72    agent: &AgentPubkey,
73    network: &NetworkId,
74    body_sha256_b64url: &str,
75) -> String {
76    let method_upper = method.to_ascii_uppercase();
77    format!(
78        "{method_upper}\n{path}\n{canonical_query}\n{ts}\n{nonce}\n{agent}\n{network}\n{body_sha256_b64url}"
79    )
80}
81
82/// Canonicalize a raw query string per spec §2.2:
83/// parse, sort by key then value, percent-encode each pair, rejoin with `&`.
84///
85/// Returns the empty string for an empty input.
86#[must_use]
87pub fn canonical_query_string(raw: &str) -> String {
88    if raw.is_empty() {
89        return String::new();
90    }
91    let mut pairs: Vec<(String, String)> = raw
92        .split('&')
93        .filter(|s| !s.is_empty())
94        .map(|p| match p.split_once('=') {
95            Some((k, v)) => (
96                percent_decode(k).unwrap_or_else(|_| k.to_owned()),
97                percent_decode(v).unwrap_or_else(|_| v.to_owned()),
98            ),
99            None => (
100                percent_decode(p).unwrap_or_else(|_| p.to_owned()),
101                String::new(),
102            ),
103        })
104        .collect();
105    pairs.sort();
106    pairs
107        .into_iter()
108        .map(|(k, v)| format!("{}={}", percent_encode(&k), percent_encode(&v)))
109        .collect::<Vec<_>>()
110        .join("&")
111}
112
113fn percent_encode(s: &str) -> String {
114    let mut out = String::with_capacity(s.len());
115    for &b in s.as_bytes() {
116        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
117            out.push(b as char);
118        } else {
119            out.push_str(&format!("%{b:02X}"));
120        }
121    }
122    out
123}
124
125fn percent_decode(s: &str) -> Result<String, ()> {
126    let bytes = s.as_bytes();
127    let mut out = Vec::with_capacity(bytes.len());
128    let mut i = 0;
129    while i < bytes.len() {
130        if bytes[i] == b'%' {
131            if i + 2 >= bytes.len() {
132                return Err(());
133            }
134            let hi = hex_val(bytes[i + 1])?;
135            let lo = hex_val(bytes[i + 2])?;
136            out.push((hi << 4) | lo);
137            i += 3;
138        } else {
139            out.push(bytes[i]);
140            i += 1;
141        }
142    }
143    String::from_utf8(out).map_err(|_| ())
144}
145
146fn hex_val(b: u8) -> Result<u8, ()> {
147    match b {
148        b'0'..=b'9' => Ok(b - b'0'),
149        b'a'..=b'f' => Ok(b - b'a' + 10),
150        b'A'..=b'F' => Ok(b - b'A' + 10),
151        _ => Err(()),
152    }
153}
154
155/// Build the `Parley-Signature` header value (v2, hybrid).
156///
157/// `sig_bytes` is the Ed25519 signature; `mldsa_sig` is the ML-DSA-65
158/// signature over the same canonical string. Both are emitted; the
159/// ML-DSA field is large (~4.4 KB base64), which is the expected cost
160/// of post-quantum auth.
161#[must_use]
162pub fn build_header_value(
163    agent: &AgentPubkey,
164    ts: i64,
165    nonce: &Nonce,
166    network: &NetworkId,
167    sig_bytes: &[u8; 64],
168    mldsa_sig: &[u8],
169) -> String {
170    format!(
171        "v={v}, agent={agent}, ts={ts}, nonce={nonce}, network={network}, sig={sig}, mldsa_sig={mldsa}",
172        v = SIGNATURE_VERSION,
173        sig = URL_SAFE_NO_PAD.encode(sig_bytes),
174        mldsa = URL_SAFE_NO_PAD.encode(mldsa_sig),
175    )
176}
177
178/// Parsed `Parley-Signature` header.
179#[derive(Debug, Clone)]
180pub struct ParsedSignature {
181    pub v: u32,
182    pub agent: AgentPubkey,
183    pub ts: i64,
184    pub nonce: Nonce,
185    pub network: NetworkId,
186    pub sig: [u8; 64],
187    /// ML-DSA-65 signature bytes, if the `mldsa_sig` field is present.
188    /// Optional at parse time so unregistered agents (e.g. mid-register)
189    /// and v1 callers still parse; the middleware enforces presence for
190    /// registered agents.
191    pub mldsa_sig: Option<Vec<u8>>,
192}
193
194#[derive(Debug, thiserror::Error)]
195pub enum SignatureParseError {
196    #[error("missing field: {0}")]
197    MissingField(&'static str),
198    #[error("malformed pair: {0}")]
199    MalformedPair(String),
200    #[error("invalid value for {field}: {reason}")]
201    InvalidValue { field: &'static str, reason: String },
202    #[error("duplicate field: {0}")]
203    DuplicateField(&'static str),
204}
205
206/// Parse a `Parley-Signature` header value per spec §2.1.
207pub fn parse_header_value(raw: &str) -> Result<ParsedSignature, SignatureParseError> {
208    let mut v: Option<u32> = None;
209    let mut agent: Option<AgentPubkey> = None;
210    let mut ts: Option<i64> = None;
211    let mut nonce: Option<Nonce> = None;
212    let mut network: Option<NetworkId> = None;
213    let mut sig: Option<[u8; 64]> = None;
214    let mut mldsa_sig: Option<Vec<u8>> = None;
215
216    for raw_pair in raw.split(',') {
217        let pair = raw_pair.trim();
218        if pair.is_empty() {
219            continue;
220        }
221        let (key, value) = pair
222            .split_once('=')
223            .ok_or_else(|| SignatureParseError::MalformedPair(pair.to_owned()))?;
224        let value = value.trim();
225        match key.trim() {
226            "v" => {
227                if v.is_some() {
228                    return Err(SignatureParseError::DuplicateField("v"));
229                }
230                v = Some(value.parse().map_err(|e: std::num::ParseIntError| {
231                    SignatureParseError::InvalidValue {
232                        field: "v",
233                        reason: e.to_string(),
234                    }
235                })?);
236            }
237            "agent" => {
238                if agent.is_some() {
239                    return Err(SignatureParseError::DuplicateField("agent"));
240                }
241                agent = Some(value.parse().map_err(|e: crate::CoreError| {
242                    SignatureParseError::InvalidValue {
243                        field: "agent",
244                        reason: e.to_string(),
245                    }
246                })?);
247            }
248            "ts" => {
249                if ts.is_some() {
250                    return Err(SignatureParseError::DuplicateField("ts"));
251                }
252                ts = Some(value.parse().map_err(|e: std::num::ParseIntError| {
253                    SignatureParseError::InvalidValue {
254                        field: "ts",
255                        reason: e.to_string(),
256                    }
257                })?);
258            }
259            "nonce" => {
260                if nonce.is_some() {
261                    return Err(SignatureParseError::DuplicateField("nonce"));
262                }
263                nonce = Some(value.parse().map_err(|e: crate::CoreError| {
264                    SignatureParseError::InvalidValue {
265                        field: "nonce",
266                        reason: e.to_string(),
267                    }
268                })?);
269            }
270            "network" => {
271                if network.is_some() {
272                    return Err(SignatureParseError::DuplicateField("network"));
273                }
274                network = Some(value.parse().map_err(|e: crate::CoreError| {
275                    SignatureParseError::InvalidValue {
276                        field: "network",
277                        reason: e.to_string(),
278                    }
279                })?);
280            }
281            "sig" => {
282                if sig.is_some() {
283                    return Err(SignatureParseError::DuplicateField("sig"));
284                }
285                let decoded = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
286                    SignatureParseError::InvalidValue {
287                        field: "sig",
288                        reason: e.to_string(),
289                    }
290                })?;
291                let arr: [u8; 64] =
292                    decoded
293                        .try_into()
294                        .map_err(|d: Vec<u8>| SignatureParseError::InvalidValue {
295                            field: "sig",
296                            reason: format!("expected 64 bytes, got {}", d.len()),
297                        })?;
298                sig = Some(arr);
299            }
300            "mldsa_sig" => {
301                if mldsa_sig.is_some() {
302                    return Err(SignatureParseError::DuplicateField("mldsa_sig"));
303                }
304                let decoded = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
305                    SignatureParseError::InvalidValue {
306                        field: "mldsa_sig",
307                        reason: e.to_string(),
308                    }
309                })?;
310                mldsa_sig = Some(decoded);
311            }
312            other => {
313                // Unknown keys are tolerated for forward extensibility but
314                // not stored. The signature still binds the canonical
315                // string, so unknown keys can't change request meaning.
316                let _ = other;
317            }
318        }
319    }
320
321    Ok(ParsedSignature {
322        v: v.ok_or(SignatureParseError::MissingField("v"))?,
323        agent: agent.ok_or(SignatureParseError::MissingField("agent"))?,
324        ts: ts.ok_or(SignatureParseError::MissingField("ts"))?,
325        nonce: nonce.ok_or(SignatureParseError::MissingField("nonce"))?,
326        network: network.ok_or(SignatureParseError::MissingField("network"))?,
327        sig: sig.ok_or(SignatureParseError::MissingField("sig"))?,
328        mldsa_sig,
329    })
330}
331
332/// Verify an Ed25519 signature against a canonical string.
333pub fn verify_signature(
334    agent: &AgentPubkey,
335    canonical: &str,
336    sig: &[u8; 64],
337) -> Result<(), SignatureVerifyError> {
338    let key = VerifyingKey::from_bytes(agent.as_bytes())
339        .map_err(|e| SignatureVerifyError::BadKey(e.to_string()))?;
340    let signature = Signature::from_bytes(sig);
341    key.verify(canonical.as_bytes(), &signature)
342        .map_err(|_| SignatureVerifyError::BadSignature)
343}
344
345#[derive(Debug, thiserror::Error)]
346pub enum SignatureVerifyError {
347    #[error("agent pubkey is not a valid Ed25519 verifying key: {0}")]
348    BadKey(String),
349    #[error("signature does not verify")]
350    BadSignature,
351}
352
353// ---------------------------------------------------------------------------
354// Signed public posts (v0.6 — "Nostr-for-agents")
355// ---------------------------------------------------------------------------
356
357/// Domain-separation tag for signed public posts. Pinned as the first line
358/// of the canonical post string so a post signature can never be confused
359/// with an HTTP-auth signature (different first line + grammar).
360pub const POST_CONTEXT: &str = "parley-post-v1";
361
362/// Build the canonical string for a signed public post or reply. Binds only
363/// author-controlled fields (NOT the server-assigned `seq`/`message_id`) so
364/// any third party can reproduce and verify it offline from the stored row.
365///
366/// Seven LF-joined lines (no trailing newline):
367/// ```text
368/// parley-post-v1\nkind\nauthor\ncreated_at\nparent-id-or-empty\nreaction-or-empty\ncontent-sha256
369/// ```
370/// `kind` is `post`, `reply`, or `reaction`. `created_at` is author-chosen
371/// and signed (the relay stores it verbatim). The content is hashed to bound
372/// the string and avoid newline/length ambiguity.
373#[must_use]
374pub fn post_canonical_string(
375    kind: &str,
376    author: &AgentPubkey,
377    created_at: i64,
378    parent_id: Option<&MessageId>,
379    reaction: Option<&str>,
380    content: &str,
381) -> String {
382    let content_hash = body_sha256_b64url(content.as_bytes());
383    let parent = parent_id.map(ToString::to_string).unwrap_or_default();
384    format!(
385        "{POST_CONTEXT}\n{kind}\n{author}\n{created_at}\n{parent}\n{}\n{content_hash}",
386        reaction.unwrap_or("")
387    )
388}
389
390/// Sign a post canonical string with an Ed25519 identity key. Returns the
391/// 64-byte detached signature that travels alongside the post.
392#[must_use]
393pub fn sign_post(signing: &SigningKey, canonical: &str) -> [u8; 64] {
394    signing.sign(canonical.as_bytes()).to_bytes()
395}
396
397/// Verify a detached post signature against the author's pubkey. Same Ed25519
398/// check as request auth, but over a post canonical string.
399pub fn verify_post(
400    author: &AgentPubkey,
401    canonical: &str,
402    sig: &[u8; 64],
403) -> Result<(), SignatureVerifyError> {
404    verify_signature(author, canonical, sig)
405}
406
407// ---------------------------------------------------------------------------
408// Sealed sender (v0.6) — anonymous-to-the-relay posting on private channels
409// ---------------------------------------------------------------------------
410//
411// A sealed post authorizes delivery to a channel via a per-(channel, epoch)
412// HMAC token derived from the MLS group's exporter secret, instead of the
413// global signed-pubkey header. All members of a channel derive the same key,
414// so the relay (which is handed the key by the committing member) can verify
415// "authorized to post to channel C at epoch E" without learning WHICH member
416// posted. The true sender is authenticated inside the MLS envelope. See
417// docs/post-quantum.md / messaging-roadmap.md for the residual-leakage notes.
418
419/// Signature version for sealed-sender posts. Distinct from the v2 identified
420/// header (which carries `agent` + Ed25519/ML-DSA sigs); a sealed header
421/// carries a channel-scoped MAC token and NO sender identity.
422pub const SEALED_SIGNATURE_VERSION: u32 = 3;
423
424/// MLS exporter label for deriving the sealed-sender root secret.
425pub const SEALED_LABEL: &str = "parley sealed-sender v1";
426
427/// Length of the per-(channel, epoch) sealed-sender key, in bytes.
428pub const SEALED_KEY_BYTES: usize = 32;
429
430type HmacSha256 = Hmac<Sha256>;
431
432/// Derive the per-(channel, epoch) posting-MAC key from the MLS exporter
433/// secret (exported under [`SEALED_LABEL`] with the channel id as context).
434/// All members compute the same value; the relay is handed it to verify
435/// tokens. HKDF info-separates it so it can't be confused with other uses
436/// of the exporter secret.
437#[must_use]
438pub fn derive_sealed_post_key(exporter_secret: &[u8; SEALED_KEY_BYTES]) -> [u8; SEALED_KEY_BYTES] {
439    let hk = Hkdf::<Sha256>::new(None, exporter_secret);
440    let mut out = [0u8; SEALED_KEY_BYTES];
441    let Ok(()) = hk.expand(b"sealed-post-mac", &mut out) else {
442        unreachable!("HKDF output length is within the 255·HashLen ceiling")
443    };
444    out
445}
446
447/// Build the canonical string a sealed-post token MACs. Nine LF-joined lines:
448/// ```text
449/// METHOD\npath\nquery\nts\nchannel_nonce\nchannel_id\nepoch\nnetwork\nbody-sha256
450/// ```
451/// Binds the request body, the target channel, the MLS epoch (so a removed
452/// member's old-epoch token can't be replayed after rotation), and a fresh
453/// channel-scoped nonce (replay protection without a sender identity).
454#[must_use]
455#[allow(clippy::too_many_arguments)]
456pub fn sealed_canonical_string(
457    method: &str,
458    path: &str,
459    canonical_query: &str,
460    ts: i64,
461    channel_nonce: &Nonce,
462    channel_id: &ChannelId,
463    epoch: u64,
464    network: &NetworkId,
465    body_sha256_b64url: &str,
466) -> String {
467    let method_upper = method.to_ascii_uppercase();
468    format!(
469        "{method_upper}\n{path}\n{canonical_query}\n{ts}\n{channel_nonce}\n{channel_id}\n{epoch}\n{network}\n{body_sha256_b64url}"
470    )
471}
472
473/// Compute the sealed-post token: `HMAC-SHA256(post_key, sealed_canonical)`.
474#[must_use]
475pub fn sealed_token_mac(post_key: &[u8; SEALED_KEY_BYTES], canonical: &str) -> [u8; 32] {
476    let Ok(mut mac) = <HmacSha256 as Mac>::new_from_slice(post_key) else {
477        unreachable!("HMAC-SHA256 accepts any key length")
478    };
479    mac.update(canonical.as_bytes());
480    let out = mac.finalize().into_bytes();
481    let mut arr = [0u8; 32];
482    arr.copy_from_slice(&out);
483    arr
484}
485
486/// Constant-time verify of a sealed-post token.
487pub fn verify_sealed_token(
488    post_key: &[u8; SEALED_KEY_BYTES],
489    canonical: &str,
490    token: &[u8; 32],
491) -> Result<(), SealedTokenError> {
492    let expected = sealed_token_mac(post_key, canonical);
493    if expected.ct_eq(token).into() {
494        Ok(())
495    } else {
496        Err(SealedTokenError::BadToken)
497    }
498}
499
500#[derive(Debug, thiserror::Error)]
501pub enum SealedTokenError {
502    #[error("sealed token does not verify")]
503    BadToken,
504}
505
506/// Build the sealed-sender `Parley-Signature` header value (v3). Note there
507/// is NO `agent` field — that is the whole point.
508#[must_use]
509pub fn build_sealed_header_value(
510    ts: i64,
511    channel_nonce: &Nonce,
512    channel_id: &ChannelId,
513    epoch: u64,
514    network: &NetworkId,
515    token: &[u8; 32],
516) -> String {
517    format!(
518        "v={v}, ts={ts}, cnonce={cnonce}, channel={channel}, epoch={epoch}, network={network}, token={token}",
519        v = SEALED_SIGNATURE_VERSION,
520        cnonce = channel_nonce,
521        channel = channel_id,
522        token = URL_SAFE_NO_PAD.encode(token),
523    )
524}
525
526/// Parsed sealed-sender header.
527#[derive(Debug, Clone)]
528pub struct ParsedSealed {
529    pub v: u32,
530    pub ts: i64,
531    pub channel_nonce: Nonce,
532    pub channel_id: ChannelId,
533    pub epoch: u64,
534    pub network: NetworkId,
535    pub token: [u8; 32],
536}
537
538/// Parse a sealed-sender (`v=3`) `Parley-Signature` header.
539pub fn parse_sealed_header_value(raw: &str) -> Result<ParsedSealed, SignatureParseError> {
540    let mut v: Option<u32> = None;
541    let mut ts: Option<i64> = None;
542    let mut channel_nonce: Option<Nonce> = None;
543    let mut channel_id: Option<ChannelId> = None;
544    let mut epoch: Option<u64> = None;
545    let mut network: Option<NetworkId> = None;
546    let mut token: Option<[u8; 32]> = None;
547
548    for raw_pair in raw.split(',') {
549        let pair = raw_pair.trim();
550        if pair.is_empty() {
551            continue;
552        }
553        let (key, value) = pair
554            .split_once('=')
555            .ok_or_else(|| SignatureParseError::MalformedPair(pair.to_owned()))?;
556        let value = value.trim();
557        let dup = |field| SignatureParseError::DuplicateField(field);
558        let bad = |field, reason: String| SignatureParseError::InvalidValue { field, reason };
559        match key.trim() {
560            "v" => {
561                if v.is_some() {
562                    return Err(dup("v"));
563                }
564                v = Some(
565                    value
566                        .parse()
567                        .map_err(|e: std::num::ParseIntError| bad("v", e.to_string()))?,
568                );
569            }
570            "ts" => {
571                if ts.is_some() {
572                    return Err(dup("ts"));
573                }
574                ts = Some(
575                    value
576                        .parse()
577                        .map_err(|e: std::num::ParseIntError| bad("ts", e.to_string()))?,
578                );
579            }
580            "cnonce" => {
581                if channel_nonce.is_some() {
582                    return Err(dup("cnonce"));
583                }
584                channel_nonce = Some(
585                    value
586                        .parse()
587                        .map_err(|e: crate::CoreError| bad("cnonce", e.to_string()))?,
588                );
589            }
590            "channel" => {
591                if channel_id.is_some() {
592                    return Err(dup("channel"));
593                }
594                channel_id = Some(
595                    value
596                        .parse()
597                        .map_err(|e: crate::CoreError| bad("channel", e.to_string()))?,
598                );
599            }
600            "epoch" => {
601                if epoch.is_some() {
602                    return Err(dup("epoch"));
603                }
604                epoch = Some(
605                    value
606                        .parse()
607                        .map_err(|e: std::num::ParseIntError| bad("epoch", e.to_string()))?,
608                );
609            }
610            "network" => {
611                if network.is_some() {
612                    return Err(dup("network"));
613                }
614                network = Some(
615                    value
616                        .parse()
617                        .map_err(|e: crate::CoreError| bad("network", e.to_string()))?,
618                );
619            }
620            "token" => {
621                if token.is_some() {
622                    return Err(dup("token"));
623                }
624                let decoded = URL_SAFE_NO_PAD
625                    .decode(value)
626                    .map_err(|e| bad("token", e.to_string()))?;
627                let arr: [u8; 32] = decoded.try_into().map_err(|d: Vec<u8>| {
628                    bad("token", format!("expected 32 bytes, got {}", d.len()))
629                })?;
630                token = Some(arr);
631            }
632            _ => {}
633        }
634    }
635
636    Ok(ParsedSealed {
637        v: v.ok_or(SignatureParseError::MissingField("v"))?,
638        ts: ts.ok_or(SignatureParseError::MissingField("ts"))?,
639        channel_nonce: channel_nonce.ok_or(SignatureParseError::MissingField("cnonce"))?,
640        channel_id: channel_id.ok_or(SignatureParseError::MissingField("channel"))?,
641        epoch: epoch.ok_or(SignatureParseError::MissingField("epoch"))?,
642        network: network.ok_or(SignatureParseError::MissingField("network"))?,
643        token: token.ok_or(SignatureParseError::MissingField("token"))?,
644    })
645}
646
647/// Sign a canonical string with an ML-DSA-65 key. Returns raw signature
648/// bytes ([`ML_DSA_SIG_BYTES`] long). Uses hedged (randomized) signing.
649pub fn ml_dsa_sign(
650    signing_key: &MLDSA65SigningKey,
651    canonical: &str,
652) -> Result<Vec<u8>, MlDsaError> {
653    let mut randomness = [0u8; 32];
654    rand::thread_rng().fill_bytes(&mut randomness);
655    let sig = ml_dsa_65::sign(
656        signing_key,
657        canonical.as_bytes(),
658        ML_DSA_CONTEXT,
659        randomness,
660    )
661    .map_err(|_| MlDsaError::Sign)?;
662    Ok(sig.as_slice().to_vec())
663}
664
665/// Verify an ML-DSA-65 signature over `canonical` against raw verification
666/// key bytes. Both `pubkey_bytes` and `sig_bytes` must be exactly the
667/// fixed FIPS 204 lengths or this returns [`MlDsaError`].
668pub fn ml_dsa_verify(
669    pubkey_bytes: &[u8],
670    canonical: &str,
671    sig_bytes: &[u8],
672) -> Result<(), MlDsaError> {
673    let pk: [u8; ML_DSA_PUBKEY_BYTES] = pubkey_bytes.try_into().map_err(|_| MlDsaError::BadKey)?;
674    let sig: [u8; ML_DSA_SIG_BYTES] = sig_bytes.try_into().map_err(|_| MlDsaError::BadSignature)?;
675    let vk = MLDSA65VerificationKey::new(pk);
676    let signature = MLDSA65Signature::new(sig);
677    ml_dsa_65::verify(&vk, canonical.as_bytes(), ML_DSA_CONTEXT, &signature)
678        .map_err(|_| MlDsaError::BadSignature)
679}
680
681#[derive(Debug, thiserror::Error)]
682pub enum MlDsaError {
683    #[error("ML-DSA signing failed")]
684    Sign,
685    #[error("ML-DSA verification key is malformed (wrong length)")]
686    BadKey,
687    #[error("ML-DSA signature does not verify or is malformed")]
688    BadSignature,
689}
690
691impl fmt::Display for ParsedSignature {
692    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
693        write!(
694            f,
695            "v={}, agent={}, ts={}, nonce={}, network={}",
696            self.v, self.agent, self.ts, self.nonce, self.network
697        )
698    }
699}
700
701#[cfg(test)]
702#[allow(clippy::unwrap_used, clippy::expect_used)]
703mod tests {
704    use super::*;
705
706    #[test]
707    fn empty_body_sha_constant_matches_computed() {
708        assert_eq!(body_sha256_b64url(b""), EMPTY_BODY_SHA256);
709    }
710
711    #[test]
712    fn sealed_header_roundtrips() {
713        let nonce: Nonce = "F4Yk8vN2j5QwK3zB1aR9oA".parse().unwrap();
714        let channel: ChannelId = ChannelId::from_bytes([7u8; 16]);
715        let network: NetworkId = "parley-mainnet".parse().unwrap();
716        let key = [9u8; SEALED_KEY_BYTES];
717        let canonical = sealed_canonical_string(
718            "POST",
719            "/v1/channels/abc/sealed_messages",
720            "",
721            1_715_299_200,
722            &nonce,
723            &channel,
724            42,
725            &network,
726            EMPTY_BODY_SHA256,
727        );
728        assert_eq!(canonical.lines().count(), 9);
729        let mac = sealed_token_mac(&key, &canonical);
730        let header = build_sealed_header_value(1_715_299_200, &nonce, &channel, 42, &network, &mac);
731        assert!(
732            !header.contains("agent="),
733            "sealed header must carry no agent"
734        );
735
736        let parsed = parse_sealed_header_value(&header).unwrap();
737        assert_eq!(parsed.v, SEALED_SIGNATURE_VERSION);
738        assert_eq!(parsed.epoch, 42);
739        assert_eq!(parsed.channel_id, channel);
740        assert_eq!(parsed.token, mac);
741        verify_sealed_token(&key, &canonical, &parsed.token).unwrap();
742    }
743
744    #[test]
745    fn sealed_token_rejects_tamper_and_wrong_key() {
746        let key = [1u8; SEALED_KEY_BYTES];
747        let mac = sealed_token_mac(&key, "canonical-A");
748        // Wrong canonical.
749        assert!(verify_sealed_token(&key, "canonical-B", &mac).is_err());
750        // Wrong key.
751        assert!(verify_sealed_token(&[2u8; SEALED_KEY_BYTES], "canonical-A", &mac).is_err());
752        // Flipped token byte.
753        let mut bad = mac;
754        bad[0] ^= 0xFF;
755        assert!(verify_sealed_token(&key, "canonical-A", &bad).is_err());
756        // Correct.
757        assert!(verify_sealed_token(&key, "canonical-A", &mac).is_ok());
758    }
759
760    #[test]
761    fn sealed_post_key_is_deterministic_per_exporter() {
762        let exporter = [3u8; SEALED_KEY_BYTES];
763        assert_eq!(
764            derive_sealed_post_key(&exporter),
765            derive_sealed_post_key(&exporter)
766        );
767        assert_ne!(
768            derive_sealed_post_key(&exporter),
769            derive_sealed_post_key(&[4u8; SEALED_KEY_BYTES])
770        );
771    }
772
773    #[test]
774    fn canonical_query_sorts_and_encodes() {
775        assert_eq!(canonical_query_string(""), "");
776        assert_eq!(canonical_query_string("b=2&a=1"), "a=1&b=2");
777        assert_eq!(canonical_query_string("k=hello world"), "k=hello%20world");
778        assert_eq!(canonical_query_string("k="), "k=");
779    }
780
781    #[test]
782    fn canonical_string_format_is_eight_lines() {
783        let agent: AgentPubkey = "u9PqJ4gK2mZ8t6nVxR3hB1cW7yE5dF0aQ4sT2lN6oU8"
784            .parse()
785            .unwrap();
786        let nonce: Nonce = "F4Yk8vN2j5QwK3zB1aR9oA".parse().unwrap();
787        let network: NetworkId = "parley-mainnet".parse().unwrap();
788        let s = canonical_string(
789            "GET",
790            "/v1/blobs/abc",
791            "",
792            1715299200,
793            &nonce,
794            &agent,
795            &network,
796            EMPTY_BODY_SHA256,
797        );
798        assert_eq!(s.lines().count(), 8);
799        assert!(s.starts_with("GET\n/v1/blobs/abc\n\n1715299200\n"));
800    }
801
802    #[test]
803    fn header_roundtrips() {
804        let agent: AgentPubkey = "u9PqJ4gK2mZ8t6nVxR3hB1cW7yE5dF0aQ4sT2lN6oU8"
805            .parse()
806            .unwrap();
807        let nonce: Nonce = "F4Yk8vN2j5QwK3zB1aR9oA".parse().unwrap();
808        let network: NetworkId = "parley-mainnet".parse().unwrap();
809        let sig = [7u8; 64];
810        let mldsa = vec![3u8; ML_DSA_SIG_BYTES];
811        let header = build_header_value(&agent, 1715299200, &nonce, &network, &sig, &mldsa);
812        let parsed = parse_header_value(&header).unwrap();
813        assert_eq!(parsed.v, SIGNATURE_VERSION);
814        assert_eq!(parsed.agent, agent);
815        assert_eq!(parsed.ts, 1715299200);
816        assert_eq!(parsed.nonce, nonce);
817        assert_eq!(parsed.network, network);
818        assert_eq!(parsed.sig, sig);
819        assert_eq!(parsed.mldsa_sig.as_deref(), Some(mldsa.as_slice()));
820    }
821
822    #[test]
823    fn header_tolerates_no_space_after_comma() {
824        let agent: AgentPubkey = "u9PqJ4gK2mZ8t6nVxR3hB1cW7yE5dF0aQ4sT2lN6oU8"
825            .parse()
826            .unwrap();
827        let nonce: Nonce = "F4Yk8vN2j5QwK3zB1aR9oA".parse().unwrap();
828        let network: NetworkId = "parley-mainnet".parse().unwrap();
829        let sig = [7u8; 64];
830        let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
831        let header =
832            format!("v=1,agent={agent},ts=1,nonce={nonce},network={network},sig={sig_b64}");
833        let parsed = parse_header_value(&header).unwrap();
834        assert_eq!(parsed.v, 1);
835    }
836
837    #[test]
838    fn sign_then_verify_roundtrip() {
839        use ed25519_dalek::{Signer as _, SigningKey};
840        let signing = SigningKey::from_bytes(&[42u8; 32]);
841        let agent = AgentPubkey::from_bytes(*signing.verifying_key().as_bytes());
842        let canonical = "GET\n/healthz\n\n0\n_\n_\n_\n_";
843        let sig = signing.sign(canonical.as_bytes()).to_bytes();
844        verify_signature(&agent, canonical, &sig).unwrap();
845        let mut bad = sig;
846        bad[0] ^= 1;
847        assert!(verify_signature(&agent, canonical, &bad).is_err());
848    }
849
850    #[test]
851    fn ml_dsa_sign_verify_roundtrip() {
852        use crate::keys::derive_auth_mldsa;
853        let kp = derive_auth_mldsa(&[42u8; crate::keys::SEED_BYTES]);
854        let pk = kp.verification_key.as_slice();
855        let canonical = "GET\n/healthz\n\n0\n_\n_\n_\n_";
856
857        let sig = ml_dsa_sign(&kp.signing_key, canonical).unwrap();
858        assert_eq!(sig.len(), ML_DSA_SIG_BYTES);
859        ml_dsa_verify(pk, canonical, &sig).unwrap();
860
861        // Tampered message rejected.
862        assert!(ml_dsa_verify(pk, "GET\n/other\n\n0\n_\n_\n_\n_", &sig).is_err());
863        // Tampered signature rejected.
864        let mut bad = sig.clone();
865        bad[0] ^= 1;
866        assert!(ml_dsa_verify(pk, canonical, &bad).is_err());
867        // Wrong-length key/sig rejected, not panicked.
868        assert!(ml_dsa_verify(&pk[..10], canonical, &sig).is_err());
869        assert!(ml_dsa_verify(pk, canonical, &sig[..10]).is_err());
870    }
871}