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

First off, sorry for ignoring all the questions from the help template, but none of them apply to my problem.

I have found a couple of private keys in a Github repo (yupp, bad idea to put them there, wasn't mine) and I have reason to believe that those could be ACME account keys that have been used for Let's Encrypt. Could have been Let's Encryopt prod or staging.

It's also possible that the corresponding accounts have been deactivated by now. But if they haven't, I'd really like to know, so that I can deactivate them myself.

Afaik, ACME specifies a request that could help me finding out , see RFC 8555, chapter 7.3.1: "Finding an Account URL Given a Key". However, my ACME client (certbot 1.18.0) does not seem to expose a command for just that ACME request.

I thought I could trick certbot by simply putting one of the private keys into the right configuration file, e.g. one like this:
/etc/letsencrypt/accounts/acme-staging-v02.api.letsencrypt.org/directory/0ae5c851a9c18be4b3adac23fa4280d9/private_key.json

But that config file has the account identifier itself in the path, which is exactly the piece of information that I lack.

Is there a way to find out the correct account identifier (and account url)? E.g. can it be derived from the key itself? Is it based on a key fingerprint?

Or is there some other way to use certbot to test, if Let's Encrypt considers my private key to be a valid account key?

If not certbot, what other client could I use to find out? Or how could I use standard open-source tools, like bash, curl, openssl, jq, ... to find out?

1 Like

Welcome to the Let's Encrypt Community, Michael :slightly_smiling_face:

You could try creating a new ACME account using the keys then checking the response code from Boulder to determine if an account was created or found. Afterwards, they will certainly be ACME account keys though (unless replaced). That's not something readily done with a standard ACME client though.

2 Likes

Thanks for the lightning-fast response, @griffin! But how could I try to create a "a new ACME account using the keys"? Afaik, certbot creates those keys under the hood. It does not let me pass in an existing key myself. Or am I missing something?

1 Like

I think that would probably entail creating the accounts configuration files manually I'm afraid.

2 Likes

You are correct. The account key would need to be "inserted" into the account creation process, which isn't directly exposed by any stock ACME client.

1 Like

This got me to thinking about https://gethttpsforfree.com ...

Problem is that you would need to dump the responses from Boulder.

2 Likes

You might try using the Windows client: LE64
Which seems to not use an ACME account directly, but instead is identified the the account key file:

 ZeroSSL Crypt::LE client v0.35

 =====================
 AVAILABLE PARAMETERS:
 =====================

-key <file>                  : Account key file.
-csr <file>                  : CSR file.
-csr-key <file>              : Key for CSR (optional if CSR exists).
-crt <file>                  : Name for the domain certificate file.
-domains <list>              : Domains list (optional if CSR exists).
-renew <XX>                  : Renew if XX or fewer days are left.
-renew-check <URL>           : Check expiration against a specific URL.
-curve <name|default>        : ECC curve name (optional).
-path <absolute path>        : Path to .well-known/acme-challenge/ (optional).
-handle-with <module>        : Module to handle challenges with (optional).
-handle-as <http|dns|tls>    : Type of challenge, by default 'http' (optional).
-handle-params <json|file>   : JSON for the challenge module (optional).
-complete-with <module>      : Module to handle completion with (optional).
-complete-params <json|file> : JSON for the completion module (optional).
-issue-code XXX              : Exit code to use on issuance/renewal (optional).
-email <some@mail.address>   : Email for expiration notifications (optional).
-server <url|host>           : Use custom server URL (optional).
-api <version>               : API version to use (optional).
-update-contacts <emails>    : Update contact details.
-export-pfx <password>       : Export PFX (Windows binaries only).
-tag-pfx <tag>               : Tag PFX with a specific name.
-config <file>               : Configuration file for the client.
-log-config <file>           : Configuration file for logging.
-generate-missing            : Generate missing files (key, csr and csr-key).
-generate-only               : Exit after generating the missing files.
-unlink                      : Remove challenge files automatically.
-revoke                      : Revoke a certificate.
-legacy                      : Legacy mode (shorter keys, separate CA file).
-delayed                     : Exit after requesting the challenge.
-live                        : Use the live server instead of the test one.
-debug                       : Print out debug messages.
-quiet                       : Suppress all messages but errors.
-help                        : Detailed help.
1 Like

My client can actually handle this, however there is a bug in this particular flow right now that was is due to a regression in the latest release by a package it depends on. I'll probably address this within the next day or two.

If you want to handle this quickly, my advice would be editing acme-tiny (GitHub - diafygi/acme-tiny: A tiny script to issue and renew TLS certs from Let's Encrypt). That project is a single file python script, and it uses previously generated keys in PEM format. You would just need to edit it to only send the signed requests to the directory, account, and deactivation urls.

2 Likes

This is an important implementation detail on the ACME spec. There is no way to simply check those keys. You can only use them to "getcreate" an account, which means:

  • keys not used: checking will create a new account, which you must now deactivate
  • keys used, but active: you must now deactivate it, but you may want to do a keyrollover instead (which will deactivate it, but also roll over the existing associations to a new key)
  • keys used, but deactivated: nothing to do

Depending on how much you care about this issue, it may not be worth doing anything.

2 Likes

I might be misreading what you're saying, but newAccount has a field which allows you to prevent the creation of new accounts:

onlyReturnExisting (optional, boolean): If this field is present with the value "true", then the server MUST NOT create a new account if one does not already exist. This allows a client to look up an account URL based on an account key (see Section 7.3.1).

The Python acme library doesn't really expose this in its API, but it's quite possible to write a small "is this key in use?" program in most ACME libraries.

5 Likes

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