本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2021年08月16日 统计字数: 11730字 阅读时间: 24分钟阅读 本文链接: https://soulteary.com/2021/08/16/write-nginx-module-for-rsa-encryption-and-decryption.html ----- # 编写 Nginx 模块进行 RSA 加解密 在[《Nginx 模块系统:前篇》](https://soulteary.com/2021/03/05/nginx-module-system-part-1.html)一文中,曾提过要展开聊聊如何编写和编译一个 Nginx 模块。 最近需要使用 RSA 密钥加密数据并提供开放接口,于是折腾了一个 Nginx RSA 加解密模块,正好聊聊这个事。 ## 写在前面 实现一个具备 RSA 加解密的功能并不难,使用任何一门“具备流行度”的语言,调用相关函数库/模块进行操作即可,一般只需要两步: - 创建或加载 RSA KEY(s)。 - 设置使用算法等参数,进行计算,得到结果。 但是,即使我们需要提供一个最简单的无需鉴权的开放接口,还会有非常多的额外工作要做,诸如:如何提供基础 Web 服务,如何进行云端架构设计,如何高效利用服务器资源,如何保证服务质量,如何进行监控...然而,以上多数的事情都偏离了我们最初的初衷,需要额外编写或进行大量的设置。有没有更省事的方案呢? 众所周知,Nginx 是一个高性能的 Web 服务器,易于部署,同时又具备很强的可扩展性,如果在上面添加我们所需要的功能,这个开放接口的需求不就能以最小代价实现了嘛? ### 社区里其他方案和不足之处 在实现之前,我在开源社区中进行了简单的调研,发现这个需求目前已有两个实现: - [https://github.com/spacewander/lua-resty-rsa](https://github.com/spacewander/lua-resty-rsa) - [https://github.com/LittleLiByte/lua-rsa](https://github.com/LittleLiByte/lua-rsa) 这两个实现,都是基于 Nginx 衍生版 OpenResty 的模块,两者功能上的重要区别在于前者只支持基于公钥加密私钥解密,而对于私钥加密公钥解密的方式却不支持,后者则进行了这个功能的补全;在实现上的差异主要在于前者重度使用了 [LUA FFI](https://luajit.org/ext_ffi.html) 这个允许以 LUA 代码调用外部 C 函数库的库,将 OpenSSL 中关于 RSA 加解密的函数导入了 LUA 模块,而后者虽然也使用了相同的方案,不同的地方在于,使用了自行构建的 RSA 库来进行 OpenSSL 函数库的调用,在执行效率上有了很大的进步(在压测过程中,CPU 一度达到 20%+)。 不知是作者和我一样对于 C 语言并不“熟练”,还是倾向于使用 LUA,代码直接使用了2014年3月国外一位技术博客作者 Ravishanker Kusuma 的示例代码:[RSA Encryption & Decryption Example with OpenSSL in C](http://hayageek.com/rsa-encryption-decryption-openssl-c/)。 上面这段示例代码,或许也启发了前文中第一个库的方案,在 2014 年 123 月的时候,初代作者 doujiang24 的提交中对于加解密的方式和上面文章如出一辙:[https://github.com/spacewander/lua-resty-rsa/commit/9262c57e89fd3c554e6625cddead20781d34b43b](https://github.com/spacewander/lua-resty-rsa/commit/9262c57e89fd3c554e6625cddead20781d34b43b),相比较原作者的示例代码,这个提交中可以看到作者添加了针对“包含密码保护的私钥”的加载处理。 不过,既然都考虑编译 C 语言函数库了,为什么不考虑再更进一步呢?**尤其是针对这类持久不易变化的功能。直接编译成 Nginx 模块,显然可以获得更多的性能福利。** ## 编写 Nginx 模块:完成基础计算部分 为了尽可能让文章简单,我将上面的代码进行了一定的精简和调整,只实现私钥加密的功能,并对外部使用的变量做了清理操作,减少了原始代码中内存溢出的问题,其他函数实现类似,在此就不多赘述。 考虑到 RSA 加密后的内容可读性不高,于是额外引入了一个简单的 Base64 编码实现。 ```c #include #include #include #include #include #include #include #include #include char privateKey[] = "-----BEGIN ENCRYPTED PRIVATE KEY-----\n" "MIIJrTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQICTqoISmC8M0CAggA\n" ... ... "onL8DKhku9s/5NB+eEVC3v4JubSfph0GEiVemMIQxMI2\n" "-----END ENCRYPTED PRIVATE KEY-----\n"; char *passphrase = "soulteary.com"; int encrypt(unsigned char *data, int data_len, unsigned char *encrypted) { BIO *keybio; keybio = BIO_new_mem_buf(privateKey, -1); RSA *rsa = PEM_read_bio_RSAPrivateKey(keybio, &rsa, NULL, passphrase); int ret_len = RSA_private_encrypt(data_len, data, encrypted, rsa, RSA_PKCS1_PADDING); BIO_free(keybio); RSA_free(rsa); return ret_len; } char *base64(const unsigned char *input, int length) { BIO *bmem, *b64; BUF_MEM *bptr; b64 = BIO_new(BIO_f_base64()); bmem = BIO_new(BIO_s_mem()); b64 = BIO_push(b64, bmem); BIO_write(b64, input, length); BIO_flush(b64); BIO_get_mem_ptr(b64, &bptr); char *buff = (char *)malloc(bptr->length); memcpy(buff, bptr->data, bptr->length - 1); buff[bptr->length - 1] = 0; BIO_free_all(b64); return buff; } int main() { char raw[2048 / 8] = "{\"key\": \"val\"}"; unsigned char encrypted[4098] = {}; int encrypted_length = encrypt(raw, strlen(raw), encrypted); if (encrypted_length == -1) { printf("Encrypt failed"); exit(0); } char *result = base64(encrypted, encrypted_length); printf("%s\n", result); exit(0); } ``` 为了更方便的测试功能,将上面的内容保存为 `encrypt.c`,然后编写一个 Dockerfile 用于基础计算部分的编译测试(这里需要注意编译顺序,避免编译不通过): ```bash FROM alpine:3.13 AS Builder RUN cat /etc/apk/repositories | sed -e "s/dl-cdn.alpinelinux.org/mirrors.aliyun.com/" | tee /etc/apk/repositories RUN apk add openssl-dev libressl-dev gcc g++ COPY ./encrypt.c / RUN cd / && \ gcc encrypt.c -lssl -lcrypto -o encrypt && \ cp encrypt /bin/ ``` 使用 `docker build -t test .` 构建一个临时的容器,然后使用 `docker run --rm -it test encrypt` 测试程序,会得到类似下面的结果。 ```TeXT rFKPHoJPR4iExWgx6EFzLVA4uISNyxYDtmOlGT+e6SBy9SPDve4o5YNzSbX1grnj bCFwvp80SwJzs1yaFzChDxo7HTdV3hK3syba+8zHw05FBeuw4/q8zn4e+KAv5QjE KzrQvMlzE1XsPrbI+IjJpqGIrpy57VBVr8CpmT/RajqZ42fy/cgn429i3NJhlckW vVPbY7x3vcXC/5FcRwR9hqPJ2qVXulVH/SxQ422bmLigFHwnWjT0qnDVTvgQFeQd 1edmJzPgbhycmGPvCdjRvN80eEX8lp3Oz92uACXfeReab26R0vhgGysPv3w97vdN TPmt9l1eyeWSnYR/gWU9HSM7FbAJyvKLp5h07X4AYyf2uDl7DxWIJp+ZG/IxzCzt 2IzKN2siq2bqvJEqR7+wDS2ttgCzD2ogmMMiQFgAa1yDruRasSJbV408n7STmE6h Z6klL8C+zXwgOLYnDi7bNVUhz6BkqHrt+utKql6zr0lKdOywwqElOQJeostBknH5 OCcoazuu+ZOOeAT3DoRLRVqHp/v75aIW9nYJmNjCqgonYrw9flKezh04nlSCjDaF DYJKVUSsL0mAhGMxfcVWrEzWOTAIOvg6U3vshSpDKk3KCaRhamWxCMVItfBNeeHL T8ZYF0tHz/cLkBm1wSNOuTxaGQRD/ZH0lSQGP8Aq4x4= ``` 在验证完毕基础功能可行后,我们来看看怎么将它编写为一个 Nginx 可以调用的模块。 ## 编写 Nginx 模块:按照 Nginx 模块要求进行改写 这里先不考虑让 Nginx 从请求中获取数据动态改变计算结果,仅做简单的代码调整,让 Nginx 能够调用我们之前实现的函数。 ```c #include #include #include #include #include #include #include #include #include #include char privateKey[] = "-----BEGIN ENCRYPTED PRIVATE KEY-----\n" "MIIJrTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQICTqoISmC8M0CAggA\n" ... ... "onL8DKhku9s/5NB+eEVC3v4JubSfph0GEiVemMIQxMI2\n" "-----END ENCRYPTED PRIVATE KEY-----\n"; char *passphrase = "soulteary.com"; int rsa_encrypt(unsigned char *data, int data_len, unsigned char *encrypted) { BIO *keybio; keybio = BIO_new_mem_buf(privateKey, -1); OpenSSL_add_all_algorithms(); RSA *rsa = PEM_read_bio_RSAPrivateKey(keybio, &rsa, NULL, passphrase); int encrypted_length = RSA_private_encrypt(data_len, data, encrypted, rsa, RSA_PKCS1_PADDING); BIO_free(keybio); RSA_free(rsa); return encrypted_length; } char *base64(const unsigned char *input, int length) { BIO *bmem, *b64; BUF_MEM *bptr; b64 = BIO_new(BIO_f_base64()); bmem = BIO_new(BIO_s_mem()); b64 = BIO_push(b64, bmem); BIO_write(b64, input, length); BIO_flush(b64); BIO_get_mem_ptr(b64, &bptr); char *buff = (char *)malloc(bptr->length); memcpy(buff, bptr->data, bptr->length - 1); buff[bptr->length - 1] = 0; BIO_free_all(b64); return buff; } static char *ngx_http_encrypt(ngx_conf_t *cf, ngx_command_t *cmd, void *conf); static ngx_int_t ngx_http_encrypt_handler(ngx_http_request_t *r); static ngx_command_t ngx_http_encrypt_commands[] = { {ngx_string("encrypt"), NGX_HTTP_LOC_CONF | NGX_CONF_NOARGS, ngx_http_encrypt, 0, 0, NULL}, ngx_null_command}; static ngx_str_t will_encrypt_string; static ngx_http_module_t ngx_http_encrypt_module_ctx = { NULL, /* preconfiguration */ NULL, /* postconfiguration */ NULL, /* create main configuration */ NULL, /* init main configuration */ NULL, /* create server configuration */ NULL, /* merge server configuration */ NULL, /* create location configuration */ NULL /* merge location configuration */ }; ngx_module_t ngx_http_encrypt_module = { NGX_MODULE_V1, &ngx_http_encrypt_module_ctx, /* module context */ ngx_http_encrypt_commands, /* module directives */ NGX_HTTP_MODULE, /* module type */ NULL, /* init master */ NULL, /* init module */ NULL, /* init process */ NULL, /* init thread */ NULL, /* exit thread */ NULL, /* exit process */ NULL, /* exit master */ NGX_MODULE_V1_PADDING}; static ngx_int_t ngx_http_encrypt_handler(ngx_http_request_t *r) { ngx_int_t rc; ngx_buf_t *b; ngx_chain_t out; /* we response to 'GET' and 'HEAD' requests only */ if (!(r->method & (NGX_HTTP_GET | NGX_HTTP_HEAD))) { return NGX_HTTP_NOT_ALLOWED; } /* discard request body, since we don't need it here */ rc = ngx_http_discard_request_body(r); if (rc != NGX_OK) { return rc; } /* set the 'Content-type' header */ r->headers_out.content_type_len = sizeof("text/html") - 1; r->headers_out.content_type.len = sizeof("text/html") - 1; r->headers_out.content_type.data = (u_char *)"text/html"; /* send the header only, if the request type is http 'HEAD' */ if (r->method == NGX_HTTP_HEAD) { r->headers_out.status = NGX_HTTP_OK; r->headers_out.content_length_n = will_encrypt_string.len; return ngx_http_send_header(r); } /* allocate a buffer for your response body */ b = ngx_pcalloc(r->pool, sizeof(ngx_buf_t)); if (b == NULL) { return NGX_HTTP_INTERNAL_SERVER_ERROR; } char *preset_words = "hi"; will_encrypt_string.data = (u_char *)preset_words; will_encrypt_string.len = strlen(preset_words); unsigned char *test_data = (unsigned char *)will_encrypt_string.data; int data_len = will_encrypt_string.len; unsigned char rsa_encrypted[4096] = {}; int encrypted_length = rsa_encrypt(test_data, data_len, rsa_encrypted); char *base64_data = base64(rsa_encrypted, encrypted_length); will_encrypt_string.data = (u_char *)base64_data; will_encrypt_string.len = ngx_strlen(will_encrypt_string.data); /* attach this buffer to the buffer chain */ out.buf = b; out.next = NULL; /* adjust the pointers of the buffer */ b->pos = will_encrypt_string.data; b->last = will_encrypt_string.data + will_encrypt_string.len; b->memory = 1; /* this buffer is in memory */ b->last_buf = 1; /* this is the last buffer in the buffer chain */ /* set the status line */ r->headers_out.status = NGX_HTTP_OK; r->headers_out.content_length_n = will_encrypt_string.len; /* send the headers of your response */ rc = ngx_http_send_header(r); if (rc == NGX_ERROR || rc > NGX_OK || r->header_only) { return rc; } /* send the buffer chain of your response */ return ngx_http_output_filter(r, &out); } static char *ngx_http_encrypt(ngx_conf_t *cf, ngx_command_t *cmd, void *conf) { ngx_http_core_loc_conf_t *clcf; clcf = ngx_http_conf_get_module_loc_conf(cf, ngx_http_core_module); clcf->handler = ngx_http_encrypt_handler; return NGX_CONF_OK; } ``` 将上面的内容保存为 `ngx_http_encrypt_module.c`,然后编写模块所需要的声明文件: ```c ngx_addon_name=ngx_http_encrypt_module if test -n "$ngx_module_link"; then ngx_module_type=HTTP ngx_module_name=$ngx_addon_name ngx_module_incs= ngx_module_deps= ngx_module_srcs="$ngx_addon_dir/ngx_http_encrypt_module.c" ngx_module_libs= . auto/module else HTTP_MODULES="$HTTP_MODULES ngx_http_encrypt_module" NGX_ADDON_SRCS="$NGX_ADDON_SRCS $ngx_addon_dir/ngx_http_encrypt_module.c" fi ``` 在编译文件之前,我们先进行 Nginx 配置文件的编写: ```bash load_module modules/ngx_http_encrypt_module.so; user nginx; worker_processes auto; error_log /var/log/nginx/error.log notice; pid /var/run/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; keepalive_timeout 65; gzip on; server { listen 80; server_name localhost; location / { encrypt; } } } ``` 我这里使用 Nginx 默认配置进行简单修改,在文件头部声明加载接下来编译生成的动态模块,在 `location` 配置中添加我自定义的 `encrypt` 指令,然后将上面的内容保存为 `nginx.conf` 等待后面使用。 接着,借助我之前文章[《如何在容器时代高效使用 Nginx 三方模块》](https://soulteary.com/2021/03/22/how-to-use-nginx-third-party-modules-efficiently-in-the-container-era.html)中提到过的 工具镜像,就能进行快速进行 Nginx 模块的编译开发了,还是先来编写一个用于编译插件的 Dockerfile: ```bash FROM soulteary/prebuilt-nginx-modules:base-1.21.1-alpine AS Builder COPY src/config /usr/src/encrypt/ COPY src/ngx_http_encrypt_module.c /usr/src/encrypt/ WORKDIR /usr/src/nginx RUN CONFARGS=$(nginx -V 2>&1 | sed -n -e 's/^.*arguments: //p') \ CONFARGS=${CONFARGS/-Os -fomit-frame-pointer -g/-Os} && \ echo $CONFARGS && \ ./configure --with-compat $CONFARGS --add-dynamic-module=../encrypt && \ make modules FROM nginx:1.21.1-alpine COPY nginx.conf /etc/nginx/nginx.conf COPY --from=Builder /usr/src/nginx/objs/ngx_http_encrypt_module.so /etc/nginx/modules/ ``` 还是执行 `docker build -t test .` 构建一个基础镜像,然后执行 `docker run --rm -it -p 8080:80 test` 来进行功能测试。浏览器或终端访问本地的 8080 端口,不出意外,能够看的我们预期中 RSA 加密后的内容被正确的 Base64 编码后展示了出来。 使用 wrk 之类的软件进行压力测试,会发现相比较使用 Lua 方案,使用纯 Nginx 模块的方案,CPU 负载从 20% 左右降低到了个位数,甚至持续稳定在 1% 以内,结果还是比较惊艳的。 ## 编写 Nginx 模块:改进模块,支持参数 接下来,我们来进行一个最常规的功能支持,让模块能够接受请求参数,并根据参数的变化,动态计算结果。 ```bash ... /* allocate a buffer for your response body */ b = ngx_pcalloc(r->pool, sizeof(ngx_buf_t)); if (b == NULL) { return NGX_HTTP_INTERNAL_SERVER_ERROR; } ngx_str_t var_user = ngx_string("arg_user"); ngx_uint_t key_user = ngx_hash_key(var_user.data, var_user.len); ngx_http_variable_value_t *val_user = ngx_http_get_variable(r, &var_user, key_user); if (val_user != NULL && val_user->not_found != 1) { char *tpl; asprintf(&tpl, "{\"name\":\"%s\"}", (char *)val_user->data); will_encrypt_string.data = (u_char *)tpl; will_encrypt_string.len = strlen(tpl); free(tpl); } else { char *preset_words = "hi"; will_encrypt_string.data = (u_char *)preset_words; will_encrypt_string.len = strlen(preset_words); } unsigned char *test_data = (unsigned char *)will_encrypt_string.data; int data_len = will_encrypt_string.len; ... ``` 上面的代码展示了如何读取 GET 请求中的 `user` 参数,并将参数安全的拼合到字符串模版中,进行计算。相信即使你不熟悉 C 语言,参考上面的代码也可以快速进行调整,将计算逻辑调整到符合你预期的方式。 如果你希望模块能够非编译环境快速复用,可以参考其他的 Nginx 插件源代码,“实现 Nginx 读取静态文件”的功能,将编译至模块中的证书剥离出来。不过,如果你有 CI 环境,能够方便的持续获取最新的构建结果,将证书编译至模块中,可以获得更高的绝对性能。 ## 其他 关于 Nginx 模块的开发,我个人建议阅读 [Nginx 官方开发文档](http://nginx.org/en/docs/dev/development_guide.html) 和 Nginx 源代码,相比较国内外陈旧的文档,可以节约不少时间。 如果你对于加密内容的长度有特殊要求,需要加密特别长的内容,我建议你阅读这篇文章,考虑使用 EVP 函数替换文中的函数执行加密过程:[Simple Public Key Encryption with RSA and OpenSSL](https://shanetully.com/2012/04/simple-public-key-encryption-with-rsa-and-openssl/)。 ## 最后 下一篇文章,我将聊聊我将本文中的功能用到了哪里。 --EOF