Sessions

Session lifecycle and management

Sessions

Reference for OctoMY™'s communication sessions including lifecycle, states, and management.

Pro Tip

Session statistics (heartbeatRTT, connected, expired) are invaluable for debugging connectivity issues. High RTT often indicates network congestion or NAT timeout issues - consider adjusting FlowControl parameters if you see frequent disconnects.


Session overview

A CommsSession represents an active communication channel between two nodes. Unlike a traditional TCP connection, sessions are tracked optimistically: they record recent communication partners with state such as last seen timestamp, address, cryptographic nonces, and flow control metrics. A session can be in various stages from freshly created through handshake to connected to expired.

Session Overview


Session states

Handshake state machine

The handshake uses an INITIATOR / ADHERENT role model with a three-step SYN / SYN_ACK / ACK exchange. There is no separate "Closed" or "Closing" state -- sessions are created when needed and expire after inactivity.

Session State Diagram

HandshakeStep enum

Step Description
SYN Start handshake (INITIATOR sends, ADHERENT receives)
SYN_ACK Continue handshake (ADHERENT sends, INITIATOR receives)
ACK Complete handshake (INITIATOR sends, ADHERENT receives)
NONE Handshake is done -- session is established

Session status flags

Flag Type Description
connected bool Active data exchange occurring (reset on receive, cleared on disconnect timeout)
expired bool Session timed out after prolonged inactivity (default 5 minutes)
established() bool Handshake complete (handshakeState().isDone())

Role assignment

When both sides attempt to initiate simultaneously (both send SYN), an IDDuel resolves the conflict: a deterministic comparison of the two node IDs decides which side becomes INITIATOR and which becomes ADHERENT. The loser deletes its session and creates a new one in the ADHERENT role.


Session lifecycle

Connection establishment

// Session creation (called by CommsChannel / Comms)
// initiator=true for the side that starts the handshake
auto session = comms->createSession(associateId, /*initiator=*/true);

// The handshake state machine progresses automatically:
// INITIATOR: sends SYN  ->  receives SYN_ACK  ->  sends ACK     -> established
// ADHERENT:  receives SYN -> sends SYN_ACK     ->  receives ACK  -> established

// Check completion
if (session->established()) {
    // Handshake is done, courier data can flow
}

Active session

// Courier data is sent via the CommsChannel scheduling loop.
// Each Courier registered with the Comms layer gets periodic
// sendingOpportunity() calls when its mandate is active and
// the destination session is established.

// Check connection status
if (session->connected()) {
    // Recent data has been exchanged
}

Session expiration

// Sessions do not have an explicit close. They expire:
// - Disconnect timeout (default 3s without send activity)
//   sets connected=false
// - Expire timeout (default 5 minutes of inactivity)
//   sets expired=true, session is pruned from directory

// Handshake timeout (default 30s) expires incomplete handshakes
if (session->isHandshakeTimedOut(now)) {
    // Session will be removed from directory
}

Session properties

Identity

Property Type Description
localSessionID SESSION_ID_TYPE (quint16) Local side's session ID (randomly generated, unique in directory)
remoteSessionID SESSION_ID_TYPE (quint16) Remote side's session ID
fullID() QString Associate ID -- 64-char hex SHA256 of the remote node's public key
address QSharedPointer<CarrierAddress> Peer's network address

Security

Property Type Description
sessionKey QByteArray 32-byte key derived during handshake for AEAD encryption
mSessionKeyReady bool Whether sessionKey has been derived
mTxNonceCounter quint64 Monotonically increasing counter for outgoing AEAD nonces
mRxNonceHighWatermark quint64 Highest received nonce counter for replay detection
mRxNonceWindow quint64 64-bit sliding window bitmap for out-of-order replay detection
mKeyMaterial QByteArray 32-byte random material for key-transport path (RSA / cross-algorithm)

Handshake nonces

Property Type Description
mOurSynNonce SESSION_NONCE_TYPE (quint32) Nonce generated for our SYN
mOurSynAckNonce SESSION_NONCE_TYPE (quint32) Nonce generated for our SYN_ACK
mOurAckNonce SESSION_NONCE_TYPE (quint32) Nonce generated for our ACK
mTheirLastNonce SESSION_NONCE_TYPE (quint32) Last nonce received from peer

Timing and flow

Property Type Description
mHeartbeatRTT quint64 Round-trip time measured via SessionCourier heartbeat echo (ms)
mLastSendTime qint64 Timestamp of last packet sent
mLastReceiveTime qint64 Timestamp of last packet received
mDisconnectTimeout qreal Seconds without send before connected goes false (default 3.0)
mExpireTimeout quint64 Milliseconds of inactivity before session expires (default 300000)

Session directory

CommsSessionDirectory

Manages all active sessions. Sessions are indexed by session ID, full ID (associate ID), and network address.

class CommsSessionDirectory : public QObject {
public:
    // Session lookup
    QSharedPointer<CommsSession> bySessionID(SESSION_ID_TYPE id) const;
    QSharedPointer<CommsSession> byFullID(const QString& id) const;
    QSharedPointer<CommsSession> byAddress(const QSharedPointer<CarrierAddress>& addr) const;

    // Session sets
    QSet<QSharedPointer<CommsSession>> all() const;
    QSet<QSharedPointer<CommsSession>> byActiveTime(quint64 lastActiveTime) const;
    QSet<QSharedPointer<CommsSession>> byIdleTime(quint64 lastActiveTime) const;

    // Session management
    void insert(const QSharedPointer<CommsSession>& session);
    void remove(const QSharedPointer<CommsSession>& session);
    SESSION_ID_TYPE generateUnusedSessionID();

    int count();
    bool hasHandshakers();
    int countHandshakers();
    quint32 prune();  // Remove expired sessions

signals:
    void clientAdded(QSharedPointer<CommsSession>);
};

Session lookup

// Find session by associate ID (64-char hex SHA256)
auto session = directory->byFullID("A3F1...");

// Find session by local session ID
auto session = directory->bySessionID(sessionID);

// Iterate all sessions
for (const auto& session : directory->all()) {
    qDebug() << session->signatureToString();
}

Handshake details

Handshake state machine

Handshake State Machine

Handshake packet format

All three handshake packets share a common outer structure. The first two bytes are the multimagic (packet type discriminator). The remainder is encrypted with the recipient's public key.

SYN (INITIATOR to ADHERENT):

Encrypted with recipient's public key:

Field Size Description
senderID 32 bytes SHA256 of sender's public key (raw binary)
destinationID 32 bytes SHA256 of intended recipient's public key (prevents cross-nonce pollution)
desiredSessionID 2 bytes SESSION_ID_TYPE -- our local session ID
nonce 4 bytes SESSION_NONCE_TYPE -- random SYN nonce
keyMaterial 32 bytes Random bytes for session key derivation (only when encryption enabled)

SYN_ACK (ADHERENT to INITIATOR):

Encrypted with recipient's public key:

Field Size Description
senderID 32 bytes SHA256 of sender's public key (raw binary)
desiredSessionID 2 bytes SESSION_ID_TYPE -- our local session ID
returnNonce 4 bytes Echo of the INITIATOR's SYN nonce (proves we received it)
ourNonce 4 bytes SESSION_NONCE_TYPE -- fresh SYN_ACK nonce
keyMaterialHash 32 bytes SHA256(keyMaterial) -- confirms receipt of key material (only when encryption enabled)

ACK (INITIATOR to ADHERENT):

Encrypted with recipient's public key:

Field Size Description
senderID 32 bytes SHA256 of sender's public key (raw binary)
returnNonce 4 bytes Echo of the ADHERENT's SYN_ACK nonce (proves we received it)

Key derivation

After the three-way handshake completes, both sides derive the session key. There are two paths depending on the key algorithms in use:

Ed25519 <-> Ed25519 (ECDH path):

// Convert Ed25519 keys to X25519 for Diffie-Hellman
auto myX25519Secret = Ecc::ed25519SecretToX25519(localKey->rawSecretKeyBytes());
auto theirX25519Public = Ecc::ed25519PublicToX25519(remoteKey->rawPublicKeyBytes());
QByteArray sharedSecret = Ecc::x25519KeyExchange(myX25519Secret, theirX25519Public);

// Derive session key: SHA256(sharedSecret || synNonce || synAckNonce)
QCryptographicHash hash(QCryptographicHash::Sha256);
hash.addData(sharedSecret);
hash.addData(synNonceBytes);      // big-endian, 4 bytes
hash.addData(synAckNonceBytes);   // big-endian, 4 bytes
sessionKey = hash.result();        // 32 bytes

RSA or cross-algorithm (key-transport path):

// The INITIATOR generates 32 random bytes (keyMaterial) and
// sends them in the SYN, encrypted with the recipient's public key.
// The ADHERENT confirms receipt by echoing SHA256(keyMaterial) in SYN_ACK.

// Derive session key: SHA256(keyMaterial || synNonce || synAckNonce)
QCryptographicHash hash(QCryptographicHash::Sha256);
hash.addData(keyMaterial);         // 32 bytes from SYN
hash.addData(synNonceBytes);      // big-endian, 4 bytes
hash.addData(synAckNonceBytes);   // big-endian, 4 bytes
sessionKey = hash.result();        // 32 bytes

In both paths, the nonce roles are determined by the session role: the INITIATOR uses ourSynNonce as synNonce and the ADHERENT's nonce as synAckNonce, and vice versa.


Reliability and flow control

Design

Per-packet reliability headers were removed from the session-level protocol. Instead:

  • ReliabilitySystem is a utility class available to individual couriers that need reliable delivery. It maintains sequence numbers, ack bitmasks, and pending/acked packet queues.
  • RTT is measured via SessionCourier heartbeat echo timestamps and stored in mHeartbeatRTT.
  • FlowControl adjusts the send rate based on the heartbeat RTT, using a Good/Bad mode with penalty-based backoff.

ReliabilitySystem

class ReliabilitySystem {
public:
    void packetSent(qint32 size);
    void packetReceived(quint32 sequence, qint32 size);
    quint32 generateAckBits();
    void processAck(quint32 ack, quint32 ack_bits);
    void update(float deltaTime);

    // Retransmission support
    void setRetransmissionCallback(RetransmissionCallback callback);
    void checkForRetransmissions();

    // Statistics
    quint32 sentPackets() const;
    quint32 receivedPackets() const;
    quint32 lostPackets() const;
    float sentBandwidth() const;
    float ackedBandwidth() const;
    float roundTripTime() const;
};

Couriers that need reliability use this system via session->reliabilitySystem(). Couriers that do not need reliability (e.g., status updates, sensor data) simply send best-effort and let stale data be overwritten by newer packets.

FlowControl

class FlowControl {
public:
    enum Mode { Good, Bad };

    void reset();
    void update(quint64 deltaTime, quint64 rtt);
    quint64 sendRate() const;
};

FlowControl is updated each send cycle with the heartbeat RTT. It switches between Good and Bad modes: when RTT exceeds thresholds, the mode degrades and the send rate is reduced. A penalty timer prevents rapid mode oscillation.


Session events

Key signals (from CommsChannel)

class CommsChannel : public QObject {
signals:
    void sessionConnected(const QString& fullID);   // Handshake completed
    void sessionExpired(const QString& fullID);      // Session timed out and was removed
    void handshakePacket(bool received);             // Handshake packet sent or received
    void dataPacket(bool received);                  // Data packet sent or received
    void packetActivity();                           // Any packet activity
    void commsError(const QString& message);         // Protocol error
    void trustNacked(const QString& senderID);       // Explicit trust rejection
};

Session timeouts

Timeout configuration

Parameter Default Description
Handshake timeout 30s Max time for incomplete handshake before session is removed
Disconnect timeout 3s Time without send activity before connected goes false
Expire timeout 5 min Inactivity period before session is marked expired and pruned
Handshake cooldown 60s Delay before retrying handshake with a failed associate
Handshake retry backoff 500ms-8s Exponential backoff for handshake retransmission

Timeout handling

// In rescheduleSending(), each tick:

// 1. Check for handshake timeouts and abandonment
for (const auto& session : sessions().all()) {
    if (!session->established()) {
        bool timedOut = session->isHandshakeTimedOut(now);
        bool abandoned = session->handshakeState().shouldAbandon(now);
        if (timedOut || abandoned) {
            sessions().remove(session);
            // 60s cooldown before retrying
        }
    }
}

// 2. Disconnect detection happens in countSend():
//    If disconnectTimeoutAccumulator > disconnectTimeout (3s),
//    connected is set to false.

// 3. Expire detection also in countSend():
//    If expireTimeoutAccumulator > expireTimeout (5 min),
//    expired is set to true, and session is pruned on next prune() call.

Session security

AEAD encryption

All established sessions with encryption enabled use XChaCha20-Poly1305 AEAD (via oc_crypt::Ecc::aeadEncrypt / aeadDecrypt).

Sending (in sendCourierData)

// 1. Write session ID as multimagic (2 bytes, unencrypted)
state.writeMultimagic(session->remoteSessionID());

// 2. Write nonce counter (8 bytes, unencrypted -- needed by receiver)
quint64 nonceCounter = session->nextTxNonce();  // monotonically increasing
*state.stream << nonceCounter;

// 3. Build plaintext: [courierID:4][payloadSize:2][payload...]
QByteArray plaintext;
// ... courier writes its data ...

// 4. Build 24-byte AEAD nonce: [counter:8 big-endian][zeros:16]
QByteArray aeadNonce(24, 0);
qToBigEndian(nonceCounter, aeadNonce.data());

// 5. AEAD encrypt
QByteArray ciphertext = Ecc::aeadEncrypt(plaintext, session->sessionKey(), aeadNonce);

// 6. Write ciphertext (includes Poly1305 auth tag)
state.stream->writeRawData(ciphertext.constData(), ciphertext.size());

Receiving (in recieveData)

// 1. Session ID already read as multimagic
// 2. Read nonce counter
quint64 nonceCounter;
*state.stream >> nonceCounter;

// 3. Replay detection (sliding window)
if (!session->checkAndUpdateRxNonce(nonceCounter)) {
    return;  // Replay or too-old nonce
}

// 4. Read remaining bytes as ciphertext
QByteArray ciphertext(state.totalAvailable, 0);
state.stream->readRawData(ciphertext.data(), state.totalAvailable);

// 5. Build 24-byte AEAD nonce from counter
QByteArray aeadNonce(24, 0);
qToBigEndian(nonceCounter, aeadNonce.data());

// 6. AEAD decrypt (returns empty on auth failure)
QByteArray plaintext = Ecc::aeadDecrypt(ciphertext, session->sessionKey(), aeadNonce);

Wire format summary

Data packet (encrypted):
[sessionID:2][nonceCounter:8][AEAD ciphertext + Poly1305 tag]

Data packet (unencrypted):
[sessionID:2][courierID:4][payloadSize:2][payload...]

Replay detection

The sliding window mechanism in checkAndUpdateRxNonce accepts nonces that are:

  • Higher than the current high watermark (advances the window), or
  • Within 64 of the high watermark and not yet seen (checked via bitmask)

Nonces more than 64 behind the watermark or already seen are rejected.

Session key lifetime

Session keys are derived once at handshake completion and are not rotated during the session lifetime. A new handshake (new session) produces a new key. Key material is securely wiped from memory when the session is destroyed.


Multimagic dispatch

The first two bytes of every packet are the multimagic field (SESSION_ID_TYPE = quint16). Reserved values below MULTIMAGIC_LAST (currently 9) identify protocol-level packet types. Values at or above FIRST_STATE_ID (= MULTIMAGIC_LAST) are interpreted as session IDs for courier data dispatch.

Multimagic value Meaning
0 MULTIMAGIC_IDLE -- keepalive/discovery
1 MULTIMAGIC_SYN -- handshake initiation
2 MULTIMAGIC_SYNACK -- handshake response
3 MULTIMAGIC_ACK -- handshake completion
4-7 LAN discovery protocol
8 MULTIMAGIC_NACK -- explicit trust rejection
>= 9 Session ID -- courier data for that session

Multiple sessions

Session per peer

Each remote associate has at most one session. The CommsSessionDirectory indexes sessions by full ID (associate ID), session ID, and address.

Session Per Peer


Debugging sessions

Session status

void debugSession(CommsSession* session) {
    qDebug() << "Signature:" << session->signatureToString();
    qDebug() << "Full ID:" << session->fullID().left(16);
    qDebug() << "Local session ID:" << session->localSessionID();
    qDebug() << "Remote session ID:" << session->remoteSessionID();
    qDebug() << "Address:" << (session->address() ? session->address()->toString() : "NULL");
    qDebug() << "Established:" << session->established();
    qDebug() << "Connected:" << session->connected();
    qDebug() << "Expired:" << session->expired();
    qDebug() << "Heartbeat RTT:" << session->heartbeatRTT() << "ms";
    qDebug() << "Session key ready:" << session->sessionKeyReady();
    qDebug() << "Handshake:" << session->handshakeState().toString();
}

Common issues

Issue Cause Solution
Stuck in handshake Firewall blocking UDP Open the UDP port used by the carrier
Frequent disconnects NAT timeout < 3s Reduce disconnect timeout or increase keepalive frequency
Handshake restarts (duel) Both sides initiate simultaneously Normal behavior -- IDDuel resolves it automatically
AEAD decryption failures Key mismatch or corrupted packet Re-pair nodes (forces new handshake and key derivation)
Session expired prematurely Expire timeout too short Increase mExpireTimeout (default 5 minutes)
NACK received Remote does not trust this node Check that nodes are properly paired in their address books

In this section
Topics
reference sessions networking encryption state-machine
See also