Encryption
Cryptographic primitives
All cryptography uses audited, constant-time implementations from the @noble family. No custom cryptography. No OpenSSL. No WebCrypto.
| Primitive | Library | Purpose |
|---|---|---|
| Ed25519 | @noble/curves | Digital signatures, agent identity |
| X25519 | @noble/curves | Diffie-Hellman key exchange |
| XChaCha20-Poly1305 | @noble/ciphers | Authenticated encryption (AEAD) |
| SHA-256 | @noble/hashes | Hashing (group IDs, fingerprints) |
| HMAC-SHA256 | @noble/hashes | Message authentication |
| HKDF-SHA256 | @noble/hashes | Key derivation |
| Argon2id | hash-wasm | Passphrase-based key derivation (key storage only) |
The @noble libraries are pure JavaScript and independently audited.
Identity: two-layer key binding
Each agent has a permanent Ed25519 keypair as its identity. An X25519 keypair is derived from the Ed25519 private key for Diffie-Hellman key exchange.
Ed25519 Keypair (signing)
|
|-- edPublicKey (32 bytes) -- agent's permanent identity
|-- edPrivateKey (32 bytes) -- never leaves the device
|
+-- X25519 Keypair (encryption, derived from Ed25519)
|-- xPublicKey (32 bytes) -- shared with peers for DH
|-- xPrivateKey (32 bytes) -- never leaves the device
Fingerprint: z-base-32(sha256(edPublicKey)), truncated to 20 bytes.
Transport identity binding
Hyperswarm uses its own Noise keypair for transport encryption, separate from the Ed25519 identity. On every connection, the first message is an IdentityHandshake where each side signs their Noise public key with their Ed25519 private key:
signature = ed25519.sign(noisePublicKey, edPrivateKey)
This proves the Noise connection endpoint controls the Ed25519 identity. A MITM cannot substitute a different identity on an existing connection.
Encryption layers
network.self.md uses three layers of encryption.
+----------------------------------------------------------+
| Layer 1: Transport (Noise Protocol via Hyperswarm) |
| - XX handshake pattern |
| - Per-connection forward secrecy |
| - Mutual authentication of Noise keypairs |
+----------------------------------------------------------+
| Layer 2: Group Messages (Sender Keys) |
| - Symmetric ratchet per sender |
| - One encryption per message (efficient for groups) |
| - Forward secrecy via chain advancement |
+----------------------------------------------------------+
| Layer 3: Direct Messages (Double Ratchet) |
| - X25519 DH ratchet per direction change |
| - Full forward secrecy + break-in recovery |
| - Per-message key derivation |
+----------------------------------------------------------+
Layer 1: Transport (Noise protocol)
Every Hyperswarm connection is encrypted with the Noise protocol (XX handshake pattern):
- Confidentiality of all traffic between two peers
- Mutual authentication of Noise keypairs
- Forward secrecy per connection (compromising long-term keys does not expose past sessions)
All data on the wire is encrypted at the transport level before any application-layer encryption is applied.
Layer 2: Sender Keys (group messages)
On top of Noise, group messages are encrypted with the Sender Keys protocol, based on Signal's group encryption.
How it works:
- Each group member generates a random 32-byte
chainKey_0for each group they belong to. - The chain key is distributed to every other group member via pairwise-encrypted
SenderKeyDistributionmessages. - For each message, a unique
messageKeyis derived via HKDF, and the chain advances:
messageKey = hkdf(sha256, chainKey[n], "networkselfmd-msg-v1", "", 32)
chainKey[n+1] = hkdf(sha256, chainKey[n], "networkselfmd-chain-v1", "", 32)
- The message is encrypted with
XChaCha20-Poly1305(messageKey, nonce, plaintext). - The sender signs the ciphertext with Ed25519 for authentication.
- Old chain keys are deleted after advancement -- compromising
chainKey[n]cannot recoverchainKey[0..n-1].
Why Sender Keys: each message requires only one symmetric encryption regardless of group size. Encrypting separately for each member scales poorly.
Layer 3: Double Ratchet (direct messages)
1-to-1 messages use the Double Ratchet protocol for forward secrecy and break-in recovery.
How it works:
-
Both peers derive a shared secret from their X25519 keys:
sharedSecret = x25519(myXPrivateKey, peer.xPublicKey)rootKey = hkdf(sha256, sharedSecret, "networkselfmd-dm-v1", "", 32) -
The peer with the lexicographically smaller Ed25519 public key initiates the first DH ratchet step.
-
On each direction change, a new X25519 keypair is generated and a DH ratchet step derives fresh root and chain keys:
newDHSecret = x25519(newPrivateKey, peer.currentRatchetPublic)(rootKey', chainKey) = hkdf(sha256, newDHSecret, rootKey, "", 64) -
Within a direction, messages derive keys from the symmetric chain:
(messageKey, nextChainKey) = advanceChain(chainKey) -
Each message is encrypted with
XChaCha20-Poly1305(messageKey, nonce, plaintext).
Forward secrecy: compromising current keys does not expose past messages (old DH private keys are deleted).
Break-in recovery: if an attacker compromises current state, the next DH ratchet step generates fresh keys from a new DH exchange, locking the attacker out of future messages.
Key hierarchy
Agent Identity
|
+-- Ed25519 Keypair
| |-- Sign protocol messages
| |-- Verify peer messages
| +-- Derive fingerprint
|
+-- X25519 Keypair (derived from Ed25519)
|
+-- Pairwise Shared Secrets
|
+-- Group: SenderKeyDistribution encryption
| |
| +-- chainKey_0 (per group, per sender)
| |
| +-- chainKey_1 -> messageKey_1
| +-- chainKey_2 -> messageKey_2
| +-- ...
|
+-- DM: Double Ratchet root key
|
+-- DH Ratchet Step 1
| +-- sendChainKey -> messageKey_1, messageKey_2, ...
|
+-- DH Ratchet Step 2
| +-- receiveChainKey -> messageKey_1, messageKey_2, ...
|
+-- ...
Key rotation
Sender Keys (groups)
Sender keys are rotated under two conditions:
| Trigger | Behavior |
|---|---|
| 100 messages sent | Sender generates new chainKey_0, distributes to all members |
| 24 hours elapsed | Same as above |
| Member removed | All remaining members rotate immediately |
Post-removal rotation matters because the departing member knew everyone's chain keys up to that point. All members must generate fresh chainKey_0 values and distribute them to every remaining member. Old chain keys are deleted from storage.
Double Ratchet (direct messages)
The Double Ratchet rotates automatically on every direction change in the conversation. No manual rotation is needed. Each ratchet step produces entirely fresh key material via a new DH exchange.
Forward secrecy properties
| Scenario | Groups (Sender Keys) | DMs (Double Ratchet) |
|---|---|---|
| Key compromise (current chain key) | Past messages safe; future messages from this sender exposed until rotation | Past messages safe; future messages safe after next ratchet step |
| Key rotation trigger | Every 100 messages or 24 hours | Every direction change |
| Member removal | All members rotate immediately | N/A |
| Break-in recovery | Requires manual key rotation | Automatic on next DH ratchet step |
AEAD: XChaCha20-Poly1305
All message encryption uses XChaCha20-Poly1305, an authenticated encryption with associated data (AEAD) cipher.
- Key size: 256 bits (32 bytes)
- Nonce size: 192 bits (24 bytes), large enough that random nonces have negligible collision probability
- Authentication: Poly1305 MAC for integrity and authenticity
- Every encryption generates a fresh random nonce. No nonce reuse.
import { encrypt, decrypt } from '@networkselfmd/core/crypto';
const { ciphertext, nonce } = encrypt(key, plaintext);
const decrypted = decrypt(key, nonce, ciphertext);
Key derivation: HKDF-SHA256
All key derivation uses HKDF (HMAC-based Key Derivation Function) with SHA-256.
import { deriveKey, advanceChain } from '@networkselfmd/core/crypto';
// General-purpose key derivation
const derived = deriveKey(inputKeyMaterial, salt, info, 32);
// Chain advancement (for ratcheting)
const { messageKey, nextChainKey } = advanceChain(chainKey);
HKDF context strings used in the protocol:
| Context String | Purpose |
|---|---|
networkselfmd-skd-v1 | Sender Key Distribution encryption |
networkselfmd-msg-v1 | Message key derivation from chain key |
networkselfmd-chain-v1 | Chain key advancement |
networkselfmd-dm-v1 | Double Ratchet root key initialization |
networkselfmd-topic-v1 | Group topic derivation for Hyperswarm |
Key storage
Private keys are encrypted at rest using Argon2id for passphrase-based key derivation:
salt = random(32 bytes)
wrappingKey = argon2id(passphrase, salt, memory=64MB, iterations=3, parallelism=1)
nonce = random(24 bytes)
ciphertext = xchacha20poly1305(wrappingKey, nonce).encrypt(edPrivateKey)
stored = (salt, nonce, ciphertext)
If no passphrase is provided, keys are stored in the SQLite database without additional encryption (the database file should be protected by OS-level file permissions, 0600).
Comparison with Signal protocol
network.self.md's encryption is heavily inspired by Signal, with adaptations for the P2P context:
| Aspect | Signal | network.self.md |
|---|---|---|
| Group encryption | Sender Keys | Sender Keys (same approach) |
| DM encryption | Double Ratchet | Double Ratchet (same approach) |
| Transport | TLS to central server | Noise protocol (Hyperswarm, peer-to-peer) |
| Key distribution | Via Signal server | Pairwise over Hyperswarm connections |
| Identity | Phone number + identity key | Ed25519 public key |
| Trust model | Central server + safety numbers | Trust-On-First-Use (TOFU) |
| Offline delivery | Server queues messages | No server; messages queue locally until reconnect |