using System.Security.Cryptography; using System.Text; namespace LlamaApp.Common; /// Why an install.ps1 response may be refused. public enum InstallScriptVerdict { /// The response is acceptable. Ok, /// A non-200 response (the server rejected the request). HttpError, /// A 3xx response — redirects are not followed, so the script never runs. Redirected, /// The response did not come from the pinned HTTPS URL/host/path. WrongUri, /// The advertised (or actual) body exceeds . TooLarge, } /// /// Provenance policy for the downloaded install.ps1. The app executes /// that script straight from memory (PowerShell -EncodedCommand — it is /// never written to disk), so a compromised endpoint, DNS answer, or /// system-trusted TLS-inspection proxy would otherwise be arbitrary code /// execution. This type centralizes the checks in one pure, unit-testable /// place: /// /// /// the response must be a direct 200 from exactly /// https://llama.app/install.ps1 — redirects are surfaced, not /// followed, so a 30x to another host is refused; /// the body is bounded to and its SHA-256 is /// recorded for audit. /// /// /// There is deliberately no hash pin: install.ps1 is revised /// independently of the app, so a build-time pin goes stale and blocks /// runtime upgrades (attended and unattended alike). The exact-URL, /// no-redirect, bounded-size policy above is the standing gate; the logged /// hash keeps each install auditable after the fact. /// public static class InstallScriptIntegrity { /// The one URL the installer may be fetched from. public const string ExpectedScriptUrl = "https://llama.app/install.ps1"; /// Expected host (case-insensitive). public const string ExpectedHost = "llama.app"; /// Expected absolute path. public const string ExpectedPath = "/install.ps1"; /// /// Hard cap on the downloaded script. The real file is a few KB; anything /// larger is either an attack or an endpoint error. /// public const int MaxBytes = 1 * 1024 * 1024; /// /// Maximum base64 payload characters that may ride on the PowerShell /// command line. Windows caps a process command line (exe + arguments) at /// 32,767 characters (CreateProcess), and the payload expands to /// ~2.67 chars per script character (UTF-16 → base64), so this bounds the /// script to roughly 11,000 characters — comfortably above the real /// install.ps1 (a few KB). A larger script is refused rather than /// spilled to disk: the app never persists the script anywhere. /// public const int MaxEncodedCommandChars = 30_000; /// True when the verdict allows the script to run. public static bool IsOk(InstallScriptVerdict verdict) => verdict == InstallScriptVerdict.Ok; /// /// Validates a response line for the installer fetch. /// is the URI the response actually came from (equal to the request URI when /// redirects are disabled). /// public static InstallScriptVerdict ValidateResponse(int statusCode, Uri? finalUri, long? contentLength) { if (statusCode is >= 300 and < 400) return InstallScriptVerdict.Redirected; if (statusCode != 200) return InstallScriptVerdict.HttpError; if (finalUri is null || !IsExpectedUri(finalUri)) return InstallScriptVerdict.WrongUri; if (contentLength is { } len && (len <= 0 || len > MaxBytes)) return InstallScriptVerdict.TooLarge; return InstallScriptVerdict.Ok; } /// /// True only for HTTPS on (default port) with /// exactly . /// public static bool IsExpectedUri(Uri uri) { if (!uri.IsAbsoluteUri) return false; if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) return false; if (!string.Equals(uri.Host, ExpectedHost, StringComparison.OrdinalIgnoreCase)) return false; if (!uri.IsDefaultPort) return false; if (uri.Query.Length != 0 || uri.Fragment.Length != 0) return false; return string.Equals(uri.AbsolutePath, ExpectedPath, StringComparison.Ordinal); } /// Lower-case hex SHA-256 of . public static string Sha256Hex(byte[] data) => Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant(); /// /// Prepares the downloaded script bytes for fileless execution: decodes /// them to text (BOM-aware UTF-8 — the encoding the script is published /// in) and returns the base64-encoded UTF-16LE form that PowerShell's /// -EncodedCommand expects. The script therefore never touches the /// disk — it travels from the validated response through memory into the /// interpreter — and every install fetches a fresh copy (nothing is /// cached between runs). Throws when /// the encoded payload would exceed /// (the Windows command-line limit) rather than falling back to a temp /// file: refusing oversized input is safer than materializing it. /// public static string EncodeCommand(byte[] scriptBytes) { string script; using (var reader = new StreamReader( new MemoryStream(scriptBytes), Encoding.UTF8, detectEncodingFromByteOrderMarks: true)) { script = reader.ReadToEnd(); } var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(script)); if (encoded.Length > MaxEncodedCommandChars) throw new InvalidDataException( $"install.ps1 is too large to execute in memory ({encoded.Length} > {MaxEncodedCommandChars} encoded characters). " + "Refusing to write it to disk."); return encoded; } /// /// Reads into memory, refusing to buffer more /// than . Keeps a hostile/oversized response /// from exhausting memory before it is ever executed. /// public static async Task ReadBoundedAsync( Stream stream, int maxBytes = MaxBytes, CancellationToken cancel = default) { using var buffer = new MemoryStream(); var chunk = new byte[16 * 1024]; int read; while ((read = await stream.ReadAsync(chunk, cancel)) > 0) { if (buffer.Length + read > maxBytes) throw new InvalidDataException($"install.ps1 exceeds the {maxBytes}-byte limit."); buffer.Write(chunk, 0, read); } return buffer.ToArray(); } /// Human-readable reason for refusing a script. public static string Describe(InstallScriptVerdict verdict) => verdict switch { InstallScriptVerdict.HttpError => "the server returned a non-200 response", InstallScriptVerdict.Redirected => "the server redirected the download (redirects are refused)", InstallScriptVerdict.WrongUri => $"the response did not come from {ExpectedScriptUrl}", InstallScriptVerdict.TooLarge => $"the script exceeds the {MaxBytes}-byte limit", _ => "the response was accepted", }; }