Skip to content

Tooling & Utilities

Tooling & Utilities

pwsh

# ~\Documents\WindowsPowerShell\Modules\Stash\Stash.psm1

Add-Type -AssemblyName System.Security

$script:StashDir = "$env:USERPROFILE\.stash"

function Initialize-Stash {
    if (-not (Test-Path $script:StashDir)) {
        New-Item -Path $script:StashDir -ItemType Directory | Out-Null
    }
}

function Get-KeyPath {
    param([string]$Key)
    # Support / grouping by replacing with __
    $safeName = $Key -replace "/", "__"
    Join-Path $script:StashDir "$safeName.secret"
}

function Invoke-StashSet {
    param(
        [Parameter(Position=0, Mandatory)][string]$Key,
        [Parameter(Position=1)][string]$Value
    )
    Initialize-Stash

    if (-not $Value) {
        $secure = Read-Host "  Enter secret for '$Key'" -AsSecureString
        $Value = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
            [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
        )
    }

    $bytes = [Text.Encoding]::UTF8.GetBytes($Value)
    $encrypted = [Security.Cryptography.ProtectedData]::Protect(
        $bytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser
    )

    [IO.File]::WriteAllBytes((Get-KeyPath $Key), $encrypted)
    Write-Host "  Stored: " -ForegroundColor Green -NoNewline
    Write-Host $Key -ForegroundColor Cyan
}

function Invoke-StashGet {
    param(
        [Parameter(Position=0, Mandatory)][string]$Key,
        [switch]$Print
    )
    $path = Get-KeyPath $Key
    if (-not (Test-Path $path)) {
        Write-Host "  Key '$Key' not found" -ForegroundColor Red
        return
    }

    $encrypted = [IO.File]::ReadAllBytes($path)
    $decrypted = [Security.Cryptography.ProtectedData]::Unprotect(
        $encrypted, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser
    )
    $value = [Text.Encoding]::UTF8.GetString($decrypted)

    if ($Print) {
        Write-Host "  $Key = " -ForegroundColor DarkGray -NoNewline
        Write-Host $value -ForegroundColor Yellow
    } else {
        Set-Clipboard $value
        Write-Host "  Copied to clipboard" -ForegroundColor Green -NoNewline
        Write-Host " (clears in 30s)" -ForegroundColor DarkGray

        Start-Job -ScriptBlock {
            Start-Sleep -Seconds 30
            Set-Clipboard ""
        } | Out-Null
    }
}

function Invoke-StashList {
    param([Parameter(Position=0)][string]$Prefix)
    Initialize-Stash

    $files = Get-ChildItem $script:StashDir -Filter "*.secret"
    if (-not $files) { Write-Host "  No secrets stored" -ForegroundColor Yellow; return }

    $keys = $files | ForEach-Object { $_.BaseName -replace "__", "/" }

    if ($Prefix) {
        $keys = $keys | Where-Object { $_ -match "^$([regex]::Escape($Prefix))" }
    }

    if (-not $keys) { Write-Host "  No matches for '$Prefix'" -ForegroundColor Yellow; return }

    Write-Host ""
    foreach ($k in $keys) {
        Write-Host "  $k" -ForegroundColor Cyan
    }
    Write-Host ""
}

function Invoke-StashRemove {
    param([Parameter(Position=0, Mandatory)][string]$Key)
    $path = Get-KeyPath $Key
    if (-not (Test-Path $path)) {
        Write-Host "  Key '$Key' not found" -ForegroundColor Red
        return
    }

    $confirm = Read-Host "  Delete '$Key'? (y/N)"
    if ($confirm -eq "y") {
        Remove-Item $path -Force
        Write-Host "  Deleted: $Key" -ForegroundColor Green
    }
}

function Invoke-StashExport {
    param([Parameter(Position=0, Mandatory)][string]$File)
    Initialize-Stash

    $files = Get-ChildItem $script:StashDir -Filter "*.secret"
    if (-not $files) { Write-Host "  Nothing to export" -ForegroundColor Yellow; return }

    # Decrypt all secrets into a hashtable
    $bundle = @{}
    foreach ($f in $files) {
        $key = $f.BaseName -replace "__", "/"
        $encrypted = [IO.File]::ReadAllBytes($f.FullName)
        $decrypted = [Security.Cryptography.ProtectedData]::Unprotect(
            $encrypted, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser
        )
        $bundle[$key] = [Convert]::ToBase64String($decrypted)
    }

    $json = $bundle | ConvertTo-Json
    $plainBytes = [Text.Encoding]::UTF8.GetBytes($json)

    # Passphrase
    $secure = Read-Host "  Export passphrase" -AsSecureString
    $passphrase = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
        [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
    )

    # Derive AES key
    $salt = New-Object byte[] 32
    [Security.Cryptography.RNGCryptoServiceProvider]::new().GetBytes($salt)

    $derived = New-Object Security.Cryptography.Rfc2898DeriveBytes($passphrase, $salt, 100000)
    $key = $derived.GetBytes(32)
    $iv  = $derived.GetBytes(16)

    # AES encrypt
    $aes = [Security.Cryptography.Aes]::Create()
    $aes.Key = $key
    $aes.IV  = $iv
    $encryptor = $aes.CreateEncryptor()
    $encryptedBlob = $encryptor.TransformFinalBlock($plainBytes, 0, $plainBytes.Length)

    # Write: salt + encrypted
    $output = New-Object byte[] ($salt.Length + $encryptedBlob.Length)
    [Array]::Copy($salt, 0, $output, 0, $salt.Length)
    [Array]::Copy($encryptedBlob, 0, $output, $salt.Length, $encryptedBlob.Length)

    [IO.File]::WriteAllBytes($File, $output)
    Write-Host "  Exported $($files.Count) secrets to $File" -ForegroundColor Green
}

function Invoke-StashImport {
    param([Parameter(Position=0, Mandatory)][string]$File)
    if (-not (Test-Path $File)) {
        Write-Host "  File not found: $File" -ForegroundColor Red
        return
    }
    Initialize-Stash

    $raw = [IO.File]::ReadAllBytes($File)

    # Split salt and encrypted blob
    $salt = $raw[0..31]
    $encryptedBlob = $raw[32..($raw.Length - 1)]

    # Passphrase
    $secure = Read-Host "  Import passphrase" -AsSecureString
    $passphrase = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
        [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
    )

    # Derive key
    $derived = New-Object Security.Cryptography.Rfc2898DeriveBytes($passphrase, [byte[]]$salt, 100000)
    $key = $derived.GetBytes(32)
    $iv  = $derived.GetBytes(16)

    # AES decrypt
    try {
        $aes = [Security.Cryptography.Aes]::Create()
        $aes.Key = $key
        $aes.IV  = $iv
        $decryptor = $aes.CreateDecryptor()
        $decryptedBytes = $decryptor.TransformFinalBlock([byte[]]$encryptedBlob, 0, $encryptedBlob.Length)
    } catch {
        Write-Host "  Wrong passphrase or corrupted file" -ForegroundColor Red
        return
    }

    $json = [Text.Encoding]::UTF8.GetString($decryptedBytes)
    $bundle = $json | ConvertFrom-Json

    $count = 0
    $bundle.PSObject.Properties | ForEach-Object {
        $plainBytes = [Convert]::FromBase64String($_.Value)
        $reEncrypted = [Security.Cryptography.ProtectedData]::Protect(
            $plainBytes, $null, [Security.Cryptography.DataProtectionScope]::CurrentUser
        )
        [IO.File]::WriteAllBytes((Get-KeyPath $_.Name), $reEncrypted)
        $count++
    }

    Write-Host "  Imported $count secrets" -ForegroundColor Green
}

function stash {
    param(
        [Parameter(Position=0)][string]$Command,
        [Parameter(ValueFromRemainingArguments)][object[]]$Args
    )

    switch ($Command) {
        'set'    { Invoke-StashSet @Args }
        'get'    { Invoke-StashGet @Args }
        'list'   { Invoke-StashList @Args }
        'ls'     { Invoke-StashList @Args }
        'rm'     { Invoke-StashRemove @Args }
        'export' { Invoke-StashExport @Args }
        'import' { Invoke-StashImport @Args }
        default  {
            Write-Host ""
            Write-Host "  stash" -ForegroundColor Cyan -NoNewline
            Write-Host " - secrets manager" -ForegroundColor DarkGray
            Write-Host ""
            Write-Host "  set <key> [value]" -ForegroundColor Yellow -NoNewline
            Write-Host "   Store a secret (prompts if no value)"
            Write-Host "  get <key> [-Print]" -ForegroundColor Yellow -NoNewline
            Write-Host "  Decrypt to clipboard (or screen)"
            Write-Host "  list [prefix]    " -ForegroundColor Yellow -NoNewline
            Write-Host "  List stored keys"
            Write-Host "  rm <key>         " -ForegroundColor Yellow -NoNewline
            Write-Host "  Delete a secret"
            Write-Host "  export <file>    " -ForegroundColor Yellow -NoNewline
            Write-Host "  AES-encrypted backup"
            Write-Host "  import <file>    " -ForegroundColor Yellow -NoNewline
            Write-Host "  Restore from backup"
            Write-Host ""
        }
    }
}

Export-ModuleMember -Function stash