1use std::{collections::HashMap, sync::Arc};
9
10use async_trait::async_trait;
11use flamingo_verifier_client::{
12 Config, Error as ClientError, FlamingoVerifierClient, PcrMeasurement,
13 VerifiedAssignment,
14};
15use flamingo_verifier_sealed_types::{FailureReason, MatchInputs, MatchResult};
16use reqwest::{
17 header::{HeaderMap, HeaderName, HeaderValue, COOKIE},
18 Url,
19};
20use thiserror::Error;
21use tokio::sync::OnceCell;
22
23#[derive(Debug, uniffi::Object)]
25pub struct FlamingoMatcher {
26 host_url: Url,
27 config: Option<Config>,
28 headers: HeaderMap,
29 client: OnceCell<FlamingoVerifierClient>,
30}
31
32#[derive(Debug, uniffi::Record)]
37pub struct FlamingoMatchRequest {
38 pub live_image: Vec<u8>,
40 pub credential_image: Vec<u8>,
42 pub hashes_json: Vec<u8>,
44 pub light_guard_image: Option<Vec<u8>>,
46 pub challenge_image: Vec<u8>,
48 pub match_threshold: f32,
50}
51
52#[derive(Debug, uniffi::Object)]
57pub struct VerifiedMatchToken {
58 token: Vec<u8>,
59 signing_key_attestation: Vec<u8>,
60}
61
62#[derive(Debug, uniffi::Enum)]
64pub enum FlamingoMatchOutcome {
65 Matched(Arc<VerifiedMatchToken>),
67 Rejected(FlamingoMatchRejection),
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
75pub enum FlamingoMatchRejection {
76 MalformedInputs,
78 InvalidHashesJson,
80 ThumbnailHashMismatch,
82 MatchBelowThreshold,
84 ImageAnalysisFailed,
86}
87
88#[derive(Debug, Error, uniffi::Error)]
90pub enum FlamingoError {
91 #[error("invalid {attribute}: {reason}")]
93 InvalidInput {
94 attribute: String,
96 reason: String,
98 },
99 #[error("invalid Flamingo verifier configuration: {0}")]
101 Configuration(String),
102 #[error("Flamingo verifier request failed: {0}")]
104 Verifier(String),
105}
106
107#[async_trait]
108trait MatchClient: Sync {
109 type Assignment: Send + Sync;
110
111 async fn request_assignment(&self) -> Result<Self::Assignment, ClientError>;
112
113 async fn request_match(
114 &self,
115 assignment: &Self::Assignment,
116 inputs: &MatchInputs,
117 ) -> Result<MatchResult, ClientError>;
118}
119
120#[uniffi::export(async_runtime = "tokio")]
121impl FlamingoMatcher {
122 #[uniffi::constructor]
128 pub fn new(host_url: &str) -> Result<Self, FlamingoError> {
129 let host_url = Url::parse(host_url)
130 .map_err(|error| FlamingoError::Configuration(error.to_string()))?;
131 if !matches!(host_url.scheme(), "http" | "https")
132 || host_url.host_str().is_none()
133 {
134 return Err(FlamingoError::Configuration(
135 "host_url must be an absolute HTTP(S) URL".to_string(),
136 ));
137 }
138 Ok(Self {
139 host_url,
140 config: None,
141 headers: HeaderMap::new(),
142 client: OnceCell::new(),
143 })
144 }
145
146 pub fn with_measurements(
154 &self,
155 measurements: HashMap<u32, Vec<u8>>,
156 ) -> Result<Self, FlamingoError> {
157 Ok(Self {
158 host_url: self.host_url.clone(),
159 config: Some(matcher_config(self.host_url.as_str(), measurements)?),
160 headers: self.headers.clone(),
161 client: OnceCell::new(),
162 })
163 }
164
165 pub fn with_headers(
173 &self,
174 headers: HashMap<String, String>,
175 ) -> Result<Self, FlamingoError> {
176 Ok(Self {
177 host_url: self.host_url.clone(),
178 config: self.config.clone(),
179 headers: parse_headers(headers)?,
180 client: OnceCell::new(),
181 })
182 }
183
184 pub async fn perform_match(
196 &self,
197 request: FlamingoMatchRequest,
198 ) -> Result<FlamingoMatchOutcome, FlamingoError> {
199 perform_match(self.client().await?, request).await
200 }
201}
202
203impl FlamingoMatcher {
204 async fn client(&self) -> Result<&FlamingoVerifierClient, FlamingoError> {
205 self.client
206 .get_or_try_init(|| async {
207 let config = self.config.clone().ok_or_else(|| {
208 FlamingoError::Configuration(
209 "trusted enclave measurements must be supplied with with_measurements"
210 .to_string(),
211 )
212 })?;
213 let http = reqwest::Client::builder().default_headers(self.headers.clone());
214 FlamingoVerifierClient::with_http_client_builder(config, http)
215 .map_err(|error| FlamingoError::Verifier(error.to_string()))
216 })
217 .await
218 }
219}
220
221impl FlamingoMatchRequest {
222 fn validate(&self) -> Result<(), FlamingoError> {
223 for (attribute, bytes) in [
224 ("live_image", self.live_image.as_slice()),
225 ("credential_image", self.credential_image.as_slice()),
226 ("hashes_json", self.hashes_json.as_slice()),
227 ] {
228 if bytes.is_empty() {
229 return Err(FlamingoError::InvalidInput {
230 attribute: attribute.to_string(),
231 reason: "must not be empty".to_string(),
232 });
233 }
234 }
235
236 if self.challenge_image.is_empty() {
237 return Err(FlamingoError::InvalidInput {
238 attribute: "challenge_image".to_string(),
239 reason: "must not be empty".to_string(),
240 });
241 }
242
243 if self.light_guard_image.as_ref().is_some_and(Vec::is_empty) {
244 return Err(FlamingoError::InvalidInput {
245 attribute: "light_guard_image".to_string(),
246 reason: "must not be empty when provided".to_string(),
247 });
248 }
249
250 if !self.match_threshold.is_finite()
251 || !(0.0..=1.0).contains(&self.match_threshold)
252 {
253 return Err(FlamingoError::InvalidInput {
254 attribute: "match_threshold".to_string(),
255 reason: "must be finite and between 0 and 1 inclusive".to_string(),
256 });
257 }
258
259 Ok(())
260 }
261
262 fn into_inputs(self) -> MatchInputs {
263 MatchInputs {
264 live_image: self.live_image,
265 credential_image: self.credential_image,
266 light_guard_image: self.light_guard_image,
267 hashes_json: self.hashes_json,
268 challenge_image: self.challenge_image,
269 match_threshold: self.match_threshold,
270 }
271 }
272}
273
274impl VerifiedMatchToken {
275 #[must_use]
277 pub fn as_bytes(&self) -> &[u8] {
278 &self.token
279 }
280
281 #[must_use]
283 pub fn signing_key_attestation(&self) -> &[u8] {
284 &self.signing_key_attestation
285 }
286}
287
288impl From<FailureReason> for FlamingoMatchRejection {
289 fn from(value: FailureReason) -> Self {
290 match value {
291 FailureReason::MalformedInputs => Self::MalformedInputs,
292 FailureReason::InvalidHashesJson => Self::InvalidHashesJson,
293 FailureReason::ThumbnailHashMismatch => Self::ThumbnailHashMismatch,
294 FailureReason::MatchBelowThreshold => Self::MatchBelowThreshold,
295 FailureReason::ImageAnalysisFailed => Self::ImageAnalysisFailed,
296 }
297 }
298}
299
300#[async_trait]
301impl MatchClient for FlamingoVerifierClient {
302 type Assignment = VerifiedAssignment;
303
304 async fn request_assignment(&self) -> Result<Self::Assignment, ClientError> {
305 self.request_assignment().await
306 }
307
308 async fn request_match(
309 &self,
310 assignment: &Self::Assignment,
311 inputs: &MatchInputs,
312 ) -> Result<MatchResult, ClientError> {
313 self.request_match(assignment, inputs).await
314 }
315}
316
317fn parse_headers(headers: HashMap<String, String>) -> Result<HeaderMap, FlamingoError> {
318 let mut parsed = HeaderMap::new();
319 for (name, value) in headers {
320 let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
321 FlamingoError::Configuration("invalid HTTP header name".to_string())
322 })?;
323 if name == COOKIE {
324 return Err(FlamingoError::Configuration(
325 "Cookie is managed by the client's affinity cookie store".to_string(),
326 ));
327 }
328 let mut value = HeaderValue::from_str(&value).map_err(|_| {
329 FlamingoError::Configuration("invalid HTTP header value".to_string())
330 })?;
331 value.set_sensitive(true);
332 if parsed.insert(name, value).is_some() {
333 return Err(FlamingoError::Configuration(
334 "duplicate HTTP header name (names are case-insensitive)".to_string(),
335 ));
336 }
337 }
338 Ok(parsed)
339}
340
341fn matcher_config(
342 host_url: &str,
343 measurements: HashMap<u32, Vec<u8>>,
344) -> Result<Config, FlamingoError> {
345 for index in 0..=2 {
346 if !measurements.contains_key(&index) {
347 return Err(FlamingoError::Configuration(format!(
348 "PCR{index} must be supplied"
349 )));
350 }
351 }
352 let mut pcrs = Vec::with_capacity(measurements.len());
353 for (index, measurement) in measurements {
354 if measurement.len() != 48 {
355 return Err(FlamingoError::Configuration(format!(
356 "PCR{index} must be exactly 48 bytes"
357 )));
358 }
359 if measurement.iter().all(|byte| *byte == 0) {
360 return Err(FlamingoError::Configuration(format!(
361 "PCR{index} must be nonzero; debug enclaves are not accepted"
362 )));
363 }
364 pcrs.push(PcrMeasurement::new(index, measurement));
365 }
366 pcrs.sort_unstable_by_key(|pcr| pcr.index);
367 Config::new(host_url, vec![pcrs])
368 .map_err(|error| FlamingoError::Configuration(error.to_string()))
369}
370
371async fn perform_match<C: MatchClient>(
372 client: &C,
373 request: FlamingoMatchRequest,
374) -> Result<FlamingoMatchOutcome, FlamingoError> {
375 request.validate()?;
376 let request = request.into_inputs();
377 let mut reassigned = false;
378
379 loop {
380 let assignment = client
381 .request_assignment()
382 .await
383 .map_err(|error| verifier_error(&error))?;
384
385 match client.request_match(&assignment, &request).await {
386 Ok(MatchResult::Success(statement)) => {
387 return Ok(FlamingoMatchOutcome::Matched(Arc::new(
388 VerifiedMatchToken {
389 token: statement.token.into_bytes(),
390 signing_key_attestation: statement.signing_key_attestation,
391 },
392 )));
393 }
394 Ok(MatchResult::Failed(reason)) => {
395 return Ok(FlamingoMatchOutcome::Rejected(reason.into()));
396 }
397 Err(ClientError::ReassignRequired) if !reassigned => reassigned = true,
398 Err(error) => return Err(verifier_error(&error)),
399 }
400 }
401}
402
403fn verifier_error(error: &ClientError) -> FlamingoError {
404 FlamingoError::Verifier(error.to_string())
405}
406
407#[cfg(test)]
408mod tests {
409 use std::{
410 collections::{HashMap, VecDeque},
411 sync::{
412 atomic::{AtomicUsize, Ordering},
413 Mutex,
414 },
415 };
416
417 use flamingo_verifier_client::Error as ClientError;
418 use flamingo_verifier_protocol::match_token::MatchToken;
419 use flamingo_verifier_sealed_types::{
420 AttestedStatement, FailureReason, MatchInputs, MatchResult,
421 };
422
423 use super::{
424 perform_match, FlamingoError, FlamingoMatchOutcome, FlamingoMatchRejection,
425 FlamingoMatchRequest, FlamingoMatcher, MatchClient,
426 };
427
428 struct FakeClient {
429 assignments: AtomicUsize,
430 results: Mutex<VecDeque<Result<MatchResult, ClientError>>>,
431 }
432
433 impl FakeClient {
434 fn new(
435 results: impl IntoIterator<Item = Result<MatchResult, ClientError>>,
436 ) -> Self {
437 Self {
438 assignments: AtomicUsize::new(0),
439 results: Mutex::new(results.into_iter().collect()),
440 }
441 }
442 }
443
444 #[async_trait::async_trait]
445 impl MatchClient for FakeClient {
446 type Assignment = usize;
447
448 async fn request_assignment(&self) -> Result<Self::Assignment, ClientError> {
449 Ok(self.assignments.fetch_add(1, Ordering::Relaxed))
450 }
451
452 async fn request_match(
453 &self,
454 _assignment: &Self::Assignment,
455 _inputs: &MatchInputs,
456 ) -> Result<MatchResult, ClientError> {
457 self.results
458 .lock()
459 .expect("fake result lock should not be poisoned")
460 .pop_front()
461 .expect("test should provide one result per request")
462 }
463 }
464
465 fn request() -> FlamingoMatchRequest {
466 FlamingoMatchRequest {
467 live_image: b"live".to_vec(),
468 credential_image: b"credential".to_vec(),
469 hashes_json: br#"{"thumbnail.png":"00"}"#.to_vec(),
470 light_guard_image: None,
471 challenge_image: b"challenge".to_vec(),
472 match_threshold: 0.7,
473 }
474 }
475
476 fn measurements() -> HashMap<u32, Vec<u8>> {
477 HashMap::from([(0, vec![1; 48]), (1, vec![2; 48]), (2, vec![3; 48])])
478 }
479
480 fn headers() -> HashMap<String, String> {
481 HashMap::from([
482 ("Authorization".to_string(), "Bearer test-token".to_string()),
483 ("client-name".to_string(), "test-client".to_string()),
484 ])
485 }
486
487 #[test]
488 fn custom_measurements_preserve_required_and_additional_pcrs() {
489 let mut pins = measurements();
490 pins.insert(8, vec![4; 48]);
491 let config =
492 super::matcher_config("https://verifier.example.com", pins).unwrap();
493 let json = serde_json::to_value(config).unwrap();
494 assert_eq!(json["allowed_pcr_configs"].as_array().unwrap().len(), 1);
495 assert_eq!(json["allowed_pcr_configs"][0].as_array().unwrap().len(), 4);
496 for (position, (index, measurement)) in
497 [(0, [1; 48]), (1, [2; 48]), (2, [3; 48]), (8, [4; 48])]
498 .into_iter()
499 .enumerate()
500 {
501 assert_eq!(json["allowed_pcr_configs"][0][position]["index"], index);
502 assert_eq!(
503 json["allowed_pcr_configs"][0][position]["value"],
504 hex::encode(measurement)
505 );
506 }
507 }
508
509 #[test]
510 fn rejects_zero_or_malformed_measurements() {
511 let matcher = FlamingoMatcher::new("https://verifier.example.com").unwrap();
512 for index in [0, 1, 2, 8] {
513 for invalid in [vec![0; 48], vec![], vec![1; 47], vec![1; 49]] {
514 let mut pins = measurements();
515 pins.insert(index, invalid);
516 assert!(matches!(
517 matcher.with_measurements(pins),
518 Err(FlamingoError::Configuration(_))
519 ));
520 }
521 }
522 }
523
524 #[test]
525 fn rejects_missing_required_measurements() {
526 let matcher = FlamingoMatcher::new("https://verifier.example.com").unwrap();
527 assert!(matches!(
528 matcher.with_measurements(HashMap::new()),
529 Err(FlamingoError::Configuration(_))
530 ));
531 for index in 0..3 {
532 let mut pins = measurements();
533 pins.remove(&index);
534 let error = matcher.with_measurements(pins).unwrap_err();
535 assert!(matches!(error, FlamingoError::Configuration(_)));
536 assert!(error.to_string().contains(&format!("PCR{index}")));
537 }
538 }
539
540 #[test]
541 fn rejects_an_invalid_host_url() {
542 for url in ["not a URL", "/relative", "ftp://verifier.example.com"] {
543 assert!(matches!(
544 FlamingoMatcher::new(url),
545 Err(FlamingoError::Configuration(_))
546 ));
547 }
548 }
549
550 #[test]
551 fn rejects_invalid_duplicate_and_cookie_headers_without_exposing_values() {
552 let matcher = FlamingoMatcher::new("https://verifier.example.com").unwrap();
553 for headers in [
554 HashMap::from([("bad name".to_string(), "secret".to_string())]),
555 HashMap::from([("authorization".to_string(), "secret\nvalue".to_string())]),
556 HashMap::from([
557 ("Authorization".to_string(), "secret".to_string()),
558 ("authorization".to_string(), "secret".to_string()),
559 ]),
560 HashMap::from([("cOoKiE".to_string(), "secret".to_string())]),
561 ] {
562 let error = matcher.with_headers(headers).unwrap_err();
563 assert!(matches!(error, FlamingoError::Configuration(_)));
564 assert!(!format!("{error:?}").contains("secret"));
565 }
566 }
567
568 #[tokio::test]
569 async fn fluent_configuration_is_order_independent_and_preserves_originals() {
570 let original = FlamingoMatcher::new("https://verifier.example.com").unwrap();
571 let first = original
572 .with_measurements(measurements())
573 .unwrap()
574 .with_headers(headers())
575 .unwrap();
576 let second = original
577 .with_headers(headers())
578 .unwrap()
579 .with_measurements(measurements())
580 .unwrap();
581 assert_eq!(
582 serde_json::to_value(&first.config).unwrap(),
583 serde_json::to_value(&second.config).unwrap()
584 );
585 assert_eq!(first.headers, second.headers);
586 assert!(original.config.is_none());
587 assert!(original.headers.is_empty());
588 assert!(first.client.get().is_none());
589 assert!(second.client.get().is_none());
590
591 let (left, right) = tokio::join!(first.client(), first.client());
592 assert!(std::ptr::eq(left.unwrap(), right.unwrap()));
593 assert!(!format!("{first:?}").contains("test-token"));
594
595 let updated = first.with_headers(HashMap::new()).unwrap();
596 assert!(updated.client.get().is_none());
597 assert!(updated.headers.is_empty());
598 assert!(!first.headers.is_empty());
599 assert!(!std::ptr::eq(
600 first.client().await.unwrap(),
601 updated.client().await.unwrap()
602 ));
603 }
604
605 #[tokio::test]
606 async fn missing_measurements_fail_before_any_request() {
607 let mut server = mockito::Server::new_async().await;
608 let assignment = server
609 .mock("POST", "/v1/enclave-assignment")
610 .expect(0)
611 .create_async()
612 .await;
613 let matcher = FlamingoMatcher::new(&server.url())
614 .unwrap()
615 .with_headers(headers())
616 .unwrap();
617 assert!(matches!(
618 matcher.perform_match(request()).await,
619 Err(FlamingoError::Configuration(_))
620 ));
621 assert!(matcher.client.get().is_none());
622 assignment.assert_async().await;
623 drop(server);
624 }
625
626 #[tokio::test]
627 async fn http_defaults_and_affinity_cookies_cover_both_routes() {
628 let mut server = mockito::Server::new_async().await;
629 let assignment = server
630 .mock("POST", "/v1/flamingo/v1/enclave-assignment")
631 .match_header("authorization", "Bearer test-token")
632 .match_header("client-name", "test-client")
633 .with_header("set-cookie", "AWSALB=assigned-pod; Path=/")
634 .with_status(204)
635 .expect(2)
636 .create_async()
637 .await;
638 let match_route = server
639 .mock("POST", "/v1/flamingo/v1/matches")
640 .match_header("authorization", "Bearer test-token")
641 .match_header("client-name", "test-client")
642 .match_header("cookie", "AWSALB=assigned-pod")
643 .with_status(409)
644 .expect(2)
645 .create_async()
646 .await;
647 let matcher = FlamingoMatcher::new(&format!("{}/v1/flamingo/", server.url()))
648 .unwrap()
649 .with_measurements(measurements())
650 .unwrap()
651 .with_headers(headers())
652 .unwrap();
653 let client = matcher.client().await.unwrap();
654 let (http, _) = client.build_assignment_request().build_split();
657 for _ in 0..2 {
658 assert_eq!(
659 client
660 .build_assignment_request()
661 .send()
662 .await
663 .unwrap()
664 .status(),
665 204
666 );
667 assert_eq!(
668 http.post(format!("{}/v1/flamingo/v1/matches", server.url()))
669 .send()
670 .await
671 .unwrap()
672 .status(),
673 409
674 );
675 }
676 assignment.assert_async().await;
677 match_route.assert_async().await;
678 drop(server);
679 }
680
681 #[tokio::test]
682 async fn rejects_a_legacy_assignment_before_sending_images() {
683 let mut server = mockito::Server::new_async().await;
684 let assignment = server
685 .mock("POST", "/v1/enclave-assignment")
686 .match_header("authorization", "Bearer test-token")
687 .with_status(200)
688 .with_header("content-type", "application/json")
689 .with_body(r#"{"attestation":"YXR0ZXN0YXRpb24="}"#)
690 .expect(1)
691 .create_async()
692 .await;
693 let image_upload = server
694 .mock("POST", "/v1/matches")
695 .expect(0)
696 .create_async()
697 .await;
698 let matcher = FlamingoMatcher::new(&server.url())
699 .unwrap()
700 .with_measurements(measurements())
701 .unwrap()
702 .with_headers(headers())
703 .unwrap();
704
705 let error = matcher.perform_match(request()).await.unwrap_err();
706
707 assert!(matches!(error, FlamingoError::Verifier(_)));
708 assignment.assert_async().await;
709 image_upload.assert_async().await;
710 drop(server);
711 }
712
713 #[tokio::test]
714 async fn returns_a_verified_token_after_the_client_verifies_success() {
715 let client = FakeClient::new([Ok(MatchResult::Success(AttestedStatement {
716 token: MatchToken::from_bytes(b"signed-token".to_vec()),
717 signing_key_attestation: b"signing-key-attestation".to_vec(),
718 }))]);
719
720 let outcome = perform_match(&client, request())
721 .await
722 .expect("match should succeed");
723
724 let FlamingoMatchOutcome::Matched(token) = outcome else {
725 panic!("expected a matched outcome");
726 };
727 assert_eq!(token.as_bytes(), b"signed-token");
728 assert_eq!(token.signing_key_attestation(), b"signing-key-attestation");
729 assert_eq!(client.assignments.load(Ordering::Relaxed), 1);
730 }
731
732 #[tokio::test]
733 async fn returns_a_typed_sealed_rejection() {
734 let client = FakeClient::new([Ok(MatchResult::Failed(
735 FailureReason::ThumbnailHashMismatch,
736 ))]);
737
738 let outcome = perform_match(&client, request())
739 .await
740 .expect("a sealed rejection is an outcome");
741
742 assert!(matches!(
743 outcome,
744 FlamingoMatchOutcome::Rejected(
745 FlamingoMatchRejection::ThumbnailHashMismatch
746 )
747 ));
748 }
749
750 #[tokio::test]
751 async fn reassigns_and_reseals_exactly_once() {
752 let client = FakeClient::new([
753 Err(ClientError::ReassignRequired),
754 Ok(MatchResult::Failed(FailureReason::MatchBelowThreshold)),
755 ]);
756
757 let outcome = perform_match(&client, request())
758 .await
759 .expect("fresh assignment should recover the match request");
760
761 assert!(matches!(
762 outcome,
763 FlamingoMatchOutcome::Rejected(FlamingoMatchRejection::MatchBelowThreshold)
764 ));
765 assert_eq!(client.assignments.load(Ordering::Relaxed), 2);
766 }
767
768 #[tokio::test]
769 async fn does_not_retry_a_second_stale_assignment() {
770 let client = FakeClient::new([
771 Err(ClientError::ReassignRequired),
772 Err(ClientError::ReassignRequired),
773 ]);
774
775 let error = perform_match(&client, request())
776 .await
777 .expect_err("a second stale assignment should be surfaced");
778
779 assert!(matches!(error, FlamingoError::Verifier(_)));
780 assert_eq!(client.assignments.load(Ordering::Relaxed), 2);
781 }
782
783 #[tokio::test]
784 async fn rejects_a_non_finite_threshold_before_assignment() {
785 let client = FakeClient::new([]);
786 let mut request = request();
787 request.match_threshold = f32::NAN;
788
789 let error = perform_match(&client, request).await.expect_err(
790 "NaN would bypass enclave comparisons and must be rejected locally",
791 );
792
793 assert!(matches!(
794 error,
795 FlamingoError::InvalidInput {
796 attribute,
797 ..
798 } if attribute == "match_threshold"
799 ));
800 assert_eq!(client.assignments.load(Ordering::Relaxed), 0);
801 }
802}