In this system, authentication and authorization are based on cryptographic keys organized in a hierarchy:
A user represents a person who is a member of a team. Each user has:
userId: A unique identifier (CUID)userName: A human-readable name or email (must be unique within the team)keys: A keyset containing cryptographic keys
Each user can have multiple devices (laptop, phone, tablet, etc.). Each device has:
deviceId: A unique identifier (CUID)deviceName: A human-readable name (e.g. "Alice's laptop")userId: The ID of the user who owns this devicekeys: A separate keyset for this devicedeviceInfo: Optional metadata about the devicecreated: Timestamp when the device was added
Devices authenticate independently of their users. When a device connects to the team, it proves its identity using its own keys, not the user's keys.
A keyset is a collection of cryptographic keys used for different purposes. Each keyset contains:
secretKey: A symmetric encryption key (32 bytes) for encrypting/decrypting contentencryption: An asymmetric keypair for encrypting content for specific recipientspublicKey: Can be shared publiclysecretKey: Must be kept private
signature: An asymmetric keypair for signing and verifying contentpublicKey: Can be shared publiclysecretKey: Must be kept private
All keys are generated from a single 32-byte random seed using a key derivation process based on Keybase's Per-User Keys.
Each keyset also includes metadata:
type: The scope of the keys (e.g.USER,DEVICE,TEAM,ROLE,SERVER,EPHEMERAL)name: An identifier within that scope (e.g. user ID, device ID, role name)generation: A version number, incremented when keys are rotated
Different entities in the system have their own keysets:
- Team keys: Shared among all team members (stored in lockboxes)
- Role keys: Shared among members of a specific role (e.g. "admin", "manager")
- User keys: Belong to a specific user, shared across their devices (stored in lockboxes)
- Device keys: Unique to each device, never leave the device
- Server keys: Used by sync servers, act as both user and device keys
- Ephemeral keys: Temporary, single-use keys (e.g. for lockbox encryption)
- Device keys are the only keys that are stored in plaintext on the device
- All other keys (user, team, role) are encrypted in lockboxes and distributed via the team graph
- When a device joins a team, it uses its device keys to unlock lockboxes containing the user's keys
- When a user joins a team, they use their user keys to unlock lockboxes containing team and role keys
When a member, device, or role is removed from a team, any keys they had access to are considered compromised and must be rotated:
- Generate new keys for the compromised scope
- Identify all keys visible from that scope (the "key graph")
- Generate new keys for all visible scopes
- Create new lockboxes for all affected recipients
- Post the new lockboxes to the team graph
This ensures that removed members or devices can no longer decrypt new content.
TODO
TODO
TODO
TODO
TODO
A lockbox allows you to encrypt content once for multiple readers.
For example, you can encrypt a dataset once for an entire team using a single secret key T, and
distribute one lockbox per team member containing the secret key. In each lockbox, the secret key is encrypted
asymmetrically using an ephemeral private key and the member's public key.
To encrypt content using lockboxes, you only need to know the recipients' public keys. You don't need a trusted side channel to communicate with the recipients, and you never have to transmit the secret in cleartext. The lockboxes are clearly labeled and can be attached to the encrypted content for storage, publication, or transmission.
A lockbox is just data: An encrypted payload, plus some metadata.
For example:
const lockbox = {
// need this to open the lockbox
encryptionKey: {
type: 'EPHEMERAL',
publicKey: 'uwphz8qQaqNbfDx9JhvgOWt9hOgfNR3eZ0sgS1eFUP6QX25Q',
},
// information to identify the key that can open this lockbox
recipient: {
type: 'USER',
name: 'alice',
publicKey: 'x9nX0sBPlbUugyai9BR0A5vuZgMCekWodDpbtty9CrK7u8al',
},
// information about the contents of the lockbox
contents: {
type: 'ROLE',
name: 'admin',
publicKey: 'BmY3ZojiKMQavrPaGc3dp7N1E0nlw6ZtBvqAN4rOIXcWn9ej',
},
// the encrypted keyset
encryptedPayload: 'BxAOzkrxpu2vwL+j98X9VDkcKqDoDQUNM2dJ9dXDsr...2wKeaT0T5wi0JVGh2lbW2VG5==',
}The lockbox contents are encrypted using a single-use, randomly-generated key. The public half of this ephemeral key is posted publicly on the lockbox; the secret half is used to encrypt the lockbox contents, and is then discarded.
We use lockboxes to:
- share team keys with team members
- share role keys with members in that role
- share all role keys with the admin role
- share user keys with the user's devices
Keys provide access to other keys, via lockboxes; so we have an acyclic directed graph where keys are nodes and lockboxes are edges.
To make a lockbox, pass in two keysets:
contents, the secret keys to be encrypted in the lockbox. This has to be aKeysetWithSecrets.recipientKeys, the public keys used to open the lockbox. At minimum, this needs to include the recipient's public encryption key (plus metadata for scope and generation).
This makes a lockbox for Alice containing the admin keys.
import * as lockbox from 'lockbox'
const adminLockboxForAlice = lockbox.create(adminKeys, alice.keys)This illustrates the minimum information needed to create a lockbox:
const adminLockboxForAlice = lockbox.create(
{
type: 'ROLE',
name: 'admin',
generation: 0,
signature: {
publicKey: 'B3B8xMFdLDLbd72tXLlgxyvsAJravbATqMtTtje1PQdikGjN=',
privateKey: 'QI4vBzCKvn6SBvyR7PBKFuuKiSGk3naX0oetx3XUtPK...AX1W0LCdWwMlHhNO3T5jVwnkz=',
},
encryption: {
publicKey: 'asuM3NexDiDs2P2OKQOu3tdXWz2zV6LoaxPfZPLIb8gFIIU0=',
privateKey: 'e1tcEjpGfKuJz8JObrVJGqq9zrXpNwyHafYEd298p3MyYThJ=',
},
},
{
type: 'USER',
name: 'alice',
generation: 0,
publicKey: 'JG81tVDDfp3BqXedrtiRiWtvqQKt2175nAceYIPjjMR7z2Y1',
}
)To open a lockbox:
const adminKeys = open(adminLockboxForAlice, alice.keys)"Rotating" a lockbox means replacing the keys it contains with new ones.
When a member leaves a team or a role, or a device is lost, we say the corresponding keyset is 'compromised' and we need to replace it -- along with any keys that it provided access to.
For example, if the admin keys are compromised, we'll need to come up with a new set of keys; then we'll need to find every lockbox that contained the old keys, and replace them with the new ones.
const newAdminKeys = createKeyset({ type: ROLE, name: ADMIN })
const newAdminLockboxForAlice = lockbox.rotate(adminLockboxForAlice, newAdminKeys)We'll also need to so the same for any keys in lockboxes that the those keys opened.
This logic is implemented in the private rotateKeys method in the Team class.
The Team class is the primary API for managing team membership, roles, devices, and encrypted content. It wraps a CRDX store containing the team's signature chain (the "team graph").
Creating a new team:
import { createTeam } from '@localfirst/auth'
const team = createTeam('My Team', context)The founding member is automatically added as an admin.
Loading an existing team:
const team = new Team({
source: savedGraph, // Uint8Array or TeamGraph
teamKeyring, // Keyring containing team keys
context, // LocalContext (user, device)
})The Team constructor requires a context that identifies the local user:
-
MemberContext: For a team memberuser: UserWithSecretsdevice: DeviceWithSecretsteam: Team instance
-
ServerContext: For a sync serverserver: ServerWithSecretsteam: Team instance
team.members(): Returns all membersteam.members(userId): Returns a specific memberteam.has(userId): Check if a member existsteam.remove(userId): Remove a member (triggers key rotation)team.memberWasRemoved(userId): Check if member was removed
team.roles(): Returns all rolesteam.roles(roleName): Returns a specific roleteam.hasRole(roleName): Check if a role existsteam.addRole(roleName): Create a new roleteam.removeRole(roleName): Remove a roleteam.addMemberRole(userId, roleName): Assign a role to a memberteam.removeMemberRole(userId, roleName): Remove a role from a member (triggers key rotation)team.memberHasRole(userId, roleName): Check if a member has a roleteam.memberIsAdmin(userId): Check if a member is an adminteam.admins(): Returns all admin membersteam.membersInRole(roleName): Returns all members with a specific role
team.device(deviceId): Get a device by IDteam.hasDevice(deviceId): Check if a device existsteam.removeDevice(deviceId): Remove a device (triggers key rotation)team.deviceWasRemoved(deviceId): Check if device was removedteam.memberByDeviceId(deviceId): Find the member who owns a device
Inviting a new member:
const { id, seed } = team.inviteMember({
expiration: Date.now() + 86400000, // optional: 24 hours
maxUses: 1, // optional
})
// Share `seed` with the invitee via a trusted channelInviting a new device for an existing member:
const { id, seed } = team.inviteDevice({
expiration: Date.now() + 1800000, // optional: 30 minutes (default)
})
// Share `seed` with the device (e.g. via QR code)Managing invitations:
team.revokeInvitation(id): Revoke an invitationteam.hasInvitation(id): Check if invitation existsteam.getInvitation(id): Get invitation detailsteam.validateInvitation(proof): Validate a proof of invitation
Admitting invited members/devices:
team.admitMember(proof, memberKeys, userName): Admit a new member presenting proofteam.admitDevice(proof, device): Admit a new device presenting proof
Joining a team (as the invitee):
team.join(teamKeyring, userKeyring)team.addServer(server): Add a sync server to the teamteam.removeServer(host): Remove a serverteam.servers(): Get all serversteam.servers(host): Get a specific serverteam.hasServer(host): Check if server existsteam.serverWasRemoved(host): Check if server was removed
Symmetric encryption for the team or a role:
// Encrypt for the whole team
const encrypted = team.encrypt(payload)
// Encrypt for a specific role
const encrypted = team.encrypt(payload, 'managers')
// Decrypt
const decrypted = team.decrypt(encrypted)Sign and verify messages:
// Sign a message
const signed = team.sign(payload)
// Verify a signed message
const isValid = team.verify(signed)team.keys(scope): Get secret keys for a scope (if available to this device)team.teamKeys(): Get the current team keysteam.teamKeyring(): Get all generations of team keysteam.roleKeys(roleName): Get keys for a specific roleteam.adminKeys(): Get admin role keysteam.userKeyring(): Get all generations of the current user's keysteam.changeKeys(newKeys): Rotate the current user's keys
team.graph: Access the underlying CRDX graphteam.id: The team's unique ID (hash of root)team.teamName: The team's human-readable nameteam.save(): Serialize the team graph toUint8Arrayteam.merge(theirGraph): Merge another graph (e.g. from a peer)team.dispatch(action): Dispatch an action to the graph
The Team class extends EventEmitter and emits:
updated: Fired when the team graph is modified (locally or via merge)team.on('updated', ({ head }) => { // Save the updated graph saveGraph(team.save()) })
The Team class wraps a CRDX store, which maintains:
- TeamGraph: A hash graph (DAG) of signed, encrypted links representing actions
- TeamState: The current state derived by running the graph through a reducer
- Reducer: A pure function that processes each link to compute state
- Resolver: A function that handles concurrent conflicting actions (e.g. two admins concurrently removing each other)
Each action (add member, remove device, etc.) is recorded as a signed link in the graph. The reducer processes these links to compute the current team state, including:
- Members and their roles
- Devices
- Lockboxes containing encrypted keys
- Invitations
- Servers
- Messages
The resolver implements domain-specific conflict resolution rules. For example, if two admins concurrently try to remove each other, the resolver marks both removals as invalid to prevent the team from losing all admins.
The Connection class implements a peer-to-peer authentication and synchronization protocol. It uses an XState state machine to manage the connection lifecycle between two devices.
A Connection allows two devices to:
- Authenticate each other using cryptographic proofs
- Synchronize their team graphs
- Exchange encrypted messages
- Detect when peers are removed from the team and disconnect
const connection = new Connection({
// Function to send messages to the peer (you provide this)
sendMessage: (message: Uint8Array) => {
// Send via WebSocket, WebRTC, etc.
},
// Your context (determines how you authenticate)
context: {
user, // UserWithSecrets
device, // DeviceWithSecrets
team, // Team instance
}
})
// Start the connection
connection.start()
// Feed incoming messages from the peer
peerSocket.on('message', (message: Uint8Array) => {
connection.receive(message)
})The connection behavior depends on your context:
-
MemberContext: You're an existing team member with a device- Authenticate using your device ID and keys
-
InviteeMemberContext: You're joining as a new member with an invitation- Present proof of invitation with your user keys
-
InviteeDeviceContext: You're adding a new device for an existing user- Present proof of invitation for the device
-
ServerContext: You're a sync server- Authenticate using server keys
The connection goes through several states:
Both peers exchange identity claims stating who they are:
- Member device: "I'm device X belonging to user Y"
- New member invitee: "I have an invitation and here are my user keys"
- New device invitee: "I have an invitation for a new device"
Alice Bob
|---- CLAIM_IDENTITY -------->|
|<--- CLAIM_IDENTITY ---------|
Depending on the identity claims, different authentication flows occur:
For invitations:
If one peer presents an invitation, the member peer validates the proof:
New Member Existing Member
|---- CLAIM_IDENTITY -------->|
| (includes invitation proof) |
| | (validates invitation)
|<--- ACCEPT_INVITATION ------|
| (includes team graph) |
For existing members:
Both peers challenge each other with a signature challenge:
Alice Bob
|--- CHALLENGE_IDENTITY ----->|
| | (signs challenge)
|<---- PROVE_IDENTITY ---------|
| (verifies signature) |
|---- ACCEPT_IDENTITY -------->|
| |
|<-- CHALLENGE_IDENTITY -------|
| (signs challenge) |
|---- PROVE_IDENTITY --------->|
| | (verifies signature)
|<--- ACCEPT_IDENTITY ---------|
The challenge includes:
- A random nonce
- A timestamp
- The device's scope (type and name)
The peer signs this challenge with their device's signature key.
Once authenticated, both peers negotiate a shared session key for encrypting subsequent messages:
- Each peer generates a random seed
- Each peer encrypts their seed using asymmetric encryption (their private key + peer's public key)
- Both peers exchange seeds
- Both peers derive the same shared key by combining both seeds
Alice Bob
|------ SEED (encrypted) ---->|
|<----- SEED (encrypted) -----|
| |
| Both derive shared key |
This establishes an encrypted channel for all further communication.
Once the session key is established, peers synchronize their team graphs using the CRDX sync protocol:
Alice Bob
|------- SYNC message ------->|
|<------ SYNC message ---------|
|------- SYNC message ------->|
| (repeat until |
| graphs are equal) |
The sync protocol exchanges links that one peer has but the other doesn't, until both graphs are identical.
Once synchronized, the connection enters the connected state. In this state:
- Peers continue to exchange sync messages when the graph is updated locally
- Peers can exchange encrypted messages
- The connection monitors for peer removal (member/device/server removed from team)
- If a peer is removed, the connection automatically disconnects
Alice Bob
| |
| (Alice adds a new member) |
|------- SYNC message ------->|
| (Bob merges the update) |
| |
| <------ SYNC message --------|
| |
| (Bob removes Alice's device) |
|<------ SYNC message ---------|
| (Alice merges, detects |
| removal, disconnects) |
X |
The Connection class extends EventEmitter and emits:
-
change: State machine transitionsconnection.on('change', (state) => { console.log('Connection state:', state) })
-
connected: Successfully connected and authenticatedconnection.on('connected', () => { console.log('Connected to peer!') })
-
joined: Successfully joined a team via invitationconnection.on('joined', ({ team, user, teamKeyring }) => { // Save the team and user info saveTeam(team.save()) saveUser(user) })
-
updated: Team graph was updated by peerconnection.on('updated', () => { // Save the updated team graph saveTeam(team.save()) })
-
message: Received an encrypted message from peerconnection.on('message', (message) => { console.log('Received:', message) })
-
localError: We detected an error (e.g. invalid invitation)connection.on('localError', (error) => { console.error('Local error:', error) })
-
remoteError: Peer detected an error and reported itconnection.on('remoteError', (error) => { console.error('Remote error:', error) })
-
disconnected: Connection terminatedconnection.on('disconnected', () => { console.log('Disconnected') })
connection.start(): Start the connection state machineconnection.receive(message: Uint8Array): Process an incoming message from the peerconnection.send(message: unknown): Send an encrypted message to the peer (when connected)connection.disconnectAndStop(): Gracefully disconnectconnection.state: Current state of the connection (XState snapshot)
The connection automatically disconnects on errors:
INVITATION_PROOF_INVALID: Invalid invitation proofIDENTITY_PROOF_INVALID: Failed signature challengeDEVICE_UNKNOWN: Device not found in teamDEVICE_REMOVED: Device was removed from teamMEMBER_REMOVED: Member was removed from teamSERVER_REMOVED: Server was removed from teamJOINED_WRONG_TEAM: Invitee tried to join a different teamNEITHER_IS_MEMBER: Both peers presented invitations (impossible)TIMEOUT: Operation took too long (7 seconds)ENCRYPTION_FAILURE: Failed to decrypt a message
When an error occurs, the connection sends an error message to the peer and transitions to the disconnected state.
- Device-level authentication: Devices authenticate independently using their own keys, not user keys
- Signature challenge: Proves possession of private signature key
- Session key: Provides forward secrecy for message encryption
- No replay attacks: Session keys are derived from random seeds, fresh per connection
- Automatic removal detection: Connections automatically terminate when peers are removed from the team
- Timeout protection: All protocol steps have timeouts to prevent hanging connections
The connection state machine can be visualized using the Stately visualizer.
The main states are:
awaitingIdentityClaim
↓
authenticating
├── checkingInvitations
├── awaitingInvitationAcceptance
├── validatingInvitation
└── checkingIdentity (parallel)
├── provingMyIdentity
└── verifyingTheirIdentity
↓
negotiating (session key)
↓
synchronizing (team graph)
↓
connected
↓
disconnected
The Connection class is network-agnostic. It doesn't care about the underlying transport:
- WebSocket
- WebRTC
- HTTP polling
- Bluetooth
- Custom protocol
You provide the sendMessage function, and call connection.receive() when messages arrive. The connection handles all authentication, encryption, and synchronization logic.


