Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 8 min read

Asking Let's Encrypt when to renew: an ARI request built by hand in Python

Let's Encrypt's 7 October post moves the default certificate lifetime from 90 days to 64 days on 10 February 2027, and the post scheduled staging to switch on 14 October. The advice in it comes in two parts. If your clie

Let's Encrypt's 7 October post moves the default certificate lifetime from 90 days to 64 days on 10 February 2027, and the post scheduled staging to switch on 14 October. The advice in it comes in two parts. If your client supports ACME Renewal Information (ARI), "you should be all set". If it doesn't, renew "at approximately โ…” of the lifetime instead".

I wanted to see what "all set" means on the wire: what the client asks, what the CA answers, and how close the answer is to the two-thirds rule. ARI is a plain unauthenticated GET, so you can make the request yourself with the Python standard library and no ACME account. Everything below was run on 27 October 2026.

Step 1: the directory tells you where to ask

Every ACME server publishes a directory object. Let's Encrypt's production one, trimmed:

curl -s https://acme-v02.api.letsencrypt.org/directory
{
  "meta": {
    "caaIdentities": ["letsencrypt.org"],
    "profiles": {
      "classic": "https://letsencrypt.org/docs/profiles#classic",
      "shortlived": "https://letsencrypt.org/docs/profiles#shortlived",
      "tlsserver": "https://letsencrypt.org/docs/profiles#tlsserver"
    },
    "termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf",
    "website": "https://letsencrypt.org"
  },
  "newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order",
  "renewalInfo": "https://acme-v02.api.letsencrypt.org/acme/renewal-info",
  "revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert"
}

Two fields matter here. renewalInfo is the ARI endpoint, and RFC 9773 ยง3 says a server that offers ARI MUST put it there. profiles lists the certificate profiles you can request. The staging directory returned the same three names that day.

The directory gives names only. Lifetimes live on the profiles page: tlsserver is 45 days, shortlived is 160 hours. That page was last updated on 4 October and still lists classic at 90 days, three days before the 64-day announcement. So the directory won't tell your code how long its certificate lasts. The certificate will, and ARI doesn't need you to know.

Step 2: build the certificate ID

RFC 9773 ยง4.1 defines the path. Take the keyIdentifier from the certificate's Authority Key Identifier extension, take the DER bytes of the serial number without the tag and length, base64url both, strip the = padding, and join them with a dot. The RFC includes a worked example, which makes a free unit test.

The standard library's ssl module hands you the peer certificate as DER, and its parsed dictionary form has no AKI in it, so here is a small DER reader. It only handles what an X.509 certificate needs to get to the serial, the validity dates and the AKI.

import base64, json, socket, ssl, sys, urllib.request
from datetime import datetime, timezone

DIRECTORY = "https://acme-v02.api.letsencrypt.org/directory"


def read_tlv(buf, i):
    tag = buf[i]
    length = buf[i + 1]
    i += 2
    if length & 0x80:
        n = length & 0x7F
        length = int.from_bytes(buf[i:i + n], "big")
        i += n
    return tag, buf[i:i + length], i + length


def children(buf):
    i, out = 0, []
    while i < len(buf):
        tag, val, i = read_tlv(buf, i)
        out.append((tag, val))
    return out


def parse_time(tag, val):
    s = val.decode()
    fmt = "%y%m%d%H%M%SZ" if tag == 0x17 else "%Y%m%d%H%M%SZ"
    return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)


AKI_OID = bytes.fromhex("551d23")  # 2.5.29.35


def cert_fields(der):
    _, cert, _ = read_tlv(der, 0)
    tbs = children(children(cert)[0][1])
    pos = 1 if tbs[0][0] == 0xA0 else 0  # skip [0] version
    serial = tbs[pos][1]                 # DER INTEGER content bytes
    validity = children(tbs[pos + 3][1])
    not_before = parse_time(*validity[0])
    not_after = parse_time(*validity[1])
    aki = None
    for tag, val in tbs:
        if tag != 0xA3:                  # [3] extensions
            continue
        for _, ext in children(children(val)[0][1]):
            parts = children(ext)
            if parts[0][1] == AKI_OID:
                aki_seq = children(parts[-1][1])[0][1]
                for t, v in children(aki_seq):
                    if t == 0x80:        # [0] keyIdentifier
                        aki = v
    return serial, not_before, not_after, aki

The serial is the one place it's easy to get wrong. DER integers are signed, so a serial whose first byte has the top bit set carries a leading 0x00. The RFC wants those bytes as encoded, leading zero included. Converting the serial to an int and back with the minimum byte length drops the zero and gives you an ID the server has never heard of. Taking the INTEGER's content bytes straight from the DER avoids the problem.

def b64url(b):
    return base64.urlsafe_b64encode(b).decode().rstrip("=")


def ari_id(aki, serial):
    return b64url(aki) + "." + b64url(serial)

Step 3: ask, and compare with two thirds

The rest connects to a host, pulls its certificate, builds the ID and asks Let's Encrypt. The first line checks the RFC's worked example, serial 00:87:65:43:21 with its leading zero.

def fetch_cert(host):
    ctx = ssl.create_default_context()
    with socket.create_connection((host, 443), timeout=10) as sock:
        with ctx.wrap_socket(sock, server_hostname=host) as tls:
            return tls.getpeercert(binary_form=True)


def get_json(url):
    req = urllib.request.Request(url, headers={"User-Agent": "ari-check/0.1"})
    with urllib.request.urlopen(req, timeout=10) as r:
        return json.load(r), r.headers.get("Retry-After")


# Self-test against the worked example in RFC 9773 section 4.1
assert ari_id(bytes.fromhex("69885B6B87464041E1B37B847BA0AE2CDE01C8D4"),
              bytes.fromhex("0087654321")) == "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"

directory, _ = get_json(DIRECTORY)
print("profiles:", ", ".join(directory["meta"]["profiles"]))
base = directory["renewalInfo"]

for host in sys.argv[1:]:
    serial, nb, na, aki = cert_fields(fetch_cert(host))
    life = na - nb
    two_thirds = nb + life * 2 / 3
    cid = ari_id(aki, serial)
    print(f"\n{host}")
    print(f"  notBefore  {nb:%Y-%m-%d %H:%M}Z  notAfter {na:%Y-%m-%d %H:%M}Z")
    print(f"  lifetime   {life.total_seconds() / 86400:.2f} days")
    print(f"  2/3 point  {two_thirds:%Y-%m-%d %H:%M}Z")
    print(f"  ARI id     {cid}")
    try:
        info, retry = get_json(f"{base}/{cid}")
    except Exception as e:
        print(f"  ARI        no answer ({e})")
        continue
    w = info["suggestedWindow"]
    start = datetime.fromisoformat(w["start"].replace("Z", "+00:00"))
    end = datetime.fromisoformat(w["end"].replace("Z", "+00:00"))
    print(f"  ARI window {start:%Y-%m-%d %H:%M}Z -> {end:%Y-%m-%d %H:%M}Z")
    print(f"  as share of lifetime {(start - nb) / life:.3f} -> {(end - nb) / life:.3f}")
    print(f"  Retry-After {retry}s")
    if "explanationURL" in info:
        print(f"  explanation {info['explanationURL']}")

I pointed it at two hosts that use Let's Encrypt and one that doesn't:

$ python3 ari_check.py letsencrypt.org community.letsencrypt.org google.com
profiles: classic, shortlived, tlsserver

letsencrypt.org
  notBefore  2026-09-04 14:34Z  notAfter 2026-12-03 14:34Z
  lifetime   90.00 days
  2/3 point  2026-11-03 14:34Z
  ARI id     uVnyjs8i8IbTN0j_dhQYuoLYVYc.BUOTO-OGs6KzrdU08hA7yLZf
  ARI window 2026-11-02 17:18Z -> 2026-11-04 12:29Z
  as share of lifetime 0.657 -> 0.677
  Retry-After 22509s

community.letsencrypt.org
  notBefore  2026-10-17 00:01Z  notAfter 2027-01-15 00:01Z
  lifetime   90.00 days
  2/3 point  2026-12-16 00:01Z
  ARI id     QBUtJnntMiCe35pyHdYyH4EMgQw.BaDPfcSmSl1rE-x3CK9ycwDS
  ARI window 2026-12-15 02:45Z -> 2026-12-16 21:56Z
  as share of lifetime 0.657 -> 0.677
  Retry-After 18328s

google.com
  notBefore  2026-09-28 18:38Z  notAfter 2026-12-21 18:38Z
  lifetime   84.00 days
  2/3 point  2026-11-23 18:38Z
  ARI id     db7Ed66J9kQ3fc-xaB8dGuvcNFk.AKNba7BW76hrEPyYtBP27-k
  ARI        no answer (HTTP Error 404: Not Found)

Three things in that output.

The window is close to two thirds but it isn't a point. For both certificates it ran from 65.7% to 67.7% of the lifetime, about 43 hours wide, with the two-thirds mark inside it. Two certificates issued six weeks apart got the same shape, which suggests a rule applied to the lifetime rather than a fixed day count. Two certificates aren't enough to say what the rule is, and nothing stops Let's Encrypt changing it. That's the point of asking.

Retry-After came back at 22,509 and 18,328 seconds, about 6.3 and 5.1 hours. Under ยง4.3 that's not a minimum. It "indicates the desired (i.e., both requested minimum and maximum) amount of time to wait", so a client should check again after roughly that long, not whenever it likes.

The ID only means something to the CA that issued the certificate. google.com's certificate isn't from Let's Encrypt, so its ID got a 404. A tool that manages certificates from more than one CA has to read each CA's own directory.

What a client should do with the window

RFC 9773 ยง4.2 recommends picking a uniformly random time inside the window and renewing right away if that time has passed. Otherwise the client schedules itself for that time if it can, renews now if the pick falls before its next normal wake-up, and failing both, sleeps until Retry-After and asks again. The random pick spreads load across the window. A window whose end is not after its start is invalid, and the client has to treat it as no answer at all. Here's that as a function with a two-thirds fallback for when ARI fails:

import random


def next_renewal(nb, na, window=None, now=None):
    now = now or datetime.now(timezone.utc)
    if window:
        start, end = window
        if end > start:
            pick = start + (end - start) * random.random()
            return max(pick, now)
    # No usable ARI answer: fall back to two thirds of the lifetime
    return max(nb + (na - nb) * 2 / 3, now)

Two more rules from the RFC catch out home-grown scripts. A client MUST NOT check a certificate's renewal info after it has expired, or after the certificate has been replaced. And a long-term error, such as a missing Retry-After or any non-5xx HTTP error, means making the next request once six hours have passed (or a locally configured default) rather than hammering the endpoint. When the client does renew, ยง5 lets it put the old certificate's ID in a replaces field on the new order, which tells the CA which certificate the new one supersedes.

Why the fallback should be a fraction, not a day count

The two-thirds fallback stays sensible when the lifetime changes. A rule written as a fixed number of days doesn't. On a 90-day certificate, two thirds is day 60. On a 64-day one it's day 42.7, and on a 45-day tlsserver certificate it's day 30. A cron job that renews every 60 days was fine at 90 and lets a 45-day certificate expire. Computing from notBefore and notAfter, as the script does, means the next lifetime change needs no code change.

The staging change in the 7 October post is how to find out before February. A second staging change is scheduled for today. From 27 October, Let's Encrypt staging is due to validate domains from seven places, one primary and six remote perspectives instead of four, with production planned for 10 November if staging goes well. A staging run this week tests both the new lifetime and the new validation path in one go. I didn't issue a staging certificate for this post, so I can't show a 64-day one here. The ARI request above works against the staging renewalInfo URL the same way.

Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.