public/New-OTPSecret.ps1

## New-OTPSecret.ps1
<#
.SYNOPSIS
    Generates a secure OTP secret and complete otpauth URI.
.DESCRIPTION
    This cmdlet generates a secure OTP secret suitable for use with
    TOTP (Time-based One-Time Password) or HOTP (HMAC-based One-Time Password) algorithms.
    The output includes:
    - A cryptographically secure random secret (Base32 encoded)
    - A valid otpauth URI with all required components
    - Optional parameters for customization

    The function ensures:
    - Cryptographically secure random number generation
    - Appropriate secret length for security (minimum 80 bits/16 Base32 chars)
    - Proper Base32 encoding
    - Valid otpauth URI format with all required components

    URI Format:
    The otpauth URI format includes:
    - Type (lowercase totp/hotp)
    - Label (URL-encoded account identifier)
    - Secret (Base32 encoded)
    - Optional parameters (issuer, algorithm, digits, period, counter)

    Example URI format:
    otpauth://totp/otp%3amodule?secret=JBSWY3DPEHPK3PXP

    Security Note:
    - Default secret length is 10 bytes (80 bits) for basic security
    - Each byte provides 8 bits of entropy (randomness)
    - Entropy is a measure of unpredictability or randomness
    - Higher entropy means better security against brute-force attacks
    - Recommended minimum is 80 bits (10 bytes) of entropy
    - High-security applications should use 160 bits (20 bytes) or more

    Output Properties:
    - Type: TOTP or HOTP
    - Label: The account identifier
    - Seed: The Base32 encoded secret
    - Characters: Number of characters in the Base32 string
    - Length: Number of bytes (each byte provides 8 bits of entropy)
    - Algorithm: Hash algorithm (SHA1, SHA256, SHA512)
    - Digits: Number of digits in the OTP
    - Period: Time step in seconds (TOTP only)
    - Counter: Initial counter value (HOTP only)
    - Tag: Optional organizational tags
    - Issuer: The service or organization name
    - URI: The complete otpauth:// URI
.PARAMETER Length
    The length of the random seed in bytes before Base32 encoding.
    Default is 10 bytes (80 bits) which provides basic security.
    Each byte adds 8 bits of entropy.
    Examples:
    - 10 bytes = 80 bits of entropy (minimum recommended)
    - 15 bytes = 120 bits of entropy
    - 20 bytes = 160 bits of entropy (high security)
.PARAMETER Seed
    An existing Base32 encoded seed to use instead of generating a new one.
    Must contain only characters A-Z, 2-7 and optionally padding with =.
    If specified, the Length parameter is ignored.
.PARAMETER Padding
    Controls whether Base32 padding characters (=) are included in the output.
    Base32 encoding normally adds padding to ensure the output length is a multiple of 8.
    Some authenticator apps prefer or require the padding to be removed.
    The actual secret value remains the same with or without padding.
    Examples:
    - Without padding (default): JBSWY3DPEHPK3PXP6HY7
    - With padding (-Padding): JBSWY3DPEHPK3PXP6HY7====
.PARAMETER Label
    The account identifier in the URI path.
    This can be any string that helps identify the account.
    Common formats include:
    - "user@example.com"
    - "username"
    - "Company:user@example.com"
    Note: Even if Label includes an issuer prefix, the -Issuer parameter
    is still used separately in the URI parameters.
.PARAMETER Issuer
    The service or organization name.
    This is added as an issuer parameter in the URI to prevent account collisions.
    The issuer parameter helps authenticator apps distinguish between accounts
    that might have similar labels but are for different services.
    Example: If Label is "user@example.com" and Issuer is "Company",
    the URI will be: otpauth://TYPE/user@example.com?...&issuer=Company
.PARAMETER Tag
    Optional tags to associate with the seed for organization.
    Useful when managing multiple OTP seeds.
    Accepts input from pipeline by property name.
.PARAMETER Algorithm
    The hash algorithm to use for OTP generation.
    Valid options are 'SHA1', 'SHA256', or 'SHA512'.
    Default is 'SHA1'.
.PARAMETER Type
    The type of OTP.
    Valid options are 'TOTP' or 'HOTP'.
    Default is 'TOTP'.
.PARAMETER Digits
    The number of digits in the generated OTP code.
    Default is 6 digits.
.PARAMETER Period
    The time step size in seconds for TOTP.
    Default is 30 seconds.
.PARAMETER Counter
    The initial counter value for HOTP.
    Required when Type is 'HOTP'.
.PARAMETER SaveQRCode
    The path to save the generated QR code image.
    If the file already exists, you will be prompted to confirm overwrite.
.PARAMETER ShowQRCode
    Switch to display the generated QR code image.
    Can only be used with SaveQRCode parameter.
.EXAMPLE
    New-OTPSecret
    Generates a new random seed with default settings:
    - Type: TOTP
    - Label: otp:module
    - Length: 10 bytes
    - Digits: 6
    - Period: 30 seconds
.EXAMPLE
    New-OTPSecret -Length 15
    Generates a medium-security seed with 120-bit length (15 bytes).
    All other settings use defaults.
.EXAMPLE
    New-OTPSecret -Length 20
    Generates a high-security seed with 160-bit length (20 bytes).
    All other settings use defaults.
.EXAMPLE
    New-OTPSecret -IncludePadding
    Generates a seed with Base32 padding characters.
    All other settings use defaults.
.EXAMPLE
    New-OTPSecret -Tag 'Work', 'Email'
    Generates a seed with associated tags.
    All other settings use defaults.
.EXAMPLE
    New-OTPSecret -Label 'Example:john.doe@email.com'
    Generates a seed with custom label.
    All other settings use defaults.
.EXAMPLE
    New-OTPSecret -Label 'Example:john.doe@email.com' -Type HOTP
    Generates a seed with custom label and HOTP type.
    All other settings use defaults.
.EXAMPLE
    $config = Read-OTPQRCode -Path 'qrcode.png'
    $config | New-OTPSecret
    Uses configuration from a QR code to generate a new OTP secret.
.EXAMPLE
    'JBSWY3DPEHPK3PXP' | New-OTPSecret
    Uses an existing Base32 encoded seed through pipeline by value.
.EXAMPLE
    @{ Seed = 'JBSWY3DPEHPK3PXP' } | New-OTPSecret
    Uses an existing Base32 encoded seed through pipeline by property name.
.EXAMPLE
    @{ Type = 'TOTP'; Length = 10; Label = 'test' } | New-OTPSecret
    Generates a new seed with specified properties through pipeline by property name.
.EXAMPLE
    New-OTPSecret -SaveQRCode 'C:\temp\qrcode.png' -ShowQRCode
    Generates a new seed and saves it as a QR code, then displays it.
    All other settings use defaults.
.EXAMPLE
    New-OTPSecret -SaveQRCode 'C:\temp\qrcode.png' -WhatIf
    Shows what would happen if the command were to run, including the path where the QR code would be saved.
.NOTES
    The function uses the cryptographically secure RNGCryptoServiceProvider
    for random number generation. The output is compatible with standard
    OTP implementations and follows RFC 4226 and RFC 6238 specifications.

    The cmdlet supports the -WhatIf parameter to preview operations without
    actually performing them. This is particularly useful when saving QR codes
    to see where files would be created or overwritten.

    This version of the cmdlet was created using "Educated Prompting" by using Claude 3.5 Sonnect (by Anthropic) and Anysphere (cursor.sh).
.LINK
    https://tools.ietf.org/html/rfc4648
    https://tools.ietf.org/html/rfc4226
    https://tools.ietf.org/html/rfc6238
    https://github.com/thorstenbutz/otp
#>

function New-OTPSecret {
    [CmdletBinding(DefaultParameterSetName = 'Generate', SupportsShouldProcess)]
    param(
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateSet('TOTP', 'HOTP')]
        [string]$Type = 'TOTP',

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]$Label = 'otp:module',

        ## Length of the random seed in bytes before Base32 encoding.
        ## Range 10-32 bytes (80-256 bits) based on security best practices:
        ## - 10 bytes (80 bits) is the minimum recommended for basic security
        ## - 32 bytes (256 bits) is a practical maximum for compatibility
        ## Note: While RFC 4226/6238 don't specify exact lengths, most authenticator
        ## apps expect seeds within this range. Larger seeds don't provide additional
        ## security with HMAC-SHA1 (160-bit output) but may cause compatibility issues.
        [Parameter(ParameterSetName = 'Generate', ValueFromPipelineByPropertyName)]
        [ValidateRange(10, 32)]
        [int]$Length = 10,

        [Parameter(ParameterSetName = 'UseSeed', ValueFromPipelineByPropertyName, ValueFromPipeline)]
        [string]$Seed,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateSet('SHA1', 'SHA256', 'SHA512')]
        [string]$Algorithm = 'SHA1',

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(6, 8)]
        [int]$Digits = 6,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(15, 90)]
        [int]$Period = 30,

        [Parameter(ValueFromPipelineByPropertyName)]
        [long]$Counter,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]$Tag,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]$Issuer,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]$IncludePadding,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]$SaveQRCode,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]$ShowQRCode
    )

    begin {
        ## Add ZXing assembly for QR code generation
        $zxingPath = Join-Path -Path $PSScriptRoot -ChildPath '..\lib\zxing.dll'
        if (-not (Test-Path -Path $zxingPath)) {
            throw 'Required ZXing.Net library not found at: ' + $zxingPath
        }
        Add-Type -Path $zxingPath
        Add-Type -AssemblyName System.Drawing
    }
    
    process {
        try {
            ## Validate HOTP counter
            if ($Type -eq 'HOTP' -and -not $Counter) {
                throw 'Counter parameter is required when Type is HOTP'
            }

            if ($Seed) {
                ## Clean up the seed by removing any padding and converting to uppercase
                $Seed = $Seed.ToUpper() -replace '=+$'
                if (-not ($Seed -match '^[A-Z2-7]+$')) {
                    throw 'Invalid seed format. Must be Base32 encoded (A-Z, 2-7)'
                }
                
                Write-Verbose -Message ('Processing existing seed: ' + $Seed)
                Write-Verbose -Message ('Seed length: ' + $Seed.Length + ' characters')
                
                $output = [ordered]@{
                    PSTypeName = 'OTP.Configuration'
                    Type      = $Type
                    Label     = $Label
                    Seed      = $Seed
                    Characters = $Seed.Length
                    Length    = [Math]::Ceiling($Seed.Length * 5 / 8)
                    Algorithm = $Algorithm
                    Digits    = $Digits
                    Period    = $Period
                    Counter   = if ($Type -eq 'HOTP') { $Counter } else { $null }
                    Tag       = $Tag
                    Issuer    = $Issuer
                }

                Write-Verbose -Message ('Calculated byte length: ' + $output.Length)
                
                ## Use New-OTPAuthUri to generate the URI
                $uriParams = @{
                    Type = $Type
                    Account = $Label
                    Seed = $Seed
                    Algorithm = $Algorithm
                    Digits = $Digits
                }
                
                if ($Issuer) { $uriParams['Issuer'] = $Issuer }
                if ($Period -ne 30) { $uriParams['Period'] = $Period }
                if ($Type -eq 'HOTP' -and $Counter -ne 0) { $uriParams['Counter'] = $Counter }
                
                $uriResult = New-OTPAuthUri @uriParams
                $output['URI'] = $uriResult.Uri

                ## Generate QR code if requested
                if ($SaveQRCode) {
                    if ($ShowQRCode -and -not $SaveQRCode) {
                        Write-Warning -Message 'ShowQRCode can only be used with SaveQRCode'
                        [PSCustomObject]$output
                    }

                    try {
                        $qrParams = @{
                            Uri = $uriResult.Uri
                            OutFile = $SaveQRCode
                            Show = $ShowQRCode
                        }
                        
                        ## Pass through the Confirm parameter if it was specified
                        if ($PSBoundParameters.ContainsKey('Confirm')) {
                            $qrParams['Confirm'] = $PSBoundParameters['Confirm']
                        }
                        
                        if ($PSCmdlet.ShouldProcess($SaveQRCode, 'Generate QR code')) {
                            $qrResult = New-OTPQRCode @qrParams
                            if ($qrResult) {
                                $output['QRCodePath'] = $qrResult.FullName
                            }
                        }
                    }
                    catch {
                        Write-Error -Message ('Failed to generate QR code: ' + $_)
                        Write-Error -Message ('Error details: ' + $_.Exception.Message)
                        if ($_.Exception.InnerException) {
                            Write-Error -Message ('Inner exception: ' + $_.Exception.InnerException.Message)
                        }
                    }
                }

                [PSCustomObject]$output
            }
            else {
                ## Generate a new random seed
                Write-Verbose -Message ('Generating new random seed with length: ' + $Length + ' bytes')
                
                ## Generate random bytes using RNGCryptoServiceProvider
                $rng = [System.Security.Cryptography.RNGCryptoServiceProvider]::new()
                $bytes = [byte[]]::new($Length)
                
                try {
                    $rng.GetBytes($bytes)
                }
                finally {
                    $rng.Dispose()
                }
                
                $seed = [OtpNet.Base32Encoding]::ToString($bytes)
                if (-not $IncludePadding) {
                    $seed = $seed -replace '=+$'
                }
                
                Write-Verbose -Message ('Generated seed: ' + $seed)
                Write-Verbose -Message ('Seed length: ' + $seed.Length + ' characters')
                
                $output = [ordered]@{
                    PSTypeName = 'OTP.Configuration'
                    Type      = $Type
                    Label     = $Label
                    Seed      = $seed
                    Characters = $seed.Length
                    Length    = $Length
                    Algorithm = $Algorithm
                    Digits    = $Digits
                    Period    = $Period
                    Counter   = if ($Type -eq 'HOTP') { $Counter } else { $null }
                    Tag       = $Tag
                    Issuer    = $Issuer
                }

                Write-Verbose -Message ('Calculated byte length: ' + $output.Length)
                
                ## Use New-OTPAuthUri to generate the URI
                $uriParams = @{
                    Type = $Type
                    Account = $Label
                    Seed = $seed
                    Algorithm = $Algorithm
                    Digits = $Digits
                }
                
                if ($Issuer) { $uriParams['Issuer'] = $Issuer }
                if ($Period -ne 30) { $uriParams['Period'] = $Period }
                if ($Type -eq 'HOTP' -and $Counter -ne 0) { $uriParams['Counter'] = $Counter }
                
                $uriResult = New-OTPAuthUri @uriParams
                $output['URI'] = $uriResult.Uri

                ## Generate QR code if requested
                if ($SaveQRCode) {
                    if ($ShowQRCode -and -not $SaveQRCode) {
                        Write-Warning -Message 'ShowQRCode can only be used with SaveQRCode'
                        [PSCustomObject]$output
                    }

                    try {
                        $qrParams = @{
                            Uri = $uriResult.Uri
                            OutFile = $SaveQRCode
                            Show = $ShowQRCode
                        }
                        
                        ## Pass through the Confirm parameter if it was specified
                        if ($PSBoundParameters.ContainsKey('Confirm')) {
                            $qrParams['Confirm'] = $PSBoundParameters['Confirm']
                        }
                        
                        if ($PSCmdlet.ShouldProcess($SaveQRCode, 'Generate QR code')) {
                            $qrResult = New-OTPQRCode @qrParams
                            if ($qrResult) {
                                $output['QRCodePath'] = $qrResult.FullName
                            }
                        }
                    }
                    catch {
                        Write-Error -Message ('Failed to generate QR code: ' + $_)
                        Write-Error -Message ('Error details: ' + $_.Exception.Message)
                        if ($_.Exception.InnerException) {
                            Write-Error -Message ('Inner exception: ' + $_.Exception.InnerException.Message)
                        }
                    }
                }

                [PSCustomObject]$output
            }
        }
        catch {
            Write-Error -Message ('An error occurred: ' + $_)
            Write-Error -Message ('Error details: ' + $_.Exception.Message)
            if ($_.Exception.InnerException) {
                Write-Error -Message ('Inner exception: ' + $_.Exception.InnerException.Message)
            }
        }
    }
    
    end {
        if ($rng) {
            $rng.Dispose()
        }
    }
}