win-acme手动执行finalize签发证书

一、前言

在使用win-acme续订证书时,在finalize步骤前发生了错误导致续订失败,再次续订时因ACME Renewal Information(ARI)机制导致无法续订;遂尝试手动执行finalize步骤签发证书。

{
  "type": "urn:ietf:params:acme:error:alreadyReplaced",
  "detail": "Could not validate ARI 'replaces' field :: cannot indicate an order replaces certificate with serial \"abcd\", which already has a replacement order",
  "status": 409
}

win-acme的数据存储在%ProgramData%\win-acme\<ACME-Server-Domain>文件夹中(例如C:\ProgramData\win-acme\acme-v02.api.letsencrypt.org),后续操作均在该文件夹中进行。

二、获取订单

(一)获取订单链接

Logs文件夹中的最新日志文件中搜索https://acme-v02.api.letsencrypt.org/acme/order,找到后在浏览器中打开完整链接(例如https://acme-v02.api.letsencrypt.org/acme/order/12345678/12345678),检查status键的值是否为ready。如果为valid,则已完成证书签发。

finalize链接为finalize键的值(例如https://acme-v02.api.letsencrypt.org/acme/finalize/12345678/12345678

(二)获取CSR

CSR文件储存在Certificates文件夹中,其文件名格式为<续订ID>-<域名>-<UUID>-csr.pem

三、获取帐户

(一)帐户ID

帐户ID存储在JSON格式的Registration_v2文件中,为Kid键的值(例如https://acme-v02.api.letsencrypt.org/acme/acct/12345678)。

(二)帐户私钥

帐户私钥经Data Protection API(DPAPI)加密后经base64编码存储在Signer_v2文件中,其内容格式为enc-<Base64字符串>。可以在PowerShell中运行以下命令进行解密:

$filePath = "Signer_v2"

$encryptedBase64 = Get-Content -Path $filePath
$encryptedBase64 = $encryptedBase64.Trim() -replace '^enc-', ''
$encryptedBytes = [Convert]::FromBase64String($encryptedBase64)

Add-Type -AssemblyName System.Security
$decryptedBytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
    $encryptedBytes, $null,
    [System.Security.Cryptography.DataProtectionScope]::LocalMachine
)

$decrypted = [System.Text.Encoding]::UTF8.GetString($decryptedBytes)
Set-Content -Path ($filePath + ".txt") -Value $decrypted

解密后帐户私钥以JSON格式存储在Signer_v2.txt文件中,其内容格式如下:

{"KeyType": "ES256", "KeyExport": "{\u0022HashSize\u0022: 256, \u0022D\u0022: \u0022abcd\u0022, \u0022X\u0022: \u0022abcd\u0022, \u0022Y\u0022: \u0022abcd\u0022}"}

可以通过以下Python脚本获取ECDSA私钥:

import base64
import json
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec

key_file_path = "Signer_v2.txt"

with open(key_file_path, 'r') as file:
    key_data = json.load(file)

assert(key_data['KeyType'] == 'ES256')
key_export = json.loads(key_data['KeyExport'])

d = int.from_bytes(base64.b64decode(key_export['D']))
x = int.from_bytes(base64.b64decode(key_export['X']))
y = int.from_bytes(base64.b64decode(key_export['Y']))

public_numbers = ec.EllipticCurvePublicNumbers(x=x, y=y, curve=ec.SECP256R1())
private_numbers = ec.EllipticCurvePrivateNumbers(private_value=d, public_numbers=public_numbers)
private_key = private_numbers.private_key()

private_key_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.TraditionalOpenSSL,
    encryption_algorithm=serialization.NoEncryption()
)

with open(key_file_path + '.pem', 'wb') as file:
    file.write(private_key_pem)

四、执行finalize

可以通过以下Python脚本执行finalize:

import base64
import json
import requests
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, utils

FINALIZE_URL = "<finalize链接>"
CSR_FILE_PATH = "<CSR文件路径>"
KID = "<帐户ID>"
ACCOUNT_KEY_FILE_PATH = "Signer_v2.txt.pem"

with open(CSR_FILE_PATH, 'rb') as file:
    csr_data = x509.load_pem_x509_csr(file.read())
csr_b64 = base64.urlsafe_b64encode(csr_data.public_bytes(serialization.Encoding.DER)).decode()

with open(ACCOUNT_KEY_FILE_PATH, 'rb') as file:
    account_key = serialization.load_pem_private_key(file.read(), None)

nonce_response = requests.head("https://acme-v02.api.letsencrypt.org/acme/new-nonce")
nonce = nonce_response.headers['Replay-Nonce']

protected = {
    "alg": "ES256",
    "kid": KID,
    "nonce": nonce,
    "url": FINALIZE_URL
}
protected_b64 = base64.urlsafe_b64encode(json.dumps(protected).encode()).decode().rstrip('=')

payload = {"csr": csr_b64}
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=')

signing_input = f"{protected_b64}.{payload_b64}".encode()
signature_der = account_key.sign(signing_input, ec.ECDSA(hashes.SHA256()))
signature_r, signature_s = utils.decode_dss_signature(signature_der)
signature_raw = signature_r.to_bytes(32, 'big') + signature_s.to_bytes(32, 'big')
signature_b64 = base64.urlsafe_b64encode(signature_raw).decode().rstrip('=')

jws = {
    "protected": protected_b64,
    "payload": payload_b64,
    "signature": signature_b64
}

response = requests.post(FINALIZE_URL, json=jws, headers={"Content-Type": "application/jose+json"})
print(response.text)

如果执行成功,刷新order链接页面,如果status键的值为ready则成功执行了finalize,可由certificate键的值下载证书。

五、后话

下载证书后,发现私钥已经被win-acme删了。最后删除了Certificates文件夹、把<续订ID>.renewal.jsoncertificate键的数组清空就能续订了。南辕北辙,白搞!

posted @ 2025-12-04 22:40  Accurio  阅读(14)  评论(0)    收藏  举报