// ============================================================================= // TeslaLauncher — Secure Pod Configuration // ============================================================================= // Implements the Tesla secure pod configuration protocol (reverse-engineered // from TeslaSecureConfiguration.dll, Elsewhen Studios LLC). // // Protocol summary: // 1. Assign temp IP, generate RequestId + Passphrase // 2. Broadcast UDP RQST beacon on port 53291 // 3. Display codes on screen and COM2 plasma display // 4. Console sends AES-encrypted network config via UDP RPLY on port 53292 // 5. Apply static IP, DNS, hostname via netsh // 6. TCP handshake on port 53292: OFB crypto negotiation + RSA key exchange // 7. Save 32-byte session key to TeslaKeyStore.key // ============================================================================= using System; using System.IO; using System.Net; using System.Net.Sockets; using System.Net.NetworkInformation; using System.Text; using System.Security.Cryptography; using System.Threading; using System.Diagnostics; using Microsoft.Win32; using System.Runtime.Serialization; using System.Runtime.Serialization.Formatters.Binary; #if WINFORMS using System.Windows.Forms; using System.Drawing; using System.Runtime.InteropServices; #endif namespace TeslaSecureConfig { // ---- Wire protocol constants ---- internal static class Proto { public const string RQST = "RQST"; public const string RPLY = "RPLY"; public const string CONF = "CONF"; public const string DONE = "DONE"; public const int UdpBroadcastPort = 53291; // Console listens here public const int UdpReplyPort = 53292; // Cockpit listens here for Console RPLY (UDP broadcast) public const int UdpBeaconIntervalMs= 2000; public const string TempIp = "172.16.0.100"; public const string TempBcast = "172.31.255.255"; // directed broadcast for 172.16.0.0/12 public const string TempMask = "255.240.0.0"; public const int PassphraseLength = 5; public const int RequestIdLength = 3; public const string Alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"; public const int RsaKeySize = 2048; public const int Pbkdf2Iterations = 1000; public const int AesKeyBytes = 32; // 256-bit AES // Static salt extracted from TeslaSecureConfiguration.dll FieldRVA public static readonly byte[] PassphraseSalt = new byte[] { 0x17, 0xab, 0x51, 0xd9, 0xec, 0xd1, 0xd4, 0x74, 0xa9, 0x09, 0x4a, 0x34, 0x27, 0xfb, 0x1f, 0xf2, 0xde, 0xc4, 0xf9, 0xf1, 0xa6, 0xd8, 0x9e, 0xda, 0x15, 0x11, 0x47, 0x65, 0x32, 0xe7, 0xe7, 0xef }; public const string ComPort = "COM2"; public const int ComBaud = 9600; public const string PlasmaFont = "Verdana"; public const string RegComputerName = @"SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName"; public const string RegTcpipParams = @"SYSTEM\CurrentControlSet\services\Tcpip\Parameters"; } // ---- Network configuration returned from Console ---- public class PodNetworkConfig { public IPAddress Address { get; set; } public IPAddress Mask { get; set; } public IPAddress Gateway { get; set; } public IPAddress Dns { get; set; } public string HostName { get; set; } } #if WINFORMS // ---- Passcode display form ---- // Request ID: sent in beacon, Console shows it to identify this cockpit. // Passphrase: displayed HERE only. Operator reads it and types it into the // Console. Console returns it inside the encrypted CONF to prove // it is configuring the correct pod. internal class PasscodeDisplayForm : Form { public PasscodeDisplayForm(string requestId, string passphrase) { SuspendLayout(); Text = "Your Configuration Passcode"; FormBorderStyle = FormBorderStyle.FixedDialog; StartPosition = FormStartPosition.CenterScreen; ClientSize = new Size(480, 220); MaximizeBox = false; MinimizeBox = false; var boldFont = new Font("Arial Narrow", 24f, FontStyle.Bold, GraphicsUnit.Point); var labelFont = new Font("Arial Narrow", 14f, FontStyle.Regular, GraphicsUnit.Point); var noteFont = new Font("Arial Narrow", 10f, FontStyle.Italic, GraphicsUnit.Point); // Request ID row var lblRqstTitle = new Label { Text = "Request ID:", AutoSize = true, Location = new Point(20, 24), Font = labelFont }; var lblRqstVal = new Label { Text = requestId, AutoSize = true, Location = new Point(200, 20), Font = boldFont }; // Passphrase row var lblPassTitle = new Label { Text = "Passphrase:", AutoSize = true, Location = new Point(20, 100), Font = labelFont }; var lblPassVal = new Label { Text = passphrase, AutoSize = true, Location = new Point(200, 96), Font = boldFont, ForeColor = System.Drawing.Color.DarkRed }; // Instruction note var lblNote = new Label { Text = "Console will show the Request ID above.\r\n" + "Read the Passphrase (red) to the operator,\r\n" + "who types it into the Console to authorise this pod.", AutoSize = true, Location = new Point(20, 158), Font = noteFont, ForeColor = System.Drawing.Color.Gray }; Controls.AddRange(new Control[] { lblRqstTitle, lblRqstVal, lblPassTitle, lblPassVal, lblNote }); ResumeLayout(false); PerformLayout(); } } #endif // ---- PlasmaIO wrapper (mirrors PlasmaIO.dll PlasmaDisplay API) ---- internal sealed class PlasmaWriter : IDisposable { private System.IO.Ports.SerialPort _port; private bool _disposed; public PlasmaWriter(string comPort, int baud = 9600) { try { _port = new System.IO.Ports.SerialPort(comPort, baud, System.IO.Ports.Parity.None, 8, System.IO.Ports.StopBits.One); _port.Open(); ClearAll(); } catch (Exception ex) { // Plasma display is optional - log but continue System.Diagnostics.Debug.WriteLine($"PlasmaIO init failed: {ex.Message}"); _port = null; } } // ClearAll: send ESC J (clear display) as per Plasma protocol public void ClearAll() { if (_port == null || !_port.IsOpen) return; _port.BaseStream.WriteByte(0x1B); // ESC _port.BaseStream.WriteByte(0x4A); // J (clear screen) _port.BaseStream.Flush(); } public void WriteLine(string format, params object[] args) { if (_port == null || !_port.IsOpen) return; var text = string.Format(format, args) + "\r\n"; var bytes = Encoding.ASCII.GetBytes(text); _port.BaseStream.Write(bytes, 0, bytes.Length); _port.BaseStream.Flush(); } public void Dispose() { if (!_disposed) { _disposed = true; try { _port?.Close(); } catch { } } } } // ---- Crypto helpers ---- internal static class CryptoHelper { /// Derive AES key from passphrase using PBKDF2 with the hard-coded salt. /// COMPATIBILITY: this MUST use the SHA1-default Rfc2898DeriveBytes(string, /// byte[], int) overload. The Console derives the same session key with the /// identical SHA1-default PBKDF2 (Tesla.PodConfigurationServer.GenerateKeyFromPassphrase), /// so switching to a SHA256 overload — as SYSLIB0041 suggests on net8 — would /// silently break the secure-config key handshake. Do not "modernize" this. #pragma warning disable SYSLIB0041 // SHA1-default PBKDF2 is required for Console wire compatibility public static byte[] DeriveKey(string passphrase) { var pbkdf2 = new Rfc2898DeriveBytes(passphrase, Proto.PassphraseSalt, Proto.Pbkdf2Iterations); return pbkdf2.GetBytes(Proto.AesKeyBytes); } #pragma warning restore SYSLIB0041 /// Encrypt data with AES-256 (CBC mode) public static byte[] Encrypt(byte[] data, byte[] key) { using var aes = Aes.Create(); aes.Mode = CipherMode.CBC; aes.Key = key; aes.GenerateIV(); var iv = aes.IV; using var enc = aes.CreateEncryptor(); using var ms = new MemoryStream(); using var cs = new CryptoStream(ms, enc, CryptoStreamMode.Write); cs.Write(data, 0, data.Length); cs.FlushFinalBlock(); var cipher = ms.ToArray(); // Prepend IV var result = new byte[iv.Length + cipher.Length]; Buffer.BlockCopy(iv, 0, result, 0, iv.Length); Buffer.BlockCopy(cipher, 0, result, iv.Length, cipher.Length); return result; } /// Decrypt data with AES-256 (CBC mode), IV prepended public static byte[] Decrypt(byte[] data, byte[] key) { using var aes = Aes.Create(); aes.Mode = CipherMode.CBC; aes.Key = key; var iv = new byte[aes.BlockSize / 8]; Buffer.BlockCopy(data, 0, iv, 0, iv.Length); aes.IV = iv; using var dec = aes.CreateDecryptor(); using var ms = new MemoryStream(); using var cs = new CryptoStream(ms, dec, CryptoStreamMode.Write); cs.Write(data, iv.Length, data.Length - iv.Length); cs.FlushFinalBlock(); return ms.ToArray(); } } // ── OFB Duplex Stream ────────────────────────────────────────────────── // // The original OFBCryptoStream wraps Rijndael-ECB (CipherMode=2) and implements OFB // (Output Feedback) mode manually: keystream is generated by repeatedly encrypting the // feedback register with the ECB block cipher, then XORing data with the keystream. // // For a full-duplex TCP connection, two independent keystream generators are maintained: // Write direction (pod→console): seeded from the pod's generated IV // Read direction (console→pod): seeded from the console's sent IV // // Both directions use the same PBKDF2-derived AES key. internal sealed class OFBDuplexStream : Stream { private readonly NetworkStream _inner; // Write-direction (pod→console) OFB state private readonly Aes _writeAes; private readonly ICryptoTransform _writeXform; private readonly byte[] _writeFb = new byte[16]; // feedback register (starts as IV) private readonly byte[] _writeKs = new byte[16]; // current keystream block private int _wPos = 16; // 16 = "generate new block on next byte" // Read-direction (console→pod) OFB state private readonly Aes _readAes; private readonly ICryptoTransform _readXform; private readonly byte[] _readFb = new byte[16]; private readonly byte[] _readKs = new byte[16]; private int _rPos = 16; /// Underlying NetworkStream (not owned — caller disposes). /// 32-byte PBKDF2 key derived from the passphrase. /// 16-byte IV for pod→console keystream (pod-generated). /// 16-byte IV for console→pod keystream (console-generated). public OFBDuplexStream(NetworkStream inner, byte[] key, byte[] writeIv, byte[] readIv) { _inner = inner; Buffer.BlockCopy(writeIv, 0, _writeFb, 0, 16); Buffer.BlockCopy(readIv, 0, _readFb, 0, 16); _writeAes = Aes.Create(); _writeAes.Mode = CipherMode.ECB; _writeAes.Padding = PaddingMode.None; _writeAes.Key = key; _writeXform = _writeAes.CreateEncryptor(); _readAes = Aes.Create(); _readAes.Mode = CipherMode.ECB; _readAes.Padding = PaddingMode.None; _readAes.Key = key; _readXform = _readAes.CreateEncryptor(); } private byte NextWriteByte() { if (_wPos == 16) { _writeXform.TransformBlock(_writeFb, 0, 16, _writeKs, 0); Buffer.BlockCopy(_writeKs, 0, _writeFb, 0, 16); _wPos = 0; } return _writeKs[_wPos++]; } private byte NextReadByte() { if (_rPos == 16) { _readXform.TransformBlock(_readFb, 0, 16, _readKs, 0); Buffer.BlockCopy(_readKs, 0, _readFb, 0, 16); _rPos = 0; } return _readKs[_rPos++]; } public override int Read(byte[] buffer, int offset, int count) { int n = _inner.Read(buffer, offset, count); for (int i = 0; i < n; i++) buffer[offset + i] ^= NextReadByte(); return n; } public override void Write(byte[] buffer, int offset, int count) { var enc = new byte[count]; for (int i = 0; i < count; i++) enc[i] = (byte)(buffer[offset + i] ^ NextWriteByte()); _inner.Write(enc, 0, count); } public override void Flush() => _inner.Flush(); public override bool CanRead => true; public override bool CanWrite => true; public override bool CanSeek => false; public override long Length => throw new NotSupportedException(); public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); public override void SetLength(long value) => throw new NotSupportedException(); // Delegate timeout properties to the inner NetworkStream. public override bool CanTimeout => true; public override int ReadTimeout { get => _inner.ReadTimeout; set => _inner.ReadTimeout = value; } public override int WriteTimeout { get => _inner.WriteTimeout; set => _inner.WriteTimeout = value; } protected override void Dispose(bool disposing) { if (disposing) { _writeXform?.Dispose(); _readXform?.Dispose(); _writeAes?.Dispose(); _readAes?.Dispose(); // _inner is not owned — do not dispose } base.Dispose(disposing); } } // ---- Main secure configuration orchestrator ---- public sealed class PodSecureConfigurator : IDisposable { private int _adapterIndex; // IPv4 interface index — used with netsh index=N private string _adapterId; // Adapter GUID — used for registry static-IP persistence private string _adapterName; // Display name e.g. "Ethernet" — used for ipconfig /release private readonly Action _log; private IPAddress _consoleAddress; // Console sender IP from RPLY — used to re-resolve adapter private string _requestId; private string _passphrase; /// /// After the RSA key exchange, the 32-byte session key is stored here. /// The Service saves it to TeslaKeyStore.key and uses it for NegotiateCryptoStreams /// on the management port (53290) for all subsequent Console connections. /// public byte[] SessionKey { get; private set; } private System.Net.Sockets.Socket _replySocket; private Thread _beaconThread; private Thread _replyThread; private volatile bool _running; private ManualResetEventSlim _configReceived = new ManualResetEventSlim(false); private PodNetworkConfig _receivedConfig; #if WINFORMS private PasscodeDisplayForm _displayForm; #endif private PlasmaWriter _plasma; public PodSecureConfigurator(string adapterName = null, Action logger = null) // adapterName = display name hint or null for auto-detect { if (adapterName != null) { _adapterIndex = FindAdapterIndex(adapterName); _adapterId = FindAdapterId(adapterName); _adapterName = adapterName; } else { (_adapterIndex, _adapterId, _adapterName) = FindFirstEthernetAdapter(); } _log = logger ?? (s => Debug.WriteLine(s)); } // --- Entry point --- public PodNetworkConfig Configure(int timeoutMs = 300_000) { Log("Beginning configuration."); // Delete any stale configuring.json from a previous run so // the Agent doesn't display old codes before we generate new ones. try { var staleFile = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "TeslaLauncher", "configuring.json"); if (File.Exists(staleFile)) File.Delete(staleFile); } catch { } // Step 1: Generate identifiers _requestId = GenerateRandomString(Proto.RequestIdLength); _passphrase = GenerateRandomString(Proto.PassphraseLength); Log($"Waiting for configuration. Request ID: {_requestId} Passphrase: {_passphrase} (passphrase is NOT transmitted — operator reads it off screen)"); // Write RequestId/Passphrase to a shared file so the Agent // (running in the user session) can display them on screen. try { var cfgDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "TeslaLauncher"); Directory.CreateDirectory(cfgDir); File.WriteAllText(Path.Combine(cfgDir, "configuring.json"), $"{{\"RequestId\":\"{_requestId}\",\"Passphrase\":\"{_passphrase}\"}}"); } catch { /* best-effort — plasma/log still have the values */ } // Step 2: Assign temporary IP Log($"Configuring temporary IP address on \"{_adapterName}\"."); if (!ConfigureTempIp(_adapterName)) { Log("Could not initalize temporary IP address."); return null; } Log("Temp IP configured."); // Step 3: Show display (WinForms + Plasma) ShowPasscodeDisplay(); // Step 4: Start UDP beacon. // Bind to the temp IP so broadcasts go out through the correct // Ethernet adapter. netsh returns before the kernel has finished // assigning the address, so retry for up to 5 seconds. _running = true; // No longer waiting for the temp IP to propagate before broadcasting. // BeaconLoop uses per-interface sockets bound to each adapter's actual current // IP so Windows routes each broadcast out the correct physical NIC. // The temp IP assignment still runs (source=static disables DHCP for TCP), // but the beacon works immediately from whatever address the adapter has. Log("Starting beacon."); _beaconThread = new Thread(BeaconLoop) { Name = "BeaconThread", IsBackground = true }; _beaconThread.Start(); // Step 5: Start UDP listener on port 53292 for RPLY from Console. // The Console broadcasts "RPLY" + AES-encrypted CONF as a 68-byte UDP datagram // to 255.255.255.255:53292. We receive it, derive the AES key from our locally- // generated passphrase (operator typed it into the Console), decrypt and apply. try { _replySocket = new System.Net.Sockets.Socket( System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); _replySocket.SetSocketOption( System.Net.Sockets.SocketOptionLevel.Socket, System.Net.Sockets.SocketOptionName.ReuseAddress, true); _replySocket.EnableBroadcast = true; _replySocket.Bind(new IPEndPoint(IPAddress.Any, Proto.UdpReplyPort)); } catch (SocketException ex) { Log($"Could not bind UDP reply socket on port {Proto.UdpReplyPort}: {ex.Message}"); _running = false; return null; } _replyThread = new Thread(UdpReplyLoop) { Name = "UdpReplyThread", IsBackground = true }; _replyThread.Start(); Log("Broadcasting... waiting for packets."); // Wait for config bool received = _configReceived.Wait(timeoutMs); _running = false; // Hide display #if WINFORMS try { _displayForm?.Invoke(new Action(() => _displayForm.Close())); } catch { } #endif _plasma?.Dispose(); if (!received || _receivedConfig == null) { Log("Timed out waiting for configuration."); return null; } Log("Packets received."); // Step 6: Open TCP listener on 0.0.0.0:53292 BEFORE changing the IP. // // After sending RPLY the Console immediately ARPs for the NEW IP it put // in the payload (not the beacon source IP). It then connects TCP to that // new IP:53292 to confirm the cockpit applied the config. // // Binding the TCP listener to 0.0.0.0 first means it is already waiting // when the IP changes. Once netsh applies the new address and the Console's // ARP gets a reply, it connects TCP — and the listener accepts it. Log("Confirming configuration to Console."); try { _replySocket?.Close(); _replySocket = null; } catch { } // Re-resolve the adapter using the TARGET IP from the RPLY config. // // The Console may route its RPLY broadcast through a secondary NIC that is // NOT on the pod's network (e.g. a 192.168.1.x NIC instead of the 10.0.x // NIC that actually connects to the pods). Using the Console's sender IP // (_consoleAddress) for adapter selection therefore fails whenever the Console // has multiple NICs. // // The target IP in the RPLY config is always on the pod's network — the // Console operator assigns it from the same IP range. We find the local NIC // whose current IP shares the same subnet as the target: that is the correct // NIC to reconfigure. if (_receivedConfig != null) ResolveAdapterForTargetIp(_receivedConfig.Address, _receivedConfig.Mask); var tcpConf = new System.Net.Sockets.TcpListener(IPAddress.Any, Proto.UdpReplyPort); tcpConf.Start(); // Step 7: Apply network configuration (IP changes here). // The listener is already bound so it will accept the Console's connection // once the new IP is live and the ARP resolves. Log($"Attempting to configure final IP."); if (!ApplyNetworkConfig(_adapterId, _adapterName, _receivedConfig)) { Log("Could not initalize final IP address."); tcpConf.Stop(); return null; } Log("Setting host name."); SetHostName(_receivedConfig.HostName); // Step 8: Accept Console connection and send DONE. SendTcpConfirmation(tcpConf); Log("...Configured"); // Clean up the shared file so the Agent doesn't show stale data try { var cfgFile = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "TeslaLauncher", "configuring.json"); if (File.Exists(cfgFile)) File.Delete(cfgFile); } catch { } return _receivedConfig; } // --- UDP beacon loop --- // On Windows, a socket bound to 0.0.0.0 sending to 255.255.255.255 // is routed through whichever interface has the lowest metric — // which is often a virtual or loopback adapter, silently discarding // the packet before it reaches the physical wire. // // Fix: enumerate every live Ethernet interface, bind a separate socket // to each one's actual current IP, and send to that subnet's directed // broadcast address. This forces each datagram out the correct NIC. private void BeaconLoop() { byte[] payload = BuildBeaconPayload(); // On boot, the service may start before the NIC driver has fully initialised. // Wait up to 60 s for at least one UP Ethernet interface with a real, *bindable* IPv4 // address. Checking OperationalStatus+UnicastAddresses is not sufficient — after // "netsh interface ip set address ... static", the new IP can appear in the interface // list for several seconds before the kernel routing table commits it, causing socket // Bind() to fail with WSAEADDRNOTAVAIL. We probe-bind a throwaway UDP socket to // confirm the address is actually usable before leaving the wait loop. int waitSecs = 0; while (_running) { bool ready = false; foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; if (nic.OperationalStatus != OperationalStatus.Up) continue; foreach (var ua in nic.GetIPProperties().UnicastAddresses) { if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue; if (IPAddress.IsLoopback(ua.Address)) continue; // Verify the address is actually bindable, not just listed try { using var probe = new System.Net.Sockets.Socket( System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); probe.Bind(new IPEndPoint(ua.Address, 0)); ready = true; } catch { /* not committed by kernel yet — keep waiting */ } if (ready) break; } if (ready) break; } if (ready) break; if (waitSecs == 0) Log("Waiting for network interface to obtain a bindable IP address..."); waitSecs++; if (waitSecs >= 60) { Log("Warning: no interface had a bindable IP after 60 s — broadcasting anyway."); break; } Thread.Sleep(1000); } while (_running) { SendBeaconOnAllInterfaces(payload); Thread.Sleep(Proto.UdpBeaconIntervalMs); } } private byte[] BuildBeaconPayload() { // RQST beacon — 13 bytes: // [0:4] "RQST" // [4:10] 6-byte raw MAC of the physical Ethernet adapter // [10:13] RequestId (3 ASCII chars) byte[] macBytes = GetAdapterMacBytes(); using var ms = new MemoryStream(13); using var w = new BinaryWriter(ms, Encoding.ASCII); w.Write(Encoding.ASCII.GetBytes(Proto.RQST)); w.Write(macBytes); w.Write(Encoding.ASCII.GetBytes(_requestId.PadRight(Proto.RequestIdLength))); return ms.ToArray(); } private void SendBeaconOnAllInterfaces(byte[] payload) { foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; if (nic.OperationalStatus != OperationalStatus.Up) continue; var ipProps = nic.GetIPProperties(); foreach (var ua in ipProps.UnicastAddresses) { if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue; if (IPAddress.IsLoopback(ua.Address)) continue; // Send to the limited broadcast address (255.255.255.255). // // The Console can be on any IP subnet — in production it is on // 10.0.0.x while the pod temp IP is 172.16.0.x. A directed // subnet broadcast (e.g. 172.31.255.255) never crosses the subnet // boundary so the Console would not receive it. // Bind to this NIC's IP so Windows routes via the physical adapter try { using var sock = new System.Net.Sockets.Socket( System.Net.Sockets.AddressFamily.InterNetwork, System.Net.Sockets.SocketType.Dgram, System.Net.Sockets.ProtocolType.Udp); sock.EnableBroadcast = true; sock.Bind(new IPEndPoint(ua.Address, 0)); sock.SendTo(payload, new IPEndPoint(IPAddress.Broadcast, Proto.UdpBroadcastPort)); } catch (Exception ex) { Log($"Beacon send error on {ua.Address}: {ex.Message}"); } } } } /// /// Returns the 6-byte raw MAC of the adapter holding the temp IP. /// Falls back to the first operational Ethernet adapter. /// private static byte[] GetAdapterMacBytes() { foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue; if (nic.OperationalStatus != OperationalStatus.Up) continue; foreach (var ua in nic.GetIPProperties().UnicastAddresses) { if (ua.Address.ToString() == Proto.TempIp) return nic.GetPhysicalAddress().GetAddressBytes(); } } // Fallback: first operational Ethernet adapter foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue; if (nic.OperationalStatus != OperationalStatus.Up) continue; var mac = nic.GetPhysicalAddress().GetAddressBytes(); if (mac.Length == 6) return mac; } return new byte[6]; // all-zero fallback } // --- UDP RPLY listener --- // Listens for AES-encrypted RPLY packets from the Console on port 53292. // Key = PBKDF2(passphrase, salt, 1000, 32). Format: "RPLY"(4) + IV(16) + ciphertext(48). private void UdpReplyLoop() { Log("Waiting for console to connect."); var buf = new byte[4096]; _replySocket.ReceiveTimeout = 2000; while (_running) { try { EndPoint remoteEp = new IPEndPoint(IPAddress.Any, 0); int len = _replySocket.ReceiveFrom(buf, ref remoteEp); if (len < 4) continue; string tag = Encoding.ASCII.GetString(buf, 0, 4); if (tag != Proto.RPLY) continue; // Payload after "RPLY" tag is the AES-encrypted CONF block int cipherLen = len - 4; byte[] cipherText = new byte[cipherLen]; Buffer.BlockCopy(buf, 4, cipherText, 0, cipherLen); // Derive AES key from our passphrase (operator typed this into Console) byte[] aesKey = CryptoHelper.DeriveKey(_passphrase); byte[] confData; try { confData = CryptoHelper.Decrypt(cipherText, aesKey); } catch { Log("RPLY decrypt failed — AES error, likely wrong passphrase key."); continue; } var config = ParseConf(confData); if (config == null) { Log("RPLY parsed but config invalid — skipping."); continue; } Log($"CONF accepted: IP {config.Address}, Mask {config.Mask}, " + $"GW {config.Gateway}, DNS {config.Dns}, Host {config.HostName}"); _receivedConfig = config; if (remoteEp is IPEndPoint rip && !IPAddress.Any.Equals(rip.Address)) _consoleAddress = rip.Address; _configReceived.Set(); return; } catch (SocketException) { /* timeout — loop */ } catch (ObjectDisposedException) { break; } catch (Exception ex) { Log($"RPLY receive error: {ex.Message}"); } } } // --- TCP session: OFB negotiation + RSA key exchange --- // Uses Socket.Poll() because ReceiveTimeout doesn't affect AcceptTcpClient in .NET 6. private void SendTcpConfirmation(System.Net.Sockets.TcpListener tcpConf) { try { const int TimeoutMs = 60_000; // 60 s — Console retries at ~0.5 s intervals Log("TCP port 53292 open — waiting for Console to connect..."); bool ready = tcpConf.Server.Poll(TimeoutMs * 1000, System.Net.Sockets.SelectMode.SelectRead); if (!ready) { Log("TCP confirmation timed out."); return; } System.Net.Sockets.TcpClient client; try { client = tcpConf.AcceptTcpClient(); } catch (SocketException) { Log("TCP accept failed."); return; } using (client) { var netStream = client.GetStream(); netStream.WriteTimeout = 10_000; netStream.ReadTimeout = 30_000; Log("Console connection received. Negotiating..."); // ── 1. IV exchange ────────────────────────────────────── var consoleIv = new byte[16]; TcpReadExact(netStream, consoleIv); var podIv = new byte[16]; RandomNumberGenerator.Fill(podIv); netStream.Write(podIv, 0, 16); netStream.Flush(); // ── 2. OFB session setup ──────────────────────────────── // Each direction keyed from the OTHER side's IV var aesKey = CryptoHelper.DeriveKey(_passphrase); using var ofb = new OFBDuplexStream(netStream, aesKey, writeIv: consoleIv, // pod→console direction readIv: podIv); // console→pod direction ofb.WriteTimeout = 10_000; ofb.ReadTimeout = 30_000; // ── 3. CONF handshake ─────────────────────────────────── // Both sides write "CONF" over OFB and verify the reply // to confirm matching PBKDF2 keys. var confMsg = Encoding.UTF8.GetBytes("CONF"); ofb.Write(confMsg, 0, confMsg.Length); ofb.Flush(); var confReply = new byte[4]; TcpReadExact(ofb, confReply); if (!confReply.AsSpan().SequenceEqual(confMsg)) { Log($"CONF mismatch: got [{BitConverter.ToString(confReply)}], expected CONF — key/IV mismatch."); return; } Log("Secure connection to console negotiated."); // ── 4. RSA key exchange ───────────────────────────────── using var rsa = RSA.Create(Proto.RsaKeySize); Log("Sending console final key."); using (var bw = new BinaryWriter(ofb, Encoding.UTF8, leaveOpen: true)) { bw.Write(rsa.ToXmlString(false)); // public key only bw.Flush(); } Log("Receiving console key."); byte[] sessionKey; using (var br = new BinaryReader(ofb, Encoding.UTF8, leaveOpen: true)) { int encLen = br.ReadInt32(); byte[] enc = br.ReadBytes(encLen); sessionKey = rsa.Decrypt(enc, RSAEncryptionPadding.Pkcs1); } Log($"Console key received ({sessionKey.Length} bytes)."); // Store session key — used for OFB on the management port (53290) SessionKey = sessionKey; // Persist in original KeyStore format: [1-byte length] + [key bytes] var keyDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "TeslaLauncher"); Directory.CreateDirectory(keyDir); var keyPath = Path.Combine(keyDir, "TeslaKeyStore.key"); using (var fs = File.Open(keyPath, FileMode.Create, FileAccess.Write, FileShare.None)) { fs.WriteByte((byte)sessionKey.Length); fs.Write(sessionKey, 0, sessionKey.Length); } Log($"Session key saved ({sessionKey.Length} bytes)."); Log("Console confirmed — configuration complete."); } } catch (Exception ex) { Log($"TCP confirmation error (non-fatal): {ex.Message}"); } finally { try { tcpConf?.Stop(); } catch { } } } /// Reads exactly buf.Length bytes from stream; throws IOException on EOF. private static void TcpReadExact(Stream stream, byte[] buf) { int off = 0; while (off < buf.Length) { int n = stream.Read(buf, off, buf.Length - off); if (n == 0) throw new IOException("TCP connection closed before all bytes received."); off += n; } } // Parse decrypted CONF payload: IP(4) + Mask(4) + GW(4) + DNS(4) + Hostname(ASCII) private PodNetworkConfig ParseConf(byte[] data) { try { if (data.Length < 16) return null; var addr = new IPAddress(new byte[] { data[0], data[1], data[2], data[3] }); var mask = new IPAddress(new byte[] { data[4], data[5], data[6], data[7] }); var gw = new IPAddress(new byte[] { data[8], data[9], data[10], data[11] }); var dns = new IPAddress(new byte[] { data[12], data[13], data[14], data[15] }); // Sanity-check: first octet of IP must be 1–223 (unicast range). // If decryption produced garbage (wrong passphrase), this will fail silently — // the cockpit just keeps broadcasting and waiting for the correct RPLY. if (data[0] == 0 || data[0] >= 224) { Log($"RPLY: decryption produced non-unicast IP first-octet ({data[0]}) — ignoring (wrong passphrase in Console?)."); return null; } // Hostname is the printable ASCII content of the remaining bytes, // terminated at the first non-printable character. var hostName = string.Empty; for (int i = 16; i < data.Length; i++) { byte b = data[i]; if (b < 0x20 || b > 0x7e) break; hostName += (char)b; } Log($"CONF decrypted: IP={addr} Mask={mask} GW={gw} DNS={dns} Host={hostName}"); return new PodNetworkConfig { Address=addr, Mask=mask, Gateway=gw, Dns=dns, HostName=hostName }; } catch (Exception ex) { Log($"ParseConf error: {ex.Message}"); return null; } } // --- Show passcode display --- private void ShowPasscodeDisplay() { _plasma = new PlasmaWriter(Proto.ComPort, Proto.ComBaud); _plasma.ClearAll(); _plasma.WriteLine("Request ID: {0}", _requestId); _plasma.WriteLine("Passphrase: {0}", _passphrase); #if WINFORMS // WinForms dialog — only shown when running as the Agent (user session) var t = new Thread(() => { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); _displayForm = new PasscodeDisplayForm(_requestId, _passphrase); _displayForm.ShowDialog(); }) { IsBackground = true }; t.SetApartmentState(ApartmentState.STA); t.Start(); #endif } // --- Temp IP via netsh --- // // Uses the old-style "interface ip set address" command (not "interface ipv4") // because the old form directly calls SetAdapterIPAddress() and immediately // updates the running TCP/IP stack. The "interface ipv4" variant only updates // the persistent config and requires a network adapter restart to take effect. // // No gateway for the temp IP — it is only needed to receive the RPLY broadcast // on the same subnet; no routing is required at this stage. private static bool ConfigureTempIp(string adapterName) { return RunNetsh( $"interface ip set address \"{adapterName}\" " + $"static {Proto.TempIp} {Proto.TempMask}"); } // --- Final IP via netsh + registry --- // // Uses the old-style "interface ip set address" command to immediately update // both the running stack and the persistent config. The registry writes provide // belt-and-suspenders persistence in case the DHCP client service ever re-asserts // on the next boot. private static bool ApplyNetworkConfig(string adapterId, string adapterName, PodNetworkConfig cfg) { // Set static IP + mask + gateway. The trailing "1" is the gateway metric. bool ok = RunNetsh( $"interface ip set address \"{adapterName}\" " + $"static {cfg.Address} {cfg.Mask} {cfg.Gateway} 1"); if (!ok) return false; // Set primary DNS. "register=primary" ensures the host registers its DNS record. ok = RunNetsh( $"interface ip set dns \"{adapterName}\" " + $"static {cfg.Dns} primary"); if (!ok) return false; // Belt-and-suspenders: write directly to the Tcpip registry key so the // static config is guaranteed to survive a reboot. PersistStaticIpRegistry(adapterId, cfg); return true; } private static void PersistStaticIpRegistry(string adapterId, PodNetworkConfig cfg) { string keyPath = $@"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{adapterId}"; try { using var key = Registry.LocalMachine.OpenSubKey(keyPath, writable: true); if (key == null) { Log2($"Registry key not found: {keyPath}"); return; } key.SetValue("EnableDHCP", 0, RegistryValueKind.DWord); key.SetValue("IPAddress", new[] { cfg.Address.ToString() }, RegistryValueKind.MultiString); key.SetValue("SubnetMask", new[] { cfg.Mask.ToString() }, RegistryValueKind.MultiString); key.SetValue("DefaultGateway", new[] { cfg.Gateway.ToString() }, RegistryValueKind.MultiString); key.SetValue("GatewayMetric", new[] { "0" }, RegistryValueKind.MultiString); key.SetValue("NameServer", cfg.Dns.ToString(), RegistryValueKind.String); // Remove cached DHCP lease data that could re-enable DHCP on boot foreach (var val in new[] { "DhcpIPAddress", "DhcpSubnetMask", "DhcpDefaultGateway", "DhcpNameServer", "DhcpServer", "Lease", "LeaseObtainedTime", "LeaseTerminatesTime" }) { try { key.DeleteValue(val); } catch { } } Log2("Static IP configuration persisted to registry."); } catch (Exception ex) { Log2($"Registry static-IP persist failed: {ex.Message}"); } } private static bool RunNetsh(string args) { var psi = new ProcessStartInfo("netsh", args) { UseShellExecute = false, CreateNoWindow = true }; var p = Process.Start(psi); p.WaitForExit(); return p.ExitCode == 0; } // --- Set hostname in registry --- private static void SetHostName(string hostName) { try { using var key1 = Registry.LocalMachine.OpenSubKey(Proto.RegComputerName, true); key1?.SetValue("ComputerName", hostName, RegistryValueKind.String); using var key2 = Registry.LocalMachine.OpenSubKey(Proto.RegTcpipParams, true); key2?.SetValue("NV Hostname", hostName, RegistryValueKind.String); Log2($"Host set to: {hostName}"); } catch (Exception ex) { Log2($"Could not set host name: {ex.Message}"); } } private static void Log2(string s) => Debug.WriteLine(s); // --- Virtual adapter filter --- // Hyper-V, VirtualBox, VMware and similar virtualization layers expose // Ethernet-type adapters that are DHCP-enabled by default. Without this // filter, FindFirstEthernetAdapter() and IsMachineConfigured() both pick // up the virtual adapter (often the lowest-index one) instead of the // physical NIC, causing netsh to configure the wrong interface. private static bool IsVirtualAdapter(NetworkInterface nic) { var desc = nic.Description ?? ""; var name = nic.Name ?? ""; // Common substrings found in virtual/software adapter descriptions. // Checked case-insensitively to cover all Windows localizations. string[] virtualMarkers = { "Virtual", "Hyper-V", "VMware", "VirtualBox", "Loopback", "Tunnel", "Miniport", "Wi-Fi Direct", "Bluetooth", "WAN Miniport", "Microsoft Kernel Debug" }; foreach (var m in virtualMarkers) if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 || name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0) return true; // Physical NICs always have a 6-byte MAC. Virtual adapters sometimes // use locally-administered addresses (bit 1 of first octet set). var mac = nic.GetPhysicalAddress().GetAddressBytes(); if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true; // locally administered return false; } // --- Re-resolve adapter using the target IP from the RPLY config --- // Using the Console's sender IP is unreliable when the Console has multiple NICs: // Windows may route the RPLY broadcast through a NIC on a completely different // subnet (e.g. 192.168.1.x instead of the 10.0.x NIC that faces the pods). // The TARGET IP assigned in the RPLY payload is always on the pod's network — // the Console operator picks it from the same range. So we find the local NIC // whose current address is on the same subnet as the target: that is the correct // NIC to reconfigure, regardless of which Console NIC happened to send the RPLY. private void ResolveAdapterForTargetIp(IPAddress targetIp, IPAddress targetMask) { byte[] tBytes = targetIp.GetAddressBytes(); byte[] mBytes = targetMask?.GetAddressBytes(); if (mBytes == null || mBytes.Length != 4) mBytes = new byte[] { 255, 255, 255, 0 }; foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (nic.OperationalStatus != OperationalStatus.Up) continue; if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue; if (IsVirtualAdapter(nic)) continue; foreach (var ua in nic.GetIPProperties().UnicastAddresses) { if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue; if (IPAddress.IsLoopback(ua.Address)) continue; byte[] aBytes = ua.Address.GetAddressBytes(); bool same = true; for (int i = 0; i < 4; i++) if ((aBytes[i] & mBytes[i]) != (tBytes[i] & mBytes[i])) { same = false; break; } if (!same) continue; try { int idx = nic.GetIPProperties().GetIPv4Properties().Index; Log($"Adapter resolved to [{idx}] {nic.Name} (same subnet as target {targetIp})"); _adapterIndex = idx; _adapterId = nic.Id; _adapterName = nic.Name; return; } catch (NetworkInformationException) { } } } Log($"Warning: target IP {targetIp} not on any local subnet — keeping [{_adapterIndex}] {_adapterName}"); } // Returns (IPv4 interface index, adapter GUID). // Index is used with `netsh interface ipv4 ... interface=N` (avoids name-quoting issues). // GUID is used for direct registry writes to persist static IP across reboots. private static (int index, string id, string name) FindFirstEthernetAdapter() { foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (nic.OperationalStatus != OperationalStatus.Up) continue; if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue; if (IsVirtualAdapter(nic)) continue; // Skip interfaces with no IPv4 unicast address (NIC Up but no IP yet) bool hasIp = false; foreach (var ua in nic.GetIPProperties().UnicastAddresses) if (ua.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork && !IPAddress.IsLoopback(ua.Address)) { hasIp = true; break; } if (!hasIp) continue; try { int idx = nic.GetIPProperties().GetIPv4Properties().Index; Log2($"Selected physical adapter: [{idx}] {nic.Name} / {nic.Description}"); return (idx, nic.Id, nic.Name); } catch (NetworkInformationException) { } } throw new InvalidOperationException("Could not find a physical Ethernet adapter with an IPv4 address."); } private static string FindAdapterId(string displayName) { foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (string.Equals(nic.Name, displayName, StringComparison.OrdinalIgnoreCase) || string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase)) return nic.Id; } return FindFirstEthernetAdapter().id; // .name ignored in fallback } // Find index for a named adapter (fallback when caller passes a display name) private static int FindAdapterIndex(string displayName) { foreach (var nic in NetworkInterface.GetAllNetworkInterfaces()) { if (string.Equals(nic.Name, displayName, StringComparison.OrdinalIgnoreCase) || string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase)) { try { return nic.GetIPProperties().GetIPv4Properties().Index; } catch (NetworkInformationException) { } } } // Fall back to auto-detect if name not matched return FindFirstEthernetAdapter().index; // .name ignored in fallback } // --- Random string generator --- // Alphabet length is 32 (2^5), byte range is 256 (2^8) — no modulo bias. private static string GenerateRandomString(int length) { var buf = new byte[length]; RandomNumberGenerator.Fill(buf); var sb = new System.Text.StringBuilder(length); for (int i = 0; i < length; i++) sb.Append(Proto.Alphabet[buf[i] % Proto.Alphabet.Length]); return sb.ToString(); } private void Log(string msg) => _log?.Invoke(msg); public void Dispose() { _running = false; try { _replySocket?.Close(); } catch { } _plasma?.Dispose(); } } }