openvtc-core 0.5.0

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
//! An application as a journey: every step it takes, which are done, which one
//! is the holder's to take now, and which wait on someone else.
//!
//! The pages that walk an applicant through vetting draw from this, so the
//! order and the rules live in one place and can be tested without a screen.
//! It is built on [`Application::next_step`] — the step this marks current is
//! the one that function already names — so the journey and the one-line
//! "next:" hints elsewhere cannot disagree about what to do.
//!
//! Each step also says, in plain words, what it is for. The journey is meant to
//! teach the process as it goes: why a face, why a match code read aloud, what
//! the community will and will not learn. Those sentences are made here for the
//! same reason [`super::guide`] makes the requirement sentences: one wording,
//! wherever it is shown.

use chrono::{DateTime, Utc};

use super::applicant::{Application, NextStep, RequestState};
use super::vetter::{DeskEntry, DeskState};

/// Where a step stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StepState {
    /// Done.
    Done,
    /// The holder's to take now. At most one step is current.
    Current,
    /// Waiting on someone else — a vetter, or the community.
    Waiting,
    /// Not reached yet.
    Todo,
}

/// The steps of an application, in the order they are taken.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ApplicantStep {
    /// Learn what the community asks for.
    Requirements,
    /// Choose the face vetters see.
    Face,
    /// Ask vetters, with their tickets.
    Vetters,
    /// Meet each vetter: read the match code, send the card.
    Sessions,
    /// Collect enough statements.
    Statements,
    /// See what the community will receive, then join.
    Join,
}

impl ApplicantStep {
    /// Every step, in order.
    pub const ALL: [ApplicantStep; 6] = [
        ApplicantStep::Requirements,
        ApplicantStep::Face,
        ApplicantStep::Vetters,
        ApplicantStep::Sessions,
        ApplicantStep::Statements,
        ApplicantStep::Join,
    ];

    /// The step's name, short enough for a strip across the top of a page.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            ApplicantStep::Requirements => "Requirements",
            ApplicantStep::Face => "Face",
            ApplicantStep::Vetters => "Vetters",
            ApplicantStep::Sessions => "Sessions",
            ApplicantStep::Statements => "Statements",
            ApplicantStep::Join => "Join",
        }
    }

    /// What this step is for, in sentences a person reads before acting.
    ///
    /// `hidden` is whether the community proves vetting with a PCS
    /// zero-knowledge proof, which changes what the later steps mean for the
    /// vetters.
    #[must_use]
    pub fn explain(self, hidden: bool) -> &'static [&'static str] {
        match (self, hidden) {
            (ApplicantStep::Requirements, _) => &[
                "A community that vets its members publishes what it needs before it will \
                 decide on you: how many vetters, how they must check you, and what they check.",
                "Nothing about you has been sent yet. Reading the requirements first is how you \
                 decide whether to apply at all.",
            ],
            (ApplicantStep::Face, _) => &[
                "Vetters never see your whole identity. They see a face — the attributes you \
                 choose to show — and check it against your documents.",
                "The face is worn in this community's context, so the community sees the same \
                 face when you join. Its values must match your documents exactly.",
            ],
            (ApplicantStep::Vetters, _) => &[
                "A vetter is a member the community has named to vouch for people. You reach \
                 one with their ticket — a link or QR code they give you, or one found in the \
                 community's directory.",
                "Each request goes to one vetter. You need statements from different vetters, \
                 so ask as many as the requirements call for.",
            ],
            (ApplicantStep::Sessions, _) => &[
                "When you and a vetter are together — in person or on a call — they open a \
                 session, and both your screens show the same match code.",
                "Read it aloud and hear it read back. Matching codes prove the card you send \
                 reaches the person in front of you, not someone who intercepted the request.",
            ],
            (ApplicantStep::Statements, false) => &[
                "After checking you, each vetter signs a statement that they did, and sends it \
                 to you. You hold them; the community has none of them yet.",
                "These statements are named: when you join, the community sees which vetters \
                 vouched for you.",
            ],
            (ApplicantStep::Statements, true) => &[
                "After checking you, each vetter sends you an attestation. You hold them; the \
                 community has none of them yet.",
                "This community uses a PCS zero-knowledge proof: when you join, it learns that \
                 enough vetters vouched for you, but never which ones.",
            ],
            (ApplicantStep::Join, false) => &[
                "Joining sends the community your persona's DID, the vetting statements you \
                 hold, and what your face shows. Review it before it goes.",
                "Each statement names its vetter, so the community will see who vouched for \
                 you.",
            ],
            (ApplicantStep::Join, true) => &[
                "Joining sends the community your persona's DID, what your face shows, and a \
                 zero-knowledge proof built from the attestations you hold.",
                "The proof shows enough vetters vouched for you without revealing who — the \
                 community never sees a vetter's identity.",
            ],
        }
    }
}

/// One step of a journey, with where it stands and a few words about progress.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JourneyStep<S> {
    pub step: S,
    pub state: StepState,
    /// Progress in a few words — "1 of 2", "Work" — when there is any.
    pub detail: Option<String>,
}

/// An application's journey, every step in order.
#[must_use]
pub fn applicant_journey(app: &Application, now: DateTime<Utc>) -> Vec<JourneyStep<ApplicantStep>> {
    let next = app.next_step(now);
    let evaluation = app.checklist(now);
    let satisfied = evaluation.as_ref().is_some_and(|e| e.satisfied());
    let needed = app
        .requirements
        .as_ref()
        .map(|r| r.min_statements.get() as usize);
    let held = app
        .statements
        .iter()
        .filter(|s| s.valid_until > now)
        .count();
    let asked = app
        .requests
        .iter()
        .filter(|r| {
            !matches!(
                r.state,
                RequestState::Declined { .. } | RequestState::Refused { .. }
            )
        })
        .count();
    let open_sessions = app
        .requests
        .iter()
        .filter(|r| matches!(r.state, RequestState::Session { ref card, .. } if card.is_none()))
        .count();
    let waiting_on_vetters = app.requests.iter().any(|r| {
        matches!(
            r.state,
            RequestState::Sent | RequestState::Accepted { .. } | RequestState::Session { .. }
        )
    });
    let cards_sent = app
        .requests
        .iter()
        .filter(|r| {
            matches!(r.state, RequestState::Session { ref card, .. } if card.is_some())
                || matches!(r.state, RequestState::Attested { .. })
        })
        .count();

    let known = app.requirements.is_some();
    let state_of = |step: ApplicantStep| -> StepState {
        match step {
            ApplicantStep::Requirements if known => StepState::Done,
            ApplicantStep::Requirements => StepState::Current,
            _ if !known => StepState::Todo,
            ApplicantStep::Face if app.face.is_some() => StepState::Done,
            // A card already sent shows a face was worn, recorded or not.
            ApplicantStep::Face if cards_sent > 0 || held > 0 => StepState::Done,
            ApplicantStep::Face => StepState::Current,
            _ if satisfied => match step {
                ApplicantStep::Join => StepState::Current,
                _ => StepState::Done,
            },
            ApplicantStep::Vetters if asked >= needed.unwrap_or(1) => StepState::Done,
            ApplicantStep::Vetters if matches!(next, NextStep::AskVetter) => StepState::Current,
            ApplicantStep::Vetters if asked > 0 => StepState::Done,
            ApplicantStep::Vetters => StepState::Current,
            ApplicantStep::Sessions if open_sessions > 0 => StepState::Current,
            ApplicantStep::Sessions if asked == 0 => StepState::Todo,
            ApplicantStep::Sessions if waiting_on_vetters => StepState::Waiting,
            ApplicantStep::Sessions => StepState::Done,
            ApplicantStep::Statements if waiting_on_vetters || held > 0 => StepState::Waiting,
            ApplicantStep::Statements => StepState::Todo,
            ApplicantStep::Join => StepState::Todo,
        }
    };
    let detail_of = |step: ApplicantStep| -> Option<String> {
        match step {
            ApplicantStep::Face => app.face.as_ref().map(|f| f.name.clone()),
            ApplicantStep::Vetters if asked > 0 => Some(format!("{asked} asked")),
            ApplicantStep::Sessions if open_sessions > 0 => Some(format!(
                "{open_sessions} code{} to read",
                if open_sessions == 1 { "" } else { "s" }
            )),
            ApplicantStep::Sessions if cards_sent > 0 => Some(format!("{cards_sent} card(s) sent")),
            ApplicantStep::Statements => needed.map(|n| format!("{} of {n}", held.min(n))),
            _ => None,
        }
    };

    let mut steps: Vec<JourneyStep<ApplicantStep>> = ApplicantStep::ALL
        .iter()
        .map(|&step| JourneyStep {
            step,
            state: state_of(step),
            detail: detail_of(step),
        })
        .collect();
    // One current step at most, and it is the first: a later step marked
    // current while an earlier one still is would offer two things to do.
    let mut seen_current = false;
    for s in &mut steps {
        if s.state == StepState::Current {
            if seen_current {
                s.state = StepState::Todo;
            }
            seen_current = true;
        }
    }
    steps
}

/// The steps a vetter takes for one request, in order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VetterStep {
    /// The request arrived on one of your tickets.
    Request,
    /// Open a session when you are together.
    Session,
    /// Read the match code aloud and hear it back.
    Code,
    /// Their card arrives and is verified.
    Card,
    /// Check the person against their document.
    Check,
    /// Sign the statement and send it.
    Sign,
}

impl VetterStep {
    /// Every step, in order.
    pub const ALL: [VetterStep; 6] = [
        VetterStep::Request,
        VetterStep::Session,
        VetterStep::Code,
        VetterStep::Card,
        VetterStep::Check,
        VetterStep::Sign,
    ];

    /// The step's name, short enough for a strip across the top of a page.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            VetterStep::Request => "Request",
            VetterStep::Session => "Session",
            VetterStep::Code => "Match code",
            VetterStep::Card => "Card",
            VetterStep::Check => "Check",
            VetterStep::Sign => "Sign",
        }
    }

    /// What this step is for. `hidden` is whether the community proves vetting
    /// with a PCS zero-knowledge proof, which changes what signing costs you.
    #[must_use]
    pub fn explain(self, hidden: bool) -> &'static [&'static str] {
        match (self, hidden) {
            (VetterStep::Request, _) => &[
                "Someone used one of your tickets to ask you to vet them for this community. \
                 Nothing is decided yet, and you never have to take a request on.",
                "Arrange to meet — in person or on a call. Decline at any point; the community \
                 is not told why.",
            ],
            (VetterStep::Session, _) => &[
                "Open a session when the two of you are together. It sends the applicant a \
                 challenge, and both screens show the same match code.",
                "Choose how you are meeting. The statement records it, and the community counts \
                 statements by method.",
            ],
            (VetterStep::Code, _) => &[
                "Read the match code aloud and wait for them to read it back. Do not continue \
                 if it differs.",
                "Matching codes prove the card you are about to receive comes from the person \
                 you are talking to — not from someone who intercepted the request.",
            ],
            (VetterStep::Card, _) => &[
                "Their card is the face they chose to show you, signed by the DID they will \
                 join with. It is verified before you see it.",
                "Its values are what you check their documents against. You keep it only \
                 briefly, and only to make this decision.",
            ],
            (VetterStep::Check, _) => &[
                "Look at their identity document. Check it appears genuine, that the photo is \
                 the person you are talking to, and that each value on the card matches it.",
                "Do not keep a copy of the document. Your statement records which kind of \
                 document you saw, never its contents.",
            ],
            (VetterStep::Sign, false) => &[
                "Signing says, as you, that you checked this person this way. It goes to the \
                 applicant, who presents it when they join.",
                "This statement is named: the community will see your DID against this \
                 applicant. You can withdraw it later if you learn it was wrong.",
            ],
            (VetterStep::Sign, true) => &[
                "Signing sends the applicant an attestation that you checked them this way.",
                "This community uses a PCS zero-knowledge proof: it counts your attestation \
                 toward the applicant's admission without ever learning it came from you.",
            ],
        }
    }
}

/// A desk request's journey, every step in order, and whether it has ended
/// (signed or declined).
#[must_use]
pub fn vetter_journey(entry: &DeskEntry) -> (Vec<JourneyStep<VetterStep>>, Option<VetterEnding>) {
    use VetterStep as V;
    let (reached, ending): (usize, Option<VetterEnding>) = match &entry.state {
        // Index of the current step; everything before it is done.
        DeskState::Accepted => (1, None),
        DeskState::Session { .. } => (2, None),
        DeskState::CardReceived { .. } => (4, None),
        DeskState::Attested { .. } => (V::ALL.len(), Some(VetterEnding::Signed)),
        DeskState::Declined { .. } => (0, Some(VetterEnding::Declined)),
    };
    let steps = V::ALL
        .iter()
        .enumerate()
        .map(|(i, &step)| {
            let state = match ending {
                Some(VetterEnding::Declined) => StepState::Todo,
                _ if i < reached => StepState::Done,
                // While the code is being read, the card is theirs to send.
                _ if matches!(entry.state, DeskState::Session { .. }) && step == V::Card => {
                    StepState::Waiting
                }
                _ if i == reached => StepState::Current,
                _ => StepState::Todo,
            };
            JourneyStep {
                step,
                state,
                detail: match (&entry.state, step) {
                    (
                        DeskState::Session { session } | DeskState::CardReceived { session, .. },
                        V::Code,
                    ) => Some(session.match_code.clone()),
                    _ => None,
                },
            }
        })
        .collect();
    (steps, ending)
}

/// How a desk request ended.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VetterEnding {
    /// You signed a statement (or a hidden attestation).
    Signed,
    /// You declined.
    Declined,
}

/// The step the holder takes now, if any — every other step is done, or
/// waits on someone else.
#[must_use]
pub fn current<S: Copy>(steps: &[JourneyStep<S>]) -> Option<S> {
    steps
        .iter()
        .find(|s| s.state == StepState::Current)
        .map(|s| s.step)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::account::PersonaId;
    use crate::vetting::applicant::ChosenFace;

    fn app() -> Application {
        Application::new(
            "did:web:vtc.example",
            PersonaId::new(),
            "did:key:zApplicant",
            Utc::now(),
        )
        .unwrap()
    }

    fn requirements() -> vta_sdk::protocols::vetting::VettingRequirements {
        serde_json::from_value(serde_json::json!({
            "version": "0.1",
            "statementType": vta_sdk::protocols::vetting::VETTED_PREDICATE,
            "minStatements": 2,
            "acceptedMethods": ["inPerson", "video"],
            "eligibleVetters": { "role": "vetter" }
        }))
        .unwrap()
    }

    fn states(app: &Application) -> Vec<StepState> {
        applicant_journey(app, Utc::now())
            .into_iter()
            .map(|s| s.state)
            .collect()
    }

    use StepState::{Current, Done, Todo};

    #[test]
    fn a_new_application_starts_by_learning_the_requirements() {
        let a = app();
        assert_eq!(states(&a), [Current, Todo, Todo, Todo, Todo, Todo]);
        assert_eq!(
            current(&applicant_journey(&a, Utc::now())),
            Some(ApplicantStep::Requirements)
        );
    }

    #[test]
    fn with_requirements_known_the_face_comes_next_then_vetters() {
        let mut a = app();
        a.requirements = Some(requirements());
        assert_eq!(states(&a), [Done, Current, Todo, Todo, Todo, Todo]);

        a.face = Some(ChosenFace {
            profile_id: "p".into(),
            name: "Work".into(),
        });
        let journey = applicant_journey(&a, Utc::now());
        assert_eq!(current(&journey), Some(ApplicantStep::Vetters));
        assert_eq!(journey[1].detail.as_deref(), Some("Work"));
        assert_eq!(journey[4].detail.as_deref(), Some("0 of 2"));
    }

    /// Every step explains itself, and the two vetting modes say different
    /// things where it matters: who the community learns vouched for you.
    #[test]
    fn every_step_explains_itself_and_hidden_mode_says_vetters_stay_hidden() {
        for step in ApplicantStep::ALL {
            assert!(!step.explain(false).is_empty(), "{step:?}");
            assert!(!step.explain(true).is_empty(), "{step:?}");
        }
        let named = ApplicantStep::Join.explain(false).join(" ");
        let hidden = ApplicantStep::Join.explain(true).join(" ");
        assert!(named.contains("see who vouched"), "{named}");
        assert!(hidden.contains("without revealing who"), "{hidden}");
    }
}