jazz-rs 0.1.0

A framework for CRDT based, end-to-end enrypted distributed apps
Documentation
// TODO-V1(design): Teams with invitations and protected member identities
// TODO-V1(design): Attachments for immutable files (with quality versions?)

// TODO-V1(design): Identities take the form of emails username+scope@usegarden.com -> that server can act as an identity provider
// TODO-V1(design): Allow user to create arbitrary profiles/inboxes in the shape anon892347@usegarden.com

use conundrum::{
    purpose,
    symm_encr::{EncrKey, Encrypted, DecryptionError},
};
use conventions::{admin_permissions, read_permissions, write_permissions};
use credo::{ClaimKind, Credential, Credo, GroupID, MakeClaimError};
use minitime::MsSinceEpoch;
use jmbl::Input;
use litl::Litl;
use rand07::{rngs::OsRng, RngCore};
use serde_derive::{Deserialize, Serialize};
use core::panic;
use std::{cell::RefCell, collections::HashMap, fmt::Display, rc::Rc, str::FromStr};
use jazz_telepathy::{TelepathyNode, RemoteManager};
use thiserror::Error;

#[cfg(feature = "js")]
use wasm_bindgen::prelude::*;

pub use managed_jmbl::ManagedJMBL;

mod conventions;
mod managed_jmbl;

#[derive(Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct DocID([u8; 16]);

impl DocID {
    pub fn new() -> Self {
        let mut id = [0u8; 16];
        OsRng {}.fill_bytes(&mut id);
        DocID(id)
    }
}

impl Display for DocID {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        Litl::from_se(&self).fmt(f)
    }
}

#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "js", wasm_bindgen)]
pub struct ScopedDocID {
    team: GroupID,
    branch: String,
    doc: DocID,
}

impl Display for ScopedDocID {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "doc_{}", bs58::encode(Litl::write_from(&self)).into_string())
    }
}

impl FromStr for ScopedDocID {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // TODO: use Err instead of unwrapping
        let (_, id_str) = s.split_once('_').unwrap();
        Ok(Litl::read_as(&bs58::decode(id_str).into_vec().unwrap()).unwrap())
    }
}

pub struct JazzNode {
    content_telepathy: Rc<RefCell<TelepathyNode>>,
    credo: Rc<RefCell<Credo>>,
    docs: HashMap<ScopedDocID, Rc<RefCell<managed_jmbl::ManagedJMBL>>>,
}

impl JazzNode {
    pub fn new(
        content_telepathy: Rc<RefCell<TelepathyNode>>,
        credo: Rc<RefCell<Credo>>,
    ) -> Self {
        Self {
            content_telepathy,
            credo,
            docs: HashMap::new(),
        }
    }

    pub fn create_document<I: Into<Input>>(
        &mut self,
        team: GroupID,
        branch: String,
        input: I,
    ) -> (ScopedDocID, Rc<RefCell<managed_jmbl::ManagedJMBL>>) {
        let doc_id = DocID::new();
        let scoped_doc_id = ScopedDocID {
            team,
            branch,
            doc: doc_id,
        };
        let managed_jmbl = Rc::new(RefCell::new(managed_jmbl::ManagedJMBL::create(
            scoped_doc_id.clone(),
            input,
            Rc::clone(&self.content_telepathy),
            Rc::clone(&self.credo),
        )));
        self.docs
            .insert(scoped_doc_id.clone(), Rc::clone(&managed_jmbl));
        (scoped_doc_id, managed_jmbl)
    }

    pub fn load_document(
        &mut self,
        scoped_doc_id: ScopedDocID,
    ) -> Rc<RefCell<managed_jmbl::ManagedJMBL>> {
        Rc::clone(self.docs.entry(scoped_doc_id.clone()).or_insert_with(|| {
            Rc::new(RefCell::new(managed_jmbl::ManagedJMBL::load(
                scoped_doc_id,
                Rc::clone(&self.content_telepathy),
                Rc::clone(&self.credo),
            )))
        }))
    }

    pub fn create_team(&mut self) -> GroupID {
        let initial_recipient = Credential::new_random();
        let team = self
            .credo
            .borrow_mut()
            .create_group(initial_recipient, admin_permissions());
        // TODO(design): Allow trusted backend to be time witness
        // TODO(design): show own identity to team
        self.credo.borrow_mut().rotate_group_secret(&team).unwrap();
        team
    }

    // TODO(design): introduce some kind of limited-use per invitation
    pub fn create_invitation(
        &mut self,
        team: GroupID,
        kind: InvitationKind,
    ) -> Result<InvitationToken, MakeClaimError> {
        let invitation = Invitation::new(team, kind);
        let mut credo = self.credo.borrow_mut();
        let invitation_permissions = match kind {
            InvitationKind::Reader => read_permissions(),
            InvitationKind::Writer => write_permissions(),
            InvitationKind::Admin => admin_permissions(),
        };

        for permission in invitation_permissions {
            credo
                .make_claim_after_frontier(
                    &team,
                    ClaimKind::Permission {
                        to: invitation.credential.make_claim.public(),
                        as_of: MsSinceEpoch::now(),
                        kind: permission,
                    },
                )
                .unwrap();
        }

        credo.make_claim_after_frontier(
            &team,
            ClaimKind::AddGroupSecretRecipient {
                recipient: invitation.credential.accept_secrets.public(),
            },
        )?;

        let (invitation_token, encrypted_invitation) = invitation.encrypt_for_sending();

        credo.make_claim_after_frontier(
            &team,
            ClaimKind::Statement {
                path: format!("invitations/{:?}", invitation_token.key.id),
                value: Litl::from_se(&encrypted_invitation),
            },
        )?;

        credo.re_reveal_group_secret(&team)?;

        Ok(invitation_token)
    }

    pub fn join_team(&mut self, invitation_token: InvitationToken) -> Result<(), JoinTeamError> {
        let (team_listener, mut team_receiver) = futures::channel::mpsc::unbounded();
        self.credo
            .borrow_mut()
            .subscribe(invitation_token.team, Box::new(team_listener));

        let team_state = match team_receiver.try_next() {
            Err(_) => {
                return Err(JoinTeamError::ExpectedInitialTeamStateReceived(
                    invitation_token,
                ))
            }
            Ok(None) => {
                return Err(JoinTeamError::ExpectedInitialTeamStateExisting(
                    invitation_token,
                ))
            }
            Ok(Some(team_state)) => team_state,
        };

        let encrypted_invitation = match team_state.valid_claims.iter().find_map(|(_, claim)| {
            if let ClaimKind::Statement { path, value } = &claim.expect_v1().kind {
                if path == &format!("invitations/{:?}", invitation_token.key.id) {
                    Litl::try_into_de::<Encrypted<Invitation, InvitationEncryption>>(value.clone())
                        .ok()
                } else {
                    None
                }
            } else {
                None
            }
        }) {
            Some(encrypted_invitation) => encrypted_invitation,
            None => {
                return Err(JoinTeamError::ExpectedEncryptedInvitationInTeamState(
                    invitation_token,
                ))
            }
        };

        let invitation = match invitation_token.key.decrypt(&encrypted_invitation) {
            Ok(invitation) => invitation,
            Err(decryption_error) => return Err(JoinTeamError::ExpectedTokenToDecryptEncryptedInvitation(decryption_error)),
        };

        self.credo
            .borrow_mut()
            .add_credential(&invitation_token.team, invitation.credential);

        Ok(())
    }

    pub fn content_and_credo_remotes(&self) -> (RemoteManager, RemoteManager) {
        (self.content_telepathy.borrow().remotes(),
        self.credo.borrow().untrusted_remotes())
    }

    pub fn sync_with_remotes(&mut self) {
        self.credo.borrow_mut().sync_with_remotes();
        self.content_telepathy.borrow_mut().sync_with_remotes();
    }
}

#[derive(Copy, Clone, Serialize, Deserialize, Debug)]
pub enum InvitationKind {
    Reader,
    Writer,
    Admin,
}

#[derive(Serialize, Deserialize)]
struct Invitation {
    team: GroupID,
    kind: InvitationKind,
    credential: Credential,
}

purpose!(InvitationEncryption);

impl Invitation {
    pub fn new(team: GroupID, kind: InvitationKind) -> Invitation {
        Invitation {
            team,
            kind,
            credential: Credential::new_random(),
        }
    }

    pub fn encrypt_for_sending(
        &self,
    ) -> (InvitationToken, Encrypted<Invitation, InvitationEncryption>) {
        let key = EncrKey::new_random();
        let encrypted = key.encrypt(self);
        (
            InvitationToken {
                key,
                team: self.team,
                kind: self.kind,
            },
            encrypted,
        )
    }
}

#[derive(Debug, Serialize, Deserialize)]
pub struct InvitationToken {
    team: GroupID,
    kind: InvitationKind,
    key: EncrKey<InvitationEncryption>,
}

impl Display for InvitationToken {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> {
        let team_str = bs58::encode(Litl::write_from(&self.team)).into_string();
        let kind_str = match self.kind {
            InvitationKind::Reader => "read_key",
            InvitationKind::Writer => "write_key",
            InvitationKind::Admin => "admin_key",
        };
        let key_str = bs58::encode(Litl::write_from(&self.key)).into_string();
        write!(f, "team_{}_{}_{}", team_str, kind_str, key_str)
    }
}

impl FromStr for InvitationToken {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        // TODO: use Err instead of unwrapping
        let (start, key_str) = s.rsplit_once('_').unwrap();
        let (_team_str, middle) = start.split_once('_').unwrap();
        let (team_str, kind_str) = middle.split_once('_').unwrap();
        Ok(InvitationToken {
            team: Litl::read_as(&bs58::decode(team_str).into_vec().unwrap()).unwrap(),
            kind: match kind_str {
                "read_key" => InvitationKind::Reader,
                "write_key" => InvitationKind::Writer,
                "admin_key" => InvitationKind::Admin,
                _ => panic!("Expected key kind to be 'read_key', 'write_key', or 'admin_key'"),
            },
            key: Litl::read_as(&bs58::decode(key_str).into_vec().unwrap()).unwrap(),
        })
    }
}

#[derive(Error, Debug)]
pub enum JoinTeamError {
    #[error("Expected to have received initial team state")]
    ExpectedInitialTeamStateReceived(InvitationToken),
    #[error("Expected team state to exist on receipt")]
    ExpectedInitialTeamStateExisting(InvitationToken),
    #[error("Expected encrypted invitation to exist in team state")]
    ExpectedEncryptedInvitationInTeamState(InvitationToken),
    #[error(transparent)]
    ExpectedTokenToDecryptEncryptedInvitation(DecryptionError),
}