How to check, if a private key is a valid Let's Encrypt account key?

Hey, thank you all for your feedback so far! That gives me a lot of useful pointers. Unfortunately, I won't be able to follow up on this during the next few days. But I'll get to it eventually and will let you know what worked for me...

2 Likes

Here's an example program:

package main

import (
	"crypto"
	"crypto/x509"
	"encoding/pem"
	"errors"
	"io/ioutil"
	"log"
	"os"

	"github.com/eggsampler/acme/v3"
)

func main() {
	if len(os.Args) != 2 {
		log.Fatalf("Usage: %s [pem private key]", os.Args[0])
	}

	// However you want to load your private key ...
	buf, err := ioutil.ReadFile(os.Args[1])
	if err != nil {
		log.Fatalf("Couldn't read PEM file: %v", err)
	}
	asPEM, _ := pem.Decode(buf)
	var privateKey interface{}
	switch asPEM.Type {
	case "PRIVATE KEY":
		privateKey, err = x509.ParsePKCS8PrivateKey(asPEM.Bytes)
	case "RSA PRIVATE KEY":
		privateKey, err = x509.ParsePKCS1PrivateKey(asPEM.Bytes)
	case "EC PRIVATE KEY":
		privateKey, err = x509.ParseECPrivateKey(asPEM.Bytes)
	default:
		log.Fatalf("Unsupported PEM key type: %s", asPEM.Type)
	}
	if err != nil {
		log.Fatalf("Couldn't parse PEM private key: %v", err)
	}

	// Query the ACME server with this key
	client, err := acme.NewClient("https://acme-v02.api.letsencrypt.org/directory")
	if err != nil {
		log.Fatalf("Error loading ACME directory: %v", err)
	}
	var acmeError acme.Problem
	_, err = client.NewAccount(privateKey.(crypto.Signer), true, true)
	if errors.As(err, &acmeError) && acmeError.Type == "urn:ietf:params:acme:error:accountDoesNotExist" {
		log.Fatalf("Account does not exist for key %s.", os.Args[1])
	} else if err != nil {
		log.Fatalf("Failed to query account: %v", err)
	} else {
		log.Printf("Ding ding! Account exists for key %s", os.Args[1])
	}
}
$ go run check-key.go letsdebug.pem
2021/08/21 09:37:47 Ding ding! Account exists for key letsdebug.pem

$ sudo go run check-key.go /etc/letsencrypt/live/1627949999.example.com/privkey.pem
2021/08/21 09:37:56 Account does not exist for key /etc/letsencrypt/live/1627949999.example.com/privkey.pem.
5 Likes

Again I don't know as common clients make this easy, but you could also try using the keys as certificate private keys, asking Let's Encrypt to make a certificate with a CSR for that key for any domain you control. If it's in the Boulder database as an account key, it will give you an error that an account key can't also be used as a certificate.

And then, if these are publicly-posted compromised keys, you can then send a revoke request for keyCompromise and the key will be put on a blocklist to not be used by anybody ever again.

The only other way to report a compromised key is to email Let's Encrypt, but then of course you're making a human look at it.

2 Likes

THANK YOU. I totally forgot about seeing that in the spec, because I have never seen that implemented in a client.

5 Likes

That's pretty brilliant actually. :yellow_heart:

1 Like

I am not certain this strategy would work. My read of Boulder's code says that it checks to see if the CSR key matches your account key, not if it matches any account key.

It is possible they also keep every single account key on the "bad keys" list, but I don't remember ever reading anything to suggest that. Yeah, Boulder accepts CSRs signed using the account key of somebody else's ACME account.

This strategy would also end up causing certificates to be issued, unless one found some way to abort the issuance at a late moment. The account key check is quite late late in the process; much later than the other checks on the CSR. Other than rate limits, I'm not seeing anything that would let you prevent the issuance that late.

Since the spec explicitly gives us a way to achieve this task, one may as well stick to that.

2 Likes

I was so hopeful that @petercooperjr's method would work since it's easy to rig with certbot out of box. I feel like these "circumstances" keep arising where most/all existing clients don't support certain obscure functionality out of box (like removing email addresses from an existing ACME account).

1 Like

Heh. I suppose I hadn't actually tested it and was just making some assumptions. Silly me.

Ah, I guess it depends on what "this task" is. I was probably reading too much into the initial post saying that private keys were found in a (presumably public) Github repo, and was thinking that the main goal was to inform Let's Encrypt to disable the keys. In that case, the only way to disable a key that I know of, besides emailing Let's Encrypt to have a human handle it, is to create a certificate with that key (or find an existing one, but that can be harder) and then revoke it for keyCompromise.

2 Likes

You can now do this pretty easily in the dev build of Posh-ACME. I haven't shipped an official release with it yet though, so you'd have to use the Github copy until I do.

Set-PAServer LE_PROD  # or whatever ACME CA
New-PAAccount -KeyFile .\mystery.key -OnlyReturnExisting

Boulder (other ACME CAs may vary) will throw an error if the account doesn't exist or a slightly different error if it has been deactivated already. If a valid account does exist, it will get returned as normal and you can then deactivate and remove it like this.

Set-PAAccount -Deactivate -Force
Get-PAAccount | Remove-PAAccount -Force
3 Likes

Thank you everyone, for proposing clients that could help me checking ACME account keys! I could not try all of them, so I picked the go program proposed by @_az. I hope the other proposals can be useful for someone else in the future.

With that go program, I was able to confirmed that one of the suspected private keys was indeed a Let's Encrypt account key. Both production and staging accepted it. In order to test that, I've done a few modifications to the code:

import (
    "crypto"
    "crypto/x509"
    "encoding/pem"
    "errors"
    "io/ioutil"
    "log"
    "os"

    "github.com/eggsampler/acme/v3"
)

func main() {
    if len(os.Args) < 2 || len(os.Args) >= 4 {
        log.Fatalf("Usage: %s [pem private key] {acme url}", os.Args[0])
    }
    keyFile := os.Args[1]
    acmeUrl := "https://acme-v02.api.letsencrypt.org/directory"
    if len(os.Args) >= 3 {
        acmeUrl = os.Args[2]
    }

    buf, err := ioutil.ReadFile(keyFile)
    if err != nil {
        log.Fatalf("Couldn't read PEM file: %v", err)
    }

    asPEM, _ := pem.Decode(buf)
    var privateKey interface{}
    switch asPEM.Type {
    case "PRIVATE KEY":
        privateKey, err = x509.ParsePKCS8PrivateKey(asPEM.Bytes)
    case "RSA PRIVATE KEY":
        privateKey, err = x509.ParsePKCS1PrivateKey(asPEM.Bytes)
    case "EC PRIVATE KEY":
        privateKey, err = x509.ParseECPrivateKey(asPEM.Bytes)
    default:
        log.Fatalf("Unsupported PEM key type: %s", asPEM.Type)
    }
    if err != nil {
        log.Fatalf("Couldn't parse PEM private key: %v", err)
    }

    // Query the ACME server with this key
    client, err := acme.NewClient(acmeUrl)
    if err != nil {
        log.Fatalf("Error loading ACME directory: %v", err)
    }
    var acmeError acme.Problem
    _, err = client.NewAccount(privateKey.(crypto.Signer), true, true)
    if err != nil {
        errors.As(err, &acmeError)
        if acmeError.Type == "urn:ietf:params:acme:error:accountDoesNotExist" {
            log.Fatalf("Account does not exist for key %s! ACME server said:\n%v", keyFile, err)
        } else if acmeError.Type == "urn:ietf:params:acme:error:unauthorized" {
            log.Fatalf("Account is deactivated for key %s! ACME server said:\n%v", keyFile, err)
        } else {
            log.Fatalf("Failed to query account for unknown reason! ACME server said:\v%v", err)
        }
    } else {
        log.Printf("Ding ding! Account exists for key %s", keyFile)
    }
}

My changes are:

  • Allow to pass an ACME server URL as optional CLI argument. That helped me testing with Let's Encrypt staging and could work against other ACME servers, too.
  • Improve handling of ACME responses. Turns out that Let's Encrypt uses different errors for unknown accounts vs deactivated accounts. The program takes that into account now, but also prints the exact error message from the server.

It's the first time I've tinkered with go code, other than a simple hello world. So please excuse the code quality!

One more remark:
The program accepts key files in PEM format. This happened to be the format that I've encountered in our git repo. Certbot stores the account key in JWK format instead (which is also used in some ACME request bodies). In order to test with a new account created with certbot, I used an online converter. This was fine, because that account had not issued certs yet, didn't have any authorizations, and I unregistered it shortly after. In other situations, please do not post private keys to random websites!

5 Likes

FYI, there is a script/working-example in the josepy directory on converting from JWK to PEM:

I only know this because I contributed it a while back :wink:

3 Likes

It can also convert from PEM to JWK it seems! At least, it does have a pem_to_jwk() function, but I'm not sure if that function is directly callable from the CLI.

4 Likes

Yes. That function is just there so it can roundtrip/self-test.

pem_to_jwk is a trivial operation in josepy, because the library has native support for loading pem and dumping json. translating JWK TO PEM was a bit of a hassle, as it took a bit of research to find the right encoding hooks and params to accomplish it. that's why the example exists!

(edit: translating JWK TO PEM is so hard, I originally wrote "PEM TO JWK" and @Osiris had to point that out to me in the next comment.)

3 Likes

You probably mean JWK to PEM here :wink:

3 Likes

FWIW, I recently modified CertSage to store the account private key as its sole saved account information rather than a json file with more as before. This means that it's now trivial to simply put your suspected account key as account.key or account-staging.key into CertSage's data directory. If the suspected key is an actual account key, CertSage will continue as normal. If not, an exception will be thrown. I will be posting the updated version in the near future.

2 Likes

A colleague has proposed another solution, based on the ansible acme_account_info plugin. No need to tinker with true source code, you'll just need a yaml playbook file like this one:

- hosts: localhost
  gather_facts: no
  tasks:
    - name: Check whether an account with the given account key exists
      acme_account_info:
        acme_version: 2
        acme_directory: "{{ acme_directory_url | default('https://acme-v02.api.letsencrypt.org/directory') }}"
        account_key_src: "{{ acme_account_key_src | default('account-private-key.pem') }}"
      register: account_data
    - name: Verify that account exists
      assert:
        that:
          - account_data.exists
    - name: Print account URI
      debug: var=account_data.account_uri
    - name: Print account contacts
      debug: var=account_data.account.contact
    - name: Print account details
      debug: var=account_data

Assuming you have saved that file to ansible-check-account.yaml and want to check file my_suspected_account_key.pem you can invoke it like this:

ansible-playbook ansible-check-account.yaml --extra-vars 'acme_account_key_src=my_suspected_account_key.pem' --extra-vars 'acme_directory_url=https://acme-staging-v02.api.letsencrypt.org/directory'

The above command checks against Let's Encrypt staging. I you want to check against LE prod instead, just omit the acme_directory_url. The playbook defaults to LE prod.

If the account already exists, the above should succeed and print account details, including the account uri (which in turn contains the account id). If the account does not exist or the key is invalid, the above should fail.

Caution: On success the play will print the account private key verbatim! Edit the playbook and remove the bottom-most task if you want to avoid that.

4 Likes

No, it's not the private key. It's the public one. The field is called public_account_key :wink:.

2 Likes

Indeed @dennis-benzinger-sap. Actually, I get two fields: key and public_account_key. But both of them only contain the public part of the key.

I could have sworn that I've seen a private key somewhere in the ansible outputs. But I cannot reproduce that anymore. So probably no need to worry.

Btw, thanks for proposing the ansible solution!

2 Likes

I've upgraded https://github.com/aptise/peter_sslers to support checking a key without registering an account as well. Thank you, @_az

Steps:

  • Upload a PEM or JWK key as "New Acme Account Key"
  • Click the button "Check Against ACME Server"

This only checks one account at a time but PeterSSLers offers a JSON API, so the entire process can be easily automated.

3 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.