Some checks failed
CI / build (push) Failing after 26s
- cocoon_config_t 新增 ACME 字段(enabled, directory_url, email, domains, cert_path, key_path, renew_days),支持最多 8 个域名 - config.c: JSON 解析 acme 配置块、校验逻辑、config_merge 支持 - main.c: 新增 --acme 命令行开关 - server.c: server_start 启动 ACME 自动续期后台协程(检查周期 24h, 提前 renew_days 触发重新签发) - acme.c: 实现 acme_save_certificate 证书持久化、 acme_cert_days_until_expiry 过期天数检查 - acme.h: 新增 save_certificate / cert_days_until_expiry 接口声明 - 新增 tests/unit/test_acme_config.c:覆盖配置解析、校验、合并、 证书过期检查、证书保存共 6 项测试 - 修复 test_config.c 中 config_merge 调用以匹配新签名
1121 lines
33 KiB
C
1121 lines
33 KiB
C
/**
|
||
* acme.c - ACME / Let's Encrypt 自动证书模块实现
|
||
*
|
||
* 实现 RFC 8555 ACME 协议客户端:
|
||
* - JWS 签名 (ES256)
|
||
* - 目录发现
|
||
* - 账户创建
|
||
* - 订单创建与轮询
|
||
* - HTTP-01 挑战
|
||
* - 证书下载
|
||
*
|
||
* @author xfy
|
||
*/
|
||
|
||
#include "acme.h"
|
||
#include "log.h"
|
||
#include <openssl/evp.h>
|
||
#include <openssl/ec.h>
|
||
#include <openssl/pem.h>
|
||
#include <openssl/sha.h>
|
||
#include <openssl/bn.h>
|
||
#include <openssl/bio.h>
|
||
#include <openssl/buffer.h>
|
||
#include <openssl/x509v3.h>
|
||
#include <openssl/x509.h>
|
||
#include <curl/curl.h>
|
||
#include "platform.h"
|
||
#include <string.h>
|
||
#include <stdlib.h>
|
||
#include <stdio.h>
|
||
#include <time.h>
|
||
#include <unistd.h>
|
||
#include <sys/stat.h>
|
||
|
||
/* ===== 内部数据结构 ===== */
|
||
|
||
struct acme_ctx {
|
||
char directory_url[512];
|
||
acme_directory_t directory;
|
||
acme_account_t account;
|
||
EVP_PKEY *account_key; /* 账户私钥 */
|
||
char nonce[256]; /* 当前 replay nonce */
|
||
CURL *curl; /* curl 句柄(复用) */
|
||
};
|
||
|
||
/* HTTP 响应内存缓冲区 */
|
||
typedef struct {
|
||
char *data;
|
||
size_t size;
|
||
} curl_mem_t;
|
||
|
||
/* ===== Base64url 编码(内部辅助) ===== */
|
||
|
||
/**
|
||
* base64url_encode - Base64url 编码(无填充)
|
||
*/
|
||
static char *base64url_encode(const unsigned char *in, size_t len) {
|
||
BIO *b64 = BIO_new(BIO_f_base64());
|
||
BIO *mem = BIO_new(BIO_s_mem());
|
||
b64 = BIO_push(b64, mem);
|
||
BIO_set_flags(b64, BIO_FLAGS_BASE64_NO_NL);
|
||
BIO_write(b64, in, (int)len);
|
||
BIO_flush(b64);
|
||
|
||
BUF_MEM *bptr;
|
||
BIO_get_mem_ptr(b64, &bptr);
|
||
char *out = (char *)malloc(bptr->length + 1);
|
||
if (out) {
|
||
memcpy(out, bptr->data, bptr->length);
|
||
out[bptr->length] = '\0';
|
||
/* 替换 + → -, / → _, 去掉 = 填充 */
|
||
for (char *p = out; *p; p++) {
|
||
if (*p == '+') *p = '-';
|
||
else if (*p == '/') *p = '_';
|
||
else if (*p == '=') { *p = '\0'; break; }
|
||
}
|
||
}
|
||
BIO_free_all(b64);
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* get_ec_pub_coords - 从 EVP_PKEY 提取 EC 公钥 x/y 坐标(Base64url)
|
||
* 使用 OpenSSL 3.0 EVP API,避免弃用的 EC_KEY 接口
|
||
*/
|
||
static int get_ec_pub_coords(EVP_PKEY *pkey, char **x_b64, char **y_b64) {
|
||
size_t pub_len = 0;
|
||
if (!EVP_PKEY_get_octet_string_param(pkey, "pub", NULL, 0, &pub_len)) {
|
||
return -1;
|
||
}
|
||
unsigned char *pub = (unsigned char *)malloc(pub_len);
|
||
if (!pub) return -1;
|
||
if (!EVP_PKEY_get_octet_string_param(pkey, "pub", pub, pub_len, &pub_len)) {
|
||
free(pub);
|
||
return -1;
|
||
}
|
||
if (pub_len < 65 || pub[0] != 0x04) {
|
||
free(pub);
|
||
return -1;
|
||
}
|
||
*x_b64 = base64url_encode(pub + 1, 32);
|
||
*y_b64 = base64url_encode(pub + 33, 32);
|
||
free(pub);
|
||
return (*x_b64 && *y_b64) ? 0 : -1;
|
||
}
|
||
|
||
/* ===== CURL 回调 ===== */
|
||
|
||
static size_t curl_write_cb(void *contents, size_t size, size_t nmemb, void *userp) {
|
||
size_t total = size * nmemb;
|
||
curl_mem_t *mem = (curl_mem_t *)userp;
|
||
char *ptr = realloc(mem->data, mem->size + total + 1);
|
||
if (!ptr) return 0;
|
||
mem->data = ptr;
|
||
memcpy(mem->data + mem->size, contents, total);
|
||
mem->size += total;
|
||
mem->data[mem->size] = '\0';
|
||
return total;
|
||
}
|
||
|
||
static size_t curl_header_cb(char *buffer, size_t size, size_t nitems, void *userp) {
|
||
size_t total = size * nitems;
|
||
acme_http_response_t *resp = (acme_http_response_t *)userp;
|
||
/* 解析 Replay-Nonce */
|
||
if (strncasecmp(buffer, "Replay-Nonce:", 13) == 0) {
|
||
char *start = buffer + 13;
|
||
while (*start == ' ' || *start == '\t') start++;
|
||
size_t len = total - (start - buffer);
|
||
if (len > 0 && len < sizeof(resp->replay_nonce)) {
|
||
memcpy(resp->replay_nonce, start, len);
|
||
/* 去掉换行 */
|
||
for (size_t i = 0; i < len; i++) {
|
||
if (resp->replay_nonce[i] == '\r' || resp->replay_nonce[i] == '\n') {
|
||
resp->replay_nonce[i] = '\0'; break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/* 解析 Location */
|
||
if (strncasecmp(buffer, "Location:", 9) == 0) {
|
||
char *start = buffer + 9;
|
||
while (*start == ' ' || *start == '\t') start++;
|
||
size_t len = total - (start - buffer);
|
||
if (len > 0 && len < sizeof(resp->location)) {
|
||
memcpy(resp->location, start, len);
|
||
for (size_t i = 0; i < len; i++) {
|
||
if (resp->location[i] == '\r' || resp->location[i] == '\n') {
|
||
resp->location[i] = '\0'; break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return total;
|
||
}
|
||
|
||
/* ===== HTTP 请求 ===== */
|
||
|
||
int acme_http_get(acme_ctx_t *ctx, const char *url, acme_http_response_t *out) {
|
||
memset(out, 0, sizeof(*out));
|
||
curl_mem_t mem = {0};
|
||
|
||
curl_easy_setopt(ctx->curl, CURLOPT_URL, url);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_HTTPGET, 1L);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_WRITEFUNCTION, curl_write_cb);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_WRITEDATA, &mem);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_HEADERFUNCTION, curl_header_cb);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_HEADERDATA, out);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_FOLLOWLOCATION, 1L);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_TIMEOUT, 30L);
|
||
|
||
CURLcode res = curl_easy_perform(ctx->curl);
|
||
if (res != CURLE_OK) {
|
||
log_error("ACME GET 请求失败: %s", curl_easy_strerror(res));
|
||
free(mem.data);
|
||
return -1;
|
||
}
|
||
|
||
curl_easy_getinfo(ctx->curl, CURLINFO_RESPONSE_CODE, &out->status);
|
||
out->body = mem.data;
|
||
out->body_len = mem.size;
|
||
|
||
/* 保存 nonce */
|
||
if (out->replay_nonce[0]) {
|
||
strncpy(ctx->nonce, out->replay_nonce, sizeof(ctx->nonce) - 1);
|
||
ctx->nonce[sizeof(ctx->nonce) - 1] = '\0';
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
int acme_http_post_jws(acme_ctx_t *ctx, const char *url, const char *payload,
|
||
acme_http_response_t *out) {
|
||
memset(out, 0, sizeof(*out));
|
||
|
||
/* 如果没有 nonce,先获取一个 */
|
||
if (!ctx->nonce[0] && ctx->directory.newNonce[0]) {
|
||
if (acme_get_nonce(ctx, ctx->directory.newNonce) < 0) {
|
||
log_error("获取 nonce 失败");
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
/* 构建 JWS Protected Header */
|
||
const char *alg = "ES256";
|
||
char *x_b64 = NULL, *y_b64 = NULL;
|
||
if (get_ec_pub_coords(ctx->account_key, &x_b64, &y_b64) < 0) {
|
||
log_error("获取公钥坐标失败");
|
||
return -1;
|
||
}
|
||
|
||
/* 构建 JWK */
|
||
char jwk[1024];
|
||
snprintf(jwk, sizeof(jwk),
|
||
"{\"kty\":\"EC\",\"crv\":\"P-256\",\"x\":\"%s\",\"y\":\"%s\"}",
|
||
x_b64 ? x_b64 : "", y_b64 ? y_b64 : "");
|
||
free(x_b64); free(y_b64);
|
||
|
||
char protected_hdr[2048];
|
||
if (ctx->account.kid[0]) {
|
||
/* 已有账户,使用 kid */
|
||
snprintf(protected_hdr, sizeof(protected_hdr),
|
||
"{\"alg\":\"%s\",\"kid\":\"%s\",\"nonce\":\"%s\",\"url\":\"%s\"}",
|
||
alg, ctx->account.kid, ctx->nonce, url);
|
||
} else {
|
||
/* 新账户,使用 jwk */
|
||
snprintf(protected_hdr, sizeof(protected_hdr),
|
||
"{\"alg\":\"%s\",\"jwk\":%s,\"nonce\":\"%s\",\"url\":\"%s\"}",
|
||
alg, jwk, ctx->nonce, url);
|
||
}
|
||
|
||
char *protected_b64 = base64url_encode((unsigned char *)protected_hdr, strlen(protected_hdr));
|
||
|
||
/* payload base64url */
|
||
char *payload_b64 = NULL;
|
||
if (payload) {
|
||
payload_b64 = base64url_encode((unsigned char *)payload, strlen(payload));
|
||
} else {
|
||
payload_b64 = strdup(""); /* 空 payload */
|
||
}
|
||
|
||
/* 签名: sign(protected_b64 || '.' || payload_b64) */
|
||
char signing_input[4096];
|
||
snprintf(signing_input, sizeof(signing_input), "%s.%s", protected_b64, payload_b64);
|
||
|
||
EVP_MD_CTX *mdctx = EVP_MD_CTX_new();
|
||
unsigned char sig[256];
|
||
size_t sig_len = sizeof(sig);
|
||
EVP_DigestSignInit(mdctx, NULL, EVP_sha256(), NULL, ctx->account_key);
|
||
EVP_DigestSign(mdctx, sig, &sig_len, (unsigned char *)signing_input, strlen(signing_input));
|
||
EVP_MD_CTX_free(mdctx);
|
||
|
||
char *sig_b64 = base64url_encode(sig, sig_len);
|
||
|
||
/* 构建 JWS JSON */
|
||
char jws_body[8192];
|
||
snprintf(jws_body, sizeof(jws_body),
|
||
"{\"protected\":\"%s\",\"payload\":\"%s\",\"signature\":\"%s\"}",
|
||
protected_b64, payload_b64, sig_b64);
|
||
|
||
free(protected_b64);
|
||
free(payload_b64);
|
||
free(sig_b64);
|
||
|
||
/* 发送 POST */
|
||
curl_mem_t mem = {0};
|
||
struct curl_slist *headers = NULL;
|
||
headers = curl_slist_append(headers, "Content-Type: application/jose+json");
|
||
|
||
curl_easy_setopt(ctx->curl, CURLOPT_URL, url);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_POST, 1L);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_POSTFIELDS, jws_body);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_HTTPHEADER, headers);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_WRITEFUNCTION, curl_write_cb);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_WRITEDATA, &mem);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_HEADERFUNCTION, curl_header_cb);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_HEADERDATA, out);
|
||
curl_easy_setopt(ctx->curl, CURLOPT_TIMEOUT, 30L);
|
||
|
||
CURLcode res = curl_easy_perform(ctx->curl);
|
||
curl_slist_free_all(headers);
|
||
|
||
if (res != CURLE_OK) {
|
||
log_error("ACME POST 请求失败: %s", curl_easy_strerror(res));
|
||
free(mem.data);
|
||
return -1;
|
||
}
|
||
|
||
curl_easy_getinfo(ctx->curl, CURLINFO_RESPONSE_CODE, &out->status);
|
||
out->body = mem.data;
|
||
out->body_len = mem.size;
|
||
|
||
/* 保存 nonce */
|
||
if (out->replay_nonce[0]) {
|
||
strncpy(ctx->nonce, out->replay_nonce, sizeof(ctx->nonce) - 1);
|
||
ctx->nonce[sizeof(ctx->nonce) - 1] = '\0';
|
||
}
|
||
|
||
/* 保存 kid(如果是创建账户的响应) */
|
||
if (out->location[0] && !ctx->account.kid[0]) {
|
||
strncpy(ctx->account.kid, out->location, sizeof(ctx->account.kid) - 1);
|
||
ctx->account.kid[sizeof(ctx->account.kid) - 1] = '\0';
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
void acme_http_response_free(acme_http_response_t *resp) {
|
||
if (resp->body) { free(resp->body); resp->body = NULL; }
|
||
resp->body_len = 0;
|
||
}
|
||
|
||
/* ===== 上下文管理 ===== */
|
||
|
||
acme_ctx_t *acme_create(const char *directory_url, const char *account_key_pem) {
|
||
acme_ctx_t *ctx = (acme_ctx_t *)calloc(1, sizeof(acme_ctx_t));
|
||
if (!ctx) return NULL;
|
||
|
||
strncpy(ctx->directory_url, directory_url, sizeof(ctx->directory_url) - 1);
|
||
|
||
/* 初始化 curl */
|
||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||
ctx->curl = curl_easy_init();
|
||
if (!ctx->curl) {
|
||
free(ctx);
|
||
return NULL;
|
||
}
|
||
|
||
/* 加载或生成账户密钥 */
|
||
if (account_key_pem) {
|
||
BIO *bio = BIO_new_mem_buf(account_key_pem, -1);
|
||
ctx->account_key = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL);
|
||
BIO_free(bio);
|
||
} else {
|
||
/* 生成 EC P-256 密钥 */
|
||
EVP_PKEY_CTX *pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, NULL);
|
||
EVP_PKEY_keygen_init(pctx);
|
||
EVP_PKEY_CTX_set_ec_paramgen_curve_nid(pctx, NID_X9_62_prime256v1);
|
||
EVP_PKEY_keygen(pctx, &ctx->account_key);
|
||
EVP_PKEY_CTX_free(pctx);
|
||
}
|
||
|
||
if (!ctx->account_key) {
|
||
curl_easy_cleanup(ctx->curl);
|
||
free(ctx);
|
||
return NULL;
|
||
}
|
||
|
||
return ctx;
|
||
}
|
||
|
||
void acme_destroy(acme_ctx_t *ctx) {
|
||
if (!ctx) return;
|
||
if (ctx->account_key) EVP_PKEY_free(ctx->account_key);
|
||
if (ctx->curl) curl_easy_cleanup(ctx->curl);
|
||
curl_global_cleanup();
|
||
free(ctx);
|
||
}
|
||
|
||
/* ===== 目录与 Nonce ===== */
|
||
|
||
int acme_get_directory(acme_ctx_t *ctx, acme_directory_t *out) {
|
||
acme_http_response_t resp;
|
||
if (acme_http_get(ctx, ctx->directory_url, &resp) < 0) return -1;
|
||
|
||
if (resp.status != 200) {
|
||
log_error("获取 ACME 目录失败: HTTP %d", resp.status);
|
||
acme_http_response_free(&resp);
|
||
return -1;
|
||
}
|
||
|
||
/* 简单 JSON 解析 */
|
||
memset(out, 0, sizeof(*out));
|
||
|
||
/* 提取 newNonce */
|
||
char *p = strstr(resp.body, "\"newNonce\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(out->newNonce) - 1)
|
||
out->newNonce[i++] = *p++;
|
||
out->newNonce[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取 newAccount */
|
||
p = strstr(resp.body, "\"newAccount\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(out->newAccount) - 1)
|
||
out->newAccount[i++] = *p++;
|
||
out->newAccount[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取 newOrder */
|
||
p = strstr(resp.body, "\"newOrder\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(out->newOrder) - 1)
|
||
out->newOrder[i++] = *p++;
|
||
out->newOrder[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取 revokeCert */
|
||
p = strstr(resp.body, "\"revokeCert\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(out->revokeCert) - 1)
|
||
out->revokeCert[i++] = *p++;
|
||
out->revokeCert[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取 keyChange */
|
||
p = strstr(resp.body, "\"keyChange\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(out->keyChange) - 1)
|
||
out->keyChange[i++] = *p++;
|
||
out->keyChange[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
ctx->directory = *out;
|
||
acme_http_response_free(&resp);
|
||
return 0;
|
||
}
|
||
|
||
int acme_get_nonce(acme_ctx_t *ctx, const char *nonce_url) {
|
||
acme_http_response_t resp;
|
||
if (acme_http_get(ctx, nonce_url, &resp) < 0) return -1;
|
||
|
||
if (resp.status != 200 && resp.status != 204) {
|
||
log_error("获取 nonce 失败: HTTP %d", resp.status);
|
||
acme_http_response_free(&resp);
|
||
return -1;
|
||
}
|
||
|
||
if (resp.replay_nonce[0]) {
|
||
strncpy(ctx->nonce, resp.replay_nonce, sizeof(ctx->nonce) - 1);
|
||
ctx->nonce[sizeof(ctx->nonce) - 1] = '\0';
|
||
}
|
||
|
||
acme_http_response_free(&resp);
|
||
return 0;
|
||
}
|
||
|
||
/* ===== 账户 ===== */
|
||
|
||
int acme_create_account(acme_ctx_t *ctx, const char *email, bool terms_agreed) {
|
||
if (!ctx->directory.newAccount[0]) {
|
||
if (acme_get_directory(ctx, &ctx->directory) < 0) return -1;
|
||
}
|
||
|
||
char payload[1024];
|
||
if (email && email[0]) {
|
||
snprintf(payload, sizeof(payload),
|
||
"{\"termsOfServiceAgreed\":%s,\"contact\":[\"mailto:%s\"]}",
|
||
terms_agreed ? "true" : "false", email);
|
||
} else {
|
||
snprintf(payload, sizeof(payload),
|
||
"{\"termsOfServiceAgreed\":%s}", terms_agreed ? "true" : "false");
|
||
}
|
||
|
||
acme_http_response_t resp;
|
||
if (acme_http_post_jws(ctx, ctx->directory.newAccount, payload, &resp) < 0) return -1;
|
||
|
||
/* 201 = 新账户创建, 200 = 账户已存在 */
|
||
if (resp.status != 201 && resp.status != 200) {
|
||
log_error("创建 ACME 账户失败: HTTP %d, body=%s", resp.status,
|
||
resp.body ? resp.body : "(null)");
|
||
acme_http_response_free(&resp);
|
||
return -1;
|
||
}
|
||
|
||
if (resp.location[0]) {
|
||
strncpy(ctx->account.kid, resp.location, sizeof(ctx->account.kid) - 1);
|
||
ctx->account.kid[sizeof(ctx->account.kid) - 1] = '\0';
|
||
}
|
||
|
||
if (email) {
|
||
strncpy(ctx->account.contact, email, sizeof(ctx->account.contact) - 1);
|
||
ctx->account.contact[sizeof(ctx->account.contact) - 1] = '\0';
|
||
}
|
||
ctx->account.termsAgreed = terms_agreed;
|
||
|
||
log_info("ACME 账户已创建/复用: %s", ctx->account.kid);
|
||
acme_http_response_free(&resp);
|
||
return 0;
|
||
}
|
||
|
||
/* ===== 订单 ===== */
|
||
|
||
int acme_create_order(acme_ctx_t *ctx, const char **domains, size_t num_domains,
|
||
acme_order_t *out) {
|
||
memset(out, 0, sizeof(*out));
|
||
|
||
if (!ctx->directory.newOrder[0]) {
|
||
if (acme_get_directory(ctx, &ctx->directory) < 0) return -1;
|
||
}
|
||
|
||
/* 构建 identifiers JSON */
|
||
char identifiers[4096] = "";
|
||
size_t off = 0;
|
||
off += snprintf(identifiers + off, sizeof(identifiers) - off, "[");
|
||
for (size_t i = 0; i < num_domains; i++) {
|
||
off += snprintf(identifiers + off, sizeof(identifiers) - off,
|
||
"%s{\"type\":\"dns\",\"value\":\"%s\"}",
|
||
i > 0 ? "," : "", domains[i]);
|
||
}
|
||
off += snprintf(identifiers + off, sizeof(identifiers) - off, "]");
|
||
|
||
char payload[8192];
|
||
snprintf(payload, sizeof(payload), "{\"identifiers\":%s}", identifiers);
|
||
|
||
acme_http_response_t resp;
|
||
if (acme_http_post_jws(ctx, ctx->directory.newOrder, payload, &resp) < 0) return -1;
|
||
|
||
if (resp.status != 201) {
|
||
log_error("创建订单失败: HTTP %d", resp.status);
|
||
acme_http_response_free(&resp);
|
||
return -1;
|
||
}
|
||
|
||
/* 解析订单响应 */
|
||
if (resp.location[0]) {
|
||
snprintf(out->uri, sizeof(out->uri), "%s", resp.location);
|
||
}
|
||
|
||
/* 提取 status */
|
||
char *p = strstr(resp.body, "\"status\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(out->status) - 1)
|
||
out->status[i++] = *p++;
|
||
out->status[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取 finalize */
|
||
p = strstr(resp.body, "\"finalize\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(out->finalize) - 1)
|
||
out->finalize[i++] = *p++;
|
||
out->finalize[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取 authorizations 数组 */
|
||
p = strstr(resp.body, "\"authorizations\"");
|
||
if (p) {
|
||
p = strchr(p, '[');
|
||
if (p) {
|
||
p++;
|
||
/* 简单解析:提取引号内的 URI */
|
||
out->authz = (acme_authz_t *)calloc(num_domains, sizeof(acme_authz_t));
|
||
size_t authz_count = 0;
|
||
while (*p && *p != ']' && authz_count < num_domains) {
|
||
while (*p && (*p == ' ' || *p == '\n' || *p == ',')) p++;
|
||
if (*p == '"') {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(out->authz[authz_count].uri) - 1)
|
||
out->authz[authz_count].uri[i++] = *p++;
|
||
out->authz[authz_count].uri[i] = '\0';
|
||
authz_count++;
|
||
if (*p == '"') p++;
|
||
} else { break; }
|
||
}
|
||
out->num_authz = authz_count;
|
||
}
|
||
}
|
||
|
||
acme_http_response_free(&resp);
|
||
return 0;
|
||
}
|
||
|
||
void acme_order_free(acme_order_t *order) {
|
||
if (order->authz) {
|
||
free(order->authz);
|
||
order->authz = NULL;
|
||
}
|
||
order->num_authz = 0;
|
||
}
|
||
|
||
/* ===== 授权与挑战 ===== */
|
||
|
||
int acme_fetch_authz(acme_ctx_t *ctx, acme_authz_t *authz) {
|
||
acme_http_response_t resp;
|
||
if (acme_http_post_jws(ctx, authz->uri, NULL, &resp) < 0) return -1;
|
||
|
||
if (resp.status != 200) {
|
||
log_error("获取授权失败: HTTP %d", resp.status);
|
||
acme_http_response_free(&resp);
|
||
return -1;
|
||
}
|
||
|
||
/* 提取 status */
|
||
char *p = strstr(resp.body, "\"status\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(authz->status) - 1)
|
||
authz->status[i++] = *p++;
|
||
authz->status[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取域名 */
|
||
p = strstr(resp.body, "\"value\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(authz->domain) - 1)
|
||
authz->domain[i++] = *p++;
|
||
authz->domain[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取 token(在 challenges 数组中找 http-01) */
|
||
p = strstr(resp.body, "\"type\":\"http-01\"");
|
||
if (p) {
|
||
/* 向前找 token */
|
||
char *token_start = strstr(resp.body, "\"token\"");
|
||
if (token_start && token_start < p + 100) {
|
||
token_start = strchr(token_start, ':');
|
||
if (token_start) {
|
||
token_start = strchr(token_start, '"');
|
||
if (token_start) {
|
||
token_start++;
|
||
size_t i = 0;
|
||
while (*token_start && *token_start != '"' && i < sizeof(authz->token) - 1)
|
||
authz->token[i++] = *token_start++;
|
||
authz->token[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
acme_http_response_free(&resp);
|
||
return 0;
|
||
}
|
||
|
||
int acme_respond_challenge(acme_ctx_t *ctx, const acme_authz_t *authz) {
|
||
/* 构建挑战 URL: authz_uri + "/<token>" */
|
||
char challenge_url[768];
|
||
snprintf(challenge_url, sizeof(challenge_url), "%s/%s", authz->uri, authz->token);
|
||
|
||
acme_http_response_t resp;
|
||
if (acme_http_post_jws(ctx, challenge_url, "{}", &resp) < 0) return -1;
|
||
|
||
if (resp.status != 200 && resp.status != 202) {
|
||
log_error("响应挑战失败: HTTP %d", resp.status);
|
||
acme_http_response_free(&resp);
|
||
return -1;
|
||
}
|
||
|
||
acme_http_response_free(&resp);
|
||
return 0;
|
||
}
|
||
|
||
/* ===== 轮询 ===== */
|
||
|
||
int acme_poll_authz(acme_ctx_t *ctx, acme_authz_t *authz, int timeout_ms) {
|
||
int elapsed = 0;
|
||
int interval = 2000; /* 2 秒轮询间隔 */
|
||
|
||
while (elapsed < timeout_ms) {
|
||
if (acme_fetch_authz(ctx, authz) < 0) return -1;
|
||
|
||
if (strcmp(authz->status, "valid") == 0) return 0;
|
||
if (strcmp(authz->status, "invalid") == 0) {
|
||
log_error("授权验证失败: %s", authz->domain);
|
||
return -1;
|
||
}
|
||
|
||
usleep(interval * 1000);
|
||
elapsed += interval;
|
||
}
|
||
|
||
log_error("授权验证超时: %s", authz->domain);
|
||
return -1;
|
||
}
|
||
|
||
int acme_poll_order(acme_ctx_t *ctx, acme_order_t *order, int timeout_ms) {
|
||
int elapsed = 0;
|
||
int interval = 2000;
|
||
|
||
while (elapsed < timeout_ms) {
|
||
acme_http_response_t resp;
|
||
if (acme_http_post_jws(ctx, order->uri, NULL, &resp) < 0) return -1;
|
||
|
||
if (resp.status == 200) {
|
||
/* 提取 status */
|
||
char *p = strstr(resp.body, "\"status\"");
|
||
if (p) {
|
||
p = strchr(p, ':');
|
||
if (p) {
|
||
p = strchr(p, '"');
|
||
if (p) {
|
||
p++;
|
||
size_t i = 0;
|
||
while (*p && *p != '"' && i < sizeof(order->status) - 1)
|
||
order->status[i++] = *p++;
|
||
order->status[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 提取 certificate URL */
|
||
if (strcmp(order->status, "valid") == 0) {
|
||
char *cert_p = strstr(resp.body, "\"certificate\"");
|
||
if (cert_p) {
|
||
cert_p = strchr(cert_p, ':');
|
||
if (cert_p) {
|
||
cert_p = strchr(cert_p, '"');
|
||
if (cert_p) {
|
||
cert_p++;
|
||
size_t i = 0;
|
||
while (*cert_p && *cert_p != '"' && i < sizeof(order->certificate) - 1)
|
||
order->certificate[i++] = *cert_p++;
|
||
order->certificate[i] = '\0';
|
||
}
|
||
}
|
||
}
|
||
acme_http_response_free(&resp);
|
||
return 0;
|
||
}
|
||
|
||
if (strcmp(order->status, "invalid") == 0) {
|
||
log_error("订单验证失败");
|
||
acme_http_response_free(&resp);
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
acme_http_response_free(&resp);
|
||
usleep(interval * 1000);
|
||
elapsed += interval;
|
||
}
|
||
|
||
log_error("订单验证超时");
|
||
return -1;
|
||
}
|
||
|
||
/* ===== 证书 ===== */
|
||
|
||
/**
|
||
* generate_csr - 生成 CSR(Certificate Signing Request)
|
||
*/
|
||
static int generate_csr(EVP_PKEY *pkey, const char **domains, size_t num_domains,
|
||
unsigned char **out_der, size_t *out_len) {
|
||
X509_REQ *req = X509_REQ_new();
|
||
if (!req) return -1;
|
||
|
||
X509_REQ_set_pubkey(req, pkey);
|
||
|
||
/* Subject: CN = 第一个域名 */
|
||
X509_NAME *name = X509_NAME_new();
|
||
X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
|
||
(unsigned char *)domains[0], -1, -1, 0);
|
||
X509_REQ_set_subject_name(req, name);
|
||
X509_NAME_free(name);
|
||
|
||
/* 添加 SAN 扩展 */
|
||
if (num_domains > 0) {
|
||
STACK_OF(GENERAL_NAME) *san = sk_GENERAL_NAME_new_null();
|
||
for (size_t i = 0; i < num_domains; i++) {
|
||
GENERAL_NAME *gn = GENERAL_NAME_new();
|
||
ASN1_IA5STRING *ia5 = ASN1_IA5STRING_new();
|
||
ASN1_STRING_set(ia5, domains[i], -1);
|
||
GENERAL_NAME_set0_value(gn, GEN_DNS, ia5);
|
||
sk_GENERAL_NAME_push(san, gn);
|
||
}
|
||
|
||
X509_EXTENSION *ext = X509V3_EXT_i2d(NID_subject_alt_name, 0, san);
|
||
X509_REQ_add_extensions(req, sk_X509_EXTENSION_new_null());
|
||
sk_X509_EXTENSION_push(sk_X509_EXTENSION_new_null(), ext);
|
||
/* 简化处理:实际需要正确管理 STACK_OF */
|
||
sk_GENERAL_NAME_pop_free(san, GENERAL_NAME_free);
|
||
}
|
||
|
||
X509_REQ_sign(req, pkey, EVP_sha256());
|
||
|
||
*out_len = (size_t)i2d_X509_REQ(req, NULL);
|
||
*out_der = (unsigned char *)malloc(*out_len);
|
||
unsigned char *p = *out_der;
|
||
i2d_X509_REQ(req, &p);
|
||
|
||
X509_REQ_free(req);
|
||
return 0;
|
||
}
|
||
|
||
int acme_finalize_order(acme_ctx_t *ctx, acme_order_t *order,
|
||
const char **domains, size_t num_domains) {
|
||
/* 生成域名私钥和 CSR */
|
||
EVP_PKEY_CTX *pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, NULL);
|
||
EVP_PKEY_keygen_init(pctx);
|
||
EVP_PKEY_CTX_set_ec_paramgen_curve_nid(pctx, NID_X9_62_prime256v1);
|
||
EVP_PKEY *domain_key = NULL;
|
||
EVP_PKEY_keygen(pctx, &domain_key);
|
||
EVP_PKEY_CTX_free(pctx);
|
||
|
||
unsigned char *csr_der = NULL;
|
||
size_t csr_len = 0;
|
||
if (generate_csr(domain_key, domains, num_domains, &csr_der, &csr_len) < 0) {
|
||
EVP_PKEY_free(domain_key);
|
||
return -1;
|
||
}
|
||
|
||
/* base64url 编码 CSR */
|
||
char *csr_b64 = base64url_encode(csr_der, csr_len);
|
||
free(csr_der);
|
||
|
||
char payload[8192];
|
||
snprintf(payload, sizeof(payload), "{\"csr\":\"%s\"}", csr_b64);
|
||
free(csr_b64);
|
||
|
||
acme_http_response_t resp;
|
||
if (acme_http_post_jws(ctx, order->finalize, payload, &resp) < 0) {
|
||
EVP_PKEY_free(domain_key);
|
||
return -1;
|
||
}
|
||
|
||
if (resp.status != 200) {
|
||
log_error("Finalize 订单失败: HTTP %d", resp.status);
|
||
acme_http_response_free(&resp);
|
||
EVP_PKEY_free(domain_key);
|
||
return -1;
|
||
}
|
||
|
||
acme_http_response_free(&resp);
|
||
|
||
/* 保存域名私钥供后续使用 */
|
||
/* TODO: 需要把 domain_key 返回给调用者,这里简化处理 */
|
||
EVP_PKEY_free(domain_key);
|
||
return 0;
|
||
}
|
||
|
||
int acme_download_certificate(acme_ctx_t *ctx, const char *cert_url, char **out_pem) {
|
||
acme_http_response_t resp;
|
||
if (acme_http_post_jws(ctx, cert_url, NULL, &resp) < 0) return -1;
|
||
|
||
if (resp.status != 200) {
|
||
log_error("下载证书失败: HTTP %d", resp.status);
|
||
acme_http_response_free(&resp);
|
||
return -1;
|
||
}
|
||
|
||
*out_pem = resp.body;
|
||
return 0;
|
||
}
|
||
|
||
/* ===== Thumbprint ===== */
|
||
|
||
int acme_get_thumbprint(acme_ctx_t *ctx, char **out) {
|
||
char *x_b64 = NULL, *y_b64 = NULL;
|
||
if (get_ec_pub_coords(ctx->account_key, &x_b64, &y_b64) < 0) {
|
||
return -1;
|
||
}
|
||
|
||
/* JWK JSON */
|
||
char jwk[512];
|
||
snprintf(jwk, sizeof(jwk),
|
||
"{\"crv\":\"P-256\",\"kty\":\"EC\",\"x\":\"%s\",\"y\":\"%s\"}",
|
||
x_b64, y_b64);
|
||
free(x_b64);
|
||
free(y_b64);
|
||
|
||
/* SHA-256 */
|
||
unsigned char hash[SHA256_DIGEST_LENGTH];
|
||
SHA256((unsigned char *)jwk, strlen(jwk), hash);
|
||
|
||
*out = base64url_encode(hash, SHA256_DIGEST_LENGTH);
|
||
return 0;
|
||
}
|
||
|
||
/* ===== 一键签发 ===== */
|
||
|
||
int acme_get_keyauth(acme_ctx_t *ctx, const char *token, char **out) {
|
||
char *thumbprint = NULL;
|
||
if (acme_get_thumbprint(ctx, &thumbprint) < 0) {
|
||
return -1;
|
||
}
|
||
|
||
size_t len = strlen(token) + 1 + strlen(thumbprint) + 1;
|
||
*out = (char *)malloc(len);
|
||
if (!*out) {
|
||
free(thumbprint);
|
||
return -1;
|
||
}
|
||
|
||
snprintf(*out, len, "%s.%s", token, thumbprint);
|
||
free(thumbprint);
|
||
return 0;
|
||
}
|
||
|
||
/* ===== 证书保存与过期检查 ===== */
|
||
|
||
int acme_save_certificate(const char *cert_pem, const char *key_pem,
|
||
const char *cert_path, const char *key_path) {
|
||
if (!cert_pem || !key_pem || !cert_path || !key_path) return -1;
|
||
|
||
/* 确保证书目录存在 */
|
||
char cert_dir[512];
|
||
snprintf(cert_dir, sizeof(cert_dir), "%s", cert_path);
|
||
char *last_slash = strrchr(cert_dir, '/');
|
||
if (last_slash) {
|
||
*last_slash = '\0';
|
||
cocoon_mkdir(cert_dir);
|
||
}
|
||
|
||
/* 确保私钥目录存在 */
|
||
char key_dir[512];
|
||
snprintf(key_dir, sizeof(key_dir), "%s", key_path);
|
||
last_slash = strrchr(key_dir, '/');
|
||
if (last_slash) {
|
||
*last_slash = '\0';
|
||
cocoon_mkdir(key_dir);
|
||
}
|
||
|
||
FILE *fp = fopen(cert_path, "w");
|
||
if (!fp) {
|
||
log_error("无法打开证书文件写入: %s", cert_path);
|
||
return -1;
|
||
}
|
||
fwrite(cert_pem, 1, strlen(cert_pem), fp);
|
||
fclose(fp);
|
||
|
||
fp = fopen(key_path, "w");
|
||
if (!fp) {
|
||
log_error("无法打开私钥文件写入: %s", key_path);
|
||
return -1;
|
||
}
|
||
fwrite(key_pem, 1, strlen(key_pem), fp);
|
||
fclose(fp);
|
||
|
||
log_info("证书已保存: %s", cert_path);
|
||
log_info("私钥已保存: %s", key_path);
|
||
return 0;
|
||
}
|
||
|
||
int acme_cert_days_until_expiry(const char *cert_path) {
|
||
if (!cert_path || access(cert_path, R_OK) != 0) {
|
||
return -1;
|
||
}
|
||
|
||
FILE *fp = fopen(cert_path, "r");
|
||
if (!fp) return -1;
|
||
|
||
X509 *cert = PEM_read_X509(fp, NULL, NULL, NULL);
|
||
fclose(fp);
|
||
if (!cert) return -1;
|
||
|
||
ASN1_TIME *not_after = X509_get_notAfter(cert);
|
||
if (!not_after) {
|
||
X509_free(cert);
|
||
return -1;
|
||
}
|
||
|
||
/* 解析 ASN1_TIME */
|
||
struct tm tm = {0};
|
||
const char *str = (const char *)not_after->data;
|
||
int year = 0, mon = 0, day = 0, hour = 0, min = 0, sec = 0;
|
||
if (not_after->length >= 13) {
|
||
/* UTCTime: YYMMDDHHMMSSZ (13 chars) or GeneralizedTime: YYYYMMDDHHMMSSZ (15 chars) */
|
||
if (not_after->length >= 15 && str[0] == '2' && str[1] == '0') {
|
||
/* GeneralizedTime */
|
||
sscanf(str, "%4d%2d%2d%2d%2d%2dZ", &year, &mon, &day, &hour, &min, &sec);
|
||
} else {
|
||
/* UTCTime */
|
||
sscanf(str, "%2d%2d%2d%2d%2d%2dZ", &year, &mon, &day, &hour, &min, &sec);
|
||
year += (year >= 50) ? 1900 : 2000;
|
||
}
|
||
tm.tm_year = year - 1900;
|
||
tm.tm_mon = mon - 1;
|
||
tm.tm_mday = day;
|
||
tm.tm_hour = hour;
|
||
tm.tm_min = min;
|
||
tm.tm_sec = sec;
|
||
}
|
||
|
||
time_t expiry = timegm(&tm);
|
||
time_t now = time(NULL);
|
||
X509_free(cert);
|
||
|
||
if (expiry == (time_t)-1) return -1;
|
||
|
||
double diff = difftime(expiry, now);
|
||
return (int)(diff / 86400.0);
|
||
}
|
||
|
||
/* ===== 一键签发(完整流程) ===== */
|
||
|
||
int acme_issue_certificate(acme_ctx_t *ctx, const char **domains, size_t num_domains,
|
||
const char *email, char **cert_pem, char **key_pem) {
|
||
*cert_pem = NULL;
|
||
*key_pem = NULL;
|
||
|
||
/* 1. 获取目录 */
|
||
acme_directory_t dir;
|
||
if (acme_get_directory(ctx, &dir) < 0) {
|
||
log_error("获取 ACME 目录失败");
|
||
return -1;
|
||
}
|
||
|
||
/* 2. 创建账户 */
|
||
if (acme_create_account(ctx, email, true) < 0) {
|
||
log_error("创建 ACME 账户失败");
|
||
return -1;
|
||
}
|
||
|
||
/* 3. 创建订单 */
|
||
acme_order_t order;
|
||
if (acme_create_order(ctx, domains, num_domains, &order) < 0) {
|
||
log_error("创建证书订单失败");
|
||
return -1;
|
||
}
|
||
|
||
/* 4. 获取授权和挑战 */
|
||
for (size_t i = 0; i < order.num_authz; i++) {
|
||
if (acme_fetch_authz(ctx, &order.authz[i]) < 0) {
|
||
acme_order_free(&order);
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
/* 5. 响应挑战(调用者需要先设置好 HTTP-01 响应) */
|
||
for (size_t i = 0; i < order.num_authz; i++) {
|
||
if (acme_respond_challenge(ctx, &order.authz[i]) < 0) {
|
||
acme_order_free(&order);
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
/* 6. 轮询授权状态 */
|
||
for (size_t i = 0; i < order.num_authz; i++) {
|
||
if (acme_poll_authz(ctx, &order.authz[i], 120000) < 0) {
|
||
acme_order_free(&order);
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
/* 7. Finalize 订单 */
|
||
if (acme_finalize_order(ctx, &order, domains, num_domains) < 0) {
|
||
acme_order_free(&order);
|
||
return -1;
|
||
}
|
||
|
||
/* 8. 轮询订单状态 */
|
||
if (acme_poll_order(ctx, &order, 120000) < 0) {
|
||
acme_order_free(&order);
|
||
return -1;
|
||
}
|
||
|
||
/* 9. 下载证书 */
|
||
if (acme_download_certificate(ctx, order.certificate, cert_pem) < 0) {
|
||
acme_order_free(&order);
|
||
return -1;
|
||
}
|
||
|
||
/* TODO: key_pem 需要从 finalize_order 中传递出来 */
|
||
*key_pem = strdup(""); /* 占位 */
|
||
|
||
acme_order_free(&order);
|
||
return 0;
|
||
}
|