Salty IM v2 Specification

Version Last Modified / Changelog
v2.0 2026-02-22

Introduction

Salty IM v2 is an open specification for an end-to-end encrypted messaging protocol with forward secrecy and self-healing properties, built on the Double Ratchet algorithm and X3DH key agreement.

The v1 protocol used Saltpack sigcrypt for message encryption. While simple and effective, it lacked forward secrecy — compromise of a long-term key would expose all past messages. The v2 protocol addresses this by introducing session-based encryption where message keys are continuously ratcheted forward. Compromise of a single message key cannot reveal past or future messages.

The core design goals remain unchanged from v1:

Note: v2 is NOT backwards-compatible with v1. The v1 specification remains available at spec-v1.html.

Protocol

Alice wants to message Bob:

  1. Alice creates an Ed25519 Private/Public key pair
  2. Alice publishes her Public Key to her Domain at a Well-Known URI
  3. Alice looks up Bob’s Public Key and Endpoint via Discovery
  4. Alice sends a Session Offer to Bob’s Endpoint containing her X3DH parameters
  5. Bob receives the Offer, computes the shared secret, and sends an Acknowledgement
  6. Alice receives the Ack and completes the X3DH handshake
  7. Alice and Bob now exchange Data Messages encrypted with the Double Ratchet

Once a session is established, Alice and Bob can exchange messages indefinitely. Either party may Close the session and establish a new one at any time.

Keys

Salty IM uses Ed25519 keys for identity, the same as v1. All cryptography for key agreement uses X25519, derived from the Ed25519 identity keys via the standard RFC 7748 conversion.

A User creates an Ed25519 Private/Public key pair on first setup and stores the Private Key securely. The Public Key is published alongside the User’s Well-Known Configuration file.

Discovery

Discovery is unchanged from v1. A User must publish their Public Key along with an Endpoint for which to receive messages on.

Delegation

Before requesting the Well-Known URI, the client should do a SRV lookup for_salty._tcp.domain.tld:

1$ dig +short SRV _salty._tcp.domain.tld
20 0 443 other.domain.tld

If this results in a found SRV record it should be used in place ofdomain.tld. In case of multiple records, sort by priority and randomise by weight within a priority. Then select the first result.

Well Known URI

This is published on a Well-Known URI that has the path:/.well-known/salty/<hash>.json where:

The resource must be served on at least a TLSv1.2-capable web server or better and TLSv1.3 is encouraged where possible.

Cross-Origin Resource Sharing (CORS)

It is encouraged (but optional) that appropriate Cross-Origin Resource Sharing (CORS) headers be set-up on the server that services the Well-Known URI and Inbox endpoint. For example the following response headers are generally recommended:

Access-Control-Allow-Headers: *
Access-Control-Allow-Origin: *

Directory Listing

It is recommended that Directory Listing be disabled for serving the Well-Known URI resource(s) to prevent crawlers and bad actors from harvesting Salty addresses.

Discovery Document

The contents of a User’s Well-Known Configuration file contains the following JSON document:

1{
2    "endpoint": "https://domain.tld/path",
3    "key": "xxx"
4}

This is identical to the v1 format. The key field contains the Ed25519 public key encoded using the keys.pub kex1 encoding.

Session Establishment

Before exchanging messages, two parties must establish a session using the Extended Triple Diffie-Hellman (X3DH) key agreement protocol.

Overview

  1. Alice generates a Signed Prekey (SPK) and sends an Offer to Bob
  2. Bob receives the Offer, generates an ephemeral key, computes the shared secret, and sends an Acknowledgement back to Alice
  3. Alice receives the Ack, computes the same shared secret, and the session is established
  4. Both parties initialise a Double Ratchet with the shared secret

X3DH Key Agreement

The X3DH handshake computes a shared secret from three Diffie-Hellman exchanges:

Alice (initiator) computes:

DH1 = X25519(Alice_IK_private, Bob_SPK_public)
DH2 = X25519(Alice_EK_private, Bob_IK_public)
DH3 = X25519(Alice_EK_private, Bob_SPK_public)

Bob (responder) computes:

DH1 = X25519(Bob_SPK_private, Alice_IK_public)
DH2 = X25519(Bob_IK_private, Alice_EK_public)
DH3 = X25519(Bob_SPK_private, Alice_EK_public)

Where:

The shared secret is derived using HKDF-SHA256:

input  = DH1 || DH2 || DH3          (96 bytes)
salt   = 0x00 repeated 32 times      (32 bytes)
info   = 0xFF                         (1 byte)
SK     = HKDF(salt, input, info, 32)  (32 bytes)

The Associated Data (AD) for the session is:

AD = Alice_IK_public || Bob_IK_public  (64 bytes)

Offer Message (Type 1)

An Offer message initiates a new session. It contains the initiator’s identity key, signed prekey, and session identifier.

Wire format:

!RAT!1<base64-payload>!CHT!

Payload structure:

| Offset   | Length   | Field       | Description                      |
|----------|----------|-------------|----------------------------------|
| 0        | 32 bytes | ID Key      | Alice's Ed25519 public key       |
| 32       | 32 bytes | SPK         | Alice's X25519 signed prekey     |
| 64       | 64 bytes | SPK Sig     | Ed25519 signature of the SPK     |
| 128      | 16 bytes | Session ID  | ULID identifying this session    |
| 144      | variable | Nick        | Alice's address (nick@domain)    |

The SPK Signature allows Bob to verify that the signed prekey was generated by the owner of the identity key.

Acknowledgement Message (Type 2)

An Ack message completes the X3DH handshake. It contains the responder’s identity key, ephemeral key, and an initial encrypted payload proving the shared secret was correctly derived.

Wire format:

!RAT!2<base64-payload>!CHT!

Payload structure:

| Offset   | Length   | Field       | Description                      |
|----------|----------|-------------|----------------------------------|
| 0        | 32 bytes | ID Key      | Bob's Ed25519 public key         |
| 32       | 32 bytes | EK          | Bob's X25519 ephemeral public key|
| 64       | 16 bytes | Session ID  | Alice's session ULID (echo back) |
| 80       | variable | Ciphertext  | DR-encrypted initial payload     |

Encrypted payload (plaintext before encryption):

| Offset   | Length   | Field         | Description                    |
|----------|----------|---------------|--------------------------------|
| 0        | 16 bytes | Session ID    | Bob's session ULID             |
| 16       | variable | Random padding| Random bytes (padded to 100)   |

The initial payload is encrypted using the Double Ratchet with the shared secret from the X3DH exchange. This proves both parties derived the same key.

Sealed Offer (Type 5)

A Sealed Offer wraps an Offer message in a NaCl anonymous box, encrypting it to the recipient’s X25519 public key. This prevents observers from seeing the offer contents in transit, providing additional privacy for the handshake.

Wire format:

!RAT!5<base64-payload>!CHT!

Payload structure:

| Offset   | Length   | Field   | Description                          |
|----------|----------|---------|--------------------------------------|
| 0        | variable | Sealed  | NaCl anonymous box encrypted content |

Decrypted content:

| Offset | Length   | Field   | Description                            |
|--------|----------|---------|----------------------------------------|
| 0      | 1 byte   | Type    | ASCII digit of inner message type      |
| 1      | variable | Content | Inner message payload                  |

Messages

Format

Messages use the same TAB-delimited format as v1:

<timestamp>\t(<sender>)\t<message>

Newlines within messages are encoded as the Unicode LINE SEPARATOR character (\u2028).

Comment lines beginning with # are used for events (e.g., read receipts) and must be ignored by clients for display purposes.

Events

The currently supported events are:

Data Message (Type 3)

Data messages carry encrypted content between parties with an established session.

Wire format:

!RAT!3<base64-payload>!CHT!

Payload structure:

| Offset   | Length   | Field       | Description                      |
|----------|----------|-------------|----------------------------------|
| 0        | 16 bytes | Session ID  | Remote party's session ULID      |
| 16       | variable | Ciphertext  | Double Ratchet encrypted message  |

The Session ID identifies which session on the remote side should decrypt this message. This allows a party to maintain multiple concurrent sessions.

Close Message (Type 4)

A Close message terminates an established session. After receiving a Close, the recipient should discard the session state for that session.

Wire format:

!RAT!4<base64-payload>!CHT!

Payload structure:

| Offset   | Length   | Field       | Description                      |
|----------|----------|-------------|----------------------------------|
| 0        | 16 bytes | Session ID  | Remote party's session ULID      |
| 16       | variable | Ciphertext  | DR-encrypted payload (0xFF)      |

The encrypted payload must decrypt to a single byte 0xFF. Any other content must be rejected.

Encryption

Double Ratchet

Once a session is established via X3DH, all messages are encrypted using the Double Ratchet algorithm. The Double Ratchet combines three ratcheting mechanisms:

  1. DH Ratchet — Each party periodically generates new X25519 key pairs and performs DH exchanges, providing forward secrecy and self-healing
  2. Root Chain Ratchet — Derives new root keys and chain keys from DH outputs using HKDF-SHA256
  3. Sending/Receiving Chain Ratchet — Derives per-message keys using HMAC-SHA256

Message Header

Each encrypted message includes a header with the sender’s current DH public key and message counters:

| Offset | Length   | Field       | Description                        |
|--------|----------|-------------|------------------------------------|
| 0      | 32 bytes | DH Public   | Sender's current X25519 DH pub key |
| 32     | 2 bytes  | Prev No     | Message count in previous chain     |
| 34     | 2 bytes  | Msg No      | Message number in current chain     |

Total header length: 36 bytes.

Key Derivation

Root Key Derivation (KDF_RK):

input  = DH output (32 bytes)
salt   = current root key (32 bytes)
info   = 0x02 (1 byte)
output = HKDF-SHA256(salt, input, info, 64)
         → first 32 bytes: new root key
         → last 32 bytes: new chain key

Chain Key Derivation (KDF_CK):

chain_key_next = HMAC-SHA256(chain_key, 0x00)
message_key    = HMAC-SHA256(chain_key, 0x01)

Encryption Parameters:

input  = message key (32 bytes)
salt   = 0x00 repeated 32 times
info   = 0x03 (1 byte)
output = HKDF-SHA256(salt, input, info, 80)
         → bytes 0-31:  encryption key (AES-256)
         → bytes 32-63: authentication key (HMAC-SHA256)
         → bytes 64-79: IV (AES-CBC initialisation vector)

AEAD Encryption

Message encryption uses AES-256-CBC with HMAC-SHA256 for authentication:

  1. Pad the plaintext using PKCS#7 to AES block size (16 bytes)
  2. Encrypt with AES-256-CBC using the derived encryption key and IV
  3. Authenticate by computing HMAC-SHA256 over the associated data and ciphertext using the derived authentication key
  4. Output: ciphertext || HMAC (HMAC is 32 bytes)

Decryption verifies the HMAC (using constant-time comparison) before decrypting.

Out-of-Order Messages

The Double Ratchet supports out-of-order message delivery by caching skipped message keys:

Delivery

Delivery is unchanged from v1. Once a message has been encrypted, it is delivered to the recipient’s Endpoint via a simple HTTP POST request:

1curl --data-binary "@./message.enc" https://domain.tld/user

The Endpoint must respond with a 202 Accepted on success or a400 Bad Request on any error.

The Endpoint must be served on at least a TLSv1.2-capable web server or better and TLSv1.3 is encouraged where possible.

Wire Format Summary

All v2 messages use the following wire encoding:

!RAT!<type><base64-payload>!CHT!

Where <type> is a single ASCII digit:

Type Name Description
1 Offer X3DH session initiation
2 Ack X3DH session acknowledgement
3 Data Double Ratchet encrypted message
4 Close Session termination
5 Sealed NaCl anonymous box wrapping another message

The <base64-payload> uses URL-safe Base64 encoding without padding (RFC 4648 Section 5).

Interoperability

The v2 protocol is NOT backwards-compatible with v1:

The v1 specification remains available for reference. Implementations should clearly indicate which protocol version they support.

Reference Implementation

The reference implementation of the v2 protocol is available at:

Changes

2026-02-22