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 } } |