feat(scripting): ScriptWorker isolation on Windows: restricted worker identity, pipe ACL, firewall + PeCacheDir installer (lmxopcua#780)
v2-ci / unit-tests (push) Has been cancelled

Owner decisions §14 #5/#6 as built (design §12.5):

- Scripting:Worker:Identity (default unset, Windows only, fail-closed validation): each worker
  starts under a restricted token derived from the host's. Every privilege but SeChangeNotify is
  removed; the restricting SIDs are NT SERVICE\OtOpcUaScriptWorker's SID + Everyone + Users +
  RESTRICTED; the token and default DACLs are host + SYSTEM + identity + RESTRICTED. The worker
  is created suspended, put in its Job Object before it runs, inherits only its stdio pipes, gets
  an ACL'd work dir as TEMP, and runs with DOTNET_EnableDiagnostics=0. The identity is granted on
  the host's window station and desktop, because USER32 init made coreclr.dll fail with
  ERROR_DLL_INIT_FAILED on windev. The logon SID is not used because the host's process DACL
  gives it PROCESS_VM_READ.
- The worker pipe on Windows always has an explicit protected DACL: host account + identity
  only. There is no owner ACE, so an elevated host's Administrators gets no access.
- The worker treats access denied on its parent-pid poll as alive; the Job Object covers that case.
- scripts/install/Install-ScriptWorkerIsolation.ps1 + ScriptWorkerIsolation.psm1 are idempotent
  and support -WhatIf, -Uninstall and -PlanOnly. They create the disabled anchor service, the
  PeCacheDir ACL (protected, no Users, key and markers host-only, pe\ Modify for the identity),
  read+execute on script-worker\, a ReadData deny on the install root, the outbound firewall
  block on the worker exe, and optionally the host-service env.
- Tests: the dry-run harness scripts/install/tests/Test-ScriptWorkerIsolation.ps1 (46
  assertions, mutation-checked); ScriptWorkerIdentityTests; WorkerIsolationTests, which cover
  the pipe ACL, token access and a real restricted worker running a script, all green on windev.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 08:46:23 -05:00
parent 0e96dd08a2
commit 2a8e2981fe
20 changed files with 1898 additions and 37 deletions
@@ -165,6 +165,13 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
_scriptRoot = scriptRoot ?? _log;
LoadTimeout = loadTimeout;
WorkerPath = ResolveWorkerPath(_worker);
// Fail closed (startup also refuses it, ScriptRunnerSelection.Validate): a configured identity that cannot be
// applied must never silently fall back to the host's own token.
if (_worker.ValidateIdentity(OperatingSystem.IsWindows()) is { } identityViolation)
throw new InvalidOperationException($"Scripting misconfiguration: {identityViolation}");
WorkerSid = string.IsNullOrWhiteSpace(_worker.Identity)
? null
: ScriptWorkerIdentity.TryResolveSid(_worker.Identity, out var sid, out _) ? sid : null;
// The PE cache is best effort: a directory or key that cannot be used is a Warning and a cache-less runner,
// never a startup failure (the path RULE is enforced at startup by ScriptRunnerSelection.Validate).
@@ -211,6 +218,9 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
/// <summary>The worker entry assembly this runner launches.</summary>
public string WorkerPath { get; }
/// <summary>The worker identity's SID (<c>Scripting:Worker:Identity</c>), or null when workers keep the host's token.</summary>
public string? WorkerSid { get; }
/// <summary>The overall state (the readiness check, the state gauge).</summary>
public ScriptRunnerState State => ComputeState(_shards.Select(s => s.DownFor).ToArray(), _isolation.DownFor, _worker.CircuitOpenAfter);
@@ -693,7 +703,8 @@ public sealed class OutOfProcessScriptRunner : IScriptRunner, IAsyncDisposable,
PeCacheKey: _peCacheKey,
MarkerDirectory: _markerDirectory,
LoadTimeout: LoadTimeout,
CompileThreads: _compileThreads);
CompileThreads: _compileThreads,
WorkerSid: WorkerSid);
return new WorkerShard(label, isolation,
(onExiting, ct) => WorkerConnection.StartAsync(spec, _log, OnLogs, onExiting, ct),
OnWorkerExit, _log);
@@ -0,0 +1,506 @@
using System.ComponentModel;
using System.Diagnostics;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Text;
using Microsoft.Win32.SafeHandles;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
/// <summary>
/// Starts a ScriptWorker under a restricted token for <c>Scripting:Worker:Identity</c> (lmxopcua#780 design §6, owner
/// decision §14 #5; see <see cref="ScriptWorkerIdentity"/> for why a restricted token and not a logon). The launch:
/// <list type="number">
/// <item>derives a primary token from the host's with <c>DISABLE_MAX_PRIVILEGE</c> and the restricting SIDs
/// (<see cref="ScriptWorkerIdentity.RestrictingSids"/>) — no password, no <c>SeAssignPrimaryTokenPrivilege</c>
/// (a restricted copy of the caller's own token does not need it);</item>
/// <item>sets the token's default DACL, and the token object's own DACL, to the host account, SYSTEM, the worker
/// identity and <c>RESTRICTED</c>, so the worker can open its own token and process, the worker's
/// process object stays openable by the host (kill, exit code, the Job Object) and objects the worker creates are
/// reachable by both;</item>
/// <item>creates the process <b>suspended</b> with <c>CreateProcessAsUserW</c>, inheriting exactly its stdout and
/// stderr pipe ends (<c>PROC_THREAD_ATTRIBUTE_HANDLE_LIST</c>), with the allow-listed environment block;</item>
/// <item>assigns it to the worker's Job Object <b>before</b> its first instruction runs, then resumes it.</item>
/// </list>
/// </summary>
[SupportedOSPlatform("windows")]
internal static class RestrictedWorkerLauncher
{
/// <summary>A started worker: its process, its job and the two stdio readers.</summary>
internal sealed class Launched : IDisposable
{
private readonly AnonymousPipeServerStream _stdout;
private readonly AnonymousPipeServerStream _stderr;
internal Launched(Process process, WindowsJobObject job, AnonymousPipeServerStream stdout, AnonymousPipeServerStream stderr)
{
Process = process;
Job = job;
_stdout = stdout;
_stderr = stderr;
}
/// <summary>The worker process (opened by pid while the creation handle was still held, so it cannot be a reused pid).</summary>
public Process Process { get; }
/// <summary>The worker's Job Object (kill on close).</summary>
public WindowsJobObject Job { get; }
/// <summary>Closes the stdio readers (the process and job are owned by the caller).</summary>
public void Dispose()
{
_stdout.Dispose();
_stderr.Dispose();
}
}
/// <summary>Starts <paramref name="psi"/> under a restricted token for <paramref name="workerSid"/>.</summary>
/// <param name="psi">What to run: file name, argument list, working directory and the final environment.</param>
/// <param name="workerSid">The worker identity's SID string.</param>
/// <param name="processMemoryLimitBytes">The Job Object's per-process commit limit.</param>
/// <param name="onStdout">Receives each stdout line.</param>
/// <param name="onStderr">Receives each stderr line.</param>
/// <returns>The started worker.</returns>
public static Launched Start(ProcessStartInfo psi, string workerSid, long processMemoryLimitBytes, Action<string> onStdout,
Action<string> onStderr)
{
ArgumentNullException.ThrowIfNull(psi);
GrantSessionObjects(workerSid);
using var token = CreateWorkerToken(workerSid);
var stdout = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
var stderr = new AnonymousPipeServerStream(PipeDirection.In, HandleInheritability.Inheritable);
WindowsJobObject? job = null;
PROCESS_INFORMATION pi = default;
var attributeList = IntPtr.Zero;
var handleList = IntPtr.Zero;
var environment = IntPtr.Zero;
try
{
var inherit = new[] { stdout.ClientSafePipeHandle.DangerousGetHandle(), stderr.ClientSafePipeHandle.DangerousGetHandle() };
handleList = Marshal.AllocHGlobal(IntPtr.Size * inherit.Length);
Marshal.Copy(inherit, 0, handleList, inherit.Length);
var size = IntPtr.Zero;
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size);
attributeList = Marshal.AllocHGlobal(size);
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, ref size))
throw new Win32Exception(Marshal.GetLastWin32Error(), "InitializeProcThreadAttributeList failed");
if (!UpdateProcThreadAttribute(attributeList, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handleList,
(IntPtr)(IntPtr.Size * inherit.Length), IntPtr.Zero, IntPtr.Zero))
throw new Win32Exception(Marshal.GetLastWin32Error(), "UpdateProcThreadAttribute(HANDLE_LIST) failed");
var si = new STARTUPINFOEX
{
StartupInfo = new STARTUPINFO
{
cb = Marshal.SizeOf<STARTUPINFOEX>(),
dwFlags = STARTF_USESTDHANDLES,
hStdInput = IntPtr.Zero,
hStdOutput = inherit[0],
hStdError = inherit[1],
},
lpAttributeList = attributeList,
};
var commandLine = new StringBuilder(WindowsProcessText.BuildCommandLine(psi.FileName, psi.ArgumentList));
environment = Marshal.StringToHGlobalUni(WindowsProcessText.BuildEnvironmentBlock(psi.Environment));
const uint flags = CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT;
if (!CreateProcessAsUserW(token, psi.FileName, commandLine, IntPtr.Zero, IntPtr.Zero, bInheritHandles: true, flags,
environment, string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, ref si, out pi))
throw new Win32Exception(Marshal.GetLastWin32Error(), $"CreateProcessAsUser for '{psi.FileName}' failed");
// The child owns its ends now.
stdout.DisposeLocalCopyOfClientHandle();
stderr.DisposeLocalCopyOfClientHandle();
// Into the job before it runs a single instruction: the kill-on-close and process-count limits hold from birth.
job = WindowsJobObject.Assign(pi.hProcess, processMemoryLimitBytes);
// Opened while the creation handle is still held, so the pid cannot have been reused; .Handle caches a handle
// the Process object uses from here on (the token's default DACL grants the host account).
var process = Process.GetProcessById(pi.dwProcessId);
_ = process.Handle;
process.EnableRaisingEvents = true;
if (ResumeThread(pi.hThread) == uint.MaxValue)
{
var error = Marshal.GetLastWin32Error();
process.Dispose();
throw new Win32Exception(error, "ResumeThread failed");
}
Pump(stdout, onStdout);
Pump(stderr, onStderr);
return new Launched(process, job, stdout, stderr);
}
catch
{
if (pi.hProcess != IntPtr.Zero) TerminateProcess(pi.hProcess, 1);
job?.Dispose();
stdout.Dispose();
stderr.Dispose();
throw;
}
finally
{
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
if (attributeList != IntPtr.Zero)
{
DeleteProcThreadAttributeList(attributeList);
Marshal.FreeHGlobal(attributeList);
}
if (handleList != IntPtr.Zero) Marshal.FreeHGlobal(handleList);
if (environment != IntPtr.Zero) Marshal.FreeHGlobal(environment);
}
}
/// <summary>
/// The worker's primary token: the host's, with every privilege but <c>SeChangeNotifyPrivilege</c> removed, the
/// restricting SIDs of <paramref name="workerSid"/>, and a default DACL of host account + SYSTEM + worker identity.
/// </summary>
/// <param name="workerSid">The worker identity's SID string.</param>
/// <returns>The token (owned by the caller).</returns>
internal static SafeAccessTokenHandle CreateWorkerToken(string workerSid)
{
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT | READ_CONTROL | WRITE_DAC,
out var processToken))
throw new Win32Exception(Marshal.GetLastWin32Error(), "OpenProcessToken failed");
using (processToken)
{
// Not the logon SID: the host's own process DACL grants it PROCESS_VM_READ (measured on windev), so a worker
// holding it could read the host's memory.
var restricting = ScriptWorkerIdentity.RestrictingSids(workerSid).Select(s => new SecurityIdentifier(s)).ToArray();
var pinned = new List<GCHandle>();
var entries = IntPtr.Zero;
try
{
var entrySize = Marshal.SizeOf<SID_AND_ATTRIBUTES>();
entries = Marshal.AllocHGlobal(entrySize * restricting.Length);
for (var i = 0; i < restricting.Length; i++)
{
var bytes = new byte[restricting[i].BinaryLength];
restricting[i].GetBinaryForm(bytes, 0);
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
pinned.Add(handle);
Marshal.StructureToPtr(new SID_AND_ATTRIBUTES { Sid = handle.AddrOfPinnedObject(), Attributes = 0 },
entries + (i * entrySize), fDeleteOld: false);
}
if (!CreateRestrictedToken(processToken, DISABLE_MAX_PRIVILEGE, 0, IntPtr.Zero, 0, IntPtr.Zero,
(uint)restricting.Length, entries, out var restricted))
throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateRestrictedToken failed");
try
{
SetDefaultDacl(restricted, new SecurityIdentifier(workerSid));
return restricted;
}
catch
{
restricted.Dispose();
throw;
}
}
finally
{
foreach (var handle in pinned) handle.Free();
if (entries != IntPtr.Zero) Marshal.FreeHGlobal(entries);
}
}
}
private static readonly object SessionGrantLock = new();
private static readonly HashSet<string> SessionGranted = new(StringComparer.Ordinal);
/// <summary>
/// Adds the worker identity to the DACL of the host's window station and desktop (once per process and identity).
/// USER32's DllMain connects the process to them, and the runtime imports USER32, so a worker whose restricting SIDs
/// have no access there fails to load <c>coreclr.dll</c> (<c>ERROR_DLL_INIT_FAILED</c>, measured on windev). The
/// logon SID, which those objects normally grant, is deliberately NOT a restricting SID (the host's process DACL
/// gives it <c>PROCESS_VM_READ</c>). The grant is to the identity only and lives on the session's kernel objects:
/// it ends with the host's session and changes nothing persistent.
/// </summary>
/// <param name="workerSid">The worker identity's SID string.</param>
internal static void GrantSessionObjects(string workerSid)
{
lock (SessionGrantLock)
{
if (!SessionGranted.Add(workerSid)) return;
try
{
var sid = new SecurityIdentifier(workerSid);
AddAllowAce(GetProcessWindowStation(), sid, WindowStationRights);
AddAllowAce(GetThreadDesktop(GetCurrentThreadId()), sid, DesktopRights);
}
catch
{
SessionGranted.Remove(workerSid);
throw;
}
}
}
/// <summary>The rights the worker identity gets on the window station: enumerate, read attributes, global atoms.</summary>
internal const int WindowStationRights = 0x0001 | 0x0002 | 0x0020 | 0x0100 | 0x00020000;
/// <summary>The rights the worker identity gets on the desktop: read objects, create windows (USER32 init), enumerate.</summary>
internal const int DesktopRights = 0x0001 | 0x0002 | 0x0040 | 0x00020000;
private static void AddAllowAce(IntPtr handle, SecurityIdentifier sid, int mask)
{
if (handle == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "no window station / desktop handle");
var error = GetSecurityInfoRaw(handle, SE_WINDOW_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero, out var daclPtr,
IntPtr.Zero, out var sd);
if (error != 0) throw new Win32Exception((int)error, "GetSecurityInfo(window object) failed");
try
{
RawAcl acl;
if (daclPtr == IntPtr.Zero) return; // a NULL DACL already admits everyone
var size = Marshal.ReadInt16(daclPtr + 2);
var bytes = new byte[size];
Marshal.Copy(daclPtr, bytes, 0, size);
acl = new RawAcl(bytes, 0);
foreach (var ace in acl.OfType<CommonAce>())
if (ace.SecurityIdentifier == sid && ace.AceQualifier == AceQualifier.AccessAllowed && (ace.AccessMask & mask) == mask) return;
acl.InsertAce(acl.Count, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed, mask, sid, false, null));
var updated = new byte[acl.BinaryLength];
acl.GetBinaryForm(updated, 0);
var pinned = GCHandle.Alloc(updated, GCHandleType.Pinned);
try
{
error = SetSecurityInfoRaw(handle, SE_WINDOW_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero,
pinned.AddrOfPinnedObject(), IntPtr.Zero);
if (error != 0) throw new Win32Exception((int)error, "SetSecurityInfo(window object) failed");
}
finally
{
pinned.Free();
}
}
finally
{
LocalFree(sd);
}
}
/// <summary>The SDDL of the default DACL a worker token carries (tests).</summary>
/// <param name="hostUser">The host account.</param>
/// <param name="worker">The worker identity.</param>
/// <returns>The raw ACL.</returns>
internal static RawAcl DefaultDacl(SecurityIdentifier hostUser, SecurityIdentifier worker)
{
var acl = new RawAcl(GenericAcl.AclRevision, 3);
var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
var restricted = new SecurityIdentifier(ScriptWorkerIdentity.RestrictedSid);
foreach (var sid in new[] { hostUser, system, worker, restricted }.Distinct())
acl.InsertAce(acl.Count, new CommonAce(AceFlags.None, AceQualifier.AccessAllowed, GENERIC_ALL, sid, false, null));
return acl;
}
private static void SetDefaultDacl(SafeAccessTokenHandle token, SecurityIdentifier worker)
{
using var identity = WindowsIdentity.GetCurrent();
var acl = DefaultDacl(identity.User ?? throw new InvalidOperationException("the host token has no user"), worker);
var bytes = new byte[acl.BinaryLength];
acl.GetBinaryForm(bytes, 0);
var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned);
try
{
var info = new TOKEN_DEFAULT_DACL { DefaultDacl = handle.AddrOfPinnedObject() };
if (!SetTokenInformation(token, TokenDefaultDacl, ref info, (uint)Marshal.SizeOf<TOKEN_DEFAULT_DACL>()))
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetTokenInformation(TokenDefaultDacl) failed");
// The token object itself: its DACL came from the host's default DACL, which no restricting SID passes, so the
// worker could not open its own token and the loader fails (coreclr.dll, ERROR_DLL_INIT_FAILED, on windev).
var error = SetSecurityInfo(token, SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, IntPtr.Zero, IntPtr.Zero,
handle.AddrOfPinnedObject(), IntPtr.Zero);
if (error != 0) throw new Win32Exception((int)error, "SetSecurityInfo(token DACL) failed");
}
finally
{
handle.Free();
}
}
/// <summary>
/// Gives the worker identity Modify on its per-launch working directory: protected, host account + SYSTEM full
/// control, worker identity modify. Its temp files go there (<c>TEMP</c>/<c>TMP</c> point at it).
/// </summary>
/// <param name="directory">The working directory the host created.</param>
/// <param name="workerSid">The worker identity's SID string.</param>
public static void GrantWorkDirectory(string directory, string workerSid)
{
using var identity = WindowsIdentity.GetCurrent();
var security = new DirectorySecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
const InheritanceFlags inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
var principals = new List<SecurityIdentifier> { new(WellKnownSidType.LocalSystemSid, null) };
if (identity.User is { } user) principals.Add(user);
if (identity.Owner is { } owner && !principals.Contains(owner)) principals.Add(owner);
foreach (var principal in principals)
security.AddAccessRule(new FileSystemAccessRule(principal, FileSystemRights.FullControl, inherit, PropagationFlags.None,
AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(workerSid), FileSystemRights.Modify | FileSystemRights.Synchronize,
inherit, PropagationFlags.None, AccessControlType.Allow));
new DirectoryInfo(directory).SetAccessControl(security);
}
private static void Pump(AnonymousPipeServerStream stream, Action<string> onLine)
{
var thread = new Thread(() =>
{
try
{
using var reader = new StreamReader(stream, Encoding.UTF8);
while (reader.ReadLine() is { } line) onLine(line);
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException)
{
// The worker is gone or the host is stopping.
}
})
{ IsBackground = true, Name = "ScriptWorker.Stdio" };
thread.Start();
}
private const uint TOKEN_DUPLICATE = 0x0002;
private const uint TOKEN_ASSIGN_PRIMARY = 0x0001;
private const uint TOKEN_QUERY = 0x0008;
private const uint TOKEN_ADJUST_DEFAULT = 0x0080;
private const uint READ_CONTROL = 0x00020000;
private const uint WRITE_DAC = 0x00040000; // the restricted token inherits these rights: its own DACL is rewritten
private const uint DISABLE_MAX_PRIVILEGE = 0x1;
private const int TokenDefaultDacl = 6;
private const int SE_KERNEL_OBJECT = 6;
private const int SE_WINDOW_OBJECT = 7;
private const uint DACL_SECURITY_INFORMATION = 0x4;
private const int GENERIC_ALL = 0x10000000;
private const int STARTF_USESTDHANDLES = 0x00000100;
private const uint CREATE_SUSPENDED = 0x00000004;
private const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
private const uint CREATE_NO_WINDOW = 0x08000000;
private const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
private static readonly IntPtr PROC_THREAD_ATTRIBUTE_HANDLE_LIST = (IntPtr)0x00020002;
[StructLayout(LayoutKind.Sequential)]
private struct SID_AND_ATTRIBUTES
{
public IntPtr Sid;
public uint Attributes;
}
[StructLayout(LayoutKind.Sequential)]
private struct TOKEN_DEFAULT_DACL
{
public IntPtr DefaultDacl;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct STARTUPINFO
{
public int cb;
public string? lpReserved;
public string? lpDesktop;
public string? lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct STARTUPINFOEX
{
public STARTUPINFO StartupInfo;
public IntPtr lpAttributeList;
}
[StructLayout(LayoutKind.Sequential)]
private struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
#pragma warning disable SYSLIB1054 // DllImport kept, as in WindowsJobObject: LibraryImport would need AllowUnsafeBlocks.
[DllImport("kernel32.dll")]
private static extern IntPtr GetCurrentProcess();
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool OpenProcessToken(IntPtr process, uint desiredAccess, out SafeAccessTokenHandle token);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateRestrictedToken(SafeAccessTokenHandle existingToken, uint flags, uint disableSidCount,
IntPtr sidsToDisable, uint deletePrivilegeCount, IntPtr privilegesToDelete, uint restrictedSidCount, IntPtr sidsToRestrict,
out SafeAccessTokenHandle newToken);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetTokenInformation(SafeAccessTokenHandle token, int infoClass, ref TOKEN_DEFAULT_DACL info, uint length);
[DllImport("advapi32.dll", SetLastError = true)]
private static extern uint SetSecurityInfo(SafeAccessTokenHandle handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
IntPtr dacl, IntPtr sacl);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool InitializeProcThreadAttributeList(IntPtr list, int count, int flags, ref IntPtr size);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool UpdateProcThreadAttribute(IntPtr list, uint flags, IntPtr attribute, IntPtr value, IntPtr size,
IntPtr previousValue, IntPtr returnSize);
[DllImport("kernel32.dll")]
private static extern void DeleteProcThreadAttributeList(IntPtr list);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CreateProcessAsUserW(SafeAccessTokenHandle token, string applicationName, StringBuilder commandLine,
IntPtr processAttributes, IntPtr threadAttributes, [MarshalAs(UnmanagedType.Bool)] bool bInheritHandles, uint creationFlags,
IntPtr environment, string? currentDirectory, ref STARTUPINFOEX startupInfo, out PROCESS_INFORMATION processInformation);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint ResumeThread(IntPtr thread);
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr GetProcessWindowStation();
[DllImport("user32.dll", SetLastError = true)]
private static extern IntPtr GetThreadDesktop(uint threadId);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[DllImport("kernel32.dll")]
private static extern IntPtr LocalFree(IntPtr memory);
[DllImport("advapi32.dll", EntryPoint = "GetSecurityInfo", SetLastError = true)]
private static extern uint GetSecurityInfoRaw(IntPtr handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
out IntPtr dacl, IntPtr sacl, out IntPtr securityDescriptor);
[DllImport("advapi32.dll", EntryPoint = "SetSecurityInfo", SetLastError = true)]
private static extern uint SetSecurityInfoRaw(IntPtr handle, int objectType, uint securityInfo, IntPtr owner, IntPtr group,
IntPtr dacl, IntPtr sacl);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool TerminateProcess(IntPtr process, uint exitCode);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr handle);
#pragma warning restore SYSLIB1054
}
@@ -29,6 +29,16 @@ internal sealed class WindowsJobObject : IDisposable
/// <param name="processMemoryLimitBytes">The per-process commit limit.</param>
/// <returns>The job; dispose it to kill the worker.</returns>
public static WindowsJobObject Assign(Process process, long processMemoryLimitBytes)
=> Assign(process.Handle, processMemoryLimitBytes);
/// <summary>
/// Creates a job with the worker limits and assigns the process behind <paramref name="processHandle"/> to it — the
/// restricted launch assigns its worker while it is still suspended (<see cref="RestrictedWorkerLauncher"/>).
/// </summary>
/// <param name="processHandle">A handle with <c>PROCESS_SET_QUOTA</c> and <c>PROCESS_TERMINATE</c>.</param>
/// <param name="processMemoryLimitBytes">The per-process commit limit.</param>
/// <returns>The job; dispose it to kill the worker.</returns>
public static WindowsJobObject Assign(IntPtr processHandle, long processMemoryLimitBytes)
{
var handle = CreateJobObjectW(IntPtr.Zero, null);
if (handle.IsInvalid) throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObject failed");
@@ -48,7 +58,7 @@ internal sealed class WindowsJobObject : IDisposable
if (!SetInformationJobObject(handle, JobObjectExtendedLimitInformation, ref info,
(uint)Marshal.SizeOf<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()))
throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
if (!AssignProcessToJobObject(handle, process.Handle))
if (!AssignProcessToJobObject(handle, processHandle))
throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
return job;
}
@@ -0,0 +1,64 @@
using System.Text;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
/// <summary>
/// The text a native Windows process launch takes (<see cref="RestrictedWorkerLauncher"/>): the command line and the
/// environment block. Pure string work, so it is testable on any OS.
/// </summary>
internal static class WindowsProcessText
{
/// <summary>A Windows command line for <paramref name="fileName"/> and <paramref name="arguments"/> (MSVC quoting rules).</summary>
/// <param name="fileName">The executable.</param>
/// <param name="arguments">The arguments.</param>
/// <returns>The command line.</returns>
internal static string BuildCommandLine(string fileName, IEnumerable<string> arguments)
{
var sb = new StringBuilder();
AppendQuoted(sb, fileName);
foreach (var argument in arguments)
{
sb.Append(' ');
AppendQuoted(sb, argument);
}
return sb.ToString();
}
private static void AppendQuoted(StringBuilder sb, string argument)
{
if (argument.Length > 0 && argument.IndexOfAny([' ', '\t', '\n', '\v', '"']) < 0)
{
sb.Append(argument);
return;
}
sb.Append('"');
var backslashes = 0;
foreach (var c in argument)
{
if (c == '\\')
{
backslashes++;
continue;
}
if (c == '"') sb.Append('\\', (backslashes * 2) + 1);
else sb.Append('\\', backslashes);
backslashes = 0;
sb.Append(c);
}
sb.Append('\\', backslashes * 2);
sb.Append('"');
}
/// <summary>A sorted, double-NUL-terminated UTF-16 environment block.</summary>
/// <param name="environment">The variables.</param>
/// <returns>The block.</returns>
internal static string BuildEnvironmentBlock(IDictionary<string, string?> environment)
{
var sb = new StringBuilder();
foreach (var (name, value) in environment.Where(kv => kv.Value is not null).OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
sb.Append(name).Append('=').Append(value).Append('\0');
if (sb.Length == 0) sb.Append('\0');
sb.Append('\0');
return sb.ToString();
}
}
@@ -77,6 +77,10 @@ internal sealed record BatchSubmission(string Key, EvalRequest Request, TimeSpan
/// it, lmxopcua#790), before the worker is killed and the key struck; null = 60 s.
/// </param>
/// <param name="CompileThreads">The worker's dedicated compile threads; 0 = its default.</param>
/// <param name="WorkerSid">
/// Windows only: the worker identity's SID (<c>Scripting:Worker:Identity</c>). Set, the worker starts under a restricted
/// token (<see cref="RestrictedWorkerLauncher"/>) and the pipe ACL admits it beside the host; null keeps the host's token.
/// </param>
internal sealed record WorkerLaunchSpec(
string Shard,
string WorkerPath,
@@ -89,7 +93,8 @@ internal sealed record WorkerLaunchSpec(
byte[]? PeCacheKey = null,
string? MarkerDirectory = null,
TimeSpan? LoadTimeout = null,
int CompileThreads = 0)
int CompileThreads = 0,
string? WorkerSid = null)
{
/// <summary>The default compile timeout.</summary>
public static readonly TimeSpan DefaultLoadTimeout = TimeSpan.FromSeconds(60);
@@ -119,6 +124,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
private readonly Process _process;
private readonly NamedPipeServerStream _pipe;
private readonly IDisposable? _job;
private readonly IDisposable? _stdio;
private readonly string _workDir;
private readonly ILogger _log;
private readonly Action<LogBatch> _onLogs;
@@ -143,10 +149,11 @@ internal sealed class WorkerConnection : IAsyncDisposable
private long _nextLoadId;
private int _disposed;
private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, string workDir,
ILogger log, Action<LogBatch> onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout, Action<WorkerExit> onExiting,
string? markerPath)
private WorkerConnection(string shard, Process process, NamedPipeServerStream pipe, IDisposable? job, IDisposable? stdio,
string workDir, ILogger log, Action<LogBatch> onLogs, TimeSpan heartbeatInterval, TimeSpan loadTimeout,
Action<WorkerExit> onExiting, string? markerPath)
{
_stdio = stdio;
_loadTimeout = loadTimeout;
_onExiting = onExiting;
_markerPath = markerPath;
@@ -222,36 +229,57 @@ internal sealed class WorkerConnection : IAsyncDisposable
var workDir = Path.Combine(Path.GetTempPath(), "otopcua-scriptworker", pipeName);
Directory.CreateDirectory(workDir);
// One instance, current user only: the pipe accepts exactly one connection, from this account.
var pipe = new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
// One instance, admitted principals only: the pipe accepts exactly one connection. Windows: an explicit,
// protected ACL — the host account and, under Scripting:Worker:Identity, the worker identity (design §6).
// Elsewhere: CurrentUserOnly (a Unix socket owned by this user, 0600).
if (spec.WorkerSid is not null && !OperatingSystem.IsWindows())
throw new PlatformNotSupportedException("Scripting:Worker:Identity is only supported on Windows");
var pipe = OperatingSystem.IsWindows()
? WorkerPipeSecurity.CreateServer(pipeName, WorkerPipeSecurity.ForCurrentHost(spec.WorkerSid))
: new NamedPipeServerStream(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
Process? process = null;
IDisposable? job = null;
IDisposable? stdio = null;
string? markerPath = null;
try
{
var psi = BuildStartInfo(spec, pipeName, nonce, workDir);
process = new Process { StartInfo = psi, EnableRaisingEvents = true };
if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
// Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
// sweep can match to exactly this process (design §3.3).
if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
if (OperatingSystem.IsWindows())
{
// Native and JIT memory backstop above the managed heap limit.
job = WindowsJobObject.Assign(process, spec.HeapLimitBytes + 256L * 1024 * 1024);
}
var shardLabel = spec.Shard;
process.ErrorDataReceived += (_, e) =>
void OnStderr(string? line)
{
if (e.Data is { Length: > 0 } line) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
};
process.OutputDataReceived += (_, e) =>
if (line is { Length: > 0 }) log.Warning("ScriptWorker[{Shard}] {WorkerStderr}", shardLabel, line);
}
void OnStdout(string? line)
{
if (e.Data is { Length: > 0 } line) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
};
process.BeginErrorReadLine();
process.BeginOutputReadLine();
if (line is { Length: > 0 }) log.Debug("ScriptWorker[{Shard}] stdout {WorkerStdout}", shardLabel, line);
}
// Native and JIT memory backstop above the managed heap limit.
var jobLimit = spec.HeapLimitBytes + 256L * 1024 * 1024;
if (spec.WorkerSid is { } workerSid && OperatingSystem.IsWindows())
{
// The worker reaches only what its identity may: its working directory is the one place it writes.
RestrictedWorkerLauncher.GrantWorkDirectory(workDir, workerSid);
var launched = RestrictedWorkerLauncher.Start(psi, workerSid, jobLimit, OnStdout, OnStderr);
process = launched.Process;
job = launched.Job;
stdio = launched;
if (spec.MarkerDirectory is { } restrictedMarkerDir)
markerPath = WorkerLaunchMarker.Write(restrictedMarkerDir, process, pipeName, log);
}
else
{
process = new Process { StartInfo = psi, EnableRaisingEvents = true };
if (!process.Start()) throw new InvalidOperationException("the worker process did not start");
// Before anything else can fail: a host that dies from here on leaves a marker the next start's orphan
// sweep can match to exactly this process (design §3.3).
if (spec.MarkerDirectory is { } markerDir) markerPath = WorkerLaunchMarker.Write(markerDir, process, pipeName, log);
if (OperatingSystem.IsWindows()) job = WindowsJobObject.Assign(process, jobLimit);
process.ErrorDataReceived += (_, e) => OnStderr(e.Data);
process.OutputDataReceived += (_, e) => OnStdout(e.Data);
process.BeginErrorReadLine();
process.BeginOutputReadLine();
}
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
handshakeCts.CancelAfter(spec.HandshakeTimeout);
@@ -283,7 +311,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
else
await HandshakeAsync(pipe, hello, nonce, process.Id, handshakeCts.Token).ConfigureAwait(false);
var connection = new WorkerConnection(spec.Shard, process, pipe, job, workDir, log, onLogs, spec.HeartbeatInterval,
var connection = new WorkerConnection(spec.Shard, process, pipe, job, stdio, workDir, log, onLogs, spec.HeartbeatInterval,
spec.EffectiveLoadTimeout, onExiting, markerPath);
connection.Begin();
return connection;
@@ -292,6 +320,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
{
try { if (process is { HasExited: false }) process.Kill(entireProcessTree: true); } catch { /* best effort */ }
job?.Dispose();
stdio?.Dispose();
process?.Dispose();
await pipe.DisposeAsync().ConfigureAwait(false);
TryDeleteDirectory(workDir);
@@ -367,7 +396,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
return reply.Ready;
}
private static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
internal static ProcessStartInfo BuildStartInfo(WorkerLaunchSpec spec, string pipeName, string nonce, string workDir)
{
var psi = new ProcessStartInfo
{
@@ -408,6 +437,15 @@ internal sealed class WorkerConnection : IAsyncDisposable
psi.Environment[ScriptWorkerEnvironment.Nonce] = nonce;
psi.Environment[ScriptWorkerEnvironment.ParentPid] = Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture);
psi.Environment[ScriptWorkerEnvironment.Shard] = spec.Shard;
if (spec.WorkerSid is not null)
{
// A restricted worker cannot write the host's temp directory; its own working directory is its temp.
psi.Environment["TEMP"] = workDir;
psi.Environment["TMP"] = workDir;
// No debugger / profiler / diagnostics IPC: a restricted token cannot create the runtime's named objects,
// and nothing should attach to a sandboxed worker anyway.
psi.Environment["DOTNET_EnableDiagnostics"] = "0";
}
return psi;
}
@@ -651,6 +689,7 @@ internal sealed class WorkerConnection : IAsyncDisposable
_writer.Complete();
await _pipe.DisposeAsync().ConfigureAwait(false);
_job?.Dispose();
_stdio?.Dispose();
_process.Dispose();
TryDeleteDirectory(_workDir);
WorkerLaunchMarker.TryDelete(_markerPath);
@@ -0,0 +1,60 @@
using System.IO.Pipes;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using System.Security.Principal;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.OutOfProcess;
/// <summary>
/// The ScriptWorker pipe's explicit ACL on Windows (lmxopcua#780 design §6, "an explicit ACL when the worker runs as a
/// different account"): a protected DACL that admits the host account and — when <c>Scripting:Worker:Identity</c> is
/// set — the worker identity, each with read/write, and nobody else (no SYSTEM, no Administrators, no Everyone). The
/// owner is the host token's owner, as .NET's <c>PipeOptions.CurrentUserOnly</c> sets it, so the worker's
/// <c>CurrentUserOnly</c> client check (pipe owner == its token owner, which a restricted token keeps) still
/// authenticates the server end.
/// </summary>
[SupportedOSPlatform("windows")]
internal static class WorkerPipeSecurity
{
/// <summary>The rights each admitted principal gets: read/write data, attributes and the security descriptor (read).</summary>
internal const PipeAccessRights GrantedRights = PipeAccessRights.ReadWrite;
/// <summary>Builds the pipe ACL for this host process.</summary>
/// <param name="workerSid">The worker identity's SID string, or null when the worker keeps the host's token.</param>
/// <returns>The pipe security.</returns>
public static PipeSecurity ForCurrentHost(string? workerSid)
{
using var identity = WindowsIdentity.GetCurrent();
var owner = identity.Owner ?? identity.User ?? throw new InvalidOperationException("the host token has no owner");
var user = identity.User ?? owner;
return Create(owner, user, workerSid is null ? null : new SecurityIdentifier(workerSid));
}
/// <summary>Builds the pipe ACL.</summary>
/// <param name="owner">The pipe owner (the host token's owner).</param>
/// <param name="hostUser">The host account (its token's user SID).</param>
/// <param name="worker">The worker identity, or null.</param>
/// <returns>The pipe security: protected, allow-only, one ACE for the host account and one for the worker identity.</returns>
public static PipeSecurity Create(SecurityIdentifier owner, SecurityIdentifier hostUser, SecurityIdentifier? worker)
{
var security = new PipeSecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.SetOwner(owner);
// The host account and the worker identity, nothing else: not the owner when it differs (an elevated host's
// owner is BUILTIN\Administrators — .NET's CurrentUserOnly would grant it; the owner keeps only its implicit
// READ_CONTROL / WRITE_DAC).
var principals = new List<SecurityIdentifier> { hostUser };
if (worker is not null && !principals.Contains(worker)) principals.Add(worker);
foreach (var principal in principals)
security.AddAccessRule(new PipeAccessRule(principal, GrantedRights, AccessControlType.Allow));
return security;
}
/// <summary>Creates the single-instance server end of a worker pipe with <paramref name="security"/>.</summary>
/// <param name="pipeName">The random pipe name.</param>
/// <param name="security">The ACL (<see cref="ForCurrentHost"/>).</param>
/// <returns>The server stream.</returns>
public static NamedPipeServerStream CreateServer(string pipeName, PipeSecurity security)
=> NamedPipeServerStreamAcl.Create(pipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous, inBufferSize: 0, outBufferSize: 0, security);
}
@@ -138,6 +138,17 @@ public sealed class ScriptWorkerOptions
/// </summary>
public string? ExecutablePath { get; set; }
/// <summary>
/// The worker's identity on Windows (design §6, owner decision §14 #5): <c>NT SERVICE\&lt;name&gt;</c> — the virtual
/// account <c>scripts/install/Install-ScriptWorkerIsolation.ps1</c> registers, by default
/// <c>NT SERVICE\OtOpcUaScriptWorker</c> — or a SID string. When set, each worker starts under a restricted token
/// derived from the host's (see <see cref="ScriptWorkerIdentity"/>): every privilege removed, and every access check
/// must ALSO pass for this identity, <c>Everyone</c>, <c>BUILTIN\Users</c> or <c>RESTRICTED</c>. The pipe ACL then
/// admits the host account and this identity only. Null (the default) keeps the host's own token. Windows only:
/// startup refuses it elsewhere.
/// </summary>
public string? Identity { get; set; }
/// <summary>The hard grace that applies to a run with soft budget <paramref name="softTimeout"/>.</summary>
/// <param name="softTimeout">The run's soft deadline <c>T</c>.</param>
/// <returns><see cref="HardGrace"/> when set, otherwise <c>max(1 s, T)</c>.</returns>
@@ -228,6 +239,7 @@ public sealed class ScriptWorkerOptions
IsolationProbationRuns = probation,
PeCacheDir = PeCacheDir,
ExecutablePath = ExecutablePath,
Identity = Identity,
};
}
@@ -252,6 +264,21 @@ public sealed class ScriptWorkerOptions
return $"{key} '{PeCacheDir}' is inside the content root '{contentRootPath}'; it must live outside it.";
return null;
}
/// <summary>
/// Fail-closed identity validation: <see cref="Identity"/>, when set, must be <c>NT SERVICE\&lt;name&gt;</c> or a SID
/// string, and the host must run on Windows.
/// </summary>
/// <param name="isWindows">Whether the host runs on Windows (callers pass <see cref="OperatingSystem.IsWindows"/>).</param>
/// <returns>The violation, or null when the identity is unset or acceptable.</returns>
public string? ValidateIdentity(bool isWindows)
{
if (string.IsNullOrWhiteSpace(Identity)) return null;
const string key = ScriptingOptions.SectionName + ":Worker:" + nameof(Identity);
if (!isWindows)
return $"{key} '{Identity}' is only supported on Windows (it is a restricted token); unset it on this host.";
return ScriptWorkerIdentity.TryResolveSid(Identity, out _, out var error) ? null : $"{key} '{Identity}': {error}";
}
}
/// <summary>
@@ -0,0 +1,123 @@
using System.Buffers.Binary;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// The ScriptWorker's Windows identity (<c>Scripting:Worker:Identity</c>, lmxopcua#780 design §6, owner decision §14 #5).
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a restricted token and not a logon.</b> Windows starts a process under a virtual account
/// (<c>NT SERVICE\name</c>) only when the Service Control Manager starts that service; a host cannot launch K + 1
/// short-lived children, each with its own nonce, pipe and Job Object, that way. A separate local user would need
/// a stored password and <c>SeAssignPrimaryTokenPrivilege</c> on the host account. Instead the host derives a
/// <b>restricted token</b> from its own (the Windows Service Hardening / browser-sandbox technique): every
/// privilege is removed, and the virtual account's SID is one of the token's <i>restricting SIDs</i>, so every
/// access check must pass twice — once for the host account and once for the restricting set
/// (<see cref="RestrictingSids"/>). The worker can therefore reach only what BOTH the host account AND the worker
/// identity (or <c>Everyone</c> / <c>BUILTIN\Users</c> / <c>RESTRICTED</c>, which the runtime needs to load from
/// <c>System32</c>) may reach: in effect a standard user bounded by the host account, with no privileges.
/// </para>
/// <para>
/// The virtual account's SID is computable from its name (<see cref="ServiceSid"/>), so the identity works for ACLs
/// and the firewall rule whether or not the installer's anchor service exists; the anchor only makes the name
/// resolvable in ACL editors.
/// </para>
/// </remarks>
public static partial class ScriptWorkerIdentity
{
/// <summary>The installer's default virtual account name (the anchor service's name).</summary>
public const string DefaultServiceName = "OtOpcUaScriptWorker";
/// <summary>The installer's default identity, as configured.</summary>
public const string DefaultIdentity = @"NT SERVICE\" + DefaultServiceName;
/// <summary>Everyone.</summary>
public const string EveryoneSid = "S-1-1-0";
/// <summary>BUILTIN\Users.</summary>
public const string BuiltinUsersSid = "S-1-5-32-545";
/// <summary>NT AUTHORITY\RESTRICTED (the SID many system objects grant for restricted tokens).</summary>
public const string RestrictedSid = "S-1-5-12";
private const string ServicePrefix = @"NT SERVICE\";
/// <summary>
/// The restricting SIDs of a worker token for <paramref name="workerSid"/>: the identity itself, then
/// <c>Everyone</c>, <c>BUILTIN\Users</c> and <c>RESTRICTED</c>. <c>Authenticated Users</c> and the logon SID are
/// deliberately absent (the first is granted Modify on many data folders; the second reaches the host's own
/// processes in an interactive session).
/// </summary>
/// <param name="workerSid">The worker identity's SID string.</param>
/// <returns>The restricting SID strings, identity first.</returns>
public static IReadOnlyList<string> RestrictingSids(string workerSid) => [workerSid, EveryoneSid, BuiltinUsersSid, RestrictedSid];
/// <summary>
/// The SID Windows assigns the virtual account <c>NT SERVICE\<paramref name="serviceName"/></c>:
/// <c>S-1-5-80-</c> followed by the SHA-1 of the upper-cased name in UTF-16LE, read as five little-endian
/// 32-bit values (the algorithm behind <c>sc.exe showsid</c>).
/// </summary>
/// <param name="serviceName">The service name (no <c>NT SERVICE\</c> prefix).</param>
/// <returns>The SID string.</returns>
public static string ServiceSid(string serviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serviceName);
var hash = SHA1.HashData(Encoding.Unicode.GetBytes(serviceName.ToUpperInvariant()));
var sb = new StringBuilder("S-1-5-80");
for (var i = 0; i < 5; i++)
sb.Append('-').Append(BinaryPrimitives.ReadUInt32LittleEndian(hash.AsSpan(i * 4, 4)).ToString(CultureInfo.InvariantCulture));
return sb.ToString();
}
/// <summary>Resolves a configured identity to a SID string without touching the OS.</summary>
/// <param name="identity"><c>NT SERVICE\name</c> or a SID string (<c>S-1-…</c>).</param>
/// <param name="sid">The SID string.</param>
/// <param name="error">Why it was refused.</param>
/// <returns>True when resolved.</returns>
public static bool TryResolveSid(string? identity, out string sid, out string? error)
{
sid = string.Empty;
error = null;
var value = identity?.Trim();
if (string.IsNullOrEmpty(value))
{
error = "no identity is set";
return false;
}
if (value.StartsWith(ServicePrefix, StringComparison.OrdinalIgnoreCase))
{
var name = value[ServicePrefix.Length..];
if (!ServiceNamePattern().IsMatch(name))
{
error = "the virtual account name must be 1–80 letters, digits, '_', '-' or '.'";
return false;
}
sid = ServiceSid(name);
return true;
}
if (SidPattern().IsMatch(value))
{
if (value is EveryoneSid or BuiltinUsersSid or RestrictedSid || value.StartsWith("S-1-5-32-", StringComparison.Ordinal)
|| value == "S-1-5-18" || value == "S-1-5-11")
{
error = "a well-known group or SYSTEM is not a worker identity; use a dedicated virtual account";
return false;
}
sid = value.ToUpperInvariant();
return true;
}
error = @"expected 'NT SERVICE\<name>' (the installer's virtual account) or a SID string 'S-1-…'";
return false;
}
[GeneratedRegex(@"^[A-Za-z0-9_.\-]{1,80}$", RegexOptions.CultureInvariant)]
private static partial Regex ServiceNamePattern();
[GeneratedRegex(@"^[Ss]-1-\d+(-\d+){1,14}$", RegexOptions.CultureInvariant)]
private static partial Regex SidPattern();
}