Files
SVSimServer/SVSim.BattleNode/Wire/NodeCrypto.cs
gamer147 24180d5b4b refactor(battle-node): de-magic wire flags and scattered constants
Quality pass from the 2026-06-04 BattleNode review (audit in the outer
repo). All changes are behavior-preserving — identical wire bytes,
verified by the full 1008-test suite staying green.

- Name scattered magic numbers: crypto key/IV lengths, outbound-sequencer
  base, WS receive buffer / EIO ping / SID length, polite-close timeout,
  upgrade-credential keys, battle-id digit math, deterministic-turn spin.
- resultCode = 1 -> (int)ReceiveNodeResultCode.Success across body records.
- Pong "3" -> EngineIoPacketType.Pong; remove dead NoOpBotParticipant.Touch
  (replace with #pragma warning disable CS0067).
- Wire-flag enums, serialized as numbers via JsonNumberEnumConverter:
  turnState -> TurnState{First,Second}, isSelf -> CardOwner{Opponent,Self},
  open -> ChoiceVisibility{Hidden,Open}.
- isOfficial / isInvoke -> bool / bool? via new NumericBoolJsonConverter
  (reads/writes 0/1; TDD'd). Scoped to the BattleNode wire boundary only;
  MatchContext and the HTTP/AI-start path stay int (AI-start uses -1 as a
  sentinel, so it is not boolean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:46:09 -04:00

87 lines
3.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Security.Cryptography;
using System.Text;
namespace SVSim.BattleNode.Wire;
/// <summary>
/// AES-256-CBC encrypt/decrypt for the node socket channel. Port of
/// Cryptographer.EncryptRJ256ForNode / DecryptRJ256ForNode in the decompilation.
/// Key is prepended to ciphertext (cleartext); IV is the first 16 chars of the key.
/// </summary>
public static class NodeCrypto
{
/// <summary>Length of the ASCII key, in chars (AES-256 = 32 bytes = 32 ASCII chars).</summary>
private const int KeyLength = 32;
/// <summary>IV length, in chars. The node derives the IV from the first half of the key.</summary>
private const int IvLength = KeyLength / 2;
/// <summary>
/// Generate a fresh 32-char key for server-initiated encryption.
/// Calls <paramref name="randHexDigit"/> 32 times; the result is masked with
/// <c>&amp; 0xF</c> so a misbehaving caller that returns a larger int still produces
/// exactly one hex digit per iteration (the internal contract is "32 hex chars").
/// The 32-char ASCII string is then base64-encoded and truncated to 32 chars.
/// </summary>
/// <remarks>
/// Differs from the client's <c>Cryptographer.generateKeyString</c> in input shape:
/// the client uses <c>Random.Next(0, 65535).ToString("x")</c> per iteration (14 hex
/// chars each). The output distribution is therefore different, but both produce a
/// valid 32-char UTF-8 AES-256 key — and the client never validates the server's key
/// since the server is decrypt-only in practice. Server-initiated encryption (e.g.
/// for <c>synchronize</c> pushes) uses this method.
/// </remarks>
public static string GenerateKey(Func<int> randHexDigit)
{
var sb = new StringBuilder(KeyLength);
for (var i = 0; i < KeyLength; i++)
{
sb.Append((randHexDigit() & 0xF).ToString("x"));
}
var ascii = Encoding.ASCII.GetBytes(sb.ToString());
return Convert.ToBase64String(ascii).Substring(0, KeyLength);
}
/// <summary>Encrypt: returns key + base64(AES-256-CBC(plain)).</summary>
public static string EncryptForNode(string plaintext, string key)
{
if (key.Length != KeyLength)
throw new ArgumentException($"Key must be exactly {KeyLength} chars, got {key.Length}", nameof(key));
using var aes = BuildAes(key);
using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plaintext);
var cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
return key + Convert.ToBase64String(cipherBytes);
}
/// <summary>Decrypt: input[0..32] is key, input[32..] is base64(ciphertext).</summary>
public static string DecryptForNode(string encrypted)
{
if (encrypted.Length < KeyLength)
throw new ArgumentException($"Encrypted blob is shorter than the {KeyLength}-char key prefix", nameof(encrypted));
var key = encrypted.Substring(0, KeyLength);
var cipherBytes = Convert.FromBase64String(encrypted.Substring(KeyLength));
using var aes = BuildAes(key);
using var decryptor = aes.CreateDecryptor();
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
return Encoding.UTF8.GetString(plainBytes);
}
/// <summary>
/// Configure an AES-256-CBC instance with the node's IV derivation (first
/// <see cref="IvLength"/> chars of the key, UTF-8). Callers own disposal. Assumes
/// <paramref name="key"/> is the <see cref="KeyLength"/>-char ASCII key the encrypt /
/// decrypt path has already validated.
/// </summary>
private static Aes BuildAes(string key)
{
var aes = Aes.Create();
aes.KeySize = 256;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = Encoding.UTF8.GetBytes(key);
aes.IV = Encoding.UTF8.GetBytes(key.Substring(0, IvLength));
return aes;
}
}