A pure-Python client for sending and receiving 1:1 direct messages on the Session messenger network
Project description
pysession-client
A pure-Python client for sending and receiving 1:1 direct messages on the Session messenger network, given your 13-word recovery phrase and a recipient's Session ID.
Reimplements the parts of Session's protocol needed for this from scratch (Ed25519/X25519 key derivation, message encryption, and onion-routed swarm storage) and has been verified end-to-end against the live production network.
Install
pip install -r requirements.txt
Dependencies: pynacl (libsodium bindings), cryptography (AES-GCM),
requests (HTTP), protobuf (unused directly — the wire format is hand-encoded
in proto_wire.py, no protoc required).
Quick start
from pysession-client import Client
client = Client("your thirdteen word recovery phrase goes here")
print(client.session_id) # your own Session ID, derived from the phrase
# Send a message as yourself
client.send("<recipient session id hex>", "Hello world!")
# Check your own swarm for new messages
for msg in client.receive():
print(msg["sender_session_id"], "says:", msg["body"])
receive() takes an optional last_hash to only fetch messages newer than a
previously-seen one (pass the "hash" field from a prior message to page
forward); without it, it returns everything currently stored in your swarm
(Session's default message TTL is 14 days).
API reference
pysession.Client(mnemonic: str)
.session_id— your Session ID (str, hex,05-prefixed)..send(session_id: str, text: str) -> dict— encryptstextfor the given recipient and stores it in their swarm. Returns the storage server's rawstoreresponse (contains a messagehash, per-nodesignatures, andexpirytimestamp)..receive(last_hash: str = "") -> list[dict]— fetches and decrypts messages waiting in your own swarm. Each result is{"sender_session_id": str, "body": str, "hash": str}. Messages that fail to decrypt (not addressed to you, corrupt, etc.) are silently skipped.
Lower-level building blocks (keys, mnemonic, envelope, onion, network,
retrieve) are all importable directly if you need more control — see
"Module walkthrough" below.
Implemented features
- Recovery phrase (13-word mnemonic) → Session ID derivation.
- Sending plain-text 1:1 direct messages (
Client.send). - Receiving/decrypting messages from your own swarm, with
last_hashpaging (Client.receive). - Full onion-routed swarm transport: seed-node bootstrap, swarm lookup,
store, and signedretrieve. - Offline self-test covering mnemonic, key derivation, and the envelope build/seal/decrypt round trip.
Features to be implemented
- Attachments (images, files, voice messages).
- Group / closed-group messaging (only 1:1 DMs are supported today).
- Disappearing messages, typing indicators, read receipts.
- Local persistence — no conversation history or seen-message tracking is
kept; callers must manage
hash/last_hashthemselves.
Known limitations
- TLS certificate verification is disabled for service-node connections. This is expected/necessary for this network (see "Network transport" above), but worth knowing if you're auditing this code.
Self-test (no network required)
python -m pysession_client._selftest
Exercises mnemonic encode/decode, key derivation determinism, and the full envelope build → seal → decrypt → signature-verify → parse round trip locally, without touching the network.
Module walkthrough
| Module | Responsibility |
|---|---|
mnemonic.py |
13-word recovery phrase ↔ 16-byte seed |
keys.py |
seed → Ed25519 keypair → X25519 keypair → Session ID |
proto_wire.py |
minimal hand-rolled protobuf wire-format encoder |
envelope.py |
builds + pads + signs + seals a DataMessage into ciphertext |
onion.py |
per-hop AES-GCM crypto + onion-nesting for routing requests |
network.py |
seed-node bootstrap, swarm lookup, store |
retrieve.py |
signed retrieve calls + decrypting fetched envelopes |
client.py |
the public Client class tying everything together |
How it works
Session has no central server: messages are end-to-end encrypted, then stored on a decentralized network of service nodes run by the Oxen/Session Foundation network. Nodes are grouped into swarms — every Session ID maps deterministically to one swarm (typically 5+ nodes) that holds its messages until the recipient polls for them or the TTL expires. Requests are routed through the network via 3-hop onion routing, similar in spirit to Tor, so no single node sees both sender and recipient.
1. Identity: recovery phrase → Session ID (mnemonic.py, keys.py)
Session's 13-word "recovery password" is a Monero/Electrum-style mnemonic: 12
data words plus 1 CRC32 checksum word, matched on a 3-character prefix against
a fixed 1626-word list (bundled as wordlist_english.json). Decoding it yields
a 16-byte seed.
That seed is zero-padded to 32 bytes and fed to libsodium's
crypto_sign_seed_keypair to derive an Ed25519 keypair (used for signing
messages). The Ed25519 keys are then converted to an X25519 keypair (used
for encryption) via crypto_sign_ed25519_*_to_curve25519. Your Session ID is
simply 0x05 followed by the X25519 public key, hex-encoded.
Because the real entropy is only 16 bytes (zero-padded, not real random data, to fit libsodium's 32-byte seed requirement), this is Session's documented "128-bit, not 256-bit" security trade-off in exchange for a shorter recovery phrase.
2. Message construction (envelope.py, proto_wire.py)
A plaintext message is wrapped in a minimal hand-encoded protobuf structure
(field numbers taken from libsession-util's SessionProtos.proto — no
protoc needed, since only a handful of fields are used):
Envelope { type=SESSION_MESSAGE, timestamp, content=<encrypted Content bytes> }
Content { dataMessage: DataMessage { body: "hello", timestamp } }
Before encryption, the serialized Content is padded to a 160-byte boundary
(a 0x80 delimiter byte followed by zero-fill — this hides the exact message
length from anyone who can see ciphertext size). The sender then:
- Signs
padded_content || sender_ed25519_pubkey || recipient_x25519_pubkeywith their Ed25519 key (proves authorship without an unencrypted "from" field). - Appends the sender's Ed25519 pubkey and the signature to the padded content.
- Encrypts the whole thing with libsodium's
crypto_box_seal— an anonymous sealed box using an ephemeral keypair, so the ciphertext itself reveals no sender information; authentication comes entirely from the signature inside.
The result becomes the Envelope.content field, and the serialized Envelope
is what gets base64'd and sent to the network.
3. Network transport (network.py, onion.py)
To deliver a message, a client needs to:
- Bootstrap — fetch an initial pool of live service nodes from one of
Session's public seed nodes (
seed1/2/3.getsession.org:4443), via a plain (non-onion)get_n_service_nodesJSON-RPC call. These seed nodes use self-signed TLS certificates — Session authenticates node identity via each node's ed25519/x25519 keys at the protocol layer, not via the CA/TLS system, so certificate verification is intentionally disabled for these connections (see the note innetwork.py). - Find the recipient's swarm — send a
get_swarmRPC call, onion-routed through 2 random relay nodes to a 3rd destination node, asking which nodes are responsible for the recipient's Session ID. - Store the message — send a
storeRPC call (recipient pubkey, TTL, timestamp, base64 envelope data, namespace0for regular 1:1 DMs), onion-routed the same way, to a random node in the recipient's swarm. - Retrieve messages — send a
retrieveRPC call to a node in your own swarm. Unlikestore(which anyone can call — that's how strangers can message you),retrieverequires proving you own the account: the request is signed with your Ed25519 key over the string"retrieve" + timestamp(or"retrieve" + namespace + timestampfor any namespace other than the default0).
Onion request format
Each onion "hop" is encrypted independently. The per-hop symmetric crypto
(confirmed directly from oxen-storage-server's own C++ decryption source,
oxenss/crypto/channel_encryption.cpp) is:
shared_secret = X25519_scalarmult(my_ephemeral_seckey, their_static_pubkey)
aes_key = HMAC-SHA256(key="LOKI", msg=shared_secret)
wire_bytes = 12-byte random IV || AES-256-GCM(aes_key, iv, plaintext) # 16-byte tag appended
Layers are nested from the destination outward to the entry ("guard") node.
Each layer's plaintext is itself a small framed structure (confirmed from
oxen-storage-server's onion_processing.cpp):
[4-byte little-endian length N][N bytes: inner data][remaining bytes: routing JSON]
The routing JSON tells a hop what to do with the inner data:
{"destination": <next hop's ed25519 pubkey>, "ephemeral_key": <hex>}— relay further to another node.{"headers": {}}— this hop is the final destination; the "inner data" at this layer is the raw request body (JSON-RPC text) to hand to the storage server's own RPC dispatcher, not further ciphertext.
The whole nested structure is POSTed as raw bytes to
https://<guard-ip>:<guard-port>/onion_req/v2. The response comes back
encrypted with the same shared secret used for the destination layer, as
{"body": "<json string>", "status": <http status>}.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pysession_client-0.1.0a0.tar.gz.
File metadata
- Download URL: pysession_client-0.1.0a0.tar.gz
- Upload date:
- Size: 56.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
696b020bd5bef440514fda8d7a85400e5834e0edbc197fd2ceac6b2ecc6cbe83
|
|
| MD5 |
4648f78800fdeebd2db0cb619afa2281
|
|
| BLAKE2b-256 |
915be8db81fba884b20893521370a82c334f4942a6df649d13da37762b0d5bdf
|
Provenance
The following attestation bundles were made for pysession_client-0.1.0a0.tar.gz:
Publisher:
publish.yml on PranThow/pysession-client
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pysession_client-0.1.0a0.tar.gz -
Subject digest:
696b020bd5bef440514fda8d7a85400e5834e0edbc197fd2ceac6b2ecc6cbe83 - Sigstore transparency entry: 2065348413
- Sigstore integration time:
-
Permalink:
PranThow/pysession-client@439fc9a4a04259b898c870f95803af1d02b043ba -
Branch / Tag:
refs/tags/v0.1-alpha - Owner: https://github.com/PranThow
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@439fc9a4a04259b898c870f95803af1d02b043ba -
Trigger Event:
release
-
Statement type:
File details
Details for the file pysession_client-0.1.0a0-py3-none-any.whl.
File metadata
- Download URL: pysession_client-0.1.0a0-py3-none-any.whl
- Upload date:
- Size: 44.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d33790876cbe016b3c72cce57f95d0611208f6d6ce6e474958d365aea16968ca
|
|
| MD5 |
9062764701d22765f77cd1e09a61a782
|
|
| BLAKE2b-256 |
60df067a7266171693e3f922c70b7a72c326a906bf8845690b40348dc13559d2
|
Provenance
The following attestation bundles were made for pysession_client-0.1.0a0-py3-none-any.whl:
Publisher:
publish.yml on PranThow/pysession-client
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pysession_client-0.1.0a0-py3-none-any.whl -
Subject digest:
d33790876cbe016b3c72cce57f95d0611208f6d6ce6e474958d365aea16968ca - Sigstore transparency entry: 2065348475
- Sigstore integration time:
-
Permalink:
PranThow/pysession-client@439fc9a4a04259b898c870f95803af1d02b043ba -
Branch / Tag:
refs/tags/v0.1-alpha - Owner: https://github.com/PranThow
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@439fc9a4a04259b898c870f95803af1d02b043ba -
Trigger Event:
release
-
Statement type: