Public/Initialize-MicrosoftPowerShellSecretStoreVault.ps1

function Initialize-MicrosoftPowerShellSecretStoreVault {
    <#
    .SYNOPSIS
        One-time setup: configures SecretStore, registers a local vault, and
        stores a JSON config string as an encrypted secret.
 
    .DESCRIPTION
        Idempotent - safe to re-run to update the stored config.
 
        Calls Use-MicrosoftPowerShellSecretStoreProvider at the start, which
        installs Microsoft.PowerShell.SecretManagement and
        Microsoft.PowerShell.SecretStore if not already present and registers
        the provider for the current session.
 
        The SecretStore vault is AES-256 encrypted and scoped to the current
        Windows user account via DPAPI. No secrets are written to disk in plain
        text. This function is specific to the Microsoft.PowerShell.SecretStore
        backend - for other backends, implement an equivalent
        Initialize-*Vault function that calls the corresponding Use-*Provider.
 
    .PARAMETER VaultName
        Name of the SecretStore vault to register (e.g. 'GHRunners').
 
    .PARAMETER SecretName
        Name of the secret to store inside the vault
        (e.g. 'GHRunnersConfig').
 
    .PARAMETER ConfigJson
        The config as a raw JSON string. Mutually exclusive with -ConfigFile.
 
    .PARAMETER ConfigFile
        Path to a JSON file. Contents are read and stored in the vault;
        the file itself is not modified. Mutually exclusive with -ConfigJson.
 
    .PARAMETER RequireVaultPassword
        When specified, the SecretStore vault requires an interactive password
        on each session. Recommended on shared or less-trusted machines.
 
    .PARAMETER Validate
        Optional scriptblock that receives the JSON string and performs
        project-specific validation. Throw to abort before touching the vault.
 
        Example:
            -Validate {
                param($json)
                $defs = @($json | ConvertFrom-Json)
                if ($defs.Count -eq 0) { throw 'No entries found.' }
            }
    #>

    [CmdletBinding(DefaultParameterSetName = 'File')]
    param(
        [Parameter(Mandatory)]
        [string] $VaultName,

        [Parameter(Mandatory)]
        [string] $SecretName,

        [Parameter(Mandatory, ParameterSetName = 'Json')]
        [string] $ConfigJson,

        [Parameter(Mandatory, ParameterSetName = 'File')]
        [string] $ConfigFile,

        [Parameter()]
        [switch] $RequireVaultPassword,

        [Parameter()]
        [scriptblock] $Validate
    )

    # -----------------------------------------------------------------------
    # 1. Load JSON from file or inline string
    # -----------------------------------------------------------------------

    if ($PSCmdlet.ParameterSetName -eq 'File') {
        if (-not (Test-Path $ConfigFile -PathType Leaf)) {
            throw "Config file not found: $ConfigFile"
        }
        $ConfigJson = Get-Content -Raw -Path $ConfigFile
    }

    # -----------------------------------------------------------------------
    # 2. Optional project-specific validation
    # Run before touching the vault so we fail fast on bad config.
    # -----------------------------------------------------------------------

    if ($null -ne $Validate) {
        & $Validate $ConfigJson
    }

    # -----------------------------------------------------------------------
    # 3. Ensure SecretStore modules are installed and register the provider
    # Use-MicrosoftPowerShellSecretStoreProvider installs
    # Microsoft.PowerShell.SecretManagement and
    # Microsoft.PowerShell.SecretStore via Invoke-ModuleInstall, then
    # registers the provider for the current session.
    # -----------------------------------------------------------------------

    Use-MicrosoftPowerShellSecretStoreProvider

    # -----------------------------------------------------------------------
    # 4. Configure SecretStore
    # Authentication=None means the vault is unlocked automatically for
    # the current Windows user (key derived from Windows user profile).
    # No separate vault password is required unless -RequireVaultPassword.
    #
    # Detection: call Get-SecretStoreConfiguration first (official API).
    # On an uninitialised store it throws - treat as "needs init".
    # On a Password-auth store the cmdlet may also throw in some module
    # versions, so we fall back to reading the storeconfig file directly.
    #
    # Initialisation: SecretStore always requires a password on first
    # Reset-SecretStore, even when the target auth mode is None. We
    # supply a temp password non-interactively, then switch to the target
    # auth mode immediately. Reset-SecretStore has a custom confirmation
    # check beyond ShouldProcess that requires -Force; $Global:ConfirmPreference
    # = 'None' suppresses the remaining ShouldProcess prompt.
    # -----------------------------------------------------------------------

    $authMode = if ($RequireVaultPassword) { 'Password' } else { 'None' }

    Write-Host "Configuring SecretStore (Authentication=$authMode) ..." `
        -ForegroundColor Cyan

    $currentAuth = $null

    try {
        $storeCfg  = Get-SecretStoreConfiguration -ErrorAction Stop
        $authValue = $storeCfg.Authentication
        $currentAuth = if ($authValue -eq 0 -or "$authValue" -eq 'None') {
            'None'
        } else {
            'Password'
        }
    }
    catch {
        # Store not initialised, or Password-auth store on an older module
        # version where the cmdlet throws - fall through to file fallback.
    }

    if ($null -eq $currentAuth) {
        $storePath   = Join-Path $env:LOCALAPPDATA `
            'Microsoft\PowerShell\secretmanagement\localstore'
        $storeConfig = Join-Path $storePath 'storeconfig'

        if (Test-Path $storeConfig) {
            try {
                $fileCfg   = Get-Content $storeConfig -Raw | ConvertFrom-Json
                $authValue = $fileCfg.Authentication
                $currentAuth = if ($authValue -eq 0 -or
                                   "$authValue" -eq 'None') {
                    'None'
                } else {
                    'Password'
                }
            }
            catch { }
        }
    }

    if ($currentAuth -ne $authMode) {
        if ($null -ne $currentAuth) {
            $storePath = Join-Path $env:LOCALAPPDATA `
                'Microsoft\PowerShell\secretmanagement\localstore'
            throw (
                "SecretStore is configured with " +
                "Authentication='$currentAuth' but '$authMode' is required.`n`n" +
                "Option A - you know your vault password:`n" +
                " Set-SecretStoreConfiguration -Authentication $authMode " +
                "-Interaction Prompt`n`n" +
                "Option B - the store has no secrets you need:`n" +
                " Remove-Item '$storePath' -Recurse -Force`n`n" +
                "Then re-run this script."
            )
        }

        $tempPass  = ConvertTo-SecureString 'InfrastructureSecretsInit1!' `
            -AsPlainText -Force
        # $ConfirmPreference = 'None' suppresses the ShouldProcess confirmation.
        # Reset-SecretStore also has a custom confirmation check on top of
        # ShouldProcess that requires -Force to bypass.
        $savedPref = $ConfirmPreference
        try {
            $ConfirmPreference = 'None'
            Reset-SecretStore -Authentication Password -Password $tempPass `
                -Interaction None -Force
            Set-SecretStoreConfiguration -Authentication $authMode `
                -Password $tempPass -Interaction None -Confirm:$false
        }
        finally {
            $Global:ConfirmPreference = $savedPref
        }
        Write-Host "OK - SecretStore initialised (Authentication=$authMode)." `
            -ForegroundColor Green
    }
    else {
        Write-Host "OK - SecretStore already configured (Authentication=$authMode)." `
            -ForegroundColor Green
    }

    # -----------------------------------------------------------------------
    # 5. Register vault (idempotent)
    # -----------------------------------------------------------------------

    if (-not (Get-SecretVault -Name $VaultName -ErrorAction SilentlyContinue)) {
        Write-Host "Registering vault '$VaultName' ..." -ForegroundColor Cyan
        Register-SecretVault `
            -Name $VaultName `
            -ModuleName Microsoft.PowerShell.SecretStore `
            -DefaultVault
        Write-Host "OK - Vault '$VaultName' registered." -ForegroundColor Green
    }
    else {
        Write-Host "OK - Vault '$VaultName' already registered." -ForegroundColor Green
    }

    # -----------------------------------------------------------------------
    # 6. Store the secret (Set-Secret overwrites - safe to re-run)
    # -----------------------------------------------------------------------

    Write-Host "Storing secret '$SecretName' in vault '$VaultName' ..." `
        -ForegroundColor Cyan
    Set-Secret -Vault $VaultName -Name $SecretName -Secret $ConfigJson
    Write-Host "OK - Secret stored." -ForegroundColor Green

    # -----------------------------------------------------------------------
    # 7. Round-trip verification
    # -----------------------------------------------------------------------

    $readBack = Get-Secret -Vault $VaultName -Name $SecretName -AsPlainText
    $null = $readBack | ConvertFrom-Json   # throws if corrupted
    Write-Host "OK - '$SecretName' readable from vault." -ForegroundColor Green
}