Decentralised MLS

Table of Contents

WARNING: This document is very much in a draft state, and may not make any sense.

Basic ideas:

TODO: show an example of the DAG and how the Lamport clock works

For simplicity, we assume that each user owns only one leaf in the MLS tree. But in practice, we would s/user/device/.

The explanation includes an implementation in Julia. The LBBTree (a left-balanced tree) and TreeKEM modules are not currently included in this file, but may be later on. We will be working with a simplified model for the tree for demonstration purposes.

1 Proposals and Commits

In MLS, users make proposals to the tree, and proposals are grouped together and committed to the tree. In a decentralised environment, this is difficult to do, since the person committing to the tree may not see all the proposals that have been made. To avoid this issue, we will assume that proposals are committed immediately. Thus we only need to merge committed changes together.

2 Implementation and tests

In addition to the normal information required for TreeKEM, the tree nodes must contain the timestamp at which the node was last updated and the user who updated it. The user will be represented by the index of the leaf that they occupy.

ClockedTreeKEM.jl =

module ClockedTreeKEM

using ..LBBTree # left-balanced binary tree implementation
using ..TreeKEM # basic TreeKEM implementation

<<clocked_tree_kem>>

end

<<clocked_tree_kem>> =

struct Data
    key::TreeKEM.Data
    time::UInt
    user_id::Int
end

"create data with no key"
Data(time::UInt, user_id::Int) =
    Data(TreeKEM.Data(nothing, nothing), time, user_id)
"create data with only a public key"
Data(pubKey::TreeKEM.Key, time::UInt, user_id::Int) =
    Data(TreeKEM.Data(nothing, pubKey), time, user_id)
"create data with a public and private key"
Data(privKey::TreeKEM.Key, pubKey::TreeKEM.Key, time::UInt, user_id::Int) =
    Data(TreeKEM.Data(privKey, pubKey), time, user_id)

# when printing, print the key data, followed by the time and user ID
Base.show(io::IO, data::Data) =
    print(io, data.key, " (", data.time, ",", data.user_id, ")")

Our tree will also contain the current timestamp, so that when we apply operations, it will know what timestamp to use.

<<clocked_tree_kem>> =

struct Tree
    tree::LBBTree.Tree{Data}
    time::UInt
end

# when we print a tree, only print the tree and not the current clock
Base.show(io::IO, tree::Tree) = Base.show(io, tree.tree)

When we create a tree, we will assume that the user in leaf 0 created the tree.

<<clocked_tree_kem>> =

"""
create a tree from a vector of leaf data
"""
function initTree(data::Vector{TreeKEM.Data})
    # the LBBTree constructor expects a vector of data for all nodes, which the
    # leaf node data with an internal node stuck in between.
    allNodes = Vector{Data}(undef, 2*length(data) - 1)
    allNodes[1] = Data(data[1], UInt(0), 0)
    for i = 2:length(data)
        allNodes[2i-2] = Data(UInt(0), 0)
        allNodes[2i-1] = Data(data[i], UInt(0), 0)
    end
    Tree(LBBTree.Tree(allNodes), UInt(0))
end

2.1 Update operations

For applying an Update operation, we define two functions. The first function is used normally by a user updating the tree, and overwrites the node data in a path of the tree. This version just uses the next Lamport timestamp from the tree.

<<clocked_tree_kem>> =

"""
perform an `Update` on the tree, given a vector of new data for the path from
the tree root to the leaf
"""
function update(
    t::Tree, index::Integer, keys::Vector{TreeKEM.Data}
)
    newtime = t.time + 1
    Tree(_update(t.tree, index, keys, newtime), newtime)
end

function _update(
    t::LBBTree.Tree{Data}, index::Integer, keys::Vector{TreeKEM.Data},
    newtime::UInt
)
    path = LBBTree.pathToLeaf(t, index)

    # convert the vector of keys to the data stored in the key by adding the
    # timestamp and the user
    #data = Data[Data(key, newtime, index) for key in keys]
    data = Data.(keys, newtime, index)

    LBBTree.replacePath(t, path, data)
end

The second function is used for applying an update coming from a different branch in the DAG. This requires you to specify the timestamp associated with the operation. It also needs to ensure that it does not overwrite any changes coming from operations that came "later" than it, according to the Lamport clock. We do this by using a _newer function, which given the current node data and the proposed replacement data, will return the data that is considered newer.

When we apply an update, we will use the special missing value to indicate that a specific node should be left alone. This will be used when merging Add operations.

<<clocked_tree_kem>> =

"""
Return the `Data` that is newer, based on the Lamport timestamp and user ID
"""
_newer(x::Data, y::Union{Data, Missing}) =
    ismissing(y) || x.time > y.time || (x.time == y.time && x.user_id > y.user_id) ? x : y

"""
apply an `Update` operation from a different branch of the DAG
"""
function applyUpdate(
    t::Tree, index::Integer, keys,
    time::UInt
)
    Tree(_applyUpdate(t.tree, index, keys, time), max(t.time, time))
end

function _applyUpdate(
    t::LBBTree.Tree{Data}, index::Integer, keys,
    time::UInt
)
    path = LBBTree.pathToLeaf(t, index)

    # convert the vector of keys to the data stored in the key by adding the
    # timestamp and the user
    data = map(key -> ismissing(key) ? missing : Data(key, time, index), keys)

    # apply the new data to the path, keeping the newer data
    LBBTree.replacePath(t, path, _newer, data)
end

Using this, a set of Update operations can be applied in any order, and end up with the same tree.

TODO: ensure that everyone gets sent all the private keys that they need.

In this example, we start with a tree with five leaf nodes and blank internal nodes. In one branch of the DAG, users 1 and 4 perform updates, and in another branch, user 3 performs an update. At this point, the changes from the two branches are communicated to the other side, so user 3's update is applied to the tree from the first branch, and user 1 and 4's updates are applied to the tree from the second branch.

(The ~ function in the example is a helper function that allows Julia's pipe operator (|>) to work with multi-argument functions.)

Example:

data = Data.(["0", "1", "2", "3", "4"])
tree = ClockedTreeKEM.initTree(data)
# clock = 0

# Users 1 and 4 update keys.  Then user 3's update from the other branch of the
# DAG is applied.
t1 = tree |>
    (~ClockedTreeKEM.update)(
        1, Data.(["new1'''", "new1''", "new1'", "new1"])
    ) |>
    # clock = 1
    (~ClockedTreeKEM.update)(
        4, Data.(["new4''", "new4'", "new4"])
    ) |>
    # clock = 2
    (~ClockedTreeKEM.applyUpdate)(
        3, Data.(["new3'''", "new3''", "new3'", "new3"]), UInt(1)
    )
    # clock = 2

# User 3 updates their key.  Then the updates from users 1 and 4 are applied.
t2 = tree |>
    (~ClockedTreeKEM.update)(
        3, Data.(["new3'''", "new3''", "new3'", "new3"])
    ) |>
    # clock = 1
    (~ClockedTreeKEM.applyUpdate)(
        1, Data.(["new1'''", "new1''", "new1'", "new1"]), UInt(1)
    ) |>
    # clock = 1
    (~ClockedTreeKEM.applyUpdate)(
        4, Data.(["new4''", "new4'", "new4"]), UInt(2)
    )
    # clock = 2

println(t1)

@test t1 == t2
Tree{Main.ClockedTreeKEM.Data}:
         ,- pubKey: 0 (0,0)
     ,- pubKey: new1' (1,1)
    |    `- pubKey: new1 (1,1)
 ,- pubKey: new3'' (1,3)
|   |    ,- pubKey: 2 (0,0)
|    `- pubKey: new3' (1,3)
|        `- pubKey: new3 (1,3)
pubKey: new4'' (2,4)
 `- pubKey: new4' (2,4)

2.2 Delete operations

Delete operations are similar to Update operations in that they just change the keys on paths in the tree. Again, we have two functions to apply Delete operations.

<<clocked_tree_kem>> =

"""
Delete the node at `index` from `t`.  The delete is performed by `deleter_index`,
who updates the nodes on the path from root to their leaf with the given `keys`
"""
function delete(
    t::Tree, index::Integer,
    deleter_index::Integer, keys::Vector{TreeKEM.Data}
)
    newtime = t.time + 1

    # blank the path from the deleted index to the root
    path = LBBTree.pathToLeaf(t.tree, index)
    len = length(path)
    data = fill(Data(newtime, deleter_index), len)
    deleted_tree = LBBTree.replacePath(t.tree, path, data)

    # the deleter does an update
    Tree(_update(deleted_tree, deleter_index, keys, newtime), newtime)
end

"""
Apply a `Delete` operation from another branch of the DAG.
"""
function applyDelete(
    t::Tree, index::Integer,
    deleter_index::Integer, keys::Vector{TreeKEM.Data},
    time::UInt
)
    path = LBBTree.pathToLeaf(t.tree, index)
    len = length(path)
    data = fill(Data(time, deleter_index), len)
    deleted_tree = LBBTree.replacePath(t.tree, path, _newer, data)
    Tree(_applyUpdate(deleted_tree, deleter_index, keys, time), max(t.time, time))
end

Delete operations can be mixed in with Update operations, and applying operations from different branches of the DAG gives the expected results.

TODO: explain example

Example:

data = Data.(["0", "1", "2", "3", "4"])
tree = ClockedTreeKEM.initTree(data)
# clock = 0

t1 = tree |>
    (~ClockedTreeKEM.update)(
        1, Data.(["new1'''", "new1''", "new1'", "new1"])
    ) |>
    # clock = 1
    (~ClockedTreeKEM.delete)(
        0, 4, Data.(["new4''", "new4'", "new4"])
    ) |>
    # clock = 2
    (~ClockedTreeKEM.applyDelete)(
        2, 3, Data.(["new3'''", "new3''", "new3'", "new3"]), UInt(1)
    )
    # clock = 2

t2 = tree |>
    (~ClockedTreeKEM.delete)(
        2, 3, Data.(["new3''''", "new3''", "new3'", "new3"])
    ) |>
    # clock = 1
    (~ClockedTreeKEM.applyUpdate)(
        1, Data.(["new1'''", "new1''", "new1'", "new1"]), UInt(1)
    ) |>
    # clock = 1
    (~ClockedTreeKEM.applyDelete)(
        0, 4, Data.(["new4''", "new4'", "new4"]), UInt(2)
    )
    # clock = 2

println(t1)

@test t1 == t2
Tree{Main.ClockedTreeKEM.Data}:
         ,- blank (2,4)
     ,- blank (2,4)
    |    `- pubKey: new1 (1,1)
 ,- blank (2,4)
|   |    ,- blank (1,3)
|    `- pubKey: new3' (1,3)
|        `- pubKey: new3 (1,3)
pubKey: new4'' (2,4)
 `- pubKey: new4' (2,4)

However, with deletes, we must ensure that the deleted user does not have access to any of the keys used by the tree. This can be performed by ensuring that the next user who sends a message re-deletes the user. This can be done by marking the user as a "dirty delete", which can be done outside of the TreeKEM structure.

TODO: how does this interact with state resolution? e.g. if Alice kicks Bob, and Bob kicks Carol, but state resolution decides that Bob's kick of Carol should be rolled back, then how does Carol get added back to the TreeKEM tree?

2.3 Add operations

Add operations are also mostly similar if the added user uses an existing node rather than increasing the size of the tree.

<<clocked_tree_kem>> =

"""
Add a new user with `pubKey` at `index`.  The `Add` is performed by `user_id`.
"""
function add(t::Tree, pubKey::TreeKEM.Key, index::Integer, user_id::Int)
    newtime = t.time + 1
    tree = t.tree

    if index == tree.size
        # adding a new node to the tree
        Tree(
            LBBTree.addNode(
                tree,
                # leaf node is the user's public key
                Data(pubKey, newtime, user_id),
                # all other nodes from root to leaf are blank
                (x, y) -> Data(newtime, user_id)
            ),
            newtime
        )
    else
        # replacing a blank node
        path = LBBTree.pathToLeaf(tree, index)
        len = length(path)

        # all the nodes other than the leaf node will be blank
        data = fill(Data(newtime, user_id), len)
        # the leaf node contains the user's public key
        data[len] = Data(pubKey, newtime, user_id)

        Tree(LBBTree.replacePath(tree, path, data), newtime)
    end
end

"""
Apply an `Add` operation from a different branch in the DAG.
"""
function applyAdd(
    t::Tree, pubKey::TreeKEM.Key, index::Integer, user_id::Int, time::UInt
)
    tree = t.tree

    if index == tree.size
        # adding a new node to the tree
        Tree(
            LBBTree.addNode(
                tree,
                # leaf node is the user's public key
                Data(pubKey, time, user_id),
                # all other nodes from root to leaf are blank
                (x, y) -> Data(time, user_id)
            ),
            max(t.time, time)
        )
    else
        path = LBBTree.pathToLeaf(tree, index)
        len = length(path)

        # all the nodes other than the leaf node will be blank
        data = fill(Data(time, user_id), len)
        # the leaf node contains the user's public key
        data[len] = Data(pubKey, time, user_id)

        Tree(LBBTree.replacePath(tree, path, _newer, data), max(t.time, time))
    end
end

If the added user is placed in a blank node, then an Add operation can be mixed with Update and Delete operations.

TODO: explain example

Example:

data = Data.(["0", nothing, "2", "3", "4"])
tree = ClockedTreeKEM.initTree(data) |>
    # clock = 0
    (~ClockedTreeKEM.update)(
        0, Data.(["0''''", "0''", "0'", "0"])
    ) |>
    # clock = 1
    (~ClockedTreeKEM.update)(
        2, Data.(["2'''", "2''", "2'", "2"])
    )
    # clock = 2

t1 = tree |>
    (~ClockedTreeKEM.add)(
        "1", 1, 0
    ) |>
    # clock = 3
    (~ClockedTreeKEM.update)(
        4, Data.(["new4''", "new4'", "new4"])
    ) |>
    # clock = 4
    (~ClockedTreeKEM.applyDelete)(
        2, 3, Data.(["new3'''", "new3''", "new3'", "new3"]), UInt(3)
    )
    # clock = 4

t2 = tree |>
    (~ClockedTreeKEM.delete)(
        2, 3, Data.(["new3'''", "new3''", "new3'", "new3"])
    ) |>
    # clock = 3
    (~ClockedTreeKEM.applyAdd)(
        "1", 1, 0, UInt(3)
    ) |>
    # clock = 3
    (~ClockedTreeKEM.applyUpdate)(
        4, Data.(["new4''", "new4'", "new4"]), UInt(4)
    )
    # clock = 4

println(t1)

@test t1 == t2
Tree{Main.ClockedTreeKEM.Data}:
         ,- pubKey: 0 (1,0)
     ,- blank (3,0)
    |    `- pubKey: 1 (3,0)
 ,- pubKey: new3'' (3,3)
|   |    ,- blank (3,3)
|    `- pubKey: new3' (3,3)
|        `- pubKey: new3 (3,3)
pubKey: new4'' (4,4)
 `- pubKey: new4' (4,4)

Two cases for Add do not work. The first case is where two or more different users are added to the same slot. In this case, we keep the user with the smallest user ID in the slot, and overwrite any updates from the other user(s) with the latest update from the selected user. The next user who sends a message to the group then re-adds the other user(s) to empty slots. If more than one user needs to be re-added, then they should be re-added in order of their user IDs. By re-adding them in a predictable order, if multiple users perform the re-add operation with the same users, then they will not conflict.

TODO: example

The second case where simply using a Lamport clock does not work is where an Add increases the size of the tree such that the path length changes for an update/delete from a different branch of the DAG. For example:

data = Data.(["0", "1", "2", "3", "4"])
tree = ClockedTreeKEM.initTree(data)
# clock = 0

t1 = ClockedTreeKEM.add(tree, "5", 5, 0)
# clock = 1

println("t1:")
println(t1)

t2 = ClockedTreeKEM.update(tree, 4, Data.(["new4'", "new4"]))
# clock = 1

println("t2:")
println(t2)
t1:
Tree{Main.ClockedTreeKEM.Data}:
         ,- pubKey: 0 (0,0)
     ,- blank (0,0)
    |    `- pubKey: 1 (0,0)
 ,- blank (0,0)
|   |    ,- pubKey: 2 (0,0)
|    `- blank (0,0)
|        `- pubKey: 3 (0,0)
blank (1,0)
|    ,- pubKey: 4 (0,0)
 `- blank (1,0)
     `- pubKey: 5 (1,0)
t2:
Tree{Main.ClockedTreeKEM.Data}:
         ,- pubKey: 0 (0,0)
     ,- blank (0,0)
    |    `- pubKey: 1 (0,0)
 ,- blank (0,0)
|   |    ,- pubKey: 2 (0,0)
|    `- blank (0,0)
|        `- pubKey: 3 (0,0)
pubKey: new4' (1,4)
 `- pubKey: new4 (1,4)

If we simply try to apply the update operation to t1, it will fail since we do not have enough keys; the path from the root to leaf 4 now has length 3 rather than length 2. One possible solution to this is to try to extend the array of values that we set on the path to leaf 4. We can try to extend it with blanks:

data = Data.(["0", "1", "2", "3", "4"])
tree = ClockedTreeKEM.initTree(data)
# clock = 0

t1 = ClockedTreeKEM.add(tree, "5", 5, 0)
# clock = 1

t1_1 = ClockedTreeKEM.applyUpdate(t1, 4, Data.([nothing, "new4'", "new4"]), UInt(1))

println(t1_1)
Tree{Main.ClockedTreeKEM.Data}:
         ,- pubKey: 0 (0,0)
     ,- blank (0,0)
    |    `- pubKey: 1 (0,0)
 ,- blank (0,0)
|   |    ,- pubKey: 2 (0,0)
|    `- blank (0,0)
|        `- pubKey: 3 (0,0)
blank (1,4)
|    ,- pubKey: new4 (1,4)
 `- pubKey: new4' (1,4)
     `- pubKey: 5 (1,0)

However, if we compare this with t2 above, we note that the public key new4' has moved from the root node to the right child of the root node. This is undesirable, since users 0 through 3 are already able to decrypt data encrypted with new4', which means that if, say user 0 is deleted by user 1, the new private key for the root node will be encrypted using new4' (since new4' is on the copath from the root to leaf 1), but user 0 will be able to decrypt it and obtain the new private key.

This can be addressed by remembering which nodes the keys were intended for, by using the node index. For example, new4 is intended for node 9 and new4' is intended for node 8. Then we filter out the keys that, when applied to the new tree, will not be applied to the intended nodes.

<<clocked_tree_kem>> =

function extendPath(t1::Tree, t2::Tree, index::Integer, keys::Vector{TreeKEM.Data})
    # compare the paths from the root to the given leaf in both trees
    path1 = LBBTree.nodeIndexPathToLeaf(t1.tree, index)
    path2 = LBBTree.nodeIndexPathToLeaf(t2.tree, index)
    if (length(path1) == length(path2))
        # if they're the same length, then we're fine
        return keys
    end

    # "keys" was created for t1, so we'll extend it by prepending with nothings
    # until it's the right length for t2, and extend the path in t1 in the same
    # way.  Then check if the node indices are the same in the two paths, and
    # only take the keys where the node indices are the same.
    filler = Iterators.repeated(undef, length(path2) - length(path1))
    map(
        (i2, i1, key) -> i2 == i1 ? key : missing,
        path2,
        Iterators.flatten((filler, path1)),
        Iterators.flatten((filler, keys))
    )
end

Example:

data = Data.(["0", "1", "2", "3", "4"])
tree = ClockedTreeKEM.initTree(data)
# clock = 0

t1 = ClockedTreeKEM.add(tree, "5", 5, 0)
# clock = 1

new_keys = ClockedTreeKEM.extendPath(tree, t1, 4, Data.(["new4'", "new4"]))

t1_1 = ClockedTreeKEM.applyUpdate(t1, 4, new_keys, UInt(1))

println(t1_1)

t2_1 = tree |>
  (~ClockedTreeKEM.update)(
    4, Data.(["new4'", "new4"])
  ) |>
  (~ClockedTreeKEM.applyAdd)(
    "5", 5, 0, UInt(1)
  )

println(t2_1)

@test t1_1 == t2_1
Tree{Main.ClockedTreeKEM.Data}:
         ,- pubKey: 0 (0,0)
     ,- blank (0,0)
    |    `- pubKey: 1 (0,0)
 ,- blank (0,0)
|   |    ,- pubKey: 2 (0,0)
|    `- blank (0,0)
|        `- pubKey: 3 (0,0)
blank (1,0)
|    ,- pubKey: new4 (1,4)
 `- blank (1,0)
     `- pubKey: 5 (1,0)

TODO: how does this interact with state resolution? e.g. if Alice kicks Bob, and Bob adds Carol, but state resolution decides that Bob's add of Carol should be rolled back, then how does Carol get removed from the TreeKEM tree?

TODO: does this work to resolve multiple conflicts?

TODO: turn this into an actual algorithm

3 Test file

include("LBBTree.jl")
include("TreeKEM.jl")
include("ClockedTreeKEM.jl")

using Test

Data = TreeKEM.Data
Key = TreeKEM.Key

"""
Helper for piping to a function that takes multiple arguments, allowing for
Elixir-like notation.  If f() takes multiple arguments, then `x |> (~f)(y1, y2, ...)`
will evaluate to `f(x, y1, y2, ...)`.

"""
~(f::Function) = (args...; kwargs...) -> ((x) -> f(x, args...; kwargs...))

<<tests>>

Author: Hubert Chathi

Created: 2020-05-13 Wed 18:53

Validate