1use crate::hash::{Hash, hash};
8use ed25519_dalek::{Signature, VerifyingKey};
9
10pub const DOMAIN: &str = "mkit-write:v2";
12pub const MAX_VALIDITY_MS: i64 = 300_000;
14pub const MAX_CLOCK_LEAD_MS: i64 = 30_000;
16
17#[derive(Clone, Copy, Debug)]
20pub struct Context<'a> {
21 pub audience: &'a str,
23 pub repository: &'a str,
25}
26
27#[derive(Clone, Copy, Debug)]
30pub struct Operation<'a> {
31 pub context: Context<'a>,
33 pub procedure: &'a str,
35 pub commitment: &'a str,
40 pub created_at: i64,
42 pub expires_at: i64,
44 pub nonce: &'a str,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
50#[error("{0}")]
51pub struct AuthError(pub &'static str);
52
53fn component(value: &str, max: usize) -> bool {
54 !value.is_empty() && value.len() <= max && value.bytes().all(|b| (0x21..=0x7e).contains(&b))
55}
56
57#[must_use]
59pub fn is_hex(value: &str, bytes: usize) -> bool {
60 value.len() == bytes * 2
61 && value
62 .bytes()
63 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
64}
65
66fn hex32(value: &str) -> Option<[u8; 32]> {
68 if is_hex(value, 32) {
69 crate::hash::from_hex(value).ok()
70 } else {
71 None
72 }
73}
74
75fn canonical_decimal<T: core::str::FromStr + ToString>(value: &str) -> Option<T> {
77 let number = value.parse::<T>().ok()?;
78 (number.to_string() == value).then_some(number)
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum ContentCommitment {
89 Body(Hash),
91 Pack {
93 id: Hash,
95 len: u64,
97 },
98 Part(PartCommitment),
101}
102
103#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct PartCommitment {
106 pub ticket: [u8; 32],
108 pub index: u32,
110 pub subtree: [u8; 32],
113 pub len: u64,
115}
116
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum CommitmentKind {
121 Body,
123 Pack,
125 Part,
127}
128
129impl ContentCommitment {
130 pub fn parse(text: &str) -> Result<Self, AuthError> {
139 if let Some(digest) = text.strip_prefix("body:") {
140 hex32(digest)
141 .map(Self::Body)
142 .ok_or(AuthError("invalid body commitment"))
143 } else if let Some(pack) = text.strip_prefix("pack:") {
144 let invalid = AuthError("invalid pack commitment");
145 let (digest, len) = pack.split_once(':').ok_or(invalid)?;
146 Ok(Self::Pack {
147 id: hex32(digest).ok_or(invalid)?,
148 len: canonical_decimal(len).ok_or(invalid)?,
149 })
150 } else if let Some(part) = text.strip_prefix("part:") {
151 Self::parse_part(part)
152 .map(Self::Part)
153 .ok_or(AuthError("invalid part commitment"))
154 } else {
155 Err(AuthError("unknown content commitment"))
156 }
157 }
158
159 fn parse_part(fields: &str) -> Option<PartCommitment> {
160 let mut fields = fields.split(':');
161 let ticket = hex32(fields.next()?)?;
162 let index = canonical_decimal(fields.next()?)?;
163 let subtree = hex32(fields.next()?)?;
164 let len = canonical_decimal(fields.next()?).filter(|&len| len != 0)?;
165 if fields.next().is_some() {
166 return None;
167 }
168 Some(PartCommitment {
169 ticket,
170 index,
171 subtree,
172 len,
173 })
174 }
175
176 #[must_use]
178 pub fn kind(&self) -> CommitmentKind {
179 match self {
180 Self::Body(_) => CommitmentKind::Body,
181 Self::Pack { .. } => CommitmentKind::Pack,
182 Self::Part(_) => CommitmentKind::Part,
183 }
184 }
185}
186
187impl core::fmt::Display for ContentCommitment {
188 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
189 use crate::hash::to_hex;
190 match self {
191 Self::Body(digest) => write!(f, "body:{}", to_hex(digest)),
192 Self::Pack { id, len } => write!(f, "pack:{}:{len}", to_hex(id)),
193 Self::Part(part) => write!(
194 f,
195 "part:{}:{}:{}:{}",
196 to_hex(&part.ticket),
197 part.index,
198 to_hex(&part.subtree),
199 part.len
200 ),
201 }
202 }
203}
204
205#[derive(Clone, Copy, Debug)]
207#[non_exhaustive]
208pub enum ExpectedCommitment<'a> {
209 Exact(&'a str),
211 PackStream,
214 PartStream,
221}
222
223pub fn validate_audience(value: &str) -> Result<(), AuthError> {
230 let (authority, default_port) = if let Some(v) = value.strip_prefix("https://") {
231 (v, "443")
232 } else if let Some(v) = value.strip_prefix("http://") {
233 (v, "80")
234 } else {
235 return Err(AuthError("audience must be a canonical HTTP(S) origin"));
236 };
237 if !component(value, 512)
238 || authority.is_empty()
239 || authority
240 .bytes()
241 .any(|b| b.is_ascii_uppercase() || b"/@?#\\".contains(&b))
242 {
243 return Err(AuthError("noncanonical audience"));
244 }
245 let (host, port) = if authority.starts_with('[') {
246 let end = authority
247 .find(']')
248 .ok_or(AuthError("invalid audience IPv6 host"))?;
249 let host = &authority[..=end];
250 host[1..host.len() - 1]
251 .parse::<std::net::Ipv6Addr>()
252 .map_err(|_| AuthError("invalid audience IPv6 host"))?;
253 let suffix = &authority[end + 1..];
254 (
255 host,
256 if suffix.is_empty() {
257 None
258 } else {
259 Some(
260 suffix
261 .strip_prefix(':')
262 .ok_or(AuthError("invalid audience port"))?,
263 )
264 },
265 )
266 } else {
267 let (host, port) = authority
268 .split_once(':')
269 .map_or((authority, None), |(h, p)| (h, Some(p)));
270 if host.is_empty()
271 || host.ends_with('.')
272 || !host
273 .bytes()
274 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b".-".contains(&b))
275 {
276 return Err(AuthError("invalid audience host"));
277 }
278 (host, port)
279 };
280 if host.is_empty() {
281 return Err(AuthError("invalid audience host"));
282 }
283 if let Some(port) = port {
284 let parsed = port
285 .parse::<u16>()
286 .map_err(|_| AuthError("invalid audience port"))?;
287 if parsed == 0 || port == default_port || parsed.to_string() != port {
288 return Err(AuthError("noncanonical audience port"));
289 }
290 }
291 Ok(())
292}
293
294impl Operation<'_> {
295 pub fn canonical(&self) -> Result<String, AuthError> {
301 validate_audience(self.context.audience)?;
302 if !component(self.context.repository, 255)
303 || !component(self.procedure, 512)
304 || !self.procedure.starts_with('/')
305 || !is_hex(self.nonce, 32)
306 {
307 return Err(AuthError("invalid repository, procedure or nonce"));
308 }
309 if self.created_at < 0
310 || self.expires_at <= self.created_at
311 || self
312 .expires_at
313 .checked_sub(self.created_at)
314 .is_none_or(|n| n > MAX_VALIDITY_MS)
315 {
316 return Err(AuthError("invalid validity interval"));
317 }
318 ContentCommitment::parse(self.commitment)?;
319 Ok(format!(
320 "{DOMAIN}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
321 self.context.audience,
322 self.context.repository,
323 self.procedure,
324 self.commitment,
325 self.created_at,
326 self.expires_at,
327 self.nonce
328 ))
329 }
330
331 pub fn digest(&self) -> Result<Hash, AuthError> {
336 Ok(hash(self.canonical()?.as_bytes()))
337 }
338
339 pub fn verify(
345 &self,
346 expected: Context<'_>,
347 now: i64,
348 public_key: &[u8; 32],
349 signature: &[u8; 64],
350 ) -> Result<(), AuthError> {
351 let digest = self.digest()?;
352 if self.context.audience != expected.audience
353 || self.context.repository != expected.repository
354 {
355 return Err(AuthError("request audience or repository mismatch"));
356 }
357 if now < 0
358 || self.created_at > now.saturating_add(MAX_CLOCK_LEAD_MS)
359 || now > self.expires_at
360 {
361 return Err(AuthError("expired or future authorization"));
362 }
363 let key =
364 VerifyingKey::from_bytes(public_key).map_err(|_| AuthError("invalid public key"))?;
365 key.verify_strict(&digest, &Signature::from_bytes(signature))
366 .map_err(|_| AuthError("invalid signature"))
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use ed25519_dalek::{Signer, SigningKey};
374
375 #[test]
376 fn shared_auth_v2_golden_matches_canonical_digest_and_signature() {
377 let fixture: serde_json::Value =
378 serde_json::from_str(include_str!("../../../tests/golden/auth-v2/unary.json")).unwrap();
379 let field = |name: &str| fixture[name].as_str().unwrap();
380 let operation = Operation {
381 context: Context {
382 audience: field("audience"),
383 repository: field("repository"),
384 },
385 procedure: field("procedure"),
386 commitment: field("commitment"),
387 created_at: fixture["created_at"].as_i64().unwrap(),
388 expires_at: fixture["expires_at"].as_i64().unwrap(),
389 nonce: field("nonce"),
390 };
391 assert_eq!(operation.canonical().unwrap(), field("canonical"));
392 assert_eq!(
393 crate::hash::to_hex(&operation.digest().unwrap()),
394 field("signing_digest")
395 );
396 assert_eq!(
397 crate::hash::to_hex(&hash(field("body").as_bytes())),
398 field("body_digest")
399 );
400 let headers = Headers {
401 version: Some("2".into()),
402 audience: Some(field("audience").into()),
403 repository: Some(field("repository").into()),
404 public_key: Some(field("public_key").into()),
405 signature: Some(field("signature").into()),
406 commitment: Some(field("commitment").into()),
407 digest: Some(field("body_digest").into()),
408 created_at: Some(operation.created_at.to_string()),
409 expires_at: Some(operation.expires_at.to_string()),
410 idempotency_key: Some(field("nonce").into()),
411 };
412 let authorized = verify_headers(
413 operation.context,
414 operation.procedure,
415 Some(operation.commitment),
416 operation.created_at + 1,
417 &headers,
418 )
419 .unwrap();
420 assert_eq!(authorized.fingerprint, field("signing_digest"));
421 assert!(
422 verify_headers(
423 operation.context,
424 operation.procedure,
425 Some(operation.commitment),
426 operation.expires_at + 1,
427 &headers
428 )
429 .is_err()
430 );
431 }
432
433 #[test]
434 fn destination_repository_and_pack_are_bound() {
435 let key = SigningKey::from_bytes(&[42; 32]);
436 let nonce = "ab".repeat(32);
437 let commitment = format!("pack:{}:12", "cd".repeat(32));
438 let mut operation = Operation {
439 context: Context {
440 audience: "https://a.example",
441 repository: "main",
442 },
443 procedure: "/mkit.transport.v1.TransportService/UploadPack",
444 commitment: &commitment,
445 created_at: 1000,
446 expires_at: 2000,
447 nonce: &nonce,
448 };
449 let signature = key.sign(&operation.digest().unwrap()).to_bytes();
450 let pk = key.verifying_key().to_bytes();
451 operation
452 .verify(operation.context, 1500, &pk, &signature)
453 .unwrap();
454 assert!(
455 operation
456 .verify(
457 Context {
458 audience: "https://b.example",
459 ..operation.context
460 },
461 1500,
462 &pk,
463 &signature
464 )
465 .is_err()
466 );
467 assert!(
468 operation
469 .verify(
470 Context {
471 repository: "other",
472 ..operation.context
473 },
474 1500,
475 &pk,
476 &signature
477 )
478 .is_err()
479 );
480 let changed = format!("pack:{}:13", "cd".repeat(32));
481 operation.commitment = &changed;
482 assert!(
483 operation
484 .verify(operation.context, 1500, &pk, &signature)
485 .is_err()
486 );
487 }
488
489 const UPLOAD_PART: &str = "/mkit.transport.v1.TransportService/UploadPart";
490
491 fn part_text(ticket: &str, index: &str, subtree: &str, len: &str) -> String {
492 format!("part:{ticket}:{index}:{subtree}:{len}")
493 }
494
495 fn signed_headers(operation: &Operation<'_>, key: &SigningKey) -> Headers {
497 let digest = operation
498 .commitment
499 .strip_prefix("body:")
500 .map(ToOwned::to_owned);
501 Headers {
502 version: Some("2".into()),
503 audience: Some(operation.context.audience.into()),
504 repository: Some(operation.context.repository.into()),
505 public_key: Some(crate::hash::to_hex(&key.verifying_key().to_bytes())),
506 signature: Some(crate::hash::to_hex_bytes(
507 &key.sign(&operation.digest().unwrap()).to_bytes(),
508 )),
509 commitment: Some(operation.commitment.into()),
510 digest,
511 created_at: Some(operation.created_at.to_string()),
512 expires_at: Some(operation.expires_at.to_string()),
513 idempotency_key: Some(operation.nonce.into()),
514 }
515 }
516
517 fn operation_with<'a>(commitment: &'a str, nonce: &'a str) -> Operation<'a> {
518 Operation {
519 context: Context {
520 audience: "https://a.example",
521 repository: "main",
522 },
523 procedure: UPLOAD_PART,
524 commitment,
525 created_at: 1000,
526 expires_at: 2000,
527 nonce,
528 }
529 }
530
531 #[test]
532 fn part_commitment_roundtrip() {
533 for index in [0, 1, u32::MAX] {
534 for len in [1, u64::MAX] {
535 let commitment = ContentCommitment::Part(PartCommitment {
536 ticket: [0x5a; 32],
537 index,
538 subtree: [0xcd; 32],
539 len,
540 });
541 let text = commitment.to_string();
542 assert_eq!(
543 text,
544 part_text(
545 &"5a".repeat(32),
546 &index.to_string(),
547 &"cd".repeat(32),
548 &len.to_string()
549 )
550 );
551 assert_eq!(ContentCommitment::parse(&text), Ok(commitment));
552 assert_eq!(commitment.kind(), CommitmentKind::Part);
553 }
554 }
555 for commitment in [
556 ContentCommitment::Body([1; 32]),
557 ContentCommitment::Pack {
558 id: [2; 32],
559 len: 0,
560 },
561 ContentCommitment::Pack {
562 id: [2; 32],
563 len: u64::MAX,
564 },
565 ] {
566 assert_eq!(
567 ContentCommitment::parse(&commitment.to_string()),
568 Ok(commitment)
569 );
570 }
571 assert_eq!(
572 ContentCommitment::Body([1; 32]).kind(),
573 CommitmentKind::Body
574 );
575 assert_eq!(
576 ContentCommitment::Pack {
577 id: [2; 32],
578 len: 3
579 }
580 .kind(),
581 CommitmentKind::Pack
582 );
583 }
584
585 #[test]
588 fn body_and_pack_parse_unchanged() {
589 let digest = "cd".repeat(32);
590 let nonce = "ab".repeat(32);
591 for (commitment, message) in [
592 (
593 format!("body:{}", digest.to_uppercase()),
594 "invalid body commitment",
595 ),
596 (format!("body:{}", &digest[..62]), "invalid body commitment"),
597 (format!("pack:{digest}:012"), "invalid pack commitment"),
598 (format!("pack:{digest}:+12"), "invalid pack commitment"),
599 (format!("pack:{digest}:"), "invalid pack commitment"),
600 (format!("pack:{digest}"), "invalid pack commitment"),
601 (
602 format!("pack:{digest}:18446744073709551616"),
603 "invalid pack commitment",
604 ),
605 (
606 format!("pack:{}:12", digest.to_uppercase()),
607 "invalid pack commitment",
608 ),
609 (format!("pack:{digest}:1:2"), "invalid pack commitment"),
610 (format!("part:{digest}:1"), "invalid part commitment"),
612 (String::new(), "unknown content commitment"),
613 (format!("blob:{digest}"), "unknown content commitment"),
614 (format!("BODY:{digest}"), "unknown content commitment"),
615 ] {
616 assert_eq!(
617 operation_with(&commitment, &nonce).canonical(),
618 Err(AuthError(message)),
619 "{commitment}"
620 );
621 }
622 for commitment in [
623 format!("body:{digest}"),
624 format!("pack:{digest}:0"),
625 format!("pack:{digest}:18446744073709551615"),
626 ] {
627 operation_with(&commitment, &nonce).canonical().unwrap();
628 }
629 }
630
631 #[test]
632 fn part_commitment_rejects() {
633 let hex = "cd".repeat(32);
634 let upper = hex.to_uppercase();
635 let short = &hex[..63];
636 let nonce = "ab".repeat(32);
637 for commitment in [
638 part_text(&upper, "1", &hex, "8"),
639 part_text(short, "1", &hex, "8"),
640 part_text(&format!("{hex}0"), "1", &hex, "8"),
641 part_text(&hex, "1", &upper, "8"),
642 part_text(&hex, "1", short, "8"),
643 part_text(&hex, "01", &hex, "8"),
644 part_text(&hex, "+1", &hex, "8"),
645 part_text(&hex, "-1", &hex, "8"),
646 part_text(&hex, "4294967296", &hex, "8"),
647 part_text(&hex, "1", &hex, "0"),
648 part_text(&hex, "1", &hex, "007"),
649 part_text(&hex, "1", &hex, "+7"),
650 part_text(&hex, "1", &hex, "18446744073709551616"),
651 part_text(&hex, "", &hex, "8"),
652 part_text(&hex, "1", &hex, ""),
653 part_text("", "1", &hex, "8"),
654 part_text(&hex, "1", "", "8"),
655 format!("part:{hex}:1:{hex}"),
656 format!("part:{hex}:1"),
657 format!("part:{hex}:1:{hex}:8:9"),
658 format!("{}:", part_text(&hex, "1", &hex, "8")),
659 format!("part::{}", part_text(&hex, "1", &hex, "8")),
660 "part:".to_owned(),
661 ] {
662 assert_eq!(
663 ContentCommitment::parse(&commitment),
664 Err(AuthError("invalid part commitment")),
665 "{commitment}"
666 );
667 assert_eq!(
668 operation_with(&commitment, &nonce).canonical(),
669 Err(AuthError("invalid part commitment")),
670 "{commitment}"
671 );
672 }
673 for (index, len) in [("0", "1"), ("4294967295", "18446744073709551615")] {
674 let commitment = part_text(&hex, index, &hex, len);
675 operation_with(&commitment, &nonce).canonical().unwrap();
676 }
677 }
678
679 fn part_golden() -> (serde_json::Value, Headers) {
681 let fixture: serde_json::Value =
682 serde_json::from_str(include_str!("../../../tests/golden/auth-v2/part.json")).unwrap();
683 let field = |name: &str| Some(fixture[name].as_str().unwrap().to_owned());
684 let headers = Headers {
685 version: Some("2".into()),
686 audience: field("audience"),
687 repository: field("repository"),
688 public_key: field("public_key"),
689 signature: field("signature"),
690 commitment: field("commitment"),
691 digest: None,
692 created_at: Some(fixture["created_at"].as_i64().unwrap().to_string()),
693 expires_at: Some(fixture["expires_at"].as_i64().unwrap().to_string()),
694 idempotency_key: field("nonce"),
695 };
696 (fixture, headers)
697 }
698
699 #[test]
700 fn verify_headers_with_part_stream_accepts_part_golden() {
701 let (fixture, headers) = part_golden();
702 let context = Context {
703 audience: fixture["audience"].as_str().unwrap(),
704 repository: fixture["repository"].as_str().unwrap(),
705 };
706 let procedure = fixture["procedure"].as_str().unwrap();
707 let now = fixture["created_at"].as_i64().unwrap() + 1;
708 let authorized = verify_headers_with(
709 context,
710 procedure,
711 ExpectedCommitment::PartStream,
712 now,
713 &headers,
714 )
715 .unwrap();
716 assert_eq!(authorized.fingerprint, fixture["signing_digest"]);
717 assert_eq!(authorized.commitment, fixture["commitment"]);
718 let ContentCommitment::Part(part) = authorized.content_commitment().unwrap() else {
719 panic!("expected a part commitment");
720 };
721 assert_eq!(part.ticket, [0x5a; 32]);
722 assert_eq!(part.index, 1);
723 let exact = fixture["commitment"].as_str().unwrap();
725 verify_headers_with(
726 context,
727 procedure,
728 ExpectedCommitment::Exact(exact),
729 now,
730 &headers,
731 )
732 .unwrap();
733 assert_eq!(
734 verify_headers(context, procedure, Some(exact), now, &headers),
735 Ok(authorized)
736 );
737 }
738
739 #[test]
740 fn verify_headers_none_still_rejects_part() {
741 let (fixture, headers) = part_golden();
742 let context = Context {
743 audience: fixture["audience"].as_str().unwrap(),
744 repository: fixture["repository"].as_str().unwrap(),
745 };
746 let now = fixture["created_at"].as_i64().unwrap() + 1;
747 for procedure in [fixture["procedure"].as_str().unwrap(), UPLOAD_PART] {
748 assert_eq!(
749 verify_headers(context, procedure, None, now, &headers),
750 Err(AuthError("stream requires a pack commitment"))
751 );
752 assert_eq!(
753 verify_headers_with(
754 context,
755 procedure,
756 ExpectedCommitment::PackStream,
757 now,
758 &headers
759 ),
760 Err(AuthError("stream requires a pack commitment"))
761 );
762 }
763 }
764
765 #[test]
766 fn part_stream_rejects_pack_and_body() {
767 let key = SigningKey::from_bytes(&[42; 32]);
768 let nonce = "ab".repeat(32);
769 let pack = format!("pack:{}:12", "cd".repeat(32));
770 let body = format!("body:{}", "cd".repeat(32));
771 for commitment in [&pack, &body] {
772 let operation = operation_with(commitment, &nonce);
773 let headers = signed_headers(&operation, &key);
774 assert_eq!(
775 verify_headers_with(
776 operation.context,
777 UPLOAD_PART,
778 ExpectedCommitment::PartStream,
779 1500,
780 &headers
781 ),
782 Err(AuthError("stream requires a part commitment")),
783 "{commitment}"
784 );
785 }
786 let operation = operation_with(&pack, &nonce);
788 let headers = signed_headers(&operation, &key);
789 let with = verify_headers_with(
790 operation.context,
791 UPLOAD_PART,
792 ExpectedCommitment::PackStream,
793 1500,
794 &headers,
795 )
796 .unwrap();
797 assert_eq!(
798 verify_headers(operation.context, UPLOAD_PART, None, 1500, &headers),
799 Ok(with)
800 );
801 let bad = format!("part:{}:01:{}:8", "5a".repeat(32), "cd".repeat(32));
803 let mut headers = signed_headers(&operation, &key);
804 headers.commitment = Some(bad);
805 assert_eq!(
806 verify_headers_with(
807 operation.context,
808 UPLOAD_PART,
809 ExpectedCommitment::PartStream,
810 1500,
811 &headers
812 ),
813 Err(AuthError("invalid part commitment"))
814 );
815 }
816
817 #[test]
818 fn authorized_content_commitment_roundtrip() {
819 let key = SigningKey::from_bytes(&[42; 32]);
820 let nonce = "ab".repeat(32);
821 let part = part_text(&"5a".repeat(32), "7", &"cd".repeat(32), "9");
822 for (commitment, expected) in [
823 (
824 format!("body:{}", "cd".repeat(32)),
825 ExpectedCommitment::Exact(
826 "body:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
827 ),
828 ),
829 (
830 format!("pack:{}:12", "cd".repeat(32)),
831 ExpectedCommitment::PackStream,
832 ),
833 (part, ExpectedCommitment::PartStream),
834 ] {
835 let operation = operation_with(&commitment, &nonce);
836 let headers = signed_headers(&operation, &key);
837 let authorized =
838 verify_headers_with(operation.context, UPLOAD_PART, expected, 1500, &headers)
839 .unwrap();
840 let typed = authorized.content_commitment().unwrap();
841 assert_eq!(typed.to_string(), authorized.commitment);
842 assert_eq!(typed, ContentCommitment::parse(&commitment).unwrap());
843 }
844 let hand_built = Authorized {
845 scope: String::new(),
846 public_key: String::new(),
847 nonce: String::new(),
848 fingerprint: String::new(),
849 commitment: "part:".into(),
850 expires_at: 0,
851 };
852 assert_eq!(
853 hand_built.content_commitment(),
854 Err(AuthError("invalid part commitment"))
855 );
856 }
857
858 #[test]
859 fn ambiguous_origins_are_rejected() {
860 for origin in [
861 "https://HOST",
862 "https://host/",
863 "https://host:443",
864 "https://u@host",
865 "https://host?x",
866 "https://host\nother",
867 "https://host:00444",
868 ] {
869 assert!(validate_audience(origin).is_err(), "{origin}");
870 }
871 for origin in ["https://host", "http://localhost:8080", "https://[::1]:444"] {
872 validate_audience(origin).unwrap();
873 }
874 }
875}
876
877#[derive(Clone, Debug, Default)]
880pub struct Headers {
881 pub version: Option<String>,
883 pub audience: Option<String>,
885 pub repository: Option<String>,
887 pub public_key: Option<String>,
889 pub signature: Option<String>,
891 pub digest: Option<String>,
893 pub commitment: Option<String>,
895 pub created_at: Option<String>,
897 pub expires_at: Option<String>,
899 pub idempotency_key: Option<String>,
901}
902
903#[derive(Clone, Debug, PartialEq, Eq)]
905pub struct Authorized {
906 pub scope: String,
908 pub public_key: String,
910 pub nonce: String,
912 pub fingerprint: String,
914 pub commitment: String,
916 pub expires_at: i64,
918}
919
920fn decimal(value: Option<&str>) -> Result<i64, AuthError> {
921 let value = value.ok_or(AuthError("missing validity header"))?;
922 let number = value
923 .parse::<i64>()
924 .map_err(|_| AuthError("invalid validity header"))?;
925 if number.to_string() != value {
926 return Err(AuthError("noncanonical validity header"));
927 }
928 Ok(number)
929}
930
931fn decode_hex<const N: usize>(value: Option<&str>) -> Result<[u8; N], AuthError> {
932 let value = value.ok_or(AuthError("missing signature header"))?;
933 if !is_hex(value, N) {
934 return Err(AuthError("noncanonical signature header"));
935 }
936 let mut output = [0; N];
937 for (byte, pair) in output.iter_mut().zip(value.as_bytes().chunks_exact(2)) {
938 let nibble = |b: u8| {
939 if b.is_ascii_digit() {
940 b - b'0'
941 } else {
942 b - b'a' + 10
943 }
944 };
945 *byte = nibble(pair[0]) * 16 + nibble(pair[1]);
946 }
947 Ok(output)
948}
949
950impl Authorized {
951 pub fn content_commitment(&self) -> Result<ContentCommitment, AuthError> {
957 ContentCommitment::parse(&self.commitment)
958 }
959}
960
961pub fn verify_headers(
972 expected: Context<'_>,
973 procedure: &str,
974 expected_commitment: Option<&str>,
975 now: i64,
976 headers: &Headers,
977) -> Result<Authorized, AuthError> {
978 verify_headers_with(
979 expected,
980 procedure,
981 expected_commitment.map_or(ExpectedCommitment::PackStream, ExpectedCommitment::Exact),
982 now,
983 headers,
984 )
985}
986
987pub fn verify_headers_with(
996 expected: Context<'_>,
997 procedure: &str,
998 commitment: ExpectedCommitment<'_>,
999 now: i64,
1000 headers: &Headers,
1001) -> Result<Authorized, AuthError> {
1002 fn required(value: Option<&str>) -> Result<&str, AuthError> {
1003 value.ok_or(AuthError("missing auth v2 header"))
1004 }
1005 if headers.version.as_deref() != Some("2") {
1006 return Err(AuthError("auth v2 required"));
1007 }
1008 let operation = Operation {
1009 context: Context {
1010 audience: required(headers.audience.as_deref())?,
1011 repository: required(headers.repository.as_deref())?,
1012 },
1013 procedure,
1014 commitment: required(headers.commitment.as_deref())?,
1015 created_at: decimal(headers.created_at.as_deref())?,
1016 expires_at: decimal(headers.expires_at.as_deref())?,
1017 nonce: required(headers.idempotency_key.as_deref())?,
1018 };
1019 match commitment {
1020 ExpectedCommitment::Exact(expected) => {
1021 if operation.commitment != expected {
1022 return Err(AuthError("content commitment mismatch"));
1023 }
1024 if let Some(digest) = expected.strip_prefix("body:")
1025 && headers.digest.as_deref() != Some(digest)
1026 {
1027 return Err(AuthError("body digest mismatch"));
1028 }
1029 }
1030 ExpectedCommitment::PackStream => {
1031 if !operation.commitment.starts_with("pack:") {
1032 return Err(AuthError("stream requires a pack commitment"));
1033 }
1034 }
1035 ExpectedCommitment::PartStream => {
1036 if !operation.commitment.starts_with("part:") {
1037 return Err(AuthError("stream requires a part commitment"));
1038 }
1039 }
1040 }
1041 operation.verify(
1042 expected,
1043 now,
1044 &decode_hex::<32>(headers.public_key.as_deref())?,
1045 &decode_hex::<64>(headers.signature.as_deref())?,
1046 )?;
1047 Ok(Authorized {
1048 scope: crate::hash::to_hex(&hash(
1049 format!(
1050 "{}\n{}\n{}\n{}",
1051 expected.audience,
1052 expected.repository,
1053 required(headers.public_key.as_deref())?,
1054 operation.nonce
1055 )
1056 .as_bytes(),
1057 )),
1058 public_key: required(headers.public_key.as_deref())?.to_owned(),
1059 nonce: operation.nonce.to_owned(),
1060 fingerprint: crate::hash::to_hex(&operation.digest()?),
1061 commitment: operation.commitment.to_owned(),
1062 expires_at: operation.expires_at,
1063 })
1064}