Skip to main content

walletkit_core/
flamingo.rs

1//! Attested `Flamingo` matching in preparation for zero-knowledge proof generation.
2//!
3//! This module deliberately knows nothing about Orb PCP storage. Its caller supplies the live
4//! image and the credential material obtained through the platform's Oxide/OrbKit adapter. The
5//! module owns assignment, attestation verification, sealing, transport, response opening, and
6//! match-token verification.
7
8use 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/// A simple wrapper around of `FlamingoVerifierClient`. Flamingo Verifier is a cloud TEE service for attested embedding generation and comparison.
24#[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/// Inputs for one attested `Flamingo` 3-way match.
33///
34/// `credential_image` and `hashes_json` must come from the same enrolled Orb PCP. In particular,
35/// `hashes_json` must contain the exact archive bytes, not parsed and reserialized JSON.
36#[derive(Debug, uniffi::Record)]
37pub struct FlamingoMatchRequest {
38    /// Raw liveness image bytes captured for this request.
39    pub live_image: Vec<u8>,
40    /// Raw `thumbnail.png` bytes decrypted from the enrolled Orb PCP.
41    pub credential_image: Vec<u8>,
42    /// Exact raw `hashes.json` bytes extracted from the enrolled Orb PCP.
43    pub hashes_json: Vec<u8>,
44    /// Optional second liveness frame for the `LightGuard` flow.
45    pub light_guard_image: Option<Vec<u8>>,
46    /// Raw challenge image bytes downloaded from the relying party.
47    pub challenge_image: Vec<u8>,
48    /// Minimum similarity required by the RP. Must be finite and between zero and one.
49    pub match_threshold: f32,
50}
51
52/// A match token whose signing-key attestation and signature were verified.
53///
54/// Foreign callers receive an opaque handle. The token and signing-key attestation remain
55/// together in Rust for proof generation and eventual relay of the attestation to the RP.
56#[derive(Debug, uniffi::Object)]
57pub struct VerifiedMatchToken {
58    token: Vec<u8>,
59    signing_key_attestation: Vec<u8>,
60}
61
62/// The outcome of the TEE match phase.
63#[derive(Debug, uniffi::Enum)]
64pub enum FlamingoMatchOutcome {
65    /// The enclave issued a token and `WalletKit` verified it against an attested signing key.
66    Matched(Arc<VerifiedMatchToken>),
67    /// The response reported a rejection. An unsigned rejection does not authenticate its sender.
68    Rejected(FlamingoMatchRejection),
69}
70
71/// A rejection reason reported in an encrypted match response.
72///
73/// The reason is unsigned; it is not proof that the attested enclave issued it.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
75pub enum FlamingoMatchRejection {
76    /// The sealed inputs were malformed.
77    MalformedInputs,
78    /// The PCP hashes file was invalid or did not contain the thumbnail commitment.
79    InvalidHashesJson,
80    /// The credential image did not match the PCP thumbnail commitment.
81    ThumbnailHashMismatch,
82    /// At least one comparison scored below the requested threshold.
83    MatchBelowThreshold,
84    /// The enclave could not obtain a usable comparison score from the images.
85    ImageAnalysisFailed,
86}
87
88/// Failures while configuring or performing a match request.
89#[derive(Debug, Error, uniffi::Error)]
90pub enum FlamingoError {
91    /// A caller-supplied value cannot form a valid match request.
92    #[error("invalid {attribute}: {reason}")]
93    InvalidInput {
94        /// Name of the invalid field.
95        attribute: String,
96        /// Why the value was rejected.
97        reason: String,
98    },
99    /// The verifier configuration was not valid.
100    #[error("invalid Flamingo verifier configuration: {0}")]
101    Configuration(String),
102    /// Assignment, attestation, transport, channel opening, or token verification failed.
103    #[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    /// Creates an instance with default values, use `with_measurements` and `with_headers` for customization.
123    ///
124    /// # Errors
125    ///
126    /// Returns [`FlamingoError::Configuration`] if the URL is not a valid HTTP(S) URL.
127    #[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    /// Returns a new instance with trusted measurements keyed by PCR index.
147    ///
148    /// PCR0, PCR1, and PCR2 must be supplied from an approved enclave build. Additional entries
149    /// are also pinned. It's the user's responsibility to ensure the measurements are from a trusted enclave and match the verifier's expectations.
150    ///
151    /// # Errors
152    /// Returns [`FlamingoError::Configuration`] if no measurement is set.
153    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    /// Returns a new instance with these default headers, replacing any previously configured set.
166    ///
167    /// Use this to set authorization, client name, or other headers. The `Cookie` header is not allowed; the client manages affinity cookies automatically.
168    ///
169    /// # Errors
170    /// Returns [`FlamingoError::Configuration`] for invalid names/values, case-insensitive duplicate
171    /// names, or a caller-supplied `Cookie` header (the client owns affinity cookies).
172    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    /// Performs an attested 3-way embedding match.
185    ///
186    /// - Fetches the enclave assignment and verifies its attestation against the trusted PCRs.
187    /// - Encrypts and sends the match inputs using the enclave's attested public key.
188    /// - Decrypts the result and, on success, verifies the token's signature and signing-key attestation.
189    ///
190    /// # Errors
191    ///
192    /// Returns [`FlamingoError::InvalidInput`] before making a network request when a caller value
193    /// is unusable, or [`FlamingoError::Configuration`] if trusted measurements are missing.
194    /// Other failures are returned as [`FlamingoError::Verifier`].
195    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    /// Borrows the encoded COSE/CBOR token for proof generation.
276    #[must_use]
277    pub fn as_bytes(&self) -> &[u8] {
278        &self.token
279    }
280
281    /// Borrows the signing-key attestation to relay alongside the generated proof.
282    #[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        // Exercise the configured HTTP transport without fabricating a trusted Nitro attestation.
655        // The separate retry tests below cover the match orchestration.
656        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}