aws-ssm-bridge

Wire protocol

The AWS Session Manager wire format: the 120-byte binary header, MessageId byte order, acknowledgements and retransmission, smux port forwarding, and KMS session encryption.

Establishing a session

ssm:StartSession returns three things: a session ID, a wss:// stream URL for the Amazon Message Gateway Service, and a token.

Establishing an SSM sessionThe client calls StartSession on the SSM API, receives a session ID, stream URL and token, opens a WebSocket to the Message Gateway, authenticates with the token in the open message, and then completes a three-message handshake with the SSM agent.ClientSSM APIMessage GatewaySSM agentStartSession(target, document)SessionId, StreamUrl, TokenValueWebSocket upgrade (StreamUrl)OpenDataChannelInput { TokenValue, ClientId, … }start_publicationHandshakeRequestHandshakeResponseHandshakeCompletesession is ready

The token goes in the open message, never in the URL. The reference plugin does the same, and the reason matters: a URL is recorded by proxies, connection traces and crash reports; a WebSocket payload is not.

Message format

Every binary frame is a 120-byte header followed by a payload. All integers are big-endian.

OffsetSizeFieldNotes
04HeaderLengthAlways 116 — the header size excluding this field
432MessageTypeASCII, space-padded
364SchemaVersionAlways 1
408CreatedDateUnix milliseconds
488SequenceNumberPer direction, starts at 0
568FlagsSYN = 1, FIN = 2
6416MessageIdUUID — see below
8032PayloadDigestSHA-256 of the payload
1124PayloadTypeSee the table below
1164PayloadLength
120Payload

The MessageId trap

The SSM agent stores UUIDs the way Java does — as two longs — and writes the least-significant half first. That is not RFC 4122 order.

RFC 4122:  [ MSB 0..8 ][ LSB 8..16 ]
On the wire: [ LSB 8..16 ][ MSB 0..8 ]

Getting this wrong is quiet rather than loud: the agent cannot match your acknowledgements to its messages, so it retransmits everything forever and the session appears to hang. binary_protocol.rs pins the byte layout with a test for exactly this reason.

Message types

TypeDirectionMeaning
input_stream_dataclient → agentKeystrokes, control payloads
output_stream_dataagent → clientOutput, handshake, exit codes
acknowledgebothConfirms one sequence number
channel_closedagent → clientThe session is over; a JSON body's Output field says why
start_publicationgateway → clientThe client may start sending
pause_publicationgateway → clientThe client should stop sending

Payload types

#NameEncrypted when KMS is on
0Undefined
1Output✅ both directions
2Error
3Size
4Parameter
5HandshakeRequest
6HandshakeResponse
7HandshakeComplete
8EncChallengeRequest
9EncChallengeResponse
10Flag
11StdErr✅ inbound
12ExitCode✅ inbound

Handshake and control payloads always travel in the clear: they carry the key agreement itself.

Integrity

PayloadDigest is a SHA-256 over the payload exactly as it appears on the wire — after encryption, if session encryption is on.

This implementation verifies it and rejects a mismatch, matching ClientMessage.Validate() in the reference plugin. A message whose digest does not match is a message we cannot trust, and delivering it would put corrupt bytes into a terminal or a forwarded TCP connection.

Two documented exemptions, both taken from the reference implementation:

  • A zero-length payload has no digest to check.
  • start_publication and pause_publication skip validation entirely; the gateway sends them with an empty payload and a zeroed digest.

The digest covers the payload and nothing else. A corrupted timestamp, sequence number, flag or message ID is not detectable at this layer — the format has no header checksum. Integrity and authenticity for the channel as a whole come from TLS and the SigV4-authenticated session token, not from this field.

Reliable delivery

The channel runs over TLS/TCP, so bytes are not lost in transit. The agent can still drop a message when its buffers are full, and it signals successful processing at the application layer rather than the transport layer. Both sides therefore sequence and retransmit.

client                                agent
  │── input_stream_data seq=0 ────────►│
  │◄─ acknowledge       seq=0 ─────────│   RTT sample
  │── input_stream_data seq=1 ────────►│
  │                (no ack)            │
  │   … RTO elapses …                  │
  │── input_stream_data seq=1 ────────►│   retransmit
  │◄─ acknowledge       seq=1 ─────────│   no RTT sample (Karn's algorithm)

Inbound, the client applies the agent's in-order contract:

ReceivedAction
seq == expectedDeliver, acknowledge, then drain anything the gap was blocking
seq > expectedBuffer and acknowledge, so the agent stops resending it
seq < expectedAlready processed — drop without acknowledging

That last row is not an oversight. A second acknowledgement for a retired sequence number confuses the agent's own buffer accounting.

Outbound, only the head of the queue is ever retransmitted: the agent processes the stream strictly in order, so resending later messages while the head is still missing cannot make progress.

An acknowledgement always carries SequenceNumber = 0 and Flags = SYN | FIN in its header; the sequence number being acknowledged lives in the JSON payload.

One counter, one sender

There is a single outbound sequence counter, shared by everything the client sends as input_stream_data — caller data, terminal-size updates, the handshake response and the encryption challenge response alike. The reference plugin does the same: all of them go through SendInputDataMessage, which increments StreamDataSequenceNumber.

That matters because those messages originate in different tasks: handshake traffic is produced while reading from the socket, caller data while draining the send queue. Allocating a sequence number, recording the message for retransmission and enqueuing it must therefore be one critical section. Split it up and two messages get the same number while the next is never used — the agent then waits forever for a message that will never arrive, and the session hangs with no error reported anywhere. This crate holds a mutex across the whole outbound path for that reason, and an integration test asserts that every sequence number is issued exactly once.

Timers

ValueSettingReference plugin
Retransmit scan100 msResendSleepInterval
Initial RTO200 msDefaultTransmissionTimeout
RTOJacobson/Karels, clamped to 50 ms – 30 sfixed
Give up after3000 attemptsResendMaxAttempt
Buffer depth10 000 messages each way{In,Out}goingMessageBufferCapacity

The adaptive RTO is a deliberate divergence: the reference implementation uses a fixed 200 ms, which retransmits constantly on a link whose round trip exceeds it.

Chunking

Outbound data is split into payload_chunk_size messages, default 1024 bytes to match config.StreamDataPayloadSize.

Measured against a live agent (3.3.3572.0), 8 KiB and 32 KiB chunks are accepted and perform identically — latency is dominated by the round trip, not by chunking. The conservative default is about interoperability with older agents, not throughput. Raising it is reasonable for bulk port-forward traffic:

SessionBuilder::new("i-…").payload_chunk_size(16 * 1024)

Chunking is invisible to the peer: the far side sees one ordered byte stream, so higher layers such as smux framing reassemble across chunk boundaries.

Liveness

The reference plugin pings every five minutes and never checks for a reply, so a silently dead connection can hang a session indefinitely.

This implementation pings every 30 seconds and judges liveness on any inbound frame — data, pong, or control. A busy session is therefore never mistaken for a dead one, and a genuinely dead one is detected within idle_timeout (120 s by default) with CloseReason::PeerUnresponsive.

Note what this does and does not catch: it detects a dead network, a dropped NAT entry, a suspended host. It does not detect an agent process that is wedged but whose WebSocket library still answers pings — same limitation as TCP keepalive.

Port forwarding and smux

Port-forwarding sessions carry many TCP connections over one WebSocket, framed with xtaci/smux v1.

 ┌────────┬────────┬──────────────┬──────────────────┐
 │ ver(1) │ cmd(1) │ length(2, LE)│ stream_id(4, LE) │
 ├────────┴────────┴──────────────┴──────────────────┤
 │                payload (length bytes)              │
 └────────────────────────────────────────────────────┘

 cmd: SYN=0  FIN=1  PSH=2  NOP=3

Client-initiated streams use odd IDs. Two consequences worth knowing:

smux v1 has no flow control — that arrived in v2. A consumer that stops reading cannot backpressure the sender, so a stalled stream is evicted rather than allowed to block every other stream on the session.

There is no resynchronisation marker. A bad version byte or an over-long length field means the byte stream is not smux at this offset, and there is no way to find where the next frame starts. Guessing would deliver corrupt bytes to a real TCP connection, so the whole multiplexer fails instead.

The agent only speaks smux for documents whose session properties set type: LocalPortForwarding — that is, AWS-StartPortForwardingSession and AWS-StartPortForwardingSessionToRemoteHost. AWS-StartSSHSession looks like port forwarding but is a plain byte stream carrying SSH's own protocol.

Keep-alive NOP frames are off by default, matching the reference plugin: SSM enforces its own idle timeout, and synthetic keep-alives defeat it and leave forgotten tunnels open indefinitely.

Session encryption

When the account's Session Manager preferences enable "Encrypt session data", the agent requests a KMSEncryption action during the handshake.

KMS session key agreementThe agent requests KMS encryption. The client calls GenerateDataKey for 64 bytes, returns the ciphertext blob in its handshake response, and the agent decrypts the same blob to derive the matching key pair. An encryption challenge then proves both sides agree.ClientAWS KMSSSM agentHandshakeRequest { KMSEncryption, KMSKeyId }GenerateDataKey(64 bytes, context)plaintext key, ciphertext blobHandshakeResponse { KMSCipherTextKey }Decrypt(ciphertext blob)same plaintext keyEncChallengeRequestEncChallengeResponseboth sides proved they derived the same key

The 64-byte plaintext key is split in half. The client uses the first half to decrypt and the last half to encrypt; the agent applies them the other way round, giving each direction its own AES-256-GCM key. Each message carries a fresh 12-byte random nonce, prepended to the ciphertext.

The base64 trap

The agent and the reference plugin are Go, and Go's encoding/json renders a []byte as a base64 string. Rust's serde renders a Vec<u8> as an array of numbers. Three handshake fields are declared []byte on the Go side:

FieldMessage
KMSCipherTextKeyHandshakeResponse, KMSEncryption action result
ChallengeEncChallengeRequest
ChallengeEncChallengeResponse

Encode them the natural serde way and the agent cannot parse the handshake response, so encryption never comes up — and because the agent reports it as a generic handshake failure, the cause is not obvious from either end. This crate pins the base64 form with tests in handshake.rs for exactly that reason. It is the same class of trap as the MessageId byte order: a silent, cross-language encoding mismatch that no type checker catches.

The encryption context is {"aws:ssm:SessionId": …, "aws:ssm:TargetId": …} and must match on both sides, which is why an attached session (Session::attach) cannot use encryption — it never learns the target ID.

Both principals need KMS grants: kms:GenerateDataKey for the caller and kms:Decrypt for the target's instance profile.

Built without the kms feature, the action is failed with an explicit message rather than silently downgrading. An account that mandated encryption must never end up with a plaintext session because a client was compiled without a flag.

Divergences from the reference plugin

Everything here is a deliberate, tested choice, not an accident of porting.

AreaReference pluginThis crateWhy
LivenessPing every 5 min, no reply checkPing every 30 s, idle deadline on any inbound frameA silently dead connection otherwise hangs forever
Retransmit timeoutFixed 200 msJacobson/Karels adaptiveA fixed 200 ms retransmits constantly on slow links
Outgoing buffer fullDrop the oldest unacknowledged messageRefuse the new one and apply backpressureDropping the head abandons a message the agent is still waiting for and stalls the stream permanently
Chunk sizeFixed 1024Configurable, default 1024Bulk transfers benefit; the default stays interoperable
Terminal inputPlatform-specificRaw byte passthroughPreserves modified keys, mouse reporting and application cursor mode