| Server IP : 172.67.201.108 / Your IP : 216.73.216.11 Web Server : Apache/2.4.68 (Amazon Linux) OpenSSL/3.5.5 System : Linux ip-172-31-69-123.ec2.internal 6.1.176-223.369.amzn2023.x86_64 #1 SMP PREEMPT_DYNAMIC Fri Jul 24 13:34:27 UTC 2026 x86_64 User : ec2-user ( 1000) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /lib/python3.9/site-packages/cfnbootstrap/ |
Upload File : |
# ==============================================================================
# Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================
"""
Windows certutil-backed override CA installer.
This module shells to ``certutil.exe`` (ships with Windows Server 2008+) to
install override CA(s) into the LocalMachine ``Root`` / ``CA`` stores. We
deliberately avoid raw ``ctypes`` calls into ``crypt32.dll`` / ``bcrypt.dll``
(Microsoft already wrote the ctypes layer behind certutil, and the prior
ctypes implementation hit two real-Windows bugs that wasted multiple
iterations -- see ``project-option-a-real-windows-bugs.md`` in the memory
notes).
Never imports ``ssl`` or ``OpenSSL``. Pure stdlib + subprocess to certutil.
Public surface (called from :mod:`cfnbootstrap._ca_install`):
install_or_rotate(pem_path, marker_path)
read_marker(marker_path) -> Optional[dict]
write_marker(marker_path, marker) -> None
classify_cert(cer_path, certutil_path, thumbprint_sha1=None) -> (store, subject_cn, is_ca)
certutil_addstore(store, cer_path, thumbprint_sha1, certutil_path) -> None
certutil_delstore(store, thumbprint, certutil_path) -> None
uninstall_all_from_marker(marker_path, certutil_path) -> None
"""
import hashlib
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
import time
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from cfnbootstrap._ca_install import CaOverrideError
log = logging.getLogger('cfn.init')
_LOG_PREFIX = '[CaOverrideInstall]'
# --------------------------------------------------------------------------- #
# Configuration constants
# --------------------------------------------------------------------------- #
# Wall-clock budget for the whole install_or_rotate() call.
_OVERALL_BUDGET_SECONDS = 300 # 5 minutes
# Per-certutil-call timeout.
_PER_CALL_TIMEOUT_SECONDS = 60
# Retry policy for transient certutil failures.
_RETRY_BACKOFF_SECONDS = 5
# Sentinel lock wait.
_LOCK_WAIT_SECONDS = 60
_LOCK_POLL_INTERVAL_SECONDS = 0.5
_LOCK_STALE_AFTER_SECONDS = 300 # 5 minutes
# FIPS-disallowed signature-algorithm OIDs.
_FIPS_DISALLOWED_SIG_OIDS = {
'1.2.840.113549.1.1.2': 'md2WithRSAEncryption',
'1.2.840.113549.1.1.4': 'md5WithRSAEncryption',
'1.2.840.113549.1.1.5': 'sha1WithRSAEncryption',
'1.2.840.10040.4.3': 'sha1WithDSA',
'1.2.840.10045.4.1': 'sha1WithECDSA',
}
# Windows error codes treated specially.
_HEX_CRYPT_E_EXISTS = '0x80092005'
# "Cert genuinely absent from the store." certutil surfaces this two ways
# depending on which layer answers the -store query, and BOTH must be treated
# as a definitive 'absent' (not a transient 'unknown'), or the #9 ownership
# probe mis-tags a cert cfn is about to install as ownedByCfn=false and then
# refuses to remove its OWN cert on rotation/uninstall:
# * 0x80092004 CRYPT_E_NOT_FOUND -- documented "object/property not found".
# * 0x80090011 NTE_NOT_FOUND -- what `certutil -f -store <s> <sha1>` ACTUALLY
# returns for an absent thumbprint on Server 2016/2019/2022 (observed on the
# air-gap run). Missing this code was the CASE 4/8/10 regression.
_HEX_CRYPT_E_NOT_FOUND = '0x80092004'
_HEX_NTE_NOT_FOUND = '0x80090011'
_HEX_NOT_FOUND_CODES = (_HEX_CRYPT_E_NOT_FOUND, _HEX_NTE_NOT_FOUND)
_HEX_ACCESS_DENIED = '0x80070005'
_HEX_FIPS_ERRORS = ('0x80090029', '0x80090030', '0x80092002', '0x80096010')
_FIPS_TEXT_RE = re.compile(r'FIPS|policy|disallowed algorithm', re.IGNORECASE)
# Cached certutil.exe path (resolved once per process).
_certutil_path_cache: Optional[str] = None
# --------------------------------------------------------------------------- #
# Subprocess helper
# --------------------------------------------------------------------------- #
def _run_subprocess_capturing(
cmd: List[str],
timeout: int = _PER_CALL_TIMEOUT_SECONDS,
) -> Tuple[int, str, str]:
"""Run a subprocess with list-form args and capture stdout/stderr as text.
Never passes ``shell=True``. Decodes stdout/stderr as UTF-8 with
``errors='replace'`` to tolerate non-UTF-8 chars in localized certutil
output.
Args:
cmd: Argv list (binary path + args). Filenames may contain spaces;
list-form handles that natively.
timeout: Per-call timeout in seconds.
Returns:
Tuple of (returncode, stdout_text, stderr_text).
Raises:
subprocess.TimeoutExpired: when the process exceeds ``timeout``. The
caller is responsible for translating to CaOverrideError.
"""
proc = subprocess.run(
cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
stdout = proc.stdout.decode('utf-8', errors='replace') if proc.stdout else ''
stderr = proc.stderr.decode('utf-8', errors='replace') if proc.stderr else ''
return proc.returncode, stdout, stderr
# --------------------------------------------------------------------------- #
# certutil discovery
# --------------------------------------------------------------------------- #
def _resolve_certutil_path() -> str:
"""Return the absolute path to ``certutil.exe`` (cached per process).
Probes canonical absolute paths first (works during early SYSTEM boot
when ``PATH`` may not yet include the System32 augmentations from
EC2Launch), then falls back to ``shutil.which``. The
``CFN_CA_OVERRIDE_FAKE_CERTUTIL_PATH`` env var, when set, overrides
everything (used by unit tests + integration_win negative tests).
Raises:
CaOverrideError: with ``kind='certutil_not_found'`` when no
certutil binary can be located.
"""
global _certutil_path_cache
if _certutil_path_cache is not None:
return _certutil_path_cache
override = os.environ.get('CFN_CA_OVERRIDE_FAKE_CERTUTIL_PATH')
if override:
_certutil_path_cache = override
return override
# Canonical paths first.
for candidate in (
r'C:\Windows\System32\certutil.exe',
r'C:\Windows\SysWOW64\certutil.exe',
):
if os.path.isfile(candidate):
_certutil_path_cache = candidate
return candidate
found = shutil.which('certutil.exe') or shutil.which('certutil')
if not found:
raise CaOverrideError(
kind='certutil_not_found',
message=(
'%s certutil.exe not found at C:\\Windows\\System32\\certutil.exe '
'or on PATH. Cannot install override CA(s).' % _LOG_PREFIX
),
)
_certutil_path_cache = found
return found
# --------------------------------------------------------------------------- #
# Marker JSON read/write
# --------------------------------------------------------------------------- #
def read_marker(marker_path: str) -> Optional[dict]:
"""Read and parse the install-marker JSON, or return ``None``.
Returns ``None`` if the file is absent OR the contents fail to parse OR
the schema is invalid (missing ``version``, unsupported version, missing
``entries`` list of well-formed dicts). The corruption-equals-absent rule
makes the self-healing recovery path the only recovery story; callers
re-install from scratch.
Never raises. Diagnostic-only INFO logs on corrupt content.
"""
try:
# Read as bytes + decode with errors='replace' so a non-UTF-8 byte
# (real disk corruption, a foreign writer) can NEVER raise a
# UnicodeDecodeError out of this function. UnicodeDecodeError is a
# ValueError (NOT an OSError), so a strict text-mode open would escape
# the `except OSError` here AND fire before the json.loads guard below,
# breaking the "never raises / corruption == absent" contract. Any
# garbage now simply flows into the json.loads failure path -> reinstall.
with open(marker_path, 'rb') as fh:
raw = fh.read().decode('utf-8', 'replace')
except FileNotFoundError:
return None
except OSError as exc:
log.info(
'%s marker at %s unreadable (%s); treating as absent (will reinstall)',
_LOG_PREFIX, marker_path, exc,
)
return None
try:
marker = json.loads(raw)
except (ValueError, TypeError) as exc:
log.info(
'%s marker at %s is not valid JSON (%s); treating as absent',
_LOG_PREFIX, marker_path, exc,
)
return None
if not isinstance(marker, dict):
return None
version = marker.get('version')
if version not in (1, 2):
# Forward-compat: any version > 2 is treated as absent and re-installed.
log.info(
'%s marker at %s has unsupported version=%r; treating as absent',
_LOG_PREFIX, marker_path, version,
)
return None
if not isinstance(marker.get('pemSha256'), str):
return None
entries = marker.get('entries')
if not isinstance(entries, list):
return None
for entry in entries:
if not isinstance(entry, dict):
return None
if entry.get('store') not in ('Root', 'CA'):
return None
if not isinstance(entry.get('thumbprintSha1'), str):
return None
# thumbprintSha256 is optional on v1 markers.
if version >= 2 and not isinstance(entry.get('thumbprintSha256'), str):
return None
# ownedByCfn is optional (added in a later 2.0-40 revision to record
# whether cfn actually installed the cert vs. found it already present).
# When present it must be a bool; when absent, consumers default to True
# (treat as cfn-owned) for backward compatibility with markers written
# before the field existed.
owned = entry.get('ownedByCfn')
if owned is not None and not isinstance(owned, bool):
return None
return marker
def write_marker(marker_path: str, marker: dict) -> None:
"""Write the install-marker JSON via plain ``open().write().close()``.
The self-healing corruption rule (see :func:`read_marker`) covers torn-
write scenarios, so no tempfile + ``os.replace`` dance is needed.
Raises:
CaOverrideError: with ``kind='marker_write'`` on any OSError.
"""
parent = os.path.dirname(marker_path)
if parent:
try:
os.makedirs(parent, exist_ok=True)
except OSError as exc:
raise CaOverrideError(
kind='marker_write',
message=(
'%s cannot create marker directory %s: %s. Verify the '
'cfn state directory is writable by SYSTEM.'
% (_LOG_PREFIX, parent, exc)
),
)
try:
with open(marker_path, 'w', encoding='utf-8') as fh:
fh.write(json.dumps(marker, sort_keys=True, indent=2))
except OSError as exc:
partial = marker_path + '.partial'
raise CaOverrideError(
kind='marker_write',
message=(
'%s failed to write install marker to %s: %s. Partial-marker '
'recovery file at %s records adds completed so far; run '
'cfn-ca-uninstall to roll back, then retry.'
% (_LOG_PREFIX, marker_path, exc, partial)
),
)
def _partial_marker_path(marker_path: str) -> str:
"""Return the partial-marker recovery filename for ``marker_path``."""
return marker_path + '.partial'
# --------------------------------------------------------------------------- #
# PEM parsing
# --------------------------------------------------------------------------- #
_PEM_BLOCK_RE = re.compile(
rb'-----BEGIN CERTIFICATE-----\s*?\n(.+?)\n-----END CERTIFICATE-----',
re.DOTALL,
)
def _read_pem_bytes(pem_path: str) -> bytes:
"""Read the override PEM, raising CaOverrideError on read failure."""
try:
with open(pem_path, 'rb') as fh:
return fh.read()
except OSError as exc:
raise CaOverrideError(
kind='pem_not_found',
message=(
'%s cannot read override PEM at %s: %s; CA install aborted. '
'Verify the file exists and is readable by SYSTEM.'
% (_LOG_PREFIX, pem_path, exc)
),
)
def _split_pem_into_blocks(pem_bytes: bytes, pem_path: str) -> List[bytes]:
"""Split a PEM bundle into individual ``BEGIN..END CERTIFICATE`` blocks.
Applies the PEM hygiene rules:
* strip a leading UTF-8 BOM (EF BB BF) at file head
* tolerate CRLF or LF line endings (the regex is permissive)
* strip ``# ...`` comment lines (common in GovCloud bundle format)
* tolerate trailing garbage after the last END CERTIFICATE
* tolerate non-64-column-wrapped b64 bodies
Returns the list of single-cert PEM byte strings (each contains its own
``-----BEGIN CERTIFICATE-----`` and ``-----END CERTIFICATE-----``).
Raises:
CaOverrideError: with ``kind='pem_parse'`` if no certificate blocks
are found.
"""
# Strip UTF-8 BOM.
if pem_bytes.startswith(b'\xef\xbb\xbf'):
pem_bytes = pem_bytes[3:]
# Strip '# ...' comment lines (line by line, handling both \n and \r\n).
cleaned_lines = []
for line in pem_bytes.splitlines():
stripped = line.lstrip()
if stripped.startswith(b'#'):
continue
cleaned_lines.append(line)
pem_bytes = b'\n'.join(cleaned_lines)
blocks: List[bytes] = []
for match in _PEM_BLOCK_RE.finditer(pem_bytes):
# Reconstruct the FULL block (BEGIN..END) for certutil to consume.
body = match.group(1)
single_cert_pem = (
b'-----BEGIN CERTIFICATE-----\n'
+ body
+ b'\n-----END CERTIFICATE-----\n'
)
blocks.append(single_cert_pem)
if not blocks:
raise CaOverrideError(
kind='pem_parse',
message=(
'%s override PEM at %s contains no valid certificate blocks '
'(found 0 BEGIN markers). File may be corrupt or not a PEM.'
% (_LOG_PREFIX, pem_path)
),
)
return blocks
def _pem_block_to_der(pem_block: bytes) -> bytes:
"""Strip BEGIN/END markers from a single-cert PEM and base64-decode the body.
Used only to compute thumbprints and to walk the signatureAlgorithm OID
for FIPS pre-flight. The bytes handed to certutil are always the original
PEM-text (single-cert PEM file), never DER.
"""
import base64
import binascii
inner = pem_block.replace(b'-----BEGIN CERTIFICATE-----', b'')
inner = inner.replace(b'-----END CERTIFICATE-----', b'')
inner_text = b''.join(inner.split()) # remove whitespace
try:
return base64.b64decode(inner_text)
except (binascii.Error, ValueError) as exc:
# A block with intact BEGIN/END markers but a mangled base64 body (a
# dropped char, a mid-body truncation) reaches here. Without this, the
# raw binascii.Error escapes the CaOverrideError taxonomy: the 7 bin
# tools swallow it (warn+continue -> silent system-store-only trust) and
# cfn-hup's startup crashes. Route it into kind='pem_parse' -> BUCKET 2
# (loud/fatal), exactly like a block with no markers at all.
raise CaOverrideError(
kind='pem_parse',
message=(
'%s override CA PEM has a certificate block with an invalid '
'base64 body (%s); cannot decode.' % (_LOG_PREFIX, exc)
),
) from exc
# --------------------------------------------------------------------------- #
# FIPS pre-flight (signature-algorithm OID walker)
# --------------------------------------------------------------------------- #
def _is_fips_enabled() -> bool:
"""Return True when the host has FIPS mode enabled.
Reads ``HKLM\\System\\CurrentControlSet\\Control\\Lsa\\FIPSAlgorithmPolicy``
via ``winreg``. Returns False on non-Windows hosts (defensive; this
module is only called via the Windows dispatch path).
"""
if os.name != 'nt':
return False
try:
import winreg # type: ignore[import-not-found]
except ImportError:
return False
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r'System\CurrentControlSet\Control\Lsa\FIPSAlgorithmPolicy',
) as key:
value, _ = winreg.QueryValueEx(key, 'Enabled')
return int(value) == 1
except OSError:
return False
def _extract_signature_algorithm_oid(der: bytes) -> Optional[str]:
"""Extract the signatureAlgorithm OID from a DER-encoded X.509 certificate.
Only descends one SEQUENCE and reads one OID at a known positional index.
Far smaller and less bug-prone than a full DER
walker. Returns ``None`` when parsing fails (caller logs and continues
without blocking on parse error -- the FIPS pre-flight is a best-effort
refusal, not a correctness gate; the certutil call will still reject the
cert on FIPS-enforced hosts).
Args:
der: DER bytes of the X.509 Certificate.
Returns:
Dotted-decimal OID string (e.g. ``"1.2.840.113549.1.1.11"``) or
``None`` on parse error.
"""
try:
# Outer SEQUENCE.
if not der or der[0] != 0x30:
return None
idx = 1
outer_len, idx = _read_der_length(der, idx)
# First inner element: TBSCertificate (SEQUENCE).
if der[idx] != 0x30:
return None
tbs_start = idx
idx += 1
tbs_len, idx = _read_der_length(der, idx)
# Skip the TBSCertificate body.
tbs_end = idx + tbs_len
# signatureAlgorithm SEQUENCE starts at tbs_end (sibling of TBS).
if tbs_end >= len(der) or der[tbs_end] != 0x30:
return None
sig_idx = tbs_end + 1
_sig_len, sig_idx = _read_der_length(der, sig_idx)
# First element of AlgorithmIdentifier is the OID (tag 0x06).
if der[sig_idx] != 0x06:
return None
sig_idx += 1
oid_len, sig_idx = _read_der_length(der, sig_idx)
oid_bytes = der[sig_idx:sig_idx + oid_len]
return _decode_der_oid(oid_bytes)
except (IndexError, ValueError):
return None
def _read_der_length(buf: bytes, idx: int) -> Tuple[int, int]:
"""Read a DER length prefix starting at ``buf[idx]``.
Returns (length_value, new_idx). Supports short and long forms. Raises
ValueError on malformed input.
"""
first = buf[idx]
idx += 1
if first < 0x80:
return first, idx
n_octets = first & 0x7f
if n_octets == 0 or n_octets > 4:
raise ValueError('unsupported DER length form: %d octets' % n_octets)
value = 0
for _ in range(n_octets):
value = (value << 8) | buf[idx]
idx += 1
return value, idx
def _decode_der_oid(oid_bytes: bytes) -> str:
"""Decode a DER-encoded OID (the value-octets only) to dotted-decimal."""
if not oid_bytes:
raise ValueError('empty OID')
first = oid_bytes[0]
arc1 = first // 40
arc2 = first - (arc1 * 40)
arcs = [str(arc1), str(arc2)]
acc = 0
for byte in oid_bytes[1:]:
acc = (acc << 7) | (byte & 0x7f)
if byte & 0x80 == 0:
arcs.append(str(acc))
acc = 0
return '.'.join(arcs)
def _fips_preflight_block_cert(der: bytes, pem_index: int) -> None:
"""Refuse FIPS-disallowed signature-algorithm certs under FIPS mode.
Raises:
CaOverrideError: with ``kind='fips'`` when the cert's signature OID
is in :data:`_FIPS_DISALLOWED_SIG_OIDS`.
"""
oid = _extract_signature_algorithm_oid(der)
if oid is None:
log.warning(
'%s could not parse signatureAlgorithm OID for cert at position %d; '
'skipping FIPS pre-flight for this cert (certutil will still '
'enforce FIPS policy).', _LOG_PREFIX, pem_index,
)
return
if oid in _FIPS_DISALLOWED_SIG_OIDS:
algo = _FIPS_DISALLOWED_SIG_OIDS[oid]
raise CaOverrideError(
kind='fips',
message=(
'%s cert at position %d signed with %s (OID %s); refusing '
'under FIPS. Verify the override PEM contains only '
'FIPS-allowed signature algorithms (no MD2, no MD5, no '
'SHA-1; RSA>=2048).' % (_LOG_PREFIX, pem_index, algo, oid)
),
)
# --------------------------------------------------------------------------- #
# Cert classification (certutil -dump)
# --------------------------------------------------------------------------- #
# The Subject: / Issuer: labels themselves are locale-stable. The DN value
# may appear EITHER on the same line as the label (some certutil builds) OR,
# as observed on real Windows hosts, spread across subsequent indented lines
# with one RDN (e.g. ``OU=...``, ``O=...``, ``CN=...``) per line. ``_LABEL_RE``
# matches the label line and captures any same-line remainder; ``_RDN_LINE_RE``
# matches a single indented RDN continuation line. See ``_parse_dn_block``.
_SUBJECT_LABEL_RE = re.compile(r'^Subject:[ \t]*(.*)$', re.MULTILINE)
_ISSUER_LABEL_RE = re.compile(r'^Issuer:[ \t]*(.*)$', re.MULTILINE)
# An RDN continuation line: leading whitespace then KEY=VALUE where KEY is an
# ASCII attribute type (CN, O, OU, C, L, ST, E, DC, SERIALNUMBER, OID dotted,
# etc.). The value runs to end of line.
_RDN_LINE_RE = re.compile(r'^[ \t]+([A-Za-z0-9.]+=.*)$')
_CN_RE = re.compile(r'CN\s*=\s*([^,]+)', re.IGNORECASE)
def _parse_dn_block(stdout: str, label_re: 're.Pattern') -> Optional[str]:
"""Parse a certutil ``-dump`` Distinguished Name into a canonical string.
certutil prints a DN in one of two shapes:
* Same-line (some builds)::
Subject: OU=cfn, O=Amazon, CN=Example
* Multi-line indented (observed on real Windows air-gap hosts), one RDN per
indented line, terminated by the next non-RDN line such as
``Name Hash(sha1): ...``, ``NotBefore: ...``, a blank line, or the next
top-level ``Subject:`` / ``Issuer:`` label::
Subject:
OU=cfn-bootstrap-test
O=Amazon-Test
CN=CFN-Test-Override-Intermediate
Both forms are handled. The collected RDNs are normalized identically
(whitespace-trimmed, joined by ``', '`` in the order certutil printed them)
so that two DNs that are semantically equal compare equal as strings. The
SAME normalization is applied to subject and issuer by the caller, which is
what makes the self-signed equality check correct.
Returns the canonical DN string, or ``None`` if the label is absent.
"""
m = label_re.search(stdout)
if not m:
return None
rdns = []
# Same-line remainder (e.g. "Subject: CN=Foo"); empty when the DN is on the
# following indented lines.
same_line = m.group(1).strip()
if same_line:
# The same-line form is comma-separated; split so it normalizes to the
# exact same shape as the multi-line form.
rdns.extend(part.strip() for part in same_line.split(',') if part.strip())
else:
# Multi-line form: walk subsequent lines, accumulating indented RDN
# continuation lines until the first line that is not one. The text
# immediately after the label match starts with the label line's own
# newline, so leading blank lines BEFORE the first RDN are part of the
# label line and are skipped; a blank line AFTER we have started
# collecting RDNs terminates the block.
rest = stdout[m.end():]
for line in rest.splitlines():
if not line.strip():
if rdns:
# Blank line after the DN block terminates it.
break
# Leading blank (the label line's newline); keep scanning.
continue
rdn_m = _RDN_LINE_RE.match(line)
if rdn_m is None:
# First non-indented / non-RDN line (e.g. " Name Hash(sha1):",
# " NotBefore:", "Public Key", next "Subject:" label) ends it.
break
rdns.append(rdn_m.group(1).strip())
return ', '.join(rdns)
def classify_cert(
cer_path: str,
certutil_path: str,
thumbprint_sha1: Optional[str] = None,
) -> Tuple[str, str, bool]:
"""Classify a single-cert PEM into a target Windows store.
Shells to ``certutil -f -dump <cer_path>`` and parses the labeled output
for ``Subject:`` and ``Issuer:`` distinguished names. The labels are stable
across locales; only the VALUES are user data. Routing is by self-signed
identity ONLY (subject == issuer -> Root, else CA); the BasicConstraints
'Subject Type' text is intentionally NOT parsed because certutil localizes
it on non-English Windows (FIX #7).
Args:
cer_path: Path to a single-cert PEM.
certutil_path: Path to certutil.exe.
thumbprint_sha1: Optional uppercase 40-hex SHA1 thumbprint of this
cert, used ONLY to name the cert in classify-failure messages.
Defaults to None so 2-arg callers/tests stay green (FIX #8).
Returns:
Tuple of ``(store_name, subject_cn, is_ca)`` where ``store_name`` is
``"Root"`` (self-signed) or ``"CA"`` (non-self-signed). ``is_ca`` is
vestigial (always True) and ignored by the caller.
Raises:
CaOverrideError: with ``kind='classify'`` only on certutil failure or
unparseable Subject:/Issuer: DNs.
"""
# Route through _run_with_retry like every OTHER certutil call: this dump
# runs on a freshly-written temp file, the single most EDR/AV-lock-prone
# moment, yet was the one call bypassing the retry+timeout-translation
# wrapper. A transient lock would otherwise raise kind='classify' (fatal ->
# cfn-init exit 1) with no retry, or a TimeoutExpired would escape the
# CaOverrideError taxonomy entirely and get silently swallowed upstream.
rc, stdout, stderr = _run_with_retry(
[certutil_path, '-f', '-dump', cer_path],
)
# Name the cert in any surviving classify-failure message (FIX #8) so an
# unparseable Subject/Issuer or a certutil-dump failure is diagnosable.
tp_hint = (' sha1=%s..' % thumbprint_sha1[:12]) if thumbprint_sha1 else ''
if rc != 0:
raise CaOverrideError(
kind='classify',
message=(
'%s certutil -dump failed for %s%s (exit=%d, stderr=%s); '
'cannot classify cert. Refusing to install with ambiguous trust.'
% (_LOG_PREFIX, cer_path, tp_hint, rc, stderr[:200])
),
)
# Parse the FULL Distinguished Name after each label. certutil prints the
# DN as multiple indented RDN lines (one per RDN) on real Windows hosts; a
# single-line regex would capture only the first RDN (commonly an OU shared
# by root and intermediate), making subject==issuer falsely True and
# mis-routing intermediates to the Root store. _parse_dn_block handles both
# the multi-line and the legacy same-line forms with identical
# normalization so equal DNs compare equal.
subject = _parse_dn_block(stdout, _SUBJECT_LABEL_RE)
issuer = _parse_dn_block(stdout, _ISSUER_LABEL_RE)
if subject is None or issuer is None or not subject or not issuer:
raise CaOverrideError(
kind='classify',
message=(
'%s cannot classify cert at %s%s: certutil -dump output did '
'not contain parseable Subject: / Issuer: distinguished '
'names. Refusing to install with ambiguous trust.'
% (_LOG_PREFIX, cer_path, tp_hint)
),
)
self_signed = (subject == issuer)
cn_match = _CN_RE.search(subject)
subject_cn = cn_match.group(1).strip() if cn_match else subject
# Route PURELY by the locale-safe self-signed identity check
# (subject == issuer). We deliberately do NOT parse the BasicConstraints
# 'Subject Type' / 'CA=' text: on non-English Windows AMIs certutil
# translates that text, so a text regex would misclassify a real CA as an
# end-entity and abort every HTTPS request (FIX #7). 2.39 handed the raw
# DER straight to OpenSSL, which is locale-proof; trusting only
# subject/issuer identity preserves that behavior. is_ca is vestigial --
# the sole caller (_install_fresh) ignores it -- and is kept True only for
# return-signature compatibility.
if self_signed:
store = 'Root'
else:
store = 'CA'
is_ca = True
return store, subject_cn, is_ca
# --------------------------------------------------------------------------- #
# certutil add/del/verify with locale-tolerant failure detection
# --------------------------------------------------------------------------- #
def _classify_certutil_failure(rc: int, stdout: str, stderr: str) -> str:
"""Return a CaOverrideError ``kind`` string for a non-zero certutil call.
Locale-tolerant: matches on hex error codes first, falls back to regex
on stdout/stderr for FIPS / access-denied indications. The post-condition
probe in ``certutil_addstore`` is the authoritative source-of-truth for
success.
"""
combined = (stdout or '') + '\n' + (stderr or '')
upper = combined.upper()
# certutil surfaces its HRESULT two ways: (1) printed in stdout/stderr text
# (e.g. "CertUtil: -store command FAILED: 0x80092004"), and (2) as the
# process EXIT CODE. `certutil -store <thumbprint>` for an absent cert often
# prints little/nothing and only sets the exit code, so a text-only match
# misses it -- which was the CASE 4/8/10 regression (probe -> 'unknown' ->
# ownedByCfn=false -> cfn refused to delete its own cert). Match on BOTH the
# text AND the numeric rc (rendered as its unsigned-32-bit hex). The numeric
# code is locale-proof, so it is the more reliable of the two.
rc_hex = '0x%08x' % (rc & 0xFFFFFFFF)
def _code_seen(code: str) -> bool:
return code.upper() in upper or code.upper() == rc_hex.upper()
if _code_seen(_HEX_CRYPT_E_EXISTS):
# Caller treats this as success and confirms via the presence probe.
return 'cert_exists'
if any(_code_seen(code) for code in _HEX_NOT_FOUND_CODES):
# Cert genuinely absent from the store (probe path relies on this to
# distinguish a real 'absent' from a transient 'unknown'). Matches BOTH
# CRYPT_E_NOT_FOUND (0x80092004) and NTE_NOT_FOUND (0x80090011) -- real
# certutil -store returns the latter for an absent thumbprint.
return 'cert_not_found'
if _code_seen(_HEX_ACCESS_DENIED) or 'ACCESS IS DENIED' in upper:
return 'store_not_writable'
for code in _HEX_FIPS_ERRORS:
if _code_seen(code):
return 'fips'
if _FIPS_TEXT_RE.search(combined):
return 'fips'
return 'certutil_call'
def _verify_store(
store: str,
thumbprint: str,
certutil_path: str,
) -> bool:
"""Run ``certutil -f -store <store> <thumbprint>``. True if present.
This is a PRESENCE check only: ``-store`` returns 0 iff the cert is in
the named store, with NO chain building or revocation checking. That is
the correct semantic for confirming an ``-addstore`` succeeded (design
section 7.3). ``-verifystore`` is deliberately NOT used here: it performs
full chain + revocation validation, which returns non-zero for a private
self-signed override root on an air-gapped host (no CRL/OCSP reachability,
no chain to a Microsoft-trusted anchor) even when the cert is physically
present and working -- a false negative that suppressed the install
marker on ADC/air-gapped hosts. The check is locale-independent (decided
purely by exit code).
"""
rc, _stdout, _stderr = _run_subprocess_capturing(
[certutil_path, '-f', '-store', store, thumbprint],
)
return rc == 0
def _probe_store_presence(
store: str,
thumbprint: str,
certutil_path: str,
) -> str:
"""Presence-probe a store entry WITH RETRY, returning a tri-state.
Unlike :func:`_verify_store` (single-shot bool used for post-addstore
confirmation), this drives the probe through :func:`_run_with_retry` so a
transient certutil hiccup is not mistaken for genuine absence -- which
would mis-tag a pre-existing (foreign) cert as cfn-owned and let a later
rotation / uninstall delete it. Returns:
* 'present' -- rc == 0 (cert is in the store).
* 'absent' -- classified cert_not_found (CRYPT_E_NOT_FOUND): the cert
is genuinely not in the store, so cfn is about to install it.
* 'unknown' -- any other non-zero exit, timeout, or exception. Caller
MUST treat this as 'cannot prove we installed it' (fail-safe).
"""
argv = [certutil_path, '-f', '-store', store, thumbprint]
try:
rc, stdout, stderr = _run_with_retry(argv)
except Exception as exc: # noqa: BLE001 - probe is best-effort metadata
# Any error (CaOverrideError timeout, certutil missing, OSError, ...)
# is indeterminate: we cannot prove the cert's pre-install state, so
# the caller must fall back to the not-owned fail-safe.
log.warning(
'%s pre-install presence probe errored for store=%s '
'thumbprint=%s.. (%s); treating as indeterminate (unknown)',
_LOG_PREFIX, store, thumbprint[:12], exc,
)
return 'unknown'
if rc == 0:
return 'present'
if _classify_certutil_failure(rc, stdout, stderr) == 'cert_not_found':
return 'absent'
log.warning(
'%s pre-install presence probe returned indeterminate exit=%d for '
'store=%s thumbprint=%s..; treating as unknown',
_LOG_PREFIX, rc, store, thumbprint[:12],
)
return 'unknown'
def certutil_addstore(
store: str,
cer_path: str,
thumbprint_sha1: str,
certutil_path: str,
) -> None:
"""Add a single cert to a Windows store via certutil.
The ``-f`` flag is REQUIRED on every certutil invocation -- without it,
``certutil -addstore Root <cer>`` opens a Win32 MessageBox confirmation
when the cert is not already in the Microsoft AuthRoot CTL, and hangs
when running as SYSTEM with no desktop attached.
After the call, regardless of stdout content, runs a post-condition
``-store`` presence probe -- the only reliable cross-locale success
signal, and the catch for EDR rollback where certutil reports success
but EDR silently quarantines the registry write a moment later. The probe
is presence-only (``-store``, not ``-verifystore``): a private self-signed
override root on an air-gapped host does not chain to a trusted anchor and
has no reachable CRL/OCSP, so ``-verifystore`` would false-negative there.
Args:
store: ``"Root"`` or ``"CA"``.
cer_path: Path to the single-cert PEM file on disk.
thumbprint_sha1: Uppercase 40-hex SHA1 thumbprint used as the
identifier for the post-condition verify probe.
certutil_path: Resolved absolute path to certutil.exe.
Raises:
CaOverrideError: with ``kind='certutil_call'``,
``kind='store_not_writable'``, or ``kind='fips'``.
"""
argv = [certutil_path, '-f', '-addstore', store, cer_path]
rc, stdout, stderr = _run_with_retry(argv)
if rc == 0:
# Confirm via post-condition probe.
if _verify_store(store, thumbprint_sha1, certutil_path):
log.info(
'%s addstore OK: store=%s cert=%s (thumbprint=%s...)',
_LOG_PREFIX, store, cer_path, thumbprint_sha1[:12],
)
return
raise CaOverrideError(
kind='certutil_call',
message=(
'%s certutil reported success on -addstore %s but the '
'post-condition -store presence probe shows the cert is not '
'present (thumbprint=%s). Possible EDR rollback (CrowdStrike '
'Falcon, Defender for Endpoint, Carbon Black, SentinelOne). '
'Check the EDR detection log for certutil activity.'
% (_LOG_PREFIX, store, thumbprint_sha1)
),
)
failure_kind = _classify_certutil_failure(rc, stdout, stderr)
if failure_kind == 'cert_exists':
# CRYPT_E_EXISTS is treated as success (cert already present is
# desirable). Confirm via the post-condition probe.
if _verify_store(store, thumbprint_sha1, certutil_path):
log.info(
'%s addstore: cert already present (CRYPT_E_EXISTS) in store=%s '
'thumbprint=%s...', _LOG_PREFIX, store, thumbprint_sha1[:12],
)
return
# Cert claimed to exist but the presence probe disagrees; treat as failure.
raise CaOverrideError(
kind='certutil_call',
message=(
'%s certutil returned CRYPT_E_EXISTS for -addstore %s but the '
'post-condition probe shows the cert is not present '
'(thumbprint=%s). Inconsistent store state.'
% (_LOG_PREFIX, store, thumbprint_sha1)
),
)
if failure_kind == 'store_not_writable':
raise CaOverrideError(
kind='store_not_writable',
message=(
"%s write to store '%s' was denied. Cause is one of: "
'(a) cfn bootstrap is not running with Administrator privileges '
'(EC2Launch SYSTEM should have them); '
'(b) endpoint security software (CrowdStrike Falcon, Defender for '
'Endpoint, Carbon Black, SentinelOne) is blocking writes to the '
'Root store -- check the EDR detection log for cfn-init / '
'certutil activity; '
'(c) STIG-hardened AMI baseline has removed SYSTEM from the '
"LocalMachine\\Root ACL -- pre-install override CAs at AMI "
'build time and let cfn no-op via missing PEM.'
% (_LOG_PREFIX, store)
),
)
if failure_kind == 'fips':
raise CaOverrideError(
kind='fips',
message=(
'%s cert refused under FIPS policy (exit=%d, stdout=%s, '
'stderr=%s). Verify the override PEM contains only FIPS-'
'allowed signature algorithms (no MD2, no MD5, no SHA-1; '
'RSA>=2048).'
% (_LOG_PREFIX, rc, stdout[:200], stderr[:200])
),
)
raise CaOverrideError(
kind='certutil_call',
message=(
'%s certutil command failed: cmd=%s, exit=%d, stdout=%s, stderr=%s'
% (_LOG_PREFIX, argv, rc, stdout[:400], stderr[:400])
),
)
def certutil_delstore(
store: str,
thumbprint: str,
certutil_path: str,
) -> None:
"""Remove a cert from a Windows store via certutil.
"Cert not in store" failures are tolerated by the caller in the
rotation/uninstall path -- this function just propagates the certutil
exit code in a structured way. Use SHA256 as the identifier on Server
2019+ (FIPS-safe); SHA1 fallback for Server 2016.
Args:
store: ``"Root"`` or ``"CA"``.
thumbprint: Either SHA256 (preferred) or SHA1 (legacy fallback)
uppercase hex.
certutil_path: Resolved absolute path to certutil.exe.
Raises:
CaOverrideError: with ``kind='cert_not_found'`` when the cert is
not in the store (tolerated by the rotation/uninstall caller),
``kind='certutil_call'`` on any other hard failure.
"""
argv = [certutil_path, '-f', '-delstore', store, thumbprint]
rc, stdout, stderr = _run_with_retry(argv)
if rc == 0:
log.info(
'%s delstore OK: store=%s thumbprint=%s...',
_LOG_PREFIX, store, thumbprint[:12],
)
return
# Caller decides whether to log-and-tolerate or propagate; we always raise.
# Classify by error CODE so the caller's "cert already gone" tolerance
# works on any locale and when certutil prints nothing and only sets the
# exit code (0x80092004 CRYPT_E_NOT_FOUND / 0x80090011 NTE_NOT_FOUND --
# the same code-only failure shape the #9 ownership probe had to handle).
# A text-only match here would hard-fail a rotation/uninstall whenever the
# cert was already removed by GPO/EDR/admin cleanup.
failure_kind = _classify_certutil_failure(rc, stdout, stderr)
raise CaOverrideError(
kind=('cert_not_found' if failure_kind == 'cert_not_found'
else 'certutil_call'),
message=(
'%s certutil command failed: cmd=%s, exit=%d, stdout=%s, stderr=%s'
% (_LOG_PREFIX, argv, rc, stdout[:400], stderr[:400])
),
)
def _run_with_retry(argv: List[str]) -> Tuple[int, str, str]:
"""Run certutil with one 5-second-backoff retry on transient failures.
Handles EDR/AV scan latency spikes. ``store_not_writable`` is treated as
permanent and not retried.
"""
try:
rc, stdout, stderr = _run_subprocess_capturing(argv)
except subprocess.TimeoutExpired as exc:
raise CaOverrideError(
kind='certutil_call',
message=(
'%s certutil timed out after %ds: cmd=%s'
% (_LOG_PREFIX, _PER_CALL_TIMEOUT_SECONDS, argv)
),
) from exc
if rc == 0:
return rc, stdout, stderr
# Permanent kinds are not retried.
fk = _classify_certutil_failure(rc, stdout, stderr)
if fk in ('store_not_writable', 'fips', 'cert_exists', 'cert_not_found'):
return rc, stdout, stderr
log.warning(
'%s certutil transient failure (exit=%d); retrying after %ds: cmd=%s',
_LOG_PREFIX, rc, _RETRY_BACKOFF_SECONDS, argv,
)
time.sleep(_RETRY_BACKOFF_SECONDS)
try:
return _run_subprocess_capturing(argv)
except subprocess.TimeoutExpired as exc:
raise CaOverrideError(
kind='certutil_call',
message=(
'%s certutil timed out after retry: cmd=%s'
% (_LOG_PREFIX, argv)
),
) from exc
# --------------------------------------------------------------------------- #
# Sentinel install-lock
# --------------------------------------------------------------------------- #
def _state_dir_for(marker_path: str) -> str:
"""Return the state directory parent of ``marker_path``."""
parent = os.path.dirname(marker_path)
return parent or os.path.expandvars(r'${SystemDrive}\cfn\state')
def _ensure_state_dir(state_dir: str) -> None:
"""Create the state directory + harden ACLs (Windows only).
Raises:
CaOverrideError: with ``kind='state_fs'`` when the directory cannot
be created. Raw OSErrors must not escape: they would bypass the
bin tools' loud ``except CaOverrideError`` branch and be silently
swallowed by their trailing generic ``except Exception``, hiding
a genuine trust-install failure behind a later confusing TLS
error. ``state_fs`` is NOT benign -- an unwritable state dir on a
PEM-present host means the override CA cannot be installed.
"""
try:
os.makedirs(state_dir, exist_ok=True)
except OSError as exc:
raise CaOverrideError(
kind='state_fs',
message=(
'%s cannot create cfn state directory %s: %s. Verify the '
'directory is writable by SYSTEM.'
% (_LOG_PREFIX, state_dir, exc)
),
)
if os.name != 'nt':
return
# ACL hardening. icacls is required because the
# POSIX-mode argument to os.makedirs is a no-op on NTFS.
argv = [
'icacls', state_dir, '/inheritance:r',
'/grant:r', 'SYSTEM:F',
'/grant:r', r'BUILTIN\Administrators:F',
]
try:
rc, _stdout, _stderr = _run_subprocess_capturing(argv, timeout=30)
if rc != 0:
# Non-fatal: log and continue. The marker write will still succeed
# under SYSTEM; the ACL hardening is defence-in-depth, not a
# correctness gate for the install itself.
log.warning(
'%s icacls on state dir %s returned %d; continuing without '
'ACL hardening', _LOG_PREFIX, state_dir, rc,
)
except (subprocess.TimeoutExpired, OSError) as exc:
log.warning(
'%s icacls invocation failed (%s); continuing without ACL hardening',
_LOG_PREFIX, exc,
)
def _acquire_install_lock(state_dir: str) -> str:
"""Acquire the sentinel install-lock file, waiting up to 60s.
Returns the absolute path to the held lock file. Caller MUST call
:func:`_release_install_lock` in a ``finally`` block to release it.
Raises:
CaOverrideError: with ``kind='concurrent_install'`` on timeout;
with ``kind='state_fs'`` when the lock file cannot be created
for a non-contention reason (permissions, read-only volume).
"""
lock_path = os.path.join(state_dir, 'install.lock')
deadline = time.monotonic() + _LOCK_WAIT_SECONDS
pid = os.getpid()
started = datetime.now(timezone.utc).isoformat()
payload = '%d %s' % (pid, started)
while True:
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
# Check for staleness.
holder_pid, holder_started = _read_lock_holder(lock_path)
stale = _lock_is_stale(holder_pid, holder_started)
if stale:
log.info(
'%s stealing stale install.lock (pid=%s started=%s)',
_LOG_PREFIX, holder_pid, holder_started,
)
try:
os.unlink(lock_path)
except OSError:
pass
continue
if time.monotonic() >= deadline:
raise CaOverrideError(
kind='concurrent_install',
message=(
'%s another cfn process is currently installing '
'(pid=%s, started=%s); waited %ds. Retry after the '
'other process completes.'
% (_LOG_PREFIX, holder_pid, holder_started,
_LOCK_WAIT_SECONDS)
),
)
time.sleep(_LOCK_POLL_INTERVAL_SECONDS)
continue
except OSError as exc:
# Non-EXIST filesystem failure (permissions, read-only volume):
# wrap so the loud-fatal policy fires instead of the bin tools'
# silent generic except (see _ensure_state_dir).
raise CaOverrideError(
kind='state_fs',
message=(
'%s cannot create install lock %s: %s. Verify the cfn '
'state directory is writable by SYSTEM.'
% (_LOG_PREFIX, lock_path, exc)
),
)
else:
try:
os.write(fd, payload.encode('utf-8'))
finally:
os.close(fd)
return lock_path
def _read_lock_holder(lock_path: str) -> Tuple[Optional[int], Optional[str]]:
"""Read the PID + start-time from an existing install.lock file."""
try:
# Bytes + errors='replace' for the same reason as read_marker: a
# non-UTF-8 byte in the lock file must not raise UnicodeDecodeError past
# the `except OSError`. Garbage falls through to the int() failure below
# -> treated as no/holder-less lock (stealable), never a crash.
with open(lock_path, 'rb') as fh:
raw = fh.read().decode('utf-8', 'replace').strip()
except OSError:
return None, None
parts = raw.split(None, 1)
if not parts:
return None, None
try:
pid = int(parts[0])
except ValueError:
pid = None
started = parts[1] if len(parts) > 1 else None
return pid, started
def _lock_is_stale(pid: Optional[int], started_iso: Optional[str]) -> bool:
"""Decide whether to steal an existing install.lock."""
if pid is None:
return True
if not _pid_is_alive(pid):
return True
if started_iso:
try:
started = datetime.fromisoformat(started_iso)
except ValueError:
return False
age = (datetime.now(timezone.utc) - started).total_seconds()
if age > _LOCK_STALE_AFTER_SECONDS:
return True
return False
def _pid_is_alive(pid: int) -> bool:
"""Return True if a process with ``pid`` is currently running.
Portable: on POSIX uses ``os.kill(pid, 0)``; on Windows uses
``OpenProcess`` via ctypes-on-kernel32 (allowed here because it does
NOT touch crypt32/bcrypt -- the prior ctypes bug class). On import
failure / EPERM / any uncertainty, returns True (conservative; we'd
rather wait than steal a live lock).
"""
if os.name == 'nt':
try:
import ctypes
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
handle = kernel32.OpenProcess(
PROCESS_QUERY_LIMITED_INFORMATION, False, pid,
)
if not handle:
return False
try:
exit_code = ctypes.c_ulong()
if kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
STILL_ACTIVE = 259
return int(exit_code.value) == STILL_ACTIVE
return True
finally:
kernel32.CloseHandle(handle)
except Exception:
return True
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return True
def _release_install_lock(lock_path: str) -> None:
"""Best-effort unlink of the sentinel install-lock file."""
try:
os.unlink(lock_path)
except OSError as exc:
log.warning(
'%s could not remove install.lock at %s: %s',
_LOG_PREFIX, lock_path, exc,
)
# --------------------------------------------------------------------------- #
# Top-level install + rotate
# --------------------------------------------------------------------------- #
def _sha1_thumbprint(der: bytes) -> str:
"""Compute uppercase SHA1 hex thumbprint of a DER cert.
Uses ``usedforsecurity=False`` because SHA1 here is the Windows-protocol
cert IDENTIFIER, NOT a cryptographic digest. This bypasses FIPS gating
on FIPS-enforced hosts (design invariant in section 16).
"""
h = hashlib.new('sha1', der, usedforsecurity=False)
return h.hexdigest().upper()
def _sha256_thumbprint(der: bytes) -> str:
"""Compute uppercase SHA256 hex thumbprint of a DER cert (FIPS-safe)."""
return hashlib.sha256(der).hexdigest().upper()
def _build_marker(pem_sha256: str, entries: List[dict]) -> dict:
"""Construct the v2 marker dict for persistence."""
return {
'version': 2,
'pemSha256': pem_sha256,
'entries': entries,
}
def install_or_rotate(pem_path: str, marker_path: str) -> None:
"""Top-level Windows install / rotate entry point.
Flow:
1. Read PEM bytes, compute sha256.
2. Read existing marker (if any). Fast-path: marker matches => no-op.
3. Slow path: acquire install.lock; if marker exists and pemSha256
differs, uninstall_all() the old entries first; then install_fresh()
the current PEM; write the fresh marker; release lock.
The override PEM is trusted by virtue of its presence on disk (placing a
file at the override path requires Administrator/SYSTEM rights, which is
already full control of the host). This mirrors the POSIX behaviour, where
the stdlib ssl module trusts the override PEM with no allowlist.
Args:
pem_path: Absolute path to the override PEM bundle.
marker_path: Absolute path to the install-marker JSON.
Raises:
CaOverrideError: on any hard failure.
"""
overall_deadline = time.monotonic() + _OVERALL_BUDGET_SECONDS
def _check_budget() -> None:
if time.monotonic() > overall_deadline:
raise CaOverrideError(
kind='budget_exceeded',
message=(
'%s install exceeded %d-minute wall-clock budget. EDR or '
'disk latency may be excessive; check certutil-call '
'latencies in the log.'
% (_LOG_PREFIX, _OVERALL_BUDGET_SECONDS // 60)
),
)
log.info('%s install_or_rotate starting: pem=%s', _LOG_PREFIX, pem_path)
pem_bytes = _read_pem_bytes(pem_path)
current_sha256 = hashlib.sha256(pem_bytes).hexdigest()
log.info(
'%s PEM sha256=%s (path=%s)', _LOG_PREFIX, current_sha256, pem_path,
)
# Fast-path: marker matches => O(1) no-op.
marker = read_marker(marker_path)
if marker is not None and marker.get('pemSha256') == current_sha256:
log.info(
'%s marker matches current PEM (sha256=%s..); no action',
_LOG_PREFIX, current_sha256[:12],
)
return
# Slow path: resolve certutil + state dir, acquire lock.
certutil_path = _resolve_certutil_path()
log.info('%s resolved certutil at %s', _LOG_PREFIX, certutil_path)
fips_active = _is_fips_enabled()
if fips_active:
log.info(
'%s FIPS mode active; per-cert signature-algorithm pre-flight enabled',
_LOG_PREFIX,
)
state_dir = _state_dir_for(marker_path)
_ensure_state_dir(state_dir)
lock_path = _acquire_install_lock(state_dir)
try:
# Re-read the marker after taking the lock (another process may have
# finished installing while we were waiting).
marker = read_marker(marker_path)
if marker is not None and marker.get('pemSha256') == current_sha256:
log.info(
'%s marker matched after lock acquire; no action', _LOG_PREFIX,
)
return
# Rotation: uninstall old entries first. Best-effort -- a missing
# cert in the store is logged and continued, not raised.
if marker is not None:
log.info(
'%s rotation detected (old pemSha256=%s -> new=%s); '
'uninstalling %d old entries first',
_LOG_PREFIX, marker.get('pemSha256'), current_sha256,
len(marker.get('entries', [])),
)
_uninstall_marker_entries_best_effort(
marker.get('entries', []), certutil_path,
)
# Remove old marker now that uninstall has run; if install_fresh
# fails, the next entrypoint will retry from a clean state.
try:
os.unlink(marker_path)
except OSError:
pass
_check_budget()
new_entries = _install_fresh(
pem_bytes=pem_bytes,
pem_path=pem_path,
marker_path=marker_path,
certutil_path=certutil_path,
fips_active=fips_active,
check_budget=_check_budget,
)
write_marker(marker_path, _build_marker(current_sha256, new_entries))
# Best-effort partial-marker cleanup on success.
try:
os.unlink(_partial_marker_path(marker_path))
except OSError:
pass
log.info(
'%s install_or_rotate complete: %d certs trusted (marker=%s)',
_LOG_PREFIX, len(new_entries), marker_path,
)
finally:
_release_install_lock(lock_path)
def _install_fresh(
pem_bytes: bytes,
pem_path: str,
marker_path: str,
certutil_path: str,
fips_active: bool,
check_budget,
) -> List[dict]:
"""Install every cert in ``pem_bytes`` into its decided store.
Writes a partial-marker file alongside the real marker as each cert
succeeds, so a failure mid-loop leaves a recovery breadcrumb for
``cfn-ca-uninstall``.
Returns:
The list of marker entries (one per installed cert).
"""
pem_sha256 = hashlib.sha256(pem_bytes).hexdigest()
blocks = _split_pem_into_blocks(pem_bytes, pem_path)
log.info('%s split PEM into %d cert blocks', _LOG_PREFIX, len(blocks))
partial_path = _partial_marker_path(marker_path)
successful_entries: List[dict] = []
try:
tmp_ctx = tempfile.TemporaryDirectory(prefix='cfn-ca-install-')
except OSError as exc:
# Same rationale as _ensure_state_dir: a raw OSError would bypass the
# bin tools' loud CaOverrideError branch and be silently swallowed.
raise CaOverrideError(
kind='state_fs',
message=(
'%s cannot create temp directory for the CA install: %s. '
'Verify TEMP is writable by SYSTEM and the disk is not full.'
% (_LOG_PREFIX, exc)
),
)
with tmp_ctx as tmpdir:
for idx, block in enumerate(blocks):
check_budget()
der = _pem_block_to_der(block)
if fips_active:
_fips_preflight_block_cert(der, idx)
sha1_tp = _sha1_thumbprint(der)
sha256_tp = _sha256_thumbprint(der)
cer_path = os.path.join(tmpdir, 'cert-%d.pem' % idx)
try:
with open(cer_path, 'wb') as fh:
fh.write(block)
except OSError as exc:
raise CaOverrideError(
kind='state_fs',
message=(
'%s cannot write temp cert file %s: %s. Verify TEMP '
'is writable by SYSTEM and the disk is not full.'
% (_LOG_PREFIX, cer_path, exc)
),
)
store, subject_cn, _is_ca = classify_cert(
cer_path, certutil_path, thumbprint_sha1=sha1_tp)
log.info(
'%s classified position=%d subject=%s -> store=%s sha1=%s..',
_LOG_PREFIX, idx, subject_cn, store, sha1_tp[:12],
)
# Ownership probe: is this cert ALREADY in the target store before
# cfn touches it? The install lock serializes cfn installs, so a
# cert present here was placed by someone else (customer, another
# product, or baked into the AMI). We still (re-)addstore it so the
# override PEM is honored, but we record that cfn did NOT install it
# -- so a later rotation / cfn-ca-uninstall never deletes a foreign
# cert cfn merely found. An INDETERMINATE probe (transient certutil
# hiccup / timeout) is treated as "not owned" (owned=False): the
# retrying tri-state probe cannot prove we installed the cert, so
# the fail-safe is to never delete what we're unsure about. This
# reverses the earlier single-shot behavior that mis-tagged a
# pre-existing (foreign) cert as cfn-owned on a transient blip and
# let a later rotation / uninstall silently delete it. The probe is
# best-effort metadata and must never abort the install itself.
presence = _probe_store_presence(store, sha1_tp, certutil_path)
if presence == 'present':
owned = False
log.info(
'%s cert sha1=%s.. already present in store=%s before '
'install; recording ownedByCfn=false (will not be removed '
'on rotation/uninstall)',
_LOG_PREFIX, sha1_tp[:12], store,
)
elif presence == 'absent':
owned = True
log.info(
'%s cert sha1=%s.. not present in store=%s before install; '
'recording ownedByCfn=true',
_LOG_PREFIX, sha1_tp[:12], store,
)
else: # 'unknown' -- fail-safe: never delete what we cannot prove we installed
owned = False
log.warning(
'%s pre-install presence probe indeterminate for sha1=%s.. '
'in store=%s; recording ownedByCfn=false as a fail-safe '
'(will not be removed on rotation/uninstall)',
_LOG_PREFIX, sha1_tp[:12], store,
)
certutil_addstore(store, cer_path, sha1_tp, certutil_path)
entry = {
'store': store,
'thumbprintSha1': sha1_tp,
'thumbprintSha256': sha256_tp,
'ownedByCfn': owned,
}
successful_entries.append(entry)
# Persist partial-marker so cfn-ca-uninstall can roll back if a
# later cert fails. Best-effort -- partial-marker write failures
# don't block the main install.
try:
_write_partial_marker(
partial_path, pem_sha256, successful_entries,
)
except CaOverrideError as exc:
log.warning(
'%s could not update partial-marker at %s: %s. '
'Continuing install; rollback via cfn-ca-uninstall may be '
'incomplete if a later cert fails.',
_LOG_PREFIX, partial_path, exc.message,
)
return successful_entries
def _write_partial_marker(
partial_path: str,
pem_sha256: str,
entries: List[dict],
) -> None:
"""Write the partial-marker recovery file used during install rollback."""
write_marker(partial_path, _build_marker(pem_sha256, entries))
def _uninstall_marker_entries_best_effort(
entries: List[dict],
certutil_path: str,
) -> None:
"""Call ``certutil -delstore`` for each marker entry; tolerate misses.
Used by the rotation path. A "cert not in store" failure is logged and
continued (someone may have manually removed it); other certutil errors
propagate.
"""
for entry in entries:
store = entry.get('store')
# Ownership gate: never delete a cert cfn did not install. Entries with
# ownedByCfn == False were already present in the store when cfn ran
# (customer / other product / AMI-baked); removing them would strip
# trust cfn never established. Legacy entries written before this field
# existed have no 'ownedByCfn' key -> default True (owned) so historical
# markers still get cleaned up.
if entry.get('ownedByCfn', True) is False:
log.info(
'%s skipping delstore for pre-existing (not cfn-installed) cert '
'store=%s thumbprint=%s..; leaving it in place',
_LOG_PREFIX, store,
(entry.get('thumbprintSha1') or '')[:12],
)
continue
# certutil -delstore matches a cert by its SHA1 thumbprint (the same
# identifier -addstore and -store use). It does NOT accept a SHA256
# thumbprint as the identifier: given a SHA256 it matches nothing,
# yet still exits 0, so the cert is silently left in the store. Always
# delete by SHA1; fall back to SHA256 only if SHA1 is somehow absent.
thumbprint = entry.get('thumbprintSha1') or entry.get('thumbprintSha256')
if not store or not thumbprint:
log.warning(
'%s malformed marker entry; skipping: %r', _LOG_PREFIX, entry,
)
continue
try:
certutil_delstore(store, thumbprint, certutil_path)
except CaOverrideError as exc:
# Best-effort: tolerate "cert not found in store" type errors;
# propagate anything that looks like a hard failure. Primary test
# is the classified kind (locale-proof, matches the not-found
# HRESULTs even when certutil prints nothing and only sets the
# exit code); the message greps are a legacy fallback.
if exc.kind == 'cert_not_found' or \
'CRYPT_E_NOT_FOUND' in (exc.message or '') or \
'not found' in (exc.message or '').lower():
log.warning(
'%s delstore tolerated miss for store=%s thumbprint=%s..: %s',
_LOG_PREFIX, store, thumbprint[:12], exc.message,
)
continue
raise
# --------------------------------------------------------------------------- #
# Uninstall entry (called by _ca_uninstall)
# --------------------------------------------------------------------------- #
def uninstall_all_from_marker(marker_path: str) -> None:
"""Uninstall all entries recorded in the marker (or partial-marker).
Called by :mod:`cfnbootstrap._ca_uninstall`. No-op when no marker is
present. Removes the marker file on success.
Raises:
CaOverrideError: on hard failures (certutil missing, marker
unreadable for non-absence reasons).
"""
certutil_path = _resolve_certutil_path()
# Real marker first; fall back to partial-marker recovery file.
marker = read_marker(marker_path)
used_partial = False
if marker is None:
partial = _partial_marker_path(marker_path)
marker = read_marker(partial)
used_partial = marker is not None
if marker is None:
log.info(
'%s no marker at %s and no partial recovery file; nothing to '
'uninstall', _LOG_PREFIX, marker_path,
)
return
log.info(
'%s using partial-marker recovery file at %s', _LOG_PREFIX, partial,
)
_uninstall_marker_entries_best_effort(
marker.get('entries', []), certutil_path,
)
target = _partial_marker_path(marker_path) if used_partial else marker_path
try:
os.unlink(target)
except FileNotFoundError:
pass
except OSError as exc:
log.warning(
'%s could not remove marker file at %s: %s',
_LOG_PREFIX, target, exc,
)
log.info('%s uninstall_all_from_marker complete', _LOG_PREFIX)