Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_pearlite/src/collab/mod.rs

7.4 KiB, 7 runs

created by r1870400018:58600, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The Pearlite collaboration backend: a signed, mergeable edit stream over a `.prl` document's
2//! annotations.
3//!
4//! An annotation anchors to a content-addressed block, not a page (see
5//! [`oxedyne_fe2o3_austenite::emit::pearl::Annotation`]), so it already survives re-pagination within
6//! one file. What it does not yet survive is being carried between authors: two people annotating the
7//! same document need a shared, verifiable record of who said what, and a way to fold that record back
8//! into each one's local `.prl`. This module supplies the first increment of that.
9//!
10//! The pieces, and the seam between them:
11//!
12//! - A document has a stable [`DocId`], minted by its author and written into the `.prl` header
13//! ([`oxedyne_fe2o3_austenite::emit::pearl::PearlDoc::doc_id`]). It is the sync key: it is the one
14//! name that survives both re-pagination and a rewrite, where a page number or a block address does
15//! not.
16//! - Each annotation action is one Ore operation ([`op`]), carrying signed provenance in an Ore
17//! [`Envelope`](oxedyne_fe2o3_ore::envelope::Envelope) ([`sign`]). Creating an annotation opens a
18//! proposal, a reply is a `Said`, an edit an `Amended`, and a resolve or a withdrawal a `Settled` --
19//! the threaded-discussion vocabulary the operation log already speaks.
20//! - The signature is P-256 ECDSA over SHA-256, exactly the encoding a browser's WebCrypto key emits,
21//! so an operation a browser signs and one a server signs are checked by the very same verifier
22//! ([`oxedyne_fe2o3_crypto::p256`], which is wasm-clean so the reader checks on-device).
23//! - A document's operations live in a [`hub`] store keyed by [`DocId`], and folding that stream
24//! ([`fold`]) reproduces the annotation set deterministically, which is then materialised into a
25//! local `.prl`.
26//!
27//! # What this increment is not
28//!
29//! There is no transport here and no browser code. The live Steel WebSocket relay that carries
30//! operations between a browser and the hub, the `web/pearl-reader/collab.js` reader UI, and the
31//! WebCrypto signing path in the browser are the next increment, deliberately left out so this one
32//! stops at a clean seam: sealed operations in, a folded annotation set out, over a store that already
33//! works.
34
35pub mod fold;
36pub mod hub;
37pub mod keyring;
38pub mod op;
39pub mod sign;
40
41pub use keyring::Keyring;
42
43use oxedyne_fe2o3_ore::id::ReplicaId;
44
45use oxedyne_fe2o3_core::prelude::*;
46use oxedyne_fe2o3_jdat::bdat::DecodeLimits;
47
48/// The bounds a peer's operation is decoded under.
49///
50/// A signed operation arriving over a relay is attacker-controlled input, and neither the depth nor
51/// the length of what it encodes is the reader's to trust: a few bytes can describe a list nested a
52/// million deep, and a decoder that trusts them recurses until its stack is gone. Sixty-four levels is
53/// deeper than any annotation the format writes, and a mebibyte is far more than an annotation body
54/// needs, so the bound only ever bites a hostile encoding. This is the collaboration layer's policy,
55/// passed down to the mechanism in [`oxedyne_fe2o3_ore`], which stays free of any opinion about it.
56pub const DECODE_LIMITS: DecodeLimits = DecodeLimits {
57 max_depth: 64,
58 max_bytes: 1024 * 1024,
59};
60
61/// The greatest a single signed operation may be, in bytes of its sealed envelope, before the hub
62/// refuses to store it. A relay peer does not get to write an unbounded blob into a document's log.
63pub const MAX_OP_BYTES: usize = 1024 * 1024;
64
65/// The greatest number of operations a single document's log may hold before the hub refuses more, so
66/// a peer cannot exhaust a store by appending without end.
67pub const MAX_OPS_PER_DOC: usize = 100_000;
68
69/// The greatest total size, in bytes of sealed envelope payloads, a single document's log may reach
70/// before the hub refuses more. The op-count cap alone leaves a peer room to store a hundred thousand
71/// mebibyte operations -- a hundred gibibytes -- so a total-bytes cap bounds the log's real cost
72/// rather than only its length.
73pub const MAX_DOC_BYTES: usize = 64 * 1024 * 1024;
74
75/// The furthest ahead of the receiving replica's own clock an operation's author-stated time may be,
76/// in seconds, before the hub refuses it. An author's clock genuinely differs from a reader's, so some
77/// skew is allowed; a `time` far in the future is a bid to win a last-writer-wins ordering forever, and
78/// is refused rather than honoured.
79pub const MAX_TIME_SKEW_SECS: u64 = 24 * 60 * 60;
80
81/// Derives a replica identity from a signer's public key **and the document it writes in**: the first
82/// eight bytes of the SHA-256 of the key followed by the document identity, read big-endian.
83///
84/// This is what binds an operation's name both to the key that signed it and to the one document it
85/// belongs to. A [`ReplicaId`] a peer picks for itself is worthless -- it could pick anyone's -- so it
86/// is not picked but computed, here, from the two things a peer cannot forge: the private key behind
87/// the public one, and the document being written. A record whose header names a replica this does not
88/// derive from its signer *under the document in hand* is refused at [`sign::seal`] and [`sign::open`].
89///
90/// Folding the document into the replica is what closes cross-document replay for every operation kind
91/// at once, the settlement and the reply included, without a wire-format change: the same key signing
92/// in document A and in document B mints two different replica identities, so an operation lifted from
93/// A and appended to B fails the binding check under B. It also makes an honest counter overlap between
94/// two documents impossible -- each document is its own replica space -- and raises identity squatting
95/// from guessing a key to guessing a `(key, document)` pair.
96pub fn replica_of(pubkey: &[u8], doc: &DocId) -> ReplicaId {
97 let mut msg = Vec::with_capacity(pubkey.len() + doc.as_str().len());
98 msg.extend_from_slice(pubkey);
99 msg.extend_from_slice(doc.as_str().as_bytes());
100 let digest = oxedyne_fe2o3_hash::sha256::digest(&msg);
101 let mut bytes = [0u8; 8];
102 bytes.copy_from_slice(&digest[..8]);
103 ReplicaId::new(u64::from_be_bytes(bytes))
104}
105
106/// A signer's stable fingerprint for display and for keying a [`Keyring`]: the SHA-256 of the public
107/// key, as lower-case hexadecimal.
108///
109/// The whole digest rather than the eight bytes [`replica_of`] takes, because a fingerprint is shown
110/// to a person and compared for identity, where the eight-byte replica identity is only an ordering
111/// key: a wider value costs nothing here and leaves no room for a collision to be mistaken for a match.
112pub fn fingerprint(pubkey: &[u8]) -> String {
113 let digest = oxedyne_fe2o3_hash::sha256::digest(pubkey);
114 let mut s = String::with_capacity(digest.len() * 2);
115 for b in digest.iter() {
116 s.push_str(&fmt!("{:02x}", b));
117 }
118 s
119}
120
121/// A document's stable identity: the key its edit stream is stored and folded against.
122///
123/// It is minted once by whoever creates the document, written into the `.prl` header, and never
124/// derived from the document's contents -- a content-derived name would change the moment the document
125/// did, which is the one thing the sync key must not do.
126#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
127pub struct DocId(String);
128
129impl DocId {
130 pub fn new<S: Into<String>>(id: S) -> Self {
131 Self(id.into())
132 }
133
134 pub fn as_str(&self) -> &str {
135 &self.0
136 }
137}
138
139impl std::fmt::Display for DocId {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 write!(f, "{}", self.0)
142 }
143}