// Copyright (C) 2026, The Duplicati Team
// https://duplicati.com, hello@duplicati.com
//
// Permission is hereby granted, free of charge, to any person obtaining a
// copy of this software and associated documentation files (the "Software"),
// to deal in the Software without restriction, including without limitation
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
// and/or sell copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
#nullable enable
using System;
using System.Runtime.Versioning;
using System.Security.AccessControl;
using Microsoft.Win32;
namespace Duplicati.WindowsService
{
///
/// Helpers for managing the HKLM\SOFTWARE\DuplicatiTeam\Duplicati\Service
/// registry key used to ferry secrets between elevated installer/CLI and the
/// elevated Duplicati Windows Service.
///
[SupportedOSPlatform("windows")]
internal static class ServiceRegistryKey
{
///
/// Sub-key path under HKLM for the protected Service registry key.
///
internal const string SUBKEY_PATH = ServiceControl.INIT_REGISTRY_KEY;
///
/// Expected SDDL form of the DACL on the Service registry key.
/// D:P - protected DACL, no inheritance
/// (A;OICI;KA;;;SY) - SYSTEM: full key access, inherit
/// (A;OICI;KA;;;BA) - Built-in Administrators: full key access, inherit
///
internal const string EXPECTED_SDDL = "D:P(A;OICI;KA;;;SY)(A;OICI;KA;;;BA)";
///
/// Builds a instance matching
/// for atomic application at key
/// creation time.
///
private static RegistrySecurity BuildExpectedSecurity()
{
var security = new RegistrySecurity();
security.SetSecurityDescriptorSddlForm(EXPECTED_SDDL);
return security;
}
///
/// Compares the actual DACL (in SDDL form) against the expected
/// locked-down DACL semantically.
///
/// SDDL form of the DACL on the actual key
/// On mismatch, a short human-readable explanation of which requirement failed; null on success.
///
private static bool DaclMatchesExpected(string actualSddl, out string? detail)
{
detail = null;
if (string.IsNullOrEmpty(actualSddl))
{
detail = "empty DACL";
return false;
}
// Parse the actual DACL through CommonSecurityDescriptor so
// we get a canonical, normalized view we can walk ACE-by-ACE.
// The expected side is parsed the same way from the static
// EXPECTED_SDDL constant so both sides flow through the
// same canonicalization path.
CommonSecurityDescriptor actual;
CommonSecurityDescriptor expected;
try
{
// isContainer:true matches a registry key (which is a
// container, holding subkeys and values).
actual = new CommonSecurityDescriptor(isContainer: true, isDS: false, sddlForm: actualSddl);
expected = new CommonSecurityDescriptor(isContainer: true, isDS: false, sddlForm: EXPECTED_SDDL);
}
catch (Exception ex)
{
detail = "could not parse DACL: " + ex.Message;
return false;
}
// The DACL must be protected from inheritance.
if ((actual.ControlFlags & ControlFlags.DiscretionaryAclProtected) == 0)
{
detail = "DACL is not protected from inheritance (SE_DACL_PROTECTED flag missing)";
return false;
}
// Compare ACE sets. We require the actual ACL to be a
// permutation of the expected ACL: same number of ACEs,
// each expected ACE matched exactly once by an actual ACE
// with identical type / flags / access mask / SID.
var expectedAces = expected.DiscretionaryAcl;
var actualAces = actual.DiscretionaryAcl;
if (expectedAces == null)
{
detail = "expected DACL is empty";
return false;
}
if (actualAces == null)
{
detail = "actual DACL is empty";
return false;
}
if (actualAces.Count != expectedAces.Count)
{
detail = $"DACL has {actualAces.Count} ACE(s), expected {expectedAces.Count}";
return false;
}
var actualMatched = new bool[actualAces.Count];
foreach (GenericAce expectedAce in expectedAces)
{
if (expectedAce is not CommonAce expectedCommon)
{
detail = "unsupported expected ACE type: " + expectedAce.GetType().Name;
return false;
}
var foundMatch = false;
for (var i = 0; i < actualAces.Count; i++)
{
if (actualMatched[i])
continue;
if (actualAces[i] is not CommonAce actualCommon)
continue;
if (actualCommon.AceType != expectedCommon.AceType)
continue;
if (actualCommon.AceFlags != expectedCommon.AceFlags)
continue;
if (actualCommon.AccessMask != expectedCommon.AccessMask)
continue;
if (!actualCommon.SecurityIdentifier.Equals(expectedCommon.SecurityIdentifier))
continue;
actualMatched[i] = true;
foundMatch = true;
break;
}
if (!foundMatch)
{
detail = $"missing expected ACE for SID {expectedCommon.SecurityIdentifier}";
return false;
}
}
return true;
}
///
/// Deletes the Service subtree and recreates the key with the
/// locked-down DACL applied at creation. The returned handle is
/// writable; callers should write the single value they need,
/// then dispose immediately.
///
/// An open writable handle to the freshly created key.
internal static RegistryKey RecreateLockedDown()
{
var view = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
// Wipe the leaf first so no stale value can survive.
view.DeleteSubKeyTree(SUBKEY_PATH, throwOnMissingSubKey: false);
// Split SUBKEY_PATH into \. SUBKEY_PATH
// is a constant ending in "\Service" so this always produces a
// non-empty parent and a non-empty leaf.
var lastSep = SUBKEY_PATH.LastIndexOf('\\');
if (lastSep <= 0 || lastSep >= SUBKEY_PATH.Length - 1)
throw new System.IO.IOException(
$"Invalid SUBKEY_PATH '{SUBKEY_PATH}': cannot split parent/leaf");
var parentPath = SUBKEY_PATH.Substring(0, lastSep);
var leafName = SUBKEY_PATH.Substring(lastSep + 1);
// Create the parent chain first with enherited security
using var parent = view.CreateSubKey(parentPath, writable: true)
?? throw new System.IO.IOException(
$"Failed to create parent registry key {parentPath}");
// Now create the leaf with the locked-down DACL applied atomically
// at creation time. The DACL is set ONLY on the leaf key.
var security = BuildExpectedSecurity();
var key = parent.CreateSubKey(
leafName,
RegistryKeyPermissionCheck.ReadWriteSubTree,
security);
if (key == null)
throw new System.IO.IOException(
$"Failed to create registry key {SUBKEY_PATH}");
return key;
}
///
/// Opens the Service registry key for reading/writing only if its
/// DACL exactly matches . Returns
/// null when the key does not exist, or when the ACL has been
/// tampered with, or when access could not be checked.
/// Whether to open the key with write access.
/// On null return, describes why
/// (does-not-exist / acl-mismatch / failure). Useful for logging.
internal static RegistryKey? OpenIfTrusted(bool writable, out string? reason)
{
reason = null;
RegistryKey? key = null;
try
{
// RegistryKey instances are independent handles in
// .NET so disposing the base key while keeping the
// returned sub-key open is safe.
using (var view = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64))
{
key = view.OpenSubKey(SUBKEY_PATH, writable: writable);
}
if (key == null)
{
reason = "key-missing";
return null;
}
// Compare the DACL of th key with the expected one.
var actualSecurity = key.GetAccessControl(AccessControlSections.Access);
var actualSddl = actualSecurity.GetSecurityDescriptorSddlForm(AccessControlSections.Access);
if (!DaclMatchesExpected(actualSddl, out var detail))
{
reason = $"acl-mismatch ({detail}); actual '{actualSddl}'";
key.Dispose();
return null;
}
return key;
}
catch (Exception ex)
{
reason = "access-failed: " + ex.Message;
try { key?.Dispose(); } catch { }
return null;
}
}
}
}