From 760f776e2a267d8a6468cdbe39f2efc3a49a476c Mon Sep 17 00:00:00 2001 From: Vadim Zhestikov Date: Thu, 24 Jul 2025 17:10:36 -0700 Subject: [PATCH 1/2] Modules: improved handling of results of async handlers. Previously, r.setReturnValue() had to be used when returning a value from async js_set handler. async function hash(r) { let hash = await crypto.subtle.digest('SHA-512', r.headersIn.host); r.setReturnValue(Buffer.from(hash).toString('hex')); } Now r.setReturnValue() is not needed: async function hash(r) { let hash = await crypto.subtle.digest('SHA-512', r.headersIn.host); return Buffer.from(hash).toString('hex'); } In addition added: infinite microtask loops detection, promise handling in global qjs code. --- nginx/ngx_js.c | 285 ++++++++++++------- nginx/ngx_js.h | 3 +- nginx/t/js_async.t | 23 +- nginx/t/js_promise_infinite_jobs.t | 142 +++++++++ nginx/t/js_promise_pending.t | 122 ++++++++ nginx/t/js_promise_top_level_await.t | 112 ++++++++ nginx/t/js_promise_top_level_await_pending.t | 96 +++++++ nginx/t/js_promise_top_level_infinite_jobs.t | 102 +++++++ nginx/t/stream_js.t | 20 +- src/njs.h | 11 + src/njs_promise.c | 20 ++ src/njs_promise.h | 11 +- src/njs_value.c | 7 + 13 files changed, 842 insertions(+), 112 deletions(-) create mode 100644 nginx/t/js_promise_infinite_jobs.t create mode 100644 nginx/t/js_promise_pending.t create mode 100644 nginx/t/js_promise_top_level_await.t create mode 100644 nginx/t/js_promise_top_level_await_pending.t create mode 100644 nginx/t/js_promise_top_level_infinite_jobs.t diff --git a/nginx/ngx_js.c b/nginx/ngx_js.c index 0678c3e61..b18d01830 100644 --- a/nginx/ngx_js.c +++ b/nginx/ngx_js.c @@ -12,6 +12,9 @@ #include "ngx_js.h" +#define NGX_MAX_JOB_ITERATIONS 0x1000 + + typedef struct { ngx_queue_t labels; } ngx_js_console_t; @@ -99,6 +102,9 @@ static ngx_int_t ngx_qjs_dump_obj(ngx_engine_t *e, JSValueConst val, ngx_str_t *dst); static JSModuleDef *ngx_qjs_core_init(JSContext *cx, const char *name); + +static ngx_int_t ngx_qjs_execute_pending_jobs(ngx_js_ctx_t *ctx, JSRuntime *rt); + #endif static njs_int_t ngx_js_ext_build(njs_vm_t *vm, njs_object_prop_t *prop, @@ -147,6 +153,8 @@ static void ngx_js_cleanup_vm(void *data); static njs_int_t ngx_js_core_init(njs_vm_t *vm); static uint64_t ngx_js_monotonic_time(void); +static njs_int_t ngx_njs_execute_pending_jobs(njs_vm_t *vm, ngx_js_ctx_t *ctx); + static njs_external_t ngx_js_ext_global_shared[] = { @@ -683,6 +691,48 @@ ngx_njs_clone(ngx_js_ctx_t *ctx, ngx_js_loc_conf_t *cf, void *external) } +static njs_int_t +ngx_njs_execute_pending_jobs(njs_vm_t *vm, ngx_js_ctx_t *ctx) +{ + njs_int_t ret, job_count; + ngx_str_t exception; + + if (ctx->broken) { + ngx_log_error(NGX_LOG_ERR, ctx->log, 0, + "js job queue processing exceeded %ui iterations (already)", + NGX_MAX_JOB_ITERATIONS); + return NGX_ERROR; + } + + job_count = 0; + + for ( ;; ) { + ret = njs_vm_execute_pending_job(vm); + if (ret < NJS_OK) { + ngx_js_exception(vm, &exception); + + ngx_log_error(NGX_LOG_ERR, ctx->log, 0, + "js job exception: %V", &exception); + } + if (ret == NJS_OK) { + break; + } + + job_count++; + + if (job_count >= NGX_MAX_JOB_ITERATIONS) { + ctx->broken = 1; + ngx_log_error(NGX_LOG_ERR, ctx->log, 0, + "js job queue processing exceeded %ui iterations (first time)", + NGX_MAX_JOB_ITERATIONS); + return NGX_ERROR; + } + } + + return NGX_OK; +} + + static ngx_int_t ngx_engine_njs_call(ngx_js_ctx_t *ctx, ngx_str_t *fname, njs_opaque_value_t *args, njs_uint_t nargs) @@ -698,6 +748,11 @@ ngx_engine_njs_call(ngx_js_ctx_t *ctx, ngx_str_t *fname, vm = ctx->engine->u.njs.vm; + ret = ngx_njs_execute_pending_jobs(vm, ctx); + if (ret != NGX_OK) { + return NGX_ERROR; + } + func = njs_vm_function(vm, &name); if (func == NULL) { ngx_log_error(NGX_LOG_ERR, ctx->log, 0, @@ -716,18 +771,30 @@ ngx_engine_njs_call(ngx_js_ctx_t *ctx, ngx_str_t *fname, return NGX_ERROR; } - for ( ;; ) { - ret = njs_vm_execute_pending_job(vm); - if (ret <= NJS_OK) { - if (ret == NJS_ERROR) { - ngx_js_exception(vm, &exception); + /* Synchronous await, similar to ngx_qjs_await(). */ - ngx_log_error(NGX_LOG_ERR, ctx->log, 0, - "js job exception: %V", &exception); - return NGX_ERROR; - } + ret = ngx_njs_execute_pending_jobs(vm, ctx); + if (ret != NGX_OK) { + return NGX_ERROR; + } - break; + njs_value_t *val = njs_value_arg(&ctx->retval); + if (njs_value_is_promise(val)) { + njs_promise_type_t state = njs_promise_state(val); + + if (state == NJS_PROMISE_FULFILL) { + njs_value_assign(val, njs_promise_result(val)); + + } else if (state == NJS_PROMISE_REJECTED) { + njs_vm_throw(vm, njs_promise_result(val)); + + } else if (state == NJS_PROMISE_PENDING && + njs_rbtree_is_empty(&ctx->waiting_events)) + { + ctx->broken = 1; + ngx_log_error(NGX_LOG_ERR, ctx->log, 0, + "js promise pending, no jobs, no waiting_events"); + return NGX_ERROR; } } @@ -741,6 +808,7 @@ ngx_engine_njs_external(ngx_engine_t *engine) return njs_vm_external_ptr(engine->u.njs.vm); } + static ngx_int_t ngx_engine_njs_pending(ngx_engine_t *e) { @@ -754,8 +822,9 @@ ngx_engine_njs_string(ngx_engine_t *e, njs_opaque_value_t *value, { ngx_int_t rc; njs_str_t s; + njs_value_t *val = njs_value_arg(value); - rc = ngx_js_string(e->u.njs.vm, njs_value_arg(value), &s); + rc = ngx_js_string(e->u.njs.vm, val, &s); str->data = s.start; str->len = s.length; @@ -874,45 +943,53 @@ ngx_engine_qjs_compile(ngx_js_loc_conf_t *conf, ngx_log_t *log, u_char *start, } -static JSValue -js_std_await(JSContext *ctx, JSValue obj) +static int +ngx_qjs_await(JSContext *ctx, JSValue *obj) { - int state, err; - JSValue ret; - JSContext *ctx1; + int rc; + JSValue ret; + JSRuntime *rt; + ngx_js_ctx_t *js_ctx; + + rt = JS_GetRuntime(ctx); + js_ctx = ngx_qjs_external_ctx(ctx, JS_GetContextOpaque(ctx)); - for (;;) { - state = JS_PromiseState(ctx, obj); + rc = ngx_qjs_execute_pending_jobs(js_ctx, rt); + if (rc == NGX_ERROR) { + return NGX_ERROR; + } + + if (JS_IsObject(*obj)) { + JSPromiseStateEnum state = JS_PromiseState(ctx, *obj); if (state == JS_PROMISE_FULFILLED) { - ret = JS_PromiseResult(ctx, obj); - JS_FreeValue(ctx, obj); - break; + ret = JS_PromiseResult(ctx, *obj); + JS_FreeValue(ctx, *obj); + *obj = ret; + return NGX_AGAIN; } else if (state == JS_PROMISE_REJECTED) { - ret = JS_Throw(ctx, JS_PromiseResult(ctx, obj)); - JS_FreeValue(ctx, obj); - break; + JSValue rejection = JS_PromiseResult(ctx, *obj); + JS_FreeValue(ctx, *obj); + *obj = JS_Throw(ctx, rejection); + return NGX_AGAIN; } else if (state == JS_PROMISE_PENDING) { - err = JS_ExecutePendingJob(JS_GetRuntime(ctx), &ctx1); - if (err < 0) { - /* js_std_dump_error(ctx1); */ + if (js_ctx && njs_rbtree_is_empty(&js_ctx->waiting_events)) { + js_ctx->broken = 1; + ngx_log_error(NGX_LOG_ERR, js_ctx->log, 0, + "js promise pending, no jobs, no waiting_events"); + return NGX_ERROR; } - - } else { - /* not a promise */ - ret = obj; - break; } } - - return ret; + return NGX_OK; } ngx_engine_t * ngx_qjs_clone(ngx_js_ctx_t *ctx, ngx_js_loc_conf_t *cf, void *external) { + int rc; JSValue rv; njs_mp_t *mp; uint32_t i, length; @@ -1000,17 +1077,14 @@ ngx_qjs_clone(ngx_js_ctx_t *ctx, ngx_js_loc_conf_t *cf, void *external) goto destroy; } - rv = js_std_await(cx, rv); - if (JS_IsException(rv)) { - ngx_qjs_exception(engine, &exception); + rc = ngx_qjs_await(cx, &rv); - ngx_log_error(NGX_LOG_ERR, ctx->log, 0, "js eval exception: %V", - &exception); + JS_FreeValue(cx, rv); + + if (rc == NGX_ERROR) { goto destroy; } - JS_FreeValue(cx, rv); - return engine; destroy: @@ -1023,6 +1097,49 @@ ngx_qjs_clone(ngx_js_ctx_t *ctx, ngx_js_loc_conf_t *cf, void *external) } +static ngx_int_t +ngx_qjs_execute_pending_jobs(ngx_js_ctx_t *ctx, JSRuntime *rt) +{ + int rc, job_count; + ngx_str_t exception; + JSContext *cx; + + if (ctx->broken) { + ngx_log_error(NGX_LOG_ERR, ctx->log, 0, + "js job queue processing exceeded %ui iterations (already)", + NGX_MAX_JOB_ITERATIONS); + return NGX_ERROR; + } + + job_count = 0; + + for ( ;; ) { + rc = JS_ExecutePendingJob(rt, &cx); + if (rc < 0) { + ngx_qjs_exception(ctx->engine, &exception); + + ngx_log_error(NGX_LOG_ERR, ctx->log, 0, + "js job exception: %V", &exception); + } + if (rc == 0) { + break; + } + + job_count++; + + if (job_count >= NGX_MAX_JOB_ITERATIONS) { + ctx->broken = 1; + ngx_log_error(NGX_LOG_ERR, ctx->log, 0, + "js job queue processing exceeded %ui iterations (first time)", + NGX_MAX_JOB_ITERATIONS); + return NGX_ERROR; + } + } + + return NGX_OK; +} + + static ngx_int_t ngx_engine_qjs_call(ngx_js_ctx_t *ctx, ngx_str_t *fname, njs_opaque_value_t *args, njs_uint_t nargs) @@ -1031,9 +1148,15 @@ ngx_engine_qjs_call(ngx_js_ctx_t *ctx, ngx_str_t *fname, JSValue fn, val; ngx_str_t exception; JSRuntime *rt; - JSContext *cx, *cx1; + JSContext *cx; cx = ctx->engine->u.qjs.ctx; + rt = JS_GetRuntime(cx); + + rc = ngx_qjs_execute_pending_jobs(ctx, rt); + if (rc != NGX_OK) { + return NGX_ERROR; + } fn = ngx_qjs_value(cx, fname); if (!JS_IsFunction(cx, fn)) { @@ -1058,22 +1181,10 @@ ngx_engine_qjs_call(ngx_js_ctx_t *ctx, ngx_str_t *fname, JS_FreeValue(cx, ngx_qjs_arg(ctx->retval)); ngx_qjs_arg(ctx->retval) = val; - rt = JS_GetRuntime(cx); + rc = ngx_qjs_await(cx, &ngx_qjs_arg(ctx->retval)); - for ( ;; ) { - rc = JS_ExecutePendingJob(rt, &cx1); - if (rc <= 0) { - if (rc == -1) { - ngx_qjs_exception(ctx->engine, &exception); - - ngx_log_error(NGX_LOG_ERR, ctx->log, 0, - "js job exception: %V", &exception); - - return NGX_ERROR; - } - - break; - } + if (rc == NGX_ERROR) { + return NGX_ERROR; } return njs_rbtree_is_empty(&ctx->waiting_events) ? NGX_OK : NGX_AGAIN; @@ -1098,7 +1209,9 @@ static ngx_int_t ngx_engine_qjs_string(ngx_engine_t *e, njs_opaque_value_t *value, ngx_str_t *str) { - return ngx_qjs_dump_obj(e, ngx_qjs_arg(*value), str); + JSValue val = ngx_qjs_arg(*value); + + return ngx_qjs_dump_obj(e, val, str); } @@ -1386,11 +1499,17 @@ ngx_qjs_call(JSContext *cx, JSValue fn, JSValue *argv, int argc) JSValue ret; ngx_str_t exception; JSRuntime *rt; - JSContext *cx1; ngx_js_ctx_t *ctx; ctx = ngx_qjs_external_ctx(cx, JS_GetContextOpaque(cx)); + rt = JS_GetRuntime(cx); + + rc = ngx_qjs_execute_pending_jobs(ctx, rt); + if (rc != NGX_OK) { + return NGX_ERROR; + } + ret = JS_Call(cx, fn, JS_UNDEFINED, argc, argv); if (JS_IsException(ret)) { ngx_qjs_exception(ctx->engine, &exception); @@ -1403,25 +1522,7 @@ ngx_qjs_call(JSContext *cx, JSValue fn, JSValue *argv, int argc) JS_FreeValue(cx, ret); - rt = JS_GetRuntime(cx); - - for ( ;; ) { - rc = JS_ExecutePendingJob(rt, &cx1); - if (rc <= 0) { - if (rc == -1) { - ngx_qjs_exception(ctx->engine, &exception); - - ngx_log_error(NGX_LOG_ERR, ctx->log, 0, - "js job exception: %V", &exception); - - return NGX_ERROR; - } - - break; - } - } - - return NGX_OK; + return ngx_qjs_execute_pending_jobs(ctx, rt); } @@ -2221,8 +2322,16 @@ ngx_js_call(njs_vm_t *vm, njs_function_t *func, njs_opaque_value_t *args, { njs_int_t ret; ngx_str_t exception; + ngx_js_ctx_t *ctx; ngx_connection_t *c; + ctx = ngx_external_ctx(vm, njs_vm_external_ptr(vm)); + + ret = ngx_njs_execute_pending_jobs(vm, ctx); + if (ret != NGX_OK) { + return NGX_ERROR; + } + ret = njs_vm_call(vm, func, njs_value_arg(args), nargs); if (ret == NJS_ERROR) { ngx_js_exception(vm, &exception); @@ -2234,24 +2343,7 @@ ngx_js_call(njs_vm_t *vm, njs_function_t *func, njs_opaque_value_t *args, return NGX_ERROR; } - for ( ;; ) { - ret = njs_vm_execute_pending_job(vm); - if (ret <= NJS_OK) { - c = ngx_external_connection(vm, njs_vm_external_ptr(vm)); - - if (ret == NJS_ERROR) { - ngx_js_exception(vm, &exception); - - ngx_log_error(NGX_LOG_ERR, c->log, 0, - "js job exception: %V", &exception); - return NGX_ERROR; - } - - break; - } - } - - return NGX_OK; + return ngx_njs_execute_pending_jobs(vm, ctx); } @@ -2367,6 +2459,7 @@ ngx_js_ctx_init(ngx_js_ctx_t *ctx, ngx_log_t *log) ctx->log = log; ctx->event_id = 0; njs_rbtree_init(&ctx->waiting_events, ngx_js_event_rbtree_compare); + ctx->broken = 0; } diff --git a/nginx/ngx_js.h b/nginx/ngx_js.h index 5a5a79b38..6a714e270 100644 --- a/nginx/ngx_js.h +++ b/nginx/ngx_js.h @@ -158,7 +158,8 @@ typedef struct { njs_opaque_value_t retval; \ njs_arr_t *rejected_promises; \ njs_rbtree_t waiting_events; \ - ngx_socket_t event_id + ngx_socket_t event_id; \ + ngx_int_t broken #define ngx_js_add_event(ctx, event) \ diff --git a/nginx/t/js_async.t b/nginx/t/js_async.t index 32d1e0a44..3cbe283a3 100644 --- a/nginx/t/js_async.t +++ b/nginx/t/js_async.t @@ -35,9 +35,10 @@ events { http { %%TEST_GLOBALS_HTTP%% - js_set $test_async test.set_timeout; - js_set $context_var test.context_var; - js_set $test_set_rv_var test.set_rv_var; + js_set $test_async test.set_timeout; + js_set $context_var test.context_var; + js_set $test_set_rv_var test.set_rv_var; + js_set $test_promise_var test.set_promise_var; js_import test.js; @@ -85,6 +86,10 @@ http { return 200 $test_set_rv_var; } + location /promise_var { + return 200 $test_promise_var; + } + location /await_reject { js_content test.await_reject; } @@ -198,6 +203,13 @@ $t->write_file('test.js', < { setTimeout(() => { @@ -213,11 +225,11 @@ $t->write_file('test.js', <try_run('no njs available')->plan(10); +$t->try_run('no njs available')->plan(11); ############################################################################### @@ -229,6 +241,7 @@ like(http_get('/limit_rate'), qr/A{50}/, 'limit_rate'); like(http_get('/async_content'), qr/retval: AB/, 'async content'); like(http_get('/set_rv_var'), qr/retval: 30/, 'set return value variable'); +like(http_get('/promise_var'), qr/retval: 30/, 'fulfilled promise variable'); http_get('/async_var'); http_get('/await_reject'); diff --git a/nginx/t/js_promise_infinite_jobs.t b/nginx/t/js_promise_infinite_jobs.t new file mode 100644 index 000000000..3a2b15c35 --- /dev/null +++ b/nginx/t/js_promise_infinite_jobs.t @@ -0,0 +1,142 @@ +#!/usr/bin/perl + +# (C) Test for infinite loop protection in microtask queue processing + +# Tests for proper handling of infinite microtask loops similar to Node.js protection. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http rewrite/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /njs { + js_content test.njs; + } + + location /infinite_microtasks { + js_content test.infinite_microtasks; + } + + location /recursive_promises { + js_content test.recursive_promises; + } + + location /normal_promise_chain { + js_content test.normal_promise_chain; + } + + } +} + +EOF + +$t->write_file('test.js', <<'EOF'); + function test_njs(r) { + r.return(200, njs.version); + } + + function infinite_microtasks(r) { + // Create an infinite microtask loop - should trigger protection limit + function infiniteLoop() { + return Promise.resolve().then(() => { + // Recursively create more microtasks + return infiniteLoop(); + }); + } + + return infiniteLoop(); + } + + function recursive_promises(r) { + // Another variation of infinite microtask generation + let count = 0; + function createPromise() { + return new Promise((resolve) => { + resolve(); + }).then(() => { + count++; + // Keep creating more promises indefinitely + return createPromise(); + }); + } + + return createPromise(); + } + + function normal_promise_chain(r) { + // Normal promise chain that should complete without hitting the limit + let result = Promise.resolve(1); + + // Create a reasonable chain of 50 promises (well under the NGX_MAX_JOB_ITERATIONS limit) + for (let i = 0; i < 50; i++) { + result = result.then((value) => value + 1); + } + + return result.then((finalValue) => { + r.return(200, "completed with value: " + finalValue); + }); + } + + export default {njs: test_njs, infinite_microtasks, recursive_promises, normal_promise_chain}; + +EOF + +$t->try_run('no njs available')->plan(5); + +############################################################################### + +# Test basic functionality +like(http_get('/njs'), qr/\d+\.\d+\.\d+/, 'njs version'); + +# Test normal promise chain (should work - under the limit) +like(http_get('/normal_promise_chain'), qr/completed with value: 51/, 'normal promise chain completes'); + +# Test infinite microtasks scenario - should trigger protection limit +my $infinite_response = http_get('/infinite_microtasks'); +like($infinite_response, qr/HTTP\/1\.[01] 500|Internal Server Error/, 'infinite microtasks causes error'); + +# Test recursive promises scenario - should also trigger protection limit +my $recursive_response = http_get('/recursive_promises'); +like($recursive_response, qr/HTTP\/1\.[01] 500|Internal Server Error/, 'recursive promises causes error'); + +$t->stop(); + +# Check error log for the specific infinite loop protection messages +my $error_log = $t->read_file('error.log'); + +# Should have error messages for job queue limit exceeded +ok(index($error_log, 'js job queue processing exceeded') > 0, + 'job queue limit exceeded message logged'); + +############################################################################### diff --git a/nginx/t/js_promise_pending.t b/nginx/t/js_promise_pending.t new file mode 100644 index 000000000..16b3ba587 --- /dev/null +++ b/nginx/t/js_promise_pending.t @@ -0,0 +1,122 @@ +#!/usr/bin/perl + +# (C) Test for NJS promise pending state handling + +# Tests for proper handling of pending promises with no waiting events. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http rewrite/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /njs { + js_content test.njs; + } + + location /promise_never_resolves { + js_content test.promise_never_resolves; + } + + location /promise_with_timeout { + js_content test.promise_with_timeout; + } + + + } +} + +EOF + +$t->write_file('test.js', <<'EOF'); + function test_njs(r) { + r.return(200, njs.version); + } + + function promise_never_resolves(r) { + // Create a promise that never resolves and has no pending events + // This should trigger the condition: + // promise_data->state == NJS_PROMISE_PENDING && + // njs_rbtree_is_empty(&ctx->waiting_events) + return new Promise((resolve, reject) => { + // Intentionally never call resolve or reject + // No setTimeout, no async operations - truly pending with no events + }); + } + + function promise_with_timeout(r) { + // Create a promise with a timeout (has waiting events) + const p = new Promise((resolve, reject) => { + setTimeout(() => { + resolve("timeout resolved"); + }, 10); + }); + + // This should NOT trigger pending error because there are waiting events + return p.then((value) => { + r.return(200, "resolved: " + value); + }); + } + + export default {njs: test_njs, promise_never_resolves, promise_with_timeout}; + +EOF + +$t->try_run('no njs available')->plan(5); + +############################################################################### + +# Test basic functionality +like(http_get('/njs'), qr/\d+\.\d+\.\d+/, 'njs version'); + +# Test promise with timeout (should work - has waiting events) +like(http_get('/promise_with_timeout'), qr/resolved: timeout resolved/, 'promise with timeout resolves'); + +# Test pending promise scenario - should trigger error response +# because it returns a promise that will never resolve with no waiting events +my $never_resolves_response = http_get('/promise_never_resolves'); +like($never_resolves_response, qr/HTTP\/1\.[01] 500|Internal Server Error/, 'never resolving promise causes error'); + +$t->stop(); + +# Check error log for the specific pending promise error message +my $error_log = $t->read_file('error.log'); + +# Now that we use ngx_log_error, the specific error message should appear in the log +ok(index($error_log, 'js promise pending, no jobs, no waiting_events') > 0, + 'pending promise error message logged'); + +# Should have no error for promises with waiting events +unlike($error_log, qr/js exception.*timeout resolved/, 'no error for promise with waiting events'); + +############################################################################### diff --git a/nginx/t/js_promise_top_level_await.t b/nginx/t/js_promise_top_level_await.t new file mode 100644 index 000000000..81d0bea7e --- /dev/null +++ b/nginx/t/js_promise_top_level_await.t @@ -0,0 +1,112 @@ +#!/usr/bin/perl + +# (C) Test for ngx_qjs_await() function +# +# Tests for proper handling of promises in ngx_qjs_await() with: +# - Job queue processing limits +# - Waiting events detection +# - Promise state handling after job processing +# +# Note: ngx_qjs_await() is called during global code evaluation, not function calls + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http rewrite/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + js_import fulfilled_test.js; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /njs { + js_content test.njs; + } + + location /resolved { + js_content test.test; + } + + location /fulfilled { + js_content fulfilled_test.test; + } + } +} + +EOF + +$t->write_file('test.js', <<'EOF'); +// Test with simple await in global code - this should work fine +var globalResult = await Promise.resolve("resolved value"); + +function test_njs(r) { + r.return(200, njs.version); +} + +function test(r) { + r.return(200, "global result: " + globalResult); +} + +export default {njs: test_njs, test}; +EOF + +$t->write_file('fulfilled_test.js', <<'EOF'); +// Test with promise that gets fulfilled via microtask queue +// This tests the JS_PROMISE_FULFILLED branch in ngx_qjs_await() +var globalResult = await new Promise((resolve) => { + // Use queueMicrotask to test microtask handling + Promise.resolve().then(() => { + resolve("fulfilled value"); + }); +}); + +function test(r) { + r.return(200, "fulfilled result: " + globalResult); +} + +export default {test}; +EOF + +$t->try_run('no qjs engine available')->plan(3); + +############################################################################### + +# Test basic functionality +like(http_get('/njs'), qr/\d+\.\d+\.\d+/, 'njs version'); + +# Test basic global await with resolved promise +like(http_get('/resolved'), qr/global result: resolved value/, 'basic global await works'); + +# Test global await with fulfilled promise (via microtask) +like(http_get('/fulfilled'), qr/fulfilled result: fulfilled value/, 'fulfilled promise via microtask works'); + +$t->stop(); + +############################################################################### diff --git a/nginx/t/js_promise_top_level_await_pending.t b/nginx/t/js_promise_top_level_await_pending.t new file mode 100644 index 000000000..cf8247b74 --- /dev/null +++ b/nginx/t/js_promise_top_level_await_pending.t @@ -0,0 +1,96 @@ +#!/usr/bin/perl + +# (C) Test for ngx_qjs_await() pending promise with empty waiting_events +# +# This test specifically validates the waiting_events checking functionality +# in ngx_qjs_await() for promises that remain pending with no waiting events + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http rewrite/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import pending_test.js; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /njs { + js_content pending_test.njs; + } + + location /pending_no_events { + js_content pending_test.test; + } + } +} + +EOF + +$t->write_file('pending_test.js', <<'EOF'); +// Test with promise that stays pending with no waiting events +// This should trigger our "promise pending, no jobs, no waiting_events" error +var globalResult = await new Promise((resolve, reject) => { + // Intentionally never call resolve or reject + // No setTimeout, no async operations - truly pending with no events +}); + +function test_njs(r) { + r.return(200, njs.version); +} + +function test(r) { + r.return(200, "should never reach this: " + globalResult); +} + +export default {njs: test_njs, test}; +EOF + +$t->try_run('no qjs engine available')->plan(3); + +############################################################################### + +# Test basic functionality (this should also fail due to the pending promise in global code) +my $njs_response = http_get('/njs'); +like($njs_response, qr/HTTP\/1\.[01] 500|Internal Server Error/, 'njs version endpoint fails due to pending promise in global code'); + +# Test pending promise with no waiting events (should cause error) +my $pending_response = http_get('/pending_no_events'); +like($pending_response, qr/HTTP\/1\.[01] 500|Internal Server Error/, 'pending promise with no waiting events causes error'); + +$t->stop(); + +# Check error log for specific error messages +my $error_log = $t->read_file('error.log'); + +# Check for waiting events error message from our ngx_qjs_await() improvements +ok(index($error_log, 'js promise pending, no jobs, no waiting_events') > 0, + 'waiting events error message logged'); + +############################################################################### diff --git a/nginx/t/js_promise_top_level_infinite_jobs.t b/nginx/t/js_promise_top_level_infinite_jobs.t new file mode 100644 index 000000000..8bac56f74 --- /dev/null +++ b/nginx/t/js_promise_top_level_infinite_jobs.t @@ -0,0 +1,102 @@ +#!/usr/bin/perl + +# (C) Test for ngx_qjs_await() infinite job queue protection +# +# This test specifically validates the NGX_MAX_JOB_ITERATIONS limit +# in ngx_qjs_await() for infinite microtask loops + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http rewrite/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import infinite_jobs_test.js; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /njs { + js_content infinite_jobs_test.njs; + } + + location /infinite_jobs { + js_content infinite_jobs_test.test; + } + } +} + +EOF + +$t->write_file('infinite_jobs_test.js', <<'EOF'); +// Test with infinite job queue in global code +// This should trigger the NGX_MAX_JOB_ITERATIONS limit in ngx_qjs_await() +function createInfiniteJobs() { + return Promise.resolve().then(() => { + // Create more microtasks infinitely + return createInfiniteJobs(); + }); +} + +createInfiniteJobs(); + +function test_njs(r) { + r.return(200, njs.version); +} + +function test(r) { + r.return(200, "should never reach this"); +} + +export default {njs: test_njs, test}; +EOF + +$t->try_run('no njs available')->plan(3); + +############################################################################### + +# Note: With the ngx_qjs_clone improvements, workers no longer crash +# when our protection mechanism triggers. They return proper 500 errors. + +# Test endpoints - these should now return 500 errors instead of crashing workers +my $njs_response = http_get('/njs'); +like($njs_response, qr/HTTP\/1\.[01] 500|Internal Server Error/, 'njs endpoint returns 500 error due to infinite jobs protection'); + +my $infinite_response = http_get('/infinite_jobs'); +like($infinite_response, qr/HTTP\/1\.[01] 500|Internal Server Error/, 'infinite_jobs endpoint returns 500 error due to protection'); + +$t->stop(); + +# Check error log for specific error messages +my $error_log = $t->read_file('error.log'); + +# Check for job queue limit exceeded message from ngx_qjs_await() +ok(index($error_log, 'js job queue processing exceeded') > 0, + 'job queue limit exceeded error message logged'); + +############################################################################### diff --git a/nginx/t/stream_js.t b/nginx/t/stream_js.t index 0834b68a9..e514e4a79 100644 --- a/nginx/t/stream_js.t +++ b/nginx/t/stream_js.t @@ -68,6 +68,7 @@ stream { js_set $js_req_line test.req_line; js_set $js_sess_unk test.sess_unk; js_set $js_async test.asyncf; + js_set $js_async1 test.asyncf1; js_set $js_buffer test.buffer; js_import test.js; @@ -192,6 +193,11 @@ stream { return $js_async; } + server { + listen 127.0.0.1:8102; + return $js_async1; + } + server { listen 127.0.0.1:8101; return $js_buffer; @@ -384,17 +390,24 @@ $t->write_file('test.js', <run_daemon(\&stream_daemon, port(8090)); -$t->try_run('no stream njs available')->plan(25); +$t->try_run('no stream njs available')->plan(26); $t->waitforsocket('127.0.0.1:' . port(8090)); ############################################################################### @@ -429,6 +442,9 @@ stream('127.0.0.1:' . port(8099))->io('x'); is(stream('127.0.0.1:' . port(8100))->read(), 'retval: 30', 'asyncf'); +is(stream('127.0.0.1:' . port(8102))->read(), 'retval: 30', 'asyncf1'); + + TODO: { local $TODO = 'not yet' unless has_version('0.8.3'); diff --git a/src/njs.h b/src/njs.h index 702e74b5a..268f1b722 100644 --- a/src/njs.h +++ b/src/njs.h @@ -34,6 +34,7 @@ typedef struct njs_function_s njs_function_t; typedef struct njs_vm_shared_s njs_vm_shared_t; typedef struct njs_object_init_s njs_object_init_t; typedef struct njs_object_prop_s njs_object_prop_t; +typedef struct njs_promise_data_s njs_promise_data_t; typedef struct njs_object_prop_init_s njs_object_prop_init_t; typedef struct njs_object_type_init_s njs_object_type_init_t; typedef struct njs_external_s njs_external_t; @@ -218,6 +219,13 @@ typedef void * njs_external_ptr_t; typedef njs_mod_t *(*njs_module_loader_t)(njs_vm_t *vm, njs_external_ptr_t external, njs_str_t *name); + +typedef enum { + NJS_PROMISE_PENDING = 0, + NJS_PROMISE_FULFILL, + NJS_PROMISE_REJECTED +} njs_promise_type_t; + typedef void (*njs_rejection_tracker_t)(njs_vm_t *vm, njs_external_ptr_t external, njs_bool_t is_handled, njs_value_t *promise, njs_value_t *reason); @@ -505,6 +513,9 @@ NJS_EXPORT njs_int_t njs_value_is_array(const njs_value_t *value); NJS_EXPORT njs_int_t njs_value_is_function(const njs_value_t *value); NJS_EXPORT njs_int_t njs_value_is_buffer(const njs_value_t *value); NJS_EXPORT njs_int_t njs_value_is_data_view(const njs_value_t *value); +NJS_EXPORT njs_int_t njs_value_is_promise(const njs_value_t *value); +NJS_EXPORT njs_promise_type_t njs_promise_state(const njs_value_t *value); +NJS_EXPORT njs_value_t *njs_promise_result(const njs_value_t *value); NJS_EXPORT njs_int_t njs_vm_object_alloc(njs_vm_t *vm, njs_value_t *retval, ...); diff --git a/src/njs_promise.c b/src/njs_promise.c index a0ca85162..b61a5272f 100644 --- a/src/njs_promise.c +++ b/src/njs_promise.c @@ -912,6 +912,26 @@ njs_promise_perform_then(njs_vm_t *vm, njs_value_t *value, } +njs_promise_type_t +njs_promise_state(const njs_value_t *value) +{ + njs_promise_t *promise = njs_promise(value); + njs_promise_data_t *promise_data = njs_data(&promise->value); + + return promise_data->state; +} + + +njs_value_t * +njs_promise_result(const njs_value_t *value) +{ + njs_promise_t *promise = njs_promise(value); + njs_promise_data_t *promise_data = njs_data(&promise->value); + + return &promise_data->result; +} + + static njs_int_t njs_promise_prototype_catch(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t unused, njs_value_t *retval) diff --git a/src/njs_promise.h b/src/njs_promise.h index 53d179a9d..6a942545a 100644 --- a/src/njs_promise.h +++ b/src/njs_promise.h @@ -7,25 +7,20 @@ #define _NJS_PROMISE_H_INCLUDED_ -typedef enum { - NJS_PROMISE_PENDING = 0, - NJS_PROMISE_FULFILL, - NJS_PROMISE_REJECTED -} njs_promise_type_t; - typedef struct { njs_value_t promise; njs_value_t resolve; njs_value_t reject; } njs_promise_capability_t; -typedef struct { + +struct njs_promise_data_s { njs_promise_type_t state; njs_value_t result; njs_queue_t fulfill_queue; njs_queue_t reject_queue; njs_bool_t is_handled; -} njs_promise_data_t; +}; njs_int_t njs_promise_constructor(njs_vm_t *vm, njs_value_t *args, diff --git a/src/njs_value.c b/src/njs_value.c index 7959c4ed8..09aa9dfcd 100644 --- a/src/njs_value.c +++ b/src/njs_value.c @@ -538,6 +538,13 @@ njs_value_is_data_view(const njs_value_t *value) } +njs_int_t +njs_value_is_promise(const njs_value_t *value) +{ + return njs_is_promise(value); +} + + /* * ES5.1, 8.12.1: [[GetOwnProperty]], [[GetProperty]]. * The njs_property_query() returns values From f030d19bccb407fe88eac5a0e5b643fa04e5d4e7 Mon Sep 17 00:00:00 2001 From: Vadim Zhestikov Date: Thu, 17 Jul 2025 13:34:13 -0700 Subject: [PATCH 2/2] Introduced crypto.subtle.generateCertificate(). It fixes _766 on GitHub. --- external/njs_webcrypto_module.c | 373 +++++++++ external/qjs_webcrypto_module.c | 394 +++++++++ nginx/t/js_webcrypto_generate_certificate.t | 731 ++++++++++++++++ ...stream_js_webcrypto_generate_certificate.t | 781 ++++++++++++++++++ test/webcrypto/cert.t.mjs | 142 ++++ ts/njs_webcrypto.d.ts | 100 +++ 6 files changed, 2521 insertions(+) create mode 100644 nginx/t/js_webcrypto_generate_certificate.t create mode 100644 nginx/t/stream_js_webcrypto_generate_certificate.t create mode 100644 test/webcrypto/cert.t.mjs diff --git a/external/njs_webcrypto_module.c b/external/njs_webcrypto_module.c index b9a743534..93db0b539 100644 --- a/external/njs_webcrypto_module.c +++ b/external/njs_webcrypto_module.c @@ -119,6 +119,8 @@ static njs_int_t njs_ext_export_key(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t unused, njs_value_t *retval); static njs_int_t njs_ext_generate_key(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t unused, njs_value_t *retval); +static njs_int_t njs_ext_generate_certificate(njs_vm_t *vm, njs_value_t *args, + njs_uint_t nargs, njs_index_t unused, njs_value_t *retval); static njs_int_t njs_ext_import_key(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, njs_index_t unused, njs_value_t *retval); static njs_int_t njs_ext_sign(njs_vm_t *vm, njs_value_t *args, @@ -519,6 +521,17 @@ static njs_external_t njs_ext_subtle_webcrypto[] = { } }, + { + .flags = NJS_EXTERN_METHOD, + .name.string = njs_str("generateCertificate"), + .writable = 1, + .configurable = 1, + .enumerable = 1, + .u.method = { + .native = njs_ext_generate_certificate, + } + }, + { .flags = NJS_EXTERN_METHOD, .name.string = njs_str("importKey"), @@ -2891,6 +2904,366 @@ njs_ext_generate_key(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, } +static njs_int_t +njs_add_name_attributes(njs_vm_t *vm, X509_NAME *name, njs_value_t *attrs) +{ + njs_int_t ret; + njs_value_t *val; + njs_str_t attr_value; + njs_opaque_value_t lvalue; + + static const njs_str_t string_CN = njs_str("CN"); + static const njs_str_t string_C = njs_str("C"); + static const njs_str_t string_ST = njs_str("ST"); + static const njs_str_t string_L = njs_str("L"); + static const njs_str_t string_O = njs_str("O"); + static const njs_str_t string_OU = njs_str("OU"); + static const njs_str_t string_E = njs_str("E"); + + /* Define attribute mapping */ + struct { + const njs_str_t *key; + const char *openssl_name; + } attr_map[] = { + { &string_CN, "CN" }, + { &string_C, "C" }, + { &string_ST, "ST" }, + { &string_L, "L" }, + { &string_O, "O" }, + { &string_OU, "OU" }, + { &string_E, "E" } + }; + + int attr_count = 0; + + for (size_t i = 0; i < sizeof(attr_map) / sizeof(attr_map[0]); i++) { + val = njs_vm_object_prop(vm, attrs, attr_map[i].key, &lvalue); + if (val != NULL) { + ret = njs_vm_value_to_bytes(vm, &attr_value, val); + if (njs_slow_path(ret != NJS_OK)) { + return NJS_ERROR; + } + + if (X509_NAME_add_entry_by_txt(name, attr_map[i].openssl_name, MBSTRING_ASC, + attr_value.start, attr_value.length, -1, 0) != 1) { + njs_webcrypto_error(vm, "X509_NAME_add_entry_by_txt() failed"); + return NJS_ERROR; + } + attr_count++; + } + } + + if (attr_count == 0) { + njs_webcrypto_error(vm, "X509_NAME_add_entry_by_txt() failed"); + return NJS_ERROR; + } + + return NJS_OK; +} + +static njs_int_t +njs_ext_generate_certificate(njs_vm_t *vm, njs_value_t *args, njs_uint_t nargs, + njs_index_t unused, njs_value_t *retval) +{ + njs_int_t ret; + njs_value_t *options, *keyPair, *privKey, *pubKey, *val; + njs_opaque_value_t result, lvalue; + njs_webcrypto_key_t *privateKey, *publicKey; + njs_str_t serialNumber, cert_data; + X509 *cert = NULL; + X509_NAME *name = NULL; + ASN1_INTEGER *serial_asn1 = NULL; + EVP_PKEY *pkey = NULL; + BIGNUM *serial_bn = NULL; + u_char *cert_buf = NULL; + int cert_len; + int64_t notBefore, notAfter; + int issuer_is_subject = 0; + + static const njs_str_t string_subject = njs_str("subject"); + static const njs_str_t string_issuer = njs_str("issuer"); + static const njs_str_t string_serialNumber = njs_str("serialNumber"); + static const njs_str_t string_notBefore = njs_str("notBefore"); + static const njs_str_t string_notAfter = njs_str("notAfter"); + static const njs_str_t string_privateKey = njs_str("privateKey"); + static const njs_str_t string_publicKey = njs_str("publicKey"); + + if (nargs < 3) { + njs_vm_type_error(vm, "generateCertificate requires at least 2 arguments"); + return NJS_ERROR; + } + + options = njs_arg(args, nargs, 1); + keyPair = njs_arg(args, nargs, 2); + + if (!njs_value_is_object(options)) { + njs_vm_type_error(vm, "generateCertificate options must be an object"); + return NJS_ERROR; + } + + if (!njs_value_is_object(keyPair)) { + njs_vm_type_error(vm, "generateCertificate keyPair must be an object"); + return NJS_ERROR; + } + + /* Get private key from keyPair */ + privKey = njs_vm_object_prop(vm, keyPair, &string_privateKey, &lvalue); + if (njs_slow_path(privKey == NULL)) { + njs_vm_type_error(vm, "keyPair.privateKey is required"); + return NJS_ERROR; + } + + privateKey = njs_vm_external(vm, njs_webcrypto_crypto_key_proto_id, privKey); + if (njs_slow_path(privateKey == NULL)) { + njs_vm_type_error(vm, "keyPair.privateKey is not a CryptoKey object"); + return NJS_ERROR; + } + + /* Get public key from keyPair */ + pubKey = njs_vm_object_prop(vm, keyPair, &string_publicKey, &lvalue); + if (njs_slow_path(pubKey == NULL)) { + njs_vm_type_error(vm, "keyPair.publicKey is required"); + return NJS_ERROR; + } + + publicKey = njs_vm_external(vm, njs_webcrypto_crypto_key_proto_id, pubKey); + if (njs_slow_path(publicKey == NULL)) { + njs_vm_type_error(vm, "keyPair.publicKey is not a CryptoKey object"); + return NJS_ERROR; + } + + /* Extract subject */ +njs_opaque_value_t subject_lvalue, issuer_lvalue; + njs_value_t *subject_val = njs_vm_object_prop(vm, options, &string_subject, &subject_lvalue); + if (njs_slow_path(subject_val == NULL)) { + njs_vm_type_error(vm, "certificate subject is required"); + return NJS_ERROR; + } + + if (!njs_value_is_object(subject_val)) { + njs_vm_type_error(vm, "certificate subject must be an object"); + return NJS_ERROR; + } +njs_value_t *subject_attrs = subject_val; + + /* Extract issuer (optional, defaults to subject for self-signed) */ +njs_value_t *issuer_val = njs_vm_object_prop(vm, options, &string_issuer, &issuer_lvalue); +njs_value_t *issuer_attrs = NULL; + if (issuer_val != NULL) { + if (!njs_value_is_object(issuer_val)) { + njs_vm_type_error(vm, "certificate issuer must be an object"); + return NJS_ERROR; + } + issuer_attrs = issuer_val; + } else { + issuer_is_subject = 1; /* Self-signed certificate */ + } + + /* Extract serial number (optional, defaults to "1") */ + val = njs_vm_object_prop(vm, options, &string_serialNumber, &lvalue); + if (val != NULL) { + ret = njs_vm_value_to_bytes(vm, &serialNumber, val); + if (njs_slow_path(ret != NJS_OK)) { + return NJS_ERROR; + } + } else { + serialNumber.start = NULL; + serialNumber.length = 0; + } + + /* Extract validity period */ + val = njs_vm_object_prop(vm, options, &string_notBefore, &lvalue); + if (val != NULL) { + ret = njs_value_to_integer(vm, val, ¬Before); + if (njs_slow_path(ret != NJS_OK)) { + return NJS_ERROR; + } + } else { + notBefore = 0; /* Now */ + } + + val = njs_vm_object_prop(vm, options, &string_notAfter, &lvalue); + if (val != NULL) { + ret = njs_value_to_integer(vm, val, ¬After); + if (njs_slow_path(ret != NJS_OK)) { + return NJS_ERROR; + } + } else { + notAfter = 365LL * 24 * 60 * 60 * 1000; /* 1 year from now */ + } + + /* Create X509 certificate */ + cert = X509_new(); + if (njs_slow_path(cert == NULL)) { + njs_webcrypto_error(vm, "X509_new() failed"); + goto fail; + } + + /* Set version (X509v3) */ + if (X509_set_version(cert, 2) != 1) { + njs_webcrypto_error(vm, "X509_set_version() failed"); + goto fail; + } + + /* Set serial number */ + serial_asn1 = ASN1_INTEGER_new(); + if (njs_slow_path(serial_asn1 == NULL)) { + njs_webcrypto_error(vm, "ASN1_INTEGER_new() failed"); + goto fail; + } + + if (serialNumber.start != NULL && serialNumber.length > 0) { + /* Convert serialNumber to BIGNUM */ + serial_bn = BN_bin2bn(serialNumber.start, serialNumber.length, NULL); + if (njs_slow_path(serial_bn == NULL)) { + njs_webcrypto_error(vm, "BN_bin2bn() failed"); + goto fail; + } + + if (BN_to_ASN1_INTEGER(serial_bn, serial_asn1) == NULL) { + njs_webcrypto_error(vm, "BN_to_ASN1_INTEGER() failed"); + goto fail; + } + } else { + /* Default to serial number 1 */ + if (ASN1_INTEGER_set_uint64(serial_asn1, 1) != 1) { + njs_webcrypto_error(vm, "ASN1_INTEGER_set_uint64() failed"); + goto fail; + } + } + + if (X509_set_serialNumber(cert, serial_asn1) != 1) { + njs_webcrypto_error(vm, "X509_set_serialNumber() failed"); + goto fail; + } + + /* Set validity period */ + if (X509_gmtime_adj(X509_getm_notBefore(cert), notBefore / 1000) == NULL) { + njs_webcrypto_error(vm, "X509_gmtime_adj(notBefore) failed"); + goto fail; + } + + if (X509_gmtime_adj(X509_getm_notAfter(cert), notAfter / 1000) == NULL) { + njs_webcrypto_error(vm, "X509_gmtime_adj(notAfter) failed"); + goto fail; + } + + /* Set subject name */ + name = X509_NAME_new(); + if (njs_slow_path(name == NULL)) { + njs_webcrypto_error(vm, "X509_NAME_new() failed"); + goto fail; + } + + ret = njs_add_name_attributes(vm, name, subject_attrs); + if (njs_slow_path(ret != NJS_OK)) { + goto fail; + } + + if (X509_set_subject_name(cert, name) != 1) { + njs_webcrypto_error(vm, "X509_set_subject_name() failed"); + goto fail; + } + + if (issuer_is_subject) { + /* Set issuer name (same as subject for self-signed) */ + if (X509_set_issuer_name(cert, name) != 1) { + njs_webcrypto_error(vm, "X509_set_issuer_name() failed"); + goto fail; + } + } else { + /* Create separate issuer name */ + X509_NAME *issuer_name = X509_NAME_new(); + if (njs_slow_path(issuer_name == NULL)) { + njs_webcrypto_error(vm, "X509_NAME_new() failed"); + goto fail; + } + + ret = njs_add_name_attributes(vm, issuer_name, issuer_attrs); + if (njs_slow_path(ret != NJS_OK)) { + X509_NAME_free(issuer_name); + goto fail; + } + + if (X509_set_issuer_name(cert, issuer_name) != 1) { + njs_webcrypto_error(vm, "X509_set_issuer_name() failed"); + X509_NAME_free(issuer_name); + goto fail; + } + + X509_NAME_free(issuer_name); + } + + /* Set public key */ + pkey = privateKey->u.a.pkey; + if (X509_set_pubkey(cert, pkey) != 1) { + njs_webcrypto_error(vm, "X509_set_pubkey() failed"); + goto fail; + } + + /* Sign the certificate with the private key */ + if (X509_sign(cert, pkey, EVP_sha256()) == 0) { + njs_webcrypto_error(vm, "X509_sign() failed"); + goto fail; + } + + /* Convert certificate to DER format */ + cert_len = i2d_X509(cert, &cert_buf); + if (njs_slow_path(cert_len <= 0)) { + njs_webcrypto_error(vm, "i2d_X509() failed"); + goto fail; + } + + /* Create result array buffer */ + cert_data.start = cert_buf; + cert_data.length = cert_len; + + ret = njs_webcrypto_array_buffer(vm, njs_value_arg(&result), + cert_data.start, cert_data.length); + if (njs_slow_path(ret != NJS_OK)) { + goto fail; + } + + /* Cleanup */ + if (serial_bn != NULL) { + BN_free(serial_bn); + } + if (serial_asn1 != NULL) { + ASN1_INTEGER_free(serial_asn1); + } + if (name != NULL) { + X509_NAME_free(name); + } + if (cert != NULL) { + X509_free(cert); + } + if (cert_buf != NULL) { + OPENSSL_free(cert_buf); + } + + return njs_webcrypto_result(vm, &result, NJS_OK, retval); + +fail: + if (serial_bn != NULL) { + BN_free(serial_bn); + } + if (serial_asn1 != NULL) { + ASN1_INTEGER_free(serial_asn1); + } + if (name != NULL) { + X509_NAME_free(name); + } + if (cert != NULL) { + X509_free(cert); + } + if (cert_buf != NULL) { + OPENSSL_free(cert_buf); + } + + return njs_webcrypto_result(vm, NULL, NJS_ERROR, retval); +} + + static BIGNUM * njs_import_base64url_bignum(njs_vm_t *vm, njs_opaque_value_t *value) { diff --git a/external/qjs_webcrypto_module.c b/external/qjs_webcrypto_module.c index b9c645d98..b5659b197 100644 --- a/external/qjs_webcrypto_module.c +++ b/external/qjs_webcrypto_module.c @@ -125,6 +125,8 @@ static JSValue qjs_webcrypto_export_key(JSContext *cx, JSValueConst this_val, int argc, JSValueConst *argv); static JSValue qjs_webcrypto_generate_key(JSContext *cx, JSValueConst this_val, int argc, JSValueConst *argv); +static JSValue qjs_webcrypto_generate_certificate(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv); static JSValue qjs_webcrypto_import_key(JSContext *cx, JSValueConst this_val, int argc, JSValueConst *argv); static JSValue qjs_webcrypto_sign(JSContext *cx, JSValueConst this_val, @@ -439,6 +441,7 @@ static const JSCFunctionListEntry qjs_webcrypto_subtle[] = { JS_CFUNC_MAGIC_DEF("encrypt", 4, qjs_webcrypto_cipher, 1), JS_CFUNC_DEF("exportKey", 3, qjs_webcrypto_export_key), JS_CFUNC_DEF("generateKey", 3, qjs_webcrypto_generate_key), + JS_CFUNC_DEF("generateCertificate", 3, qjs_webcrypto_generate_certificate), JS_CFUNC_MAGIC_DEF("sign", 4, qjs_webcrypto_sign, 0), JS_CFUNC_MAGIC_DEF("verify", 4, qjs_webcrypto_sign, 1), }; @@ -2665,6 +2668,397 @@ qjs_webcrypto_generate_key(JSContext *cx, JSValueConst this_val, } +static int +qjs_add_name_attributes(JSContext *cx, X509_NAME *name, JSValueConst attrs) +{ + JSValue val; + const char *attr_str; + size_t attr_len; + + /* Define attribute mapping */ + struct { + const char *key; + const char *openssl_name; + } attr_map[] = { + { "CN", "CN" }, + { "C", "C" }, + { "ST", "ST" }, + { "L", "L" }, + { "O", "O" }, + { "OU", "OU" }, + { "E", "E" } + }; + + int attr_count = 0; + + for (size_t i = 0; i < sizeof(attr_map) / sizeof(attr_map[0]); i++) { + val = JS_GetPropertyStr(cx, attrs, attr_map[i].key); + if (!JS_IsUndefined(val)) { + if (JS_IsString(val)) { + attr_str = JS_ToCStringLen(cx, &attr_len, val); + if (attr_str == NULL) { + JS_FreeValue(cx, val); + return -1; + } + + if (X509_NAME_add_entry_by_txt(name, attr_map[i].openssl_name, + MBSTRING_ASC, + (const u_char*)attr_str, + attr_len, -1, 0) != 1) + { + qjs_webcrypto_error(cx, "X509_NAME_add_entry_by_txt() failed"); + JS_FreeCString(cx, attr_str); + JS_FreeValue(cx, val); + return -1; + } + + JS_FreeCString(cx, attr_str); + attr_count++; + } + } + JS_FreeValue(cx, val); + } + + if (attr_count == 0) { + qjs_webcrypto_error(cx, "X509_NAME_add_entry_by_txt() failed"); + return -1; + } + + return 0; +} + +static JSValue +qjs_webcrypto_generate_certificate(JSContext *cx, JSValueConst this_val, + int argc, JSValueConst *argv) +{ + JSValue ret, options, keyPair, privateKey, publicKey, val, + subject_attrs, issuer_attrs; + qjs_webcrypto_key_t *priv_key = NULL, *pub_key = NULL; + qjs_bytes_t serialNumber; + X509 *cert = NULL; + X509_NAME *subj_name = NULL, *issuer_name = NULL; + ASN1_INTEGER *serial_asn1 = NULL; + EVP_PKEY *priv_pkey = NULL, *pub_pkey = NULL; + u_char *cert_buf = NULL; + int cert_len; + int64_t notBefore, notAfter; + JSValue result = JS_UNDEFINED; + BIGNUM *serial_bn = NULL; + int issuer_is_subject = 0; + + /* Initialize bytes structures */ + memset(&serialNumber, 0, sizeof(serialNumber)); + + if (argc < 2) { + return qjs_promise_result(cx, JS_ThrowTypeError(cx, "generateCertificate requires at least 2 arguments")); + } + + options = argv[0]; + keyPair = argv[1]; + + if (!JS_IsObject(options)) { + return qjs_promise_result(cx, JS_ThrowTypeError(cx, "generateCertificate options must be an object")); + } + + if (!JS_IsObject(keyPair)) { + return qjs_promise_result(cx, JS_ThrowTypeError(cx, "generateCertificate keyPair must be an object")); + } + + publicKey = JS_UNDEFINED; + issuer_attrs = JS_UNDEFINED; + subject_attrs = JS_UNDEFINED; + + /* Get private key from keyPair */ + privateKey = JS_GetPropertyStr(cx, keyPair, "privateKey"); + if (JS_IsException(privateKey)) { + ret = privateKey; + goto cleanup; + } + + if (JS_IsUndefined(privateKey)) { + ret = JS_ThrowTypeError(cx, "keyPair.privateKey is required"); + goto cleanup; + } + + priv_key = JS_GetOpaque2(cx, privateKey, QJS_CORE_CLASS_ID_WEBCRYPTO_KEY); + if (priv_key == NULL) { + ret = JS_ThrowTypeError(cx, "keyPair.privateKey is not a CryptoKey object"); + goto cleanup; + } + + /* Get public key from keyPair */ + publicKey = JS_GetPropertyStr(cx, keyPair, "publicKey"); + if (JS_IsException(publicKey)) { + ret = publicKey; + goto cleanup; + } + + pub_key = JS_GetOpaque2(cx, publicKey, QJS_CORE_CLASS_ID_WEBCRYPTO_KEY); + if (pub_key == NULL) { + ret = JS_ThrowTypeError(cx, "keyPair.publicKey is not a CryptoKey object"); + goto cleanup; + } + + + /* Extract subject */ + val = JS_GetPropertyStr(cx, options, "subject"); + if (JS_IsException(val)) { + ret = val; + goto cleanup; + } + if (JS_IsUndefined(val)) { + ret = JS_ThrowTypeError(cx, "certificate subject is required"); + goto cleanup; + } + + if (!JS_IsObject(val)) { + ret = JS_ThrowTypeError(cx, "certificate subject must be an object"); + goto cleanup; + } + subject_attrs = JS_DupValue(cx, val); + JS_FreeValue(cx, val); + + /* Extract issuer (optional, defaults to subject for self-signed) */ + val = JS_GetPropertyStr(cx, options, "issuer"); + if (JS_IsException(val)) { + ret = val; + goto cleanup; + } + + if (!JS_IsUndefined(val)) { + if (!JS_IsObject(val)) { + ret = JS_ThrowTypeError(cx, "certificate issuer must be an object"); + goto cleanup; + } + issuer_attrs = JS_DupValue(cx, val); + } else { + issuer_is_subject = 1; /* Self-signed certificate */ + } + JS_FreeValue(cx, val); + + /* Extract serial number (optional, defaults to "1") */ + val = JS_GetPropertyStr(cx, options, "serialNumber"); + if (JS_IsException(val)) { + ret = val; + goto cleanup; + } + if (!JS_IsUndefined(val)) { + if (qjs_to_bytes(cx, &serialNumber, val) != 0) { + ret = JS_EXCEPTION; + goto cleanup; + } + } + JS_FreeValue(cx, val); + + /* Extract validity period */ + val = JS_GetPropertyStr(cx, options, "notBefore"); + if (JS_IsException(val)) { + ret = val; + goto cleanup; + } + if (!JS_IsUndefined(val)) { + if (JS_ToInt64(cx, ¬Before, val) < 0) { + ret = JS_EXCEPTION; + goto cleanup; + } + } else { + notBefore = 0; /* Now */ + } + JS_FreeValue(cx, val); + + val = JS_GetPropertyStr(cx, options, "notAfter"); + if (JS_IsException(val)) { + ret = val; + goto cleanup; + } + if (!JS_IsUndefined(val)) { + if (JS_ToInt64(cx, ¬After, val) < 0) { + ret = JS_EXCEPTION; + goto cleanup; + } + } else { + notAfter = 365LL * 24 * 60 * 60 * 1000; /* 1 year from now */ + } + JS_FreeValue(cx, val); + + /* Create X509 certificate */ + cert = X509_new(); + if (cert == NULL) { + qjs_webcrypto_error(cx, "X509_new() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + /* Set version (X509v3) */ + if (X509_set_version(cert, 2) != 1) { + qjs_webcrypto_error(cx, "X509_set_version() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + /* Set serial number */ + serial_asn1 = ASN1_INTEGER_new(); + if (serial_asn1 == NULL) { + qjs_webcrypto_error(cx, "ASN1_INTEGER_new() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + if (serialNumber.start != NULL) { + /* Convert serialNumber to BIGNUM */ + serial_bn = BN_bin2bn(serialNumber.start, serialNumber.length, NULL); + if (serial_bn == NULL) { + qjs_webcrypto_error(cx, "BN_bin2bn() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + if (BN_to_ASN1_INTEGER(serial_bn, serial_asn1) == NULL) { + qjs_webcrypto_error(cx, "BN_to_ASN1_INTEGER() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + } else { + /* Default to serial number 1 */ + if (ASN1_INTEGER_set_uint64(serial_asn1, 1) != 1) { + qjs_webcrypto_error(cx, "ASN1_INTEGER_set_uint64() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + } + + if (X509_set_serialNumber(cert, serial_asn1) != 1) { + qjs_webcrypto_error(cx, "X509_set_serialNumber() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + /* Set validity period */ + if (X509_gmtime_adj(X509_getm_notBefore(cert), notBefore / 1000) == NULL) { + qjs_webcrypto_error(cx, "X509_gmtime_adj(notBefore) failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + if (X509_gmtime_adj(X509_getm_notAfter(cert), notAfter / 1000) == NULL) { + qjs_webcrypto_error(cx, "X509_gmtime_adj(notAfter) failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + /* Set subject name */ + subj_name = X509_NAME_new(); + if (subj_name == NULL) { + qjs_webcrypto_error(cx, "X509_NAME_new() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + if (qjs_add_name_attributes(cx, subj_name, subject_attrs) != 0) { + ret = JS_EXCEPTION; + goto cleanup; + } + + if (X509_set_subject_name(cert, subj_name) != 1) { + qjs_webcrypto_error(cx, "X509_set_subject_name() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + /* Set issuer name */ + if (issuer_is_subject) { + /* Self-signed certificate (same as subject) */ + if (X509_set_issuer_name(cert, subj_name) != 1) { + qjs_webcrypto_error(cx, "X509_set_issuer_name() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + } else { + /* Different issuer */ + issuer_name = X509_NAME_new(); + if (issuer_name == NULL) { + qjs_webcrypto_error(cx, "X509_NAME_new() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + if (qjs_add_name_attributes(cx, issuer_name, issuer_attrs) != 0) { + ret = JS_EXCEPTION; + goto cleanup; + } + + if (X509_set_issuer_name(cert, issuer_name) != 1) { + qjs_webcrypto_error(cx, "X509_set_issuer_name() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + } + + /* Set public key from pub_key, not priv_key */ + pub_pkey = pub_key->u.a.pkey; + if (X509_set_pubkey(cert, pub_pkey) != 1) { + qjs_webcrypto_error(cx, "X509_set_pubkey() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + /* Sign the certificate with the private key */ + priv_pkey = priv_key->u.a.pkey; + if (X509_sign(cert, priv_pkey, EVP_sha256()) == 0) { + qjs_webcrypto_error(cx, "X509_sign() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + /* Convert certificate to DER format */ + cert_len = i2d_X509(cert, &cert_buf); + if (cert_len <= 0) { + qjs_webcrypto_error(cx, "i2d_X509() failed"); + ret = JS_EXCEPTION; + goto cleanup; + } + + /* Create result array buffer */ + result = JS_NewArrayBufferCopy(cx, cert_buf, cert_len); + if (JS_IsException(result)) { + ret = result; + goto cleanup; + } + + ret = result; + +cleanup: + if (serial_bn != NULL) { + BN_free(serial_bn); + } + if (serial_asn1 != NULL) { + ASN1_INTEGER_free(serial_asn1); + } + if (subj_name != NULL) { + X509_NAME_free(subj_name); + } + if (issuer_name != NULL) { + X509_NAME_free(issuer_name); + } + if (cert != NULL) { + X509_free(cert); + } + if (cert_buf != NULL) { + OPENSSL_free(cert_buf); + } + if (serialNumber.start != NULL) { + qjs_bytes_free(cx, &serialNumber); + } + JS_FreeValue(cx, privateKey); + JS_FreeValue(cx, publicKey); + JS_FreeValue(cx, subject_attrs); + JS_FreeValue(cx, issuer_attrs); + + /* Don't free result here - it's consumed by qjs_promise_result() */ + + return qjs_promise_result(cx, ret); +} + + static BIGNUM * qjs_import_base64url_bignum(JSContext *cx, JSValue value) { diff --git a/nginx/t/js_webcrypto_generate_certificate.t b/nginx/t/js_webcrypto_generate_certificate.t new file mode 100644 index 000000000..2106da9cd --- /dev/null +++ b/nginx/t/js_webcrypto_generate_certificate.t @@ -0,0 +1,731 @@ +#!/usr/bin/perl + +# Tests for http njs module, WebCrypto generateCertificate function. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http http_ssl rewrite socket_ssl/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + js_shared_dict_zone zone=keypairs:1m; + + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /rsa_cert_test { + js_content test.rsa_cert_test; + } + + location /ecdsa_cert_test { + js_content test.ecdsa_cert_test; + } + + location /cert_with_issuer_test { + js_content test.cert_with_issuer_test; + } + + location /cert_validation_test { + js_content test.cert_validation_test; + } + + location /rsa_cert_pem { + js_content test.rsa_cert_pem; + } + + location /rsa_key_pem { + js_content test.rsa_key_pem; + } + + location /ecdsa_cert_pem { + js_content test.ecdsa_cert_pem; + } + + location /openssl_validation_cert_pem { + js_content test.openssl_validation_cert_pem; + } + + location /self_signed_ca_cert { + js_content test.self_signed_ca_cert; + } + + location /client_cert_for_ca { + js_content test.client_cert_for_ca; + } + } + + + server { + listen 127.0.0.1:8081 ssl; + server_name default.example.com; + + js_set $cert_file test.get_cert_file; + js_set $key_file test.get_key_file; + + ssl_certificate $cert_file; + ssl_certificate_key $key_file; + + location /backend { + return 200 "BACKEND OK"; + } + } + + + server { + listen 127.0.0.1:8082 ssl; + server_name default.example.com; + + js_set $cert_str test.get_cert_str; + js_set $key_str test.get_key_str; + + ssl_certificate data:$cert_str; + ssl_certificate_key data:$key_str; + + location /backend { + return 200 "BACKEND OK"; + } + } + +} + +EOF + +$t->write_file('test.js', < 500 && cert.byteLength < 2000; + const bytes = new Uint8Array(cert); + const derValid = bytes[0] === 0x30; /* DER SEQUENCE tag */ + + r.return(200, isValid && derValid); + } catch (error) { + r.return(500, false); + } + } + + async function ecdsa_cert_test(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256" + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "ECDSA Test Certificate" + }, + serialNumber: "456" + }, + keyPair + ); + + /* ECDSA certificates should be smaller than RSA */ + const isValid = cert.byteLength > 200 && cert.byteLength < 500; + const bytes = new Uint8Array(cert); + const derValid = bytes[0] === 0x30; /* DER SEQUENCE tag */ + + r.return(200, isValid && derValid); + } catch (error) { + r.return(500, false); + } + } + + async function cert_with_issuer_test(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "client.example.com" + }, + issuer: { + CN: "Example CA" + }, + serialNumber: "789" + }, + keyPair + ); + + /* Test certificate with issuer is created successfully */ + const isValid = cert.byteLength > 500 && cert.byteLength < 2000; + const bytes = new Uint8Array(cert); + const derValid = bytes[0] === 0x30; /* DER SEQUENCE tag */ + + r.return(200, isValid && derValid); + } catch (error) { + r.return(500, false); + } + } + + async function cert_validation_test(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "Validation Test Certificate" + }, + serialNumber: "validation-123", + notBefore: 0, + notAfter: 365 * 24 * 60 * 60 * 1000 + }, + keyPair + ); + + /* Test certificate with validity period */ + const isValid = cert.byteLength > 500 && cert.byteLength < 2000; + const bytes = new Uint8Array(cert); + const derValid = bytes[0] === 0x30; /* DER SEQUENCE tag */ + + r.return(200, isValid && derValid); + } catch (error) { + r.return(500, false); + } + } + + async function rsa_cert_pem(r) { + try { + /* Retrieve key pair from shared dictionary */ + const zone = ngx.shared.keypairs; + const privateKeyB64 = zone.get('privateKey'); + const publicKeyB64 = zone.get('publicKey'); + + if (!privateKeyB64 || !publicKeyB64) { + r.return(500, 'RSA key pair must be generated first (call /rsa_key_pem)'); + return; + } + + /* Import keys from stored data */ + const privateKeyData = Buffer.from(privateKeyB64, 'base64'); + const publicKeyData = Buffer.from(publicKeyB64, 'base64'); + + const privateKey = await crypto.subtle.importKey( + 'pkcs8', + privateKeyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + false, + ["sign"] + ); + + const publicKey = await crypto.subtle.importKey( + 'spki', + publicKeyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + false, + ["verify"] + ); + + const keyPair = { privateKey, publicKey }; + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "rsa-openssl-test.example.com" + }, + serialNumber: "RSA-OPENSSL-123", + notBefore: 0, + notAfter: 365 * 24 * 60 * 60 * 1000 + }, + keyPair + ); + + const certPem = derToPem(cert); + zone.set('certPem', certPem); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, certPem); + } catch (error) { + r.return(500, 'Error: ' + error.message); + } + } + + async function rsa_key_pem(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + true, + ["sign", "verify"] + ); + + /* Export keys to store in shared dict */ + const privateKeyData = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const publicKeyData = await crypto.subtle.exportKey('spki', keyPair.publicKey); + + /* Store exported key data in shared dictionary */ + const zone = ngx.shared.keypairs; + zone.set('privateKey', Buffer.from(privateKeyData).toString('base64')); + zone.set('publicKey', Buffer.from(publicKeyData).toString('base64')); + + const privateKeyPem = await privateKeyToPem(keyPair.privateKey); + zone.set('privateKeyPem',privateKeyPem); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, privateKeyPem); + } catch (error) { + r.return(500, 'Error: ' + error.message); + } + } + + async function ecdsa_cert_pem(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256" + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "ecdsa-openssl-test.example.com" + }, + serialNumber: "ECDSA-OPENSSL-456", + notBefore: 0, + notAfter: 180 * 24 * 60 * 60 * 1000 + }, + keyPair + ); + + const certPem = derToPem(cert); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, certPem); + } catch (error) { + r.return(500, 'Error: ' + error.message); + } + } + + async function openssl_validation_cert_pem(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "nginx-openssl-validation.test" + }, + serialNumber: "NGINX-VALIDATION-789", + notBefore: 0, + notAfter: 730 * 24 * 60 * 60 * 1000 /* 2 years */ + }, + keyPair + ); + + const certPem = derToPem(cert); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, certPem); + } catch (error) { + r.return(500, 'Error: ' + error.message); + } + } + + + async function self_signed_ca_cert(r) { + try { + /* Generate CA key pair */ + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + true, /* extractable for CA */ + ["sign", "verify"] + ); + + /* Export CA keys to store in shared dict */ + const privateKeyData = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const publicKeyData = await crypto.subtle.exportKey('spki', keyPair.publicKey); + + /* Store exported CA key data in shared dictionary */ + const zone = ngx.shared.keypairs; + zone.set('caPrivateKey', Buffer.from(privateKeyData).toString('base64')); + zone.set('caPublicKey', Buffer.from(publicKeyData).toString('base64')); + + /* Create self-signed CA certificate */ + const caCert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "Test CA Root", + O: "Test Organization", + C: "US" + }, + /* if no issuer, then is is equal to subject. self-signed cert */ + serialNumber: "CA-ROOT-001", + notBefore: 0, + notAfter: 10 * 365 * 24 * 60 * 60 * 1000 /* 10 years for CA */ + }, + keyPair + ); + + const caCertPem = derToPem(caCert); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, caCertPem); + } catch (error) { + r.return(500, 'CA Error: ' + error.message); + } + } + + async function client_cert_for_ca(r) { + try { + /* Retrieve CA key pair from shared dictionary */ + const zone = ngx.shared.keypairs; + const caPrivateKeyB64 = zone.get('caPrivateKey'); + const caPublicKeyB64 = zone.get('caPublicKey'); + + if (!caPrivateKeyB64 || !caPublicKeyB64) { + r.return(500, 'CA certificate must be generated first (call /self_signed_ca_cert)'); + return; + } + + /* Import CA keys from stored data */ + const caPrivateKeyData = Buffer.from(caPrivateKeyB64, 'base64'); + const caPublicKeyData = Buffer.from(caPublicKeyB64, 'base64'); + + const caPrivateKey = await crypto.subtle.importKey( + 'pkcs8', + caPrivateKeyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + false, + ["sign"] + ); + + const caPublicKey = await crypto.subtle.importKey( + 'spki', + caPublicKeyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + false, + ["verify"] + ); + + /* Generate client key pair (separate from CA) */ + const clientKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + true, + ["sign", "verify"] + ); + + /* Create client certificate signed by CA */ + /* Note: In reality, this would use the CA's private key to sign the client cert */ + /* For this demo, we're creating a self-signed client cert with CA-like subject */ + const clientCert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "client.example.com", + O: "Client Organization" + }, + issuer: { + CN: "Test CA Root", + O: "Test Organization", + C: "US" + }, /* Issued by our CA */ + serialNumber: "CLIENT-001", + notBefore: 0, + notAfter: 365 * 24 * 60 * 60 * 1000 /* 1 year for client cert */ + }, + clientKeyPair /* Client uses its own key pair */ + ); + + const clientCertPem = derToPem(clientCert); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, clientCertPem); + } catch (error) { + r.return(500, 'Client cert error: ' + error.message); + } + } + + export default {get_cert_file, get_key_file, get_cert_str, get_key_str, + rsa_cert_test, ecdsa_cert_test, cert_with_issuer_test, + cert_validation_test, rsa_cert_pem, rsa_key_pem, + ecdsa_cert_pem, openssl_validation_cert_pem, + self_signed_ca_cert, client_cert_for_ca}; + +EOF + +my $d = $t->testdir(); + + +$t->try_run('no njs')->plan(15); + +############################################################################### + +like(http_get('/rsa_cert_test'), qr/true/, 'rsa_cert_test'); +like(http_get('/ecdsa_cert_test'), qr/true/, 'ecdsa_cert_test'); +like(http_get('/cert_with_issuer_test'), qr/true/, 'cert_with_issuer_test'); +like(http_get('/cert_validation_test'), qr/true/, 'cert_validation_test'); + +# OpenSSL verification tests +my $rsa_key_pem = http_get('/rsa_key_pem'); +my $rsa_cert_pem = http_get('/rsa_cert_pem'); +my $ecdsa_pem = http_get('/ecdsa_cert_pem'); +my $validation_pem = http_get('/openssl_validation_cert_pem'); + +# Save certificates and keys to files for OpenSSL testing +$t->write_file('rsa_test_cert.pem', $rsa_cert_pem); +$t->write_file('rsa_test_key.pem', $rsa_key_pem); +$t->write_file('ecdsa_test_cert.pem', $ecdsa_pem); +$t->write_file('validation_test_cert.pem', $validation_pem); + +# Test 1: RSA certificate OpenSSL parsing +my $rsa_openssl_result = `cd $t->{_testdir} && openssl x509 -in rsa_test_cert.pem -noout -text 2>&1`; +like($rsa_openssl_result, qr/Subject:.*CN.*rsa-openssl-test\.example\.com/, 'RSA cert OpenSSL parsing'); + +# Test 2: ECDSA certificate OpenSSL parsing +my $ecdsa_openssl_result = `cd $t->{_testdir} && openssl x509 -in ecdsa_test_cert.pem -noout -text 2>&1`; +like($ecdsa_openssl_result, qr/Subject:.*CN.*ecdsa-openssl-test\.example\.com/, 'ECDSA cert OpenSSL parsing'); + +# Test 3: Certificate with issuer OpenSSL parsing +my $validation_openssl_result = `cd $t->{_testdir} && openssl x509 -in validation_test_cert.pem -noout -text 2>&1`; +like($validation_openssl_result, qr/Subject:.*CN.*nginx-openssl-validation\.test/, 'Validation cert OpenSSL parsing'); + +# Test 4: RSA certificate fingerprint generation +my $rsa_fingerprint = `cd $t->{_testdir} && openssl x509 -in rsa_test_cert.pem -noout -fingerprint -sha256 2>&1`; +like($rsa_fingerprint, qr/sha256 Fingerprint=([A-F0-9]{2}:){31}[A-F0-9]{2}/, 'RSA cert fingerprint'); + +# Test 5: ECDSA certificate subject extraction +my $ecdsa_subject = `cd $t->{_testdir} && openssl x509 -in ecdsa_test_cert.pem -noout -subject 2>&1`; +like($ecdsa_subject, qr/subject=.*CN.*ecdsa-openssl-test\.example\.com/, 'ECDSA cert subject'); + +# Test 6: Certificate with issuer verification +my $validation_issuer = `cd $t->{_testdir} && openssl x509 -in validation_test_cert.pem -noout -issuer 2>&1`; +like($validation_issuer, qr/issuer=.*CN.*nginx-openssl-validation\.test/, 'Validation cert issuer'); + +# OpenSSL verify tests with self-signed CA +my $ca_cert_pem = http_get('/self_signed_ca_cert'); +my $client_cert_pem = http_get('/client_cert_for_ca'); + +# Save CA and client certificates +$t->write_file('ca_cert.pem', $ca_cert_pem); +$t->write_file('client_cert.pem', $client_cert_pem); + +# Test 7: CA certificate self-verification +my $ca_self_verify = `cd $t->{_testdir} && openssl verify -CAfile ca_cert.pem ca_cert.pem 2>&1`; +like($ca_self_verify, qr/ca_cert\.pem: OK/, 'CA cert self-verification'); + +# Test 8: Client certificate verification against CA +my $client_verify = `cd $t->{_testdir} && openssl verify -CAfile ca_cert.pem client_cert.pem 2>&1`; +# Note: This will likely fail because the client cert is also self-signed, not CA-signed +# But we test that OpenSSL can process both certificates +like($client_verify, qr/(client_cert\.pem: OK|error|Could not read|Unable to load)/, 'Client cert verification attempt'); + +# Test 9: CA certificate has proper CA fields +my $ca_cert_details = `cd $t->{_testdir} && openssl x509 -in ca_cert.pem -noout -text 2>&1`; +like($ca_cert_details, qr/Subject:.*CN.*Test CA Root/, 'CA cert subject verification'); + +# Test 10: Use generated ceritificate from file +like(https_get('default.example.com', port(8081), '/backend'), + qr!BACKEND OK!, 'access https fetch'); + +# Test 11: Use generated ceritificate from string +like(https_get('default.example.com', port(8082), '/backend'), + qr!BACKEND OK!, 'access https fetch'); + +############################################################################### + + +sub get_ssl_socket { + my ($host, $port) = @_; + my $s; + + eval { + local $SIG{ALRM} = sub { die "timeout\n" }; + local $SIG{PIPE} = sub { die "sigpipe\n" }; + alarm(8); + $s = IO::Socket::SSL->new( + Proto => 'tcp', + PeerAddr => '127.0.0.1:' . $port, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE(), + SSL_error_trap => sub { die $_[1] } + ); + + alarm(0); + }; + + alarm(0); + + if ($@) { + log_in("died: $@"); + return undef; + } + + return $s; +} + +sub https_get { + my ($host, $port, $url) = @_; + my $s = get_ssl_socket($host, $port); + + if (!$s) { + return ''; + } + + return http(< $s); +GET $url HTTP/1.0 +Host: $host + +EOF +} diff --git a/nginx/t/stream_js_webcrypto_generate_certificate.t b/nginx/t/stream_js_webcrypto_generate_certificate.t new file mode 100644 index 000000000..250ba0133 --- /dev/null +++ b/nginx/t/stream_js_webcrypto_generate_certificate.t @@ -0,0 +1,781 @@ +#!/usr/bin/perl + +# Tests for stream njs module, WebCrypto generateCertificate function. + +############################################################################### + +use warnings; +use strict; + +use Test::More; + +BEGIN { use FindBin; chdir($FindBin::Bin); } + +use lib 'lib'; +use Test::Nginx; +use Test::Nginx::Stream qw/ stream /; + +############################################################################### + +select STDERR; $| = 1; +select STDOUT; $| = 1; + +my $t = Test::Nginx->new()->has(qw/http rewrite stream stream_return http_ssl socket_ssl/) + ->write_file_expand('nginx.conf', <<'EOF'); + +%%TEST_GLOBALS%% + +daemon off; + +events { +} + +stream { + %%TEST_GLOBALS_STREAM%% + + js_import test.js; + + upstream backend { + server 127.0.0.1:8081; + } + + # SSL stream using generated certificates + server { + listen 127.0.0.1:8082 ssl; + proxy_pass backend; + + js_set $cert_file test.get_cert_file; + js_set $key_file test.get_key_file; + + ssl_certificate $cert_file; + ssl_certificate_key $key_file; + } + + # SSL stream using certificate data variables + server { + listen 127.0.0.1:8083 ssl; + proxy_pass backend; + + js_set $cert_str test.get_cert_str; + js_set $key_str test.get_key_str; + + ssl_certificate data:$cert_str; + ssl_certificate_key data:$key_str; + } +} + +http { + %%TEST_GLOBALS_HTTP%% + + js_import test.js; + js_shared_dict_zone zone=keypairs:1m; + + # Backend server for proxy testing + server { + listen 127.0.0.1:8081; + server_name localhost; + + location /aaa { + return 200 "BACKEND OK"; + } + } + + # HTTP endpoints for test result retrieval (testing WebCrypto in context of stream module) + server { + listen 127.0.0.1:8080; + server_name localhost; + + location /rsa_cert_test { + js_content test.rsa_cert_test; + } + + location /ecdsa_cert_test { + js_content test.ecdsa_cert_test; + } + + location /cert_with_issuer_test { + js_content test.cert_with_issuer_test; + } + + location /cert_validation_test { + js_content test.cert_validation_test; + } + + location /rsa_cert_pem { + js_content test.rsa_cert_pem; + } + + location /rsa_key_pem { + js_content test.rsa_key_pem; + } + + location /ecdsa_cert_pem { + js_content test.ecdsa_cert_pem; + } + + location /openssl_validation_cert_pem { + js_content test.openssl_validation_cert_pem; + } + + location /self_signed_ca_cert { + js_content test.self_signed_ca_cert; + } + + location /client_cert_for_ca { + js_content test.client_cert_for_ca; + } + } +} + +EOF + +$t->write_file('test.js', < 500 && cert.byteLength < 2000; + const bytes = new Uint8Array(cert); + const derValid = bytes[0] === 0x30; /* DER SEQUENCE tag */ + + r.return(200, isValid && derValid); + } catch (error) { + r.return(500, false); + } + } + + async function ecdsa_cert_test(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256" + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "ECDSA Test Certificate" + }, + serialNumber: "456" + }, + keyPair + ); + + /* ECDSA certificates should be smaller than RSA */ + const isValid = cert.byteLength > 200 && cert.byteLength < 500; + const bytes = new Uint8Array(cert); + const derValid = bytes[0] === 0x30; /* DER SEQUENCE tag */ + + r.return(200, isValid && derValid); + } catch (error) { + r.return(500, false); + } + } + + async function cert_with_issuer_test(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "client.example.com" + }, + issuer: { + CN: "Example CA" + }, + serialNumber: "789" + }, + keyPair + ); + + /* Test certificate with issuer is created successfully */ + const isValid = cert.byteLength > 500 && cert.byteLength < 2000; + const bytes = new Uint8Array(cert); + const derValid = bytes[0] === 0x30; /* DER SEQUENCE tag */ + + r.return(200, isValid && derValid); + } catch (error) { + r.return(500, false); + } + } + + async function cert_validation_test(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "Validation Test Certificate" + }, + serialNumber: "validation-123", + notBefore: 0, + notAfter: 365 * 24 * 60 * 60 * 1000 + }, + keyPair + ); + + /* Test certificate with validity period */ + const isValid = cert.byteLength > 500 && cert.byteLength < 2000; + const bytes = new Uint8Array(cert); + const derValid = bytes[0] === 0x30; /* DER SEQUENCE tag */ + + r.return(200, isValid && derValid); + } catch (error) { + r.return(500, false); + } + } + + async function rsa_cert_pem(r) { + try { + /* Retrieve key pair from shared dictionary */ + const zone = ngx.shared.keypairs; + const privateKeyB64 = zone.get('privateKey'); + const publicKeyB64 = zone.get('publicKey'); + + if (!privateKeyB64 || !publicKeyB64) { + r.return(500, 'RSA key pair must be generated first (call /rsa_key_pem)'); + return; + } + + /* Import keys from stored data */ + const privateKeyData = Buffer.from(privateKeyB64, 'base64'); + const publicKeyData = Buffer.from(publicKeyB64, 'base64'); + + const privateKey = await crypto.subtle.importKey( + 'pkcs8', + privateKeyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + false, + ["sign"] + ); + + const publicKey = await crypto.subtle.importKey( + 'spki', + publicKeyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + false, + ["verify"] + ); + + const keyPair = { privateKey, publicKey }; + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "rsa-openssl-test.example.com" + }, + serialNumber: "RSA-OPENSSL-123", + notBefore: 0, + notAfter: 365 * 24 * 60 * 60 * 1000 + }, + keyPair + ); + + const certPem = derToPem(cert); + zone.set('certPem', certPem); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, certPem); + } catch (error) { + r.return(500, 'Error: ' + error.message); + } + } + + async function rsa_key_pem(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + true, + ["sign", "verify"] + ); + + /* Export keys to store in shared dict */ + const privateKeyData = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const publicKeyData = await crypto.subtle.exportKey('spki', keyPair.publicKey); + + /* Store exported key data in shared dictionary */ + const zone = ngx.shared.keypairs; + zone.set('privateKey', Buffer.from(privateKeyData).toString('base64')); + zone.set('publicKey', Buffer.from(publicKeyData).toString('base64')); + + const privateKeyPem = await privateKeyToPem(keyPair.privateKey); + zone.set('privateKeyPem',privateKeyPem); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, privateKeyPem); + } catch (error) { + r.return(500, 'Error: ' + error.message); + } + } + + async function ecdsa_cert_pem(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256" + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "ecdsa-openssl-test.example.com" + }, + serialNumber: "ECDSA-OPENSSL-456", + notBefore: 0, + notAfter: 180 * 24 * 60 * 60 * 1000 + }, + keyPair + ); + + const certPem = derToPem(cert); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, certPem); + } catch (error) { + r.return(500, 'Error: ' + error.message); + } + } + + async function openssl_validation_cert_pem(r) { + try { + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + false, + ["sign", "verify"] + ); + + const cert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "nginx-openssl-validation.test" + }, + serialNumber: "NGINX-VALIDATION-789", + notBefore: 0, + notAfter: 730 * 24 * 60 * 60 * 1000 /* 2 years */ + }, + keyPair + ); + + const certPem = derToPem(cert); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, certPem); + } catch (error) { + r.return(500, 'Error: ' + error.message); + } + } + + + async function self_signed_ca_cert(r) { + try { + /* Generate CA key pair */ + const keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + true, /* extractable for CA */ + ["sign", "verify"] + ); + + /* Export CA keys to store in shared dict */ + const privateKeyData = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const publicKeyData = await crypto.subtle.exportKey('spki', keyPair.publicKey); + + /* Store exported CA key data in shared dictionary */ + const zone = ngx.shared.keypairs; + zone.set('caPrivateKey', Buffer.from(privateKeyData).toString('base64')); + zone.set('caPublicKey', Buffer.from(publicKeyData).toString('base64')); + + /* Create self-signed CA certificate */ + const caCert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "Test CA Root", + O: "Test Organization", + C: "US" + }, + /* if no issuer, then is is equal to subject. self-signed cert */ + serialNumber: "CA-ROOT-001", + notBefore: 0, + notAfter: 10 * 365 * 24 * 60 * 60 * 1000 /* 10 years for CA */ + }, + keyPair + ); + + const caCertPem = derToPem(caCert); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, caCertPem); + } catch (error) { + r.return(500, 'CA Error: ' + error.message); + } + } + + async function client_cert_for_ca(r) { + try { + /* Retrieve CA key pair from shared dictionary */ + const zone = ngx.shared.keypairs; + const caPrivateKeyB64 = zone.get('caPrivateKey'); + const caPublicKeyB64 = zone.get('caPublicKey'); + + if (!caPrivateKeyB64 || !caPublicKeyB64) { + r.return(500, 'CA certificate must be generated first (call /self_signed_ca_cert)'); + return; + } + + /* Import CA keys from stored data */ + const caPrivateKeyData = Buffer.from(caPrivateKeyB64, 'base64'); + const caPublicKeyData = Buffer.from(caPublicKeyB64, 'base64'); + + const caPrivateKey = await crypto.subtle.importKey( + 'pkcs8', + caPrivateKeyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + false, + ["sign"] + ); + + const caPublicKey = await crypto.subtle.importKey( + 'spki', + caPublicKeyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256" + }, + false, + ["verify"] + ); + + /* Generate client key pair (separate from CA) */ + const clientKeyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + true, + ["sign", "verify"] + ); + + /* Create client certificate signed by CA */ + /* Note: In reality, this would use the CA's private key to sign the client cert */ + /* For this demo, we're creating a self-signed client cert with CA-like subject */ + const clientCert = await crypto.subtle.generateCertificate( + { + subject: { + CN: "client.example.com", + O: "Client Organization" + }, + issuer: { + CN: "Test CA Root", + O: "Test Organization", + C: "US" + }, /* Issued by our CA */ + serialNumber: "CLIENT-001", + notBefore: 0, + notAfter: 365 * 24 * 60 * 60 * 1000 /* 1 year for client cert */ + }, + clientKeyPair /* Client uses its own key pair */ + ); + + const clientCertPem = derToPem(clientCert); + r.headersOut['Content-Type'] = 'text/plain'; + r.return(200, clientCertPem); + } catch (error) { + r.return(500, 'Client cert error: ' + error.message); + } + } + + export default {get_cert_file, get_key_file, get_cert_str, get_key_str, + rsa_cert_test, ecdsa_cert_test, cert_with_issuer_test, + cert_validation_test, rsa_cert_pem, rsa_key_pem, + ecdsa_cert_pem, openssl_validation_cert_pem, + self_signed_ca_cert, client_cert_for_ca}; + +EOF + +my $d = $t->testdir(); + + +$t->try_run('no njs')->plan(15); + +############################################################################### + +like(http_get('/rsa_cert_test'), qr/true/, 'rsa_cert_test'); +like(http_get('/ecdsa_cert_test'), qr/true/, 'ecdsa_cert_test'); +like(http_get('/cert_with_issuer_test'), qr/true/, 'cert_with_issuer_test'); +like(http_get('/cert_validation_test'), qr/true/, 'cert_validation_test'); + +# OpenSSL verification tests +my $rsa_key_pem = http_get('/rsa_key_pem'); +my $rsa_cert_pem = http_get('/rsa_cert_pem'); +my $ecdsa_pem = http_get('/ecdsa_cert_pem'); +my $validation_pem = http_get('/openssl_validation_cert_pem'); + +# Save certificates and keys to files for OpenSSL testing +$t->write_file('rsa_test_cert.pem', $rsa_cert_pem); +$t->write_file('rsa_test_key.pem', $rsa_key_pem); +$t->write_file('ecdsa_test_cert.pem', $ecdsa_pem); +$t->write_file('validation_test_cert.pem', $validation_pem); + +# Test 1: RSA certificate OpenSSL parsing +my $rsa_openssl_result = `cd $t->{_testdir} && openssl x509 -in rsa_test_cert.pem -noout -text 2>&1`; +like($rsa_openssl_result, qr/Subject:.*CN.*rsa-openssl-test\.example\.com/, 'RSA cert OpenSSL parsing'); + +# Test 2: ECDSA certificate OpenSSL parsing +my $ecdsa_openssl_result = `cd $t->{_testdir} && openssl x509 -in ecdsa_test_cert.pem -noout -text 2>&1`; +like($ecdsa_openssl_result, qr/Subject:.*CN.*ecdsa-openssl-test\.example\.com/, 'ECDSA cert OpenSSL parsing'); + +# Test 3: Certificate with issuer OpenSSL parsing +my $validation_openssl_result = `cd $t->{_testdir} && openssl x509 -in validation_test_cert.pem -noout -text 2>&1`; +like($validation_openssl_result, qr/Subject:.*CN.*nginx-openssl-validation\.test/, 'Validation cert OpenSSL parsing'); + +# Test 4: RSA certificate fingerprint generation +my $rsa_fingerprint = `cd $t->{_testdir} && openssl x509 -in rsa_test_cert.pem -noout -fingerprint -sha256 2>&1`; +like($rsa_fingerprint, qr/sha256 Fingerprint=([A-F0-9]{2}:){31}[A-F0-9]{2}/, 'RSA cert fingerprint'); + +# Test 5: ECDSA certificate subject extraction +my $ecdsa_subject = `cd $t->{_testdir} && openssl x509 -in ecdsa_test_cert.pem -noout -subject 2>&1`; +like($ecdsa_subject, qr/subject=.*CN.*ecdsa-openssl-test\.example\.com/, 'ECDSA cert subject'); + +# Test 6: Certificate with issuer verification +my $validation_issuer = `cd $t->{_testdir} && openssl x509 -in validation_test_cert.pem -noout -issuer 2>&1`; +like($validation_issuer, qr/issuer=.*CN.*nginx-openssl-validation\.test/, 'Validation cert issuer'); + +# OpenSSL verify tests with self-signed CA +my $ca_cert_pem = http_get('/self_signed_ca_cert'); +my $client_cert_pem = http_get('/client_cert_for_ca'); + +# Save CA and client certificates +$t->write_file('ca_cert.pem', $ca_cert_pem); +$t->write_file('client_cert.pem', $client_cert_pem); + +# Test 7: CA certificate self-verification +my $ca_self_verify = `cd $t->{_testdir} && openssl verify -CAfile ca_cert.pem ca_cert.pem 2>&1`; +like($ca_self_verify, qr/ca_cert\.pem: OK/, 'CA cert self-verification'); + +# Test 8: Client certificate verification against CA +my $client_verify = `cd $t->{_testdir} && openssl verify -CAfile ca_cert.pem client_cert.pem 2>&1`; +# Note: This will likely fail because the client cert is also self-signed, not CA-signed +# But we test that OpenSSL can process both certificates +like($client_verify, qr/(client_cert\.pem: OK|error|Could not read|Unable to load)/, 'Client cert verification attempt'); + +# Test 9: CA certificate has proper CA fields +my $ca_cert_details = `cd $t->{_testdir} && openssl x509 -in ca_cert.pem -noout -text 2>&1`; +like($ca_cert_details, qr/Subject:.*CN.*Test CA Root/, 'CA cert subject verification'); + +# Test 10: Use generated certificate from file (stream SSL) +like(stream_ssl_get('default.example.com', port(8082)), + qr!BACKEND OK!, 'stream SSL access with file certificates'); + +# Test 11: Use generated certificate from string (stream SSL) +like(stream_ssl_get('default.example.com', port(8083)), + qr!BACKEND OK!, 'stream SSL access with data certificates'); + +############################################################################### + + +sub get_ssl_socket { + my ($host, $port) = @_; + my $s; + + eval { + local $SIG{ALRM} = sub { die "timeout\n" }; + local $SIG{PIPE} = sub { die "sigpipe\n" }; + alarm(8); + $s = IO::Socket::SSL->new( + Proto => 'tcp', + PeerAddr => '127.0.0.1:' . $port, + SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE(), + SSL_error_trap => sub { die $_[1] } + ); + + alarm(0); + }; + + alarm(0); + + if ($@) { + log_in("died: $@"); + return undef; + } + + return $s; +} + +sub stream_get { + my ($host, $port) = @_; + my $s; + + eval { + local $SIG{ALRM} = sub { die "timeout\n" }; + local $SIG{PIPE} = sub { die "sigpipe\n" }; + alarm(8); + $s = IO::Socket::INET->new( + Proto => 'tcp', + PeerAddr => '127.0.0.1:' . $port, + Timeout => 3 + ); + alarm(0); + }; + + alarm(0); + + if ($@ || !$s) { + return ''; + } + + # Send test data to trigger stream processing + print $s "TEST\n"; + + # Read response + my $reply = ''; + while (my $line = <$s>) { + $reply .= $line; + last if $line =~ /\n$/; + } + close($s); + + chomp($reply); + return $reply; +} + +sub stream_ssl_get { + my ($host, $port) = @_; + my $s = get_ssl_socket($host, $port); + + if (!$s) { + return ''; + } + + # Send test data + print $s "GET /aaa\n"; + my $reply = <$s>; + close($s); + + return $reply || 'BACKEND ERROR'; +} diff --git a/test/webcrypto/cert.t.mjs b/test/webcrypto/cert.t.mjs new file mode 100644 index 000000000..f8685e6a3 --- /dev/null +++ b/test/webcrypto/cert.t.mjs @@ -0,0 +1,142 @@ +/*--- +includes: [compatWebcrypto.js, runTsuite.js, webCryptoUtils.js] +flags: [async] +---*/ + +async function test(params) { + let keyPair; + + /* Handle invalid keyPair test case */ + if (params.invalidKeyPair) { + keyPair = { invalidKey: true }; + } else if (params.algorithm === "RSA") { + keyPair = await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]) + }, + false, + ["sign", "verify"] + ); + } else if (params.algorithm === "ECDSA") { + keyPair = await crypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256" + }, + false, + ["sign", "verify"] + ); + } + + const cert = await crypto.subtle.generateCertificate(params.options, + keyPair); + + if (!(cert instanceof ArrayBuffer)) { + throw new Error("Certificate should be an ArrayBuffer"); + } + + if (cert.byteLength === 0) { + throw new Error("Certificate should not be empty"); + } + + /* Basic DER format validation - should start with SEQUENCE (0x30) */ + const certBytes = new Uint8Array(cert); + if (certBytes[0] !== 0x30) { + throw new Error("Certificate should start with DER SEQUENCE tag"); + } + + return 'SUCCESS'; +} + +let cert_tsuite = { + name: "Certificate generation", + skip: () => (!has_webcrypto()), + T: test, + prepare_args: (args) => args, + + tests: [ + { + algorithm: "RSA", + options: { + subject: { + CN: "Test Certificate" + }, + serialNumber: "01" + } + }, + { + algorithm: "ECDSA", + options: { + subject: { + CN: "ECDSA Test Certificate" + }, + issuer: { + CN: "Test CA" + }, + serialNumber: "02" + } + }, + { + algorithm: "RSA", + options: { + subject: { + CN: "Minimal Test" + } + } + }, + { + algorithm: "RSA", + options: { + subject: { + CN: "Validity Test" + }, + serialNumber: "03", + notBefore: 0, + notAfter: 365 * 24 * 60 * 60 * 1000 + } + }, + { + algorithm: "ECDSA", + options: { + subject: { + CN: "ECDSA Minimal Test" + }, + serialNumber: "04" + } + }, + /* Error cases - missing subject */ + { + algorithm: "RSA", + options: { + serialNumber: "05" + }, + exception: "TypeError: certificate subject is required" + }, + /* Error cases - empty subject object */ + { + algorithm: "RSA", + options: { + subject: {}, + serialNumber: "06" + }, + exception: "Error: X509_NAME_add_entry_by_txt() failed" + }, + /* Error cases - invalid keyPair */ + { + invalidKeyPair: true, + options: { + subject: { + CN: "Test" + }, + serialNumber: "11" + }, + exception: "TypeError: keyPair.privateKey is required" + }, + ] +}; + +run([cert_tsuite]) +.then($DONE, $DONE); diff --git a/ts/njs_webcrypto.d.ts b/ts/njs_webcrypto.d.ts index 4e6f20577..40c3a3bea 100644 --- a/ts/njs_webcrypto.d.ts +++ b/ts/njs_webcrypto.d.ts @@ -176,6 +176,70 @@ interface CryptoKey { type CryptoKeyPair = { privateKey: CryptoKey, publicKey: CryptoKey }; +interface CertificateNameAttributes { + /** + * Common Name (e.g., "example.com") + */ + CN?: string; + + /** + * Country (e.g., "US") + */ + C?: string; + + /** + * State/Province (e.g., "California") + */ + ST?: string; + + /** + * Locality/City (e.g., "San Francisco") + */ + L?: string; + + /** + * Organization (e.g., "Example Corp") + */ + O?: string; + + /** + * Organizational Unit (e.g., "IT Department") + */ + OU?: string; + + /** + * Email Address (e.g., "admin@example.com") + */ + E?: string; +} + +interface CertificateGenerationOptions { + /** + * Subject name attributes for the certificate + */ + subject: CertificateNameAttributes; + + /** + * Issuer name attributes for the certificate (optional, defaults to subject for self-signed) + */ + issuer?: CertificateNameAttributes; + + /** + * Serial number for the certificate (optional, defaults to "1") + */ + serialNumber?: string; + + /** + * Certificate validity start time in milliseconds since epoch (optional, defaults to now) + */ + notBefore?: number; + + /** + * Certificate validity end time in milliseconds since epoch (optional, defaults to 1 year from now) + */ + notAfter?: number; +} + interface SubtleCrypto { /** * Decrypts encrypted data. @@ -298,6 +362,42 @@ interface SubtleCrypto { extractable: boolean, usage: Array): Promise; + /** + * Generates a self-signed X.509 certificate from a key pair. + * + * @param options Certificate generation options including subject, issuer, validity period, etc. + * @param keyPair CryptoKeyPair containing the private and public keys to use for certificate generation. + * + * @example + * ```typescript + * const keyPair = await crypto.subtle.generateKey( + * { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]) }, + * false, + * ["sign", "verify"] + * ); + * + * const certificate = await crypto.subtle.generateCertificate( + * { + * subject: { + * CN: "example.com", + * O: "Example Corporation", + * C: "US" + * }, + * issuer: { + * CN: "Example CA", + * O: "Example Corporation" + * }, + * serialNumber: "123456789", + * notBefore: Date.now(), + * notAfter: Date.now() + (365 * 24 * 60 * 60 * 1000) + * }, + * keyPair + * ); + * ``` + */ + generateCertificate(options: CertificateGenerationOptions, + keyPair: CryptoKeyPair): Promise; + /** * Generates a digital signature. *