Skip to main content

mkit_core/
write_auth.rs

1//! Destination-bound signed request contract (SPEC-TRANSPORT-CONNECT, auth v2).
2//!
3//! This module is pure: it neither reads configuration nor reserves nonces.
4//! Callers supply trusted destination context and persist the verified operation
5//! alongside its effects. A valid signature alone does not prevent replay.
6
7use crate::hash::{Hash, hash};
8use ed25519_dalek::{Signature, VerifyingKey};
9
10/// Domain/version. v1 requests are never interpreted as v2 requests.
11pub const DOMAIN: &str = "mkit-write:v2";
12/// Maximum validity interval; replay records must survive at least until expiry.
13pub const MAX_VALIDITY_MS: i64 = 300_000;
14/// Maximum permitted clock lead of the sender.
15pub const MAX_CLOCK_LEAD_MS: i64 = 30_000;
16
17/// Trusted server identity. Values come from deployment configuration and the
18/// decoded request target, never from unverified forwarded headers.
19#[derive(Clone, Copy, Debug)]
20pub struct Context<'a> {
21    /// Canonical HTTP(S) origin, without a trailing slash or default port.
22    pub audience: &'a str,
23    /// Repository/room identity within that service.
24    pub repository: &'a str,
25}
26
27/// Fields authenticated by a v2 request signature. All string fields are
28/// bounded and newline-free, making the newline-separated encoding unambiguous.
29#[derive(Clone, Copy, Debug)]
30pub struct Operation<'a> {
31    /// Intended service and repository.
32    pub context: Context<'a>,
33    /// Full Connect procedure, or the documented full procedure for REST.
34    pub procedure: &'a str,
35    /// Canonical content commitment text: `body:<64 lowercase hex>`,
36    /// `pack:<64 lowercase hex>:<decimal length>` or
37    /// `part:<64 hex ticket>:<decimal index>:<64 hex subtree>:<decimal length>`
38    /// (see [`ContentCommitment`]).
39    pub commitment: &'a str,
40    /// Inclusive start time in epoch milliseconds.
41    pub created_at: i64,
42    /// Inclusive expiry time in epoch milliseconds.
43    pub expires_at: i64,
44    /// 32 random bytes, encoded as 64 lowercase hexadecimal characters.
45    pub nonce: &'a str,
46}
47
48/// A malformed envelope, wrong destination, expired request or bad signature.
49#[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/// Whether a value is the fixed-length canonical lowercase hexadecimal form.
58#[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
66/// Canonical 64-hex digest: lowercase fixed-length hex only.
67fn 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
75/// Canonical unsigned decimal: no sign, no leading zero (`0` itself allowed).
76fn 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/// A parsed auth v2 content commitment (SPEC-TRANSPORT-CONNECT §7.1, §7.6).
82///
83/// [`ContentCommitment::parse`] accepts only the canonical text form, and
84/// [`Display`](core::fmt::Display) writes it back, so
85/// `parse(&c.to_string()) == Ok(c)`.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum ContentCommitment {
89    /// `body:<64 hex>`: BLAKE3 of the exact unary request bytes.
90    Body(Hash),
91    /// `pack:<64 hex>:<decimal len>`: an `UploadPack` stream.
92    Pack {
93        /// Pack id (BLAKE3 of the whole pack).
94        id: Hash,
95        /// Pack byte count.
96        len: u64,
97    },
98    /// `part:<64 hex ticket>:<decimal index>:<64 hex subtree>:<decimal len>`:
99    /// one `UploadPart` stream (SPEC-TRANSPORT-CONNECT §7.6).
100    Part(PartCommitment),
101}
102
103/// The fields of a `part:` commitment (SPEC-TRANSPORT-CONNECT §7.6).
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub struct PartCommitment {
106    /// Upload ticket id.
107    pub ticket: [u8; 32],
108    /// Zero-based part index.
109    pub index: u32,
110    /// The part's BLAKE3 chaining value as a non-root subtree at offset
111    /// `index × part_size` (see [`crate::upload_parts`]).
112    pub subtree: [u8; 32],
113    /// Part byte count; never zero.
114    pub len: u64,
115}
116
117/// The kind of a [`ContentCommitment`], without its fields.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119#[non_exhaustive]
120pub enum CommitmentKind {
121    /// `body:`
122    Body,
123    /// `pack:`
124    Pack,
125    /// `part:`
126    Part,
127}
128
129impl ContentCommitment {
130    /// Strict canonical parse: lowercase fixed-length hex; canonical decimals
131    /// (no sign, no leading zero, `0` allowed for a `part:` index); a `part:`
132    /// length of at least 1; an index that fits `u32`; exactly four fields
133    /// after `part:`.
134    ///
135    /// # Errors
136    /// `invalid body commitment`, `invalid pack commitment`,
137    /// `invalid part commitment` or `unknown content commitment`.
138    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    /// The commitment's kind.
177    #[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/// What a verifier expects the signed content commitment to be.
206#[derive(Clone, Copy, Debug)]
207#[non_exhaustive]
208pub enum ExpectedCommitment<'a> {
209    /// Unary: exactly this text, plus the `X-Digest` check for `body:`.
210    Exact(&'a str),
211    /// `UploadPack` stream: any well-formed `pack:`. The handler compares it
212    /// with the first stream message itself.
213    PackStream,
214    /// `UploadPart` stream: any well-formed `part:`. Verification does not
215    /// bind the commitment to a ticket, so the handler (WP-1.11) MUST, before
216    /// reading any part byte, compare the ticket id and index with the first
217    /// stream message and call
218    /// [`PartPlan::check`](crate::upload_parts::PartPlan::check) with the
219    /// ticket's plan, which enforces `<len>` (SPEC-TRANSPORT-CONNECT §7.6).
220    PartStream,
221}
222
223/// Validate the canonical deployment origin. DNS names are ASCII lowercase;
224/// international names must be their ASCII URL form. No userinfo, path, query,
225/// fragment, trailing dot or default port is accepted.
226///
227/// # Errors
228/// Returns an error for an ambiguous or noncanonical origin.
229pub 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    /// Encode all authenticated fields in the v2 canonical order.
296    ///
297    /// # Errors
298    /// Rejects malformed fields, invalid validity intervals and unknown content
299    /// commitment forms before signing or verification.
300    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    /// Digest signed with plain Ed25519 (without commit-signing domains).
332    ///
333    /// # Errors
334    /// Same validation errors as [`Self::canonical`].
335    pub fn digest(&self) -> Result<Hash, AuthError> {
336        Ok(hash(self.canonical()?.as_bytes()))
337    }
338
339    /// Validate destination, time and Ed25519 signature. Persistence/effect
340    /// adapters must reserve the nonce after this returns, before any effects.
341    ///
342    /// # Errors
343    /// Rejects destination mismatch, stale/future requests and invalid signatures.
344    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    /// Headers for `operation`, signed by `key`.
496    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    /// The reject cases of `mkit-server`'s `verified_auth_rejects_noncanonical_fields`
586    /// keep their `Operation::canonical` messages.
587    #[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            // Formerly an unknown kind; now a malformed `part:`.
611            (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    /// The committed `part.json` golden (see `tests/golden_uploads.rs`).
680    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        // The exact expectation accepts it too, with no body digest.
724        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        // The pack stream still accepts a pack commitment, both ways.
787        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        // A malformed part commitment fails canonical validation.
802        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/// Transport-independent v2 header values. Adapters must not normalize signed
878/// fields on read; noncanonical representations are rejected.
879#[derive(Clone, Debug, Default)]
880pub struct Headers {
881    /// Must be exactly `2`.
882    pub version: Option<String>,
883    /// Canonical intended origin.
884    pub audience: Option<String>,
885    /// Intended repository.
886    pub repository: Option<String>,
887    /// Raw Ed25519 public key, canonical hexadecimal.
888    pub public_key: Option<String>,
889    /// Raw Ed25519 signature, canonical hexadecimal.
890    pub signature: Option<String>,
891    /// Unary body digest (retained as an independently checked header).
892    pub digest: Option<String>,
893    /// Full typed content commitment.
894    pub commitment: Option<String>,
895    /// Canonical decimal start milliseconds.
896    pub created_at: Option<String>,
897    /// Canonical decimal expiry milliseconds.
898    pub expires_at: Option<String>,
899    /// Stable operation nonce; retained across transport retries.
900    pub idempotency_key: Option<String>,
901}
902
903/// Validated identity and operation binding for a transactional effect adapter.
904#[derive(Clone, Debug, PartialEq, Eq)]
905pub struct Authorized {
906    /// Destination/repository/signer/nonce replay namespace.
907    pub scope: String,
908    /// Author authenticated by Ed25519.
909    pub public_key: String,
910    /// Stable nonce within the destination/repository/signer scope.
911    pub nonce: String,
912    /// Digest of all authenticated operation fields, including times/nonce.
913    pub fingerprint: String,
914    /// Canonical content commitment text; see [`Self::content_commitment`].
915    pub commitment: String,
916    /// Replay records cannot be removed before this timestamp has passed.
917    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    /// Typed view of [`Self::commitment`], which is canonical whenever the
952    /// verifier produced this value.
953    ///
954    /// # Errors
955    /// The [`ContentCommitment::parse`] errors, for a hand-built value only.
956    pub fn content_commitment(&self) -> Result<ContentCommitment, AuthError> {
957        ContentCommitment::parse(&self.commitment)
958    }
959}
960
961/// Verify adapter headers against deployment-owned context and actual content.
962/// `expected_commitment` is required for unary operations; with `None`, an
963/// `UploadPack` handler may defer that comparison until the first header,
964/// before attributed effects. Same as [`verify_headers_with`] with
965/// [`ExpectedCommitment::Exact`], or [`ExpectedCommitment::PackStream`] for
966/// `None`.
967///
968/// # Errors
969/// Rejects legacy/missing versions, malformed fields, context/content mismatch,
970/// validity-window failures and invalid signatures.
971pub 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
987/// Verify adapter headers against deployment-owned context and actual
988/// content, with the commitment expectation of a unary call, an
989/// `UploadPack` stream or an `UploadPart` stream.
990///
991/// # Errors
992/// Rejects legacy/missing versions, malformed fields, context/content mismatch,
993/// a commitment of the wrong kind for the stream, validity-window failures
994/// and invalid signatures.
995pub 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}