
虽说项目只需要往cloudflare后一塞就可以获得HTTPS,但还是想自己研究下证书的签发和自动化,就从实现ACME客户端开始吧。
RFC8555
RFC8555 就是ACME规范了,这里边定义了ACME客户端和服务器之间的交互方式。
Section 4 Protocol Overview - Four major steps the client needs to take to get a certificate:
Submit an order for a certificate to be issued
Prove control of any identifiers requested in the certificate
Finalize the order by submitting a CSR
Await issuance and download the issued certificate
Section 6 Message Transport - How to send request and receive data from ACME server:
Mandatory HTTPS and anti-replay mechanism
Use JWS for POST method, Post-As-Get for GET method
If request is rejected, server will return a error message formatted in RFC7807 which presented as class
ProblemDetails
Section 7.1 Resources - This section defines some types we will use in the client.
Directory - The directory object contains the URLs of the ACME server’s resources
Order - The order object contains the information of the order
Authorization - The authorization object contains the information of the authorization
Challenge - The challenge object contains the information of the challenge
section 4描述了大致的流程:下单、验证、确认验证、获取证书
section 6描述了交互方式:使用HTTPS、JWS、Post-As-Get
section 7.1描述了需要用到的资源类型
代码实现
account and JWS
首先要做的就是获取一个账号用于跟服务器交互了,根据RFC,账号就是一个JWK,可以通过RSA生成。
说到JWK,那就然就会想到JWT,微软有第一方的实现Microsoft.IdentityModel.Tokens,我这里就直接使用了。
public class AcmeClient
{
private readonly HttpClient _client;
private readonly RSA _privateKey;
private readonly JsonWebKey _jwk;
private AcmeDirectory _directory = null!;
private string _kid = null!;
public AcmeClient(HttpClient client, RSA privateKey)
{
_client = client;
_privateKey = privateKey;
var rsaParams = privateKey.ExportParameters(false);
_jwk = new JsonWebKey()
{
Kty = "RSA",
E = Base64Url.EncodeToString(rsaParams.Exponent),
N = Base64Url.EncodeToString(rsaParams.Modulus),
Alg = "RS256",
Use = "sig"
};
}
public async Task InitiateAsync(string url, string[] emails)
{
if (_directory != null && _kid != null) return;
_directory = (await _client.GetFromJsonAsync(url, AcmeJsonSerializerContext.Default.AcmeDirectory))!;
(_kid, _) = await CreateAccountAsync(emails);
}
}_directory 这个对象是用来描述服务器提供资源及其url的,可以直接请求不需要账号。
_kid 则是key id的缩写,标识我们的账号的JWK,是从服务器生成的,需要记录下来,这就是另外数据库的事了,与我要实现的工具无关。在账号接口,需要直接发送JWK,其他接口则用kid。
在注册账号之前,还需要一步,实现section 6中的Post-As-Get和反replay机制。
private async Task<HttpResponseMessage> PostAsGetAsync(string url)
{
var nonce = await GetNonceAsync();
var header = new AcmeMessageHeader(nonce, url, _kid);
var message = new PostAsGetMessage(header);
return await _client.SendAsync(message.GetRequestMessage(_privateKey));
}
private async Task<HttpResponseMessage> PostAsync<T>(string url, T request)
{
var nonce = await GetNonceAsync();
var header = new AcmeMessageHeader(nonce, url, _kid);
var message = new AcmeMessage<T>(header, request).GetRequestMessage(_privateKey);
return await _client.SendAsync(message);
}
private async Task<AcmeMessageHeader> GetAcmeMessageHeader(string url, bool useEmbedKey = false)
{
if (_directory == null)
{
throw new InvalidOperationException("AcmeClient not initiated");
}
var nonce = await GetNonceAsync();
return useEmbedKey
? new AcmeMessageHeader(nonce, url, _jwk)
: new AcmeMessageHeader(nonce, url, _kid);
}
public async Task<string> GetNonceAsync()
{
var request = new HttpRequestMessage(HttpMethod.Head, _directory.NewNonce);
using var response = await _client.SendAsync(request);
return response.Headers.GetValues("Replay-Nonce").First();
}PostAsGet和Nonce都实现了现在终于可以注册账号了。
private async Task<(string, AcmeAccount)> CreateAccountAsync(string[] emails)
{
var header = await GetAcmeMessageHeader(_directory.NewAccount, true);
var message = new AcmeMessage<CreateAccountRequest>(
header,
new() { Contact = [.. emails.Select(x => $"mailto:{x}")] }
).GetRequestMessage(_privateKey);
using var response = await _client.SendAsync(message);
await EnsureStatusCodeAsync(response);
var kid = response.Headers.GetValues("Location").First();
var account = await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeAccount);
return (kid, account!);
}
public async Task<AcmeAccount> GetAccountAsync()
{
using var response = await PostAsync<CreateAccountRequest>(_directory.NewAccount, new() { OnlyReturnExisting = true });
await EnsureStatusCodeAsync(response);
return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeAccount))!;
}获取证书
ACME 服务器会在响应头中返回账号的url(也叫做kid),同样,newOrder资源会在响应体中返回订单的url。
public async Task<(string, AcmeOrder)> NewOrderAsync(string[] domains)
{
using var response = await PostAsync<NewOrderRequest>(_directory.NewOrder, new()
{
Identifiers = [.. domains.Select(x => new Identifier { Value = x })]
});
await EnsureStatusCodeAsync(response);
var order = await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeOrder);
var location = response.Headers.GetValues("Location").First();
return (location, order!);
}
public async Task<AcmeOrder> GetOrderAsync(string url)
{
using var response = await PostAsGetAsync(url);
response.EnsureSuccessStatusCode();
return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeOrder))!;
}在newOrder之后,我们需要完成它的授权,与域名一一对应。在每一个授权中,都有一些challenges需要完成。最常见的是dns-01,需要向域名添加一个TXT记录。
public async Task<AcmeAuthorization> GetAuthorizationAsync(string url)
{
using var response = await PostAsGetAsync(url);
await EnsureStatusCodeAsync(response);
return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeAuthorization))!;
}
public async Task<AcmeChallenge> GetChallengeAsync(string url)
{
using var response = await PostAsGetAsync(url);
await EnsureStatusCodeAsync(response);
return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeChallenge))!;
}
/// <summary>
/// https://datatracker.ietf.org/doc/html/rfc8555#section-8.4
/// <br/><br/>
/// TXT _acme-challenge.{host} "{value}"
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public string GetDnsChallengeSha256Digest(string token)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes($$"""{"e":"{{_jwk.E}}","kty":"RSA","n":"{{_jwk.N}}"}"""));
var thumbprint = Base64Url.EncodeToString(hash);
string result = token + "." + thumbprint;
return Base64Url.EncodeToString(SHA256.HashData(Encoding.UTF8.GetBytes(result)));
}在DNS记录添加之后,就可以确认challenge,完成订单并下载证书。
public async Task<AcmeChallenge> AcknowledgeChallengeAsync(string url)
{
using var response = await PostAsync<AcknowledgeChallengeRequest>(url, new());
await EnsureStatusCodeAsync(response);
return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeChallenge))!;
}
public async Task<AcmeOrder> FinalizeOrderAsync(string url, string[] domains, RSA rsa)
{
var subject = new X500DistinguishedName($"CN={domains[0]}");
var request = new CertificateRequest(subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var sanBuilder = new SubjectAlternativeNameBuilder();
foreach (var domainName in domains)
{
if (Uri.CheckHostName(domainName) == UriHostNameType.Dns)
{
sanBuilder.AddDnsName(domainName);
}
}
request.CertificateExtensions.Add(sanBuilder.Build());
var csr = Base64Url.EncodeToString(request.CreateSigningRequest());
using var response = await PostAsync<FinalizeOrderRequest>(url, new() { Csr = csr });
await EnsureStatusCodeAsync(response);
return (await response.Content.ReadFromJsonAsync(AcmeJsonSerializerContext.Default.AcmeOrder))!;
}
/// <summary>
///
/// </summary>
/// <param name="url"></param>
/// <returns>Leaf Certificate, Intermediate Certificate</returns>
public async Task<(string, string)> DownloadCertificateAsync(string url)
{
using var response = await PostAsGetAsync(url);
await EnsureStatusCodeAsync(response);
var text = await response.Content.ReadAsStringAsync();
var reader = new StringReader(text);
var leafEnd = 0;
var flag = 0;
while (reader.ReadLine() is string line)
{
leafEnd += line.Length + 1;
if (line.EndsWith('-') && ++flag > 1) break;
}
return (text[..leafEnd], text[leafEnd..]);
}完整代码请查看 GIT.