Private/Credential.ps1

# The user's saved credentials, by their target names only.
#
# Whether a credential is saved for an account is read off the names Windows' Credential
# Manager lists for the user who runs it, through cmdkey /list: it prints the target names
# and never a secret. Nothing here calls CredEnumerate or CredRead, which hand over the
# secret with the name. See ADR-0016.
#
# cmdkey's output is localised ("Ziel:", "Typ:" on a German Windows, "Cible :" on a French
# one), so it is read by position: the first line of each indented block is the target.
# Observed on a German Windows 11 26200, 2026-09-25: blocks of three or four lines,
# separated by empty lines or by lines of four spaces, the persistence line sometimes
# missing. First read for OutlookAuth (#24); the Nextcloud Kind reads it too (#185).

function ConvertFrom-CmdkeyEntry {
    <#
    .SYNOPSIS
        The entries of cmdkey /list output, in the order listed, each with its target, how it
        was stored, its type and its user. Pure.
    .DESCRIPTION
        Read by position, never by label, because the labels are localised. An entry is a
        block of indented lines: the first is the target, "<label>: <target>" (German
        "Ziel:"), the second the type, "<label>: <type>" ("Typ:"), the third the user,
        "<label>: <user>" ("Benutzer:"), and then a line saying how long it is kept, which
        has no colon. Some entries have no user line (a credential saved without one), so the
        third line is the user only where it has a label and a colon. The type is localised
        words (German "Allgemeine"), kept as they stand; what tells a domain credential from
        a generic one is Style, the storage prefix cmdkey puts in front of the target
        ("Domain", "LegacyGeneric", "MicrosoftAccount"), dropped from Target, which is left
        as Credential Manager shows it. The target may hold colons, so the line is split at
        the first one. Style, Type and User are $null where the entry has none. The
        unindented heading and any line without a colon (an empty store's message) are not
        entries. cmdkey prints no secret, so none is read here (ADR-0016).
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()][AllowEmptyString()][string[]]$Line = @())

    $value = '^\s+[^:]+:\s*(\S.*)$'
    $block = [System.Collections.Generic.List[string]]::new()
    $entries = [System.Collections.Generic.List[object]]::new()
    $close = {
        if ($block.Count -gt 0 -and $block[0] -match $value) {
            $target = $Matches[1].Trim()
            $style = $null
            if ($target -match '^([A-Za-z]+):target=(.*)$') { $style = $Matches[1]; $target = $Matches[2] }
            if ($target) {
                $type = $null
                $user = $null
                if ($block.Count -gt 1 -and $block[1] -match $value) { $type = $Matches[1].Trim() }
                if ($block.Count -gt 2 -and $block[2] -match $value) { $user = $Matches[1].Trim() }
                $entries.Add([pscustomobject]@{ Target = $target; Style = $style; Type = $type; User = $user })
            }
        }
        $block.Clear()
    }
    foreach ($l in @($Line)) {
        if (-not "$l".Trim()) { & $close; continue }
        $block.Add("$l")
    }
    & $close
    $entries
}

function ConvertFrom-CmdkeyList {
    <#
    .SYNOPSIS
        The target names out of cmdkey /list output, in the order listed. Pure.
    .DESCRIPTION
        The Target of each entry ConvertFrom-CmdkeyEntry reads: the callers that only want
        to know which names are saved ask for these.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyCollection()][AllowEmptyString()][string[]]$Line = @())

    foreach ($entry in @(ConvertFrom-CmdkeyEntry -Line $Line)) { $entry.Target }
}

function Read-CredentialTargetName {
    <#
    .SYNOPSIS
        The credentials saved for whoever runs this, as cmdkey /list prints them: Targets
        (the names), Entries (each with its Target, Style, Type and User, see
        ConvertFrom-CmdkeyEntry), and Error where the store could not be listed. Asks only;
        reads no secret. Never throws.
    .DESCRIPTION
        Targets and Entries are $null where the store was not read, and an empty list where it was and
        holds nothing: a store with nothing in it is a different fact from one whose
        listing failed. Error is said in words, as the Report prints it: that cmdkey ended
        with an error instead of a list, or was not found. Never its bare exit code: cmdkey
        has no list of what its codes mean.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param()

    $targets = $null
    $entries = $null
    $problem = $null
    try {
        $listed = @(& cmdkey.exe /list 2>$null)
        if ($LASTEXITCODE -ne 0) { $problem = Get-Text 'Value.Credential.CmdkeyFailed' }
        else {
            $entries = @(ConvertFrom-CmdkeyEntry -Line $listed)
            $targets = @($entries | ForEach-Object { $_.Target })
        }
    }
    catch [System.Management.Automation.CommandNotFoundException] { $problem = Get-Text 'Value.Credential.CmdkeyMissing' }
    catch { $problem = $_.Exception.Message }
    [pscustomobject]@{ Targets = $targets; Entries = $entries; Error = $problem }
}