Overview#
The TantoC2 wire protocol has three layers:
- Envelope — 20-byte header that routes the message to the correct agent package
- Crypto — symmetric encryption provided by the agent package’s
CryptoProvider - 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#
| |
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:
- Extract bytes 0–3
- Look up in the magic map
- Retrieve the
CryptoProviderandProtocolCodecdeclared by that package - 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 toCryptoProvider.create_session(). - Any other value: Session message. The pipeline looks up the
CryptoSessionand routes toCryptoProvider.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 → Server (Registration Request)#
| |
The registration_payload format is determined by the CryptoProvider. For the dev agent (ECDH + AES-256-GCM):
| |
The codec payload is the zlib-compressed JSON of a register message:
| |
Server Processing#
| |
Server → Agent (Registration Response)#
| |
The ack message:
| |
The agent:
- Extracts the server’s public key from the response
- Performs ECDH:
shared_secret = ECDH(agent_priv, server_pub) - Derives session key:
HKDF(shared_secret, info="tantoc2-dev-agent-session") - Stores
session_tokenfor 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#
| |
The pipeline:
- Extracts magic and token
- Looks up
CryptoSessionby token - Calls
crypto.decrypt(session, encrypted_payload)→ plaintext bytes - Calls
codec.decode(plaintext)→InternalMessage - Dispatches to the appropriate handler
- Encodes and encrypts the response
Server → Agent#
| |
Built by the pipeline after handler dispatch:
| |
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#
| |
Encrypted Message Format#
| |
| Field | Size | Description |
|---|---|---|
| Counter | 4 bytes (big-endian uint32) | Monotonic counter for anti-replay. Starts at 0, incremented on each send. |
| Nonce | 12 bytes | Random nonce per message. Generated with os.urandom(12). |
| Ciphertext | variable | AES-256-GCM encrypted plaintext. |
| GCM Auth Tag | 16 bytes | Appended by AESGCM — not separate in the wire bytes; part of the ciphertext field. |
| |
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:
| |
Codec Layer (Dev Codec Reference)#
The dev agent codec uses JSON + zlib compression.
Decoded Wire Format#
After CryptoProvider.decrypt(), the bytes are:
| |
The JSON object:
| |
Compression#
| |
separators=(",", ":") removes whitespace to minimize payload size before compression.
Message Types#
All agent–server communication uses these MessageType values (from messages.py):
| Type | Direction | Description |
|---|---|---|
register | agent→server, server→agent | Initial registration handshake |
checkin | agent→server | Agent check-in (heartbeat) |
task_assignment | server→agent | Delivery of pending tasks |
task_result | agent→server | Result from a completed or streaming task |
survey_result | agent→server | Agent metadata update (special case of task_result) |
beacon_config | server→agent | Update check-in interval and jitter |
kill | server→agent | Signal agent to self-destruct |
key_rotation_request | server→agent | Server requests key rotation |
key_rotation_response | agent→server | Agent acknowledges key rotation |
relay_forward | agent→server | Relay agent forwarding interior agent traffic |
plugin_message | bidirectional | Agent-package-specific message type |
checkin Payload#
| |
The metadata field is optional. When present, it updates the agent’s stored metadata.
task_assignment Payload#
| |
relay_responses is only present when the receiving agent is acting as a P2P relay.
task_result Payload#
| |
Set "streaming": true for intermediate results from long-running tasks.
relay_forward Payload#
| |
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():
| |
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_assignmentresponses: extract and execute tasks - Send
task_resultfor each completed task (includetask_id) - Send
survey_resultfor survey tasks (includesdatadict) - Respect
beacon_configupdates: change interval and jitter - Enforce kill date: exit cleanly without contacting the server
- Support key rotation if
rotate_session_keyis used by your package
See Building Agent Packages for the full implementation guide.