vtc-service 0.12.0

Service for Verifiable Trust Communities
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
//! Credential-exchange `present` → join decision (close-the-join-loop, part 2).
//!
//! The VTA holder answers a verifier's DCQL query with an OID4VP `vp_token`
//! (the map keyed by `credential_query_id` that
//! `vta-service`'s `present_query` produces). This module is the VTC verifier's
//! decision path: it **cryptographically verifies** the `vp_token`
//! ([`crate::credentials::verify_vp_token`]), projects the verified set into the
//! ceremony [`Presentation`] evidence shape, runs the active **join** policy, and
//! realizes the verdict — auto-admitting (issuing the MembershipCredential) on
//! `allow`, else queuing / rejecting like the VP submit path.
//!
//! Unlike the VP submit path (whose `presentation.verified` rests on a
//! route-layer hex signature), here `verified` rests on the holder `kb-jwt` /
//! DI holder proof binding the verifier's `nonce` + audience — real VP
//! cryptography. [`present_and_decide_join`] takes the expected audience, nonce
//! and exchange thread as parameters so it is transport-agnostic; the DIDComm
//! `credential-exchange/present` handler (`messaging.rs`) sources them from the
//! single-use presentation challenge it consumes and the `thid` that keyed it.
//!
//! ## Trust Task Context Binding
//!
//! The thread is not only a correlation handle. It is the identity of the
//! exchange, and every presented credential is resolved against it: a VWC
//! without the `taskContext` DTG Credentials marks REQUIRED is refused outright,
//! and one naming a different exchange is surfaced to the policy as such
//! ([`crate::credentials::task_context`]). Without that, a credential issued
//! during some other exchange is indistinguishable from one issued during this
//! one — Security Considerations 5, context collapse.
//!
//! This module also hosts the **query send side** ([`prepare_join_query`] +
//! [`send_query`]): the VTC issues the challenge and builds the DCQL query (from
//! a registered Accepts criterion) the holder answers.

use std::sync::Arc;

use affinidi_openid4vp::DcqlQuery;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value as JsonValue, json};
use tracing::{info, warn};
use uuid::Uuid;
use vta_sdk::protocols::credential_exchange::{QUERY as CREDENTIAL_QUERY_TYPE, QueryBody};

use vti_common::error::AppError;

use crate::ceremony::{Credential, CredentialStatus, Presentation};
use crate::credentials::present_challenge::{self, DEFAULT_CHALLENGE_TTL};
use crate::credentials::task_context::{self, TaskContextBinding};
use crate::credentials::witness::{self, WitnessBinding};
use crate::credentials::{VerifiedPresentation, VerifiedPresentationSet};
use vti_common::auth::PurposeVmResolver;

use crate::credentials::vm_resolver::DidVmResolver;
use crate::join::JoinTransport;
use crate::recognition::{HttpStatusListFetcher, StatusListFetcher};
use crate::registry::TrustRegistryClient;
use crate::schemas::accepts::get_accepts;
use crate::server::AppState;

use crate::join::{JoinSubmitOutcome, decide_join, realize_join_verdict};

/// Verify a presented OID4VP `vp_token`, run the join decision, and realize it.
///
/// `expected_aud` is this VTC's identity (the `domain`/`aud` the holder bound
/// into the kb-jwt); `expected_nonce` is the single-use freshness value the VTC
/// issued with its query. `thread_id` is the exchange the holder presented on —
/// the same thread that keyed the single-use challenge — and is what each
/// credential's `taskContext` is resolved against, so a credential issued in a
/// different exchange is visible as such to the policy rather than
/// indistinguishable from a local one. On success the applicant is the **proven
/// holder** of the presentation. On `allow` the MembershipCredential is issued
/// inline (the returned [`JoinSubmitOutcome::admit`]).
///
/// `criterion` is the join criterion the query was built from — the one the
/// presentation is decided under. `None` decides it as a submission naming no
/// criterion is decided: under the first criterion it meets.
#[allow(clippy::too_many_arguments)]
pub async fn present_and_decide_join(
    state: &AppState,
    vp_token: &JsonValue,
    expected_aud: &str,
    expected_nonce: &str,
    thread_id: &str,
    criterion: Option<&str>,
    transport: JoinTransport,
    now: DateTime<Utc>,
) -> Result<JoinSubmitOutcome, AppError> {
    // 1. Cryptographically verify every presentation in the vp_token. The holder
    //    is proven (kb-jwt / DI holder proof) and consistent across the set.
    //    did:webvh / did:web issuers + holders resolve through the DID cache.
    let set = crate::credentials::verify_vp_token_with_anchors(
        vp_token,
        expected_aud,
        expected_nonce,
        state.did_resolver.as_ref(),
        &state.x509_trust_anchors,
        now,
    )
    .await?;
    let applicant_did = set.holder.clone();

    // 2. Project the verified set into the ceremony evidence shape, resolving
    //    each issuer's trust via TRQP against the community's recognition graph
    //    and each credential's task-context binding against this exchange. A
    //    credential the specification requires a `taskContext` on and that does
    //    not carry one is refused here, before any decision is taken.
    let presentation = presentation_from_verified_set(state, &set, thread_id).await?;

    // Which criterion governs, and whether these credentials meet it. This path
    // carries no invitation and no vetting statements, so a criterion asking
    // for either is not met by it.
    let presented = crate::join::criteria::Presented {
        credentials: crate::join::criteria::presented_from_verified(
            &presentation,
            &set.presentations,
        ),
        ..Default::default()
    };
    let governing =
        crate::join::criteria::govern_by_id(state, &applicant_did, &presented, criterion, now)
            .await
            .map_err(|r| {
                match r {
        crate::join::criteria::CriterionRefusal::NotAccepting => AppError::Validation(
            "this community publishes no join criteria, so it is not accepting applications"
                .into(),
        ),
        crate::join::criteria::CriterionRefusal::Unknown(id) => AppError::Validation(format!(
            "the join criterion `{id}` this presentation answers is no longer published"
        )),
        crate::join::criteria::CriterionRefusal::Challenge(r) => r.into(),
        crate::join::criteria::CriterionRefusal::Other(e) => e,
    }
            })?;

    // 3 + 4. Decide under the active join policy, then realize the verdict. The
    // credential-exchange path carries no VIC (invitations ride the VP-submit
    // path), so no invitation fact and nothing to consume.
    // No vetting facts on this path yet: identity-vetting statements are counted
    // on the VP-submit path, which is the one the OpenVTC client presents them on.
    let verdict = decide_join(
        state,
        &applicant_did,
        presentation,
        None,
        &governing,
        Some(thread_id),
    )
    .await?;
    let vp_claims = vp_claims_from_set(&set);
    let outcome = realize_join_verdict(
        state,
        &applicant_did,
        vp_token.clone(),
        vp_claims,
        false,
        JsonValue::Null,
        // The credential-exchange path carries a presentation only; requested
        // attributes arrive on `submit`.
        Vec::new(),
        verdict,
        transport,
        None,
    )
    .await?;
    crate::join::orchestrate::record_criterion(state, outcome.request.id, &governing).await;
    Ok(outcome)
}

// ---------------------------------------------------------------------------
// Query send side (close-the-join-loop, part B): the VTC issues the challenge
// and builds the DCQL query the holder answers.
// ---------------------------------------------------------------------------

/// Prepare a `credential-exchange/query` for a join: load the registered Accepts
/// DCQL criterion `criterion_id` (Phase 2 — the community's acceptance rule),
/// issue a **single-use presentation challenge** keyed by `thread_id` and bound
/// to the VTC's own DID, and assemble the [`QueryBody`] (the DCQL query + the
/// freshness nonce + a purpose shown to the holder).
///
/// The holder presents on this `thread_id`; the
/// `credential-exchange/present` handler consumes the challenge to recover the
/// nonce + audience and verify the presentation.
pub async fn prepare_join_query(
    state: &AppState,
    thread_id: &str,
    criterion_id: &str,
    now: DateTime<Utc>,
) -> Result<QueryBody, AppError> {
    let vtc_did = state.config.read().await.vtc_did.clone().ok_or_else(|| {
        AppError::Internal("VTC DID not configured — cannot bind a presentation challenge".into())
    })?;

    let criterion = get_accepts(&state.schemas_ks, criterion_id)
        .await?
        .ok_or_else(|| {
            AppError::NotFound(format!("no Accepts criterion `{criterion_id}` registered"))
        })?;
    let dcql_query: DcqlQuery = criterion.dcql()?.ok_or_else(|| {
        AppError::Validation(format!(
            "the Accepts criterion `{criterion_id}` asks for no credential, so there is no query \
             to send"
        ))
    })?;

    let nonce = present_challenge::issue(
        &state.join_requests_ks,
        thread_id,
        &vtc_did,
        Some(criterion_id),
        DEFAULT_CHALLENGE_TTL,
        now,
    )
    .await?;

    let purpose = criterion
        .description
        .unwrap_or_else(|| format!("join: present credentials satisfying `{criterion_id}`"));
    Ok(QueryBody {
        dcql_query,
        nonce,
        purpose,
        // The VTC join flow is a Trust-Task exchange, not OID4VP over HTTP —
        // there is no `response_uri` to bind an mdoc's DeviceAuth to, so an
        // mdoc is deliberately not offered here. A VTC that later wants to
        // accept one has to run a real OID4VP session and pass it through.
        oid4vp_session: None,
    })
}

/// `POST /v1/join-requests/query` request — ask the VTC to prepare a join query
/// for `holderDid` from the named Accepts criterion.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct SendQueryRequest {
    /// The holder the query is for (delivered to it over DIDComm by the VTC or a
    /// relayer — relayer ≠ holder is supported; the holder still proves binding).
    pub holder_did: String,
    /// The registered Accepts criterion whose DCQL query to send.
    pub criterion_id: String,
}

/// `POST /v1/join-requests/query` response — the prepared query to deliver, and
/// the thread the holder presents on.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[derive(utoipa::ToSchema)]
pub struct SendQueryResponse {
    /// The DIDComm thread id the holder must reply on (the challenge is keyed by
    /// it; the `present` handler consumes it).
    pub thread_id: String,
    /// Echoed for the caller's correlation.
    pub holder_did: String,
    /// The `credential-exchange/query` body to deliver to the holder.
    #[schema(value_type = Object)]
    pub query: QueryBody,
    /// Whether the VTC **accepted the query for guaranteed delivery** (durably
    /// queued to the outbox; the drain loop sends + retries it) — not a
    /// confirmation that it has been received. `false` when no mediator is
    /// configured or the enqueue failed — the caller can still relay the returned
    /// [`query`](Self::query) out-of-band (relayer ≠ holder). The wire field name
    /// stays `delivered` (camelCase, `deny_unknown_fields`) for API stability.
    pub delivered: bool,
}

/// `POST /v1/join-requests/query` (admin) — prepare a `credential-exchange/query`
/// for `holderDid` from the Accepts criterion `criterionId`: issue the single-use
/// challenge, **push the query to the holder over DIDComm** when a mediator is
/// configured, and return the [`QueryBody`] (so a relayer can deliver it when the
/// push is unavailable — relayer ≠ holder is supported). The holder presents on
/// the returned `threadId`, and the `credential-exchange/present` handler
/// consumes the challenge.
/// POST /join-requests/query — prepare + push a credential-exchange query to
/// a holder for a registered Accepts criterion. Auth: Admin.
pub(crate) async fn send_query_inner(
    state: &AppState,
    body: SendQueryRequest,
) -> Result<SendQueryResponse, AppError> {
    let thread_id = Uuid::new_v4().to_string();
    let query = prepare_join_query(state, &thread_id, &body.criterion_id, Utc::now()).await?;

    // Best-effort DIDComm push. A failure (no mediator, unreachable holder) is not
    // fatal — the query is still returned for relay delivery.
    let delivered = match push_credential_query(state, &body.holder_did, &thread_id, &query).await {
        Ok(()) => {
            info!(holder = %body.holder_did, thread = %thread_id, "queued credential query for guaranteed delivery");
            true
        }
        Err(e) => {
            warn!(holder = %body.holder_did, thread = %thread_id, error = %e, "credential-query push failed — returning query for relay");
            false
        }
    };

    Ok(SendQueryResponse {
        thread_id,
        holder_did: body.holder_did,
        query,
        delivered,
    })
}

/// Push a signed `credential-exchange/query` to `holder_did`, over whichever
/// transport it speaks ([`crate::credentials::delivery::push_document`]). The
/// query document's `id` **is** `thread_id` (the thread root), so the holder's
/// `present` carries it as `threadId` and threads back to the single-use
/// challenge the VTC just issued.
async fn push_credential_query(
    state: &AppState,
    holder_did: &str,
    thread_id: &str,
    query: &QueryBody,
) -> Result<(), AppError> {
    let body = serde_json::to_value(query)
        .map_err(|e| AppError::Internal(format!("query serialise: {e}")))?;
    crate::credentials::delivery::push_document(
        state,
        holder_did,
        CREDENTIAL_QUERY_TYPE,
        body,
        crate::credentials::delivery::Thread::Root(thread_id),
    )
    .await
    .map(|_| ())
}

/// Project a [`VerifiedPresentationSet`] into the verified ceremony
/// [`Presentation`]. Crypto is already resolved, so `verified: true`. Each
/// credential's `issuer_trusted` is resolved per-issuer via TRQP
/// ([`issuer_trusted`]) and its lifecycle `status` is resolved live against the
/// issuer's status list ([`resolve_presented_status`]). The credential `type` is
/// the SD-JWT-VC `vct`.
///
/// `exchange_thread` is the thread the presentation arrived on, against which
/// each credential's `taskContext` is resolved.
///
/// Fallible where the sibling resolutions are not: an unreachable status list or
/// a flaky registry degrade to a surfaced verdict the policy can weigh, but a
/// VWC with no `taskContext` is not a credential with a weaker claim — it is not
/// a well-formed VWC, and the specification marks the property REQUIRED. It is
/// refused for the whole presentation rather than dropped from it, because
/// silently discarding one credential turns a malformed submission into a
/// decision made on less evidence than the holder thinks it supplied.
async fn presentation_from_verified_set(
    state: &AppState,
    set: &VerifiedPresentationSet,
    exchange_thread: &str,
) -> Result<Presentation, AppError> {
    let own_did = state.config.read().await.vtc_did.clone();
    let registry = state.registry_client.as_deref();
    // A presented credential's revocation is checked against the *issuer's*
    // status list (a foreign URL): same fetcher + SSRF guard the recognition
    // path uses. When a DID resolver is configured, the fetcher also verifies
    // the list credential's own issuer signature (bound to the presented
    // credential's issuer) — so a MITM on the status-list host can't forge or
    // hide a revocation. Built once for the whole set.
    let status_fetcher = credential_status_fetcher(state);

    // Statements are accepted fail-closed, by predicate: one under a predicate
    // this community has not registered is refused rather than processed as a
    // generic statement (vtc/endorsement-types/register/0.1).
    let accepted = crate::endorsement_types::accept_list(&state.endorsement_types_ks).await?;

    // The witnesses this community trusts, read once for the whole set. This
    // path has no carriage for a witnessing session's outcome evidence, so a
    // VWC from a trusted witness is `noOutcomeEvidence` here (VTI-CMP-023) and
    // meets no criterion; one from any other witness is `untrustedWitness`.
    let trusted_witnesses = crate::credentials::trusted_witnesses::live_for(state).await;
    let vm_resolver = state.trust_task_vm_resolver();
    let witness_ctx = witness::WitnessContext {
        relationships_ks: &state.relationships_ks,
        trusted: &trusted_witnesses,
        evidence: &[],
        resolver: &vm_resolver,
    };

    let mut credentials = Vec::with_capacity(set.presentations.len());
    for p in &set.presentations {
        if let Some(predicate) = crate::credentials::ingress::statement_predicate(&p.claims)
            && !accepted.contains(&predicate)
        {
            return Err(AppError::Validation(format!(
                "presented statement from {} is under predicate `{predicate}`, which this \
                 community does not accept",
                p.issuer_did
            )));
        }
        let trusted = issuer_trusted(registry, own_did.as_deref(), &p.issuer_did).await;
        let status = resolve_presented_status(
            p.credential_status.as_ref(),
            Some(&p.issuer_did),
            &status_fetcher,
        )
        .await;
        // A VWC names the edge it witnessed by digest, and nothing else in the
        // credential says which one. Resolved here — where the relationships
        // keyspace is in hand — so the policy branches on a settled verdict
        // rather than being handed an unchecked assertion. DTG Credentials
        // Security Considerations 4: without recomputing the digest against
        // the referenced VRC, a VWC is not evidence of *which* edge was
        // witnessed.
        //
        // A storage failure resolves to `Unresolved` rather than aborting the
        // whole presentation: one unreadable keyspace must not decide a
        // ceremony, and `Unresolved` is the honest answer — we could not find
        // the edge. The policy still sees an unbound witness.
        let is_witness = witness::is_witness_statement(&p.claims);
        let witness_binding = if is_witness {
            Some(witness::assess_or_unresolved(&witness_ctx, &p.issuer_did, &p.claims).await)
        } else {
            None
        };
        // The receipt half of Trust Task Context Binding. A statement whose
        // predicate profile marks `taskContext` REQUIRED (a VWC under
        // `witnessed/1`, a `vetted/1` statement) makes its absence an error
        // rather than a verdict — and the error is raised instead of filling
        // the current thread in, which would forge the very binding being
        // checked. Everything else resolves to a verdict the policy reads.
        let requirement = task_context::requirement_for_predicate(
            crate::credentials::ingress::statement_predicate(&p.claims).as_deref(),
        );
        let task_context = task_context::resolve(
            requirement,
            p.task_context.as_deref(),
            Some(exchange_thread),
        )?;
        credentials.push(credential_from_verified(
            p,
            trusted,
            status,
            witness_binding,
            Some(task_context),
        ));
    }

    Ok(Presentation {
        verified: true,
        holder: set.holder.clone(),
        credentials,
    })
}

/// Pure projection of a single [`VerifiedPresentation`] into a ceremony
/// [`Credential`], with the caller-resolved `issuer_trusted` verdict and
/// lifecycle `status`. Kept separate from the TRQP + status-list lookups so it
/// stays unit-testable without a registry or network.
fn credential_from_verified(
    p: &VerifiedPresentation,
    issuer_trusted: bool,
    status: CredentialStatus,
    witness_binding: Option<WitnessBinding>,
    task_context: Option<TaskContextBinding>,
) -> Credential {
    Credential {
        credential_type: p
            .vct
            .clone()
            .unwrap_or_else(|| "VerifiableCredential".to_string()),
        predicate: crate::credentials::ingress::statement_predicate(&p.claims),
        issuer: p.issuer_did.clone(),
        issuer_trusted,
        status,
        holder_bound: p.holder_bound,
        claims: p.claims.clone(),
        // SD-JWT-VC carries expiry in `exp` (epoch seconds).
        valid_until: p
            .claims
            .get("exp")
            .and_then(JsonValue::as_i64)
            .and_then(|s| DateTime::from_timestamp(s, 0)),
        // Resolved by the caller, which has the keyspace this needs. Kept out
        // of the pure projection so it stays unit-testable without storage,
        // for the same reason `issuer_trusted` and `status` are parameters.
        witness_binding,
        // Also caller-resolved: the comparison needs the exchange's thread,
        // which is a property of the ceremony rather than of the credential.
        task_context,
    }
}

/// The status-list fetcher for credentials presented to this community: it
/// verifies each list's signature as its credential issuer's — through the DID
/// resolver when one is configured, and through the X.509 trust anchors for an
/// issuer known by a certificate chain.
pub(crate) fn credential_status_fetcher(state: &AppState) -> HttpStatusListFetcher {
    let fetcher = match state.did_resolver.clone() {
        Some(resolver) => {
            let key_resolver: Arc<dyn PurposeVmResolver> =
                Arc::new(DidVmResolver::new(Some(resolver)));
            HttpStatusListFetcher::with_issuer_verification(key_resolver)
        }
        None => HttpStatusListFetcher::new(),
    };
    fetcher.with_x509_anchors(state.x509_trust_anchors.clone())
}

/// Resolve a presented credential's lifecycle [`CredentialStatus`] against the
/// issuer's status list.
///
/// The verifier must not accept a **revoked** credential at join. Resolution is
/// surfaced into the ceremony evidence rather than enforced here, so the join
/// policy decides (the ceremony [`CredentialStatus`] model carries `Unknown`
/// exactly for this):
///
/// - no `credentialStatus` entry → [`CredentialStatus::Valid`] (the issuer opted
///   into no status list — an implicit "not revocable" claim, matching the
///   recognition path).
/// - status bit clear → [`CredentialStatus::Valid`].
/// - status bit set → [`CredentialStatus::Revoked`], or
///   [`CredentialStatus::Suspended`] when the entry's `statusPurpose` is
///   `suspension`.
/// - an SD-JWT-VC IETF Token Status List entry (`status_list: { idx, uri }`),
///   read from its issuer-signed `statuslist+jwt`: `VALID` → Valid, `INVALID` →
///   Revoked, `SUSPENDED` → Suspended, any other value → Unknown (#1988).
/// - entry present but unresolvable (malformed, or the list unreachable) →
///   [`CredentialStatus::Unknown`]: **surfaced, not guessed**, so the policy can
///   refuse rather than the verifier silently trusting an uncheckable credential.
pub(crate) async fn resolve_presented_status(
    status_entry: Option<&JsonValue>,
    expected_issuer: Option<&str>,
    fetcher: &dyn StatusListFetcher,
) -> CredentialStatus {
    // No status block → the credential never opted into revocation.
    let Some(entry) = status_entry else {
        return CredentialStatus::Valid;
    };

    // The SD-JWT-VC IETF Token Status List (`{ status_list: { idx, uri } }`) is
    // its own format — a `statuslist+jwt`, ZLIB, multi-bit entries — not a
    // Bitstring list at another address.
    if entry.get("status_list").is_some() {
        return resolve_token_status(entry, expected_issuer, fetcher).await;
    }

    // The W3C `BitstringStatusListEntry`
    // (`{ statusListCredential, statusListIndex, statusPurpose }`).
    let (url, index, suspension) = match parse_status_entry(entry) {
        Some(parts) => parts,
        // A malformed entry is suspicious — surface Unknown, don't trust it.
        None => return CredentialStatus::Unknown,
    };

    match fetcher.check_status_bit(&url, index, expected_issuer).await {
        Ok(false) => CredentialStatus::Valid,
        Ok(true) if suspension => CredentialStatus::Suspended,
        Ok(true) => CredentialStatus::Revoked,
        Err(e) => {
            warn!(
                url = %url,
                error = %e,
                "presented credential's status list did not resolve — surfacing Unknown for the join policy"
            );
            CredentialStatus::Unknown
        }
    }
}

/// Resolve an SD-JWT-VC `status` object naming an IETF Token Status List. A
/// malformed reference, an unreadable or unverifiable list, or a status value
/// with no registered meaning is Unknown: surfaced, never guessed.
async fn resolve_token_status(
    status: &JsonValue,
    expected_issuer: Option<&str>,
    fetcher: &dyn StatusListFetcher,
) -> CredentialStatus {
    use affinidi_status_list::token::{StatusListReference, TokenStatus};

    let reference = match StatusListReference::from_claims(&json!({ "status": status })) {
        Ok(Some(reference)) => reference,
        _ => return CredentialStatus::Unknown,
    };
    match fetcher
        .check_token_status(&reference, expected_issuer)
        .await
    {
        Ok(TokenStatus::Valid) => CredentialStatus::Valid,
        Ok(TokenStatus::Invalid) => CredentialStatus::Revoked,
        Ok(TokenStatus::Suspended) => CredentialStatus::Suspended,
        Ok(other) => {
            warn!(
                uri = %reference.uri,
                value = other.value(),
                "presented credential's Token Status List entry has no registered meaning — surfacing Unknown"
            );
            CredentialStatus::Unknown
        }
        Err(e) => {
            warn!(
                uri = %reference.uri,
                error = %e,
                "presented credential's Token Status List did not resolve — surfacing Unknown for the join policy"
            );
            CredentialStatus::Unknown
        }
    }
}

/// Parse a W3C `BitstringStatusListEntry` into
/// `(status_list_url, index, is_suspension)`, or `None` without a usable URL +
/// index.
fn parse_status_entry(entry: &JsonValue) -> Option<(String, usize, bool)> {
    // W3C: { statusListCredential, statusListIndex, statusPurpose }.
    if let Some(url) = entry
        .get("statusListCredential")
        .and_then(JsonValue::as_str)
    {
        let index = parse_status_index(entry.get("statusListIndex"))?;
        let suspension =
            entry.get("statusPurpose").and_then(JsonValue::as_str) == Some("suspension");
        return Some((url.to_string(), index, suspension));
    }
    None
}

/// Parse a `statusListIndex` / `idx` that may be a JSON string or number.
fn parse_status_index(v: Option<&JsonValue>) -> Option<usize> {
    match v {
        Some(JsonValue::String(s)) => s.parse::<usize>().ok(),
        Some(JsonValue::Number(n)) => n.as_u64().map(|u| u as usize),
        _ => None,
    }
}

/// Resolve whether the community trusts `issuer_did` to issue credentials, for
/// the ceremony evidence shape. The community's **own** DID is always trusted
/// (it issued the credential itself). Any other issuer is resolved via TRQP
/// `recognise` against the trust registry:
///
/// - `Ok(true)` → the issuer is in the recognition graph → trusted.
/// - `Ok(false)` (clean not-found) → not trusted.
/// - transport / parse `Err` → not trusted (**fail-soft** + warn): the join
///   policy still gets to decide over a `false` rather than the whole request
///   erroring on a flaky registry.
/// - no-registry mode (`registry` is `None`) → not trusted.
///
/// Trust is **never cached** here (spec §8.4): a peer removed from the
/// recognition graph loses trust on the next presentation, not when a TTL
/// elapses. The verdict feeds the Rego `cred_trusted` policy helper — it is an
/// input to the decision, not the decision itself.
pub(crate) async fn issuer_trusted(
    registry: Option<&dyn TrustRegistryClient>,
    own_did: Option<&str>,
    issuer_did: &str,
) -> bool {
    if own_did == Some(issuer_did) {
        return true;
    }
    let Some(registry) = registry else {
        return false;
    };
    match registry.recognise(issuer_did).await {
        Ok(trusted) => trusted,
        Err(e) => {
            warn!(
                issuer = %issuer_did,
                error = %e,
                "trust-registry recognise failed — treating issuer as untrusted; join policy decides"
            );
            false
        }
    }
}

/// The lossy `vp_claims` admin-display projection (mirrors the VP path's
/// `extract_vp_claims`): the holder + a credential summary, persisted on the
/// join-request row for the admin show.
fn vp_claims_from_set(set: &VerifiedPresentationSet) -> JsonValue {
    let credentials: Vec<JsonValue> = set
        .presentations
        .iter()
        .map(|p| {
            json!({
                "issuer": p.issuer_did,
                "type": p.vct,
                "credentialSubject": p.claims,
            })
        })
        .collect();
    json!({ "holder": set.holder, "credentials": credentials })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::recognition::RecognitionError;
    use crate::registry::MockRegistryClient;

    fn sample_presentation() -> VerifiedPresentation {
        VerifiedPresentation {
            issuer_did: "did:key:zIssuer".into(),
            holder_did: "did:key:zHolder".into(),
            vct: Some("https://openvtc.org/credentials/MembershipCredential".into()),
            holder_bound: true,
            claims: json!({ "givenName": "Alice", "exp": 1_900_000_000 }),
            credential_status: None,
            task_context: None,
            issuer_authority_key_ids: Vec::new(),
        }
    }

    /// A `StatusListFetcher` stub: returns a fixed bit, or an error, without
    /// touching the network.
    struct StubFetcher(Result<bool, ()>);

    #[async_trait::async_trait]
    impl StatusListFetcher for StubFetcher {
        async fn check_status_bit(
            &self,
            _url: &str,
            _index: usize,
            _expected_issuer: Option<&str>,
        ) -> Result<bool, RecognitionError> {
            self.0
                .map_err(|()| RecognitionError::StatusListFailed("stub".into()))
        }
    }

    /// A Token Status List stub: answers one status for any reference, and
    /// refuses Bitstring reads so a test sees which path ran.
    struct TokenStub(Result<affinidi_status_list::token::TokenStatus, ()>);

    #[async_trait::async_trait]
    impl StatusListFetcher for TokenStub {
        async fn check_status_bit(
            &self,
            _url: &str,
            _index: usize,
            _expected_issuer: Option<&str>,
        ) -> Result<bool, RecognitionError> {
            panic!("a Token Status List entry must not be read as a Bitstring bit")
        }

        async fn check_token_status(
            &self,
            reference: &affinidi_status_list::token::StatusListReference,
            expected_issuer: Option<&str>,
        ) -> Result<affinidi_status_list::token::TokenStatus, RecognitionError> {
            assert_eq!(reference.idx, 7);
            assert_eq!(reference.uri, "https://issuer.example/sl");
            assert_eq!(expected_issuer, Some("did:web:issuer.example"));
            self.0
                .map_err(|()| RecognitionError::StatusListFailed("stub".into()))
        }
    }

    fn w3c_status(purpose: &str) -> JsonValue {
        json!({
            "type": "BitstringStatusListEntry",
            "statusPurpose": purpose,
            "statusListIndex": "42",
            "statusListCredential": "https://issuer.example/status/1",
        })
    }

    #[test]
    fn projects_a_verified_presentation_into_a_ceremony_credential() {
        let c = credential_from_verified(
            &sample_presentation(),
            true,
            CredentialStatus::Valid,
            None,
            None,
        );
        assert_eq!(
            c.credential_type,
            "https://openvtc.org/credentials/MembershipCredential"
        );
        assert_eq!(c.issuer, "did:key:zIssuer");
        assert!(c.issuer_trusted);
        assert_eq!(c.status, CredentialStatus::Valid);
        assert!(c.valid_until.is_some());
        assert_eq!(c.claims["givenName"], "Alice");
    }

    #[test]
    fn resolved_status_carries_through_to_the_credential() {
        let c = credential_from_verified(
            &sample_presentation(),
            true,
            CredentialStatus::Revoked,
            None,
            None,
        );
        assert_eq!(c.status, CredentialStatus::Revoked);
    }

    #[tokio::test]
    async fn no_status_block_is_valid_without_a_fetch() {
        let status = resolve_presented_status(None, None, &StubFetcher(Ok(true))).await;
        assert_eq!(status, CredentialStatus::Valid);
    }

    #[tokio::test]
    async fn clear_bit_is_valid() {
        let entry = w3c_status("revocation");
        let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(false))).await;
        assert_eq!(status, CredentialStatus::Valid);
    }

    #[tokio::test]
    async fn set_revocation_bit_is_revoked() {
        let entry = w3c_status("revocation");
        let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(true))).await;
        assert_eq!(status, CredentialStatus::Revoked);
    }

    #[tokio::test]
    async fn set_suspension_bit_is_suspended() {
        let entry = w3c_status("suspension");
        let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(true))).await;
        assert_eq!(status, CredentialStatus::Suspended);
    }

    /// #1988: a Token Status List entry is read as one — its own resolver,
    /// the credential's issuer passed for binding — and each registered value
    /// maps onto the ceremony status; anything else is Unknown.
    #[tokio::test]
    async fn token_status_list_entries_resolve_by_status_value() {
        use affinidi_status_list::token::TokenStatus;
        let entry = json!({ "status_list": { "idx": 7, "uri": "https://issuer.example/sl" } });
        let issuer = Some("did:web:issuer.example");
        for (answer, expected) in [
            (Ok(TokenStatus::Valid), CredentialStatus::Valid),
            (Ok(TokenStatus::Invalid), CredentialStatus::Revoked),
            (Ok(TokenStatus::Suspended), CredentialStatus::Suspended),
            (Ok(TokenStatus::from_value(0x03)), CredentialStatus::Unknown),
            (Ok(TokenStatus::from_value(0x07)), CredentialStatus::Unknown),
            (Err(()), CredentialStatus::Unknown),
        ] {
            let status = resolve_presented_status(Some(&entry), issuer, &TokenStub(answer)).await;
            assert_eq!(status, expected, "{answer:?}");
        }
    }

    /// A fetcher that predates Token Status Lists yields Unknown, never the
    /// Bitstring bit at that address.
    #[tokio::test]
    async fn a_token_status_list_without_support_is_unknown() {
        let entry = json!({ "status_list": { "idx": 7, "uri": "https://issuer.example/sl" } });
        let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(false))).await;
        assert_eq!(status, CredentialStatus::Unknown);
    }

    #[tokio::test]
    async fn a_malformed_token_status_reference_is_unknown() {
        for entry in [
            json!({ "status_list": { "idx": "7", "uri": "https://issuer.example/sl" } }),
            json!({ "status_list": { "idx": 7 } }),
        ] {
            let status = resolve_presented_status(
                Some(&entry),
                Some("did:web:issuer.example"),
                &TokenStub(Ok(affinidi_status_list::token::TokenStatus::Valid)),
            )
            .await;
            assert_eq!(status, CredentialStatus::Unknown, "{entry}");
        }
    }

    #[tokio::test]
    async fn unreachable_status_list_is_unknown_not_valid() {
        // The verifier must not silently trust an uncheckable credential —
        // surface Unknown so the join policy can refuse.
        let entry = w3c_status("revocation");
        let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Err(()))).await;
        assert_eq!(status, CredentialStatus::Unknown);
    }

    #[tokio::test]
    async fn malformed_status_entry_is_unknown() {
        let entry = json!({ "type": "BitstringStatusListEntry" }); // no url/index
        let status = resolve_presented_status(Some(&entry), None, &StubFetcher(Ok(false))).await;
        assert_eq!(status, CredentialStatus::Unknown);
    }

    #[test]
    fn untrusted_issuer_carries_through_to_the_credential() {
        let c = credential_from_verified(
            &sample_presentation(),
            false,
            CredentialStatus::Valid,
            None,
            None,
        );
        assert!(!c.issuer_trusted);
    }

    #[tokio::test]
    async fn own_did_is_trusted_without_consulting_the_registry() {
        let registry = MockRegistryClient::new();
        // Own DID short-circuits — recognise is never called.
        assert!(issuer_trusted(Some(&registry), Some("did:vtc:home"), "did:vtc:home").await);
        assert_eq!(registry.call_counts().await.recognise, 0);
    }

    #[tokio::test]
    async fn recognised_foreign_issuer_is_trusted() {
        let registry = MockRegistryClient::new();
        registry.set_recognised("did:webvh:peer.example:abc").await;
        assert!(
            issuer_trusted(
                Some(&registry),
                Some("did:vtc:home"),
                "did:webvh:peer.example:abc"
            )
            .await
        );
        assert_eq!(registry.call_counts().await.recognise, 1);
    }

    #[tokio::test]
    async fn unrecognised_foreign_issuer_is_not_trusted() {
        let registry = MockRegistryClient::new();
        assert!(
            !issuer_trusted(
                Some(&registry),
                Some("did:vtc:home"),
                "did:webvh:stranger.example"
            )
            .await
        );
    }

    #[tokio::test]
    async fn registry_error_fails_soft_to_untrusted() {
        let registry = MockRegistryClient::new();
        registry
            .fail_next_recognise(crate::registry::RegistryError::Unreachable("dns".into()))
            .await;
        // A flaky registry must not error the request — it degrades to untrusted
        // and lets the join policy decide.
        assert!(
            !issuer_trusted(
                Some(&registry),
                Some("did:vtc:home"),
                "did:webvh:peer.example"
            )
            .await
        );
    }

    #[tokio::test]
    async fn no_registry_mode_is_not_trusted() {
        // No-registry deployment: no recognition graph, so no foreign issuer is
        // trusted (the own-DID short-circuit still applies elsewhere).
        assert!(!issuer_trusted(None, Some("did:vtc:home"), "did:webvh:peer.example").await);
    }
}