1use 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
24pub const SIGNATURE_HEADER: &str = "Parley-Signature";
26
27pub const SIGNATURE_VERSION: u32 = 2;
35
36pub const ML_DSA_PUBKEY_BYTES: usize = 1952;
38
39pub const ML_DSA_SIG_BYTES: usize = 3309;
41
42const ML_DSA_CONTEXT: &[u8] = b"parley-auth-v2";
44
45pub const EMPTY_BODY_SHA256: &str = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
48
49#[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#[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#[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#[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#[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 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
206pub 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 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
332pub 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
353pub const POST_CONTEXT: &str = "parley-post-v1";
361
362#[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#[must_use]
393pub fn sign_post(signing: &SigningKey, canonical: &str) -> [u8; 64] {
394 signing.sign(canonical.as_bytes()).to_bytes()
395}
396
397pub fn verify_post(
400 author: &AgentPubkey,
401 canonical: &str,
402 sig: &[u8; 64],
403) -> Result<(), SignatureVerifyError> {
404 verify_signature(author, canonical, sig)
405}
406
407pub const SEALED_SIGNATURE_VERSION: u32 = 3;
423
424pub const SEALED_LABEL: &str = "parley sealed-sender v1";
426
427pub const SEALED_KEY_BYTES: usize = 32;
429
430type HmacSha256 = Hmac<Sha256>;
431
432#[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#[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#[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
486pub 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#[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#[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
538pub 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
647pub 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
665pub 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 assert!(verify_sealed_token(&key, "canonical-B", &mac).is_err());
750 assert!(verify_sealed_token(&[2u8; SEALED_KEY_BYTES], "canonical-A", &mac).is_err());
752 let mut bad = mac;
754 bad[0] ^= 0xFF;
755 assert!(verify_sealed_token(&key, "canonical-A", &bad).is_err());
756 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 assert!(ml_dsa_verify(pk, "GET\n/other\n\n0\n_\n_\n_\n_", &sig).is_err());
863 let mut bad = sig.clone();
865 bad[0] ^= 1;
866 assert!(ml_dsa_verify(pk, canonical, &bad).is_err());
867 assert!(ml_dsa_verify(&pk[..10], canonical, &sig).is_err());
869 assert!(ml_dsa_verify(pk, canonical, &sig[..10]).is_err());
870 }
871}