diff --git a/.github/workflows/sqlpipeline.tests.yml b/.github/workflows/sqlpipeline.tests.yml index 99ff03e..7a586c6 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: Debug run: | @@ -53,8 +53,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' diff --git a/EncryptCredential/EncryptCredential/EncryptCredential.psd1 b/EncryptCredential/EncryptCredential/EncryptCredential.psd1 index a476fd4..570bd60 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,19 @@ PrivateData = @{ # ReleaseNotes of this module ReleaseNotes = " +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 new file mode 100644 index 0000000..cef24d0 --- /dev/null +++ b/EncryptCredential/EncryptCredential/Private/Get-BoundKey.ps1 @@ -0,0 +1,86 @@ +Function Get-BoundKey { +<# + Derives or recovers a machine-and-user-bound 32-byte AES key from raw keyfile bytes. + + SECURITY MODEL + -------------- + 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[]])] + param( + [Parameter(Mandatory=$true)][byte[]]$RawKey + ) + + if ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) { + + # 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: 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) { + $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}" + + $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/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 df76998..480a6a6 100644 --- a/EncryptCredential/README.md +++ b/EncryptCredential/README.md @@ -32,14 +32,361 @@ 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**: 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 approach differs by platform. + +## Windows — DPAPI (Data Protection API) + +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. + +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. + +## Linux / macOS — HMAC-SHA256 binding + +DPAPI is not available on Linux. Instead the AES key is derived as: + +``` +AES key = HMAC-SHA256( key = keyfile bytes ← the secret you must possess + data = machine_id | user | uid ← read from the OS at runtime ) +``` + +| Value | Source | +|-------|--------| +| `machine_id` | `/etc/machine-id` (set once at OS install) | +| `user` | `$env:UserName` | +| `uid` | numeric user ID from `id -u` | + +All three are read from the OS at runtime. There is no parameter a caller can supply +to override them. + +> **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. + +## What this means in practice + +- 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 + +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 + +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) + +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 — 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. + +**One-time setup** — run this as the service account (`runas /user:DOMAIN\svc_myservice powershell`): + +```PowerShell +Import-Module EncryptCredential + +# Place the keyfile in a shared, admin-controlled location +$sharedKey = "C:\ProgramData\AptecoPSModules\svc_myservice.aes" +Export-Keyfile -Path $sharedKey + +# 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", + [System.Security.AccessControl.AccessControlType]::Allow +) +$svcRule = New-Object System.Security.AccessControl.FileSystemAccessRule( + "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 (running as `svc_myservice`): + +```PowerShell +Import-Module EncryptCredential +Import-Keyfile -Path "C:\ProgramData\AptecoPSModules\svc_myservice.aes" +$password = $encryptedString | Convert-SecureToPlaintext +``` + +## Option 4 — Linux systemd service + +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 +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 +``` + +## 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 + +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. + +> **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 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 stored credential + +# 2. Upgrade the module +Update-Module EncryptCredential # or: Install-Module EncryptCredential -Force + +# 3. Re-encrypt — this also generates a new DPAPI-protected keyfile automatically +Import-Module EncryptCredential -Force # loads v0.4.0 + +$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 +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 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 } + + # 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 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 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 + $secure.Dispose() + return $plain +} + +$plain1 = Decrypt-OldString "" $keyBytes +$plain2 = Decrypt-OldString "" $keyBytes +# repeat for every stored credential + +# 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 +# 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. 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 diff --git a/SqlPipeline/Tests/SqlPipeline.Tests.ps1 b/SqlPipeline/Tests/SqlPipeline.Tests.ps1 index c25835d..992b59b 100644 --- a/SqlPipeline/Tests/SqlPipeline.Tests.ps1 +++ b/SqlPipeline/Tests/SqlPipeline.Tests.ps1 @@ -88,4 +88,4 @@ Describe "Add-RowsToSql" { $query.Data | Should -Match '"Key":"Value"' $query.Data | Should -Match '"Num":123' } -} \ No newline at end of file +}