Lichen
Prototype of a decentralized, end-to-end encrypted chat where everyone keeps a phonebook of everyone else.
- You are your key. Each node has an Ed25519 key (identity, signatures) and an X25519 key (encryption). The Ed25519 public key is who you are.
- Records are signed. A contact record says "this key lives at these addresses, and here's how to
encrypt to it", with a sequence number, signed by that key. Anyone can store and forward records
without being trusted; forgeries fail verification and stale records lose to newer
seq. - Moving = gossip. When your address changes you sign a new record and send it to everyone you know; anyone who learns something new forwards it on.
- Find one person, reach everyone.
helloto any node returns its whole phonebook. - Phonebook server = just a node at a public address: an IP/domain with an open port, or a
wss://URL behind Cloudflare Tunnel or any HTTPS proxy. It introduces peers so they can connect directly (UDP hole punching), and relays for anyone whose network won't allow that. - Hubs federate. Point one hub at another and their phonebooks merge; users on either can reach users on both.
- Stale address? If delivery fails, the sender asks its contacts and bootstrap nodes for a fresher record and retries.
- Everything is end-to-end encrypted. Relays and phonebooks only see ciphertext and who it's for.
Install
Needs Python 3.11+. From PyPI:
pip install lichen-chat
lichen --init # writes a starter lichen.yaml; put your name and your hub in it
lichen # run it
(python -m lichen works too. For the latest unreleased code: pip install git+https://github.com/LegoManACM/Lichen.)
From a checkout (for hacking on it)
PowerShell (Windows):
python -m venv .venv
.\.venv\Scripts\Activate.ps1 # once per terminal; prompt shows (.venv)
python -m pip install -e . # editable install: gives you the `lichen` command
If activation says scripts are disabled: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned.
In cmd use .venv\Scripts\activate.bat; on macOS/Linux source .venv/bin/activate.
All commands below assume the venv is active.
Terminal UI
Run it in a normal terminal and you get a full-screen app:
- Sidebar: your contacts (plus strangers who wrote to you, marked
?) and groups (invites marked✉ … (invite)).●green = direct peer-to-peer path,●cyan = they accept connections directly (hubs, open ports),◐yellow = through their hub. A number badge shows unread messages. - Requests (Ctrl+R): group invites, file offers and messages from people who aren't your
contacts. Type
accept NAME/decline NAMEthere (Tab completes). - Sending files: Ctrl+F in a conversation opens a file picker (browse, or type/paste a path with Tab completion). Or drag a file into the terminal window: the path gets pasted into the box and Enter sends it. Offers and downloads show up inline in the chat, with live progress.
- Console (top of the sidebar, and where the app starts): a terminal for running commands, no
slash needed (
help,peers,group create devs alice bob, ...), with all their output kept. Ctrl+O jumps back to it from anywhere. - Pick a conversation (arrow keys + Enter, or click) and just type; Enter sends.
/commandswork there too, with their output shown inline (and also kept in the console). - Autocomplete: as you type a command, grey ghost text suggests the rest: command names,
/groupsubcommands, contact names, group names,private/shared. Tab (or →) accepts it; a hint line above the box shows usage and what fits. - Ctrl+L shows the activity pane (connections, hole punches, relay events), F5 syncs (groups, chats, and anything waiting to be delivered), Esc jumps back to the message box, Ctrl+Q quits.
- Direct-message history is kept in
data/<name>/dms.json(group history ingroups.json).
ui: tui / ui: cli (or --ui) picks one explicitly. The default auto uses the full-screen UI in
an interactive terminal and the plain prompt when input is piped (scripts, tests).
Config files
Every option can live in a YAML or JSON file, so day to day you just run:
lichen # uses lichen.yaml / .yml / .json in this folder
lichen --config hub.yaml # or a specific file
lichen --init # writes a commented starter lichen.yaml
Flags on the command line override the file. Options (same names as the flags):
| option | what it does |
|---|---|
name |
your display name (your key is your real identity) |
relay |
hub(s) to receive messages through: HOST:PORT, ws://... or wss://...; a list = several hubs |
bootstrap |
list of nodes/hubs to fetch phonebooks from (the relay is added automatically) |
port |
port to listen on, TCP and UDP (hubs; default 9000) |
host |
public IP / DNS name others can reach you at directly (hub with an open port) |
bind |
interface to listen on, e.g. 0.0.0.0 |
url |
public address to advertise instead of host:port, e.g. wss://chat.example.com |
stun |
STUN servers for learning your public UDP address ([] disables) |
data |
where keys, phonebook and messages are stored (default data/<name>) |
ui |
tui, cli or auto (default: full-screen UI in a real terminal) |
downloads |
where accepted files are saved (default data/<name>/downloads) |
max_file_mb |
refuse file offers bigger than this (default 100) |
auto_download_mb |
auto-accept files up to this size from your contacts (default 0 = always ask) |
vault_mb |
hubs: MB of encrypted sync data for people's other devices to hold (default 256; 0 = hold none, see Using Lichen on more than one device) |
Ready-made examples are in examples/: friend.yaml, hub-open-port.yaml,
hub-cloudflare.yaml, local-test.json.
Try it locally
Self-checking demo (address changes, stale phonebooks, groups, key rotation, private history, NAT'd nodes via relay, offline delivery via IOUs, hole punching, federated hubs, a WebSocket-only hub, invites, trust, file transfer, chat sync, forged records):
python demo.py
python demo_sync.py # one identity on two devices that are never on together (the hub's vault)
Interactive on one PC, one terminal each:
lichen --name hub --port 9000
lichen --name alice --relay 127.0.0.1:9000
lichen --name bob --relay ws://127.0.0.1:9000
(The hub accepts both raw TCP and WebSocket on the same port, so alice and bob can mix.)
Commands: /peers, /msg bob hello, /whoami, /join ADDRESS, /move PORT, /quit.
Groups: /group create devs bob carol (add --shared to let new members see old messages),
/group history devs shared, /g devs hi all, /group add devs dave,
/group remove devs bob, /groups, /history devs, /sync. /help lists everything.
State lives in data/<name>/: key and enckey are your private keys (don't share them),
plus phonebook.json, groups.json, and mailbox.json (messages held for others, if you're a relay).
Using it over the internet
Only one machine needs to be reachable: the hub. Everyone else connects out to it, which works behind any home router, mobile data, dorm or office network. Pick whichever way of hosting suits you:
A. Cloudflare Tunnel (no port forwarding, home IP hidden)
The hub stays on localhost; cloudflared carries WebSocket traffic to it.
cloudflared tunnel login
cloudflared tunnel create lichen
cloudflared tunnel route dns lichen chat.example.com
cloudflared tunnel run --url http://localhost:9000 lichen
# hub.yaml -> lichen --config hub.yaml
name: hub
port: 9000
url: wss://chat.example.com
Friends use relay: wss://chat.example.com. To try it without a domain,
cloudflared tunnel --url http://localhost:9000 prints a temporary https://<random>.trycloudflare.com
address; use it as wss://<random>.trycloudflare.com (it changes every time you restart cloudflared).
Any other HTTPS reverse proxy that passes WebSockets through (nginx, Caddy, ngrok, ...) works the same way.
B. Open port (VPS, or your PC with a port forward)
Open or forward port 9000 for TCP and UDP (UDP lets the hub tell people their public address; without it, public STUN servers are used instead). Allow Python through the firewall.
name: hub
port: 9000
bind: 0.0.0.0
host: 203.0.113.7 # your public IP...
# host: chat.example.com # ...or a domain pointing at it
Friends use relay: 203.0.113.7:9000 (or chat.example.com:9000). For a home connection whose IP
changes, point a domain at it with dynamic DNS (DuckDNS, No-IP, or Cloudflare DNS in "DNS only" /
grey-cloud mode with an updater script).
Everyone else
name: yourname
relay: wss://chat.example.com # or HOST:PORT, whatever the hub owner gave you
You'll get the hub's phonebook, show up in everyone else's, and can /msg anyone. Messages go
directly between you and the other person whenever your networks allow it; /peers shows
[direct ip:port] or [via relay] for each contact. If someone is offline, your message waits in
your outbox and goes out when you're both online (see Offline delivery).
Windows may ask to allow Python on the network the first time; allow it, or direct connections can't
be made.
For redundancy, list several hubs: relay: [wss://chat.example.com, other-hub.example.org:9000]. You
attach to all of them, so you stay reachable if one goes down.
If you can accept connections yourself, add host/bind/port (or url) as well: people will
then try you directly first and only fall back to the relay.
Several hubs
Give a hub bootstrap: [<other hub address>] (or run /join <address> on it) and the two phonebooks
merge both ways, then spread to everyone on each. People who join either hub later get the combined
phonebook, and can message and connect directly to anyone on any linked hub, whatever way each hub is
hosted. Actually any node that joins two hubs bridges them.
Direct connections (hole punching)
Most people are behind a router (NAT) that blocks incoming connections. Lichen gets around it the same way Tailscale and WebRTC do, with the hub only as an introducer:
- Each node learns its public UDP address (the IP:port its router maps its socket to): from its hub's reply to a UDP ping if the hub has an open UDP port, otherwise from a public STUN server (Google, Cloudflare; configurable). Repeating this every 15 s keeps the router mapping open.
- When you message someone through their relay, you also send them a small
punchrequest the same way, carrying your public and local-network addresses; their reply carries theirs. Because it travels through the relay connection, this works for hubs behind Cloudflare and across hubs. - Both sides send UDP pings to every candidate address at once. Each router sees outgoing traffic to the other side and lets the replies in. The first signed reply from the right key is the direct path.
- From then on, requests between you go peer to peer over UDP (chunked, with retransmits), with keepalive pings every 15 s. If a direct request fails, it falls back to the relay immediately, and punching is retried after a minute.
Works on most home routers and Wi-Fi, and on the same LAN. Often fails on carrier-grade NAT
(mobile data) and some corporate/university networks ("symmetric NAT"); those contacts stay
[via relay], which still works, just through the hub.
How relaying works
A node with relay: ADDRESS doesn't listen at all. It opens one outgoing connection to the relay
(raw TCP, or a WebSocket for ws:///wss://), proves it holds its key, and advertises its address
as relay:ADDRESS. To reach it, you send {"type": "forward", "to": <key>, "msg": ...} to the
relay, which pushes the request down that open connection and hands back the reply. So every
request type (messages, lookups, group sync, punch, file chunks) works through a relay. A keepalive
every 25 s stops proxies from closing it. If the node isn't connected, the hub doesn't store the
message: see the next section.
Offline delivery (IOUs)
Hubs store as little as possible: no messages, not even encrypted ones, just IOUs. (The one exception is the opt-out device vault: an encrypted snapshot of your own state, for your own other devices, never anyone else's messages.)
- You send something to someone who's offline (a message, a file offer, a group invite, a leave
request). Their hub replies "they're away" and records an IOU: you owe them item
<id>, plus a timestamp. If they use several hubs, each one gets the same IOU. - The item itself stays sealed in your
outbox.json. - When they reconnect to any of their hubs, that hub tells them who owes them what ("alice has 1 thing
for you") and nudges you to deliver. You also retry by yourself when their fresh phonebook record
reaches you (they announce it on startup), every minute, on startup, and on
/sync. - If you're offline when they come back, the IOU is redeemed when you're both online again. Unredeemed IOUs and outbox items expire after 30 days.
Message ids. Every direct message carries a random id chosen (and signed) by its author. Whatever
route a message takes (direct, relay, a retry, an IOU redeemed through two hubs at once) the
recipient keeps one copy, and conversations sort by time. Chat sync uses the same ids: on startup,
when someone comes back online, and on /sync, you and they swap "here are the ids I have" and each
sends what the other is missing (sealed, and checked against each author's signature), so gaps heal
themselves.
The trade-off: delivery needs you and the recipient online at the same time at some point; the hub can't hand over anything on your behalf. (If you use several devices, any of them can be the one that's online: they share the outbox.) Group chat already catches up from any member who's online, so groups don't need IOUs.
Replies, reactions, edits and deletes
Every message in a conversation has a number on the left. In a chat (or after /open NAME /
/open #group in the plain CLI):
| command | does |
|---|---|
/reply 12 sounds good |
replies to message 12 (shown with a quote of it) |
/react 12 +1 |
reacts with 👍 (also heart, laugh, wow, sad, fire, party, eyes, 100, or any emoji); again to undo |
/edit 12 new text / /edit last ... |
changes one of your messages; in the TUI, ↑ in an empty box starts editing your last one |
/delete 12 / /delete last |
deletes one of your messages, for everyone |
Under the hood these are ordinary signed, encrypted messages that point at another message's id, so they work offline (IOUs), sync, and dedupe like everything else. Edits and deletes from anyone but the original author are ignored. Deleting is best effort: honest clients hide the text, but someone who already received it could have kept a copy.
Presence, receipts and typing
- Online / last seen: your node quietly checks in with your contacts every 30 s; anyone heard from in the last ~75 s shows as online (a filled dot in the sidebar), otherwise "last seen 5m ago".
- Ticks on your direct messages:
…waiting in your outbox (they're offline),✓delivered,✓✓read (they had the chat open). - Typing: "typing…" shows in the chat header while they write to you.
These are signals: sealed like messages, but never stored, synced or queued, and only exchanged between contacts, so strangers can't tell when you're around or what you've read. Receipts and typing are for direct messages only (in groups they'd be noise).
Using Lichen on more than one device
lichen --export me.lichen # on the device you already use (asks for a passphrase for the file)
lichen --import me.lichen # on the new device: no config or --name needed; then just run lichen
The export holds your keys, contacts (with nicknames and verifications), groups (with their keys and
history), chats, unsent messages and phonebook, and a lichen.yaml with your portable settings
(name, hubs, STUN servers, file limits), encrypted with the passphrase you choose. Settings that
describe one machine (data, downloads, host, bind, port, url) are left out. Importing
writes that lichen.yaml into the current folder (it never overwrites one that's there: you get
lichen.from-export.yaml instead) and sets up data/<name>. Anyone with the file and its
passphrase can be you, so treat it like a password and delete it once you've imported it.
After that both devices are you, at once:
- Each device attaches to your hubs with its own device id, and hubs deliver to all of them.
- If your devices use different hubs, they notice each other in the phonebook and advertise the combined addresses, so people can reach whichever one is up.
- Everything syncs between your devices: contacts, blocks, nicknames and verifications; groups (including solo groups, joins, leaves, deletes, keys and every message); all your chats with their delivered / read ticks; messages still waiting in the outbox (so whichever device is on when the recipient returns delivers them, and the other one drops its copy); file offers (a decline travels, downloads stay per device); and the phonebook. Whichever device changed something last wins; messages and unsent items are merged, so nothing is lost and the order doesn't matter.
When the devices aren't on at the same time
Two ways, used together:
- Live. Devices that are on at the same time compare a digest of their state through your hubs (or directly) and, if it differs, swap everything. This happens at startup, as soon as another device appears, about a second after a change, and every minute. It costs one tiny message when nothing differs.
- Through your hubs' vault. Once an identity is on several devices, each device leaves an encrypted snapshot of its state on your hubs (a moment after each change, and when it shuts down). When a device starts, it collects the snapshots its siblings left, even if they are off right now, and merges them. So you can change things on your laptop, switch it off, and open your desktop an hour (or a week) later: it picks everything up when it starts, with no overlap needed.
What the vault means for privacy: a snapshot is compressed, sealed to your own encryption key and
signed by your identity key, so the hub sees only that your key keeps a blob of some size, which
device uploaded it and when. It can't read it, and can't make one up (an unsigned or foreign snapshot
is ignored). A hub keeps one snapshot per device (a new upload replaces the old one), at most 24 MB
per identity, only for identities it knows, and forgets a device that hasn't uploaded for 90 days.
Hub owners can turn it off with vault_mb: 0; the live path then still works, and devices on
other hubs still meet there. Single-device identities never upload anything: the vault only starts
once you've exported, imported, or a second device has shown up.
If your history is too big for one snapshot (about 2 MB compressed, tens of thousands of messages), the oldest messages of each chat are left out of it; they still come from the other person's device and from group members, as before.
Not synced between devices: files you've downloaded (each device downloads its own copy), and which chat you're looking at / unread counts.
Trust, requests and invites
Every key is trusted (a contact), unknown, or blocked (trust.json):
- You trust someone by messaging them, sending them a file, accepting their request, or
/trust NAME./untrust,/blockand/unblockdo what they say. - Messages from people who aren't your contacts arrive as requests: kept, listed in
/requests(and the TUI's Requests view), no notifications./accept NAMEmakes them a contact. - Blocked keys are ignored completely: messages, file offers and group invites are dropped.
- Group invites need accepting. Being added to a group is an invite: you see who invited you and
who's in it, but no messages until you
/accept GROUP./decline GROUPor/group leave GROUPsends the owner a signed leave request, and the owner's node removes you (rotating the group key). - Pending invites, requests, offers and trust levels are saved, so nothing is lost across restarts.
Making sure people are who they say they are
Names aren't unique: anyone can start Lichen with --name alice. What can't be faked is the key;
everything (messages, records, invites) is signed with it. So Lichen helps you keep names and keys
straight:
- Clashing names get a key prefix. If two keys both call themselves "alice", you see
alice#3fK9xQandalice#Wc1dPq, and/msg aliceasks which one you mean instead of guessing. - Lookalikes are flagged. If a stranger uses the same name as one of your contacts, they're marked ⚠ and their chat opens with a warning showing the different key.
/nick NAME NICKNAMEgives someone your own name for them. Only you see it, and nobody else can take it over, whatever they call themselves./verify NAMEshows a 20-digit safety code for the two of you. Compare it with them in person, on a call, or in another app you trust; if it matches,/verify NAME yesmarks them ✓ verified./whoamishows your own fingerprint.- Passphrase for your keys:
lichen --passphraseencrypts your key files (scrypt + ChaCha20-Poly1305), so a copied data folder can't be used to sign as you or read your messages. You're asked for it at startup; unattended hubs can setLICHEN_PASSPHRASE. Run it again to change or remove it.
Hosting a group: handing it over or deleting it
The person who created a group is its host: only they can invite, remove, or change settings. The
host can't just leave; /group leave GROUP tells them to pick one of:
/group leave GROUP NAME: hand the group to another member and leave. (/group transfer GROUP NAMEhands it over but stays a member.) The host signs "the owner is now NAME" and sends NAME a sealed hand-over with the group's key history (needed to give future members the right keys). NAME's node then republishes the group as host with a fresh key, minus the old host if they left. If NAME is offline, the hand-over waits in the outbox like anything else./group delete GROUP: a final signed "deleted" update; members' nodes forget the group's messages and keys (best effort: an honest client deletes, a modified one could keep a copy).
Ownership is verifiable by anyone: the group id is derived from the founder's key, and the record carries the chain of hand-overs, each signed by the previous host. A member who joins long after a hand-over can still check it, and nobody can claim a group they weren't handed.
File sharing
/send NAME PATH (or /send #group PATH, or Ctrl+F / drag-and-drop in the TUI) offers a file:
- Your node hashes the file and sends each recipient an offer, sealed to them: name, size, SHA-256, and a random per-file key. Nothing else moves yet.
- They decide:
/accept NAMEor/decline NAME. Offers from strangers wait in requests; blocked senders' offers are dropped; anything overmax_file_mbis refused automatically. Withauto_download_mbset, small files from contacts download by themselves. - Accepting pulls the file in 48 KB chunks, each encrypted with the per-file key (bound to its offset), over the direct peer-to-peer path when there is one and the relay otherwise. Relays only ever see ciphertext.
- Interrupted downloads resume where they stopped (
/acceptagain). The finished file must match the SHA-256 from the offer, then lands indownloads/under a safe name that never overwrites anything.
The sender has to be online while you download (files aren't stored on hubs). /files lists
everything offered and received.
Encryption
- Direct messages are signed by the sender, then sealed to the recipient's X25519 key (ephemeral X25519 + HKDF-SHA256 + ChaCha20-Poly1305). The relay sees the recipient's key and nothing else.
- Groups use a shared key per epoch. Every change (create, add, remove, history setting) starts a new epoch with a fresh random key, which the owner seals to each member individually inside the signed group record. Removed members never get the new key, so they can't read anything sent after their removal.
Groups
- A group is a signed member list owned by its creator. Its id is
hash(owner key, nonce), so no one else can publish records for it. Changes = owner signs a new record with a higherseq. - Sending: the message is signed by its author, encrypted with the current epoch key, and sent to every member directly (or via their relay).
- Catching up: every member keeps the group's encrypted log. On startup and on
/sync, you send the other members the ids you already have, and they reply with what you're missing. Any member can fill the gap. Newly added members pull the history the same way. - Receivers only accept messages that decrypt, verify, and come from a current member.
History setting
/group create devs bob carol makes a private-history group (the default): people added later
only see messages from when they joined. /group create devs --shared bob carol lets new members
catch up on everything sent while the group was shared. The owner can switch with
/group history devs private|shared.
This is enforced by cryptography, not just politeness: a member only ever receives the keys for
epochs that started after they joined, plus (while the group is shared) the keys of epochs that were
shared. Private-era messages are unreadable to later joiners even if someone hands them the
ciphertext. Switching to shared and then adding someone doesn't expose anything said while the
group was private. Removing and re-adding someone gives them a new join time.
Wire protocol
One connection per request (except attach, which stays open), either raw TCP with one JSON object
per line, or a WebSocket with one JSON object per text message. Servers accept both on the same port.
| type | request | reply |
|---|---|---|
hello |
{record} |
{record, records[]} |
announce |
{record, from} |
{ok} |
lookup |
{pubkey} |
{record | null} |
msg |
{to, sealed} |
{ok} |
forward |
{to, msg, from, item} |
the target's reply, or {ok, queued, iou} |
attach |
{req: {pubkey, ts}, sig} then stays open |
{ok}, then {rid, msg} / {rid, reply} frames |
group_msg |
{envelope, group} |
{ok} |
group_update |
{group} |
{ok} |
group_sync |
{req: {group, from, ts, have[]}, sig} |
{group, messages[]} |
punch |
{peer, addrs[]} (usually via forward) |
{addrs[]}; both sides then ping each other |
group_leave |
{req: {group, from, ts}, sig} |
{ok}; the owner removes the member |
dm_sync |
{req: {from, to, ts, have[]}, sig} |
{sealed: {messages[]}} |
file_offer |
{to, sealed} |
{ok} |
file_get |
{file_id, offset} |
{n, ct} (one encrypted chunk) |
nudge |
{peer} (hub -> sender) |
{ok}; sender delivers what it owes peer |
owed |
{from: {sender: [item ids]}} (hub -> recipient) |
{ok} |
device_sync |
{to, sealed}: a digest probe, or your whole state (via forward with fanout) |
{sealed} |
vault_put |
{req: {pubkey, device, rev, hash, ts}, sig, blob} |
{ok} |
vault_get |
{req: {pubkey, device, ts, have{}}, sig} |
{snaps[{device, rev, blob}]} |
Over UDP, every request above can also travel on a punched path. Datagrams are JSON:
ping/pong (signed; pong reports the address it saw), and req/rep chunks
{id, i, n, d} that reassemble into the same request/reply objects as over TCP.
Not done yet (prototype limits)
- Not security-reviewed. The crypto uses standard building blocks from
cryptography, but the protocol around them hasn't been audited. Don't rely on it for anything sensitive yet. - No forward secrecy for direct messages: if someone steals your
enckeyfile, they can decrypt past direct messages they recorded. (Groups are better: old epoch keys don't decrypt new epochs.) - First contact is trust-on-first-use: until you
/verifysomeone, you're trusting that the key you messaged is theirs. Names can be copied; compare safety codes for anyone who matters. - Contact records aren't encrypted: the phonebook reveals everyone's name, key and addresses (including direct IPs for nodes that advertise one) to everyone.
- Spam / abuse: anyone can join a hub and flood the phonebook with fake identities. Strangers' messages and offers only land in requests, and you can block, but there's no rate limiting yet.
- Gossip floods to everyone: O(N²) messages per change; fine for hundreds of nodes, not millions.
- Hubs matter for reachability: finding people, introductions and relayed traffic go through them.
Direct paths that already exist keep working if a hub goes down, and listing several hubs in
relaykeeps you reachable when one fails. - Offline delivery needs an overlap: with IOUs nothing is stored on hubs, so a message only arrives once sender and recipient have both been online at the same time. Outbox retries need the recipient's address to be findable; senders retry roughly once a minute and ask everyone they know for a fresh record each time, which is chatty with very large phonebooks.
- STUN servers see your IP. With a WebSocket-only hub, your node asks Google's/Cloudflare's STUN
servers for its public address every 15 s. Set
stun: []to opt out (you'll still connect directly on the same LAN, and via relay otherwise), or point it at a STUN server you trust. - A hub behind Cloudflare means Cloudflare can see relayed traffic metadata (who's attached, sizes, timing), though not message contents, which are end-to-end encrypted.
- No IPv6 yet for direct paths (UDP is IPv4 only), and no port prediction for symmetric NATs.
- Direct connections reveal IPs: a peer you connect to directly learns your public IP, and introductions share your local-network addresses with them.
- UDP transfers are simple stop-and-wait chunks with no congestion control: fine for chat, slow for very large catch-up syncs over lossy links.
- Group catch-up only happens on startup and
/sync, not continuously. - Removing a member also drops their old messages from catch-up for anyone who syncs later.
- Only the host can change a group, from the device with their key. They can hand it over, but if they vanish without doing so, the group can't change membership any more.
- Groups saved by versions before encryption are ignored (contacts are kept).
- Join times come from the owner's clock and message times from the author's; a big clock difference only matters for which ciphertexts are offered during catch-up, not what can be decrypted.
License
Lichen is licensed under the Apache License, Version 2.0. See NOTICE for attribution.
Metadata
Release files for lichen-chat 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| lichen_chat-0.1.0.tar.gz | 121.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lichen_chat-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 229.7 kB
Release files / lichen_chat-0.1.0.tar.gz
| Download URL | lichen_chat-0.1.0.tar.gz |
|---|---|
| Size | 121.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
47a3e9e7238e7367791f916df74080a388edbd6f2a576def7dc77735d5ef35bb
|
|
BLAKE2b-256 checksum How to use checksums |
d2b3b1bd97e905ce974f71dc5a76df2ec3e82e8eeaa11f48b878befec4e31a64
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency logRelease files / lichen_chat-0.1.0-py3-none-any.whl
| Download URL | lichen_chat-0.1.0-py3-none-any.whl |
|---|---|
| Size | 107.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e08334f2b425f3560e20b085c5e779ed1909cdfe35a93627d7910e88db65865f
|
|
BLAKE2b-256 checksum How to use checksums |
a1a5e2c63c3495dc3714bd9540ae07b876e98ca935a83730bae0918579809907
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 13, 2026.
Transparency log