openvtc-core 0.3.1

OpenVTC Core Library
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
//! Reading a community's join manifest from the REST endpoint it publishes.
//!
//! A community that vets says what it requires *before* anything about the
//! applicant is sent (`docs/design/vetting-process.md` §6.1, "informed
//! non-application"). [`crate::vetting::wire::manifest_request`] asks that
//! question over DIDComm — which needs a mediator socket, a persona to ask as,
//! and a loop that can hear the answer. A first join has none of those: before
//! any community is joined there is no inbound arm to hear a reply on, so the
//! one question an applicant most needs answered is the one that could not be
//! asked.
//!
//! The same answer is served over HTTPS. A VTC publishes a `VTCRest` service in
//! its DID document, and `POST {endpoint}/v1/trust-tasks` dispatches on the
//! document's `type` — so a `join-requests/manifest` document gets the
//! manifest back, signed, with no session, no mediator and no inbound arm.
//!
//! 0.3 is asked first; a community that refuses the version
//! (`unsupportedVersion` / `unsupportedType`) is asked again in 0.2
//! ([`super::protocol`]).
//!
//! # Nothing about the applicant is sent
//!
//! The request carries **no `issuer`**: the manifest is a public read and the
//! service answers one that names nobody. That matters beyond tidiness — the
//! join page promises "nothing about you has been sent to {community}", and
//! stamping a persona DID on a pre-application question would quietly make that
//! false. Reading what a community asks of applicants must not tell it who is
//! considering applying.
//!
//! # What is trusted, and why
//!
//! The endpoint is attacker-influenced: it comes out of a DID document naming
//! a host this client then dials. So the same guards the health probe uses
//! apply — HTTPS only, no redirects, no proxy, no userinfo, and a resolver that
//! refuses a name pointing at a non-routable address ([`ProbePolicy`]).
//!
//! The answer is then trusted only as far as its proof. A manifest is what an
//! applicant gathers evidence *against*: a forged one could ask for a passport
//! scan the real community never wanted, which makes an unverified manifest a
//! phishing surface in the same way an unverified agent name is. So the reply's
//! Data-Integrity proof is verified and the proven signer must be the community
//! being asked — the claimed `issuer` alone is not enough, and neither is TLS
//! to a host the DID document named.

use std::time::Duration;

use affinidi_did_resolver_cache_sdk::DIDCacheClient;
use serde_json::Value;
use trust_tasks_rs::TrustTask;
use vta_sdk::protocols::join_requests::manifest;

use super::protocol::{CriterionMeta, JoinProtocol, is_version_refusal, read_manifest};
use vta_sdk::trust_task_proof::{TrustTaskVmResolver, verify_trust_task_proof_with};

use crate::health::ProbePolicy;

/// The DID-document service type a VTC publishes its REST API under.
const VTC_REST_SERVICE_TYPE: &str = "VTCRest";

/// The Trust Task document endpoint, relative to the published REST base.
const TRUST_TASKS_PATH: &str = "v1/trust-tasks";

/// Total and connect budget for the fetch. Finite by rule R1.2: a hung service
/// must produce an error, never a hung command.
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);

/// Why a community's manifest could not be read over its published endpoint.
///
/// The variants are the distinctions rule R6.4 asks for: an operator has to be
/// able to tell "this community does not publish one" from "the network did not
/// reach it" from "it answered and refused" from "it answered something this
/// client cannot read" — because those have four different next steps. One
/// fixed hint for all four is what the rule forbids.
#[derive(Debug, Clone)]
pub enum DiscoverError {
    /// The DID document publishes no `VTCRest` service, so there is nowhere to
    /// ask. Not a failure of this community — an older one, or one that serves
    /// its ceremony only over messaging.
    NoEndpoint,
    /// The published URL is not one this client will dial, and why.
    Blocked(String),
    /// The endpoint did not answer.
    Unreachable(String),
    /// It answered and declined. `code` is the Trust Task error code, when the
    /// refusal was one.
    Refused {
        status: u16,
        code: Option<String>,
        detail: String,
    },
    /// It answered something that is not a manifest this client can read.
    Unreadable(String),
    /// The answer carries no usable proof, or one that does not verify.
    Unproven(String),
    /// The answer verifies, but against a DID that is not the community asked.
    WrongSigner { proven: String },
}

impl std::fmt::Display for DiscoverError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NoEndpoint => {
                write!(f, "it publishes no REST endpoint to ask over")
            }
            Self::Blocked(reason) => {
                write!(f, "the endpoint it publishes cannot be used ({reason})")
            }
            Self::Unreachable(error) => {
                write!(f, "its endpoint could not be reached ({error})")
            }
            Self::Refused { status, detail, .. } => {
                write!(f, "its endpoint refused the question ({status}: {detail})")
            }
            Self::Unreadable(detail) => write!(
                f,
                "its answer is in a form this client cannot read ({detail})"
            ),
            Self::Unproven(detail) => {
                write!(f, "its answer could not be shown to be genuine ({detail})")
            }
            Self::WrongSigner { proven } => write!(
                f,
                "its answer was signed by {proven}, which is not the community asked"
            ),
        }
    }
}

impl std::error::Error for DiscoverError {}

/// The `VTCRest` endpoint `doc` publishes, if it publishes one.
///
/// `type` is read as both a string and an array, because a DID document may
/// write either and a community that used the array form is not thereby
/// without a REST endpoint.
#[must_use]
pub fn rest_endpoint(doc: &Value) -> Option<String> {
    let services = doc.get("service")?.as_array()?;
    services.iter().find_map(|svc| {
        let matches = match svc.get("type") {
            Some(Value::String(t)) => t == VTC_REST_SERVICE_TYPE,
            Some(Value::Array(types)) => types
                .iter()
                .any(|t| t.as_str() == Some(VTC_REST_SERVICE_TYPE)),
            _ => false,
        };
        if !matches {
            return None;
        }
        // A `serviceEndpoint` may be a bare string or an object with a `uri`.
        match svc.get("serviceEndpoint") {
            Some(Value::String(uri)) => Some(uri.clone()),
            Some(Value::Object(map)) => map.get("uri").and_then(Value::as_str).map(str::to_string),
            _ => None,
        }
    })
}

/// The Trust Task document endpoint under `base`, vetted for dialling.
fn trust_tasks_url(base: &str, policy: ProbePolicy) -> Result<reqwest::Url, DiscoverError> {
    crate::health::vet_probe_url(&trust_tasks_url_string(base), policy)
        .map_err(DiscoverError::Blocked)
}

/// `base` joined with the Trust Task path, without doubling the API mount.
///
/// A community publishes its `VTCRest` endpoint in one of two forms: the API
/// base including its `/v1` mount (what VTI's `vtc-host` template advertises
/// since VTI #1615), or the bare host (communities minted before it). Appending
/// `v1/trust-tasks` to the first gave `/v1/v1/trust-tasks`, which a VTC answers
/// with 405 (VTI-55), so the mount is added only when `base` does not already
/// end with it.
fn trust_tasks_url_string(base: &str) -> String {
    let base = base.trim_end_matches('/');
    let (mount, rest) = TRUST_TASKS_PATH
        .split_once('/')
        .expect("TRUST_TASKS_PATH is <mount>/<path>");
    if base.rsplit('/').next() == Some(mount) {
        format!("{base}/{rest}")
    } else {
        format!("{base}/{TRUST_TASKS_PATH}")
    }
}

/// The manifest question, as a document that names nobody.
///
/// Built by hand rather than through [`crate::trust_task_doc::build`] because
/// that one stamps an `issuer`, and this request deliberately has none — see
/// the module's "Nothing about the applicant is sent".
fn anonymous_request(community_did: &str, protocol: JoinProtocol) -> Value {
    serde_json::json!({
        "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
        "type": protocol.manifest_type(),
        "recipient": community_did,
        "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
        "payload": {},
    })
}

/// Read `community_did`'s join manifest from the REST endpoint `doc` publishes.
///
/// `doc` is the community's already-resolved DID document. `resolver` verifies
/// the answer's proof; it is separate from `doc` because the proof's
/// verification method is resolved in its own right rather than read out of a
/// document the answer came packaged with.
/// Returns the parsed manifest **and the payload as received**.
///
/// The raw payload is not a convenience: a generated criterion carries the members its schema
/// names and drops the rest, so `vetting.ext` — where a community publishes what this version
/// does not enumerate, and marks what a client must honour — is readable only from these bytes
/// (`vetting::hidden`). Re-serialising the parse gives a criterion with those members missing,
/// and a `requirementsDigest` that no longer matches.
/// A manifest read over REST: the 0.2-shaped manifest, the payload as received,
/// the version the community answered in, and what that version says per
/// criterion beyond the 0.2 shape.
pub struct FetchedManifest {
    pub manifest: manifest::v0_2::Response,
    pub raw: Value,
    pub protocol: JoinProtocol,
    pub meta: Vec<CriterionMeta>,
}

/// Read `community_did`'s join manifest, asking in 0.3 and, if the community
/// refuses that version, once more in 0.2.
pub async fn fetch_manifest(
    doc: &Value,
    community_did: &str,
    resolver: &DIDCacheClient,
    policy: ProbePolicy,
) -> Result<FetchedManifest, DiscoverError> {
    let mut protocol = JoinProtocol::default();
    loop {
        match fetch_manifest_in(doc, community_did, resolver, policy, protocol).await {
            Err(DiscoverError::Refused {
                code: Some(code), ..
            }) if is_version_refusal(&code) && protocol.fallback().is_some() => {
                protocol = protocol.fallback().expect("checked above");
            }
            other => return other,
        }
    }
}

async fn fetch_manifest_in(
    doc: &Value,
    community_did: &str,
    resolver: &DIDCacheClient,
    policy: ProbePolicy,
    protocol: JoinProtocol,
) -> Result<FetchedManifest, DiscoverError> {
    let endpoint = rest_endpoint(doc).ok_or(DiscoverError::NoEndpoint)?;
    let url = trust_tasks_url(&endpoint, policy)?;

    let mut builder = reqwest::Client::builder()
        .timeout(FETCH_TIMEOUT)
        .connect_timeout(FETCH_TIMEOUT)
        .redirect(reqwest::redirect::Policy::none())
        .no_proxy();
    if policy == ProbePolicy::PublicOnly {
        builder = builder.dns_resolver(affinidi_did_web::guarded_dns_resolver());
    }
    let client = builder
        .build()
        .map_err(|e| DiscoverError::Blocked(e.to_string()))?;

    let response = client
        .post(url)
        .json(&anonymous_request(community_did, protocol))
        .send()
        .await
        .map_err(|e| DiscoverError::Unreachable(e.to_string()))?;

    let status = response.status();
    let body = response
        .text()
        .await
        .map_err(|e| DiscoverError::Unreachable(e.to_string()))?;
    if !status.is_success() {
        let (code, detail) = refusal_detail(&body);
        return Err(DiscoverError::Refused {
            status: status.as_u16(),
            code,
            detail,
        });
    }

    let reply: TrustTask<Value> =
        serde_json::from_str(&body).map_err(|e| DiscoverError::Unreadable(e.to_string()))?;
    // A refusal is a refusal whatever status it came under. The HTTP binding
    // derives the status from the error code, and a `trust-task-error` read as
    // a manifest would hide the very code that says which version to ask in.
    if crate::messaging::is_trust_task_error_type(&reply.type_uri.to_string()) {
        let (code, detail) = refusal_detail(&body);
        return Err(DiscoverError::Refused {
            status: status.as_u16(),
            code,
            detail,
        });
    }

    // Proof before payload: nothing is read out of a document that has not been
    // shown to come from the community being asked.
    let proven = verify_trust_task_proof_with(&reply, &TrustTaskVmResolver::new(resolver.clone()))
        .await
        .map_err(|e| DiscoverError::Unproven(e.to_string()))?;
    if proven != community_did {
        return Err(DiscoverError::WrongSigner { proven });
    }

    // The version is the one the reply says it is, which a conformant
    // community answers in the version asked.
    let protocol =
        JoinProtocol::from_manifest_response(&reply.type_uri.to_string()).unwrap_or(protocol);
    let raw = reply.payload;
    let (manifest, meta) =
        read_manifest(protocol, &raw).map_err(|e| DiscoverError::Unreadable(e.to_string()))?;
    Ok(FetchedManifest {
        manifest,
        raw,
        protocol,
        meta,
    })
}

/// The `code` and `message` out of a `trust-task-error` body, or no code and
/// the body itself.
///
/// The code is what decides whether to ask again in another version; the
/// message is the whole value of the variant for a reader — falling back to the
/// raw body keeps a non-Trust-Task error (a proxy page, say) from becoming an
/// empty parenthesis. Bounded, because it is someone else's text on its way to
/// a terminal.
fn refusal_detail(body: &str) -> (Option<String>, String) {
    let parsed = serde_json::from_str::<Value>(body).ok();
    let field = |name: &str| {
        parsed.as_ref().and_then(|v| {
            v.get("payload")
                .and_then(|p| p.get(name))
                .or_else(|| v.get(name))
                .and_then(Value::as_str)
                .map(str::to_string)
        })
    };
    let code = field("code");
    let detail = field("message").unwrap_or_else(|| body.to_string());
    (
        code,
        crate::display::truncate_chars(&detail, 200).to_string(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn doc_with(service: Value) -> Value {
        json!({ "id": "did:webvh:x", "service": service })
    }

    /// VTI-55: a `VTCRest` endpoint that already carries the `/v1` mount (VTI's
    /// current `vtc-host` template) must not get a second one. A bare host
    /// (communities minted before the template carried it) still gets it.
    #[test]
    fn vti_55_the_api_mount_is_added_only_when_missing() {
        for base in [
            "https://vtc.example/v1",
            "https://vtc.example/v1/",
            "https://vtc.example",
            "https://vtc.example/",
        ] {
            assert_eq!(
                trust_tasks_url_string(base),
                "https://vtc.example/v1/trust-tasks",
                "{base}"
            );
        }
        // A community mounted under a path prefix keeps it.
        assert_eq!(
            trust_tasks_url_string("https://host.example/community/v1"),
            "https://host.example/community/v1/trust-tasks"
        );
        // A segment that merely ends in "v1" is not the mount.
        assert_eq!(
            trust_tasks_url_string("https://host.example/apiv1"),
            "https://host.example/apiv1/v1/trust-tasks"
        );
    }

    #[test]
    fn the_rest_endpoint_is_found_under_either_type_form() {
        let string_form = doc_with(json!([
            { "id": "#didcomm", "type": "DIDCommMessaging", "serviceEndpoint": "did:webvh:m" },
            { "id": "#vtc-rest", "type": "VTCRest", "serviceEndpoint": "https://vtc.example" },
        ]));
        assert_eq!(
            rest_endpoint(&string_form).as_deref(),
            Some("https://vtc.example")
        );

        // A document that writes `type` as an array has a REST endpoint too.
        let array_form = doc_with(json!([
            { "id": "#vtc-rest", "type": ["VTCRest"], "serviceEndpoint": "https://vtc.example" },
        ]));
        assert_eq!(
            rest_endpoint(&array_form).as_deref(),
            Some("https://vtc.example")
        );

        // And so does one that writes the endpoint as an object.
        let object_form = doc_with(json!([
            { "id": "#vtc-rest", "type": "VTCRest",
              "serviceEndpoint": { "uri": "https://vtc.example" } },
        ]));
        assert_eq!(
            rest_endpoint(&object_form).as_deref(),
            Some("https://vtc.example")
        );
    }

    /// No REST service is a community to ask over messaging instead, not an
    /// error to report — which is why it has a variant of its own.
    #[test]
    fn a_community_publishing_no_rest_service_has_no_endpoint() {
        let messaging_only = doc_with(json!([
            { "id": "#didcomm", "type": "DIDCommMessaging", "serviceEndpoint": "did:webvh:m" },
        ]));
        assert!(rest_endpoint(&messaging_only).is_none());
        assert!(rest_endpoint(&json!({ "id": "did:webvh:x" })).is_none());
    }

    /// The endpoint comes out of someone else's document, so the guards that
    /// apply to a health probe apply here — and a refusal says which one bit.
    #[test]
    fn a_published_endpoint_is_vetted_before_it_is_dialled() {
        let blocked = |url: &str| {
            matches!(
                trust_tasks_url(url, ProbePolicy::PublicOnly),
                Err(DiscoverError::Blocked(_))
            )
        };
        assert!(blocked("http://vtc.example"), "plaintext");
        assert!(blocked("https://user:pw@vtc.example"), "userinfo");
        assert!(blocked("https://127.0.0.1"), "loopback");
        assert!(blocked("file:///etc/passwd"), "scheme");

        let ok = trust_tasks_url("https://vtc.example/", ProbePolicy::PublicOnly)
            .expect("a public https endpoint is dialled");
        assert_eq!(ok.as_str(), "https://vtc.example/v1/trust-tasks");

        // A development stack on loopback is reachable under the other policy,
        // which is the whole difference between them.
        assert!(trust_tasks_url("http://127.0.0.1:8080", ProbePolicy::AllowPrivate).is_ok());
    }

    /// The request must name nobody: reading what a community asks of
    /// applicants cannot be what tells it who is considering applying.
    #[test]
    fn the_question_carries_no_issuer() {
        let request = anonymous_request("did:webvh:community", JoinProtocol::V0_3);
        assert!(
            request.get("issuer").is_none(),
            "a pre-application read must not name the reader"
        );
        assert_eq!(request["recipient"], "did:webvh:community");
        assert_eq!(request["type"], JoinProtocol::V0_3.manifest_type());
        assert!(request["payload"].as_object().is_some_and(|p| p.is_empty()));
    }

    #[test]
    fn a_refusal_is_reported_by_its_message_not_its_envelope() {
        let trust_task_error = r#"{"type":"…/trust-task-error/0.5",
            "payload":{"code":"malformedRequest","message":"body did not parse"}}"#;
        assert_eq!(
            refusal_detail(trust_task_error),
            (
                Some("malformedRequest".to_string()),
                "body did not parse".to_string()
            ),
            "the code is kept: it decides whether to ask in another version"
        );

        // A plain `message` (not every refusal is a Trust Task error).
        assert_eq!(
            refusal_detail(r#"{"message":"unauthorized"}"#).1,
            "unauthorized"
        );

        // Anything else is shown as it came, so an HTML proxy page does not
        // become an empty parenthesis.
        assert_eq!(
            refusal_detail("<html>502</html>"),
            (None, "<html>502</html>".to_string())
        );
    }

    /// Each variant has a different next step for the operator, so each says
    /// something different — rule R6.4.
    #[test]
    fn every_failure_says_which_one_it_is() {
        let said = |e: DiscoverError| e.to_string();
        let all = [
            said(DiscoverError::NoEndpoint),
            said(DiscoverError::Blocked("loopback".into())),
            said(DiscoverError::Unreachable("dns".into())),
            said(DiscoverError::Refused {
                status: 503,
                code: None,
                detail: "down".into(),
            }),
            said(DiscoverError::Unreadable("bad json".into())),
            said(DiscoverError::Unproven("no proof".into())),
            said(DiscoverError::WrongSigner {
                proven: "did:webvh:someone-else".into(),
            }),
        ];
        let mut seen = all.clone().to_vec();
        seen.sort();
        seen.dedup();
        assert_eq!(seen.len(), all.len(), "two failures read the same");
        assert!(all.iter().all(|s| !s.is_empty()));
    }
}