Files
TeslaSuite/Launcher/SecureConfig.cs
T
CydandClaude Fable 5 8730b9b966 XP11: single net40 binary runs on XP SP3 through Windows 11
Merge TeslaLauncherService (Session 0) + TeslaLauncherAgent (tray) into one
userland TeslaLauncher.exe. The split existed only to work around Vista+
Session 0 isolation; running everything in the auto-logged-in admin session
needs no service, no named pipe, and no flat<->wire conversion layer. The
original Elsewhen software was likewise a single binary.

net40 is the newest framework XP SP3 can install, and net40 assemblies load
in-place on the 4.8 runtime in Win10/11 -- one exe covers both.

- Contract: multi-target net48;net40. The net40 leg of PodRpcProtocol uses
  Newtonsoft.Json (STJ has no net40 target); JSON is shape-identical on the
  wire, and the request reader keeps date strings raw so Ping echoes
  byte-identically. Console keeps the untouched net48/STJ leg.
- MiniZip.cs: central-directory ZIP extractor (stored/deflate/ZIP64) since
  net40 has no ZipFile; zip-slip guarded.
- SecureConfig: RSACryptoServiceProvider instead of RSA.Create (net46+),
  no leaveOpen BinaryWriter/Reader, struct instead of ValueTuple, and no
  SetCompatibleTextRenderingDefault on the passcode thread (the merged app
  already has a form). netsh "interface ip" syntax was already XP-correct.
- Volume: nircmd -> CoreAudio (Vista+) -> winmm waveOutSetVolume (XP).
- Paths: CommonApplicationData resolved per-OS (XP has no C:\ProgramData);
  launcher log moved next to the key/config in the data dir.
- install.bat: dual-OS (cacls/icacls, netsh firewall/advfirewall, dism and
  UAC/notification steps skipped on XP, .NET 4.0 redist check, XP DHCP reset
  via netsh); no service registration -- HKLM Run key + auto-login on both;
  RegisterApplicationRestart supplies crash-restart on Vista+.
- Bench switches: /skipconfig, /port:NNNN.

Verified: Console + diff suite (103 tests) still green on the net48 leg;
E2E smoke test drove the net40 launcher over real TCP with the Console's own
PodManagerConnection (STJ client vs Newtonsoft server) -- Ping echo, volume,
app registry, launch/kill/watch, InstallProduct zip transfer + extract, and
uninstall orphan cleanup: 17/17 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:59:06 -05:00

1221 lines
56 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.
// =============================================================================
// 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;
/// <param name="inner">Underlying NetworkStream (not owned — caller disposes).</param>
/// <param name="key">32-byte PBKDF2 key derived from the passphrase.</param>
/// <param name="writeIv">16-byte IV for pod→console keystream (pod-generated).</param>
/// <param name="readIv">16-byte IV for console→pod keystream (console-generated).</param>
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<string> _log;
private IPAddress _consoleAddress; // Console sender IP from RPLY — used to re-resolve adapter
private string _requestId;
private string _passphrase;
/// <summary>
/// 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.
/// </summary>
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<string> 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}");
}
}
}
}
/// <summary>
/// Returns the 6-byte raw MAC of the adapter holding the temp IP.
/// Falls back to the first operational Ethernet adapter.
/// </summary>
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 { } }
}
/// <summary>Reads exactly buf.Length bytes from stream; throws IOException on EOF.</summary>
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 1223 (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();
}
}
}