Skip to main content
  1. Documentation/
  2. Developer Guide/

Wire Protocol Reference

Table of Contents
Every byte that passes between an agent and the teamserver follows this specification. This page is the authoritative reference for agent implementors.

Overview
#

The TantoC2 wire protocol has three layers:

  1. Envelope — 20-byte header that routes the message to the correct agent package
  2. Crypto — symmetric encryption provided by the agent package’s CryptoProvider
  3. Codec — serialization format provided by the agent package’s ProtocolCodec

The teamserver treats all bytes beyond the 20-byte header as opaque until the registered CryptoProvider decrypts them. This means each agent package can implement its own encryption and serialization scheme independently.

Envelope Format
#

Wire Protocol Format
1
2
3
4
5
6
Offset  Length  Field           Description
------  ------  -----           -----------
0       4       Magic           Agent package identifier. Routes to CryptoProvider + ProtocolCodec.
4       20      Session Token   16-byte random token assigned at registration.
                                All zeros (0x000...000) on the first (registration) message.
20      var     Payload         Encrypted payload. Opaque to the pipeline until decrypted.

Total header: 20 bytes.

Magic Bytes
#

Magic bytes uniquely identify an agent package. The MessagePipeline maintains a dict mapping magic → package_name. When a message arrives:

  1. Extract bytes 0–3
  2. Look up in the magic map
  3. Retrieve the CryptoProvider and ProtocolCodec declared by that package
  4. Dispatch to registration or session handler based on session token

Magic bytes must be exactly 4 bytes and globally unique across all installed agent packages. Collisions cause silent misrouting.

Session Token
#

The session token identifies a registered agent session:

  • All zeros (b"\x00" * 16): Registration message. The pipeline routes to CryptoProvider.create_session().
  • Any other value: Session message. The pipeline looks up the CryptoSession and routes to CryptoProvider.decrypt().

Session tokens are generated by os.urandom(16) inside create_session() and returned to the agent in the registration response. The agent must store and use this token for all subsequent messages.

Registration Flow
#

Agent Registration Handshake

Agent → Server (Registration Request)
#

1
[magic 4B] [null_token 16B] [registration_payload variable]

The registration_payload format is determined by the CryptoProvider. For the dev agent (ECDH + AES-256-GCM):

1
[agent_ecdh_pub_key 91B DER] [codec_payload variable]

The codec payload is the zlib-compressed JSON of a register message:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
{
  "type": "register",
  "payload": {
    "mode": "beacon",
    "beacon_interval": 60,
    "beacon_jitter": 10,
    "metadata": {
      "hostname": "WORKSTATION-1",
      "os": "Linux 6.x",
      "arch": "x64",
      "username": "root"
    }
  }
}

Server Processing
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# pipeline.py — _handle_registration()

1. Look up package by magic bytes
2. Call crypto.create_session(registration_data, server_private_key)
    CryptoSession (session_token, state="established", session_data)
    response_data (server's public key bytes)
3. Decode registration message from registration_data
   (tries offset 0, then offset 91 for dev_crypto P-256 key)
4. Create agent record in database
5. Store session in _sessions, _token_to_agent, _token_to_package maps
6. Emit agent_registered event
7. Build response

Server → Agent (Registration Response)
#

1
[magic 4B] [session_token 16B] [server_pub_key variable] [codec(ack_msg) variable]

The ack message:

1
2
3
4
5
6
7
{
  "type": "register",
  "payload": {
    "agent_id": "uuid-...",
    "session_token": "hex-encoded-token"
  }
}

The agent:

  1. Extracts the server’s public key from the response
  2. Performs ECDH: shared_secret = ECDH(agent_priv, server_pub)
  3. Derives session key: HKDF(shared_secret, info="tantoc2-dev-agent-session")
  4. Stores session_token for all future messages

Both sides now hold the same 32-byte AES-256 key derived from the ECDH shared secret.

Session Messages
#

After registration, all messages use the session token:

Agent → Server
#

1
[magic 4B] [session_token 16B] [encrypted_payload variable]

The pipeline:

  1. Extracts magic and token
  2. Looks up CryptoSession by token
  3. Calls crypto.decrypt(session, encrypted_payload) → plaintext bytes
  4. Calls codec.decode(plaintext)InternalMessage
  5. Dispatches to the appropriate handler
  6. Encodes and encrypts the response

Server → Agent
#

1
[magic 4B] [session_token 16B] [encrypted_response variable]

Built by the pipeline after handler dispatch:

1
2
3
encoded = codec.encode(response_msg)
encrypted = crypto.encrypt(session, encoded)
return pkg.magic_bytes() + session_token + encrypted

Encryption Layer (Dev Crypto Reference)
#

The dev agent uses ECDH P-256 + HKDF-SHA256 + AES-256-GCM. This is the reference implementation — your agent package can use any scheme.

Key Exchange
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Key generation (secp256r1 / NIST P-256)
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()

# DER encoding sizes:
# SubjectPublicKeyInfo (public key): 91 bytes
# PKCS8 (private key): 138 bytes

# ECDH shared secret derivation
shared_secret = server_priv.exchange(ec.ECDH(), agent_pub)  # 32 bytes

# Session key derivation
session_key = HKDF(
    algorithm=hashes.SHA256(),
    length=32,
    salt=None,
    info=b"tantoc2-dev-agent-session",
).derive(shared_secret)                                       # 32 bytes

Encrypted Message Format
#

1
[counter 4B BE] [nonce 12B] [ciphertext variable] [GCM tag 16B]
FieldSizeDescription
Counter4 bytes (big-endian uint32)Monotonic counter for anti-replay. Starts at 0, incremented on each send.
Nonce12 bytesRandom nonce per message. Generated with os.urandom(12).
CiphertextvariableAES-256-GCM encrypted plaintext.
GCM Auth Tag16 bytesAppended by AESGCM — not separate in the wire bytes; part of the ciphertext field.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Encryption
counter = session_data["send_counter"]
nonce = os.urandom(12)
ciphertext_with_tag = AESGCM(session_key).encrypt(nonce, plaintext, None)
wire_payload = struct.pack(">I", counter) + nonce + ciphertext_with_tag
session_data["send_counter"] = counter + 1

# Decryption
counter = struct.unpack(">I", data[:4])[0]
nonce = data[4:16]
ciphertext = data[16:]
# Anti-replay check
if counter < session_data["recv_counter"]:
    raise ValueError(f"Replay detected: counter={counter} expected>={session_data['recv_counter']}")
plaintext = AESGCM(session_key).decrypt(nonce, ciphertext, None)
session_data["recv_counter"] = counter + 1

Key Rotation
#

The server’s background key rotation service periodically rotates session keys. The rotation message b"rotate" signals the agent to derive a new key from the current one:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def rotate_session_key(self, session):
    current_key = bytes.fromhex(session.session_data["session_key"])
    new_key = HKDF(
        algorithm=hashes.SHA256(), length=32, salt=None,
        info=b"tantoc2-dev-agent-key-rotation",
    ).derive(current_key)
    session.session_data["session_key"] = new_key.hex()
    session.session_data["send_counter"] = 0
    session.session_data["recv_counter"] = 0
    return session, b"rotate"

Codec Layer (Dev Codec Reference)
#

The dev agent codec uses JSON + zlib compression.

Decoded Wire Format
#

After CryptoProvider.decrypt(), the bytes are:

1
zlib( JSON_object )

The JSON object:

1
2
3
4
5
6
{
  "type": "<MessageType>",
  "agent_id": "<uuid or null>",
  "payload": { ... },
  "timestamp": "2026-03-23T10:00:00+00:00"
}

Compression
#

1
2
3
4
5
6
7
# Encode: JSON → bytes → compress
json_bytes = json.dumps(data, separators=(",", ":")).encode()
compressed = zlib.compress(json_bytes)

# Decode: decompress → JSON
decompressed = zlib.decompress(compressed)
parsed = json.loads(decompressed)

separators=(",", ":") removes whitespace to minimize payload size before compression.

Message Types
#

All agent–server communication uses these MessageType values (from messages.py):

TypeDirectionDescription
registeragent→server, server→agentInitial registration handshake
checkinagent→serverAgent check-in (heartbeat)
task_assignmentserver→agentDelivery of pending tasks
task_resultagent→serverResult from a completed or streaming task
survey_resultagent→serverAgent metadata update (special case of task_result)
beacon_configserver→agentUpdate check-in interval and jitter
killserver→agentSignal agent to self-destruct
key_rotation_requestserver→agentServer requests key rotation
key_rotation_responseagent→serverAgent acknowledges key rotation
relay_forwardagent→serverRelay agent forwarding interior agent traffic
plugin_messagebidirectionalAgent-package-specific message type

checkin Payload
#

1
2
3
4
5
6
7
8
9
{
  "type": "checkin",
  "payload": {
    "metadata": {
      "hostname": "WORKSTATION-1",
      "pid": 1234
    }
  }
}

The metadata field is optional. When present, it updates the agent’s stored metadata.

task_assignment Payload
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "type": "task_assignment",
  "payload": {
    "tasks": [
      {
        "task_id": "uuid-...",
        "task_type": "shell",
        "payload": { "command": "whoami" }
      }
    ],
    "relay_responses": [
      {
        "target_token": "hex-encoded-session-token",
        "data": "base64-encoded-wire-bytes"
      }
    ]
  }
}

relay_responses is only present when the receiving agent is acting as a P2P relay.

task_result Payload
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "type": "task_result",
  "payload": {
    "task_id": "uuid-...",
    "streaming": false,
    "data": {
      "output": "root\n",
      "exit_code": 0
    }
  }
}

Set "streaming": true for intermediate results from long-running tasks.

relay_forward Payload
#

1
2
3
4
5
6
{
  "type": "relay_forward",
  "payload": {
    "inner_message": "<base64-encoded raw wire bytes from interior agent>"
  }
}

The pipeline decodes inner_message, processes it as if it arrived directly, and queues the response to be returned in the relay agent’s next task_assignment.

Server Response to check-in
#

The pipeline handles checkin in _handle_checkin():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def _handle_checkin(self, message, db_session):
    # Update agent last-seen and metadata
    self._agent_manager.process_checkin(db_session, message.agent_id, metadata)

    # Get pending tasks
    pending = self._agent_manager.get_pending_tasks(db_session, message.agent_id)
    tasks_data = [{"task_id": t.id, "task_type": t.task_type, "payload": ...} for t in pending]

    # Include relay responses if this is a relay agent
    relay_responses = self._relay_responses.pop(relay_token, [])

    return InternalMessage(
        msg_type=MessageType.TASK_ASSIGNMENT,
        payload={"tasks": tasks_data, "relay_responses": relay_responses},
    )

Implementation Checklist
#

For a new agent binary:

  • Send registration with null session token (16 × 0x00)
  • Include the full 20-byte header on every message
  • Parse server’s public key from registration response bytes 20 onward
  • Perform the same key derivation (ECDH + HKDF) as the server
  • Store session token from bytes 4–19 of the response
  • Include counter + nonce in every encrypted payload
  • Validate that received counter is >= recv_counter (anti-replay)
  • Handle task_assignment responses: extract and execute tasks
  • Send task_result for each completed task (include task_id)
  • Send survey_result for survey tasks (includes data dict)
  • Respect beacon_config updates: change interval and jitter
  • Enforce kill date: exit cleanly without contacting the server
  • Support key rotation if rotate_session_key is used by your package

See Building Agent Packages for the full implementation guide.