- 网络安全
- CLI
- 后端
【免费下载链接】certbot
Certbot is EFF's tool to obtain certs from Let's Encrypt and (optionally) auto-enable HTTPS on your server. It can also act as a client for any other CA that uses the ACME protocol.
本文以 Certbot 仓库中 acme/docs/api/challenges.rst 所对应的acme.challenges模块为核心,系统讲解 ACME(RFC 8555)标识符验证挑战(Challenge)的完整类型体系:从抽象基类、Key Authorization 机制,到http-01、dns-01以及遗留版dns挑战的具体实现与验证逻辑。读完本文,你将掌握 python-acme 客户端如何解析 CA 下发的挑战、如何生成响应与验证数据、如何调用simple_verify自测,并能直接复用 http01_example.py 中的完整签发流程。
一、文档入口与模块定位
acme/docs/api/challenges.rst是 python-acme API 文档体系(由 acme/docs/api.rst 通过.. toctree:: api/*聚合)中专门描述挑战类型的页面,其主体是一条 automodule 指令:
Challenges ---------- .. automodule:: acme.challenges :members:它指示 Sphinx 自动提取 acme/src/acme/challenges.py 中所有公开类的 docstring 与签名。该模块的模块级 docstring 为 “ACME Identifier Validation Challenges”,即标识符验证挑战——这是 ACME 协议中 CA 证明“你对某个域名/标识符拥有控制权”的机制核心,也是 Certbot 签发证书前必须完成的环节。
acme.challenges是 python-acme(acme/目录)这一独立 ACME 客户端库的组成模块,与messages(资源对象)、client(ACME 交互)、standalone(自建挑战服务器)等模块协同工作。整体依赖关系为:messages.ChallengeBody包装challenges.Challenge,client负责网络交互,standalone负责本地起服务响应http-01。
二、两大抽象基类:Challenge 与 ChallengeResponse
整个模块建立在一对对称的抽象之上:CA 下发的是Challenge(挑战),客户端回传的是ChallengeResponse(挑战响应)。二者都继承自 josepy 的TypedJSONObjectWithFields,从而获得“按type字段自动反序列化为对应子类”的注册表能力。
2.1 Challenge:挑战的统一入口
class Challenge(jose.TypedJSONObjectWithFields): """ACME challenge.""" TYPES: dict[str, type['Challenge']] = {} @classmethod def from_json(cls, jobj): try: return cast(GenericChallenge, super().from_json(jobj)) except jose.UnrecognizedTypeError as error: logger.debug(error) return UnrecognizedChallenge.from_json(jobj)关键设计点:
- 子类注册机制:
DNS01、HTTP01、DNS等具体挑战类均通过@Challenge.register装饰器注册到TYPES表,序列化/反序列化时依据 JSON 中的type字段(如"dns-01"、"http-01")自动路由。 - 对未知类型的容错:
from_json捕获UnrecognizedTypeError,将无法识别的挑战原样封装为UnrecognizedChallenge。正如其 docstring 所述,ACME 规范允许 CA 或对端实现额外挑战类型,客户端应忽略无法识别的类型而不是报错。对应测试见 challenges_test.py 的ChallengeTest.test_from_json_unrecognized。
2.2 ChallengeResponse:RFC 8555 合规的空负载响应
class ChallengeResponse(jose.TypedJSONObjectWithFields): def to_partial_json(self): # Removes the `type` field which is inserted by # TypedJSONObjectWithFields.to_partial_json. # This field breaks RFC8555 compliance. jobj = super().to_partial_json() jobj.pop(self.type_field_name, None) return jobj这个看似简单的重写具有重要的协议意义:RFC 8555 规定挑战响应(如 POST 到 challenge URL 的 JWS 负载)必须是空对象{},不能包含type、keyAuthorization等字段。因此ChallengeResponse.to_partial_json()显式剔除自动注入的type字段(子类还会剔除keyAuthorization),确保序列化结果合规。测试类JWSPayloadRFC8555Compliant对此做了直接断言:
challenge_body = HTTP01Response() jobj = challenge_body.json_dumps(indent=2).encode() # RFC8555 states that challenge responses must have an empty payload. assert jobj == b'{}'2.3 UnrecognizedChallenge:原样保留未知挑战
class UnrecognizedChallenge(Challenge): jobj: dict[str, Any] def to_partial_json(self): return self.jobj @classmethod def from_json(cls, jobj): return cls(jobj)它不做任何解析,保留原始 JSON 对象,保证往返(from_json → to_partial_json)无损。测试UnrecognizedChallengeTest验证了{"type": "foo"}这类对象可被原样还原。
三、Token 与 Key Authorization:挑战的共同地基
绝大多数现代挑战(http-01、dns-01)都基于Token + 账户密钥指纹(thumbprint)组合出的 Key Authorization 字符串。这一层由_TokenChallenge、KeyAuthorizationChallenge和KeyAuthorizationChallengeResponse三个类实现。
3.1 _TokenChallenge:token 字段与安全检查
class _TokenChallenge(Challenge): TOKEN_SIZE = 128 // 8 # Based on the entropy value from the spec token: bytes = jose.field( "token", encoder=jose.encode_b64jose, decoder=functools.partial( jose.decode_b64jose, size=TOKEN_SIZE, minimum=True)) @property def good_token(self) -> bool: return b'..' not in self.token and b'/' not in self.tokenTOKEN_SIZE = 16字节:对应规范要求的 128 bit 熵,反序列化时通过jose.decode_b64jose(size=16, minimum=True)强制校验 token 至少 16 字节。- token 以 base64url(
b64jose)编码存储与传输。 good_token属性检查 token 中不得包含..或/——源码注释说明,这是为了防止将 token 拼入 URL 路径后引发路径穿越(path traversal),是客户端侧的安全防线。测试HTTP01Test.test_good_token验证了包含..的 token 返回False。
3.2 KeyAuthorizationChallengeResponse:服务端式自校验
class KeyAuthorizationChallengeResponse(ChallengeResponse): key_authorization: str = jose.field("keyAuthorization") thumbprint_hash_function = hashes.SHA256 def verify(self, chall, account_public_key) -> bool: parts = self.key_authorization.split('.') if len(parts) != 2: return False if parts[0] != chall.encode("token"): return False thumbprint = jose.b64encode(account_public_key.thumbprint( hash_function=self.thumbprint_hash_function)).decode() if parts[1] != thumbprint: return False return Trueverify(chall, account_public_key)模拟了 CA 端的校验过程,分三步:
- Key Authorization 必须形如
token.thumbprint,恰好一个.分隔,否则格式非法; - 前半段必须与挑战的 token 完全一致(
chall.encode("token")将 bytes 编码为 base64url 字符串); - 后半段必须等于账户公钥的SHA-256 thumbprint的 base64url 编码。
KeyAuthorizationChallengeResponseTest覆盖了成功、错误 token、错误 thumbprint、错误格式四种用例。注意verify只做本地密码学校验,不涉及任何网络请求。
3.3 KeyAuthorizationChallenge:生成响应与验证数据的工厂
class KeyAuthorizationChallenge(_TokenChallenge, metaclass=abc.ABCMeta): def key_authorization(self, account_key: jose.JWK) -> str: return self.encode("token") + "." + jose.b64encode( account_key.thumbprint( hash_function=self.thumbprint_hash_function)).decode() def response(self, account_key): return self.response_cls( key_authorization=self.key_authorization(account_key)) @abc.abstractmethod def validation(self, account_key, **kwargs): raise NotImplementedError() def response_and_validation(self, account_key, *args, **kwargs): return (self.response(account_key), self.validation(account_key, *args, **kwargs))这是所有基于 Key Authorization 的挑战(HTTP01、DNS01)的公共抽象:
key_authorization(account_key):拼接token.thumbprint;response(account_key):用子类指定的response_cls生成可回传给 CA 的响应对象;validation(account_key):抽象方法,由子类决定“验证数据”的具体形态(对http-01就是 Key Authorization 明文本身,对dns-01则是其 SHA-256 摘要的 base64url);response_and_validation(account_key):一次性拿到“回传 CA 的响应”和“部署到本地/ DNS 的验证数据”,是实际签发流程中最常用的便捷入口(见 http01_example.py 中challb.response_and_validation(...)的用法)。
四、http-01:基于 80 端口 HTTP 资源验证
http-01是 Certbot 最常用的挑战类型:CA 访问http://<域名>/.well-known/acme-challenge/<token>,要求返回 Key Authorization 明文。模块中以HTTP01(挑战)与HTTP01Response(响应)两个类实现。
4.1 HTTP01:URL 路径与 URI 构造
class HTTP01(KeyAuthorizationChallenge): response_cls = HTTP01Response typ = response_cls.typ URI_ROOT_PATH = ".well-known/acme-challenge" @property def path(self) -> str: return '/' + self.URI_ROOT_PATH + '/' + self.encode('token') def uri(self, identifier: str) -> str: try: ipaddress.IPv6Address(identifier) identifier = "[" + identifier + "]" except ipaddress.AddressValueError: pass return "http://" + identifier + self.path def validation(self, account_key, **unused_kwargs) -> str: return self.key_authorization(account_key)要点:
- 固定路径:验证资源固定位于
/.well-known/acme-challenge/<token>(URI_ROOT_PATH常量即路径前缀)。 - IPv6 括号化:
uri()中若 identifier 是 IPv6 地址,会自动加方括号(遵循 RFC 2732),例如http://[::1]/.well-known/acme-challenge/<token>。测试HTTP01Test.test_uri同时覆盖了域名、IPv4、IPv6 三种形态。 - validation 即明文:
http-01的验证数据就是 Key Authorization 字符串本身。
4.2 HTTP01Response.simple_verify:本地自测关键方法
class HTTP01Response(KeyAuthorizationChallengeResponse): PORT = 80 WHITESPACE_CUTSET = "\n\r\t " def simple_verify(self, chall, domain, account_public_key, port=None, timeout=30) -> bool: if not self.verify(chall, account_public_key): return False if port is not None and port != self.PORT: logger.warning("Using non-standard port for http-01 verification: %s", port) domain += ":{0}".format(port) uri = chall.uri(domain) try: http_response = requests.get(uri, verify=False, timeout=timeout) except requests.exceptions.RequestException as error: logger.error("Unable to reach %s: %s", uri, error) return False http_response.encoding = "ascii" challenge_response = http_response.text.rstrip(self.WHITESPACE_CUTSET) if self.key_authorization != challenge_response: return False return Truesimple_verify(chall, domain, account_public_key, port=None, timeout=30)是开发者自测http-01部署是否正确的核心工具,完整流程:
- 先做本地密码学校验(复用
verify); - 通过
requests.get请求http://<domain>[:port]/.well-known/acme-challenge/<token>,默认超时 30 秒(参数timeout可调,测试test_simple_verify_timeout验证了自定义timeout=1234的传递);verify=False表示这里不校验 TLS(http-01本就是明文 HTTP); - 按 RFC 8555 将响应按ASCII 解码(避免 requests 的编码猜测引入误差),并用
WHITESPACE_CUTSET = "\n\r\t "去除行尾空白——规范允许响应体带尾随空白,测试test_simple_verify_whitespace_validation专门验证了这一点; - 与
self.key_authorization逐字节比对。
非 80 端口时(如本地测试 8080),会发出 warning 并在域名后拼上端口(测试test_simple_verify_port断言了local:8080的请求目标);CA 真实验证固定走 80 端口。
4.3 standalone:本地自建 HTTP-01 验证服务器
acme.challenges与 acme/src/acme/standalone.py 配套使用:HTTP01RequestHandler是一个BaseHTTPRequestHandler,当请求路径以/.well-known/acme-challenge开头时,从资源集合中查找resource.chall.path == self.path的条目并返回resource.validation;HTTP01Server/HTTP01DualNetworkedServers负责同时监听 IPv4/IPv6(BaseDualNetworkedServers逐个尝试双栈绑定)。资源以命名元组组织:
HTTP01Resource = collections.namedtuple( "HTTP01Resource", "chall response validation")也就是说,一次完整部署 = 用HTTP01生成path,用response_and_validation拿到response与validation,再把三元组交给 standalone 服务器对外服务——这正好与 http01_example.py 的perform_http01实现一一对应。
五、dns-01:基于 TXT 记录的 DNS 验证
dns-01用于不方便开放 80 端口(或需要验证通配符域名)的场景:CA 查询_acme-challenge.<域名>的 TXT 记录,要求其值等于Key Authorization 的 SHA-256 摘要的 base64url 编码。
5.1 DNS01 与验证值生成
class DNS01(KeyAuthorizationChallenge): response_cls = DNS01Response typ = response_cls.typ LABEL = "_acme-challenge" def validation(self, account_key, **unused_kwargs) -> str: return jose.b64encode(hashlib.sha256(self.key_authorization( account_key).encode("utf-8")).digest()).decode() def validation_domain_name(self, name: str) -> str: return f"{self.LABEL}.{name}"LABEL = "_acme-challenge":需要写入 TXT 记录的主机名前缀;validation():base64url( SHA256( key_authorization ) )——注意与http-01的“明文”不同,dns-01的验证数据是单向哈希,因此不能从 TXT 记录反推出账户密钥;validation_domain_name(name):返回_acme-challenge.<name>。测试断言DNS01('www.example.com')得到_acme-challenge.www.example.com,并验证了已知密钥下的具体摘要值rAa7iIg4K2y63fvUhCfy8dP1Xl7wEhmQq0oChTcE3Zk(test_validation)。
5.2 DNS01Response.simple_verify:本地校验包装
class DNS01Response(KeyAuthorizationChallengeResponse): typ = "dns-01" def simple_verify(self, chall, domain, account_public_key) -> bool: verified = self.verify(chall, account_public_key) if not verified: logger.debug("Verification of key authorization in response failed") return verified从源码看,DNS01Response.simple_verify不再实际查询 DNS 记录,而只是verify的简单包装(docstring 明确说明 “This method no longer checks DNS records”)。它的用途是本地确认“响应里的 Key Authorization 本身构造正确”;DNS 记录是否真正发布、是否可被 CA 解析,需要另行通过真实解析来确认。测试DNS01ResponseTest覆盖了密钥匹配与不匹配两种结果。
六、遗留的 dns 挑战(ACME v1 时代)
模块中还保留了 ACME v1 时代的dns挑战类型(typ = "dns"),与dns-01不同,它要求把签名后的挑战对象本身放入 DNS:
class DNS(_TokenChallenge): typ = "dns" LABEL = "_acme-challenge" def gen_validation(self, account_key, alg=jose.RS256, **kwargs) -> jose.JWS: return jose.JWS.sign( payload=self.json_dumps(sort_keys=True).encode('utf-8'), key=account_key, alg=alg, **kwargs) def check_validation(self, validation, account_public_key) -> bool: if not validation.verify(key=account_public_key): return False try: return self == self.json_loads(validation.payload.decode('utf-8')) except jose.DeserializationError as error: return Falsegen_validation(account_key, alg=RS256):把挑战自身序列化后签名成 JWS;check_validation(validation, account_public_key):验签并反序列化回挑战对象,与自身比对;测试TestDNS覆盖了 RS256/ES384 两种算法(test_gen_check_validation、test_validation_domain_name_ecdsa)以及错误密钥、错误负载、错误字段等失败路径;DNSResponse.validation字段是一个jose.JWS(decoder=jose.JWS.from_json),通过gen_response生成。
该类型已被dns-01取代,仅作兼容保留;实现 `ACME 客户端时一般无需实现它。
七、与 messages 模块的协作:ChallengeBody 与授权资源
在实际 ACME 流程中,挑战不是孤立对象,而是挂在授权(Authorization)资源下的。这些封装定义在 acme/src/acme/messages.py:
class ChallengeBody(ResourceBody): _url: str = jose.field('url', omitempty=True, default=None) status: Status = jose.field('status', decoder=Status.from_json, ...) validated: datetime.datetime = fields.rfc3339('validated', omitempty=True) error: Error = jose.field('error', decoder=Error.from_json, ...) def to_partial_json(self): jobj = super().to_partial_json() jobj.update(self.chall.to_partial_json()) return jobj @classmethod def fields_from_json(cls, jobj): jobj_fields = super().fields_from_json(jobj) jobj_fields['chall'] = challenges.Challenge.from_json(jobj) return jobj_fields def __getattr__(self, name): return getattr(self.chall, name)理解ChallengeBody是读懂 python-acme 客户端代码的关键:
- 它通过
fields_from_json调用challenges.Challenge.from_json(jobj),把原始 JSON 中的挑战部分委托给acme.challenges解析——两个模块在此汇合; - 通过
__getattr__代理底层挑战的所有字段,因此challb.token等价于challb.chall.token,docstring 建议用challb这样的短名指代ChallengeBody实例以区分裸Challenge; - 兼容 ACME v1 的
uri与 v2 的url字段(内部存_url,对外统一暴露uri); - 携带
status(pending/processing/valid/invalid)、validated(验证时间,RFC 3339 格式)、error(失败原因)等协议状态字段。
上层Authorization(messages.py)则持有identifier、challenges(ChallengeBody元组)、status、expires、wildcard等字段——Certbot 客户端从 Order 的authorizations里取出这些授权,再逐个挑选可用的挑战(见 http01_example.py 中select_http01_chall用isinstance(i.chall, challenges.HTTP01)筛选的逻辑)。
八、端到端流程:从挑战解析到证书签发
结合 acme/examples/http01_example.py 与 acme/src/acme/client.py,可以串起acme.challenges在真实签发流程中的位置:
- 创建账户:生成
JWKRSA账户密钥,ClientV2.get_directory获取目录,new_account注册并同意 ToS; - 下单:
new_order(csr_pem)拿到 Order 资源,其中orderr.authorizations内嵌授权与挑战列表; - 挑选挑战:
select_http01_chall遍历authz.body.challenges,用isinstance(i.chall, challenges.HTTP01)找到http-01(对应 challenges.py 中@Challenge.register注册的类型分发); - 生成响应与验证数据:
challb.response_and_validation(client_acme.net.key)一次性获得response(回传 CA)与validation(部署到本地); - 部署并应答:构造
standalone.HTTP01RequestHandler.HTTP01Resource,启动HTTP01DualNetworkedServers对外服务,然后client_acme.answer_challenge(challb, response)通知 CA; - 轮询与定稿:
poll_and_finalize(orderr)等待挑战状态变为 valid 后提交 CSR 并取回证书(fullchain_pem); - 续期与吊销:使用相同密钥重新下单签发,或调用
revoke(fullchain_com, 0)吊销。
其中answer_challenge、poll_and_finalize的定义均位于 acme/src/acme/client.py(ClientV2类,行号 504 与 166 附近),它们负责 POST 响应到 challenge URL、轮询授权状态,是challenges模块与协议交互层的桥梁。
九、测试佐证:行为即文档
acme.challenges的行为在 acme/src/acme/_internal/tests/challenges_test.py 中有完整的测试矩阵,可作为阅读与二次开发的参考:
| 被测行为 | 对应测试 |
|---|---|
未知挑战类型回退到UnrecognizedChallenge | ChallengeTest.test_from_json_unrecognized |
| Key Authorization 三段校验(格式/token/指纹) | KeyAuthorizationChallengeResponseTest |
http-01URL 构造(域名/IPv4/IPv6 加括号) | HTTP01Test.test_uri |
token 包含..时good_token为假 | HTTP01Test.test_good_token |
| 响应体允许尾随空白、ASCII 解码、超时参数传递 | HTTP01ResponseTest系列 |
dns-01摘要值与_acme-challenge域名拼接 | DNS01Test |
遗留dns挑战 JWS 验签(RS256/ES384) | TestDNS系列 |
响应序列化为空负载{}(RFC 8555) | JWSPayloadRFC8555Compliant |
这些测试同时给出了大量可直接复用的样例数据(如 16 字节 token 的 base64url 形态evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA、已知摘要值rAa7iIg4K2y63fvUhCfy8dP1Xl7wEhmQq0oChTcE3Zk),便于开发者快速验证自己的实现。
十、小结
acme.challenges是 python-acme 的挑战类型中枢:Challenge/ChallengeResponse提供按类型分发与 RFC 8555 合规序列化,_TokenChallenge/KeyAuthorizationChallenge奠定 token 与 Key Authorization 地基,HTTP01/DNS01分别落地 HTTP 与 DNS 两条主流验证路径,遗留DNS保留 v1 兼容。理解这套体系后,无论是阅读 Certbot 的standalone服务器、messages.ChallengeBody解析,还是自行扩展新的挑战类型(注册新子类并实现validation),都有了清晰的源码级地图。深入阅读建议按 acme/docs/api/challenges.rst → acme/src/acme/challenges.py → acme/src/acme/_internal/tests/challenges_test.py → acme/examples/http01_example.py 的顺序进行。
- 网络安全
- CLI
- 后端
【免费下载链接】certbot
Certbot is EFF's tool to obtain certs from Let's Encrypt and (optionally) auto-enable HTTPS on your server. It can also act as a client for any other CA that uses the ACME protocol.
相关推荐
Certbot achallenges 模块深度解析:ACME 客户端注记挑战(AnnotatedChallenge)的设计与实战
Certbot achallenges 模块深度解析:ACME 客户端注记挑战(AnnotatedChallenge)的设计与实战 导读 certbot.ach
网络安全CLI后端Certbot 测试工具模块 certbot.tests.acme_util 深入解析:ACME 挑战与授权资源的测试数据工厂
Certbot 测试工具模块 certbot.tests.acme_util 深入解析:ACME 挑战与授权资源的测试数据工厂 certbot.tests.ac
网络安全CLI后端Certbot ACME 客户端 API 详解:基于 python-acme 的 ClientV2 与 ClientNetwork 完整指南
Certbot ACME 客户端 API 详解:基于 python acme 的 ClientV2 与 ClientNetwork 完整指南 本文围绕 acme
网络安全CLI后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考