1use 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
22pub 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
36pub 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#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct AuthV2Config {
46 audience: String,
47 repository: String,
48}
49
50impl AuthV2Config {
51 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 #[must_use]
70 pub fn audience(&self) -> &str {
71 &self.audience
72 }
73
74 #[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
88pub(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
98pub 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
115pub 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
130pub(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
150pub 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
165pub(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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
213#[error("pack header differs from signed commitment")]
214pub struct PackCommitmentMismatch;
215
216pub 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 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 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 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}