Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Modern e2e messengers (Signal, Matrix, etc.) face scalability issues and some limitations that restrict their functionality, broader adoption and overall user experience. For example, Signal which is based on its own Private Group System limits maximum number of users in a group to 1000 because a Signal group of size $N$ resembles $\binom{N}{2}$ peer-to-peer chats leading to overall quadratic complexity of group communication size and linear in the number of members time of encryption which is quite expensive for end users and Service Provider as well. Therefore, it’s also challenging to adopt private channels that work on a publisher-subscriber model, but end-to-end encryption (this property, for instance, allows for not being responsible for content moderation). Our novel 0-ART protocol aims to fix these problems and presents a new framework for building end-to-group(e2g) private messengers in various decentralized settings.

Asynchronous Ratchet Tree

In the heart of our 0-ART protocol lays Asynchronous Ratchet Tree (ART) — a cryptographic protocol designed to enhance the efficiency of group communications in centralized or decentralized settings. It introduces a mechanism for asynchronous updates of a ratchet tree structure, providing a forward-secrecy without the need for all participants to be online simultaneously, even if a number of group members is substantially large. ART is also adopted as a core part of the Message Layer Security (MLS) protocol, which is used in various messaging applications (Wire, etc). Unfortunately, the protocol is not designed to handle malicious group members trying to disrupt the group communication, leading to Denial-of-Service (DoS) ****or Sybil attacks in decentralized settings, which leads to limitations of broader adoption of the ART protocol in modern messengers.

0-ART

Our contribution is a novel 0-ART (zero-ART) protocol built on top of the ART protocol that mitigates such attacks by using zero-knowledge proofs of every group operation (InitGroup, AddMember, RemoveMember, KeyUpdate). The protocol allows a trustless group communication in decentralized settings where every group member(possibly malicious) proves the correctness of tree updates, making the disruption of ART state practically unfeasible. This approach improves scalability and security of ART without a trusted party assistance or costly Proof-of-Works.

Also, 0-ART provides a new zk-based anonymous credentials scheme enabling dynamic group membership changes while maintaining security and privacy.

Security Considerations

  • Identity management is decoupled from the Service Provider in our protocol, allowing users to manage their own identities or delegate them to a trusted Directory Service. Therefore, the Service provider couldn’t link user identities across different chats.
  • All group metadata is hidden from out-of-group users and the Service Provider.
  • All messages in a group are end-to-group encrypted, so the Service Provider or out-of-group users couldn’t decrypt them or determine the authority of each message.
  • Service Provider or out-of-group users couldn’t actively interfere with the group ART state without being disclosed.
  • 0-ART supports several anonymity modes inside a group: full-anonymous, partially-public (restricted to specific chat), and full-public (traceable between different mutual chats).
  • The use of anonymous credentials enables users to demonstrate eligibility for group operations without disclosing their identities or other sensitive information.
  • The user doesn’t require the involvement of all other group members to update their key in the tree.

Infrastructure

The architecture of the asynchronous verifiable group management system consists of the following components: Node, MessageRelay, Transistor(+PostgreSQL), NATS JetStream, Centrifugo, and MongoDB.

The system comprises two core components:

  • Node: A server-side component that brokers messages, stores encrypted data, and manages chat state.
  • Client: A user-side component that handles message encryption, exchange, and key synchronization.

The high level of the system architecture:

High level architecture

Storage

MongoDB was chosen for data storage because there is no need for relationships between data and high throughput is required. MongoDB can be replaced with any other KV NoSQL database.

Reactivity

To make the system reactive, Centrifugo was added — a service that provides SSE channels. Each channel is responsible for a separate group and sends real-time updates about the group. For ease of use, the NATS JetStream message broker was added to Centrifugo, which integrates with Centrifugo. Another component of reactivity is the MessageRelay service, which connects to the MongoDB replica set, listens for new frame insertion events, and sends new frames to the appropriate topic in NATS JetStream.

Node

Node is a delivery service that performs proof verification and stores the frame history. Node does not know the identity of any group members except the owner and cannot read group messages.

Transistor

The Transistor service is a service used as a file sharing service, for example, to share your identity or a large piece of information that cannot be sent in a frame. Data/files are deleted after the first read.

Client

The client side is implemented by the consumer. For this, there is the crate zrt-client-sdk.

Local development

For local development and testing, it is convenient to use the infrastructure repository, which allows you to raise the entire system locally with a single command.

Node

Since only the centralized model is currently supported, Node is the core of the system. The main function of Node is to provide an API for creating and managing a group and obtaining historical data. Swagger documentation can be found here: click.

Group creation

There is no separate endpoint for creating a group, because all operations on the group, including creation and deletion, occur at the application layer protocol, which is described in protobuf files.

To create a group in a frame that will be sent to the backend, you must specify GroupOperation::Init and pass the formed public ART tree. As proof of the frame, you must provide the frame signature with your own identity key, the public key of which must be specified in the nonce field.

Now the group creation frame must be sent to the appropriate endpoint:

POST /v1/group/{id}/frames

Message sending

Frames are sent to the endpoint:

POST /v1/group/{id}/frames

Frame proof depends on GroupOperation

Message receiving

To obtain frames stored on the backend, use the same endpoint as for sending, but with the GET method. As proof that you are a member of this tree and have the right to receive frames, you must sign the group ID and random nonce with either the tree's private key or your own leaf's private key. The epoch required in the request is the epoch of the keys you used to sign the group ID and nonce.

GET /v1/group/{id}/frames

Obtaining an ART tree

Obtaining an ART tree may be necessary when joining a group after deriving the leaf secret from the invitation, and now you need to obtain the tree in order to be able to read and write to the group.

To do this, you must first obtain the challenge and sign it with the tree key or your own leaf secret:

GET /v1/group/{id}/challenge

Then, with this signature, obtain the tree at the time of epoch n:

GET /v1/group/{id}/{epoch}
If you obtain the tree of epoch n, then the tree key or leaf secret must also be from that epoch.

Configuration

# config.toml

[centrifugo]
hmac_secret = "centrifugo_jwt_secret"
ttl = { secs = 86400, nanos = 0 }

[api]
address = "0.0.0.0:8000"
merge_changes = false

[storage]
database_url = "mongodb://mongo:27017/zkartgroup?replicaSet=rs0"
database_name = "zkartgroup"

[nats]
messages_namespace = "personal"

MongoDB

MongoDB must be running in replica set mode so that MessageRelay can track frame insertion events.

# Start MongoDB in replica set mode
mongod --replSet rs0 --bind_ip_all --port 27017
# Initialize replica set
echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}) }" | mongosh --port 27017 --quiet

Transistor

Transistor is a very small service that has only two endpoints: one for uploading data to the server and the other for downloading. Data is deleted after downloading.

Uploading:

POST /transistor/v1/public/transfers
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "type": "transfers",
    "attributes": {
      "data": "user_data"
    }
  }
}

Downloading:

GET /transistor/v1/public/transfers/{transfer_id}
{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "type": "transfers",
    "attributes": {
      "data": "user_data"
    }
  }
}

Configuration

# export KV_VIPER_FILE=config.yaml

log:
  level: debug
  disable_sentry: true

db:
  url: postgresql://test:test@127.0.0.1:7000/test?sslmode=disable

listener:
  addr: :8000

Use Case

In order to share your profile with another user, for example via a QR code or link, not all metadata and SPKs can fit into the QR code, let alone the link. One option for sharing is to add the ID and AES key to the QR code or link, encrypt the data with the specified AES key, and upload it to Transistor with the specified ID.

Transistor use case

Reactivity

The following services are responsible for the reactivity of the system: Message Relay, NATS JetStream, and Centrifugo.

Message Relay

Message Relay subscribes to frame insertion events in MongoDB and sends new frames to the NATS JetStream message broker.

Configuration

# config.toml

[logging]
level = "debug"

[storage]
database_url = "mongodb://mongodb:27017/zkartgroup?replicaSet=rs0"
database_name = "zkartgroup"
messages_outbox_collection_name = "messages_outbox"

[nats]
url = "nats://nats:4222"
subject = "events.>"
messages_namespace = "personal"
The database name must match the name in Node

NATS JetStream

nats-server -js -sd /data -m 8222
echo 'Waiting for NATS to be ready...'
while ! nats server check connection --server=nats://nats:4222 2>/dev/null; do
    echo 'NATS not ready, waiting...'
    sleep 2
done
echo 'NATS is ready! Creating EVENTS stream...'
nats stream add EVENTS --subjects 'events.>' --storage file --retention limits --max-msgs=-1 --max-bytes=-1 --max-age='' --max-msg-size=-1 --dupe-window=2m --replicas=1 --server=nats://nats:4222 --defaults
echo 'EVENTS stream created successfully!'

Centrifugo

Configuration

{
  "client": {
    "token": {
      "hmac_secret_key": "centrifugo_jwt_secret"
    },
    "allowed_origins": ["*"]
  },
  "admin": {
    "enabled": false,
    "password": "password",
    "secret": "secret"
  },
  "http_api": {
    "key": "secret"
  },
  "uni_sse": {
    "enabled": true
  },
  "channel": {
    "namespaces": [
      {
        "name": "personal",
        "history_size": 300,
        "history_ttl": "600s",
        "force_recovery": true,
        "presence": true
      }
    ]
  },
  "consumers": [
    {
      "enabled": true,
      "name": "nats_events",
      "type": "nats_jetstream",
      "nats_jetstream": {
        "url": "nats://nats:4222",
        "stream_name": "EVENTS",
        "subjects": ["events.>"],
        "durable_consumer_name": "centrifugo_consumer",
        "deliver_policy": "new",
        "max_ack_pending": 100
      }
    }
  ]
}
`centrifugo_jwt_secret` must be the same as in Node

Running

centrifugo -c config.json

Application Layer Protocol

We assume classic Pub/Sub model for our e2ee collaboration application where the service provider is merely untrusted delivery service which could:

  • see encrypted application layer messages
  • see current ART tree structure: e.g. public key of each node of a tree
  • see current ART epoch (but importantly not set it)
  • set sequence number of each message and ART update as it’s a part of a protocol
  • potentially lead denial of service attacks by not delivering or selectively delivering messages
  • see group creator identity public key.

SP couldn’t:

  • see the content of any protected message inside group
  • impersonate any user inside group providing fake proofs
  • see metadata of users inside some group (meaning their identity public keys, names and other private information) except constantly ratcheting leaf public key
  • see metadata of group itself except neutral group_id
syntax = "proto3";

import "google/protobuf/timestamp.proto";

package zero_art_proto;

enum Role {
  READ = 0; // only read permission
	WRITE = 1; // only write
	OWNERSHIP = 2; // owner
	ADMIN = 3; // administrator
}

// User definition
message User {
  string id = 1; // actor id
  string name = 2; // user name
  bytes public_key = 3; // user identity public key
  bytes picture = 4; // user picture
  Role role = 5; // user role
}

enum ContentAttachmentType {
	IMAGE = 0;
	BINARY = 1;
	VIDEO = 2;
}

message ContentAttachment {
	string id = 1;
	ContentAttachmentType type = 2;
	bytes data = 3;
}

// CRDT payload: could be either incremental change or full document
message CRDTPayload {
	oneof payload {
		bytes incremental_change = 1;
		bytes full_document = 2;
		ContentAttachment media_attachment = 3;
	}
}

// Ordinary chat payload: text, image or file
message ChatPayload {
	oneof payload {
		bytes text = 1;
		bytes img = 2;
		bytes file = 3;
	}
}

message GroupInfo {
  string id = 1; // document id
  string name = 2; // document name
  google.protobuf.Timestamp created = 3; // document creation time
  bytes picture = 4;
  repeated User members = 10; // membership list of document
}

// Protected (could be visible only inside group) group actions
message GroupActionPayload {
  oneof action {
	  GroupInfo init = 1; // group init action
	  GroupInfo invite_member = 2; // invite member action
	  User remove_member = 3; // remove member action
	  User join_group = 4; // join group action
	  User change_user = 5; // user changes they metadata
	  GroupInfo change_group = 6; // group metadata change
	  User leave_group = 7; // member leaves group
	  User finalize_removal = 8; // finalize removal of a member
  }
}

// Payload wrapper
message Payload {
  oneof content {
    CRDTPayload crdt = 1;
    ChatPayload chat = 2;
    
    GroupActionPayload action = 10;
  }
}

// High level group operation visible by SP
message GroupOperation {
	oneof operation {
		bytes init = 1; // public art
		bytes add_member = 2; // branch_changes
		bytes remove_member = 3; // branch_changes
		bytes key_update = 4; // branch_changes
		bytes leave_group = 5; // own_node_index
		bytes drop_group = 10; // challenge
	}
}

message FrameTBS {
    string group_id = 1; // group identifier
    uint64 epoch = 2; // epoch number
    bytes nonce = 3; // random 16b nonce for replay attack protection
    GroupOperation group_operation = 5; // group operation
    bytes protected_payload = 6; // encrypted ProtectedPayload message
}

// main transport layer frame [client] -> [SP]
message Frame {
    FrameTBS frame = 1; // frame data included in proof transcript
    bytes proof = 9; // proof of group_operation with included context of frame
}

// transport layer frame [SP] -> [client]
message SPFrame {
    uint64 seq_num = 1; // sequence number set by SP when Frame received from client
    google.protobuf.Timestamp created = 4; // time SP received frame
    Frame frame = 5;
}

// vector of SPFrames for SP
message SPFrames {
    repeated SPFrame sp_frames = 1;
}

message ProtectedPayloadTBS {
    uint64 seq_num = 1; // internal sequence number of message (might not be equal to SPFrame.seq_num)
    oneof sender {
        string user_id = 2; // user identifier (User.id)
        string leaf_id = 3; // leaf identifier (masking user real identity)
        // null if user wants to post in anonymous mode
    }
    google.protobuf.Timestamp created = 4;
    repeated Payload payload = 5;
}

// protected application layer message(decrypted Frame.protected_payload)
message ProtectedPayload {
    ProtectedPayloadTBS payload = 1;
    bytes signature = 9; // signature of payload using method defined in ProtectedPayloadTBS.sender field
}

// auxiliary protected invite data
message ProtectedInviteData {
    string group_id = 1;
    uint64 epoch = 2;
    bytes stage_key = 3;
}

// Invite for some identified user (Q_id, Q_spk)
message IdentifiedInvite {
    bytes identity_public_key = 1; // identity public key of invitee
    bytes spk_public_key = 2; // spk public key of invitee
}

// Invite for unidentified user
message UnidentifiedInvite {
	bytes private_key = 3; // private key of invitee
}

message InviteTbs {
    oneof invite {
        IdentifiedInvite identified_invite = 1;
        UnidentifiedInvite unidentified_invite = 2;
    }
    bytes protected_invite_data = 9; // encrypted on shared secret ProtectedInviteData
    bytes identity_public_key = 10; // public key of invitor
    bytes ephemeral_public_key = 11; // one-time ephemeral public key of invitor
}

message Invite {
    InviteTbs invite = 1;
    bytes signature = 9; // Sign(identity_public_key, invite.serialize())
}

Protocol description

Main unit of transmission between client and SP is Frame that encodes group operation Frame.group_operation and optional message Frame.protected_payload to group. Upon receiving a Frame SP validates a Frame.proof, assigns to Frame a seq_num and stores it in DB. Each user then might acquire a list of SPFrame from SP for given group.

Here we describe a protocol for e2e collaborative work on document, so semantically we assume the following duality:

  • Group ↔ Document
  • Message ↔ CRDT incremental update

Initialization

In order to create a group (document) with identifier group_id the owner delivers to SP via POST /v1/group/{group_id}/frames:

Frame { // initial group frame
	frame: FrameTBS {
		epoch: 0,
		group_operation: GroupOperation {
			init: <serialized signed initial ART tree>
		},
		protected_payload: Encrypt(ProtectedPayload {
			payload: ProtectedPayloadTBS {
				seq_num: 0,
				payload: [
					GroupActionPayload {
						init: GroupInfo { <description of a group, except users' public keys> }
					},
					CRDTPayload: {
						full_document: <full serialized CRDT document>
					}
				]
			},
			signature: Sign(identity_public_key, SHA3(payload.serialize()))
		}.serialize())
	},
	proof: Sign(identity_public_key, SHA3(frame.serialize()))
}

And sends to each invited member their Invite using some private channel.

Joining group

Invitee then acquires challenge from SP via GET /v1/group/challenge and delivers signed GET /v1/group/:id/:epoch request on which SP responds with ART tree a user uses to join the group by deriving root key for particular epoch(0 if member is added in initial ART), obtaining historic frames, checking the first initial frame’s initial ART corresponds to ART from SP if user is added from initial phase and sending to the group the following frame:

Frame { // joining group frame
	frame: FrameTBS {
		epoch: <current_epoch + 1>,
		group_operation: GroupOperation {
			key_update: <branch update>
		},
		protected_payload: Encrypt(ProtectedPayload {
			payload: ProtectedPayloadTBS {
				seq_num: <current_seq_num + 1>,
				payload: [
					GroupActionPayload {
						join_group: User { <user full info including identity_public_key> }
					},
				]
			},
			signature: Sign(identity_public_key, SHA3(payload.serialize()))
		}.serialize())
	},
	proof: ARTProve(frame.group_operation, associated_data = SHA3(frame.serialize()))
}

Sending a message to group

Let a group member wants to send a message containing some document change to the group. They could do it in different ways depending on a sender of previous message in the group:

  • if previous frame sender is current user then frame takes the following format:

    Frame { // sending a consequtive frame (previous frame was from the same user))
    	frame: FrameTBS {
    		epoch: <current_epoch>, // stay the epoch untouched
    		// group_operation intentionally skipped
    		protected_payload: Encrypt(ProtectedPayload {
    			payload: ProtectedPayloadTBS {
    				seq_num: <current_seq_num + 1>,
    				payload: [
    					CRDTPayload: {
    						incremental_change: <incremental change of CRDT document>
    					}
    					// user could also include ChatPayload
    				]
    			},
    			signature: Sign(identity_public_key, SHA3(payload.serialize()))
    		}.serialize())
    	},
    	proof: Sign(current_art_root_key, SHA3(frame.serialize()))
    }
    
  • if previous frame sender is another user current user must propagate the epoch:

    Frame { // sending frame with epoch propagation (previous frame was from another user)
    	frame: FrameTBS {
    		epoch: <current_epoch + 1>, // increment the epoch
    		group_operation: GroupOperation {
    			key_update: <branch update> // perform ART branch update 
    		},
    		protected_payload: Encrypt(ProtectedPayload {
    			payload: ProtectedPayloadTBS {
    				seq_num: <current_seq_num + 1>,
    				payload: [
    					CRDTPayload: {
    						incremental_change: <incremental change of CRDT document>
    					}
    					// user could also include ChatPayload
    				]
    			},
    			signature: Sign(identity_public_key, SHA3(payload.serialize()))
    		}.serialize())
    	},
    	proof: ARTProve(frame.group_operation, associated_data = SHA3(frame.serialize()))
    }
    

Inviting new member

Each user with appropriate role has a right to perform group management operation, especially inviting new members to group or removing existing members. By convention group owner owes a leftmost leaf in ART tree so SP could easily check an eligibility of that group operation by checking PoK of leftmost secret leaf (already included in AddMember and RemoveMember proofs). For more complicated case of an administrator (not owner) changing membership we propose to include credential presentation proof as a proof of eligibility, but it’s a subject for separate topic.

In order to invite a new member to a group invitor sends Invite to invitee by private channel and posts the following Frame to group:

Frame { // invitational group frame
	frame: FrameTBS {
		epoch: <current_epoch + 1>,
		group_operation: GroupOperation {
			add_member: <branch update for new member>
		},
		protected_payload: Encrypt(ProtectedPayload {
			payload: ProtectedPayloadTBS {
				seq_num: <current_seq_num + 1>,
				payload: [
					GroupActionPayload {
						invite_member: User { <invitor introduces new user to group setting Role and optionally PublicKey> }
					},
					CRDTPayload: {
						full_document: <full serialized CRDT document so that invitee could see the full doc without breaking Forward Secrecy>
					}
				]
			},
			signature: Sign(identity_public_key, SHA3(payload.serialize()))
		}.serialize())
	},
	proof: ARTProve(frame.group_operation, associated_data = SHA3(frame.serialize()))
}

Removing existing member

In a group each user might leave the group intentionally or be removed by other group member with appropriate rights.

If user decides to remove themselves from a group they post the following Frame:

Frame { // group leaving frame
	frame: FrameTBS {
		epoch: <current_epoch>,
		group_operation: GroupOperation {
			leave_group: <user index>,
		},
		protected_payload: Encrypt(ProtectedPayload {
			payload: ProtectedPayloadTBS {
				seq_num: <current_seq_num + 1>,
				payload: [
					GroupActionPayload {
						leave_group: User { <user info> }
					}
				]
			},
			signature: Sign(identity_public_key, SHA3(payload.serialize()))
		}.serialize())
	},
	proof: Sign(current_art_leaf_key, SHA3(frame.serialize()))
}

In either case (if user leaves group or is removed by other member) full removal of users could be performed in two phases according to our cryptoprotocol. Firstly, a member with granted access to performing membership changes updates a branch for removing user rewriting public keys on removing user’s direct path (importantly not deleting ART tree node itself) by sending the Frame:

Frame { // removing member frame
	frame: FrameTBS {
		epoch: <current_epoch + 1>,
		group_operation: GroupOperation {
			remove_member: <branch update for removing member>
		},
		protected_payload: Encrypt(ProtectedPayload {
			payload: ProtectedPayloadTBS {
				seq_num: <current_seq_num + 1>,
				payload: [
					GroupActionPayload {
						remove_member: User { <user info> }
					},
				]
			},
			signature: Sign(identity_public_key, SHA3(payload.serialize()))
		}.serialize())
	},
	proof: ARTProve(frame.group_operation, associated_data = SHA3(frame.serialize()))
}

than SP and members of the group treat the updated leaf as removing. To complete removal of the leaf and assure that noone possesses its secret any member could finalize it by simply updating it once more:

Frame { // removing member frame
	frame: FrameTBS {
	c
		group_operation: GroupOperation {
			remove_member: <branch update for removing member once again>
		},
		protected_payload: Encrypt(ProtectedPayload {
			payload: ProtectedPayloadTBS {
				seq_num: <current_seq_num + 1>,
				payload: [
					GroupActionPayload {
						finalize_removal: User { <user info> }
					},
				]
			},
			signature: Sign(identity_public_key, SHA3(payload.serialize()))
		}.serialize())
	},
	proof: ARTProve(frame.group_operation, associated_data = SHA3(frame.serialize()))
}

SP and any member treat this update using merge technique introduced in CausalART protocol.

Concurrent Updates

Our protocol handles concurrent updates of the group state (epoch counter incremented to the same value by different members in parallel) using CausalART if and only if operations commute with each other:

  • add_member + key_update
  • key_update + key_update
  • remove_member + key_update
  • add_member + remove_member
  • any frame with at the same epoch without group_operation commutes with any group_operation performed by another user (although epoch is incremented).

In other cases such as add_member + add_member SP will fail the operation explicitly.

To handle concurrent updates of the document itself we use common CRDT protocol implemented in automerge library.

Metadata changes

Each user could change their profile at any moment by including the following Payload in Frame(either along with some group operation or out of it):

GroupActionPayload {
	change_user: User { <user could update any field except public_key> }
},

Metadata of the group could be changed only by the owner by including in Frame the following Payload:

GroupActionPayload {
	change_group: User { <owner could only update `name` and `picture`> }
},

Client SDK

To interact with 0-ART groups, each user must have an identity key pair: identity_secret_key and identity_public_key. Hereinafter, we will consider either identity_public_key or SHA3-256(identity_public_key)[..16] to be the user identifier. Within each group, the user has a leaf_secret, which is used to derive the tree key (tk) and stage key (stk). leaf_secret is not permanent and can change so that the cryptosystem has forward secrecy. In addition to leaf_secret, the user must store the stk and epoch of the group.

Example of how to generate a key pair:

use cortado::{self, CortadoAffine, Fr as ScalarField};
use ark_std::rand::prelude::StdRng;
use ark_std::rand::thread_rng;
use ark_std::UniformRand;
use ark_ec::{AffineRepr, CurveGroup};
...
let mut rng = StdRng::from_rng(thread_rng()).unwrap();
let identity_secret_key = ScalarField::rand(&mut rng);
let identity_public_key = (CortadoAffine::generator() * identity_secret_key).into_affine();

The SDK provides a tool for creating, using, and managing groups based on 0-ART trees. Let's look at the high-level components of a group:

#[derive(Debug, Clone, Default)]
pub struct User {
    id: String,
    name: String,
    public_key: CortadoAffine,
    metadata: Vec<u8>,
    ...
}

The metadata and name refer to metadata. The public_key is the user's identity_public_key, which the user will use to sign the payload. In order to distinguish invited users from group members, the public_key field is set to CortadoAffine::default() (i.e., a point at infinity), which is related to the structure of the 0-ART tree and some other operations. The id is the first 16 bytes of the SHA3-256(public_key) and SHA3-256(leaf_key) hash in hex without 0x for group members and invited members, respectively.

#[derive(Debug, Default, Clone)]
pub struct GroupInfo {
    id: Uuid,
    name: String,
    created: DateTime<Utc>,
    metadata: Vec<u8>,
    members: GroupMembers,
}

For GroupInfo, as in User, the name, metadata, and created fields refer to metadata. The Group ID is set by the owner during creation and remains unchanged throughout the group's existence.

Group creation

To create a new group, use the method GroupContext::new(identity_secret_key: ScalarField, group_info: GroupInfo) -> Result<(Self, Frame)>.

Note that GroupMembers must contain a User with your identity_public_key, for example:

let mut rng = StdRng::from_rng(thread_rng()).unwrap();

let identity_secret_key = ScalarField::rand(&mut rng);
let identity_public_key = (CortadoAffine::generator() * identity_secret_key).into_affine();

let owner = User::new(
    "owner".to_string(),
    identity_public_key,
    vec![],
    zero_art_proto::Role::Ownership,
);

let group_info = GroupInfo::new(
    Uuid::new_v4(),
    "group".to_string(),
    Utc::now(),
    vec![],
    vec![owner].into(),
);

let (mut group_context, initial_frame) =
    GroupContext::new(identity_secret_key, group_info).expect("Failed to create GroupContext");

At the moment, the group has only been created locally, and now it is necessary to send initial_frame to the Service Provider so that other invited participants can synchronize changes in the group through it.

Send message

Currently, the SDK supports three types of payloads: GroupAction, CRDT, and Chat. GroupAction refers to any change in a group, such as adding or removing a member, updating group or member metadata, etc. CRDT and Chat payloads are defined in the protobuf file.

#[derive(Debug, Clone)]
pub enum Payload {
    Action(GroupActionPayload),
    Crdt(zero_art_proto::crdt_payload::Payload),
    Chat(zero_art_proto::chat_payload::Payload),
}

#[derive(Debug, Clone)]
pub enum GroupActionPayload {
    Init(GroupInfo),
    InviteMember(GroupInfo),
    RemoveMember(User),
    JoinGroup(User),
    ChangeUser(User),
    ChangeGroup(GroupInfo),
    LeaveGroup(User),
    FinalizeRemoval(User),
}

To send a message, you need to create a frame with the desired payloads.

Note that creating a frame can sometimes take longer because leaf_secret is implicitly updated, which requires generating a zero-knowledge proof.

impl GroupContext {
    pub fn create_frame(
        &mut self,
        payloads: Vec<models::payload::Payload>,
    ) -> Result<models::frame::Frame> {...}
}

Recieve message

Frames accepted by the Service Provider will be sent to all active participants via the SSE channel, or participants can obtain these frames by requesting them from the Node. Frames contain encrypted data and information about group updates, so in order to update the group state and read the data, it is necessary to process this frame:

impl GroupContext {
    pub fn process_frame(
        &mut self,
        frame: models::frame::Frame,
    ) -> Result<Vec<models::payload::Payload>> {...}
}

Add member

To add a user to a group, you need to generate a leaf_secret for them. There are two types of invited participants: Identified and Unidentified.

An Identified participant is someone whose identity public key we know. SPK is an optional temporary public key of the person we are inviting, which can be used, for example, to track the invitation. If the person being invited has lost their private key from the SPK, they will not be able to accept the invitation.

Unidentified, in turn, corresponds to a user whose identity public key we do not know, aka an anonymous invitation for which we generate the private key ourselves.

#[derive(Debug, Clone, Copy)]
pub enum Invitee {
    Identified {
        identity_public_key: CortadoAffine,
        spk_public_key: Option<CortadoAffine>,
    },
    Unidentified(ScalarField),
}

impl GroupContext {
    pub fn add_member(
        &mut self,
        invitee: Invitee,
        mut payloads: Vec<Payload>,
    ) -> Result<(Frame, Invite)> {...}
}

Remove member

To delete a user, you need to know their ID or identity_public_key.

impl GroupContext {
    pub fn remove_member(
        &mut self,
        user_id: &str,
        mut payloads: Vec<Payload>,
    ) -> Result<(Frame, Option<User>)> {...}
}

Each of these actions: CreateFrame, AddMember, and RemoveMember results in the creation of a Frame, which must be sent to the Service Provider.

State commiting

To prevent tree states from becoming out of sync, a commit mechanism is used. Therefore, if you sent a frame and the Service Provider returned a Status code OK, you need to commit the current state:

impl GroupContext {
    pub fn commit_state(&mut self) {...}
}

Accept invite

To accept an invitation, you need to create InviteContext. spk_secret_key is a private key if spk was used when creating the invitation.

impl InviteContext {
    pub fn new(
        identity_secret_key: ScalarField,
        spk_secret_key: Option<ScalarField>,
        invite: Invite,
    ) -> Result<Self> {...}
}

Invite context is necessary in order to be able to obtain the ART tree from the Service Provider. To do this, you need to obtain a challenge and sign it with sign_as_leaf, and with the signed challenge you can obtain the tree. This is necessary so that no one except the group members can obtain the tree.

impl InviteContext {
    pub fn sign_as_leaf(&self, msg: &[u8]) -> Result<Vec<u8>> {...}
}

Now that you have the tree, you can upgrade InviteContext to PendingGroupContext. At this stage, you have not yet accepted the invitation, and PendingGroupContext is necessary to synchronize the tree state, after which you will be able to accept the invitation.

impl InviteContext {
    pub fn upgrade(self, art: PublicART<CortadoAffine>) -> Result<PendingGroupContext> {...}
}

To synchronize the status in PendingGroupContext, there is a method similar to GroupContext: process_frame.

Once you have synchronized, you can try to accept the invitation:

impl PendingGroupContext {
    pub fn join_group_as(&mut self, mut user: User) -> Result<Frame> {...}
}

If the Service Provider has accepted the Frame, you are now in the group and can upgrade PendingGroupContext to GroupContext to unlock the ability to send frames.

impl PendingGroupContext {
    pub fn upgrade(mut self) -> GroupContext {...}
}

Example

fn generate_key_pair(rng: &mut StdRng) -> (CortadoAffine, ScalarField) {
    let secret_key = ScalarField::rand(rng);
    let public_key = (CortadoAffine::generator() * secret_key).into_affine();
    (public_key, secret_key)
}

fn generate_uuid(rng: &mut StdRng) -> Uuid {
    let mut bytes = [0u8; 16];
    rng.fill_bytes(&mut bytes);
    Uuid::from_bytes(bytes)
}

...

let mut rng = StdRng::seed_from_u64(0);

let (owner_public_key, owner_secret_key) = generate_key_pair(&mut rng);
let (member_identity_public_key, member_identity_secret_key) = generate_key_pair(&mut rng);
let (member_spk_public_key, member_spk_secret_key) = generate_key_pair(&mut rng);

let owner = User::new(
    "owner".to_string(),
    owner_public_key,
    vec![],
    zero_art_proto::Role::Ownership,
);

let group_info = GroupInfo::new(
    generate_uuid(&mut rng),
    "group".to_string(),
    Utc::now(),
    vec![],
    vec![owner].into(),
);

let (mut group_context, _) =
    GroupContext::new(owner_secret_key, group_info).expect("Failed to create GroupContext");

let (frame_0, invite) = group_context
    .add_member(
        Invitee::Identified {
            identity_public_key: member_identity_public_key,
            spk_public_key: Some(member_spk_public_key),
        },
        vec![],
    )
    .expect("Failed to add member in group context");

group_context.commit_state();

let new_leaf_secret = ScalarField::rand(&mut rng);
let frame_1 = group_context
    .key_update(new_leaf_secret, vec![])
    .expect("Failed to key update");

let public_art: PublicART<CortadoAffine> = group_context.state.art.clone().into();
group_context.commit_state();

let invite_context = InviteContext::new(
    member_identity_secret_key,
    Some(member_spk_secret_key),
    invite,
)
.expect("Failed to create InviritContext");
let mut pending_group_context = invite_context
    .upgrade(public_art)
    .expect("Failed to upgrade invite context to pending group context");
pending_group_context
    .process_frame(frame_0)
    .expect("Failed to process sync frame");
pending_group_context
    .process_frame(frame_1)
    .expect("Failed to sync 2");

let user = User::new(
    "user".to_string(),
    member_identity_public_key,
    vec![],
    zero_art_proto::Role::Write,
);

let frame_2 = pending_group_context
    .join_group_as(user)
    .expect("Failed to join group");
let mut member_group_context = pending_group_context.upgrade();

group_context
    .process_frame(frame_2)
    .expect("Failed to process accept invite flow");

let new_leaf_secret = ScalarField::rand(&mut rng);
let frame_3 = member_group_context
    .key_update(new_leaf_secret, vec![])
    .expect("Failed to update key");
member_group_context.commit_state();

group_context.process_frame(frame_3).expect("Failed to process frame");
let frame_4 = group_context.create_frame(vec![]).expect("Failed to create frame");