Do not check a pinned certificate's dates

A pin trusts one exact certificate, so an expired one or an engine clock
that is off no longer locks a device out (X509_V_FLAG_NO_CHECK_TIME).
This commit is contained in:
2026-10-07 10:11:08 +02:00
parent cd6f8fec55
commit d482b32970
3 changed files with 117 additions and 0 deletions
+4
View File
@@ -83,6 +83,10 @@ def context_for(pem: str) -> ssl.SSLContext:
context.check_hostname = False
# The pinned certificate is the trust anchor, not a chain up to a root.
context.verify_flags |= ssl.VERIFY_X509_PARTIAL_CHAIN
# X509_V_FLAG_NO_CHECK_TIME, which `ssl` has no name for. A pin trusts one
# exact certificate, so its dates add nothing, and a device's certificate
# that expired, or a clock that is off, would otherwise lock it out.
context.verify_flags |= 0x200000
context.load_verify_locations(cadata=pem)
return context
+110
View File
@@ -0,0 +1,110 @@
"""A pinned certificate is trusted for what it is, not for its dates."""
import datetime
import socket
import ssl
import threading
from collections.abc import Iterator
from pathlib import Path
import pytest
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
from fluksio.flow.connector_tls import (
CertificateChanged,
context_for,
pin_key,
pinned_certificate,
raise_if_changed,
)
def _expired(name: str) -> tuple[ec.EllipticCurvePrivateKey, str]:
"""A self-signed certificate that expired a month ago."""
key = ec.generate_private_key(ec.SECP256R1())
now = datetime.datetime.now(datetime.UTC)
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, name)])
certificate = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - datetime.timedelta(days=60))
.not_valid_after(now - datetime.timedelta(days=30))
.sign(key, hashes.SHA256())
)
return key, certificate.public_bytes(serialization.Encoding.PEM).decode()
DEVICE = _expired("00005e005301")
IMPOSTOR = _expired("00005e005302")
class Store:
def __init__(self) -> None:
self.values: dict[str, str] = {}
def get(self, key: str) -> str | None:
return self.values.get(key)
def set(self, key: str, value: str) -> None:
self.values[key] = value
@pytest.fixture
def device(tmp_path: Path) -> Iterator[str]:
"""A TLS server presenting the expired certificate; yields its address."""
key, pem = DEVICE
(tmp_path / "cert.pem").write_text(pem)
(tmp_path / "key.pem").write_bytes(
key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
)
)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(tmp_path / "cert.pem", tmp_path / "key.pem")
listener = socket.create_server(("127.0.0.1", 0))
def serve() -> None:
while True:
try:
connection, _ = listener.accept()
except OSError:
return
try:
context.wrap_socket(connection, server_side=True).close()
except OSError:
connection.close()
threading.Thread(target=serve, daemon=True).start()
yield f"127.0.0.1:{listener.getsockname()[1]}"
listener.close()
def _handshake(address: str, pem: str) -> None:
host, port = address.split(":")
with socket.create_connection((host, int(port)), timeout=5) as raw:
context_for(pem).wrap_socket(raw, server_hostname=host).close()
def test_an_expired_pinned_certificate_is_still_trusted(device: str) -> None:
store = Store()
pem = pinned_certificate(store, device, 5.0)
assert store.get(pin_key(device)) == DEVICE[1]
_handshake(device, pem) # raises if the dates were checked
def test_another_certificate_is_still_refused(device: str) -> None:
pinned = IMPOSTOR[1]
with pytest.raises(ssl.SSLCertVerificationError) as refused:
_handshake(device, pinned)
with pytest.raises(CertificateChanged, match="/connectors/test/state/cert:"):
raise_if_changed(refused.value, pinned, device, 5.0, "test")
+3
View File
@@ -266,6 +266,9 @@ except httpx.ConnectError as exc:
- Any other certificate is refused in the handshake, before a key or a command
is sent. `raise_if_changed` turns that refusal into `CertificateChanged`,
which names the new SHA-256 and the key to clear, under the node type given.
- The pinned certificate's dates are not checked: a pin trusts that exact
certificate, so one that expired, or an engine clock that is off, does not
lock the device out.
- Clearing the key accepts whatever the device presents next.
## Lifecycle