vti-rooms 0.2.18

Data-room storage, wire types, and authorization — the parts of a room that are not a service
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
//! Sealing and opening records on the `attributed` and `private` tiers.
//!
//! This is where the MLS group layer and the room's task surface meet: a record is sealed
//! under the key [`super::mls::RoomGroup::storage_key`] derives for the current epoch, and
//! the host stores ciphertext it cannot read.
//!
//! # The binding is the interesting part
//!
//! Each record's AEAD associated data commits to `roomId | key | version | epoch`. A host
//! that relocates a sealed record — to another key, another version, another epoch, or
//! another room — produces an authentication failure rather than a readable record. It holds
//! every byte and still cannot move one, which is the property that makes an untrusted host
//! tolerable.
//!
//! This is the same class of defence `vti_common::store::encryption` already applies to
//! keyspace values by binding them to their `(keyspace, key)` location. Repeating it here is
//! deliberate: the reasoning was paid for once and should not have to be rediscovered.
//!
//! # Version is bound before it is known
//!
//! A record's version is assigned by the host, from the room's counter — so a writer does
//! not know it at sealing time. [`SealedRoom::seal_record`] therefore takes the version the
//! writer *intends*, and a caller that lets the host assign a different one will find the
//! record does not open. That is the correct failure: silently accepting whatever version
//! came back would mean the binding commits to nothing.
//!
//! The practical shape is create-only writes (`expected_version: Some(0)`) or a read of the
//! current version before a rewrite — both of which the task surface already supports.
//!
//! # What is deliberately not sealed
//!
//! The record's key, version and epoch travel in the clear: the host needs them to store and
//! serve the right ciphertext. Keys must therefore be **opaque** on these tiers — a key
//! reading `decision/acquire-northwind` defeats the encryption sitting beside it.
//! [`SealedRoom::opaque_key`] mints one.

use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};

use openmls::prelude::LeafNodeIndex;

use crate::error::RoomKeyError;
use crate::mls::{MembershipChange, RoomGroup};
use crate::retention::{self, EpochKeyChain};
use crate::wire::{EpochLink, SealedContent};

/// A room whose records are sealed, and the group state that seals them.
///
/// Holds the room's identifier and its [`RoomGroup`] — nothing about *credentials*. That
/// separation is deliberate and it is the reason this type moved out of the client: the
/// credentials a caller presents travel to the host on every request, and the keys never
/// travel anywhere. Pairing them in one struct made a client the only place a room could be
/// opened, which is wrong the moment a VTA has to open one on an agent's behalf.
///
/// The identifier is here because it is bound into every record's associated data, not
/// because this type does anything with it.
pub struct SealedRoom {
    room_id: String,
    group: RoomGroup,
    chain: EpochKeyChain,
}

impl SealedRoom {
    /// Pair a room identifier with its group.
    ///
    /// The chain is anchored at the group's current epoch and holds no links, so this room
    /// opens records from the current epoch only. Feed it [`SealedRoom::add_links`] to reach
    /// the room's history — see [`crate::retention`] for why history needs feeding.
    pub fn new(room_id: impl Into<String>, group: RoomGroup) -> Self {
        let room_id = room_id.into();
        Self {
            chain: EpochKeyChain::new(&room_id),
            room_id,
            group,
        }
    }

    /// Point the chain at the group's current epoch and key.
    ///
    /// Called before every resolution rather than once at construction. The group is the
    /// authority on which epoch this member is at, so asking it each time is what makes the
    /// two impossible to disagree — and it lets a failed exporter surface as the group error
    /// it is, rather than as a record that mysteriously did not open.
    fn reanchor(&mut self) -> Result<(), RoomKeyError> {
        let epoch = self.room_epoch();
        let key = self.group.storage_key()?;
        self.chain.reanchor(epoch, key);
        Ok(())
    }

    /// Add the epoch links a host or an owner served, extending how far back this room
    /// can read.
    ///
    /// Idempotent and order-independent.
    pub fn add_links(&mut self, links: impl IntoIterator<Item = EpochLink>) {
        self.chain.add_links(links);
    }

    /// Every epoch link this room holds, ascending.
    ///
    /// What an owner hands a joining member so they can read what was already there, and
    /// what a host stores on their behalf. Ciphertext: a party holding no epoch key learns
    /// nothing from them.
    pub fn links(&self) -> Vec<EpochLink> {
        self.chain.links()
    }

    /// The earliest epoch this room can currently open.
    ///
    /// Worth surfacing to a member: on a chained room it should be 1, and anything else
    /// means either links that have not been delivered or history that was deliberately
    /// severed.
    pub fn earliest_readable_epoch(&mut self) -> Result<u32, RoomKeyError> {
        self.reanchor()?;
        Ok(self.chain.earliest_reachable())
    }

    /// The room these keys are for.
    pub fn room_id(&self) -> &str {
        &self.room_id
    }

    /// The group, for membership changes and epoch anchoring.
    pub fn group(&self) -> &RoomGroup {
        &self.group
    }

    /// Add a member from the KeyPackage bytes they sent, keeping the chain intact.
    ///
    /// # Why this exists instead of a `group_mut()`
    ///
    /// Handing out `&mut RoomGroup` let a caller advance the epoch behind the chain's back.
    /// Nothing failed at the time: the group moved on, the chain stayed anchored at the old
    /// epoch with the old key, and the room silently lost the ability to read everything
    /// written before the change — the exact defect the chain exists to fix, reintroduced
    /// one call site at a time. There is no accessor because there is no safe one.
    pub fn add_member(
        &mut self,
        key_package: &[u8],
    ) -> Result<(MembershipChange, Option<EpochLink>), RoomKeyError> {
        let outgoing = self.group.storage_key()?;
        let change = self.group.add_member_from_bytes(key_package)?;
        let link = self.mint_link(outgoing)?;
        self.chain.add_links(link.clone());
        Ok((change, link))
    }

    /// Remove a member and commit, keeping the chain intact.
    ///
    /// Forward-only: the removed member reads nothing sealed after this, and every member
    /// who remains still reads everything sealed before it.
    pub fn remove_member(
        &mut self,
        index: LeafNodeIndex,
    ) -> Result<(MembershipChange, Option<EpochLink>), RoomKeyError> {
        let outgoing = self.group.storage_key()?;
        let change = self.group.remove_member(index)?;
        let link = self.mint_link(outgoing)?;
        self.chain.add_links(link.clone());
        Ok((change, link))
    }

    /// Apply a commit another member produced, keeping the chain intact.
    ///
    /// Returns the room epoch after the commit.
    pub fn apply_commit(
        &mut self,
        commit: &[u8],
    ) -> Result<(u32, Option<EpochLink>), RoomKeyError> {
        let outgoing = self.group.storage_key()?;
        let mls_epoch = self.group.apply_commit(commit)?;
        let link = self.mint_link(outgoing)?;
        self.chain.add_links(link.clone());
        let epoch = u32::try_from(mls_epoch + 1)
            .map_err(|_| RoomKeyError::Group(format!("epoch {mls_epoch} exceeds u32")))?;
        Ok((epoch, link))
    }

    /// Seal the outgoing epoch's storage key under the incoming one, bound to this room.
    ///
    /// Called immediately after a merge, while the group is at the new epoch and `outgoing`
    /// still holds the old key — the only moment any party knows both.
    ///
    /// **This lives here rather than on [`RoomGroup`] because a rung is bound to its room.**
    /// `roomId` is in the associated data for the same reason it is in a record's
    /// (`rooms/epoch/chain/0.1`, and `SealedRecord` before it): a rung served under the
    /// wrong room must fail to open rather than rely on two rooms happening to derive
    /// different keys. The group does not know which room it is for, and should not.
    fn mint_link(
        &self,
        outgoing: retention::StorageKey,
    ) -> Result<Option<EpochLink>, RoomKeyError> {
        let epoch = self.room_epoch();
        if epoch < 2 {
            return Ok(None);
        }
        Ok(Some(retention::seal_link(
            &self.room_id,
            epoch,
            &self.group.storage_key()?,
            &outgoing,
        )?))
    }

    /// The room's current epoch, as the host records it.
    ///
    /// MLS epochs start at 0 and the room's start at 1, so this is the MLS epoch plus one.
    /// Kept in one place rather than at each call site: an off-by-one here would seal
    /// records under an epoch the host rejects, and the failure would look like a key
    /// problem rather than an arithmetic one.
    pub fn room_epoch(&self) -> u32 {
        (self.group.epoch() + 1) as u32
    }

    /// A random, opaque record key.
    ///
    /// Sealed tiers require these: a descriptive key is readable by the host and defeats the
    /// encryption beside it. Structured naming belongs *inside* the sealed body.
    pub fn opaque_key() -> String {
        let mut bytes = [0u8; 16];
        getrandom::fill(&mut bytes).expect("OS randomness unavailable");
        B64.encode(bytes)
    }

    /// Seal `plaintext` for `key` at `version`.
    ///
    /// `version` is the version the writer intends the record to take — see the module docs
    /// on why it is bound before the host assigns it.
    pub fn seal_record(
        &self,
        key: &str,
        version: u64,
        plaintext: &[u8],
    ) -> Result<SealedContent, RoomKeyError> {
        let epoch = self.room_epoch();
        let storage_key = self.group.storage_key()?;
        let aad = associated_data(&self.room_id, key, version, epoch);

        let cipher = ChaCha20Poly1305::new(&Key::from(storage_key));
        let mut nonce_bytes = [0u8; 12];
        getrandom::fill(&mut nonce_bytes).expect("OS randomness unavailable");

        let ciphertext = cipher
            .encrypt(
                &Nonce::from(nonce_bytes),
                Payload {
                    msg: plaintext,
                    aad: &aad,
                },
            )
            .map_err(|e| RoomKeyError::Seal(format!("seal record: {e}")))?;

        Ok(SealedContent {
            ciphertext: B64.encode(ciphertext),
            nonce: B64.encode(nonce_bytes),
            epoch,
        })
    }

    /// Open a record the host returned.
    ///
    /// Fails rather than returning wrong bytes if the record was relocated, if the epoch was
    /// relabelled, or if the key for that epoch is not one this member can reach.
    ///
    /// # The epoch is resolved, not assumed
    ///
    /// The key is the one for **the record's own epoch**, walked out of the chain — not the
    /// group's current key. Assuming the current one is what made a room unreadable to
    /// everybody the moment any member was added or removed: a record sealed at epoch 3 was
    /// being opened with epoch 4's key, which is a wrong key rather than a missing one, so
    /// it failed as `DidNotOpen` and read like corruption.
    ///
    /// `&mut self` because resolving walks the chain and memoises what it derives. Opening a
    /// room's history is one walk, not one per record.
    pub fn open_record(
        &mut self,
        key: &str,
        version: u64,
        sealed: &SealedContent,
    ) -> Result<Vec<u8>, RoomKeyError> {
        self.reanchor()?;
        let storage_key = self.chain.key_for(sealed.epoch)?;
        let aad = associated_data(&self.room_id, key, version, sealed.epoch);

        let ciphertext = B64
            .decode(&sealed.ciphertext)
            .map_err(|e| RoomKeyError::Seal(format!("decode ciphertext: {e}")))?;
        let nonce = B64
            .decode(&sealed.nonce)
            .map_err(|e| RoomKeyError::Seal(format!("decode nonce: {e}")))?;
        if nonce.len() != 12 {
            return Err(RoomKeyError::Seal(format!(
                "nonce is {} bytes, expected 12",
                nonce.len()
            )));
        }

        let cipher = ChaCha20Poly1305::new(&Key::from(storage_key));
        cipher
            .decrypt(
                &Nonce::try_from(&nonce[..])
                    .map_err(|e| RoomKeyError::Seal(format!("nonce: {e}")))?,
                Payload {
                    msg: &ciphertext,
                    aad: &aad,
                },
            )
            .map_err(|_| RoomKeyError::DidNotOpen)
    }

    /// The value to anchor in the room's witnessed DID log for this epoch.
    ///
    /// A host that forks the group shows different members different commit sequences.
    /// Comparing this against the anchored value is how a member finds out.
    pub fn epoch_anchor(&self) -> Vec<u8> {
        self.group.epoch_authenticator()
    }
}

/// `roomId | key | version | epoch`, the associated data a record is bound to.
///
/// Length-prefix-free but unambiguous by construction: `|` cannot appear in a base64url key
/// or in the decimal fields, and `roomId` is a DID. If any of those ever stops holding, this
/// needs length prefixes — the failure mode otherwise is two different records producing the
/// same associated data, which is exactly what the binding exists to prevent.
fn associated_data(room_id: &str, key: &str, version: u64, epoch: u32) -> Vec<u8> {
    format!("{room_id}|{key}|{version}|{epoch}").into_bytes()
}

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

    fn room(did: &str) -> SealedRoom {
        let group = RoomGroup::create("did:key:zAlice").expect("group");
        SealedRoom::new(did, group)
    }

    #[test]
    fn a_record_round_trips_under_the_group_key() {
        let mut r = room("did:webvh:zRoom");
        let sealed = r.seal_record("k1", 1, b"a decision").expect("seal");
        let opened = r.open_record("k1", 1, &sealed).expect("open");
        assert_eq!(opened, b"a decision");
    }

    /// The property that makes an untrusted host tolerable: it holds every byte and still
    /// cannot move one.
    #[test]
    fn a_relocated_record_does_not_open() {
        let mut r = room("did:webvh:zRoom");
        let sealed = r.seal_record("k1", 1, b"a decision").expect("seal");

        assert!(
            r.open_record("k2", 1, &sealed).is_err(),
            "moving a record to another key must fail"
        );
        assert!(
            r.open_record("k1", 2, &sealed).is_err(),
            "moving it to another version must fail"
        );

        let mut relabelled = sealed.clone();
        relabelled.epoch += 1;
        assert!(
            r.open_record("k1", 1, &relabelled).is_err(),
            "relabelling the epoch must fail authentication, not decrypt wrongly"
        );

        let mut moved = SealedRoom::new(
            "did:webvh:zOther",
            RoomGroup::create("did:key:zAlice").unwrap(),
        );
        assert!(
            moved.open_record("k1", 1, &sealed).is_err(),
            "moving it to another room must fail"
        );
    }

    #[test]
    fn a_non_member_cannot_open_a_record() {
        let r = room("did:webvh:zRoom");
        let sealed = r.seal_record("k1", 1, b"members only").expect("seal");

        // A different group is a different key, however identical everything else looks.
        let mut outsider = SealedRoom::new(
            "did:webvh:zRoom",
            RoomGroup::create("did:key:zMallory").unwrap(),
        );
        assert!(outsider.open_record("k1", 1, &sealed).is_err());
    }

    #[test]
    fn the_room_epoch_is_the_mls_epoch_plus_one() {
        let r = room("did:webvh:zRoom");
        assert_eq!(r.group().epoch(), 0, "MLS starts at 0");
        assert_eq!(r.room_epoch(), 1, "the room's first epoch is 1");
    }

    #[test]
    fn opaque_keys_are_random_and_carry_no_meaning() {
        let a = SealedRoom::opaque_key();
        let b = SealedRoom::opaque_key();
        assert_ne!(a, b);
        assert!(
            !a.contains('/'),
            "url-safe, so it needs no escaping in a payload"
        );
    }

    /// Sealing twice must not reuse a nonce, or the AEAD's guarantee is gone.
    #[test]
    fn sealing_the_same_plaintext_twice_uses_a_fresh_nonce() {
        let mut r = room("did:webvh:zRoom");
        let a = r.seal_record("k1", 1, b"same").expect("seal");
        let b = r.seal_record("k1", 1, b"same").expect("seal again");
        assert_ne!(a.nonce, b.nonce, "a reused nonce breaks ChaCha20-Poly1305");
        assert_ne!(a.ciphertext, b.ciphertext);
        // Both still open.
        assert_eq!(r.open_record("k1", 1, &a).unwrap(), b"same");
        assert_eq!(r.open_record("k1", 1, &b).unwrap(), b"same");
    }
}