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 | |
| 35 | pub mod fold; |
| 36 | pub mod hub; |
| 37 | pub mod keyring; |
| 38 | pub mod op; |
| 39 | pub mod sign; |
| 40 | |
| 41 | pub use keyring::Keyring; |
| 42 | |
| 43 | use oxedyne_fe2o3_ore::id::ReplicaId; |
| 44 | |
| 45 | use oxedyne_fe2o3_core::prelude::*; |
| 46 | use 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. |
| 56 | pub 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. |
| 63 | pub 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. |
| 67 | pub 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. |
| 73 | pub 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. |
| 79 | pub 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. |
| 96 | pub 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. |
| 112 | pub 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)] |
| 127 | pub struct DocId(String); |
| 128 | |
| 129 | impl 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 | |
| 139 | impl std::fmt::Display for DocId { |
| 140 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 141 | write!(f, "{}", self.0) |
| 142 | } |
| 143 | } |