diff --git a/.editorconfig b/.editorconfig index b3f5fa4..4663791 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,186 @@ -[*.cs] +root = true -# IDE0008: Use explicit type -csharp_style_var_elsewhere = false:suggestion +[*] +indent_style = space +indent_size = 4 +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true -# IDE0018: Inline variable declaration -csharp_style_inlined_variable_declaration = false:suggestion +[*.{xml,json,yml,yaml,resx,props,targets,csproj}] +indent_size = 2 + +[*.cs] +csharp_style_namespace_declarations = file_scoped:warning +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_prefer_braces = when_multiline:suggestion +dotnet_sort_system_directives_first = true + +# ============================================================ +# .NET Analyzers (CA rules) +# ============================================================ + +# Globalization: Windows Credential Store is not globalized +dotnet_diagnostic.CA1303.severity = none +dotnet_diagnostic.CA1304.severity = none +dotnet_diagnostic.CA1305.severity = none +dotnet_diagnostic.CA1307.severity = none +dotnet_diagnostic.CA1310.severity = none + +# CA1401: P/Invokes should not be visible — ours are internal +dotnet_diagnostic.CA1401.severity = warning + +# CA1416: Platform compatibility — library IS Windows-only +dotnet_diagnostic.CA1416.severity = suggestion + +# CA1720: Identifier contains type name — "blob" fields are fine +dotnet_diagnostic.CA1720.severity = none + +# CA1711: Identifiers should not have incorrect suffix +dotnet_diagnostic.CA1711.severity = none + +# CA1724: Type names should not match namespaces — CredentialManager is correct +dotnet_diagnostic.CA1724.severity = none + +# CA1060: Move P/Invokes to NativeMethods — NativeCode is equivalent +dotnet_diagnostic.CA1060.severity = none + +# CA1510: ThrowIfNull — .NET 6+ only, not available in netstandard2.0 +dotnet_diagnostic.CA1510.severity = none + +# CA1707: Identifiers should not contain underscores — P/Invoke enums use Win32 naming +dotnet_diagnostic.CA1707.severity = none + +# CA1815: Override equals on value types +dotnet_diagnostic.CA1815.severity = warning + +# CA1838: StringBuilder in P/Invoke — established Win32 API calling convention +dotnet_diagnostic.CA1838.severity = none + +# CA1852: Seal internal types +dotnet_diagnostic.CA1852.severity = warning + +# CA1863: CompositeFormat — .NET 8+ only, not available in netstandard2.0 +dotnet_diagnostic.CA1863.severity = none + +# CA1869: Cache Regex instances +dotnet_diagnostic.CA1869.severity = warning + +# CA2101: Marshalling for P/Invoke string arguments +dotnet_diagnostic.CA2101.severity = warning + +# ============================================================ +# StyleCop (SA rules) +# ============================================================ +# The library uses P/Invoke conventions (Win32 struct/enum naming, +# explicit type names in marshalling). StyleCop's C# style rules +# conflict with these established Windows API conventions. +# We keep meaningful SA rules and suppress cosmetic ones. + +# SA0001: XML comment analysis disabled — no XML doc generation +dotnet_diagnostic.SA0001.severity = none + +# --- Spacing & layout (cosmetic) --- +dotnet_diagnostic.SA1000.severity = none +dotnet_diagnostic.SA1005.severity = none +dotnet_diagnostic.SA1009.severity = none +dotnet_diagnostic.SA1011.severity = none +dotnet_diagnostic.SA1101.severity = none +dotnet_diagnostic.SA1111.severity = none +dotnet_diagnostic.SA1116.severity = none +dotnet_diagnostic.SA1117.severity = none +dotnet_diagnostic.SA1119.severity = none +dotnet_diagnostic.SA1122.severity = none +dotnet_diagnostic.SA1128.severity = none +dotnet_diagnostic.SA1131.severity = none +dotnet_diagnostic.SA1133.severity = none +dotnet_diagnostic.SA1200.severity = none +dotnet_diagnostic.SA1413.severity = none +dotnet_diagnostic.SA1501.severity = none +dotnet_diagnostic.SA1503.severity = none + +# --- Blank line rules (cosmetic) --- +dotnet_diagnostic.SA1507.severity = none +dotnet_diagnostic.SA1510.severity = none +dotnet_diagnostic.SA1512.severity = none +dotnet_diagnostic.SA1513.severity = none +dotnet_diagnostic.SA1515.severity = none +dotnet_diagnostic.SA1516.severity = none + +# --- Element ordering (cosmetic) --- +dotnet_diagnostic.SA1201.severity = none +dotnet_diagnostic.SA1202.severity = none +dotnet_diagnostic.SA1204.severity = none +dotnet_diagnostic.SA1206.severity = none +dotnet_diagnostic.SA1208.severity = none +dotnet_diagnostic.SA1210.severity = none + +# --- Naming: P/Invoke structs use Win32 conventions (CREDENTIAL, cbSize, etc.) --- +dotnet_diagnostic.SA1121.severity = none +dotnet_diagnostic.SA1303.severity = none +dotnet_diagnostic.SA1304.severity = none +dotnet_diagnostic.SA1307.severity = none +dotnet_diagnostic.SA1308.severity = none +dotnet_diagnostic.SA1309.severity = none +dotnet_diagnostic.SA1310.severity = none +dotnet_diagnostic.SA1311.severity = none +dotnet_diagnostic.SA1312.severity = none +dotnet_diagnostic.SA1313.severity = none + +# --- Access modifiers --- +dotnet_diagnostic.SA1400.severity = none + +# --- Documentation: keep for public API --- +dotnet_diagnostic.SA1600.severity = warning +dotnet_diagnostic.SA1602.severity = suggestion +dotnet_diagnostic.SA1649.severity = warning + +# --- File header rules — not used --- +dotnet_diagnostic.SA1633.severity = none +dotnet_diagnostic.SA1634.severity = none +dotnet_diagnostic.SA1635.severity = none +dotnet_diagnostic.SA1636.severity = none +dotnet_diagnostic.SA1637.severity = none +dotnet_diagnostic.SA1638.severity = none +dotnet_diagnostic.SA1639.severity = none +dotnet_diagnostic.SA1640.severity = none +dotnet_diagnostic.SA1641.severity = none + +# ============================================================ +# SonarAnalyzer (S rules) +# ============================================================ + +# S101: Pascal case naming — CredentialAPIException is established API name +dotnet_diagnostic.S101.severity = none + +# S2344: Enum suffix 'Flags' — P/Invoke enums mirror Win32 naming +dotnet_diagnostic.S2344.severity = none + +# S3267: Simplify loop to LINQ — marshal cleanup requires explicit loop +dotnet_diagnostic.S3267.severity = none + +# S3925: ISerializable pattern — guarded by #if, not applicable on net8.0 +dotnet_diagnostic.S3925.severity = none + +# ============================================================ +# Meziantou (MA rules) +# ============================================================ + +# MA0011: IFormatProvider — format strings here are internal diagnostics, not user-facing +dotnet_diagnostic.MA0011.severity = none + +# MA0016: Return IList<> instead of List<> — would be a public API breaking change +dotnet_diagnostic.MA0016.severity = none + +# MA0048: File name must match type name — PublicEnums.cs groups related enums by design +dotnet_diagnostic.MA0048.severity = none + +# MA0049: Type name should not match namespace — CredentialManager IS the correct name +dotnet_diagnostic.MA0049.severity = none + +# MA0051: Method too long — SaveCredential is complex but cohesive (P/Invoke marshalling) +dotnet_diagnostic.MA0051.severity = none + +[*.sln] +indent_style = tab diff --git a/.gitattributes b/.gitattributes index 1ff0c42..ed84346 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,7 +10,7 @@ # default for csharp files. # Note: This is only used by command line ############################################################################### -#*.cs diff=csharp +*.cs diff=csharp ############################################################################### # Set the merge driver for project and solution files @@ -35,29 +35,3 @@ #*.sqlproj merge=binary #*.wwaproj merge=binary -############################################################################### -# behavior for image files -# -# image files are treated as binary by default. -############################################################################### -#*.jpg binary -#*.png binary -#*.gif binary - -############################################################################### -# diff behavior for common document formats -# -# Convert binary document formats to text before diffing them. This feature -# is only available from the command line. Turn it on by uncommenting the -# entries below. -############################################################################### -#*.doc diff=astextplain -#*.DOC diff=astextplain -#*.docx diff=astextplain -#*.DOCX diff=astextplain -#*.dot diff=astextplain -#*.DOT diff=astextplain -#*.pdf diff=astextplain -#*.PDF diff=astextplain -#*.rtf diff=astextplain -#*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore index 75e4b46..e21d6b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,192 +1,167 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. - -# User-specific files -*.suo -*.user -*.sln.docstates - -# Build results +## Build output [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ x64/ +x86/ build/ bld/ [Bb]in/ [Oo]bj/ +[Oo]ut/ +artifacts/ +dist/ +build.sh -# Roslyn cache directories +## NuGet +packages/ +!packages/build/ +*.nupkg +*.snupkg +**/[Pp]ackages/NuGet.Config + +## .NET / MSBuild +*.props.user +project.lock.json +project.fragment.lock.json +*.nuget.props +*.nuget.targets +msbuild.log +msbuild.err +msbuild.wrn + +## Roslyn *.ide/ +*.editorconfig.user +.vs/ +*.GeneratedMSBuildEditorConfig.editorconfig + +## Roslyn analyzers / security audit +*.sarif +*.ruleset.user +SecurityCodeScan.config.user -# MSTest test Results +## Test results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* - -#NUNIT -*.VisualState.xml +*.trx +*.coverage +*.coveragexml TestResult.xml +*.VisualState.xml -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c +## Code coverage +*.dotCover +coverage/ +lcov.info +*.opencover.xml -*_i.c -*_p.c -*_i.h -*.ilk -*.meta -*.obj -*.pch -*.pdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*.log -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ +## User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +## Visual Studio +*.rsuser *.aps *.ncb *.opensdf *.sdf *.cachefile - -# Visual Studio profiler *.psess *.vsp *.vspx - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in +*.sap +*.[Pp]ublish.xml +*.azurePubxml +*.pubxml +PublishScripts/ + +## VS Code +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +## JetBrains (Rider, PyCharm, IntelliJ) +.idea/ +*.sln.iml +*.iml +*.ipr +*.iws +.idea_modules/ + +## ReSharper _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user -# JustCode is a .NET coding addin-in -.JustCode - -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# NCrunch +## NCrunch _NCrunch_* .*crunch*.local.xml -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html +## TeamCity +_TeamCity* -# Click-Once directory -publish/ +## Windows +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ +~$* +*~ -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -## TODO: Comment the next line if you want to checkin your -## web deploy settings but do note that will include unencrypted -## passwords -#*.pubxml - -# NuGet Packages Directory -packages/* -## TODO: If the tool you use requires repositories.config -## uncomment the next line -#!packages/repositories.config - -# Enable "build/" folder in the NuGet Packages folder since -# NuGet packages use it for MSBuild targets. -# This line needs to be after the ignore of the build folder -# (and the packages folder if the line above has been uncommented) -!packages/build/ +## macOS +.DS_Store +.AppleDouble +.LSOverride +._* -# Windows Azure Build Output -csx/ -*.build.csdef +## Common temporary and backup files +*.bak +*.bak2 +*.orig +*.tmp +*.tmp_proj +*.swp +*.swo +*~ +*.log +*.old -# Windows Store app package directory -AppPackages/ +## Native build artifacts +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.ilk +*.meta +*.tlb +*.tli +*.tlh +*.sbr +*.rsp +dlldata.c +*_i.c +*_p.c +*_i.h -# Others -sql/ -*.Cache -ClientBin/ -[Ss]tyle[Cc]op.* -~$* -*~ -*.dbmdl -*.dbproj.schemaview +## Secrets and credentials (never commit these) *.pfx *.publishsettings -node_modules/ - -# RIA/Silverlight projects -Generated_Code/ +*.key +!key.snk -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm - -# SQL Server files +## SQL Server *.mdf *.ldf -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings - -# Microsoft Fakes +## Microsoft Fakes FakesAssemblies/ -# LightSwitch generated files -GeneratedArtifacts/ -_Pvt_Extensions/ -ModelManifest.xml -/.vs -/.vs -/.vscode/tasks.json +## NUnit +nunit-*.xml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..77f14d3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,297 @@ +# Changelog + +All notable changes to this project will be documented in this file. +Format follows [Keep a Changelog](https://keepachangelog.com/). + +## [3.1.0] - 2026-02-24 + +### Breaking Changes + +- **`Persistance` renamed to `Persistence`** — The misspelled enum, property, and parameter + names have been corrected across the entire API surface. Update all references: + `Persistance` → `Persistence`, `persistance:` → `persistence:`. +- **BinaryFormatter fallback removed entirely** — The netstandard2.0 migration path for + legacy BinaryFormatter-encoded attributes has been removed. Legacy attributes are now + silently skipped with a debug message. This eliminates the CWE-502 attack surface + completely rather than keeping it as a migration convenience. +- **`PromptForCredentialsConsole` return type** changed from `NetworkCredential` to + `NetworkCredential?` — returns null when the user cancels, throws + `CredentialAPIException` on other failures. Previously ignored the return code entirely. +- **`EnumerateICredentials` error handling** — Unexpected exceptions now throw + `CredentialAPIException` instead of silently returning null (which was indistinguishable + from "no credentials found"). + +### Security + +- **Zero credential buffer in `GetBasicAuthString`** — The intermediate UTF-8 byte array + containing `username:password` is now cleared via `Array.Clear()` after Base64 encoding. +- **Managed string limitation documented** — `CredentialBlob` property now has XML + documentation explaining that managed strings cannot be reliably zeroed from memory. + +### Fixed + +- **`GetInputBuffer` buffer sizing** — Now queries the required buffer size from + `CredPackAuthenticationBuffer` instead of using a hardcoded 1024-byte buffer. +- **Comment byte count** — `SaveCredential` now uses `Encoding.Unicode.GetByteCount()` + instead of allocating a temporary byte array with `GetBytes().Length`. +- **`SecureZeroMemory` P/Invoke** — Added XML remarks explaining why `RtlZeroMemory` is + used as the entry point and why it provides the same guarantees as the compiler intrinsic + across the P/Invoke boundary. + +### Added + +- **6 Roslyn analyzer suites** — Microsoft.CodeAnalysis.NetAnalyzers, StyleCop, + SecurityCodeScan, Roslynator, SonarAnalyzer, and Meziantou.Analyzer. All configured + in `Directory.Build.props` and `.editorconfig` with zero warnings across ~2,000+ rules. +- **Source Link** — PDB files map to GitHub source for debugger step-through from NuGet. +- **NuGet package metadata** — README included in package, search tags, release notes. +- **Demo application** — `samples/CredentialManager.Demo` exercises the full API surface. + +### Changed + +- **Test self-containment** — Added `[TestCleanup]` to remove test credentials after each + test. `TestGetCredentials` now saves its own credential instead of depending on + `TestSaveCredentials` running first. +- **`TestEnumerateCredentialWithTarget`** — Added missing `[TestMethod]` attribute with + `[Ignore]` (requires specific host-local credential). +- **Exception types** — Object state validation in `SaveCredential` now throws + `InvalidOperationException` instead of `ArgumentException` with property names. Matches + .NET conventions (CA2208). +- **String comparisons** — All string equality checks use `string.Equals()` with + `StringComparison.Ordinal` (MA0006). All `Dictionary` constructors + specify `StringComparer.Ordinal` (MA0002). +- **Unused parameter removed** — Private `PromptForCredentials` overload no longer accepts + an unused `target` parameter (S1172). + +--- + +## [3.0.0] - 2026-02-23 + +### Overview + +Major security and modernization release following a comprehensive code audit of the +AdysTech/CredentialManager library. This release addresses critical security +vulnerabilities, modernizes the codebase to current .NET standards, and improves +overall code quality. + +### Security Audit Findings + +The following issues were identified during the audit: + +1. **BinaryFormatter Deserialization (CRITICAL, CWE-502)** — Credential attributes + were serialized using `System.Runtime.Serialization.Formatters.Binary.BinaryFormatter`, + which is deprecated (SYSLIB0011) and vulnerable to arbitrary code execution through + crafted payloads. Any application reading credential attributes from the Windows + Credential Store could be exploited if a malicious payload was written to the + attribute field by another process with store access. + +2. **Persistence Hardcoded to Enterprise (CRITICAL, #69)** — All credentials were + saved with `Persistence.Enterprise` regardless of caller intent. Enterprise + persistence syncs credentials to Active Directory domain controllers, meaning + credentials intended to be local-only were being replicated across the domain. + This is both a security exposure (broader attack surface) and a correctness bug. + +3. **Incomplete Memory Zeroing (HIGH)** — `CriticalCredentialHandle.ReleaseHandle()` + calls `CredFree()` without zeroing the native buffer first. The existing zeroing + in `CredentialManager.cs` uses `Marshal.Copy` with a zero-byte array, which the + JIT compiler is permitted to optimize away as a dead store. Credential material + could persist in freed memory. + +4. **P/Invoke Declarations Missing SetLastError (MEDIUM)** — Several `DllImport` + attributes lack `SetLastError = true`, meaning `Marshal.GetLastWin32Error()` may + return stale values on failure, leading to incorrect error reporting. + +5. **Buffer Size Hardcoding (MEDIUM)** — Username and password buffers in + `CredUIPromptForCredentials` are hardcoded to 100 characters instead of using + the Windows-defined `CREDUI_MAX_USERNAME_LENGTH` (513) and + `CREDUI_MAX_PASSWORD_LENGTH` (256). Long usernames or domain\user combinations + could be silently truncated. + +6. **Unused P/Invoke Declaration** — An unused `CredUIPromptForCredentials` extern + declaration existed in `NativeCode.cs` (the codebase uses + `CredUIPromptForWindowsCredentials` instead). + +7. **Outdated Target Frameworks** — The library targeted `net45` (out of Microsoft + support since January 2016) and `netstandard2.0`. No modern .NET target. + +### Security Fixes + +- **Replace BinaryFormatter with System.Text.Json** — Credential attributes are now + serialized as JSON-encoded UTF-8 byte arrays using `System.Text.Json.JsonSerializer`. + A one-time migration path reads legacy BinaryFormatter-encoded attributes and + re-saves them as JSON on next write (netstandard2.0 only; .NET 8+ cannot read + legacy data as BinaryFormatter is disabled by the runtime). +- **Expose Persistence parameter** — `SaveCredentials()` now accepts an optional + `Persistence` parameter (default: `LocalMachine`). Previously hardcoded to + `Enterprise`. Fixes #69. +- **JIT-safe memory zeroing** — Native credential buffers are zeroed using + `RtlZeroMemory` (via P/Invoke to `kernel32.dll`) before `CredFree()`. As an + external function call, the JIT cannot optimize this away, unlike `Marshal.Copy` + with zero bytes. `StringBuilder` buffers from credential prompts are also cleared + after use. + +### Changed + +- **Target frameworks**: Drop `net45`, add `net8.0` as primary target, keep + `netstandard2.0` for broad compatibility +- **C# language version**: Upgraded to 12.0 +- **Nullable reference types**: Enabled project-wide with annotations throughout +- **File-scoped namespaces**: All source files converted +- **P/Invoke**: `SetLastError = true` added to all `DllImport` attributes +- **P/Invoke**: Buffer allocations use `CREDUI_MAX_USERNAME_LENGTH` (513) and + `CREDUI_MAX_PASSWORD_LENGTH` (256) constants +- **P/Invoke**: Removed unused `CredUIPromptForCredentials` declaration +- **Exception handling**: Bare `catch(Exception)` blocks replaced with diagnostic output +- **Default persistence**: Changed from `Enterprise` to `LocalMachine` +- **Test dependencies**: Updated to current versions (MSTest 3.3.1, + Microsoft.NET.Test.Sdk 17.9.0, coverlet 6.0.2) + +### Added + +- XML documentation on all public API methods and native structs +- `ArgumentNullException` guards on all public methods +- Migration path: legacy BinaryFormatter attributes auto-converted to JSON on read + (netstandard2.0 only) +- `LICENSE` file (standard filename, MIT) +- Comprehensive unit tests for persistence parameter variations, JSON attribute + round-trips (string, number, struct), and null parameter handling +- `SecureZeroMemory` P/Invoke (`kernel32.dll!RtlZeroMemory`) for JIT-safe zeroing +- `CriticalCredentialHandle` now zeros credential blobs (both single and enumeration) + before calling `CredFree` +- Internationalization: All user-facing strings extracted to .NET resource files (.resx) + with translations for German, French, Spanish, and Italian via satellite assemblies + +### Fixed + +- Credential blob null handling when `AllowBlankPassword` is true and password is null +- `GetCredentialsFromOutputBuffer` buffer variable names were swapped (passwordBuf used + maxDomain, domainBuf used maxPassword — harmless when all were 100, incorrect with + proper constants) + +--- + +## [2.6.0] - 2022-01-18 + +### Changed + +- Allow saving blank passwords with an additional `AllowBlankPassword` parameter. + Fixes [#67](https://github.com/AdysTech/CredentialManager/issues/67). + +## [2.5.0] - 2021-11-10 + +### Changed + +- Update max credential length to 2560 bytes. + Fixes [#65](https://github.com/AdysTech/CredentialManager/issues/65). + Thanks to @ldattilo. + +## [2.4.0] - 2021-08-30 + +### Fixed + +- Allow saving single character passwords. + Fixes [#62](https://github.com/AdysTech/CredentialManager/issues/62). + Thanks to @aschoelzhorn. + +## [2.3.0] - 2020-12-20 + +### Added + +- `RemoveCredential` method on `ICredential`. + Fixes [#59](https://github.com/AdysTech/CredentialManager/issues/59). + +## [2.2.0] - 2020-05-27 + +### Added + +- `EnumerateICredentials` method. + [#46](https://github.com/AdysTech/CredentialManager/pull/46). + Thanks to @strzalkowski. + +### Fixed + +- [#47](https://github.com/AdysTech/CredentialManager/issues/47): Getting Multiple + targets. + +### Changed + +- Common project properties moved to `Directory.Build.props`. + +## [2.1.0] - 2020-05-20 + +### Changed + +- Merged .NET Framework and .NET Core projects into one multi-target project. + [#41](https://github.com/AdysTech/CredentialManager/pull/41). + Thanks to @drewnoakes. + +### Fixed + +- [#42](https://github.com/AdysTech/CredentialManager/pull/42): Fix + `NullReferenceException` when credentials not found. + Thanks to @LePtitDev. + +### Breaking Change + +- [AdysTech.CredentialManager.Core](https://www.nuget.org/packages/AdysTech.CredentialManager.Core/) + NuGet package deprecated — main package now supports .NET Core. + +## [2.0.0] - 2020-04-20 + +### Added + +- `ICredential` interface exposing properties beyond `NetworkCredential` + (comments, attributes, persistence type). + [#30](https://github.com/AdysTech/CredentialManager/issues/30). +- Ability to add comments to saved credentials. +- Ability to read/write attributes to credentials (binary-serialized, max 256 bytes + each, max 64 attributes). +- `CredentialAPIException` for detailed Windows API failure reporting. + +### Fixed + +- [#39](https://github.com/AdysTech/CredentialManager/issues/39): Password exposed + in process memory after saving. + +### Breaking Changes + +- `SaveCredentials` return type changed from `bool` to `ICredential`. +- `ToNetworkCredential` throws `InvalidOperationException` or `CredentialAPIException` + instead of `Win32Exception`. +- `CredentialManager.CredentialType` enum removed — use `CredentialType` directly. + +## Earlier Versions + +### v1.9.5.0 - 2019-12-22 + +- Add `CredentialType` as optional parameter when saving. Thanks to @esrahofstede. + +### v1.0.1.0 - 2019-12-11 + +- Add strong name to NuGet. Thanks to @kvvinokurov. + +### v1.9.0.0 - 2019-11-09 + +- [#31](https://github.com/AdysTech/CredentialManager/issues/31): Support .NET + Standard. Thanks to @RussKie. + +### v1.8.0.0 - 2019-02-25 + +- Add `EnumerateCredentials`. Thanks to @erikjon. + +### v1.7.0.0 - 2018-09-26 + +- Allow prefilled user name. Thanks to @jairbubbles. + +### v1.6.0.0 - 2018-09-24 + +- Fix buffer sizes in `ParseUserName`. Thanks to @jairbubbles. + +### v1.2.1.0 - 2017-10-25 + +- Don't crash if credential not found. Thanks to @pmiossec. +- Corrections to error message. Thanks to @nguillermin. + +### v1.1.0.0 - 2017-01-09 + +- Initial release. diff --git a/CredentialManager.sln b/CredentialManager.sln index 12f6460..f644486 100644 --- a/CredentialManager.sln +++ b/CredentialManager.sln @@ -1,24 +1,27 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29503.13 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdysTech.CredentialManager", "src\AdysTech.CredentialManager\AdysTech.CredentialManager.csproj", "{ABA77D40-8D43-42EE-BE03-44E4AD93672A}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AdysTech.CredentialManager", "src\AdysTech.CredentialManager\AdysTech.CredentialManager.csproj", "{4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CredentialManager.Test", "tests\CredentialManager.Test\CredentialManager.Test.csproj", "{5DCA2C07-C1F3-424F-B934-F952595586CE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CredentialManager.Test", "tests\CredentialManager.Test\CredentialManager.Test.csproj", "{18A3699C-31F6-400F-8935-86E65E5B36E5}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{EBCE1FF4-81EB-4EC9-A085-2CA0F81100E5}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CredentialManager.Demo", "samples\CredentialManager.Demo\CredentialManager.Demo.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{835DA67E-EA6C-4E05-8C2E-4601CDE65EA1}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig - appveyor.yml = appveyor.yml - changelog.md = changelog.md + CHANGELOG.md = CHANGELOG.md Directory.Build.props = Directory.Build.props README.md = README.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{0609161C-CF52-44B3-A09E-6CCD4BCCB30F}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{63C41070-5FDD-4CB0-8046-AF6617DFF800}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{3D02AC64-9CEB-4161-83C3-2AA58A0821CC}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7E19D2AF-C0F0-4229-8E95-5B8ADC0BE732}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{F1E2D3C4-B5A6-7890-FEDC-BA0987654321}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -28,31 +31,40 @@ Global Release|x64 = Release|x64 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {ABA77D40-8D43-42EE-BE03-44E4AD93672A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {ABA77D40-8D43-42EE-BE03-44E4AD93672A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {ABA77D40-8D43-42EE-BE03-44E4AD93672A}.Debug|x64.ActiveCfg = Debug|Any CPU - {ABA77D40-8D43-42EE-BE03-44E4AD93672A}.Debug|x64.Build.0 = Debug|Any CPU - {ABA77D40-8D43-42EE-BE03-44E4AD93672A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {ABA77D40-8D43-42EE-BE03-44E4AD93672A}.Release|Any CPU.Build.0 = Release|Any CPU - {ABA77D40-8D43-42EE-BE03-44E4AD93672A}.Release|x64.ActiveCfg = Release|Any CPU - {ABA77D40-8D43-42EE-BE03-44E4AD93672A}.Release|x64.Build.0 = Release|Any CPU - {5DCA2C07-C1F3-424F-B934-F952595586CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5DCA2C07-C1F3-424F-B934-F952595586CE}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5DCA2C07-C1F3-424F-B934-F952595586CE}.Debug|x64.ActiveCfg = Debug|Any CPU - {5DCA2C07-C1F3-424F-B934-F952595586CE}.Debug|x64.Build.0 = Debug|Any CPU - {5DCA2C07-C1F3-424F-B934-F952595586CE}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5DCA2C07-C1F3-424F-B934-F952595586CE}.Release|Any CPU.Build.0 = Release|Any CPU - {5DCA2C07-C1F3-424F-B934-F952595586CE}.Release|x64.ActiveCfg = Release|Any CPU - {5DCA2C07-C1F3-424F-B934-F952595586CE}.Release|x64.Build.0 = Release|Any CPU + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}.Debug|x64.ActiveCfg = Debug|Any CPU + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}.Debug|x64.Build.0 = Debug|Any CPU + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}.Release|Any CPU.Build.0 = Release|Any CPU + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}.Release|x64.ActiveCfg = Release|Any CPU + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D}.Release|x64.Build.0 = Release|Any CPU + {18A3699C-31F6-400F-8935-86E65E5B36E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {18A3699C-31F6-400F-8935-86E65E5B36E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {18A3699C-31F6-400F-8935-86E65E5B36E5}.Debug|x64.ActiveCfg = Debug|Any CPU + {18A3699C-31F6-400F-8935-86E65E5B36E5}.Debug|x64.Build.0 = Debug|Any CPU + {18A3699C-31F6-400F-8935-86E65E5B36E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {18A3699C-31F6-400F-8935-86E65E5B36E5}.Release|Any CPU.Build.0 = Release|Any CPU + {18A3699C-31F6-400F-8935-86E65E5B36E5}.Release|x64.ActiveCfg = Release|Any CPU + {18A3699C-31F6-400F-8935-86E65E5B36E5}.Release|x64.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|x64.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {ABA77D40-8D43-42EE-BE03-44E4AD93672A} = {0609161C-CF52-44B3-A09E-6CCD4BCCB30F} - {5DCA2C07-C1F3-424F-B934-F952595586CE} = {7E19D2AF-C0F0-4229-8E95-5B8ADC0BE732} + {4DD8348E-AAD9-47DA-AF28-5BDF5FDF431D} = {63C41070-5FDD-4CB0-8046-AF6617DFF800} + {18A3699C-31F6-400F-8935-86E65E5B36E5} = {3D02AC64-9CEB-4161-83C3-2AA58A0821CC} + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} = {F1E2D3C4-B5A6-7890-FEDC-BA0987654321} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {36A3FDA1-ACC5-4934-8E8A-DBE3ACADFE39} + SolutionGuid = {D81D02DD-15CE-4066-B904-E3F5F469427F} EndGlobalSection EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 95780f5..1b236e7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,16 +1,65 @@ - - true - net45;netstandard2.0 - 8 - https://github.com/AdysTech/CredentialManager + + true + netstandard2.0;net8.0 + 12.0 + enable + https://github.com/AdysTech/CredentialManager https://github.com/AdysTech/CredentialManager + git MIT - © AdysTech 2016-2022 + © Adys Tech 2016-2026 AdysTech - true - false - true - ../../key.snk + + + true + true + + + true + latest-recommended + true - \ No newline at end of file + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + all + runtime; build; native; contentfiles; analyzers + + + + all + runtime; build; native; contentfiles; analyzers + + + + all + runtime; build; native; contentfiles; analyzers + + + + all + runtime; build; native; contentfiles; analyzers + + + + all + runtime; build; native; contentfiles; analyzers + + + diff --git a/License.md b/LICENSE similarity index 94% rename from License.md rename to LICENSE index e377240..0ddd4df 100644 --- a/License.md +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2018 Adys Tech +Copyright (c) 2026 shakeyourbunny Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 28e9ddb..141b80e 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,280 @@ - [![Build status](https://ci.appveyor.com/api/projects/status/b6osdeuob7qeuivr?svg=true)](https://ci.appveyor.com/project/AdysTech/credentialmanager) +# CredentialManager - -### Nuget Package -[AdysTech.CredentialManager](https://www.nuget.org/packages/AdysTech.CredentialManager/) +[![NuGet](https://img.shields.io/nuget/v/AdysTech.CredentialManager)](https://www.nuget.org/packages/AdysTech.CredentialManager) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -Supports .NET Framework 4.5+, and .NET Standard 2.1+. +A .NET library for storing and retrieving credentials from the Windows Credential Store. +Wraps the native `CredWrite`, `CredRead`, `CredEnumerate`, and `CredDelete` APIs via P/Invoke, +providing a safe managed interface for credential management in desktop applications, CLI tools, +and background services. -#### Latest Download -[AdysTech.CredentialManager](https://ci.appveyor.com/api/buildjobs/so3ev8bmq51pp2im/artifacts/AdysTech.CredentialManager%2Fbin%2FCredentialManager.zip) +## Features -# CredentialManager -C# wrapper around CredWrite / CredRead functions to store and retrieve from Windows Credential Store. -Windows OS comes equipped with a very secure robust [Credential Manager](https://technet.microsoft.com/en-us/library/jj554668.aspx) from Windows XP onwards, and [good set of APIs](https://msdn.microsoft.com/en-us/library/windows/desktop/aa374731(v=vs.85).aspx#credentials_management_functions) to interact with it. However .NET Framework did not provide any standard way to interact with this vault [until Windows 8.1](https://msdn.microsoft.com/en-us/library/windows/apps/windows.security.credentials.aspx). - -Microsoft Peer Channel blog (WCF team) has written [a blog post](http://blogs.msdn.com/b/peerchan/archive/2005/11/01/487834.aspx) in 2005 which provided basic structure of using the Win32 APIs for credential management in .NET. -I used their code, and improved up on it to add `PromptForCredentials` function to display a dialog to get the credentials from user. - -Need: Many web services and REST Urls use basic authentication. .Net does not have a way to generate basic auth text (username:password encoded in Base64) for the current logged in user, with their credentials. -`ICredential.GetCredential (uri, "Basic")` does not provide a way to get current user security context either as it will expose the current password in plain text. So only way to retrieve Basic auth text is to prompt the user for the credentials and storing it, or assume some stored credentials in Windows store, and retrieving it. - -This project provides access to all three -#### 1. Prompt user for Credentials -```C# -var cred = CredentialManager.PromptForCredentials ("Some Webservice", ref save, "Please provide credentials", "Credentials for service"); -``` - -#### 2. Save Credentials -```C# -var cred = new NetworkCredential ("TestUser", "Pwd"); -CredentialManager.SaveCredentials ("TestSystem", cred); -``` - -#### 3. Retrieve saved Credentials -```C# -var cred = CredentialManager.GetCredentials ("TestSystem"); -``` - -With v2.0 release exposes raw credential, with additional information not available in normal `NetworkCredential` available in previous versions. This library also allows to store comments and additional attributes associated with a Credential object. The attributes are serialized using `BinaryFormatter` and API has 256 byte length. `BinaryFormatter` generates larger than what you think the object size is going to be, si keep an eye on that. - -Comments and attributes are only accessible programmatically. Windows always supported such a feature (via `CREDENTIALW` [structure](https://docs.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentialw)) but `Windows Credential Manager applet` does not have any way to show this information to user. So if an user edits the saved credentials using control panel comments and attributes gets lost. The lack of this information may be used as a tamper check. Note that this information is accessible all programs with can read write to credential store, so don't assume the information is secure from everything. - -#### 4. Save and retrieve credentials with comments and attributes -```C# - var cred = (new NetworkCredential(uName, pwd, domain)).ToICredential(); - cred.TargetName = "TestSystem_Attributes"; - cred.Attributes = new Dictionary(); - var sample = new SampleAttribute() { role = "regular", created = DateTime.UtcNow }; - cred.Attributes.Add("sampleAttribute", sample); - cred.Comment = "This comment is only visible via API, not in Windows UI"; - cred.SaveCredential(); -``` - -#### 5. Getting ICredential from previously saved credential -```C# - var cred = CredentialManager.GetICredential(TargetName); - cred.Comment = "Update the credential data and save back"; - cred.SaveCredential(); -``` -#### deprecated -[AdysTech.CredentialManager.Core](https://www.nuget.org/packages/AdysTech.CredentialManager.Core/) \ No newline at end of file +- Save, retrieve, enumerate, and remove credentials from Windows Credential Store +- Prompt users for credentials via Windows UI dialog or console +- Store comments and custom attributes (JSON-serialized, up to 64 per credential) +- Configurable persistence (Session, LocalMachine, Enterprise) +- JIT-safe memory zeroing of credential buffers via `RtlZeroMemory` P/Invoke +- Full nullable reference type annotations +- Source Link enabled for debugger integration + +## Target Frameworks + +| Framework | Purpose | +|-----------|---------| +| **.NET 8.0** | Primary target, full analyzer coverage | +| **.NET Standard 2.0** | Broad compatibility (.NET Framework 4.6.1+, .NET Core 2.0+, Mono, Unity) | + +## Installation + +### NuGet (recommended) + +``` +dotnet add package AdysTech.CredentialManager +``` + +Or in your `.csproj`: + +```xml + +``` + +### Project Reference + +For local development or when building from source: + +```xml + +``` + +## Quick Start + +```csharp +using System.Net; +using AdysTech.CredentialManager; + +// Save +var cred = new NetworkCredential("user", "password", "domain"); +CredentialManager.SaveCredentials("MyApp:api-token", cred); + +// Retrieve +var stored = CredentialManager.GetCredentials("MyApp:api-token"); +if (stored != null) + Console.WriteLine($"User: {stored.UserName}, Domain: {stored.Domain}"); + +// Remove +CredentialManager.RemoveCredentials("MyApp:api-token"); +``` + +## Integration Guide + +### Adding to Your Project + +1. Install the NuGet package (see Installation above). + +2. Add the using directive: + ```csharp + using AdysTech.CredentialManager; + ``` + +3. Use `CredentialManager` as a static class — no instantiation needed. + +### Target Naming Convention + +Use a consistent prefix for your target names to avoid collisions with other applications: + +```csharp +const string TargetPrefix = "MyApp:"; +CredentialManager.SaveCredentials($"{TargetPrefix}api-key", cred); +CredentialManager.SaveCredentials($"{TargetPrefix}oauth-token", cred); +``` + +### Typical Integration Pattern + +```csharp +public static class SecureStore +{ + private const string Prefix = "MyApp:"; + + public static void StoreToken(string key, string token, string user = "token") + { + var cred = new NetworkCredential(user, token); + CredentialManager.SaveCredentials($"{Prefix}{key}", cred, + persistence: Persistence.LocalMachine); + } + + public static string? GetToken(string key) + { + return CredentialManager.GetCredentials($"{Prefix}{key}")?.Password; + } + + public static void RemoveToken(string key) + { + try { CredentialManager.RemoveCredentials($"{Prefix}{key}"); } + catch (CredentialAPIException) { /* not found — already removed */ } + } +} +``` + +### Error Handling + +All public methods throw `ArgumentNullException` for null parameters and `CredentialAPIException` +for Windows API failures. `GetCredentials` and `GetICredential` return `null` when the target +is not found (not an error). + +```csharp +try +{ + CredentialManager.SaveCredentials("target", cred); +} +catch (CredentialAPIException ex) +{ + // ex.Message — human-readable description + // ex.ApiName — Win32 function that failed (e.g. "CredWrite") + // ex.ErrorCode — Win32 error code + Console.Error.WriteLine($"{ex.ApiName} failed: {ex.Message} (error {ex.ErrorCode})"); +} +``` + +### Platform Considerations + +This library is Windows-only (P/Invoke to `advapi32.dll` and `credui.dll`). For cross-platform +credential storage, use it behind an abstraction: + +```csharp +public interface ICredentialStore +{ + void Save(string key, string user, string password); + NetworkCredential? Get(string key); + void Remove(string key); +} +``` + +Implement with `CredentialManager` on Windows and an alternative (e.g., libsecret/keyring) +on Linux/macOS. + +## API Reference + +### Static Methods on `CredentialManager` + +| Method | Description | +|--------|-------------| +| `SaveCredentials(target, credential, ...)` | Save a `NetworkCredential` to the store. Returns `ICredential` on success. | +| `GetCredentials(target, type)` | Retrieve as `NetworkCredential`. Returns `null` if not found. | +| `GetICredential(target, type)` | Retrieve as `ICredential` (includes comment, attributes, persistence). | +| `EnumerateCredentials(target?)` | List all credentials, optionally filtered by target prefix. | +| `EnumerateICredentials(target?)` | Same as above, returning `ICredential` objects. | +| `RemoveCredentials(target, type)` | Delete a credential from the store. | +| `PromptForCredentials(target, ...)` | Show the Windows credential dialog. | +| `PromptForCredentialsConsole(target)` | Console-based credential prompt. | +| `GetBasicAuthString(credential)` | Extension method: Base64-encode `user:password` for HTTP Basic Auth. | + +### ICredential Interface + +`ICredential` exposes properties beyond `NetworkCredential`: + +| Property | Type | Description | +|----------|------|-------------| +| `TargetName` | `string` | The target identifier | +| `UserName` | `string?` | Username | +| `CredentialBlob` | `string?` | Password or token | +| `Comment` | `string?` | Comment (visible via API only, not in Windows UI) | +| `Persistence` | `Persistence` | Storage scope | +| `Type` | `CredentialType` | Generic or Windows (domain) | +| `Attributes` | `IDictionary?` | JSON-serialized key-value pairs (max 64, 256 bytes each) | +| `LastWritten` | `DateTime` | Last modification timestamp | + +### Persistence + +| Value | Behavior | +|-------|----------| +| `Session` | Exists only for the current logon session, cleared on reboot | +| `LocalMachine` | **(Default)** Persisted locally, not synced to domain controllers | +| `Enterprise` | Persisted and synced to Active Directory domain controllers | + +## Attributes + +Store structured metadata alongside credentials using JSON-serialized attributes: + +```csharp +var cred = (new NetworkCredential("user", "token")).ToICredential()!; +cred.TargetName = "MyApp:service"; +cred.Attributes = new Dictionary(StringComparer.Ordinal) +{ + { "role", "admin" }, + { "expiresAt", DateTime.UtcNow.AddHours(1).ToString("o") }, + { "retryCount", 3 } +}; +cred.SaveCredential(); + +// Reading back — attributes are returned as JsonElement +var stored = CredentialManager.GetICredential("MyApp:service"); +var role = ((JsonElement)stored!.Attributes!["role"]).GetString(); +var count = ((JsonElement)stored.Attributes["retryCount"]).GetInt32(); +``` + +Constraints: each attribute value must serialize to 256 bytes or less, maximum 64 attributes +per credential, attribute keys maximum 256 characters. + +## Upgrading from v2.x + +### BinaryFormatter Attributes + +Legacy BinaryFormatter-encoded attributes can no longer be read. The BinaryFormatter +compatibility layer has been removed entirely. Legacy attributes are silently skipped +with a debug message. Re-save credentials to convert them to JSON format. + +### Attribute Type Changes + +Attribute values are now `JsonElement` objects instead of the original .NET types: + +```csharp +// v2.x +var role = (string)cred.Attributes["role"]; + +// v3.x +var role = ((JsonElement)cred.Attributes["role"]).GetString(); +// Complex types: +var info = ((JsonElement)cred.Attributes["info"]).Deserialize(); +``` + +### Spelling Corrections + +The misspelled `Persistance` enum, properties, and parameters have been corrected to +`Persistence`. Update all references accordingly. + +### Default Persistence + +The default persistence changed from `Enterprise` to `LocalMachine`. If your application +relies on domain-replicated credentials, explicitly pass `Persistence.Enterprise`. + +## Security + +- **No BinaryFormatter** — attribute serialization uses `System.Text.Json` exclusively. + BinaryFormatter was removed due to its critical deserialization vulnerability (CWE-502). +- **JIT-safe memory zeroing** — native credential buffers are zeroed using `RtlZeroMemory` + via P/Invoke before being freed. The JIT cannot optimize away an external function call, + unlike `Marshal.Copy` with zero bytes. +- **Configurable persistence** — credentials default to `LocalMachine` (not `Enterprise`), + preventing unintended replication across domain controllers. +- **Correct buffer sizes** — credential UI prompts use the Windows-defined constants + `CREDUI_MAX_USERNAME_LENGTH` (513) and `CREDUI_MAX_PASSWORD_LENGTH` (256). +- **Static analysis** — the library builds with zero warnings across 6 Roslyn analyzer suites: + Microsoft.CodeAnalysis.NetAnalyzers, StyleCop, SecurityCodeScan, Roslynator, + SonarAnalyzer, and Meziantou.Analyzer (~2,000+ combined rules). + +> **Note:** `CredentialBlob` is a managed `string`, which the GC may copy or retain in memory. +> For maximum security, keep credential objects short-lived and avoid caching password values. + +## Building + +``` +dotnet build -c Release +dotnet test -c Release +``` + +The project requires Windows for testing (credential store access via interactive logon session). + +## License + +[MIT](LICENSE) — Copyright (c) 2016-2026 Adys Tech diff --git a/changelog.md b/changelog.md deleted file mode 100644 index 34f89d4..0000000 --- a/changelog.md +++ /dev/null @@ -1,115 +0,0 @@ -## v2.6.0 [Jan 18, 2022] - -### Release Notes - -This version allows to save blank passwords with an additional `AllowBlankPassowrd` paremeter. Fixes #67. - -## v2.5.0 [Nov 10, 2021] - -### Release Notes - -Update max credential length to 2560 charecters. Fixes #65, Thanks to @ldattilo - -## v2.4.0 [Aug 30, 2021] - -### Release Notes - -Fixes #62 allowing to save single charecter passowrds, thanks to @aschoelzhorn - - - -## v2.3.0 [Dec 20, 2020] - -### Release Notes - -This version adds RemoveCredential. Fixes #59 - -## v2.2.0 [May 27, 2020] - -### Release Notes - -This version adds EnumerateICredentials. Housekeeping to move common project properties to build.props. - -### Bugfixes - -- [#47](https://github.com/AdysTech/CredentialManager/issues/47): Getting Multiple targets - -### Features - -- [#46](https://github.com/AdysTech/CredentialManager/pull/46): Add EnumerateICredentials . Thanks to @strzalkowski - -## v2.1.0 [May 20, 2020] - -### Release Notes - -This version merges the .NET Framework and .NET Core projects into one multi-target project. - -### Bugfixes - -- [#42](https://github.com/AdysTech/CredentialManager/pull/42): Fix NullReferenceException when credentials not found. Thanks to @LePtitDev - -### Features - -- [#41](https://github.com/AdysTech/CredentialManager/pull/41): Use single project to target .NET Framework & Core. SDK-style projects allow multi-targeting which makes this much simpler. Thanks to @drewnoakes - -### Breaking Change -- since [main Nuget Package](https://www.nuget.org/packages/AdysTech.CredentialManager) supports .NET Core specific Nuget package will be deprecated. - - -## v2.0.0 [Apr 20, 2020] - -### Release Notes - -This version support to expose raw credential, with additional information not available in normal `NetworkCredential` available in previous versions. Currently there is a open [issue #30](https://github.com/AdysTech/CredentialManager/issues/30) for quite some asking for this feature. - -This version also adds support to store comments and additional attributes associated with a Credential object. The attributes are serialized using `BinaryFormatter` and API has 256 byte length. `BinaryFormatter` generates larger than what you think the object size is going to be, si keep an eye on that. - -Comments and attributes are only accessible programmatically. Windows always supported such a feature (via `CREDENTIALW` [structure](https://docs.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentialw)) but `Windows Credential Manager applet` does not have any way to show this information to user. So if an user edits the saved credentials using control panel comments and attributes gets lost. The lack of this information may be used as a tamper check. Note that this information is accessible all programs with can read write to credential store, so don't assume the information is secure from everything. - -### Bugfixes - -- [#39](https://github.com/AdysTech/CredentialManager/issues/39): Password is exposed in the process memory after saving in the Windows credentials storage - -### Features - -- [#30](https://github.com/AdysTech/CredentialManager/issues/30): Expose properties of Credential object which are not part of generic `NetworkCredential` -- Ability to add comments to saved credentials. Use the `ICredential` returned from `SaveCredentials`, and call save on the interface for the second time after updating comment. -- Ability to read write attributes to credentials. These Attributes can be any binary data, including strings, user roles etc, as it applies to use case. -- New `CredentialAPIException` to indicate the credential API failures, with API name. - -### Breaking Change -- `SaveCredentials` return type changed from `bool` to `ICredential`, giving reference to just saved instance. -- `ToNetworkCredential` doesn't throw `Win32Exception` anymore. It will throw `InvalidOperationException` or new `CredentialAPIException` instead. -- `CredentialManager.CredentialType` enum is removed. Use `CredentialType` instead. - - - -## Acknowledging external contributors to previous versions. - -### v1.9.5.0 [Dec 22, 2019] -- Add CredentialType as an extra optional parameter when saving credentials, Thanks to @esrahofstede - -### v1.0.1.0 [Dec 11, 2019] -- add strong name to nuget, Thanks to @kvvinokurov - -### v1.9.0.0 [Nov 9, 2019] -- [#31](https://github.com/AdysTech/CredentialManager/issues/31): Support .NET Standard, Thanks to @RussKie for the issue - -### v1.8.0.0 [Feb 25, 2019] -- Add EnumerateCredentials, Thanks to @erikjon - -### v1.7.0.0 [Sep 26, 2018] -- Allow prefilled user name, Thanks to @jairbubbles - - -### v1.6.0.0 [Sep 24, 2018] -- Fix buffer sizes in ParseUserName, Thanks to @jairbubbles - -### v1.2.1.0 [Oct 25, 2017] -- Don't crash if credential not found, Thanks to @pmiossec - -### v1.2.1.0 [Oct 25, 2017] -- Corrections to Error message, Thanks to @nguillermin - -### v1.1.0.0 [Jan 9, 2017] -- Initial Release \ No newline at end of file diff --git a/key.snk b/key.snk deleted file mode 100644 index 80a5977..0000000 Binary files a/key.snk and /dev/null differ diff --git a/project.conf b/project.conf new file mode 100644 index 0000000..0383b30 --- /dev/null +++ b/project.conf @@ -0,0 +1,4 @@ +# CredentialManager build configuration for build-tools +PROJECT_NAME="CredentialManager" +DOTNET_CHANNEL="8.0" +LOCAL_ARTIFACTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/dist" diff --git a/samples/CredentialManager.Demo/CredentialManager.Demo.csproj b/samples/CredentialManager.Demo/CredentialManager.Demo.csproj new file mode 100644 index 0000000..8ef27fa --- /dev/null +++ b/samples/CredentialManager.Demo/CredentialManager.Demo.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + enable + enable + 12.0 + + + + + + diff --git a/samples/CredentialManager.Demo/Program.cs b/samples/CredentialManager.Demo/Program.cs new file mode 100644 index 0000000..11bcbc3 --- /dev/null +++ b/samples/CredentialManager.Demo/Program.cs @@ -0,0 +1,135 @@ +using System.Net; +using System.Text.Json; +using AdysTech.CredentialManager; + +#pragma warning disable SCS0015 // Hardcoded passwords — this is a demo application + +// ----------------------------------------------------------------------- +// CredentialManager Demo — exercises the full API surface +// ----------------------------------------------------------------------- +// Run: dotnet run --project samples/CredentialManager.Demo +// ----------------------------------------------------------------------- + +const string Target = "CredentialManager.Demo"; +const string TargetAttribs = "CredentialManager.Demo.Attribs"; + +try +{ + // --- 1. Save a credential --- + Console.WriteLine("=== 1. Save Credential ==="); + var netCred = new NetworkCredential("demo_user", "s3cret!", "WORKGROUP"); + var saved = CredentialManager.SaveCredentials(Target, netCred); + if (saved != null) + Console.WriteLine($" Saved: target={saved.TargetName}, user={saved.UserName}, persistence={saved.Persistence}"); + else + Console.WriteLine(" SaveCredentials returned null (unexpected)."); + + // --- 2. Retrieve the credential --- + Console.WriteLine("\n=== 2. Retrieve Credential ==="); + var retrieved = CredentialManager.GetCredentials(Target); + if (retrieved != null) + Console.WriteLine($" Retrieved: user={retrieved.UserName}, password={retrieved.Password}, domain={retrieved.Domain}"); + else + Console.WriteLine(" Not found."); + + // --- 3. ICredential with comment and attributes --- + Console.WriteLine("\n=== 3. ICredential with Comment & Attributes ==="); + var iCred = new NetworkCredential("token_user", "eyJhbGciOi...").ToICredential()!; + iCred.TargetName = TargetAttribs; + iCred.Comment = "API token for demo service (visible via API only, not in Credential Manager UI)"; + iCred.Persistence = Persistence.Session; // Not persisted across reboots + iCred.Attributes = new Dictionary(StringComparer.Ordinal) + { + { "role", "admin" }, + { "expiresAt", DateTime.UtcNow.AddHours(1).ToString("o") }, + { "retryCount", 3 } + }; + iCred.SaveCredential(); + + var iRetrieved = CredentialManager.GetICredential(TargetAttribs); + if (iRetrieved != null) + { + Console.WriteLine($" Target: {iRetrieved.TargetName}"); + Console.WriteLine($" User: {iRetrieved.UserName}"); + Console.WriteLine($" Comment: {iRetrieved.Comment}"); + Console.WriteLine($" Persistence: {iRetrieved.Persistence}"); + Console.WriteLine($" Attributes: {iRetrieved.Attributes?.Count ?? 0}"); + if (iRetrieved.Attributes != null) + { + foreach (var attr in iRetrieved.Attributes) + { + // Attributes come back as JsonElement — demonstrate typed access + var je = (JsonElement)attr.Value; + Console.WriteLine($" [{attr.Key}] = {je} (kind: {je.ValueKind})"); + } + } + } + + // --- 4. Enumerate all credentials --- + Console.WriteLine("\n=== 4. Enumerate Credentials ==="); + var all = CredentialManager.EnumerateICredentials(); + if (all != null) + { + Console.WriteLine($" Total credentials in store: {all.Count}"); + // Show first 5 + foreach (var c in all.Take(5)) + Console.WriteLine($" - {c.TargetName} (type={c.Type}, user={c.UserName})"); + if (all.Count > 5) + Console.WriteLine($" ... and {all.Count - 5} more"); + } + else + { + Console.WriteLine(" No credentials found."); + } + + // --- 5. Basic Auth header --- + Console.WriteLine("\n=== 5. Basic Auth Header ==="); + if (retrieved != null) + { + var authString = retrieved.GetBasicAuthString(); + Console.WriteLine($" Authorization: Basic {authString}"); + } + + // --- 6. Persistence options --- + Console.WriteLine("\n=== 6. Persistence Options ==="); + Console.WriteLine($" Session: {Persistence.Session} ({(uint)Persistence.Session})"); + Console.WriteLine($" LocalMachine: {Persistence.LocalMachine} ({(uint)Persistence.LocalMachine}) [default]"); + Console.WriteLine($" Enterprise: {Persistence.Enterprise} ({(uint)Persistence.Enterprise})"); + + // --- 7. Error handling --- + Console.WriteLine("\n=== 7. Error Handling ==="); + var missing = CredentialManager.GetCredentials("NonExistent_Target_12345"); + Console.WriteLine($" GetCredentials for missing target: {(missing == null ? "null (correct)" : "unexpected value")}"); + + try + { + CredentialManager.SaveCredentials(null!, netCred); + } + catch (ArgumentNullException ex) + { + Console.WriteLine($" Null target throws: {ex.GetType().Name} (correct)"); + } + + // --- Cleanup --- + Console.WriteLine("\n=== Cleanup ==="); + CredentialManager.RemoveCredentials(Target); + Console.WriteLine($" Removed: {Target}"); + CredentialManager.RemoveCredentials(TargetAttribs); + Console.WriteLine($" Removed: {TargetAttribs}"); + + Console.WriteLine("\nAll demos completed successfully."); +} +catch (Exception ex) +{ + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\nError: {ex.GetType().Name}: {ex.Message}"); + Console.ResetColor(); + + // Attempt cleanup on error — ignore failures since the credential may not exist + try { CredentialManager.RemoveCredentials(Target); } catch (CredentialAPIException) { /* best-effort cleanup */ } + try { CredentialManager.RemoveCredentials(TargetAttribs); } catch (CredentialAPIException) { /* best-effort cleanup */ } + + return 1; +} + +return 0; diff --git a/src/AdysTech.CredentialManager/AdysTech.CredentialManager.csproj b/src/AdysTech.CredentialManager/AdysTech.CredentialManager.csproj index b415fba..4c6e878 100644 --- a/src/AdysTech.CredentialManager/AdysTech.CredentialManager.csproj +++ b/src/AdysTech.CredentialManager/AdysTech.CredentialManager.csproj @@ -1,19 +1,28 @@ - - + C# wrapper around CredWrite / CredRead functions to store and retrieve from Windows Credential Store. - The library .NET Standard 1.2+ and .NET Framework 4.5+ and provides - 1. Prompt user for Windows Generic Credentials - 2. Save Windows Generic Credentials - 3. Retrieve saved Windows Generic Credentials - 4. Add or edit ccomments, store additional attribute objects associated with Credentials - 2.6.0.0 + Supports .NET 8.0+ and .NET Standard 2.0+. Provides credential prompting, save/retrieve, + enumeration, and secure attribute storage using JSON serialization. + 3.1.0 true true snupkg - © AdysTech 2016-2022 - 2.6.0.0 - 2.6.0.0 + 3.1.0.0 + 3.1.0.0 + + + README.md + credentials;credential-manager;windows;credential-store;credwrite;credread;pinvoke;security;keyring;password + v3.1.0: Spelling fix (Persistance→Persistence), BinaryFormatter fallback removed, 6 Roslyn analyzer suites (0 warnings), Source Link, demo app. See CHANGELOG.md. + + + + + + + + + diff --git a/src/AdysTech.CredentialManager/Credential.cs b/src/AdysTech.CredentialManager/Credential.cs index 88dac61..342c556 100644 --- a/src/AdysTech.CredentialManager/Credential.cs +++ b/src/AdysTech.CredentialManager/Credential.cs @@ -1,304 +1,329 @@ -using System; +using System; using System.Collections.Generic; -using System.IO; +using System.Diagnostics; using System.Net; using System.Runtime.InteropServices; -using System.Runtime.Serialization; -using System.Runtime.Serialization.Formatters.Binary; using System.Text; +using System.Text.Json; -namespace AdysTech.CredentialManager + +namespace AdysTech.CredentialManager; + +internal class Credential : ICredential { - internal class Credential : ICredential + /// + /// Maximum size in bytes of a credential that can be stored. While the API + /// documentation lists 512 as the max size, the current Windows SDK sets + /// it to 5*512 via CRED_MAX_CREDENTIAL_BLOB_SIZE in wincred.h. This has + /// been verified to work on Windows Server 2016 and later. + /// + /// API Doc: https://docs.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentiala + /// + /// + /// + /// This only controls the guard in the library. The actual underlying OS + /// controls the actual limit. Operating Systems older than Windows Server + /// 2016 may only support 512 bytes. + /// + /// Tokens often are 1040 bytes or more. + /// + /// + internal const int MaxCredentialBlobSize = 2560; + + /// + /// JSON serialization options used for credential attribute serialization. + /// Includes fields to support structs with public fields (common pattern for simple attribute types). + /// + internal static readonly JsonSerializerOptions s_jsonOptions = new() { - - public CredentialType Type { get; set; } - public string TargetName { get; set; } - public string Comment { get; set; } - public DateTime LastWritten { get; set; } - public string CredentialBlob { get; set; } - public Persistance Persistance { get; set; } - public IDictionary Attributes { get; set; } - public string UserName { get; set; } - - - public UInt32 Flags; - public string TargetAlias; - - /// - /// Maximum size in bytes of a credential that can be stored. While the API - /// documentation lists 512 as the max size, the current Windows SDK sets - /// it to 5*512 via CRED_MAX_CREDENTIAL_BLOB_SIZE in wincred.h. This has - /// been verified to work on Windows Server 2016 and later. - /// - /// API Doc: https://docs.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentiala - /// - /// - /// - /// This only controls the guard in the library. The actual underlying OS - /// controls the actual limit. Operating Systems older than Windows Server - /// 2016 may only support 512 bytes. - /// - /// Tokens often are 1040 bytes or more. - /// - /// - internal const int MaxCredentialBlobSize = 2560; - - internal Credential(NativeCode.NativeCredential ncred) + IncludeFields = true, + WriteIndented = false + }; + + public CredentialType Type { get; set; } + public string TargetName { get; set; } = string.Empty; + public string? Comment { get; set; } + public DateTime LastWritten { get; set; } + /// + /// The credential secret (password or token) as a managed string. + /// Note: Managed strings are immutable and cannot be reliably zeroed from memory. + /// The GC may retain or copy the value. For maximum security, keep the lifetime + /// of Credential objects short and avoid caching CredentialBlob values. + /// + public string? CredentialBlob { get; set; } + public Persistence Persistence { get; set; } + public IDictionary? Attributes { get; set; } + public string? UserName { get; set; } + + public UInt32 Flags { get; set; } + public string? TargetAlias { get; set; } + + internal Credential(NativeCode.NativeCredential ncred) + { + Flags = ncred.Flags; + TargetName = ncred.TargetName; + Comment = ncred.Comment; + try { - Flags = ncred.Flags; - TargetName = ncred.TargetName; - Comment = ncred.Comment; - try - { #pragma warning disable CS0675 // Bitwise-or operator used on a sign-extended operand - LastWritten = DateTime.FromFileTime((long)((ulong)ncred.LastWritten.dwHighDateTime << 32 | (uint)ncred.LastWritten.dwLowDateTime)); + LastWritten = DateTime.FromFileTime((long)((ulong)ncred.LastWritten.dwHighDateTime << 32 | (uint)ncred.LastWritten.dwLowDateTime)); #pragma warning restore CS0675 // Bitwise-or operator used on a sign-extended operand - } - catch (ArgumentOutOfRangeException) - { } + } + catch (ArgumentOutOfRangeException) + { + // Invalid FILETIME — leave LastWritten as default (DateTime.MinValue) + } + if (ncred.CredentialBlobSize >= 2) + { + CredentialBlob = Marshal.PtrToStringUni(ncred.CredentialBlob, (int)ncred.CredentialBlobSize / 2); + } + Persistence = (Persistence)ncred.Persist; - var CredentialBlobSize = ncred.CredentialBlobSize; - if (ncred.CredentialBlobSize >= 2) - { - CredentialBlob = Marshal.PtrToStringUni(ncred.CredentialBlob, (int)ncred.CredentialBlobSize / 2); - } - Persistance = (Persistance)ncred.Persist; - var AttributeCount = ncred.AttributeCount; - if (AttributeCount > 0) + var AttributeCount = ncred.AttributeCount; + if (AttributeCount > 0) + { + var attribSize = Marshal.SizeOf(typeof(NativeCode.NativeCredentialAttribute)); + Attributes = new Dictionary(StringComparer.Ordinal); + byte[] rawData = new byte[AttributeCount * attribSize]; + var buffer = Marshal.AllocHGlobal(attribSize); + + try { - try + Marshal.Copy(ncred.Attributes, rawData, 0, (int)AttributeCount * attribSize); + for (int i = 0; i < AttributeCount; i++) { - var formatter = new BinaryFormatter(); - var attribSize = Marshal.SizeOf(typeof(NativeCode.NativeCredentialAttribute)); - Attributes = new Dictionary(); - byte[] rawData = new byte[AttributeCount * attribSize]; - var buffer = Marshal.AllocHGlobal(attribSize); - Marshal.Copy(ncred.Attributes, rawData, (int)0, (int)AttributeCount * attribSize); - for (int i = 0; i < AttributeCount; i++) + Marshal.Copy(rawData, i * attribSize, buffer, attribSize); + var attr = (NativeCode.NativeCredentialAttribute)Marshal.PtrToStructure(buffer, + typeof(NativeCode.NativeCredentialAttribute))!; + var key = attr.Keyword; + var val = new byte[attr.ValueSize]; + Marshal.Copy(attr.Value, val, 0, (int)attr.ValueSize); + + try { - Marshal.Copy(rawData, i * attribSize, buffer, attribSize); - var attr = (NativeCode.NativeCredentialAttribute)Marshal.PtrToStructure(buffer, - typeof(NativeCode.NativeCredentialAttribute)); - var key = attr.Keyword; - var val = new byte[attr.ValueSize]; - Marshal.Copy(attr.Value, val, (int)0, (int)attr.ValueSize); - using var stream = new MemoryStream(val, false); - Attributes.Add(key, formatter.Deserialize(stream)); + // Deserialize as JSON (current format) + var jsonStr = Encoding.UTF8.GetString(val); + Attributes.Add(key, JsonSerializer.Deserialize(jsonStr)); + } + catch (JsonException) + { + Debug.WriteLine($"Could not deserialize attribute '{key}' — data may be corrupted or in an unsupported format"); } - Marshal.FreeHGlobal(buffer); - rawData = null; - } - catch - { - } } - TargetAlias = ncred.TargetAlias; - UserName = ncred.UserName; - Type = (CredentialType)ncred.Type; + finally + { + Marshal.FreeHGlobal(buffer); + } } - public Credential(System.Net.NetworkCredential credential) - { - CredentialBlob = credential.Password; - UserName = String.IsNullOrWhiteSpace(credential.Domain) ? credential.UserName : credential.Domain + "\\" + credential.UserName; - Attributes = null; - Comment = null; - TargetAlias = null; - Type = CredentialType.Generic; - Persistance = Persistance.Session; - } + TargetAlias = ncred.TargetAlias; + UserName = ncred.UserName; + Type = (CredentialType)ncred.Type; + } + + public Credential(NetworkCredential credential) + { + if (credential == null) + throw new ArgumentNullException(nameof(credential)); + + CredentialBlob = credential.Password; + UserName = String.IsNullOrWhiteSpace(credential.Domain) ? credential.UserName : credential.Domain + "\\" + credential.UserName; + Attributes = null; + Comment = null; + TargetAlias = null; + Type = CredentialType.Generic; + Persistence = Persistence.Session; + } - public Credential(ICredential credential) + public Credential(ICredential credential) + { + if (credential == null) + throw new ArgumentNullException(nameof(credential)); + + CredentialBlob = credential.CredentialBlob; + UserName = credential.UserName; + if (credential.Attributes?.Count > 0) { - CredentialBlob = credential.CredentialBlob; - UserName = credential.UserName; - if (credential.Attributes?.Count > 0) + this.Attributes = new Dictionary(StringComparer.Ordinal); + foreach (var a in credential.Attributes) { - this.Attributes = new Dictionary(); - foreach (var a in credential.Attributes) - { - Attributes.Add(a); - } + Attributes.Add(a); } - Comment = credential.Comment; - TargetAlias = null; - Type = credential.Type; - Persistance = credential.Persistance; } + Comment = credential.Comment; + TargetAlias = null; + Type = credential.Type; + Persistence = credential.Persistence; + } - public Credential(string target, CredentialType type) - { - Type = type; - TargetName = target; - } + public Credential(string target, CredentialType type) + { + if (target == null) + throw new ArgumentNullException(nameof(target)); - public NetworkCredential ToNetworkCredential() - { - if (!string.IsNullOrEmpty(UserName)) - { - var userBuilder = new StringBuilder(UserName.Length + 2); - var domainBuilder = new StringBuilder(UserName.Length + 2); + Type = type; + TargetName = target; + } - var returnCode = NativeCode.CredUIParseUserName(UserName, userBuilder, userBuilder.Capacity, domainBuilder, domainBuilder.Capacity); - var lastError = Marshal.GetLastWin32Error(); + public NetworkCredential ToNetworkCredential() + { + if (!string.IsNullOrEmpty(UserName)) + { + var userBuilder = new StringBuilder(UserName!.Length + 2); + var domainBuilder = new StringBuilder(UserName.Length + 2); - //assuming invalid account name to be not meeting condition for CredUIParseUserName - //"The name must be in UPN or down-level format, or a certificate" - if (returnCode == NativeCode.CredentialUIReturnCodes.InvalidAccountName) - { - userBuilder.Append(UserName); - } - else if (returnCode != 0) - { - throw new CredentialAPIException($"Unable to Parse UserName", "CredUIParseUserName", lastError); - } + var returnCode = NativeCode.CredUIParseUserName(UserName, userBuilder, userBuilder.Capacity, domainBuilder, domainBuilder.Capacity); + var lastError = Marshal.GetLastWin32Error(); - return new NetworkCredential(userBuilder.ToString(), this.CredentialBlob, domainBuilder.ToString()); + //assuming invalid account name to be not meeting condition for CredUIParseUserName + //"The name must be in UPN or down-level format, or a certificate" + if (returnCode == NativeCode.CredentialUIReturnCodes.InvalidAccountName) + { + userBuilder.Append(UserName); } - else + else if (returnCode != NativeCode.CredentialUIReturnCodes.Success) { - return new NetworkCredential(UserName, this.CredentialBlob); + throw new CredentialAPIException(SR.UnableToParseUserName, "CredUIParseUserName", lastError); } - } - public bool SaveCredential(bool AllowBlankPassword=false) + return new NetworkCredential(userBuilder.ToString(), this.CredentialBlob, domainBuilder.ToString()); + } + else { - IntPtr buffer = default(IntPtr); - GCHandle pinned = default(GCHandle); + return new NetworkCredential(UserName, this.CredentialBlob); + } + } - if (!String.IsNullOrEmpty(this.Comment) && Encoding.Unicode.GetBytes(this.Comment).Length > 256) - throw new ArgumentException("Comment can't be more than 256 bytes long", "Comment"); + public bool SaveCredential(bool AllowBlankPassword = false) + { + IntPtr buffer = default; + GCHandle pinned = default; - if (String.IsNullOrEmpty(this.TargetName)) - throw new ArgumentNullException("TargetName", "TargetName can't be Null or Empty"); - else if (this.TargetName.Length > 32767) - throw new ArgumentNullException("TargetName can't be more than 32kB", "TargetName"); + if (!String.IsNullOrEmpty(this.Comment) && Encoding.Unicode.GetByteCount(this.Comment) > 256) + throw new InvalidOperationException(SR.CommentTooLong); - if (!AllowBlankPassword && String.IsNullOrEmpty(this.CredentialBlob)) - throw new ArgumentNullException("CredentialBlob", "CredentialBlob can't be Null or Empty"); + if (String.IsNullOrEmpty(this.TargetName)) + throw new InvalidOperationException(SR.TargetNameNullOrEmpty); + else if (this.TargetName.Length > 32767) + throw new InvalidOperationException(SR.TargetNameTooLong); - NativeCode.NativeCredential ncred = new NativeCode.NativeCredential - { - Comment = this.Comment, - TargetAlias = null, - Type = (UInt32)this.Type, - Persist = (UInt32)this.Persistance, - UserName = this.UserName, - TargetName = this.TargetName, - CredentialBlobSize = (UInt32)Encoding.Unicode.GetBytes(this.CredentialBlob).Length - }; - if (ncred.CredentialBlobSize > MaxCredentialBlobSize) - throw new ArgumentException($"Credential can't be more than {MaxCredentialBlobSize} bytes long", "CredentialBlob"); - - ncred.CredentialBlob = Marshal.StringToCoTaskMemUni(this.CredentialBlob); - if (this.LastWritten != DateTime.MinValue) + if (!AllowBlankPassword && String.IsNullOrEmpty(this.CredentialBlob)) + throw new InvalidOperationException(SR.CredentialBlobNullOrEmpty); + + var credentialBlob = this.CredentialBlob ?? ""; + NativeCode.NativeCredential ncred = new NativeCode.NativeCredential + { + Comment = this.Comment, + TargetAlias = null, + Type = (UInt32)this.Type, + Persist = (UInt32)this.Persistence, + UserName = this.UserName, + TargetName = this.TargetName, + CredentialBlobSize = (UInt32)Encoding.Unicode.GetByteCount(credentialBlob) + }; + if (ncred.CredentialBlobSize > MaxCredentialBlobSize) + throw new InvalidOperationException(string.Format(SR.CredentialBlobTooLong, MaxCredentialBlobSize)); + + ncred.CredentialBlob = Marshal.StringToCoTaskMemUni(credentialBlob); + if (this.LastWritten != DateTime.MinValue) + { + var fileTime = this.LastWritten.ToFileTimeUtc(); + ncred.LastWritten.dwLowDateTime = (int)(fileTime & 0xFFFFFFFFL); + ncred.LastWritten.dwHighDateTime = (int)((fileTime >> 32) & 0xFFFFFFFFL); + } + + NativeCode.NativeCredentialAttribute[]? nativeAttribs = null; + try + { + if (Attributes == null || Attributes.Count == 0) { - var fileTime = this.LastWritten.ToFileTimeUtc(); - ncred.LastWritten.dwLowDateTime = (int)(fileTime & 0xFFFFFFFFL); - ncred.LastWritten.dwHighDateTime = (int)((fileTime >> 32) & 0xFFFFFFFFL); + ncred.AttributeCount = 0; + ncred.Attributes = IntPtr.Zero; } - - NativeCode.NativeCredentialAttribute[] nativeAttribs = null; - try + else { - if (Attributes == null || Attributes.Count == 0) - { - ncred.AttributeCount = 0; - ncred.Attributes = IntPtr.Zero; - } - else + if (Attributes.Count > 64) + throw new InvalidOperationException(SR.TooManyAttributes); + + ncred.AttributeCount = (UInt32)Attributes.Count; + nativeAttribs = new NativeCode.NativeCredentialAttribute[Attributes.Count]; + var attribSize = Marshal.SizeOf(typeof(NativeCode.NativeCredentialAttribute)); + byte[] rawData = new byte[Attributes.Count * attribSize]; + buffer = Marshal.AllocHGlobal(attribSize); + + var i = 0; + foreach (var a in Attributes) { - if (Attributes.Count > 64) - throw new ArgumentException("Credentials can't have more than 64 Attributes!!"); - - ncred.AttributeCount = (UInt32)Attributes.Count; - nativeAttribs = new NativeCode.NativeCredentialAttribute[Attributes.Count]; - var attribSize = Marshal.SizeOf(typeof(NativeCode.NativeCredentialAttribute)); - byte[] rawData = new byte[Attributes.Count * attribSize]; - buffer = Marshal.AllocHGlobal(attribSize); - var formatter = new BinaryFormatter(); - - var i = 0; - foreach (var a in Attributes) + if (a.Key.Length > 256) + throw new ArgumentException(string.Format(SR.AttributeNameTooLong, a.Key), a.Key); + if (a.Value == null) + throw new ArgumentNullException(a.Key, string.Format(SR.AttributeValueNull, a.Key)); + + var value = JsonSerializer.SerializeToUtf8Bytes(a.Value, a.Value.GetType(), s_jsonOptions); + + if (value.Length > 256) + throw new ArgumentException(string.Format(SR.AttributeValueTooLong, a.Key), a.Key); + + var attrib = new NativeCode.NativeCredentialAttribute { - if (a.Key.Length > 256) - throw new ArgumentException($"Attribute names can't be more than 256 bytes long. Error with key:{a.Key}", a.Key); - if (a.Value == null) - throw new ArgumentNullException(a.Key, $"Attribute value cant'be null. Error with key:{a.Key}"); - if (!a.Value.GetType().IsSerializable) - throw new ArgumentException($"Attribute value must be Serializable. Error with key:{a.Key}", a.Key); - - using var stream = new MemoryStream(); - formatter.Serialize(stream, a.Value); - var value = stream.ToArray(); - - if (value.Length > 256) - throw new ArgumentException($"Attribute values can't be more than 256 bytes long after serialization. Error with Value for key:{a.Key}", a.Key); - - var attrib = new NativeCode.NativeCredentialAttribute - { - Keyword = a.Key, - ValueSize = (UInt32)value.Length - }; - - attrib.Value = Marshal.AllocHGlobal(value.Length); - Marshal.Copy(value, 0, attrib.Value, value.Length); - nativeAttribs[i] = attrib; - - Marshal.StructureToPtr(attrib, buffer, false); - Marshal.Copy(buffer, rawData, i * attribSize, attribSize); - i++; - } - pinned = GCHandle.Alloc(rawData, GCHandleType.Pinned); - ncred.Attributes = pinned.AddrOfPinnedObject(); - } - // Write the info into the CredMan storage. + Keyword = a.Key, + ValueSize = (UInt32)value.Length + }; - if (NativeCode.CredWrite(ref ncred, 0)) - { - return true; - } - else - { - int lastError = Marshal.GetLastWin32Error(); - throw new CredentialAPIException($"Unable to Save Credential", "CredWrite", lastError); + attrib.Value = Marshal.AllocHGlobal(value.Length); + Marshal.Copy(value, 0, attrib.Value, value.Length); + nativeAttribs[i] = attrib; + + Marshal.StructureToPtr(attrib, buffer, false); + Marshal.Copy(buffer, rawData, i * attribSize, attribSize); + i++; } + pinned = GCHandle.Alloc(rawData, GCHandleType.Pinned); + ncred.Attributes = pinned.AddrOfPinnedObject(); } + // Write the info into the CredMan storage. - finally + if (NativeCode.CredWrite(ref ncred, 0)) + { + return true; + } + else + { + int lastError = Marshal.GetLastWin32Error(); + throw new CredentialAPIException(SR.UnableToSaveCredential, "CredWrite", lastError); + } + } + + finally + { + if (ncred.CredentialBlob != default) + Marshal.FreeCoTaskMem(ncred.CredentialBlob); + if (nativeAttribs != null) { - if (ncred.CredentialBlob != default(IntPtr)) - Marshal.FreeCoTaskMem(ncred.CredentialBlob); - if (nativeAttribs != null) + foreach (var a in nativeAttribs) { - foreach (var a in nativeAttribs) - { - if (a.Value != default(IntPtr)) - Marshal.FreeHGlobal(a.Value); - } - if (pinned.IsAllocated) - pinned.Free(); - if (buffer != default(IntPtr)) - Marshal.FreeHGlobal(buffer); + if (a.Value != default) + Marshal.FreeHGlobal(a.Value); } + if (pinned.IsAllocated) + pinned.Free(); + if (buffer != default) + Marshal.FreeHGlobal(buffer); } } + } - public bool RemoveCredential() - { - // Make the API call using the P/Invoke signature - var isSuccess = NativeCode.CredDelete(TargetName, (uint)Type, 0); + public bool RemoveCredential() + { + // Make the API call using the P/Invoke signature + var isSuccess = NativeCode.CredDelete(TargetName, (uint)Type, 0); - if (isSuccess) - return true; + if (isSuccess) + return true; - int lastError = Marshal.GetLastWin32Error(); - throw new CredentialAPIException($"Unable to Delete Credential", "CredDelete", lastError); - } + int lastError = Marshal.GetLastWin32Error(); + throw new CredentialAPIException(SR.UnableToDeleteCredential, "CredDelete", lastError); } - } - diff --git a/src/AdysTech.CredentialManager/CredentialAPIException.cs b/src/AdysTech.CredentialManager/CredentialAPIException.cs index 7203e9b..15d7433 100644 --- a/src/AdysTech.CredentialManager/CredentialAPIException.cs +++ b/src/AdysTech.CredentialManager/CredentialAPIException.cs @@ -1,40 +1,65 @@ -using System; -using System.Collections.Generic; -using System.Net; +using System; using System.Runtime.InteropServices; -namespace AdysTech.CredentialManager -{ - [Serializable] - public class CredentialAPIException : ExternalException - { - public string APIName { get; internal set; } +namespace AdysTech.CredentialManager; - public CredentialAPIException(string message,string api, int errorCode) : base(message, errorCode) - { - APIName = api; - } +/// +/// Exception thrown when a Windows Credential Manager API call fails. +/// Contains the API name and Win32 error code for diagnostic purposes. +/// +[Serializable] +public class CredentialAPIException : ExternalException +{ + /// + /// Gets the name of the Windows API function that failed. + /// + public string? APIName { get; internal set; } - public CredentialAPIException(string message, int errorCode): base(message, errorCode) - { + /// + /// Creates a new exception for a failed credential API call. + /// + /// Description of the failure. + /// Name of the API function that failed (e.g. "CredWrite"). + /// Win32 error code from Marshal.GetLastWin32Error(). + public CredentialAPIException(string message, string api, int errorCode) : base(message, errorCode) + { + APIName = api; + } - } + /// + /// Creates a new exception with a message and error code. + /// + public CredentialAPIException(string message, int errorCode) : base(message, errorCode) + { + } - public CredentialAPIException(string message) : base(message) - { - } + /// + /// Creates a new exception with a message. + /// + public CredentialAPIException(string message) : base(message) + { + } - public CredentialAPIException(string message, Exception innerException) : base(message, innerException) - { - } + /// + /// Creates a new exception with a message and inner exception. + /// + public CredentialAPIException(string message, Exception innerException) : base(message, innerException) + { + } - public CredentialAPIException() - { - } + /// + /// Creates a new exception with default message. + /// + public CredentialAPIException() + { + } - protected CredentialAPIException(System.Runtime.Serialization.SerializationInfo serializationInfo, System.Runtime.Serialization.StreamingContext streamingContext):base(serializationInfo,streamingContext) - { - - } +#if !NET8_0_OR_GREATER + /// + /// Serialization constructor for cross-AppDomain scenarios. + /// + protected CredentialAPIException(System.Runtime.Serialization.SerializationInfo serializationInfo, System.Runtime.Serialization.StreamingContext streamingContext) : base(serializationInfo, streamingContext) + { } +#endif } diff --git a/src/AdysTech.CredentialManager/CredentialExtensions.cs b/src/AdysTech.CredentialManager/CredentialExtensions.cs index 039d6a4..6ba8a9f 100644 --- a/src/AdysTech.CredentialManager/CredentialExtensions.cs +++ b/src/AdysTech.CredentialManager/CredentialExtensions.cs @@ -1,17 +1,26 @@ -using System.Net; +using System.Net; -namespace AdysTech.CredentialManager +namespace AdysTech.CredentialManager; + +/// +/// Extension methods for credential types. +/// +public static class CredentialExtensions { - public static class CredentialExtensions + /// + /// Converts a to an + /// that can be used with the Windows Credential Store (supports comments, + /// attributes, and persistence configuration). + /// + /// The network credential to convert. May be null. + /// An wrapping the credential, or null if input is null. + public static ICredential? ToICredential(this NetworkCredential? cred) { - public static ICredential ToICredential(this NetworkCredential cred) + if (cred == null) { - if (cred == null) - { - return null; - } - - return new Credential(cred); + return null; } + + return new Credential(cred); } } diff --git a/src/AdysTech.CredentialManager/CredentialManager.cs b/src/AdysTech.CredentialManager/CredentialManager.cs index c2eda55..0a004e4 100644 --- a/src/AdysTech.CredentialManager/CredentialManager.cs +++ b/src/AdysTech.CredentialManager/CredentialManager.cs @@ -1,386 +1,464 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Net; using System.Runtime.InteropServices; using System.Text; -namespace AdysTech.CredentialManager -{ - //ref: https://docs.microsoft.com/en-us/archive/blogs/peerchan/application-password-security +namespace AdysTech.CredentialManager; + +//ref: https://docs.microsoft.com/en-us/archive/blogs/peerchan/application-password-security - public static class CredentialManager +/// +/// Provides static methods for interacting with the Windows Credential Store. +/// Supports saving, retrieving, enumerating, and removing credentials, +/// as well as prompting users for credentials via Windows UI or console. +/// +public static class CredentialManager +{ + private static bool PromptForCredentials(NativeCode.CredentialUIInfo credUI, ref bool save, ref string user, out string password, out string domain) { - private static bool PromptForCredentials(string target, NativeCode.CredentialUIInfo credUI, ref bool save, ref string user, out string password, out string domain) + password = string.Empty; + domain = string.Empty; + + // Setup the flags and variables + credUI.cbSize = Marshal.SizeOf(credUI); + int errorcode = 0; + uint authPackage = 0; + + IntPtr outCredBuffer; + var flags = NativeCode.PromptForWindowsCredentialsFlags.GenericCredentials | + NativeCode.PromptForWindowsCredentialsFlags.EnumerateCurrentUser; + flags = save ? flags | NativeCode.PromptForWindowsCredentialsFlags.ShowCheckbox : flags; + + // Prefill username + IntPtr inCredBuffer; + int inCredSize; + GetInputBuffer(user, out inCredBuffer, out inCredSize); + + // Setup the flags and variables + int result = NativeCode.CredUIPromptForWindowsCredentials(ref credUI, + errorcode, + ref authPackage, + inCredBuffer, + inCredSize, + out outCredBuffer, + out uint outCredSize, + ref save, + flags); + + if (inCredBuffer != IntPtr.Zero) { - password = string.Empty; - domain = string.Empty; - - // Setup the flags and variables - credUI.cbSize = Marshal.SizeOf(credUI); - int errorcode = 0; - uint authPackage = 0; - - IntPtr outCredBuffer; - var flags = NativeCode.PromptForWindowsCredentialsFlags.GenericCredentials | - NativeCode.PromptForWindowsCredentialsFlags.EnumerateCurrentUser; - flags = save ? flags | NativeCode.PromptForWindowsCredentialsFlags.ShowCheckbox : flags; - - // Prefill username - IntPtr inCredBuffer; - int inCredSize; - GetInputBuffer(user, out inCredBuffer, out inCredSize); - - // Setup the flags and variables - int result = NativeCode.CredUIPromptForWindowsCredentials(ref credUI, - errorcode, - ref authPackage, - inCredBuffer, - inCredSize, - out outCredBuffer, - out uint outCredSize, - ref save, - flags); - - if (inCredBuffer != IntPtr.Zero) - { - NativeCode.CoTaskMemFree(inCredBuffer); - } - - if (result == 0) - { - GetCredentialsFromOutputBuffer(ref user, ref password, ref domain, outCredBuffer, outCredSize); - return true; - } + NativeCode.CoTaskMemFree(inCredBuffer); + } - user = null; - domain = null; - return false; + if (result == 0) + { + GetCredentialsFromOutputBuffer(ref user, ref password, ref domain, outCredBuffer, outCredSize); + return true; } - private static void GetCredentialsFromOutputBuffer(ref string user, ref string password, ref string domain, IntPtr outCredBuffer, uint outCredSize) + user = null!; + domain = null!; + return false; + } + + private static void GetCredentialsFromOutputBuffer(ref string user, ref string password, ref string domain, IntPtr outCredBuffer, uint outCredSize) + { + int maxUserName = NativeCode.CREDUI_MAX_USERNAME_LENGTH; + int maxDomain = NativeCode.CREDUI_MAX_USERNAME_LENGTH; + int maxPassword = NativeCode.CREDUI_MAX_PASSWORD_LENGTH; + var usernameBuf = new StringBuilder(maxUserName); + var passwordBuf = new StringBuilder(maxPassword); + var domainBuf = new StringBuilder(maxDomain); + + if (NativeCode.CredUnPackAuthenticationBuffer(0, outCredBuffer, outCredSize, usernameBuf, ref maxUserName, + domainBuf, ref maxDomain, passwordBuf, ref maxPassword)) { - int maxUserName = 100; - int maxDomain = 100; - int maxPassword = 100; - var usernameBuf = new StringBuilder(maxUserName); - var passwordBuf = new StringBuilder(maxDomain); - var domainBuf = new StringBuilder(maxPassword); - - if (NativeCode.CredUnPackAuthenticationBuffer(0, outCredBuffer, outCredSize, usernameBuf, ref maxUserName, - domainBuf, ref maxDomain, passwordBuf, ref maxPassword)) + user = usernameBuf.ToString(); + password = passwordBuf.ToString(); + domain = domainBuf.ToString(); + if (string.IsNullOrWhiteSpace(domain)) { - user = usernameBuf.ToString(); + Debug.WriteLine("Domain null"); + if (!ParseUserName(usernameBuf.ToString(), usernameBuf.Capacity, domainBuf.Capacity, out user, out domain)) + user = usernameBuf.ToString(); password = passwordBuf.ToString(); - domain = domainBuf.ToString(); - if (string.IsNullOrWhiteSpace(domain)) - { - Debug.WriteLine("Domain null"); - if (!ParseUserName(usernameBuf.ToString(), usernameBuf.Capacity, domainBuf.Capacity, out user, out domain)) - user = usernameBuf.ToString(); - password = passwordBuf.ToString(); - } } + } - //mimic SecureZeroMem function to make sure buffer is zeroed out. SecureZeroMem is not an exported function, neither is RtlSecureZeroMemory - var zeroBytes = new byte[outCredSize]; - Marshal.Copy(zeroBytes, 0, outCredBuffer, (int)outCredSize); + // Zero sensitive buffers using SecureZeroMemory (cannot be optimized away by JIT) + NativeCode.SecureZeroMemory(outCredBuffer, new UIntPtr(outCredSize)); - //clear the memory allocated by CredUIPromptForWindowsCredentials - NativeCode.CoTaskMemFree(outCredBuffer); - } + // Zero StringBuilder contents to prevent password lingering in managed memory + for (int i = 0; i < passwordBuf.Length; i++) + passwordBuf[i] = '\0'; + + // Clear the memory allocated by CredUIPromptForWindowsCredentials + NativeCode.CoTaskMemFree(outCredBuffer); + } - private static void GetInputBuffer(string user, out IntPtr inCredBuffer, out int inCredSize) + private static void GetInputBuffer(string user, out IntPtr inCredBuffer, out int inCredSize) + { + if (!string.IsNullOrEmpty(user)) { - if (!string.IsNullOrEmpty(user)) - { - var usernameBuf = new StringBuilder(user); - var passwordBuf = new StringBuilder(); + var usernameBuf = new StringBuilder(user); + var passwordBuf = new StringBuilder(); + + // Query required buffer size first (pass IntPtr.Zero, size 0) + inCredSize = 0; + NativeCode.CredPackAuthenticationBuffer(0x00, usernameBuf, passwordBuf, IntPtr.Zero, ref inCredSize); - inCredSize = 1024; + if (inCredSize > 0) + { inCredBuffer = Marshal.AllocCoTaskMem(inCredSize); if (NativeCode.CredPackAuthenticationBuffer(0x00, usernameBuf, passwordBuf, inCredBuffer, ref inCredSize)) return; - if (inCredBuffer != IntPtr.Zero) - { - NativeCode.CoTaskMemFree(inCredBuffer); - } + NativeCode.CoTaskMemFree(inCredBuffer); } - - inCredBuffer = IntPtr.Zero; - inCredSize = 0; } - internal static bool ParseUserName(string usernameBuf, int maxUserName, int maxDomain, out string user, out string domain) - { - var userBuilder = new StringBuilder(maxUserName); - var domainBuilder = new StringBuilder(maxDomain); - user = String.Empty; - domain = String.Empty; - - var returnCode = NativeCode.CredUIParseUserName(usernameBuf, userBuilder, maxUserName, domainBuilder, maxDomain); - Debug.WriteLine(returnCode); - switch (returnCode) - { - case NativeCode.CredentialUIReturnCodes.Success: // The username is valid. - user = userBuilder.ToString(); - domain = domainBuilder.ToString(); - return true; - } - return false; - } + inCredBuffer = IntPtr.Zero; + inCredSize = 0; + } - internal static bool PromptForCredentials(string target, ref bool save, ref string user, out string password, out string domain, IntPtr parentWindowHandle = default) + internal static bool ParseUserName(string usernameBuf, int maxUserName, int maxDomain, out string user, out string domain) + { + var userBuilder = new StringBuilder(maxUserName); + var domainBuilder = new StringBuilder(maxDomain); + user = String.Empty; + domain = String.Empty; + + var returnCode = NativeCode.CredUIParseUserName(usernameBuf, userBuilder, maxUserName, domainBuilder, maxDomain); + Debug.WriteLine(returnCode); + switch (returnCode) { - var credUI = new NativeCode.CredentialUIInfo - { - hwndParent = parentWindowHandle, - pszMessageText = " ", - pszCaptionText = " ", - hbmBanner = IntPtr.Zero - }; - return PromptForCredentials(target, credUI, ref save, ref user, out password, out domain); + case NativeCode.CredentialUIReturnCodes.Success: + user = userBuilder.ToString(); + domain = domainBuilder.ToString(); + return true; } + return false; + } - internal static bool PromptForCredentials(string target, ref bool save, string message, string caption, ref string user, out string password, out string domain, IntPtr parentWindowHandle = default) + internal static bool PromptForCredentials(string target, ref bool save, ref string user, out string password, out string domain, IntPtr parentWindowHandle = default) + { + var credUI = new NativeCode.CredentialUIInfo { - var credUI = new NativeCode.CredentialUIInfo - { - hwndParent = parentWindowHandle, - pszMessageText = message, - pszCaptionText = caption, - hbmBanner = IntPtr.Zero - }; - return PromptForCredentials(target, credUI, ref save, ref user, out password, out domain); - } + hwndParent = parentWindowHandle, + pszMessageText = " ", + pszCaptionText = " ", + hbmBanner = IntPtr.Zero + }; + return PromptForCredentials(credUI, ref save, ref user, out password, out domain); + } - /// - /// Opens OS Version specific Window prompting for credentials - /// - /// A descriptive text for where teh credentials being asked are used for - /// Whether or not to offer the checkbox to save the credentials - /// NetworkCredential object containing the user name, - public static NetworkCredential PromptForCredentials(string target, ref bool save, IntPtr parentWindowHandle = default) + internal static bool PromptForCredentials(string target, ref bool save, string message, string caption, ref string user, out string password, out string domain, IntPtr parentWindowHandle = default) + { + var credUI = new NativeCode.CredentialUIInfo { - string username = "", password, domain; - return PromptForCredentials(target, ref save, ref username, out password, out domain, parentWindowHandle) ? new NetworkCredential(username, password, domain) : null; - } + hwndParent = parentWindowHandle, + pszMessageText = message, + pszCaptionText = caption, + hbmBanner = IntPtr.Zero + }; + return PromptForCredentials(credUI, ref save, ref user, out password, out domain); + } - /// - /// Opens OS Version specific Window prompting for credentials - /// - /// A descriptive text for where teh credentials being asked are used for - /// Whether or not to offer the checkbox to save the credentials - /// A brief message to display in the dialog box - /// Title for the dialog box - /// NetworkCredential object containing the user name, - public static NetworkCredential PromptForCredentials(string target, ref bool save, string message, string caption, IntPtr parentWindowHandle = default) - { - string username = "", password, domain; - return PromptForCredentials(target, ref save, message, caption, ref username, out password, out domain, parentWindowHandle) ? new NetworkCredential(username, password, domain) : null; - } + /// + /// Opens OS Version specific Window prompting for credentials. + /// + /// A descriptive text for where the credentials being asked are used for. + /// Whether or not to offer the checkbox to save the credentials. + /// Handle to the parent window. + /// NetworkCredential object containing the user name, password, and domain; or null if cancelled. + public static NetworkCredential? PromptForCredentials(string target, ref bool save, IntPtr parentWindowHandle = default) + { + if (target == null) throw new ArgumentNullException(nameof(target)); - /// - /// Opens OS Version specific Window prompting for credentials - /// - /// A descriptive text for where teh credentials being asked are used for - /// Whether or not to offer the checkbox to save the credentials - /// A brief message to display in the dialog box - /// Title for the dialog box - /// Default value for username - /// NetworkCredential object containing the user name, - public static NetworkCredential PromptForCredentials(string target, ref bool save, string message, string caption, string defaultUserName, IntPtr parentWindowHandle = default) - { - string username = defaultUserName, password, domain; - return PromptForCredentials(target, ref save, message, caption, ref username, out password, out domain, parentWindowHandle) ? new NetworkCredential(username, password, domain) : null; - } + string username = "", password, domain; + return PromptForCredentials(target, ref save, ref username, out password, out domain, parentWindowHandle) ? new NetworkCredential(username, password, domain) : null; + } - /// - /// Accepts credentials in a console window - /// - /// A descriptive text for where teh credentials being asked are used for - /// NetworkCredential object containing the user name, - public static NetworkCredential PromptForCredentialsConsole(string target) - { - var user = String.Empty; - var domain = string.Empty; + /// + /// Opens OS Version specific Window prompting for credentials. + /// + /// A descriptive text for where the credentials being asked are used for. + /// Whether or not to offer the checkbox to save the credentials. + /// A brief message to display in the dialog box. + /// Title for the dialog box. + /// Handle to the parent window. + /// NetworkCredential object containing the user name, password, and domain; or null if cancelled. + public static NetworkCredential? PromptForCredentials(string target, ref bool save, string message, string caption, IntPtr parentWindowHandle = default) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (message == null) throw new ArgumentNullException(nameof(message)); + if (caption == null) throw new ArgumentNullException(nameof(caption)); - // Setup the flags and variables - StringBuilder userPassword = new StringBuilder(), userID = new StringBuilder(); - bool save = true; - NativeCode.CredentialUIFlags flags = NativeCode.CredentialUIFlags.CompleteUsername | NativeCode.CredentialUIFlags.ExcludeCertificates | NativeCode.CredentialUIFlags.GenericCredentials; + string username = "", password, domain; + return PromptForCredentials(target, ref save, message, caption, ref username, out password, out domain, parentWindowHandle) ? new NetworkCredential(username, password, domain) : null; + } - // Prompt the user - NativeCode.CredentialUIReturnCodes returnCode = NativeCode.CredUICmdLinePromptForCredentials(target, IntPtr.Zero, 0, userID, 100, userPassword, 100, ref save, flags); + /// + /// Opens OS Version specific Window prompting for credentials. + /// + /// A descriptive text for where the credentials being asked are used for. + /// Whether or not to offer the checkbox to save the credentials. + /// A brief message to display in the dialog box. + /// Title for the dialog box. + /// Default value for username. + /// Handle to the parent window. + /// NetworkCredential object containing the user name, password, and domain; or null if cancelled. + public static NetworkCredential? PromptForCredentials(string target, ref bool save, string message, string caption, string defaultUserName, IntPtr parentWindowHandle = default) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (message == null) throw new ArgumentNullException(nameof(message)); + if (caption == null) throw new ArgumentNullException(nameof(caption)); - string password = userPassword.ToString(); + string username = defaultUserName, password, domain; + return PromptForCredentials(target, ref save, message, caption, ref username, out password, out domain, parentWindowHandle) ? new NetworkCredential(username, password, domain) : null; + } - StringBuilder userBuilder = new StringBuilder(); - StringBuilder domainBuilder = new StringBuilder(); + /// + /// Accepts credentials in a console window. + /// + /// A descriptive text for where the credentials being asked are used for. + /// NetworkCredential object containing the user name, password, and domain; or null if the user cancelled. + public static NetworkCredential? PromptForCredentialsConsole(string target) + { + if (target == null) throw new ArgumentNullException(nameof(target)); - returnCode = NativeCode.CredUIParseUserName(userID.ToString(), userBuilder, int.MaxValue, domainBuilder, int.MaxValue); - switch (returnCode) - { - case NativeCode.CredentialUIReturnCodes.Success: // The username is valid. - user = userBuilder.ToString(); - domain = domainBuilder.ToString(); - break; + var user = String.Empty; + var domain = string.Empty; - case NativeCode.CredentialUIReturnCodes.InvalidAccountName: // The username is not valid. - user = userID.ToString(); - domain = null; - break; + // Setup the flags and variables + var userPassword = new StringBuilder(NativeCode.CREDUI_MAX_PASSWORD_LENGTH); + var userID = new StringBuilder(NativeCode.CREDUI_MAX_USERNAME_LENGTH); + bool save = true; + NativeCode.CredentialUIFlags flags = NativeCode.CredentialUIFlags.CompleteUsername | NativeCode.CredentialUIFlags.ExcludeCertificates | NativeCode.CredentialUIFlags.GenericCredentials; - case NativeCode.CredentialUIReturnCodes.InsufficientBuffer: // One of the buffers is too small. - throw new OutOfMemoryException(); + // Prompt the user + NativeCode.CredentialUIReturnCodes promptResult = NativeCode.CredUICmdLinePromptForCredentials( + target, IntPtr.Zero, 0, + userID, NativeCode.CREDUI_MAX_USERNAME_LENGTH, + userPassword, NativeCode.CREDUI_MAX_PASSWORD_LENGTH, + ref save, flags); - case NativeCode.CredentialUIReturnCodes.InvalidParameter: // ulUserMaxChars or ulDomainMaxChars is zero OR userName, user, or domain is NULL. - throw new ArgumentException("userName"); - } - return new NetworkCredential(user, password, domain); - } - - /// - /// Saves the given Network Credential into Windows Credential store - /// - /// Name of the application/Url where the credential is used for - /// Credential to store - /// Credential type - /// True:Success, throw if failed - public static ICredential SaveCredentials(string target, NetworkCredential credential, CredentialType type = CredentialType.Generic, bool AllowNullPassword= false) + if (promptResult == NativeCode.CredentialUIReturnCodes.Cancelled) { - // Go ahead with what we have are stuff it into the CredMan structures. - var cred = new Credential(credential) - { - TargetName = target, - Persistance = Persistance.Enterprise, - Type = type - }; - if( cred.SaveCredential(AllowNullPassword)) - { - return cred; - } + // Zero the password buffer even on cancel + for (int i = 0; i < userPassword.Length; i++) + userPassword[i] = '\0'; return null; } - /// - /// Extract the stored credential from Windows Credential store - /// - /// Name of the application/Url where the credential is used for - /// Credential type - /// return the credentials if success, null if target not found, throw if failed to read stored credentials - public static NetworkCredential GetCredentials(string target, CredentialType type = CredentialType.Generic) + if (promptResult != NativeCode.CredentialUIReturnCodes.Success) { - return (GetICredential(target, type) as Credential)?.ToNetworkCredential(); + for (int i = 0; i < userPassword.Length; i++) + userPassword[i] = '\0'; + throw new CredentialAPIException($"Console credential prompt failed", "CredUICmdLinePromptForCredentials", (int)promptResult); } - /// - /// Enumerate the specified stored credentials in the Windows Credential store - /// - /// Name of the application or URL for which the credential is used - /// Return a if success, null if target not found, throw if failed to read stored credentials - public static List EnumerateCredentials(string target = null) + string password = userPassword.ToString(); + + // Zero the password StringBuilder after extracting the string + for (int i = 0; i < userPassword.Length; i++) + userPassword[i] = '\0'; + + var userBuilder = new StringBuilder(NativeCode.CREDUI_MAX_USERNAME_LENGTH); + var domainBuilder = new StringBuilder(NativeCode.CREDUI_MAX_USERNAME_LENGTH); + + var parseResult = NativeCode.CredUIParseUserName(userID.ToString(), userBuilder, NativeCode.CREDUI_MAX_USERNAME_LENGTH, domainBuilder, NativeCode.CREDUI_MAX_USERNAME_LENGTH); + switch (parseResult) { - return EnumerateICredentials(target)?.Select(c => c.ToNetworkCredential())?.ToList(); + case NativeCode.CredentialUIReturnCodes.Success: + user = userBuilder.ToString(); + domain = domainBuilder.ToString(); + break; + + case NativeCode.CredentialUIReturnCodes.InvalidAccountName: + user = userID.ToString(); + domain = null!; + break; + + case NativeCode.CredentialUIReturnCodes.InsufficientBuffer: + throw new CredentialAPIException("Buffer too small for parsed user name", "CredUIParseUserName", (int)parseResult); + + case NativeCode.CredentialUIReturnCodes.InvalidParameter: + throw new CredentialAPIException("Invalid parameter for user name parsing", "CredUIParseUserName", (int)parseResult); } + return new NetworkCredential(user, password, domain); + } - /// - /// Enumerate the specified stored credentials in the Windows Credential store - /// - /// Name of the application or URL for which the credential is used - /// Return a if success, null if target not found, throw if failed to read stored credentials - public static List EnumerateICredentials(string target = null) + /// + /// Saves the given Network Credential into Windows Credential store. + /// + /// Name of the application/URL where the credential is used for. + /// Credential to store. + /// Credential type. + /// If true, allows saving credentials with an empty password. + /// How the credential should be persisted. Defaults to LocalMachine (local only, no domain roaming). + /// The saved ICredential on success, or null on failure. + /// Thrown when target or credential is null. + /// Thrown when the Windows API call fails. + public static ICredential? SaveCredentials(string target, NetworkCredential credential, CredentialType type = CredentialType.Generic, bool AllowNullPassword = false, Persistence persistence = Persistence.LocalMachine) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + if (credential == null) throw new ArgumentNullException(nameof(credential)); + + var cred = new Credential(credential) + { + TargetName = target, + Persistence = persistence, + Type = type + }; + if (cred.SaveCredential(AllowNullPassword)) { - IntPtr pCredentials = IntPtr.Zero; - uint count = 0; + return cred; + } + return null; + } - var success = NativeCode.CredEnumerate(target, 0, out count, out pCredentials); + /// + /// Extract the stored credential from Windows Credential store. + /// + /// Name of the application/URL where the credential is used for. + /// Credential type. + /// The credentials if found, null if target not found. + /// Thrown when target is null. + /// Thrown when the Windows API call fails (other than not found). + public static NetworkCredential? GetCredentials(string target, CredentialType type = CredentialType.Generic) + { + if (target == null) throw new ArgumentNullException(nameof(target)); - if (!success) - { - var lastError = Marshal.GetLastWin32Error(); - if (lastError == (int)NativeCode.CredentialUIReturnCodes.NotFound) - { - return null; - } + return (GetICredential(target, type) as Credential)?.ToNetworkCredential(); + } - throw new CredentialAPIException($"Unable to Enumerate Credential store", "CredEnumerate", lastError); - } + /// + /// Enumerate the specified stored credentials in the Windows Credential store. + /// + /// Name of the application or URL for which the credential is used. Pass null to enumerate all credentials. + /// A list of credentials if found, null if none match. + public static List? EnumerateCredentials(string? target = null) + { + return EnumerateICredentials(target)?.Select(c => c.ToNetworkCredential())?.ToList(); + } - List networkCredentials = new List(); - Credential[] credentials; + /// + /// Enumerate the specified stored credentials in the Windows Credential store. + /// + /// Name of the application or URL for which the credential is used. Pass null to enumerate all credentials. + /// A list of ICredential objects if found, null if none match. + public static List? EnumerateICredentials(string? target = null) + { + var success = NativeCode.CredEnumerate(target, 0, out uint count, out IntPtr pCredentials); - try - { - using var criticalSection = new CriticalCredentialHandle(pCredentials); - credentials = criticalSection.EnumerateCredentials(count); - } - catch (Exception) + if (!success) + { + var lastError = Marshal.GetLastWin32Error(); + if (lastError == (int)NativeCode.CredentialUIReturnCodes.NotFound) { return null; } - return credentials.Select(c => c as ICredential).ToList(); + throw new CredentialAPIException(SR.UnableToEnumerateCredentials, "CredEnumerate", lastError); } - /// - /// Remove stored credentials from windows credential store - /// - /// Name of the application/Url where the credential is used for - /// True: Success, throw if failed - public static bool RemoveCredentials(string target, CredentialType type = CredentialType.Generic) + Credential[] credentials; + + try + { + using var criticalSection = new CriticalCredentialHandle(pCredentials, count); + credentials = criticalSection.EnumerateCredentials(count); + } + catch (CredentialAPIException) { - var cred = new Credential( - target, - type - ); - return cred.RemoveCredential(); + throw; } + catch (Exception ex) + { + throw new CredentialAPIException($"Failed to enumerate credentials: {ex.Message}", "CredEnumerate", 0); + } + + return credentials.Select(c => c as ICredential).ToList(); + } + + /// + /// Remove stored credentials from windows credential store. + /// + /// Name of the application/URL where the credential is used for. + /// Credential type. + /// True on success. + /// Thrown when target is null. + /// Thrown when the Windows API call fails. + public static bool RemoveCredentials(string target, CredentialType type = CredentialType.Generic) + { + if (target == null) throw new ArgumentNullException(nameof(target)); + + var cred = new Credential( + target, + type + ); + return cred.RemoveCredential(); + } + + /// + /// Generates a string that can be used for "Auth" headers in web requests, "username:password" encoded in Base64. + /// + /// The credential to encode. + /// Base64-encoded "username:password" string. + /// Thrown when cred is null. + public static string GetBasicAuthString(this NetworkCredential cred) + { + if (cred == null) throw new ArgumentNullException(nameof(cred)); - /// - /// Generates a string that can be used for "Auth" headers in web requests, "username:password" encoded in Base64 - /// - /// - /// - public static string GetBasicAuthString(this NetworkCredential cred) + byte[] credentialBuffer = new UTF8Encoding().GetBytes(cred.UserName + ":" + cred.Password); + try { - byte[] credentialBuffer = new UTF8Encoding().GetBytes(cred.UserName + ":" + cred.Password); return Convert.ToBase64String(credentialBuffer); } - - /// - /// Extract the stored credential from Windows Credential store - /// - /// Name of the application/Url where the credential is used for - /// Credential type - /// return the ICredential if success, null if target not found, throw if failed to read stored credentials - public static ICredential GetICredential(string target, CredentialType type = CredentialType.Generic) + finally { - IntPtr nCredPtr; + Array.Clear(credentialBuffer, 0, credentialBuffer.Length); + } + } - // Make the API call using the P/Invoke signature - bool isSuccess = NativeCode.CredRead(target, (UInt32)type, 0, out nCredPtr); - if (!isSuccess) - { - var lastError = Marshal.GetLastWin32Error(); - if (lastError == (int)NativeCode.CredentialUIReturnCodes.NotFound) - return null; - throw new CredentialAPIException($"Unable to Read Credential", "CredRead", lastError); - } + /// + /// Extract the stored credential from Windows Credential store as an ICredential. + /// + /// Name of the application/URL where the credential is used for. + /// Credential type. + /// The ICredential if found, null if target not found. + /// Thrown when target is null. + /// Thrown when the Windows API call fails (other than not found). + public static ICredential? GetICredential(string target, CredentialType type = CredentialType.Generic) + { + if (target == null) throw new ArgumentNullException(nameof(target)); - try - { - using var critCred = new CriticalCredentialHandle(nCredPtr); - Credential cred = critCred.GetCredential(); - return cred; - } - catch (Exception) - { + IntPtr nCredPtr; + + // Make the API call using the P/Invoke signature + bool isSuccess = NativeCode.CredRead(target, (UInt32)type, 0, out nCredPtr); + if (!isSuccess) + { + var lastError = Marshal.GetLastWin32Error(); + if (lastError == (int)NativeCode.CredentialUIReturnCodes.NotFound) return null; - } + throw new CredentialAPIException(SR.UnableToReadCredential, "CredRead", lastError); + } + + try + { + using var critCred = new CriticalCredentialHandle(nCredPtr); + Credential cred = critCred.GetCredential(); + return cred; + } + catch (Exception ex) + { + Debug.WriteLine($"Failed to read credential: {ex.GetType().Name}: {ex.Message}"); + return null; } } -} \ No newline at end of file +} diff --git a/src/AdysTech.CredentialManager/CriticalCredentialHandle.cs b/src/AdysTech.CredentialManager/CriticalCredentialHandle.cs index fb664ab..8eba1a2 100644 --- a/src/AdysTech.CredentialManager/CriticalCredentialHandle.cs +++ b/src/AdysTech.CredentialManager/CriticalCredentialHandle.cs @@ -1,77 +1,138 @@ -using Microsoft.Win32.SafeHandles; using System; using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; -namespace AdysTech.CredentialManager +namespace AdysTech.CredentialManager; + +/// +/// Safe handle wrapper for unmanaged credential memory allocated by the Windows Credential API. +/// Ensures native credential buffers (including sensitive credential blobs) are securely zeroed +/// before being freed. +/// +sealed class CriticalCredentialHandle : CriticalHandleZeroOrMinusOneIsInvalid { - sealed class CriticalCredentialHandle : CriticalHandleZeroOrMinusOneIsInvalid + private readonly uint _enumerationCount; + + /// + /// Creates a handle for a single credential (from CredRead). + /// + internal CriticalCredentialHandle(IntPtr preexistingHandle) + { + SetHandle(preexistingHandle); + _enumerationCount = 0; + } + + /// + /// Creates a handle for an enumeration result (from CredEnumerate). + /// + /// Pointer to the credential array. + /// Number of credentials in the array. + internal CriticalCredentialHandle(IntPtr preexistingHandle, uint enumerationCount) { - // Set the handle. - internal CriticalCredentialHandle(IntPtr preexistingHandle) + SetHandle(preexistingHandle); + _enumerationCount = enumerationCount; + } + + /// + /// Reads a single credential from the native handle. + /// + /// Thrown when the handle is invalid. + internal Credential GetCredential() + { + if (!IsInvalid) { - SetHandle (preexistingHandle); - } + var ncred = (NativeCode.NativeCredential)Marshal.PtrToStructure(handle, + typeof(NativeCode.NativeCredential))!; - internal Credential GetCredential() + return new Credential(ncred); + } + else { - if ( !IsInvalid ) - { - // Get the Credential from the mem location - NativeCode.NativeCredential ncred = (NativeCode.NativeCredential) Marshal.PtrToStructure (handle, - typeof (NativeCode.NativeCredential)); - - // Create a managed Credential type and fill it with data from the native counterpart. - Credential cred = new Credential (ncred); - - return cred; - } - else - { - throw new InvalidOperationException ("Invalid CriticalHandle!"); - } + throw new InvalidOperationException(SR.InvalidCriticalHandle); } + } - internal Credential[] EnumerateCredentials(uint size) + /// + /// Reads multiple credentials from the native handle (enumeration result). + /// + /// Number of credentials to read. + /// Thrown when the handle is invalid. + internal Credential[] EnumerateCredentials(uint size) + { + if (!IsInvalid) { - if (!IsInvalid) + var credentialArray = new Credential[size]; + + for (int i = 0; i < size; i++) { - var credentialArray = new Credential[size]; + IntPtr ptrPlc = Marshal.ReadIntPtr(handle, i * IntPtr.Size); - for (int i = 0; i < size; i++) - { - IntPtr ptrPlc = Marshal.ReadIntPtr(handle, i * IntPtr.Size); + var nc = (NativeCode.NativeCredential)Marshal.PtrToStructure(ptrPlc, + typeof(NativeCode.NativeCredential))!; - var nc = (NativeCode.NativeCredential)Marshal.PtrToStructure(ptrPlc, typeof(NativeCode.NativeCredential)); + credentialArray[i] = new Credential(nc); + } - credentialArray[i] = new Credential(nc); - } + return credentialArray; + } + else + { + throw new InvalidOperationException(SR.InvalidCriticalHandle); + } + } - return credentialArray; + /// + /// Securely zeros credential blobs in native memory, then frees the handle via CredFree. + /// Uses RtlZeroMemory via P/Invoke to prevent JIT dead store elimination. + /// + override protected bool ReleaseHandle() + { + if (!IsInvalid) + { + // Zero credential blobs before freeing — prevents sensitive data from + // lingering in freed memory pages. + ZeroCredentialBlobs(); + + NativeCode.CredFree(handle); + SetHandleAsInvalid(); + return true; + } + return false; + } + + private void ZeroCredentialBlobs() + { + try + { + if (_enumerationCount == 0) + { + // Single credential (from CredRead) + ZeroSingleCredentialBlob(handle); } else { - throw new InvalidOperationException("Invalid CriticalHandle!"); + // Enumeration (from CredEnumerate) — handle is array of pointers + for (uint i = 0; i < _enumerationCount; i++) + { + IntPtr ptr = Marshal.ReadIntPtr(handle, (int)(i * (uint)IntPtr.Size)); + ZeroSingleCredentialBlob(ptr); + } } } + catch + { + // Best-effort zeroing — don't prevent CredFree on failure + } + } - // Perform any specific actions to release the handle in the ReleaseHandle method. - // Often, you need to use Pinvoke to make a call into the Win32 API to release the - // handle. In this case, however, we can use the Marshal class to release the unmanaged memory. + private static void ZeroSingleCredentialBlob(IntPtr credPtr) + { + var ncred = (NativeCode.NativeCredential)Marshal.PtrToStructure(credPtr, + typeof(NativeCode.NativeCredential))!; - override protected bool ReleaseHandle() + if (ncred.CredentialBlob != IntPtr.Zero && ncred.CredentialBlobSize > 0) { - // If the handle was set, free it. Return success. - if ( !IsInvalid ) - { - // NOTE: We should also ZERO out the memory allocated to the handle, before free'ing it - // so there are no traces of the sensitive data left in memory. - NativeCode.CredFree (handle); - // Mark the handle as invalid for future users. - SetHandleAsInvalid (); - return true; - } - // Return false. - return false; + NativeCode.SecureZeroMemory(ncred.CredentialBlob, new UIntPtr(ncred.CredentialBlobSize)); } } } diff --git a/src/AdysTech.CredentialManager/ICredential.cs b/src/AdysTech.CredentialManager/ICredential.cs index d1432d9..d9f9b8b 100644 --- a/src/AdysTech.CredentialManager/ICredential.cs +++ b/src/AdysTech.CredentialManager/ICredential.cs @@ -1,27 +1,81 @@ -using System; +using System; using System.Collections.Generic; -using System.Linq; using System.Net; -using System.Runtime.Serialization; -using System.Text; -using System.Threading.Tasks; -namespace AdysTech.CredentialManager +namespace AdysTech.CredentialManager; + +/// +/// Represents a credential stored in the Windows Credential Store, exposing +/// properties beyond what provides (comments, +/// attributes, persistence type). +/// +public interface ICredential { - public interface ICredential - { - CredentialType Type { get; set; } - string TargetName { get; set; } - string Comment { get; set; } - DateTime LastWritten { get; set; } - string CredentialBlob { get; set; } - Persistance Persistance { get; set; } - IDictionary Attributes { get; set; } - string UserName { get; set; } - - NetworkCredential ToNetworkCredential(); - bool SaveCredential(bool AllowBlankPassword=false); - - bool RemoveCredential(); - } + /// + /// Gets or sets the type of the credential (Generic, Windows, or Certificate). + /// + CredentialType Type { get; set; } + + /// + /// Gets or sets the target name that identifies this credential in the store. + /// + string TargetName { get; set; } + + /// + /// Gets or sets an optional comment associated with the credential. + /// Comments are only accessible programmatically (not visible in Windows Credential Manager UI). + /// Maximum 256 bytes when encoded as Unicode. + /// + string? Comment { get; set; } + + /// + /// Gets or sets the time the credential was last written. + /// + DateTime LastWritten { get; set; } + + /// + /// Gets or sets the credential secret (typically a password or token). + /// Maximum bytes when encoded as Unicode. + /// + string? CredentialBlob { get; set; } + + /// + /// Gets or sets the persistence type (Session, LocalMachine, or Enterprise). + /// + Persistence Persistence { get; set; } + + /// + /// Gets or sets custom attributes associated with the credential. + /// Attributes are serialized as JSON. Each attribute value must be JSON-serializable + /// and the serialized form must not exceed 256 bytes. Maximum 64 attributes per credential. + /// When read back, attribute values are returned as + /// objects; use JsonElement.Deserialize<T>() to convert to the original type. + /// + IDictionary? Attributes { get; set; } + + /// + /// Gets or sets the user name associated with the credential. + /// + string? UserName { get; set; } + + /// + /// Converts this credential to a , parsing + /// domain\user format if present. + /// + NetworkCredential ToNetworkCredential(); + + /// + /// Saves this credential to the Windows Credential Store. + /// + /// If true, allows saving credentials with an empty password. + /// True if the credential was saved successfully. + /// Thrown when the Windows API call fails. + bool SaveCredential(bool AllowBlankPassword = false); + + /// + /// Removes this credential from the Windows Credential Store. + /// + /// True if the credential was removed successfully. + /// Thrown when the Windows API call fails. + bool RemoveCredential(); } diff --git a/src/AdysTech.CredentialManager/NativeCode.cs b/src/AdysTech.CredentialManager/NativeCode.cs index 7eb7c8c..ef93752 100644 --- a/src/AdysTech.CredentialManager/NativeCode.cs +++ b/src/AdysTech.CredentialManager/NativeCode.cs @@ -1,183 +1,232 @@ -using System; +using System; using System.Runtime.InteropServices; using System.Text; -namespace AdysTech.CredentialManager +namespace AdysTech.CredentialManager; + +internal static class NativeCode { - internal static class NativeCode + /// + /// Maximum username length for credential UI prompts (CREDUI_MAX_USERNAME_LENGTH). + /// + internal const int CREDUI_MAX_USERNAME_LENGTH = 513; + + /// + /// Maximum password length for credential UI prompts (CREDUI_MAX_PASSWORD_LENGTH). + /// + internal const int CREDUI_MAX_PASSWORD_LENGTH = 256; + + [Flags] + internal enum CredentialUIFlags { - [Flags] - internal enum CredentialUIFlags - { - IncorrectPassword = 0x1, - DoNotPersist = 0x2, - RequestAdministrator = 0x4, - ExcludeCertificates = 0x8, - RequireCertificate = 0x10, - ShowSaveCheckBox = 0x40, - AlwaysShowUi = 0x80, - RequireSmartcard = 0x100, - PasswordOnlyOk = 0x200, - ValidateUsername = 0x400, - CompleteUsername = 0x800, - Persist = 0x1000, - ServerCredential = 0x4000, - ExpectConfirmation = 0x20000, - GenericCredentials = 0x40000, - UsernameTargetCredentials = 0x80000, - KeepUsername = 0x100000 - } - - internal enum CredentialUIReturnCodes : uint - { - Success = 0, - Cancelled = 1223, - NoSuchLogonSession = 1312, - NotFound = 1168, - InvalidAccountName = 1315, - InsufficientBuffer = 122, - InvalidParameter = 87, - InvalidFlags = 1004 - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - internal struct CredentialUIInfo - { - public int cbSize; - public IntPtr hwndParent; - public string pszMessageText; - public string pszCaptionText; - public IntPtr hbmBanner; - } - - [DllImport("credui", CharSet = CharSet.Unicode)] - internal static extern CredentialUIReturnCodes CredUIPromptForCredentials(ref CredentialUIInfo creditUR, - string targetName, - IntPtr reserved1, - int iError, - StringBuilder userName, - int maxUserName, - StringBuilder password, - int maxPassword, - [MarshalAs(UnmanagedType.Bool)] ref bool pfSave, - CredentialUIFlags flags); - - [DllImport("credui.dll", EntryPoint = "CredUIParseUserNameW", CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern CredentialUIReturnCodes CredUIParseUserName( - string userName, - StringBuilder user, - int userMaxChars, - StringBuilder domain, - int domainMaxChars); - - [DllImport("credui.dll", CharSet = CharSet.Unicode, SetLastError = true)] - public static extern bool CredPackAuthenticationBuffer( - Int32 dwFlags, - StringBuilder pszUserName, - StringBuilder pszPassword, - IntPtr pPackedCredentials, - ref Int32 pcbPackedCredentials - ); - - [DllImport("credui.dll", CharSet = CharSet.Unicode)] - internal static extern bool CredUnPackAuthenticationBuffer(int dwFlags, - IntPtr pAuthBuffer, - uint cbAuthBuffer, - StringBuilder pszUserName, - ref int pcchMaxUserName, - StringBuilder pszDomainName, - ref int pcchMaxDomainame, - StringBuilder pszPassword, - ref int pcchMaxPassword); - - [DllImport("credui.dll", EntryPoint = "CredUIPromptForWindowsCredentialsW", CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern int CredUIPromptForWindowsCredentials(ref CredentialUIInfo creditUR, - int authError, - ref uint authPackage, - IntPtr inAuthBuffer, - int inAuthBufferSize, - out IntPtr refOutAuthBuffer, - out uint refOutAuthBufferSize, - ref bool fSave, - PromptForWindowsCredentialsFlags flags); - - [DllImport("credui", CharSet = CharSet.Unicode)] - internal static extern CredentialUIReturnCodes CredUICmdLinePromptForCredentials( - string targetName, - IntPtr reserved1, - int iError, - StringBuilder userName, - int maxUserName, - StringBuilder password, - int maxPassword, - [MarshalAs(UnmanagedType.Bool)] ref bool pfSave, - CredentialUIFlags flags); - - - [Flags] - internal enum PromptForWindowsCredentialsFlags : uint - { - GenericCredentials = 0x1, - ShowCheckbox = 0x2, - AuthpackageOnly = 0x10, - InCredOnly = 0x20, - EnumerateAdmins = 0x100, - EnumerateCurrentUser = 0x200, - SecurePrompt = 0x1000, - Pack32Wow = 0x10000000 - } - - - - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - internal struct NativeCredential - { - public UInt32 Flags; - public UInt32 Type; - [MarshalAs(UnmanagedType.LPWStr)] - public string TargetName; - [MarshalAs(UnmanagedType.LPWStr)] - public string Comment; - public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; - public UInt32 CredentialBlobSize; - public IntPtr CredentialBlob; - public UInt32 Persist; - public UInt32 AttributeCount; - public IntPtr Attributes; - [MarshalAs(UnmanagedType.LPWStr)] - public string TargetAlias; - [MarshalAs(UnmanagedType.LPWStr)] - public string UserName; - - } - - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] - internal struct NativeCredentialAttribute - { - [MarshalAs(UnmanagedType.LPWStr)] - public string Keyword; - public UInt32 Flags; - public UInt32 ValueSize; - public IntPtr Value; - } - - [DllImport("Advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern bool CredDelete([MarshalAs(UnmanagedType.LPWStr)] string target, uint type, int reservedFlag); - - [DllImport("Advapi32.dll", EntryPoint = "CredEnumerateW", CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern bool CredEnumerate([MarshalAs(UnmanagedType.LPWStr)] string target, UInt32 flags, out UInt32 count, out IntPtr credentialsPtr); - - [DllImport("Advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern bool CredRead([MarshalAs(UnmanagedType.LPWStr)]string target, uint type, int reservedFlag, out IntPtr CredentialPtr); - - [DllImport("Advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] - internal static extern bool CredWrite([In] ref NativeCredential userCredential, [In] UInt32 flags); - - [DllImport("Advapi32.dll", EntryPoint = "CredFree", SetLastError = true)] - internal static extern bool CredFree([In] IntPtr cred); - - [DllImport("ole32.dll", EntryPoint = "CoTaskMemFree", SetLastError = true)] - internal static extern void CoTaskMemFree(IntPtr buffer); + IncorrectPassword = 0x1, + DoNotPersist = 0x2, + RequestAdministrator = 0x4, + ExcludeCertificates = 0x8, + RequireCertificate = 0x10, + ShowSaveCheckBox = 0x40, + AlwaysShowUi = 0x80, + RequireSmartcard = 0x100, + PasswordOnlyOk = 0x200, + ValidateUsername = 0x400, + CompleteUsername = 0x800, + Persist = 0x1000, + ServerCredential = 0x4000, + ExpectConfirmation = 0x20000, + GenericCredentials = 0x40000, + UsernameTargetCredentials = 0x80000, + KeepUsername = 0x100000 } + + internal enum CredentialUIReturnCodes : uint + { + Success = 0, + Cancelled = 1223, + NoSuchLogonSession = 1312, + NotFound = 1168, + InvalidAccountName = 1315, + InsufficientBuffer = 122, + InvalidParameter = 87, + InvalidFlags = 1004 + } + + /// + /// Contains information about the appearance and behavior of a credential dialog box. + /// Corresponds to the native CREDUI_INFO structure. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct CredentialUIInfo + { + /// Size of this structure, in bytes. + public int cbSize; + /// Handle to the parent window of the dialog box. + public IntPtr hwndParent; + /// Message to display in the dialog box. + public string pszMessageText; + /// Title for the dialog box. + public string pszCaptionText; + /// Handle to a bitmap to display in the dialog box. + public IntPtr hbmBanner; + } + + /// + /// Represents a credential stored in the Windows Credential Store. + /// Corresponds to the native CREDENTIALW structure. + /// + /// + /// See: https://docs.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentialw + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct NativeCredential + { + /// Bit member that identifies characteristics of the credential. + public UInt32 Flags; + /// Type of credential (Generic, Windows, Certificate). + public UInt32 Type; + /// Name of the credential target. + [MarshalAs(UnmanagedType.LPWStr)] + public string TargetName; + /// Comment associated with the credential (max 256 bytes). + [MarshalAs(UnmanagedType.LPWStr)] + public string? Comment; + /// Time the credential was last written, as a FILETIME. + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + /// Size, in bytes, of the CredentialBlob member. + public UInt32 CredentialBlobSize; + /// Pointer to the credential data (e.g. password). + public IntPtr CredentialBlob; + /// Persistence type (Session, LocalMachine, Enterprise). + public UInt32 Persist; + /// Number of credential attributes. + public UInt32 AttributeCount; + /// Pointer to an array of CREDENTIAL_ATTRIBUTE structures. + public IntPtr Attributes; + /// Alias for the target name (reserved, typically null). + [MarshalAs(UnmanagedType.LPWStr)] + public string? TargetAlias; + /// User name associated with the credential. + [MarshalAs(UnmanagedType.LPWStr)] + public string? UserName; + } + + /// + /// Represents a key-value attribute attached to a credential. + /// Corresponds to the native CREDENTIAL_ATTRIBUTE structure. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct NativeCredentialAttribute + { + /// Name of the attribute (max 256 characters). + [MarshalAs(UnmanagedType.LPWStr)] + public string Keyword; + /// Reserved flags (must be zero). + public UInt32 Flags; + /// Size, in bytes, of the Value member. + public UInt32 ValueSize; + /// Pointer to the attribute value data. + public IntPtr Value; + } + + [Flags] + internal enum PromptForWindowsCredentialsFlags : uint + { + GenericCredentials = 0x1, + ShowCheckbox = 0x2, + AuthpackageOnly = 0x10, + InCredOnly = 0x20, + EnumerateAdmins = 0x100, + EnumerateCurrentUser = 0x200, + SecurePrompt = 0x1000, + Pack32Wow = 0x10000000 + } + + // --- credui.dll imports --- + + [DllImport("credui.dll", EntryPoint = "CredUIParseUserNameW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern CredentialUIReturnCodes CredUIParseUserName( + string userName, + StringBuilder user, + int userMaxChars, + StringBuilder domain, + int domainMaxChars); + + [DllImport("credui.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool CredPackAuthenticationBuffer( + Int32 dwFlags, + StringBuilder pszUserName, + StringBuilder pszPassword, + IntPtr pPackedCredentials, + ref Int32 pcbPackedCredentials + ); + + [DllImport("credui.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern bool CredUnPackAuthenticationBuffer(int dwFlags, + IntPtr pAuthBuffer, + uint cbAuthBuffer, + StringBuilder pszUserName, + ref int pcchMaxUserName, + StringBuilder pszDomainName, + ref int pcchMaxDomainame, + StringBuilder pszPassword, + ref int pcchMaxPassword); + + [DllImport("credui.dll", EntryPoint = "CredUIPromptForWindowsCredentialsW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern int CredUIPromptForWindowsCredentials(ref CredentialUIInfo creditUR, + int authError, + ref uint authPackage, + IntPtr inAuthBuffer, + int inAuthBufferSize, + out IntPtr refOutAuthBuffer, + out uint refOutAuthBufferSize, + ref bool fSave, + PromptForWindowsCredentialsFlags flags); + + [DllImport("credui.dll", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern CredentialUIReturnCodes CredUICmdLinePromptForCredentials( + string targetName, + IntPtr reserved1, + int iError, + StringBuilder userName, + int maxUserName, + StringBuilder password, + int maxPassword, + [MarshalAs(UnmanagedType.Bool)] ref bool pfSave, + CredentialUIFlags flags); + + // --- Advapi32.dll imports --- + + [DllImport("Advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern bool CredDelete([MarshalAs(UnmanagedType.LPWStr)] string target, uint type, int reservedFlag); + + [DllImport("Advapi32.dll", EntryPoint = "CredEnumerateW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern bool CredEnumerate([MarshalAs(UnmanagedType.LPWStr)] string? target, UInt32 flags, out UInt32 count, out IntPtr credentialsPtr); + + [DllImport("Advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern bool CredRead([MarshalAs(UnmanagedType.LPWStr)] string target, uint type, int reservedFlag, out IntPtr CredentialPtr); + + [DllImport("Advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern bool CredWrite([In] ref NativeCredential userCredential, [In] UInt32 flags); + + [DllImport("Advapi32.dll", EntryPoint = "CredFree", SetLastError = true)] + internal static extern bool CredFree([In] IntPtr cred); + + // --- ole32.dll imports --- + + [DllImport("ole32.dll", EntryPoint = "CoTaskMemFree", SetLastError = true)] + internal static extern void CoTaskMemFree(IntPtr buffer); + + // --- kernel32.dll imports --- + + /// + /// Fills a block of memory with zeros in a way that cannot be optimized away by the JIT. + /// + /// + /// Named SecureZeroMemory for intent clarity, but the actual entry point is + /// RtlZeroMemory. The native SecureZeroMemory is a compiler intrinsic + /// (volatile write loop) that cannot be called via P/Invoke. However, since P/Invoke + /// calls cross the managed/native boundary, the JIT cannot optimize away the write — + /// giving us the same dead-store-elimination resistance that the intrinsic provides. + /// + [DllImport("kernel32.dll", EntryPoint = "RtlZeroMemory", SetLastError = false)] + internal static extern void SecureZeroMemory(IntPtr dest, UIntPtr size); } diff --git a/src/AdysTech.CredentialManager/Properties/AssemblyInfo.cs b/src/AdysTech.CredentialManager/Properties/AssemblyInfo.cs index d8e6cb9..f10254c 100644 --- a/src/AdysTech.CredentialManager/Properties/AssemblyInfo.cs +++ b/src/AdysTech.CredentialManager/Properties/AssemblyInfo.cs @@ -1,3 +1,3 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("CredentialManager.Test, PublicKey=00240000048000009400000006020000002400005253413100040000010001000100220309e07b2bcbc4cc402fd9cb14db50bf946aa993cc7617a55be474f1364d4cc60bd09325621fafb12f46ba26cf5bd39b8d2aa58ff1c4ff096d827528209527afd63be3b9aa5cce2db016dd68b56f4839e06afa27b9fef6c018783e197ee77901ae401fe347e44231ee03abfb6595d6b12a19f21d24ae199763cea49fb0")] \ No newline at end of file +[assembly: InternalsVisibleTo("CredentialManager.Test")] diff --git a/src/AdysTech.CredentialManager/PublicEnums.cs b/src/AdysTech.CredentialManager/PublicEnums.cs index 1e7a699..2e119bd 100644 --- a/src/AdysTech.CredentialManager/PublicEnums.cs +++ b/src/AdysTech.CredentialManager/PublicEnums.cs @@ -1,16 +1,51 @@ -namespace AdysTech.CredentialManager +namespace AdysTech.CredentialManager; + +/// +/// Specifies the type of credential stored in the Windows Credential Store. +/// +public enum CredentialType : uint { - public enum CredentialType : uint - { - Generic = 1, - Windows = 2, - Certificate = 3 - } + /// + /// Generic credential, not associated with any particular authentication package. + /// This is the most common type for application-managed credentials. + /// + Generic = 1, + + /// + /// Windows domain credential (CRED_TYPE_DOMAIN_PASSWORD). + /// The credential blob can only be read by the authentication packages. + /// + Windows = 2, + + /// + /// Certificate-based credential (CRED_TYPE_CERTIFICATE). + /// + Certificate = 3 +} + +/// +/// Specifies how a credential is persisted in the Windows Credential Store. +/// +public enum Persistence : uint +{ + /// + /// The credential persists for the life of the logon session. + /// It is not visible to other logon sessions of the same user and is + /// not persisted across reboots. + /// + Session = 1, + + /// + /// The credential persists on the local machine. It is not visible to + /// other machines and is not replicated to domain controllers. + /// This is the recommended default for most applications. + /// + LocalMachine = 2, - public enum Persistance : uint - { - Session = 1, - LocalMachine = 2, - Enterprise = 3 - } + /// + /// The credential persists on the local machine and is replicated to + /// Active Directory domain controllers. Use with caution — this + /// broadens the attack surface by syncing credentials across the domain. + /// + Enterprise = 3 } diff --git a/src/AdysTech.CredentialManager/Resources/SR.cs b/src/AdysTech.CredentialManager/Resources/SR.cs new file mode 100644 index 0000000..0e01170 --- /dev/null +++ b/src/AdysTech.CredentialManager/Resources/SR.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using System.Resources; + +namespace AdysTech.CredentialManager; + +/// +/// Internal string resource accessor. Loads localized strings from embedded .resx resources +/// based on . +/// +internal static class SR +{ + private static readonly ResourceManager s_resourceManager = + new ResourceManager("AdysTech.CredentialManager.Resources.Strings", typeof(SR).Assembly); + + internal static string GetString(string name) => + s_resourceManager.GetString(name, CultureInfo.CurrentUICulture) ?? name; + + internal static string Format(string name, params object[] args) => + string.Format(CultureInfo.CurrentCulture, GetString(name), args); + + // --- Credential validation --- + + internal static string CommentTooLong => GetString(nameof(CommentTooLong)); + internal static string TargetNameNullOrEmpty => GetString(nameof(TargetNameNullOrEmpty)); + internal static string TargetNameTooLong => GetString(nameof(TargetNameTooLong)); + internal static string CredentialBlobNullOrEmpty => GetString(nameof(CredentialBlobNullOrEmpty)); + internal static string CredentialBlobTooLong => GetString(nameof(CredentialBlobTooLong)); + internal static string TooManyAttributes => GetString(nameof(TooManyAttributes)); + internal static string AttributeNameTooLong => GetString(nameof(AttributeNameTooLong)); + internal static string AttributeValueNull => GetString(nameof(AttributeValueNull)); + internal static string AttributeValueTooLong => GetString(nameof(AttributeValueTooLong)); + + // --- API errors --- + + internal static string UnableToSaveCredential => GetString(nameof(UnableToSaveCredential)); + internal static string UnableToParseUserName => GetString(nameof(UnableToParseUserName)); + internal static string UnableToDeleteCredential => GetString(nameof(UnableToDeleteCredential)); + internal static string UnableToEnumerateCredentials => GetString(nameof(UnableToEnumerateCredentials)); + internal static string UnableToReadCredential => GetString(nameof(UnableToReadCredential)); + + // --- Handle errors --- + + internal static string InvalidCriticalHandle => GetString(nameof(InvalidCriticalHandle)); +} diff --git a/src/AdysTech.CredentialManager/Resources/Strings.de.resx b/src/AdysTech.CredentialManager/Resources/Strings.de.resx new file mode 100644 index 0000000..4c9e6a8 --- /dev/null +++ b/src/AdysTech.CredentialManager/Resources/Strings.de.resx @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Kommentar darf nicht mehr als 256 Bytes lang sein + + + TargetName darf nicht null oder leer sein + + + TargetName darf nicht mehr als 32 kB lang sein + + + CredentialBlob darf nicht null oder leer sein + + + Anmeldedaten dürfen nicht mehr als {0} Bytes lang sein + + + Anmeldedaten dürfen nicht mehr als 64 Attribute haben + + + Attributnamen dürfen nicht mehr als 256 Bytes lang sein. Fehler bei Schlüssel: {0} + + + Attributwert darf nicht null sein. Fehler bei Schlüssel: {0} + + + Attributwerte dürfen nach der Serialisierung nicht mehr als 256 Bytes lang sein. Fehler bei Wert für Schlüssel: {0} + + + Anmeldedaten konnten nicht gespeichert werden + + + Benutzername konnte nicht analysiert werden + + + Anmeldedaten konnten nicht gelöscht werden + + + Anmeldedatenspeicher konnte nicht aufgelistet werden + + + Anmeldedaten konnten nicht gelesen werden + + + Ungültiges CriticalHandle! + + diff --git a/src/AdysTech.CredentialManager/Resources/Strings.es.resx b/src/AdysTech.CredentialManager/Resources/Strings.es.resx new file mode 100644 index 0000000..2b7db1c --- /dev/null +++ b/src/AdysTech.CredentialManager/Resources/Strings.es.resx @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + El comentario no puede superar los 256 bytes + + + TargetName no puede ser nulo o vacío + + + TargetName no puede superar los 32 kB + + + CredentialBlob no puede ser nulo o vacío + + + Las credenciales no pueden superar los {0} bytes + + + Las credenciales no pueden tener más de 64 atributos + + + Los nombres de atributo no pueden superar los 256 bytes. Error con la clave: {0} + + + El valor del atributo no puede ser nulo. Error con la clave: {0} + + + Los valores de atributo no pueden superar los 256 bytes después de la serialización. Error con el valor para la clave: {0} + + + No se pudieron guardar las credenciales + + + No se pudo analizar el nombre de usuario + + + No se pudieron eliminar las credenciales + + + No se pudo enumerar el almacén de credenciales + + + No se pudieron leer las credenciales + + + ¡CriticalHandle no válido! + + diff --git a/src/AdysTech.CredentialManager/Resources/Strings.fr.resx b/src/AdysTech.CredentialManager/Resources/Strings.fr.resx new file mode 100644 index 0000000..758734d --- /dev/null +++ b/src/AdysTech.CredentialManager/Resources/Strings.fr.resx @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Le commentaire ne peut pas dépasser 256 octets + + + TargetName ne peut pas être nul ou vide + + + TargetName ne peut pas dépasser 32 ko + + + CredentialBlob ne peut pas être nul ou vide + + + Les informations d'identification ne peuvent pas dépasser {0} octets + + + Les informations d'identification ne peuvent pas avoir plus de 64 attributs + + + Les noms d'attribut ne peuvent pas dépasser 256 octets. Erreur avec la clé : {0} + + + La valeur de l'attribut ne peut pas être nulle. Erreur avec la clé : {0} + + + Les valeurs d'attribut ne peuvent pas dépasser 256 octets après sérialisation. Erreur avec la valeur pour la clé : {0} + + + Impossible d'enregistrer les informations d'identification + + + Impossible d'analyser le nom d'utilisateur + + + Impossible de supprimer les informations d'identification + + + Impossible d'énumérer le magasin d'informations d'identification + + + Impossible de lire les informations d'identification + + + CriticalHandle non valide ! + + diff --git a/src/AdysTech.CredentialManager/Resources/Strings.it.resx b/src/AdysTech.CredentialManager/Resources/Strings.it.resx new file mode 100644 index 0000000..9802e76 --- /dev/null +++ b/src/AdysTech.CredentialManager/Resources/Strings.it.resx @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Il commento non può superare i 256 byte + + + TargetName non può essere nullo o vuoto + + + TargetName non può superare i 32 kB + + + CredentialBlob non può essere nullo o vuoto + + + Le credenziali non possono superare i {0} byte + + + Le credenziali non possono avere più di 64 attributi + + + I nomi degli attributi non possono superare i 256 byte. Errore con la chiave: {0} + + + Il valore dell'attributo non può essere nullo. Errore con la chiave: {0} + + + I valori degli attributi non possono superare i 256 byte dopo la serializzazione. Errore con il valore per la chiave: {0} + + + Impossibile salvare le credenziali + + + Impossibile analizzare il nome utente + + + Impossibile eliminare le credenziali + + + Impossibile enumerare l'archivio delle credenziali + + + Impossibile leggere le credenziali + + + CriticalHandle non valido! + + diff --git a/src/AdysTech.CredentialManager/Resources/Strings.resx b/src/AdysTech.CredentialManager/Resources/Strings.resx new file mode 100644 index 0000000..5137f6f --- /dev/null +++ b/src/AdysTech.CredentialManager/Resources/Strings.resx @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Comment cannot be more than 256 bytes long + + + TargetName cannot be null or empty + + + TargetName cannot be more than 32 kB + + + CredentialBlob cannot be null or empty + + + Credential cannot be more than {0} bytes long + + + Credentials cannot have more than 64 attributes + + + Attribute names cannot be more than 256 bytes long. Error with key: {0} + + + Attribute value cannot be null. Error with key: {0} + + + Attribute values cannot be more than 256 bytes long after serialization. Error with value for key: {0} + + + Unable to save credential + + + Unable to parse user name + + + Unable to delete credential + + + Unable to enumerate credential store + + + Unable to read credential + + + Invalid CriticalHandle! + + diff --git a/tests/CredentialManager.Test/CredentialManager.Test.csproj b/tests/CredentialManager.Test/CredentialManager.Test.csproj index 2e70fb5..c56bb94 100644 --- a/tests/CredentialManager.Test/CredentialManager.Test.csproj +++ b/tests/CredentialManager.Test/CredentialManager.Test.csproj @@ -1,14 +1,18 @@ - + + + + net8.0 + - - - - + + + + diff --git a/tests/CredentialManager.Test/CredentialManagerTest.cs b/tests/CredentialManager.Test/CredentialManagerTest.cs index c618916..f146782 100644 --- a/tests/CredentialManager.Test/CredentialManagerTest.cs +++ b/tests/CredentialManager.Test/CredentialManagerTest.cs @@ -4,480 +4,514 @@ using System.Net; using System.Diagnostics; using System.Collections.Generic; +using System.Text.Json; -namespace CredentialManagerTest +#pragma warning disable SCS0015 // Hardcoded passwords — test credentials are intentional + +namespace CredentialManagerTest; + +[TestClass] +public class CredentialManagerTest { - [TestClass] - public class CredentialManagerTest - { - private const string uName = "ZYYM3ufm3kFY9ZJZUAqYFQfzxcRc9rzdYxUwqEhBqqdrHttrh"; - private const string pwd = "5NJuqKfJBtAZYYM3ufm3kFY9ZJZUAqYFQfzxcRc9rzdYxUwqEhBqqdrHttrhcvnnDPFHEn3L"; - private const string domain = "AdysTech.com"; + private const string uName = "ZYYM3ufm3kFY9ZJZUAqYFQfzxcRc9rzdYxUwqEhBqqdrHttrh"; + private const string pwd = "5NJuqKfJBtAZYYM3ufm3kFY9ZJZUAqYFQfzxcRc9rzdYxUwqEhBqqdrHttrhcvnnDPFHEn3L"; + private const string domain = "test.example.com"; - [Serializable] - struct SampleAttribute - { -#pragma warning disable CA2235 // Mark all non-serializable fields - public string role; - public DateTime created; -#pragma warning restore CA2235 // Mark all non-serializable fields - } + private static readonly JsonSerializerOptions s_jsonOptions = new() { IncludeFields = true }; - [TestMethod, TestCategory("AppVeyor")] - public void TestSaveCredentials() + /// + /// All target names created by tests. Cleaned up in TestCleanup to ensure + /// tests are self-contained and don't depend on execution order. + /// + private static readonly string[] TestTargets = + { + "TestSystem", "TestCredWithoutUserName", "TestCredWithPasswordSingleCharacter", + "TestSystem_comment", "TestSystem_Attributes", "TestSystem_LongComment", + "TestSystem_LongPassword", "TestSystem_nullPwd", "TestWindowsCredential", + "TestDeletingWindowsCredential", "TestSystem_DefaultPersist", + "TestSystem_Session", "TestSystem_Enterprise", "TestSystem_JsonRoundTrip_Str", + "TestSystem_JsonRoundTrip_Num", "TestSystem_JsonRoundTrip_Struct", + "TestSystem1" + }; + + [TestCleanup] + public void TestCleanup() + { + foreach (var target in TestTargets) { - try - { - var cred = new NetworkCredential(uName, pwd, domain); - Assert.IsNotNull(CredentialManager.SaveCredentials("TestSystem", cred), "SaveCredential failed"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } + try { CredentialManager.RemoveCredentials(target); } catch (CredentialAPIException) { /* may not exist */ } + try { CredentialManager.RemoveCredentials(target, CredentialType.Windows); } catch (CredentialAPIException) { /* may not exist */ } } + } - [TestMethod, TestCategory("AppVeyor")] - public void TestGetCredentials() - { + struct SampleAttribute + { + public string role; + public DateTime created; + } - try - { - var cred = CredentialManager.GetCredentials("TestSystem"); - Assert.IsNotNull(cred, "GetCredential failed"); - Assert.IsTrue(uName == cred.UserName && pwd == cred.Password && domain == cred.Domain, "Saved and retrieved data doesn't match"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + // ------------------------------------------------------------------- + // Basic credential operations + // ------------------------------------------------------------------- + [TestMethod, TestCategory("CI")] + public void TestSaveCredentials() + { + var cred = new NetworkCredential(uName, pwd, domain); + Assert.IsNotNull(CredentialManager.SaveCredentials("TestSystem", cred), "SaveCredential failed"); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestICredential_Comment() - { - try - { - var cred = (new NetworkCredential(uName, pwd, domain)).ToICredential(); - cred.TargetName = "TestSystem_comment"; - cred.Comment = "This comment is only visible via API, not in Windows UI"; - Assert.IsTrue(cred.SaveCredential(), "SaveCredential on ICredential failed"); - - var cred1 = CredentialManager.GetICredential(cred.TargetName); - Assert.IsNotNull(cred, "GetICredential failed"); - Assert.IsTrue(cred1.UserName == cred.UserName && cred1.CredentialBlob == cred.CredentialBlob && cred1.Comment == cred.Comment, "Saved and retrieved data doesn't match"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + [TestMethod, TestCategory("CI")] + public void TestGetCredentials() + { + // Self-contained: save first, then retrieve + var cred = new NetworkCredential(uName, pwd, domain); + CredentialManager.SaveCredentials("TestSystem", cred); - [TestMethod, TestCategory("AppVeyor")] - public void TestICredential_Attributes() - { + var retrieved = CredentialManager.GetCredentials("TestSystem"); + Assert.IsNotNull(retrieved, "GetCredential failed"); + Assert.IsTrue(string.Equals(uName, retrieved.UserName, StringComparison.Ordinal) && string.Equals(pwd, retrieved.Password, StringComparison.Ordinal) && string.Equals(domain, retrieved.Domain, StringComparison.Ordinal), "Saved and retrieved data doesn't match"); + } - try - { - var cred = (new NetworkCredential(uName, pwd, domain)).ToICredential(); - cred.TargetName = "TestSystem_Attributes"; - cred.Attributes = new Dictionary(); - - var sample = new SampleAttribute() { role = "regular", created = DateTime.UtcNow }; - cred.Attributes.Add("sampleAttribute", sample); - - Assert.IsTrue(cred.SaveCredential(), "SaveCredential on ICredential failed"); - var cred1 = CredentialManager.GetICredential(cred.TargetName); - Assert.IsNotNull(cred, "GetICredential failed"); - Assert.IsTrue(cred1.UserName == cred.UserName && cred1.CredentialBlob == cred.CredentialBlob && cred1.Attributes?.Count == cred.Attributes?.Count, "Saved and retrieved data doesn't match"); - //Assert.IsTrue(cred.Attributes.All(a=>a.Value == cred1.Attributes[a.Key]), "Saved and retrieved data doesn't match"); - Assert.IsTrue(((SampleAttribute)cred1.Attributes["sampleAttribute"]).role == sample.role, "Saved and retrieved data doesn't match"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + [TestMethod, TestCategory("CI")] + public void TestGetCredentials_NonExistantCredential() + { + var cred = CredentialManager.GetCredentials("TotallyNonExistingTarget"); + Assert.IsNull(cred); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestEnumerateCredentials() - { - try - { - var creds = CredentialManager.EnumerateCredentials(); - Assert.IsNotNull(creds, "EnumerateCredentials failed"); - Assert.IsTrue(creds?.Count > 0, "No credentials stored in the system"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + [TestMethod, TestCategory("CI")] + public void TestGetCredentials_NullUserName() + { + var cred = new NetworkCredential(string.Empty, "P@$$w0rd"); + CredentialManager.SaveCredentials("TestCredWithoutUserName", cred); + var cred1 = CredentialManager.GetCredentials("TestCredWithoutUserName"); + Assert.IsTrue(string.Equals(cred1!.UserName, cred.UserName, StringComparison.Ordinal) && string.Equals(cred1.Password, cred.Password, StringComparison.Ordinal) && string.Equals(cred1.Domain, cred.Domain, StringComparison.Ordinal), "Saved and retrieved data doesn't match"); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestEnumerateICredentials() - { - try - { - var creds = CredentialManager.EnumerateICredentials(); - Assert.IsNotNull(creds, "EnumerateICredentials failed"); - Assert.IsTrue(creds?.Count > 0, "No credentials stored in the system"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + [TestMethod, TestCategory("CI")] + public void TestGetCredentials_PasswordLengthOne() + { + var cred = new NetworkCredential("admin", "P"); + CredentialManager.SaveCredentials("TestCredWithPasswordSingleCharacter", cred); + var cred1 = CredentialManager.GetCredentials("TestCredWithPasswordSingleCharacter"); + Assert.IsTrue(string.Equals(cred1!.Password, cred.Password, StringComparison.Ordinal), "Saved and retrieved password doesn't match"); + } - /// - /// This test assumes you have a Generic Credential for https://github.com stored on your system. - /// - public void TestEnumerateCredentialWithTarget() - { - try - { - var creds = CredentialManager.EnumerateCredentials(@"git:https://github.com"); - Assert.IsNotNull(creds, "EnumerateCredentials failed"); - Assert.IsTrue(creds?.Count > 0, "No credentials stored in the system"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + // ------------------------------------------------------------------- + // ICredential: comments and attributes + // ------------------------------------------------------------------- - [TestMethod] - public void TestPromptForCredentials() - { + [TestMethod, TestCategory("CI")] + public void TestICredential_Comment() + { + var cred = (new NetworkCredential(uName, pwd, domain)).ToICredential()!; + cred.TargetName = "TestSystem_comment"; + cred.Comment = "This comment is only visible via API, not in Windows UI"; + Assert.IsTrue(cred.SaveCredential(), "SaveCredential on ICredential failed"); + + var cred1 = CredentialManager.GetICredential(cred.TargetName); + Assert.IsNotNull(cred1, "GetICredential failed"); + Assert.IsTrue(string.Equals(cred1.UserName, cred.UserName, StringComparison.Ordinal) && string.Equals(cred1.CredentialBlob, cred.CredentialBlob, StringComparison.Ordinal) && string.Equals(cred1.Comment, cred.Comment, StringComparison.Ordinal), "Saved and retrieved data doesn't match"); + } - try - { - bool save = false; - Assert.IsNotNull(CredentialManager.PromptForCredentials("Some Webservice", ref save, "Please provide credentials", "Credentials for service"), "PromptForCredentials failed"); - - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + [TestMethod, TestCategory("CI")] + public void TestICredential_Attributes() + { + var cred = (new NetworkCredential(uName, pwd, domain)).ToICredential()!; + cred.TargetName = "TestSystem_Attributes"; + cred.Attributes = new Dictionary(StringComparer.Ordinal); + + var sample = new SampleAttribute() { role = "regular", created = DateTime.UtcNow }; + cred.Attributes.Add("sampleAttribute", sample); + + Assert.IsTrue(cred.SaveCredential(), "SaveCredential on ICredential failed"); + var cred1 = CredentialManager.GetICredential(cred.TargetName); + Assert.IsNotNull(cred1, "GetICredential failed"); + Assert.IsTrue(string.Equals(cred1.UserName, cred.UserName, StringComparison.Ordinal) && string.Equals(cred1.CredentialBlob, cred.CredentialBlob, StringComparison.Ordinal) && cred1.Attributes?.Count == cred.Attributes?.Count, "Saved and retrieved data doesn't match"); + + // Attributes come back as JsonElement — deserialize to verify round-trip + var jsonOptions = s_jsonOptions; + var retrieved = ((JsonElement)cred1.Attributes!["sampleAttribute"]).Deserialize(jsonOptions); + Assert.IsTrue(string.Equals(retrieved.role, sample.role, StringComparison.Ordinal), "Saved and retrieved attribute data doesn't match"); + } - /// - /// Not working as Console window can't be seen during test - /// - //[TestMethod] - // public void TestPromptForCredentialsConsole() - // { - - // try - // { - // bool save = false; - // Assert.IsNotNull (CredentialManager.PromptForCredentialsConsole ("Some Webservice"), "PromptForCredentialsConsole failed"); - - // } - // catch ( Exception e ) - // { - // Assert.Fail ("Unexpected exception of type {0} caught: {1}", - // e.GetType (), e.Message); - // return; - // } - // } - - [TestMethod] - public void IntegrationTest() - { + [TestMethod, TestCategory("CI")] + public void TestICredential_LongComment() + { + string test = "test"; + var cred = (new NetworkCredential(test, test, test)).ToICredential()!; + cred.TargetName = "TestSystem_LongComment"; + cred.Comment = new String('*', 257); + Assert.ThrowsException(() => cred.SaveCredential(), "SaveCredential didn't throw InvalidOperationException for larger than 256 byte Comment"); + } - try - { - bool save = true; - var cred = CredentialManager.PromptForCredentials("Some Webservice", ref save, "Please provide credentials", "Credentials for service"); - Assert.IsNotNull(cred, "PromptForCredentials failed"); - if (save) - { - var usr = cred.UserName; - var pwd = cred.Password; - var dmn = cred.Domain; - Debug.WriteLine("Usr:{0}, Pwd{1}, Dmn{2}", usr, pwd, dmn); - Assert.IsNotNull(CredentialManager.SaveCredentials("TestSystem", cred), "SaveCredential failed"); - cred = CredentialManager.GetCredentials("TestSystem"); - Assert.IsNotNull(cred, "GetCredential failed"); - Assert.IsTrue(usr == cred.UserName && pwd == cred.Password && dmn == cred.Domain, "Saved and retrieved data doesn't match"); - } - - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1} on {2}", - e.GetType(), e.Message, e.StackTrace); - return; - } - } + [TestMethod, TestCategory("CI")] + public void TestICredential_LongPassword() + { + int tooLong = 2 * Credential.MaxCredentialBlobSize; + string test = "test"; + var cred = (new NetworkCredential(test, new String('*', tooLong), test)).ToICredential()!; + cred.TargetName = "TestSystem_LongPassword"; + Assert.ThrowsException(() => cred.SaveCredential(), + $"SaveCredential didn't throw InvalidOperationException for exceeding {Credential.MaxCredentialBlobSize} bytes."); + } - [TestMethod] - public void IntegrationTest_with_prefilled_username() - { - try - { - bool save = true; - var cred = CredentialManager.PromptForCredentials("Some Webservice", ref save, "Please provide credentials", "Credentials for service", "mike.flemming@domain.com"); - Assert.IsNotNull(cred, "PromptForCredentials failed"); - if (save) - { - var usr = cred.UserName; - var pwd = cred.Password; - var dmn = cred.Domain; - Debug.WriteLine("Usr:{0}, Pwd{1}, Dmn{2}", usr, pwd, dmn); - Assert.IsNotNull(CredentialManager.SaveCredentials("TestSystem1", cred), "SaveCredential failed"); - cred = CredentialManager.GetCredentials("TestSystem1"); - Assert.IsNotNull(cred, "GetCredential failed"); - Assert.IsTrue(usr == cred.UserName && pwd == cred.Password && dmn == cred.Domain, "Saved and retrieved data doesn't match"); - } - - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1} on {2}", - e.GetType(), e.Message, e.StackTrace); - return; - } - } + [TestMethod, TestCategory("CI")] + public void TestICredential_LongTokenShouldWork() + { + // Tokens can be rather large. 1040: a size that can be stored. + const int tokenLength = 1040; + Assert.IsTrue(tokenLength < Credential.MaxCredentialBlobSize, "This test is supposed to verify a valid length."); + + string test = "longPasswordTest"; + var net = new NetworkCredential(test, new String('1', tokenLength), test); + ICredential cred = net.ToICredential()!; + cred.TargetName = "TestSystem_LongPassword"; + Assert.IsNotNull(cred.SaveCredential(), "SaveCredential should handle passwords of token size"); + + var cred1 = CredentialManager.GetCredentials("TestSystem_LongPassword"); + Assert.IsTrue(string.Equals(cred1!.Password, net.Password, StringComparison.Ordinal), "Saved and retrieved password doesn't match"); + } + [TestMethod, TestCategory("CI")] + public void TestICredential_AttributesNullValue() + { + string test = "test"; + var cred = (new NetworkCredential(test, test, test)).ToICredential()!; + cred.TargetName = "TestSystem_Attributes"; + cred.Attributes = new Dictionary(StringComparer.Ordinal); + cred.Attributes.Add("sampleAttribute", null!); + Assert.ThrowsException(() => cred.SaveCredential(), "SaveCredential didn't throw ArgumentNullException for null valued Attribute"); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestSaveCredentials_Windows() - { - var cred = new NetworkCredential("admin", "P@$$w0rd"); - var res = CredentialManager.SaveCredentials("TestWindowsCredential", cred, CredentialType.Windows); - var cred1 = CredentialManager.GetCredentials("TestWindowsCredential", CredentialType.Windows); - //https://msdn.microsoft.com/en-us/library/windows/desktop/aa374788(v=vs.85).aspx - //CredentialType.Windows internally gets translated to CRED_TYPE_DOMAIN_PASSWORD - //as per MSDN, for this type CredentialBlob can only be read by the authentication packages. - //I am not able to get the password even while running in elevated mode. more to come. - Assert.IsTrue(cred1 != null && cred1.UserName == cred.UserName, "Saved and retrieved data doesn't match"); - } + [TestMethod, TestCategory("CI")] + public void TestICredential_AttributesLargeValue() + { + string test = "test"; + var cred = (new NetworkCredential(test, test, test)).ToICredential()!; + cred.TargetName = "TestSystem_Attributes"; + cred.Attributes = new Dictionary(StringComparer.Ordinal); + // A 300-char string serializes to 302+ bytes in JSON (with quotes), exceeding the 256-byte limit + cred.Attributes.Add("sampleAttribute", new string('x', 300)); + + Assert.ThrowsException(() => cred.SaveCredential(), "SaveCredential didn't throw ArgumentException for larger than 256 byte Attribute"); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestGetCredentials_NullUserName() - { - var cred = new NetworkCredential(string.Empty, "P@$$w0rd"); - var res = CredentialManager.SaveCredentials("TestCredWithoutUserName", cred); - var cred1 = CredentialManager.GetCredentials("TestCredWithoutUserName"); - Assert.IsTrue(cred1.UserName == cred.UserName && cred1.Password == cred.Password && cred1.Domain == cred.Domain, "Saved and retrieved data doesn't match"); - } + // ------------------------------------------------------------------- + // Enumeration + // ------------------------------------------------------------------- - [TestMethod, TestCategory("AppVeyor")] - public void TestGetCredentials_NonExistantCredential() - { - var cred = CredentialManager.GetCredentials("TotallyNonExistingTarget"); - Assert.IsNull(cred); - } + [TestMethod, TestCategory("CI")] + public void TestEnumerateCredentials() + { + // Ensure at least one credential exists + CredentialManager.SaveCredentials("TestSystem", new NetworkCredential(uName, pwd, domain)); - [TestMethod, TestCategory("AppVeyor")] - public void Test_ParseUserName_supports_long_name() - { - var longUserName = "ksdqkdbkbqskdbqskdqsdsqdqsdjsqdjqsdjlqsjd@domain.com"; - string domain; - string user; - Assert.IsTrue(CredentialManager.ParseUserName(longUserName, 100, 100, out user, out domain)); + var creds = CredentialManager.EnumerateCredentials(); + Assert.IsNotNull(creds, "EnumerateCredentials failed"); + Assert.IsTrue(creds?.Count > 0, "No credentials found after saving one"); + } - Assert.AreEqual(longUserName, user); - Assert.AreEqual("", domain); - } + [TestMethod, TestCategory("CI")] + public void TestEnumerateICredentials() + { + // Ensure at least one credential exists + CredentialManager.SaveCredentials("TestSystem", new NetworkCredential(uName, pwd, domain)); + + var creds = CredentialManager.EnumerateICredentials(); + Assert.IsNotNull(creds, "EnumerateICredentials failed"); + Assert.IsTrue(creds?.Count > 0, "No credentials found after saving one"); + } - [TestMethod, TestCategory("AppVeyor")] - public void Test_ParseUserName_returns_false_if_buffer_is_too_small() + /// + /// This test assumes you have a Generic Credential for https://github.com stored on your system. + /// + [TestMethod, Ignore("Requires git:https://github.com credential on the host system")] + public void TestEnumerateCredentialWithTarget() + { + var creds = CredentialManager.EnumerateCredentials(@"git:https://github.com"); + Assert.IsNotNull(creds, "EnumerateCredentials failed"); + Assert.IsTrue(creds?.Count > 0, "No credentials stored in the system"); + } + + // ------------------------------------------------------------------- + // Credential types and deletion + // ------------------------------------------------------------------- + + [TestMethod, TestCategory("CI")] + public void TestSaveCredentials_Windows() + { + var cred = new NetworkCredential("admin", "P@$$w0rd"); + CredentialManager.SaveCredentials("TestWindowsCredential", cred, CredentialType.Windows); + var cred1 = CredentialManager.GetCredentials("TestWindowsCredential", CredentialType.Windows); + //https://msdn.microsoft.com/en-us/library/windows/desktop/aa374788(v=vs.85).aspx + //CredentialType.Windows internally gets translated to CRED_TYPE_DOMAIN_PASSWORD + //as per MSDN, for this type CredentialBlob can only be read by the authentication packages. + Assert.IsTrue(cred1 != null && string.Equals(cred1.UserName, cred.UserName, StringComparison.Ordinal), "Saved and retrieved data doesn't match"); + } + + [TestMethod, TestCategory("CI")] + public void TestDeleteCredentials_Windows() + { + var cred = new NetworkCredential("admin", "P@$$w0rd"); + var saved = CredentialManager.SaveCredentials("TestDeletingWindowsCredential", cred, CredentialType.Windows); + Assert.IsNotNull(saved, "SaveCredential on ICredential failed"); + + var cred1 = CredentialManager.GetICredential(saved.TargetName, CredentialType.Windows); + Assert.IsNotNull(cred1, "GetICredential failed"); + Assert.IsTrue(string.Equals(cred1.UserName, saved.UserName, StringComparison.Ordinal), "Saved and retrieved data doesn't match"); + Assert.IsTrue(CredentialManager.RemoveCredentials(saved.TargetName, saved.Type), "RemoveCredentials returned false"); + + cred1 = CredentialManager.GetICredential(saved.TargetName); + Assert.IsNull(cred1, "Deleted credential was read"); + } + + [TestMethod, TestCategory("CI")] + public void TestDeleteCredentials_Enumerated() + { + var credentials = CredentialManager.EnumerateICredentials(); + + if (credentials != null) { - var longUserName = "ksdqkdbkbqskdbqskdqsdsqdqsdjsqdjqsdjlqsjd@domain.com"; - string domain; - string user; - Assert.IsFalse(CredentialManager.ParseUserName(longUserName, 10, 100, out user, out domain)); - Assert.AreEqual("", user); - Assert.AreEqual("", domain); + credentials.ForEach(x => { if (x.Type == CredentialType.Windows) Assert.IsTrue(x.RemoveCredential(), "RemoveCredentials returned false"); }); } + } - [TestMethod, TestCategory("AppVeyor")] - public void Test_ParseUserName_supports_domain_name() - { - string user; - string domain; - Assert.IsTrue(CredentialManager.ParseUserName("domain.com\\mike", 100, 100, out user, out domain)); + // ------------------------------------------------------------------- + // Empty/blank password support + // ------------------------------------------------------------------- - Assert.AreEqual("mike", user); - Assert.AreEqual("domain.com", domain); - } + [TestMethod, TestCategory("CI")] + public void TestSaveCredentials_EmptyPassword() + { + var cred = new NetworkCredential(uName, "", domain); + Assert.IsNotNull(CredentialManager.SaveCredentials("TestSystem_nullPwd", cred, AllowNullPassword: true), "SaveCredential failed"); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestICredential_LongComment() - { + // ------------------------------------------------------------------- + // Username parsing + // ------------------------------------------------------------------- - try - { - string test = "test"; - var cred = (new NetworkCredential(test, test, test)).ToICredential(); - cred.TargetName = "TestSystem_Attributes"; - cred.Comment = new String('*', 257); - Assert.ThrowsException(() => cred.SaveCredential(), "SaveCredential didn't throw ArgumentException for larger than 256 byte Comment"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + [TestMethod, TestCategory("CI")] + public void Test_ParseUserName_supports_long_name() + { + var longUserName = "ksdqkdbkbqskdbqskdqsdsqdqsdjsqdjqsdjlqsjd@domain.com"; + Assert.IsTrue(CredentialManager.ParseUserName(longUserName, 100, 100, out string user, out string domain_parsed)); + + Assert.AreEqual(longUserName, user); + Assert.AreEqual("", domain_parsed); + } + + [TestMethod, TestCategory("CI")] + public void Test_ParseUserName_returns_false_if_buffer_is_too_small() + { + var longUserName = "ksdqkdbkbqskdbqskdqsdsqdqsdjsqdjqsdjlqsjd@domain.com"; + Assert.IsFalse(CredentialManager.ParseUserName(longUserName, 10, 100, out string user, out string domain_parsed)); + Assert.AreEqual("", user); + Assert.AreEqual("", domain_parsed); + } + + [TestMethod, TestCategory("CI")] + public void Test_ParseUserName_supports_domain_name() + { + Assert.IsTrue(CredentialManager.ParseUserName("domain.com\\mike", 100, 100, out string user, out string domain_parsed)); + + Assert.AreEqual("mike", user); + Assert.AreEqual("domain.com", domain_parsed); + } + + // ------------------------------------------------------------------- + // User prompting (interactive — not run in CI) + // ------------------------------------------------------------------- + + [TestMethod] + public void TestPromptForCredentials() + { + bool save = false; + Assert.IsNotNull(CredentialManager.PromptForCredentials("Some Webservice", ref save, "Please provide credentials", "Credentials for service"), "PromptForCredentials failed"); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestICredential_LongPassword() + [TestMethod] + public void IntegrationTest() + { + bool save = true; + var cred = CredentialManager.PromptForCredentials("Some Webservice", ref save, "Please provide credentials", "Credentials for service"); + Assert.IsNotNull(cred, "PromptForCredentials failed"); + if (save) { - try - { - int tooLong = 2 * Credential.MaxCredentialBlobSize; - string test = "test"; - var cred = (new NetworkCredential(test, new String('*', tooLong), test)).ToICredential(); - cred.TargetName = "TestSystem_Attributes"; - Assert.ThrowsException(() => cred.SaveCredential(), - $"SaveCredential didn't throw ArgumentException for exceeding {Credential.MaxCredentialBlobSize} bytes."); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } + var usr = cred.UserName; + var pwdLocal = cred.Password; + var dmn = cred.Domain; + Debug.WriteLine("Usr:{0}, Pwd{1}, Dmn{2}", usr, pwdLocal, dmn); + Assert.IsNotNull(CredentialManager.SaveCredentials("TestSystem", cred), "SaveCredential failed"); + cred = CredentialManager.GetCredentials("TestSystem"); + Assert.IsNotNull(cred, "GetCredential failed"); + Assert.IsTrue(string.Equals(usr, cred.UserName, StringComparison.Ordinal) && string.Equals(pwdLocal, cred.Password, StringComparison.Ordinal) && string.Equals(dmn, cred.Domain, StringComparison.Ordinal), "Saved and retrieved data doesn't match"); } + } - [TestMethod, TestCategory("AppVeyor")] - public void TestICredential_LongTokenShouldWork() + [TestMethod] + public void IntegrationTest_with_prefilled_username() + { + bool save = true; + var cred = CredentialManager.PromptForCredentials("Some Webservice", ref save, "Please provide credentials", "Credentials for service", "mike.flemming@domain.com"); + Assert.IsNotNull(cred, "PromptForCredentials failed"); + if (save) { - // Tokens can be rather large. 1040: a size that can be stored. - const int tokenLength = 1040; - Assert.IsTrue(tokenLength < Credential.MaxCredentialBlobSize, "This test is supposed to verify a valid length."); - - string test = "longPasswordTest"; - var net = new NetworkCredential(test, new String('1', tokenLength), test); - ICredential cred = net.ToICredential(); - cred.TargetName = "TestSystem_LongPassword"; - Assert.IsNotNull(cred.SaveCredential(), "SaveCredential should handle passwords of token size"); - - var cred1 = CredentialManager.GetCredentials("TestSystem_LongPassword"); - Assert.IsTrue(cred1.Password == net.Password, "Saved and retrieved password doesn't match"); + var usr = cred.UserName; + var pwdLocal = cred.Password; + var dmn = cred.Domain; + Debug.WriteLine("Usr:{0}, Pwd{1}, Dmn{2}", usr, pwdLocal, dmn); + Assert.IsNotNull(CredentialManager.SaveCredentials("TestSystem1", cred), "SaveCredential failed"); + cred = CredentialManager.GetCredentials("TestSystem1"); + Assert.IsNotNull(cred, "GetCredential failed"); + Assert.IsTrue(string.Equals(usr, cred.UserName, StringComparison.Ordinal) && string.Equals(pwdLocal, cred.Password, StringComparison.Ordinal) && string.Equals(dmn, cred.Domain, StringComparison.Ordinal), "Saved and retrieved data doesn't match"); } + } - [TestMethod, TestCategory("AppVeyor")] - public void TestICredential_AttributesNullValue() - { + // ------------------------------------------------------------------- + // Persistence parameter (v3.1.0) + // ------------------------------------------------------------------- - try - { - string test = "test"; - var cred = (new NetworkCredential(test, test, test)).ToICredential(); - cred.TargetName = "TestSystem_Attributes"; - cred.Attributes = new Dictionary(); - cred.Attributes.Add("sampleAttribute", null); - - Assert.ThrowsException(() => cred.SaveCredential(), "SaveCredential didn't throw ArgumentNullException for null valued Attribute"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + [TestMethod, TestCategory("CI")] + public void TestSaveCredentials_DefaultPersistence_IsLocalMachine() + { + var cred = new NetworkCredential(uName, pwd, domain); + var saved = CredentialManager.SaveCredentials("TestSystem_DefaultPersist", cred); + Assert.IsNotNull(saved, "SaveCredential failed"); + Assert.AreEqual(Persistence.LocalMachine, saved.Persistence, "Default persistence should be LocalMachine"); + CredentialManager.RemoveCredentials("TestSystem_DefaultPersist"); + } -#if !NET45 - [TestMethod, TestCategory("AppVeyor")] - public void TestICredential_AttributesLargeValue() - { + [TestMethod, TestCategory("CI")] + public void TestSaveCredentials_SessionPersistence() + { + var cred = new NetworkCredential(uName, pwd, domain); + var saved = CredentialManager.SaveCredentials("TestSystem_Session", cred, persistence: Persistence.Session); + Assert.IsNotNull(saved, "SaveCredential with Session persistence failed"); + Assert.AreEqual(Persistence.Session, saved.Persistence, "Persistence should be Session"); + CredentialManager.RemoveCredentials("TestSystem_Session"); + } - try - { - string test = "test"; - var cred = (new NetworkCredential(test, test, test)).ToICredential(); - cred.TargetName = "TestSystem_Attributes"; - cred.Attributes = new Dictionary(); - cred.Attributes.Add("sampleAttribute", ValueTuple.Create("RegularUser", DateTime.UtcNow)); - - Assert.ThrowsException(() => cred.SaveCredential(), "SaveCredential didn't throw ArgumentException for larger than 256 byte Attribute"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } -#endif + [TestMethod, TestCategory("CI")] + public void TestSaveCredentials_EnterprisePersistence() + { + var cred = new NetworkCredential(uName, pwd, domain); + var saved = CredentialManager.SaveCredentials("TestSystem_Enterprise", cred, persistence: Persistence.Enterprise); + Assert.IsNotNull(saved, "SaveCredential with Enterprise persistence failed"); + Assert.AreEqual(Persistence.Enterprise, saved.Persistence, "Persistence should be Enterprise"); + CredentialManager.RemoveCredentials("TestSystem_Enterprise"); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestDeleteCredentials_Windows() + // ------------------------------------------------------------------- + // JSON attribute serialization round-trip (v3.1.0) + // ------------------------------------------------------------------- + + [TestMethod, TestCategory("CI")] + public void TestICredential_JsonAttributeRoundTrip_String() + { + var cred = (new NetworkCredential(uName, pwd, domain)).ToICredential()!; + cred.TargetName = "TestSystem_JsonRoundTrip_Str"; + cred.Attributes = new Dictionary(StringComparer.Ordinal) { - var cred = new NetworkCredential("admin", "P@$$w0rd"); - var saved = CredentialManager.SaveCredentials("TestDeletingWindowsCredential", cred, CredentialType.Windows); - Assert.IsNotNull(saved, "SaveCredential on ICredential failed"); + { "greeting", "hello" } + }; - var cred1 = CredentialManager.GetICredential(saved.TargetName, CredentialType.Windows); - Assert.IsNotNull(cred1, "GetICredential failed"); - Assert.IsTrue(cred1.UserName == saved.UserName, "Saved and retrieved data doesn't match"); - Assert.IsTrue(CredentialManager.RemoveCredentials(saved.TargetName, saved.Type), "RemoveCredentials returned false"); + Assert.IsTrue(cred.SaveCredential(), "SaveCredential failed"); - cred1 = CredentialManager.GetICredential(saved.TargetName); - Assert.IsNull(cred1, "Deleted credential was read"); - } + var cred1 = CredentialManager.GetICredential(cred.TargetName); + Assert.IsNotNull(cred1); + Assert.AreEqual(1, cred1.Attributes?.Count); + + var stringVal = ((JsonElement)cred1.Attributes!["greeting"]).GetString(); + Assert.AreEqual("hello", stringVal); + + CredentialManager.RemoveCredentials("TestSystem_JsonRoundTrip_Str"); + } - [TestMethod, TestCategory("AppVeyor")] - public void TestDeleteCredentials_Enumerated() + [TestMethod, TestCategory("CI")] + public void TestICredential_JsonAttributeRoundTrip_Number() + { + var cred = (new NetworkCredential(uName, pwd, domain)).ToICredential()!; + cred.TargetName = "TestSystem_JsonRoundTrip_Num"; + cred.Attributes = new Dictionary(StringComparer.Ordinal) { - var credentials = CredentialManager.EnumerateICredentials(); + { "count", 42 } + }; - if (credentials != null) - { + Assert.IsTrue(cred.SaveCredential(), "SaveCredential failed"); - credentials.ForEach(x => { if (x.Type == CredentialType.Windows) Assert.IsTrue(x.RemoveCredential(),"RemoveCredentials returned false"); }); - } - } + var cred1 = CredentialManager.GetICredential(cred.TargetName); + Assert.IsNotNull(cred1); - [TestMethod, TestCategory("AppVeyor")] - public void TestGetCredentials_PasswordLengthOne() - { - var cred = new NetworkCredential("admin", "P"); - var res = CredentialManager.SaveCredentials("TestCredWithPasswordSingleCharacter", cred); - var cred1 = CredentialManager.GetCredentials("TestCredWithPasswordSingleCharacter"); - Assert.IsTrue(cred1.Password == cred.Password, "Saved and retrieved password doesn't match"); - } - [TestMethod, TestCategory("AppVeyor")] - - public void TestSaveCredentials_EmptyPassword() - { - try - { - var cred = new NetworkCredential(uName, "", domain); - Assert.IsNotNull(CredentialManager.SaveCredentials("TestSystem_nullPwd", cred,AllowNullPassword:true), "SaveCredential failed"); - } - catch (Exception e) - { - Assert.Fail("Unexpected exception of type {0} caught: {1}", - e.GetType(), e.Message); - return; - } - } + var intVal = ((JsonElement)cred1.Attributes!["count"]).GetInt32(); + Assert.AreEqual(42, intVal); + + CredentialManager.RemoveCredentials("TestSystem_JsonRoundTrip_Num"); + } + + [TestMethod, TestCategory("CI")] + public void TestICredential_JsonAttributeRoundTrip_Struct() + { + var cred = (new NetworkCredential(uName, pwd, domain)).ToICredential()!; + cred.TargetName = "TestSystem_JsonRoundTrip_Struct"; + cred.Attributes = new Dictionary(StringComparer.Ordinal); + + var sample = new SampleAttribute() { role = "admin", created = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) }; + cred.Attributes.Add("userInfo", sample); + + Assert.IsTrue(cred.SaveCredential(), "SaveCredential failed"); + + var cred1 = CredentialManager.GetICredential(cred.TargetName); + Assert.IsNotNull(cred1); + + var jsonOptions = s_jsonOptions; + var retrieved = ((JsonElement)cred1.Attributes!["userInfo"]).Deserialize(jsonOptions); + Assert.AreEqual("admin", retrieved.role); + Assert.AreEqual(sample.created, retrieved.created); + + CredentialManager.RemoveCredentials("TestSystem_JsonRoundTrip_Struct"); + } + + // ------------------------------------------------------------------- + // Null parameter handling (v3.1.0) + // ------------------------------------------------------------------- + + [TestMethod, TestCategory("CI")] + public void TestSaveCredentials_NullTarget_Throws() + { + var cred = new NetworkCredential(uName, pwd, domain); + Assert.ThrowsException(() => + CredentialManager.SaveCredentials(null!, cred)); + } + + [TestMethod, TestCategory("CI")] + public void TestSaveCredentials_NullCredential_Throws() + { + Assert.ThrowsException(() => + CredentialManager.SaveCredentials("TestTarget", null!)); + } + + [TestMethod, TestCategory("CI")] + public void TestGetCredentials_NullTarget_Throws() + { + Assert.ThrowsException(() => + CredentialManager.GetCredentials(null!)); + } + + [TestMethod, TestCategory("CI")] + public void TestRemoveCredentials_NullTarget_Throws() + { + Assert.ThrowsException(() => + CredentialManager.RemoveCredentials(null!)); + } + + [TestMethod, TestCategory("CI")] + public void TestGetICredential_NullTarget_Throws() + { + Assert.ThrowsException(() => + CredentialManager.GetICredential(null!)); } }