diff --git a/eng/provenance/files.json b/eng/provenance/files.json index 4f35881a..735c061c 100644 --- a/eng/provenance/files.json +++ b/eng/provenance/files.json @@ -952,7 +952,6 @@ "src/DesktopHelpers/ArcForges.ContentSandbox/Tests/MacProfileTests.cs", "src/DesktopHelpers/ArcForges.ContentSandbox/Tests/OsIsolationTests.cs", "src/DesktopHelpers/ArcForges.ContentSandbox/Tests/packages.lock.json", - "src/DesktopHelpers/ArcForges.ContentSandbox/macos/ArcForges.ContentSandbox.entitlements", "src/BuildingBlocks/ArcForges.Security/CapabilityEnforcement/ArcForges.Security.CapabilityEnforcement.csproj", "src/BuildingBlocks/ArcForges.Security/CapabilityEnforcement/CapabilityEvidence.cs", "src/BuildingBlocks/ArcForges.Security/CapabilityEnforcement/CapabilityGate.cs", diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox.Broker/ContentSandboxTypes.cs b/src/DesktopHelpers/ArcForges.ContentSandbox.Broker/ContentSandboxTypes.cs index 7aa9415c..01b4a87f 100644 --- a/src/DesktopHelpers/ArcForges.ContentSandbox.Broker/ContentSandboxTypes.cs +++ b/src/DesktopHelpers/ArcForges.ContentSandbox.Broker/ContentSandboxTypes.cs @@ -17,7 +17,10 @@ public enum ContentSandboxProfile /// Linux Landlock, seccomp and no_new_privs. LinuxLandlockSeccomp = 2, - /// macOS App Sandbox with an XPC descriptor handoff. It is not implemented: a launch for it is refused. + /// + /// Reserved and wire-stable; macOS is not a supported platform. No profile of this family exists, it is never selected, and a launch or a + /// launch frame carrying it is refused fail-closed (security.isolation_unavailable). + /// MacOsAppSandboxXpc = 3, } diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox.Broker/README.md b/src/DesktopHelpers/ArcForges.ContentSandbox.Broker/README.md index d78c2fb3..846fdabc 100644 --- a/src/DesktopHelpers/ArcForges.ContentSandbox.Broker/README.md +++ b/src/DesktopHelpers/ArcForges.ContentSandbox.Broker/README.md @@ -17,7 +17,7 @@ var image = await invocation.OpenImageAsync(0, 0, 1); using var tile = (await invocation.ReadImageTileAsync(image.Value, 0, 0, 256, 256)).Value!; // bytes verified on a private copy ``` -- **No fallback.** A platform without a verified profile (macOS here, an unknown OS, a failed AppContainer or self-check) fails with +- **No fallback.** A platform without a verified profile (macOS, which is not a supported platform, an unknown OS, a failed AppContainer or self-check) fails with `security.isolation_unavailable`, starts nothing, and never parses in process. - **Windows (`Windows/`).** The helper is opened with writers refused and its SHA-256 compared with the pin; an AppContainer identity is leased from a pool of eight (a lock file per identity, released by the OS if the owner ends); the process is created suspended with a handle list naming diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox.Contracts/ContentSandboxLaunchFrame.cs b/src/DesktopHelpers/ArcForges.ContentSandbox.Contracts/ContentSandboxLaunchFrame.cs index e3ee6e4b..1890281d 100644 --- a/src/DesktopHelpers/ArcForges.ContentSandbox.Contracts/ContentSandboxLaunchFrame.cs +++ b/src/DesktopHelpers/ArcForges.ContentSandbox.Contracts/ContentSandboxLaunchFrame.cs @@ -14,7 +14,10 @@ internal enum ContentSandboxProfileKind : byte /// Linux Landlock, seccomp and no_new_privs. LinuxLandlockSeccomp = 2, - /// macOS App Sandbox with an XPC descriptor handoff (not implemented here; a launch for it is refused). + /// + /// Reserved and wire-stable; macOS is not a supported platform. No profile of this family exists, it is never selected, and a frame carrying + /// it is refused by the helper with the isolation-unavailable exit on every operating system. + /// MacOsAppSandboxXpc = 3, } diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox/Host/ProfileEnforcement.cs b/src/DesktopHelpers/ArcForges.ContentSandbox/Host/ProfileEnforcement.cs index 069e305a..3e7ba165 100644 --- a/src/DesktopHelpers/ArcForges.ContentSandbox/Host/ProfileEnforcement.cs +++ b/src/DesktopHelpers/ArcForges.ContentSandbox/Host/ProfileEnforcement.cs @@ -16,11 +16,14 @@ namespace ArcForges.ContentSandbox.Host; /// internal static class ProfileEnforcement { - /// The profile family of the operating system this process runs on, or macOS/unsupported values the launch frame never matches. - internal static ContentSandboxProfileKind ThisPlatform => + /// + /// The profile family of the operating system this process runs on. It is null where no restricted profile exists (macOS and every other + /// operating system), so no launch frame matches it and HelperEntry refuses the frame with the isolation-unavailable exit. + /// + internal static ContentSandboxProfileKind? ThisPlatform => OperatingSystem.IsWindows() ? ContentSandboxProfileKind.WindowsAppContainerJob : OperatingSystem.IsLinux() ? ContentSandboxProfileKind.LinuxLandlockSeccomp - : ContentSandboxProfileKind.MacOsAppSandboxXpc; + : null; /// Applies and verifies the profile; false means the helper must not run a parser. [SuppressMessage("Design", "CA1031", Justification = "Any failure of enforcement or verification is a refusal to run.")] diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox/README.md b/src/DesktopHelpers/ArcForges.ContentSandbox/README.md index ea19c44f..d9f4da7a 100644 --- a/src/DesktopHelpers/ArcForges.ContentSandbox/README.md +++ b/src/DesktopHelpers/ArcForges.ContentSandbox/README.md @@ -22,8 +22,8 @@ generated `ContentSandboxService` contract. This project is non-packable; packag filter to itself and checks them. Seccomp is synchronised to every thread (TSYNC). Landlock restricts only the calling thread unless its own TSYNC flag is given, so the helper passes that flag, refuses to run if the kernel rejects it (a kernel without multithread Landlock, which is recent), and its self-check observes the denial from the applying thread, from several thread-pool threads and from a new thread. Parent loss is detected by the helper polling its parent id (no parent-death signal: the kernel ties it to the spawning thread, which may retire while the parent lives) and the supervising parent kills the helper on every path. A wedged helper whose parent was killed is not ended by the kernel. No offline test observes the Landlock TSYNC flag or the shrink seal (a mutant that drops either survives offline); both need a Linux kernel. Not covered on Linux: tgkill to another same-user process (the runtime needs it), no PID/user/mount namespaces, and - the profile has never run on a Linux kernel here. macOS: no launcher exists; `macos/ArcForges.ContentSandbox.entitlements` is the declarative input of the - profile that a signed sandboxed bundle and an XPC descriptor handoff would need, and a launch there is refused. + the profile has never run on a Linux kernel here. macOS is not supported: there is no launcher, no profile and no entitlements file; the + reserved `MacOsAppSandboxXpc` value is never selected, and a launch or a frame carrying it is refused (security.isolation_unavailable). - **Fixture (`Fixture/`).** TEST ONLY: the same host with one deliberately hostile first-party test composition (the registered `SUB-hostile-test-parser`). It parses no real format. Its script attacks the boundary (reads a product file, connects to a real listener, sends datagrams, opens other processes, spawns, maps the read-only input writable) or crashes, hangs, exhausts memory or overruns its @@ -51,7 +51,7 @@ Publishing needs the Visual Studio C++ tools on the path of the shell; on a mach ## What is not proven See the Plan ledger record of PLT.45. In short: the Linux profile and launcher are compiled in hosted CI and unit-tested only; they have not been run -on Linux. macOS has no launcher. Windows signature (Authenticode) verification of the helper is not implemented: the launcher pins the +on Linux. macOS is not supported and has no launcher. Windows signature (Authenticode) verification of the helper is not implemented: the launcher pins the SHA-256 of the installed helper from the signed inventory. ## Production composition diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox/Tests/ContractFacadeTests.cs b/src/DesktopHelpers/ArcForges.ContentSandbox/Tests/ContractFacadeTests.cs index de0ef688..87d12ee4 100644 --- a/src/DesktopHelpers/ArcForges.ContentSandbox/Tests/ContractFacadeTests.cs +++ b/src/DesktopHelpers/ArcForges.ContentSandbox/Tests/ContractFacadeTests.cs @@ -307,8 +307,9 @@ public void TheProfileOfThisPlatformIsReportedWithoutLaunchingAnything() var status = ContentSandboxLauncher.ProbeProfile(); Assert.Equal(OperatingSystem.IsWindows(), status.Profile == ContentSandboxProfile.WindowsAppContainerJob); Assert.Equal(OperatingSystem.IsLinux(), status.Profile == ContentSandboxProfile.LinuxLandlockSeccomp); - if (OperatingSystem.IsMacOS()) + if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux()) { + // macOS, and any other operating system: a typed refusal, never an available profile. Assert.False(status.IsAvailable); Assert.NotEmpty(status.Reason); } diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox/Tests/MacProfileTests.cs b/src/DesktopHelpers/ArcForges.ContentSandbox/Tests/MacProfileTests.cs index 416a1305..c9a172d2 100644 --- a/src/DesktopHelpers/ArcForges.ContentSandbox/Tests/MacProfileTests.cs +++ b/src/DesktopHelpers/ArcForges.ContentSandbox/Tests/MacProfileTests.cs @@ -1,42 +1,133 @@ // SPDX-License-Identifier: AGPL-3.0-only -using System.Xml.Linq; +using System.Diagnostics; +using System.Security.Cryptography; using ArcForges.ContentSandbox.Broker; +using ArcForges.ContentSandbox.Contracts; +using ArcForges.ContentSandbox.Host; using Xunit; namespace ArcForges.ContentSandbox.Tests; -/// The macOS profile is declarative data and a refusal: no launcher exists, so nothing is ever started on that platform. +/// +/// macOS is not a supported platform, so its restricted profile is a typed refusal and nothing else. The broker launches nothing there, and a +/// helper that receives a frame carrying the reserved macOS value exits with the isolation-unavailable code before any resource or parser exists. +/// public sealed class MacProfileTests { - private static string EntitlementsPath() + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Fact] + public async Task ALaunchOnMacOsIsRefusedWithoutStartingAnything() + { + var options = Fixtures.Options(); + await using var launcher = new ContentSandboxLauncher(options, ContentSandboxProfile.MacOsAppSandboxXpc, launcherOverride: null); + var result = await launcher.LaunchAsync("data"u8.ToArray(), Ct); + Assert.False(result.IsSuccess); + Assert.Equal("security.isolation_unavailable", result.Failure!.Code); + Assert.False(Directory.Exists(options.RuntimeRoot)); + } + + [Fact] + public void TheReservedMacOsValueIsWireStableAndIsNeverThisPlatformsProfile() { - var directory = new DirectoryInfo(AppContext.BaseDirectory); - while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "DesktopPlatform.slnx"))) + Assert.Equal((byte)3, (byte)ContentSandboxProfileKind.MacOsAppSandboxXpc); + Assert.NotEqual((ContentSandboxProfileKind?)ContentSandboxProfileKind.MacOsAppSandboxXpc, ProfileEnforcement.ThisPlatform); + } + + [Fact] + public void TheWindowsAndLinuxKindsAreUnchangedAndAreTheOnlyProfilesOfThisPlatform() + { + Assert.Equal((byte)1, (byte)ContentSandboxProfileKind.WindowsAppContainerJob); + Assert.Equal((byte)2, (byte)ContentSandboxProfileKind.LinuxLandlockSeccomp); + if (OperatingSystem.IsWindows()) { - directory = directory.Parent; + Assert.Equal(ContentSandboxProfileKind.WindowsAppContainerJob, ProfileEnforcement.ThisPlatform); + } + else if (OperatingSystem.IsLinux()) + { + Assert.Equal(ContentSandboxProfileKind.LinuxLandlockSeccomp, ProfileEnforcement.ThisPlatform); + } + else + { + Assert.Null(ProfileEnforcement.ThisPlatform); } - - return Path.Combine(directory?.FullName ?? throw new InvalidOperationException("The repository root was not found."), - "src", "DesktopHelpers", "ArcForges.ContentSandbox", "macos", "ArcForges.ContentSandbox.entitlements"); } [Fact] - public void TheEntitlementsEnableTheAppSandboxAndNothingElse() + public async Task AHelperRefusesAFrameCarryingTheMacOsValueWithTheIsolationUnavailableExit() { - var document = XDocument.Load(EntitlementsPath()); - var keys = document.Descendants("key").Select(key => key.Value).ToArray(); - Assert.Equal(["com.apple.security.app-sandbox"], keys); - Assert.Equal("true", document.Descendants("dict").Single().Elements().ElementAt(1).Name.LocalName); - Assert.DoesNotContain(keys, key => key.Contains("network", StringComparison.Ordinal) - || key.Contains("files", StringComparison.Ordinal) || key.Contains("inherit", StringComparison.Ordinal) - || key.Contains("application-groups", StringComparison.Ordinal)); + var exit = await RunHelperAsync(ContentSandboxProfileKind.MacOsAppSandboxXpc, Ct); + Assert.Equal(ContentSandboxContract.ExitIsolationUnavailable, exit); } [Fact] - public async Task ALaunchOnMacOsIsRefusedWithoutStartingAnything() + public async Task AHelperKeepsItsOwnPlatformKindPastTheProfileGate() { - await using var launcher = new ContentSandboxLauncher(Fixtures.Options(), ContentSandboxProfile.MacOsAppSandboxXpc, launcherOverride: null); - var result = await launcher.LaunchAsync("data"u8.ToArray(), TestContext.Current.CancellationToken); - Assert.Equal("security.isolation_unavailable", result.Failure!.Code); + Assert.SkipUnless(OperatingSystem.IsWindows() || OperatingSystem.IsLinux(), "This platform has no restricted profile kind."); + var own = OperatingSystem.IsWindows() ? ContentSandboxProfileKind.WindowsAppContainerJob : ContentSandboxProfileKind.LinuxLandlockSeccomp; + + // The bootstrap resource below is too short to be a launch descriptor, so the helper stops at that check, after the profile gate. + var exit = await RunHelperAsync(own, Ct); + Assert.Equal(ContentSandboxContract.ExitLaunchFrameInvalid, exit); + } + + private static string HelperPath() => + Path.Combine(AppContext.BaseDirectory, "ArcForges.ContentSandbox" + (OperatingSystem.IsWindows() ? ".exe" : string.Empty)); + + private static ContentSandboxLaunchFrame Frame(ContentSandboxProfileKind kind) => + new( + kind, + Guid.NewGuid(), + Guid.NewGuid(), + 1, + Guid.NewGuid(), + 100, + SHA256.HashData("input"u8), + [1024], + new ContentSandboxLimits(), + [ + new(ContentSandboxHandleRole.Control, 10), + new(ContentSandboxHandleRole.Service, 11), + new(ContentSandboxHandleRole.Input, 12), + new(ContentSandboxHandleRole.Slot0, 13), + ], + "hostile-test-parser", + [1, 2, 3, 4]); + + /// Starts the built helper, writes one launch frame of the given kind to its standard input and returns its exit code. + private static async Task RunHelperAsync(ContentSandboxProfileKind kind, CancellationToken cancellationToken) + { + var helper = HelperPath(); + Assert.True(File.Exists(helper), "The helper host was not built next to the tests: " + helper); + using var frame = Frame(kind); + var encoded = frame.Encode(); + var startInfo = new ProcessStartInfo(helper) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var process = Process.Start(startInfo) ?? throw new InvalidOperationException("The helper host did not start."); + var output = process.StandardOutput.ReadToEndAsync(cancellationToken); + var diagnostics = process.StandardError.ReadToEndAsync(cancellationToken); + await process.StandardInput.BaseStream.WriteAsync(encoded, cancellationToken); + await process.StandardInput.BaseStream.FlushAsync(cancellationToken); + process.StandardInput.Close(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(TimeSpan.FromSeconds(60)); + try + { + await process.WaitForExitAsync(deadline.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + throw; + } + + _ = await output; + _ = await diagnostics; + return process.ExitCode; } } diff --git a/src/DesktopHelpers/ArcForges.ContentSandbox/macos/ArcForges.ContentSandbox.entitlements b/src/DesktopHelpers/ArcForges.ContentSandbox/macos/ArcForges.ContentSandbox.entitlements deleted file mode 100644 index dacb2408..00000000 --- a/src/DesktopHelpers/ArcForges.ContentSandbox/macos/ArcForges.ContentSandbox.entitlements +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - com.apple.security.app-sandbox - - -