1use 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
21pub const SIGNATURE_HEADER: &str = "Parley-Signature";
23
24pub const SIGNATURE_VERSION: u32 = 2;
32
33pub const ML_DSA_PUBKEY_BYTES: usize = 1952;
35
36pub const ML_DSA_SIG_BYTES: usize = 3309;
38
39const ML_DSA_CONTEXT: &[u8] = b"parley-auth-v2";
41
42pub const EMPTY_BODY_SHA256: &str = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
45
46#[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#[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#[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#[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#[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 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
203pub 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 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
329pub 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
350pub 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
368pub 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 assert!(ml_dsa_verify(pk, "GET\n/other\n\n0\n_\n_\n_\n_", &sig).is_err());
504 let mut bad = sig.clone();
506 bad[0] ^= 1;
507 assert!(ml_dsa_verify(pk, canonical, &bad).is_err());
508 assert!(ml_dsa_verify(&pk[..10], canonical, &sig).is_err());
510 assert!(ml_dsa_verify(pk, canonical, &sig[..10]).is_err());
511 }
512}