├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── SecretManagement.DpapiNG.sln ├── build.ps1 ├── docs └── en-US │ ├── Add-DpapiNGDescriptor.md │ ├── ConvertFrom-DpapiNGSecret.md │ ├── ConvertTo-DpapiNGSecret.md │ ├── New-DpapiNGDescriptor.md │ ├── SecretManagement.DpapiNG.md │ ├── about_DpapiNGProtectionDescriptor.md │ └── about_DpapiNGSecretManagement.md ├── manifest.psd1 ├── module ├── SecretManagement.DpapiNG.Extension │ ├── SecretManagement.DpapiNG.Extension.psd1 │ └── SecretManagement.DpapiNG.Extension.psm1 └── SecretManagement.DpapiNG.psd1 ├── src ├── SecretManagement.DpapiNG.Module │ ├── ConvertFromDpapiNGSecret.cs │ ├── ConvertToDpapiNGSecret.cs │ ├── DpapiNGDescriptor.cs │ ├── DpapiNGDescriptorBase.cs │ ├── DpapiNGSecretBase.cs │ ├── EncodingAttribute.cs │ ├── GetSecret.cs │ ├── GetSecretInfo.cs │ ├── RemoveSecret.cs │ ├── SecretConverters.cs │ ├── SecretManagement.DpapiNG.Module.csproj │ ├── SetSecret.cs │ ├── SetSecretInfo.cs │ └── TestSecretVault.cs └── SecretManagement.DpapiNG │ ├── LoadContext.cs │ ├── Native │ ├── NCryptCloseProtectionDescriptor.cs │ ├── NCryptCreateProtectionDescriptor.cs │ ├── NCryptProtectSecret.cs │ └── NCryptUnprotectSecret.cs │ └── SecretManagement.DpapiNG.csproj ├── tests ├── Convert-DpapiNGSecret.Tests.ps1 ├── DpapiNGDescriptor.Tests.ps1 ├── SecretManagement.Tests.ps1 └── common.ps1 └── tools ├── InvokeBuild.ps1 ├── PesterTest.ps1 └── common.ps1 /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test SecretManagement.DpapiNG 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | env: 12 | DOTNET_CLI_TELEMETRY_OPTOUT: 1 13 | POWERSHELL_TELEMETRY_OPTOUT: 1 14 | DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 15 | DOTNET_NOLOGO: true 16 | BUILD_CONFIGURATION: ${{ fromJSON('["Debug", "Release"]')[github.ref == 'refs/heads/main'] }} 17 | 18 | jobs: 19 | build: 20 | name: build 21 | runs-on: ubuntu-latest 22 | permissions: 23 | id-token: write # Azure OIDC auth 24 | contents: read # Repo checkout 25 | 26 | steps: 27 | - name: Check out repository 28 | uses: actions/checkout@v4 29 | 30 | - name: OIDC Login to Azure 31 | if: ${{ env.BUILD_CONFIGURATION == 'Release' }} 32 | uses: azure/login@v2 33 | with: 34 | client-id: ${{ secrets.AZURE_CLIENT_ID }} 35 | tenant-id: ${{ secrets.AZURE_TENANT_ID }} 36 | subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 37 | 38 | - name: Build module 39 | shell: pwsh 40 | run: ./build.ps1 -Configuration $env:BUILD_CONFIGURATION -Task Build 41 | env: 42 | AZURE_KEYVAULT_NAME: ${{ env.BUILD_CONFIGURATION == 'Release' && secrets.AZURE_KEYVAULT_NAME || '' }} 43 | AZURE_KEYVAULT_CERT: ${{ env.BUILD_CONFIGURATION == 'Release' && secrets.AZURE_KEYVAULT_CERT || '' }} 44 | 45 | - name: Capture PowerShell Module 46 | uses: actions/upload-artifact@v4 47 | with: 48 | name: PSModule 49 | path: output/*.nupkg 50 | 51 | test: 52 | name: test 53 | needs: 54 | - build 55 | runs-on: ${{ matrix.info.os }} 56 | strategy: 57 | fail-fast: false 58 | matrix: 59 | info: 60 | - name: PS_5.1_x64_Windows 61 | psversion: '5.1' 62 | os: windows-latest 63 | - name: PS_7.2_x64_Windows 64 | psversion: '7.2.0' 65 | os: windows-latest 66 | - name: PS_7.3_x64_Windows 67 | psversion: '7.3.0' 68 | os: windows-latest 69 | - name: PS_7.4_x64_Windows 70 | psversion: '7.4.0' 71 | os: windows-latest 72 | 73 | steps: 74 | - uses: actions/checkout@v4 75 | 76 | - name: Restore Built PowerShell Module 77 | uses: actions/download-artifact@v4 78 | with: 79 | name: PSModule 80 | path: output 81 | 82 | - name: Run Tests 83 | shell: pwsh 84 | run: | 85 | $buildParams = @{ 86 | Configuration = $env:BUILD_CONFIGURATION 87 | Task = 'Test' 88 | PowerShellVersion = '${{ matrix.info.psversion }}' 89 | ModuleNupkg = 'output/*.nupkg' 90 | } 91 | ./build.ps1 @buildParams 92 | exit $LASTEXITCODE 93 | 94 | - name: Upload Test Results 95 | if: always() 96 | uses: actions/upload-artifact@v4 97 | with: 98 | name: Unit Test Results (${{ matrix.info.name }}) 99 | path: ./output/TestResults/Pester.xml 100 | 101 | - name: Upload Coverage Results 102 | if: always() 103 | uses: actions/upload-artifact@v4 104 | with: 105 | name: Coverage Results (${{ matrix.info.name }}) 106 | path: ./output/TestResults/Coverage.xml 107 | 108 | - name: Upload Coverage to codecov 109 | if: always() 110 | uses: codecov/codecov-action@v4 111 | with: 112 | files: ./output/TestResults/Coverage.xml 113 | flags: ${{ matrix.info.name }} 114 | token: ${{ secrets.CODECOV_TOKEN }} 115 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish module 2 | on: 3 | release: 4 | types: 5 | - published 6 | 7 | jobs: 8 | build: 9 | name: publish 10 | if: startsWith(github.event.release.tag_name, 'v') 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write # Needed for GitHub release asset task 14 | 15 | steps: 16 | - name: Download 17 | uses: dawidd6/action-download-artifact@v3 18 | with: 19 | workflow: ci.yml 20 | commit: ${{ github.sha }} 21 | name: PSModule 22 | 23 | - name: Upload nupkg as release asset 24 | uses: softprops/action-gh-release@v2 25 | with: 26 | files: '*.nupkg' 27 | 28 | - name: Publish to the PowerShell Gallery 29 | shell: pwsh 30 | run: >- 31 | dotnet nuget push '*.nupkg' 32 | --api-key $env:PSGALLERY_TOKEN 33 | --source 'https://www.powershellgallery.com/api/v2/package' 34 | --no-symbols 35 | env: 36 | PSGALLERY_TOKEN: ${{ secrets.PSGALLERY_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.vspscc 94 | *.vssscc 95 | .builds 96 | *.pidb 97 | *.svclog 98 | *.scc 99 | 100 | # Chutzpah Test files 101 | _Chutzpah* 102 | 103 | # Visual C++ cache files 104 | ipch/ 105 | *.aps 106 | *.ncb 107 | *.opendb 108 | *.opensdf 109 | *.sdf 110 | *.cachefile 111 | *.VC.db 112 | *.VC.VC.opendb 113 | 114 | # Visual Studio profiler 115 | *.psess 116 | *.vsp 117 | *.vspx 118 | *.sap 119 | 120 | # Visual Studio Trace Files 121 | *.e2e 122 | 123 | # TFS 2012 Local Workspace 124 | $tf/ 125 | 126 | # Guidance Automation Toolkit 127 | *.gpState 128 | 129 | # ReSharper is a .NET coding add-in 130 | _ReSharper*/ 131 | *.[Rr]e[Ss]harper 132 | *.DotSettings.user 133 | 134 | # TeamCity is a build add-in 135 | _TeamCity* 136 | 137 | # DotCover is a Code Coverage Tool 138 | *.dotCover 139 | 140 | # AxoCover is a Code Coverage Tool 141 | .axoCover/* 142 | !.axoCover/settings.json 143 | 144 | # Coverlet is a free, cross platform Code Coverage Tool 145 | coverage*.json 146 | coverage*.xml 147 | coverage*.info 148 | 149 | # Visual Studio code coverage results 150 | *.coverage 151 | *.coveragexml 152 | 153 | # NCrunch 154 | _NCrunch_* 155 | .*crunch*.local.xml 156 | nCrunchTemp_* 157 | 158 | # MightyMoose 159 | *.mm.* 160 | AutoTest.Net/ 161 | 162 | # Web workbench (sass) 163 | .sass-cache/ 164 | 165 | # Installshield output folder 166 | [Ee]xpress/ 167 | 168 | # DocProject is a documentation generator add-in 169 | DocProject/buildhelp/ 170 | DocProject/Help/*.HxT 171 | DocProject/Help/*.HxC 172 | DocProject/Help/*.hhc 173 | DocProject/Help/*.hhk 174 | DocProject/Help/*.hhp 175 | DocProject/Help/Html2 176 | DocProject/Help/html 177 | 178 | # Click-Once directory 179 | publish/ 180 | 181 | # Publish Web Output 182 | *.[Pp]ublish.xml 183 | *.azurePubxml 184 | # Note: Comment the next line if you want to checkin your web deploy settings, 185 | # but database connection strings (with potential passwords) will be unencrypted 186 | *.pubxml 187 | *.publishproj 188 | 189 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 190 | # checkin your Azure Web App publish settings, but sensitive information contained 191 | # in these scripts will be unencrypted 192 | PublishScripts/ 193 | 194 | # NuGet Packages 195 | *.nupkg 196 | # NuGet Symbol Packages 197 | *.snupkg 198 | # The packages folder can be ignored because of Package Restore 199 | **/[Pp]ackages/* 200 | # except build/, which is used as an MSBuild target. 201 | !**/[Pp]ackages/build/ 202 | # Uncomment if necessary however generally it will be regenerated when needed 203 | #!**/[Pp]ackages/repositories.config 204 | # NuGet v3's project.json files produces more ignorable files 205 | *.nuget.props 206 | *.nuget.targets 207 | 208 | # Microsoft Azure Build Output 209 | csx/ 210 | *.build.csdef 211 | 212 | # Microsoft Azure Emulator 213 | ecf/ 214 | rcf/ 215 | 216 | # Windows Store app package directories and files 217 | AppPackages/ 218 | BundleArtifacts/ 219 | Package.StoreAssociation.xml 220 | _pkginfo.txt 221 | *.appx 222 | *.appxbundle 223 | *.appxupload 224 | 225 | # Visual Studio cache files 226 | # files ending in .cache can be ignored 227 | *.[Cc]ache 228 | # but keep track of directories ending in .cache 229 | !?*.[Cc]ache/ 230 | 231 | # Others 232 | ClientBin/ 233 | ~$* 234 | *~ 235 | *.dbmdl 236 | *.dbproj.schemaview 237 | *.jfm 238 | *.pfx 239 | *.publishsettings 240 | orleans.codegen.cs 241 | 242 | # Including strong name files can present a security risk 243 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 244 | #*.snk 245 | 246 | # Since there are multiple workflows, uncomment next line to ignore bower_components 247 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 248 | #bower_components/ 249 | 250 | # RIA/Silverlight projects 251 | Generated_Code/ 252 | 253 | # Backup & report files from converting an old project file 254 | # to a newer Visual Studio version. Backup files are not needed, 255 | # because we have git ;-) 256 | _UpgradeReport_Files/ 257 | Backup*/ 258 | UpgradeLog*.XML 259 | UpgradeLog*.htm 260 | ServiceFabricBackup/ 261 | *.rptproj.bak 262 | 263 | # SQL Server files 264 | *.mdf 265 | *.ldf 266 | *.ndf 267 | 268 | # Business Intelligence projects 269 | *.rdl.data 270 | *.bim.layout 271 | *.bim_*.settings 272 | *.rptproj.rsuser 273 | *- [Bb]ackup.rdl 274 | *- [Bb]ackup ([0-9]).rdl 275 | *- [Bb]ackup ([0-9][0-9]).rdl 276 | 277 | # Microsoft Fakes 278 | FakesAssemblies/ 279 | 280 | # GhostDoc plugin setting file 281 | *.GhostDoc.xml 282 | 283 | # Node.js Tools for Visual Studio 284 | .ntvs_analysis.dat 285 | node_modules/ 286 | 287 | # Visual Studio 6 build log 288 | *.plg 289 | 290 | # Visual Studio 6 workspace options file 291 | *.opt 292 | 293 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 294 | *.vbw 295 | 296 | # Visual Studio LightSwitch build output 297 | **/*.HTMLClient/GeneratedArtifacts 298 | **/*.DesktopClient/GeneratedArtifacts 299 | **/*.DesktopClient/ModelManifest.xml 300 | **/*.Server/GeneratedArtifacts 301 | **/*.Server/ModelManifest.xml 302 | _Pvt_Extensions 303 | 304 | # Paket dependency manager 305 | .paket/paket.exe 306 | paket-files/ 307 | 308 | # FAKE - F# Make 309 | .fake/ 310 | 311 | # CodeRush personal settings 312 | .cr/personal 313 | 314 | # Python Tools for Visual Studio (PTVS) 315 | __pycache__/ 316 | *.pyc 317 | 318 | # Cake - Uncomment if you are using it 319 | # tools/** 320 | # !tools/packages.config 321 | 322 | # Tabs Studio 323 | *.tss 324 | 325 | # Telerik's JustMock configuration file 326 | *.jmconfig 327 | 328 | # BizTalk build output 329 | *.btp.cs 330 | *.btm.cs 331 | *.odx.cs 332 | *.xsd.cs 333 | 334 | # OpenCover UI analysis results 335 | OpenCover/ 336 | 337 | # Azure Stream Analytics local run output 338 | ASALocalRun/ 339 | 340 | # MSBuild Binary and Structured Log 341 | *.binlog 342 | 343 | # NVidia Nsight GPU debugger configuration file 344 | *.nvuser 345 | 346 | # MFractors (Xamarin productivity tool) working folder 347 | .mfractor/ 348 | 349 | # Local History for Visual Studio 350 | .localhistory/ 351 | 352 | # BeatPulse healthcheck temp database 353 | healthchecksdb 354 | 355 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 356 | MigrationBackup/ 357 | 358 | # Ionide (cross platform F# VS Code tools) working folder 359 | .ionide/ 360 | 361 | # Fody - auto-generated XML schema 362 | FodyWeavers.xsd 363 | 364 | ### Custom entries ### 365 | output/ 366 | tools/Modules 367 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "ms-dotnettools.csharp", 6 | "ms-vscode.powershell", 7 | ], 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "PowerShell launch", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "program": "pwsh", 12 | "args": [ 13 | "-NoExit", 14 | "-NoProfile", 15 | "-Command", 16 | "Import-Module ./output/SecretManagement.DpapiNG" 17 | ], 18 | "cwd": "${workspaceFolder}", 19 | "stopAtEntry": false, 20 | "console": "integratedTerminal", 21 | }, 22 | { 23 | "name": "PowerShell Launch Current File", 24 | "type": "PowerShell", 25 | "request": "launch", 26 | "script": "${file}", 27 | "cwd": "${workspaceFolder}" 28 | }, 29 | { 30 | "name": ".NET CoreCLR Attach", 31 | "type": "coreclr", 32 | "request": "attach", 33 | "processId": "${command:pickProcess}", 34 | "justMyCode": true, 35 | }, 36 | ], 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | //-------- Files configuration -------- 3 | // When enabled, will trim trailing whitespace when you save a file. 4 | "files.trimTrailingWhitespace": true, 5 | // When enabled, insert a final new line at the end of the file when saving it. 6 | "files.insertFinalNewline": true, 7 | "search.exclude": { 8 | "Release": true, 9 | "tools/ResGen": true, 10 | "tools/dotnet": true, 11 | }, 12 | "editor.rulers": [ 13 | 120, 14 | ], 15 | //-------- PowerShell configuration -------- 16 | // Binary modules cannot be unloaded so running in separate processes solves that problem 17 | //"powershell.debugging.createTemporaryIntegratedConsole": true, 18 | // We use Pester v5 so we don't need the legacy code lens 19 | "powershell.pester.useLegacyCodeLens": false, 20 | "powershell.debugging.createTemporaryIntegratedConsole": true, 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "build", 8 | "command": "pwsh", 9 | "type": "shell", 10 | "args": [ 11 | "-File", 12 | "${workspaceFolder}/build.ps1" 13 | ], 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | }, 18 | "problemMatcher": "$msCompile" 19 | }, 20 | { 21 | "label": "update docs", 22 | "command": "pwsh", 23 | "type": "shell", 24 | "args": [ 25 | "-Command", 26 | "Import-Module ${workspaceFolder}/output/SecretManagement.DpapiNG; Import-Module ${workspaceFolder}/output/Modules/platyPS; Update-MarkdownHelpModule ${workspaceFolder}/docs/en-US -AlphabeticParamsOrder -RefreshModulePage -UpdateInputOutput" 27 | ], 28 | "problemMatcher": [], 29 | "dependsOn": [ 30 | "build" 31 | ] 32 | }, 33 | { 34 | "label": "test", 35 | "command": "pwsh", 36 | "type": "shell", 37 | "args": [ 38 | "-File", 39 | "${workspaceFolder}/build.ps1", 40 | "-Task", 41 | "Test" 42 | ], 43 | "problemMatcher": [], 44 | "dependsOn": [ 45 | "build" 46 | ] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for SecretManagement.DpapiNG 2 | 3 | ## v0.5.0 - TBD 4 | 5 | ## v0.4.0 - 2024-07-10 6 | 7 | + Bump `LiteDB` version to `5.0.21` 8 | + Support concurrent operations on the same vault file using LiteDB's shared concurrency model 9 | + Support parsing a `SecurityIdentifier` or `NTAccount` string for the `-Sid` parameter on the `Add-DpapiNGDescriptor` and `ConvertTo-DpapiNGSecret` cmdlets 10 | 11 | ## v0.3.0 - 2023-11-22 12 | 13 | + Add support for `-WebCredential` when specifying a DPAPI-NG protection descriptor 14 | 15 | ## v0.2.0 - 2023-11-21 16 | 17 | + Use a default vault path when registering a vault without a path 18 | + The path will be `$env:LOCALAPPDATA\SecretManagement.DpapiNG\default.vault` 19 | + Add support for `-Certificate` and `-CertificateThumbprint` when specifying a DPAPI-NG protection descriptor 20 | 21 | ## v0.1.0 - 2023-11-21 22 | 23 | + Initial version of the `SecretManagement.DpapiNG` module 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jordan Borean 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SecretManagement.DpapiNG 2 | 3 | [![Test workflow](https://github.com/jborean93/SecretManagement.DpapiNG/workflows/Test%20SecretManagement.DpapiNG/badge.svg)](https://github.com/jborean93/SecretManagement.DpapiNG/actions/workflows/ci.yml) 4 | [![codecov](https://codecov.io/gh/jborean93/SecretManagement.DpapiNG/branch/main/graph/badge.svg?token=b51IOhpLfQ)](https://codecov.io/gh/jborean93/SecretManagement.DpapiNG) 5 | [![PowerShell Gallery](https://img.shields.io/powershellgallery/dt/SecretManagement.DpapiNG.svg)](https://www.powershellgallery.com/packages/SecretManagement.DpapiNG) 6 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/jborean93/SecretManagement.DpapiNG/blob/main/LICENSE) 7 | 8 | A PowerShell module that can be used to encrypt and decrypt data using [DPAPI NG](https://learn.microsoft.com/en-us/windows/win32/seccng/cng-dpapi) also known as `CNG DPAPI`. 9 | The module also implements a `SecretManagement` extension that can be used for interacting with a DPAPI-NG vault registered with the [SecretManagement](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/?view=ps-modules) module. 10 | 11 | See [SecretManagement.DpapiNG index](docs/en-US/SecretManagement.DpapiNG.md) for more details. 12 | 13 | ## Requirements 14 | 15 | These cmdlets have the following requirements 16 | 17 | * PowerShell v5.1 or newer 18 | 19 | Currently this module only works on Windows, it cannot be used on Linux or macOS. 20 | 21 | ## Examples 22 | To encrypt a string with DPAPI-NG use the following: 23 | 24 | ```powershell 25 | # Encrypts the secret so only the current user on the current host can decrypt. 26 | ConvertTo-DpapiNGSecret MySecret 27 | 28 | # Encrypts the secret so only the current domain user on any host can decrypt. 29 | ConvertTo-DpapiNGSecret MySecret -CurrentSid 30 | 31 | # Encrypts the secret so only Domain Admins on any host can decrypt. 32 | ConvertTo-DpapiNGSecret MySecret -Sid 'DOMAIN\Domain Admins' 33 | ``` 34 | 35 | The `-CurrentSid` and `-Sid` options can be used on domain joined hosts to protect a secret for that domain user/group specified. 36 | This secret can be decrypted by that user or member of the group specified on any domain joined host. 37 | 38 | To decrypt the DPAPI-NG blob back: 39 | 40 | ```powershell 41 | # Decrypts back as a SecureString 42 | ConvertFrom-DpapiNGSecret $secret 43 | 44 | # Decrypts back as a String 45 | ConvertFrom-DpapiNGSecret $secret -AsString 46 | ``` 47 | 48 | See [ConvertTo-DpapiNGSecret](./docs/en-US/ConvertTo-DpapiNGSecret.md) and [ConvertFrom-DpapiNGSecret](./docs/en-US/ConvertFrom-DpapiNGSecret.md) for more details. 49 | 50 | To register a DPAPI-NG vault for use with `SecretManagement`: 51 | 52 | ```powershell 53 | # Registers a DPAPI-NG vault with the default path in the user profile. 54 | Register-SecretVault -Name DpapiNG -ModuleName SecretManagement.DpapiNG 55 | 56 | # Registers a DPAPI-NG vault with a custom vault path. 57 | $vaultParams = @{ 58 | Name = 'MyVault' 59 | ModuleName = 'SecretManagement.DpapiNG' 60 | VaultParameters = @{ 61 | Path = 'C:\path\to\vault_file' 62 | } 63 | } 64 | Register-SecretVault @vaultParams 65 | ``` 66 | 67 | The vault name that was registered can now be used with [Set-Secret](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/set-secret?view=ps-modules) and [Get-Secret](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/get-secret?view=ps-modules) to get and set secrets using DPAPI-NG. 68 | 69 | ```powershell 70 | # Uses the default protection of 'LOCAL=user' 71 | Set-Secret -Name MySecret -Vault MyVault -Secret password 72 | 73 | # Uses a custom protection descriptor to protect for the current user 74 | $desc = New-DpapiNGDescriptor | 75 | Add-DpapiNGDescriptor -CurrentSid 76 | Set-Secret -Name MySecret -Vault MyVault -Secret password @desc 77 | 78 | # Uses a custom protection descriptor as a manual string 79 | Set-Secret -Name MySecret -Vault MyVault -Secret password -Metadata @{ 80 | ProtectionDescriptor = "SID=..." 81 | } 82 | 83 | # Get the secret value 84 | Get-Secret -Name MySecret -Vault MyVault 85 | ``` 86 | 87 | See [about_DpapiNGSecretManagement](./docs/en-US/about_DpapiNGSecretManagement.md) for more information on how to use this module with `SecretManagement`. 88 | 89 | ## Installing 90 | 91 | The easiest way to install this module is through [PowerShellGet](https://docs.microsoft.com/en-us/powershell/gallery/overview). 92 | 93 | You can install this module by running either of the following `Install-PSResource` or `Install-Module` command. 94 | 95 | ```powershell 96 | # Install for only the current user 97 | Install-PSResource -Name SecretManagement.DpapiNG, Microsoft.PowerShell.SecretManagement -Scope CurrentUser 98 | Install-Module -Name SecretManagement.DpapiNG, Microsoft.PowerShell.SecretManagement -Scope CurrentUser 99 | 100 | # Install for all users 101 | Install-PSResource -Name SecretManagement.DpapiNG, Microsoft.PowerShell.SecretManagement -Scope AllUsers 102 | Install-Module -Name SecretManagement.DpapiNG, Microsoft.PowerShell.SecretManagement -Scope AllUsers 103 | ``` 104 | 105 | If the `SecretManagement` implementation is not needed, the `Microsoft.PowerShell.SecretManagement` package can be omitted during the install. 106 | The `Install-PSResource` cmdlet is part of the new `PSResourceGet` module from Microsoft available in newer versions while `Install-Module` is present on older systems. 107 | 108 | ## Contributing 109 | 110 | Contributing is quite easy, fork this repo and submit a pull request with the changes. 111 | To build this module run `.\build.ps1 -Task Build` in PowerShell. 112 | To test a build run `.\build.ps1 -Task Test` in PowerShell. 113 | This script will ensure all dependencies are installed before running the test suite. 114 | -------------------------------------------------------------------------------- /SecretManagement.DpapiNG.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.002.0 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5C498C4E-22B2-47B0-BC41-C5665535CCC1}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SecretManagement.DpapiNG", "src\SecretManagement.DpapiNG\SecretManagement.DpapiNG.csproj", "{1DA885D9-AAB8-4B10-A27E-F065018110E7}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SecretManagement.DpapiNG.Module", "src\SecretManagement.DpapiNG.Module\SecretManagement.DpapiNG.Module.csproj", "{7035653B-FF26-4D71-9166-D3EF18F75873}" 11 | EndProject 12 | Global 13 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 14 | Debug|Any CPU = Debug|Any CPU 15 | Release|Any CPU = Release|Any CPU 16 | EndGlobalSection 17 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 18 | {1DA885D9-AAB8-4B10-A27E-F065018110E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 19 | {1DA885D9-AAB8-4B10-A27E-F065018110E7}.Debug|Any CPU.Build.0 = Debug|Any CPU 20 | {1DA885D9-AAB8-4B10-A27E-F065018110E7}.Release|Any CPU.ActiveCfg = Release|Any CPU 21 | {1DA885D9-AAB8-4B10-A27E-F065018110E7}.Release|Any CPU.Build.0 = Release|Any CPU 22 | {7035653B-FF26-4D71-9166-D3EF18F75873}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {7035653B-FF26-4D71-9166-D3EF18F75873}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {7035653B-FF26-4D71-9166-D3EF18F75873}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {7035653B-FF26-4D71-9166-D3EF18F75873}.Release|Any CPU.Build.0 = Release|Any CPU 26 | EndGlobalSection 27 | GlobalSection(SolutionProperties) = preSolution 28 | HideSolutionNode = FALSE 29 | EndGlobalSection 30 | GlobalSection(NestedProjects) = preSolution 31 | {1DA885D9-AAB8-4B10-A27E-F065018110E7} = {5C498C4E-22B2-47B0-BC41-C5665535CCC1} 32 | {7035653B-FF26-4D71-9166-D3EF18F75873} = {5C498C4E-22B2-47B0-BC41-C5665535CCC1} 33 | EndGlobalSection 34 | GlobalSection(ExtensibilityGlobals) = postSolution 35 | SolutionGuid = {93E7AC3C-13C7-4F5C-A68D-CC23C7A357D6} 36 | EndGlobalSection 37 | EndGlobal 38 | -------------------------------------------------------------------------------- /build.ps1: -------------------------------------------------------------------------------- 1 | using namespace System.IO 2 | using namespace System.Runtime.InteropServices 3 | 4 | #Requires -Version 7.2 5 | 6 | [CmdletBinding()] 7 | param( 8 | [Parameter()] 9 | [ValidateSet('Debug', 'Release')] 10 | [string] 11 | $Configuration = 'Debug', 12 | 13 | [Parameter()] 14 | [ValidateSet('Build', 'Test')] 15 | [string] 16 | $Task = 'Build', 17 | 18 | [Parameter()] 19 | [Version] 20 | $PowerShellVersion = $PSVersionTable.PSVersion, 21 | 22 | [Parameter()] 23 | [Architecture] 24 | $PowerShellArch = [RuntimeInformation]::ProcessArchitecture, 25 | 26 | [Parameter()] 27 | [string] 28 | $ModuleNupkg 29 | ) 30 | 31 | $ErrorActionPreference = 'Stop' 32 | 33 | . ([Path]::Combine($PSScriptRoot, "tools", "common.ps1")) 34 | 35 | $manifestPath = ([Path]::Combine($PSScriptRoot, 'manifest.psd1')) 36 | $Manifest = [Manifest]::new($Configuration, $PowerShellVersion, $PowerShellArch, $manifestPath) 37 | 38 | if ($ModuleNupkg) { 39 | Write-Host "Expanding module nupkg to '$($Manifest.ReleasePath)'" -ForegroundColor Cyan 40 | Expand-Nupkg -Path $ModuleNupkg -DestinationPath $Manifest.ReleasePath 41 | } 42 | 43 | Write-Host "Installing PowerShell dependencies" -ForegroundColor Cyan 44 | $deps = $Task -eq 'Build' ? $Manifest.BuildRequirements : $Manifest.TestRequirements 45 | $deps | Install-BuildDependencies 46 | 47 | $buildScript = [Path]::Combine($PSScriptRoot, "tools", "InvokeBuild.ps1") 48 | $invokeBuildSplat = @{ 49 | Task = $Task 50 | File = $buildScript 51 | Manifest = $manifest 52 | } 53 | Invoke-Build @invokeBuildSplat 54 | -------------------------------------------------------------------------------- /docs/en-US/Add-DpapiNGDescriptor.md: -------------------------------------------------------------------------------- 1 | --- 2 | external help file: SecretManagement.DpapiNG.Module.dll-Help.xml 3 | Module Name: SecretManagement.DpapiNG 4 | online version: https://www.github.com/jborean93/SecretManagement.DpapiNG/blob/main/docs/en-US/Add-DpapiNGDescriptor.md 5 | schema: 2.0.0 6 | --- 7 | 8 | # Add-DpapiNGDescriptor 9 | 10 | ## SYNOPSIS 11 | Adds a new protection descriptor clause. 12 | 13 | ## SYNTAX 14 | 15 | ### Local (Default) 16 | ``` 17 | Add-DpapiNGDescriptor -InputObject [-Or] [-Local ] [] 18 | ``` 19 | 20 | ### Sid 21 | ``` 22 | Add-DpapiNGDescriptor -InputObject [-Or] -Sid [] 23 | ``` 24 | 25 | ### SidCurrent 26 | ``` 27 | Add-DpapiNGDescriptor -InputObject [-Or] [-CurrentSid] [] 28 | ``` 29 | 30 | ### Certificate 31 | ``` 32 | Add-DpapiNGDescriptor -InputObject [-Or] -Certificate 33 | [] 34 | ``` 35 | 36 | ### CertificateThumbprint 37 | ``` 38 | Add-DpapiNGDescriptor -InputObject [-Or] -CertificateThumbprint 39 | [] 40 | ``` 41 | 42 | ### WebCredential 43 | ``` 44 | Add-DpapiNGDescriptor -InputObject [-Or] -WebCredential [] 45 | ``` 46 | 47 | ## DESCRIPTION 48 | Adds a new protection descriptor clause to an existing protection descriptor created by [New-DpapiNGDescriptor](./New-DpapiNGDescriptor.md). 49 | Each new clause will be added with an `AND` unless `-Or` is specified. 50 | The protection descriptor is used to descibe what entities are allowed to decrypt the secret it protects. 51 | The following descriptor types are supported: 52 | 53 | + `LOCAL` 54 | 55 | + `SID` 56 | 57 | + `CERTIFICATE` 58 | 59 | + `WEBCREDENTIALS` 60 | 61 | See [about_DpapiNGProtectionDescriptor](./about_DpapiNGProtectionDescriptor.md) for more details. 62 | 63 | ## EXAMPLES 64 | 65 | ### Example 1 - Adds the Local user clause 66 | ```powershell 67 | PS C:\> $desc = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Local User 68 | PS C:\> Set-Secret -Vault MyVault -Name MySecret -Secret foo @desc 69 | ``` 70 | 71 | Creates a new protection descriptor for `LOCAL=user` which will protect the secret for the current user. 72 | The descriptor is then used with `Set-Secret` to define how to protect the secret stored in the vault. 73 | It is important to use the descriptor output using the splat syntax when provided ith `Set-Secret`. 74 | 75 | ### Example 2 - Adds the SID specified 76 | ```powershell 77 | PS C:\> $desc = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -CurrentSid 78 | PS C:\> ConvertTo-DpapiNGSecret secret -ProtectionDescriptor $desc 79 | ``` 80 | 81 | Creates a DPAPI-NG secret that is protected by the current user. 82 | This secret can be decrypted on any host in the domain running under the same user. 83 | 84 | ### Example 3 - Adds multiple SIDs 85 | ```powershell 86 | PS C:\> $domainAdmins = 'DOMAIN\Domain Admins' 87 | PS C:\> $desc = New-DpapiNGDescriptor | 88 | Add-DpapiNGDescriptor -CurrentSid | 89 | Add-DpapiNGDescriptor -Sid $domainAdmins 90 | PS C:\> ConvertTo-DpapiNGSecret secret -ProtectionDescriptor $desc 91 | ``` 92 | 93 | Creates a DPAPI-NG secret that is protected by the current user when a `Domain Admins` member. 94 | The string value for `-Sid` here is automatically converted to the `System.Security.Principal.NTAccount` object and translated to the `SecurityIdentifier` string. 95 | 96 | ## PARAMETERS 97 | 98 | ### -Certificate 99 | The `X509Certificate2` to use when encrypting the data. 100 | The decryptor needs to have the associated private key of the certificate used to decrypt the value. 101 | This method will set the protection descriptor `CERTIFICATE=CertBlob:$certBase64String`. 102 | 103 | ```yaml 104 | Type: X509Certificate2 105 | Parameter Sets: Certificate 106 | Aliases: 107 | 108 | Required: True 109 | Position: Named 110 | Default value: None 111 | Accept pipeline input: False 112 | Accept wildcard characters: False 113 | ``` 114 | 115 | ### -CertificateThumbprint 116 | The thumbprint for a certificate stored inside `Cert:\CurrentUser\My` to use for encryption. 117 | Only the public key needs to be present to encrypt the value but the decryption process requires the associated private key to be present. 118 | This method will set the protection descriptor `CERTIFICATE=HashID:$CertificateThumbprint`. 119 | 120 | ```yaml 121 | Type: String 122 | Parameter Sets: CertificateThumbprint 123 | Aliases: 124 | 125 | Required: True 126 | Position: Named 127 | Default value: None 128 | Accept pipeline input: False 129 | Accept wildcard characters: False 130 | ``` 131 | 132 | ### -CurrentSid 133 | Adds the clause `SID=$UserSid` where `$UserSid` represents the current user's SecurityIdentifier. 134 | A secret protected by this value can be decrypted by this user on any machine in the domain. 135 | 136 | Using a `SID` protection descriptor requires the host to be joined to a domain with a forest level of 2012 or newer. 137 | 138 | ```yaml 139 | Type: SwitchParameter 140 | Parameter Sets: SidCurrent 141 | Aliases: 142 | 143 | Required: True 144 | Position: Named 145 | Default value: None 146 | Accept pipeline input: False 147 | Accept wildcard characters: False 148 | ``` 149 | 150 | ### -InputObject 151 | The protection descriptor object generated by [New-DpapiNGDescriptor](./New-DpapiNGDescriptor.md). 152 | 153 | ```yaml 154 | Type: ProtectionDescriptor 155 | Parameter Sets: (All) 156 | Aliases: 157 | 158 | Required: True 159 | Position: Named 160 | Default value: None 161 | Accept pipeline input: True (ByValue) 162 | Accept wildcard characters: False 163 | ``` 164 | 165 | ### -Local 166 | Adds the `LOCAL` descriptor clause to either `User`, `Machine`, `Logon`. 167 | The `User` value protects the secret to just this user on the current host. 168 | The `Machine` value protects the secret to the current computer. 169 | The `Logon` value protects the secret to just this user's logon session. 170 | This is slightly different to `User` in that the same user logged on through another session will be unable to decrypt the secret. 171 | 172 | ```yaml 173 | Type: String 174 | Parameter Sets: Local 175 | Aliases: 176 | Accepted values: User, Machine 177 | 178 | Required: False 179 | Position: Named 180 | Default value: None 181 | Accept pipeline input: False 182 | Accept wildcard characters: False 183 | ``` 184 | 185 | ### -Or 186 | Adds the new descriptor clause with an `OR` rather than an `AND`. 187 | How this is treated by DPAPI-NG depends on the existing clauses that have already been added. 188 | 189 | ```yaml 190 | Type: SwitchParameter 191 | Parameter Sets: (All) 192 | Aliases: 193 | 194 | Required: False 195 | Position: Named 196 | Default value: None 197 | Accept pipeline input: False 198 | Accept wildcard characters: False 199 | ``` 200 | 201 | ### -Sid 202 | Adds the `SID` descriptor clause to the SecurityIdentifier specified. 203 | The SecurityIdentifier can be the SID of a domain user or group. 204 | If a group SID is specified, any user who is a member of that group can decrypt the secret it applies to. 205 | The value can either by a SecurityIdentifier string in the format `S-1-...`, NTAccount string that will be translated to a `SecurityIdentifier` string, or as a [System.Security.Principal.NTAccount](https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.ntaccount?view=net-8.0) object which will automatically be translated to a SID. 206 | 207 | Using a `SID` protection descriptor requires the host to be joined to a domain with a forest level of 2012 or newer. 208 | 209 | ```yaml 210 | Type: StringOrAccount 211 | Parameter Sets: Sid 212 | Aliases: 213 | 214 | Required: True 215 | Position: Named 216 | Default value: None 217 | Accept pipeline input: False 218 | Accept wildcard characters: False 219 | ``` 220 | 221 | ### -WebCredential 222 | The credential manager to protect the secret with. 223 | The string value is in the format `username,resource`, for example a web credential for `dpapi-ng.com` with the user `MyUser` would be `-WebCredential 'MyUser,dpapi-ng.com'`. 224 | 225 | ```yaml 226 | Type: String 227 | Parameter Sets: WebCredential 228 | Aliases: 229 | 230 | Required: True 231 | Position: Named 232 | Default value: None 233 | Accept pipeline input: False 234 | Accept wildcard characters: False 235 | ``` 236 | 237 | ### CommonParameters 238 | This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). 239 | 240 | ## INPUTS 241 | 242 | ### ProtectionDescriptor 243 | The ProtectionDescriptor object created by New-DpapiNGDescriptor (./New-DpapiNGDescriptor.md). 244 | 245 | ## OUTPUTS 246 | 247 | ### ProtectionDescriptor 248 | The modified ProtectionDescriptor object with the new clause. 249 | 250 | ## NOTES 251 | 252 | ## RELATED LINKS 253 | 254 | [DPAPI NG Protection Descriptors](https://learn.microsoft.com/en-us/windows/win32/seccng/protection-descriptors) 255 | [about_DpapiNGProtectionDescriptor](./about_DpapiNGProtectionDescriptor.md) 256 | -------------------------------------------------------------------------------- /docs/en-US/ConvertFrom-DpapiNGSecret.md: -------------------------------------------------------------------------------- 1 | --- 2 | external help file: SecretManagement.DpapiNG.Module.dll-Help.xml 3 | Module Name: SecretManagement.DpapiNG 4 | online version: https://www.github.com/jborean93/SecretManagement.DpapiNG/blob/main/docs/en-US/ConvertFrom-DpapiNGSecret.md 5 | schema: 2.0.0 6 | --- 7 | 8 | # ConvertFrom-DpapiNGSecret 9 | 10 | ## SYNOPSIS 11 | Decrypts a DPAPI-NG secret. 12 | 13 | ## SYNTAX 14 | 15 | ### AsSecureString (Default) 16 | ``` 17 | ConvertFrom-DpapiNGSecret [-InputObject] [-AsSecureString] [-Encoding ] 18 | [] 19 | ``` 20 | 21 | ### AsByteArray 22 | ``` 23 | ConvertFrom-DpapiNGSecret [-InputObject] [-AsByteArray] [] 24 | ``` 25 | 26 | ### AsString 27 | ``` 28 | ConvertFrom-DpapiNGSecret [-InputObject] [-AsString] [-Encoding ] [] 29 | ``` 30 | 31 | ## DESCRIPTION 32 | Decrypts a DPAPI-NG secret created by [ConvertTo-DpapiNGSecret](./ConvertTo-DpapiNGSecret.md). 33 | The input data is a base64 encoded string of the raw DPAPI-NG blob. 34 | The output object is dependent on the `-As*` switch specified and defaults as a `SecureString`. 35 | 36 | ## EXAMPLES 37 | 38 | ### Example 1 - Decrypts a DPAPI-NG secret 39 | ```powershell 40 | PS C:\> $secret = ConvertTo-DpapiNGSecret secret 41 | PS C:\> $secret | ConvertFrom-DpapiNGSecret 42 | ``` 43 | 44 | Decrypts a DPAPI-NG encoded secret into a `SecureString`. 45 | 46 | ### Example 2 - Decrypts a DPAPI-NG secret to bytes 47 | ```powershell 48 | PS C:\> $secret | ConvertFrom-DpapiNGSecret -AsByteArray 49 | ``` 50 | 51 | Decrypts a DPAPI-NG encoded secret into a `byte[]`. 52 | 53 | ### Example 3 - Decrypts a DPAPI-NG secret to a string with specific encoding 54 | ```powershell 55 | PS C:\> $secret | ConvertFrom-DpapiNGSecret -AsString -Encoding windows-1252 56 | ``` 57 | 58 | Decrypts a DPAPI-NG encoded secret into a `string` using the `windows-1252` encoding. 59 | 60 | ## PARAMETERS 61 | 62 | ### -AsByteArray 63 | Outputs the decrypted value as a `byte[]` object. 64 | 65 | ```yaml 66 | Type: SwitchParameter 67 | Parameter Sets: AsByteArray 68 | Aliases: 69 | 70 | Required: False 71 | Position: Named 72 | Default value: None 73 | Accept pipeline input: False 74 | Accept wildcard characters: False 75 | ``` 76 | 77 | ### -AsSecureString 78 | Outputs the decrypted value as a `SecureString`. 79 | This is the default output if no switch is specified. 80 | 81 | ```yaml 82 | Type: SwitchParameter 83 | Parameter Sets: AsSecureString 84 | Aliases: 85 | 86 | Required: False 87 | Position: Named 88 | Default value: None 89 | Accept pipeline input: False 90 | Accept wildcard characters: False 91 | ``` 92 | 93 | ### -AsString 94 | Outputs the decrypted value as a `String`. 95 | 96 | ```yaml 97 | Type: SwitchParameter 98 | Parameter Sets: AsString 99 | Aliases: 100 | 101 | Required: False 102 | Position: Named 103 | Default value: None 104 | Accept pipeline input: False 105 | Accept wildcard characters: False 106 | ``` 107 | 108 | ### -Encoding 109 | The encoding to use when decoding the bytes to a `String` or `SecureString`. 110 | Defaults to UTF8 if no encoding is specified. 111 | 112 | This accepts a `System.Text.Encoding` type but also a string or int representing the encoding from `[System.Text.Encoding]::GetEncoding(...)`. 113 | Some common encoding values are: 114 | 115 | + `UTF8` - UTF-8 but without a Byte Order Mark (BOM) 116 | 117 | + `ASCII` - ASCII (bytes 0-127) 118 | 119 | + `ANSI` - The ANSI encoding commonly used in legacy Windows encoding 120 | 121 | + `OEM` - The value of `[System.Console]::OutputEncoding` 122 | 123 | + `Unicode` - UTF-16-LE 124 | 125 | + `UTF8Bom` - UTF-8 but with a BOM 126 | 127 | + `UTF8NoBom` - Same as Utf8 128 | 129 | The `ANSI` encoding typically refers to the legacy Windows encoding used in older PowerShell versions. 130 | If creating a script that should be used across the various PowerShell versions, it is highly recommended to use an encoding with a BOM like `UTF8Bom` or `Unicode`. 131 | 132 | ```yaml 133 | Type: Encoding 134 | Parameter Sets: AsSecureString, AsString 135 | Aliases: 136 | 137 | Required: False 138 | Position: Named 139 | Default value: None 140 | Accept pipeline input: False 141 | Accept wildcard characters: False 142 | ``` 143 | 144 | ### -InputObject 145 | The base64 encoded DPAPI-NG blob to decrypt. 146 | 147 | ```yaml 148 | Type: String[] 149 | Parameter Sets: (All) 150 | Aliases: 151 | 152 | Required: True 153 | Position: 0 154 | Default value: None 155 | Accept pipeline input: True (ByValue) 156 | Accept wildcard characters: False 157 | ``` 158 | 159 | ### CommonParameters 160 | This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). 161 | 162 | ## INPUTS 163 | 164 | ### None 165 | ## OUTPUTS 166 | 167 | ### System.Byte[] 168 | The decrypted value as a `byte[]` when `-AsByteArray` is specified. 169 | 170 | ### System.Security.SecureString 171 | The decrypted value as a `SecureString` when no `-As*` switch or `-AsSecureString` is specified. 172 | 173 | ### System.String 174 | The decrypted value as a `String` when `-AsString` is specified. 175 | 176 | ## NOTES 177 | 178 | ## RELATED LINKS 179 | -------------------------------------------------------------------------------- /docs/en-US/ConvertTo-DpapiNGSecret.md: -------------------------------------------------------------------------------- 1 | --- 2 | external help file: SecretManagement.DpapiNG.Module.dll-Help.xml 3 | Module Name: SecretManagement.DpapiNG 4 | online version: https://www.github.com/jborean93/SecretManagement.DpapiNG/blob/main/docs/en-US/ConvertTo-DpapiNGSecret.md 5 | schema: 2.0.0 6 | --- 7 | 8 | # ConvertTo-DpapiNGSecret 9 | 10 | ## SYNOPSIS 11 | Encrypts data as a DPAPI-NG secret. 12 | 13 | ## SYNTAX 14 | 15 | ### Local (Default) 16 | ``` 17 | ConvertTo-DpapiNGSecret [-InputObject] [-Encoding ] 18 | [-Local ] [] 19 | ``` 20 | 21 | ### ProtectionDescriptor 22 | ``` 23 | ConvertTo-DpapiNGSecret [-InputObject] 24 | [[-ProtectionDescriptor] ] [-Encoding ] [] 25 | ``` 26 | 27 | ### Sid 28 | ``` 29 | ConvertTo-DpapiNGSecret [-InputObject] [-Encoding ] 30 | -Sid [] 31 | ``` 32 | 33 | ### SidCurrent 34 | ``` 35 | ConvertTo-DpapiNGSecret [-InputObject] [-Encoding ] [-CurrentSid] 36 | [] 37 | ``` 38 | 39 | ### Certificate 40 | ``` 41 | ConvertTo-DpapiNGSecret [-InputObject] [-Encoding ] 42 | -Certificate [] 43 | ``` 44 | 45 | ### CertificateThumbprint 46 | ``` 47 | ConvertTo-DpapiNGSecret [-InputObject] [-Encoding ] 48 | -CertificateThumbprint [] 49 | ``` 50 | 51 | ### WebCredential 52 | ``` 53 | ConvertTo-DpapiNGSecret [-InputObject] [-Encoding ] 54 | -WebCredential [] 55 | ``` 56 | 57 | ## DESCRIPTION 58 | Encrypts the input data into a base64 encoded string. 59 | The encrypted data is protected using the protection descriptor specified. 60 | Use [ConvertFrom-DpapiNGSecret](./ConvertFrom-DpapiNGSecret.md) to decrypt the secret data back into a usable object. 61 | By default the secret will be protected with the `LOCAL=user` protection descriptor which only allows the current user on the current host the ability to decrypt the secret. 62 | 63 | See [about_DpapiNGProtectionDescriptor](./about_DpapiNGProtectionDescriptor.md) for more details. 64 | 65 | ## EXAMPLES 66 | 67 | ### Example 1 - Encrypt a string for the current domain user 68 | ```powershell 69 | PS C:\> ConvertTo-DpapiNGSecret secret -CurrentSid 70 | ``` 71 | 72 | Encrypts the string `secret` as a DPAPI-NG blob protected by the current domain user. 73 | The same user can decrypt the encrypted blob on any host in the domain. 74 | 75 | ### Exapmle 2 - Encrypt bytes for the local machine 76 | ```powershell 77 | PS C:\> $bytes = [System.IO.File]::ReadAllBytes($path) 78 | # Example using pipeline 79 | PS C:\> , $bytes | ConvertTo-DpapiNGSecret -Local Machine 80 | # Exapmle using parameters 81 | PS C:\> ConvertTo-DpapiNGSecret -InputObject $bytes -Local Machine 82 | ``` 83 | 84 | Encrypts the provided bytes as a DPAPI-NG blob protected by the current local machine. 85 | The same machine can decrypt the encrypted blob. 86 | 87 | ### Example 3 - Encrypt a secret for a specific domain group 88 | ```powershell 89 | PS C:\> $da = 'DOMAIN\Domain Admins' 90 | PS C:\> ConvertTo-DpapiNGSecret secret -Sid $da 91 | ``` 92 | 93 | Encrypts the provided string as a DPAPI-NG blob protected by membership to the `Domain Admins` group. 94 | Any other member of that group will be able to decrypt that secret. 95 | 96 | ### Example 4 - Encrypt a secret using a complex protection descriptor 97 | ```powershell 98 | PS C:\> $da = 'DOMAIN\Domain Admins' 99 | PS C:\> $desc = New-DpapiNGDescriptor | 100 | Add-DpapiNGDescriptor -CurrentSid | 101 | Add-DpapiNGDescriptor -Sid $da -Or 102 | PS C:\> ConvertTo-DpapiNGSecret secret -ProtectionDescriptor $desc 103 | ``` 104 | 105 | Builds a more complex protection descriptor that allows a member of the `Domain Admins` group or the current domain user the ability to decrypt the DPAPI-NG secret. 106 | It is also possible to provide a string to `-ProtectionDescriptor` if crafting it manually. 107 | 108 | ## PARAMETERS 109 | 110 | ### -Certificate 111 | The `X509Certificate2` to use when encrypting the data. 112 | The decryptor needs to have the associated private key of the certificate used to decrypt the value. 113 | This method will set the protection descriptor `CERTIFICATE=CertBlob:$certBase64String`. 114 | 115 | ```yaml 116 | Type: X509Certificate2 117 | Parameter Sets: Certificate 118 | Aliases: 119 | 120 | Required: True 121 | Position: Named 122 | Default value: None 123 | Accept pipeline input: False 124 | Accept wildcard characters: False 125 | ``` 126 | 127 | ### -CertificateThumbprint 128 | The thumbprint for a certificate stored inside `Cert:\CurrentUser\My` to use for encryption. 129 | Only the public key needs to be present to encrypt the value but the decryption process requires the associated private key to be present. 130 | This method will set the protection descriptor `CERTIFICATE=HashID:$CertificateThumbprint`. 131 | 132 | ```yaml 133 | Type: String 134 | Parameter Sets: CertificateThumbprint 135 | Aliases: 136 | 137 | Required: True 138 | Position: Named 139 | Default value: None 140 | Accept pipeline input: False 141 | Accept wildcard characters: False 142 | ``` 143 | 144 | ### -CurrentSid 145 | Protects the secret with the current domain user's identity. 146 | The encrypted secret can be decrypted by this user on any other host in the domain. 147 | This is the equivalent of doing `-ProtectionDescriptor "SID=$([System.Security.Principal.WindowsIdentity]::GetCurrent().User)"`. 148 | 149 | Using a `SID` protection descriptor requires the host to be joined to a domain with a forest level of 2012 or newer. 150 | 151 | ```yaml 152 | Type: SwitchParameter 153 | Parameter Sets: SidCurrent 154 | Aliases: 155 | 156 | Required: True 157 | Position: Named 158 | Default value: None 159 | Accept pipeline input: False 160 | Accept wildcard characters: False 161 | ``` 162 | 163 | ### -Encoding 164 | The encoding used to encode the string into bytes before it is encrypted. 165 | The encoding default to `UTF-8` if not specified. 166 | 167 | This accepts a `System.Text.Encoding` type but also a string or int representing the encoding from `[System.Text.Encoding]::GetEncoding(...)`. 168 | Some common encoding values are: 169 | 170 | + `UTF8` - UTF-8 but without a Byte Order Mark (BOM) 171 | 172 | + `ASCII` - ASCII (bytes 0-127) 173 | 174 | + `ANSI` - The ANSI encoding commonly used in legacy Windows encoding 175 | 176 | + `OEM` - The value of `[System.Console]::OutputEncoding` 177 | 178 | + `Unicode` - UTF-16-LE 179 | 180 | + `UTF8Bom` - UTF-8 but with a BOM 181 | 182 | + `UTF8NoBom` - Same as Utf8 183 | 184 | The `ANSI` encoding typically refers to the legacy Windows encoding used in older PowerShell versions. 185 | If creating a script that should be used across the various PowerShell versions, it is highly recommended to use an encoding with a BOM like `UTF8Bom` or `Unicode`. 186 | 187 | ```yaml 188 | Type: Encoding 189 | Parameter Sets: (All) 190 | Aliases: 191 | 192 | Required: False 193 | Position: Named 194 | Default value: None 195 | Accept pipeline input: False 196 | Accept wildcard characters: False 197 | ``` 198 | 199 | ### -InputObject 200 | The data to encrypt. 201 | The value can be on of the following types 202 | 203 | + `String` 204 | 205 | + `SecureString` 206 | 207 | + `byte[]` 208 | 209 | When a `String` or `SecureString` is used, the `-Encoding` argument is used to convert the value to bytes before it is encrypted. 210 | 211 | ```yaml 212 | Type: StringSecureStringOrByteArray[] 213 | Parameter Sets: (All) 214 | Aliases: 215 | 216 | Required: True 217 | Position: 0 218 | Default value: None 219 | Accept pipeline input: True (ByValue) 220 | Accept wildcard characters: False 221 | ``` 222 | 223 | ### -Local 224 | Protects the secret using the `LOCAL=machine`, `LOCAL=user`, or `LOCAL=logon` protection descriptor. 225 | The `User` value protects the secret to just this user on the current host and is the default value. 226 | The `Machine` value protects the secret to the current computer. 227 | The `Logon` value protects the secret to just this user's logon session. 228 | This is slightly different to `User` in that the same user logged on through another session will be unable to decrypt the secret. 229 | 230 | ```yaml 231 | Type: String 232 | Parameter Sets: Local 233 | Aliases: 234 | 235 | Required: False 236 | Position: Named 237 | Default value: None 238 | Accept pipeline input: False 239 | Accept wildcard characters: False 240 | ``` 241 | 242 | ### -ProtectionDescriptor 243 | The protection descriptor string to use to protect the value. 244 | The [New-DpapiNGDescriptor](./New-DpapiNGDescriptor.md) and [Add-DpapiNGDescriptor](./Add-DpapiNGDescriptor.md) can be used to build the protection descriptor value. 245 | A string can also be provided here as the protection descriptor using the rules defined by DPAPI-NG. 246 | 247 | ```yaml 248 | Type: StringOrProtectionDescriptor 249 | Parameter Sets: ProtectionDescriptor 250 | Aliases: 251 | 252 | Required: False 253 | Position: 1 254 | Default value: None 255 | Accept pipeline input: False 256 | Accept wildcard characters: False 257 | ``` 258 | 259 | ### -Sid 260 | Allows only the domain user or domain group specified by this SID to be able to decrypt the DPAPI-NG secret. 261 | If a group SID is specified, any user who is a member of that group can decrypt the secret it applies to. 262 | The value can either by a SecurityIdentifier string in the format `S-1-...`, NTAccount string that will be translated to a `SecurityIdentifier` string, or as a [System.Security.Principal.NTAccount](https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.ntaccount?view=net-8.0) object which will automatically be translated to a SID. 263 | 264 | Using a `SID` protection descriptor requires the host to be joined to a domain with a forest level of 2012 or newer. 265 | 266 | ```yaml 267 | Type: StringOrAccount 268 | Parameter Sets: Sid 269 | Aliases: 270 | 271 | Required: True 272 | Position: Named 273 | Default value: None 274 | Accept pipeline input: False 275 | Accept wildcard characters: False 276 | ``` 277 | 278 | ### -WebCredential 279 | The credential manager to protect the secret with. 280 | The string value is in the format `username,resource`, for example a web credential for `dpapi-ng.com` with the user `MyUser` would be `-WebCredential 'MyUser,dpapi-ng.com'`. 281 | 282 | ```yaml 283 | Type: String 284 | Parameter Sets: WebCredential 285 | Aliases: 286 | 287 | Required: True 288 | Position: Named 289 | Default value: None 290 | Accept pipeline input: False 291 | Accept wildcard characters: False 292 | ``` 293 | 294 | ### CommonParameters 295 | This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). 296 | 297 | ## INPUTS 298 | 299 | ### StringSecureStringOrByteArray 300 | The `-InputObject` to encrypt. 301 | 302 | ## OUTPUTS 303 | 304 | ### System.String 305 | The encrypted DPAPI-NG blob as a base64 encoded string. 306 | 307 | ## NOTES 308 | 309 | ## RELATED LINKS 310 | 311 | [DPAPI NG Protection Descriptors](https://learn.microsoft.com/en-us/windows/win32/seccng/protection-descriptors) 312 | [about_DpapiNGProtectionDescriptor](./about_DpapiNGProtectionDescriptor.md) 313 | -------------------------------------------------------------------------------- /docs/en-US/New-DpapiNGDescriptor.md: -------------------------------------------------------------------------------- 1 | --- 2 | external help file: SecretManagement.DpapiNG.Module.dll-Help.xml 3 | Module Name: SecretManagement.DpapiNG 4 | online version: https://www.github.com/jborean93/SecretManagement.DpapiNG/blob/main/docs/en-US/New-DpapiNGDescriptor.md 5 | schema: 2.0.0 6 | --- 7 | 8 | # New-DpapiNGDescriptor 9 | 10 | ## SYNOPSIS 11 | Creates a DPAPI-NG protection descriptor used to encrypt data with DPAPI-NG. 12 | 13 | ## SYNTAX 14 | 15 | ``` 16 | New-DpapiNGDescriptor [] 17 | ``` 18 | 19 | ## DESCRIPTION 20 | This is used to create the DPAPI-NG protection descriptor string. 21 | Use with [Add-DpapiNGDescriptor](./Add-DpapiNGDescriptor.md) to add descriptor elements to the protection string. 22 | 23 | See [about_DpapiNGProtectionDescriptor](./about_DpapiNGProtectionDescriptor.md) for more details. 24 | 25 | ## EXAMPLES 26 | 27 | ### Example 1 28 | ```powershell 29 | PS C:\> $desc = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Local User 30 | PS C:\> Set-Secret -Vault MyVault -Name MySecret -Secret foo @desc 31 | ``` 32 | 33 | Creates a new protection descriptor for `LOCAL=user` which will protect the secret for the current user. 34 | The descriptor is then used with `Set-Secret` to define how to protect the secret stored in the vault. 35 | It is important to use the descriptor output using the splat syntax when provided ith `Set-Secret`. 36 | 37 | ## PARAMETERS 38 | 39 | ### CommonParameters 40 | This cmdlet supports the common parameters: -Debug, -ErrorAction, -ErrorVariable, -InformationAction, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable, -Verbose, -WarningAction, and -WarningVariable. For more information, see [about_CommonParameters](http://go.microsoft.com/fwlink/?LinkID=113216). 41 | 42 | ## INPUTS 43 | 44 | ### None 45 | ## OUTPUTS 46 | 47 | ### ProtectionDescriptor 48 | The ProtectionDescriptor object that can be piped to Add-DpapiNGDescriptor (./Add-DpapiNGDescriptor.md). 49 | 50 | ## NOTES 51 | 52 | ## RELATED LINKS 53 | 54 | [DPAPI NG Protection Descriptors](https://learn.microsoft.com/en-us/windows/win32/seccng/protection-descriptors) 55 | [about_DpapiNGProtectionDescriptor](./about_DpapiNGProtectionDescriptor.md) 56 | -------------------------------------------------------------------------------- /docs/en-US/SecretManagement.DpapiNG.md: -------------------------------------------------------------------------------- 1 | --- 2 | Module Name: SecretManagement.DpapiNG 3 | Module Guid: edfc3dfe-b362-49ff-8e87-0124f4b3307a 4 | Download Help Link: 5 | Help Version: 1.0.0.0 6 | Locale: en-US 7 | --- 8 | 9 | # SecretManagement.DpapiNG Module 10 | ## Description 11 | A module that can encrypt and decrypt data with DPAPI-NG. It also contains an implementation needed to register a DPAPI-NG vault with SecretManagement . See about_DpapiNGSecretManagement for more details on how to use this module with `SecretManagement`. 12 | 13 | ## SecretManagement.DpapiNG Cmdlets 14 | ### [Add-DpapiNGDescriptor](Add-DpapiNGDescriptor.md) 15 | Adds a new protection descriptor clause. 16 | 17 | ### [ConvertFrom-DpapiNGSecret](ConvertFrom-DpapiNGSecret.md) 18 | Decrypts a DPAPI-NG secret. 19 | 20 | ### [ConvertTo-DpapiNGSecret](ConvertTo-DpapiNGSecret.md) 21 | Encrypts data as a DPAPI-NG secret. 22 | 23 | ### [New-DpapiNGDescriptor](New-DpapiNGDescriptor.md) 24 | Creates a DPAPI-NG protection descriptor used to encrypt data with DPAPI-NG. 25 | 26 | -------------------------------------------------------------------------------- /docs/en-US/about_DpapiNGProtectionDescriptor.md: -------------------------------------------------------------------------------- 1 | # DPAPI-NG SecretManagement 2 | ## about_DpapiNGProtectionDescriptor 3 | 4 | # SHORT DESCRIPTION 5 | DPAPI-NG uses a protection descriptor that defines how the data is encrypted and what is allowed to decrypt the data. 6 | This guide will go through the known protection descriptor types and how they are used in this module. 7 | 8 | # LONG DESCRIPTION 9 | Microsoft documents the protection descriptor string format under [CNG Protection Descriptors](https://learn.microsoft.com/en-us/windows/win32/seccng/protection-descriptors). 10 | While it is possible to define a custom protection descriptor string there are a few types that are predefined through some helper parameters: 11 | 12 | |Type|Options|Description| 13 | |-|-|-| 14 | |`LOCAL`|`Logon`, `Machine`, `User`|Protects the data to the logon session, computer, or user| 15 | |`SID`|`S-*`|Protects the data to the domain user or group specified| 16 | |`CERTIFICATE`|`HashID:$certThumbprint`, `CertBlobc:$certB64String`|Protects the data using the certificate provided.| 17 | |`WEBCREDENTIALS`|`$username,$resource`|Protects the data with the password of a web credential stored in credential manager.| 18 | 19 | There is also the `SDDL` type but it is not exposed through a helper parameter in this module. 20 | The `SDDL` format is the more advanced format of the `SID` type but the way it is defined is a lot more complex. 21 | It is still possible to use this type, or any future types, with `-ProtectionDescriptor "SDDL=..."` through a manual string. 22 | 23 | # LOCAL 24 | The `LOCAL` protection descriptor can be used to encrypt a secret that only the current logon session, computer/machine, or user can decrypt. 25 | A `LOCAL=logon` protection description is designed to encrypt a secret just for the current logon session. 26 | A logon session is a Windows logon session like an interactive logon or through secondary logon tools like `runas.exe`. 27 | Once a logon session is closed any secret scoped to that session can no longer be decrypted. 28 | A `LOCAL=user` protection descriptor can be used to protect data that only that user on that host can decrypt. 29 | This is similar to how a serialized `SecureString` can only be decrypted by the current user on the current host. 30 | A `LOCAL=machine` protection descriptor can be used to protect data that can be decrypted by any user on the current host. 31 | The `LOCAL` type is specified through the `-Local Logon|Machine|User` parameter: 32 | 33 | ```powershell 34 | ConvertTo-DpapiNGSecret foo -Local Logon 35 | ConvertTo-DpapiNGSecret foo -Local Machine 36 | ConvertTo-DpapiNGSecret foo -Local User 37 | ``` 38 | 39 | By default if no protection descriptor is specified the `-Local User` descriptor will be used. 40 | 41 | # SID 42 | The `SID` protection descriptor is used to encrypt data that is scoped to a specific domain user or domain groups. 43 | To use the `SID` type, the host must be joined to a domain with a forest level of 2012 or newer. 44 | Attempting to use the `SID` type on a non-domain host or one joined to an older forest will fail. 45 | The `SID` value is just the SecurityIdentifier string `S-1-...` that represents the domain user or the domain group a user is a member of that is allowed to decrypt the value. 46 | The `SID` type is specified through the `-Sid S-1-...` or `-CurrentSid` parameters. 47 | The `-CurrentSid` switch is shorthand for `-Sid ([System.Security.Principal.WindowsIdentity]::GetCurrent().Sid)`. 48 | It is also possible to specify an `NTAccount` object as the value and the cmdlet will internally convert the account to a SecurityIdentifier. 49 | 50 | ```powershell 51 | ConvertTo-DpapiNGSecret foo -Sid S-1-5-21-1786775912-3884064449-72196952-1104 52 | ConvertTo-DpapiNGSecret foo -CurrentSid 53 | 54 | $da = [System.Security.Principal.NTAccount]'DOMAIN\Domain Admins' 55 | ConvertTo-DpapiNGSecret foo -Sid $da 56 | ``` 57 | 58 | It is possible to use an `OR` clause with `SID` types when specifying the accounts that can decrypt a value. 59 | Unlike `AND`, an `OR` condition means that an account only needs to have one of the SecurityIdentifiers specified. 60 | 61 | ```powershell 62 | $desc = New-DpapiNGDescriptor | 63 | Add-DpapiNGDescriptor -Sid S-1-5-10 | 64 | Add-DpapiNGDescriptor -Sid S-1-5-11 -Or 65 | 66 | ConvertTo-DpapiNGSecret foo -ProtectionDescriptor $desc 67 | ``` 68 | 69 | In the above example a domain user only needs to have the `S-1-5-10` or `S-1-5-11` SecurityIdentifiers to be able to decrypt the data. 70 | 71 | While undocumented the `SDDL` type is tied to the `SID` type with `SID` being a user friendly representation of how `SDDL` works. 72 | 73 | # CERTIFICATE 74 | The `CERTIFICATE` protection descriptor can be used to protect a secret using a locally stored certificate. 75 | Anyone who has access to the certificate public key can encrypt a value while only users with the private key can decrypt it. 76 | The `-CertificateThumbprint` or `-Certificate` parameters can be used to specify this protection type. 77 | 78 | ```powershell 79 | ConvertTo-DpapiNGSecret foo -CertificateThumbprint F952FF847B99811990DB27B04ABDB318A28ACD6E 80 | 81 | $cert = Import-PfxCertificate -FilePath cert.pfx 82 | ConvertTo-DpapiNGSecret foo -Certificate $cert 83 | ``` 84 | 85 | If using `-CertificateThumbprint` the certificate referenced by the thumbprint must exist in the `Cert:\CurrentUser\My` certificate store. 86 | If using `-Certificate` the certificate does not need to be in any store to encrypt the data. 87 | To decrypt the data, the private key referenced by the certificate must be accessible by the user. 88 | Typically this means the certificate is stored in `Cert:\CurrentUser\My` with a referenced private key. 89 | 90 | # WEBCREDENTIALS 91 | The `WEBCREDENTIALS` protection descriptor can be used to protect a secret using a saved Web Credential in the Credential Manager. 92 | Web Credentials are typically used by WinRT/Store applications where a credential is scoped specifically for the application that created them. 93 | They are set to roam across devices using the same Microsoft Account profile making the secret portable outside of a domain environment. 94 | One downside is that for normal Win32 applications that are not WinRT/Store apps, these credentials are visible to that user and not just when running the app that created it. 95 | 96 | The `-WebCredential` parameter can be used to specify this protection type. 97 | 98 | ```powershell 99 | ConvertTo-DpapiNGSecret foo -WebCredential 'username,resource' 100 | ``` 101 | 102 | While typically Web Credentials are created by specific WinRT/Store applications it is possible to do so globally for user. 103 | The following code can be used in Windows PowerShell 5.1 to manage Web Credentials using the WinRT [PasswordVault Class](https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.passwordvault?view=winrt-22621): 104 | 105 | ```powershell 106 | $vault = [Windows.Security.Credentials.PasswordVault, Windows.Security.Credentials, ContentType = WindowsRuntime]::new() 107 | 108 | # Retrieves all Web Credentials 109 | $vault.RetrieveAll() | Select-Object -Property UserName, Resource 110 | 111 | # Retrieves all Web Credentials for a resource 112 | $vault.FindAllByResource("resource") 113 | 114 | # Retrieves all Web Credentials for a username 115 | $vault.FindAllByUserName("username") 116 | 117 | # Adds a new Web Credential 118 | $vault.Add([Windows.Security.Credentials.PasswordCredential, Windows.Security.Credentials, ContentType = WindowsRuntime]::new( 119 | "resource", 120 | "username", 121 | "password" 122 | )) 123 | 124 | # Removes a specific Web Credential 125 | $vault.Remove($vault.Retrieve("resource", "username")) 126 | ``` 127 | 128 | Please note that this will only work in Windows PowerShell (`powershell.exe` 5.1) and not PowerShell (`pwsh.exe` 7+) which lacks the required WinRT components. 129 | Also doing so will create a web credential that is not scoped to a specific application but rather the user as Windows PowerShell is a Win32 application. 130 | 131 | # AND Conditions 132 | Using the [New-DpapiNGDescriptor](./New-DpapiNGDescriptor.md) and [Add-DpapiNGDescriptor](./Add-DpapiNGDescriptor.md) cmdlets it is possible to create a descriptor with multiple clauses. 133 | It is possible to use with with the `SID` type, other types might be possible but the behaviour with `AND` on other types is unknown and undocumented. 134 | 135 | When using conditions put together with `AND`, the decryptor must meet each condition to be able to decrypt the value. 136 | 137 | ```powershell 138 | $dbAdmins = [System.Security.Principal.NTAccount]'DOMAIN\DBA Admins' 139 | $backupAdmins = [System.Security.Principal.NTAccount]'DOMAIN\Backup Admins' 140 | $desc = New-DpapiNGDescriptor | 141 | Add-DpapiNGDescriptor -Sid $dbAdmins | 142 | Add-DpapiNGDescriptor -Sid $backupAdmins 143 | 144 | ConvertTo-DpapiNGSecret foo -ProtectionDescriptor $desc 145 | ``` 146 | 147 | In the above example, only domain users who are members of the `DBA Admins` and `Backup Admins` group will be able to decrypt the value. 148 | -------------------------------------------------------------------------------- /docs/en-US/about_DpapiNGSecretManagement.md: -------------------------------------------------------------------------------- 1 | # DPAPI-NG SecretManagement 2 | ## about_DpapiNGSecretManagement 3 | 4 | # SHORT DESCRIPTION 5 | This module can be used as a vault implementation for Microsoft's [SecretManagement](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/?view=ps-modules) module. 6 | This guide will demonstrate how to register a DPAPI-NG vault and interact with it using the other `SecretManagement` cmdlets. 7 | It is also possible to use the [ConvertTo-DpapiNGSecret](./ConvertTo-DpapiNGSecret.md) and [ConvertFrom-DpapiNGSecret](./ConvertFrom-DpapiNGSecret.md) cmdlets to encrypt and decrypt DPAPI-NG values manually without integration with a `SecretManagement` vault. 8 | 9 | # LONG DESCRIPTION 10 | The `SecretManagement` integration will only work if the [Microsoft.PowerShell.SecretManagement](https://www.powershellgallery.com/packages/Microsoft.PowerShell.SecretManagement/) has been installed. 11 | 12 | ```powershell 13 | # Using the new PSResourceGet module 14 | Install-PSResource -Name Microsoft.PowerShell.SecretManagement 15 | 16 | # Using the old PowerShellGet module 17 | Install-Module -Name Microsoft.PowerShell.SecretManagement 18 | ``` 19 | 20 | Once installed the next step is to register a DPAPI-NG vault to interact with using [Register-SecretVault](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/register-secretvault?view=ps-modules). 21 | 22 | ```powershell 23 | # Registers a DPAPI-NG vault with the default path in the user profile. 24 | Register-SecretVault -Name MyVault -ModuleName SecretManagement.DpapiNG 25 | 26 | # Registers a DPAPI-NG vault with a custom vault path 27 | $vaultParams = @{ 28 | Name = 'MyVault' 29 | ModuleName = 'SecretManagement.DpapiNG' 30 | VaultParameters = @{ 31 | Path = 'C:\path\to\vault_file' 32 | } 33 | } 34 | Register-SecretVault @vaultParams 35 | ``` 36 | 37 | This cmdlet will create a DPAPI-NG vault called `MyVault`, if no `Path` was specified in the `VaultParameters` a default path under `$env:LOCALAPPDATA\SecretManagement.DpapiNG\default.vault` is used as the path. 38 | If the vault file specified does not exist it will automatically be created when the vault operation is performed. 39 | It must be a the path to a file and the parent directory must already exist. 40 | Once the vault is registered it can referenced by the `-VaultName MyVault` parameter on the other `SecretManagement` cmdlets. 41 | It is possible to copy the vault file directory to other hosts as long as the secrets it contains is protected in a way that isn't tied to the same host. 42 | The file format of the vault uses [LiteDB](https://github.com/mbdavid/LiteDB) but this is an implementation detail and can change in the future. 43 | 44 | To set a DPAPI-NG secret the [Set-Secret](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/set-secret?view=ps-modules) cmdlet can be used: 45 | 46 | ```powershell 47 | # Uses the default protection of 'LOCAL=user' 48 | Set-Secret -Name MySecret -Vault MyVault -Secret password 49 | 50 | # Uses a custom protection descriptor 51 | $desc = New-DpapiNGDescriptor | 52 | Add-DpapiNGDescriptor -CurrentSid 53 | Set-Secret -Name MySecret -Vault MyVault -Secret password @desc 54 | 55 | # Uses a custom protection descriptor as a manual string 56 | # Also defines the Created metadata 57 | Set-Secret -Name MySecret -Vault MyVault -Secret password -Metadata @{ 58 | ProtectionDescriptor = "LOCAL=machine" 59 | Created = (Get-Date) 60 | } 61 | ``` 62 | 63 | These cmdlets will both register the secret called `MySecret` which will encrypt the value `password` using DPAPI-NG. 64 | The first example will protect it with the `LOCAL=user` protection descriptor ensuring on the current user on the current host can decrypt it. 65 | The second example will protect it with the `SID=$([System.Security.Principal.WindowsIdentity]::GetCurrent().User)` protection descriptor ensuring the current domain user can decrypt the secret on any domain joined host. 66 | It is important in that scenario the `$desc` variable is splatted as this will automatically define it through the `-Metadata` parameter as needed. 67 | The third example manually defines the protection descriptor as a string but also defines the `Created` metadata set to the current `DateTime`. 68 | See [New-DpapiNGDescriptor](./New-DpapiNGDescriptor.md) and [Add-DpapniNGDescriptor](./Add-DpapiNGDescriptor.md) for how to build a protection descriptor for your needs. 69 | Also see [DPAPI NG Protection Descriptors](https://learn.microsoft.com/en-us/windows/win32/seccng/protection-descriptors) for more information on known protection descriptors. 70 | 71 | To retrieve a DPAPI-NG secret the [Get-Secret](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/get-secret?view=ps-modules) cmdlet can be used: 72 | 73 | ```powershell 74 | Get-Secret -Name MySecret -Vault MyVault 75 | ``` 76 | 77 | The vault will automatically decrypt the value if the user is authorized to do so. 78 | If the secret has been protected in a way the user cannot decrypt then it will fail. 79 | 80 | To remove a DPAPI-NG secret the [Remove-Secret](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/remove-secret?view=ps-modules) cmdlet can be used: 81 | 82 | ```powershell 83 | Remove-Secret -Name MySecret -Vault MyVault 84 | ``` 85 | 86 | It is also possible to use [Get-SecretInfo](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/get-secretinfo?view=ps-modules) and [Set-SecretInfo](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.secretmanagement/set-secretinfo?view=ps-modules) to get and set the metadata on a secret. 87 | Metadata is extra data associated with the secret that is stored in plaintext. 88 | The DPAPI-NG vault accepts free-form metadata keys allowing it to store whatever is needed in the scenario. 89 | The only exception is the `ProtectionDescriptor` key and value which is reserved as the DPAPI-NG protection descriptor string used for the encrypted secret. 90 | -------------------------------------------------------------------------------- /manifest.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | DotnetProject = 'SecretManagement.DpapiNG.Module' 3 | InvokeBuildVersion = '5.11.3' 4 | PesterVersion = '5.6.1' 5 | BuildRequirements = @( 6 | @{ 7 | ModuleName = 'Microsoft.PowerShell.PSResourceGet' 8 | ModuleVersion = '1.0.5' 9 | } 10 | @{ 11 | ModuleName = 'OpenAuthenticode' 12 | RequiredVersion = '0.4.0' 13 | } 14 | @{ 15 | ModuleName = 'platyPS' 16 | RequiredVersion = '0.14.2' 17 | } 18 | ) 19 | TestRequirements = @( 20 | @{ 21 | ModuleName = 'Microsoft.PowerShell.SecretManagement' 22 | ModuleVersion = '1.1.2' 23 | } 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /module/SecretManagement.DpapiNG.Extension/SecretManagement.DpapiNG.Extension.psd1: -------------------------------------------------------------------------------- 1 | # Copyright: (c) 2023, Jordan Borean (@jborean93) 2 | # MIT License (see LICENSE or https://opensource.org/licenses/MIT) 3 | 4 | @{ 5 | RootModule = 'SecretManagement.DpapiNG.Extension.psm1' 6 | ModuleVersion = '0.0.0.0' 7 | GUID = '167d7de5-1d14-4eb4-9316-5a5aedbb1f30' 8 | Author = '' 9 | CompanyName = '' 10 | Copyright = '' 11 | FunctionsToExport = @() 12 | CmdletsToExport = @( 13 | # Used for SecretManagement 14 | 'Get-Secret' 15 | 'Get-SecretInfo' 16 | 'Remove-Secret' 17 | 'Set-Secret' 18 | 'Set-SecretInfo' 19 | 'Test-SecretVault' 20 | ) 21 | VariablesToExport = @() 22 | AliasesToExport = @() 23 | } 24 | -------------------------------------------------------------------------------- /module/SecretManagement.DpapiNG.Extension/SecretManagement.DpapiNG.Extension.psm1: -------------------------------------------------------------------------------- 1 | # Copyright: (c) 2023, Jordan Borean (@jborean93) 2 | # MIT License (see LICENSE or https://opensource.org/licenses/MIT) 3 | 4 | $importModule = Get-Command -Name Import-Module -Module Microsoft.PowerShell.Core 5 | 6 | # Get the name of the module without .Extension 7 | $currentModuleName = [System.IO.Path]::GetFileNameWithoutExtension($PSCommandPath) 8 | $moduleName = $currentModuleName.Substring(0, $currentModuleName.Length - 10) 9 | 10 | if ($PSVersionTable.PSVersion.Major -eq 5) { 11 | # PowerShell 5.1 has no concept of an Assembly Load Context so it will 12 | # just load the module assembly directly. 13 | 14 | $innerMod = if ('SecretManagement.DpapiNG.Module.GetSecretCommand' -as [type]) { 15 | $modAssembly = [SecretManagement.DpapiNG.Module.GetSecretCommand].Assembly 16 | &$importModule -Assembly $modAssembly -Force -PassThru 17 | } 18 | else { 19 | $modPath = [System.IO.Path]::Combine($PSScriptRoot, '..', 'bin', 'net472', "$moduleName.Module.dll") 20 | &$importModule -Name $modPath -ErrorAction Stop -PassThru 21 | } 22 | } 23 | else { 24 | # This is used to load the shared assembly in the Default ALC which then sets 25 | # an ALC for the moulde and any dependencies of that module to be loaded in 26 | # that ALC. 27 | 28 | if (-not ('SecretManagement.DpapiNG.LoadContext' -as [type])) { 29 | Add-Type -Path ([System.IO.Path]::Combine($PSScriptRoot, '..', 'bin', 'net6.0', "$moduleName.dll")) 30 | } 31 | 32 | $mainModule = [SecretManagement.DpapiNG.LoadContext]::Initialize() 33 | $innerMod = &$importModule -Assembly $mainModule -Force -PassThru 34 | } 35 | 36 | # The way SecretManagement runs doesn't like that the needed functions are part 37 | # of an nested module of this one. This is a hack to ensure it's exposed here 38 | # properly. 39 | $addExportedCmdlet = [System.Management.Automation.PSModuleInfo].GetMethod( 40 | 'AddExportedCmdlet', 41 | [System.Reflection.BindingFlags]'Instance, NonPublic' 42 | ) 43 | foreach ($cmd in $innerMod.ExportedCmdlets.Values) { 44 | $addExportedCmdlet.Invoke($ExecutionContext.SessionState.Module, @(, $cmd)) 45 | } 46 | 47 | # Use this for testing that the dlls are loaded correctly and outside the Default ALC. 48 | # [System.AppDomain]::CurrentDomain.GetAssemblies() | 49 | # Where-Object { $_.GetName().Name -like "*DpapiNG*" } | 50 | # ForEach-Object { 51 | # $alc = [Runtime.Loader.AssemblyLoadContext]::GetLoadContext($_) 52 | # [PSCustomObject]@{ 53 | # Name = $_.FullName 54 | # Location = $_.Location 55 | # ALC = $alc 56 | # } 57 | # } | Format-List 58 | -------------------------------------------------------------------------------- /module/SecretManagement.DpapiNG.psd1: -------------------------------------------------------------------------------- 1 | # Copyright: (c) 2023, Jordan Borean (@jborean93) 2 | # MIT License (see LICENSE or https://opensource.org/licenses/MIT) 3 | # 4 | # Module manifest for module 'SecretManagement.DpapiNG' 5 | # 6 | # Generated by: Jordan Borean 7 | # 8 | # Generated on: 2023-11-17 9 | # 10 | 11 | @{ 12 | # Version number of this module. 13 | ModuleVersion = '0.5.0' 14 | 15 | # Supported PSEditions 16 | # CompatiblePSEditions = @() 17 | 18 | # ID used to uniquely identify this module 19 | GUID = 'edfc3dfe-b362-49ff-8e87-0124f4b3307a' 20 | 21 | # Author of this module 22 | Author = 'Jordan Borean' 23 | 24 | # Company or vendor of this module 25 | CompanyName = 'Community' 26 | 27 | # Copyright statement for this module 28 | Copyright = '(c) 2023 Jordan Borean. All rights reserved.' 29 | 30 | # Description of the functionality provided by this module 31 | Description = 'SecretManagement module for DPAPI-NG' 32 | 33 | # Minimum version of the PowerShell engine required by this module 34 | PowerShellVersion = '5.1' 35 | 36 | # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 37 | DotNetFrameworkVersion = '4.7.2' 38 | 39 | # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 40 | # ClrVersion = '4.0' 41 | 42 | # Processor architecture (None, X86, Amd64) required by this module 43 | # ProcessorArchitecture = '' 44 | 45 | # Modules that must be imported into the global environment prior to importing this module 46 | RequiredModules = @() 47 | 48 | # Assemblies that must be loaded prior to importing this module 49 | # RequiredAssemblies = @() 50 | 51 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 52 | # ScriptsToProcess = @() 53 | 54 | # Type files (.ps1xml) to be loaded when importing this module 55 | TypesToProcess = @() 56 | 57 | # Format files (.ps1xml) to be loaded when importing this module 58 | FormatsToProcess = @() 59 | 60 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 61 | NestedModules = @( 62 | './SecretManagement.DpapiNG.Extension' 63 | ) 64 | 65 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 66 | FunctionsToExport = @() 67 | 68 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 69 | CmdletsToExport = @( 70 | 'Add-DpapiNGDescriptor' 71 | 'ConvertFrom-DpapiNGSecret' 72 | 'ConvertTo-DpapiNGSecret' 73 | 'New-DpapiNGDescriptor' 74 | ) 75 | 76 | # Variables to export from this module 77 | VariablesToExport = @() 78 | 79 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 80 | AliasesToExport = @() 81 | 82 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 83 | PrivateData = @{ 84 | 85 | PSData = @{ 86 | 87 | # Prerelease = 'preview1' 88 | 89 | # Tags applied to this module. These help with module discovery in online galleries. 90 | Tags = @( 91 | 'Dpapi' 92 | 'Encryption' 93 | 'Secret' 94 | 'SecretManagement' 95 | 'SecretVault' 96 | 'Vault' 97 | ) 98 | 99 | # A URL to the license for this module. 100 | LicenseUri = 'https://github.com/jborean93/SecretManagement.DpapiNG/blob/main/LICENSE' 101 | 102 | # A URL to the main website for this project. 103 | ProjectUri = 'https://github.com/jborean93/SecretManagement.DpapiNG' 104 | 105 | # A URL to an icon representing this module. 106 | # IconUri = '' 107 | 108 | # ReleaseNotes of this module 109 | ReleaseNotes = 'See https://github.com/jborean93/SecretManagement.DpapiNG/blob/main/CHANGELOG.md' 110 | 111 | # Prerelease string of this module 112 | # Prerelease = '' 113 | 114 | # Flag to indicate whether the module requires explicit user acceptance for install/update/save 115 | # RequireLicenseAcceptance = $false 116 | 117 | # External dependent modules of this module 118 | # ExternalModuleDependencies = @() 119 | 120 | } # End of PSData hashtable 121 | 122 | } # End of PrivateData hashtable 123 | 124 | } 125 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/ConvertFromDpapiNGSecret.cs: -------------------------------------------------------------------------------- 1 | using SecretManagement.DpapiNG.Native; 2 | using System; 3 | using System.ComponentModel; 4 | using System.Management.Automation; 5 | using System.Security; 6 | using System.Text; 7 | 8 | namespace SecretManagement.DpapiNG.Module; 9 | 10 | [Cmdlet(VerbsData.ConvertFrom, "DpapiNGSecret", DefaultParameterSetName = "AsSecureString")] 11 | [OutputType(typeof(byte[]), ParameterSetName = new[] { "AsByteArray" })] 12 | [OutputType(typeof(SecureString), ParameterSetName = new[] { "AsSecureString" })] 13 | [OutputType(typeof(string), ParameterSetName = new[] { "AsString" })] 14 | public sealed class ConvertFromDpapiNGCommand : PSCmdlet 15 | { 16 | [Parameter( 17 | Mandatory = true, 18 | Position = 0, 19 | ValueFromPipeline = true 20 | )] 21 | public string[] InputObject { get; set; } = Array.Empty(); 22 | 23 | [Parameter( 24 | ParameterSetName = "AsByteArray" 25 | )] 26 | public SwitchParameter AsByteArray { get; set; } 27 | 28 | [Parameter( 29 | ParameterSetName = "AsSecureString" 30 | )] 31 | public SwitchParameter AsSecureString { get; set; } 32 | 33 | [Parameter( 34 | ParameterSetName = "AsString" 35 | )] 36 | public SwitchParameter AsString { get; set; } 37 | 38 | [Parameter( 39 | ParameterSetName = "AsSecureString" 40 | )] 41 | [Parameter( 42 | ParameterSetName = "AsString" 43 | )] 44 | [EncodingTransformAttribute] 45 | #if NET6_0_OR_GREATER 46 | [EncodingCompletionsAttribute] 47 | #else 48 | [ArgumentCompleter(typeof(EncodingCompletionsAttribute))] 49 | #endif 50 | public Encoding? Encoding { get; set; } 51 | 52 | protected override void ProcessRecord() 53 | { 54 | Encoding enc = Encoding ?? Encoding.UTF8; 55 | 56 | foreach (string input in InputObject) 57 | { 58 | SafeNCryptData blob; 59 | try 60 | { 61 | blob = NCrypt.NCryptUnprotectSecret( 62 | NCrypt.NCRYPT_SILENT_FLAG, 63 | Convert.FromBase64String(input), 64 | out var desc); 65 | desc.Dispose(); 66 | } 67 | catch (Win32Exception e) 68 | { 69 | ErrorRecord err = new( 70 | e, 71 | "SecretManagement.DpapiNG.DecryptError", 72 | ErrorCategory.NotSpecified, 73 | null 74 | ); 75 | err.ErrorDetails = new($"Failed to decrypt data: {e.Message} (0x{e.NativeErrorCode:X2})"); 76 | WriteError(err); 77 | continue; 78 | } 79 | 80 | using (blob) 81 | { 82 | ReadOnlySpan blobSpan = blob.DangerousGetSpan(); 83 | if (ParameterSetName == "AsSecureString") 84 | { 85 | WriteObject(SecretConverters.ConvertToSecureString(blobSpan, enc)); 86 | } 87 | else if (ParameterSetName == "AsString") 88 | { 89 | WriteObject(SecretConverters.ConvertToString(blobSpan, enc)); 90 | } 91 | else 92 | { 93 | WriteObject(blobSpan.ToArray(), enumerateCollection: false); 94 | } 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/ConvertToDpapiNGSecret.cs: -------------------------------------------------------------------------------- 1 | using SecretManagement.DpapiNG.Native; 2 | using System; 3 | using System.Collections; 4 | using System.Collections.Generic; 5 | using System.ComponentModel; 6 | using System.Linq; 7 | using System.Management.Automation; 8 | using System.Net; 9 | using System.Security; 10 | using System.Text; 11 | 12 | namespace SecretManagement.DpapiNG.Module; 13 | 14 | public sealed class StringOrProtectionDescriptor 15 | { 16 | internal string Value { get; } 17 | 18 | public StringOrProtectionDescriptor(ProtectionDescriptor value) 19 | { 20 | Value = value.ToString(); 21 | } 22 | 23 | public StringOrProtectionDescriptor(string value) 24 | { 25 | Value = value; 26 | } 27 | } 28 | 29 | [Cmdlet( 30 | VerbsData.ConvertTo, 31 | "DpapiNGSecret", 32 | DefaultParameterSetName = DEFAULT_PARAM_SET 33 | )] 34 | [OutputType(typeof(string))] 35 | public sealed class ConvertToDpapiNGCommand : DpapiNGDescriptorBase 36 | { 37 | [Parameter( 38 | Mandatory = true, 39 | Position = 0, 40 | ValueFromPipeline = true 41 | )] 42 | [SecretValueTransformer] 43 | public StringSecureStringOrByteArray[] InputObject { get; set; } = Array.Empty(); 44 | 45 | [Parameter( 46 | Position = 1, 47 | ParameterSetName = "ProtectionDescriptor" 48 | )] 49 | public StringOrProtectionDescriptor? ProtectionDescriptor { get; set; } 50 | 51 | [Parameter] 52 | [EncodingTransformAttribute] 53 | #if NET6_0_OR_GREATER 54 | [EncodingCompletionsAttribute] 55 | #else 56 | [ArgumentCompleter(typeof(EncodingCompletionsAttribute))] 57 | #endif 58 | public Encoding? Encoding { get; set; } 59 | 60 | protected override void ProcessRecord() 61 | { 62 | Encoding enc = Encoding ?? Encoding.UTF8; 63 | 64 | string protectionDescriptor; 65 | if (ProtectionDescriptor != null) 66 | { 67 | protectionDescriptor = ProtectionDescriptor.Value; 68 | } 69 | else 70 | { 71 | protectionDescriptor = GetRuleString(); 72 | } 73 | 74 | SafeNCryptProtectionDescriptor desc; 75 | try 76 | { 77 | desc = NCrypt.NCryptCreateProtectionDescriptor(protectionDescriptor, 0); 78 | } 79 | catch (Win32Exception e) 80 | { 81 | ErrorRecord err = new( 82 | e, 83 | "SecretManagement.DpapiNG.InvalidDescriptor", 84 | ErrorCategory.InvalidArgument, 85 | protectionDescriptor 86 | ); 87 | err.ErrorDetails = new( 88 | $"Failed to create protection descriptor object: {e.Message} (0x{e.NativeErrorCode:X2})"); 89 | ThrowTerminatingError(err); 90 | return; 91 | } 92 | 93 | using (desc) 94 | { 95 | foreach (StringSecureStringOrByteArray input in InputObject) 96 | { 97 | SafeNCryptData blob; 98 | try 99 | { 100 | blob = NCrypt.NCryptProtectSecret(desc, NCrypt.NCRYPT_SILENT_FLAG, input.GetBytes(enc)); 101 | } 102 | catch (Win32Exception e) 103 | { 104 | ErrorRecord err = new( 105 | e, 106 | "SecretManagement.DpapiNG.EncryptError", 107 | ErrorCategory.NotSpecified, 108 | null 109 | ); 110 | err.ErrorDetails = new($"Failed to encrypt data: {e.Message} (0x{e.NativeErrorCode:X2})"); 111 | WriteError(err); 112 | continue; 113 | } 114 | 115 | string b64; 116 | using (blob) 117 | { 118 | #if NET6_0_OR_GREATER 119 | b64 = Convert.ToBase64String(blob.DangerousGetSpan()); 120 | #else 121 | b64 = Convert.ToBase64String(blob.DangerousGetSpan().ToArray()); 122 | #endif 123 | } 124 | 125 | WriteObject(b64); 126 | } 127 | } 128 | } 129 | } 130 | 131 | // A transformer is needed to ensure a byte[] isn't casted using the string overload. 132 | public class SecretValueTransformer : ArgumentTransformationAttribute 133 | { 134 | public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) 135 | { 136 | return TransformValues(inputData); 137 | } 138 | 139 | private StringSecureStringOrByteArray[] TransformValues(object? inputData) 140 | { 141 | PSObject psObj; 142 | if (inputData is PSObject objPS) 143 | { 144 | psObj = objPS; 145 | } 146 | else 147 | { 148 | psObj = PSObject.AsPSObject(inputData); 149 | } 150 | 151 | List transformed = new(); 152 | if (psObj.BaseObject is IList objList && psObj.BaseObject is not IList) 153 | { 154 | foreach (object? obj in objList) 155 | { 156 | transformed.AddRange(TransformValues(obj)); 157 | } 158 | 159 | return transformed.ToArray(); 160 | } 161 | else 162 | { 163 | transformed.Add(TransformValue(psObj)); 164 | } 165 | 166 | return transformed.ToArray(); 167 | } 168 | 169 | private StringSecureStringOrByteArray TransformValue(object? inputData) 170 | { 171 | PSObject psObj; 172 | if (inputData is PSObject objPS) 173 | { 174 | psObj = objPS; 175 | inputData = objPS.BaseObject; 176 | } 177 | else 178 | { 179 | psObj = PSObject.AsPSObject(inputData); 180 | } 181 | 182 | if (inputData is SecureString secString) 183 | { 184 | return new StringSecureStringOrByteArray(secString); 185 | } 186 | else if (inputData is IList objByteArray) 187 | { 188 | return new StringSecureStringOrByteArray(objByteArray); 189 | } 190 | else 191 | { 192 | return new StringSecureStringOrByteArray(LanguagePrimitives.ConvertTo(psObj)); 193 | } 194 | } 195 | } 196 | 197 | public sealed class StringSecureStringOrByteArray 198 | { 199 | private string? _string; 200 | private SecureString? _secureString; 201 | private byte[]? _byteArray; 202 | 203 | public StringSecureStringOrByteArray(string value) 204 | { 205 | _string = value; 206 | } 207 | 208 | public StringSecureStringOrByteArray(SecureString value) 209 | { 210 | _secureString = value; 211 | } 212 | 213 | public StringSecureStringOrByteArray(IList value) 214 | { 215 | _byteArray = value.ToArray(); 216 | } 217 | 218 | internal byte[] GetBytes(Encoding encoding) 219 | { 220 | if (_string != null) 221 | { 222 | return encoding.GetBytes(_string); 223 | } 224 | else if (_secureString != null) 225 | { 226 | return encoding.GetBytes(new NetworkCredential("", _secureString).Password); 227 | } 228 | else 229 | { 230 | return _byteArray ?? Array.Empty(); 231 | } 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/DpapiNGDescriptor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Management.Automation; 5 | using System.Security.Principal; 6 | using System.Text; 7 | 8 | public sealed class ProtectionDescriptor : IEnumerable 9 | { 10 | private StringBuilder _builder = new(); 11 | 12 | internal ProtectionDescriptor() 13 | { } 14 | 15 | internal void AppendRule(string rule, bool and) 16 | { 17 | if (_builder.Length > 0) 18 | { 19 | _builder.AppendFormat(" {0} ", and ? "AND" : "OR"); 20 | } 21 | _builder.Append(rule); 22 | } 23 | 24 | public IEnumerator GetEnumerator() 25 | { 26 | // This is used as an easy way to splat Metadata for Set-Secret 27 | return GetMetadataParams().GetEnumerator(); 28 | } 29 | 30 | private IEnumerable GetMetadataParams() 31 | { 32 | PSObject paramName = PSObject.AsPSObject("-Metadata"); 33 | paramName.Properties.Add(new PSNoteProperty("", "Metadata")); 34 | yield return paramName; 35 | 36 | yield return new Hashtable() 37 | { 38 | { "ProtectionDescriptor", ToString() } 39 | }; 40 | } 41 | 42 | public static implicit operator string(ProtectionDescriptor v) 43 | => v.ToString(); 44 | 45 | public override string ToString() 46 | => _builder.ToString(); 47 | } 48 | 49 | [Cmdlet(VerbsCommon.New, "DpapiNGDescriptor")] 50 | [OutputType(typeof(ProtectionDescriptor))] 51 | public sealed class NewDpapiNGDescriptorCommand : PSCmdlet 52 | { 53 | protected override void BeginProcessing() 54 | { 55 | WriteObject(new ProtectionDescriptor()); 56 | } 57 | } 58 | 59 | [Cmdlet( 60 | VerbsCommon.Add, 61 | "DpapiNGDescriptor", 62 | DefaultParameterSetName = DEFAULT_PARAM_SET 63 | )] 64 | [OutputType(typeof(ProtectionDescriptor))] 65 | public class AddDpapiNGDescriptorCommand : DpapiNGDescriptorBase 66 | { 67 | [Parameter( 68 | Mandatory = true, 69 | ValueFromPipeline = true 70 | )] 71 | public ProtectionDescriptor InputObject { get; set; } = default!; 72 | 73 | [Parameter] 74 | public SwitchParameter Or { get; set; } 75 | 76 | protected override void ProcessRecord() 77 | { 78 | bool isAnd = !Or.IsPresent; 79 | string ruleValue = GetRuleString(); 80 | InputObject.AppendRule(ruleValue, isAnd); 81 | 82 | WriteObject(InputObject); 83 | } 84 | } 85 | 86 | public sealed class StringOrAccount 87 | { 88 | internal string Value { get; } 89 | 90 | public StringOrAccount(string value) 91 | { 92 | #pragma warning disable CA1416 93 | // First check if it's a SID string value. 94 | try 95 | { 96 | SecurityIdentifier sid = new(value); 97 | Value = sid.Value; 98 | return; 99 | } 100 | catch (ArgumentException) 101 | {} 102 | 103 | // If not try to translate the account name to a SID. 104 | NTAccount account = new(value); 105 | Value = account.Translate(typeof(SecurityIdentifier)).Value; 106 | #pragma warning restore CA1416 107 | } 108 | 109 | #if NET6_0_OR_GREATER 110 | [System.Runtime.Versioning.SupportedOSPlatform("windows")] 111 | #endif 112 | public StringOrAccount(IdentityReference value) 113 | { 114 | if (value is SecurityIdentifier sid) 115 | { 116 | Value = sid.Value; 117 | } 118 | else 119 | { 120 | Value = value.Translate(typeof(SecurityIdentifier)).Value; 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/DpapiNGDescriptorBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Management.Automation; 3 | using System.Security.Cryptography.X509Certificates; 4 | using System.Security.Principal; 5 | 6 | public abstract class DpapiNGDescriptorBase : PSCmdlet 7 | { 8 | internal const string DEFAULT_PARAM_SET = "Local"; 9 | 10 | [Parameter( 11 | ParameterSetName = "Local" 12 | )] 13 | [ValidateSet("Logon", "Machine", "User")] 14 | public string Local { get; set; } = "User"; 15 | 16 | [Parameter( 17 | Mandatory = true, 18 | ParameterSetName = "Sid" 19 | )] 20 | public StringOrAccount Sid { get; set; } = default!; 21 | 22 | [Parameter( 23 | Mandatory = true, 24 | ParameterSetName = "SidCurrent" 25 | )] 26 | public SwitchParameter CurrentSid { get; set; } 27 | 28 | [Parameter( 29 | Mandatory = true, 30 | ParameterSetName = "Certificate" 31 | )] 32 | public X509Certificate2? Certificate { get; set; } 33 | 34 | [Parameter( 35 | Mandatory = true, 36 | ParameterSetName = "CertificateThumbprint" 37 | )] 38 | public string? CertificateThumbprint { get; set; } 39 | 40 | [Parameter( 41 | Mandatory = true, 42 | ParameterSetName = "WebCredential" 43 | )] 44 | public string? WebCredential { get; set; } 45 | 46 | internal string GetRuleString() => ParameterSetName switch 47 | { 48 | "Local" => $"LOCAL={Local.ToLowerInvariant()}", 49 | "Sid" => $"SID={Sid.Value}", 50 | #pragma warning disable CA1416 51 | #pragma warning disable CS8602 52 | "SidCurrent" => $"SID={WindowsIdentity.GetCurrent().User.Value}", 53 | #pragma warning restore CA1416 54 | #pragma warning restore CS8602 55 | "Certificate" => $"CERTIFICATE=CertBlob:{Convert.ToBase64String(Certificate!.Export(X509ContentType.Cert))}", 56 | "CertificateThumbprint" => $"CERTIFICATE=HashId:{CertificateThumbprint!}", 57 | "WebCredential" => $"WEBCREDENTIALS={WebCredential}", 58 | _ => throw new NotImplementedException(), 59 | }; 60 | } 61 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/DpapiNGSecretBase.cs: -------------------------------------------------------------------------------- 1 | using LiteDB; 2 | using Microsoft.PowerShell.Commands; 3 | using Microsoft.PowerShell.SecretManagement; 4 | using System; 5 | using System.Collections; 6 | using System.IO; 7 | using System.Management.Automation; 8 | 9 | namespace SecretManagement.DpapiNG.Module; 10 | 11 | public abstract class DpapiNGSecretBase : PSCmdlet 12 | { 13 | protected virtual bool ReadOnly => false; 14 | 15 | [Parameter] 16 | public Hashtable AdditionalParameters { get; set; } = new(); 17 | 18 | protected override void ProcessRecord() 19 | { 20 | string vaultPath; 21 | if ( 22 | AdditionalParameters.ContainsKey("Path") && 23 | AdditionalParameters["Path"] is string tempVaultPath && 24 | !string.IsNullOrWhiteSpace(tempVaultPath) 25 | ) 26 | { 27 | vaultPath = tempVaultPath; 28 | } 29 | else 30 | { 31 | string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); 32 | string secretManagementDir = Path.Combine(localAppData, "SecretManagement.DpapiNG"); 33 | if (!Directory.Exists(secretManagementDir)) 34 | { 35 | Directory.CreateDirectory(secretManagementDir); 36 | } 37 | 38 | vaultPath = Path.Combine(secretManagementDir, "default.vault"); 39 | } 40 | 41 | string providerPath = SessionState.Path.GetUnresolvedProviderPathFromPSPath( 42 | vaultPath, 43 | out var provider, 44 | out var _); 45 | if (provider.ImplementingType != typeof(FileSystemProvider)) 46 | { 47 | string msg = 48 | $"Invalid SecretManagement.DpapiNG vault registration: Path '{vaultPath}' must be a local file path " + 49 | "to the local LiteDB database. If the DB does not exist at the path a new vault will be created."; 50 | ErrorRecord err = new( 51 | new ArgumentException(msg), 52 | "SecretManagement.DpapiNG.InvalidPath", 53 | ErrorCategory.InvalidArgument, 54 | vaultPath 55 | ); 56 | WriteError(err); 57 | return; 58 | } 59 | 60 | FileAttributes dbAttr = new FileInfo(providerPath).Attributes; 61 | bool dbExists = (int)dbAttr != -1; 62 | if (!dbExists && !Directory.Exists(Path.GetDirectoryName(providerPath))) 63 | { 64 | string msg = 65 | $"Invalid SecretManagement.DpapiNG vault registration: Path '{vaultPath}' must exist or the parent " + 66 | "directory in the path must exist to create the new vault file."; 67 | ErrorRecord err = new( 68 | new ArgumentException(msg), 69 | "SecretManagement.DpapiNG.PathMissingNoParent", 70 | ErrorCategory.InvalidArgument, 71 | vaultPath 72 | ); 73 | WriteError(err); 74 | return; 75 | } 76 | else if (dbExists && (dbAttr & FileAttributes.Directory) != 0) 77 | { 78 | string msg = 79 | $"Invalid SecretManagement.DpapiNG vault registration: Path '{vaultPath}' must be the path to a " + 80 | "file not a directory."; 81 | ErrorRecord err = new( 82 | new ArgumentException(msg), 83 | "SecretManagement.DpapiNG.PathIsDirectory", 84 | ErrorCategory.InvalidArgument, 85 | vaultPath 86 | ); 87 | WriteError(err); 88 | return; 89 | } 90 | 91 | ConnectionString connString = new() 92 | { 93 | Filename = providerPath, 94 | // Allows concurrent connections 95 | Connection = ConnectionType.Shared, 96 | ReadOnly = ReadOnly && dbExists, 97 | }; 98 | using LiteDatabase db = new(connString); 99 | ILiteCollection secrets = db.GetCollection("secrets"); 100 | ProcessVault(secrets); 101 | } 102 | 103 | internal abstract void ProcessVault(ILiteCollection secrets); 104 | } 105 | 106 | internal class Secret 107 | { 108 | public int Id { get; set; } 109 | public string Name { get; set; } = ""; 110 | public byte[] Value { get; set; } = Array.Empty(); 111 | public SecretType SecretType { get; set; } = SecretType.Unknown; 112 | public string Metadata { get; set; } = ""; 113 | } 114 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/EncodingAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Management.Automation; 5 | using System.Management.Automation.Language; 6 | using System.Text; 7 | using System.Globalization; 8 | 9 | namespace SecretManagement.DpapiNG.Module; 10 | 11 | public sealed class EncodingTransformAttribute : ArgumentTransformationAttribute 12 | { 13 | internal static string[] KNOWN_ENCODINGS = new[] { 14 | "UTF8", 15 | "ASCII", 16 | "ANSI", 17 | "OEM", 18 | "Unicode", 19 | "UTF8Bom", 20 | "UTF8NoBom" 21 | }; 22 | 23 | public override object Transform(EngineIntrinsics engineIntrinsics, object inputData) => inputData switch 24 | { 25 | Encoding => inputData, 26 | string s => GetEncodingFromString(s.ToUpperInvariant()), 27 | int i => Encoding.GetEncoding(i), 28 | _ => throw new ArgumentTransformationMetadataException($"Could not convert input '{inputData}' to a valid Encoding object."), 29 | }; 30 | 31 | private static Encoding GetEncodingFromString(string encoding) => encoding switch 32 | { 33 | "ASCII" => new ASCIIEncoding(), 34 | "ANSI" => Encoding.GetEncoding(CultureInfo.CurrentCulture.TextInfo.ANSICodePage), 35 | "BIGENDIANUNICODE" => new UnicodeEncoding(true, true), 36 | "BIGENDIANUTF32" => new UTF32Encoding(true, true), 37 | "OEM" => Console.OutputEncoding, 38 | "UNICODE" => new UnicodeEncoding(), 39 | "UTF8" => new UTF8Encoding(), 40 | "UTF8BOM" => new UTF8Encoding(true), 41 | "UTF8NOBOM" => new UTF8Encoding(), 42 | "UTF32" => new UTF32Encoding(), 43 | _ => Encoding.GetEncoding(encoding), 44 | }; 45 | } 46 | 47 | #if NET6_0_OR_GREATER 48 | public class EncodingCompletionsAttribute : ArgumentCompletionsAttribute 49 | { 50 | public EncodingCompletionsAttribute() : base(EncodingTransformAttribute.KNOWN_ENCODINGS) 51 | { } 52 | } 53 | #else 54 | public class EncodingCompletionsAttribute : IArgumentCompleter { 55 | public IEnumerable CompleteArgument( 56 | string commandName, 57 | string parameterName, 58 | string wordToComplete, 59 | CommandAst commandAst, 60 | IDictionary fakeBoundParameters 61 | ) 62 | { 63 | if (string.IsNullOrWhiteSpace(wordToComplete)) 64 | { 65 | wordToComplete = ""; 66 | } 67 | 68 | WildcardPattern pattern = new($"{wordToComplete}*"); 69 | foreach (string encoding in EncodingTransformAttribute.KNOWN_ENCODINGS) 70 | { 71 | if (pattern.IsMatch(encoding)) 72 | { 73 | yield return new CompletionResult(encoding); 74 | } 75 | } 76 | } 77 | } 78 | #endif 79 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/GetSecret.cs: -------------------------------------------------------------------------------- 1 | using LiteDB; 2 | using Microsoft.PowerShell.SecretManagement; 3 | using SecretManagement.DpapiNG.Native; 4 | using System; 5 | using System.Management.Automation; 6 | 7 | namespace SecretManagement.DpapiNG.Module; 8 | 9 | [Cmdlet(VerbsCommon.Get, "Secret")] 10 | public sealed class GetSecretCommand : DpapiNGSecretBase 11 | { 12 | protected override bool ReadOnly => true; 13 | 14 | [Parameter] 15 | public string Name { get; set; } = ""; 16 | 17 | [Parameter] 18 | public string VaultName { get; set; } = ""; 19 | 20 | internal override void ProcessVault(ILiteCollection secrets) 21 | { 22 | Secret? foundSecret = secrets.FindOne(x => x.Name == Name); 23 | if (foundSecret == null) 24 | { 25 | // SecretManagement handles this 26 | return; 27 | } 28 | 29 | using SafeNCryptData unprotectedData = NCrypt.NCryptUnprotectSecret( 30 | NCrypt.NCRYPT_SILENT_FLAG, 31 | foundSecret.Value, 32 | out var descriptor); 33 | descriptor.Dispose(); 34 | 35 | object outputObj = CreateOutputObject(unprotectedData.DangerousGetSpan(), foundSecret.SecretType); 36 | WriteObject(outputObj); 37 | } 38 | 39 | private static object CreateOutputObject(ReadOnlySpan data, SecretType secretType) => secretType switch 40 | { 41 | SecretType.ByteArray => data.ToArray(), 42 | SecretType.String => SecretConverters.ConvertToString(data), 43 | SecretType.SecureString => SecretConverters.ConvertToSecureString(data), 44 | SecretType.PSCredential => SecretConverters.ConvertToPSCredential(data), 45 | SecretType.Hashtable => SecretConverters.ConvertToHashtable(data), 46 | _ => throw new NotImplementedException(), 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/GetSecretInfo.cs: -------------------------------------------------------------------------------- 1 | using LiteDB; 2 | using Microsoft.PowerShell.SecretManagement; 3 | using System.Collections; 4 | using System.Collections.Generic; 5 | using System.Collections.ObjectModel; 6 | using System.Linq; 7 | using System.Management.Automation; 8 | 9 | namespace SecretManagement.DpapiNG.Module; 10 | 11 | [Cmdlet(VerbsCommon.Get, "SecretInfo")] 12 | public sealed class GetSecretInfoCommand : DpapiNGSecretBase 13 | { 14 | protected override bool ReadOnly => true; 15 | 16 | [Parameter] 17 | public string Filter { get; set; } = ""; 18 | 19 | [Parameter] 20 | public string VaultName { get; set; } = ""; 21 | 22 | internal override void ProcessVault(ILiteCollection secrets) 23 | { 24 | WildcardPattern pattern = new(Filter); 25 | 26 | foreach (Secret s in secrets.Find(Query.All("Name")).Where(x => pattern.IsMatch(x.Name.ToString()))) 27 | { 28 | Dictionary metadata = new(); 29 | Hashtable rawMeta = (Hashtable)((PSObject)PSSerializer.Deserialize(s.Metadata)).BaseObject; 30 | foreach (DictionaryEntry kvp in rawMeta) 31 | { 32 | metadata[kvp.Key?.ToString() ?? ""] = kvp.Value!; 33 | } 34 | 35 | ReadOnlyDictionary roMetadata = new(metadata); 36 | SecretInformation si = new(s.Name, s.SecretType, VaultName, roMetadata); 37 | WriteObject(si); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/RemoveSecret.cs: -------------------------------------------------------------------------------- 1 | using LiteDB; 2 | using System.Management.Automation; 3 | 4 | namespace SecretManagement.DpapiNG.Module; 5 | 6 | [Cmdlet(VerbsCommon.Remove, "Secret")] 7 | public sealed class RemoveSecretCommand : DpapiNGSecretBase 8 | { 9 | [Parameter] 10 | public string Name { get; set; } = ""; 11 | 12 | [Parameter] 13 | public string VaultName { get; set; } = ""; 14 | 15 | internal override void ProcessVault(ILiteCollection secrets) 16 | { 17 | Secret? existingSecret = secrets.FindOne(x => x.Name == Name); 18 | if (existingSecret != null) 19 | { 20 | secrets.Delete(existingSecret.Id); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/SecretConverters.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Management.Automation; 4 | using System.Net; 5 | using System.Security; 6 | using System.Text; 7 | 8 | namespace SecretManagement.DpapiNG.Module; 9 | 10 | internal static class SecretConverters 11 | { 12 | public static string ConvertToString(ReadOnlySpan data) 13 | => ConvertToString(data, Encoding.UTF8); 14 | 15 | public static string ConvertToString(ReadOnlySpan data, Encoding encoding) 16 | { 17 | #if NET6_0_OR_GREATER 18 | return encoding.GetString(data); 19 | #else 20 | unsafe 21 | { 22 | fixed (byte* dataPtr = data) 23 | { 24 | return encoding.GetString(dataPtr, data.Length); 25 | } 26 | } 27 | #endif 28 | } 29 | 30 | public static SecureString ConvertToSecureString(ReadOnlySpan data) 31 | => ConvertToSecureString(ConvertToString(data)); 32 | 33 | public static SecureString ConvertToSecureString(ReadOnlySpan data, Encoding encoding) 34 | => ConvertToSecureString(ConvertToString(data, encoding)); 35 | 36 | public static SecureString ConvertToSecureString(string data) 37 | { 38 | unsafe 39 | { 40 | fixed (char* dataPtr = data.ToCharArray()) 41 | { 42 | return new(dataPtr, data.Length); 43 | } 44 | } 45 | } 46 | 47 | public static Span ConvertFromSecureString(SecureString data) 48 | => Encoding.UTF8.GetBytes(new NetworkCredential("", data).Password); 49 | 50 | public static PSCredential ConvertToPSCredential(ReadOnlySpan data) 51 | { 52 | Hashtable raw = ConvertToHashtable(data); 53 | 54 | return new( 55 | raw["UserName"]?.ToString() ?? "", 56 | ConvertToSecureString(raw["Password"]?.ToString() ?? "") 57 | ); 58 | } 59 | 60 | public static Span ConvertFromPSCredential(PSCredential data) 61 | { 62 | Hashtable psco = new() 63 | { 64 | { "UserName", data.UserName }, 65 | { "Password", data.GetNetworkCredential().Password }, 66 | }; 67 | 68 | return ConvertFromHashtable(psco); 69 | } 70 | 71 | public static Hashtable ConvertToHashtable(ReadOnlySpan data) 72 | => (Hashtable)((PSObject)PSSerializer.Deserialize(ConvertToString(data))).BaseObject; 73 | 74 | public static Span ConvertFromHashtable(Hashtable data) 75 | => Encoding.UTF8.GetBytes(PSSerializer.Serialize(data)); 76 | } 77 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/SecretManagement.DpapiNG.Module.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net472;net6.0 5 | true 6 | 10.0 7 | enable 8 | NU1701 9 | 10 | 11 | 12 | true 13 | true 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/SetSecret.cs: -------------------------------------------------------------------------------- 1 | using LiteDB; 2 | using Microsoft.PowerShell.SecretManagement; 3 | using SecretManagement.DpapiNG.Native; 4 | using System; 5 | using System.Collections; 6 | using System.Management.Automation; 7 | using System.Security; 8 | using System.Text; 9 | 10 | namespace SecretManagement.DpapiNG.Module; 11 | 12 | [Cmdlet(VerbsCommon.Set, "Secret")] 13 | public sealed class SetSecretCommand : DpapiNGSecretBase 14 | { 15 | [Parameter] 16 | public string Name { get; set; } = ""; 17 | 18 | [Parameter] 19 | public object? Secret { get; set; } 20 | 21 | [Parameter] 22 | public string VaultName { get; set; } = ""; 23 | 24 | [Parameter] 25 | public Hashtable Metadata { get; set; } = new(); 26 | 27 | internal override void ProcessVault(ILiteCollection secrets) 28 | { 29 | Secret? existingSecret = secrets.FindOne(x => x.Name == Name); 30 | 31 | string metadata = ProcessMetadata(out string protectionDescriptor); 32 | 33 | using SafeNCryptProtectionDescriptor desc = NCrypt.NCryptCreateProtectionDescriptor(protectionDescriptor, 0); 34 | 35 | Span toProtect = GetSecretBytes(out SecretType secretType); 36 | if (secretType == SecretType.Unknown) 37 | { 38 | ErrorRecord err = new( 39 | new ArgumentException($"Invalid Secret type, cannot store secret of type '{Secret!.GetType().Name}'"), 40 | "SecretManagement.DpapiNG.InvalidSecretType", 41 | ErrorCategory.InvalidArgument, 42 | null 43 | ); 44 | return; 45 | } 46 | 47 | using SafeNCryptData protectedBlock = NCrypt.NCryptProtectSecret(desc, NCrypt.NCRYPT_SILENT_FLAG, toProtect); 48 | byte[] protectedData = protectedBlock.DangerousGetSpan().ToArray(); 49 | 50 | if (existingSecret != null) 51 | { 52 | existingSecret.Value = protectedData; 53 | existingSecret.SecretType = secretType; 54 | existingSecret.Metadata = metadata; 55 | secrets.Update(existingSecret); 56 | } 57 | else 58 | { 59 | Secret secret = new() 60 | { 61 | Name = Name, 62 | Value = protectedData, 63 | SecretType = secretType, 64 | Metadata = metadata, 65 | }; 66 | secrets.EnsureIndex(x => x.Name, true); 67 | secrets.Insert(secret); 68 | } 69 | } 70 | 71 | private Span GetSecretBytes(out SecretType secretType) 72 | { 73 | if (Secret is byte[] ba) 74 | { 75 | secretType = SecretType.ByteArray; 76 | return ba; 77 | } 78 | else if (Secret is string s) 79 | { 80 | secretType = SecretType.String; 81 | return Encoding.UTF8.GetBytes(s); 82 | } 83 | else if (Secret is SecureString ss) 84 | { 85 | secretType = SecretType.SecureString; 86 | return SecretConverters.ConvertFromSecureString(ss); 87 | } 88 | else if (Secret is PSCredential ps) 89 | { 90 | secretType = SecretType.PSCredential; 91 | return SecretConverters.ConvertFromPSCredential(ps); 92 | } 93 | else if (Secret is Hashtable ht) 94 | { 95 | secretType = SecretType.Hashtable; 96 | return SecretConverters.ConvertFromHashtable(ht); 97 | } 98 | 99 | secretType = SecretType.Unknown; 100 | return default; 101 | } 102 | 103 | private string ProcessMetadata(out string protectionDescriptor) 104 | { 105 | Hashtable localMetadata = (Hashtable)Metadata.Clone(); 106 | if (localMetadata.ContainsKey("ProtectionDescriptor")) 107 | { 108 | protectionDescriptor = localMetadata["ProtectionDescriptor"]?.ToString() ?? ""; 109 | } 110 | else 111 | { 112 | protectionDescriptor = "LOCAL=user"; 113 | localMetadata["ProtectionDescriptor"] = protectionDescriptor; 114 | } 115 | 116 | return PSSerializer.Serialize(localMetadata); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/SetSecretInfo.cs: -------------------------------------------------------------------------------- 1 | using LiteDB; 2 | using System; 3 | using System.Collections; 4 | using System.Management.Automation; 5 | 6 | namespace SecretManagement.DpapiNG.Module; 7 | 8 | [Cmdlet(VerbsCommon.Set, "SecretInfo")] 9 | public sealed class SetSecretInfoCommand : DpapiNGSecretBase 10 | { 11 | [Parameter] 12 | public string Name { get; set; } = ""; 13 | 14 | [Parameter] 15 | public Hashtable Metadata { get; set; } = new(); 16 | 17 | [Parameter] 18 | public string VaultName { get; set; } = ""; 19 | 20 | internal override void ProcessVault(ILiteCollection secrets) 21 | { 22 | Secret? existingSecret = secrets.FindOne(x => x.Name == Name); 23 | if (existingSecret == null) 24 | { 25 | string msg = 26 | $"Failed to find SecretManagement.DpapiNG vault secret '{Name}'. The secret must exist to set the " + 27 | "metadata on. Use Set-Secret to create a secret with metadata instead."; 28 | ErrorRecord err = new( 29 | new ArgumentException(msg), 30 | "SecretManagement.DpapiNG.SetSecretInfoNoSecret", 31 | ErrorCategory.InvalidArgument, 32 | Name 33 | ); 34 | WriteError(err); 35 | return; 36 | } 37 | 38 | Hashtable existingMetadata = (Hashtable)((PSObject)PSSerializer.Deserialize(existingSecret.Metadata)).BaseObject; 39 | bool changed = false; 40 | foreach (DictionaryEntry kvp in Metadata) 41 | { 42 | string key = kvp.Key.ToString() ?? ""; 43 | object? value = kvp.Value; 44 | object? existingValue = null; 45 | if (existingMetadata.ContainsKey(key)) 46 | { 47 | existingValue = existingMetadata[key]; 48 | } 49 | 50 | if (key == "ProtectionDescriptor") 51 | { 52 | if (!LanguagePrimitives.Equals(value, existingValue)) 53 | { 54 | string msg = 55 | "It is not possible to change the ProtectionDescriptor for an existing set. Use " + 56 | "Set-SecretInfo to create a new secret instead."; 57 | ErrorRecord err = new( 58 | new ArgumentException(msg), 59 | "SecretManagement.DpapiNG.SetSecretInfoChangedDesc", 60 | ErrorCategory.InvalidArgument, 61 | value 62 | ); 63 | WriteError(err); 64 | } 65 | } 66 | else if (existingValue == null || !LanguagePrimitives.Equals(value, existingValue)) 67 | { 68 | // The entry wasn't present or doesn't match the new value. 69 | existingMetadata[key] = value; 70 | changed = true; 71 | } 72 | } 73 | 74 | if (changed) 75 | { 76 | existingSecret.Metadata = PSSerializer.Serialize(existingMetadata); 77 | secrets.Update(existingSecret); 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG.Module/TestSecretVault.cs: -------------------------------------------------------------------------------- 1 | using LiteDB; 2 | using System.Management.Automation; 3 | 4 | namespace SecretManagement.DpapiNG.Module; 5 | 6 | [Cmdlet(VerbsDiagnostic.Test, "SecretVault")] 7 | public sealed class TestSecretVaultCommand : DpapiNGSecretBase 8 | { 9 | protected override bool ReadOnly => true; 10 | 11 | [Parameter] 12 | public string VaultName { get; set; } = ""; 13 | 14 | internal override void ProcessVault(ILiteCollection secrets) 15 | { 16 | // Checks are done in the base cmdlet. 17 | WriteObject(true); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG/LoadContext.cs: -------------------------------------------------------------------------------- 1 | #if NET6_0_OR_GREATER 2 | using System.IO; 3 | using System.Reflection; 4 | using System.Runtime.Loader; 5 | 6 | namespace SecretManagement.DpapiNG; 7 | 8 | public class LoadContext : AssemblyLoadContext 9 | { 10 | private static LoadContext? _instance; 11 | private static object _sync = new object(); 12 | 13 | private Assembly _thisAssembly; 14 | private AssemblyName _thisAssemblyName; 15 | private Assembly _moduleAssembly; 16 | private string _assemblyDir; 17 | 18 | private LoadContext(string mainModulePathAssemblyPath) 19 | : base(name: "SecretManagement.DpapiNG", isCollectible: false) 20 | { 21 | _assemblyDir = Path.GetDirectoryName(mainModulePathAssemblyPath) ?? ""; 22 | _thisAssembly = typeof(LoadContext).Assembly; 23 | _thisAssemblyName = _thisAssembly.GetName(); 24 | _moduleAssembly = LoadFromAssemblyPath(mainModulePathAssemblyPath); 25 | } 26 | 27 | protected override Assembly? Load(AssemblyName assemblyName) 28 | { 29 | if (AssemblyName.ReferenceMatchesDefinition(_thisAssemblyName, assemblyName)) 30 | { 31 | return _thisAssembly; 32 | } 33 | 34 | string asmPath = Path.Join(_assemblyDir, $"{assemblyName.Name}.dll"); 35 | if (File.Exists(asmPath)) 36 | { 37 | return LoadFromAssemblyPath(asmPath); 38 | } 39 | else 40 | { 41 | return null; 42 | } 43 | } 44 | 45 | public static Assembly Initialize() 46 | { 47 | LoadContext? instance = _instance; 48 | if (instance is not null) 49 | { 50 | return instance._moduleAssembly; 51 | } 52 | 53 | lock (_sync) 54 | { 55 | if (_instance is not null) 56 | { 57 | return _instance._moduleAssembly; 58 | } 59 | 60 | string assemblyPath = typeof(LoadContext).Assembly.Location; 61 | string modulePath = Path.Combine( 62 | Path.GetDirectoryName(assemblyPath)!, 63 | $"{Path.GetFileNameWithoutExtension(assemblyPath)}.Module.dll" 64 | ); 65 | _instance = new LoadContext(modulePath); 66 | return _instance._moduleAssembly; 67 | } 68 | } 69 | } 70 | #endif 71 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG/Native/NCryptCloseProtectionDescriptor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.InteropServices; 3 | 4 | namespace SecretManagement.DpapiNG.Native; 5 | 6 | public static partial class NCrypt 7 | { 8 | [DllImport("NCrypt.dll")] 9 | public static extern int NCryptCloseProtectionDescriptor( 10 | nint hDescriptor 11 | ); 12 | } 13 | 14 | public sealed class SafeNCryptProtectionDescriptor : SafeHandle 15 | { 16 | internal SafeNCryptProtectionDescriptor() : base(IntPtr.Zero, false) 17 | { } 18 | 19 | internal SafeNCryptProtectionDescriptor(nint handle) : base(handle, true) 20 | { } 21 | 22 | public override bool IsInvalid => handle == IntPtr.Zero; 23 | 24 | protected override bool ReleaseHandle() 25 | { 26 | return NCrypt.NCryptCloseProtectionDescriptor(handle) == 0; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG/Native/NCryptCreateProtectionDescriptor.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | using System.Runtime.InteropServices; 3 | 4 | namespace SecretManagement.DpapiNG.Native; 5 | 6 | public static partial class NCrypt 7 | { 8 | public const int NCRYPT_NAMED_DESCRIPTOR_FLAG = 0x00000001; 9 | public const int NCRYPT_MACHINE_KEY_FLAG = 0x00000020; 10 | 11 | [DllImport("NCrypt.dll", CharSet = CharSet.Unicode)] 12 | private static extern int NCryptCreateProtectionDescriptor( 13 | [MarshalAs(UnmanagedType.LPWStr)] string pwszDescriptorString, 14 | int dwFlags, 15 | out SafeNCryptProtectionDescriptor phDescriptor 16 | ); 17 | 18 | public static SafeNCryptProtectionDescriptor NCryptCreateProtectionDescriptor( 19 | string descriptorString, 20 | int flags 21 | ) 22 | { 23 | int res = NCryptCreateProtectionDescriptor(descriptorString, flags, out var desc); 24 | if (res != 0) 25 | { 26 | throw new Win32Exception(res); 27 | } 28 | 29 | return desc; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG/Native/NCryptProtectSecret.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Runtime.InteropServices; 4 | 5 | namespace SecretManagement.DpapiNG.Native; 6 | 7 | public static partial class NCrypt 8 | { 9 | public const int NCRYPT_SILENT_FLAG = 0x00000040; 10 | 11 | [DllImport("NCrypt.dll")] 12 | private unsafe static extern int NCryptProtectSecret( 13 | nint hDescriptor, 14 | int dwFlags, 15 | byte* pbData, 16 | int cbData, 17 | nint pMemPara, 18 | nint hWnd, 19 | out nint ppbProtectedBlock, 20 | out int pcbProtectedBlock 21 | ); 22 | 23 | public static SafeNCryptData NCryptProtectSecret( 24 | SafeNCryptProtectionDescriptor descriptor, 25 | int flags, 26 | ReadOnlySpan data 27 | ) 28 | { 29 | int res = 0; 30 | nint protectedBlock; 31 | int protectedLength; 32 | 33 | unsafe 34 | { 35 | fixed (byte* toEncrypt = data) 36 | { 37 | res = NCryptProtectSecret( 38 | descriptor.DangerousGetHandle(), 39 | flags, 40 | toEncrypt, 41 | data.Length, 42 | IntPtr.Zero, 43 | IntPtr.Zero, 44 | out protectedBlock, 45 | out protectedLength 46 | ); 47 | } 48 | } 49 | 50 | if (res != 0) 51 | { 52 | throw new Win32Exception(res); 53 | } 54 | 55 | return new(protectedBlock, protectedLength); 56 | } 57 | } 58 | 59 | public sealed class SafeNCryptData : SafeHandle 60 | { 61 | public int Length { get; } 62 | 63 | internal SafeNCryptData(nint buffer, int length) : base(buffer, true) 64 | { 65 | Length = length; 66 | } 67 | 68 | public override bool IsInvalid => handle == IntPtr.Zero; 69 | 70 | public Span DangerousGetSpan() 71 | { 72 | unsafe 73 | { 74 | return new((void*)handle, Length); 75 | } 76 | } 77 | 78 | protected override bool ReleaseHandle() 79 | { 80 | Marshal.FreeHGlobal(handle); 81 | return true; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG/Native/NCryptUnprotectSecret.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Runtime.InteropServices; 4 | 5 | namespace SecretManagement.DpapiNG.Native; 6 | 7 | public static partial class NCrypt 8 | { 9 | public const int NCRYPT_UNPROTECT_NO_DECRYPT = 0x00000001; 10 | 11 | [DllImport("NCrypt.dll")] 12 | private unsafe static extern int NCryptUnprotectSecret( 13 | out nint hDescriptor, 14 | int dwFlags, 15 | byte* pbProtectedBlob, 16 | int cbProtectedBlob, 17 | nint pMemPara, 18 | nint hWnd, 19 | out nint ppbData, 20 | out int pcbData 21 | ); 22 | 23 | public static SafeNCryptData NCryptUnprotectSecret( 24 | int flags, 25 | ReadOnlySpan protectedBlob, 26 | out SafeNCryptProtectionDescriptor descriptor 27 | ) 28 | { 29 | int res = 0; 30 | nint descriptorHandle; 31 | nint data; 32 | int dataLength; 33 | 34 | unsafe 35 | { 36 | fixed (byte* toDecrypt = protectedBlob) 37 | { 38 | res = NCryptUnprotectSecret( 39 | out descriptorHandle, 40 | flags, 41 | toDecrypt, 42 | protectedBlob.Length, 43 | IntPtr.Zero, 44 | IntPtr.Zero, 45 | out data, 46 | out dataLength 47 | ); 48 | } 49 | } 50 | 51 | if (res != 0) 52 | { 53 | throw new Win32Exception(res); 54 | } 55 | 56 | descriptor = new(descriptorHandle); 57 | return new(data, dataLength); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/SecretManagement.DpapiNG/SecretManagement.DpapiNG.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net472;net6.0 5 | true 6 | enable 7 | 10.0 8 | 9 | 10 | 11 | true 12 | true 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /tests/Convert-DpapiNGSecret.Tests.ps1: -------------------------------------------------------------------------------- 1 | . ([System.IO.Path]::Combine($PSScriptRoot, 'common.ps1')) 2 | 3 | Describe "Convert*-DpapiNGSecret" { 4 | It "Converts a string secret" { 5 | $secret = ConvertTo-DpapiNGSecret foo 6 | 7 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 8 | } 9 | 10 | It "Converts a string secret as pipeline input" { 11 | $secret = 'foo' | ConvertTo-DpapiNGSecret 12 | 13 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 14 | } 15 | 16 | It "Converts a SecureString secret" { 17 | $value = ConvertTo-SecureString -AsPlainText -Force foo 18 | $secret = ConvertTo-DpapiNGSecret $value 19 | 20 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 21 | } 22 | 23 | It "Converts a SecureString secret as pipeline input" { 24 | $value = ConvertTo-SecureString -AsPlainText -Force foo 25 | $secret = $value | ConvertTo-DpapiNGSecret 26 | 27 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 28 | } 29 | 30 | It "Converts a byte[] secret" { 31 | $value = [System.Text.Encoding]::UTF8.GetBytes("foo") 32 | $secret = ConvertTo-DpapiNGSecret $value 33 | 34 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 35 | } 36 | 37 | It "Converts a byte[] secret as pipeline input" { 38 | $value = [System.Text.Encoding]::UTF8.GetBytes("foo") 39 | $secret = , $value | ConvertTo-DpapiNGSecret 40 | 41 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 42 | } 43 | 44 | It "Converts a byte array as list secret" { 45 | $value = [System.Collections.Generic.List[byte]][System.Text.Encoding]::UTF8.GetBytes("foo") 46 | $secret = ConvertTo-DpapiNGSecret $value 47 | 48 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 49 | } 50 | 51 | It "Converts other objects to a string for secret" { 52 | $obj = [PSCustomObject]@{} 53 | $obj | Add-Member -Name ToString -MemberType ScriptMethod -Value { "foo" } -Force 54 | 55 | $secret = ConvertTo-DpapiNGSecret $obj 56 | 57 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 58 | } 59 | 60 | It "Decrypts to a SecureString" { 61 | $secret = ConvertTo-DpapiNGSecret foo 62 | 63 | $actual = $secret | ConvertFrom-DpapiNGSecret 64 | $actual | Should -BeOfType ([securestring]) 65 | [System.Net.NetworkCredential]::new("", $actual).Password | Should -Be foo 66 | } 67 | 68 | It "Decrypts to a Byte Array" { 69 | $secret = ConvertTo-DpapiNGSecret foo 70 | 71 | $actual = $secret | ConvertFrom-DpapiNGSecret -AsByteArray 72 | , $actual | Should -BeOfType ([byte[]]) 73 | $actual.Count | Should -Be 3 74 | $actual[0] | Should -Be ([byte]102) 75 | $actual[1] | Should -Be ([byte]111) 76 | $actual[2] | Should -Be ([byte]111) 77 | } 78 | 79 | It "Fails to convert with invalid descriptor" { 80 | { 81 | ConvertTo-DpapiNGSecret foo -ProtectionDescriptor 'INVALID=value' 82 | } | Should -Throw 83 | } 84 | 85 | It "Fails to decrypt data" -Skip:$SIDUnvailable { 86 | $secret = ConvertTo-DpapiNGSecret foo -Sid "S-1-5-19" 87 | 88 | $actual = $secret | ConvertFrom-DpapiNGSecret -ErrorAction SilentlyContinue -ErrorVariable err 89 | $actual | Should -BeNullOrEmpty 90 | $err.Count | Should -Be 1 91 | [string]$err | Should -BeLike "Failed to decrypt data: The specified data could not be decrypted* (0x8009002C)" 92 | } 93 | 94 | It "Converts string with encoding " -TestCases @( 95 | @{ Encoding = 'utf8'; Expected = "636166C3A9" } 96 | @{ Encoding = 65001; Expected = "636166C3A9" } 97 | @{ Encoding = [System.Text.Encoding]::UTF8; Expected = "636166C3A9" } 98 | @{ Encoding = 'windows-1252'; Expected = "636166E9" } 99 | @{ Encoding = 'Unicode'; Expected = "630061006600E900" } 100 | ) { 101 | param ($Encoding, $Expected) 102 | 103 | # café 104 | $value = "caf$([char]0xE9)" 105 | 106 | $secret = ConvertTo-DpapiNGSecret $value -Encoding $Encoding 107 | 108 | $secret | ConvertFrom-DpapiNGSecret -AsString -Encoding $Encoding | Should -Be $value 109 | 110 | $actualBytes = (($secret | ConvertFrom-DpapiNGSecret -AsByteArray) | ForEach-Object ToString X2) -join "" 111 | $actualBytes | Should -Be $Expected 112 | } 113 | 114 | It "Converts with protection descriptor string" { 115 | $secret = 'foo' | ConvertTo-DpapiNGSecret -ProtectionDescriptor "LOCAL=user" 116 | 117 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 118 | } 119 | 120 | It "Converts with protection descriptor object" { 121 | $desc = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Local User 122 | $secret = 'foo' | ConvertTo-DpapiNGSecret -ProtectionDescriptor $desc 123 | 124 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 125 | } 126 | 127 | It "Converts with the current sid" -Skip:$SIDUnvailable { 128 | $secret = ConvertTo-DpapiNGSecret foo -CurrentSid 129 | 130 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 131 | } 132 | 133 | It "Converts with the explicit sid " -Skip:$SIDUnvailable -TestCases @( 134 | @{ Sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User } 135 | @{ Sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value } 136 | @{ Sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Translate([System.Security.Principal.NTAccount]) } 137 | ) { 138 | param ($Sid) 139 | 140 | $secret = ConvertTo-DpapiNGSecret foo -Sid $Sid 141 | 142 | $secret | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 143 | } 144 | 145 | It "Completes -Encoding parameter" -TestCases @( 146 | @{ Cmdlet = 'ConvertFrom-DpapiNGSecret' } 147 | @{ Cmdlet = 'ConvertTo-DpapiNGSecret' } 148 | ) { 149 | param ($Cmdlet) 150 | 151 | $actual = Complete "$Cmdlet -Encoding " 152 | $actual.Count | Should -Be 7 153 | 154 | # The first should be UTF8 155 | $actual[0].CompletionText | Should -Be 'UTF8' 156 | 157 | $actual | ForEach-Object { 158 | $_.CompletionText | Should -BeIn @( 159 | "UTF8" 160 | "ASCII" 161 | "ANSI" 162 | "OEM" 163 | "Unicode" 164 | "UTF8Bom" 165 | "UTF8NoBom" 166 | ) 167 | } 168 | } 169 | 170 | It "Completes -Encoding parameter with partial match" -TestCases @( 171 | @{ Cmdlet = 'ConvertFrom-DpapiNGSecret' } 172 | @{ Cmdlet = 'ConvertTo-DpapiNGSecret' } 173 | ) { 174 | param ($Cmdlet) 175 | 176 | $actual = Complete "$Cmdlet -Encoding A" 177 | $actual.Count | Should -Be 2 178 | 179 | $actual | ForEach-Object { 180 | $_.CompletionText | Should -BeIn @( 181 | "ASCII" 182 | "ANSI" 183 | ) 184 | } 185 | 186 | $actual = Complete "$Cmdlet -Encoding A*" 187 | $actual.Count | Should -Be 2 188 | 189 | $actual | ForEach-Object { 190 | $_.CompletionText | Should -BeIn @( 191 | "ASCII" 192 | "ANSI" 193 | ) 194 | } 195 | } 196 | 197 | Context "Certificate tests" { 198 | BeforeAll { 199 | $cert = New-X509Certificate -Subject DPAPING-Test 200 | 201 | $certWithPublicBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) 202 | $certWithPrivateBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx) 203 | $certWithPublicOnly = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certWithPublicBytes) 204 | } 205 | 206 | AfterAll { 207 | $cert.Dispose() 208 | $certWithPublicOnly.Dispose() 209 | } 210 | 211 | It "Fails with cert thumbprint not in user store" { 212 | $actual = ConvertTo-DpapiNGSecret foo -CertificateThumbprint $cert.Thumbprint -ErrorAction SilentlyContinue -ErrorVariable err 213 | $actual | Should -BeNullOrEmpty 214 | $err.Count | Should -Be 1 215 | [string]$err | Should -BeLike "Failed to encrypt data: * (*)" 216 | } 217 | 218 | It "Protects with CertificateThumbprint" { 219 | $certWithPrivate = $myStore = $null 220 | try { 221 | $certWithPrivate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( 222 | $certWithPrivateBytes, 223 | "", 224 | [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::UserKeySet) 225 | $myStore = Get-Item Cert:\CurrentUser\My 226 | $myStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) 227 | $myStore.Add($certWithPrivate) 228 | 229 | $actual = ConvertTo-DpapiNGSecret foo -CertificateThumbprint $certWithPublicOnly.Thumbprint 230 | 231 | ConvertFrom-DpapiNGSecret $actual -AsString | Should -Be foo 232 | } 233 | finally { 234 | if ($myStore) { 235 | $myStore.Remove($certWithPrivate) 236 | $myStore.Dispose() 237 | } 238 | if ($certWithPrivate) { 239 | $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey( 240 | $certWithPrivate) 241 | $key.Key.Delete() 242 | $certWithPrivate.Dispose() 243 | } 244 | } 245 | } 246 | 247 | It "Protects with Certificate object" { 248 | $actual = ConvertTo-DpapiNGSecret foo -Certificate $certWithPublicOnly 249 | 250 | $certWithPrivate = $myStore = $null 251 | try { 252 | $certWithPrivate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( 253 | $certWithPrivateBytes, 254 | "", 255 | [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::UserKeySet) 256 | 257 | $myStore = Get-Item Cert:\CurrentUser\My 258 | $myStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) 259 | $myStore.Add($certWithPrivate) 260 | 261 | ConvertFrom-DpapiNGSecret $actual -AsString | Should -Be foo 262 | } 263 | finally { 264 | if ($myStore) { 265 | $myStore.Remove($certWithPrivate) 266 | $myStore.Dispose() 267 | } 268 | if ($certWithPrivate) { 269 | $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey( 270 | $certWithPrivate) 271 | $key.Key.Delete() 272 | $certWithPrivate.Dispose() 273 | } 274 | } 275 | } 276 | 277 | It "Fails to decrypt Certificate without stored key" { 278 | $actual = ConvertTo-DpapiNGSecret foo -Certificate $certWithPublicOnly 279 | 280 | $actual = ConvertFrom-DpapiNGSecret $actual -AsString -ErrorAction SilentlyContinue -ErrorVariable err 281 | $actual | Should -BeNullOrEmpty 282 | $err.Count | Should -Be 1 283 | [string]$err | Should -BeLike "Failed to decrypt data: * (*)" 284 | } 285 | 286 | It "Fails to decrypt Certificate with only public certificate" { 287 | $actual = ConvertTo-DpapiNGSecret foo -Certificate $certWithPublicOnly 288 | 289 | $myStore = $null 290 | try { 291 | $myStore = Get-Item Cert:\CurrentUser\My 292 | $myStore.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadWrite) 293 | $myStore.Add($certWithPublicOnly) 294 | 295 | $actual = ConvertFrom-DpapiNGSecret $actual -AsString -ErrorAction SilentlyContinue -ErrorVariable err 296 | $actual | Should -BeNullOrEmpty 297 | $err.Count | Should -Be 1 298 | [string]$err | Should -BeLike "Failed to decrypt data: * (*)" 299 | } 300 | finally { 301 | if ($myStore) { 302 | $myStore.Remove($certWithPublicOnly) 303 | $myStore.Dispose() 304 | } 305 | } 306 | } 307 | } 308 | 309 | Context "WebCredential test" { 310 | BeforeAll { 311 | $resource = 'SecretManagement.DpapiNG.Test' 312 | $username = 'DpapiNG-User' 313 | 314 | # WinRT only works in Windows PowerShell, use an implicit 315 | # removing session for Pwsh. 316 | $session = $null 317 | if ($IsCoreCLR) { 318 | $session = New-PSSession -UseWindowsPowerShell 319 | 320 | Invoke-Command -Session $session -Scriptblock ${function:New-WebCredential} -ArgumentList $resource, $username 321 | } 322 | else { 323 | New-WebCredential -Resource $resource -UserName $username 324 | } 325 | } 326 | 327 | AfterAll { 328 | if ($session) { 329 | Invoke-Command -Session $session -Scriptblock ${function:Remove-WebCredential} -ArgumentList $resource, $username 330 | 331 | $session | Remove-PSSession 332 | } 333 | else { 334 | Remove-WebCredential -Resource $resource -UserName $username 335 | } 336 | } 337 | 338 | It "Creates web credential secret" { 339 | $actual = ConvertTo-DpapiNGSecret foo -WebCredential "$username,$resource" 340 | 341 | $actual | ConvertFrom-DpapiNGSecret -AsString | Should -Be foo 342 | } 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /tests/DpapiNGDescriptor.Tests.ps1: -------------------------------------------------------------------------------- 1 | . ([System.IO.Path]::Combine($PSScriptRoot, 'common.ps1')) 2 | 3 | Describe "*-DpapiNGDescriptor" { 4 | It "Builds descriptor string" { 5 | $actual = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Local User 6 | $actual.ToString() | Should -Be "LOCAL=user" 7 | } 8 | 9 | It "Builds metadata splat" { 10 | $actual = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Local User 11 | $enumerated = @($actual) 12 | $enumerated.Count | Should -Be 2 13 | $enumerated[0] | Should -Be "-Metadata" 14 | $enumerated[1] | Should -BeOfType ([Hashtable]) 15 | $enumerated[1].Count | Should -Be 1 16 | $enumerated[1].ProtectionDescriptor | Should -Be "LOCAL=user" 17 | } 18 | 19 | It "Adds local descriptor" { 20 | $actual = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Local Machine 21 | $actual.ToString() | Should -Be "LOCAL=machine" 22 | } 23 | 24 | It "Adds sid descriptor" { 25 | $actual = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Sid "S-1-5-19" 26 | $actual.ToString() | Should -Be "SID=S-1-5-19" 27 | } 28 | 29 | It "Adds sid from SID type" { 30 | $actual = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Sid ([System.Security.Principal.SecurityIdentifier]::new("S-1-5-18")) 31 | $actual.ToString() | Should -Be "SID=S-1-5-18" 32 | } 33 | 34 | It "Adds sid from NTAccount type" { 35 | $actual = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Sid ([System.Security.Principal.NTAccount]::new("SYSTEM")) 36 | $actual.ToString() | Should -Be "SID=S-1-5-18" 37 | } 38 | 39 | It "Adds sid from translated account string" { 40 | $actual = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Sid SYSTEM 41 | $actual.ToString() | Should -Be "SID=S-1-5-18" 42 | } 43 | 44 | It "Fails to translate unknown account string for SID" { 45 | $err = { 46 | New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Sid invalid 47 | } | Should -Throw -PassThru 48 | [string]$err | Should -BeLike "Cannot bind parameter 'Sid'. Cannot convert value `"invalid`" to type `"StringOrAccount`". *" 49 | } 50 | 51 | It "Adds current sid descriptor" { 52 | $actual = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -CurrentSid 53 | $actual.ToString() | Should -Be "SID=$([System.Security.Principal.WindowsIdentity]::GetCurrent().User)" 54 | } 55 | 56 | It "Combines multiple conditional" { 57 | $actual = New-DpapiNGDescriptor | 58 | Add-DpapiNGDescriptor -Local User | 59 | Add-DpapiNGDescriptor -Local Machine | 60 | Add-DpapiNGDescriptor -Sid "S-1-5-19" -Or 61 | $actual.ToString() | Should -Be "LOCAL=user AND LOCAL=machine OR SID=S-1-5-19" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/SecretManagement.Tests.ps1: -------------------------------------------------------------------------------- 1 | . ([System.IO.Path]::Combine($PSScriptRoot, 'common.ps1')) 2 | 3 | Describe "SecretManagement" { 4 | BeforeAll { 5 | $vault = 'DpapiNGTest' 6 | $vaultPath = "TestDrive:\dpapi-ng.vault" 7 | 8 | Register-SecretVault -Name $vault -ModuleName $CurrentModule.Path -VaultParameters @{ 9 | Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($vaultPath) 10 | } 11 | } 12 | 13 | AfterAll { 14 | Unregister-SecretVault -Name $vault 15 | Remove-Item -Path $vaultPath -Force -ErrorAction SilentlyContinue 16 | } 17 | 18 | AfterEach { 19 | Get-SecretInfo -Vault $vault | Remove-Secret 20 | } 21 | 22 | It "Verifies a valid vault registration" { 23 | Test-SecretVault -Name $vault | Should -BeTrue 24 | } 25 | 26 | It "Sets a secret" { 27 | Set-Secret -Name MySecret -Secret value -Vault $vault 28 | 29 | $actual = Get-Secret -Name MySecret -Vault $vault -AsPlainText 30 | $actual | Should -Be value 31 | } 32 | 33 | It "Overwrites a secret" { 34 | Set-Secret -Name MySecret -Secret value -Vault $vault 35 | Set-Secret -Name MySecret -Secret value2 -Vault $vault 36 | 37 | $actual = Get-Secret -Name MySecret -Vault $vault -AsPlainText 38 | $actual | Should -Be value2 39 | } 40 | 41 | It "Fails to overwrite a secret with NoClobber" { 42 | Set-Secret -Name MySecret -Secret value -Vault $vault 43 | 44 | { 45 | Set-Secret -Name MySecret -Secret value2 -Vault $vault -NoClobber 46 | } | Should -Throw "A secret with name MySecret already exists in vault $vault." 47 | 48 | $actual = Get-Secret -Name MySecret -Vault $vault -AsPlainText 49 | $actual | Should -Be value 50 | } 51 | 52 | It "Sets a byte array secret" { 53 | $value = [byte[]]@(0, 1, 2, 3) 54 | 55 | Set-Secret -Name MySecret -Secret $value -Vault $vault 56 | $actual = Get-Secret -Name MySecret -Vault $vault 57 | , $actual | Should -BeOfType ([byte[]]) 58 | $actual.Count | Should -Be 4 59 | $actual[0] | Should -Be ([byte]0) 60 | $actual[1] | Should -Be ([byte]1) 61 | $actual[2] | Should -Be ([byte]2) 62 | $actual[3] | Should -Be ([byte]3) 63 | } 64 | 65 | It "Sets a SecureString secret" { 66 | $value = ConvertTo-SecureString -AsPlainText -Force value 67 | 68 | Set-Secret -Name MySecret -Secret $value -Vault $vault 69 | 70 | $actual = Get-Secret -Name MySecret -Vault $vault 71 | $actual | Should -BeOfType ([securestring]) 72 | [System.Net.NetworkCredential]::new("", $actual).Password | Should -Be value 73 | 74 | $actual = Get-Secret -Name MySecret -Vault $vault -AsPlainText 75 | $actual | Should -Be value 76 | } 77 | 78 | It "Sets a PSCredential secret" { 79 | $value = [PSCredential]::new("user", (ConvertTo-SecureString -AsPlainText -Force value)) 80 | 81 | Set-Secret -Name MySecret -Secret $value -Vault $vault 82 | 83 | $actual = Get-Secret -Name MySecret -Vault $vault 84 | $actual | Should -BeOfType ([pscredential]) 85 | $actual.UserName | Should -Be user 86 | $actual.GetNetworkCredential().Password | Should -Be value 87 | } 88 | 89 | It "Sets a Hashtable secret" { 90 | $value = @{ 91 | foo = 'bar' 92 | value = 1 93 | } 94 | 95 | Set-Secret -Name MySecret -Secret $value -Vault $vault 96 | 97 | $actual = Get-Secret -Name MySecret -Vault $vault 98 | $actual | Should -BeOfType ([hashtable]) 99 | $actual.Count | Should -Be 2 100 | $actual.foo | Should -BeOfType ([securestring]) 101 | [System.Net.NetworkCredential]::new("", $actual.foo).Password | Should -Be bar 102 | $actual.value | Should -BeOfType ([int]) 103 | $actual.value | Should -Be 1 104 | 105 | $actual = Get-Secret -Name MySecret -Vault $vault -AsPlainText 106 | $actual | Should -BeOfType ([hashtable]) 107 | $actual.Count | Should -Be 2 108 | $actual.foo | Should -BeOfType ([string]) 109 | $actual.foo | Should -Be bar 110 | $actual.value | Should -BeOfType ([int]) 111 | $actual.value | Should -Be 1 112 | } 113 | 114 | It "Gets SecretInfo" { 115 | Set-Secret -Name MySecret1 -Secret value1 -Vault $vault 116 | Set-Secret -Name MySecret2 -Secret (ConvertTo-SecureString -AsPlainText -Force value2) -Vault $vault 117 | Set-Secret -Name MySecret3 -Secret ([byte[]]@(0, 1)) -Vault $vault 118 | Set-Secret -Name MySecret4 -Secret ([PSCredential]::new("user", (ConvertTo-SecureString -AsPlainText -Force value))) -Vault $vault 119 | Set-Secret -Name MySecret5 -Secret @{foo = 'bar' } -Vault $vault 120 | 121 | $secretInfo = Get-SecretInfo -Vault $vault 122 | $secretInfo.Count | Should -Be 5 123 | $secretInfo[0].Name | Should -Be MySecret1 124 | $secretInfo[0].Type | Should -Be String 125 | $secretInfo[0].VaultName | Should -Be $vault 126 | $secretInfo[0].Metadata.Count | Should -Be 1 127 | $secretInfo[0].Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 128 | 129 | $secretInfo[1].Name | Should -Be MySecret2 130 | $secretInfo[1].Type | Should -Be SecureString 131 | $secretInfo[1].VaultName | Should -Be $vault 132 | $secretInfo[1].Metadata.Count | Should -Be 1 133 | $secretInfo[1].Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 134 | 135 | $secretInfo[2].Name | Should -Be MySecret3 136 | $secretInfo[2].Type | Should -Be ByteArray 137 | $secretInfo[2].VaultName | Should -Be $vault 138 | $secretInfo[2].Metadata.Count | Should -Be 1 139 | $secretInfo[2].Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 140 | 141 | $secretInfo[3].Name | Should -Be MySecret4 142 | $secretInfo[3].Type | Should -Be PSCredential 143 | $secretInfo[3].VaultName | Should -Be $vault 144 | $secretInfo[3].Metadata.Count | Should -Be 1 145 | $secretInfo[3].Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 146 | 147 | $secretInfo[4].Name | Should -Be MySecret5 148 | $secretInfo[4].Type | Should -Be Hashtable 149 | $secretInfo[4].VaultName | Should -Be $vault 150 | $secretInfo[4].Metadata.Count | Should -Be 1 151 | $secretInfo[4].Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 152 | } 153 | 154 | It "Gets SecretInfo with wildcard" { 155 | Set-Secret -Name MySecret1 -Secret value -Vault $vault 156 | Set-Secret -Name MySecret2 -Secret value -Vault $vault 157 | Set-Secret -Name Other -Secret value -Vault $vault 158 | 159 | $secretInfo = Get-SecretInfo -Vault $vault -Name MySec* 160 | $secretInfo.Count | Should -Be 2 161 | $secretInfo[0].Name | Should -Be MySecret1 162 | $secretInfo[1].Name | Should -Be MySecret2 163 | } 164 | 165 | It "Copies a secret across vaults" { 166 | $otherVault = 'OtherDpapiNG' 167 | $otherVaultPath = "TestDrive:\dpapi-ng-other.vault" 168 | 169 | Register-SecretVault -Name $otherVault -ModuleName $CurrentModule.Path -VaultParameters @{ 170 | Path = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($otherVaultPath) 171 | } 172 | try { 173 | Set-Secret -Name MySecret -Secret foo -Vault $vault 174 | $si = Get-SecretInfo -Name MySecret 175 | Set-Secret -SecretInfo $si -Vault $otherVault 176 | 177 | $actual = Get-Secret -Name MySecret -AsPlainText 178 | $actual | Should -Be foo 179 | } 180 | finally { 181 | Unregister-SecretVault -Name $otherVault 182 | Remove-Item -Path $otherVaultPath -Force -ErrorAction SilentlyContinue 183 | } 184 | } 185 | 186 | It "Sets secret with metadata" { 187 | Set-Secret -Name MySecret -Secret value -Vault $vault -Metadata @{ 188 | Created = ([DateTime]::new(1970, 1, 1)) 189 | } 190 | 191 | Get-Secret -Name MySecret -Vault $vault -AsPlainText | Should -Be value 192 | 193 | $actual = Get-SecretInfo -Name MySecret -Vault $vault 194 | $actual.Name | Should -Be MySecret 195 | $actual.Metadata.Count | Should -Be 2 196 | $actual.Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 197 | $actual.Metadata.Created | Should -BeOfType ([DateTime]) 198 | $actual.Metadata.Created | Should -Be ([DateTime]::new(1970, 1, 1)) 199 | } 200 | 201 | It "Adds extra metadata" { 202 | Set-Secret -Name MySecret -Secret value -Vault $vault -Metadata @{ 203 | Created = ([DateTime]::new(1970, 1, 1)) 204 | Other = 1 205 | } 206 | Set-SecretInfo -Name MySecret -Vault $vault -Metadata @{ 207 | Other = 2 208 | Foo = 'bar' 209 | } 210 | 211 | Get-Secret -Name MySecret -Vault $vault -AsPlainText | Should -Be value 212 | 213 | $actual = Get-SecretInfo -Name MySecret -Vault $vault 214 | $actual.Name | Should -Be MySecret 215 | $actual.Metadata.Count | Should -Be 4 216 | $actual.Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 217 | $actual.Metadata.Created | Should -BeOfType ([DateTime]) 218 | $actual.Metadata.Created | Should -Be ([DateTime]::new(1970, 1, 1)) 219 | $actual.Metadata.Other | Should -BeOfType ([int]) 220 | $actual.Metadata.Other | Should -Be 2 221 | $actual.Metadata.Foo | Should -BeOfType ([string]) 222 | $actual.Metadata.Foo | Should -Be bar 223 | } 224 | 225 | It "Sets secret with explicit ProtectionDescriptor" { 226 | $desc = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Local machine 227 | 228 | Set-Secret -Name MySecret -Secret value -Vault $vault @desc 229 | 230 | Get-Secret -Name MySecret -Vault $vault -AsPlainText | Should -Be value 231 | 232 | $actual = Get-SecretInfo -Name MySecret -Vault $vault 233 | $actual.Name | Should -Be MySecret 234 | $actual.Metadata.Count | Should -Be 1 235 | $actual.Metadata.ProtectionDescriptor | Should -Be "LOCAL=machine" 236 | } 237 | 238 | It "Sets secret with explicit ProtectionDescriptor and extra metadata" { 239 | $desc = New-DpapiNGDescriptor | Add-DpapiNGDescriptor -Local machine 240 | 241 | Set-Secret -Name MySecret -Secret value -Vault $vault -Metadata @{ 242 | ProtectionDescriptor = $desc.ToString() 243 | Other = 'foo' 244 | } 245 | 246 | Get-Secret -Name MySecret -Vault $vault -AsPlainText | Should -Be value 247 | 248 | $actual = Get-SecretInfo -Name MySecret -Vault $vault 249 | $actual.Name | Should -Be MySecret 250 | $actual.Metadata.Count | Should -Be 2 251 | $actual.Metadata.ProtectionDescriptor | Should -Be "LOCAL=machine" 252 | $actual.Metadata.Other | Should -BeOfType ([string]) 253 | $actual.Metadata.Other | Should -Be foo 254 | } 255 | 256 | It "Ignore changing ProtectionDescriptor with same value" { 257 | Set-Secret -Name MySecret -Secret value -Vault $vault 258 | Set-SecretInfo -Name MySecret -Vault $vault -Metadata @{ 259 | Foo = 'bar' 260 | ProtectionDescriptor = 'LOCAL=user' 261 | } 262 | 263 | $actual = Get-SecretInfo -Name MySecret -Vault $vault 264 | $actual.Name | Should -Be MySecret 265 | $actual.Metadata.Count | Should -Be 2 266 | $actual.Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 267 | $actual.Metadata.Foo | Should -BeOfType ([string]) 268 | $actual.Metadata.Foo | Should -Be bar 269 | } 270 | 271 | It "Errors changing ProtectionDescriptor with different value" { 272 | Set-Secret -Name MySecret -Secret value -Vault $vault -Metadata @{ 273 | Foo = 1 274 | } 275 | Set-SecretInfo -Name MySecret -Vault $vault -Metadata @{ 276 | Foo = 2 277 | ProtectionDescriptor = 'LOCAL=machine' 278 | } -ErrorAction SilentlyContinue -ErrorVariable err 279 | 280 | $err.Count | Should -Be 1 281 | [string]$err | Should -Be "It is not possible to change the ProtectionDescriptor for an existing set. Use Set-SecretInfo to create a new secret instead." 282 | 283 | $actual = Get-SecretInfo -Name MySecret -Vault $vault 284 | $actual.Name | Should -Be MySecret 285 | $actual.Metadata.Count | Should -Be 2 286 | $actual.Metadata.ProtectionDescriptor | Should -Be "LOCAL=user" 287 | $actual.Metadata.Foo | Should -BeOfType ([int]) 288 | $actual.Metadata.Foo | Should -Be 2 289 | } 290 | 291 | It "Errors while trying to set secret metadata on non-existent secret" { 292 | Set-SecretInfo -Name MySecret -Vault $vault -Metadata @{ 293 | Foo = 2 294 | } -ErrorAction SilentlyContinue -ErrorVariable err 295 | 296 | $err.Count | Should -Be 1 297 | [string]$err | Should -Be "Failed to find SecretManagement.DpapiNG vault secret 'MySecret'. The secret must exist to set the metadata on. Use Set-Secret to create a secret with metadata instead." 298 | } 299 | 300 | It "Uses default vault path if none set" { 301 | $name1 = 'TestVault1' 302 | $name2 = 'TestVault2' 303 | $secretName = 'DpapiNGTestSecret' 304 | 305 | Register-SecretVault -ModuleName $CurrentModule.Path -Name $name1 306 | Register-SecretVault -ModuleName $CurrentModule.Path -Name $name2 307 | try { 308 | Test-SecretVault -Name $name1 | Should -BeTrue 309 | Test-SecretVault -Name $name2 | Should -BeTrue 310 | 311 | Set-Secret -Name $secretName -Secret value -Vault $name1 312 | 313 | Get-Secret -Name $secretName -AsPlainText -Vault $name1 | Should -Be value 314 | Get-Secret -Name $secretName -AsPlainText -Vault $name2 | Should -Be value 315 | } 316 | finally { 317 | Remove-Secret -Name $secretName -Vault $name1 318 | Unregister-SecretVault -Name $name1 319 | Unregister-SecretVault -Name $name2 320 | } 321 | } 322 | 323 | It "Fails if vault Path is not a FileSystem path" { 324 | $name = 'TestVault' 325 | Register-SecretVault -ModuleName $CurrentModule.Path -Name $name -VaultParameters @{ 326 | Path = "HKLM:\SOFTWARE" 327 | } 328 | try { 329 | Test-SecretVault -Name $name -ErrorAction SilentlyContinue -ErrorVariable err 330 | $err.Count | Should -Be 1 331 | [string]$err | Should -Be "Invalid SecretManagement.DpapiNG vault registration: Path 'HKLM:\SOFTWARE' must be a local file path to the local LiteDB database. If the DB does not exist at the path a new vault will be created." 332 | } 333 | finally { 334 | Unregister-SecretVault -Name $name 335 | } 336 | } 337 | 338 | It "Fails if vault path parent does not exist" { 339 | $name = 'TestVault' 340 | Register-SecretVault -ModuleName $CurrentModule.Path -Name $name -VaultParameters @{ 341 | Path = 'C:\missing\parent\vault' 342 | } 343 | try { 344 | Test-SecretVault -Name $name -ErrorAction SilentlyContinue -ErrorVariable err 345 | $err.Count | Should -Be 1 346 | [string]$err | Should -Be "Invalid SecretManagement.DpapiNG vault registration: Path 'C:\missing\parent\vault' must exist or the parent directory in the path must exist to create the new vault file." 347 | } 348 | finally { 349 | Unregister-SecretVault -Name $name 350 | } 351 | } 352 | 353 | It "Fails if vault path is a directory" { 354 | $name = 'TestVault' 355 | Register-SecretVault -ModuleName $CurrentModule.Path -Name $name -VaultParameters @{ 356 | Path = "C:\Windows" 357 | } 358 | try { 359 | Test-SecretVault -Name $name -ErrorAction SilentlyContinue -ErrorVariable err 360 | $err.Count | Should -Be 1 361 | [string]$err | Should -Be "Invalid SecretManagement.DpapiNG vault registration: Path 'C:\Windows' must be the path to a file not a directory." 362 | } 363 | finally { 364 | Unregister-SecretVault -Name $name 365 | } 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /tests/common.ps1: -------------------------------------------------------------------------------- 1 | $moduleName = (Get-Item ([IO.Path]::Combine($PSScriptRoot, '..', 'module', '*.psd1'))).BaseName 2 | $manifestPath = [IO.Path]::Combine($PSScriptRoot, '..', 'output', $moduleName) 3 | 4 | $global:CurrentModule = Get-Module -Name $moduleName -ErrorAction SilentlyContinue 5 | if (-not $CurrentModule) { 6 | $global:CurrentModule = Import-Module $manifestPath -PassThru 7 | } 8 | 9 | if (-not (Get-Variable IsWindows -ErrorAction SilentlyContinue)) { 10 | # Running WinPS so guaranteed to be Windows. 11 | Set-Variable -Name IsWindows -Value $true -Scope Global 12 | } 13 | 14 | Function Global:Complete { 15 | [OutputType([System.Management.Automation.CompletionResult])] 16 | [CmdletBinding()] 17 | param( 18 | [Parameter(Mandatory, Position = 0)] 19 | [string] 20 | $Expression 21 | ) 22 | 23 | [System.Management.Automation.CommandCompletion]::CompleteInput( 24 | $Expression, 25 | $Expression.Length, 26 | $null).CompletionMatches 27 | } 28 | 29 | # The SID protector only works in a domain so some tests won't work in CI 30 | $global:SIDUnvailable = $false 31 | try { 32 | $null = ConvertTo-DpapiNGSecret foo -CurrentSid -ErrorAction Stop | ConvertFrom-DpapiNGSecret -ErrorAction Stop 33 | } 34 | catch { 35 | $global:SIDUnvailable = $true 36 | } 37 | 38 | Function global:New-WebCredential { 39 | [CmdletBinding()] 40 | param ( 41 | [Parameter(Mandatory, Position = 0)] 42 | [string] 43 | $Resource, 44 | 45 | [Parameter(Mandatory, Position = 1)] 46 | [string] 47 | $UserName 48 | ) 49 | 50 | $vault = [Windows.Security.Credentials.PasswordVault, Windows.Security.Credentials, ContentType = WindowsRuntime]::new() 51 | $vault.Add([Windows.Security.Credentials.PasswordCredential, Windows.Security.Credentials, ContentType = WindowsRuntime]::new( 52 | $Resource, 53 | $UserName, 54 | "ResourcePassword" 55 | )) 56 | } 57 | 58 | Function global:Remove-WebCredential { 59 | [CmdletBinding()] 60 | param ( 61 | [Parameter(Mandatory)] 62 | [string] 63 | $Resource, 64 | 65 | [Parameter(Mandatory)] 66 | [string] 67 | $UserName 68 | ) 69 | 70 | $vault = [Windows.Security.Credentials.PasswordVault, Windows.Security.Credentials, ContentType = WindowsRuntime]::new() 71 | $vault.Remove($vault.Retrieve($Resource, $UserName)) 72 | } 73 | 74 | if ($IsCoreCLR) { 75 | Function global:New-X509Certificate { 76 | [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] 77 | [CmdletBinding()] 78 | param ( 79 | [Parameter(Mandatory)] 80 | [string]$Subject, 81 | 82 | [Parameter()] 83 | [System.Security.Cryptography.HashAlgorithmName] 84 | $HashAlgorithm = "SHA256" 85 | ) 86 | 87 | $key = [System.Security.Cryptography.RSA]::Create(4096) 88 | $request = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( 89 | "CN=$Subject", 90 | $key, 91 | $HashAlgorithm, 92 | [System.Security.Cryptography.RSASignaturePadding]::Pkcs1) 93 | 94 | $request.CertificateExtensions.Add( 95 | [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new( 96 | $request.PublicKey, 97 | $false) 98 | ) 99 | 100 | $notBefore = [DateTimeOffset]::UtcNow.AddDays(-1) 101 | $notAfter = [DateTimeOffset]::UtcNow.AddDays(30) 102 | $request.CreateSelfSigned($notBefore, $notAfter) 103 | } 104 | } 105 | else { 106 | Function global:New-X509Certificate { 107 | [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] 108 | [CmdletBinding()] 109 | param ( 110 | [Parameter(Mandatory)] 111 | [string]$Subject, 112 | 113 | [Parameter()] 114 | [System.Security.Cryptography.HashAlgorithmName] 115 | $HashAlgorithm = "SHA256" 116 | ) 117 | 118 | $certParams = @{ 119 | CertStoreLocation = 'Cert:\CurrentUser\My' 120 | HashAlgorithm = $HashAlgorithm.ToString() 121 | KeyAlgorithm = 'RSA' 122 | KeyLength = 4096 123 | Subject = $Subject 124 | } 125 | $cert = New-SelfSignedCertificate @certParams 126 | 127 | # We want to remove the private key file by exporting the cert as a PFX 128 | # and reimporting it without the persist key flag. 129 | $certBytes = $cert.Export( 130 | [System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx) 131 | 132 | # New-SelfSignedCertificate stores the key in the store, we want to 133 | # remove the cert and key 134 | Remove-Item -LiteralPath "Cert:\CurrentUser\My\$($cert.Thumbprint)" -Force 135 | $certKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($cert) 136 | $certKey.Key.Delete() 137 | 138 | # EphemeralKeySet will ensure the key isn't persisted to the disk replicating 139 | # the PS 7 New-X509Certificate cmdlet 140 | [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( 141 | $certBytes, 142 | '', 143 | [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]'EphemeralKeySet, Exportable') 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /tools/InvokeBuild.ps1: -------------------------------------------------------------------------------- 1 | using namespace System.Collections 2 | using namespace System.IO 3 | 4 | #Requires -Version 7.2 5 | 6 | [CmdletBinding()] 7 | param( 8 | [Parameter(Mandatory)] 9 | [Manifest] 10 | $Manifest 11 | ) 12 | 13 | #region Build 14 | 15 | task Clean { 16 | if (Test-Path -LiteralPath $Manifest.ReleasePath) { 17 | Remove-Item -LiteralPath $Manifest.ReleasePath -Recurse -Force 18 | } 19 | New-Item -Path $Manifest.ReleasePath -ItemType Directory | Out-Null 20 | } 21 | 22 | task BuildManaged { 23 | $arguments = @( 24 | 'publish' 25 | '--configuration', $Manifest.Configuration 26 | '--verbosity', 'quiet' 27 | '-nologo' 28 | "-p:Version=$($Manifest.Module.Version)" 29 | ) 30 | 31 | $csproj = (Get-Item -Path "$($Manifest.DotnetPath)/*.csproj").FullName 32 | foreach ($framework in $Manifest.TargetFrameworks) { 33 | Write-Host "Compiling for $framework" -ForegroundColor Cyan 34 | $outputDir = [Path]::Combine($Manifest.ReleasePath, "bin", $framework) 35 | New-Item -Path $outputDir -ItemType Directory -Force | Out-Null 36 | dotnet @arguments --framework $framework --output $outputDir $csproj 37 | 38 | if ($LASTEXITCODE) { 39 | throw "Failed to compiled code for $framework" 40 | } 41 | } 42 | } 43 | 44 | task BuildModule { 45 | $copyParams = @{ 46 | Path = [Path]::Combine($Manifest.PowerShellPath, '*') 47 | Destination = $Manifest.ReleasePath 48 | Recurse = $true 49 | Force = $true 50 | } 51 | Copy-Item @copyParams 52 | 53 | # Ensure the same values of the parent manifest are set to the child one. 54 | $extManifestPath = [Path]::Combine( 55 | $Manifest.ReleasePath, 56 | "$($Manifest.Module.Name).Extension", 57 | "$($Manifest.Module.Name).Extension.psd1") 58 | $extManifest = Get-Content -LiteralPath $extManifestPath 59 | $extManifest = $extManifest.Replace( 60 | "ModuleVersion = '0.0.0.0'", 61 | "ModuleVersion = '$($Manifest.Module.Version)'" 62 | ).Replace( 63 | "Author = ''", 64 | "Author = '$($Manifest.Module.Author)'" 65 | ).Replace( 66 | "CompanyName = ''", 67 | "CompanyName = '$($Manifest.Module.CompanyName)'" 68 | ).Replace( 69 | "Copyright = ''", 70 | "Copyright = '$($Manifest.Module.Copyright)'" 71 | ) 72 | if ($Manifest.Module.ExportedCmdlets.Count) { 73 | $extManifest = $extManifest.Replace( 74 | "CmdletsToExport = @(", 75 | "CmdletsToExport = @(`n '$($Manifest.Module.ExportedCmdlets.Keys -join "'`n '")'" 76 | ) 77 | } 78 | Set-Content -LiteralPath $extManifestPath -Value $extManifest 79 | } 80 | 81 | task BuildDocs { 82 | Get-ChildItem -LiteralPath $Manifest.DocsPath -Directory | ForEach-Object { 83 | Write-Host "Building docs for $($_.Name)" -ForegroundColor Cyan 84 | $helpParams = @{ 85 | Path = $_.FullName 86 | OutputPath = [Path]::Combine($Manifest.ReleasePath, $_.Name) 87 | } 88 | New-ExternalHelp @helpParams | Out-Null 89 | } 90 | } 91 | 92 | task Sign { 93 | $vaultName = $env:AZURE_KEYVAULT_NAME 94 | $vaultCert = $env:AZURE_KEYVAULT_CERT 95 | if (-not $vaultName -or -not $vaultCert) { 96 | return 97 | } 98 | 99 | Write-Host "Authenticating with Azure KeyVault '$vaultName' for signing" -ForegroundColor Cyan 100 | $key = Get-OpenAuthenticodeAzKey -Vault $vaultName -Certificate $vaultCert 101 | $signParams = @{ 102 | Key = $key 103 | TimeStampServer = 'http://timestamp.digicert.com' 104 | } 105 | 106 | $toSign = Get-ChildItem -LiteralPath $Manifest.ReleasePath -Recurse -ErrorAction SilentlyContinue | 107 | Where-Object { 108 | $_.Extension -in ".ps1", ".psm1", ".psd1", ".ps1xml" -or ( 109 | $_.Extension -eq ".dll" -and $_.BaseName -like "$($Manifest.Module.Name)*" 110 | ) 111 | } | 112 | ForEach-Object -Process { 113 | Write-Host "Signing '$($_.FullName)'" 114 | $_.FullName 115 | } 116 | 117 | Set-OpenAuthenticodeSignature -LiteralPath $toSign @signParams 118 | } 119 | 120 | task Package { 121 | $repoParams = @{ 122 | Name = "$($Manifest.Module.Name)-Local" 123 | Uri = $Manifest.OutputPath 124 | Trusted = $true 125 | Force = $true 126 | } 127 | Register-PSResourceRepository @repoParams 128 | try { 129 | Publish-PSResource -Path $Manifest.ReleasePath -Repository $repoParams.Name -SkipModuleManifestValidate 130 | } 131 | finally { 132 | Unregister-PSResourceRepository -Name $repoParams.Name 133 | } 134 | } 135 | 136 | #endregion Build 137 | 138 | #region Test 139 | 140 | task UnitTests { 141 | $testsPath = [Path]::Combine($Manifest.TestPath, 'units') 142 | if (-not (Test-Path -LiteralPath $testsPath)) { 143 | Write-Host "No unit tests found, skipping" -ForegroundColor Yellow 144 | return 145 | } 146 | 147 | # dotnet test places the results in a subfolder of the results-directory. 148 | # This subfolder is based on a random guid so a temp folder is used to 149 | # ensure we only get the current runs results 150 | $tempResultsPath = [Path]::Combine($Manifest.TestResultsPath, "TempUnit") 151 | if (Test-Path -LiteralPath $tempResultsPath) { 152 | Remove-Item -LiteralPath $tempResultsPath -Force -Recurse 153 | } 154 | New-Item -Path $tempResultsPath -ItemType Directory | Out-Null 155 | 156 | try { 157 | $runSettingsPrefix = 'DataCollectionRunSettings.DataCollectors.DataCollector.Configuration' 158 | $arguments = @( 159 | 'test' 160 | $testsPath 161 | '--results-directory', $tempResultsPath 162 | '--collect:"XPlat Code Coverage"' 163 | '--' 164 | "$runSettingsPrefix.Format=json" 165 | "$runSettingsPrefix.IncludeDirectory=`"$CSharpPath`"" 166 | ) 167 | 168 | dotnet @arguments 169 | if ($LASTEXITCODE) { 170 | throw "Unit tests failed" 171 | } 172 | 173 | $moveParams = @{ 174 | Path = [Path]::Combine($tempResultsPath, "*", "*.json") 175 | Destination = [Path]::Combine($Manifest.TestResultsPath, "UnitCoverage.json") 176 | Force = $true 177 | } 178 | Move-Item @moveParams 179 | } 180 | finally { 181 | Remove-Item -LiteralPath $tempResultsPath -Force -Recurse 182 | } 183 | } 184 | 185 | task PesterTests { 186 | $testsPath = [Path]::Combine($Manifest.TestPath, '*.tests.ps1') 187 | if (-not (Test-Path -Path $testsPath)) { 188 | Write-Host "No Pester tests found, skipping" -ForegroundColor Yellow 189 | return 190 | } 191 | 192 | if (-not $Manifest.TestFramework) { 193 | throw "No compatible target test framework for PowerShell '$($Manifest.PowerShellVersion)'" 194 | } 195 | 196 | $dotnetTools = @(dotnet tool list --global) -join "`n" 197 | if (-not $dotnetTools.Contains('coverlet.console')) { 198 | Write-Host 'Installing dotnet tool coverlet.console' -ForegroundColor Yellow 199 | dotnet tool install --global coverlet.console 200 | } 201 | 202 | $pwsh = Assert-PowerShell -Version $Manifest.PowerShellVersion -Arch $Manifest.PowerShellArch 203 | $resultsFile = [Path]::Combine($Manifest.TestResultsPath, 'Pester.xml') 204 | if (Test-Path -LiteralPath $resultsFile) { 205 | Remove-Item $resultsFile -ErrorAction Stop -Force 206 | } 207 | $pesterScript = [Path]::Combine($PSScriptRoot, 'PesterTest.ps1') 208 | $pwshArguments = @( 209 | '-NoProfile' 210 | '-NonInteractive' 211 | if (-not $IsUnix) { 212 | '-ExecutionPolicy', 'Bypass' 213 | } 214 | '-File', $pesterScript 215 | '-TestPath', $Manifest.TestPath 216 | '-OutputFile', $resultsFile 217 | ) -join '" "' 218 | 219 | $watchFolder = [Path]::Combine($Manifest.ReleasePath, 'bin', $Manifest.TestFramework) 220 | $unitCoveragePath = [Path]::Combine($Manifest.TestResultsPath, "UnitCoverage.json") 221 | $coveragePath = [Path]::Combine($Manifest.TestResultsPath, "Coverage.xml") 222 | $sourceMappingFile = [Path]::Combine($Manifest.TestResultsPath, "CoverageSourceMapping.txt") 223 | 224 | $arguments = @( 225 | $watchFolder 226 | '--target', $pwsh 227 | '--targetargs', "`"$pwshArguments`"" 228 | '--output', $coveragePath 229 | '--format', 'cobertura' 230 | '--verbosity', 'minimal' 231 | if (Test-Path -LiteralPath $unitCoveragePath) { 232 | '--merge-with', $unitCoveragePath 233 | } 234 | if ($env:GITHUB_ACTIONS -eq 'true') { 235 | Set-Content $sourceMappingFile "|$($Manifest.RepositoryPath)$([Path]::DirectorySeparatorChar)=/_/" 236 | '--source-mapping-file', $sourceMappingFile 237 | } 238 | ) 239 | $origEnv = $env:PSModulePath 240 | try { 241 | $pwshHome = Split-Path -Path $pwsh -Parent 242 | $env:PSModulePath = @( 243 | [Path]::Combine($pwshHome, "Modules") 244 | [Path]::Combine($Manifest.OutputPath, "Modules") 245 | ) -join ([Path]::PathSeparator) 246 | 247 | coverlet @arguments 248 | } 249 | finally { 250 | $env:PSModulePath = $origEnv 251 | } 252 | 253 | if ($LASTEXITCODE) { 254 | throw "Pester failed tests" 255 | } 256 | } 257 | 258 | task CoverageReport { 259 | $dotnetTools = @(dotnet tool list --global) -join "`n" 260 | if (-not $dotnetTools.Contains('dotnet-reportgenerator-globaltool')) { 261 | Write-Host 'Installing dotnet tool dotnet-reportgenerator-globaltool' -ForegroundColor Yellow 262 | dotnet tool install --global dotnet-reportgenerator-globaltool 263 | } 264 | 265 | $reportPath = [Path]::Combine($Manifest.TestResultsPath, "CoverageReport") 266 | $coveragePath = [Path]::Combine($Manifest.TestResultsPath, "Coverage.xml") 267 | $reportArgs = @( 268 | "-reports:$coveragePath" 269 | "-targetdir:$reportPath" 270 | '-reporttypes:Html_Dark;JsonSummary' 271 | ) 272 | reportgenerator @reportArgs 273 | if ($LASTEXITCODE) { 274 | throw "reportgenerator failed with RC of $LASTEXITCODE" 275 | } 276 | 277 | $resultPath = [Path]::Combine($reportPath, "Summary.json") 278 | Format-CoverageInfo -Path $resultPath 279 | } 280 | 281 | #endregion Test 282 | 283 | task Build -Jobs Clean, BuildManaged, BuildModule, BuildDocs, Sign, Package 284 | 285 | task Test -Jobs UnitTests, PesterTests, CoverageReport 286 | -------------------------------------------------------------------------------- /tools/PesterTest.ps1: -------------------------------------------------------------------------------- 1 | using namespace System.IO 2 | 3 | #Requires -Module Pester 4 | 5 | <# 6 | .SYNOPSIS 7 | Run Pester test 8 | 9 | .PARAMETER TestPath 10 | The path to the tests to run 11 | 12 | .PARAMETER OutputFile 13 | The path to write the Pester test results to. 14 | #> 15 | [CmdletBinding()] 16 | param ( 17 | [Parameter(Mandatory)] 18 | [String] 19 | $TestPath, 20 | 21 | [Parameter(Mandatory)] 22 | [String] 23 | $OutputFile 24 | ) 25 | 26 | $ErrorActionPreference = 'Stop' 27 | 28 | [PSCustomObject]$PSVersionTable | 29 | Select-Object -Property *, @{N = 'Architecture'; E = { 30 | switch ([IntPtr]::Size) { 31 | 4 { 'x86' } 32 | 8 { 'x64' } 33 | default { 'Unknown' } 34 | } 35 | } 36 | } | 37 | Format-List | 38 | Out-Host 39 | 40 | $configuration = [PesterConfiguration]::Default 41 | $configuration.Output.Verbosity = 'Detailed' 42 | $configuration.Run.Path = $TestPath 43 | $configuration.Run.Throw = $true 44 | $configuration.TestResult.Enabled = $true 45 | $configuration.TestResult.OutputPath = $OutputFile 46 | $configuration.TestResult.OutputFormat = 'NUnitXml' 47 | 48 | Invoke-Pester -Configuration $configuration -WarningAction Ignore 49 | -------------------------------------------------------------------------------- /tools/common.ps1: -------------------------------------------------------------------------------- 1 | using namespace System.Collections 2 | using namespace System.Collections.Generic 3 | using namespace System.IO 4 | using namespace System.Management.Automation 5 | using namespace System.Net 6 | using namespace System.Net.Http 7 | using namespace System.Runtime.InteropServices 8 | 9 | #Requires -Version 7.2 10 | 11 | class Manifest { 12 | [PSModuleInfo]$Module 13 | 14 | [ValidateSet("Debug", "Release")] 15 | [string]$Configuration 16 | 17 | [string]$RepositoryPath 18 | [string]$DocsPath 19 | [string]$DotnetPath 20 | [string]$OutputPath 21 | [string]$PowerShellPath 22 | [string]$ReleasePath 23 | [string]$TestPath 24 | [string]$TestResultsPath 25 | 26 | [string]$DotnetProject 27 | [Hashtable[]]$BuildRequirements 28 | [Hashtable[]]$TestRequirements 29 | [Version]$PowerShellVersion 30 | [Architecture]$PowerShellArch 31 | [string[]]$TargetFrameworks 32 | [string]$TestFramework 33 | 34 | Manifest( 35 | [string]$Configuration, 36 | [Version]$PowerShellVersion, 37 | [Architecture]$PowerShellArch, 38 | [string]$ManifestPath 39 | ) { 40 | $this.RepositoryPath = [Path]::GetFullPath([Path]::Combine($PSScriptRoot, "..")) 41 | $moduleManifestParams = @{ 42 | Path = [Path]::Combine($this.RepositoryPath, "module", "*.psd1") 43 | # Can emit errors about invalid RootModule which don't matter here 44 | ErrorAction = 'Ignore' 45 | WarningAction = 'Ignore' 46 | } 47 | $this.Module = Test-ModuleManifest @moduleManifestParams 48 | 49 | $this.Configuration = $Configuration 50 | 51 | $raw = Import-PowerShellDataFile -LiteralPath $ManifestPath 52 | $this.DotnetProject = $raw.DotnetProject ?? $this.Module.Name 53 | 54 | $this.DocsPath = [Path]::Combine($this.RepositoryPath, "docs") 55 | $this.DotnetPath = [Path]::Combine($this.RepositoryPath, "src", $this.DotnetProject) 56 | $this.OutputPath = [Path]::Combine($this.RepositoryPath, "output") 57 | $this.PowerShellPath = [Path]::Combine($this.RepositoryPath, "module") 58 | $this.ReleasePath = [Path]::Combine($this.OutputPath, $this.Module.Name, $this.Module.Version) 59 | $this.TestPath = [Path]::Combine($this.RepositoryPath, "tests") 60 | $this.TestResultsPath = [Path]::Combine($this.OutputPath, "TestResults") 61 | 62 | if (-not (Test-Path -LiteralPath $this.ReleasePath)) { 63 | New-Item -Path $this.ReleasePath -ItemType Directory -Force | Out-Null 64 | } 65 | 66 | if (-not (Test-Path -LiteralPath $this.TestResultsPath)) { 67 | New-Item -Path $this.TestResultsPath -ItemType Directory -Force | Out-Null 68 | } 69 | 70 | $invokeBuildReq = @{ 71 | ModuleName = 'InvokeBuild' 72 | RequiredVersion = $raw.InvokeBuildVersion 73 | } 74 | $pesterReq = @{ 75 | ModuleName = 'Pester' 76 | RequiredVersion = $raw.PesterVersion 77 | } 78 | $this.BuildRequirements = @( 79 | $invokeBuildReq 80 | $raw.BuildRequirements 81 | ) 82 | $this.TestRequirements = @( 83 | $invokeBuildReq 84 | $pesterReq 85 | $raw.TestRequirements 86 | ) 87 | 88 | if ($PowerShellVersion.Major -lt 6) { 89 | $this.PowerShellVersion = "5.1" 90 | } 91 | else { 92 | $build = $PowerShellVersion.Build 93 | if ($build -eq -1) { 94 | $build = 0 95 | } 96 | $this.PowerShellVersion = "$($PowerShellVersion.Major).$($PowerShellVersion.Minor).$build" 97 | } 98 | $this.PowerShellArch = $PowerShellArch 99 | 100 | $csProjPath = [Path]::Combine($this.DotnetPath, "*.csproj") 101 | [xml]$csharpProjectInfo = Get-Content $csProjPath 102 | $this.TargetFrameworks = @( 103 | @($csharpProjectInfo.Project.PropertyGroup)[0].TargetFrameworks.Split( 104 | ';', [StringSplitOptions]::RemoveEmptyEntries) 105 | ) 106 | 107 | $availableFrameworks = @( 108 | if ($this.PowerShellVersion -eq '5.1') { 109 | 'net48' 110 | foreach ($minor in '7', '6', '5') { 111 | foreach ($build in '2', '1', '') { 112 | "net4$minor$build" 113 | } 114 | } 115 | } 116 | else { 117 | # Minor releases + 4 correspond to the highest framework 118 | # available. e.g. 7.1 runs on net5.0 or lower, 7.2, on net6.0 119 | # or lower, etc. 120 | $netFrameworks = @( 121 | for ($i = 5; $i -le $this.PowerShellVersion.Minor + 4; $i++) { 122 | "net$i.0" 123 | } 124 | ) 125 | [Array]::Reverse($netFrameworks) 126 | 127 | $netFrameworks 128 | 'netstandard2.1' 129 | } 130 | 131 | # WinPS and PS are compatible with netstandard to 2.0 132 | '2.0', '1.6', '1.5', '1.4', '1.3', '1.2', '1.1', '1.0' | 133 | ForEach-Object { "netstandard$_" } 134 | ) 135 | 136 | foreach ($framework in $availableFrameworks) { 137 | foreach ($actualFramework in $this.TargetFrameworks) { 138 | if ($actualFramework.StartsWith($framework)) { 139 | $this.TestFramework = $framework 140 | break 141 | } 142 | } 143 | 144 | if ($this.TestFramework) { 145 | break 146 | } 147 | } 148 | } 149 | } 150 | 151 | Function Assert-ModuleFast { 152 | [CmdletBinding()] 153 | param( 154 | [Parameter()] 155 | [string]$Version = 'latest' 156 | ) 157 | 158 | $moduleName = 'ModuleFast' 159 | if (Get-Module $moduleName) { 160 | Write-Warning "Module $moduleName already loaded, skipping bootstrap." 161 | return 162 | } 163 | 164 | & ([scriptblock]::Create((Invoke-WebRequest -Uri 'bit.ly/modulefast'))) -Release $Version 165 | } 166 | 167 | Function Assert-PowerShell { 168 | [OutputType([string])] 169 | [CmdletBinding()] 170 | param( 171 | [Parameter(Mandatory)] 172 | [ValidateNotNullOrEmpty()] 173 | [Version]$Version, 174 | 175 | [Parameter()] 176 | [Architecture] 177 | $Arch = [RuntimeInformation]::ProcessArchitecture 178 | ) 179 | 180 | $releaseArch = switch ($Arch) { 181 | X64 { 'x64' } 182 | X86 { 'x86' } 183 | ARM64 { 'arm64' } 184 | default { 185 | $err = [ErrorRecord]::new( 186 | [Exception]::new("Unsupported archecture requests '$_'"), 187 | "UnknownArch", 188 | [ErrorCategory]::InvalidArgument, 189 | $_ 190 | ) 191 | $PSCmdlet.ThrowTerminatingError($err) 192 | } 193 | } 194 | 195 | $osArch = [RuntimeInformation]::OSArchitecture 196 | $procArch = [RuntimeInformation]::ProcessArchitecture 197 | if ($Version -eq '5.1') { 198 | if ($IsCoreCLR -and -not $IsWindows) { 199 | $err = [ErrorRecord]::new( 200 | [Exception]::new("Cannot use PowerShell 5.1 on non-Windows hosts"), 201 | "WinPSNotAvailable", 202 | [ErrorCategory]::InvalidArgument, 203 | $Version 204 | ) 205 | $PSCmdlet.ThrowTerminatingError($err) 206 | } 207 | 208 | $system32 = if ($Arch -eq [Architecture]::X64) { 209 | if ($osArch -ne [Architecture]::X64) { 210 | $err = [ErrorRecord]::new( 211 | [Exception]::new("Cannot use PowerShell 5.1 $Arch on Windows $osArch"), 212 | "WinPSNoAvailableArch", 213 | [ErrorCategory]::InvalidArgument, 214 | $Arch 215 | ) 216 | $PSCmdlet.ThrowTerminatingError($err) 217 | } 218 | 219 | ($procArch -eq [Architecture]::X64) ? 'System32' : 'SystemNative' 220 | } 221 | else { 222 | ($procArch -eq [Architecture]::X86) ? 'System32' : 'SysWow64' 223 | } 224 | 225 | return [Path]::Combine($env:SystemRoot, $system32, "WindowsPowerShell", "v1.0", "powershell.exe") 226 | } 227 | elseif ( 228 | $PSVersionTable.PSVersion.Major -eq $Version.Major -and 229 | $PSVersionTable.PSVersion.Minor -eq $Version.Minor -and 230 | $PSVersionTable.PSVersion.Patch -eq $Version.Build -and 231 | $procArch -eq $Arch 232 | ) { 233 | return [Environment]::GetCommandLineArgs()[0] -replace '\.dll$', '' 234 | } 235 | 236 | $targetFolder = $PSCmdlet.GetUnresolvedProviderPathFromPSPath( 237 | [Path]::Combine($PSScriptRoot, "..", "output", "PowerShell-$Version-$releaseArch")) 238 | $pwshExe = [Path]::Combine($targetFolder, "pwsh$nativeExt") 239 | 240 | if (Test-Path -LiteralPath $pwshExe) { 241 | return 242 | } 243 | 244 | if ($IsWindows) { 245 | $releasePath = "PowerShell-$Version-win-$releaseArch.zip" 246 | $fileName = "pwsh-$Version-$releaseArch.zip" 247 | $nativeExt = ".exe" 248 | } 249 | else { 250 | $os = $IsLinux ? "linux" : "osx" 251 | $releasePath = "powershell-$Version-$os-$releaseArch.tar.gz" 252 | $fileName = "pwsh-$Version-$releaseArch.tar.gz" 253 | $nativeExt = "" 254 | } 255 | $downloadUrl = "https://github.com/PowerShell/PowerShell/releases/download/v$Version/$releasePath" 256 | $downloadArchive = [Path]::Combine($targetFolder, $fileName) 257 | 258 | if (-not (Test-Path -LiteralPath $targetFolder)) { 259 | New-Item $targetFolder -ItemType Directory -Force | Out-Null 260 | } 261 | 262 | if (-not (Test-Path -LiteralPath $downloadArchive)) { 263 | Invoke-WebRequest -UseBasicParsing -Uri $downloadUrl -OutFile $downloadArchive 264 | } 265 | 266 | if (-not (Test-Path -LiteralPath $pwshExe)) { 267 | if ($IsWindows) { 268 | $oldPreference = $global:ProgressPreference 269 | try { 270 | $global:ProgressPreference = 'SilentlyContinue' 271 | Expand-Archive -LiteralPath $downloadArchive -DestinationPath $targetFolder -Force 272 | } 273 | finally { 274 | $global:ProgressPreference = $oldPreference 275 | } 276 | } 277 | else { 278 | tar -xf $downloadArchive --directory $targetFolder 279 | if ($LASTEXITCODE) { 280 | $err = [ErrorRecord]::new( 281 | [Exception]::new("Failed to extract pwsh tar for $Version"), 282 | "FailedToExtractTar", 283 | [ErrorCategory]::NotSpecified, 284 | $null 285 | ) 286 | $PSCmdlet.ThrowTerminatingError($err) 287 | } 288 | 289 | chmod +x $pwshExe 290 | if ($LASTEXITCODE) { 291 | $err = [ErrorRecord]::new( 292 | [Exception]::new("Failed to set pwsh as executable at '$pwshExe'"), 293 | "FailedToSetPwshExecutable", 294 | [ErrorCategory]::NotSpecified, 295 | $null 296 | ) 297 | $PSCmdlet.ThrowTerminatingError($err) 298 | } 299 | } 300 | } 301 | 302 | $pwshExe 303 | } 304 | 305 | function Expand-Nupkg { 306 | param ( 307 | [Parameter(Mandatory)] 308 | [string] 309 | $Path, 310 | 311 | [Parameter(Mandatory)] 312 | [string] 313 | $DestinationPath 314 | ) 315 | 316 | $Path = (Resolve-Path -Path $Path).Path 317 | 318 | # WinPS doesn't support extracting from anything without a .zip extension 319 | # so it needs to be renamed there 320 | $renamed = $false 321 | try { 322 | if ($PSVersionTable.PSVersion.Major -lt 6) { 323 | $zipPath = $Path -replace '.nupkg$', '.zip' 324 | Move-Item -LiteralPath $Path -Destination $zipPath 325 | $renamed = $true 326 | } 327 | else { 328 | $zipPath = $Path 329 | } 330 | 331 | $oldPreference = $global:ProgressPreference 332 | try { 333 | $global:ProgressPreference = 'SilentlyContinue' 334 | Expand-Archive -LiteralPath $zipPath -DestinationPath $DestinationPath -Force 335 | } 336 | finally { 337 | $global:ProgressPreference = $oldPreference 338 | } 339 | } 340 | finally { 341 | if ($renamed) { 342 | Move-Item -LiteralPath $zipPath -Destination $Path 343 | } 344 | } 345 | 346 | '`[Content_Types`].xml', '*.nuspec', '_rels', 'package' | ForEach-Object -Process { 347 | $uneededPath = [Path]::Combine($DestinationPath, $_) 348 | Remove-Item -Path $uneededPath -Recurse -Force 349 | } 350 | } 351 | 352 | Function Install-BuildDependencies { 353 | [CmdletBinding()] 354 | param( 355 | [Parameter(Mandatory, ValueFromPipeline)] 356 | [IDictionary[]] 357 | $Requirements 358 | ) 359 | 360 | begin { 361 | $modules = [List[IDictionary]]::new() 362 | $modulePath = [Path]::Combine($PSScriptRoot, "..", "output", "Modules") 363 | } 364 | process { 365 | foreach ($dep in $Requirements) { 366 | $currentModPath = [Path]::Combine($modulePath, $dep.ModuleName) 367 | if (Test-Path -LiteralPath $currentModPath) { 368 | Import-Module -Name $currentModPath 369 | continue 370 | } 371 | $modules.Add($dep) 372 | } 373 | } 374 | end { 375 | if (-not $modules) { 376 | return 377 | } 378 | 379 | Assert-ModuleFast -Version v0.2.0 380 | 381 | $installParams = @{ 382 | ModulesToInstall = $modules 383 | Destination = $modulePath 384 | DestinationOnly = $true 385 | NoPSModulePathUpdate = $true 386 | NoProfileUpdate = $true 387 | Update = $true 388 | } 389 | if (-not (Test-Path -LiteralPath $installParams.Destination)) { 390 | New-Item -Path $installParams.Destination -ItemType Directory -Force | Out-Null 391 | } 392 | Install-ModuleFast @installParams 393 | 394 | Get-ChildItem -LiteralPath $modulePath -Directory | 395 | ForEach-Object { Import-Module -Name $_.FullName } 396 | } 397 | } 398 | 399 | Function Format-CoverageInfo { 400 | [CmdletBinding()] 401 | param ( 402 | [Parameter(Mandatory)] 403 | [string] 404 | $Path 405 | ) 406 | 407 | $coverageInfo = Get-Content -LiteralPath $Path | ConvertFrom-Json 408 | 409 | $s = $coverageInfo.summary 410 | [PSCustomObject]@{ 411 | GeneratedOn = $s.generatedon 412 | Parser = $s.parser 413 | Assemblies = $s.assemblies 414 | Classes = $s.classes 415 | Files = $s.files 416 | LineCoverage = "$($s.linecoverage)% ($($s.coveredlines) of $($s.coverablelines))" 417 | CoveredLines = $s.coveredlines 418 | UncoveredLines = $s.uncoveredlines 419 | CoverableLines = $s.coverablelines 420 | TotalLines = $s.totallines 421 | BranchCoverage = "$($s.branchcoverage)% ($($s.coveredbranches) of $($s.totalbranches))" 422 | CoveredBranches = $s.coveredbranches 423 | TotalsBranches = $s.totalbranches 424 | MethodCoverage = "$($s.methodcoverage)% ($($s.coveredmethods) of $($s.totalmethods))" 425 | CoveredMethods = $s.coveredmethods 426 | TotalMethods = $s.totalmethods 427 | } | Format-List 428 | 429 | $coverageInfo.coverage.assemblies | 430 | ForEach-Object { 431 | @{ Bold = $true; Value = $_ } 432 | $_.classesinassembly | ForEach-Object { @{ Bold = $false; Value = $_ } } 433 | } | 434 | ForEach-Object { 435 | $bold = $_.Bold 436 | $v = $_.Value 437 | 438 | $table = [PSCustomObject]@{ 439 | Name = $v.name 440 | Line = "$($v.coveredlines) / $($v.coverablelines)" 441 | LPercent = "$($v.coverage)%" 442 | Branch = "$($v.coveredbranches) / $($v.totalbranches)" 443 | BPercent = "$($v.branchcoverage)%" 444 | Method = "$($v.coveredmethods) / $($v.totalmethods)" 445 | MPercent = "$($v.methodcoverage)%" 446 | } 447 | $table.PSObject.Properties | ForEach-Object { 448 | # Fixes up entries there there was no value set 449 | if ($_.Name.EndsWith('Percent') -and $_.Value -eq '%') { 450 | $_.Value = "0%" 451 | } 452 | 453 | if ($bold) { 454 | $_.Value = "$([char]27)[93;1m$($_.Value)$([char]27)[0m" 455 | } 456 | } 457 | 458 | $table 459 | } | Format-Table 460 | } 461 | --------------------------------------------------------------------------------