Public/New-PAAccount.ps1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
function New-PAAccount {
    [CmdletBinding(SupportsShouldProcess,DefaultParameterSetName='Generate')]
    [OutputType('PoshACME.PAAccount')]
    param(
        [Parameter(Position=0)]
        [string[]]$Contact,
        [Parameter(ParameterSetName='Generate',Position=1)]
        [ValidateScript({Test-ValidKeyLength $_ -ThrowOnFail})]
        [Alias('AccountKeyLength')]
        [string]$KeyLength='ec-256',
        [Parameter(ParameterSetName='ImportKey',Mandatory)]
        [string]$KeyFile,
        [ValidateScript({Test-ValidFriendlyName $_ -ThrowOnFail})]
        [Alias('Name')]
        [string]$ID,
        [switch]$AcceptTOS,
        [Parameter(ParameterSetName='ImportKey')]
        [switch]$OnlyReturnExisting,
        [switch]$Force,
        [string]$ExtAcctKID,
        [string]$ExtAcctHMACKey,
        [ValidateSet('HS256','HS384','HS512')]
        [string]$ExtAcctAlgorithm = 'HS256',
        [switch]$UseAltPluginEncryption,
        [Parameter(ValueFromRemainingArguments=$true)]
        $ExtraParams
    )

    # make sure we have a server configured
    if (-not ($server = Get-PAServer)) {
        try { throw "No ACME server configured. Run Set-PAServer first." }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    # make sure the external account binding parameters were specified if this ACME
    # server requires them.
    if ($server.meta -and $server.meta.externalAccountRequired -and
        (-not $ExtAcctKID -or -not $ExtAcctHMACKey))
    {
        try { throw "The current ACME server requires external account credentials to create a new ACME account. Please run New-PAAccount with the ExtAcctKID and ExtAcctHMACKey parameters." }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    # try to decode the HMAC key if specified
    if ($ExtAcctHMACKey) {
        $keyBytes = ConvertFrom-Base64Url $ExtAcctHMACKey -AsByteArray
        $hmacKey = switch ($ExtAcctAlgorithm) {
            'HS256' { [Security.Cryptography.HMACSHA256]::new($keyBytes); break; }
            'HS384' { [Security.Cryptography.HMACSHA384]::new($keyBytes); break; }
            'HS512' { [Security.Cryptography.HMACSHA512]::new($keyBytes); break; }
        }
    }

    # make sure the Contact emails have a "mailto:" prefix
    # this may get more complex later if ACME servers support more than email based contacts
    if ($Contact.Count -gt 0) {
        0..($Contact.Count-1) | ForEach-Object {
            if ($Contact[$_] -notlike 'mailto:*') {
                $Contact[$_] = "mailto:$($Contact[$_])"
            }
        }
    } else {
        Write-Warning "No email contacts specified for this account. Certificate expiration warnings will not be sent unless you add at least one with Set-PAAccount."
    }

    if ('Generate' -eq $PSCmdlet.ParameterSetName) {

        # There's a chance we may be creating effectively a duplicate account. So check
        # for confirmation if there's already one with the same contacts and keylength.
        if (-not $Force) {
            $accts = @(Get-PAAccount -List -Refresh -Contact $Contact -KeyLength $KeyLength -Status 'valid')
            if ($accts.Count -gt 0) {
                if (-not $PSCmdlet.ShouldContinue("Do you wish to duplicate?",
                    "An account exists with matching contacts and key length.")) { return }
            }
        }

        Write-Debug "Creating new $KeyLength account with contact: $($Contact -join ', ')"

        # create the account key
        $acctKey = New-PAKey $KeyLength

    } else { # ImportKey parameter set

        try {
            $kLength = [string]::Empty
            $acctKey = New-PAKey -KeyFile $KeyFile -ParsedLength ([ref]$kLength)
            $KeyLength = $kLength
        }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    # create the algorithm identifier as described by
    # https://tools.ietf.org/html/rfc7518#section-3.1
    # and what we know LetsEncrypt supports today which includes
    # RS256 for all RSA keys
    # ES256 for P-256 keys, ES384 for P-384 keys, ES512 for P-521 keys
    $alg = 'RS256'
    if     ($KeyLength -eq 'ec-256') { $alg = 'ES256' }
    elseif ($KeyLength -eq 'ec-384') { $alg = 'ES384' }
    elseif ($KeyLength -eq 'ec-521') { $alg = 'ES512' }

    # build the protected header for the request
    $header = @{
        alg   = $alg
        jwk   = ($acctKey | ConvertTo-Jwk -PublicOnly)
        nonce = $script:Dir.nonce
        url   = $script:Dir.newAccount
    }

    # init the payload
    $payload = @{}
    if ($Contact.Count -gt 0) {
        $payload.contact = $Contact
    }
    if ($AcceptTOS) {
        $payload.termsOfServiceAgreed = $true
    }
    if ($OnlyReturnExisting) {
        $payload.onlyReturnExisting = $true
    }

    # add external account binding if specified
    if ($ExtAcctKID -and $ExtAcctHMACKey) {

        $eabHeader = @{
            alg = $ExtAcctAlgorithm
            kid = $ExtAcctKID
            url = $script:Dir.newAccount
        }

        $eabPayload = $header.jwk | ConvertTo-Json -Depth 5 -Compress

        $payload.externalAccountBinding =
            New-Jws $hmacKey $eabHeader $eabPayload | ConvertFrom-Json
    }

    # convert it to json
    $payloadJson = $payload | ConvertTo-Json -Depth 5 -Compress

    # send the request
    try {
        $response = Invoke-ACME $header $payloadJson -Key $acctKey -EA Stop
    } catch { $PSCmdlet.ThrowTerminatingError($_) }

    # grab the Location header
    if ($response.Headers.ContainsKey('Location')) {
        $location = $response.Headers['Location'] | Select-Object -First 1
    } else {
        try { throw 'No Location header found in newAccount output' }
        catch { $PSCmdlet.ThrowTerminatingError($_) }
    }

    $respObj = $response.Content | ConvertFrom-Json

    # Before RFC8555 was finalized, LE/Boulder used to return the raw account ID value as a
    # property in the JSON output for new account requests. But the finalized RFC 8555 does
    # not require this and Boulder will be removing it. But it's still a useful value to have
    # as a simpler identifier/name for referencing accounts than a full URL. So if it's not
    # returned and the user didn't provide an explicit ID value to use, we're going to try
    # and parse it from the location header. This may come back to haunt us if other ACME
    # providers use different location schemes in the future.

    if (-not $respObj.ID) {
        # https://acme-staging-v02.api.letsencrypt.org/acme/acct/xxxxxxxx
        # https://acme-v02.api.letsencrypt.org/acme/acct/xxxxxxxx
        $fallbackID = ([Uri]$location).Segments[-1]
    } else {
        $fallbackID = $respObj.ID.ToString()
    }

    # if an explicit ID was provided, make sure it doesn't conflict with
    # another account
    if ($ID) {
        if (Get-PAAccount -ID $ID) {
            Write-Warning "Account ID '$ID' is already in use. Falling back to the default ID value."
            $ID = $fallbackID
        }
    } else {
        $ID = $fallbackID
    }

    # build the return value
    $acct = [pscustomobject]@{
        PSTypeName = 'PoshACME.PAAccount'
        id = $ID
        status = $respObj.status
        contact = $respObj.contact
        location = $location
        key = ($acctKey | ConvertTo-Jwk)
        alg = $alg
        KeyLength = $KeyLength
        # The orders field is supposed to exist according to
        # https://tools.ietf.org/html/rfc8555#section-7.1.2
        # But it's not currently implemented in Boulder. Tracking issue is here:
        # https://github.com/letsencrypt/boulder/issues/3335
        orders = $respObj.orders
        sskey = $null
        Folder = Join-Path $server.Folder $ID
    }

    # add a new AES key if specified
    if ($UseAltPluginEncryption) {
        $acct.sskey = New-AesKey
    }

    # save it to memory and disk
    $acct.id | Out-File (Join-Path $server.Folder 'current-account.txt') -Force -EA Stop
    $script:Acct = $acct
    if (-not (Test-Path $acct.Folder -PathType Container)) {
        New-Item -ItemType Directory -Path $acct.Folder -Force -EA Stop | Out-Null
    }
    $acct | Select-Object -Property * -ExcludeProperty id,Folder |
        ConvertTo-Json -Depth 5 |
        Out-File (Join-Path $acct.Folder 'acct.json') -Force -EA Stop

    return $acct
}