go-threads
go-threads copied to clipboard
RFC: Static ACLs and Forking
Threads RFC: Static ACLs and Forking
Mutable access control lists (ACLs) are hard. Static ACLs are restrictive. Is there some middle ground?
One way to implement a static ACL is to encode the ACL's CID into the Thread's ID, enabling all participants to independently "find"/derive the ACL. To provide a "mutability" component to an immutable ACL, we would additional want to support Thread "forking", which would lead to a "chain" of ACLs, where each one points to the previous. This provides Thread provenance, the ACL chain provides an access control change log/history, and it also provides a very powerful "forking" framework for Threads V2.
In this RFC, we first outline an initial proposal for Thread ID structure that takes into account ACL information. This is presented conceptually, as well as via working code. Next, we outline some of the benefits of this approach in particular, and highlight its similarities with alternative implementations. We then finish with a discussion of additional requirements to make ACL encoded Threads and Thread forking possible.
Thread ID
We start with some imports and code preamble so readers get a feel for what is currently required in terms of existing code:
package main
import (
"context"
"crypto/rand"
"encoding/binary"
"log"
cid "github.com/ipfs/go-cid"
cbornode "github.com/ipfs/go-ipld-cbor"
crypto "github.com/libp2p/go-libp2p-crypto"
b58 "github.com/mr-tron/base58/base58"
mbase "github.com/multiformats/go-multibase"
mh "github.com/multiformats/go-multihash"
cbor "github.com/textileio/go-threads/cbor"
symmetric "github.com/textileio/go-threads/crypto/symmetric"
)
func main() {
ctx := context.Background()
Next, we create a "dummy" ACL document. We'd ideally follow the structure outlined in the Threads whitepaper:
{
"default": "no-access",
"peers": {
"<public-key>": ["write", "delete"],
"<public-key>": ["replicate"],
"<public-key>": ["read"],
...
}
}
Note that each peer entry is a public key. While the specific type of key may vary,
some type of public key infrastructure (PKI)-based solution would be useful here, as
it provides a means to verify and query information about a given peer. To represent
no ACL, implementations can simply use an 'empty' map[string]interface{}, which
is equivalent to an empty JSON document.
For this demo, we stick to a very simple full-access single-writer ACL:
// Create ACL document
body, err := cbornode.WrapObject(map[string]interface{}{
"default": "no-access",
"lucas": []string{"write", "delete", "read", "replicate"},
}, mh.SHA2_256, -1)
if err != nil {
log.Fatal(err)
return
}
This is essentially the only information required a piori to derive a new Thread ID. From here, the Thread initiator (creator) peer is responsible for deriving the appropriate keys. For the purposes of this demo, it is sufficient to assume that a given peer is simply defined by an Ed25519 keypair, which we create via, along with read and replicator keys:
// Peer identity keypair
sk, _, err := crypto.GenerateEd25519Key(rand.Reader)
if err != nil {
log.Fatal(err)
return
}
// Read Key
read1, err := symmetric.CreateKey()
if err != nil {
log.Fatal(err)
return
}
// Replicator Key
repl1, err := symmetric.CreateKey()
if err != nil {
log.Fatal(err)
return
}
Note that the read key isn't actually used in this demo, but would be when adding records to a thread log...
Next, the initiator peer would create an ACL "event". This uses the same mechanics as a normal Log or Thread Event, but is used/treated slightly differently, and is not added to any logs (the chain of ACL events is essentially the log). The replicator key is used, as opposed to the read key (which is what is used when adding records to a log/thread). This means anyone with access to the thread can view the ACL, but, without the replicator key, a peer cannot trace back previous ACLs, nor are they guaranteed to be able to find new ones without explicit access to the new replicator key (see fork below). However, if a peer does have access to the replicator key and the thread id, they can always determine the ACL for said thread, as we will see shortly.
// ACL "event"
event, err := cbor.CreateEvent(ctx, nil, body, repl1)
if err != nil {
log.Fatal(err)
return
}
The above ACL "event" is mostly just a nice way to structure the ACL IPLD DAG object. It does not have to be an Event per se, but we have the infrastructure in place to operate on Events already, so this is being "reused" to good effect here. One nice feature the CBOR Event does provide is the Event header with its Time component, which creates a useful "uniqueness" factor to our ACL documents.
The ACL "event" is then wrapped in a Record, in order to link with previous ACL records, as well as provide an additional "uniqueness" component via the Record's Sig (signature) key. Here, the private key of the initiator peer is used to sign a payload (the combined bytes of the ACL "event" CID and previous ACL CID), which is added to the record. This payload can be verified, but not reproduced by any peer other than the initiator peer, which provides a level of authentication for thread forks (e.g., peers can choose to only follow forks from peers they "trust").
In the event of a fork, prev would be set to the CID of the previous ACL record
(not a previous thread/log record). Conversely, a brand new Thread would simply
set prev := cid.Undef. Every new record will lead to a unique ACL CID, because of
- The
prevkey, - the
timecomponent in the Event's header, and - the
sigcomponent of the record.
As such, even if two peers create new threads at exactly the same time, with exactly
the same underlying ACL doc, they will still produce different record CIDs because
this sig component. Concurrent thread creation by a single also isn't isn't a
problem assuming sufficient precision in our time component. In practice, we can do
nanosecond precision in Go (though not easily in Javascript), but right now we're
only using Unix seconds. Milliseconds might be a good compromise to consider here?
prev := cid.Undef
// ACL record
rec, err := cbor.CreateRecord(ctx, nil, event, prev, sk, repl1)
if err != nil {
log.Fatal(err)
return
}
Now comes the actual ID creation, which is almost exactly the same process as with the existing Thread ID implementation. The CID-encoded bytes of the ACL record are used as the Random Component of the ID, along with two additional 8 byte (varint) components to represent the version and variant of the Thread ID.
Note that, if the ACL CID is always included (even if an explicit ACL doc is not used) it might be possible to drop the "variant" component here.
// CID-encoded bytes
bytes := rec.Cid().Bytes()
numlen := len(bytes)
// Two 8 bytes (max) numbers plus num
buf := make([]byte, 2*binary.MaxVarintLen64+numlen)
n := binary.PutUvarint(buf, 0x02) // Version, here set to "2"
n += binary.PutUvarint(buf[n:], 0x70) // Variant, here set to "access controlled"
cn := copy(buf[n:], bytes)
if cn != numlen {
log.Fatal("copy length is inconsistent")
}
// Truncate and encode as base32
id := buf[:n+numlen]
mbstr, err := mbase.Encode(mbase.Base32, id)
if err != nil {
log.Fatal(err)
return
}
log.Printf("Thread id:\t%s", mbstr)
log.Println("Send keys via alternative channel...")
log.Printf("Read key:\t%s", b58.Encode(read1.Bytes()))
log.Printf("Replicator key:\t%s", b58.Encode(repl1.Bytes()))
Now that we have a Thread ID, the mechanics of Thread coordination and log
creation/management is the same as it is with the existing Thread ID framework.
Since the required LogKey (see used pk above) is not longer directly tied
to a peer host per se, it is possible for a given peer to have multiple logs
per Thread, and it is actually possible to re-use the same Log for multiple
Threads. This will prove useful/important in a moment.
Thread Forking
The above Thread ID formulation doesn't change much from how Threads/Logs work now. However, things get interesting when Thread forking comes into play. The proposed Thread ID framework above makes this process relatively simple. The following works through the simplest pieces of this puzzle first. Say a peer wishes to fork the Thread, either to modify the ACL document, or perhaps to perform a key rotation. They now become the Thread initiator, and perform the above steps as outlined:
// New Replicator Key
repl2, err := symmetric.CreateKey()
if err != nil {
log.Fatal(err)
return
}
// New Read Key
read2, err := symmetric.CreateKey()
if err != nil {
log.Fatal(err)
return
}
For demo purposes, the same ACL body will be used again, but a unique ID is still created thanks to the reasons outlined above, and the fact that this, fork was initiated by a different peer. This provides a nice simple way to do key rotation without changing any of the underlying ACL. In theory key rotation could actually just be done without this added step (a peer would just ask everyone in the Thread to change keys), but this keeps things explicit: replicator + read keys are "immutable" for a given Thread. It also makes key management across forks much simpler, peers can assume only one set of keys per Thead, leaving the LogStore implementation unchanged.
Side note: Should the hash of (or some bytes from) the replicator/read key be taken into account when creating the Thread ID to make this immutability explicit? My vote is probably that its not needed... the immutability of keys here is more of a "contract" than an explicit protocol requirement (i.e., the protocol still works if keys are swapped, but it isn't as nice)
// New ACL "event"
event, err = cbor.CreateEvent(ctx, nil, body, repl2)
if err != nil {
log.Fatal(err)
return
}
// Previous ACL record
prev = rec.Cid()
// New ACL record
rec, err = cbor.CreateRecord(ctx, nil, event, prev, sk, repl2)
if err != nil {
log.Fatal(err)
return
}
Finally, the same ID generation process is used to derive the new Thread ID:
// Use the CID-encoded bytes
bytes = rec.Cid().Bytes()
numlen = len(bytes)
// two 8 bytes (max) numbers plus num
buf = make([]byte, 2*binary.MaxVarintLen64+numlen)
n = binary.PutUvarint(buf, 0x02) // Version 2
n += binary.PutUvarint(buf[n:], 0x70) // Variant access controlled
cn = copy(buf[n:], bytes)
if cn != numlen {
log.Fatal("copy length is inconsistent")
}
// Truncate and encode
id = buf[:n+numlen]
mbstr, err = mbase.Encode(mbase.Base32, id)
if err != nil {
log.Fatal(err)
return
}
log.Printf("New thread id:\t%s", mbstr)
log.Println("Send keys via alternative channel...")
log.Printf("New read key:\t%s", b58.Encode(read2.Bytes()))
log.Printf("New replicator key:\t%s", b58.Encode(repl2.Bytes()))
}
Note that new Thread/Log records can still reference (via prev) Records from
the previous Thread(s). However, only those peers with explicit access will
be able to obtain these Records (see next section on forward/backward secrecy).
A peer should be able to request these records if they do not already have
them, but the past Thread's ACL will dictate whether a Thread peer will grant
said request or not.
Multiple Parents
While not required for this proposal to work, it might be useful to include the
ability to specify multiple parent CIDs via the prev key in the ACL (or other)
record. This would provide the mechanisms required for the following new features:
- Thread merging
- By specifying
prev := []cid.CID{<parent-1>, <parent-2>}, a peer can indicate that two past Thread ACLs (and by association, Thread IDs) were used to derive some new Thread. This does not affect the actual Thread event records, which could continue to reference single prev CIDs are per normal.
- By specifying
- Thread snapshots (see [Features])
- By specifying
prev := []cid.CID{<parent-1>, cid.Undef}, in the first event Record (not the ACL record), a peer can indicate that some new Thread is the result of a fork, but that this first entry is a standalone entry. Providing both a real prev link and an Undef think, other peers can still trace back to the previous thread (i.e., follow the valid CID path), but could alternatively choose to stop at the snapshot (i.e., follow only the Undef path).
- By specifying
- Back-tracing optimizations
- By having
len(prev) >= 2, a peer can link a given Thread/Log event record to records further in the past, facilitating things like the Bamboo protocol. Note that this application is less flushed out, so take this one with a grain of salt... It is possible that thelipmaalinkfrom Bamboo actually needs to be a separate entry in a Log Record...?
- By having
Features
The above Thread forking process is simple, requires minimal new concepts, does not introduce any new mechanics to Logs, and provides many useful features beyond ACL "mutability". The following are some high-level features that stand out:
- Provides backward secrecy
- Just because a peer has access to the new ACL record, does not mean have access to the previous one. They will know that the ACL derives from some previous state, but will be unable to "follow" the trail back unless the obtain the previous replicator key. Even if they obtain the past replicator key, this will only give them access to the ACL itself, not any private information beyond the ACL entries.
- Provides forward Secrecy
- Unless a peer is explicitly mentioned in the new ACL, they will not be informed of the updated keys/ACL structure (see next section). It is up to the existing Thread peers to share the updated key information. Also, since the keys are randomly generated, it is not possible to derive the new keys from past keys.
- Provides explicit access control
- Because the ACL is encoded in the Thread ID, it provides explicit rules that apply to that Thread for all time. This is essentially a form of consensus: if a peer is participating in a given Thread, the ACL for said Thread is fixed, and known by all participating. This is counter to other dynamic ACL implementations, where the point in time at which an ACL mutation should be applied can be ambiguous).
- Provides a mechanism for key rotation
- This one speaks for itself. It is a good idea to perform key rotations on a regular basis in order to avoid issues of forward or backward secrecy.
- Provides a mechanism for snapshots
- While not explicitly required for this protocol to work, the first new
record of a forked Thread could be a snapshot of the previous Thread. This
would then either set
prev := cid.Undef, or alternatively (see previous section) twopreventries could be used, one linking to the actual previous CID, and one set tocid.Undefto indicate this is a standalone entry.
- While not explicitly required for this protocol to work, the first new
record of a forked Thread could be a snapshot of the previous Thread. This
would then either set
Requirements
Mechanism for forking a thread
This should perform the above operations for creating the ACL Event Record, and associated ID, and then automatically contact Thread peers listed in the new ACL with the new Thread information. This would include the new keys, and optionally the heads etc. The mechanics for this might look something like [Sharing].
Mechanisms back-tracing
To make it as easy as possible to actually consume forked Threads, a way to trace back through Thread/Log records is needed. This functionality is already part of Threads. The only additional functionality required is the ability to "jump" back to a past Thread given a Record linking to a Record in a previous Thread. There are three scenarios here worth working through:
- The peer was already a participant in the previous Thread, and therefore has all the required keys etc stored locally. Here, once the peer reaches a CID from a past Thread, they'll either a) already have it locally, all good or b) not have it, in which case they'll fetch and try to decrypt, which will fail using the active replicator key. They can then look at the current ACL, see that it references a past ACL, compute the past Thread ID from this information, and then pull the required keys from their local Log Store. This is the easy one.
- The peer wasn't involved in the last Thread, and therefore doesn't have any of the required keys. If they don't have access via the past ACL, we're pretty much done here. They can request the information, but if they aren't listed in the ACL, none of the Thread participants should send them anything.
- Alternatively, if a peer was just not up-to-date with the previous Thread (i.e., they have access but didn't actually obtain the Thread Records), they can perform the same check as in case 1, then request the keys etc from a Thread peer, who should respond with the Thread keys etc. This requires at least one peer to be online at a given time, but this seems reasonable given the ideas around Thread replicators.
Note that, a request would likely look something like the current head fetching mechanisms that are already part of Threads, so only minimal additional tooling would be required here. Some way to invite or share key information would need to be included (again, see [Sharing]). When a request for Thread/Log information did go out, it would require the responding peers to respond appropriately (i.e., if the ACL says a peer only has read replicator access, then only send the replicator key).
Mechanisms for merging
The Thread forking is implemented, then some tooling to managing these forks would likely be required. This might include tooling to merge forks (see above) or to "choose" which new fork to follow in the event of multiple concurrent forks. In some instances, these "decisions" could be pushed up to the developer, or even the user. But in other cases, some automatic functionality might be useful. For example methods to perform a merge (i.e., create a new Thread and specify the multiple prev CIDs etc) and notify Thread participants, or the ability to cull or otherwise trim Thread forks could be useful. It is likely that these will "fall out" of usage, and it is probably not a good idea to build these tools until they are needed.
Sharing
Thread forking makes key management more difficult. We now have to have automated ways to share keys with Thread participants so that the act of forking a Thread is not painful for users. Forking a Thread should feel like a mutation, rather than a destructive operation. Luckily, we have the ACL full of public keys, and several "standard" practices for sharing keys among a set of collaborating peers. Once a Thread fork has been performed, it is up to the initiator peer to inform others. The easiest mode is when we can assume all (both) parties are online. In that case, direct 1-to-1, p2p messaging using a standard Diffie–Hellman key exchange can be used to send keys. Similarly, if multiple parties are online, a group-based DH key exchange can be used. This is pretty unlikely tho, so we need a way to handle when a peer is offline. One possible solution is to consider Thread peers as 'trusted' 3rd parties. Then, a replicator peer could be used to "store" the required keys. Here, each peer's public key could be used to encrypt the key information for each Thread peer, and this information could be stored with a Thread replicator (or multiple replicators), or could be added to IPFS and "pinned" by a Thread peer/replicator, so that when a peer comes back online, they would request a Thead head as per usual, and then be informed of the fork, with a pointer to their encrypted "package" of keys. Note that this requires that peers store a "forks" key locally for Threads they are participating in. Not a big ask!
This section is purposefully over-simplified in the interest of keeping things short and moving along. It requires much additional discussion!
References/Notes
A dynamic access controller, which does not have forward or backward secrecy capabilities. Additionally, this type of access controller requires full network access. It does not work in an offline first or semi-connected environment. It is however, much more flexible due to its dynamic nature.
Alternatives
While the above proposed role-based access control provides an intuitive and reasonably standard access control framework, it is not the only option. In a decentralized system such as Threads, something like attribute based access control may actually be preferable. This could provide a more 'dynamic' access control framework, while still being 'immutable' for a given Thread. For example, rather than explicit roles for specific peers encoded in the ACL for a Thread, access attributes could be specified (all peers with this verifiable credential are allowed to write). Then, the Thread initiator is responsible for issuing credentials. These can be done entirely offline, peers can have their credentials change 'off chain' so to speak, and while having the Thread initiator be the credential issuing may seem limiting at first, it is actually pretty much no different from the above proposed solution). More to be evaluated here, but something to consider for the future.
Very nicely written.
Leaving comments here as they happen while reading. I think doing it this way might help uncover if something could be clarified earlier (if it's done later). Also, took some freedom to ask random questions that appeared while thinking a bit.
The replicator key is used as opposed to the read key (which is what is used when adding records to a log/thread).
Rephrasing: the replicator key of the Thread is used to encrypt the ACL event, right? Reading the WP, still mentions content key... needs updating?
This means anyone with access to the thread can view the ACL, but, without the replicator key, a peer cannot trace back previous ACLs, nor are they guaranteed to be able to find new ones without explicit access to the new replicator key (see fork below). Regarding tracing back previous ACLs: At this point I've the feeling that ACL have a CID to older ACLs (since talked about earlier about ACL chains), but I think may not be that clear up to this point?
Idea: maybe highlighting again that newer ACLs have new keys a new Threads gots created. The real reason of the new keys is that a new Thread is created (not really because a new ACL needs them). I may be anxious about holding doubts, but continue reading...
The above ACL "event" is mostly just a nice way to structure the ACL IPLD DAG object.
May worth using some concepts like detached-event to refer to the ACL event?. I share the feeling (expressed by using " "). It's an Event but isn't attached to a Log. Just a thought.
The ACL "event" is then wrapped in a Record, in order to link with previous ACL records, as well as provide an additional "uniqueness" component via the Record's Sig (signature) key. Here, the private key of the initiator peer is used to sign a payload (the combined bytes of the ACL "event" CID and previous ACL CID), which is added to the record. This payload can be verified, but not reproduced by any peer other than the initiator peer, which provides a level of authentication for thread forks (e.g., peers can choose to only follow forks from peers they "trust").
Maybe clarifying here that when the Event is wrapped in a Record, the Event payload isn't encrypted with the read key as mentioned before; since only the Replicator key is used to encrypt the Record IPLD node. Kind of connection this paragraph with previous things, and also to insist on this exception compared to how usual Record and Event are built.
So, up to this point, we have the ACL, the ACL Event and the ACL Record. Not sure this might help clarify some mental model the reader might be building.
In the event of a fork,
prevwould be set to the CID of the previous ACL record (not a previous thread/log record). Conversely, a brand new Thread would simply setprev := cid.Undef. Every new record will lead to a unique ACL CID, because of1. The `prev` key, 2. the `time` component in the Event's header, and 3. the `sig` component of the record.
Ok, I see here that the time was for adding more randomness to the CID. :+1:
.... Milliseconds might be a good compromise to consider here?
Idea: We could allow setting an arbitrary nonce. It sounds more like a possible replacement to relying on time since the client could do a randomized sleep to indirectly play with time.
Note that, if the ACL CID is always included (even if an explicit ACL doc is not used) it might be possible to drop the "variant" component here.
I'm not sure I followed. The variant isn't always needed to give semantics to the Random component? By always included you mean that if we decide that all Threads always have an ACL (with maybe a default setting of allow-all)? If that's the case, agree.
I'll continue with next sections in other posts.
About Thread Forking:
Side note: Should the hash of (or some bytes from) the replicator/read key be taken into account when creating the Thread ID to make this immutability explicit? My vote is probably that its not needed... the immutability of keys here is more of a "contract" than an explicit protocol requirement (i.e., the protocol still works if keys are swapped, but it isn't as nice)
SGTM
Note that new Thread/Log records can still reference (via
prev) Records from the previous Thread(s). However, only those peers with explicit access will be able to obtain these Records (see next section on forward/backward secrecy). A peer should be able to request these records if they do not already have them, but the past Thread's ACL will dictate whether a Thread peer will grant said request or not.
OK, so the client is the one keeping track at which height of each newly created Thread logs the fork happened. I'm not sure something might be missing. Say I get the third log record from a forked Thread. If I keep walking backwards, I'll eventually start walking in the old-thread. It seems that none of the data in the walked records would indicate when an ACL happened, so that thing isn't self-contained in the records.
Maybe I'm missing something, but feels might be easier to reason about which ACL applies to a particular Log Record if that information was somehow self-contained. Also, if I ask a peer give me all the Records from this Log (which is forked), he would understand that must stop at the first forked log from the original thread, or conceptually go all the way from the original record-genesis of the original Thread? (hope not to confuse much)
* Thread snapshots (see [Features]) * By specifying `prev := []cid.CID{<parent-1>, cid.Undef}`, in the first event Record (_not the ACL record_), a peer can indicate that some new Thread is the result of a fork, but that this first entry is a standalone entry. Providing _both_ a real prev link and an Undef think, other peers can still trace back to the previous thread (i.e., follow the valid CID path), but could alternatively choose to stop at the snapshot (i.e., follow only the Undef path).
So I guess this answers my previous comment about how to know while walking backward when we're jumping between different Threads.
At the walking path, we are aware of being on a particular Thread ID. The current ACL can be deduced from the random component of the Thread ID. Whenever a cid.Undef is found in a prev slice, we're at a Thread ID boundary. Going to the parent would mean that somehow (how?) we should resolve what Thread ID (and thus ACL) this new record is. Some comments on this?
Features
* Provides backward secrecy ... * Provides forward Secrecy ...
Seems to me that the titles should be flipped. The first description sounds to forward secrecy to me and viceversa?.
* While not explicitly required for this protocol to work, the first new record of a forked Thread could be a snapshot of the previous Thread. This would then either set `prev := cid.Undef`, or alternatively (see previous section) two `prev` entries could be used, one linking to the actual previous CID, and one set to `cid.Undef` to indicate this is a standalone entry.
If the first record of a forked Thread has a snapshot of history, would make the other problem of how to deduce in which ACL we're in disappearing. But don't know if this is optional or not... but makes a lot of sense if there're new readers (since they can't walk past the Thread boundary in the records (using prev)).
Log reuse
This wasn't mentioned in the above description, but is relevant here. There is nothing in the above formulation or the existing Threads setup that would preclude peers from "reusing" (or continuing to use) an existing Log during a Thread fork. Since a log is just a linked list of Records, and the keys at the top-level are not tied to a Log, but rather the Thread, this is as easy as switching the keys used. This point should probably be made clear in any documentation/papers that come from this RFC.
Repurposing Thread Variant
In the above discussion, it was suggested that the variant varint could be removed in favour of assuming all Threads have an ACL (even a default one). However, after further discussion, it seems appropriate to keep the variant component of the Thread ID, and re-use it to encode the variant of the ACL. This could leave room for alternative AC descriptions outside of the default/core static ACL. For instance, a Smart Contract AC might be variant=1 (whereas the default static doc is variant=0), in which the Smart Contract (say ETH) address is used rather than the ACL document CID when encoding the AC Event Record. Most of the mechanics here would stay the same, and in the beginning, the static ACL would be the only/initial implementation. This leaves room for a range of AC types, including centralized variants such as Windows Azure Access Control Service (ACS), etc.
Easier Tracking
Based on some comments above and a discussion IRL, it might be useful to encode the ACL information (or just the Thread ID) into the first new Record of a forked Thread. For example, if we assume each new Thread fork has a "Snapshot" Record as its first record, it might be worth always including the Thread ID or AC information in this snapshot record to make it easier for peers tracing back through records to know which AC information to use, rather than interring this. Additionally, it may be worth always having a snapshot at the first Record, so that peers who do not have access to previous Threads can actually operate on and contribute to, and Thread already in progress (the alternative is that they have to request the 'head' state from a peer).
Idea: We could allow setting an arbitrary nonce. It sounds more like a possible replacement to relying on time since the client could do a randomized sleep to indirectly play with time.
Yes totally reasonable... in fact, we don't really need this time component at all to get our uniqueness. We only need the 'nonce' to be unique given Peer LogKey and ACL hash/CID/address, so it could almost be anything. The original intention of using time was just to reuse our existing Event Header structure... but actually, your comment made me think that perhaps we could put this to better use. We could simply have a 'topic' field, which could be []byte, and could be a random nonce, or a topic/descriptive string, or even time if such a thing was needed? Keeping it more general is nice, and having it be a unique string would allow devs/users to specify the 'usage' of a given thread as a human-readable value.