diff --git a/Posh-ACME/DnsPlugins/GCloud-Readme.md b/Posh-ACME/DnsPlugins/GCloud-Readme.md new file mode 100644 index 00000000..fd4c7567 --- /dev/null +++ b/Posh-ACME/DnsPlugins/GCloud-Readme.md @@ -0,0 +1,52 @@ +# How To Use the GCloud DNS Plugin + +This plugin works against the [Google Cloud DNS](https://cloud.google.com/dns) provider. It is assumed that you have already setup a project, billing, and created the DNS zone(s) you will be working against. If not, check the [Cloud DNS Quickstart](https://cloud.google.com/dns/quickstart). + +## Setup + +We need to create a service account and give it permission to add TXT records to the zone(s) we'll be issuing certificates for. + +### Create a Custom Role + +It's always a good idea to limit a service account's access to only what is needed to perform its function. So rather than giving it the default `DNS Administrator` role, we'll create a custom one that is less dangerous. Start by going to the [IAM Roles](https://console.cloud.google.com/iam-admin/roles) page and make sure the correct project is selected. + +- Filter the Roles for "dns" and find the `DNS Administrator` role +- Open the context menu for the role and click `Create role from this role` +- Title: `DNS Zone Editor` +- Description: `List/Read Zones and Write Zone data` +- ID: `DNSZoneEditor` +- Role launch stage: `General Availability` +- In the list of permissions, uncheck all **except** the following: + - `dns.changes.create` + - `dns.changes.get` + - `dns.changes.list` + - `dns.managedZones.get` + - `dns.managedZones.list` + - `dns.resourceRecordSets.create` + - `dns.resourceRecordSets.delete` + - `dns.resourceRecordSets.get` + - `dns.resourceRecordSets.list` + - `dns.resourceRecordSets.update` +- Click `Create` + +This will give the account it is applied to the ability to edit all record types for all existing zones in the current project. Unfortunately, the current Google APIs don't allow us to further restrict this role so that the account can only modify TXT records or only specific zones. + +### Create a Service Account + +Start by going to the [Service accounts](https://console.cloud.google.com/iam-admin/serviceaccounts) page and make sure the correct project is selected. + +- Click `Create service account` +- Service account name: `posh-acme` +- Role: `DNS Zone Editor` +- Check `Furnish a new private key` + - Key type: `JSON` +- Click `Create` +- A JSON file should be automatically downloaded. **Don't lose it**. + +## Using the Plugin + +The only plugin argument you need is the path to the JSON account file you downloaded. The plugin will cache the contents of this file on first use in case the original gets deleted or moved. But you must always specify the path regardless. + +```powershell +New-PACertificate test.example.com -DnsPlugin GCloud -PluginArgs @{GCKeyFile=''} +``` diff --git a/Posh-ACME/DnsPlugins/GCloud.ps1 b/Posh-ACME/DnsPlugins/GCloud.ps1 new file mode 100644 index 00000000..25958042 --- /dev/null +++ b/Posh-ACME/DnsPlugins/GCloud.ps1 @@ -0,0 +1,335 @@ +function Add-DnsTxtGCloud { + [CmdletBinding()] + param( + [Parameter(Mandatory,Position=0)] + [string]$RecordName, + [Parameter(Mandatory,Position=1)] + [string]$TxtValue, + [Parameter(Mandatory,Position=2)] + [string]$GCKeyFile, + [pscustomobject]$GCKeyObj, + [Parameter(ValueFromRemainingArguments)] + $ExtraParams + ) + + # Cloud DNS API Reference + # https://cloud.google.com/dns/api/v1beta2/ + + Connect-GCloudDns $GCKeyFile $GCKeyObj + $token = $script:GCToken + + Write-Verbose "Attempting to find hosted zone for $RecordName" + if (!($zoneID = Find-GCZone $RecordName)) { + throw "Unable to find Google hosted zone for $RecordName" + } + + $recRoot = "https://www.googleapis.com/dns/v1beta2/projects/$($token.ProjectID)/managedZones/$zoneID" + + # query the current txt record set + try { + $response = Invoke-RestMethod "$recRoot/rrsets?type=TXT&name=$RecordName." -Headers $script:GCToken.AuthHeader + Write-Debug ($response | ConvertTo-Json -Depth 5) + } catch { throw } + $rrsets = $response.rrsets + + if ($rrsets.Count -eq 0) { + # create a new record from scratch + Write-Debug "Creating new record for $RecordName" + $changeBody = @{additions=@(@{ + name="$RecordName."; + type='TXT'; + ttl=10; + rrdatas=@("`"$TxtValue`"") + })} + } else { + if ("`"$TxtValue`"" -in $rrsets[0].rrdatas) { + Write-Debug "Record $RecordName already contains $TxtValue. Nothing to do." + return + } + + # append to the existing value list which basically involves + # both deleting and re-creating the record in the same "change" + # operation + Write-Debug "Appending to $RecordName with $($rrsets[0].Count) existing value(s)" + $toDelete = $rrsets[0] | ConvertTo-Json | ConvertFrom-Json + $rrsets[0].rrdatas += "`"$TxtValue`"" + $changeBody = @{ + deletions=@($toDelete); + additions=@($rrsets[0]); + } + } + + Write-Verbose "Sending update for $RecordName" + Write-Debug ($changeBody | ConvertTo-Json -Depth 5) + try { + $response = Invoke-RestMethod "$recRoot/changes" -Method Post -Body ($changeBody | ConvertTo-Json -Depth 5) -Headers $script:GCToken.AuthHeader -ContentType 'application/json' + Write-Debug ($response | ConvertTo-Json -Depth 5) + } catch { throw } + + <# + .SYNOPSIS + Add a DNS TXT record to Google Cloud DNS + + .DESCRIPTION + Add a DNS TXT record to Google Cloud DNS + + .PARAMETER RecordName + The fully qualified name of the TXT record. + + .PARAMETER TxtValue + The value of the TXT record. + + .PARAMETER GCKeyFile + Path to a service account JSON file that contains the account's private key and other metadata. This should have been downloaded when originally creating the service account. + + .PARAMETER GCKeyObj + A cached copy of the service account JSON object. This parameter is managed by the plugin and you shouldn't ever need to specify it manually. + + .PARAMETER ExtraParams + This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. + + .EXAMPLE + Add-DnsTxtGCloud '_acme-challenge.site1.example.com' 'asdfqwer12345678' -GCKeyFile .\account.json + + Adds a TXT record for the specified site with the specified value using the specified Google Cloud service account. + #> +} + +function Remove-DnsTxtGCloud { + [CmdletBinding()] + param( + [Parameter(Mandatory,Position=0)] + [string]$RecordName, + [Parameter(Mandatory,Position=1)] + [string]$TxtValue, + [Parameter(Mandatory,Position=2)] + [string]$GCKeyFile, + [pscustomobject]$GCKeyObj, + [Parameter(ValueFromRemainingArguments)] + $ExtraParams + ) + + # Cloud DNS API Reference + # https://cloud.google.com/dns/api/v1beta2/ + + Connect-GCloudDns $GCKeyFile $GCKeyObj + $token = $script:GCToken + + Write-Verbose "Attempting to find hosted zone for $RecordName" + if (!($zoneID = Find-GCZone $RecordName)) { + throw "Unable to find Google hosted zone for $RecordName" + } + + $recRoot = "https://www.googleapis.com/dns/v1beta2/projects/$($token.ProjectID)/managedZones/$zoneID" + + # query the current txt record set + try { + $response = Invoke-RestMethod "$recRoot/rrsets?type=TXT&name=$RecordName." -Headers $script:GCToken.AuthHeader + Write-Debug ($response | ConvertTo-Json -Depth 5) + } catch { throw } + $rrsets = $response.rrsets + + if ($rrsets.Count -eq 0) { + Write-Debug "Record $RecordName already deleted." + return + } else { + if ("`"$TxtValue`"" -notin $rrsets[0].rrdatas) { + Write-Debug "Record $RecordName doesn't contain $TxtValue. Nothing to do." + return + } + + # removing the value involves deleting the existing record and + # re-creating it without the value in the same change set. But if it's + # the last one, we just want to delete it. + Write-Debug "Removing from $RecordName with $($rrsets[0].Count) existing value(s)" + $changeBody = @{ + deletions=@(($rrsets[0] | ConvertTo-Json | ConvertFrom-Json)) + } + if ($rrsets[0].rrdatas.Count -gt 1) { + $rrsets[0].rrdatas = @($rrsets[0].rrdatas | Where-Object { $_ -ne "`"$TxtValue`"" }) + $changeBody.additions = @($rrsets[0]) + } + } + + Write-Verbose "Sending update for $RecordName" + Write-Debug ($changeBody | ConvertTo-Json -Depth 5) + try { + $response = Invoke-RestMethod "$recRoot/changes" -Method Post -Body ($changeBody | ConvertTo-Json -Depth 5) -Headers $script:GCToken.AuthHeader -ContentType 'application/json' + Write-Debug ($response | ConvertTo-Json -Depth 5) + } catch { throw } + + <# + .SYNOPSIS + Remove a DNS TXT record from Google Cloud DNS + + .DESCRIPTION + Remove a DNS TXT record from Google Cloud DNS + + .PARAMETER RecordName + The fully qualified name of the TXT record. + + .PARAMETER TxtValue + The value of the TXT record. + + .PARAMETER GCKeyFile + Path to a service account JSON file that contains the account's private key and other metadata. This should have been downloaded when originally creating the service account. + + .PARAMETER GCKeyObj + A cached copy of the service account JSON object. This parameter is managed by the plugin and you shouldn't ever need to specify it manually. + + .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-DnsTxtGCloud '_acme-challenge.site1.example.com' 'asdfqwer12345678' -GCKeyFile .\account.json + + Removes a TXT record the specified site with the specified value using the specified Google Cloud service account. + #> +} + +function Save-DnsTxtGCloud { + [CmdletBinding()] + param( + [Parameter(ValueFromRemainingArguments)] + $ExtraParams + ) + + # Nothing to do. Google Cloud DNS doesn't require a save step + + <# + .SYNOPSIS + Not required for Google Cloud DNS. + + .DESCRIPTION + Google Cloud DNS does not require calling this function to commit changes to DNS records. + + .PARAMETER ExtraParams + This parameter can be ignored and is only used to prevent errors when splatting with more parameters than this function supports. + #> +} + +############################ +# Helper Functions +############################ + +function Connect-GCloudDns { + [CmdletBinding()] + param( + [Parameter(Mandatory,Position=0)] + [string]$GCKeyFile, + [Parameter(Position=1)] + [pscustomobject]$GCKeyObj + ) + + # Using OAuth 2.0 for Server to Server Applications + # https://developers.google.com/identity/protocols/OAuth2ServiceAccount + + # just return if we've already got a valid non-expired token + if ($script:GCToken -and [DateTimeOffset]::Now -lt $script:GCToken.Expires) { + return + } + + Write-Verbose "Signing into GCloud DNS" + + # We want to save the contents of GCKeyFile so the user isn't necessarily stuck + # keeping it wherever it originally was when they ran the command. But we're always + # going to use it rather than the cached version as long as in continues to exist + # so they can update it as necessary. + $GCKeyFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($GCKeyFile) + if (Test-Path $GCKeyFile -PathType Leaf) { + Write-Debug "Using key file" + $GCKeyObj = Get-Content $GCKeyFile | ConvertFrom-Json + + # merge and save updated plugin args + Merge-PluginArgs @{GCKeyObj=$GCKeyObj} | Out-Null + } elseif (!$GCKeyObj) { + throw "Key file $GCKeyFile not found and no cached data exists." + } else { + Write-Debug "Using cached key data" + } + + Write-Debug "Loading private key for $($GCKeyObj.client_email)" + $key = Import-Pem -InputString $GCKeyObj.private_key | ConvertFrom-BCKey + + $unixNow = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + + # build the claim set for DNS read/write + $jwtClaim = @{ + iss = $GCKeyObj.client_email; + aud = $GCKeyObj.token_uri; + scope = 'https://www.googleapis.com/auth/ndev.clouddns.readwrite'; + exp = ($unixNow + 3600).ToString(); + iat = $unixNow.ToString(); + } + Write-Debug "Claim set: $($jwtClaim | ConvertTo-Json)" + + # build a signed jwt + $header = @{alg='RS256';typ='JWT'} + $jwt = New-Jws $key $header ($jwtClaim | ConvertTo-Json -Compress) -Compact -NoHeaderValidation + + # build the POST body + $authBody = "assertion=$jwt&grant_type=$([uri]::EscapeDataString('urn:ietf:params:oauth:grant-type:jwt-bearer'))" + + # attempt to sign in + try { + Write-Debug "Sending OAuth2 login" + $response = Invoke-RestMethod $GCKeyObj.token_uri -Method Post -Body $authBody + Write-Debug ($response | ConvertTo-Json) + } catch { throw } + + # save a custom token to memory + $script:GCToken = @{ + AuthHeader = @{Authorization="$($response.token_type) $($response.access_token)"}; + Expires = [DateTimeOffset]::Now.AddSeconds(3300); + ProjectID = $GCKeyObj.project_id; + } + +} + +function Find-GCZone { + [CmdletBinding()] + param( + [Parameter(Mandatory,Position=0)] + [string]$RecordName + ) + + # setup a module variable to cache the record to zone mapping + # so it's quicker to find later + if (!$script:GCRecordZones) { $script:GCRecordZones = @{} } + + # check for the record in the cache + if ($script:GCRecordZones.ContainsKey($RecordName)) { + return $script:GCRecordZones.$RecordName + } + + $token = $script:GCToken + $projRoot = "https://www.googleapis.com/dns/v1beta2/projects/$($token.ProjectID)" + + # get the list of available zones + try { + $zones = (Invoke-RestMethod "$projRoot/managedZones" -Headers $script:GCToken.AuthHeader).managedZones + } catch { throw } + + # Since Google could be hosting both apex and sub-zones, we need to find the closest/deepest + # sub-zone that would hold the record rather than just adding it to the apex. So for something + # like _acme-challenge.site1.sub1.sub2.example.com, we'd look for zone matches in the following + # order: + # - site1.sub1.sub2.example.com + # - sub1.sub2.example.com + # - sub2.example.com + # - example.com + + $pieces = $RecordName.Split('.') + for ($i=1; $i -lt ($pieces.Count-1); $i++) { + $zoneTest = "$( $pieces[$i..($pieces.Count-1)] -join '.' )." + Write-Debug "Checking $zoneTest" + + if ($zoneTest -in $zones.dnsName) { + $zoneID = ($zones | Where-Object { $_.dnsName -eq $zoneTest }).id + $script:GCRecordZones.$RecordName = $zoneID + return $zoneID + } + } + + return $null +} diff --git a/Posh-ACME/Private/ConvertFrom-BCKey.ps1 b/Posh-ACME/Private/ConvertFrom-BCKey.ps1 new file mode 100644 index 00000000..04f783c7 --- /dev/null +++ b/Posh-ACME/Private/ConvertFrom-BCKey.ps1 @@ -0,0 +1,39 @@ +function ConvertFrom-BCKey { + [CmdletBinding()] + [OutputType('System.Security.Cryptography.AsymmetricAlgorithm')] + param( + [Parameter(Mandatory,Position=0,ValueFromPipeline)] + [Org.BouncyCastle.Crypto.AsymmetricCipherKeyPair]$BCKeyPair + ) + + if ($BCKeyPair.Private -is [Org.BouncyCastle.Crypto.Parameters.ECPrivateKeyParameters]) { + + # TODO: Implement this + throw "Unsupported key type." + + } elseif ($BCKeyPair.Private -is [Org.BouncyCastle.Crypto.Parameters.RsaPrivateCrtKeyParameters]) { + + $pKey = $BCKeyPair.Private + + $keyParams = New-Object Security.Cryptography.RSAParameters + $keyParams.Exponent = $pKey.PublicExponent.ToByteArrayUnsigned() + $keyParams.Modulus = $pKey.Modulus.ToByteArrayUnsigned() + $keyParams.D = $pKey.Exponent.ToByteArrayUnsigned() + $keyParams.P = $pKey.P.ToByteArrayUnsigned() + $keyParams.Q = $pKey.Q.ToByteArrayUnsigned() + $keyParams.DP = $pKey.DP.ToByteArrayUnsigned() + $keyParams.DQ = $pKey.DQ.ToByteArrayUnsigned() + $keyParams.InverseQ = $pKey.QInv.ToByteArrayUnsigned() + + # create the key + $key = New-Object Security.Cryptography.RSACryptoServiceProvider + $key.ImportParameters($keyParams) + + return $key + + } else { + # not EC or RSA...don't know what to do with it + throw "Unsupported key type." + } + +} diff --git a/Posh-ACME/Private/Import-Pem.ps1 b/Posh-ACME/Private/Import-Pem.ps1 index 0b615d17..6fa26b5e 100644 --- a/Posh-ACME/Private/Import-Pem.ps1 +++ b/Posh-ACME/Private/Import-Pem.ps1 @@ -1,8 +1,10 @@ function Import-Pem { [CmdletBinding()] param( - [Parameter(Mandatory,Position=0)] - [string]$InputFile + [Parameter(ParameterSetName='File',Mandatory,Position=0)] + [string]$InputFile, + [Parameter(ParameterSetName='String',Mandatory)] + [string]$InputString ) # DER uses TLV (Tag/Length/Value) triplets. @@ -13,9 +15,13 @@ function Import-Pem { # 0x82 (more than 0x80) means the length is the next 2 (0x82-0x80) bytes # Value starts the byte after the length bytes end - # normalize the file path and read it in - $InputFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InputFile) - $pemStr = (Get-Content $InputFile) -join '' + if ('File' -eq $PSCmdlet.ParameterSetName) { + # normalize the file path and read it in + $InputFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InputFile) + $pemStr = (Get-Content $InputFile) -join '' + } else { + $pemStr = $InputString.Replace("`n",'') + } # private keys if ($pemStr -like '*-----BEGIN *PRIVATE KEY-----*' -and $pemStr -like '*-----END *PRIVATE KEY-----*') { diff --git a/Posh-ACME/Private/New-Jws.ps1 b/Posh-ACME/Private/New-Jws.ps1 index 57beabd8..cd5146c6 100644 --- a/Posh-ACME/Private/New-Jws.ps1 +++ b/Posh-ACME/Private/New-Jws.ps1 @@ -7,7 +7,9 @@ function New-Jws { [Parameter(Mandatory)] [hashtable]$Header, [Parameter(Mandatory)] - [string]$PayloadJson + [string]$PayloadJson, + [switch]$Compact, + [switch]$NoHeaderValidation ) # RFC 7515 - JSON Web Signature (JWS) @@ -55,28 +57,30 @@ function New-Jws { throw "Unsupported Key type. Must be RSA or ECDsa" } - # validate the header - if ('alg' -notin $Header.Keys -or $Header.alg -notin 'RS256','ES256','ES384') { - throw "Missing or invalid 'alg' in supplied Header" - } - if (!('jwk' -in $Header.Keys -xor 'kid' -in $Header.Keys)) { - if ('jwk' -in $Header.Keys) { - throw "Conflicting key entries. Both 'jwk' and 'kid' found in supplied Header" - } else { - throw "Missing key entries. Neither 'jwk' or 'kid' found in supplied Header" + if (!$NoHeaderValidation) { + # validate the header + if ('alg' -notin $Header.Keys -or $Header.alg -notin 'RS256','ES256','ES384') { + throw "Missing or invalid 'alg' in supplied Header" + } + if (!('jwk' -in $Header.Keys -xor 'kid' -in $Header.Keys)) { + if ('jwk' -in $Header.Keys) { + throw "Conflicting key entries. Both 'jwk' and 'kid' found in supplied Header" + } else { + throw "Missing key entries. Neither 'jwk' or 'kid' found in supplied Header" + } + } + if ('jwk' -in $Header.Keys -and [string]::IsNullOrWhiteSpace($Header.jwk)) { + throw "Empty 'jwk' in supplied Header." + } + if ('kid' -in $Header.Keys -and [string]::IsNullOrWhiteSpace($Header.kid)) { + throw "Empty 'kid' in supplied Header." + } + if ('nonce' -notin $Header.Keys -or [string]::IsNullOrWhiteSpace($Header.nonce)) { + throw "Missing or empty 'nonce' in supplied Header." + } + if ('url' -notin $Header.Keys -or [string]::IsNullOrWhiteSpace($Header.url)) { + throw "Missing or empty 'url' in supplied Header." } - } - if ('jwk' -in $Header.Keys -and [string]::IsNullOrWhiteSpace($Header.jwk)) { - throw "Empty 'jwk' in supplied Header." - } - if ('kid' -in $Header.Keys -and [string]::IsNullOrWhiteSpace($Header.kid)) { - throw "Empty 'kid' in supplied Header." - } - if ('nonce' -notin $Header.Keys -or [string]::IsNullOrWhiteSpace($Header.nonce)) { - throw "Missing or empty 'nonce' in supplied Header." - } - if ('url' -notin $Header.Keys -or [string]::IsNullOrWhiteSpace($Header.url)) { - throw "Missing or empty 'url' in supplied Header." } # build the "." string we're going to be signing @@ -118,12 +122,23 @@ function New-Jws { } # now put everything together into the final JWS format - $jws = [ordered]@{} - $jws.payload = $PayloadB64 - $jws.protected = $HeaderB64 - $jws.signature = ConvertTo-Base64Url $SignedBytes + if ($Compact) { + # JWS Compact Serialization + # https://tools.ietf.org/html/rfc7515#section-3.1 - # and return it - return ($jws | ConvertTo-Json -Compress) + return "$HeaderB64.$PayloadB64.$(ConvertTo-Base64Url $SignedBytes)" + + } else { + # JWS JSON Serialization + # https://tools.ietf.org/html/rfc7515#section-3.2 + + $jws = [ordered]@{} + $jws.payload = $PayloadB64 + $jws.protected = $HeaderB64 + $jws.signature = ConvertTo-Base64Url $SignedBytes + + # and return it + return ($jws | ConvertTo-Json -Compress) + } }