// =============================================================================
// 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.Linq;
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
{
var adapter = FindFirstEthernetAdapter();
_adapterIndex = adapter.Index;
_adapterId = adapter.Id;
_adapterName = adapter.Name;
}
_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];
using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(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.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 ─────────────────────────────────
// net40: RSA.Create(int) and RSAEncryptionPadding are net46+.
// RSACryptoServiceProvider.Decrypt(data, false) is the same
// PKCS#1 v1.5 padding, and works on XP SP3's CAPI.
using var rsa = new RSACryptoServiceProvider(Proto.RsaKeySize);
Log("Sending console final key.");
// net40 BinaryWriter/Reader have no leaveOpen overload; not
// disposing them keeps the OFB stream open, which is the intent.
var bw = new BinaryWriter(ofb, Encoding.UTF8);
bw.Write(rsa.ToXmlString(false)); // public key only
bw.Flush();
Log("Receiving console key.");
var br = new BinaryReader(ofb, Encoding.UTF8);
int encLen = br.ReadInt32();
byte[] enc = br.ReadBytes(encLen);
byte[] sessionKey = rsa.Decrypt(enc, false); // PKCS#1 v1.5
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 on its own STA thread. Do NOT call
// SetCompatibleTextRenderingDefault here: the single-binary launcher
// already created its tray form, and calling it after any control
// exists throws InvalidOperationException.
var t = new Thread(() =>
{
_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}");
}
// IPv4 interface index + adapter GUID + display name (a struct instead of a
// ValueTuple — net40 has no System.ValueTuple).
// Index avoids netsh name-quoting issues; GUID is used for direct registry
// writes to persist the static IP across reboots.
private struct AdapterInfo
{
public int Index;
public string Id;
public string Name;
}
private static AdapterInfo 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 new AdapterInfo { Index = idx, Id = nic.Id, Name = 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];
using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(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();
}
}
}