Search code examples
c#.netpowershelluserprincipalsecurity-identifier

How to determine if a string is an NT Account or a Security Identifier?


Question

How can I reliably tell if a given string represents an NTAccount, or a SecurityIdentifier?

Verbose

Given a string I can convert it to an NTAccount or a SecurityIdentifier via the constructor:

[string]$myAccount = '...' # some value
$ntAccount = [System.Security.Principal.NTAccount]::new($myAccount)
$sid = [System.Security.Principal.SecurityPrincipal]::new($myAccount) // throws exception if not a valid SID

If the string's not a SID, the SecurityIdentifier constructor will throw an exception. If the string's a SID, the NTAccount constructor will accept it... however, when I try to translate it to a SID, a System.Security.Principal.IdentityNotMapped exception will be thrown.

$sidFromNT = $ntAccount.Translate([System.Security.Principal.SecurityPrincipal]) # throw exception
$ntFromSid = $sid.Translate([System.Security.Principal.NTAccount]) # should work as if the SID were invalid we'd have already erred

It would be great if I could say I don't know the type by using the shared base class; but that's abstract / has no public constructor; so I can't do this:

[string]$account = '...'
$idRef = [System.Security.Principal.IdentityReference]::new($account) # this is not valid code
$ntFromId = $idRef.Translate([System.Security.Principal.NTAccount])
$sidFromId = $idRef.Translate([System.Security.Principal.SecurityIdentifier])

As such, the only options I can think of are variants of:

  • Use the thrown error* to determine the type; but this makes the error part of my normal flow.
  • Check if the value begins S-; very quick and should work in most cases; but it's a hack.

* Note: I realize that an invalid value (i.e. neither an NT Account nor SID) would also through exceptions; I've ignored that scenario for now to remain brief.

--

C# Version

Since this is a .net question rather than PowerShell specific, here's code to illustrate the same issue in C# (sadly I can't fiddle this, as the various Fiddle sites restrict the required functionality).

public static void Main()
{
    var ids = new string[] {"S-1-1-0", "Everyone"}; // a list of values which may be SIDs or NTAccounts
    var pseudoRandomIndex = DateTime.Now.Millisecond % ids.Length; // saves initialising Random for a simple demo
    var idString = ids[pseudoRandomIndex]; // pick one id at random; be it a SID or an NT Account

    Debug.WriteLine($"Selected value is {idString}");

    TryToProcessIdentityReference<NTAccount, SecurityIdentifier>(idString, (id) => new NTAccount(id));
    TryToProcessIdentityReference<SecurityIdentifier, NTAccount>(idString, (id) => new SecurityIdentifier(id));
}

static void TryToProcessIdentityReference<T1, T2>(string idString, Func<string, T1> callConstructor)
    where T1 : IdentityReference
    where T2 : IdentityReference
{
    var t1Type = typeof(T1);
    var t2Type = typeof(T2);
    Console.WriteLine($"Trying to process {idString} as a {t1Type.Name} ...");
    try 
    {
        var t1 = callConstructor(idString);
        _ = t1.Translate(t2Type); 
        Debug.WriteLine($" - {idString} is a valid {t1Type.Name}!");
    } catch (Exception e) when(e is ArgumentException || e is IdentityNotMappedException) { 
        Debug.WriteLine($" - Failed to process {idString} as {t1Type.Name}; error thrown when translating to {t2Type.Name}");
        Debug.WriteLine($" - {e.ToString()}");
    }   
}

Solution

  • I realised that I can use as instead of a constructor to avoid having to catch exceptions.

    PowerShell Version

    [string]$idString = @('Everyone', 'S-1-1-0') | Get-Random -Count 1
    [System.Security.Principal.IdentityReference]$idRef = $idString -as [System.Security.Principal.SecurityIdentifier]
    if ($null -eq $idRef) {
        $idRef = $idString -as [System.Security.Principal.NTAccount]
    }
    "Input     = $idString"
    "SID       = $($idRef.Translate([System.Security.Principal.SecurityIdentifier]).Value)"
    "NTAccount = $($idRef.Translate([System.Security.Principal.NTAccount]).Value)"
    

    C# version

    Note: The as keyword isn't valid in the C# scenario, so my above solution is language specific...

    The best I've come up with is to encapsulate the error flow piece in its own function so that we can easily improve things once a solution becomes available.

    public static void Main()
    {
        var ids = new string[] {"S-1-1-0", "Everyone"}; // a list of values which may be SIDs or NTAccounts
        var pseudoRandomIndex = DateTime.Now.Millisecond % ids.Length; // saves initialising Random for a simple demo
        var idString = ids[pseudoRandomIndex]; // pick one id at random; be it a SID or an NT Account
    
        var idRef = TryParse(idString, out var temp) ? (IdentityReference)temp : (IdentityReference)new NTAccount(idString);
        Debug.WriteLine($"Selected value is {idString}");
        Debug.WriteLine($"SID:              {idRef.Translate(typeof(SecurityIdentifier))}");
        Debug.WriteLine($"NTAccount:        {idRef.Translate(typeof(NTAccount))}");
    }
    
    public static bool TryParse(string value, out SecurityIdentifier result) 
    {
        try 
        {
            result = new SecurityIdentifier(value);
            return true;
        } 
        catch (ArgumentException) 
        {
            result = null;
            return false;
        }
    }