Plugins/INWX.ps1
function Get-CurrentPluginType { 'dns-01' } function Add-DnsTxt { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [string]$RecordName, [Parameter(Mandatory,Position=1)] [string]$TxtValue, [Parameter(Mandatory,Position=2)] [string]$INWXUsername, [Parameter(Mandatory,Position=3)] [securestring]$INWXPassword, [Parameter(Position=4)] [AllowNull()] [securestring]$INWXSharedSecret, [string]$INWXApiRoot = "https://api.domrobot.com/jsonrpc/", [Parameter(ValueFromRemainingArguments)] $ExtraParams ) # login Connect-Inwx $INWXUsername $INWXPassword $INWXSharedSecret $INWXApiRoot # get DNS zone (main domain) belonging to the record (assumes # $zoneName contains the zone name containing the record) $zoneName = Find-InwxZone $RecordName $INWXApiRoot Write-Debug "RecordName: $RecordName" Write-Debug "zoneName: $zoneName" # check if the record exists # https://www.inwx.de/en/help/apidoc/f/ch02s15.html#nameserver.info $reqParams = @{} $reqParams.Uri = $INWXApiRoot $reqParams.Method = "POST" $reqParams.ContentType = "application/json" $reqParams.WebSession = $INWXSession $reqParams.Body = @{ "jsonrpc" = "2.0"; "id" = [guid]::NewGuid() "method" = "nameserver.info"; "params" = @{ "domain" = $zoneName; "type" = "TXT"; "name" = $RecordName; "content" = $TxtValue; }; } | ConvertTo-Json $reqParams.Verbose = $False $response = $False $responseContent = $False $recordId = $False try { Write-Verbose "Checking for $RecordName record(s)." Write-Debug "$($reqParams.Method) $INWXApiRoot`n$($reqParams.Body)" $response = Invoke-WebRequest @reqParams @script:UseBasic } catch { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (unknown error)." } if ($response -eq $False -or $response.StatusCode -ne 200) { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (status code $($response.StatusCode))." } else { $responseContent = $response.Content | ConvertFrom-Json } Write-Debug "Received content:`n$($response.Content)" switch ($responseContent.code) { # 1000: Command completed successfully # 2302: Object exists {($PSItem -eq 1000 -or $PSItem -eq 2302)} { Write-Debug "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) was successful" if ($responseContent.resData.record) { $recordId = $responseContent.resData.record[0].id Write-Debug "Found record with ID $recordId." } } # unexpected default { throw "Unexpected response from INWX (code: $($responseContent.code)). The plugin might need an update (Add-DnsTxt)." } } Remove-Variable "reqParams", "response", "responseContent" if ($recordId) { Write-Debug "A record $RecordName with an associated value of $TxtValue already exists. Nothing to do." } else { Write-Verbose "DNS record does not exist, going to create it." # create record # https://www.inwx.de/en/help/apidoc/f/ch02s15.html#nameserver.createRecord $reqParams = @{} $reqParams.Uri = $INWXApiRoot $reqParams.Method = "POST" $reqParams.ContentType = "application/json" $reqParams.WebSession = $INWXSession $reqParams.Body = @{ "jsonrpc" = "2.0"; "id" = [guid]::NewGuid() "method" = "nameserver.createRecord"; "params" = @{ "domain" = $zoneName; "type" = "TXT"; "name" = $RecordName; "content" = $TxtValue; "ttl" = 300; }; } | ConvertTo-Json $reqParams.Verbose = $False $response = $False $responseContent = $False try { Write-Verbose "Adding record $RecordName with value $TxtValue." Write-Debug "$($reqParams.Method) $INWXApiRoot`n$($reqParams.Body)" $response = Invoke-WebRequest @reqParams @script:UseBasic } catch { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (unknown error)." } if ($response -eq $False -or $response.StatusCode -ne 200) { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (status code $($response.StatusCode))." } else { $responseContent = $response.Content | ConvertFrom-Json } Write-Debug "Received content:`n$($response.Content)" # 1000: Command completed successfully if ($responseContent.code -eq 1000) { Write-Verbose "Adding the record was successful." if ($responseContent.resData.id -gt 0) { Write-Debug "Created record with ID $($responseContent.resData.id)." } } else { throw "Adding the record failed (code: $($responseContent.code))." } Remove-Variable "reqParams", "response", "responseContent" } Remove-Variable "recordId" <# .SYNOPSIS Add a DNS TXT record to INWX. .DESCRIPTION Uses the INWX DNS API to add or update a DNS TXT record. .PARAMETER RecordName The fully qualified name of the TXT record. .PARAMETER TxtValue The value of the TXT record. .PARAMETER INWXUsername The INWX Username to access the API. .PARAMETER INWXPassword The password associated with the username provided via -INWXUsername. .PARAMETER INWXSharedSecret If your account is secured by mobile TAN ("2FA", "two-factor authentication"), you must define the shared secret (usually presented below the QR code during mobile TAN setup) to enable this function to generate OTP codes. The shared secret is NOT not the 6-digit code you need to enter when logging in. If you are not using 2FA, leave this parameter undefined or set it to $null.. .PARAMETER INWXApiRoot The API root URL which is set to https://api.domrobot.com/jsonrpc/ by default. To test against the OTE environment, set this to https://api.ote.domrobot.com/jsonrpc/ .PARAMETER ExtraParams This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. .EXAMPLE $password = Read-Host 'API Secret' -AsSecureString Add-DnsTxt '_acme-challenge.example.com' 'txt-value' -INWXUsername 'xxxxxx' -INWXPassword $password Adds or updates the specified TXT record with the specified value. #> } function Remove-DnsTxt { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [string]$RecordName, [Parameter(Mandatory,Position=1)] [string]$TxtValue, [Parameter(Mandatory,Position=2)] [string]$INWXUsername, [Parameter(Mandatory,Position=3)] [securestring]$INWXPassword, [Parameter(Position=4)] [securestring]$INWXSharedSecret, [string]$INWXApiRoot = "https://api.domrobot.com/jsonrpc/", [Parameter(ValueFromRemainingArguments)] $ExtraParams ) # login Connect-Inwx $INWXUsername $INWXPassword $INWXSharedSecret $INWXApiRoot # get DNS zone (main domain) belonging to the record (assumes # $zoneName contains the zone name containing the record) $zoneName = Find-InwxZone $RecordName $INWXApiRoot Write-Debug "RecordName: $RecordName" Write-Debug "zoneName: $zoneName" # check if the record exists # https://www.inwx.de/en/help/apidoc/f/ch02s15.html#nameserver.info $reqParams = @{} $reqParams.Uri = $INWXApiRoot $reqParams.Method = "POST" $reqParams.ContentType = "application/json" $reqParams.WebSession = $INWXSession $reqParams.Body = @{ "jsonrpc" = "2.0"; "id" = [guid]::NewGuid() "method" = "nameserver.info"; "params" = @{ "domain" = $zoneName; "type" = "TXT"; "name" = $RecordName; "content" = $TxtValue; }; } | ConvertTo-Json $reqParams.Verbose = $False $response = $False $responseContent = $False $recordId = $False try { Write-Verbose "Checking for $RecordName record(s) with value $TxtValue." Write-Debug "$($reqParams.Method) $INWXApiRoot`n$($reqParams.Body)" $response = Invoke-WebRequest @reqParams @script:UseBasic } catch { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (unknown error)." } if ($response -eq $False -or $response.StatusCode -ne 200) { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (status code $($response.StatusCode))." } else { $responseContent = $response.Content | ConvertFrom-Json } Write-Debug "Received content:`n$($response.Content)" switch ($responseContent.code) { # 1000: Command completed successfully # 2302: Object exists {($PSItem -eq 1000 -or $PSItem -eq 2302)} { Write-Debug "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) was successful" if ($responseContent.resData.record) { $recordId = $responseContent.resData.record[0].id Write-Debug "Found record with ID $recordId." } } # unexpected default { throw "Unexpected response from INWX (code: $($responseContent.code)). The plugin might need an update (Remove-DnsTxt)." } } Remove-Variable "reqParams", "response", "responseContent" if ($recordId) { Write-Verbose "DNS record is existing, going to delete it." # delete record # https://www.inwx.de/en/help/apidoc/f/ch02s15.html#nameserver.deleteRecord $reqParams = @{} $reqParams.Uri = $INWXApiRoot $reqParams.Method = "POST" $reqParams.ContentType = "application/json" $reqParams.WebSession = $INWXSession $reqParams.Body = @{ "jsonrpc" = "2.0"; "id" = [guid]::NewGuid() "method" = "nameserver.deleteRecord"; "params" = @{ "id" = $recordId; }; } | ConvertTo-Json $reqParams.Verbose = $False $response = $False $responseContent = $False try { Write-Verbose "Deleting record $RecordName with value $TxtValue." Write-Debug "$($reqParams.Method) $INWXApiRoot`n$($reqParams.Body)" $response = Invoke-WebRequest @reqParams @script:UseBasic } catch { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (unknown error)." } if ($response -eq $False -or $response.StatusCode -ne 200) { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (status code $($response.StatusCode))." } else { $responseContent = $response.Content | ConvertFrom-Json } Write-Debug "Received content:`n$($response.Content)" # 1000: Command completed successfully if ($responseContent.code -eq 1000) { Write-Verbose "Deleting the record was successful." } else { throw "Deleting the record failed (code: $($responseContent.code))." } Remove-Variable "reqParams", "response", "responseContent" } else { Write-Debug "A record $RecordName with an associated value of $TxtValue does not exist. Nothing to do." } Remove-Variable "recordId" <# .SYNOPSIS Remove a DNS TXT record from INWX. .DESCRIPTION Uses the INWX DNS API to remove a DNS TXT record with a certain value. .PARAMETER RecordName The fully qualified name of the TXT record. .PARAMETER TxtValue The value of the TXT record. .PARAMETER INWXUsername The INWX Username to access the API. .PARAMETER INWXPassword The password associated with the username provided via -INWXUsername. .PARAMETER INWXSharedSecret If your account is secured by mobile TAN ("2FA", "two-factor authentication"), you must define the shared secret (usually presented below the QR code during mobile TAN setup) to enable this function to generate OTP codes. The shared secret is NOT not the 6-digit code you need to enter when logging in. If you are not using 2FA, leave this parameter undefined or set it to $null.. .PARAMETER INWXApiRoot The API root URL which is set to https://api.domrobot.com/jsonrpc/ by default. To test against the OTE environment, set this to https://api.ote.domrobot.com/jsonrpc/ .PARAMETER ExtraParams This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. .EXAMPLE Remove-DnsTxt '_acme-challenge.example.com' 'txt-value' Removes a TXT record for the specified site with the specified value. #> } function Save-DnsTxt { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [string]$INWXUsername, [Parameter(Mandatory,Position=1)] [securestring]$INWXPassword, [Parameter(Position=2)] [AllowNull()] [securestring]$INWXSharedSecret, [string]$INWXApiRoot = "https://api.domrobot.com/jsonrpc/", [Parameter(ValueFromRemainingArguments)] $ExtraParams ) # There is currently no additional work to be done to save # or finalize changes performed by Add/Remove functions. # let's logout (best effort) # https://www.inwx.de/en/help/apidoc/f/ch02.html#account.logout $reqParams = @{} $reqParams.Uri = $INWXApiRoot $reqParams.Method = "POST" $reqParams.ContentType = "application/json" $reqParams.WebSession = $INWXSession $reqParams.Body = @{ "jsonrpc" = "2.0"; "id" = [guid]::NewGuid() "method" = "account.logout"; } | ConvertTo-Json $reqParams.Verbose = $False $response = $False $responseContent = $False try { Write-Verbose "Starting INWX logout to end the session (best-effort)." Write-Debug "$($reqParams.Method) $INWXApiRoot`n$($reqParams.Body)" $response = Invoke-WebRequest @reqParams @script:UseBasic } catch { Write-Debug "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (unknown error)." } if ($response -eq $False -or $response.StatusCode -ne 200) { Write-Debug "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (status code $($response.StatusCode))." } else { $responseContent = $response.Content | ConvertFrom-Json } Write-Debug "Received content:`n$($response.Content)" # 1000: Command completed successfully # 1500: Command completed successfully; ending session if ($responseContent.code -eq 1000 -or $responseContent.code -eq 1500) { Write-Verbose "Logout was successful." } else { Write-Debug "Logout failed (code: $($responseContent.code))." } Remove-Variable "reqParams", "response", "responseContent" # invalidate saved session data $script:INWXSession = $False <# .SYNOPSIS Commits changes to pending DNS TXT record modifications to INWX and closes an existing RPC session by logging out. .DESCRIPTION This function is currently a dummy which just does a clean logout as INWX does not support a 'finalize' or 'commit' workflow. .PARAMETER INWXUsername The INWX Username to access the API. .PARAMETER INWXPassword The password associated with the username provided via -INWXUsername. .PARAMETER INWXSharedSecret If your account is secured by mobile TAN ("2FA", "two-factor authentication"), you must define the shared secret (usually presented below the QR code during mobile TAN setup) to enable this function to generate OTP codes. The shared secret is NOT not the 6-digit code you need to enter when logging in. If you are not using 2FA, leave this parameter undefined or set it to $null.. .PARAMETER INWXApiRoot The API root URL which is set to https://api.domrobot.com/jsonrpc/ by default. To test against the OTE environment, set this to https://api.ote.domrobot.com/jsonrpc/ .PARAMETER ExtraParams This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. .EXAMPLE Save-DnsTxt Commits changes for pending DNS TXT record modifications and closes an existing RPC session by logging out. #> } ############################ # Helper Functions ############################ # API Docs at https://www.inwx.de/en/help/apidoc # Result codes at https://www.inwx.de/en/help/apidoc/f/ch04.html # # There is also an OT&E test system. It provides the usual WebUI and API using a test database. # On the OTE system no actions will be charged. So one can test how to register domains etc.. # An OT&E account can be created at https://www.ote.inwx.de/en/customer/signup function Connect-Inwx { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [string]$INWXUsername, [Parameter(Mandatory,Position=1)] [securestring]$INWXPassword, [Parameter(Position=2)] [AllowNull()] [securestring]$INWXSharedSecret, [Parameter(Position=3)] [string]$INWXApiRoot = "https://api.domrobot.com/jsonrpc/", [Parameter(ValueFromRemainingArguments)] $ExtraConnectParams ) # no need to log in again; an authenticated session already exists if ((Test-Path 'variable:script:INWXSession') -and ($script:INWXSession)) { Write-Debug "Login not needed, using cached INWX session." return } # get password as plaintext $INWXPasswordInsecure = [pscredential]::new('a',$INWXPassword).GetNetworkCredential().Password Write-Debug "Starting INWX login to get a session." # login # https://www.inwx.com/en/help/apidoc/f/ch02.html#account.login $reqParams = @{} $reqParams.Uri = $INWXApiRoot $reqParams.Method = "POST" $reqParams.ContentType = "application/json" $reqParams.SessionVariable = "INWXSession" $reqParams.Body = @{ "jsonrpc" = "2.0"; "id" = [guid]::NewGuid() "method" = "account.login"; "params" = @{ "user" = $INWXUsername; "pass" = $INWXPasswordInsecure; }; } | ConvertTo-Json $reqParams.Verbose = $False $response = $False $responseContent = $False $2faActive = $False try { # commented out to prevent printing the credentials: Write-Debug "$($reqParams.Method) $INWXApiRoot`n<login body redacted>" $response = Invoke-WebRequest @reqParams @script:UseBasic } catch { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (unknown error)" } if ($response -eq $False -or $response.StatusCode -ne 200) { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (status code $($response.StatusCode))." } else { $responseContent = $response.Content | ConvertFrom-Json } Write-Debug "Received content:`n$($response.Content)" switch ($responseContent.code) { # 1000: Command completed successfully 1000 { Write-Verbose "INWX login was successful." if ($responseContent.resData.tfa -eq "GOOGLE-AUTH") { Write-Verbose "2FA (Mobile TAN) is active, account needs unlocking." $2faActive = $True } } # 2200: Authentication error # 2400: Command failed {$PSItem -eq 2200 -or $PSItem -eq 2400} { throw "INWX login failed. Please check your credentials." } # unexpected default { throw "Unexpected response from INWX (code: $($responseContent.code)). The plugin might need an update (Connect-Inwx)." } } Remove-Variable "reqParams", "response", "responseContent" if ($2faActive) { # generate needed OTP if ($INWXSharedSecret) { $Otp = Get-InwXOtp $INWXSharedSecret } else { throw "Mobile TAN (2FA) is active for the $INWXUsername account. Please provide the INWXSharedSecret plugin parameter or disable 2FA for the account." } # unlock account # https://www.inwx.de/en/help/apidoc/f/ch02.html#account.unlock $reqParams = @{} $reqParams.Uri = $INWXApiRoot $reqParams.Method = "POST" $reqParams.ContentType = "application/json" $reqParams.WebSession = $INWXSession $reqParams.Body = @{ "jsonrpc" = "2.0"; "id" = [guid]::NewGuid() "method" = "account.unlock"; "params" = @{ "tan" = $Otp; }; } | ConvertTo-Json $reqParams.Verbose = $False $response = $False $responseContent = $False try { Write-Verbose "Deleting record $RecordName with value $TxtValue." Write-Debug "$($reqParams.Method) $INWXApiRoot`n$($reqParams.Body)" $response = Invoke-WebRequest @reqParams @script:UseBasic } catch { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (unknown error)." } if ($response -eq $False -or $response.StatusCode -ne 200) { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (status code $($response.StatusCode))." } else { $responseContent = $response.Content | ConvertFrom-Json } Write-Debug "Received content:`n$($response.Content)" # 1000: Command completed successfully if ($responseContent.code -eq 1000) { Write-Verbose "Unlocking the account was successful." } else { throw "Unlocking the account failed (code: $($responseContent.code))." } Remove-Variable "reqParams", "response", "responseContent" } # save the session variable for usage in all later calls $script:INWXSession = $INWXSession <# .SYNOPSIS Internal helper function to create a session ("login") to communicate with the INWX API. .PARAMETER INWXUsername The INWX Username to access the API. .PARAMETER INWXPassword The password associated with the username provided via -INWXUsername. .PARAMETER INWXSharedSecret If your account is secured by mobile TAN ("2FA", "two-factor authentication"), you must define the shared secret (usually presented below the QR code during mobile TAN setup) to enable this function to generate OTP codes. The shared secret is NOT not the 6-digit code you need to enter when logging in. If you are not using 2FA, leave this parameter undefined or set it to $null.. .PARAMETER ExtraParams This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. #> } function Find-InwxZone { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [string]$RecordName, [Parameter(Position=1)] [string]$INWXApiRoot = "https://api.domrobot.com/jsonrpc/" ) # setup a module variable to cache the record to zone mapping # so it's quicker to find later if (!(Test-Path 'variable:script:INWXRecordZones')) { $script:INWXRecordZones = @{} } # check for the record in the cache if ($script:INWXRecordZones.ContainsKey($RecordName)) { return $script:INWXRecordZones.$RecordName } # Search for the zone from longest to shortest set of FQDN pieces. $pieces = $RecordName.Split('.') for ($i=0; $i -lt ($pieces.Count-1); $i++) { $zoneTest = $pieces[$i..($pieces.Count-1)] -join '.' # check if the part of the domain is the zone # https://www.inwx.de/en/help/apidoc/f/ch02s15.html#nameserver.info $reqParams = @{} $reqParams.Uri = $INWXApiRoot $reqParams.Method = "POST" $reqParams.ContentType = "application/json" $reqParams.WebSession = $INWXSession $reqParams.Body = @{ "jsonrpc" = "2.0"; "id" = [guid]::NewGuid() "method" = "nameserver.list"; "params" = @{ "domain" = $zoneTest; }; } | ConvertTo-Json $reqParams.Verbose = $False $response = $False $responseContent = $False try { Write-Verbose "Checking if $zoneTest is the zone holding the records." Write-Debug "$($reqParams.Method) $INWXApiRoot`n$($reqParams.Body)" $response = Invoke-WebRequest @reqParams @script:UseBasic } catch { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (unknown error)." } if ($response -eq $False -or $response.StatusCode -ne 200) { throw "INWX method call $(($reqParams.Body | ConvertFrom-Json).method) failed (status code $($response.StatusCode))." } else { $responseContent = $response.Content | ConvertFrom-Json } Write-Debug "Received content:`n$($response.Content)" switch ($responseContent.code) { # 1000: Command completed successfully 1000 { if ($responseContent.resData.count -gt 0) { Write-Verbose "$zoneTest seems to be the zone holding the records." $script:INWXRecordZones.$RecordName = $zoneTest return $zoneTest break } else { continue } } # 2303: Object does not exist 2303 { Write-Debug "$zoneTest does not seem to be the zone holding the records, trying the next deeper match." } # unexpected default { throw "Unexpected response from INWX (code: $($responseContent.code)). The plugin might need an update (Find-InwxZone)." } } Remove-Variable "reqParams", "response", "responseContent" } throw "Unable to find zone matching $RecordName." <# .SYNOPSIS Internal helper function to figure out which zone $RecordName needs to be added to. .PARAMETER RecordName The DNS Resource Record of which to find the belonging DNS zone. #> } function Get-InwxOtp { [CmdletBinding()] param( [Parameter(Mandatory,Position=0)] [securestring]$SharedSecret, [Parameter(Position=1)] [int]$Length = 6, [Parameter(Position=2)] [int]$Window = 30 ) # wait a bit if there are only a few seconds left in the current TOTP window $windowRemaining = $Window - ([DateTimeOffset]::Now.ToUnixTimeSeconds() % $Window) if ($windowRemaining -le 5) { Write-Debug "Current TOTP window is a bit tight, waiting a few seconds for the next one." Start-Sleep -Seconds 5 } # get shared secret as plaintext $SharedSecretInsecure = [pscredential]::new('a',$SharedSecret).GetNetworkCredential().Password # decode the base32 secret to bytes and create the HMAC instance $keyBytes = ConvertFrom-Base32 $SharedSecretInsecure $hmac = [Security.Cryptography.HMACSHA1]::new($keyBytes) # hash the lower 8 bytes of our time step value $step = [long]([Math]::Floor([DateTimeOffset]::Now.ToUnixTimeSeconds() / $Window)) $stepHash = $hmac.ComputeHash([BitConverter]::GetBytes($step)[7..0]) # extract the dynamic offset from the last byte of the hash $offset = $stepHash[-1] -band 0xf # build the raw OTP value $rawOTP = ($stepHash[$offset] -band 0x7f) -shl 24 $rawOTP += ($stepHash[$offset + 1] -band 0xff) -shl 16 $rawOTP += ($stepHash[$offset + 2] -band 0xff) -shl 8 $rawOTP += ($stepHash[$offset + 3] -band 0xff) # return the processed value with the correct length return [int](($rawOTP % [math]::pow(10, $Length)).ToString("0" * $Length)) <# .SYNOPSIS Get Time-base One-Time Password Algorithm (RFC 6238) .PARAMETER SharedSecret The shared secret to use. .PARAMETER Length Length of the generated OTP. Defaults to 6. .PARAMETER Window Window of time in seconds within which the OTP code will be valid. Defaults to 30. .EXAMPLE Get-Otp (ConvertTo-SecureString -String "xxxxxxxx" -AsPlainText -Force) Generates a 6-digit OTP code based on the shared secret "xxxxxxxx" #> } function ConvertFrom-Base32 { [CmdletBinding()] [OutputType('System.Byte[]')] param( [Parameter(Mandatory, Position = 0, ValueFromPipeline)] [string]$Base32String ) Begin { # Base32 alphabet $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' $reBase32 = [regex]'^[A-Z2-7]+=*$' } Process { # Normalize to uppercase $Base32String = $Base32String.ToUpper() try { # Validate input format if ($Base32String -notmatch $reBase32) { throw [ArgumentException]::new("Invalid Base32 input: contains non-Base32 characters.", 'Base32String') } # Validate padding alignment if ($Base32String.Length % 8 -ne 0) { throw [ArgumentException]::new("Invalid Base32 input: length must be a multiple of 8.", 'Base32String') } } catch { $PSCmdlet.WriteError($_) return } # Decode into bytes $bitBuffer = 0 $bitCount = 0 $decodedBytes = [Collections.Generic.List[byte]]::new() foreach ($char in $Base32String.TrimEnd('=').ToCharArray()) { # Get the value of the character from the Base32 alphabet $value = $alphabet.IndexOf($char) # Add the 5 bits of the current character to the bit buffer $bitBuffer = ($bitBuffer -shl 5) -bor $value $bitCount += 5 # Extract bytes while we have enough bits (8 bits = 1 byte) while ($bitCount -ge 8) { $bitCount -= 8 $decodedBytes.Add(($bitBuffer -shr $bitCount) -band 0xFF) } } # Output decoded bytes [byte[]]($decodedBytes.ToArray()) } } |