did-hosting-server 0.9.0

DID Hosting Server, handles management and serving of WebVH Content
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
//! Server DID bootstrap — creates DID log entries for hosted DIDs.
//!
//! Shared logic used by both the `bootstrap-did` CLI subcommand and the
//! auto-bootstrap path on server startup.

use affinidi_tdk::secrets_resolver::secrets::Secret;
use did_hosting_common::did::{
    DidDocumentOptions, build_did_document, create_log_entry, encode_host,
};
use tracing::info;

use crate::did_ops::{
    DidRecord, content_log_key, content_witness_key, did_key, extract_did_id,
    extract_service_types, owner_key, validate_did_jsonl,
};
use crate::error::AppError;
use crate::store::{KeyspaceHandle, Store};
use did_hosting_common::server::auth::session::now_epoch;

/// Result of bootstrapping the root DID.
#[derive(Debug)]
pub struct BootstrapResult {
    pub scid: String,
    pub did_id: String,
    pub jsonl: String,
    pub mnemonic: String,
}

/// Have a witness service witness a freshly bootstrapped DID's last log entry
/// with `webvh/witness/sign/0.1`, and return the `did-witness.json` to serve
/// with it.
///
/// The request is signed by the new DID itself (its `#key-0`, the
/// `authentication` key [`bootstrap_did`] gives it), so the witness resolves
/// that DID to verify the request: the DID must already be served where its
/// identifier says, and the witness must list it as an administrator. The
/// witness verifies the whole log before it signs, and its signed reply is
/// verified against `witness_did` before the proof is used.
pub async fn request_witness_proof(
    witness_url: &str,
    witness_did: &str,
    witness_id: &str,
    result: &BootstrapResult,
    signing_secret: &Secret,
) -> Result<String, AppError> {
    let version_id = result
        .jsonl
        .lines()
        .rfind(|l| !l.trim().is_empty())
        .and_then(|line| serde_json::from_str::<serde_json::Value>(line).ok())
        .and_then(|v| v.get("versionId")?.as_str().map(String::from))
        .ok_or_else(|| AppError::Internal("the DID log has no versionId to witness".into()))?;
    let mut signer = signing_secret.clone();
    signer.id = format!("{}#key-0", result.did_id);
    let resolver = affinidi_did_resolver_cache_sdk::DIDCacheClient::new(
        affinidi_did_resolver_cache_sdk::config::DIDCacheConfigBuilder::default().build(),
    )
    .await
    .map_err(|e| AppError::Internal(format!("DID resolver: {e}")))?;
    let client = did_hosting_common::WitnessClient::new(
        witness_url,
        witness_did,
        &result.did_id,
        signer,
        resolver,
    );
    let signed = client
        .sign(witness_id, &version_id, &result.jsonl)
        .await
        .map_err(|e| AppError::Internal(format!("witness/sign: {e}")))?;
    let witness_file = serde_json::json!([{
        "versionId": signed.version_id.as_str(),
        "proof": [signed.proof],
    }]);
    Ok(witness_file.to_string())
}

/// Check whether the `.well-known` root DID already exists.
pub async fn root_did_exists(dids_ks: &KeyspaceHandle) -> Result<bool, AppError> {
    dids_ks.contains_key(did_key(".well-known")).await
}

/// Create the `.well-known` root DID log entry and store it atomically.
///
/// Convenience wrapper around [`bootstrap_did`] for the root DID path.
pub async fn bootstrap_root_did(
    store: &Store,
    dids_ks: &KeyspaceHandle,
    signing_secret: &Secret,
    ka_secret: Option<&Secret>,
    mediator_did: Option<&str>,
    public_url: &str,
) -> Result<BootstrapResult, AppError> {
    bootstrap_did(
        store,
        dids_ks,
        signing_secret,
        ka_secret,
        mediator_did,
        // Convenience wrapper: advertise both transports at the mediator
        // (callers that need a specific transport call `bootstrap_did`).
        true,
        true,
        public_url,
        ".well-known",
    )
    .await
}

/// Create a DID log entry at the given path and store it atomically.
///
/// The signing secret's public key is embedded in the DID document. If
/// `ka_secret` is provided, an X25519 key agreement key is also added.
/// If `mediator_did` is provided, a `DIDCommMessaging` service is added
/// when `advertise_didcomm` is set and a `TSPTransport` service when
/// `advertise_tsp` is set — both point at the same mediator (they share
/// its socket). Gating these on the node's transport selection lets a
/// self-managed node advertise DIDComm-only, TSP-only, or both.
/// The resulting log entry is stored alongside a `DidRecord` with owner `"system"`.
#[allow(clippy::too_many_arguments)]
pub async fn bootstrap_did(
    store: &Store,
    dids_ks: &KeyspaceHandle,
    signing_secret: &Secret,
    ka_secret: Option<&Secret>,
    mediator_did: Option<&str>,
    advertise_didcomm: bool,
    advertise_tsp: bool,
    public_url: &str,
    mnemonic: &str,
) -> Result<BootstrapResult, AppError> {
    // Guard: must not already exist
    if dids_ks.contains_key(did_key(mnemonic)).await? {
        return Err(AppError::Conflict(format!(
            "DID at path '{mnemonic}' already exists"
        )));
    }

    let host = encode_host(public_url)
        .map_err(|e| AppError::Config(format!("failed to encode host from public_url: {e}")))?;

    let public_key = signing_secret
        .get_public_keymultibase()
        .map_err(|e| AppError::Internal(format!("failed to get public key multibase: {e}")))?;

    let ka_public_key = ka_secret
        .map(|s| s.get_public_keymultibase())
        .transpose()
        .map_err(|e| AppError::Internal(format!("failed to get KA public key multibase: {e}")))?;

    let doc = build_did_document(
        &host,
        mnemonic,
        &public_key,
        &DidDocumentOptions {
            key_agreement_multibase: ka_public_key.as_deref(),
            // Advertise each transport's service entry only when selected;
            // both point at the same mediator (shared socket).
            mediator_endpoint: if advertise_didcomm {
                mediator_did
            } else {
                None
            },
            tsp_endpoint: if advertise_tsp { mediator_did } else { None },
        },
    );

    let (scid, jsonl) = create_log_entry(&doc, signing_secret)
        .await
        .map_err(|e| AppError::Internal(format!("failed to create log entry: {e}")))?;

    let did_id = extract_did_id(&jsonl)
        .ok_or_else(|| AppError::Internal("failed to extract DID id from log entry".into()))?;

    let mnemonic = mnemonic.to_string();
    let now = now_epoch();

    let record = DidRecord {
        owner: "system".to_string(),
        mnemonic: mnemonic.clone(),
        created_at: now,
        updated_at: now,
        version_count: 1,
        did_id: Some(did_id.clone()),
        content_size: jsonl.len() as u64,
        disabled: false,
        deleted_at: None,

        // T12: legacy construction site; T13 migration fills `domain`.
        method: "webvh".to_string(),
        domain: String::new(),

        // The server's own DID — its badges (WebVHHosting, and TSP and/or
        // DIDComm per `advertise_tsp` / `mediator_did` above) are exactly
        // what `build_did_document` just wrote into the doc.
        services: extract_service_types(&jsonl),
        agent_names: Vec::new(),
    };

    let mut batch = store.batch();
    batch.insert(dids_ks, did_key(&mnemonic), &record)?;
    batch.insert_raw(
        dids_ks,
        content_log_key(&mnemonic),
        jsonl.as_bytes().to_vec(),
    );
    batch.insert_raw(
        dids_ks,
        owner_key("system", &mnemonic),
        mnemonic.as_bytes().to_vec(),
    );
    batch.commit().await?;

    info!(did = %did_id, scid = %scid, path = %mnemonic, "DID bootstrapped");

    Ok(BootstrapResult {
        scid,
        did_id,
        jsonl,
        mnemonic,
    })
}

/// Import an existing `.well-known` root DID from provided JSONL content.
///
/// Validates the JSONL, extracts the DID id and SCID, and stores everything
/// atomically. Optionally stores witness content alongside the log.
pub async fn import_root_did(
    store: &Store,
    dids_ks: &KeyspaceHandle,
    jsonl: &str,
    witness_content: Option<&str>,
) -> Result<BootstrapResult, AppError> {
    // Guard: must not already exist
    if root_did_exists(dids_ks).await? {
        return Err(AppError::Conflict(
            "root DID (.well-known) already exists".into(),
        ));
    }

    import_did_at_path(store, dids_ks, ".well-known", jsonl, witness_content).await
}

/// What setup found when it went to import the service's **own** DID — the
/// daemon's or the server's; both setups go through here.
#[derive(Debug)]
pub enum OwnDidImport {
    /// The path was free; the DID is now stored.
    Imported(BootstrapResult),
    /// The path already holds **this same DID** — a re-run of setup. Nothing
    /// to write; the caller still points its config at it.
    AlreadyPresent { did_id: String },
    /// The path holds a **different** DID. Nothing was written.
    ///
    /// The ordinary cause is moving a service to a new `public_url` with
    /// `--force-reprovision`: setup mints a new DID for the new host, while the
    /// store still holds the previous one at the same path, bound to a hostname
    /// that may no longer exist.
    HeldByAnother {
        existing: Option<String>,
        minted: String,
    },
}

/// Import the service's own DID during setup, telling a re-run apart from a
/// stale identity rather than reporting both as one conflict.
///
/// Keyring VTI-17, reported on the daemon; the server's three setup paths had
/// the identical defect. Setup used to call [`import_did_at_path`], whose guard is
/// "anything at this path", and on any error printed a warning and carried
/// on. So a daemon moved to a new host kept serving its **old** DID — the new
/// host answered 404 for `/.well-known/did.jsonl` — while setup reported
/// success and the daemon reported healthy. The warning also advised retrying
/// with `bootstrap-did`, which hits the identical guard.
///
/// [`import_did_at_path`] is left unchanged: `bootstrap-did` is an explicit
/// request, and a conflict there should stay an error.
pub async fn import_own_did(
    store: &Store,
    dids_ks: &KeyspaceHandle,
    mnemonic: &str,
    jsonl: &str,
) -> Result<OwnDidImport, AppError> {
    let minted = extract_did_id(jsonl)
        .ok_or_else(|| AppError::Validation("could not extract DID id from did.jsonl".into()))?;

    if let Some(existing) = dids_ks.get::<DidRecord>(did_key(mnemonic)).await? {
        return Ok(if existing.did_id.as_deref() == Some(minted.as_str()) {
            OwnDidImport::AlreadyPresent { did_id: minted }
        } else {
            OwnDidImport::HeldByAnother {
                existing: existing.did_id,
                minted,
            }
        });
    }

    import_did_at_path(store, dids_ks, mnemonic, jsonl, None)
        .await
        .map(OwnDidImport::Imported)
}

/// Replace the service's own DID at `mnemonic`: hard-delete whatever is
/// stored there and import the freshly minted `jsonl` in its place.
///
/// Only meant to be called after [`import_own_did`] reports
/// [`OwnDidImport::HeldByAnother`], **and** the caller has independently
/// confirmed the operator opted into rotation (`--force-reprovision`).
/// Without that confirmation, [`stale_own_did_message`] is the right
/// response — refuse loudly rather than guess.
///
/// Keyring VTI-17: reprovisioning onto a new `public_url` mints a DID for the
/// new host while the store still holds the previous one at the same path —
/// bound to a hostname that may no longer serve it. These are test
/// deployments (no migration path), so the stale DID is simply removed
/// rather than left to 404 while the daemon reports healthy.
pub async fn replace_own_did(
    store: &Store,
    dids_ks: &KeyspaceHandle,
    mnemonic: &str,
    jsonl: &str,
) -> Result<BootstrapResult, AppError> {
    if let Some(existing) = dids_ks.get::<DidRecord>(did_key(mnemonic)).await? {
        let mut batch = store.batch();
        batch.remove(dids_ks, did_key(mnemonic));
        batch.remove(dids_ks, content_log_key(mnemonic));
        batch.remove(dids_ks, content_witness_key(mnemonic));
        batch.remove(dids_ks, owner_key(&existing.owner, mnemonic));
        batch.commit().await?;
    }
    import_did_at_path(store, dids_ks, mnemonic, jsonl, None).await
}

/// The operator-facing refusal for [`OwnDidImport::HeldByAnother`], shared
/// by the interactive wizard and the recipe so both say the same thing.
///
/// It names the targeted fix. Deleting the whole store (`data/`) also clears
/// it, and is what a workaround would reach for, but it removes **every** DID
/// the service hosts — every tenant's, not just its own.
pub fn stale_own_did_message(path: &str, existing: Option<&str>, minted: &str) -> String {
    format!(
        "the store already holds a different DID at '{path}' ({existing}), not the one this \
         setup minted ({minted}). This usually means `public_url` changed: the old DID is \
         still bound to the previous hostname, and serving it from the new one returns 404.\n\
         Remove just that DID and re-run setup:\n  \
         did-hosting-server remove-did --path {path}\n\
         (Deleting the data directory also clears it, but removes every DID this daemon \
         hosts, not only its own.)",
        existing = existing.unwrap_or("an unidentified DID"),
    )
}

/// Import an existing DID at an arbitrary path (mnemonic).
///
/// Generalisation of `import_root_did` — works for any mnemonic, not just
/// `.well-known`. Guards against overwriting an existing entry at the same
/// path. The DID is stored with owner `"system"`.
pub async fn import_did_at_path(
    store: &Store,
    dids_ks: &KeyspaceHandle,
    mnemonic: &str,
    jsonl: &str,
    witness_content: Option<&str>,
) -> Result<BootstrapResult, AppError> {
    // Guard: must not already exist at this path
    if dids_ks.contains_key(did_key(mnemonic)).await? {
        return Err(AppError::Conflict(format!(
            "DID at path '{mnemonic}' already exists"
        )));
    }

    // Validate the JSONL content
    validate_did_jsonl(jsonl)?;

    let did_id = extract_did_id(jsonl)
        .ok_or_else(|| AppError::Validation("could not extract DID id from did.jsonl".into()))?;

    // Extract SCID from the DID id (did:webvh:<scid>:host:...)
    let scid = did_id
        .strip_prefix("did:webvh:")
        .and_then(|rest| rest.split(':').next())
        .ok_or_else(|| AppError::Validation("could not extract SCID from DID id".into()))?
        .to_string();

    // Validate witness content is valid JSON if provided
    if let Some(witness) = witness_content {
        serde_json::from_str::<serde_json::Value>(witness).map_err(|e| {
            AppError::Validation(format!("did-witness.json must be valid JSON: {e}"))
        })?;
    }

    let mnemonic_str = mnemonic.to_string();
    let now = now_epoch();
    let version_count = jsonl.lines().filter(|l| !l.trim().is_empty()).count() as u64;

    let record = DidRecord {
        owner: "system".to_string(),
        mnemonic: mnemonic_str.clone(),
        created_at: now,
        updated_at: now,
        version_count,
        did_id: Some(did_id.clone()),
        content_size: jsonl.len() as u64,
        disabled: false,
        deleted_at: None,

        // T12: legacy construction site; T13 migration fills `domain`.
        method: "webvh".to_string(),
        domain: String::new(),

        services: extract_service_types(jsonl),

        agent_names: Vec::new(),
    };

    let mut batch = store.batch();
    batch.insert(dids_ks, did_key(&mnemonic_str), &record)?;
    batch.insert_raw(
        dids_ks,
        content_log_key(&mnemonic_str),
        jsonl.as_bytes().to_vec(),
    );
    batch.insert_raw(
        dids_ks,
        owner_key("system", &mnemonic_str),
        mnemonic_str.as_bytes().to_vec(),
    );
    if let Some(witness) = witness_content {
        batch.insert_raw(
            dids_ks,
            content_witness_key(&mnemonic_str),
            witness.as_bytes().to_vec(),
        );
    }
    batch.commit().await?;

    info!(did = %did_id, scid = %scid, path = %mnemonic_str, "DID imported from files");

    Ok(BootstrapResult {
        scid,
        did_id,
        jsonl: jsonl.to_string(),
        mnemonic: mnemonic_str,
    })
}

#[cfg(test)]
mod own_did_import_tests {
    use super::*;
    use did_hosting_common::server::config::StoreConfig;
    use did_hosting_common::server::store::KS_DIDS;

    async fn store() -> (tempfile::TempDir, Store, KeyspaceHandle) {
        let dir = tempfile::tempdir().expect("tempdir");
        let store = Store::open(&StoreConfig {
            data_dir: dir.path().to_path_buf(),
            ..StoreConfig::default()
        })
        .await
        .expect("open store");
        let ks = store.keyspace(KS_DIDS).expect("dids keyspace");
        (dir, store, ks)
    }

    /// A fresh DID for `public_url` — a new key each call, so two calls stand
    /// for the same service minting again after moving host.
    async fn minted_for(public_url: &str) -> String {
        use did_hosting_common::did::{build_did_document, create_log_entry, encode_host};
        let secret = affinidi_tdk::secrets_resolver::secrets::Secret::generate_ed25519(None, None);
        let pk = secret.get_public_keymultibase().unwrap();
        let host = encode_host(public_url).unwrap();
        let doc = build_did_document(&host, ".well-known", &pk, &Default::default());
        create_log_entry(&doc, &secret).await.unwrap().1
    }

    #[tokio::test]
    async fn a_free_path_imports() {
        let (_dir, store, ks) = store().await;
        let jsonl = minted_for("http://localhost:3000").await;
        assert!(matches!(
            import_own_did(&store, &ks, ".well-known", &jsonl)
                .await
                .unwrap(),
            OwnDidImport::Imported(_)
        ));
    }

    /// Re-running setup is not a conflict. Under `import_did_at_path` it was,
    /// and the only advice was to retry with a command that hit the same guard.
    #[tokio::test]
    async fn the_same_did_again_is_a_re_run_not_a_conflict() {
        let (_dir, store, ks) = store().await;
        let jsonl = minted_for("http://localhost:3000").await;
        import_own_did(&store, &ks, ".well-known", &jsonl)
            .await
            .unwrap();

        match import_own_did(&store, &ks, ".well-known", &jsonl)
            .await
            .unwrap()
        {
            OwnDidImport::AlreadyPresent { did_id } => {
                assert_eq!(Some(did_id), extract_did_id(&jsonl));
            }
            other => panic!("expected AlreadyPresent, got {other:?}"),
        }
    }

    /// Keyring VTI-17: moved to a new host, setup mints a new DID while the old
    /// one still occupies the path. That must be reported — and nothing
    /// overwritten, since the old DID may be one clients still resolve.
    #[tokio::test]
    async fn a_different_did_at_the_path_is_refused_and_left_untouched() {
        let (_dir, store, ks) = store().await;
        let old = minted_for("http://old-host.example").await;
        let new = minted_for("http://new-host.example").await;
        import_own_did(&store, &ks, ".well-known", &old)
            .await
            .unwrap();

        match import_own_did(&store, &ks, ".well-known", &new)
            .await
            .unwrap()
        {
            OwnDidImport::HeldByAnother { existing, minted } => {
                assert_eq!(existing, extract_did_id(&old));
                assert_eq!(Some(minted), extract_did_id(&new));
            }
            other => panic!("expected HeldByAnother, got {other:?}"),
        }

        let kept = ks
            .get::<DidRecord>(did_key(".well-known"))
            .await
            .unwrap()
            .expect("record still there");
        assert_eq!(
            kept.did_id,
            extract_did_id(&old),
            "the old DID was not overwritten"
        );
    }

    /// Keyring VTI-17: `--force-reprovision` onto a new host replaces the
    /// stale DID rather than leaving it in the store to 404 forever.
    #[tokio::test]
    async fn replace_own_did_swaps_the_stale_did_for_the_new_one() {
        let (_dir, store, ks) = store().await;
        let old = minted_for("http://old-host.example").await;
        let new = minted_for("http://new-host.example").await;
        import_own_did(&store, &ks, ".well-known", &old)
            .await
            .unwrap();

        let result = replace_own_did(&store, &ks, ".well-known", &new)
            .await
            .expect("replace succeeds");
        assert_eq!(Some(result.did_id.clone()), extract_did_id(&new));

        let kept = ks
            .get::<DidRecord>(did_key(".well-known"))
            .await
            .unwrap()
            .expect("record still there");
        assert_eq!(
            kept.did_id,
            extract_did_id(&new),
            "the new DID replaced the stale one"
        );

        // The re-run case (`AlreadyPresent`) now sees the new DID.
        match import_own_did(&store, &ks, ".well-known", &new)
            .await
            .unwrap()
        {
            OwnDidImport::AlreadyPresent { did_id } => {
                assert_eq!(Some(did_id), extract_did_id(&new));
            }
            other => panic!("expected AlreadyPresent, got {other:?}"),
        }
    }

    /// A free path is also a valid target — reprovisioning after an
    /// uninstall (or a first-ever setup that happens to route through the
    /// replace path) must not require something to already be there.
    #[tokio::test]
    async fn replace_own_did_on_a_free_path_just_imports() {
        let (_dir, store, ks) = store().await;
        let jsonl = minted_for("http://host.example").await;
        let result = replace_own_did(&store, &ks, ".well-known", &jsonl)
            .await
            .expect("replace on a free path succeeds");
        assert_eq!(Some(result.did_id), extract_did_id(&jsonl));
    }

    /// The refusal names the targeted fix, and says why the blunt one is worse:
    /// deleting the data directory takes every tenant's DID with it.
    #[test]
    fn the_refusal_names_the_targeted_fix() {
        let msg = stale_own_did_message(".well-known", Some("did:webvh:old"), "did:webvh:new");
        assert!(msg.contains("remove-did --path .well-known"), "{msg}");
        assert!(
            msg.contains("did:webvh:old") && msg.contains("did:webvh:new"),
            "{msg}"
        );
        assert!(msg.contains("every DID"), "{msg}");
        assert!(
            !msg.contains("bootstrap-did"),
            "the old advice hit the same guard: {msg}"
        );
    }
}