package net.rustnotmc.protocol; import java.util.LinkedHashMap; import java.util.Map; /** * The packets that start a connection, and the table that maps them to ids. * * The ids are written down here rather than generated, because they end up in * every capture and every log line forever. Renumbering one is a protocol * change and should look like a deliberate act, not a side effect of moving a * class around. * * The order of a connection is fixed and short: * *
 *   client -> Hello        protocol version, what the client is
 *   server -> HelloAck     accepted version, server name, motd, online count
 *   client -> Auth         account and proof
 *   server -> AuthAck      session token, or a reason it was refused
 *   client -> Login        the session token
 *   server -> LoginAck     player id and entity id, or a reason
 *   either -> Disconnect   a human readable reason
 * 
*/ public final class Packets { private Packets() { } /** Longest a name, motd or reason may be, in bytes. */ public static final int MAX_TEXT = 512; /** Longest a handshake proof may be, in bytes. */ public static final int MAX_PROOF = 256; /** A salt and a nonce, in the sizes the crypto actually produces. */ public static final int SALT_BYTES = 16; public static final int CHALLENGE_BYTES = 32; /** Rounds of PBKDF2 that both ends must use. */ public static final int KEY_ITERATIONS = 210000; /** Derived key length in bytes, matching PBKDF2WithHmacSHA256. */ public static final int KEY_BYTES = 32; /** Longest the handshake body may be, in bytes. */ public static final int MAX_HANDSHAKE_BODY = 8 * 1024; public static final int HELLO = 0x01; public static final int HELLO_ACK = 0x02; public static final int AUTH = 0x03; public static final int AUTH_ACK = 0x04; public static final int LOGIN = 0x05; public static final int LOGIN_ACK = 0x06; public static final int DISCONNECT = 0x7F; /** The client introducing itself, and naming the account it wants. */ public static final class Hello { public final int protocolVersion; public final String account; public final String clientName; public final int clientVersion; public Hello(int protocolVersion, String account, String clientName, int clientVersion) { this.protocolVersion = protocolVersion; this.account = account; this.clientName = clientName; this.clientVersion = clientVersion; } public Buf write() { Buf buf = new Buf(); buf.writeVarInt(this.protocolVersion); buf.writeString(this.account); buf.writeString(this.clientName); buf.writeVarInt(this.clientVersion); return buf; } public static Hello read(Buf in) { return new Hello(in.readVarInt(), in.readString(MAX_TEXT), in.readString(MAX_TEXT), in.readVarInt()); } } /** The server accepting, and describing itself. */ public static final class HelloAck { public final int protocolVersion; public final String serverName; public final String motd; public final int onlinePlayers; public final int maxPlayers; /** * A value fresh for this connection. * * The proof is computed over it, so a recording of one login cannot be * replayed against the next one. */ public final byte[] nonce; /** * The salt of the account that was named, when it exists. * * Sent before the client has proved anything, which does reveal whether * an account exists. That is a deliberate trade for a design where the * password never crosses the wire and the server stores no reversible * form of it. */ public final byte[] salt; public HelloAck(int protocolVersion, String serverName, String motd, int onlinePlayers, int maxPlayers, byte[] nonce, byte[] salt) { this.protocolVersion = protocolVersion; this.serverName = serverName; this.motd = motd; this.onlinePlayers = onlinePlayers; this.maxPlayers = maxPlayers; this.nonce = nonce; this.salt = salt; } public Buf write() { Buf buf = new Buf(); buf.writeVarInt(this.protocolVersion); buf.writeString(this.serverName); buf.writeString(this.motd); buf.writeVarInt(this.onlinePlayers); buf.writeVarInt(this.maxPlayers); buf.writeBytes(this.nonce); buf.writeBytes(this.salt); return buf; } public static HelloAck read(Buf in) { return new HelloAck(in.readVarInt(), in.readString(MAX_TEXT), in.readString(MAX_TEXT), in.readVarInt(), in.readVarInt(), in.readBytes(CHALLENGE_BYTES), in.readBytes(SALT_BYTES)); } } /** * The client claiming an account, with proof rather than with the password. * * The proof is HMAC-SHA256 over the derived key, the nonce and the account * name. The password itself is never sent, the server stores no reversible * form of it, and a proof captured from one connection is useless against * the next because the nonce differs. */ public static final class Auth { public final String account; public final byte[] proof; public Auth(String account, byte[] proof) { this.account = account; this.proof = proof; } public Buf write() { Buf buf = new Buf(); buf.writeString(this.account); buf.writeBytes(this.proof); return buf; } public static Auth read(Buf in) { return new Auth(in.readString(MAX_TEXT), in.readBytes(KEY_BYTES)); } /** Derives the key from a password and salt the way the server does. */ public static byte[] deriveKey(char[] password, byte[] salt) { try { javax.crypto.SecretKeyFactory factory = javax.crypto.SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); javax.crypto.spec.PBEKeySpec spec = new javax.crypto.spec.PBEKeySpec( password, salt, KEY_ITERATIONS, KEY_BYTES * 8); try { return factory.generateSecret(spec).getEncoded(); } finally { // PBEKeySpec keeps its own copy of the password, and only // clearPassword reaches it spec.clearPassword(); } } catch (java.security.NoSuchAlgorithmException | java.security.spec.InvalidKeySpecException e) { throw new IllegalStateException("PBKDF2WithHmacSHA256 is unavailable", e); } } /** Builds the proof for a password, a salt and a nonce. */ public static byte[] proofFor(String account, char[] password, byte[] salt, byte[] nonce) { byte[] key = deriveKey(password, salt); try { javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); mac.init(new javax.crypto.spec.SecretKeySpec(key, "HmacSHA256")); mac.update(nonce); mac.update(account.getBytes(java.nio.charset.StandardCharsets.UTF_8)); return mac.doFinal(); } catch (java.security.NoSuchAlgorithmException | java.security.InvalidKeyException e) { throw new IllegalStateException("HmacSHA256 is unavailable", e); } finally { java.util.Arrays.fill(key, (byte) 0); } } /** Compares two proofs without leaking how much matched. */ public static boolean proofsEqual(byte[] a, byte[] b) { if (a == null || b == null || a.length != b.length) { return false; } int diff = 0; for (int i = 0; i < a.length; i++) { diff |= a[i] ^ b[i]; } return diff == 0; } } /** Whether the account was accepted, and on what terms. */ public static final class AuthAck { public final boolean accepted; public final String reason; public final String token; public final long expiresAt; private AuthAck(boolean accepted, String reason, String token, long expiresAt) { this.accepted = accepted; this.reason = reason; this.token = token; this.expiresAt = expiresAt; } public static AuthAck ok(String token, long expiresAt) { return new AuthAck(true, "", token, expiresAt); } public static AuthAck refused(String reason) { return new AuthAck(false, reason, "", 0L); } public Buf write() { Buf buf = new Buf(); buf.writeBoolean(this.accepted); buf.writeString(this.reason); if (this.accepted) { buf.writeString(this.token); buf.writeLong(this.expiresAt); } return buf; } public static AuthAck read(Buf in) { boolean accepted = in.readBoolean(); // the reason is written either way, so it has to be read either way: // skipping it here shifts everything after it by one field String reason = in.readString(MAX_TEXT); if (!accepted) { return refused(reason); } return ok(in.readString(MAX_TEXT), in.readLong()); } } /** The client redeeming the token it was just handed. */ public static final class Login { public final String account; public final String token; public Login(String account, String token) { this.account = account; this.token = token; } public Buf write() { Buf buf = new Buf(); buf.writeString(this.account); buf.writeString(this.token); return buf; } public static Login read(Buf in) { return new Login(in.readString(MAX_TEXT), in.readString(MAX_TEXT)); } } /** Whether the player is in. */ public static final class LoginAck { public final boolean accepted; public final String reason; public final int playerId; public final int entityId; private LoginAck(boolean accepted, String reason, int playerId, int entityId) { this.accepted = accepted; this.reason = reason; this.playerId = playerId; this.entityId = entityId; } public static LoginAck ok(int playerId, int entityId) { return new LoginAck(true, "", playerId, entityId); } public static LoginAck refused(String reason) { return new LoginAck(false, reason, -1, -1); } public Buf write() { Buf buf = new Buf(); buf.writeBoolean(this.accepted); buf.writeString(this.reason); if (this.accepted) { buf.writeVarInt(this.playerId); buf.writeVarInt(this.entityId); } return buf; } public static LoginAck read(Buf in) { boolean accepted = in.readBoolean(); String reason = in.readString(MAX_TEXT); if (!accepted) { return refused(reason); } return ok(in.readVarInt(), in.readVarInt()); } } /** A reason, said out loud, before the connection goes away. */ public static final class Disconnect { public final String reason; public Disconnect(String reason) { this.reason = reason; } public Buf write() { return new Buf().writeString(this.reason); } public static Disconnect read(Buf in) { return new Disconnect(in.readString(MAX_TEXT)); } } /** * Every packet id this build knows, and what it decodes into. * * Written as plain data so that adding a packet is one line and an unknown * id is a loud error instead of a silent skip. */ public static Map names() { Map map = new LinkedHashMap(); map.put(Integer.valueOf(HELLO), "Hello"); map.put(Integer.valueOf(HELLO_ACK), "HelloAck"); map.put(Integer.valueOf(AUTH), "Auth"); map.put(Integer.valueOf(AUTH_ACK), "AuthAck"); map.put(Integer.valueOf(LOGIN), "Login"); map.put(Integer.valueOf(LOGIN_ACK), "LoginAck"); map.put(Integer.valueOf(DISCONNECT), "Disconnect"); return map; } }