diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs index 8571878..8e1fa3f 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/ApplyService.cs @@ -33,6 +33,7 @@ public sealed class ApplyService(IProcessRunner runner, IAccountService accounts await bitlocker.EnableAsync(req.BitLockerPin, ct); progress.Report(new("Finishing up", 95)); + await bootstrap.RevertKioskAsync(ct); // revert kiosk before account deletion (SID must still resolve) await bootstrap.TearDownAsync(req.BootstrapUser, ct); // last — only after success progress.Report(new("Done", 100)); } diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs index e367dcf..0e2c1a5 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/BootstrapService.cs @@ -1,6 +1,25 @@ namespace SilverOS.Welcome.Core.Apply; public sealed class BootstrapService(IProcessRunner runner) : IBootstrapService { + public async Task RevertKioskAsync(CancellationToken ct = default) + { + // Remove sm-bootstrap custom shell entry + disable Shell Launcher's per-user entry. + await Ps( + "$c='root\\\\standardcimv2\\\\embedded';" + + "$w=Get-CimInstance -Namespace $c -ClassName WESL_UserSetting -EA SilentlyContinue;" + + "if($w){" + + "$sid=(New-Object System.Security.Principal.NTAccount('sm-bootstrap')).Translate([System.Security.Principal.SecurityIdentifier]).Value;" + + "Invoke-CimMethod -InputObject $w -MethodName RemoveCustomShell -Arguments @{Sid=$sid} -EA SilentlyContinue | Out-Null;" + + "Invoke-CimMethod -InputObject $w -MethodName SetEnabled -Arguments @{Enabled=$false} -EA SilentlyContinue | Out-Null" + + "}", + "Revert Shell Launcher", ct); + // Revert escape policies set by Configure-Kiosk.ps1. + await Ps( + "$s='HKLM:\\\\SOFTWARE\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Policies\\\\System';" + + "Remove-ItemProperty $s -Name DisableTaskMgr,DisableLockWorkstation,HideFastUserSwitching -EA SilentlyContinue", + "Revert escape policies", ct); + } + public async Task TearDownAsync(string bootstrapUser, CancellationToken ct = default) { const string key = "'HKLM:\\SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon'"; diff --git a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs index 7fef74f..5e48d5b 100644 --- a/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs +++ b/windows/welcome/src/SilverOS.Welcome.Core/Apply/IBootstrapService.cs @@ -1,2 +1,6 @@ namespace SilverOS.Welcome.Core.Apply; -public interface IBootstrapService { Task TearDownAsync(string bootstrapUser, CancellationToken ct = default); } +public interface IBootstrapService +{ + Task RevertKioskAsync(CancellationToken ct = default); + Task TearDownAsync(string bootstrapUser, CancellationToken ct = default); +} diff --git a/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs b/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs new file mode 100644 index 0000000..198b51e --- /dev/null +++ b/windows/welcome/tests/SilverOS.Welcome.Tests/BootstrapServiceRevertKioskTests.cs @@ -0,0 +1,99 @@ +using Moq; +using SilverOS.Welcome.Core.Apply; + +public class BootstrapServiceRevertKioskTests +{ + private static Mock Ok() + { + var m = new Mock(); + m.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessResult(0, "", "")); + return m; + } + + private static Mock Fail() + { + var m = new Mock(); + m.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ProcessResult(1, "", "the operation failed")); + return m; + } + + [Fact] + public async Task RevertKioskAsync_throws_on_nonzero_exit() + { + await Assert.ThrowsAsync(() => + new BootstrapService(Fail().Object).RevertKioskAsync()); + } + + [Fact] + public async Task RevertKioskAsync_removes_custom_shell_and_disables_shell_launcher() + { + var run = Ok(); + await new BootstrapService(run.Object).RevertKioskAsync(); + // First call: Shell Launcher revert — must reference WESL_UserSetting and RemoveCustomShell + SetEnabled. + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("WESL_UserSetting") && + s.Contains("RemoveCustomShell") && + s.Contains("SetEnabled")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task RevertKioskAsync_reverts_escape_policies() + { + var run = Ok(); + await new BootstrapService(run.Object).RevertKioskAsync(); + // Second call: policy revert — must remove the three escape policy values. + run.Verify(r => r.RunAsync("powershell.exe", It.Is(s => + s.Contains("Remove-ItemProperty") && + s.Contains("DisableTaskMgr") && + s.Contains("DisableLockWorkstation") && + s.Contains("HideFastUserSwitching")), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task ApplyService_calls_revert_kiosk_before_teardown() + { + var order = new List(); + var run = new Mock(); + run.Setup(r => r.RunAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, a, _) => + { + if (a.Contains("Invoke-Hardening")) order.Add("modules"); + }) + .ReturnsAsync(new ProcessResult(0, "", "")); + + var acct = new Mock(); + acct.Setup(a => a.CreateAccountsAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback(() => order.Add("accounts")) + .Returns(Task.CompletedTask); + + var bl = new Mock(); + bl.Setup(b => b.EnableAsync(It.IsAny(), It.IsAny())) + .Callback(() => order.Add("bitlocker")) + .Returns(Task.CompletedTask); + + var boot = new Mock(); + boot.Setup(b => b.RevertKioskAsync(It.IsAny())) + .Callback(() => order.Add("revert-kiosk")) + .Returns(Task.CompletedTask); + boot.Setup(b => b.TearDownAsync(It.IsAny(), It.IsAny())) + .Callback(() => order.Add("teardown")) + .Returns(Task.CompletedTask); + + var sut = new ApplyService(run.Object, acct.Object, bl.Object, boot.Object, "C:\\hard"); + var flavour = new SilverOS.Welcome.Core.Flavours.FlavourManifest + { + Id = "daily-driver", + Hardening = new SilverOS.Welcome.Core.Flavours.HardeningSpec { Modules = new[] { "00" } } + }; + var req = new ApplyRequest(flavour, "alice", "pw", "adminpw", "123456", "sm-bootstrap"); + + await sut.RunAsync(req, new Progress(_ => { })); + + // revert-kiosk must precede teardown so the sm-bootstrap SID still resolves. + Assert.Equal(new[] { "modules", "accounts", "bitlocker", "revert-kiosk", "teardown" }, order); + } +}