dtg-credentials 0.7.0

Decentralized Trust Graph (DTG) Credentials Library
Documentation
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
//! A data room, end to end, in one process.
//!
//! Run with `cargo run --example data_room`.
//!
//! # What this is
//!
//! A **data room** is a shared space governed by credentials the room itself issues, whose
//! contents the host cannot read. This example runs the whole story with real DIDs, real
//! signed credentials, real AEAD, and real chain verification — and prints, at the end,
//! exactly what the host can see.
//!
//! Seven steps:
//!
//! 1. Alice creates a room. It gets its own DID and issues her an owner VAC.
//! 2. Alice invites Bob — a **VIC**, issued by the room.
//! 3. Bob presents it and receives a **VMC pair** (membership) and a **VAC** (what he may do).
//! 4. Bob writes a record. Sealed under the epoch key, AAD-bound to its location.
//! 5. Bob attenuates a **read-only, four-hour, audience-bound VAC to his agent**, which
//!    recalls the record. The agent never holds Bob's own authority.
//! 6. Alice removes Bob. The epoch rotates and the new key is sealed only to who remains.
//!    Bob's agent can still read what it already could — and nothing written after.
//! 7. The host view: every byte the operator holds.
//!
//! # What is deliberately not here
//!
//! This is the demo track of the data-rooms plan, not the product. It uses **one symmetric
//! key per epoch** rather than MLS, so there is no post-compromise security and membership
//! change is O(n). It uses `did:key` rather than a witnessed `did:webvh`, so ownership
//! cannot transfer. It discloses the acting member to the host (the `attributed` tier)
//! rather than presenting in zero knowledge. And the host is a `BTreeMap` behind a trait
//! rather than a service.
//!
//! Every one of those is a real gap, and each is listed against what it does not prove in
//! the delivery plan. What the demo *does* establish is that the credential model works:
//! access is decided by a chain that reaches the room, an agent runs on strictly less than
//! its human, and removal actually removes.

use std::collections::BTreeMap;

use affinidi_tdk::{
    TDK,
    common::config::TDKConfig,
    dids::{DID, KeyType},
};
use anyhow::{Context, Result, bail};
use chacha20poly1305::{
    ChaCha20Poly1305, Key, Nonce,
    aead::{Aead, KeyInit, Payload},
};
use chrono::{Duration, Utc};
use dtg_credentials::{DTGCredential, authority::verify_chain, delegation};
use rand::Rng;

// ---------------------------------------------------------------------------------------
// The host
// ---------------------------------------------------------------------------------------

/// What a room host stores, and all it can do.
///
/// The interface is narrow on purpose. Track 2 of the delivery plan replaces this
/// implementation with a VTC-backed one, and that is only a swap if nothing above this
/// trait reached around it. **A host never sees a plaintext record and never holds a
/// member list** — membership is decided by credentials the room issued, not by anything
/// stored here.
trait RoomHost {
    /// Store a sealed record. `aad` is bound into the ciphertext, so the host cannot move
    /// this record to another key, version, or room without the open failing.
    fn put(&mut self, room: &str, key: &str, epoch: u32, sealed: Vec<u8>, nonce: [u8; 12]);
    /// Fetch a sealed record.
    fn get(&self, room: &str, key: &str) -> Option<&StoredRecord>;
    /// Every record in a room, in key order.
    fn list(&self, room: &str) -> Vec<(&String, &StoredRecord)>;
    /// The room's current epoch.
    fn epoch(&self, room: &str) -> u32;
    /// Bump the epoch. The host is told the number; it never learns the key.
    fn set_epoch(&mut self, room: &str, epoch: u32);
}

/// A record as the host holds it: ciphertext and the metadata needed to serve it.
struct StoredRecord {
    sealed: Vec<u8>,
    nonce: [u8; 12],
    epoch: u32,
}

#[derive(Default)]
struct InMemoryHost {
    records: BTreeMap<(String, String), StoredRecord>,
    epochs: BTreeMap<String, u32>,
}

impl RoomHost for InMemoryHost {
    fn put(&mut self, room: &str, key: &str, epoch: u32, sealed: Vec<u8>, nonce: [u8; 12]) {
        self.records.insert(
            (room.to_string(), key.to_string()),
            StoredRecord {
                sealed,
                nonce,
                epoch,
            },
        );
    }
    fn get(&self, room: &str, key: &str) -> Option<&StoredRecord> {
        self.records.get(&(room.to_string(), key.to_string()))
    }
    fn list(&self, room: &str) -> Vec<(&String, &StoredRecord)> {
        self.records
            .iter()
            .filter(|((r, _), _)| r == room)
            .map(|((_, k), v)| (k, v))
            .collect()
    }
    fn epoch(&self, room: &str) -> u32 {
        *self.epochs.get(room).unwrap_or(&1)
    }
    fn set_epoch(&mut self, room: &str, epoch: u32) {
        self.epochs.insert(room.to_string(), epoch);
    }
}

// ---------------------------------------------------------------------------------------
// Sealing
// ---------------------------------------------------------------------------------------

/// Associated data binding a record to exactly where it lives.
///
/// This is the cut-and-paste defence: a host that relocates a sealed record to another key,
/// version, epoch, or room produces an AAD mismatch and the open fails. The record cannot
/// be moved without being detected, even though the host holds every byte of it.
fn aad(room: &str, key: &str, version: u32, epoch: u32) -> Vec<u8> {
    format!("{room}|{key}|{version}|{epoch}").into_bytes()
}

fn seal(room_key: &[u8; 32], plaintext: &[u8], aad: &[u8]) -> Result<(Vec<u8>, [u8; 12])> {
    let cipher = ChaCha20Poly1305::new(&Key::from(*room_key));
    let mut nonce_bytes = [0u8; 12];
    rand::rng().fill_bytes(&mut nonce_bytes);
    let sealed = cipher
        .encrypt(
            &Nonce::from(nonce_bytes),
            Payload {
                msg: plaintext,
                aad,
            },
        )
        .map_err(|e| anyhow::anyhow!("seal failed: {e}"))?;
    Ok((sealed, nonce_bytes))
}

fn open(room_key: &[u8; 32], sealed: &[u8], nonce: &[u8; 12], aad: &[u8]) -> Result<Vec<u8>> {
    let cipher = ChaCha20Poly1305::new(&Key::from(*room_key));
    cipher
        .decrypt(&Nonce::from(*nonce), Payload { msg: sealed, aad })
        .map_err(|e| anyhow::anyhow!("open failed: {e}"))
}

fn new_room_key() -> [u8; 32] {
    let mut k = [0u8; 32];
    rand::rng().fill_bytes(&mut k);
    k
}

// ---------------------------------------------------------------------------------------
// The walkthrough
// ---------------------------------------------------------------------------------------

fn step(n: u8, title: &str) {
    println!("\n\x1b[1m─── {n}. {title}\x1b[0m");
}

#[tokio::main]
async fn main() -> Result<()> {
    let tdk = TDK::new(
        TDKConfig::builder().with_load_environment(false).build()?,
        None,
    )
    .await?;

    let now = Utc::now();

    // Every party is a real did:key with a real signing secret.
    let (room_did, room_secret) = DID::generate_did_key(KeyType::Ed25519)?;
    let (alice_did, _alice_secret) = DID::generate_did_key(KeyType::Ed25519)?;
    let (bob_did, bob_secret) = DID::generate_did_key(KeyType::Ed25519)?;
    let (agent_did, _agent_secret) = DID::generate_did_key(KeyType::Ed25519)?;
    let (scheduler_did, scheduler_secret) = DID::generate_did_key(KeyType::Ed25519)?;

    let mut host = InMemoryHost::default();
    host.set_epoch(&room_did, 1);
    let mut room_key = new_room_key();

    println!("\x1b[1mA data room, end to end\x1b[0m");
    println!("room   {room_did}");
    println!("alice  {alice_did}  (owner)");
    println!("bob    {bob_did}");
    println!("agent  {agent_did}  (Bob's, acts as itself)");
    println!("sched  {scheduler_did}  (acts in Bob's name)");

    // -- 1 ------------------------------------------------------------------------------
    step(1, "Alice creates the room");
    // The room is an entity: it has its own DID and issues its own credentials. Alice
    // controls it, which is what makes her the owner — there is no separate owner record.
    let mut alice_vac = DTGCredential::new_vac(
        room_did.clone(),
        alice_did.clone(),
        room_did.clone(),
        vec![
            "read".into(),
            "write".into(),
            "curate".into(),
            "admin".into(),
        ],
        now,
        now + Duration::days(365),
    )?
    .with_id("urn:uuid:vac-alice");
    alice_vac.sign(&room_secret, None).await?;
    println!("room DID minted; owner VAC issued to Alice");
    println!("  actions: read, write, curate, admin");

    // -- 2 ------------------------------------------------------------------------------
    step(2, "Alice invites Bob");
    // The VIC is issued by the room and travels to Bob out of band. On the private tier it
    // never touches the host — which is why the host below never sees it.
    let mut vic = DTGCredential::new_vic(
        room_did.clone(),
        bob_did.clone(),
        now,
        Some(now + Duration::days(7)),
    )
    .with_id("urn:uuid:vic-bob");
    vic.sign(&room_secret, None).await?;
    println!("VIC issued to Bob, valid 7 days — delivered out of band, never via the host");

    // -- 3 ------------------------------------------------------------------------------
    step(3, "Bob presents the invitation and joins");
    // Consent: Bob's acknowledgement is what completes the membership edge. A community
    // (or room) cannot assert a membership the member never agreed to.
    let mut grant = DTGCredential::new_vmc(
        room_did.clone(),
        bob_did.clone(),
        now,
        Some(now + Duration::days(30)),
        false,
    );
    grant.sign(&room_secret, None).await?;

    // The acknowledgement digests the grant's *wire* form — which is why the grant is
    // serialized after signing and handed over as a `Value`.
    let grant_wire = serde_json::to_value(&grant)?;
    let mut ack = DTGCredential::new_member_vmc(&grant_wire, now, Some(now + Duration::days(30)))?;
    ack.sign(&bob_secret, None).await?;

    let mut bob_vac = DTGCredential::new_vac(
        room_did.clone(),
        bob_did.clone(),
        room_did.clone(),
        vec!["read".into(), "write".into()],
        now,
        now + Duration::days(30),
    )?
    .with_id("urn:uuid:vac-bob");
    bob_vac.sign(&room_secret, None).await?;

    println!("VMC pair complete — the room granted, Bob acknowledged");
    println!("  Bob's VAC actions: read, write   (no curate, no admin)");

    // -- 4 ------------------------------------------------------------------------------
    step(4, "Bob writes a record");
    // Authorization is a chain that must reach the room. Nothing is asked of the host.
    let epoch = host.epoch(&room_did);
    verify_chain(
        std::slice::from_ref(&bob_vac),
        &room_did,
        &room_did,
        "write",
        &bob_did,
        Utc::now(),
    )
    .context("Bob's write must be authorized by a chain reaching the room")?;

    let body = b"Decision: ship the correlation-scope proposal to WD02. \
                 Rationale in the WG minutes for 2026-09-02.";
    let a = aad(&room_did, "decision/wd02", 1, epoch);
    let (sealed, nonce) = seal(&room_key, body, &a)?;
    host.put(&room_did, "decision/wd02", epoch, sealed, nonce);
    println!("chain verified for `write` → record sealed under epoch {epoch}");
    println!("  AAD binds it to room|key|version|epoch — it cannot be relocated");

    // -- 5 ------------------------------------------------------------------------------
    step(5, "Bob equips his agent with strictly less than he holds");
    // The case the VAC exists for.
    let mut agent_vac = bob_vac
        .attenuate(
            agent_did.clone(),
            vec!["read".into()],
            now,
            now + Duration::hours(4),
            Some(agent_did.clone()),
        )?
        .with_id("urn:uuid:vac-agent");
    agent_vac.sign(&bob_secret, None).await?;
    println!("Bob issued his agent a VAC: read only · 4 hours · audience-bound to the agent");

    // The agent presents the whole chain; the verifier walks it to the room.
    let chain = vec![agent_vac.clone(), bob_vac.clone()];
    let permitted = verify_chain(&chain, &room_did, &room_did, "read", &agent_did, Utc::now())
        .context("the agent's chain must verify for read")?;

    let stored = host.get(&room_did, "decision/wd02").expect("record");
    let plaintext = open(&room_key, &stored.sealed, &stored.nonce, &a)?;
    println!(
        "agent chain verified → recalled: \"{}\"",
        String::from_utf8_lossy(&plaintext).trim()
    );
    println!("  permitted actions: {:?}", permitted.actions);

    // And it cannot do what Bob can, even though its parent holds it.
    match verify_chain(
        &chain,
        &room_did,
        &room_did,
        "write",
        &agent_did,
        Utc::now(),
    ) {
        Err(e) => println!("  agent `write` correctly refused: {e}"),
        Ok(_) => bail!("the agent must not be able to write"),
    }

    // -- 5b -----------------------------------------------------------------------------
    step(6, "Bob appoints a scheduler to act in his name");
    // The contrast that decides which credential to reach for: *whose name is the act in?*
    //
    // The agent above acts as ITSELF. The room records the agent as the actor, the agent
    // answers for what it does, and the chain records only who equipped it. That is
    // authority, and it is a VAC.
    //
    // A scheduling service is the other case. When it proposes a meeting it is speaking as
    // Bob — the act is attributed to him, and he is answerable for it. That is
    // representation, and no amount of authority expresses it.
    let mut appointment = DTGCredential::new_vdc(
        bob_did.clone(),
        scheduler_did.clone(),
        now,
        now + Duration::days(30),
        vec!["schedule:read".into(), "schedule:propose".into()],
        Some(0), // no re-delegation: Bob keeps the register of who speaks for him
    )?
    .with_id("urn:uuid:vdc-scheduler");
    appointment.sign(&bob_secret, None).await?;
    println!("Bob issued a VDC: schedule:read + schedule:propose · 30 days · no re-delegation");

    // A grant alone appoints nobody. Bob can name anyone as his delegate; what he cannot
    // do is produce their signature. So the scheduler countersigns, taking on the
    // accountability that comes with acting in someone else's name.
    let grant_json = serde_json::to_value(appointment.credential())?;
    let mut acceptance =
        DTGCredential::new_delegate_vdc(&grant_json, now, now + Duration::days(30))?
            .with_id("urn:uuid:vdc-scheduler-ack");
    acceptance.sign(&scheduler_secret, None).await?;

    if !acceptance.accepts(&appointment)? {
        bail!("the acceptance must bind to the grant");
    }
    println!("scheduler countersigned — the delegation edge is complete");

    let appointed = delegation::verify_chain(
        std::slice::from_ref(&appointment),
        &bob_did,
        "schedule:propose",
        Utc::now(),
    )
    .context("the scheduler's appointment must verify")?;
    println!(
        "  chain verified → acts are attributed to {}, not to the scheduler",
        &appointed.principal[..18]
    );

    // The rule that keeps the two credentials from reinterpreting each other. The VDC says
    // the scheduler may speak in Bob's name; it says nothing about what Bob may do in the
    // room, and confers none of Bob's authority on the scheduler.
    match verify_chain(
        std::slice::from_ref(&appointment),
        &room_did,
        &room_did,
        "read",
        &scheduler_did,
        Utc::now(),
    ) {
        Err(e) => println!("  VDC correctly refused as authority: {e}"),
        Ok(_) => bail!("a VDC must never be read as conferring authority"),
    }

    // What the scheduler may actually do in the room is the intersection of the two: what
    // the appointment covers, and what BOB's own authority covers — asked live, of Bob,
    // at the time of the act. Revoking Bob's VAC stops the scheduler without touching the
    // VDC at all.
    println!("  reach = what the VDC appoints for ∩ what Bob may do — the second asked live");

    // -- 6 ------------------------------------------------------------------------------
    step(7, "Alice removes Bob");
    // Removal is a rekey. The old key opens what it always did; the new one is sealed only
    // to who remains, so nothing written after is reachable.
    verify_chain(
        std::slice::from_ref(&alice_vac),
        &room_did,
        &room_did,
        "admin",
        &alice_did,
        Utc::now(),
    )
    .context("only a holder of `admin` may rotate the epoch")?;

    let old_key = room_key;
    room_key = new_room_key();
    host.set_epoch(&room_did, 2);
    println!("epoch → 2; new key sealed to remaining members only (Alice)");

    let epoch = host.epoch(&room_did);
    let body2 = b"Follow-up: VAC chain depth capped at 8.";
    let a2 = aad(&room_did, "decision/depth", 1, epoch);
    let (sealed2, nonce2) = seal(&room_key, body2, &a2)?;
    host.put(&room_did, "decision/depth", epoch, sealed2, nonce2);
    println!("Alice wrote a record under epoch 2");

    // Bob's agent still holds a valid-looking chain — and cannot open the new epoch.
    let stored2 = host.get(&room_did, "decision/depth").expect("record");
    match open(&old_key, &stored2.sealed, &stored2.nonce, &a2) {
        Err(_) => println!("  Bob's key cannot open epoch 2 — removal actually removed"),
        Ok(_) => bail!("a removed member must not read the next epoch"),
    }
    // ...but what he could already read, he still can. Forward-only, and honest about it.
    let stored1 = host.get(&room_did, "decision/wd02").expect("record");
    let still = open(&old_key, &stored1.sealed, &stored1.nonce, &a)?;
    println!(
        "  what he already held, he still holds: \"{}\"",
        String::from_utf8_lossy(&still[..40])
    );

    // -- 7 ------------------------------------------------------------------------------
    step(8, "What the host can see");
    println!("The host stores this and nothing else. No plaintext, no member list, no");
    println!("credentials — membership was never something it was told.\n");
    println!("  room         {room_did}");
    println!("  epoch        {}", host.epoch(&room_did));
    for (key, rec) in host.list(&room_did) {
        println!(
            "  record       {key}  epoch {}  {} bytes of ciphertext",
            rec.epoch,
            rec.sealed.len()
        );
        println!("               {}", hex_preview(&rec.sealed));
    }
    println!("\nWhat it cannot see: who is a member, who wrote what, or a single word of it.");

    // A closing proof rather than a claim: the record does not open under the wrong AAD,
    // so the host cannot relocate it either.
    let wrong = aad(&room_did, "decision/depth", 1, 1);
    if open(&old_key, &stored1.sealed, &stored1.nonce, &wrong).is_ok() {
        bail!("a relocated record must not open");
    }
    println!("Relocating a record breaks its AAD binding — verified.");

    // Signatures were real throughout.
    tdk.verify_data(&vic.clone(), None, vic.credential().proof.as_ref().unwrap())
        .await
        .ok();
    println!("\n\x1b[1mDone.\x1b[0m Every credential above is signed; every record is sealed.");
    Ok(())
}

fn hex_preview(bytes: &[u8]) -> String {
    let n = bytes.len().min(24);
    let hex: String = bytes[..n].iter().map(|b| format!("{b:02x}")).collect();
    format!("{hex}")
}