Chat and signing
Verified against Minecraft 26.3 · Part IX · A player presses T, types a line and hits enter: the message is signed on the way out, taken apart on the way in, and verified again by every client that holds the sender’s session.
A player presses T, types hey and hits enter. Before the line leaves the
machine, ChatScreen.normalizeChatMessage has squeezed the whitespace and cut
it to 256 characters, and ClientPacketListener.sendChat has taken a
timestamp, a random salt and the signatures of the messages the player saw
among the last twenty signed ones it was sent, and signed all of it with a key Mojang issued to that
account. The server pulls the packet apart on the Netty thread, hands the
cryptography to the Server thread, filters it, decorates it, broadcasts it —
and every receiving client that holds the sender’s session verifies the
signature again before drawing a character. Every one of those steps can say no, and no does not mean the
same thing twice. Forge a signature and you stay connected: you get a red line
and your chain dies, so everything else you say this session fails too.
Miscount which messages you have seen — a number nothing in the game shows
you — and the server closes the connection mid-sentence. The bookkeeping is
defended harder than the cryptography is, and that is the right way round.
The cast
| class | what it decides | thread |
|---|---|---|
ServerGamePacketListenerImpl | the order the checks run in, and which failure closes the connection | Netty for the window, the characters and the chat-visibility refusal, Server for everything after |
LastSeenMessagesValidator | whether the client’s twenty-slot acknowledgement still matches the server’s mirror | Netty, and Server for the pending count — inside a lock on itself |
SignedMessageChain.Decoder | whether this message continues the sender’s chain — and whether the chain survives the answer | Server |
PlayerChatMessage | what a signature covers: the chain link, the content, the timestamp, the salt, the window | wherever a message is built |
MessageSignatureCache | which of those signatures travel as a small index instead of 256 bytes | both sides, one 128-slot cache each |
PlayerList · OutgoingChatMessage | who gets a copy, and whether it goes as a signed message or a disguised one | Server |
SignedMessageValidator.KeyBased | whether the receiving client believes the sender said this | Render |
ChatTrustLevel | secure, modified or not secure — the tag drawn beside the line | Render |
A message is not the text you see
Two packets carry all of this, and the page names them once here: a line
leaves its author in a ServerboundChatPacket and reaches everybody else in a
ClientboundPlayerChatPacket, which is the one packet in the game whose
contents a recipient checks a player’s signature over before drawing.
A message on the wire is a PlayerChatMessage: a SignedMessageLink saying
where in the sender’s chain it sits, a MessageSignature, a
SignedMessageBody of exactly four fields, and — optionally — a Component
to display instead of the signed string. That Component is Part II’s
subject (text
components); all this page
needs from it is that it is a different object from the signed text and that
the signature does not cover it. Vanilla’s decorator never produces one:
MinecraftServer.getChatDecorator is hard-coded to ChatDecorator.PLAIN, so
PlayerChatMessage.withUnsignedContent always finds the decorated copy equal
to the original and drops it — that method keeps a copy only when it differs
from the signed text. Vanilla still reaches it by another road:
MessageArgument.resolveChatMessage, behind the message-shaped commands, hands
it the resolved text, which differs exactly when a selector expanded. Type a
selector into a whisper and the recipient sees a string the signature does not
cover — the security half of a mechanism the Commands section below picks
up.
One line, typed and delivered
sequenceDiagram
box transparent the sender's client
participant CScr as ChatScreen
participant CPL as ClientPacketListener
end
box transparent the server
participant SGPL as ServerGamePacket<br/>ListenerImpl
participant PL as PlayerList
end
box transparent every recipient's client
participant RCPL as ClientPacketListener
participant CLis as ChatListener
end
CScr->>CScr: normalizeChatMessage squeezes the whitespace, cuts to 256
CScr->>CPL: sendChat
CPL->>CPL: a timestamp, a salt, the last-seen window, then the signature
CPL->>SGPL: ServerboundChatPacket
SGPL->>SGPL: Netty thread, apply the last-seen update, check the characters
rect rgba(0, 0, 0, 0.04)
Note over SGPL,PL: the rest of the server's work, all of it on the Server thread
SGPL->>SGPL: SignedMessageChain.<br/>Decoder.unpack, the signature check
SGPL->>SGPL: the filter starts, the decoration is immediate, a FutureChain joins them
SGPL->>PL: broadcastChatMessage, bound to ChatType.CHAT
end
PL->>RCPL: ClientboundPlayerChatPacket, cached last-seen signatures sent as ids
RCPL->>RCPL: the global index, then the cache ids, then the signature
RCPL->>CLis: handlePlayerChatMessage: the delay queue, then trust level and blocklist
CLis->>RCPL: markMessageAsProcessed
RCPL->>SGPL: ServerboundChatAckPacket, once the offset passes 64
One line’s whole journey, three machines in three boxes: the two
ClientPacketListener lanes are the sender’s and every recipient’s, and the
same signature is checked once in the middle box and again in the right-hand
one. The shaded band is the server’s work on the Server thread, which ends in the
arrow that leaves it.
Five things in that picture are worth naming before the checks are.
The client signs the conversation, not just the sentence. The window it signs is the window it now treats as acknowledged, so the signature binds the context the sender had in front of them — which is what makes a report show what a message was a reply to.
The hop is deliberate. ServerGamePacketListenerImpl.handleChat never
calls the usual same-thread guard (the
connection has what that
guard normally does): the window and the character check run on
the Netty thread, and only then does
ServerGamePacketListenerImpl.tryHandleChat post the rest to the server. That
posted task, and the FutureChain continuation that joins the text filter to
it, drain with every other queued server task — so a slow filter service
delays delivery by however many ticks it takes
(the server tick).
The filter is an HTTP service, and usually there is none. TextFilter is
the per-player interface every chat line — and every signed command message,
book page, sign line and anvil name — passes through, and MinecraftServer.createTextFilterForPlayer
hands out TextFilter.DUMMY, which passes everything, unless the server is a
dedicated one whose text-filtering-config property names an endpoint.
ServerTextFilter.createFromConfig reads that property at startup (starting a
server) and
builds either a LegacyTextFilter or a PlayerSafetyServiceTextFilter
depending on the configured version, each a pool of Chat-Filter-Worker
threads posting JSON to that endpoint. What comes back is a FilteredText —
the raw string and a FilterMask of which characters were objected to — so
the filter is never destructive here and never binary: the mask travels with
the message and is applied per recipient, later. Filterable is the same idea
frozen into saved data, a raw value beside an optional filtered one.
Decoration is not sequenced after filtering. The handler starts the filter future, decorates immediately and synchronously, and only then registers the continuation that joins the two. A decorator never sees filtered text.
Broadcast is per recipient, and decided in three places.
PlayerList.broadcastChatMessage logs the line — marked Not Secure by
PlayerList.verifyChatTrusted if it has no signature or has expired — and
then asks the sender’s ServerPlayer.shouldFilterMessageTo of each player in
turn. ServerPlayer drops the copy unless that player’s setting is
ChatVisiblity.FULL; OutgoingChatMessage.Player applies the filter mask to a
filtered copy and skips one that was filtered away entirely, and PlayerList
tells the sender once if any was. A message whose
sender is Util.NIL_UUID is a system message and leaves as an unsigned,
unreportable ClientboundDisguisedChatPacket instead.
Three ways to say no, and one way not to ask
Everything above happens to a message that survives. Five of the checks run before the server thread ever sees the packet, and where a refusal lands decides how much of the session it takes with it.
flowchart TD
P["ServerboundChatPacket"]:::netty --> N{"the window, the characters, chat visibility"}:::netty
N -- "the window, or a character" --> XC["the connection dies"]
N -- "chat turned off" --> XM["the message dies, and the next one may still land"]
N -- "all five pass" --> S{"SignedMessageChain.<br/>Decoder.unpack"}:::server
S -- "no signature, or key expired" --> XM
S -- "out of order, or signature invalid" --> XB["the chain dies, and every later message this session with it"]
S -- accepted --> OK["filter, decorate, broadcast"]:::server
The five checks on the Netty thread, in green, and the Server thread’s work, in blue, drawn so that the arrows meet at the three things a refusal can kill. The middle terminal is reached from both, which is the picture’s point: where a check runs says nothing about what it costs.
Those three endings are the whole vocabulary of failure here, and every check in the next section lands on exactly one of them.
The message dies alone: it is dropped, the sender usually gets a red
system line explaining why, and the next thing they send is judged on its own
merits. The chain dies for the session: SignedMessageChain clears the
link it was going to advance, and from then on no unpack can succeed — the
chain broken error if the message is otherwise well-formed, and a missing-key
or expired-key error before that if it is not. Only a
new session key, announced with ServerboundChatSessionUpdatePacket, or a new
connection restores it. The connection dies immediately — through the same funnel as any other
fault (the connection) — and the
player is back at the multiplayer list.
And there is a fourth way, which is why the flowchart starts where it
does. A client can refuse before the server is ever asked. ChatAbilities
is a client-side permission set the server has no part in, stripped
independently by game options, launcher policy and the account profile, and it
decides whether this client will send or accept anything at all
(permissions).
Nothing below applies to a message that was never sent, and a player in that
state sees no error from the server because there was no exchange to fail.
ChatVisiblity is the sliver of the same idea the server is told about, and
it has three values, not two — ChatVisiblity.HIDDEN still lets action-bar
text through.
Every check, and what it costs
The first fifteen rows are the server treating the client as the adversary. The last three are the client treating the server as one — the same design mirrored, because a server can lie about who said what at least as easily as a client can.
The first nine rows are in the order a chat line meets them, and the first
five run on the Netty thread, before the message is handed to the server at all: the
three window checks inside
ServerGamePacketListenerImpl.unpackAndApplyLastSeen, then the character
check and the chat-visibility refusal inside
ServerGamePacketListenerImpl.tryHandleChat. Four of those five close the
connection and the fifth does not, which is the split the flowchart above
draws; rows six to nine happen inside the task
ServerGamePacketListenerImpl.tryHandleChat posts to the server, and the rate
check in the continuation that broadcasts. The
rest are other paths: a command, each recipient’s copy, a new session key, and
the receiving client.
| the check | what it catches | what dies |
|---|---|---|
LastSeenMessagesValidator.applyOffset, from a chat packet or a bare ServerboundChatAckPacket | a client advancing its window past messages the server has not sent it | connection |
LastSeenMessagesValidator.applyUpdate, the acknowledged bits | a bit set longer than twenty, one naming a slot the server does not hold, or one un-acknowledging a slot already acknowledged | connection |
LastSeenMessages.Update.verifyChecksum | the two sides holding different signatures in slots whose bits agree — a desync the crypto would otherwise report as a bad signature | connection, unless the client sent LastSeenMessages.Update.IGNORE_CHECKSUM |
ServerGamePacketListenerImpl.isChatMessageIllegal, over StringUtil.isAllowedChatCharacter | section signs and control characters — formatting injected into everyone else’s chat | connection |
ServerPlayer.getChatVisibility, non-commands only | a player who turned chat off and sent a line anyway | message, with a red chat.disabled.options back to the sender |
SignedMessageChain.Decoder.unpack, no signature present | an unsigned message once a chat session exists — unconditionally, whatever MinecraftServer.enforceSecureProfile says: that flag decides what the decoder used before a session exists does with an unsigned message, and separately gates the unsigned-command refusal six rows down | message |
the same, ProfilePublicKey.Data.hasExpired | a session key past its expiry still being used to sign | message |
| the same, a timestamp before the last accepted one | a replayed or reordered message from this sender | chain |
the same, PlayerChatMessage.verify | content, timestamp, salt or window that do not match the signature — a forgery, or a proxy editing text in flight | chain |
ServerGamePacketListenerImpl.collectSignedArguments, an unknown argument name | a client signing arguments of a command the server’s own parse does not have | chain, broken explicitly |
| the same, a signable argument with no signature | signatures stripped from some arguments of a signed command | message — the chain is left intact |
ServerGamePacketListenerImpl.performUnsignedChatCommand | a signable command sent down the plain command packet with its signatures removed | message, and only when MinecraftServer.enforceSecureProfile is on |
ServerGamePacketListenerImpl.detectRateSpam, a TickThrottler per player | flooding: each message costs 20, one point decays per tick, and the ceiling is twenty per configured second — chat-spam-threshold-seconds, ten by default, so 200 points, or about ten messages sent faster than the decay | connection, except for operators, and on an integrated server for anyone, its threshold being zero |
ServerGamePacketListenerImpl.sendPlayerChatMessage, via LastSeenMessagesValidator.trackedMessagesCount | a client that is sent signed messages and never acknowledges them | connection, past 4,096 pending |
ServerGamePacketListenerImpl.handleChatSessionUpdate | a key that expires earlier than the one it replaces, or one RemoteChatSession.Data.validate cannot trace to Mojang’s services key | connection |
ClientPacketListener.handlePlayerChat, the global index | a server dropping, duplicating or reordering messages beneath the player, which would falsify any report drawn from the log | connection |
MessageSignature.Packed.unpack against the client’s cache | a server naming a cached signature the client has never held | connection |
SignedMessageValidator.KeyBased — expired key, failed signature, or a link that is not SignedMessageLink.isDescendantOf the last | a server inventing lines in another player’s name | chain, latched: the validator never returns to valid |
Two rows want a sentence more. The checksum is the only check in the table a
client may decline: LastSeenMessages.Update.verifyChecksum passes anything
when the byte is zero, and a real checksum that computes to zero is bumped to
one so it can never be mistaken for the opt-out — though the vanilla client
never opts out, because LastSeenMessagesTracker.generateAndApplyUpdate
always computes one. And chain broken is a latch on both sides: the server’s
SignedMessageChain and the receiving client’s
SignedMessageValidator.KeyBased both refuse everything afterwards, so one
bad signature costs a sender their voice until a new key or a new connection, not one line.
Why losing the window is worse than losing the signature
The asymmetry looks backwards until you notice what the window is for. The
last-seen list — twenty LastSeenTrackedEntry slots on the tracker — is
signed in full but sent as a bitset over a window the server mirrors, and
passed on to other clients as MessageSignature.Packed entries, each an index
into a cache both ends keep in step where that cache holds the signature. If the server’s mirror ever disagrees with the
client’s window, the server reconstructs a different SignedMessageBody, and
every signature after that fails for a reason no cryptographic error message
can explain. There is no recovery from inside: the state is shared, and half of it
is wrong.
So the game ends the connection at the first sign of that divergence, and
LastSeenMessagesValidator is written to be suspicious. It rejects an
acknowledgement of a slot it does not hold, an un-acknowledgement of a slot
it already acknowledged, a negative offset, an offset larger than the number
of tracked messages outside the window it has
sent, and a bit set wider than the window; a checksum mismatch on top
of all that says the client and server must have desynced in as many words.
A bad signature is the opposite kind of problem: local, provable and attributable. One sender is misbehaving, everyone else’s conversation is intact, and the proportionate answer is to stop trusting that sender — not to end a session for every other player in the room.
What keeps the two caches identical is that neither end chooses what goes in.
MessageSignatureCache.push takes a message’s body and its signature, builds
a queue of the last-seen signatures with that signature behind them, and
writes them into 128 slots newest-first, shuffling back any displaced entry
the new batch did not already contain. It is called in exactly two places, and
they are the two ends of one connection: the server as it sends a signed
message to that player, and that player’s client as it handles the packet.
Same input, same method, same order — which is why an index means the same
thing at both ends, and why nothing can put them back in step once they are
not.
Sixty-four messages is the acknowledgement offset a client may
accumulate before ClientPacketListener.markMessageAsProcessed sends a bare
ServerboundChatAckPacket unprompted. That packet exists so that a player who
only listens never reaches the server’s 4,096 pending messages and gets
dropped for saying nothing all evening.
What the signature covers
PlayerChatMessage.updateSignature feeds the signer a version constant, then
the link — sender id, session id, index — then the body: the salt, the
timestamp in seconds, the length of the content, the content bytes, the
count of last-seen signatures and each one’s raw bytes. Not fed to it: the
decorated Component, the FilterMask, the ChatType.Bound that supplies the
someone said wrapper — a ChatType is a data-pack registry entry and
ChatTypeDecoration is the translation key and argument list that turns a
message into so-and-so said this, so the phrasing around a line is data and the
line is not — and the global index the receiving client checks. A
server is free to change any of those. The signature is over what the player
typed and what they had seen when they typed it, and nothing else.
On the receiving client all of that lands in ChatListener, the class the
figure at the top of this page ends on. ChatListener.handlePlayerChatMessage
records a signed message in the client’s window whether or not it is drawn (a
refused one as an empty slot, never acknowledged) and puts the line, with that
bookkeeping, in ChatListener.delayedMessageQueue rather than on the screen
when the chatDelay option is set — a queue its own tick drains one line at a
time, which is why the option is a client-side stagger and not a server one.
That gap is what ChatTrustLevel exists to expose, and it tests for it
crudely on purpose. ChatTrustLevel.evaluate calls a message modified the
moment the rendered string does not contain the signed string — the limb
that catches a server rewriting what someone said. Only if that passes does it
look at style, and only inside the unsigned copy — which vanilla sends only for
a command message whose selector expanded, and that copy has already failed
the contains test. A
message with no signature at all, or one older than seven minutes, is not
secure instead. The tag is normally all that happens; with
Options.onlyShowSecureChat on, a not-secure message is discarded rather than
drawn — that test runs first, and Minecraft.isBlocked and
Minecraft.isFriendOnlyRestricted can swallow what survives it.
The session key is signed one level up. ProfilePublicKey.Data carries an
expiry, the public key and a signature over the profile id, the expiry in
milliseconds and the encoded key, checked against Mojang’s services key. The
receiving client allows ProfilePublicKey.EXPIRY_GRACE_PERIOD — eight hours —
that the signing chain on the server does not.
The private half lives nowhere on disk. LocalChatSession holds the pair
for as long as a session lasts, and AccountProfileKeyPairManager writes the
key file only when SharedConstants.IS_RUNNING_IN_IDE and deletes it
otherwise, so a shipped client re-fetches from the account service every
launch — and only if ClientboundLoginPacket.onlineMode said the server was
in online mode, which is the packet that opens the play phase (protocol
phases).
Commands: one signature per argument
ClientPacketListener.sendCommand parses the command locally and builds a
SignableCommand. If nothing in it needs signing it sends
ServerboundChatCommandPacket, which carries the string and nothing else. If
something does, it sends ServerboundChatCommandSignedPacket with
ArgumentSignatures: one signature per argument, each consuming its own chain
index, all of them sharing one timestamp, salt and window. Signable means
the argument type implements SignedArgument, and in 26.3 exactly one type
does — MessageArgument, behind the message-shaped commands, and the
enumeration of which commands register it belongs to brigadier and
commands.
MessageArgument is also the one route by which chat text has its selectors
expanded, which is how a command message comes to carry unsigned content at
all: the expansion itself, its permission and its parse belong to text
components.
What matters here is only that the expanded string is the one the recipient
reads and the signature does not cover it.
The server re-parses its own copy and looks each signature up by argument
name, which is where two rows of the table above come from: a name its parse
does not have breaks the chain outright, while a signable argument the client
left unsigned only fails the command. Both sides cap the shape of the packet
at ArgumentSignatures.MAX_ARGUMENT_COUNT — eight — and
ArgumentSignatures.MAX_ARGUMENT_NAME_LENGTH, sixteen. Commands run against their own TickThrottler, separate from chat’s and built
the same way from command-spam-threshold-seconds, which also defaults to
ten — so a player has two independent budgets and can spend both. A
command message sent down the plain command packet, where secure profiles are
not enforced, is broadcast as a
ClientboundDisguisedChatPacket: chat-type decorated, unsigned, unreportable.
Questions players ask
What makes the “Not Secure” tag appear? No signature, or a timestamp more than seven minutes old by the receiving client’s clock. The server calls the same message expired after five, and logs it as Not Secure there too. Two machines whose clocks are a few minutes apart will flag perfectly honest messages, and the server says so in its log.
Why does a custom font not flag every line on my server? Because the style test only looks inside the unsigned copy, and vanilla’s decorator never produces one. For ordinary chat the font check is therefore dead, and it comes alive only on a server that decorates. A player’s own lines on an integrated server skip both tests and are secure by definition.
Why can I report some lines and not others? LoggedChatMessage.canReport
needs a signature from the player being reported, and system messages,
disguised command output and anything a broken chain swallowed carry none.
What a report uploads is the signed material — index, session id, timestamp,
salt, the last-seen signatures and the signed content — so it can be
re-verified independently, with
ChatReportContextBuilder.collectAllContext walking the last-seen links
backwards for the conversation around it.
Does dying clear a broken chain? No, and nothing short of a new key or a
new connection does. The chain decoder lives on ServerGamePacketListenerImpl, and a respawn
builds a new ServerPlayer while keeping the same listener —
PlayerList.respawn copies the connection across and ServerPlayer.restoreFrom
copies the chat session with it — so the broken link survives death and the other respawn that
replaces the player object, leaving the End. Within one connection only
ServerGamePacketListenerImpl.handleChatSessionUpdate, given a different key,
installs a fresh decoder.
And if somebody’s session key fails validation? Which side notices decides
the cost. On the server it closes the connection. On another client,
ClientPacketListener.initializeChatSession calls
PlayerInfo.clearChatSession, and that player’s lines are treated as unsigned — the
signature stripped by SignedMessageValidator.ACCEPT_UNSIGNED and the line
tagged not secure, or refused
by SignedMessageValidator.REJECT_ALL where secure profiles are enforced.
Where to look
Follow one message, in the order it travels. ClientPacketListener.sendChat
is where a line becomes a signed object, and
LastSeenMessagesTracker.generateAndApplyUpdate beside it is the window
being committed to. ServerGamePacketListenerImpl.handleChat is the
receiving end, and it is short: the two methods it calls,
ServerGamePacketListenerImpl.unpackAndApplyLastSeen and
ServerGamePacketListenerImpl.tryHandleChat, are the five Netty-thread
checks in the table, in order. Then PlayerList.broadcastChatMessage for the
per-recipient fan-out and ClientPacketListener.handlePlayerChat for the
far end doing it all again in reverse.
Two classes are the argument rather than the route.
LastSeenMessagesValidator is a short file of suspicion and is worth
reading in full, because every rejection in it is a connection close.
SignedMessageChain is the other, and the one line in
SignedMessageChain.Decoder.setChainBroken that drops the next link is what
chain broken means.
Three doors this page only points at:
PlayerChatMessage.updateSignature, the exact byte order the signature
covers; ChatTrustLevel.evaluate, the crude contains test behind the
tag; and ChatReportContextBuilder.collectAllContext, which walks the
last-seen links backwards and is the reason the window is signed at all.
Rules: names, never code · how the system works, not how the code reads ·
newest version only · every backticked name passes tools/verify_names.py.