BYOID & WASTE - A simple system for application defined, verifiable, decentralized identity
Overview
So youâve started building things using Trystero! Thatâs awesome, the clandestine world of WebRTC mesh networks is a magical place. You may have noticed a missing piece. How do clients know who is who? More importantly, how do they verify those identity claims? This is a proposal for a Bring Your Own Identity (BYOID) system, where peers self-assert their own identities.
Applications are able to define exactly what identities are (e.g. public key, username, a DID document, a wallet address + signature) and Trystero handles the distribution / verification of those identities via a proposed Web-Authenticated Signature & Trust Engine (W.A.S.T.E).
Comments, edits, criticisms are all welcome! This is intended to serve as the basis of discussion before implementation.
Design Goals
- No Lock in ð ââï¸ - Developers can define their own identity frameworks so that Trystero remains light-weight and extensible.
- Optional ð¤ - This is an optional feature and doesn't introduce breaking changes.
- Self-Sovereign ð - Identities are created, and self-attested by peers - not provided / authenticated by a central third party.
- Batteries Included! ð - default, production-ready identity module is provided for new projects / developers (see next steps)
Why?
Verifiable identities - the internet can be a scary place. If Bob and Alice are communicating, whatâs to stop Charlie from claiming to be Bob the next time Alice opens her decentralized-trystero-powered chat? In centralized systems we would rely on an identity provider (e.g. SSO w/ google, GitHub, etc.). This is BAD because those same providers may, in some dystopian future, refuse to authenticate a given user. Youâre only as decentralized as your most centralized point of failure! We need a mechanism for peers to share their identity with others in a way that is censorship resistant and verifiable.
Persistence across sessions - Without persistent identities each session with a decentralized WebRTC mesh forces peers to adopt a new identity. Any meaningful communication between peers would be unattributable to previous sessions. This creates a serious UX challenge for some of the most common p2p use cases including chat rooms, decentralized gaming or decentralized social networking.
Flexibility - there are MANY different use cases for a Trystero. Rather than trying to build a proverbial round hole and then various square pegs - a module based approach allows users to bring their own identity systems. Allowing for better integration with existing identities frameworks (e.g. an in-game chat room) and excising of the feature entirely when meaningful peer identities are less important (e.g. a torrent site).
Current State
Trystero self IDs (that which peers use to self-represent) are ephemeral and unverified. Developers using the library must build bespoke identity management to ensure peers can be certain of whom they are communicating with and that those connections can be resumed across sessions. Trystero selfIds are simply strings composed of 20 alphanumeric characters. They are randomly generated client side on init.
const charSet = '0123456789AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz'
export const genId = n =>
alloc(n, () => charSet[floor(random() * charSet.length)]).join('')
export const selfId = genId(20)
Bring Your Own Identity (BYOID)
Trystero should adopt a layered identity system. SelfIDs should continue to be ephemeral and used to establish peer connections. A second layer of identity will be introduced where peers can negotiate / self-attest persisted identities of arbitrary shape.
- Layer 1 - Self IDs, client generated unique IDs used in signaling
- Layer 2 (Optional) - BYO Modules - which define manage verifiable identities - referred to herein as byos (a play on bio shorthand for a short biographical profile of someone) with a public, shareable component and corresponding method of verification.
Trystero will optionally allows users to install byo module which conform to a published spec. If no module is installed, Trystero behaves exactly like it doesn't in the current state; layer 2 is optional! If a module IS installed, Trystero resolves / injects application defined identities (byos) into the exposed actions API.
Proposed Interface
Byo modules should be very simple. Their responsibilities are to:
- Maintain a current "self" PEER identity (distinct from Trystero selfID)
- Produce a "BindingPacket" which is a signed, verifiable statement binding the clients current byo to the current selfID
- Maintain a two-way map of known BYOs and their associated self IDs
The proposed interface is listed below:
/** Opaque module-defined identity. */
export type ByoIdentity = unknown;
/** Opaque binding payload exchanged during handshake. */
export type BindingPacket = unknown;
/**
* Extensible key-value store that keeps both directions of the peer mapping.
* Implementers decide how to persist (in-memory, IndexedDB, Local Storage etc.).
*/
export interface IdentityStore<I = ByoIdentity> {
// ---- Self identity ----
getSelf(): Promise<I | null>;
setSelf(value: I): Promise<void>;
// ---- Peer lookups ----
/** Lookup identity by its transport selfId. */
getBySelfId(selfId: string): Promise<I | null>;
/** Lookup current Trystero selfId for a known identity (reverse lookup). */
getByByoIdentity(identity: I): Promise<string | null>;
/** Save or update a verified mapping between selfId and identity. */
setMapping(selfId: string, byoIdentity: I): Promise<void>;
/** Remove mapping in either direction. */
deleteBySelfId(selfId: string): Promise<void>;
deleteByIdentity(identity: I): Promise<void>;
/** Optional utilities. */
clear?(): Promise<void>;
listMappings?(): AsyncIterable<{ selfId: string; identity: I }>;
}
export interface IdentityModule<I = Identity, P = BindingPacket> {
/** Initialize with a chosen store (defaults to in-memory). */
init(store?: IdentityStore<I>): Promise<void>;
/** Return the current self identity */
getSelf(): Promise<I>;
/**
* Produce a proof packet binding the current identity to this session's selfId.
* This packet is sent to peers during handshake.
*/
makeBinding(selfId: string): Promise<P>;
/**
* Verify an incoming peer's binding packet.
* If valid, persist mapping in store and return the verified identity.
*/
verifyAndRemember(selfId: string, packet: P): Promise<I | null>;
/** Resolve a transport selfId to a verified identity (if known). */
resolveBySelfId(selfId: string): Promise<I | null>;
/** Resolve a known identity back to its current selfId (if known). */
resolveByIdentity(identity: I): Promise<string | null>;
/** Optional helpers. */
reset?(): Promise<void>;
rotateSelf?(): Promise<I>;
}
Example Implementation
The simplest possible in-memory implementation might look like this:
export function createMemoryStore<I = unknown>(): IdentityStore<I> {
const bySelfId = new Map<string, I>();
const byIdentity = new Map<I, string>();
let self: I | null = null;
return {
// self
async getSelf() { return self; },
async setSelf(v: I) { self = v; },
// lookups
async getBySelfId(id: string) { return bySelfId.get(id) ?? null; },
async getByIdentity(identity: I) { return byIdentity.get(identity) ?? null; },
// set / update bidirectional mapping
async setMapping(id: string, identity: I) {
const prevId = byIdentity.get(identity);
if (prevId && prevId !== id) bySelfId.delete(prevId);
const prevIdentity = bySelfId.get(id);
if (prevIdentity && prevIdentity !== identity) byIdentity.delete(prevIdentity);
bySelfId.set(id, identity);
byIdentity.set(identity, id);
},
// deletes
async deleteBySelfId(id: string) {
const identity = bySelfId.get(id);
if (identity !== undefined) { bySelfId.delete(id); byIdentity.delete(identity); }
},
async deleteByIdentity(identity: I) {
const id = byIdentity.get(identity);
if (id !== undefined) { byIdentity.delete(identity); bySelfId.delete(id); }
},
// utils
async clear() { self = null; bySelfId.clear(); byIdentity.clear(); },
async *listMappings() { for (const [id, identity] of bySelfId) yield { selfId: id, identity }; }
};
}
Web-Authenticated Signature & Trust Engine (W.A.S.T.E)
"We Await Silent Trystero's Empire"
We're almost there - a system now exists for extensible, application defined identity definitions amongst previously unknowable peers. But how are these identities distributed so that they can be used in application logic? Distribution of byos and verification of their contents happens in the Trystero library, using methods exposed in the BYO modules.
Put simply: "If identities aren't known, resolve them dynamically when a peer joins or sends a message. The continue with application logic."
Here is a high-level flow example:
- Alice connects to Bob using Trystero selfIDs.
- Alice sends a BindingPacket (e.g. public key + name + signature of selfID).
- Bob verifies the binding packet using the application defined BYO module
- Bob persists the verified mapping { selfId -> identity }.
- Bob responds with a success message and their own BindingPacket.
- All future actions automatically resolve Trystero IDs to verified BYOs.
API / Usage
If a conforming identity module is installed, it is expected that the application uses those identities holistically and does NOT have access to / make reference of Trystero Self IDs. This means that - Sending actions specify identity module identities, not trystero self IDs. Trystero self IDs are resolved internally to the library and then used to route messages via the appropriate web etc connection. - Receiving actions are proxied and pass through the expected peer identity to the receiver NOT the Trystero self ID.
Example Usage (Typescript)
// No identity module
const room = joinTrysteroRoom(âexampleâ);
const [, receiveAction] = room.makeAction<number>(actionId);
receiveAction((payload, trysteroId => {
console.log(`Wow look at this cool number I was sent :${payload}. Thanks ${trysteroId}!`)
})
// Identity module installed
const identityModule = new ECDSALocalStorageModule();
const room = joinTrysteroRoom(âexampleâ, identityModule);
const [, receiveAction] = room.makeAction<number>(actionId);
receiveAction((payload, resolvedPeerIdentity) => {
console.log(`Wow look at this cool number I was sent :${payload}. Thanks ${resolvedPeerIdentity.name}!`)
})
Why are we overloading the joinRoom and makeAction methods? The Trystero API is wonderfully uncomplicated. Changing the behavior under the hood based on the installation of a module is in line with the spirit of implementation.
Next Steps - Standard Module
Trystero should offer a default module to perform the identity management function described in this proposal. This will serve as an OOB solution, and example implementation should other developers wish to roll their own.
In keeping with the theme of the library, this should be a minimal solution offering maximum security + scalability with the smallest possible foot print. This will be further defined in another RFC but will rely on Ethereum addresses as DID, persistance via local storage (using Zustand) and basic signature verification.
Future Work
This system lays the ground work for many future enhancements to the library. Extensible identities with a minimal interface for verification will allow for:
- Partial mesh networking - where peers connect to a subset of the graph and forward messages
- Gossip Identity Discovery - where peers broadcast the identity / selfID of OTHER peers
I think this is great. Nice work @rogersanick ! I think making this opt-in is critical as the core library should be as lean as possible.
I wonder if it would be worth generalizing the extensibility you're proposing into a more flexible plugin system for Trystero. For instance, I wonder if we could make the extension less about identity management and more about pre/post processing core parts of the Trystero lifecycle.
I don't feel super strongly about making that a prerequisite for this proposal, but I wanted to put it out there in case it appeals to anyone.
Thanks @jeremyckahn! That's a great idea, and something I'll consider in implementation. It does seem like making this an arbitrary piece of "middleware" is the right call.
This proposal is great and introduces some nice ideas (also love the naming!).
One issue Trystero has always had is the visibility of peer IDs in its API. This makes things simple but sort of implies that a peer ID corresponds to a user or identity rather than an ephemeral connection ID. It also could also be spoofed by a bad actor so it’s dangerous to imply it’s a reliable indicator of anything.
For a while I’ve been thinking about ways to make this system more robust for simple anonymous cases while also allowing the flexibility you’re suggesting for more complex pseudonymous use cases (e.g. actual persistent identities or “accounts”).
We should add BYOID middleware functionality as you’ve proposed and have a lightweight built-in reference implementation of it that makes the current ephemeral peer IDs a bit more robust.
Here’s an example scenario:
A peer with ephemeral id xyz gets disconnected for a minute (RTC connection dies), then xyz reconnects and re-announces itself for handshaking over the relay. The problem right now is it could be an imposter so we should at least enforce “ephemeral” identities as being consistent. This is fairly low-grade security since we don’t know who the first xyz is in the first place, but at least allows us to know the same xyz has returned in the same session. This may also help us for a future release where an app can choose to use multiple strategies in parallel.
In this first implementation a key pair would be generated at the beginning of every Trystero session (page load) and used to sign messages. When peers announce themselves to the relay, they could include a public key and signature. In this naive/basic approach, that becomes the “identity” and peers would check all subsequent relay messages from that peer ID to see if the signature matches. If there’s a discrepancy (wrong signature for a peer ID seen earlier in the session), it can be silently rejected. For advanced use cases where you have some sort of persistent identity system (Ethereum, PGP, etc), the app’s author can write a more complex middleware that checks with an external system to see that the peer is who it claims to be. The built-in reference implementation should be as lightweight as possible: no storage (beyond in-memory). If we can get this lightweight version working, I think that paves the way for more advanced custom system ID systems that can be optionally plugged in.
Possible obstacles
- Performance - I don’t anticipate this being a major source of latency, but worth measuring the effects throughout the building of this.
- Dependencies/build size - The basic built-in version shouldn’t add dependencies and should rely on the built-in crypto API.
- Relay tolerance - This is the trickiest issue, as some relays may not like larger sized messages, and in the case of torrent, some torrent servers are overly strict in what shape of data they accept (there may be ways around that). If these prove to be issues, there is an approach where keys aren’t exchanged over the relay and p2p connections are always allowed to be established and then the key/signature exchange happens p2p. If the challenge succeeds, only then is the peerJoin callback actually triggered.
These are my initial thoughts, I’d encourage everyone to chime in with more ideas. Thanks for getting things rolling on this, I think it will be a big step for the project.
TY @dmotz! 100% agreed, I think we can't use the peerID as "identity" unless it's verifiable. My initial idea was actually to start with what you've proposed as a backup - where p2p connections are always allowed, then some arbitrary key/signature exchange happens. I think that's the best way to facilitate extensible identities, where some arbitrary middleware using composable Trystero actions can handle p2p communication, persistence, etc.
I guess the question remains, is it an anti-pattern / undesirable to always allow p2p connections - then verify identity after the fact? There was a related issue about this posted here - https://github.com/dmotz/trystero/issues/141
I think it's reasonable to establish p2p connections behind the scenes automatically to facilitate some business logic, especially since we're limited in what we can assume the relay layers can do. I like the idea of a middleware function that can decide whether or not to make the connection visible/open from the Trystero POV (whether onPeerJoin gets fired). This logic could either be identity validation, or a room size cap test like in the linked issue. We could have some other event like onPeerJoinFailure to indicate when a middleware predicate fails.
I am (or will be) handrolling a system to do exactly this for a P2P browser gaming app/framework I'm working on. I'm going to watch this and would be happy to dogfood anything or contribute a plugin compatible with what I need. Two features I anticipate are:
- to authenticate a message came from a previously known peer
- to be able to encrypt a message (or parts of a message object) with a peer's public key so only they can decrypt it