Skip to main content

mkit_server/
auth_v2.rs

1//! Auth v2 glue (SPEC-TRANSPORT-CONNECT §7.1): read the ten headers, run
2//! [`mkit_core::write_auth::verify_headers`] and decode its result into a
3//! [`VerifiedAuth`]. Verification itself (canonical fields, validity window,
4//! strict Ed25519) stays in `mkit-core`; nothing here reimplements it.
5//!
6//! This is step 1, **authenticate**, of the signed-write order in §7.1. It
7//! writes no state; the replay lookup comes after it.
8//!
9//! The canonical copy of `apps/vcs-worker`'s `envelope.rs`, `hashing.rs`, the
10//! header reading in `worker_impl/auth.rs` and the pack commitment check in
11//! `worker_impl/service.rs`. The old copies go when `vcs-worker` switches in
12//! WP-M0-17. `apps/repo-worker` keeps its own copy (planner decision Q11).
13
14use mkit_core::hash::{hash, to_hex};
15use mkit_core::write_auth::{
16    AuthError, Context, ExpectedCommitment, Headers, validate_audience, verify_headers_with,
17};
18
19use crate::error::ServerError;
20use crate::op::{Commitment, Procedure, VerifiedAuth};
21
22/// The auth v2 request headers, lowercase, in [`Headers`] field order.
23pub const HEADER_NAMES: [&str; 10] = [
24    "x-envelope-version",
25    "x-audience",
26    "x-repository",
27    "x-public-key",
28    "x-signature",
29    "x-digest",
30    "x-content-commitment",
31    "x-created-at",
32    "x-expires-at",
33    "idempotency-key",
34];
35
36/// `Access-Control-Allow-Headers` for browser clients: the auth v2 headers
37/// plus the Connect request headers.
38pub const CORS_ALLOW_HEADERS: &str = "x-envelope-version, x-audience, x-repository, x-content-commitment, x-expires-at, x-public-key, x-signature, x-digest, x-created-at, \
39     idempotency-key, x-mkit-ref, x-write-grant, content-type, connect-protocol-version";
40
41/// The trusted audience and Single deployment's expected repository.
42/// In Multi mode the pipeline ignores this repository field and verifies
43/// against the resolved request identity instead.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct AuthV2Config {
46    audience: String,
47    repository: String,
48}
49
50impl AuthV2Config {
51    /// A configuration for `audience`, the deployment's canonical HTTP(S)
52    /// origin, and `repository`, the repository identity it serves.
53    ///
54    /// # Errors
55    /// `audience` is not a canonical origin.
56    pub fn new(
57        audience: impl Into<String>,
58        repository: impl Into<String>,
59    ) -> Result<Self, AuthError> {
60        let audience = audience.into();
61        validate_audience(&audience)?;
62        Ok(Self {
63            audience,
64            repository: repository.into(),
65        })
66    }
67
68    /// The canonical origin.
69    #[must_use]
70    pub fn audience(&self) -> &str {
71        &self.audience
72    }
73
74    /// The repository identity.
75    #[must_use]
76    pub fn repository(&self) -> &str {
77        &self.repository
78    }
79
80    fn context<'a>(&'a self, repository: &'a str) -> Context<'a> {
81        Context {
82            audience: &self.audience,
83            repository,
84        }
85    }
86}
87
88/// Whether the request carries any auth v2 marker header — even an empty
89/// or undecodable value counts (adapters fail closed, mapping bad bytes to
90/// a value). SPEC-TRANSPORT-CONNECT §7.1: a signed read verifies in full;
91/// it never falls back to anonymous.
92pub(crate) fn carries_auth_headers(get: impl Fn(&str) -> Option<String>) -> bool {
93    ["x-envelope-version", "x-public-key", "x-signature"]
94        .iter()
95        .any(|name| get(name).is_some())
96}
97
98/// Read the auth v2 headers through `get`, which looks a header up by its
99/// lowercase name. Values are passed through unnormalized.
100pub fn headers_from(get: impl Fn(&str) -> Option<String>) -> Headers {
101    Headers {
102        version: get(HEADER_NAMES[0]),
103        audience: get(HEADER_NAMES[1]),
104        repository: get(HEADER_NAMES[2]),
105        public_key: get(HEADER_NAMES[3]),
106        signature: get(HEADER_NAMES[4]),
107        digest: get(HEADER_NAMES[5]),
108        commitment: get(HEADER_NAMES[6]),
109        created_at: get(HEADER_NAMES[7]),
110        expires_at: get(HEADER_NAMES[8]),
111        idempotency_key: get(HEADER_NAMES[9]),
112    }
113}
114
115/// Authenticate a unary request: the signature must commit to
116/// `body:<BLAKE3 of body>` for `procedure_path` at `now_ms`.
117///
118/// # Errors
119/// [`crate::Code::Unauthenticated`] with `mkit-core`'s reason.
120pub fn verify_unary(
121    cfg: &AuthV2Config,
122    procedure_path: &str,
123    body: &[u8],
124    now_ms: i64,
125    headers: &Headers,
126) -> Result<VerifiedAuth, ServerError> {
127    verify_unary_for(cfg, cfg.repository(), procedure_path, body, now_ms, headers)
128}
129
130/// Verify against the stage-0 resolved repository in Multi mode.
131pub(crate) fn verify_unary_for(
132    cfg: &AuthV2Config,
133    repository: &str,
134    procedure_path: &str,
135    body: &[u8],
136    now_ms: i64,
137    headers: &Headers,
138) -> Result<VerifiedAuth, ServerError> {
139    let commitment = format!("body:{}", to_hex(&hash(body)));
140    verify(
141        cfg,
142        repository,
143        procedure_path,
144        Some(&commitment),
145        now_ms,
146        headers,
147    )
148}
149
150/// Authenticate a streaming upload: the signature must carry a `pack:`
151/// commitment, which [`check_pack_commitment`] later compares with the
152/// stream's header.
153///
154/// # Errors
155/// [`crate::Code::Unauthenticated`] with `mkit-core`'s reason.
156pub fn verify_stream(
157    cfg: &AuthV2Config,
158    procedure_path: &str,
159    now_ms: i64,
160    headers: &Headers,
161) -> Result<VerifiedAuth, ServerError> {
162    verify_stream_for(cfg, cfg.repository(), procedure_path, now_ms, headers)
163}
164
165/// Verify a streaming envelope against the resolved repository in Multi mode.
166pub(crate) fn verify_stream_for(
167    cfg: &AuthV2Config,
168    repository: &str,
169    procedure_path: &str,
170    now_ms: i64,
171    headers: &Headers,
172) -> Result<VerifiedAuth, ServerError> {
173    verify(cfg, repository, procedure_path, None, now_ms, headers)
174}
175
176fn verify(
177    cfg: &AuthV2Config,
178    repository: &str,
179    procedure_path: &str,
180    commitment: Option<&str>,
181    now_ms: i64,
182    headers: &Headers,
183) -> Result<VerifiedAuth, ServerError> {
184    let expected = if let Some(text) = commitment {
185        ExpectedCommitment::Exact(text)
186    } else if procedure_path == Procedure::UploadPart.connect_path() {
187        ExpectedCommitment::PartStream
188    } else {
189        ExpectedCommitment::PackStream
190    };
191    let authorized = verify_headers_with(
192        cfg.context(repository),
193        procedure_path,
194        expected,
195        now_ms,
196        headers,
197    )
198    .map_err(|e| ServerError::unauthenticated(e.0))?;
199    let mut auth = VerifiedAuth::try_from(&authorized)?;
200    // `verify_headers_with` accepted this canonical decimal.
201    auth.created_at_ms = headers
202        .created_at
203        .as_deref()
204        .and_then(|text| text.parse().ok())
205        .unwrap_or(0);
206    Ok(auth)
207}
208
209/// An `UploadPack` header that differs from the signed commitment. The
210/// caller picks the code: `unauthenticated` for the auth v2 `pack:`
211/// commitment, `permission_denied` for a ticket (SPEC-TRANSPORT-CONNECT §5).
212#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
213#[error("pack header differs from signed commitment")]
214pub struct PackCommitmentMismatch;
215
216/// Check an `UploadPack` header against the signed `pack:` commitment,
217/// before any quota is reserved or chunk read.
218///
219/// # Errors
220/// [`PackCommitmentMismatch`] when the commitment is not `pack:` or names a
221/// different id or length.
222pub fn check_pack_commitment(
223    auth: &VerifiedAuth,
224    pack_id: &[u8],
225    total_bytes: u64,
226) -> Result<(), PackCommitmentMismatch> {
227    match auth.commitment {
228        Commitment::Pack { id, len } if id.as_slice() == pack_id && len == total_bytes => Ok(()),
229        _ => Err(PackCommitmentMismatch),
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use ed25519_dalek::{Signer, SigningKey};
236    use mkit_core::hash::{from_hex, to_hex_bytes};
237    use mkit_core::write_auth::Operation;
238
239    use super::*;
240    use crate::error::Code;
241
242    fn golden() -> serde_json::Value {
243        serde_json::from_str(include_str!("../../../tests/golden/auth-v2/unary.json")).unwrap()
244    }
245
246    fn field(fixture: &serde_json::Value, name: &str) -> String {
247        fixture[name].as_str().unwrap().to_owned()
248    }
249
250    /// The fixture's headers, read through [`headers_from`].
251    fn golden_headers(fixture: &serde_json::Value) -> Headers {
252        let values = [
253            "2".to_owned(),
254            field(fixture, "audience"),
255            field(fixture, "repository"),
256            field(fixture, "public_key"),
257            field(fixture, "signature"),
258            field(fixture, "body_digest"),
259            field(fixture, "commitment"),
260            fixture["created_at"].as_i64().unwrap().to_string(),
261            fixture["expires_at"].as_i64().unwrap().to_string(),
262            field(fixture, "nonce"),
263        ];
264        headers_from(|name| {
265            HEADER_NAMES
266                .iter()
267                .position(|n| *n == name)
268                .map(|i| values[i].clone())
269        })
270    }
271
272    fn golden_config(fixture: &serde_json::Value) -> AuthV2Config {
273        AuthV2Config::new(field(fixture, "audience"), field(fixture, "repository")).unwrap()
274    }
275
276    #[test]
277    fn golden_unary_verifies() {
278        let fixture = golden();
279        let created_at = fixture["created_at"].as_i64().unwrap();
280        let auth = verify_unary(
281            &golden_config(&fixture),
282            &field(&fixture, "procedure"),
283            field(&fixture, "body").as_bytes(),
284            created_at + 1,
285            &golden_headers(&fixture),
286        )
287        .unwrap();
288        assert_eq!(
289            auth.commitment,
290            Commitment::Body(from_hex(&field(&fixture, "body_digest")).unwrap())
291        );
292        assert_eq!(to_hex(&auth.signer), field(&fixture, "public_key"));
293        assert_eq!(to_hex(&auth.fingerprint), field(&fixture, "signing_digest"));
294        assert_eq!(auth.nonce, field(&fixture, "nonce"));
295    }
296
297    #[test]
298    fn unary_failures_are_unauthenticated() {
299        let fixture = golden();
300        let cfg = golden_config(&fixture);
301        let procedure = field(&fixture, "procedure");
302        let body = field(&fixture, "body");
303        let created_at = fixture["created_at"].as_i64().unwrap();
304        let expires_at = fixture["expires_at"].as_i64().unwrap();
305        let headers = golden_headers(&fixture);
306        let other_audience =
307            AuthV2Config::new("https://other.example.test", cfg.repository()).unwrap();
308        let other_repo = AuthV2Config::new(cfg.audience(), "room-b").unwrap();
309        let cases: [(&AuthV2Config, &[u8], i64, &str); 4] = [
310            (
311                &other_audience,
312                body.as_bytes(),
313                created_at + 1,
314                "request audience or repository mismatch",
315            ),
316            (
317                &other_repo,
318                body.as_bytes(),
319                created_at + 1,
320                "request audience or repository mismatch",
321            ),
322            (
323                &cfg,
324                b"tampered body",
325                created_at + 1,
326                "content commitment mismatch",
327            ),
328            (
329                &cfg,
330                body.as_bytes(),
331                expires_at + 1,
332                "expired or future authorization",
333            ),
334        ];
335        for (cfg, body, now, reason) in cases {
336            let err = verify_unary(cfg, &procedure, body, now, &headers).unwrap_err();
337            assert_eq!(err.code(), Code::Unauthenticated);
338            assert_eq!(err.public_message(), reason);
339        }
340    }
341
342    #[test]
343    fn config_rejects_a_noncanonical_audience() {
344        for audience in ["https://API.example.test", "https://a.test/", "a.test"] {
345            assert!(AuthV2Config::new(audience, "room-a").is_err(), "{audience}");
346        }
347    }
348
349    const PACK_ID: [u8; 32] = [0xcd; 32];
350    const UPLOAD: &str = "/mkit.transport.v1.TransportService/UploadPack";
351
352    /// Headers for an `UploadPack` signed with the fixture's key over
353    /// `commitment`.
354    fn signed_stream(fixture: &serde_json::Value, commitment: &str) -> Headers {
355        signed_stream_for(fixture, UPLOAD, commitment)
356    }
357
358    fn signed_stream_for(
359        fixture: &serde_json::Value,
360        procedure: &str,
361        commitment: &str,
362    ) -> Headers {
363        let (audience, repository) = (field(fixture, "audience"), field(fixture, "repository"));
364        let nonce = field(fixture, "nonce");
365        let (created_at, expires_at) = (
366            fixture["created_at"].as_i64().unwrap(),
367            fixture["expires_at"].as_i64().unwrap(),
368        );
369        let operation = Operation {
370            context: Context {
371                audience: &audience,
372                repository: &repository,
373            },
374            procedure,
375            commitment,
376            created_at,
377            expires_at,
378            nonce: &nonce,
379        };
380        let seed: [u8; 32] = from_hex(&field(fixture, "seed")).unwrap();
381        let key = SigningKey::from_bytes(&seed);
382        let signature = key.sign(&operation.digest().unwrap());
383        Headers {
384            version: Some("2".into()),
385            audience: Some(audience.clone()),
386            repository: Some(repository.clone()),
387            public_key: Some(to_hex(key.verifying_key().as_bytes())),
388            signature: Some(to_hex_bytes(&signature.to_bytes())),
389            digest: None,
390            commitment: Some(commitment.to_owned()),
391            created_at: Some(created_at.to_string()),
392            expires_at: Some(expires_at.to_string()),
393            idempotency_key: Some(nonce.clone()),
394        }
395    }
396
397    #[test]
398    fn stream_verifies_and_checks_the_pack_header() {
399        let fixture = golden();
400        let now = fixture["created_at"].as_i64().unwrap() + 1;
401        let commitment = format!("pack:{}:12", to_hex(&PACK_ID));
402        let headers = signed_stream(&fixture, &commitment);
403        let auth = verify_stream(&golden_config(&fixture), UPLOAD, now, &headers).unwrap();
404        assert_eq!(
405            auth.commitment,
406            Commitment::Pack {
407                id: PACK_ID,
408                len: 12
409            }
410        );
411
412        check_pack_commitment(&auth, &PACK_ID, 12).unwrap();
413        for (id, len) in [
414            (&[0xce; 32][..], 12),
415            (&PACK_ID[..], 13),
416            (&PACK_ID[..31], 12),
417        ] {
418            let err = check_pack_commitment(&auth, id, len).unwrap_err();
419            assert_eq!(err, PackCommitmentMismatch);
420            assert_eq!(
421                err.to_string(),
422                "pack header differs from signed commitment"
423            );
424        }
425    }
426
427    #[test]
428    fn stream_without_a_pack_commitment_is_unauthenticated() {
429        let fixture = golden();
430        let now = fixture["created_at"].as_i64().unwrap() + 1;
431        let headers = signed_stream(&fixture, &field(&fixture, "commitment"));
432        let err = verify_stream(&golden_config(&fixture), UPLOAD, now, &headers).unwrap_err();
433        assert_eq!(err.code(), Code::Unauthenticated);
434        assert_eq!(err.public_message(), "stream requires a pack commitment");
435
436        // A verified unary authorization never passes the pack check.
437        let unary = verify_unary(
438            &golden_config(&fixture),
439            &field(&fixture, "procedure"),
440            field(&fixture, "body").as_bytes(),
441            now,
442            &golden_headers(&fixture),
443        )
444        .unwrap();
445        assert_eq!(
446            check_pack_commitment(&unary, &PACK_ID, 12),
447            Err(PackCommitmentMismatch)
448        );
449    }
450
451    #[test]
452    fn upload_part_selects_part_stream_commitment() {
453        let fixture = golden();
454        let now = fixture["created_at"].as_i64().unwrap() + 1;
455        let part = format!("part:{}:1:{}:8388608", "ab".repeat(32), "cd".repeat(32));
456        let path = Procedure::UploadPart.connect_path();
457        let headers = signed_stream_for(&fixture, path, &part);
458        let auth = verify_stream(&golden_config(&fixture), path, now, &headers).unwrap();
459        assert!(matches!(
460            auth.commitment,
461            Commitment::Part {
462                index: 1,
463                len: 8_388_608,
464                ..
465            }
466        ));
467        let wrong = signed_stream_for(&fixture, path, &format!("pack:{}:8388608", "cd".repeat(32)));
468        assert_eq!(
469            verify_stream(&golden_config(&fixture), path, now, &wrong)
470                .unwrap_err()
471                .public_message(),
472            "stream requires a part commitment"
473        );
474    }
475
476    #[test]
477    fn cors_allows_every_auth_header() {
478        for name in HEADER_NAMES {
479            assert!(
480                CORS_ALLOW_HEADERS.split(", ").any(|h| h.trim() == name),
481                "{name}"
482            );
483        }
484    }
485}