From fb4e50b0ce18268908a78b79ca38f4877d340e47 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 19 Nov 2025 15:51:50 +0100 Subject: [PATCH 01/10] Update psnotify.publish.yml --- .github/workflows/psnotify.publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/psnotify.publish.yml b/.github/workflows/psnotify.publish.yml index 8d1594c..13a2194 100644 --- a/.github/workflows/psnotify.publish.yml +++ b/.github/workflows/psnotify.publish.yml @@ -40,6 +40,7 @@ jobs: Install-Module -Name PSScriptAnalyzer -force Install-Module -Name EncryptCredential Install-Module -Name ExtendFunction + Install-Module -Name ImportDependency Import-Module PSScriptAnalyzer Invoke-ScriptAnalyzer -Path $path -Recurse -IncludeRule PSAvoidTrailingWhitespace -Fix Publish-Module -Path $path -NuGetApiKey ${{ secrets.NUGET_API_KEY }} -Repository PSGallery From cb4a3cb0f0180adb81a619efa759b13433812b8f Mon Sep 17 00:00:00 2001 From: Florian von Bracht Date: Fri, 16 Jan 2026 11:51:20 +0100 Subject: [PATCH 02/10] Update copyright year in LICENSE file --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 729a140..93b2d59 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Apteco +Copyright (c) 2026 Apteco Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 88c1a88e88cd3bca42c68e5887e77b61adc1f444 Mon Sep 17 00:00:00 2001 From: Florian von Bracht Date: Fri, 27 Mar 2026 14:26:34 +0100 Subject: [PATCH 03/10] Change module installation scope to CurrentUser Updated PowerShell module installation to use CurrentUser scope. --- .github/workflows/sqlpipeline.tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sqlpipeline.tests.yml b/.github/workflows/sqlpipeline.tests.yml index 37766ad..f11aed8 100644 --- a/.github/workflows/sqlpipeline.tests.yml +++ b/.github/workflows/sqlpipeline.tests.yml @@ -32,8 +32,8 @@ jobs: shell: powershell run: | Set-PSRepository psgallery -InstallationPolicy trusted - Install-Module WriteLog, ImportDependency -Repository PSGallery - Install-Module SimplySQL -AllowClobber -Repository PSGallery + Install-Module WriteLog, ImportDependency -Repository PSGallery -Scope CurrentUser + Install-Module SimplySQL -AllowClobber -Repository PSGallery -Scope CurrentUser - name: Run Pester Tests (powershell on ${{ matrix.os }}) if: matrix.shell == 'powershell' @@ -55,4 +55,4 @@ jobs: env: CI: true shell: pwsh - run: Invoke-Pester -Path SqlPipeline/Tests/ -Output Detailed -Passthru \ No newline at end of file + run: Invoke-Pester -Path SqlPipeline/Tests/ -Output Detailed -Passthru From 9ff301904207e066e4123c58fd8f6390709a350e Mon Sep 17 00:00:00 2001 From: Florian von Bracht Date: Fri, 27 Mar 2026 14:47:46 +0100 Subject: [PATCH 04/10] Update module installation scope in SQL pipeline Changed module installation to use CurrentUser scope. --- .github/workflows/sqlpipeline.tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sqlpipeline.tests.yml b/.github/workflows/sqlpipeline.tests.yml index f11aed8..5b157a0 100644 --- a/.github/workflows/sqlpipeline.tests.yml +++ b/.github/workflows/sqlpipeline.tests.yml @@ -47,8 +47,8 @@ jobs: shell: pwsh run: | Set-PSRepository psgallery -InstallationPolicy trusted - Install-Module WriteLog, ImportDependency -Repository PSGallery - Install-Module SimplySQL -AllowClobber -Repository PSGallery + Install-Module WriteLog, ImportDependency -Repository PSGallery -Scope CurrentUser + Install-Module SimplySQL -AllowClobber -Repository PSGallery -Scope CurrentUser - name: Run Pester Tests (pwsh on ${{ matrix.os }}) if: matrix.shell != 'powershell' From 1a95468ba31b18617aadab155ad8b47bb829da11 Mon Sep 17 00:00:00 2001 From: Florian von Bracht Date: Fri, 27 Mar 2026 15:19:07 +0100 Subject: [PATCH 05/10] Add ConnectionName parameter to Open-SQLiteConnection --- SqlPipeline/Tests/SqlPipeline.Tests.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SqlPipeline/Tests/SqlPipeline.Tests.ps1 b/SqlPipeline/Tests/SqlPipeline.Tests.ps1 index acc80e4..895304d 100644 --- a/SqlPipeline/Tests/SqlPipeline.Tests.ps1 +++ b/SqlPipeline/Tests/SqlPipeline.Tests.ps1 @@ -3,7 +3,7 @@ BeforeAll { Import-Module "$PSScriptRoot/../SqlPipeline" -Force -Verbose # Create a test SQLite connection Write-Host "Hello World 2" - Open-SQLiteConnection -DataSource "$PSScriptRoot/test.db" -Verbose + Open-SQLiteConnection -DataSource "$PSScriptRoot/test.db" -ConnectionName "default" -Verbose Write-Host "Hello Worl3" } @@ -87,4 +87,4 @@ Describe "Add-RowsToSql" { $query.Data | Should -Match '"Key":"Value"' $query.Data | Should -Match '"Num":123' } -} \ No newline at end of file +} From 51819116121a078c58aa1fb2b60c7da31ec33cf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 12:55:07 +0000 Subject: [PATCH 06/10] docs(EncryptCredential): add keyfile ACL guidance and scheduled task/service usage - Fix factual error: encrypted strings are AES-based (not DPAPI) and ARE portable across machines/users when the same keyfile is available - Fix function name typo: Get-PlaintextToSecure -> Convert-PlaintextToSecure - Add 'Keyfile Security' section documenting auto-applied permissions and PowerShell/bash snippets for manually verifying or tightening ACLs - Add 'Using with Scheduled Tasks or Windows Services' section with four options: same-user, dedicated service account, shared keyfile with ACL (Windows), and Linux systemd service with restricted file ownership https://claude.ai/code/session_01EMrR6kMzxdtT58UWQchGkw --- EncryptCredential/README.md | 137 +++++++++++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/EncryptCredential/README.md b/EncryptCredential/README.md index df76998..3a1e261 100644 --- a/EncryptCredential/README.md +++ b/EncryptCredential/README.md @@ -32,14 +32,145 @@ This module is used to double encrypt sensitive data like credentials, tokens et At the first encryption or when calling `Export-Keyfile` a new random keyfile will be generated for salting with AES. The key ist saved per default in your users profile, but can be exported into any other folder and use it from there. -Be aware that the encrypted strings are only valid for the executing machine as it uses SecureStrings that cannot be -copied over to other machines. -If you don't provide a keyfile, it will be automatically generated with your first call of 'Get-PlaintextToSecure' +> **Important**: The encrypted strings are tied to the **keyfile**, not to a specific machine or user account. Because the module encrypts with AES using the keyfile as the key (not Windows DPAPI), the encrypted strings **can** be used on other machines or by other user accounts — as long as the same keyfile is available and readable. Guard the keyfile accordingly. + +If you don't provide a keyfile, it will be automatically generated with your first call of `Convert-PlaintextToSecure` You can use `Import-Keyfile` to use a keyfile that has been exported before. +# Keyfile Security + +When a keyfile is first created, the module automatically restricts file system access: + +- **Windows**: inherited ACEs are removed; only the current user is granted `FullControl` +- **Linux/macOS**: file permissions are set to `600` (owner read/write only) + +You can verify and manually tighten permissions at any time: + +**Windows** + +```PowerShell +# Verify current ACL +Get-Acl -Path "$env:LOCALAPPDATA\AptecoPSModules\key.aes" | Format-List + +# Lock down to current user only (removes all inherited rules) +$keyPath = "$env:LOCALAPPDATA\AptecoPSModules\key.aes" +$acl = Get-Acl -Path $keyPath +$acl.SetAccessRuleProtection($true, $false) # disable inheritance, discard inherited rules +$rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + [System.Security.Principal.WindowsIdentity]::GetCurrent().Name, + "FullControl", + [System.Security.AccessControl.AccessControlType]::Allow +) +$acl.SetAccessRule($rule) +Set-Acl -Path $keyPath -AclObject $acl +``` + +**Linux / macOS** + +```bash +# Verify permissions (should show -rw-------) +ls -la ~/.local/share/AptecoPSModules/key.aes + +# Restrict to owner only if needed +chmod 600 ~/.local/share/AptecoPSModules/key.aes +``` + + +# Using with Scheduled Tasks or Windows Services + +Because encryption is AES-based (not Windows DPAPI), the encrypted strings are portable. The only requirement is that **the account running the task or service can read the keyfile**. + +## Option 1 — Run as the same user (simplest) + +Configure the scheduled task to run as the user who originally encrypted the credentials. The default keyfile at `%LOCALAPPDATA%\AptecoPSModules\key.aes` will be picked up automatically. + +## Option 2 — Dedicated service account, profile keyfile + +1. Log in as the service account (or use `runas`) and encrypt the credentials once: + +```PowerShell +Import-Module EncryptCredential +$encrypted = "MyPassword" | Convert-PlaintextToSecure +# Store $encrypted in your config file or script +``` + +The keyfile is written to the service account's own profile. The scheduled task or service then runs as that same account and finds the keyfile automatically. + +## Option 3 — Shared keyfile with restricted ACL (Windows) + +Use this when the encrypting user and the running account are different. + +```PowerShell +# One-time setup: export the keyfile to a shared, admin-controlled location +$sharedKey = "C:\ProgramData\AptecoPSModules\key.aes" +Export-Keyfile -Path $sharedKey + +# Lock down: remove inheritance, grant Administrators + service account only +$acl = Get-Acl -Path $sharedKey +$acl.SetAccessRuleProtection($true, $false) + +$adminRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "BUILTIN\Administrators", + "FullControl", + [System.Security.AccessControl.AccessControlType]::Allow +) +$svcRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "DOMAIN\svc_myservice", # replace with your service account + "Read", + [System.Security.AccessControl.AccessControlType]::Allow +) +$acl.SetAccessRule($adminRule) +$acl.SetAccessRule($svcRule) +Set-Acl -Path $sharedKey -AclObject $acl +``` + +In the scheduled task / service script, import the keyfile before decrypting: + +```PowerShell +Import-Module EncryptCredential +Import-Keyfile -Path "C:\ProgramData\AptecoPSModules\key.aes" +$password = $encryptedString | Convert-SecureToPlaintext +``` + +## Option 4 — Linux systemd service + +Create a dedicated system user and restrict keyfile access: + +```bash +# Copy the keyfile to a directory only the service user can read +sudo mkdir -p /var/lib/apteco +sudo cp ~/.local/share/AptecoPSModules/key.aes /var/lib/apteco/key.aes +sudo chown apteco:apteco /var/lib/apteco/key.aes +sudo chmod 600 /var/lib/apteco/key.aes +``` + +Example systemd unit (`/etc/systemd/system/myservice.service`): + +```ini +[Unit] +Description=My Apteco Service + +[Service] +User=apteco +ExecStart=/usr/bin/pwsh -File /opt/apteco/myservice.ps1 +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +In the PowerShell script: + +```PowerShell +Import-Module EncryptCredential +Import-Keyfile -Path "/var/lib/apteco/key.aes" +$password = $encryptedString | Convert-SecureToPlaintext +``` + + # Installation You can just download the whole repository here and pick this script or your can use PSGallery through PowerShell commands directly. From 8f3d1e5cb9c971ee2d08ce3bab031691476f4060 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 13:21:53 +0000 Subject: [PATCH 07/10] feat(EncryptCredential): bind encrypted strings to machine and OS user via HMAC-SHA256 Adds Private/Get-BoundKey.ps1 which derives the actual AES key as: HMAC-SHA256(key=keyfileBytes, data=machineId|userId) Machine and user identity are read from the OS at runtime: - Windows: MachineGuid (registry) + current user SID - Linux: /etc/machine-id + username + numeric UID (id -u) There is no caller-supplied parameter to override these values; the only way to decrypt is to physically be running as the same user on the same machine that performed the encryption. Both Convert-PlaintextToSecure and Convert-SecureToPlaintext now call Get-BoundKey after Read-Keyfile instead of using raw keyfile bytes. BREAKING CHANGE: all previously encrypted strings must be re-encrypted. Bumps version to 0.4.0. Updates README with a 'Machine and User Binding' section, corrects the scheduled-task options to reflect that encryption and decryption must be performed by the same OS account, and updates the shared-keyfile option accordingly. https://claude.ai/code/session_01EMrR6kMzxdtT58UWQchGkw --- .../EncryptCredential/EncryptCredential.psd1 | 10 ++- .../Private/Get-BoundKey.ps1 | 82 +++++++++++++++++++ .../Public/Convert-PlaintextToSecure.ps1 | 5 +- .../Public/Convert-SecureToPlaintext.ps1 | 6 +- EncryptCredential/README.md | 74 +++++++++++++---- 5 files changed, 158 insertions(+), 19 deletions(-) create mode 100644 EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 diff --git a/EncryptCredential/EncryptCredential/EncryptCredential.psd1 b/EncryptCredential/EncryptCredential/EncryptCredential.psd1 index a476fd4..e08eff6 100644 --- a/EncryptCredential/EncryptCredential/EncryptCredential.psd1 +++ b/EncryptCredential/EncryptCredential/EncryptCredential.psd1 @@ -4,7 +4,7 @@ RootModule = 'EncryptCredential.psm1' # Version number of this module. -ModuleVersion = '0.3.0' +ModuleVersion = '0.4.0' # Supported PSEditions # CompatiblePSEditions = @() @@ -135,6 +135,14 @@ PrivateData = @{ # ReleaseNotes of this module ReleaseNotes = " +0.4.0 Added machine-and-user binding via HMAC-SHA256 key derivation (Get-BoundKey). + The raw keyfile bytes are no longer used directly as the AES key. Instead the + actual key is derived as HMAC-SHA256(key=keyfileBytes, data=machineId|userId), + where machineId and userId are read from the OS at runtime (MachineGuid + SID on + Windows; /etc/machine-id + username + UID on Linux). Encrypted strings can no + longer be decrypted on a different machine or under a different user account even + if the keyfile is available. BREAKING CHANGE: all previously encrypted strings + must be re-encrypted after upgrading. 0.3.0 Reworked the module with Claude AI to be more secure and robust, now using another way to create a keyfile for salting. The old encryption method is still supported, so all previously encrypted strings will stay valid UNTIL you call New-Keyfile. After that, diff --git a/EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 b/EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 new file mode 100644 index 0000000..4a079ff --- /dev/null +++ b/EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 @@ -0,0 +1,82 @@ +Function Get-BoundKey { +<# + Derives a machine-and-user-bound 32-byte AES key from raw keyfile bytes. + + SECURITY MODEL + -------------- + The raw keyfile bytes are never used directly as the AES encryption key. + Instead, an HMAC-SHA256 is computed using: + + HMAC-SHA256( key = raw keyfile bytes, <- the secret you must possess + data = machine_id | user_id <- read from the OS at runtime ) + + This means that even if an attacker obtains the keyfile, they still cannot + decrypt anything unless they are ALSO: + - Running on the same machine (same Machine GUID / machine-id) + - Running as the same OS user account (same SID / UID) + + Critically, the machine identity and user identity are obtained at runtime + from the operating system itself. There is no parameter a caller can pass + to override or forge them. The only way to decrypt on a given machine as a + given user is to physically be that user on that machine. + + HMAC output is always 32 bytes, which is the correct size for AES-256. +#> + + [OutputType([byte[]])] + param( + [Parameter(Mandatory=$true)][byte[]]$RawKey + ) + + # --- collect OS-provided binding (not caller-supplied) ------------------- + + if ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) { + + # Windows: MachineGuid (unique per Windows install) + current user SID + # Both values come from the OS; neither can be faked by the calling script. + $machineGuid = (Get-ItemProperty ` + -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' ` + -Name 'MachineGuid' ` + -ErrorAction Stop).MachineGuid + + $userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value + + $binding = "${machineGuid}|${userSid}" + + } else { + + # Linux / macOS: /etc/machine-id (unique per OS install) + username + numeric UID + # /etc/machine-id is set once at install time and never changes. + # The numeric UID is the OS-assigned identity for the running process. + $machineId = $null + foreach ($candidate in @('/etc/machine-id', '/var/lib/dbus/machine-id')) { + if (Test-Path -Path $candidate) { + $machineId = ([System.IO.File]::ReadAllText($candidate)).Trim() + break + } + } + if ([string]::IsNullOrEmpty($machineId)) { + throw "Cannot determine machine identity: neither /etc/machine-id nor " + + "/var/lib/dbus/machine-id was found. Ensure one of these files exists." + } + + $userName = [System.Environment]::UserName + $uid = (& id -u 2>$null) + $binding = "${machineId}|${userName}|${uid}" + + } + + # --- derive key ----------------------------------------------------------- + # HMAC-SHA256(key=keyfileBytes, data=bindingString) + # The keyfile is the HMAC key (secret); machine+user identity is the data. + # Changing any part of the binding produces a completely different output. + + $bindingBytes = [System.Text.Encoding]::UTF8.GetBytes($binding) + $hmac = [System.Security.Cryptography.HMACSHA256]::new($RawKey) + try { + return [byte[]]$hmac.ComputeHash($bindingBytes) # always 32 bytes + } finally { + $hmac.Dispose() + } + +} diff --git a/EncryptCredential/EncryptCredential/Public/Convert-PlaintextToSecure.ps1 b/EncryptCredential/EncryptCredential/Public/Convert-PlaintextToSecure.ps1 index 41b1b26..a39086a 100644 --- a/EncryptCredential/EncryptCredential/Public/Convert-PlaintextToSecure.ps1 +++ b/EncryptCredential/EncryptCredential/Public/Convert-PlaintextToSecure.ps1 @@ -78,8 +78,9 @@ Function Convert-PlaintextToSecure { $return = "" - # read key bytes (handles both binary and legacy text format) - $salt = Read-Keyfile -Path $Script:keyfile + # read key bytes (handles both binary and legacy text format), + # then derive a machine-and-user-bound key so the result is not portable + $salt = Get-BoundKey -RawKey (Read-Keyfile -Path $Script:keyfile) # convert $stringSecure = ConvertTo-secureString -String $String -asplaintext -force diff --git a/EncryptCredential/EncryptCredential/Public/Convert-SecureToPlaintext.ps1 b/EncryptCredential/EncryptCredential/Public/Convert-SecureToPlaintext.ps1 index 2ebb117..07271ca 100644 --- a/EncryptCredential/EncryptCredential/Public/Convert-SecureToPlaintext.ps1 +++ b/EncryptCredential/EncryptCredential/Public/Convert-SecureToPlaintext.ps1 @@ -52,8 +52,10 @@ Function Convert-SecureToPlaintext { $return = "" - # read key bytes (handles both binary and legacy text format) - $salt = Read-Keyfile -Path $Script:keyfile + # read key bytes (handles both binary and legacy text format), + # then derive a machine-and-user-bound key so decryption only succeeds + # on the same machine and under the same OS user account + $salt = Get-BoundKey -RawKey (Read-Keyfile -Path $Script:keyfile) #convert Try { diff --git a/EncryptCredential/README.md b/EncryptCredential/README.md index 3a1e261..085feea 100644 --- a/EncryptCredential/README.md +++ b/EncryptCredential/README.md @@ -33,13 +33,51 @@ This module is used to double encrypt sensitive data like credentials, tokens et At the first encryption or when calling `Export-Keyfile` a new random keyfile will be generated for salting with AES. The key ist saved per default in your users profile, but can be exported into any other folder and use it from there. -> **Important**: The encrypted strings are tied to the **keyfile**, not to a specific machine or user account. Because the module encrypts with AES using the keyfile as the key (not Windows DPAPI), the encrypted strings **can** be used on other machines or by other user accounts — as long as the same keyfile is available and readable. Guard the keyfile accordingly. +> **Important**: Encrypted strings are bound to the **machine they were created on** and the **OS user account that created them**. They cannot be decrypted on a different machine or by a different user, even if the keyfile is available. See [Machine and User Binding](#machine-and-user-binding) below for how this works. If you don't provide a keyfile, it will be automatically generated with your first call of `Convert-PlaintextToSecure` You can use `Import-Keyfile` to use a keyfile that has been exported before. +# Machine and User Binding + +The raw keyfile bytes are **never** used directly as the AES encryption key. +Before any encryption or decryption, the module derives the actual key via HMAC-SHA256: + +``` +AES key = HMAC-SHA256( key = keyfile bytes ← the secret you must possess + data = machine_id | user_id ← read from the OS at runtime ) +``` + +### Where the identity comes from + +| Platform | Machine identity | User identity | +|----------|-----------------|---------------| +| Windows | `MachineGuid` from `HKLM:\SOFTWARE\Microsoft\Cryptography` | Current user SID via `WindowsIdentity.GetCurrent()` | +| Linux | `/etc/machine-id` (set once at OS install time) | `$env:UserName` + numeric UID from `id -u` | + +Both values are **read from the operating system at runtime**. There is no parameter a script or caller can pass to override them. The only way to decrypt on a given machine as a given user is to actually be running as that user on that machine. + +### What this means in practice + +- Copying the encrypted string to another machine → decryption fails (different machine ID) +- Copying the keyfile to another machine and running the same script → decryption fails (different machine ID) +- Running as a different user account on the same machine → decryption fails (different SID / UID) +- An attacker who steals the keyfile but is on a different machine → cannot decrypt + +### What this does NOT protect against + +- An attacker who **is already running as the same user on the same machine** (they have everything needed) +- Physical access to the machine combined with extraction of `/etc/machine-id` and the keyfile (both inputs to the HMAC are then known) + +The binding adds a meaningful extra layer, but it is not a substitute for protecting the keyfile itself with strict file permissions. Both defences work together. + +### Scheduled tasks and services + +Because the binding uses the OS user identity at runtime, a scheduled task or service **must run as the same user account that originally encrypted the credentials**. If you change the service account, you must re-encrypt. See [Using with Scheduled Tasks or Windows Services](#using-with-scheduled-tasks-or-windows-services) for setup options. + + # Keyfile Security When a keyfile is first created, the module automatically restricts file system access: @@ -99,45 +137,53 @@ $encrypted = "MyPassword" | Convert-PlaintextToSecure The keyfile is written to the service account's own profile. The scheduled task or service then runs as that same account and finds the keyfile automatically. -## Option 3 — Shared keyfile with restricted ACL (Windows) +## Option 3 — Custom keyfile location for the service account (Windows) + +> **Note**: Because encryption is bound to the OS user, the credentials **must be encrypted by the same service account** that will later decrypt them. You cannot encrypt as one user and decrypt as another. + +Use this when you want the keyfile stored centrally (e.g. `ProgramData`) rather than in the service account's roaming profile. -Use this when the encrypting user and the running account are different. +**One-time setup** — run this as the service account (`runas /user:DOMAIN\svc_myservice powershell`): ```PowerShell -# One-time setup: export the keyfile to a shared, admin-controlled location -$sharedKey = "C:\ProgramData\AptecoPSModules\key.aes" +Import-Module EncryptCredential + +# Place the keyfile in a shared, admin-controlled location +$sharedKey = "C:\ProgramData\AptecoPSModules\svc_myservice.aes" Export-Keyfile -Path $sharedKey -# Lock down: remove inheritance, grant Administrators + service account only +# Restrict: remove inheritance, grant Administrators + this service account only $acl = Get-Acl -Path $sharedKey $acl.SetAccessRuleProtection($true, $false) - $adminRule = New-Object System.Security.AccessControl.FileSystemAccessRule( - "BUILTIN\Administrators", - "FullControl", + "BUILTIN\Administrators", "FullControl", [System.Security.AccessControl.AccessControlType]::Allow ) $svcRule = New-Object System.Security.AccessControl.FileSystemAccessRule( - "DOMAIN\svc_myservice", # replace with your service account - "Read", + "DOMAIN\svc_myservice", "Read", # the account that will also decrypt [System.Security.AccessControl.AccessControlType]::Allow ) $acl.SetAccessRule($adminRule) $acl.SetAccessRule($svcRule) Set-Acl -Path $sharedKey -AclObject $acl + +# Now encrypt — must be run as the same service account +$encrypted = "MyPassword" | Convert-PlaintextToSecure +# Store $encrypted in your config file ``` -In the scheduled task / service script, import the keyfile before decrypting: +In the scheduled task / service script (running as `svc_myservice`): ```PowerShell Import-Module EncryptCredential -Import-Keyfile -Path "C:\ProgramData\AptecoPSModules\key.aes" +Import-Keyfile -Path "C:\ProgramData\AptecoPSModules\svc_myservice.aes" $password = $encryptedString | Convert-SecureToPlaintext ``` ## Option 4 — Linux systemd service -Create a dedicated system user and restrict keyfile access: +Create a dedicated system user, encrypt credentials as that user, and restrict keyfile access. +The systemd service must run as the same user that did the encryption. ```bash # Copy the keyfile to a directory only the service user can read From df28e07a7686dfb3664c5799ca2d8ca29484cf29 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 13:26:43 +0000 Subject: [PATCH 08/10] docs(EncryptCredential): add v0.4.0 migration instructions to README Two paths covered: - Path A: decrypt all stored strings while v0.3.0 is still installed, upgrade, then re-encrypt with v0.4.0 - Path B: already upgraded without migrating; decrypt using raw PowerShell (ConvertTo-SecureString -Key with raw keyfile bytes, bypassing the new HMAC binding) then re-encrypt with the new module Also includes a Read-KeyfileRaw helper that handles both the new binary format and the legacy line-per-byte text format. https://claude.ai/code/session_01EMrR6kMzxdtT58UWQchGkw --- EncryptCredential/README.md | 76 +++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/EncryptCredential/README.md b/EncryptCredential/README.md index 085feea..dc6c556 100644 --- a/EncryptCredential/README.md +++ b/EncryptCredential/README.md @@ -217,6 +217,82 @@ $password = $encryptedString | Convert-SecureToPlaintext ``` +# Migrating to v0.4.0 + +v0.4.0 changed how the AES key is derived (see [Machine and User Binding](#machine-and-user-binding)). +All strings encrypted with v0.3.0 or earlier will fail to decrypt with v0.4.0. +You need to decrypt the old strings and re-encrypt them with the new module. + +## Path A — upgrade before migrating (recommended) + +Do this while v0.3.0 is still installed. + +```PowerShell +# 1. Decrypt every stored string using the old module +Import-Module EncryptCredential # must be v0.3.0 +# Import-Keyfile -Path "C:\...\key.aes" # only needed if you use a non-default location + +$plain1 = "" | Convert-SecureToPlaintext +$plain2 = "" | Convert-SecureToPlaintext +# repeat for every credential you have stored + +# 2. Upgrade the module +Update-Module EncryptCredential # or: Install-Module EncryptCredential -Force + +# 3. Re-encrypt with the new module +Import-Module EncryptCredential -Force # loads v0.4.0 +# Import-Keyfile -Path "C:\...\key.aes" # same keyfile as before + +$new1 = $plain1 | Convert-PlaintextToSecure +$new2 = $plain2 | Convert-PlaintextToSecure +# replace the stored values with $new1, $new2, etc. +``` + +## Path B — already upgraded to v0.4.0 without migrating first + +If you upgraded before decrypting, the module can no longer read the old strings. +Decrypt them directly using raw PowerShell (no module needed), then re-encrypt: + +```PowerShell +# Helper: read the keyfile bytes (handles both binary and legacy text format) +function Read-KeyfileRaw ([string]$Path) { + $bytes = [System.IO.File]::ReadAllBytes($Path) + if ($bytes.Length -in @(16, 24, 32)) { return $bytes } # new binary format + + # Legacy format: one decimal byte value per line + $lines = [System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::UTF8) ` + -split "`r?`n" | Where-Object { $_.Trim() -ne '' } + return [byte[]]($lines | ForEach-Object { [byte]$_.Trim() }) +} + +# Adjust this path if you used a custom keyfile location +$keyPath = "$env:LOCALAPPDATA\AptecoPSModules\key.aes" # Windows default +# $keyPath = "$env:HOME/.local/share/AptecoPSModules/key.aes" # Linux default + +$keyBytes = Read-KeyfileRaw -Path $keyPath + +# Decrypt each old string using the raw AES key (v0.3.0 method, no binding) +function Decrypt-OldString ([string]$Encrypted, [byte[]]$Key) { + $secure = ConvertTo-SecureString -String $Encrypted -Key $Key + $plain = (New-Object PSCredential "x", $secure).GetNetworkCredential().Password + $secure.Dispose() + return $plain +} + +$plain1 = Decrypt-OldString -Encrypted "" -Key $keyBytes +$plain2 = Decrypt-OldString -Encrypted "" -Key $keyBytes +# repeat for every credential + +# Re-encrypt with v0.4.0 (bound to this machine + current user) +Import-Module EncryptCredential +# Import-Keyfile -Path $keyPath # only needed for a non-default location + +$new1 = $plain1 | Convert-PlaintextToSecure +$new2 = $plain2 | Convert-PlaintextToSecure +# replace the stored values with $new1, $new2, etc. +``` + + # Installation You can just download the whole repository here and pick this script or your can use PSGallery through PowerShell commands directly. From 582be20a00584e87478595098a0287cbc80202f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 13:52:51 +0000 Subject: [PATCH 09/10] feat(EncryptCredential): replace HMAC binding with DPAPI on Windows Windows (New-KeyfileRaw): keyfile is now written as a DPAPI-protected blob (ProtectedData.Protect, CurrentUser scope) instead of raw bytes. The raw AES key never touches disk. DPAPI is backed by Windows credential infrastructure (LSASS / TPM); knowing the user SID or machine GUID alone cannot bypass it. Windows (Get-BoundKey): calls ProtectedData.Unprotect() to recover the AES key. Throws a clear error for legacy raw-bytes keyfiles pointing to the migration guide. Read-Keyfile: returns DPAPI blobs (> 32 bytes on Windows) as-is for Get-BoundKey to handle, preserving the legacy text-format fallback on Linux. Linux/macOS: HMAC-SHA256 binding unchanged. README: rewrites the Machine and User Binding section to explain the platform split, documents the DPAPI user-profile requirement for scheduled tasks (LogonType=Password, not S4U), adds domain password-reset / gMSA warning, and updates the migration guide to reflect the new keyfile format. https://claude.ai/code/session_01EMrR6kMzxdtT58UWQchGkw --- .../EncryptCredential/EncryptCredential.psd1 | 21 +-- .../Private/Get-BoundKey.ps1 | 98 ++++++------- .../Private/New-KeyfileRaw.ps1 | 23 +++- .../Private/Read-Keyfile.ps1 | 16 ++- EncryptCredential/README.md | 129 +++++++++++------- 5 files changed, 176 insertions(+), 111 deletions(-) diff --git a/EncryptCredential/EncryptCredential/EncryptCredential.psd1 b/EncryptCredential/EncryptCredential/EncryptCredential.psd1 index e08eff6..570bd60 100644 --- a/EncryptCredential/EncryptCredential/EncryptCredential.psd1 +++ b/EncryptCredential/EncryptCredential/EncryptCredential.psd1 @@ -135,14 +135,19 @@ PrivateData = @{ # ReleaseNotes of this module ReleaseNotes = " -0.4.0 Added machine-and-user binding via HMAC-SHA256 key derivation (Get-BoundKey). - The raw keyfile bytes are no longer used directly as the AES key. Instead the - actual key is derived as HMAC-SHA256(key=keyfileBytes, data=machineId|userId), - where machineId and userId are read from the OS at runtime (MachineGuid + SID on - Windows; /etc/machine-id + username + UID on Linux). Encrypted strings can no - longer be decrypted on a different machine or under a different user account even - if the keyfile is available. BREAKING CHANGE: all previously encrypted strings - must be re-encrypted after upgrading. +0.4.0 Added machine-and-user binding to make encrypted strings non-portable. + Windows: keyfile is now written as a DPAPI-protected blob (CurrentUser scope) + by New-KeyfileRaw. Get-BoundKey calls ProtectedData.Unprotect() to recover + the AES key; this only succeeds for the same user on the same machine and is + backed by Windows credential infrastructure (LSASS, optionally TPM). + Knowing the user SID or machine GUID alone is not sufficient to bypass it. + Linux/macOS: key is derived via HMAC-SHA256(key=keyfileBytes, + data=machine-id|username|uid), all read from the OS at runtime. + Read-Keyfile updated to return DPAPI blobs as-is for Get-BoundKey to handle. + Scheduled tasks on Windows must use LogonType=Password to ensure the user + profile is loaded (required by DPAPI). + BREAKING CHANGE: all previously encrypted strings must be re-encrypted. + See 'Migrating to v0.4.0' in the README. 0.3.0 Reworked the module with Claude AI to be more secure and robust, now using another way to create a keyfile for salting. The old encryption method is still supported, so all previously encrypted strings will stay valid UNTIL you call New-Keyfile. After that, diff --git a/EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 b/EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 index 4a079ff..cef24d0 100644 --- a/EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 +++ b/EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 @@ -1,26 +1,27 @@ Function Get-BoundKey { <# - Derives a machine-and-user-bound 32-byte AES key from raw keyfile bytes. + Derives or recovers a machine-and-user-bound 32-byte AES key from raw keyfile bytes. SECURITY MODEL -------------- - The raw keyfile bytes are never used directly as the AES encryption key. - Instead, an HMAC-SHA256 is computed using: - - HMAC-SHA256( key = raw keyfile bytes, <- the secret you must possess - data = machine_id | user_id <- read from the OS at runtime ) - - This means that even if an attacker obtains the keyfile, they still cannot - decrypt anything unless they are ALSO: - - Running on the same machine (same Machine GUID / machine-id) - - Running as the same OS user account (same SID / UID) - - Critically, the machine identity and user identity are obtained at runtime - from the operating system itself. There is no parameter a caller can pass - to override or forge them. The only way to decrypt on a given machine as a - given user is to physically be that user on that machine. - - HMAC output is always 32 bytes, which is the correct size for AES-256. + Windows + New-KeyfileRaw writes the keyfile as a DPAPI-protected blob (CurrentUser scope). + Get-BoundKey calls ProtectedData.Unprotect() to recover the original random bytes. + Unprotect only succeeds for the same user on the same machine; it is backed by + Windows credential infrastructure (LSASS, optionally TPM-backed master key). + Unlike the previous HMAC approach, knowing the user SID or machine GUID alone + is NOT sufficient — the user's actual login credentials are part of the key + material managed by Windows. + + Linux / macOS + The keyfile holds raw random bytes (no DPAPI available). + An HMAC-SHA256 is computed over those bytes using machine identity + (/etc/machine-id) and the current numeric UID as the HMAC key. + This raises the bar against cross-machine / cross-user keyfile theft but is + a software-only binding — the binding values are not themselves secret. + + In both cases the binding is determined by the operating system at runtime. + There is no parameter a caller can supply to override or forge the identity. #> [OutputType([byte[]])] @@ -28,26 +29,32 @@ Function Get-BoundKey { [Parameter(Mandatory=$true)][byte[]]$RawKey ) - # --- collect OS-provided binding (not caller-supplied) ------------------- - if ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) { - # Windows: MachineGuid (unique per Windows install) + current user SID - # Both values come from the OS; neither can be faked by the calling script. - $machineGuid = (Get-ItemProperty ` - -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' ` - -Name 'MachineGuid' ` - -ErrorAction Stop).MachineGuid - - $userSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value - - $binding = "${machineGuid}|${userSid}" + # Windows: keyfile is a DPAPI blob written by New-KeyfileRaw. + # Unprotect recovers the original AES key bytes. + # This only succeeds for the same user on the same machine. + Add-Type -AssemblyName System.Security + try { + return [byte[]][System.Security.Cryptography.ProtectedData]::Unprotect( + $RawKey, + $null, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser + ) + } catch [System.Security.Cryptography.CryptographicException] { + throw ( + "Keyfile cannot be decrypted by DPAPI. Possible causes: " + + "(1) the keyfile was created on a different machine or by a different user account; " + + "(2) it is in the legacy raw-bytes format from v0.3.0 or earlier — " + + "see 'Migrating to v0.4.0' in the README." + ) + } } else { - # Linux / macOS: /etc/machine-id (unique per OS install) + username + numeric UID - # /etc/machine-id is set once at install time and never changes. - # The numeric UID is the OS-assigned identity for the running process. + # Linux / macOS: HMAC-SHA256 binding. + # machine-id + username + numeric UID are read from the OS at runtime; + # they cannot be overridden by the calling script. $machineId = $null foreach ($candidate in @('/etc/machine-id', '/var/lib/dbus/machine-id')) { if (Test-Path -Path $candidate) { @@ -56,27 +63,24 @@ Function Get-BoundKey { } } if ([string]::IsNullOrEmpty($machineId)) { - throw "Cannot determine machine identity: neither /etc/machine-id nor " + - "/var/lib/dbus/machine-id was found. Ensure one of these files exists." + throw ( + "Cannot determine machine identity: neither /etc/machine-id nor " + + "/var/lib/dbus/machine-id was found. Ensure one of these files exists." + ) } $userName = [System.Environment]::UserName $uid = (& id -u 2>$null) $binding = "${machineId}|${userName}|${uid}" - } - - # --- derive key ----------------------------------------------------------- - # HMAC-SHA256(key=keyfileBytes, data=bindingString) - # The keyfile is the HMAC key (secret); machine+user identity is the data. - # Changing any part of the binding produces a completely different output. + $bindingBytes = [System.Text.Encoding]::UTF8.GetBytes($binding) + $hmac = [System.Security.Cryptography.HMACSHA256]::new($RawKey) + try { + return [byte[]]$hmac.ComputeHash($bindingBytes) # always 32 bytes + } finally { + $hmac.Dispose() + } - $bindingBytes = [System.Text.Encoding]::UTF8.GetBytes($binding) - $hmac = [System.Security.Cryptography.HMACSHA256]::new($RawKey) - try { - return [byte[]]$hmac.ComputeHash($bindingBytes) # always 32 bytes - } finally { - $hmac.Dispose() } } diff --git a/EncryptCredential/EncryptCredential/Private/New-KeyfileRaw.ps1 b/EncryptCredential/EncryptCredential/Private/New-KeyfileRaw.ps1 index 24b76ac..7840f28 100644 --- a/EncryptCredential/EncryptCredential/Private/New-KeyfileRaw.ps1 +++ b/EncryptCredential/EncryptCredential/Private/New-KeyfileRaw.ps1 @@ -42,11 +42,22 @@ Function New-KeyfileRaw { $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() $rng.GetBytes($Key) $rng.Dispose() - [System.IO.File]::WriteAllBytes($Path, $Key) - # Restrict file access to the current user only If ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) { - # Windows: remove inherited ACEs, grant current user full control + + # Windows: DPAPI-protect the key bytes before writing to disk. + # The resulting blob can only be decrypted by the same user on the + # same machine — it is backed by Windows credential infrastructure + # (LSASS, optionally TPM). The raw AES key never touches disk. + Add-Type -AssemblyName System.Security + $protected = [System.Security.Cryptography.ProtectedData]::Protect( + $Key, + $null, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser + ) + [System.IO.File]::WriteAllBytes($Path, $protected) + + # ACL: also restrict file access to the current user (defence in depth) $acl = Get-Acl -Path $Path $acl.SetAccessRuleProtection($true, $false) $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( @@ -56,9 +67,13 @@ Function New-KeyfileRaw { ) $acl.SetAccessRule($rule) Set-Acl -Path $Path -AclObject $acl + } else { - # Linux/macOS: owner read/write only (600) + + # Linux/macOS: save raw bytes, restrict to owner read/write only (600) + [System.IO.File]::WriteAllBytes($Path, $Key) & chmod 600 $Path + } } else { diff --git a/EncryptCredential/EncryptCredential/Private/Read-Keyfile.ps1 b/EncryptCredential/EncryptCredential/Private/Read-Keyfile.ps1 index 45d3678..32b162b 100644 --- a/EncryptCredential/EncryptCredential/Private/Read-Keyfile.ps1 +++ b/EncryptCredential/EncryptCredential/Private/Read-Keyfile.ps1 @@ -1,9 +1,11 @@ Function Read-Keyfile { <# - Returns the AES key bytes from the keyfile. - Supports both formats: - - New: raw binary (16/24/32 bytes written with WriteAllBytes) - - Legacy: UTF8 text with one decimal number per line (written with Set-Content -Encoding UTF8) + Returns raw file bytes for Get-BoundKey to interpret. + + Supports three formats: + - Windows v0.4.0+: DPAPI-protected blob (variable length, always > 32 bytes) + - Binary (Linux / Windows v0.3.0): raw AES key, exactly 16, 24, or 32 bytes + - Legacy text (all platforms, very old): UTF8, one decimal byte value per line #> param( [Parameter(Mandatory=$true)][String]$Path @@ -11,8 +13,10 @@ Function Read-Keyfile { $rawBytes = [System.IO.File]::ReadAllBytes($Path) - If ($rawBytes.Length -in @(16, 24, 32)) { - # Binary keyfile (new format) + # Binary file: either a raw AES key (16/24/32 bytes) or a Windows DPAPI blob (> 32 bytes). + # Return as-is in both cases; Get-BoundKey handles the interpretation. + If ($rawBytes.Length -in @(16, 24, 32) -or + (($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) -and $rawBytes.Length -gt 32)) { return $rawBytes } diff --git a/EncryptCredential/README.md b/EncryptCredential/README.md index dc6c556..c20dc84 100644 --- a/EncryptCredential/README.md +++ b/EncryptCredential/README.md @@ -42,40 +42,54 @@ You can use `Import-Keyfile` to use a keyfile that has been exported before. # Machine and User Binding -The raw keyfile bytes are **never** used directly as the AES encryption key. -Before any encryption or decryption, the module derives the actual key via HMAC-SHA256: +The approach differs by platform. -``` -AES key = HMAC-SHA256( key = keyfile bytes ← the secret you must possess - data = machine_id | user_id ← read from the OS at runtime ) -``` +## Windows — DPAPI (Data Protection API) -### Where the identity comes from +On Windows the keyfile is written to disk as a **DPAPI-protected blob** (`CurrentUser` scope). +DPAPI is a Windows OS service that encrypts data using key material derived from the user's +login credentials, managed by LSASS and optionally backed by the machine's TPM. -| Platform | Machine identity | User identity | -|----------|-----------------|---------------| -| Windows | `MachineGuid` from `HKLM:\SOFTWARE\Microsoft\Cryptography` | Current user SID via `WindowsIdentity.GetCurrent()` | -| Linux | `/etc/machine-id` (set once at OS install time) | `$env:UserName` + numeric UID from `id -u` | +When the module needs the AES key it calls `ProtectedData.Unprotect()`, which only succeeds +for the **same user on the same machine**. There is no way to bypass this by knowing +the user SID or machine GUID — you need the user's actual Windows credentials. +An attacker who steals the keyfile file cannot unprotect it on a different machine or account. -Both values are **read from the operating system at runtime**. There is no parameter a script or caller can pass to override them. The only way to decrypt on a given machine as a given user is to actually be running as that user on that machine. +## Linux / macOS — HMAC-SHA256 binding -### What this means in practice +DPAPI is not available on Linux. Instead the AES key is derived as: -- Copying the encrypted string to another machine → decryption fails (different machine ID) -- Copying the keyfile to another machine and running the same script → decryption fails (different machine ID) -- Running as a different user account on the same machine → decryption fails (different SID / UID) -- An attacker who steals the keyfile but is on a different machine → cannot decrypt +``` +AES key = HMAC-SHA256( key = keyfile bytes ← the secret you must possess + data = machine_id | user | uid ← read from the OS at runtime ) +``` -### What this does NOT protect against +| Value | Source | +|-------|--------| +| `machine_id` | `/etc/machine-id` (set once at OS install) | +| `user` | `$env:UserName` | +| `uid` | numeric user ID from `id -u` | -- An attacker who **is already running as the same user on the same machine** (they have everything needed) -- Physical access to the machine combined with extraction of `/etc/machine-id` and the keyfile (both inputs to the HMAC are then known) +All three are read from the OS at runtime. There is no parameter a caller can supply +to override them. -The binding adds a meaningful extra layer, but it is not a substitute for protecting the keyfile itself with strict file permissions. Both defences work together. +> **Limitation**: unlike DPAPI, these binding values are not themselves secret — +> they can be looked up on the machine. The HMAC binding raises the bar against +> opportunistic cross-machine/cross-user keyfile theft, but a targeted attacker +> who has both the keyfile and knowledge of the machine-id + UID could reconstruct +> the key. File permissions (`chmod 600`) are therefore still the primary defence on Linux. -### Scheduled tasks and services +## What this means in practice -Because the binding uses the OS user identity at runtime, a scheduled task or service **must run as the same user account that originally encrypted the credentials**. If you change the service account, you must re-encrypt. See [Using with Scheduled Tasks or Windows Services](#using-with-scheduled-tasks-or-windows-services) for setup options. +- Copying the encrypted string to another machine → decryption fails +- Copying the keyfile to another machine → decryption fails (DPAPI / different machine-id) +- Running as a different user account → decryption fails (DPAPI / different UID) +- Changing the service account → must re-encrypt all credentials + +## Scheduled tasks and services + +A scheduled task or service **must run as the same OS user account that encrypted the credentials**. +See [Using with Scheduled Tasks or Windows Services](#using-with-scheduled-tasks-or-windows-services) for setup options. # Keyfile Security @@ -119,7 +133,23 @@ chmod 600 ~/.local/share/AptecoPSModules/key.aes # Using with Scheduled Tasks or Windows Services -Because encryption is AES-based (not Windows DPAPI), the encrypted strings are portable. The only requirement is that **the account running the task or service can read the keyfile**. +On Windows, DPAPI requires the **user profile to be loaded** when the task runs. +Configure the scheduled task with `LogonType = Password` (not `S4U`), which ensures +the profile is loaded. `S4U` logon may skip profile loading and cause DPAPI to fail. + +```PowerShell +# Correct: Password logon loads the user profile +$principal = New-ScheduledTaskPrincipal -UserId "DOMAIN\svc_myservice" -LogonType Password + +# Avoid: S4U may not load the profile, causing DPAPI decryption to fail +# $principal = New-ScheduledTaskPrincipal -UserId "DOMAIN\svc_myservice" -LogonType S4U +``` + +> **Domain environments**: if an administrator resets a service account password **without +> knowing the old password** (a forced reset), Windows cannot re-protect the DPAPI master +> key and it may become permanently inaccessible. Always change service account passwords +> via a normal password change, or use a **Group Managed Service Account (gMSA)** which +> handles rotation automatically without this risk. ## Option 1 — Run as the same user (simplest) @@ -219,29 +249,33 @@ $password = $encryptedString | Convert-SecureToPlaintext # Migrating to v0.4.0 -v0.4.0 changed how the AES key is derived (see [Machine and User Binding](#machine-and-user-binding)). -All strings encrypted with v0.3.0 or earlier will fail to decrypt with v0.4.0. -You need to decrypt the old strings and re-encrypt them with the new module. +v0.4.0 introduced machine-and-user binding. All strings encrypted with v0.3.0 +or earlier will fail to decrypt with v0.4.0. You need to decrypt the old strings +and re-encrypt them with the new module. -## Path A — upgrade before migrating (recommended) +> **Windows note**: v0.4.0 also changes the keyfile format from raw bytes to a +> DPAPI-protected blob. After re-encrypting, run `New-Keyfile` to regenerate the +> keyfile in the new format. The old raw-bytes keyfile will no longer be readable +> by v0.4.0. + +## Path A — decrypt before upgrading (recommended) Do this while v0.3.0 is still installed. ```PowerShell -# 1. Decrypt every stored string using the old module -Import-Module EncryptCredential # must be v0.3.0 -# Import-Keyfile -Path "C:\...\key.aes" # only needed if you use a non-default location +# 1. Decrypt every stored string using v0.3.0 +Import-Module EncryptCredential # must still be v0.3.0 +# Import-Keyfile -Path "C:\...\key.aes" # only if you use a non-default location $plain1 = "" | Convert-SecureToPlaintext $plain2 = "" | Convert-SecureToPlaintext -# repeat for every credential you have stored +# repeat for every stored credential # 2. Upgrade the module Update-Module EncryptCredential # or: Install-Module EncryptCredential -Force -# 3. Re-encrypt with the new module +# 3. Re-encrypt — this also generates a new DPAPI-protected keyfile automatically Import-Module EncryptCredential -Force # loads v0.4.0 -# Import-Keyfile -Path "C:\...\key.aes" # same keyfile as before $new1 = $plain1 | Convert-PlaintextToSecure $new2 = $plain2 | Convert-PlaintextToSecure @@ -250,14 +284,17 @@ $new2 = $plain2 | Convert-PlaintextToSecure ## Path B — already upgraded to v0.4.0 without migrating first -If you upgraded before decrypting, the module can no longer read the old strings. -Decrypt them directly using raw PowerShell (no module needed), then re-encrypt: +If you upgraded before decrypting, the module can no longer read the old strings +because the old keyfile is raw bytes but v0.4.0 expects a DPAPI blob on Windows, +or an HMAC-bound key on Linux. + +Decrypt using raw PowerShell (bypasses the module entirely), then re-encrypt: ```PowerShell -# Helper: read the keyfile bytes (handles both binary and legacy text format) +# Helper: read raw keyfile bytes (handles binary and legacy text format) function Read-KeyfileRaw ([string]$Path) { $bytes = [System.IO.File]::ReadAllBytes($Path) - if ($bytes.Length -in @(16, 24, 32)) { return $bytes } # new binary format + if ($bytes.Length -in @(16, 24, 32)) { return $bytes } # Legacy format: one decimal byte value per line $lines = [System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::UTF8) ` @@ -265,13 +302,13 @@ function Read-KeyfileRaw ([string]$Path) { return [byte[]]($lines | ForEach-Object { [byte]$_.Trim() }) } -# Adjust this path if you used a custom keyfile location +# Adjust path if you used a custom keyfile location $keyPath = "$env:LOCALAPPDATA\AptecoPSModules\key.aes" # Windows default # $keyPath = "$env:HOME/.local/share/AptecoPSModules/key.aes" # Linux default $keyBytes = Read-KeyfileRaw -Path $keyPath -# Decrypt each old string using the raw AES key (v0.3.0 method, no binding) +# Decrypt using the old raw-AES method (no binding, no DPAPI) function Decrypt-OldString ([string]$Encrypted, [byte[]]$Key) { $secure = ConvertTo-SecureString -String $Encrypted -Key $Key $plain = (New-Object PSCredential "x", $secure).GetNetworkCredential().Password @@ -279,13 +316,13 @@ function Decrypt-OldString ([string]$Encrypted, [byte[]]$Key) { return $plain } -$plain1 = Decrypt-OldString -Encrypted "" -Key $keyBytes -$plain2 = Decrypt-OldString -Encrypted "" -Key $keyBytes -# repeat for every credential +$plain1 = Decrypt-OldString "" $keyBytes +$plain2 = Decrypt-OldString "" $keyBytes +# repeat for every stored credential -# Re-encrypt with v0.4.0 (bound to this machine + current user) -Import-Module EncryptCredential -# Import-Keyfile -Path $keyPath # only needed for a non-default location +# Re-encrypt with v0.4.0 — a new DPAPI-protected keyfile is created automatically +Import-Module EncryptCredential -Force +# Note: do NOT call Import-Keyfile here — let the module create a fresh keyfile $new1 = $plain1 | Convert-PlaintextToSecure $new2 = $plain2 | Convert-PlaintextToSecure From 22af9eddfb6375c01cbe32a35d7aca6cc3fbca66 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 14:27:50 +0000 Subject: [PATCH 10/10] docs(EncryptCredential): add C# hosted PowerShell section with DPAPI smoke-test Adds Option 5 to the scheduled tasks section covering both in-process (Runspace.CreateRunspace) and out-of-process (PowerShellProcessInstance) C# hosting. Explains that the same LoadUserProfile requirement applies in both cases, since the child process inherits the parent's Windows token and profile state. Includes a C# out-of-process code example and a DPAPI smoke-test snippet (ProtectedData round-trip) that can be added to startup/health-check logic to verify DPAPI is available before the module is called at runtime. https://claude.ai/code/session_01EMrR6kMzxdtT58UWQchGkw --- EncryptCredential/README.md | 57 +++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/EncryptCredential/README.md b/EncryptCredential/README.md index c20dc84..480a6a6 100644 --- a/EncryptCredential/README.md +++ b/EncryptCredential/README.md @@ -246,6 +246,63 @@ Import-Keyfile -Path "/var/lib/apteco/key.aes" $password = $encryptedString | Convert-SecureToPlaintext ``` +## Option 5 — C# hosted PowerShell (in-process or out-of-process) + +The module works when called from C# via the PowerShell SDK, both with an +in-process runspace and with an out-of-process runspace spawned at a specific +bitness using `PowerShellProcessInstance`. + +**In-process** (`Runspace.CreateRunspace()`): the runspace runs inside the C# +process and inherits its Windows identity and profile state directly. + +**Out-of-process** (`PowerShellProcessInstance`): a child PowerShell process is +spawned. It inherits the parent process's Windows token and profile state, so +DPAPI behaves identically to the parent. + +In both cases the **same `LoadUserProfile` requirement applies** as for scheduled +tasks. If the hosting process is a Windows Service or IIS app pool, ensure the +user profile is loaded before any DPAPI call is made (see the note at the top of +this section). + +```csharp +// Out-of-process example — 32-bit PowerShell 5.1 +var exe = new FileInfo( + @"C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe"); +var instance = new PowerShellProcessInstance( + new Version(5, 1), null, exe, false); + +using var runspace = RunspaceFactory.CreateOutOfProcessRunspace(null, instance); +runspace.Open(); + +using var ps = PowerShell.Create(); +ps.Runspace = runspace; +ps.AddCommand("Import-Module").AddArgument("EncryptCredential"); +ps.Invoke(); + +ps.Commands.Clear(); +ps.AddCommand("Convert-SecureToPlaintext") + .AddParameter("String", encryptedString); + +string plaintext = ps.Invoke().FirstOrDefault(); +``` + +> **Verify DPAPI is available** from the hosting process before deploying. +> Add the snippet below to your startup / health-check logic — it will throw +> immediately with a clear message if the user profile is not loaded, rather +> than failing silently later at runtime: +> +> ```csharp +> // Smoke-test: round-trip a dummy value through DPAPI +> var dummy = System.Text.Encoding.UTF8.GetBytes("dpapi-check"); +> var blob = System.Security.Cryptography.ProtectedData.Protect( +> dummy, null, +> System.Security.Cryptography.DataProtectionScope.CurrentUser); +> System.Security.Cryptography.ProtectedData.Unprotect( +> blob, null, +> System.Security.Cryptography.DataProtectionScope.CurrentUser); +> // If this line is reached, DPAPI is working correctly. +> ``` + # Migrating to v0.4.0