Private/PasswordStore.ps1

# Account passwords. The environment variable SMTP_PASS_<ACCOUNT> comes first (CI, containers, systemd);
# otherwise the password is kept where the platform keeps such secrets:
# - Windows: the Credential Manager, as the generic credential "ElektoMailPosh:<account>", encrypted by
# DPAPI with the user's logon, like any password Windows remembers.
# - Linux and macOS: the file secrets/<account> in the accounts folder, readable only by its owner
# (mode 600, the folder 700), as msmtp, ssh and .pgpass do on machines without a desktop keyring.
# A file that others can read is refused.

$script:CredentialTargetPrefix = 'ElektoMailPosh:'

function Get-PasswordVariableName([string]$Account) {
    return 'SMTP_PASS_' + ($Account.ToUpperInvariant() -replace '[^A-Z0-9]', '_')
}

function Get-PasswordFilePath([string]$Account) {
    return Join-Path (Join-Path (Get-AccountDirectory) 'secrets') $Account.ToLowerInvariant()
}

# How to give an account its password, for the error raised when it has none
function Get-MissingPasswordMessage([string]$Account) {
    return "Account '$Account' has no password. Store it with: Set-MailAccount -Name $Account -Password (Read-Host -AsSecureString 'Password')" +
        " (or set the environment variable $(Get-PasswordVariableName $Account))."
}

# Compiles, once per session, the calls to the Windows Credential Manager
function Initialize-CredentialManager {
    if ('ElektoMailPosh.CredentialManager' -as [type]) { return }
    Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;

namespace ElektoMailPosh
{
    public static class CredentialManager
    {
        private const int CRED_TYPE_GENERIC = 1;
        private const int CRED_PERSIST_LOCAL_MACHINE = 2;
        private const int ERROR_NOT_FOUND = 1168;

        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
        private struct CREDENTIAL
        {
            public int Flags;
            public int Type;
            public string TargetName;
            public string Comment;
            public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
            public int CredentialBlobSize;
            public IntPtr CredentialBlob;
            public int Persist;
            public int AttributeCount;
            public IntPtr Attributes;
            public string TargetAlias;
            public string UserName;
        }

        [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)]
        private static extern bool CredRead(string target, int type, int flags, out IntPtr credential);

        [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)]
        private static extern bool CredWrite(ref CREDENTIAL credential, int flags);

        [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)]
        private static extern bool CredDelete(string target, int type, int flags);

        [DllImport("advapi32.dll")]
        private static extern void CredFree(IntPtr buffer);

        // The password of the generic credential, or null when there is none
        public static string Read(string target)
        {
            IntPtr pointer;
            if (!CredRead(target, CRED_TYPE_GENERIC, 0, out pointer))
            {
                int error = Marshal.GetLastWin32Error();
                if (error == ERROR_NOT_FOUND) return null;
                throw new Win32Exception(error);
            }
            try
            {
                CREDENTIAL credential = Marshal.PtrToStructure<CREDENTIAL>(pointer);
                if (credential.CredentialBlobSize == 0) return string.Empty;
                return Marshal.PtrToStringUni(credential.CredentialBlob, credential.CredentialBlobSize / 2);
            }
            finally
            {
                CredFree(pointer);
            }
        }

        // Creates or replaces the generic credential; the password is stored as UTF-16, as cmdkey does
        public static void Write(string target, string userName, string password)
        {
            CREDENTIAL credential = new CREDENTIAL
            {
                Type = CRED_TYPE_GENERIC,
                TargetName = target,
                UserName = userName,
                CredentialBlobSize = password.Length * 2,
                CredentialBlob = Marshal.StringToCoTaskMemUni(password),
                Persist = CRED_PERSIST_LOCAL_MACHINE
            };
            try
            {
                if (!CredWrite(ref credential, 0)) throw new Win32Exception(Marshal.GetLastWin32Error());
            }
            finally
            {
                Marshal.ZeroFreeCoTaskMemUnicode(credential.CredentialBlob);
            }
        }

        // False when there was no such credential
        public static bool Delete(string target)
        {
            if (CredDelete(target, CRED_TYPE_GENERIC, 0)) return true;
            int error = Marshal.GetLastWin32Error();
            if (error == ERROR_NOT_FOUND) return false;
            throw new Win32Exception(error);
        }
    }
}
'@

}

# The stored password of the account, or $null when none is stored
function Read-StoredPassword([string]$Account) {
    if ($IsWindows) {
        Initialize-CredentialManager
        return [ElektoMailPosh.CredentialManager]::Read($script:CredentialTargetPrefix + $Account)
    }

    $path = Get-PasswordFilePath $Account
    if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null }
    $othersAccess = [IO.UnixFileMode]'GroupRead, GroupWrite, GroupExecute, OtherRead, OtherWrite, OtherExecute'
    if ([IO.File]::GetUnixFileMode($path) -band $othersAccess) {
        throw "The password file '$path' can be read by other users. Restrict it with: chmod 600 '$path'"
    }
    return ([IO.File]::ReadAllText($path)).TrimEnd("`r", "`n")
}

function Save-StoredPassword([string]$Account, [string]$User, [string]$Secret) {
    if ($IsWindows) {
        Initialize-CredentialManager
        [ElektoMailPosh.CredentialManager]::Write($script:CredentialTargetPrefix + $Account, $User, $Secret)
        return
    }

    $path = Get-PasswordFilePath $Account
    $ownerOnly = [IO.UnixFileMode]'UserRead, UserWrite'
    $directory = Split-Path -Path $path
    [IO.Directory]::CreateDirectory($directory, $ownerOnly -bor [IO.UnixFileMode]::UserExecute) | Out-Null
    [IO.File]::SetUnixFileMode($directory, $ownerOnly -bor [IO.UnixFileMode]::UserExecute)
    # The mode is set before anything is written, so the password is never in a file others can read
    if (Test-Path -LiteralPath $path) { [IO.File]::SetUnixFileMode($path, $ownerOnly) }
    $options = [IO.FileStreamOptions]@{ Mode = 'Create'; Access = 'Write'; UnixCreateMode = $ownerOnly }
    $stream = [IO.FileStream]::new($path, $options)
    try {
        $bytes = [Text.Encoding]::UTF8.GetBytes($Secret)
        $stream.Write($bytes, 0, $bytes.Length)
    } finally {
        $stream.Dispose()
    }
}

# False when no password was stored
function Clear-StoredPassword([string]$Account) {
    if ($IsWindows) {
        Initialize-CredentialManager
        return [ElektoMailPosh.CredentialManager]::Delete($script:CredentialTargetPrefix + $Account)
    }

    $path = Get-PasswordFilePath $Account
    if (-not (Test-Path -LiteralPath $path)) { return $false }
    Remove-Item -LiteralPath $path -Force
    return $true
}

# The password of the account and where it came from (Environment, CredentialManager or File), or
# $null when it has none
function Get-AccountPassword([string]$Account) {
    $password = [Environment]::GetEnvironmentVariable((Get-PasswordVariableName $Account))
    if ($password) { return @{ Password = $password; Source = 'Environment' } }

    $password = Read-StoredPassword $Account
    if ($password) { return @{ Password = $password; Source = if ($IsWindows) { 'CredentialManager' } else { 'File' } } }
    return $null
}