vtc-service 0.13.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
//! A group's identity and its authority, held by the community's VTA
//! (`vtc/groups/create` steps 1 – 2).
//!
//! The VTA mints the group's `did:webvh` from the `vtc-group` template and signs,
//! as the community, the authority credential the community issues the group
//! (`keys/sign-document`), so this community never holds a group key and never
//! falls back to one (`noKeyHolder`). It reaches the VTA over the link it
//! already keeps for the rooms it owns.
//!
//! The authority credential carries a slot in the community's own revocation
//! list, flipped when the group is deleted (`vtc/groups/delete` item 3), and an
//! expiry, renewed well before it lapses (create item 5).

use axum::extract::{Path, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::IntoResponse;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use vti_common::error::AppError;

use super::{Authority, GROUP_DID_PATH, did_log_key};
use crate::credentials::vmc::CredentialStatusRef;
use crate::rooms::community::{HolderError, KeyHolder, SIGN_TIMEOUT};
use crate::server::AppState;
use crate::status_list;

/// The action the community's authority credential grants a group: issuing its
/// own members' membership credentials, within the community.
pub const GROUP_AUTHORITY_ACTION: &str = "issue-membership";

/// Where a group identity's log is published.
#[derive(Debug, Clone)]
pub enum Publish {
    /// By a hosting server registered at the VTA, by its id there.
    Server(String),
    /// By this service, under its public URL.
    Served { public_url: String },
}

/// Everything a group's identity needs from configuration.
#[derive(Clone)]
pub struct Setup {
    pub holder: KeyHolder,
    pub community_did: String,
    /// The VTA key the community signs with, by its id there.
    pub community_key_id: String,
    pub context: String,
    pub publish: Publish,
    pub authority_days: u32,
    /// How the group's identity is presented, as the community states it to
    /// its VTA (VTI-VTC-004).
    pub presentation: crate::did_hosting::Presentation,
}

/// What to configure when the community cannot hold a group's keys: the
/// message of a `noKeyHolder`.
pub async fn setup(state: &AppState) -> Result<Setup, String> {
    let (hosting, presentation) = {
        let config = state.config.read().await.clone();
        let db = crate::config_store::ConfigStore::new(state.config_ks.clone());
        let hosting = crate::did_hosting::live(&config, &db)
            .await
            .map_err(|e| format!("where group identities are published could not be read: {e}"))?;
        let presentation = crate::did_hosting::live_presentation(&config, &db)
            .await
            .map_err(|e| format!("how group identities are presented could not be read: {e}"))?;
        (hosting, presentation)
    };
    let config = state.config.read().await;
    let Some(groups) = config.groups.clone() else {
        return Err(
            "this community has no groups until its operator turns them on: add a `[groups]` \
             section naming the context at the community's VTA the groups' keys are minted in \
             (`context = \"<context id>\"`), and restart"
                .into(),
        );
    };
    let Some(holder) = state.community_rooms.key_holder() else {
        return Err(
            "this community has no link to a VTA that can hold the group's keys: set `vta_did`, \
             the community's signing key and `[messaging]`, and restart"
                .into(),
        );
    };
    let Some(community_did) = config.vtc_did.clone().filter(|d| !d.is_empty()) else {
        return Err("this community has no DID yet".into());
    };
    let Some(signer) = state.credential_signer.as_ref() else {
        return Err("this community has no signing key to name at its VTA".into());
    };
    let context = groups
        .context
        .clone()
        .or_else(|| config.rooms.community.as_ref().map(|c| c.context.clone()))
        .filter(|c| !c.is_empty())
        .ok_or_else(|| {
            "a group's keys are minted in a context at the community's VTA: set `[groups] \
             context`, or `[rooms.community] context`"
                .to_string()
        })?;
    let server = crate::did_hosting::server_for(hosting.as_deref(), groups.webvh_server.as_deref());
    let publish = match (&server, &config.public_url) {
        (Some(server), _) => Publish::Server(server.clone()),
        (None, Some(url)) => Publish::Served {
            public_url: url.trim_end_matches('/').to_string(),
        },
        (None, None) => {
            return Err(
                "a group's identity is served from this community's public URL unless \
                 `dids.webvh_server` (or `[groups] webvh_server`) names a hosting server at the \
                 VTA: set `public_url`, or `dids.webvh_server`"
                    .into(),
            );
        }
    };
    Ok(Setup {
        holder,
        community_did,
        community_key_id: signer.assertion_method_id().to_string(),
        context,
        publish,
        authority_days: groups.authority_valid_days(),
        presentation,
    })
}

/// Why minting a group's identity or authority did not complete.
#[derive(Debug)]
pub enum Failure {
    /// Nothing reached the VTA, or this service failed after.
    Local(AppError),
    /// The VTA did not answer, or refused; the DID it minted, when it did.
    Holder(HolderError, Option<String>),
}

/// A group identity the VTA minted, with its authority.
#[derive(Debug, Clone)]
pub struct Minted {
    pub group_did: String,
    pub signing_key_id: String,
    pub did_log_path: Option<String>,
    pub authority: Option<(Authority, Value)>,
}

fn read<T: serde::de::DeserializeOwned>(what: &str, v: Value) -> Result<T, HolderError> {
    serde_json::from_value(v)
        .map_err(|e| HolderError::Unavailable(format!("{what} answered a shape not its own: {e}")))
}

/// Steps 1 and 2 of `vtc/groups/create`: mint the group's DID, and where it
/// issues credentials, the community's authority credential to it. A DID log
/// this service serves is published before anything verifies against the DID,
/// and withdrawn when a later step fails.
pub async fn mint(
    state: &AppState,
    setup: &Setup,
    issues_credentials: bool,
) -> Result<Minted, Failure> {
    let did_path = match &setup.publish {
        Publish::Served { .. } => Some(uuid::Uuid::new_v4().simple().to_string()),
        Publish::Server(_) => None,
    };
    let webvh_server = match &setup.publish {
        Publish::Served { public_url } => public_url.clone(),
        Publish::Server(id) => id.clone(),
    };
    // A label at the VTA only: the group's name is never in its DID document,
    // nor anywhere the VTA publishes. No transport either, said explicitly: the
    // `vtc-group` template carries no service (nothing delivers messages to a
    // group yet), and a VTA left to its own default would try to add `#tsp`
    // at a mediator the document does not name.
    let mut request = json!({
        "contextId": setup.context,
        "template": "vtc-group",
        "templateVars": { "WEBVH_SERVER": webvh_server },
        "setPrimary": false,
        "label": "Community group",
        "addMediatorService": false,
        "addTspService": false,
    });
    match (&setup.publish, &did_path) {
        (Publish::Server(id), _) => request["serverId"] = json!(id),
        (Publish::Served { public_url }, Some(path)) => {
            request["url"] = json!(format!("{public_url}/{GROUP_DID_PATH}/{path}/did.jsonl"))
        }
        (Publish::Served { .. }, None) => unreachable!("a served identity always has a path"),
    }
    let create_uri = crate::did_hosting::with_presentation(&mut request, setup.presentation);
    let request: vta_sdk::protocols::did_management::create::CreateDidWebvhBody =
        serde_json::from_value(request)
            .map_err(|e| Failure::Local(AppError::Internal(format!("webvh/dids/create: {e}"))))?;
    let request = serde_json::to_value(request)
        .map_err(|e| Failure::Local(AppError::Internal(format!("webvh/dids/create: {e}"))))?;
    let answer = setup
        .holder
        .ask(create_uri, request, SIGN_TIMEOUT)
        .await
        .map_err(|e| Failure::Holder(e, None))?;
    let minted: vta_sdk::protocols::did_management::create::CreateDidWebvhResultBody =
        read("webvh/dids/create", answer).map_err(|e| Failure::Holder(e, None))?;
    let group_did = crate::did_hosting::presented_did(&minted);

    if let Some(path) = &did_path {
        let Some(log) = minted.log_entry.as_deref() else {
            return Err(Failure::Holder(
                HolderError::Unavailable(
                    "the community's VTA minted the group's identity serverless and returned no \
                     log"
                    .into(),
                ),
                Some(group_did),
            ));
        };
        put_did_log(state, path, minted.log_did(), log)
            .await
            .map_err(Failure::Local)?;
    }

    let authority = if issues_credentials {
        match issue_authority(state, setup, &group_did, Utc::now(), None).await {
            Ok(a) => Some(a),
            Err(e) => {
                if let Some(path) = &did_path {
                    let _ = remove_did_log(state, path).await;
                }
                return Err(match e {
                    Failure::Holder(h, _) => Failure::Holder(h, Some(group_did)),
                    other => other,
                });
            }
        }
    } else {
        None
    };
    Ok(Minted {
        group_did,
        signing_key_id: minted.signing_key_id,
        did_log_path: did_path,
        authority,
    })
}

/// The community's authority credential to `group_did`, signed by its VTA as
/// the community: a DTG `AuthorityCredential` granting
/// [`GROUP_AUTHORITY_ACTION`] within the community, with a slot in the
/// community's revocation list. Answers what this community keeps of it, and
/// the signed credential.
///
/// `slot` is the slot a renewal reuses: every authority a group is ever issued
/// shares one, so deleting the group revokes them all with one bit. `None`
/// allocates a fresh one.
pub async fn issue_authority(
    state: &AppState,
    setup: &Setup,
    group_did: &str,
    valid_from: DateTime<Utc>,
    slot: Option<u32>,
) -> Result<(Authority, Value), Failure> {
    let valid_until = valid_from + Duration::days(i64::from(setup.authority_days));
    let slot = status_list::with_locked(
        &state.status_lists_ks,
        affinidi_status_list::StatusPurpose::Revocation,
        move |row| {
            let slot = match slot {
                Some(slot) => Some(slot),
                None => status_list::allocate(row),
            };
            Ok(slot.map(|slot| (slot, row.list_credential_id.clone())))
        },
    )
    .await
    .map_err(Failure::Local)?;
    let Some((slot, list_credential_id)) = slot else {
        return Err(Failure::Local(AppError::Internal(
            "revocation status list is full — cannot allocate a slot for a group's authority"
                .into(),
        )));
    };
    let dtg = dtg_credentials::DTGCredential::new_vac(
        setup.community_did.clone(),
        dtg_credentials::IssuerScope::Public,
        group_did.to_string(),
        setup.community_did.clone(),
        vec![GROUP_AUTHORITY_ACTION.to_string()],
        valid_from,
        valid_until,
    )
    .map_err(|e| Failure::Local(AppError::Validation(format!("group authority: {e}"))))?;
    dtg.validate().map_err(|e| {
        Failure::Local(AppError::Validation(format!(
            "group authority is not conformant: {e}"
        )))
    })?;
    let mut document = serde_json::to_value(dtg.credential())
        .map_err(|e| Failure::Local(AppError::Internal(format!("group authority: {e}"))))?;
    let credential_id = format!("urn:uuid:{}", uuid::Uuid::new_v4());
    document["id"] = json!(credential_id);
    document["credentialStatus"] =
        serde_json::to_value(CredentialStatusRef::revocation(list_credential_id, slot))
            .map_err(|e| Failure::Local(AppError::Internal(format!("credentialStatus: {e}"))))?;

    let answer = setup
        .holder
        .ask(
            vta_sdk::trust_tasks::TASK_KEYS_SIGN_DOCUMENT_0_1,
            json!({ "keyId": setup.community_key_id, "document": document }),
            SIGN_TIMEOUT,
        )
        .await
        .map_err(|e| Failure::Holder(e, None))?;
    let signed: vta_sdk::protocols::key_management::sign_document::SignDocumentResponse =
        read("keys/sign-document", answer).map_err(|e| Failure::Holder(e, None))?;
    Ok((
        Authority {
            credential_id,
            valid_until,
            status_list_index: slot,
        },
        Value::Object(signed.document),
    ))
}

/// Revoke a group's authority credential in the community's revocation list.
pub async fn revoke_authority(state: &AppState, authority: &Authority) -> Result<(), AppError> {
    let slot = authority.status_list_index;
    status_list::with_locked(
        &state.status_lists_ks,
        affinidi_status_list::StatusPurpose::Revocation,
        move |row| {
            status_list::flip(row, slot, true)
                .map_err(|e| AppError::Internal(format!("flip status-list bit {slot}: {e}")))
        },
    )
    .await
}

#[derive(Serialize, Deserialize)]
struct DidLog {
    did: String,
    log: String,
}

pub async fn put_did_log(
    state: &AppState,
    path: &str,
    did: &str,
    log: &str,
) -> Result<(), AppError> {
    state
        .directory
        .ks()
        .insert(
            did_log_key(path),
            &DidLog {
                did: did.to_string(),
                log: log.to_string(),
            },
        )
        .await
}

pub async fn remove_did_log(state: &AppState, path: &str) -> Result<(), AppError> {
    state.directory.ks().remove(did_log_key(path)).await
}

/// Whether `id` is a path segment this service could have issued: hex.
fn plain_id(id: &str) -> bool {
    (1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_hexdigit())
}

/// `GET /group-dids/{id}/did.jsonl` — the log of a group identity this
/// community minted serverless. Public: a resolver carries no credential, and a
/// `did:webvh` log is published by design. It names no group.
pub async fn serve_group_did_log(
    State(state): State<AppState>,
    Path(id): Path<String>,
) -> impl IntoResponse {
    if !plain_id(&id) {
        return (StatusCode::NOT_FOUND, "did log not found").into_response();
    }
    match state.directory.ks().get::<DidLog>(did_log_key(&id)).await {
        Ok(Some(l)) => (
            StatusCode::OK,
            [(
                header::CONTENT_TYPE,
                HeaderValue::from_static("text/jsonl; charset=utf-8"),
            )],
            l.log,
        )
            .into_response(),
        Ok(None) => (StatusCode::NOT_FOUND, "did log not found").into_response(),
        Err(e) => {
            tracing::warn!(error = %e, "could not read a group identity's log");
            (StatusCode::INTERNAL_SERVER_ERROR, "did log unavailable").into_response()
        }
    }
}

/// Whether `path` is a group identity's log this service may serve: the
/// surface gate lets it through on every host, as it does a room identity's.
pub fn is_group_did_log_path(path: &str) -> bool {
    path.strip_prefix(&format!("/{GROUP_DID_PATH}/"))
        .and_then(|rest| rest.strip_suffix("/did.jsonl"))
        .is_some_and(plain_id)
}

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

    #[test]
    fn only_a_served_group_log_path_passes_the_surface_gate() {
        assert!(is_group_did_log_path("/group-dids/0a1b2c/did.jsonl"));
        assert!(!is_group_did_log_path("/group-dids/../did.jsonl"));
        assert!(!is_group_did_log_path("/group-dids/0a1b2c/other"));
        assert!(!is_group_did_log_path("/room-dids/0a1b2c/did.jsonl"));
    }
}